From a6be98315c605ce9546ebbf819bf1fe14a128e0a Mon Sep 17 00:00:00 2001 From: tejas ghatte Date: Mon, 22 Jun 2026 18:27:30 +0530 Subject: [PATCH] feat: video operations in runware --- core/providers/runware/images.go | 26 ++-- core/providers/runware/runware.go | 189 +++++++++++++++++++++++-- core/providers/runware/runware_test.go | 6 +- core/providers/runware/types.go | 103 ++++++++++---- core/providers/runware/utils.go | 8 +- core/providers/runware/videos.go | 96 +++++++++++++ 6 files changed, 374 insertions(+), 54 deletions(-) create mode 100644 core/providers/runware/videos.go diff --git a/core/providers/runware/images.go b/core/providers/runware/images.go index 1088fe7e85a..ccaf59d430f 100644 --- a/core/providers/runware/images.go +++ b/core/providers/runware/images.go @@ -11,25 +11,26 @@ import ( // ToRunwareImageGenerationRequest converts a Bifrost image generation request to a Runware // imageInference task. A "seedImage" supplied via extra params (a Runware image UUID, a public // URL, or a base64/data-URI string) turns the request into an image-to-image generation. -func ToRunwareImageGenerationRequest(bifrostReq *schemas.BifrostImageGenerationRequest) (*RunwareImageInferenceRequest, error) { +func ToRunwareImageGenerationRequest(bifrostReq *schemas.BifrostImageGenerationRequest) (*RunwareInferenceRequest, error) { if bifrostReq.Input == nil { return nil, fmt.Errorf("input is required") } - request := &RunwareImageInferenceRequest{ + width, height := defaultRunwareWidth, defaultRunwareHeight + request := &RunwareInferenceRequest{ TaskType: taskTypeImageInference, TaskUUID: uuid.New().String(), Model: bifrostReq.Model, - PositivePrompt: bifrostReq.Input.Prompt, - Width: defaultRunwareWidth, - Height: defaultRunwareHeight, + PositivePrompt: &bifrostReq.Input.Prompt, + Width: &width, + Height: &height, } if bifrostReq.Params != nil { params := bifrostReq.Params if params.Size != nil && *params.Size != "" { - request.Width, request.Height = parseRunwareSize(*params.Size) + *request.Width, *request.Height = parseRunwareSize(*params.Size) } request.NegativePrompt = params.NegativePrompt request.Steps = params.NumInferenceSteps @@ -54,7 +55,7 @@ func ToRunwareImageGenerationRequest(bifrostReq *schemas.BifrostImageGenerationR // ToRunwareImageEditRequest converts a Bifrost image edit request to a Runware imageInference task. // The first input image is the seed image; an optional mask enables inpainting. Outpainting, // strength, maskMargin and other operation-specific fields flow through via extra params. -func ToRunwareImageEditRequest(bifrostReq *schemas.BifrostImageEditRequest) (*RunwareImageInferenceRequest, error) { +func ToRunwareImageEditRequest(bifrostReq *schemas.BifrostImageEditRequest) (*RunwareInferenceRequest, error) { if bifrostReq.Input == nil { return nil, fmt.Errorf("input is required") } @@ -62,13 +63,14 @@ func ToRunwareImageEditRequest(bifrostReq *schemas.BifrostImageEditRequest) (*Ru return nil, fmt.Errorf("at least one input image is required") } - request := &RunwareImageInferenceRequest{ + width, height := defaultRunwareWidth, defaultRunwareHeight + request := &RunwareInferenceRequest{ TaskType: taskTypeImageInference, TaskUUID: uuid.New().String(), Model: bifrostReq.Model, - PositivePrompt: bifrostReq.Input.Prompt, - Width: defaultRunwareWidth, - Height: defaultRunwareHeight, + PositivePrompt: &bifrostReq.Input.Prompt, + Width: &width, + Height: &height, } // Seed image: the base image being edited (raw bytes -> base64 data URI). @@ -79,7 +81,7 @@ func ToRunwareImageEditRequest(bifrostReq *schemas.BifrostImageEditRequest) (*Ru params := bifrostReq.Params if params.Size != nil && *params.Size != "" { - request.Width, request.Height = parseRunwareSize(*params.Size) + *request.Width, *request.Height = parseRunwareSize(*params.Size) } request.NegativePrompt = params.NegativePrompt request.Steps = params.NumInferenceSteps diff --git a/core/providers/runware/runware.go b/core/providers/runware/runware.go index 00e5a70df77..37aa41dce06 100644 --- a/core/providers/runware/runware.go +++ b/core/providers/runware/runware.go @@ -6,6 +6,7 @@ package runware import ( "context" + "fmt" "net/http" "strings" "time" @@ -251,19 +252,189 @@ func (provider *RunwareProvider) ImageVariation(ctx *schemas.BifrostContext, key return nil, providerUtils.NewUnsupportedOperationError(schemas.ImageVariationRequest, provider.GetProviderKey()) } -// VideoGeneration is not supported by the Runware provider. -func (provider *RunwareProvider) VideoGeneration(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoGenerationRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { - return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoGenerationRequest, provider.GetProviderKey()) +// sendTaskArray wraps a single task object in the Runware array envelope, posts it to the +// unified endpoint, and returns the wrapped request body, decoded response body, and latency. +func (provider *RunwareProvider) sendTaskArray(ctx *schemas.BifrostContext, key schemas.Key, jsonData []byte) (reqBody []byte, respBody []byte, latency time.Duration, bifrostErr *schemas.BifrostError) { + reqBody = make([]byte, 0, len(jsonData)+2) + reqBody = append(reqBody, '[') + reqBody = append(reqBody, jsonData...) + reqBody = append(reqBody, ']') + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + req.SetRequestURI(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "")) + req.Header.SetMethod(http.MethodPost) + req.Header.SetContentType("application/json") + if key.Value.GetValue() != "" { + req.Header.Set("Authorization", "Bearer "+key.Value.GetValue()) + } + req.SetBody(reqBody) + + lat, bErr, wait := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + defer wait() + if bErr != nil { + return reqBody, nil, 0, bErr + } + if resp.StatusCode() != fasthttp.StatusOK { + return reqBody, nil, 0, parseRunwareError(resp) + } + decoded, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return reqBody, nil, 0, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err) + } + // Copy out: the fasthttp response buffer is released when this function returns. + return reqBody, append([]byte(nil), decoded...), lat, nil } -// VideoRetrieve is not supported by the Runware provider. -func (provider *RunwareProvider) VideoRetrieve(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoRetrieveRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { - return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoRetrieveRequest, provider.GetProviderKey()) +// VideoGeneration submits an async videoInference task and returns the queued job. +// The caller polls VideoRetrieve to fetch the finished video. +func (provider *RunwareProvider) VideoGeneration(ctx *schemas.BifrostContext, key schemas.Key, bifrostReq *schemas.BifrostVideoGenerationRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + sendBackRawRequest := providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest) + sendBackRawResponse := providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) + + jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody( + ctx, + bifrostReq, + func() (providerUtils.RequestBodyWithExtraParams, error) { + return ToRunwareVideoGenerationRequest(bifrostReq) + }) + if bifrostErr != nil { + return nil, bifrostErr + } + + reqBody, respBody, latency, bifrostErr := provider.sendTaskArray(ctx, key, jsonData) + if bifrostErr != nil { + return nil, providerUtils.EnrichError(ctx, bifrostErr, reqBody, nil, sendBackRawRequest, sendBackRawResponse) + } + + var videoResp RunwareResponse + rawRequest, rawResponse, bifrostErr := providerUtils.HandleProviderResponse(respBody, &videoResp, reqBody, sendBackRawRequest, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + result, bifrostErr := firstVideoResult(&videoResp) + if bifrostErr != nil { + return nil, providerUtils.EnrichError(ctx, bifrostErr, reqBody, respBody, sendBackRawRequest, sendBackRawResponse) + } + + bifrostResp := ToBifrostVideoGenerationResponse(result) + bifrostResp.ID = providerUtils.AddVideoIDProviderSuffix(result.TaskUUID, providerName) + bifrostResp.Model = bifrostReq.Model + bifrostResp.ExtraFields.Latency = latency.Milliseconds() + if sendBackRawRequest { + bifrostResp.ExtraFields.RawRequest = rawRequest + } + if sendBackRawResponse { + bifrostResp.ExtraFields.RawResponse = rawResponse + } + + return bifrostResp, nil } -// VideoDownload is not supported by the Runware provider. -func (provider *RunwareProvider) VideoDownload(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoDownloadRequest) (*schemas.BifrostVideoDownloadResponse, *schemas.BifrostError) { - return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoDownloadRequest, provider.GetProviderKey()) +// VideoRetrieve polls a previously submitted videoInference task via a getResponse task. +func (provider *RunwareProvider) VideoRetrieve(ctx *schemas.BifrostContext, key schemas.Key, bifrostReq *schemas.BifrostVideoRetrieveRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { + providerName := provider.GetProviderKey() + taskID := providerUtils.StripVideoIDProviderSuffix(bifrostReq.ID, providerName) + sendBackRawRequest := providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest) + sendBackRawResponse := providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse) + + jsonData, err := providerUtils.MarshalSorted(RunwareGetResponseRequest{TaskType: taskTypeGetResponse, TaskUUID: taskID}) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderRequestMarshal, err) + } + + reqBody, respBody, latency, bifrostErr := provider.sendTaskArray(ctx, key, jsonData) + if bifrostErr != nil { + return nil, providerUtils.EnrichError(ctx, bifrostErr, reqBody, nil, sendBackRawRequest, sendBackRawResponse) + } + + var videoResp RunwareResponse + rawRequest, rawResponse, bifrostErr := providerUtils.HandleProviderResponse(respBody, &videoResp, reqBody, sendBackRawRequest, sendBackRawResponse) + if bifrostErr != nil { + return nil, bifrostErr + } + + result, bifrostErr := firstVideoResult(&videoResp) + if bifrostErr != nil { + return nil, providerUtils.EnrichError(ctx, bifrostErr, reqBody, respBody, sendBackRawRequest, sendBackRawResponse) + } + + bifrostResp := ToBifrostVideoGenerationResponse(result) + bifrostResp.ID = providerUtils.AddVideoIDProviderSuffix(taskID, providerName) + bifrostResp.ExtraFields.Latency = latency.Milliseconds() + if sendBackRawRequest { + bifrostResp.ExtraFields.RawRequest = rawRequest + } + if sendBackRawResponse { + bifrostResp.ExtraFields.RawResponse = rawResponse + } + + return bifrostResp, nil +} + +// VideoDownload retrieves the task, then downloads the finished video from its URL. +func (provider *RunwareProvider) VideoDownload(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostVideoDownloadRequest) (*schemas.BifrostVideoDownloadResponse, *schemas.BifrostError) { + taskDetails, bifrostErr := provider.VideoRetrieve(ctx, key, &schemas.BifrostVideoRetrieveRequest{Provider: request.Provider, ID: request.ID}) + if bifrostErr != nil { + return nil, bifrostErr + } + if taskDetails.Status != schemas.VideoStatusCompleted { + return nil, providerUtils.NewBifrostOperationError(fmt.Sprintf("video not ready, current status: %s", taskDetails.Status), nil) + } + if len(taskDetails.Videos) == 0 || taskDetails.Videos[0].URL == nil || *taskDetails.Videos[0].URL == "" { + return nil, providerUtils.NewBifrostOperationError("video URL not available", nil) + } + videoURL := *taskDetails.Videos[0].URL + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + req.SetRequestURI(videoURL) + req.Header.SetMethod(http.MethodGet) + + latency, bifrostErr, wait := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + defer wait() + if bifrostErr != nil { + return nil, bifrostErr + } + if resp.StatusCode() != fasthttp.StatusOK { + return nil, providerUtils.NewBifrostOperationError(fmt.Sprintf("failed to download video: HTTP %d", resp.StatusCode()), nil) + } + body, err := providerUtils.CheckAndDecodeBody(resp) + if err != nil { + return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderResponseDecode, err) + } + contentType := string(resp.Header.ContentType()) + if contentType == "" { + contentType = "video/mp4" + } + + bifrostResp := &schemas.BifrostVideoDownloadResponse{ + VideoID: request.ID, + Content: append([]byte(nil), body...), + ContentType: contentType, + } + bifrostResp.ExtraFields.Latency = latency.Milliseconds() + + return bifrostResp, nil +} + +// firstVideoResult returns the first video task result, surfacing task-level errors. +func firstVideoResult(resp *RunwareResponse) (*RunwareResult, *schemas.BifrostError) { + if len(resp.Data) == 0 { + if msg := firstRunwareErrorMessage(resp.Errors); msg != "" { + return nil, providerUtils.NewBifrostOperationError(msg, nil) + } + return nil, providerUtils.NewBifrostOperationError("runware returned no video task", nil) + } + return &resp.Data[0], nil } // VideoDelete is not supported by the Runware provider. diff --git a/core/providers/runware/runware_test.go b/core/providers/runware/runware_test.go index fb192b0fdc0..717a2d96e6a 100644 --- a/core/providers/runware/runware_test.go +++ b/core/providers/runware/runware_test.go @@ -25,10 +25,14 @@ func TestRunware(t *testing.T) { testConfig := llmtests.ComprehensiveTestConfig{ Provider: schemas.Runware, ImageGenerationModel: "runware:101@1", - ImageEditModel: "runware:400@1", + ImageEditModel: "runware:102@1", // FLUX Fill: supports seedImage + maskImage (inpainting) + VideoGenerationModel: "klingai:kling-video@3-pro", // set a video model; flip the scenarios below on to exercise it Scenarios: llmtests.TestScenarios{ ImageGeneration: true, ImageEdit: true, + VideoGeneration: false, + VideoRetrieve: false, + VideoDownload: false, }, } diff --git a/core/providers/runware/types.go b/core/providers/runware/types.go index abe9991b5c9..3dd8958fa0e 100644 --- a/core/providers/runware/types.go +++ b/core/providers/runware/types.go @@ -1,55 +1,96 @@ package runware -// taskTypeImageInference is the Runware task type used for all image operations -// (text-to-image, image-to-image, inpainting, outpainting). -const taskTypeImageInference = "imageInference" +// Runware task types. +const ( + // taskTypeImageInference is used for all image operations + // (text-to-image, image-to-image, inpainting, outpainting). + taskTypeImageInference = "imageInference" + // taskTypeVideoInference is used for text-to-video and image-to-video generation. + taskTypeVideoInference = "videoInference" + // taskTypeGetResponse polls an async task (e.g. video) by its taskUUID. + taskTypeGetResponse = "getResponse" +) -// RunwareImageInferenceRequest is a single Runware image inference task. Runware accepts an -// array of these objects per request; the provider wraps a single task in an array before sending. -type RunwareImageInferenceRequest struct { +// deliveryMethodAsync queues a task instead of holding the connection open; used for video. +const deliveryMethodAsync = "async" + +// RunwareFrameImage anchors an input image to a video frame for image-to-video generation. +type RunwareFrameImage struct { + InputImage string `json:"inputImage"` // image UUID, URL, or base64/data-URI string + Frame *string `json:"frame,omitempty"` // "first" | "last" +} + +// RunwareInferenceRequest is a single Runware task. taskType selects the operation; each +// operation populates only the subset of fields it needs. Runware accepts an array of these +// objects per request; the provider wraps a single task in an array before sending. +type RunwareInferenceRequest struct { + // Common TaskType string `json:"taskType"` TaskUUID string `json:"taskUUID"` Model string `json:"model"` - PositivePrompt string `json:"positivePrompt"` + PositivePrompt *string `json:"positivePrompt,omitempty"` NegativePrompt *string `json:"negativePrompt,omitempty"` - Width int `json:"width"` - Height int `json:"height"` - Steps *int `json:"steps,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` Seed *int `json:"seed,omitempty"` NumberResults *int `json:"numberResults,omitempty"` - OutputType *string `json:"outputType,omitempty"` // "URL", "base64Data", "dataURI" - OutputFormat *string `json:"outputFormat,omitempty"` // "PNG", "JPG", "WEBP" - SeedImage *string `json:"seedImage,omitempty"` // image-to-image / inpainting / outpainting base image - MaskImage *string `json:"maskImage,omitempty"` // inpainting mask + OutputType *string `json:"outputType,omitempty"` // "URL" | "base64Data" | "dataURI" + OutputFormat *string `json:"outputFormat,omitempty"` // image: "PNG"/"JPG"/"WEBP"; video: "MP4"/"WEBM" + + // Image-only + Steps *int `json:"steps,omitempty"` + SeedImage *string `json:"seedImage,omitempty"` // image-to-image / inpainting / outpainting base image + MaskImage *string `json:"maskImage,omitempty"` // inpainting mask + + // Video-only + DeliveryMethod *string `json:"deliveryMethod,omitempty"` + Duration *float64 `json:"duration,omitempty"` + FrameImages []RunwareFrameImage `json:"frameImages,omitempty"` // image-to-video + ReferenceImages []string `json:"referenceImages,omitempty"` // ExtraParams carries provider-native fields with no Bifrost equivalent - // (CFGScale, scheduler, strength, maskMargin, outpaint, lora, ...). Merged into + // (CFGScale, scheduler, strength, maskMargin, outpaint, fps, lora, ...). Merged into // the request body by the transport layer when passthrough is enabled. ExtraParams map[string]interface{} `json:"-"` } -func (r *RunwareImageInferenceRequest) GetExtraParams() map[string]interface{} { +func (r *RunwareInferenceRequest) GetExtraParams() map[string]interface{} { return r.ExtraParams } -// RunwareResponse is the envelope returned by the Runware API. Successful task outputs -// land in Data; per-task failures land in Errors (which can be present on a 200 response). +// RunwareGetResponseRequest polls an async task by its UUID. +type RunwareGetResponseRequest struct { + TaskType string `json:"taskType"` + TaskUUID string `json:"taskUUID"` +} + +// RunwareResponse is the universal Runware envelope: successful task outputs land in Data, +// per-task failures land in Errors (which can be present on a 200 response). type RunwareResponse struct { - Data []RunwareImageResult `json:"data,omitempty"` - Errors []RunwareError `json:"errors,omitempty"` + Data []RunwareResult `json:"data,omitempty"` + Errors []RunwareError `json:"errors,omitempty"` } -// RunwareImageResult is a single generated image returned for an imageInference task. -type RunwareImageResult struct { - TaskType string `json:"taskType"` - TaskUUID string `json:"taskUUID"` - ImageUUID string `json:"imageUUID"` - ImageURL string `json:"imageURL,omitempty"` - ImageBase64Data string `json:"imageBase64Data,omitempty"` - ImageDataURI string `json:"imageDataURI,omitempty"` - Seed *int `json:"seed,omitempty"` - Cost float64 `json:"cost,omitempty"` - NSFWContent *bool `json:"NSFWContent,omitempty"` +// RunwareResult is a single task result. Fields are populated per modality: image tasks fill +// the image* fields, video tasks fill the video* fields; the rest are shared. +type RunwareResult struct { + // Common + TaskType string `json:"taskType"` + TaskUUID string `json:"taskUUID"` + Status string `json:"status,omitempty"` // video: "processing" | "success" | "error" + Seed *int `json:"seed,omitempty"` + Cost float64 `json:"cost,omitempty"` + + // Image + ImageUUID string `json:"imageUUID,omitempty"` + ImageURL string `json:"imageURL,omitempty"` + ImageBase64Data string `json:"imageBase64Data,omitempty"` + ImageDataURI string `json:"imageDataURI,omitempty"` + NSFWContent *bool `json:"NSFWContent,omitempty"` + + // Video + VideoUUID string `json:"videoUUID,omitempty"` + VideoURL string `json:"videoURL,omitempty"` } // RunwareError describes a single task failure returned by the Runware API. diff --git a/core/providers/runware/utils.go b/core/providers/runware/utils.go index 7557f80a4f4..e4270be5b0e 100644 --- a/core/providers/runware/utils.go +++ b/core/providers/runware/utils.go @@ -5,10 +5,16 @@ import ( "strings" ) -// Runware requires explicit pixel dimensions; default to a square when the caller omits a size. +// Runware requires explicit pixel dimensions; default when the caller omits a size. +// Images default to a square; video defaults to 16:9 720p (video models reject square sizes). const ( defaultRunwareWidth = 1024 defaultRunwareHeight = 1024 + + // 1080p 16:9: within Runware's 256-1920 x 256-1080 range, a multiple of 8, and accepted by + // the widest set of video models (Kling Pro rejects 720p but accepts 1920x1080). + defaultRunwareVideoWidth = 1920 + defaultRunwareVideoHeight = 1080 ) // parseRunwareSize converts a Bifrost size string ("1024x1024") to width/height pixels. diff --git a/core/providers/runware/videos.go b/core/providers/runware/videos.go new file mode 100644 index 00000000000..96e45b0edec --- /dev/null +++ b/core/providers/runware/videos.go @@ -0,0 +1,96 @@ +package runware + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// ToRunwareVideoGenerationRequest converts a Bifrost video generation request to a Runware +// videoInference task. An input reference image turns it into image-to-video generation. +func ToRunwareVideoGenerationRequest(bifrostReq *schemas.BifrostVideoGenerationRequest) (*RunwareInferenceRequest, error) { + if bifrostReq.Input == nil { + return nil, fmt.Errorf("input is required") + } + + // Runware requires explicit width/height for video; default to 16:9 1080p when no size is given. + request := &RunwareInferenceRequest{ + TaskType: taskTypeVideoInference, + TaskUUID: uuid.New().String(), + DeliveryMethod: new(deliveryMethodAsync), + Model: bifrostReq.Model, + Width: new(defaultRunwareVideoWidth), + Height: new(defaultRunwareVideoHeight), + } + + if bifrostReq.Input.Prompt != "" { + request.PositivePrompt = &bifrostReq.Input.Prompt + } + + // Input reference image (image-to-video): anchored to the first frame. + if bifrostReq.Input.InputReference != nil && *bifrostReq.Input.InputReference != "" { + sanitizedURL, err := schemas.SanitizeImageURL(*bifrostReq.Input.InputReference) + if err != nil { + return nil, fmt.Errorf("invalid input reference: %w", err) + } + request.FrameImages = []RunwareFrameImage{{InputImage: sanitizedURL, Frame: new("first")}} + } + + if bifrostReq.Params != nil { + params := bifrostReq.Params + + request.NegativePrompt = params.NegativePrompt + request.Seed = params.Seed + + if params.Size != "" { + *request.Width, *request.Height = parseRunwareSize(params.Size) + } + + if params.Seconds != nil && *params.Seconds != "" { + seconds, err := strconv.ParseFloat(*params.Seconds, 64) + if err != nil { + return nil, fmt.Errorf("invalid seconds value: %w", err) + } + request.Duration = &seconds + } + + request.ExtraParams = params.ExtraParams + } + + return request, nil +} + +// ToBifrostVideoGenerationResponse converts a Runware video task result to a Bifrost video response. +func ToBifrostVideoGenerationResponse(result *RunwareResult) *schemas.BifrostVideoGenerationResponse { + response := &schemas.BifrostVideoGenerationResponse{ + ID: result.TaskUUID, + Object: "video", + CreatedAt: time.Now().Unix(), + } + + switch strings.ToLower(result.Status) { + case "success": + response.Status = schemas.VideoStatusCompleted + case "processing": + response.Status = schemas.VideoStatusInProgress + case "error": + response.Status = schemas.VideoStatusFailed + response.Error = &schemas.VideoCreateError{Code: result.Status, Message: "runware video task failed"} + default: + response.Status = schemas.VideoStatusQueued + } + + if result.VideoURL != "" { + response.Videos = []schemas.VideoOutput{{ + Type: schemas.VideoOutputTypeURL, + URL: new(result.VideoURL), + ContentType: "video/mp4", + }} + } + + return response +}