412 lines
16 KiB
Go
412 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"yingce/backend/internal/model"
|
|
"yingce/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, fieldPath string) 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"]))
|
|
imagePrompt := strings.TrimSpace(stringValue(row["imageGenerationPrompt"]))
|
|
if plot == "" && motion == "" && imagePrompt == "" {
|
|
return cloudAgentFieldError(fieldPath, "required", "新增镜头需要画面描述、首帧提示词或视频提示词")
|
|
}
|
|
}
|
|
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, fmt.Sprintf("rows[%d]", index)); 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, "patch"); 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
|
|
}
|