Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_director.go
T

273 lines
8.7 KiB
Go

package app
import (
"fmt"
"strings"
"infinite-canvas/backend/internal/repository"
)
// Director Agent tools deliberately expose a projection rather than the raw
// DirectorScene payload. The browser owns model URLs and storage references;
// the Agent only needs semantic shot/layout data to plan a preview.
func cloudAgentDirectorSceneRead(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"`
}
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 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["directorScenes"])
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,
"scenes": items,
"count": len(items),
"guidance": "先用返回的 sceneId 读取一个场景,再使用 shotId 进行布局或预演。",
}, nil
}
scene, ok := findDirectorScene(rawScenes, args.SceneID)
if !ok {
return nil, BadAuthRequest("导演场景不存在或不属于当前画布")
}
if args.ShotID != "" {
if _, ok := findDirectorShot(scene, args.ShotID); !ok {
return nil, BadAuthRequest("导演镜头不存在或不属于指定场景")
}
}
objectIDs := make(map[string]bool, len(args.ObjectIDs))
for _, id := range args.ObjectIDs {
objectIDs[id] = true
}
return map[string]any{
"canvasId": canvasID,
"scene": projectDirectorScene(scene, args.ShotID, objectIDs),
"snapshotHash": creationHash(scene),
"revision": canvas.Revision,
"guidance": "这是安全摘要,不是可直接回写的完整 JSON;需要预演时使用 director_preview。",
}, nil
}
func cloudAgentDirectorPreview(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 := findDirectorScene(creationMaps(doc["directorScenes"]), args.SceneID)
if !ok {
return nil, BadAuthRequest("导演场景不存在或不属于当前画布")
}
shot, ok := findDirectorShot(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("director-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_director_viewport",
"text": "已请求导演台在浏览器视口录制白模预演;录制完成后会回写为画布视频节点。",
}, nil
}
func findDirectorScene(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 findDirectorShot(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 projectDirectorScene(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": projectDirectorTransform(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": projectDirectorTransform(camera["transform"]),
"target": projectDirectorVec3(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 projectDirectorTransform(value any) map[string]any {
transform, _ := value.(map[string]any)
return map[string]any{
"position": projectDirectorVec3(transform["position"]),
"rotation": projectDirectorVec3(transform["rotation"]),
"scale": projectDirectorVec3(transform["scale"]),
}
}
func projectDirectorVec3(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
}