411 lines
16 KiB
Go
411 lines
16 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/repository"
|
||
)
|
||
|
||
const maxCloudAgentStoryboardRows = 100
|
||
|
||
var cloudAgentStoryboardTextFields = []string{
|
||
"plotDescription", "dialogue", "videoMotionPrompt", "imageGenerationPrompt", "camera", "motion", "shotSize",
|
||
"emotion", "lightingAndAtmosphere", "audioEffects", "narrativeIntent", "viewerPOV", "performanceBlocking",
|
||
"timeBeats", "continuityOut", "negativePrompt",
|
||
}
|
||
|
||
type cloudAgentStoryboardCreateArgs struct {
|
||
SnapshotHash string `json:"snapshotHash"`
|
||
NodeID string `json:"nodeId"`
|
||
Title string `json:"title"`
|
||
Rows []map[string]any `json:"rows"`
|
||
X float64 `json:"x"`
|
||
Y float64 `json:"y"`
|
||
}
|
||
|
||
type cloudAgentStoryboardEditArgs struct {
|
||
SnapshotHash string `json:"snapshotHash"`
|
||
NodeID string `json:"nodeId"`
|
||
Action string `json:"action"`
|
||
RowID string `json:"rowId"`
|
||
Patch map[string]any `json:"patch"`
|
||
}
|
||
|
||
type cloudAgentStoryboardMutationPlan struct {
|
||
Canvas *model.CanvasProject
|
||
Document map[string]any
|
||
BeforeJSON string
|
||
BeforeSnapshotHash string
|
||
Preview cloudAgentApprovalPreview
|
||
}
|
||
|
||
func storyboardArgumentError(action string) error {
|
||
return &cloudAgentArgumentError{BadAuthRequest(fmt.Sprintf("%s分镜工具参数无效:仅允许工具 schema 中声明的字段,请重新读取分镜并按结构化参数重试", action))}
|
||
}
|
||
|
||
func cloudAgentStoryboardRowSchema() map[string]any {
|
||
properties := map[string]any{
|
||
"durationSeconds": map[string]any{"type": "number", "exclusiveMinimum": 0},
|
||
"plotDescription": map[string]any{"type": "string", "maxLength": 20000},
|
||
"dialogue": map[string]any{"type": "string", "maxLength": 20000},
|
||
"videoMotionPrompt": map[string]any{"type": "string", "maxLength": 20000},
|
||
"imageGenerationPrompt": map[string]any{"type": "string", "maxLength": 20000},
|
||
"camera": map[string]any{"type": "string", "maxLength": 20000},
|
||
"motion": map[string]any{"type": "string", "maxLength": 20000},
|
||
"shotSize": map[string]any{"type": "string", "maxLength": 20000},
|
||
"emotion": map[string]any{"type": "string", "maxLength": 20000},
|
||
"lightingAndAtmosphere": map[string]any{"type": "string", "maxLength": 20000},
|
||
"audioEffects": map[string]any{"type": "string", "maxLength": 20000},
|
||
"narrativeIntent": map[string]any{"type": "string", "maxLength": 20000},
|
||
"viewerPOV": map[string]any{"type": "string", "maxLength": 20000},
|
||
"performanceBlocking": map[string]any{"type": "string", "maxLength": 20000},
|
||
"timeBeats": map[string]any{"type": "string", "maxLength": 20000},
|
||
"continuityOut": map[string]any{"type": "string", "maxLength": 20000},
|
||
"negativePrompt": map[string]any{"type": "string", "maxLength": 20000},
|
||
}
|
||
return map[string]any{"type": "object", "properties": properties, "required": []string{"durationSeconds"}, "additionalProperties": false}
|
||
}
|
||
|
||
func cloudAgentStoryboardPatchSchema() map[string]any {
|
||
schema := cloudAgentStoryboardRowSchema()
|
||
delete(schema, "required")
|
||
schema["minProperties"] = 1
|
||
return schema
|
||
}
|
||
|
||
func validateCloudAgentStoryboardRow(row map[string]any, requireDescription bool) error {
|
||
if row == nil {
|
||
return BadAuthRequest("分镜行不能为空")
|
||
}
|
||
if duration, ok := row["durationSeconds"]; !ok {
|
||
return BadAuthRequest("每个分镜行必须有 durationSeconds")
|
||
} else if value, ok := duration.(float64); !ok || value <= 0 {
|
||
return BadAuthRequest("分镜时长必须是大于零的数字")
|
||
}
|
||
for key := range row {
|
||
if key != "durationSeconds" && !cloudAgentStoryboardTextField(key) {
|
||
return BadAuthRequest(fmt.Sprintf("不能通过分镜工具写入字段 %s", key))
|
||
}
|
||
}
|
||
for _, key := range cloudAgentStoryboardTextFields {
|
||
if value, exists := row[key]; exists {
|
||
text, ok := value.(string)
|
||
if !ok || utf8.RuneCountInString(text) > 20000 {
|
||
return BadAuthRequest(fmt.Sprintf("分镜字段 %s 必须是不超过20000字的文本", key))
|
||
}
|
||
}
|
||
}
|
||
if requireDescription {
|
||
plot := strings.TrimSpace(stringValue(row["plotDescription"]))
|
||
motion := strings.TrimSpace(stringValue(row["videoMotionPrompt"]))
|
||
if plot == "" && motion == "" {
|
||
return BadAuthRequest("新增镜头需要画面描述或视频提示词")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeCloudAgentStoryboardRows(rows []map[string]any, userID, nodeID, seed string) ([]any, error) {
|
||
if len(rows) < 1 || len(rows) > maxCloudAgentStoryboardRows {
|
||
return nil, BadAuthRequest("分镜脚本必须包含 1 到 100 个镜头")
|
||
}
|
||
out := make([]any, 0, len(rows))
|
||
for index, input := range rows {
|
||
if err := validateCloudAgentStoryboardRow(input, true); err != nil {
|
||
return nil, err
|
||
}
|
||
row := cloudAgentStoryboardRowDefaults()
|
||
for key, value := range input {
|
||
row[key] = value
|
||
}
|
||
row["id"] = cloudAgentID(userID, fmt.Sprintf("storyboard:%s:%s:%d", nodeID, seed, index+1))
|
||
row["shotNumber"] = float64(index + 1)
|
||
out = append(out, row)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func storyboardNodeFromDocument(doc map[string]any, nodeID string) (map[string]any, map[string]any, []map[string]any, error) {
|
||
for _, node := range creationMaps(doc["nodes"]) {
|
||
if stringValue(node["id"]) != nodeID {
|
||
continue
|
||
}
|
||
if stringValue(node["type"]) != "script" {
|
||
return nil, nil, nil, BadAuthRequest("目标节点不是分镜脚本节点")
|
||
}
|
||
metadata, ok := node["metadata"].(map[string]any)
|
||
if !ok {
|
||
return nil, nil, nil, BadAuthRequest("分镜节点数据格式无效")
|
||
}
|
||
storyboard, ok := metadata["storyboard"].(map[string]any)
|
||
if !ok {
|
||
return nil, nil, nil, BadAuthRequest("分镜节点缺少结构化表格")
|
||
}
|
||
rows := creationMaps(storyboard["rows"])
|
||
if len(rows) > maxCloudAgentStoryboardRows {
|
||
return nil, nil, nil, BadAuthRequest("分镜脚本超过100个镜头限制")
|
||
}
|
||
ids := map[string]bool{}
|
||
for _, row := range rows {
|
||
rowID := stringValue(row["id"])
|
||
if err := validateCloudAgentID(rowID, "分镜行ID", 120); err != nil || ids[rowID] {
|
||
return nil, nil, nil, BadAuthRequest("分镜节点包含无效或重复的镜头行ID")
|
||
}
|
||
ids[rowID] = true
|
||
}
|
||
return node, storyboard, rows, nil
|
||
}
|
||
return nil, nil, nil, BadAuthRequest("未找到当前画布的分镜脚本节点")
|
||
}
|
||
|
||
func prepareCloudAgentStoryboardCreate(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentStoryboardMutationPlan, error) {
|
||
var args cloudAgentStoryboardCreateArgs
|
||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
|
||
return nil, storyboardArgumentError("创建")
|
||
}
|
||
if args.SnapshotHash == "" || args.NodeID == "" || strings.TrimSpace(args.Title) == "" {
|
||
return nil, BadAuthRequest("创建分镜脚本需要快照、节点ID和标题")
|
||
}
|
||
if err := validateCloudAgentID(args.NodeID, "分镜节点ID", 80); err != nil {
|
||
return nil, err
|
||
}
|
||
if utf8.RuneCountInString(args.Title) > 240 {
|
||
return nil, BadAuthRequest("分镜标题超出限制")
|
||
}
|
||
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 新建节点不依赖其它节点的内容:用户同时删改别的节点不影响这次创建,只要求 ID 未被占用。
|
||
beforeHash := cloudAgentCanvasHash(doc)
|
||
for _, node := range creationMaps(doc["nodes"]) {
|
||
if stringValue(node["id"]) == args.NodeID {
|
||
return nil, BadAuthRequest("分镜节点ID已存在")
|
||
}
|
||
}
|
||
rows, err := normalizeCloudAgentStoryboardRows(args.Rows, userID, args.NodeID, call.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
node := creationAddedNode(CreationCanvasOp{
|
||
Type: "add_node", ID: args.NodeID, NodeType: "script", Title: args.Title, X: &args.X, Y: &args.Y,
|
||
Metadata: map[string]any{"storyboard": map[string]any{
|
||
"rows": rows, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{},
|
||
}},
|
||
})
|
||
doc["nodes"] = append(creationMaps(doc["nodes"]), node)
|
||
return &cloudAgentStoryboardMutationPlan{
|
||
Canvas: canvas, Document: doc, BeforeJSON: canvas.PayloadJSON, BeforeSnapshotHash: beforeHash,
|
||
Preview: cloudAgentApprovalPreview{Kind: "canvas_mutation", Title: "确认创建分镜脚本", Description: fmt.Sprintf("Agent 准备创建分镜脚本《%s》,包含 %d 个镜头。批准后才会写入画布。", args.Title, len(rows)), Items: []cloudAgentApprovalPreviewItem{{Operation: "create_storyboard", NodeID: args.NodeID, NodeTitle: args.Title, NodeType: "script", NodeTypeLabel: "分镜脚本", Summary: fmt.Sprintf("创建分镜脚本《%s》(%d个镜头)", args.Title, len(rows))}}},
|
||
}, nil
|
||
}
|
||
|
||
func prepareCloudAgentStoryboardEdit(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentStoryboardMutationPlan, error) {
|
||
var args cloudAgentStoryboardEditArgs
|
||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
|
||
return nil, storyboardArgumentError("编辑")
|
||
}
|
||
if args.SnapshotHash == "" || args.NodeID == "" {
|
||
return nil, BadAuthRequest("编辑分镜需要快照和节点ID")
|
||
}
|
||
if args.Action != "append" && args.Action != "update" && args.Action != "remove" {
|
||
return nil, BadAuthRequest("分镜操作必须是 append、update 或 remove")
|
||
}
|
||
if err := validateCloudAgentID(args.NodeID, "分镜节点ID", 80); err != nil {
|
||
return nil, err
|
||
}
|
||
if args.Action == "append" && args.RowID != "" {
|
||
return nil, BadAuthRequest("追加镜头不能指定已有 rowId")
|
||
}
|
||
if args.Action != "append" {
|
||
if err := validateCloudAgentID(args.RowID, "分镜行ID", 120); err != nil {
|
||
return nil, BadAuthRequest("修改或删除镜头必须使用最新读取结果中的真实 rowId")
|
||
}
|
||
}
|
||
if args.Action == "remove" && len(args.Patch) != 0 {
|
||
return nil, BadAuthRequest("删除镜头不接受 patch")
|
||
}
|
||
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
beforeHash := cloudAgentCanvasHash(doc)
|
||
if !cloudAgentNodeSnapshotMatches(doc, args.NodeID, args.SnapshotHash) {
|
||
return nil, cloudAgentFieldError("snapshotHash", "stale_snapshot", "这个分镜在你读取之后被修改过,本次未写入;请重新读取分镜后再改")
|
||
}
|
||
node, storyboard, rows, err := storyboardNodeFromDocument(doc, args.NodeID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
index := -1
|
||
for i, row := range rows {
|
||
if stringValue(row["id"]) == args.RowID {
|
||
index = i
|
||
break
|
||
}
|
||
}
|
||
if args.Action != "append" && index < 0 {
|
||
return nil, BadAuthRequest("分镜行不存在,请先读取真实行ID")
|
||
}
|
||
next := make([]map[string]any, len(rows))
|
||
for i, row := range rows {
|
||
next[i] = row
|
||
}
|
||
fields := []string{}
|
||
switch args.Action {
|
||
case "remove":
|
||
next = append(next[:index], next[index+1:]...)
|
||
fields = []string{"镜头行"}
|
||
case "append", "update":
|
||
if len(args.Patch) == 0 {
|
||
return nil, BadAuthRequest("分镜操作需要 patch")
|
||
}
|
||
patch := map[string]any{}
|
||
for key, value := range args.Patch {
|
||
if key == "durationSeconds" {
|
||
if duration, ok := value.(float64); !ok || duration <= 0 {
|
||
return nil, BadAuthRequest("分镜时长必须是大于零的数字")
|
||
}
|
||
} else if cloudAgentStoryboardTextField(key) {
|
||
text, ok := value.(string)
|
||
if !ok || utf8.RuneCountInString(text) > 20000 {
|
||
return nil, BadAuthRequest(fmt.Sprintf("分镜字段 %s 必须是不超过20000字的文本", key))
|
||
}
|
||
} else {
|
||
return nil, BadAuthRequest(fmt.Sprintf("不能通过分镜编辑修改字段 %s", key))
|
||
}
|
||
patch[key] = value
|
||
fields = append(fields, key)
|
||
}
|
||
sort.Strings(fields)
|
||
if args.Action == "append" {
|
||
if len(rows) >= maxCloudAgentStoryboardRows {
|
||
return nil, BadAuthRequest("单个分镜表最多100个镜头")
|
||
}
|
||
if err := validateCloudAgentStoryboardRow(patch, true); err != nil {
|
||
return nil, err
|
||
}
|
||
row := cloudAgentStoryboardRowDefaults()
|
||
for key, value := range patch {
|
||
row[key] = value
|
||
}
|
||
row["id"] = cloudAgentID(userID, fmt.Sprintf("storyboard:%s:%s:%d", args.NodeID, call.ID, len(rows)+1))
|
||
row["shotNumber"] = float64(len(rows) + 1)
|
||
next = append(next, row)
|
||
} else {
|
||
for key, value := range patch {
|
||
next[index][key] = value
|
||
}
|
||
}
|
||
}
|
||
for i, row := range next {
|
||
row["shotNumber"] = float64(i + 1)
|
||
}
|
||
storyboard["rows"] = mapsAsAny(next)
|
||
node["metadata"].(map[string]any)["storyboard"] = storyboard
|
||
return &cloudAgentStoryboardMutationPlan{
|
||
Canvas: canvas, Document: doc, BeforeJSON: canvas.PayloadJSON, BeforeSnapshotHash: beforeHash,
|
||
Preview: cloudAgentApprovalPreview{Kind: "canvas_mutation", Title: "确认修改分镜脚本", Description: "Agent 准备修改分镜脚本。批准后才会写入画布。", Items: []cloudAgentApprovalPreviewItem{{Operation: "edit_storyboard", NodeID: args.NodeID, NodeTitle: stringValue(node["title"]), NodeType: "script", NodeTypeLabel: "分镜脚本", Fields: fields, Summary: fmt.Sprintf("%s分镜脚本《%s》", map[string]string{"append": "追加镜头到", "update": "修改", "remove": "删除"}[args.Action], stringValue(node["title"]))}}},
|
||
}, nil
|
||
}
|
||
|
||
func applyCloudAgentStoryboardMutation(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) {
|
||
var plan *cloudAgentStoryboardMutationPlan
|
||
var err error
|
||
if call.Function.Name == "canvas_create_storyboard" {
|
||
plan, err = prepareCloudAgentStoryboardCreate(repo, userID, canvasID, call)
|
||
} else {
|
||
plan, err = prepareCloudAgentStoryboardEdit(repo, userID, canvasID, call)
|
||
}
|
||
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 {
|
||
if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: call.Function.Name, BeforeSnapshotHash: plan.BeforeSnapshotHash, AfterSnapshotHash: cloudAgentCanvasHash(plan.Document), BeforeJSON: plan.BeforeJSON, Preview: &plan.Preview}); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
nodeID := ""
|
||
if len(plan.Preview.Items) > 0 {
|
||
nodeID = plan.Preview.Items[0].NodeID
|
||
}
|
||
return map[string]any{"canvasId": canvasID, "nodeId": nodeID, "snapshotHash": cloudAgentNodeHash(plan.Document, nodeID), "summary": plan.Preview.Description, "preview": plan.Preview}, nil
|
||
}
|
||
|
||
func mapsAsAny(values []map[string]any) []any {
|
||
out := make([]any, len(values))
|
||
for i, value := range values {
|
||
out[i] = value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloudAgentStoryboardReadResult(view any, nodeID, nodeHash string) (map[string]any, error) {
|
||
state, ok := view.(map[string]any)
|
||
if !ok {
|
||
return nil, BadAuthRequest("分镜读取结果无效")
|
||
}
|
||
nodes, _ := state["nodes"].([]any)
|
||
if len(nodes) != 1 {
|
||
return nil, BadAuthRequest("分镜读取结果缺少目标节点")
|
||
}
|
||
node, ok := nodes[0].(map[string]any)
|
||
if !ok {
|
||
return nil, BadAuthRequest("分镜读取结果格式无效")
|
||
}
|
||
storyboard, ok := node["storyboard"].(map[string]any)
|
||
if !ok {
|
||
return nil, BadAuthRequest("分镜读取结果缺少镜头行")
|
||
}
|
||
rows, _ := storyboard["rows"].([]any)
|
||
for _, value := range rows {
|
||
if row, ok := value.(map[string]any); ok {
|
||
row["rowId"] = row["id"]
|
||
}
|
||
}
|
||
return map[string]any{
|
||
"nodeId": nodeID,
|
||
"title": node["title"],
|
||
"snapshotHash": nodeHash,
|
||
"storyboard": storyboard,
|
||
}, nil
|
||
}
|
||
|
||
func cloudAgentStoryboardTextField(target string) bool {
|
||
for _, field := range cloudAgentStoryboardTextFields {
|
||
if field == target {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func cloudAgentStoryboardRowDefaults() map[string]any {
|
||
row := map[string]any{
|
||
"characters": []any{},
|
||
"mustHave": []any{},
|
||
"optionalDetails": []any{},
|
||
"assetBindings": []any{},
|
||
"status": "idle",
|
||
}
|
||
for _, field := range cloudAgentStoryboardTextFields {
|
||
row[field] = ""
|
||
}
|
||
return row
|
||
}
|