refactor(canvas): 预演台 - 重构工作台与场景交互链路

This commit is contained in:
ddcat666
2026-10-06 09:06:43 +08:00
parent 1dfc8ce7ac
commit 112916587c
128 changed files with 42372 additions and 5011 deletions

View File

@@ -0,0 +1,365 @@
package app
import (
"fmt"
"strings"
"infinite-canvas/backend/internal/repository"
)
// Previs Agent tools deliberately expose a projection rather than the raw
// PrevisScene payload. The browser owns model URLs and storage references;
// the Agent only needs semantic shot/layout data to plan a preview.
func cloudAgentPrevisSceneRead(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (any, error) {
var args struct {
SceneID string `json:"sceneId"`
ShotID string `json:"shotId"`
ObjectIDs []string `json:"objectIds"`
IncludeTransforms bool `json:"includeTransforms"` // Return exact position/rotation/fov for spatial planning
}
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, cloudAgentJSONArgumentError(err)
}
if args.SceneID != "" {
if err := validateCloudAgentID(args.SceneID, "导演场景 ID", 80); err != nil {
return nil, err
}
}
if args.ShotID != "" {
if err := validateCloudAgentID(args.ShotID, "导演镜头 ID", 80); err != nil {
return nil, err
}
if args.SceneID == "" {
return nil, cloudAgentFieldError("shotId", "requires_scene", "指定 shotId 时必须同时提供 sceneId")
}
}
if len(args.ObjectIDs) > 16 {
return nil, BadAuthRequest("导演对象读取最多包含16个对象 ID")
}
for _, id := range args.ObjectIDs {
if err := validateCloudAgentID(id, "导演对象 ID", 80); err != nil {
return nil, err
}
}
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, err
}
rawScenes := creationMaps(doc["previsScenes"])
if args.SceneID == "" {
items := make([]map[string]any, 0, len(rawScenes))
for _, scene := range rawScenes {
items = append(items, map[string]any{
"id": stringValue(scene["id"]),
"title": truncateRunes(stringValue(scene["title"]), 160),
"activeShotId": stringValue(scene["activeShotId"]),
"shotCount": len(creationMaps(scene["shots"])),
"objectCount": len(creationMaps(scene["objects"])),
})
}
return map[string]any{
"canvasId": canvasID,
"canvasSnapshotHash": cloudAgentCanvasHash(doc),
"scenes": items,
"count": len(items),
"guidance": "先用返回的 sceneId 读取一个场景,再使用 shotId 进行布局或预演。创建场景时把 canvasSnapshotHash 原样传回。",
}, nil
}
scene, ok := findPrevisScene(rawScenes, args.SceneID)
if !ok {
return nil, BadAuthRequest("导演场景不存在或不属于当前画布")
}
if args.ShotID != "" {
if _, ok := findPrevisShot(scene, args.ShotID); !ok {
return nil, BadAuthRequest("导演镜头不存在或不属于指定场景")
}
}
objectIDs := make(map[string]bool, len(args.ObjectIDs))
for _, id := range args.ObjectIDs {
objectIDs[id] = true
}
projection := projectPrevisScene(scene, args.ShotID, objectIDs)
if args.IncludeTransforms {
// Augment with full spatial data for layout planning: object positions, camera transforms, FOV
projection["spatialLayout"] = cloudAgentPrevisSpatialLayout(scene)
projection["coordinateGuide"] = "坐标系: Y轴向上,单位为米。人物高约1.8m。position=[X,Y,Z],rotation=[绕X,绕Y,绕Z弧度]。target是摄影机焦点坐标。"
}
return map[string]any{
"canvasId": canvasID,
"scene": projection,
"snapshotHash": creationHash(scene),
"canvasSnapshotHash": cloudAgentCanvasHash(doc),
"revision": canvas.Revision,
"guidance": "安全摘要。修改用 previs_apply_patch;预演用 previs_preview;布局规划用 includeTransforms=true 获取精确坐标。snapshotHash 只代表该场景。",
}, nil
}
// previsMapSafe safely casts a value to map[string]any, returning an empty map on failure.
func previsMapSafe(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
return map[string]any{}
}
// cloudAgentPrevisSpatialLayout returns precise spatial data for Agent layout planning.
// It never returns model URLs, storage keys, or any other sensitive references.
func cloudAgentPrevisSpatialLayout(scene map[string]any) map[string]any {
objects := creationMaps(scene["objects"])
cameras := creationMaps(scene["cameras"])
shots := creationMaps(scene["shots"])
objLayouts := make([]map[string]any, 0, len(objects))
for _, obj := range objects {
if !boolValue(obj["visible"], true) {
continue
}
transform := previsMapSafe(obj["transform"])
entry := map[string]any{
"id": stringValue(obj["id"]),
"name": stringValue(obj["name"]),
"kind": stringValue(obj["kind"]),
"archetype": stringValue(obj["archetype"]),
"pose": stringValue(obj["pose"]),
"color": stringValue(obj["color"]),
"position": transform["position"],
"rotation": transform["rotation"],
"scale": transform["scale"],
"keyframeCount": len(creationMaps(obj["keyframes"])),
"hasMotionPath": obj["motionPath"] != nil,
}
if binding, ok := obj["characterBinding"].(map[string]any); ok {
entry["characterBinding"] = map[string]any{
"characterAssetId": stringValue(binding["characterAssetId"]),
"characterVersionId": stringValue(binding["characterVersionId"]),
"referenceNodeId": stringValue(binding["referenceNodeId"]),
"characterName": truncateRunes(stringValue(binding["characterName"]), 160),
}
}
objLayouts = append(objLayouts, entry)
}
camLayouts := make([]map[string]any, 0, len(cameras))
for _, cam := range cameras {
transform := previsMapSafe(cam["transform"])
camLayouts = append(camLayouts, map[string]any{
"id": stringValue(cam["id"]),
"name": stringValue(cam["name"]),
"position": transform["position"],
"target": cam["target"],
"focalLength": cam["focalLength"],
"fov": cam["fov"],
})
}
shotLayouts := make([]map[string]any, 0, len(shots))
for _, shot := range shots {
shotLayouts = append(shotLayouts, map[string]any{
"id": stringValue(shot["id"]),
"name": stringValue(shot["name"]),
"cameraId": stringValue(shot["cameraId"]),
"shotSize": stringValue(shot["shotSize"]),
"cameraMove": stringValue(shot["cameraMove"]),
"duration": shot["duration"],
})
}
return map[string]any{
"objects": objLayouts,
"cameras": camLayouts,
"shots": shotLayouts,
}
}
func cloudAgentPrevisPreview(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (any, error) {
var args struct {
SceneID string `json:"sceneId"`
ShotID string `json:"shotId"`
Duration *float64 `json:"duration"`
FPS *int `json:"fps"`
Output string `json:"output"`
}
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, cloudAgentJSONArgumentError(err)
}
if err := validateCloudAgentID(args.SceneID, "导演场景 ID", 80); err != nil {
return nil, err
}
if err := validateCloudAgentID(args.ShotID, "导演镜头 ID", 80); err != nil {
return nil, err
}
if args.Output != "" && args.Output != "clay_video" {
return nil, BadAuthRequest("导演预演 output 目前只支持 clay_video")
}
if args.Duration != nil && (*args.Duration < 0.1 || *args.Duration > 60) {
return nil, BadAuthRequest("导演预演 duration 必须在0.1到60秒之间")
}
if args.FPS != nil && (*args.FPS < 1 || *args.FPS > 60) {
return nil, BadAuthRequest("导演预演 fps 必须在1到60之间")
}
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, err
}
scene, ok := findPrevisScene(creationMaps(doc["previsScenes"]), args.SceneID)
if !ok {
return nil, BadAuthRequest("导演场景不存在或不属于当前画布")
}
shot, ok := findPrevisShot(scene, args.ShotID)
if !ok {
return nil, BadAuthRequest("导演镜头不存在或不属于指定场景")
}
duration := numberValue(shot["duration"], 5)
if args.Duration != nil {
duration = *args.Duration
}
fps := int(numberValue(shot["fps"], 24))
if args.FPS != nil {
fps = *args.FPS
}
requestID := strings.TrimSpace(call.ID)
if requestID == "" {
requestID = fmt.Sprintf("previs-preview-%s-%s", args.SceneID, args.ShotID)
}
return map[string]any{
"previewRequestId": requestID,
"canvasId": canvasID,
"sceneId": args.SceneID,
"sceneTitle": truncateRunes(stringValue(scene["title"]), 160),
"shotId": args.ShotID,
"shotName": truncateRunes(stringValue(shot["name"]), 160),
"duration": duration,
"fps": fps,
"output": "clay_video",
"snapshotHash": creationHash(scene),
"execution": "browser_previs_viewport",
"text": "已请求预演台在浏览器视口录制白模预演;录制完成后会回写为画布视频节点。",
}, nil
}
func findPrevisScene(scenes []map[string]any, id string) (map[string]any, bool) {
for _, scene := range scenes {
if stringValue(scene["id"]) == id {
return scene, true
}
}
return nil, false
}
func findPrevisShot(scene map[string]any, id string) (map[string]any, bool) {
for _, shot := range creationMaps(scene["shots"]) {
if stringValue(shot["id"]) == id {
return shot, true
}
}
return nil, false
}
func projectPrevisScene(scene map[string]any, shotID string, objectIDs map[string]bool) map[string]any {
shots := make([]map[string]any, 0)
for _, shot := range creationMaps(scene["shots"]) {
if shotID != "" && stringValue(shot["id"]) != shotID {
continue
}
shots = append(shots, map[string]any{
"id": stringValue(shot["id"]),
"name": truncateRunes(stringValue(shot["name"]), 160),
"cameraId": stringValue(shot["cameraId"]),
"duration": numberValue(shot["duration"], 5),
"fps": int(numberValue(shot["fps"], 24)),
"shotSize": stringValue(shot["shotSize"]),
"cameraMove": stringValue(shot["cameraMove"]),
})
}
objects := make([]map[string]any, 0)
for _, object := range creationMaps(scene["objects"]) {
id := stringValue(object["id"])
if len(objectIDs) > 0 && !objectIDs[id] {
continue
}
objects = append(objects, map[string]any{
"id": id,
"name": truncateRunes(stringValue(object["name"]), 160),
"kind": stringValue(object["kind"]),
"primitive": stringValue(object["primitive"]),
"color": stringValue(object["color"]),
"pose": stringValue(object["pose"]),
"visible": boolValue(object["visible"], true),
"transform": projectPrevisTransform(object["transform"]),
})
}
cameras := make([]map[string]any, 0)
for _, camera := range creationMaps(scene["cameras"]) {
cameras = append(cameras, map[string]any{
"id": stringValue(camera["id"]),
"name": truncateRunes(stringValue(camera["name"]), 160),
"focalLength": numberValue(camera["focalLength"], 50),
"transform": projectPrevisTransform(camera["transform"]),
"target": projectPrevisVec3(camera["target"]),
})
}
return map[string]any{
"id": stringValue(scene["id"]),
"title": truncateRunes(stringValue(scene["title"]), 160),
"activeShotId": stringValue(scene["activeShotId"]),
"shots": shots,
"objects": objects,
"cameras": cameras,
"lightCount": len(creationMaps(scene["lights"])),
}
}
func projectPrevisTransform(value any) map[string]any {
transform, _ := value.(map[string]any)
return map[string]any{
"position": projectPrevisVec3(transform["position"]),
"rotation": projectPrevisVec3(transform["rotation"]),
"scale": projectPrevisVec3(transform["scale"]),
}
}
func projectPrevisVec3(value any) []any {
items, ok := value.([]any)
if !ok || len(items) != 3 {
return []any{0, 0, 0}
}
result := make([]any, 3)
for index, item := range items {
result[index], _ = cloudAgentSafeNumber(item)
if result[index] == nil {
result[index] = 0
}
}
return result
}
func numberValue(value any, fallback float64) float64 {
if number, ok := cloudAgentSafeNumber(value); ok {
switch typed := number.(type) {
case float64:
return typed
case int:
return float64(typed)
case int64:
return float64(typed)
}
}
return fallback
}
func boolValue(value any, fallback bool) bool {
if result, ok := value.(bool); ok {
return result
}
return fallback
}