fix(canvas): 短剧分镜 - 支持多镜头并行生成并正确引用角色图声资产
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user