diff --git a/backend/internal/app/cloud_agent_previs_mutation.go b/backend/internal/app/cloud_agent_previs_mutation.go index 95ed66c0a..056439e71 100644 --- a/backend/internal/app/cloud_agent_previs_mutation.go +++ b/backend/internal/app/cloud_agent_previs_mutation.go @@ -15,14 +15,16 @@ import ( ) const ( - cloudAgentPrevisMaxOperations = 32 - cloudAgentPrevisMaxScenes = 32 - cloudAgentPrevisMaxShots = 64 - cloudAgentPrevisMaxObjects = 128 - cloudAgentPrevisMaxCameras = 32 - cloudAgentPrevisMaxLights = 64 - cloudAgentPrevisDocumentLimit = 1 << 20 - cloudAgentPrevisTextLimit = 160 + cloudAgentPrevisMaxOperations = 32 + cloudAgentPrevisMaxScenes = 32 + cloudAgentPrevisMaxShots = 64 + cloudAgentPrevisMaxObjects = 128 + cloudAgentPrevisMaxCameras = 32 + cloudAgentPrevisMaxLights = 64 + cloudAgentPrevisDocumentLimit = 1 << 20 + cloudAgentPrevisTextLimit = 160 + cloudAgentPrevisWorkstationWidth = 520.0 + cloudAgentPrevisWorkstationHeight = 340.0 ) type cloudAgentPrevisSceneCreateArgs struct { @@ -30,6 +32,8 @@ type cloudAgentPrevisSceneCreateArgs struct { SceneID string `json:"sceneId"` Title string `json:"title"` TemplateID string `json:"templateId"` + NodeID string `json:"nodeId,omitempty"` + NodeTitle string `json:"nodeTitle,omitempty"` } type cloudAgentPrevisApplyPatchArgs struct { @@ -101,6 +105,8 @@ type cloudAgentPrevisMutationPlan struct { Operation string Preview cloudAgentApprovalPreview SceneID string + NodeID string + ShotID string } func cloudAgentPrevisSceneCreateSchema() map[string]any { @@ -111,6 +117,8 @@ func cloudAgentPrevisSceneCreateSchema() map[string]any { "sceneId": map[string]any{"type": "string", "maxLength": 80, "description": "新的稳定场景 ID"}, "title": map[string]any{"type": "string", "maxLength": cloudAgentPrevisTextLimit, "description": "场景标题"}, "templateId": map[string]any{"type": "string", "enum": []string{"empty", "monologue", "dialogue", "blocking", "product", "action_chase", "interior", "crowd"}, "description": "模板:empty/monologue/dialogue/blocking/product/action_chase/interior/crowd"}, + "nodeId": map[string]any{"type": "string", "maxLength": 80, "description": "可选的预演视频工作站节点 ID;省略时由 sceneId 派生稳定 ID"}, + "nodeTitle": map[string]any{"type": "string", "maxLength": cloudAgentPrevisTextLimit, "description": "可选的预演视频工作站节点标题"}, }, "required": []string{"canvasSnapshotHash", "sceneId", "title", "templateId"}, "additionalProperties": false, @@ -747,6 +755,165 @@ func cloudAgentPrevisSceneCreateTemplate(args cloudAgentPrevisSceneCreateArgs) ( return scene, cloudAgentPrevisValidateScene(scene) } +func cloudAgentPrevisWorkstationNodeID(sceneID string) string { + candidate := "previs-" + sceneID + if utf8.RuneCountInString(candidate) <= 80 { + return candidate + } + return "previs-" + creationHash(map[string]any{"sceneId": sceneID})[:40] +} + +func cloudAgentPrevisActiveShot(scene map[string]any) (string, string, int, error) { + activeShotID := stringValue(scene["activeShotId"]) + for index, shot := range creationMaps(scene["shots"]) { + if activeShotID != "" && stringValue(shot["id"]) != activeShotID { + continue + } + shotID := stringValue(shot["id"]) + if shotID == "" { + return "", "", 0, BadAuthRequest("预演场景的活动镜头缺少稳定 ID") + } + shotTitle := stringValue(shot["name"]) + if shotTitle == "" { + shotTitle = fmt.Sprintf("镜头 %d", index+1) + } + return shotID, shotTitle, index + 1, nil + } + return "", "", 0, BadAuthRequest("预演场景没有可绑定的活动镜头") +} + +func cloudAgentPrevisWorkstationPosition(nodes []map[string]any) (float64, float64) { + right := math.Inf(-1) + y := 80.0 + for _, node := range nodes { + position, _ := node["position"].(map[string]any) + x := numberValue(position["x"], 0) + width := numberValue(node["width"], 340) + if x+width > right { + right = x + width + y = math.Max(80, numberValue(position["y"], 80)) + } + } + if math.IsInf(right, -1) { + return 80, 80 + } + return math.Max(80, right+80), y +} + +func cloudAgentPrevisEnsureWorkstationNode(doc map[string]any, scene map[string]any, requestedNodeID, requestedTitle string) (string, string, bool, error) { + sceneID := stringValue(scene["id"]) + if err := validateCloudAgentID(sceneID, "导演场景 ID", 80); err != nil { + return "", "", false, err + } + shotID, shotTitle, shotIndex, err := cloudAgentPrevisActiveShot(scene) + if err != nil { + return "", "", false, err + } + + nodeID := strings.TrimSpace(requestedNodeID) + if nodeID != "" { + if err := validateCloudAgentID(nodeID, "预演工作站节点 ID", 80); err != nil { + return "", "", false, err + } + } else { + nodeID = cloudAgentPrevisWorkstationNodeID(sceneID) + } + title := strings.TrimSpace(requestedTitle) + if title == "" { + title = stringValue(scene["title"]) + } + if title == "" { + title = shotTitle + } + if err := cloudAgentPrevisValidateText(title, "预演工作站标题", cloudAgentPrevisTextLimit, true); err != nil { + return "", "", false, err + } + + nodes := creationMaps(doc["nodes"]) + var bound map[string]any + for _, node := range nodes { + metadata, _ := node["metadata"].(map[string]any) + if stringValue(metadata["previsSceneId"]) != sceneID { + continue + } + if stringValue(node["type"]) != "video" { + return "", "", false, BadAuthRequest("预演场景已绑定非视频节点,无法修复工作站") + } + if bound != nil && stringValue(bound["id"]) != stringValue(node["id"]) { + return "", "", false, BadAuthRequest("预演场景绑定了多个工作站节点,无法自动选择") + } + bound = node + } + + var requested map[string]any + for _, node := range nodes { + if stringValue(node["id"]) == nodeID { + requested = node + break + } + } + if requested != nil { + if stringValue(requested["type"]) != "video" { + return "", "", false, BadAuthRequest("预演工作站节点 ID 已被非视频节点占用") + } + metadata, _ := requested["metadata"].(map[string]any) + otherSceneID := stringValue(metadata["previsSceneId"]) + if otherSceneID != "" && otherSceneID != sceneID { + return "", "", false, BadAuthRequest("预演工作站节点 ID 已绑定其他场景") + } + if bound != nil && stringValue(bound["id"]) != nodeID { + return "", "", false, BadAuthRequest("预演场景已有其他工作站节点绑定") + } + bound = requested + } + + created := false + if bound == nil { + positionX, positionY := cloudAgentPrevisWorkstationPosition(nodes) + metadata := map[string]any{ + "content": "", + "status": "idle", + "workflowKind": "shot", + "workflowTitle": shotTitle, + "shotIndex": float64(shotIndex), + "generationMode": "video", + "videoEditOperation": "text_to_video", + "composerContent": "", + "previsSceneId": sceneID, + "previsShotId": shotID, + } + width, height := cloudAgentPrevisWorkstationWidth, cloudAgentPrevisWorkstationHeight + op := CreationCanvasOp{Type: "add_node", ID: nodeID, NodeType: "video", Title: title, X: &positionX, Y: &positionY, Width: &width, Height: &height, Metadata: metadata} + nodes = append(nodes, creationAddedNode(op)) + doc["nodes"] = nodes + return nodeID, shotID, true, nil + } + + metadata, _ := bound["metadata"].(map[string]any) + if metadata == nil { + metadata = map[string]any{} + } + metadata["workflowKind"] = "shot" + metadata["workflowTitle"] = shotTitle + metadata["shotIndex"] = float64(shotIndex) + metadata["generationMode"] = "video" + metadata["videoEditOperation"] = "text_to_video" + metadata["previsSceneId"] = sceneID + metadata["previsShotId"] = shotID + if _, exists := metadata["status"]; !exists { + metadata["status"] = "idle" + } + if _, exists := metadata["composerContent"]; !exists { + metadata["composerContent"] = "" + } + bound["metadata"] = metadata + if stringValue(bound["title"]) == "" || strings.TrimSpace(requestedTitle) != "" { + bound["title"] = title + } + doc["nodes"] = nodes + return stringValue(bound["id"]), shotID, created, nil +} + func prepareCloudAgentPrevisSceneCreate(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentPrevisMutationPlan, error) { var args cloudAgentPrevisSceneCreateArgs if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { @@ -755,6 +922,19 @@ func prepareCloudAgentPrevisSceneCreate(repo *repository.Repository, userID, can if strings.TrimSpace(args.CanvasSnapshotHash) == "" { return nil, cloudAgentFieldError("canvasSnapshotHash", "required", "创建预演场景前必须先读取场景目录") } + if err := validateCloudAgentID(args.SceneID, "导演场景 ID", 80); err != nil { + return nil, cloudAgentFieldError("sceneId", "invalid_value", cloudAgentSafeToolError(err)) + } + if args.NodeID != "" { + if err := validateCloudAgentID(args.NodeID, "预演工作站节点 ID", 80); err != nil { + return nil, cloudAgentFieldError("nodeId", "invalid_value", cloudAgentSafeToolError(err)) + } + } + if args.NodeTitle != "" { + if err := cloudAgentPrevisValidateText(args.NodeTitle, "预演工作站标题", cloudAgentPrevisTextLimit, false); err != nil { + return nil, cloudAgentFieldError("nodeTitle", "invalid_value", cloudAgentSafeToolError(err)) + } + } canvas, doc, err := cloudAgentPrevisCanvas(repo, userID, canvasID) if err != nil { return nil, err @@ -779,7 +959,12 @@ func prepareCloudAgentPrevisSceneCreate(repo *repository.Repository, userID, can return nil, err } doc["previsScenes"] = scenes - return &cloudAgentPrevisMutationPlan{Canvas: canvas, Document: doc, BeforeJSON: canvas.PayloadJSON, BeforeSnapshotHash: beforeHash, AfterSnapshotHash: cloudAgentCanvasHash(doc), ResultSnapshotHash: creationHash(scene), Operation: "previs_scene_create", SceneID: args.SceneID, Preview: cloudAgentPrevisMutationApprovalPreview("previs_scene_create", args.SceneID, args.Title, []string{"创建受控白模场景", "模板:" + args.TemplateID})}, nil + nodeID, shotID, _, err := cloudAgentPrevisEnsureWorkstationNode(doc, scene, args.NodeID, args.NodeTitle) + if err != nil { + return nil, err + } + details := []string{"创建受控白模场景", "模板:" + args.TemplateID, "绑定预演视频工作站:" + nodeID} + return &cloudAgentPrevisMutationPlan{Canvas: canvas, Document: doc, BeforeJSON: canvas.PayloadJSON, BeforeSnapshotHash: beforeHash, AfterSnapshotHash: cloudAgentCanvasHash(doc), ResultSnapshotHash: creationHash(scene), Operation: "previs_scene_create", SceneID: args.SceneID, NodeID: nodeID, ShotID: shotID, Preview: cloudAgentPrevisMutationApprovalPreview("previs_scene_create", args.SceneID, args.Title, details)}, nil } func prepareCloudAgentPrevisApplyPatch(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentPrevisMutationPlan, error) { @@ -829,7 +1014,15 @@ func prepareCloudAgentPrevisApplyPatch(repo *repository.Repository, userID, canv return nil, err } doc["previsScenes"] = scenes - return &cloudAgentPrevisMutationPlan{Canvas: canvas, Document: doc, BeforeJSON: canvas.PayloadJSON, BeforeSnapshotHash: canvasSnapshotHash, AfterSnapshotHash: cloudAgentCanvasHash(doc), ResultSnapshotHash: creationHash(scene), Operation: "previs_apply_patch", SceneID: args.SceneID, Preview: cloudAgentPrevisMutationApprovalPreview("previs_apply_patch", args.SceneID, stringValue(scene["title"]), itemsToDetails(items))}, nil + nodeID, shotID, workstationCreated, err := cloudAgentPrevisEnsureWorkstationNode(doc, scene, "", "") + if err != nil { + return nil, err + } + details := itemsToDetails(items) + if workstationCreated { + details = append(details, "创建并绑定预演视频工作站:"+nodeID) + } + return &cloudAgentPrevisMutationPlan{Canvas: canvas, Document: doc, BeforeJSON: canvas.PayloadJSON, BeforeSnapshotHash: canvasSnapshotHash, AfterSnapshotHash: cloudAgentCanvasHash(doc), ResultSnapshotHash: creationHash(scene), Operation: "previs_apply_patch", SceneID: args.SceneID, NodeID: nodeID, ShotID: shotID, Preview: cloudAgentPrevisMutationApprovalPreview("previs_apply_patch", args.SceneID, stringValue(scene["title"]), details)}, nil } func itemsToDetails(items []cloudAgentApprovalPreviewItem) []string { @@ -1762,5 +1955,5 @@ func applyCloudAgentPrevisMutation(repo *repository.Repository, userID, canvasID if resultSnapshotHash == "" { resultSnapshotHash = plan.AfterSnapshotHash } - return map[string]any{"canvasId": canvasID, "sceneId": plan.SceneID, "snapshotHash": resultSnapshotHash, "canvasSnapshotHash": plan.AfterSnapshotHash, "committed": true, "summary": plan.Preview.Description}, nil + return map[string]any{"canvasId": canvasID, "sceneId": plan.SceneID, "nodeId": plan.NodeID, "shotId": plan.ShotID, "snapshotHash": resultSnapshotHash, "canvasSnapshotHash": plan.AfterSnapshotHash, "committed": true, "summary": plan.Preview.Description}, nil } diff --git a/backend/internal/app/cloud_agent_previs_mutation_test.go b/backend/internal/app/cloud_agent_previs_mutation_test.go index e50f14021..970d37f03 100644 --- a/backend/internal/app/cloud_agent_previs_mutation_test.go +++ b/backend/internal/app/cloud_agent_previs_mutation_test.go @@ -154,6 +154,206 @@ func TestCloudAgentPrevisSceneCreateAndPatchKeepSemanticAndCanvasHashesSeparate( } } +func TestCloudAgentPrevisSceneCreateBindsVideoWorkstation(t *testing.T) { + s, canvas := previsMutationFixture(t) + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + call := previsMutationCall(t, "previs-bind-create", "previs_scene_create", map[string]any{ + "canvasSnapshotHash": cloudAgentCanvasHash(doc), + "sceneId": "bound-scene", + "title": "绑定镜头", + "templateId": "empty", + }) + result, err := applyCloudAgentPrevisMutation(s.repo, "user", canvas.ID, call, policy) + if err != nil { + t.Fatal(err) + } + nodeID := previsResultString(t, result, "nodeId") + shotID := previsResultString(t, result, "shotId") + if nodeID != "previs-bound-scene" { + t.Fatalf("nodeId = %q, want stable scene-derived ID", nodeID) + } + stored, err := s.repo.CanvasProjectForUser("user", canvas.ID) + if err != nil { + t.Fatal(err) + } + storedDoc, err := creationDocument(stored.PayloadJSON) + if err != nil { + t.Fatal(err) + } + var workstation map[string]any + for _, node := range creationMaps(storedDoc["nodes"]) { + if stringValue(node["id"]) == nodeID { + workstation = node + break + } + } + if workstation == nil { + t.Fatalf("bound workstation node missing: %#v", storedDoc["nodes"]) + } + if stringValue(workstation["type"]) != "video" || numberValue(workstation["width"], 0) != cloudAgentPrevisWorkstationWidth || numberValue(workstation["height"], 0) != cloudAgentPrevisWorkstationHeight { + t.Fatalf("unexpected workstation shape: %#v", workstation) + } + metadata, _ := workstation["metadata"].(map[string]any) + if stringValue(metadata["workflowKind"]) != "shot" || stringValue(metadata["previsSceneId"]) != "bound-scene" || stringValue(metadata["previsShotId"]) != shotID || stringValue(metadata["generationMode"]) != "video" { + t.Fatalf("workstation is not bound as a shot node: %#v", metadata) + } + if stringValue(metadata["videoEditOperation"]) != "text_to_video" || stringValue(metadata["status"]) != "idle" { + t.Fatalf("workstation generation metadata is incomplete: %#v", metadata) + } +} + +func TestCloudAgentPrevisPatchReusesIdleBoundWorkstationWithoutTaskCollision(t *testing.T) { + s, canvas := previsMutationFixture(t) + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + create := previsMutationCall(t, "reuse-create", "previs_scene_create", map[string]any{ + "canvasSnapshotHash": cloudAgentCanvasHash(doc), + "sceneId": "reuse-scene", + "title": "可复用镜头", + "templateId": "empty", + }) + created, err := applyCloudAgentPrevisMutation(s.repo, "user", canvas.ID, create, policy) + if err != nil { + t.Fatal(err) + } + nodeID := previsResultString(t, created, "nodeId") + stored, err := s.repo.CanvasProjectForUser("user", canvas.ID) + if err != nil { + t.Fatal(err) + } + storedDoc, err := creationDocument(stored.PayloadJSON) + if err != nil { + t.Fatal(err) + } + workstation := creationMaps(storedDoc["nodes"])[0] + metadata, _ := workstation["metadata"].(map[string]any) + metadata["taskStatus"] = "not_submitted" + metadata["composerContent"] = "保留现有提示词" + raw, err := json.Marshal(storedDoc) + if err != nil { + t.Fatal(err) + } + stored.PayloadJSON = string(raw) + if err := s.repo.Save(stored); err != nil { + t.Fatal(err) + } + + patch := previsMutationCall(t, "reuse-patch", "previs_apply_patch", map[string]any{ + "snapshotHash": previsResultString(t, created, "snapshotHash"), + "sceneId": "reuse-scene", + "operations": []map[string]any{{ + "type": "scene_update", + "id": "reuse-scene", + "title": "可复用镜头·已更新", + }}, + }) + result, err := applyCloudAgentPrevisMutation(s.repo, "user", canvas.ID, patch, policy) + if err != nil { + t.Fatal(err) + } + if previsResultString(t, result, "nodeId") != nodeID { + t.Fatalf("idle bound workstation was replaced: got %q want %q", previsResultString(t, result, "nodeId"), nodeID) + } + stored, err = s.repo.CanvasProjectForUser("user", canvas.ID) + if err != nil { + t.Fatal(err) + } + storedDoc, err = creationDocument(stored.PayloadJSON) + if err != nil { + t.Fatal(err) + } + nodes := creationMaps(storedDoc["nodes"]) + if len(nodes) != 1 || stringValue(nodes[0]["id"]) != nodeID { + t.Fatalf("idle bound workstation was not reused: %#v", nodes) + } + metadata, _ = nodes[0]["metadata"].(map[string]any) + if stringValue(metadata["taskStatus"]) != "not_submitted" || stringValue(metadata["composerContent"]) != "保留现有提示词" { + t.Fatalf("workstation metadata was overwritten during repair: %#v", metadata) + } +} + +func TestCloudAgentPrevisPatchRepairsLegacySceneWithoutWorkstation(t *testing.T) { + s, canvas := previsMutationFixture(t) + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + create := previsMutationCall(t, "legacy-create", "previs_scene_create", map[string]any{ + "canvasSnapshotHash": cloudAgentCanvasHash(doc), + "sceneId": "legacy-scene", + "title": "旧场景", + "templateId": "empty", + }) + if _, err := applyCloudAgentPrevisMutation(s.repo, "user", canvas.ID, create, policy); err != nil { + t.Fatal(err) + } + stored, err := s.repo.CanvasProjectForUser("user", canvas.ID) + if err != nil { + t.Fatal(err) + } + legacyDoc, err := creationDocument(stored.PayloadJSON) + if err != nil { + t.Fatal(err) + } + legacyDoc["nodes"] = []any{} + legacyRaw, err := json.Marshal(legacyDoc) + if err != nil { + t.Fatal(err) + } + stored.PayloadJSON = string(legacyRaw) + if err := s.repo.Save(stored); err != nil { + t.Fatal(err) + } + legacyScene, ok := findPrevisScene(creationMaps(legacyDoc["previsScenes"]), "legacy-scene") + if !ok { + t.Fatal("legacy scene missing") + } + patch := previsMutationCall(t, "legacy-repair", "previs_apply_patch", map[string]any{ + "snapshotHash": creationHash(legacyScene), + "sceneId": "legacy-scene", + "operations": []map[string]any{{"type": "scene_update", "id": "legacy-scene", "title": "旧场景已修复"}}, + }) + result, err := applyCloudAgentPrevisMutation(s.repo, "user", canvas.ID, patch, policy) + if err != nil { + t.Fatal(err) + } + if previsResultString(t, result, "nodeId") != "previs-legacy-scene" { + t.Fatalf("legacy patch returned unexpected node: %#v", result) + } + stored, err = s.repo.CanvasProjectForUser("user", canvas.ID) + if err != nil { + t.Fatal(err) + } + repairedDoc, err := creationDocument(stored.PayloadJSON) + if err != nil { + t.Fatal(err) + } + if len(creationMaps(repairedDoc["nodes"])) != 1 { + t.Fatalf("legacy repair created unexpected node count: %#v", repairedDoc["nodes"]) + } + metadata, _ := creationMaps(repairedDoc["nodes"])[0]["metadata"].(map[string]any) + if stringValue(metadata["previsSceneId"]) != "legacy-scene" || stringValue(metadata["previsShotId"]) == "" { + t.Fatalf("legacy repair did not bind scene and shot: %#v", metadata) + } +} + func TestCloudAgentPrevisRejectsStaleSnapshotsAndCrossUserCanvas(t *testing.T) { s, canvas := previsMutationFixture(t) doc, err := creationDocument(canvas.PayloadJSON) diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index 4d14c7d9c..c0f8cd204 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -139,7 +139,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma }, "sceneId", "shotId") } if req.PermissionMode != "read_only" { - add("previs_scene_create", "创建预演场景;先读 canvasSnapshotHash。", cloudAgentPrevisSceneCreateSchema()["properties"].(map[string]any), "canvasSnapshotHash", "sceneId", "title", "templateId") + add("previs_scene_create", "创建预演场景并绑定一个可打开预演工作台的 video 工作站节点;先读 canvasSnapshotHash。", cloudAgentPrevisSceneCreateSchema()["properties"].(map[string]any), "canvasSnapshotHash", "sceneId", "title", "templateId") add("previs_apply_patch", "审批后应用语义补丁,最多32项;先读 snapshotHash。支持场景、镜头、对象、相机、灯光、动画;角色绑定须匹配画布角色卡,动画时间不超镜头时长。禁止原始 JSON、URL、storage key。", cloudAgentPrevisApplyPatchSchema()["properties"].(map[string]any), "snapshotHash", "sceneId", "operations") } add("canvas_list_node_types", "列出可创建的节点类型、尺寸与连接约束;先读能力卡再选择,不要猜 nodeType。", map[string]any{})