fix(canvas): 短剧分镜 - 支持多镜头并行生成并正确引用角色图声资产

This commit is contained in:
ddCat
2026-08-30 16:30:51 +08:00
parent f62ea62355
commit 4787c130d0
13 changed files with 393 additions and 55 deletions

View File

@@ -1772,6 +1772,14 @@ func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Sho
return &shot, nil
}
func (r *Repository) ShotRevisionForShot(shotID string, revisionID string) (*model.ShotRevision, error) {
var revision model.ShotRevision
if err := r.db.First(&revision, "id = ? AND shot_id = ?", revisionID, shotID).Error; err != nil {
return nil, err
}
return &revision, nil
}
// DeleteProjectShot 原子删除单个镜头的领域关联,并重新压紧同章节镜头顺序。
func (r *Repository) DeleteProjectShot(projectID string, shotID string, updatedAt time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {

View File

@@ -44,6 +44,7 @@ type RegisterTaskOutputRequest struct {
CanvasID string `json:"canvasId"`
UnitID string `json:"unitId"`
ShotID string `json:"shotId"`
ShotRevisionID string `json:"shotRevisionId"`
ArtifactType string `json:"artifactType"`
AssetVersionID string `json:"assetVersionId"`
ResourceID string `json:"resourceId"`
@@ -240,6 +241,16 @@ func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID str
return model.WorkflowStepInstance{}, err
}
}
shotRevisionID := strings.TrimSpace(req.ShotRevisionID)
if shot != nil {
if shotRevisionID == "" {
shotRevisionID = shot.CurrentRevisionID
} else if _, revisionErr := s.repo.ShotRevisionForShot(shot.ID, shotRevisionID); revisionErr != nil {
return model.WorkflowStepInstance{}, revisionErr
}
} else if shotRevisionID != "" {
return model.WorkflowStepInstance{}, BadAuthRequest("镜头版本缺少所属镜头")
}
if versionID := strings.TrimSpace(req.AssetVersionID); versionID != "" {
if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil {
return model.WorkflowStepInstance{}, err
@@ -305,7 +316,7 @@ func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID str
productionLink := &model.ProductionTaskLink{ID: newID(), TaskID: task.ID, ProjectID: projectID, CanvasID: canvasID, UnitID: unitID, ShotID: shotID, WorkflowStepID: step.ID, ArtifactType: artifactType, CreatedAt: now, UpdatedAt: now}
var artifact *model.ShotArtifact
if shot != nil && strings.TrimSpace(req.ResourceID) != "" && artifactType != "" {
artifact = &model.ShotArtifact{ID: newID(), ProjectID: projectID, UnitID: shot.UnitID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, TaskID: task.ID, Type: artifactType, ResourceID: strings.TrimSpace(req.ResourceID), Status: "ready", Selected: true, MetadataJSON: metadata, CreatedAt: now, UpdatedAt: now}
artifact = &model.ShotArtifact{ID: newID(), ProjectID: projectID, UnitID: shot.UnitID, ShotID: shot.ID, RevisionID: shotRevisionID, TaskID: task.ID, Type: artifactType, ResourceID: strings.TrimSpace(req.ResourceID), Status: "ready", Selected: true, MetadataJSON: metadata, CreatedAt: now, UpdatedAt: now}
}
// 单镜产物成功只代表该镜头完成,不能提前放行整个章节阶段。
if shot != nil {
@@ -421,11 +432,13 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
CanvasID string `json:"canvasId"`
UnitID string `json:"unitId"`
ShotID string `json:"shotId"`
ShotRevisionID string `json:"shotRevisionId"`
ArtifactType string `json:"artifactType"`
AssetVersionID string `json:"assetVersionId"`
ResourceID string `json:"resourceId"`
MediaType string `json:"mediaType"`
Role string `json:"role"`
MetadataJSON string `json:"metadataJson"`
Metadata map[string]any `json:"metadata"`
}
if err := json.Unmarshal([]byte(decrypted), &input); err != nil {
@@ -450,6 +463,9 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
if input.ShotID == "" {
input.ShotID, _ = input.Metadata["shotId"].(string)
}
if input.ShotRevisionID == "" {
input.ShotRevisionID, _ = input.Metadata["shotRevisionId"].(string)
}
if input.ArtifactType == "" {
input.ArtifactType, _ = input.Metadata["artifactType"].(string)
}
@@ -462,6 +478,13 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
if input.Role == "" {
input.Role, _ = input.Metadata["role"].(string)
}
if input.MetadataJSON == "" {
if artifactMetadata, ok := input.Metadata["artifactMetadata"]; ok {
if encoded, encodeErr := json.Marshal(artifactMetadata); encodeErr == nil {
input.MetadataJSON = string(encoded)
}
}
}
}
if strings.TrimSpace(input.ResourceID) == "" {
input.ResourceID, input.MediaType = taskOutputResource(task.ResultJSON, task.Type)
@@ -488,7 +511,7 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
}
input.AssetVersionID = assetVersionID
}
_, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, CanvasID: input.CanvasID, UnitID: input.UnitID, ShotID: input.ShotID, ArtifactType: input.ArtifactType, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON})
_, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, CanvasID: input.CanvasID, UnitID: input.UnitID, ShotID: input.ShotID, ShotRevisionID: input.ShotRevisionID, ArtifactType: input.ArtifactType, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, MetadataJSON: input.MetadataJSON, OutputJSON: task.ResultJSON})
return err
}

View File

@@ -50,13 +50,17 @@ func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t *
if err != nil {
t.Fatal(err)
}
submittedRevisionID := shot.CurrentRevisionID
if _, _, err = service.CreateShotRevision("user-1", project.ID, shot.ID, ShotRevisionInput{PlotDescription: "任务提交后修改的镜头", DurationMs: 3000}); err != nil {
t.Fatal(err)
}
now := time.Now()
resource := model.Resource{ID: "resource-video-1", UserID: "user-1", Kind: "video", Status: model.ResourceStatusReady, MimeType: "video/mp4", Size: 1024, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&resource).Error; err != nil {
t.Fatal(err)
}
task := model.Task{ID: "workflow-video-task-1", UserID: "user-1", ProjectID: project.ID, Type: "canvas_video", Status: model.TaskStatusSucceeded,
InputJSON: `{"metadata":{"workflowStepId":"` + videoStep.ID + `","domainProjectId":"` + project.ID + `","unitId":"` + unit.ID + `","shotId":"` + shot.ID + `","artifactType":"video","role":"output"}}`,
InputJSON: `{"metadata":{"workflowStepId":"` + videoStep.ID + `","domainProjectId":"` + project.ID + `","unitId":"` + unit.ID + `","shotId":"` + shot.ID + `","shotRevisionId":"` + submittedRevisionID + `","artifactType":"video","role":"output","artifactMetadata":{"model":"MiniMax-H3"}}}`,
ResultJSON: `{"mode":"video","video":{"resourceId":"resource-video-1","storageKey":"resource:resource-video-1","mimeType":"video/mp4"}}`, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
@@ -78,6 +82,16 @@ func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t *
t.Fatalf("%s count = %d, error = %v", table, count, err)
}
}
var artifact model.ShotArtifact
if err := db.First(&artifact, "task_id = ?", task.ID).Error; err != nil {
t.Fatal(err)
}
if artifact.RevisionID != submittedRevisionID {
t.Fatalf("artifact revision = %q, want submitted revision %q", artifact.RevisionID, submittedRevisionID)
}
if artifact.MetadataJSON != `{"model":"MiniMax-H3"}` {
t.Fatalf("artifact metadata = %q", artifact.MetadataJSON)
}
}
func seedWorkflowProject(t *testing.T, db *gorm.DB) (model.Project, model.ProjectUnit) {

View File

@@ -2858,18 +2858,25 @@ func runMiniMaxVideoTask(ctx context.Context, input canvasGenerationInput) (map[
if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 {
return nil, errors.New("MiniMax 视频最多支持 9 张参考图、3 个参考视频和 3 个参考音频")
}
operation := metadataString(input.Metadata, "videoEditOperation")
startFrameID := metadataString(input.Metadata, "videoStartFrameNodeId")
endFrameID := metadataString(input.Metadata, "videoEndFrameNodeId")
frameMode := operation != "reference_to_video" && (startFrameID != "" || endFrameID != "")
content := []miniMaxVideoContent{{Type: "text", Text: strings.TrimSpace(input.Prompt)}}
for index, image := range input.ReferenceImages {
for _, image := range input.ReferenceImages {
url, err := miniMaxMediaURLValue(image)
if err != nil {
return nil, fmt.Errorf("MiniMax 参考图无效:%w", err)
}
role := "reference_image"
if len(input.ReferenceVideos) == 0 && len(input.ReferenceAudios) == 0 && len(input.ReferenceImages) <= 2 {
if index == 0 {
if frameMode {
switch {
case startFrameID != "" && image.ID == startFrameID:
role = "first_frame"
} else if index == 1 {
case endFrameID != "" && image.ID == endFrameID:
role = "last_frame"
default:
return nil, errors.New("MiniMax 首尾帧模式不能混合未标记的参考图")
}
}
content = append(content, miniMaxVideoContent{Type: "image_url", ImageURL: &miniMaxMediaURL{URL: url}, Role: role})
@@ -2889,7 +2896,6 @@ func runMiniMaxVideoTask(ctx context.Context, input canvasGenerationInput) (map[
content = append(content, miniMaxVideoContent{Type: "audio_url", AudioURL: &miniMaxMediaURL{URL: url}, Role: "reference_audio"})
}
watermark := parseBool(input.Config.VideoWatermark, false)
frameMode := len(input.ReferenceImages) > 0 && len(input.ReferenceImages) <= 2 && len(input.ReferenceVideos) == 0 && len(input.ReferenceAudios) == 0
body := miniMaxVideoRequest{
Model: input.Config.Model,
Content: content,

View File

@@ -2510,6 +2510,49 @@ func TestRunMiniMaxVideoTaskCreatesPollsAndDownloads(t *testing.T) {
}
}
func TestRunMiniMaxVideoTaskUsesExplicitReferenceRoles(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /v2/video_generation":
var body miniMaxVideoRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if len(body.Content) != 3 || body.Content[1].Role != "reference_image" || body.Content[2].Role != "reference_audio" {
t.Errorf("content = %#v", body.Content)
}
if body.Ratio != "16:9" {
t.Errorf("ratio = %q, want 16:9 for reference mode", body.Ratio)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"minimax-reference-task"}`))
case "GET /v2/query/video_generation/minimax-reference-task":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task":{"status":"succeeded","content":{"url":"` + server.URL + `/video.mp4"}}}`))
case "GET /video.mp4":
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
_, err := runVideoTask(context.Background(), canvasGenerationInput{
Mode: "video",
Prompt: "保持角色一致",
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "6", VQuality: "768P", Size: "16:9"},
ReferenceImages: []providerMedia{{ID: "character-1", URL: server.URL + "/character.png"}},
ReferenceAudios: []providerMedia{{ID: "voice-1", URL: server.URL + "/voice.mp3"}},
Metadata: map[string]interface{}{"videoEditOperation": "reference_to_video"},
})
if err != nil {
t.Fatal(err)
}
}
func TestRunMiniMaxVideoTaskReturnsFailureReason(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {