366 lines
12 KiB
Go
366 lines
12 KiB
Go
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
|
||
}
|