fix(canvas): 视频参考协议 - 统一首尾帧与角色资产语义

This commit is contained in:
ddCat committed 2026-08-30 16:52:09 +08:00
1 parent 4787c130d0
commit 6c723fa282
15 files changed
+479 -68

No files matched your search

+102 -15
View File
@@ -340,7 +340,7 @@ func newAPIChannel1Adapter() Adapter {
return videoAdapter(info, func(r GenerationRequest) (RequestSpec, error) {
media := make([]map[string]string, 0, len(r.Images)+len(r.Videos)+len(r.Audios))
for _, image := range r.Images {
media = append(media, map[string]string{"type": "reference_image", "url": mediaValue(image)})
media = append(media, map[string]string{"type": mediaRole(image, "reference_image"), "url": mediaValue(image)})
}
for _, video := range r.Videos {
media = append(media, map[string]string{"type": "reference_video", "url": mediaValue(video)})
@@ -365,11 +365,26 @@ func xAIVideosAdapter() Adapter {
info.Parameters = videoParams()
return videoAdapter(info, func(r GenerationRequest) (RequestSpec, error) {
body := map[string]any{"model": r.Model, "prompt": r.Prompt, "duration": defaultInt(r.Duration, 6), "aspect_ratio": defaultValue(r.AspectRatio, "16:9"), "resolution": defaultValue(r.Resolution, "720p")}
if len(r.Images) == 1 {
body["image"] = map[string]any{"url": mediaValue(r.Images[0])}
} else if len(r.Images) > 1 {
frameImages, referenceImages, unspecifiedImages := splitVideoImages(r.Images)
if len(frameImages) > 0 && len(referenceImages) > 0 {
return RequestSpec{}, fmt.Errorf("xAI 视频协议不能同时混用首尾帧和角色参考图")
}
if len(frameImages) > 1 {
return RequestSpec{}, fmt.Errorf("xAI 视频协议最多支持 1 张起始图,不支持尾帧输入")
}
if len(frameImages) == 1 {
body["image"] = map[string]any{"url": mediaValue(frameImages[0])}
} else if len(referenceImages) > 0 {
refs := make([]any, 0, len(referenceImages))
for _, image := range referenceImages {
refs = append(refs, map[string]any{"url": mediaValue(image)})
}
body["reference_images"] = refs
} else if len(unspecifiedImages) == 1 {
body["image"] = map[string]any{"url": mediaValue(unspecifiedImages[0])}
} else if len(unspecifiedImages) > 1 {
refs := make([]any, 0, len(r.Images))
for _, image := range r.Images {
for _, image := range unspecifiedImages {
refs = append(refs, map[string]any{"url": mediaValue(image)})
}
body["reference_images"] = refs
@@ -390,7 +405,7 @@ func arkVideosAdapter() Adapter {
}
content := []any{map[string]any{"type": "text", "text": r.Prompt}}
for _, image := range r.Images {
content = append(content, map[string]any{"type": "image_url", "image_url": map[string]any{"url": mediaValue(image)}, "role": "reference_image"})
content = append(content, map[string]any{"type": "image_url", "image_url": map[string]any{"url": mediaValue(image)}, "role": mediaRole(image, "reference_image")})
}
for _, video := range r.Videos {
content = append(content, map[string]any{"type": "video_url", "video_url": map[string]any{"url": mediaValue(video)}, "role": "reference_video"})
@@ -428,6 +443,9 @@ func geminiVeoAdapter() Adapter {
info := metadata("gemini-veo", "Gemini Veo", "Google", CapabilityVideo, "POST /v1beta/models/{model}:predictLongRunning", "GET /v1beta/{operation_name}", "application/json")
info.Parameters = videoParams()
create := func(r GenerationRequest) (RequestSpec, error) {
if hasVideoImageRole(r.Images, "reference_image") {
return RequestSpec{}, fmt.Errorf("Gemini Veo 当前不支持角色或风格参考图生视频,请改用支持 reference_to_video 的模型")
}
instance := map[string]any{"prompt": r.Prompt}
if len(r.Images) > 0 {
image := r.Images[0]
@@ -450,6 +468,9 @@ func novitaVideosAdapter() Adapter {
info := metadata("novita-video", "Novita 视频", "Novita", CapabilityVideo, "POST /v3/video/create", "GET /v3/async/task-result?task_id={id}", "application/json")
info.Parameters = videoParams()
return videoAdapter(info, func(r GenerationRequest) (RequestSpec, error) {
if hasVideoImageRole(r.Images, "reference_image") {
return RequestSpec{}, fmt.Errorf("Novita 视频当前不支持角色或风格参考图生视频,请改用支持 reference_to_video 的模型")
}
body := map[string]any{"model_name": r.Model, "prompt": r.Prompt, "duration": defaultInt(r.Duration, 5), "aspect_ratio": defaultValue(r.AspectRatio, "16:9")}
if len(r.Images) > 0 {
body["image_url"] = mediaValue(r.Images[0])
@@ -464,7 +485,13 @@ func miniMaxVideosAdapter() Adapter {
return videoAdapter(info, func(r GenerationRequest) (RequestSpec, error) {
content := []any{map[string]any{"type": "text", "text": r.Prompt}}
for _, image := range r.Images {
content = append(content, map[string]any{"type": "image_url", "image_url": map[string]any{"url": mediaValue(image)}, "role": "reference_image"})
content = append(content, map[string]any{"type": "image_url", "image_url": map[string]any{"url": mediaValue(image)}, "role": mediaRole(image, "reference_image")})
}
for _, video := range r.Videos {
content = append(content, map[string]any{"type": "video_url", "video_url": map[string]any{"url": mediaValue(video)}, "role": "reference_video"})
}
for _, audio := range r.Audios {
content = append(content, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": mediaValue(audio)}, "role": "reference_audio"})
}
body := map[string]any{"model": r.Model, "prompt": r.Prompt, "duration": defaultInt(r.Duration, 6), "content": content}
mergeExtra(body, r.Extra, "model", "prompt", "duration", "content", "resolution", "aspect_ratio")
@@ -541,6 +568,10 @@ func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) {
}
func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) {
frameImages, referenceImages, _ := splitVideoImages(r.Images)
if len(frameImages) > 0 && (len(referenceImages) > 0 || len(r.Videos)+len(r.Audios) > 0) {
return nil, fmt.Errorf("Agnes 2.5 不能同时混用首尾帧和角色、视频或音频参考素材")
}
mode := firstString(r.Extra, "mode")
if mode == "" {
switch strings.TrimSpace(r.Operation) {
@@ -550,8 +581,10 @@ func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) {
mode = "keyframe"
default:
switch {
case len(r.Videos) > 0 || len(r.Audios) > 0:
case len(r.Videos) > 0 || len(r.Audios) > 0 || len(referenceImages) > 0:
mode = "reference"
case len(frameImages) > 0:
mode = "keyframe"
case len(r.Images) > 2:
mode = "reference"
case len(r.Images) > 0:
@@ -610,9 +643,17 @@ func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) {
if len(r.Images) == 0 || len(r.Images) > 2 || len(r.Videos)+len(r.Audios) > 0 {
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式需要 1 到 2 张图片,且不接受音视频参考")
}
body["first_frame"] = mediaValue(r.Images[0])
if len(r.Images) > 1 {
body["last_frame"] = mediaValue(r.Images[1])
_, referenceImages, _ := splitVideoImages(r.Images)
if len(referenceImages) > 0 {
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式不能混用角色或风格参考图")
}
frameImages := orderedFrameImages(r.Images)
if len(frameImages) == 0 {
return nil, fmt.Errorf("Agnes 2.5 keyframe 模式缺少首帧图片")
}
body["first_frame"] = mediaValue(frameImages[0])
if len(frameImages) > 1 {
body["last_frame"] = mediaValue(frameImages[1])
}
case "reference":
if len(r.Images)+len(r.Videos)+len(r.Audios) == 0 {
@@ -681,6 +722,10 @@ func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) {
if len(r.Videos)+len(r.Audios) > 0 {
return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频")
}
_, referenceImages, _ := splitVideoImages(r.Images)
if r.Operation == "reference_to_video" || len(referenceImages) > 0 {
return nil, fmt.Errorf("Agnes Video V2.0 不支持角色或风格参考图模式,请改用 Agnes Video 2.5")
}
body := map[string]any{"model": r.Model, "prompt": r.Prompt}
mergeExtra(body, r.Extra, "mode", "width", "height", "num_frames", "frame_rate", "num_inference_steps", "seed", "negative_prompt")
if r.Duration > 0 {
@@ -703,16 +748,17 @@ func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) {
body["num_frames"] = frames
}
}
if len(r.Images) == 1 {
body["image"] = mediaValue(r.Images[0])
} else if len(r.Images) > 1 {
orderedImages := orderedFrameImages(r.Images)
if len(orderedImages) == 1 {
body["image"] = mediaValue(orderedImages[0])
} else if len(orderedImages) > 1 {
extraBody := map[string]any{}
if configured := object(r.Extra["extra_body"]); configured != nil {
for key, value := range configured {
extraBody[key] = value
}
}
extraBody["image"] = mediaValues(r.Images)
extraBody["image"] = mediaValues(orderedImages)
extraBody["mode"] = "keyframes"
body["extra_body"] = extraBody
} else if configured := object(r.Extra["extra_body"]); configured != nil {
@@ -1120,6 +1166,47 @@ func compactMap(body map[string]any) map[string]any {
return result
}
func mediaValue(media MediaReference) string { return defaultValue(media.URL, media.DataURL) }
func mediaRole(media MediaReference, fallback string) string {
return defaultValue(strings.TrimSpace(media.Role), fallback)
}
func splitVideoImages(images []MediaReference) (frames []MediaReference, references []MediaReference, unspecified []MediaReference) {
firstFrames := make([]MediaReference, 0, 1)
lastFrames := make([]MediaReference, 0, 1)
for _, image := range images {
switch strings.TrimSpace(image.Role) {
case "first_frame":
firstFrames = append(firstFrames, image)
case "last_frame":
lastFrames = append(lastFrames, image)
case "reference_image":
references = append(references, image)
default:
unspecified = append(unspecified, image)
}
}
frames = append(frames, firstFrames...)
frames = append(frames, lastFrames...)
return frames, references, unspecified
}
func orderedFrameImages(images []MediaReference) []MediaReference {
frames, _, unspecified := splitVideoImages(images)
if len(frames) == 0 {
return unspecified
}
return append(frames, unspecified...)
}
func hasVideoImageRole(images []MediaReference, role string) bool {
for _, image := range images {
if strings.TrimSpace(image.Role) == role {
return true
}
}
return false
}
func mediaValues(values []MediaReference) []string {
result := make([]string, 0, len(values))
for _, value := range values {
+56
View File
@@ -196,6 +196,62 @@ func TestArkVideoAdapterMapsFullModalReferences(t *testing.T) {
}
}
func TestVideoAdaptersTranslateImageRolesWithoutGuessingFromCount(t *testing.T) {
tests := []struct {
name string
id string
role string
}{
{name: "newapi channel 1", id: "newapi-channel-1", role: "reference_image"},
{name: "volcengine ark", id: "volcengine-ark-video", role: "first_frame"},
{name: "minimax", id: "minimax-video", role: "last_frame"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter, _ := Builtins().Get(test.id)
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "video-model", Prompt: "test", Duration: 5,
Images: []MediaReference{{ID: "image-1", URL: "https://cdn.example/image.png", Role: test.role}},
}})
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(spec.Body)
if !strings.Contains(string(body), `"`+test.role+`"`) {
t.Fatalf("body does not contain role %q: %s", test.role, body)
}
})
}
}
func TestXAIVideoUsesReferenceImagesEvenForOneReferenceAsset(t *testing.T) {
adapter, _ := Builtins().Get("xai-video")
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "grok-video", Prompt: "keep the character", Images: []MediaReference{{URL: "https://cdn.example/character.png", Role: "reference_image"}},
}})
if err != nil {
t.Fatal(err)
}
body := spec.Body.(map[string]any)
if body["image"] != nil || body["reference_images"] == nil {
t.Fatalf("xAI reference body = %#v", body)
}
}
func TestStartFrameOnlyAdaptersRejectReferenceAssetSemantics(t *testing.T) {
for _, id := range []string{"gemini-veo", "novita-video"} {
t.Run(id, func(t *testing.T) {
adapter, _ := Builtins().Get(id)
_, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: id, Prompt: "keep the character", Images: []MediaReference{{URL: "https://cdn.example/character.png", Role: "reference_image"}},
}})
if err == nil || !strings.Contains(err.Error(), "reference_to_video") {
t.Fatalf("BuildCreate() error = %v", err)
}
})
}
}
func TestAsyncVideoResponseNormalizesStatusAndResult(t *testing.T) {
adapter, _ := Builtins().Get("minimax-video")
created, err := adapter.ParseCreate(context.Background(), []byte(`{"task":{"id":"mm-1","status":"processing"}}`))
+2
View File
@@ -37,9 +37,11 @@ const (
)
type MediaReference struct {
ID string `json:"id,omitempty"`
URL string `json:"url,omitempty"`
DataURL string `json:"dataUrl,omitempty"`
Kind string `json:"kind,omitempty"`
Role string `json:"role,omitempty"`
Ephemeral bool `json:"ephemeral,omitempty"`
}
+52 -17
View File
@@ -2341,7 +2341,7 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe
request := protocol.GenerationRequest{
Model: input.Config.Model,
Prompt: input.Prompt,
Images: protocolMediaReferences(input.ReferenceImages, "image"),
Images: protocolVideoImageReferences(input),
Videos: protocolMediaReferences(input.ReferenceVideos, "video"),
Audios: protocolMediaReferences(input.ReferenceAudios, "audio"),
AspectRatio: input.Config.Size,
@@ -2370,10 +2370,31 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe
return request
}
func protocolVideoImageReferences(input canvasGenerationInput) []protocol.MediaReference {
result := make([]protocol.MediaReference, 0, len(input.ReferenceImages))
fallbackRole := ""
if metadataString(input.Metadata, "videoStartFrameNodeId") != "" || metadataString(input.Metadata, "videoEndFrameNodeId") != "" {
fallbackRole = "reference_image"
}
for _, value := range input.ReferenceImages {
item := protocol.MediaReference{
ID: strings.TrimSpace(value.ID),
URL: strings.TrimSpace(value.URL),
DataURL: strings.TrimSpace(value.DataURL),
Kind: "image",
Role: videoImageRoleOrDefault(input, value, fallbackRole),
}
if item.URL != "" || item.DataURL != "" {
result = append(result, item)
}
}
return result
}
func protocolMediaReferences(values []providerMedia, kind string) []protocol.MediaReference {
result := make([]protocol.MediaReference, 0, len(values))
for _, value := range values {
item := protocol.MediaReference{URL: strings.TrimSpace(value.URL), DataURL: strings.TrimSpace(value.DataURL), Kind: kind}
item := protocol.MediaReference{ID: strings.TrimSpace(value.ID), URL: strings.TrimSpace(value.URL), DataURL: strings.TrimSpace(value.DataURL), Kind: kind}
if item.URL != "" || item.DataURL != "" {
result = append(result, item)
}
@@ -2868,16 +2889,9 @@ func runMiniMaxVideoTask(ctx context.Context, input canvasGenerationInput) (map[
if err != nil {
return nil, fmt.Errorf("MiniMax 参考图无效:%w", err)
}
role := "reference_image"
if frameMode {
switch {
case startFrameID != "" && image.ID == startFrameID:
role = "first_frame"
case endFrameID != "" && image.ID == endFrameID:
role = "last_frame"
default:
return nil, errors.New("MiniMax 首尾帧模式不能混合未标记的参考图")
}
role := videoImageRole(input, image)
if frameMode && role == "reference_image" {
return nil, errors.New("MiniMax 首尾帧模式不能混合未标记的参考图")
}
content = append(content, miniMaxVideoContent{Type: "image_url", ImageURL: &miniMaxMediaURL{URL: url}, Role: role})
}
@@ -3014,6 +3028,9 @@ func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (ma
if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
return nil, errors.New("Gemini Veo 当前只支持 1 张起始图,不支持参考视频或音频")
}
if len(input.ReferenceImages) > 0 && metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" {
return nil, errors.New("Gemini Veo 当前不支持角色或风格参考图生视频,请改用支持 reference_to_video 的模型")
}
id := resumedProviderRequestID(ctx)
if id == "" {
instance := geminiVeoInstance{Prompt: strings.TrimSpace(input.Prompt)}
@@ -3074,6 +3091,9 @@ func runNovitaVideoTask(ctx context.Context, input canvasGenerationInput) (map[s
if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
return nil, errors.New("Novita 视频当前只支持 1 张起始图,不支持参考视频或参考音频")
}
if len(input.ReferenceImages) > 0 && metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" {
return nil, errors.New("Novita 视频当前不支持角色或风格参考图生视频,请改用支持 reference_to_video 的模型")
}
id := resumedProviderRequestID(ctx)
if id == "" {
body := map[string]interface{}{
@@ -3636,7 +3656,7 @@ func newAPIChannel1VideoBody(input canvasGenerationInput) (map[string]interface{
if err != nil {
return nil, err
}
media = append(media, map[string]string{"type": seedanceImageRole(input, image), "url": url})
media = append(media, map[string]string{"type": videoImageRole(input, image), "url": url})
}
}
for _, video := range input.ReferenceVideos {
@@ -3798,6 +3818,9 @@ func xaiVideoRequestBody(input canvasGenerationInput) (xaiVideoRequest, error) {
return body, nil
}
startFrameID := metadataString(input.Metadata, "videoStartFrameNodeId")
if metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" {
startFrameID = ""
}
if startFrameID == "" {
// 未设置首帧:所有参考图作为 R2V 参考,不受单张起始图限制。
for index := range input.ReferenceImages {
@@ -4874,7 +4897,7 @@ func seedanceContent(input canvasGenerationInput) ([]map[string]interface{}, err
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": url}, "role": seedanceImageRole(input, image)})
content = append(content, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": url}, "role": videoImageRole(input, image)})
}
for _, video := range input.ReferenceVideos {
url, err := mediaReferenceURL(video)
@@ -4943,7 +4966,9 @@ func seedanceVideosRequestBody(input canvasGenerationInput) (seedanceVideosReque
if err != nil {
return seedanceVideosRequest{}, err
}
if len(frameImageURLs) > 0 {
if metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" {
body.ReferenceImageURLs = imageURLs
} else if len(frameImageURLs) > 0 {
body.ImageURLs = frameImageURLs
} else if len(imageURLs) > 0 {
body.ImageURL = imageURLs[0]
@@ -4993,17 +5018,27 @@ func seedanceVideosPromptText(input canvasGenerationInput) string {
return strings.TrimSpace(input.Prompt)
}
func seedanceImageRole(input canvasGenerationInput, image providerMedia) string {
func videoImageRole(input canvasGenerationInput, image providerMedia) string {
return videoImageRoleOrDefault(input, image, "reference_image")
}
func videoImageRoleOrDefault(input canvasGenerationInput, image providerMedia, fallback string) string {
if metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" {
return "reference_image"
}
if id := metadataString(input.Metadata, "videoStartFrameNodeId"); id != "" && image.ID == id {
return "first_frame"
}
if id := metadataString(input.Metadata, "videoEndFrameNodeId"); id != "" && image.ID == id {
return "last_frame"
}
return "reference_image"
return fallback
}
func videoFrameImageURLs(input canvasGenerationInput, imageURLs []string) ([]string, error) {
if metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" {
return nil, nil
}
startFrameID := metadataString(input.Metadata, "videoStartFrameNodeId")
endFrameID := metadataString(input.Metadata, "videoEndFrameNodeId")
if startFrameID == "" && endFrameID == "" {
+73
View File
@@ -1372,6 +1372,31 @@ func TestSeedanceVideosBodyUsesOrderedFrameImageURLsWhenConfigured(t *testing.T)
}
}
func TestSeedanceVideosBodyKeepsProjectAssetsAsReferenceImages(t *testing.T) {
body, err := seedanceVideosRequestBody(canvasGenerationInput{
Prompt: "keep the character consistent",
Config: providerConfig{Model: "seedance-2.0"},
ReferenceImages: []providerMedia{
{ID: "character-1", DataURL: testReferenceImageDataURL},
{ID: "character-2", DataURL: "data:image/png;base64,d29ybGQ="},
},
Metadata: map[string]interface{}{
"videoEditOperation": "reference_to_video",
"videoStartFrameNodeId": "character-1",
},
})
if err != nil {
t.Fatalf("seedanceVideosRequestBody() error = %v", err)
}
want := []string{testReferenceImageDataURL, "data:image/png;base64,d29ybGQ="}
if !reflect.DeepEqual(body.ReferenceImageURLs, want) {
t.Fatalf("reference_image_urls = %#v, want %#v", body.ReferenceImageURLs, want)
}
if body.ImageURL != "" || body.ImageURLs != nil {
t.Fatalf("reference operation leaked frame fields: %#v", body)
}
}
func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
paths := make([]string, 0, 3)
@@ -1731,6 +1756,22 @@ func TestXAIVideoBodyWithStartFrameKeepsOfficialImageShape(t *testing.T) {
}
}
func TestXAIVideoReferenceOperationIgnoresStaleStartFrameMetadata(t *testing.T) {
body, err := xaiVideoRequestBody(canvasGenerationInput{
Config: providerConfig{Model: "grok-imagine-video-1.5", InterfaceType: "xai-video"},
ReferenceImages: []providerMedia{
{ID: "character", DataURL: testReferenceImageDataURL},
},
Metadata: map[string]interface{}{"videoEditOperation": "reference_to_video", "videoStartFrameNodeId": "character"},
})
if err != nil {
t.Fatalf("xaiVideoRequestBody() error = %v", err)
}
if body.Image != nil || len(body.ReferenceImages) != 1 {
t.Fatalf("xAI reference operation body = %#v", body)
}
}
func TestXAIVideoBodyWithStartFrameRejectsMultipleImages(t *testing.T) {
_, err := xaiVideoRequestBody(canvasGenerationInput{
Config: providerConfig{Model: "grok-imagine-video-1.5", InterfaceType: "xai-video"},
@@ -1919,6 +1960,38 @@ func TestNewAPIChannel1VideoBodyMapsFramesAndReferences(t *testing.T) {
}
}
func TestProtocolRequestPreservesVideoImageIDsAndRoles(t *testing.T) {
request := protocolRequestFromInput(canvasGenerationInput{
Mode: "video",
ReferenceImages: []providerMedia{
{ID: "start", URL: "https://example.com/start.png"},
{ID: "character", URL: "https://example.com/character.png"},
},
Metadata: map[string]interface{}{
"videoEditOperation": "image_to_video",
"videoStartFrameNodeId": "start",
},
})
if len(request.Images) != 2 {
t.Fatalf("images = %#v", request.Images)
}
if request.Images[0].ID != "start" || request.Images[0].Role != "first_frame" {
t.Fatalf("start image = %#v", request.Images[0])
}
if request.Images[1].ID != "character" || request.Images[1].Role != "reference_image" {
t.Fatalf("unmarked image role = %#v", request.Images[1])
}
request = protocolRequestFromInput(canvasGenerationInput{
Mode: "video",
ReferenceImages: []providerMedia{{ID: "character", URL: "https://example.com/character.png"}},
Metadata: map[string]interface{}{"videoEditOperation": "reference_to_video", "videoStartFrameNodeId": "character"},
})
if request.Images[0].Role != "reference_image" {
t.Fatalf("reference operation image = %#v", request.Images[0])
}
}
func TestNewAPIChannel1VideoBodyRejectsInlineMedia(t *testing.T) {
_, err := newAPIChannel1VideoBody(canvasGenerationInput{
Prompt: "make it move",