fix(canvas): 视频参考协议 - 统一首尾帧与角色资产语义
This commit is contained in:
1 parent
4787c130d0
commit
6c723fa282
15 files changed
+479
-68
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"}}`))
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user