feat(agent): 预演工作站 - 创建场景时绑定可打开预演台的视频节点

创建场景时放置或修复唯一的 video 工作站,补丁复用空闲绑定并拒绝多节点或类型冲突;没有工作站的旧场景会在补丁时补上。
This commit is contained in:
ddcat666
2026-10-08 10:54:40 +08:00
parent 9b1464544f
commit dd52588a5b
3 changed files with 405 additions and 12 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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{})