Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_previs_mutation.go
ddcat666 dd52588a5b feat(agent): 预演工作站 - 创建场景时绑定可打开预演台的视频节点
创建场景时放置或修复唯一的 video 工作站,补丁复用空闲绑定并拒绝多节点或类型冲突;没有工作站的旧场景会在补丁时补上。
2026-10-08 10:54:40 +08:00

1960 lines
83 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
const (
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 {
CanvasSnapshotHash string `json:"canvasSnapshotHash"`
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 {
SnapshotHash string `json:"snapshotHash"`
SceneID string `json:"sceneId"`
Operations []cloudAgentPrevisPatchOperation `json:"operations"`
}
// cloudAgentPrevisPatchOperation is deliberately a closed semantic contract.
// It has no generic path, metadata, URL, storage key or raw scene field.
type cloudAgentPrevisPatchOperation struct {
Type string `json:"type"`
ID string `json:"id"`
Name *string `json:"name"`
Title *string `json:"title"`
Kind string `json:"kind"`
Primitive string `json:"primitive"`
Archetype string `json:"archetype"` // adult/child/elderly/man/woman/monster
Color *string `json:"color"`
ParentID *string `json:"parentId"`
Visible *bool `json:"visible"`
Pose string `json:"pose"`
CharacterAssetID *string `json:"characterAssetId"`
CharacterVersionID *string `json:"characterVersionId"`
ReferenceNodeID *string `json:"referenceNodeId"`
CharacterName *string `json:"characterName"`
Position *[]float64 `json:"position"`
Rotation *[]float64 `json:"rotation"`
Scale *[]float64 `json:"scale"`
Target *[]float64 `json:"target"`
KeyframeID *string `json:"keyframeId"`
Time *float64 `json:"time"`
Easing string `json:"easing"`
PathPoints [][]float64 `json:"pathPoints"`
PathSpeed string `json:"pathSpeed"`
StartDelay *float64 `json:"startDelay"`
OrientToPath *bool `json:"orientToPath"`
CameraID *string `json:"cameraId"`
Duration *float64 `json:"duration"`
FPS *int `json:"fps"`
ShotSize string `json:"shotSize"`
CameraMove string `json:"cameraMove"`
Prompt *string `json:"prompt"`
FocalLength *float64 `json:"focalLength"`
LightType *string `json:"lightType"`
Intensity *float64 `json:"intensity"`
Angle *float64 `json:"angle"`
Penumbra *float64 `json:"penumbra"`
CastShadow *bool `json:"castShadow"`
Background *string `json:"background"`
EnvironmentIntensity *float64 `json:"environmentIntensity"`
GridVisible *bool `json:"gridVisible"`
ActiveShotID *string `json:"activeShotId"`
}
type cloudAgentPrevisMutationPlan struct {
Canvas *model.CanvasProject
Document map[string]any
BeforeJSON string
BeforeSnapshotHash string
AfterSnapshotHash string
ResultSnapshotHash string
Operation string
Preview cloudAgentApprovalPreview
SceneID string
NodeID string
ShotID string
}
func cloudAgentPrevisSceneCreateSchema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"canvasSnapshotHash": map[string]any{"type": "string", "description": "previs_scene_read 场景目录返回的 canvasSnapshotHash;创建前画布必须未变化"},
"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,
}
}
func cloudAgentPrevisPatchOperationSchema() map[string]any {
vec3 := map[string]any{"type": "array", "minItems": 3, "maxItems": 3, "items": map[string]any{"type": "number"}}
properties := map[string]any{
"type": map[string]any{"type": "string", "enum": []string{"scene_update", "shot_add", "shot_update", "shot_remove", "object_add", "object_update", "object_remove", "object_keyframe_upsert", "object_keyframe_remove", "object_motion_path_set", "object_motion_path_clear", "camera_add", "camera_update", "camera_remove", "camera_keyframe_upsert", "camera_keyframe_remove", "light_add", "light_update", "light_remove"}},
"id": map[string]any{"type": "string", "maxLength": 80, "description": "目标或新建资源 ID"},
"name": map[string]any{"type": "string", "maxLength": cloudAgentPrevisTextLimit},
"title": map[string]any{"type": "string", "maxLength": cloudAgentPrevisTextLimit},
"kind": map[string]any{"type": "string", "enum": []string{"primitive", "actor"}},
"primitive": map[string]any{"type": "string", "enum": []string{"box", "sphere", "cylinder", "plane", "character"}},
"archetype": map[string]any{"type": "string", "enum": []string{"adult", "child", "elderly", "man", "woman", "monster"}, "description": "演员体型原型"},
"characterAssetId": map[string]any{"type": "string", "maxLength": 120},
"characterVersionId": map[string]any{"type": "string", "maxLength": 120},
"referenceNodeId": map[string]any{"type": "string", "maxLength": 120},
"characterName": map[string]any{"type": "string", "maxLength": cloudAgentPrevisTextLimit},
"pose": map[string]any{"type": "string", "enum": []string{"stand", "sit", "walk", "run", "wave", "think", "fight", "kick", "throw", "push", "reach", "arms_crossed", "phone", "bow", "kneel_single", "kneel_double", "lean", "squat", "hands_hips", "neutral", "t_pose"}, "description": "演员姿态"},
"color": map[string]any{"type": "string", "maxLength": 9, "pattern": `^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`, "description": "十六进制颜色"},
"parentId": map[string]any{"type": "string", "maxLength": 80},
"visible": map[string]any{"type": "boolean"},
"position": vec3,
"rotation": vec3,
"scale": vec3,
"target": vec3,
"keyframeId": map[string]any{"type": "string", "maxLength": 120},
"time": map[string]any{"type": "number", "minimum": 0, "maximum": 60},
"easing": map[string]any{"type": "string", "enum": []string{"step", "linear", "smooth"}},
"pathPoints": map[string]any{"type": "array", "minItems": 2, "maxItems": 64, "items": vec3},
"pathSpeed": map[string]any{"type": "string", "enum": []string{"slow", "walk", "run"}},
"startDelay": map[string]any{"type": "number", "minimum": 0, "maximum": 60},
"orientToPath": map[string]any{"type": "boolean"},
"cameraId": map[string]any{"type": "string", "maxLength": 80},
"duration": map[string]any{"type": "number", "minimum": 0.1, "maximum": 60},
"fps": map[string]any{"type": "integer", "minimum": 1, "maximum": 60},
"shotSize": map[string]any{"type": "string", "enum": []string{"extreme_wide", "wide", "full", "medium", "close_up", "extreme_close_up"}},
"cameraMove": map[string]any{"type": "string", "enum": []string{"static", "push_in", "pull_out", "pan_left", "pan_right", "tilt_up", "tilt_down", "orbit_left", "orbit_right", "handheld"}},
"prompt": map[string]any{"type": "string", "maxLength": 2000},
"focalLength": map[string]any{"type": "number", "minimum": 1, "maximum": 300},
"lightType": map[string]any{"type": "string", "enum": []string{"directional", "point", "spot", "ambient"}},
"intensity": map[string]any{"type": "number", "minimum": 0, "maximum": 100},
"angle": map[string]any{"type": "number", "minimum": 0, "maximum": 6.29},
"penumbra": map[string]any{"type": "number", "minimum": 0, "maximum": 1},
"castShadow": map[string]any{"type": "boolean"},
"background": map[string]any{"type": "string", "maxLength": 32},
"environmentIntensity": map[string]any{"type": "number", "minimum": 0, "maximum": 100},
"gridVisible": map[string]any{"type": "boolean"},
"activeShotId": map[string]any{"type": "string", "maxLength": 80},
}
return map[string]any{"type": "object", "properties": properties, "required": []string{"type", "id"}, "additionalProperties": false}
}
func cloudAgentPrevisApplyPatchSchema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"snapshotHash": map[string]any{"type": "string", "description": "previs_scene_read 聚焦读取返回的场景 snapshotHash;只代表该场景"},
"sceneId": map[string]any{"type": "string", "maxLength": 80, "description": "要修改的场景 ID"},
"operations": map[string]any{"type": "array", "minItems": 1, "maxItems": cloudAgentPrevisMaxOperations, "items": cloudAgentPrevisPatchOperationSchema(), "description": "受限语义操作;不能替换整个场景或写任意 JSON"},
},
"required": []string{"snapshotHash", "sceneId", "operations"},
"additionalProperties": false,
}
}
func cloudAgentPrevisValidateText(value, label string, maxRunes int, required bool) error {
if required && strings.TrimSpace(value) == "" {
return BadAuthRequest(label + "不能为空")
}
if !utf8.ValidString(value) || strings.TrimSpace(value) != value {
return BadAuthRequest(label + "不能包含首尾空白或无效字符")
}
if utf8.RuneCountInString(value) > maxRunes {
return fmt.Errorf("%s不能超过 %d 个字符", label, maxRunes)
}
for _, r := range value {
if unicode.IsControl(r) {
return BadAuthRequest(label + "不能包含控制字符")
}
}
return nil
}
// cloudAgentPrevisValidatePose validates pose against the canonical enum.
func cloudAgentPrevisValidatePose(pose string) error {
valid := map[string]bool{
"stand": true, "sit": true, "walk": true, "run": true, "wave": true, "think": true,
"fight": true, "kick": true, "throw": true, "push": true, "reach": true, "arms_crossed": true,
"phone": true, "bow": true, "kneel_single": true, "kneel_double": true, "lean": true,
"squat": true, "hands_hips": true, "neutral": true, "t_pose": true,
}
if !valid[pose] {
return BadAuthRequest(fmt.Sprintf("pose 值 %q 不受支持;请使用 stand/sit/walk/run/wave/think/fight 等", pose))
}
return nil
}
func cloudAgentPrevisValidateEnum(value, label string, allowed ...string) error {
for _, candidate := range allowed {
if value == candidate {
return nil
}
}
return BadAuthRequest(label + "不受支持")
}
func cloudAgentPrevisIsHexColor(value string) bool {
if len(value) != 4 && len(value) != 7 && len(value) != 9 || value[0] != '#' {
return false
}
for _, r := range value[1:] {
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
return false
}
}
return true
}
func cloudAgentPrevisNormalizeLegacyColor(value string) string {
trimmed := strings.TrimSpace(value)
if cloudAgentPrevisIsHexColor(trimmed) {
return strings.ToLower(trimmed)
}
lower := strings.ToLower(trimmed)
var raw string
var rgba bool
switch {
case strings.HasPrefix(lower, "rgb(") && strings.HasSuffix(lower, ")"):
raw = trimmed[4 : len(trimmed)-1]
case strings.HasPrefix(lower, "rgba(") && strings.HasSuffix(lower, ")"):
raw = trimmed[5 : len(trimmed)-1]
rgba = true
default:
return value
}
parts := strings.Split(raw, ",")
if len(parts) != 3 && len(parts) != 4 {
return value
}
channels := make([]int, 3)
for index := range channels {
channel, err := strconv.Atoi(strings.TrimSpace(parts[index]))
if err != nil || channel < 0 || channel > 255 {
return value
}
channels[index] = channel
}
if !rgba && len(parts) == 4 {
return value
}
result := fmt.Sprintf("#%02x%02x%02x", channels[0], channels[1], channels[2])
if rgba && len(parts) == 4 {
alpha, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
if err != nil {
return value
}
if alpha <= 1 {
alpha = math.Round(alpha * 255)
}
if alpha < 0 || alpha > 255 {
return value
}
result += fmt.Sprintf("%02x", int(alpha))
}
return result
}
// Legacy scenes can contain CSS rgb/rgba colors from the browser-era editor.
// Normalize those values before strict validation so a harmless scene read or
// patch is not blocked by historical presentation data.
func cloudAgentPrevisNormalizeSceneColor(value, fallback string) string {
normalized := cloudAgentPrevisNormalizeLegacyColor(value)
if cloudAgentPrevisIsHexColor(normalized) {
return normalized
}
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func cloudAgentPrevisValidateColor(value, label string) error {
if err := cloudAgentPrevisValidateText(value, label, 32, true); err != nil {
return err
}
if !cloudAgentPrevisIsHexColor(value) {
return BadAuthRequest(label + "必须是十六进制颜色")
}
return nil
}
func cloudAgentPrevisFiniteInRange(value float64, label string, minimum, maximum float64) error {
if math.IsNaN(value) || math.IsInf(value, 0) || value < minimum || value > maximum {
return BadAuthRequest(label + "超出限制")
}
return nil
}
func cloudAgentPrevisValidateTemplateID(value string) error {
valid := map[string]bool{"empty": true, "monologue": true, "dialogue": true, "blocking": true, "product": true, "action_chase": true, "interior": true, "crowd": true}
if !valid[value] {
return BadAuthRequest("templateId 不受支持;有效值:empty/monologue/dialogue/blocking/product/action_chase/interior/crowd")
}
return nil
}
func cloudAgentPrevisValidateVec3(value []float64, label string) error {
if len(value) != 3 {
return BadAuthRequest(label + "必须是包含3个数字的数组")
}
for _, number := range value {
if math.IsNaN(number) || math.IsInf(number, 0) || math.Abs(number) > 100000 {
return BadAuthRequest(label + "包含无效或超出范围的数字")
}
}
return nil
}
func cloudAgentPrevisVec3(value []float64) []any {
return []any{value[0], value[1], value[2]}
}
func cloudAgentPrevisNumberVec3(value any) ([]float64, bool) {
result := make([]float64, 3)
switch items := value.(type) {
case []float64:
if len(items) != 3 {
return nil, false
}
copy(result, items)
case []any:
if len(items) != 3 {
return nil, false
}
for index, item := range items {
result[index] = numberValue(item, math.NaN())
}
default:
return nil, false
}
for _, number := range result {
if math.IsNaN(number) || math.IsInf(number, 0) {
return nil, false
}
}
return result, true
}
func cloudAgentPrevisOptionalVec3(value *[]float64, label string) error {
if value == nil {
return nil
}
return cloudAgentPrevisValidateVec3(*value, label)
}
func cloudAgentPrevisTextPointer(value *string, label string, maxRunes int) error {
if value == nil {
return nil
}
return cloudAgentPrevisValidateText(*value, label, maxRunes, false)
}
func cloudAgentPrevisCanvas(repo *repository.Repository, userID, canvasID string) (*model.CanvasProject, map[string]any, error) {
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, nil, err
}
return canvas, doc, nil
}
func cloudAgentPrevisScenesSize(scenes []map[string]any) error {
encoded, err := json.Marshal(scenes)
if err != nil || len(encoded) > cloudAgentPrevisDocumentLimit {
return BadAuthRequest("预演台场景数据超过限制")
}
return nil
}
func cloudAgentPrevisIDSet(items []map[string]any, label string) (map[string]map[string]any, error) {
result := make(map[string]map[string]any, len(items))
for _, item := range items {
id := stringValue(item["id"])
if err := validateCloudAgentID(id, label, 80); err != nil {
return nil, err
}
if _, exists := result[id]; exists {
return nil, BadAuthRequest(label + "重复")
}
result[id] = item
}
return result, nil
}
func cloudAgentPrevisFinite(value any) bool {
number := numberValue(value, math.NaN())
return !math.IsNaN(number) && !math.IsInf(number, 0)
}
func cloudAgentPrevisValidateTransform(value any, label string) error {
transform, ok := value.(map[string]any)
if !ok {
return BadAuthRequest(label + "缺少 transform")
}
for _, key := range []string{"position", "rotation", "scale"} {
items, ok := transform[key].([]any)
if !ok || len(items) != 3 {
return BadAuthRequest(label + "的 transform." + key + " 必须包含3个数字")
}
for _, item := range items {
if !cloudAgentPrevisFinite(item) {
return BadAuthRequest(label + "的 transform 包含无效数字")
}
}
}
return nil
}
func cloudAgentPrevisValidateScene(scene map[string]any) error {
if err := validateCloudAgentID(stringValue(scene["id"]), "导演场景 ID", 80); err != nil {
return err
}
if numberValue(scene["version"], 0) != 1 {
return BadAuthRequest("预演台场景版本不受支持")
}
if err := cloudAgentPrevisValidateText(stringValue(scene["title"]), "场景标题", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
background := cloudAgentPrevisNormalizeSceneColor(stringValue(scene["background"]), "#111827")
scene["background"] = background
if err := cloudAgentPrevisValidateColor(stringValue(scene["background"]), "场景背景色"); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(numberValue(scene["environmentIntensity"], math.NaN()), "环境光强度", 0, 100); err != nil {
return err
}
objects := creationMaps(scene["objects"])
cameras := creationMaps(scene["cameras"])
lights := creationMaps(scene["lights"])
shots := creationMaps(scene["shots"])
if len(objects) > cloudAgentPrevisMaxObjects || len(cameras) > cloudAgentPrevisMaxCameras || len(lights) > cloudAgentPrevisMaxLights || len(shots) > cloudAgentPrevisMaxShots {
return BadAuthRequest("预演台场景对象、摄影机、灯光或镜头数量超过限制")
}
objectByID, err := cloudAgentPrevisIDSet(objects, "导演对象 ID")
if err != nil {
return err
}
cameraByID, err := cloudAgentPrevisIDSet(cameras, "导演摄影机 ID")
if err != nil {
return err
}
_, err = cloudAgentPrevisIDSet(lights, "导演灯光 ID")
if err != nil {
return err
}
shotByID, err := cloudAgentPrevisIDSet(shots, "导演镜头 ID")
if err != nil {
return err
}
if len(shots) == 0 || stringValue(scene["activeShotId"]) == "" || shotByID[stringValue(scene["activeShotId"])] == nil {
return BadAuthRequest("预演台场景必须包含有效的 activeShotId")
}
for id, object := range objectByID {
if err := cloudAgentPrevisValidateText(stringValue(object["name"]), "导演对象名称", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
if err := cloudAgentPrevisValidateEnum(stringValue(object["kind"]), "导演对象类型", "primitive", "model", "actor", "billboard"); err != nil {
return err
}
if primitive := stringValue(object["primitive"]); primitive != "" {
if err := cloudAgentPrevisValidateEnum(primitive, "导演对象 primitive", "box", "sphere", "cylinder", "plane", "character"); err != nil {
return err
}
}
objectColor := cloudAgentPrevisNormalizeSceneColor(stringValue(object["color"]), "#8795a5")
object["color"] = objectColor
if err := cloudAgentPrevisValidateColor(stringValue(object["color"]), "导演对象颜色"); err != nil {
return err
}
if err := cloudAgentPrevisValidateTransform(object["transform"], "导演对象 "+id); err != nil {
return err
}
if binding, ok := object["characterBinding"].(map[string]any); ok {
for _, key := range []string{"characterAssetId", "characterVersionId", "referenceNodeId"} {
if err := validateCloudAgentID(stringValue(binding[key]), "角色绑定 "+key, 120); err != nil {
return err
}
}
if name := stringValue(binding["characterName"]); name != "" {
if err := cloudAgentPrevisValidateText(name, "角色绑定 characterName", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
}
}
if keyframes, ok := object["keyframes"]; ok {
for _, keyframe := range creationMaps(keyframes) {
if err := validateCloudAgentID(stringValue(keyframe["id"]), "对象关键帧 ID", 120); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(numberValue(keyframe["time"], math.NaN()), "对象关键帧 time", 0, 60); err != nil {
return err
}
if err := cloudAgentPrevisValidateTransform(keyframe["transform"], "对象关键帧 "+stringValue(keyframe["id"])); err != nil {
return err
}
}
}
if motionPath, ok := object["motionPath"].(map[string]any); ok {
points, pointsOK := motionPath["points"].([]any)
if !pointsOK || len(points) < 2 || len(points) > 64 {
return BadAuthRequest("预演台 motionPath 点数无效")
}
for index, point := range points {
value, ok := cloudAgentPrevisNumberVec3(point)
if !ok {
return BadAuthRequest(fmt.Sprintf("预演台 motionPath 点 %d 无效", index))
}
if err := cloudAgentPrevisValidateVec3(value, "motionPath point"); err != nil {
return err
}
}
if err := cloudAgentPrevisValidateEnum(stringValue(motionPath["speed"]), "motionPath speed", "slow", "walk", "run"); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(numberValue(motionPath["startDelay"], math.NaN()), "motionPath startDelay", 0, 60); err != nil {
return err
}
}
if parentID := stringValue(object["parentId"]); parentID != "" {
if err := validateCloudAgentID(parentID, "导演对象 parentId", 80); err != nil {
return err
}
if objectByID[parentID] == nil || parentID == id {
return BadAuthRequest("预演台对象 parentId 无效")
}
}
}
for id, camera := range cameraByID {
if err := cloudAgentPrevisValidateText(stringValue(camera["name"]), "导演摄影机名称", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(numberValue(camera["focalLength"], math.NaN()), "摄影机焦段", 1, 300); err != nil {
return err
}
target, targetOK := cloudAgentPrevisNumberVec3(camera["target"])
if !targetOK {
return BadAuthRequest("导演摄影机 " + id + " target 必须包含3个数字")
}
if err := cloudAgentPrevisValidateVec3(target, "导演摄影机 "+id+" target"); err != nil {
return err
}
if err := cloudAgentPrevisValidateTransform(camera["transform"], "导演摄影机 "+id); err != nil {
return err
}
if keyframes, ok := camera["keyframes"]; ok {
for _, keyframe := range creationMaps(keyframes) {
if err := validateCloudAgentID(stringValue(keyframe["id"]), "摄影机关键帧 ID", 120); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(numberValue(keyframe["time"], math.NaN()), "摄影机关键帧 time", 0, 60); err != nil {
return err
}
if err := cloudAgentPrevisValidateTransform(keyframe["transform"], "摄影机关键帧 "+stringValue(keyframe["id"])); err != nil {
return err
}
}
}
}
for id, shot := range shotByID {
cameraID := stringValue(shot["cameraId"])
if cameraByID[cameraID] == nil {
return BadAuthRequest("导演镜头 " + id + " 引用了不存在的摄影机")
}
duration := numberValue(shot["duration"], math.NaN())
fps := numberValue(shot["fps"], math.NaN())
if err := cloudAgentPrevisFiniteInRange(duration, "导演镜头时长", 0.1, 60); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(fps, "导演镜头帧率", 1, 60); err != nil {
return err
}
if err := cloudAgentPrevisValidateText(stringValue(shot["name"]), "导演镜头名称", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
if err := cloudAgentPrevisValidateEnum(stringValue(shot["shotSize"]), "导演景别", "extreme_wide", "wide", "full", "medium", "close_up", "extreme_close_up"); err != nil {
return err
}
if err := cloudAgentPrevisValidateEnum(stringValue(shot["cameraMove"]), "导演运镜", "static", "push_in", "pull_out", "pan_left", "pan_right", "tilt_up", "tilt_down", "orbit_left", "orbit_right", "handheld"); err != nil {
return err
}
if err := cloudAgentPrevisValidateText(stringValue(shot["prompt"]), "导演镜头提示词", 2000, false); err != nil {
return err
}
}
for _, light := range creationMaps(scene["lights"]) {
lightID := stringValue(light["id"])
if err := cloudAgentPrevisValidateText(stringValue(light["name"]), "导演灯光名称", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
if err := cloudAgentPrevisValidateEnum(stringValue(light["type"]), "导演灯光类型", "directional", "point", "spot", "ambient"); err != nil {
return err
}
lightColor := cloudAgentPrevisNormalizeSceneColor(stringValue(light["color"]), "#ffffff")
light["color"] = lightColor
if err := cloudAgentPrevisValidateColor(stringValue(light["color"]), "导演灯光颜色"); err != nil {
return err
}
if err := cloudAgentPrevisFiniteInRange(numberValue(light["intensity"], math.NaN()), "导演灯光强度", 0, 100); err != nil {
return err
}
if angle, ok := light["angle"]; ok {
if err := cloudAgentPrevisFiniteInRange(numberValue(angle, math.NaN()), "导演灯光 "+lightID+" angle", 0, 6.29); err != nil {
return err
}
}
if penumbra, ok := light["penumbra"]; ok {
if err := cloudAgentPrevisFiniteInRange(numberValue(penumbra, math.NaN()), "导演灯光 "+lightID+" penumbra", 0, 1); err != nil {
return err
}
}
if err := cloudAgentPrevisValidateTransform(light["transform"], "导演灯光 "+lightID); err != nil {
return err
}
}
// Parent links are a small directed graph. Walk every chain to reject cycles.
for id := range objectByID {
seen := map[string]bool{}
for current := id; current != ""; {
if seen[current] {
return BadAuthRequest("预演台对象 parentId 不能形成环")
}
seen[current] = true
current = stringValue(objectByID[current]["parentId"])
if current != "" && objectByID[current] == nil {
return BadAuthRequest("预演台对象 parentId 引用不存在对象")
}
}
}
return nil
}
func cloudAgentPrevisSceneCreateTemplate(args cloudAgentPrevisSceneCreateArgs) (map[string]any, error) {
if err := validateCloudAgentID(args.SceneID, "导演场景 ID", 80); err != nil {
return nil, err
}
if err := cloudAgentPrevisValidateText(args.Title, "场景标题", cloudAgentPrevisTextLimit, true); err != nil {
return nil, err
}
if err := cloudAgentPrevisValidateTemplateID(args.TemplateID); err != nil {
return nil, err
}
now := time.Now().UTC().Format(time.RFC3339Nano)
cameraID := args.SceneID + "-camera-1"
shotID := args.SceneID + "-shot-1"
camera := map[string]any{
"id": cameraID, "name": "主摄影机",
"transform": map[string]any{"position": []any{4.8, 2.7, 6.8}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}},
"target": []any{0, 1, 0}, "focalLength": 35, "fov": 50, "aperture": 2.8, "focusDistance": 5, "near": 0.05, "far": 500, "keyframes": []any{},
}
objects := []any{}
lights := []any{
map[string]any{"id": args.SceneID + "-light-key", "name": "主光", "type": "directional", "transform": map[string]any{"position": []any{4, 6, 4}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}, "color": "#ffffff", "intensity": 2.4, "angle": 0.7853981634, "penumbra": 0.35, "castShadow": true},
map[string]any{"id": args.SceneID + "-light-fill", "name": "轮廓光", "type": "directional", "transform": map[string]any{"position": []any{-4, 3, -2}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}, "color": "#ffffff", "intensity": 1.1, "angle": 0.7853981634, "penumbra": 0.35, "castShadow": true},
map[string]any{"id": args.SceneID + "-light-ambient", "name": "环境光", "type": "ambient", "transform": map[string]any{"position": []any{0, 0, 0}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}, "color": "#ffffff", "intensity": 0.65, "angle": 0.7853981634, "penumbra": 0.35, "castShadow": false},
}
if args.TemplateID == "action_chase" {
// Two actors: pursuer behind, target ahead, side-angle camera for kinetic energy
objects = append(objects,
map[string]any{"id": args.SceneID + "-actor-1", "name": "追逐者", "kind": "actor", "primitive": "character", "transform": map[string]any{"position": []any{0, 0, -1.2}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}, "color": "#2f7de1", "visible": true, "castShadow": true, "receiveShadow": true, "pose": "run", "archetype": "man", "keyframes": []any{}, "boneTracks": []any{}, "boneOverrides": map[string]any{}, "motionClips": []any{}},
map[string]any{"id": args.SceneID + "-actor-2", "name": "逃跑者", "kind": "actor", "primitive": "character", "transform": map[string]any{"position": []any{0, 0, 2.0}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}, "color": "#d84949", "visible": true, "castShadow": true, "receiveShadow": true, "pose": "run", "archetype": "adult", "keyframes": []any{}, "boneTracks": []any{}, "boneOverrides": map[string]any{}, "motionClips": []any{}},
)
camera["transform"] = map[string]any{"position": []any{3.8, 1.4, 0}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}
camera["target"] = []any{0, 1, 0.5}
camera["focalLength"] = 50
camera["fov"] = 39.6
}
if args.TemplateID == "interior" {
// Room sketch: floor plane, 3 wall planes, sofa-scale box, table-scale box
objects = append(objects,
map[string]any{"id": args.SceneID + "-wall-back", "name": "后墙", "kind": "primitive", "primitive": "plane", "transform": map[string]any{"position": []any{0, 2.0, -3.0}, "rotation": []any{0, 0, 0}, "scale": []any{6, 4, 1}}, "color": "#d8dde3", "visible": true, "castShadow": false, "receiveShadow": true, "keyframes": []any{}},
map[string]any{"id": args.SceneID + "-wall-left", "name": "左墙", "kind": "primitive", "primitive": "plane", "transform": map[string]any{"position": []any{-3.0, 2.0, 0}, "rotation": []any{0, 1.5708, 0}, "scale": []any{6, 4, 1}}, "color": "#d8dde3", "visible": true, "castShadow": false, "receiveShadow": true, "keyframes": []any{}},
map[string]any{"id": args.SceneID + "-sofa", "name": "沙发", "kind": "primitive", "primitive": "box", "transform": map[string]any{"position": []any{0, 0.45, -1.8}, "rotation": []any{0, 0, 0}, "scale": []any{2.2, 0.9, 0.9}}, "color": "#8795a5", "visible": true, "castShadow": true, "receiveShadow": true, "keyframes": []any{}},
map[string]any{"id": args.SceneID + "-table", "name": "茶几", "kind": "primitive", "primitive": "box", "transform": map[string]any{"position": []any{0, 0.23, 0.2}, "rotation": []any{0, 0, 0}, "scale": []any{1.1, 0.46, 0.65}}, "color": "#6b7580", "visible": true, "castShadow": true, "receiveShadow": true, "keyframes": []any{}},
map[string]any{"id": args.SceneID + "-actor-1", "name": "演员 1", "kind": "actor", "primitive": "character", "transform": map[string]any{"position": []any{-0.6, 0, 0.6}, "rotation": []any{0, -0.4, 0}, "scale": []any{1, 1, 1}}, "color": "#2f7de1", "visible": true, "castShadow": true, "receiveShadow": true, "pose": "sit", "archetype": "adult", "keyframes": []any{}, "boneTracks": []any{}, "boneOverrides": map[string]any{}, "motionClips": []any{}},
)
camera["transform"] = map[string]any{"position": []any{3.2, 1.65, 3.0}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}
camera["target"] = []any{-0.2, 1.0, 0}
camera["focalLength"] = 35
}
if args.TemplateID == "crowd" {
// 3x3 crowd grid with distinct colors
crowdColors := []string{"#2f7de1", "#d84949", "#dfae3f", "#34a276", "#8b5cf6", "#e879f9", "#2f7de1", "#d84949", "#dfae3f"}
for row := 0; row < 3; row++ {
for col := 0; col < 3; col++ {
idx := row*3 + col
id := fmt.Sprintf("%s-actor-%d", args.SceneID, idx+1)
x := float64(col-1) * 1.2
z := float64(row-1) * 1.2
objects = append(objects, map[string]any{
"id": id, "name": fmt.Sprintf("群众 %d", idx+1), "kind": "actor", "primitive": "character",
"transform": map[string]any{"position": []any{x, 0, z}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}},
"color": crowdColors[idx], "visible": true, "castShadow": true, "receiveShadow": true,
"pose": "stand", "archetype": "adult", "keyframes": []any{}, "boneTracks": []any{}, "boneOverrides": map[string]any{}, "motionClips": []any{},
})
}
}
camera["transform"] = map[string]any{"position": []any{0.5, 4.5, 6.5}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}
camera["target"] = []any{0, 0.8, 0}
camera["focalLength"] = 28
camera["fov"] = 65.0
}
if args.TemplateID == "monologue" || args.TemplateID == "dialogue" || args.TemplateID == "blocking" {
objects = append(objects, map[string]any{"id": args.SceneID + "-actor-1", "name": "演员 1", "kind": "actor", "primitive": "character", "transform": map[string]any{"position": []any{0, 0, 0}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}, "color": "#f1f3f5", "visible": true, "castShadow": true, "receiveShadow": true, "pose": "stand", "keyframes": []any{}, "boneTracks": []any{}, "boneOverrides": map[string]any{}, "motionClips": []any{}})
}
if args.TemplateID == "dialogue" || args.TemplateID == "blocking" {
objects = append(objects, map[string]any{"id": args.SceneID + "-actor-2", "name": "演员 2", "kind": "actor", "primitive": "character", "transform": map[string]any{"position": []any{1.8, 0, -0.8}, "rotation": []any{0, 0.2, 0}, "scale": []any{1, 1, 1}}, "color": "#2f7de1", "visible": true, "castShadow": true, "receiveShadow": true, "pose": "stand", "keyframes": []any{}, "boneTracks": []any{}, "boneOverrides": map[string]any{}, "motionClips": []any{}})
}
if args.TemplateID == "product" {
objects = append(objects, map[string]any{"id": args.SceneID + "-product-1", "name": "产品", "kind": "primitive", "primitive": "box", "transform": map[string]any{"position": []any{0, 0.6, 0}, "rotation": []any{0, 0, 0}, "scale": []any{1.2, 1.2, 1.2}}, "color": "#8795a5", "visible": true, "castShadow": true, "receiveShadow": true, "keyframes": []any{}})
}
scene := map[string]any{
"id": args.SceneID, "version": 1, "title": args.Title, "background": "#d8dde3", "environmentIntensity": 0.7, "gridVisible": true,
"objects": objects, "cameras": []any{camera}, "lights": lights,
"shots": []any{map[string]any{"id": shotID, "name": "镜头 1", "cameraId": cameraID, "duration": 5, "fps": 24, "shotSize": "medium", "cameraMove": "static", "prompt": "", "cuts": []any{}}},
"activeShotId": shotID, "createdAt": now, "updatedAt": now,
}
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 {
return nil, cloudAgentJSONArgumentError(err)
}
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
}
beforeHash := cloudAgentCanvasHash(doc)
if beforeHash != args.CanvasSnapshotHash {
return nil, &cloudAgentFieldArgumentError{error: &cloudAgentArgumentError{creationConflict("画布已变化,本次未创建场景;请重新读取场景目录")}, Field: "canvasSnapshotHash", Issue: "stale_snapshot"}
}
scenes := creationMaps(doc["previsScenes"])
if len(scenes) >= cloudAgentPrevisMaxScenes {
return nil, BadAuthRequest("当前画布的预演场景数量已达到限制")
}
if _, exists := findPrevisScene(scenes, args.SceneID); exists {
return nil, BadAuthRequest("预演场景 ID 已存在")
}
scene, err := cloudAgentPrevisSceneCreateTemplate(args)
if err != nil {
return nil, err
}
scenes = append(scenes, scene)
if err := cloudAgentPrevisScenesSize(scenes); err != nil {
return nil, err
}
doc["previsScenes"] = scenes
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) {
var args cloudAgentPrevisApplyPatchArgs
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, cloudAgentJSONArgumentError(err)
}
if strings.TrimSpace(args.SnapshotHash) == "" {
return nil, cloudAgentFieldError("snapshotHash", "required", "应用预演补丁前必须先读取指定场景")
}
if len(args.Operations) < 1 || len(args.Operations) > cloudAgentPrevisMaxOperations {
return nil, cloudAgentFieldError("operations", "item_count", fmt.Sprintf("预演补丁 operations 必须包含1到%d项操作", cloudAgentPrevisMaxOperations))
}
if err := validateCloudAgentID(args.SceneID, "导演场景 ID", 80); err != nil {
return nil, err
}
canvas, doc, err := cloudAgentPrevisCanvas(repo, userID, canvasID)
if err != nil {
return nil, err
}
scenes := creationMaps(doc["previsScenes"])
scene, ok := findPrevisScene(scenes, args.SceneID)
if !ok {
return nil, BadAuthRequest("导演场景不存在或不属于当前画布")
}
canvasSnapshotHash := cloudAgentCanvasHash(doc)
sceneSnapshotHash := creationHash(scene)
if sceneSnapshotHash != args.SnapshotHash {
return nil, &cloudAgentFieldArgumentError{error: &cloudAgentArgumentError{creationConflict("预演场景已变化,本次未写入;请重新读取并重新申请审批")}, Field: "snapshotHash", Issue: "stale_snapshot"}
}
items := make([]cloudAgentApprovalPreviewItem, 0, len(args.Operations))
for index, operation := range args.Operations {
if err := cloudAgentPrevisValidateCharacterBindingInCanvas(doc, operation); err != nil {
return nil, err
}
item, err := cloudAgentPrevisApplyPatchOperation(scene, operation, index)
if err != nil {
return nil, err
}
items = append(items, item)
}
scene["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
if err := cloudAgentPrevisValidateScene(scene); err != nil {
return nil, err
}
if err := cloudAgentPrevisScenesSize(scenes); err != nil {
return nil, err
}
doc["previsScenes"] = scenes
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 {
result := make([]string, 0, len(items))
for _, item := range items {
if item.Summary != "" {
result = append(result, item.Summary)
}
}
return result
}
func cloudAgentPrevisMutationApprovalPreview(operation, sceneID, title string, details []string) cloudAgentApprovalPreview {
if len(details) > 12 {
details = details[:12]
}
return cloudAgentApprovalPreview{Kind: "previs_mutation", Title: "确认预演台修改", Description: fmt.Sprintf("Agent 准备修改预演场景《%s》。批准后才会写入当前画布。", truncateRunes(title, cloudAgentPrevisTextLimit)), Items: []cloudAgentApprovalPreviewItem{{Operation: operation, NodeID: sceneID, NodeTitle: truncateRunes(title, cloudAgentPrevisTextLimit), Details: details, Summary: fmt.Sprintf("修改预演场景《%s》", truncateRunes(title, cloudAgentPrevisTextLimit))}}}
}
func cloudAgentPrevisMapTransform(item map[string]any) map[string]any {
if transform, ok := item["transform"].(map[string]any); ok {
return transform
}
transform := map[string]any{"position": []any{0, 0, 0}, "rotation": []any{0, 0, 0}, "scale": []any{1, 1, 1}}
item["transform"] = transform
return transform
}
func cloudAgentPrevisApplyTransform(item map[string]any, operation cloudAgentPrevisPatchOperation) error {
if err := cloudAgentPrevisOptionalVec3(operation.Position, "position"); err != nil {
return err
}
if err := cloudAgentPrevisOptionalVec3(operation.Rotation, "rotation"); err != nil {
return err
}
if err := cloudAgentPrevisOptionalVec3(operation.Scale, "scale"); err != nil {
return err
}
transform := cloudAgentPrevisMapTransform(item)
if operation.Position != nil {
transform["position"] = cloudAgentPrevisVec3(*operation.Position)
}
if operation.Rotation != nil {
transform["rotation"] = cloudAgentPrevisVec3(*operation.Rotation)
}
if operation.Scale != nil {
transform["scale"] = cloudAgentPrevisVec3(*operation.Scale)
}
return nil
}
func cloudAgentPrevisApplyName(item map[string]any, value *string, label string) error {
if value == nil {
return nil
}
if err := cloudAgentPrevisValidateText(*value, label, cloudAgentPrevisTextLimit, true); err != nil {
return err
}
item["name"] = *value
return nil
}
func cloudAgentPrevisApplyCharacterBinding(item map[string]any, operation cloudAgentPrevisPatchOperation) error {
provided := operation.CharacterAssetID != nil || operation.CharacterVersionID != nil || operation.ReferenceNodeID != nil || operation.CharacterName != nil
if !provided {
return nil
}
if operation.CharacterAssetID == nil || operation.CharacterVersionID == nil || operation.ReferenceNodeID == nil {
return BadAuthRequest("角色绑定需要 characterAssetId、characterVersionId 和 referenceNodeId")
}
for value, label := range map[*string]string{
operation.CharacterAssetID: "characterAssetId",
operation.CharacterVersionID: "characterVersionId",
operation.ReferenceNodeID: "referenceNodeId",
} {
if value == nil || strings.TrimSpace(*value) == "" {
return BadAuthRequest(label + " 不能为空")
}
if err := validateCloudAgentID(*value, label, 120); err != nil {
return err
}
}
binding := map[string]any{
"characterAssetId": *operation.CharacterAssetID,
"characterVersionId": *operation.CharacterVersionID,
"referenceNodeId": *operation.ReferenceNodeID,
}
if operation.CharacterName != nil {
if err := cloudAgentPrevisValidateText(*operation.CharacterName, "characterName", cloudAgentPrevisTextLimit, true); err != nil {
return err
}
binding["characterName"] = *operation.CharacterName
}
item["characterBinding"] = binding
item["sourceNodeId"] = *operation.ReferenceNodeID
return nil
}
func cloudAgentPrevisValidateCharacterBindingInCanvas(doc map[string]any, operation cloudAgentPrevisPatchOperation) error {
if operation.CharacterAssetID == nil && operation.CharacterVersionID == nil && operation.ReferenceNodeID == nil && operation.CharacterName == nil {
return nil
}
if operation.CharacterAssetID == nil || operation.CharacterVersionID == nil || operation.ReferenceNodeID == nil {
return BadAuthRequest("角色绑定需要 characterAssetId、characterVersionId 和 referenceNodeId")
}
for _, node := range creationMaps(doc["nodes"]) {
if stringValue(node["id"]) != *operation.ReferenceNodeID {
continue
}
metadata, _ := node["metadata"].(map[string]any)
if stringValue(metadata["workflowKind"]) != "character" || stringValue(metadata["characterAssetId"]) != *operation.CharacterAssetID || stringValue(metadata["characterVersionId"]) != *operation.CharacterVersionID {
return BadAuthRequest("referenceNodeId 必须是匹配 characterAssetId 和 characterVersionId 的角色卡节点")
}
return nil
}
return BadAuthRequest("referenceNodeId 不属于当前画布")
}
func cloudAgentPrevisShotDuration(scene map[string]any) float64 {
for _, shot := range creationMaps(scene["shots"]) {
if stringValue(shot["id"]) == stringValue(scene["activeShotId"]) {
return numberValue(shot["duration"], 5)
}
}
return 5
}
func cloudAgentPrevisKeyframeTransform(item map[string]any, operation cloudAgentPrevisPatchOperation) (map[string]any, error) {
transform := cloudAgentPrevisMapTransform(item)
result := map[string]any{}
for _, key := range []string{"position", "rotation", "scale"} {
value, ok := cloudAgentPrevisNumberVec3(transform[key])
if !ok {
return nil, BadAuthRequest("关键帧目标对象 transform 无效")
}
result[key] = cloudAgentPrevisVec3(value)
}
for value, key := range map[*[]float64]string{operation.Position: "position", operation.Rotation: "rotation", operation.Scale: "scale"} {
if value == nil {
continue
}
if err := cloudAgentPrevisValidateVec3(*value, key); err != nil {
return nil, err
}
result[key] = cloudAgentPrevisVec3(*value)
}
return result, nil
}
func cloudAgentPrevisValidateEasing(value string) error {
if value == "" {
return nil
}
return cloudAgentPrevisValidateEnum(value, "关键帧 easing", "step", "linear", "smooth")
}
func cloudAgentPrevisUpsertKeyframe(item map[string]any, operation cloudAgentPrevisPatchOperation, duration float64) error {
if operation.Time == nil {
return BadAuthRequest("关键帧操作需要 time")
}
if err := cloudAgentPrevisFiniteInRange(*operation.Time, "关键帧 time", 0, duration); err != nil {
return err
}
if err := cloudAgentPrevisValidateEasing(operation.Easing); err != nil {
return err
}
transform, err := cloudAgentPrevisKeyframeTransform(item, operation)
if err != nil {
return err
}
keyframes := creationMaps(item["keyframes"])
keyframeID := ""
if operation.KeyframeID != nil {
if err := validateCloudAgentID(*operation.KeyframeID, "keyframeId", 120); err != nil {
return err
}
keyframeID = *operation.KeyframeID
}
match := -1
for index, keyframe := range keyframes {
if (keyframeID != "" && stringValue(keyframe["id"]) == keyframeID) || math.Abs(numberValue(keyframe["time"], math.NaN())-*operation.Time) < 0.001 {
match = index
break
}
}
if match < 0 {
if keyframeID == "" {
keyframeID = fmt.Sprintf("%s-kf-%d", stringValue(item["id"]), len(keyframes)+1)
}
keyframe := map[string]any{"id": keyframeID, "time": *operation.Time, "transform": transform}
if operation.Easing != "" {
keyframe["easing"] = operation.Easing
}
keyframes = append(keyframes, keyframe)
} else {
keyframes[match]["id"] = keyframeIDOrExisting(keyframeID, keyframes[match])
keyframes[match]["time"] = *operation.Time
keyframes[match]["transform"] = transform
if operation.Easing == "" {
delete(keyframes[match], "easing")
} else {
keyframes[match]["easing"] = operation.Easing
}
}
for i := range keyframes {
for j := i + 1; j < len(keyframes); j++ {
if numberValue(keyframes[j]["time"], 0) < numberValue(keyframes[i]["time"], 0) {
keyframes[i], keyframes[j] = keyframes[j], keyframes[i]
}
}
}
item["keyframes"] = keyframes
return nil
}
func keyframeIDOrExisting(requested string, existing map[string]any) string {
if requested != "" {
return requested
}
return stringValue(existing["id"])
}
func cloudAgentPrevisRemoveKeyframe(item map[string]any, operation cloudAgentPrevisPatchOperation) error {
if operation.KeyframeID == nil || strings.TrimSpace(*operation.KeyframeID) == "" {
return BadAuthRequest("删除关键帧需要 keyframeId")
}
if err := validateCloudAgentID(*operation.KeyframeID, "keyframeId", 120); err != nil {
return err
}
keyframes := creationMaps(item["keyframes"])
filtered := make([]map[string]any, 0, len(keyframes))
for _, keyframe := range keyframes {
if stringValue(keyframe["id"]) != *operation.KeyframeID {
filtered = append(filtered, keyframe)
}
}
if len(filtered) == len(keyframes) {
return BadAuthRequest("目标关键帧不存在")
}
item["keyframes"] = filtered
return nil
}
func cloudAgentPrevisSetMotionPath(item map[string]any, operation cloudAgentPrevisPatchOperation, duration float64) error {
if len(operation.PathPoints) < 2 || len(operation.PathPoints) > 64 {
return BadAuthRequest("motion path 至少需要2个、最多64个点")
}
for index, point := range operation.PathPoints {
if err := cloudAgentPrevisValidateVec3(point, fmt.Sprintf("pathPoints[%d]", index)); err != nil {
return err
}
}
speed := operation.PathSpeed
if speed == "" {
speed = "walk"
}
if err := cloudAgentPrevisValidateEnum(speed, "pathSpeed", "slow", "walk", "run"); err != nil {
return err
}
startDelay := 0.0
if operation.StartDelay != nil {
startDelay = *operation.StartDelay
}
if err := cloudAgentPrevisFiniteInRange(startDelay, "startDelay", 0, duration); err != nil {
return err
}
orient := false
if operation.OrientToPath != nil {
orient = *operation.OrientToPath
}
points := make([]any, 0, len(operation.PathPoints))
for _, point := range operation.PathPoints {
points = append(points, cloudAgentPrevisVec3(point))
}
item["motionPath"] = map[string]any{"points": points, "speed": speed, "startDelay": startDelay, "orientToPath": orient}
return nil
}
func cloudAgentPrevisApplyObjectPatch(objects *[]map[string]any, operation cloudAgentPrevisPatchOperation, index int) (cloudAgentApprovalPreviewItem, error) {
items := *objects
byID, err := cloudAgentPrevisIDSet(items, "导演对象 ID")
if err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := validateCloudAgentID(operation.ID, fmt.Sprintf("operations[%d].id", index), 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
existing := byID[operation.ID]
summary := ""
switch operation.Type {
case "object_add":
if existing != nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增对象 ID 已存在")
}
if operation.Kind != "primitive" && operation.Kind != "actor" {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增对象 kind 只支持 primitive 或 actor")
}
primitive := operation.Primitive
if primitive == "" {
primitive = "box"
}
if primitive != "box" && primitive != "sphere" && primitive != "cylinder" && primitive != "plane" && primitive != "character" {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增对象 primitive 不受支持")
}
name := "新对象"
if operation.Name != nil {
name = *operation.Name
}
if err := cloudAgentPrevisValidateText(name, "对象名称", cloudAgentPrevisTextLimit, true); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
position, rotation, scale := []float64{0, 0.5, 0}, []float64{0, 0, 0}, []float64{1, 1, 1}
if operation.Position != nil {
position = *operation.Position
}
if operation.Rotation != nil {
rotation = *operation.Rotation
}
if operation.Scale != nil {
scale = *operation.Scale
}
if err := cloudAgentPrevisValidateVec3(position, "position"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisValidateVec3(rotation, "rotation"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisValidateVec3(scale, "scale"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if operation.Color != nil {
if err := cloudAgentPrevisValidateColor(*operation.Color, "对象颜色"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
}
if operation.Pose != "" {
if err := cloudAgentPrevisValidatePose(operation.Pose); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
}
if operation.ParentID != nil && *operation.ParentID != "" {
if err := validateCloudAgentID(*operation.ParentID, "对象 parentId", 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
}
defaultColor := "#8795a5"
if operation.Kind == "actor" {
// Assign a distinct colour based on current actor count so each new actor stands out
actorCount := 0
for _, obj := range items {
if stringValue(obj["kind"]) == "actor" {
actorCount++
}
}
actorColors := []string{"#2f7de1", "#d84949", "#dfae3f", "#34a276", "#8b5cf6", "#e879f9"}
defaultColor = actorColors[actorCount%len(actorColors)]
}
item := map[string]any{"id": operation.ID, "name": name, "kind": operation.Kind, "primitive": primitive, "transform": map[string]any{"position": cloudAgentPrevisVec3(position), "rotation": cloudAgentPrevisVec3(rotation), "scale": cloudAgentPrevisVec3(scale)}, "color": defaultColor, "visible": true, "castShadow": true, "receiveShadow": true, "keyframes": []any{}}
if operation.Color != nil {
item["color"] = *operation.Color
}
if operation.Visible != nil {
item["visible"] = *operation.Visible
}
if operation.Pose != "" {
item["pose"] = operation.Pose
}
if operation.Kind == "actor" && operation.Pose == "" {
item["pose"] = "stand"
}
if operation.Kind == "actor" {
item["archetype"] = "adult"
item["boneTracks"] = []any{}
item["boneOverrides"] = map[string]any{}
item["motionClips"] = []any{}
}
if operation.Archetype != "" {
validArchetypes := map[string]bool{"adult": true, "child": true, "elderly": true, "man": true, "woman": true, "monster": true}
if !validArchetypes[operation.Archetype] {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("archetype 值不受支持")
}
item["archetype"] = operation.Archetype
}
if operation.ParentID != nil && *operation.ParentID != "" {
item["parentId"] = *operation.ParentID
}
if err := cloudAgentPrevisApplyCharacterBinding(item, operation); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
items = append(items, item)
summary = fmt.Sprintf("新增对象《%s》", name)
case "object_update":
if existing == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("要更新的对象不存在")
}
if err := cloudAgentPrevisApplyName(existing, operation.Name, "对象名称"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if operation.Color != nil {
if err := cloudAgentPrevisValidateColor(*operation.Color, "对象颜色"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
existing["color"] = *operation.Color
}
if operation.Visible != nil {
existing["visible"] = *operation.Visible
}
if operation.Pose != "" {
if err := cloudAgentPrevisValidatePose(operation.Pose); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
existing["pose"] = operation.Pose
}
if operation.ParentID != nil {
if *operation.ParentID == "" {
delete(existing, "parentId")
} else {
if err := validateCloudAgentID(*operation.ParentID, "对象 parentId", 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
existing["parentId"] = *operation.ParentID
}
}
if err := cloudAgentPrevisApplyCharacterBinding(existing, operation); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisApplyTransform(existing, operation); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
summary = fmt.Sprintf("修改对象《%s》", stringValue(existing["name"]))
case "object_remove":
if existing == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("要删除的对象不存在")
}
for _, candidate := range items {
if stringValue(candidate["parentId"]) == operation.ID {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("存在子对象时不能删除父对象")
}
}
for i, candidate := range items {
if stringValue(candidate["id"]) == operation.ID {
items = append(items[:i], items[i+1:]...)
break
}
}
summary = fmt.Sprintf("删除对象《%s》", stringValue(existing["name"]))
default:
return cloudAgentApprovalPreviewItem{}, nil
}
*objects = items
nodeTitle := stringValue(existing["name"])
if nodeTitle == "" && operation.Type == "object_add" {
nodeTitle = "新对象"
if operation.Name != nil {
nodeTitle = *operation.Name
}
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: truncateRunes(nodeTitle, cloudAgentPrevisTextLimit), Summary: summary}, nil
}
func cloudAgentPrevisFindByID(items []map[string]any, id string) map[string]any {
for _, item := range items {
if stringValue(item["id"]) == id {
return item
}
}
return nil
}
func cloudAgentPrevisApplyPatchOperation(scene map[string]any, operation cloudAgentPrevisPatchOperation, index int) (cloudAgentApprovalPreviewItem, error) {
if err := cloudAgentPrevisValidateText(operation.Type, fmt.Sprintf("operations[%d].type", index), 40, true); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := validateCloudAgentID(operation.ID, fmt.Sprintf("operations[%d].id", index), 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if operation.Type == "scene_update" && operation.ID != stringValue(scene["id"]) {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("scene_update 的 id 必须是当前场景 ID")
}
objects, cameras, lights, shots := creationMaps(scene["objects"]), creationMaps(scene["cameras"]), creationMaps(scene["lights"]), creationMaps(scene["shots"])
switch operation.Type {
case "scene_update":
if operation.Title != nil {
if err := cloudAgentPrevisValidateText(*operation.Title, "场景标题", cloudAgentPrevisTextLimit, true); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
scene["title"] = *operation.Title
}
if operation.Background != nil {
if err := cloudAgentPrevisValidateColor(*operation.Background, "场景背景色"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
scene["background"] = *operation.Background
}
if operation.EnvironmentIntensity != nil {
if err := cloudAgentPrevisFiniteInRange(*operation.EnvironmentIntensity, "environmentIntensity", 0, 100); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
scene["environmentIntensity"] = *operation.EnvironmentIntensity
}
if operation.GridVisible != nil {
scene["gridVisible"] = *operation.GridVisible
}
if operation.ActiveShotID != nil {
if *operation.ActiveShotID == "" {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("activeShotId 不能为空")
}
if err := validateCloudAgentID(*operation.ActiveShotID, "activeShotId", 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
scene["activeShotId"] = *operation.ActiveShotID
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: stringValue(scene["id"]), NodeTitle: stringValue(scene["title"]), Summary: "修改场景级设置"}, nil
case "object_add", "object_update", "object_remove":
item, err := cloudAgentPrevisApplyObjectPatch(&objects, operation, index)
if err == nil {
scene["objects"] = objects
}
return item, err
case "object_keyframe_upsert", "object_keyframe_remove", "object_motion_path_set", "object_motion_path_clear":
object := cloudAgentPrevisFindByID(objects, operation.ID)
if object == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("目标对象不存在")
}
duration := cloudAgentPrevisShotDuration(scene)
var err error
switch operation.Type {
case "object_keyframe_upsert":
err = cloudAgentPrevisUpsertKeyframe(object, operation, duration)
case "object_keyframe_remove":
err = cloudAgentPrevisRemoveKeyframe(object, operation)
case "object_motion_path_set":
err = cloudAgentPrevisSetMotionPath(object, operation, duration)
case "object_motion_path_clear":
delete(object, "motionPath")
}
if err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
scene["objects"] = objects
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(object["name"]), Summary: fmt.Sprintf("修改对象《%s》的动画", stringValue(object["name"]))}, nil
case "shot_add":
if cloudAgentPrevisFindByID(shots, operation.ID) != nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增镜头 ID 已存在")
}
cameraID := ""
if operation.CameraID != nil {
if err := validateCloudAgentID(*operation.CameraID, "摄影机 ID", 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
cameraID = *operation.CameraID
} else if len(cameras) > 0 {
cameraID = stringValue(cameras[0]["id"])
}
if cloudAgentPrevisFindByID(cameras, cameraID) == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增镜头必须引用现有摄影机")
}
name := "新镜头"
if operation.Name != nil {
name = *operation.Name
}
if err := cloudAgentPrevisValidateText(name, "镜头名称", cloudAgentPrevisTextLimit, true); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
duration, fps := 5.0, 24
if operation.Duration != nil {
duration = *operation.Duration
}
if operation.FPS != nil {
fps = *operation.FPS
}
if err := cloudAgentPrevisFiniteInRange(duration, "镜头时长", 0.1, 60); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if fps < 1 || fps > 60 {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("镜头帧率超出限制")
}
shotSize, cameraMove := operation.ShotSize, operation.CameraMove
if shotSize == "" {
shotSize = "medium"
}
if cameraMove == "" {
cameraMove = "static"
}
if err := cloudAgentPrevisValidateEnum(shotSize, "镜头景别", "extreme_wide", "wide", "full", "medium", "close_up", "extreme_close_up"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisValidateEnum(cameraMove, "镜头运镜", "static", "push_in", "pull_out", "pan_left", "pan_right", "tilt_up", "tilt_down", "orbit_left", "orbit_right", "handheld"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
shot := map[string]any{"id": operation.ID, "name": name, "cameraId": cameraID, "duration": duration, "fps": fps, "shotSize": shotSize, "cameraMove": cameraMove, "prompt": "", "cuts": []any{}}
if operation.Prompt != nil {
if err := cloudAgentPrevisValidateText(*operation.Prompt, "镜头提示词", 2000, false); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
shot["prompt"] = *operation.Prompt
}
scene["shots"] = append(shots, shot)
if stringValue(scene["activeShotId"]) == "" {
scene["activeShotId"] = operation.ID
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: name, Summary: fmt.Sprintf("新增镜头《%s》", name)}, nil
case "shot_update", "shot_remove":
shot := cloudAgentPrevisFindByID(shots, operation.ID)
if shot == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("目标镜头不存在")
}
if operation.Type == "shot_remove" {
if len(shots) <= 1 {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("预演场景至少保留一个镜头")
}
next := make([]map[string]any, 0, len(shots)-1)
for _, item := range shots {
if stringValue(item["id"]) != operation.ID {
next = append(next, item)
}
}
scene["shots"] = next
if stringValue(scene["activeShotId"]) == operation.ID {
scene["activeShotId"] = stringValue(next[0]["id"])
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(shot["name"]), Summary: fmt.Sprintf("删除镜头《%s》", stringValue(shot["name"]))}, nil
}
if err := cloudAgentPrevisApplyName(shot, operation.Name, "镜头名称"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if operation.CameraID != nil {
if err := validateCloudAgentID(*operation.CameraID, "摄影机 ID", 80); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if cloudAgentPrevisFindByID(cameras, *operation.CameraID) == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("镜头引用的摄影机不存在")
}
shot["cameraId"] = *operation.CameraID
}
if operation.Duration != nil {
if err := cloudAgentPrevisFiniteInRange(*operation.Duration, "镜头时长", 0.1, 60); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
shot["duration"] = *operation.Duration
}
if operation.FPS != nil {
if *operation.FPS < 1 || *operation.FPS > 60 {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("镜头帧率超出限制")
}
shot["fps"] = *operation.FPS
}
if operation.ShotSize != "" {
if err := cloudAgentPrevisValidateEnum(operation.ShotSize, "镜头景别", "extreme_wide", "wide", "full", "medium", "close_up", "extreme_close_up"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
shot["shotSize"] = operation.ShotSize
}
if operation.CameraMove != "" {
if err := cloudAgentPrevisValidateEnum(operation.CameraMove, "镜头运镜", "static", "push_in", "pull_out", "pan_left", "pan_right", "tilt_up", "tilt_down", "orbit_left", "orbit_right", "handheld"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
shot["cameraMove"] = operation.CameraMove
}
if operation.Prompt != nil {
if err := cloudAgentPrevisValidateText(*operation.Prompt, "镜头提示词", 2000, false); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
shot["prompt"] = *operation.Prompt
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(shot["name"]), Summary: fmt.Sprintf("修改镜头《%s》", stringValue(shot["name"]))}, nil
case "camera_keyframe_upsert", "camera_keyframe_remove":
camera := cloudAgentPrevisFindByID(cameras, operation.ID)
if camera == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("目标摄影机不存在")
}
duration := cloudAgentPrevisShotDuration(scene)
var err error
if operation.Type == "camera_keyframe_upsert" {
err = cloudAgentPrevisUpsertKeyframe(camera, operation, duration)
} else {
err = cloudAgentPrevisRemoveKeyframe(camera, operation)
}
if err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(camera["name"]), Summary: fmt.Sprintf("修改摄影机《%s》的动画", stringValue(camera["name"]))}, nil
case "camera_add":
if cloudAgentPrevisFindByID(cameras, operation.ID) != nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增摄影机 ID 已存在")
}
name := "摄影机"
if operation.Name != nil {
name = *operation.Name
}
if err := cloudAgentPrevisValidateText(name, "摄影机名称", cloudAgentPrevisTextLimit, true); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
camPos := []float64{4.8, 2.7, 6.8}
camRot := []float64{0, 0, 0}
camScale := []float64{1, 1, 1}
camTarget := []float64{0, 1, 0}
if operation.Position != nil {
camPos = *operation.Position
}
if operation.Rotation != nil {
camRot = *operation.Rotation
}
if operation.Scale != nil {
camScale = *operation.Scale
}
if operation.Target != nil {
camTarget = *operation.Target
}
if err := cloudAgentPrevisValidateVec3(camPos, "摄影机 position"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisValidateVec3(camTarget, "摄影机 target"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
focalLength := 35.0
if operation.FocalLength != nil {
focalLength = *operation.FocalLength
}
if err := cloudAgentPrevisFiniteInRange(focalLength, "摄影机焦段", 1, 300); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
fov := 2 * math.Atan(36.0/(2*math.Max(1, focalLength))) * 180 / math.Pi
newCamera := map[string]any{
"id": operation.ID, "name": name,
"transform": map[string]any{"position": cloudAgentPrevisVec3(camPos), "rotation": cloudAgentPrevisVec3(camRot), "scale": cloudAgentPrevisVec3(camScale)},
"target": cloudAgentPrevisVec3(camTarget), "focalLength": focalLength, "fov": fov,
"aperture": 2.8, "focusDistance": 5.0, "near": 0.05, "far": 500.0, "keyframes": []any{},
}
scene["cameras"] = append(cameras, newCamera)
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: name, Summary: fmt.Sprintf("新增摄影机《%s》", name)}, nil
case "camera_update", "camera_remove":
camera := cloudAgentPrevisFindByID(cameras, operation.ID)
if camera == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("目标摄影机不存在")
}
if operation.Type == "camera_remove" {
if len(cameras) <= 1 {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("预演场景至少保留一台摄影机")
}
for _, shot := range shots {
if stringValue(shot["cameraId"]) == operation.ID {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("摄影机仍被镜头引用,不能删除")
}
}
next := make([]map[string]any, 0, len(cameras)-1)
for _, item := range cameras {
if stringValue(item["id"]) != operation.ID {
next = append(next, item)
}
}
scene["cameras"] = next
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(camera["name"]), Summary: fmt.Sprintf("删除摄影机《%s》", stringValue(camera["name"]))}, nil
}
if err := cloudAgentPrevisApplyName(camera, operation.Name, "摄影机名称"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if operation.FocalLength != nil {
if err := cloudAgentPrevisFiniteInRange(*operation.FocalLength, "摄影机焦段", 1, 300); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
camera["focalLength"] = *operation.FocalLength
}
if operation.Target != nil {
if err := cloudAgentPrevisValidateVec3(*operation.Target, "target"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
camera["target"] = cloudAgentPrevisVec3(*operation.Target)
}
if err := cloudAgentPrevisApplyTransform(camera, operation); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(camera["name"]), Summary: fmt.Sprintf("修改摄影机《%s》", stringValue(camera["name"]))}, nil
case "light_add", "light_update", "light_remove":
if operation.Type == "light_add" {
if cloudAgentPrevisFindByID(lights, operation.ID) != nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("新增灯光 ID 已存在")
}
name := "新灯光"
if operation.Name != nil {
name = *operation.Name
}
if err := cloudAgentPrevisValidateText(name, "灯光名称", cloudAgentPrevisTextLimit, true); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
lightType := "point"
if operation.LightType != nil {
lightType = *operation.LightType
}
if err := cloudAgentPrevisValidateEnum(lightType, "灯光类型", "directional", "point", "spot", "ambient"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
position, rotation, scale := []float64{0, 3, 0}, []float64{0, 0, 0}, []float64{1, 1, 1}
if operation.Position != nil {
position = *operation.Position
}
if operation.Rotation != nil {
rotation = *operation.Rotation
}
if operation.Scale != nil {
scale = *operation.Scale
}
if err := cloudAgentPrevisValidateVec3(position, "position"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisValidateVec3(rotation, "rotation"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisValidateVec3(scale, "scale"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
intensity := 1.0
if operation.Intensity != nil {
intensity = *operation.Intensity
}
if err := cloudAgentPrevisFiniteInRange(intensity, "灯光强度", 0, 100); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
angle, penumbra := math.Pi/4, 0.35
if operation.Angle != nil {
angle = *operation.Angle
}
if operation.Penumbra != nil {
penumbra = *operation.Penumbra
}
if err := cloudAgentPrevisFiniteInRange(angle, "灯光角度", 0, 6.29); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if err := cloudAgentPrevisFiniteInRange(penumbra, "灯光 penumbra", 0, 1); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
color := "#ffffff"
if operation.Color != nil {
if err := cloudAgentPrevisValidateColor(*operation.Color, "灯光颜色"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
color = *operation.Color
}
castShadow := lightType != "ambient"
if operation.CastShadow != nil {
castShadow = *operation.CastShadow
}
light := map[string]any{"id": operation.ID, "name": name, "type": lightType, "transform": map[string]any{"position": cloudAgentPrevisVec3(position), "rotation": cloudAgentPrevisVec3(rotation), "scale": cloudAgentPrevisVec3(scale)}, "color": color, "intensity": intensity, "angle": angle, "penumbra": penumbra, "castShadow": castShadow}
scene["lights"] = append(lights, light)
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: name, Summary: fmt.Sprintf("新增灯光《%s》", name)}, nil
}
light := cloudAgentPrevisFindByID(lights, operation.ID)
if light == nil {
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("目标灯光不存在")
}
if operation.Type == "light_remove" {
next := make([]map[string]any, 0, len(lights)-1)
for _, item := range lights {
if stringValue(item["id"]) != operation.ID {
next = append(next, item)
}
}
scene["lights"] = next
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(light["name"]), Summary: fmt.Sprintf("删除灯光《%s》", stringValue(light["name"]))}, nil
}
if err := cloudAgentPrevisApplyName(light, operation.Name, "灯光名称"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
if operation.LightType != nil {
if err := cloudAgentPrevisValidateEnum(*operation.LightType, "灯光类型", "directional", "point", "spot", "ambient"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
light["type"] = *operation.LightType
}
if operation.Intensity != nil {
if err := cloudAgentPrevisFiniteInRange(*operation.Intensity, "灯光强度", 0, 100); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
light["intensity"] = *operation.Intensity
}
if operation.Color != nil {
if err := cloudAgentPrevisValidateColor(*operation.Color, "灯光颜色"); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
light["color"] = *operation.Color
}
if operation.Angle != nil {
if err := cloudAgentPrevisFiniteInRange(*operation.Angle, "灯光角度", 0, 6.29); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
light["angle"] = *operation.Angle
}
if operation.Penumbra != nil {
if err := cloudAgentPrevisFiniteInRange(*operation.Penumbra, "灯光 penumbra", 0, 1); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
light["penumbra"] = *operation.Penumbra
}
if operation.CastShadow != nil {
light["castShadow"] = *operation.CastShadow
}
if err := cloudAgentPrevisApplyTransform(light, operation); err != nil {
return cloudAgentApprovalPreviewItem{}, err
}
return cloudAgentApprovalPreviewItem{Operation: operation.Type, NodeID: operation.ID, NodeTitle: stringValue(light["name"]), Summary: fmt.Sprintf("修改灯光《%s》", stringValue(light["name"]))}, nil
default:
return cloudAgentApprovalPreviewItem{}, BadAuthRequest("不支持的预演台语义操作")
}
}
func applyCloudAgentPrevisMutation(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) {
var plan *cloudAgentPrevisMutationPlan
var err error
switch call.Function.Name {
case "previs_scene_create":
plan, err = prepareCloudAgentPrevisSceneCreate(repo, userID, canvasID, call)
case "previs_apply_patch":
plan, err = prepareCloudAgentPrevisApplyPatch(repo, userID, canvasID, call)
default:
return nil, BadAuthRequest("不支持的预演台写工具")
}
if err != nil {
return nil, err
}
if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil {
return nil, err
}
if len(recorder) > 0 && recorder[0] != nil {
preview := plan.Preview
if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: plan.Operation, BeforeSnapshotHash: plan.BeforeSnapshotHash, AfterSnapshotHash: plan.AfterSnapshotHash, BeforeJSON: plan.BeforeJSON, Preview: &preview}); err != nil {
return nil, err
}
}
resultSnapshotHash := plan.ResultSnapshotHash
if resultSnapshotHash == "" {
resultSnapshotHash = plan.AfterSnapshotHash
}
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
}