Files
ttstd-infinite-canvas/backend/internal/service/storyboard.go
liang007 df2821e1f8 fix(ai): 兼容模型返回的多种 JSON 顶层形态,修复角色卡与分镜解析误报失败
部分模型不遵守 JSON Schema:直接返回顶层数组、数组外再包一层、每个角色单独包一层 characters、或正文先列出角色名数组再给出契约对象。原解析只取第一个可解析 JSON 值,容易命中旁枝片段并被判为解析失败。

- prompt_template.go: extractPreferredJSONText 优先选取包含 preferKey 的顶层对象候选;角色卡校验按形态归一处理,结果为空仍提示未识别到角色,字段缺失报出具体角色序号与字段名
- storyboard.go: normalizeStoryboardRootJSON 兼容单元素 plan 数组解包与镜头对象数组自动包装为完整 plan
- canvas-character-reference.ts: 前端角色卡提取同步统一归一逻辑
- 补充前后端形态兼容回归测试与 CHANGELOG
2026-09-01 17:32:38 +08:00

863 lines
32 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 service
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"unicode/utf8"
)
const maxStoryboardShots = 12
type agentStoryboardInput struct {
References []string `json:"references"`
CanvasSnapshot map[string]any `json:"canvasSnapshot"`
Requirements string `json:"requirements"`
CanvasAssets []storyboardAsset `json:"canvasAssets"`
ProjectStyle storyboardProjectStyle `json:"projectStyle"`
Characters []storyboardCharacterCard `json:"characters"`
Config providerConfig `json:"config"`
ShotDuration int `json:"shotDurationSeconds"`
ShotCount int `json:"shotCount"`
}
type storyboardProjectStyle struct {
PresetID string `json:"presetId"`
Title string `json:"title"`
Prompt string `json:"prompt"`
ProfileJSON string `json:"profileJson,omitempty"`
}
type storyboardCharacterCard struct {
AssetID string `json:"assetId"`
VersionID string `json:"versionId"`
Name string `json:"name"`
Definition map[string]any `json:"definition"`
}
type storyboardAsset struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Category string `json:"category,omitempty"`
Tags []string `json:"tags"`
Prompt string `json:"prompt"`
CharacterAssetID string `json:"characterAssetId,omitempty"`
CharacterVersionID string `json:"characterVersionId,omitempty"`
}
type storyboardAssetRef struct {
NodeID string `json:"nodeId"`
Role string `json:"role"`
Priority int `json:"priority"`
}
type agentStoryboardPlan struct {
Title string `json:"title"`
Logline string `json:"logline"`
StyleGuide string `json:"styleGuide"`
Characters []string `json:"characters"`
Locations []string `json:"locations"`
Shots []agentStoryboardShot `json:"shots"`
Raw map[string]interface{} `json:"-"`
}
type agentStoryboardShot struct {
Title string `json:"title"`
Description string `json:"description"`
Duration int `json:"durationSeconds"`
Dialogue string `json:"dialogue"`
ShotSize string `json:"shotSize"`
Emotion string `json:"emotion"`
Lighting string `json:"lightingAndAtmosphere"`
AudioEffects string `json:"audioEffects"`
VisualPrompt string `json:"visualPrompt"`
VideoPrompt string `json:"videoPrompt"`
Camera string `json:"camera"`
Motion string `json:"motion"`
TimeBeats string `json:"timeBeats"`
Negative string `json:"negativePrompt"`
AssetRefs []storyboardAssetRef `json:"assetRefs"`
CharacterIDs []string `json:"characterIds"`
CharacterNames []string `json:"-"`
CharacterLabels []string `json:"-"`
Intent string `json:"narrativeIntent"`
ViewerPOV string `json:"viewerPOV"`
Performance string `json:"performanceBlocking"`
MustHave []string `json:"mustHave"`
Optional []string `json:"optionalDetails"`
ContinuityOut string `json:"continuityOut"`
}
func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) {
jsonText, err := extractJSONText(raw)
if err != nil {
return agentStoryboardPlan{}, err
}
jsonText = normalizeStoryboardRootJSON(jsonText)
if err := validateStoryboardJSONFields(jsonText); err != nil {
return agentStoryboardPlan{}, err
}
var plan agentStoryboardPlan
if err := json.Unmarshal([]byte(jsonText), &plan); err != nil {
return agentStoryboardPlan{}, fmt.Errorf("分镜 JSON 解析失败:%w", err)
}
plan.Title = defaultString(strings.TrimSpace(plan.Title), "影视分镜")
plan.Logline = defaultString(strings.TrimSpace(plan.Logline), "根据剧情生成的分镜方案")
plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "严格沿用当前项目画风,保持角色、空间、道具、色彩和视觉媒介一致。")
if len(plan.Shots) == 0 {
return agentStoryboardPlan{}, errors.New("分镜模型没有返回 shots")
}
if len(plan.Shots) > maxStoryboardShots {
return agentStoryboardPlan{}, fmt.Errorf("分镜数量最多 %d 个,实际返回 %d 个", maxStoryboardShots, len(plan.Shots))
}
for i := range plan.Shots {
if strings.TrimSpace(plan.Shots[i].Title) == "" {
plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1)
}
plan.Shots[i].CharacterIDs = nonNilStrings(plan.Shots[i].CharacterIDs)
if plan.Shots[i].AssetRefs == nil {
plan.Shots[i].AssetRefs = []storyboardAssetRef{}
}
plan.Shots[i].Optional = nonNilStrings(plan.Shots[i].Optional)
plan.Shots[i].Intent = defaultString(strings.TrimSpace(plan.Shots[i].Intent), strings.TrimSpace(plan.Shots[i].Description))
plan.Shots[i].ViewerPOV = defaultString(strings.TrimSpace(plan.Shots[i].ViewerPOV), "客观观察当前主要角色与事件")
plan.Shots[i].Performance = defaultString(strings.TrimSpace(plan.Shots[i].Performance), strings.TrimSpace(plan.Shots[i].Description))
plan.Shots[i].ContinuityOut = defaultString(strings.TrimSpace(plan.Shots[i].ContinuityOut), "保持本镜头结尾的人物位置、动作状态、道具和光线方向进入下一镜")
if len(plan.Shots[i].MustHave) == 0 {
plan.Shots[i].MustHave = []string{"主要角色身份与当前版本稳定", "主要动作完成并有清晰落点", "结尾状态可供下一镜继承"}
}
if strings.TrimSpace(plan.Shots[i].VideoPrompt) == "" {
plan.Shots[i].VideoPrompt = defaultString(plan.Shots[i].VisualPrompt, plan.Shots[i].Description)
}
if strings.TrimSpace(plan.Shots[i].VisualPrompt) == "" {
return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 visualPrompt", i+1)
}
if strings.TrimSpace(plan.Shots[i].Camera) == "" || strings.TrimSpace(plan.Shots[i].Motion) == "" || strings.TrimSpace(plan.Shots[i].TimeBeats) == "" {
return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 缺少 camera、motion 或 timeBeats", i+1)
}
if plan.Shots[i].Duration <= 0 || plan.Shots[i].Duration > 60 {
return agentStoryboardPlan{}, fmt.Errorf("镜头 %d 的 durationSeconds 必须在 1 到 60 之间", i+1)
}
}
return plan, nil
}
// normalizeStoryboardRootJSON 兼容部分模型不遵守 object schema、把结果作为顶层数组返回的情况。
// - 单元素对象数组(如 [ { ...plan } ])解包为对象;
// - 直接返回镜头对象数组(如 [ { ...shot }, ... ])包装成完整 plan,缺省字段由后续解析兜底填充。
func normalizeStoryboardRootJSON(jsonText string) string {
trimmed := strings.TrimSpace(jsonText)
if trimmed == "" || trimmed[0] != '[' {
return trimmed
}
var arr []json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &arr); err != nil || len(arr) == 0 {
return trimmed
}
// 优先识别镜头对象数组(无论单/多元素)→ 包装成完整 plan,缺省字段由后续解析兜底。
if looksLikeStoryboardShot(arr[0]) {
return `{"title":"","logline":"","styleGuide":"","characters":[],"locations":[],"shots":` + trimmed + `}`
}
// 单元素对象数组(完整 plan 对象)→ 解包。
if len(arr) == 1 {
elem := strings.TrimSpace(string(arr[0]))
if strings.HasPrefix(elem, "{") {
return elem
}
}
return trimmed
}
func looksLikeStoryboardShot(raw json.RawMessage) bool {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return false
}
_, hasVisual := obj["visualPrompt"]
_, hasDuration := obj["durationSeconds"]
_, hasDescription := obj["description"]
return hasVisual || (hasDuration && hasDescription)
}
func validateStoryboardJSONFields(jsonText string) error {
var root map[string]json.RawMessage
if err := json.Unmarshal([]byte(jsonText), &root); err != nil {
return fmt.Errorf("分镜 JSON 解析失败:%w", err)
}
for _, field := range []string{"title", "logline", "styleGuide", "characters", "locations", "shots"} {
if _, ok := root[field]; !ok {
return fmt.Errorf("分镜 JSON 缺少受保护字段 %s", field)
}
}
var shots []map[string]json.RawMessage
if err := json.Unmarshal(root["shots"], &shots); err != nil {
return errors.New("分镜 JSON 的 shots 必须是数组")
}
required := []string{"title", "description", "durationSeconds", "dialogue", "characterIds", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "visualPrompt", "videoPrompt", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt", "assetRefs"}
for index, shot := range shots {
for _, field := range required {
if _, ok := shot[field]; !ok {
return fmt.Errorf("镜头 %d 缺少受保护字段 %s", index+1, field)
}
}
}
return nil
}
func nonNilStrings(values []string) []string {
if values == nil {
return []string{}
}
return values
}
func validateStoryboardContext(projectStyle storyboardProjectStyle, characters []storyboardCharacterCard) error {
if strings.TrimSpace(projectStyle.PresetID) == "" || strings.TrimSpace(projectStyle.Title) == "" || strings.TrimSpace(projectStyle.Prompt) == "" {
return errors.New("请先设置项目画风,再生成分镜")
}
if strings.TrimSpace(projectStyle.ProfileJSON) != "" {
if _, err := validateStyleProfileJSON(projectStyle.ProfileJSON); err != nil {
return err
}
}
for _, character := range characters {
if strings.TrimSpace(character.Name) == "" {
return errors.New("角色卡缺少角色名称,请刷新角色信息后再生成分镜")
}
hasAssetID := strings.TrimSpace(character.AssetID) != ""
hasVersionID := strings.TrimSpace(character.VersionID) != ""
if hasAssetID != hasVersionID {
return fmt.Errorf("角色 %s 的资产 ID 与版本 ID 必须同时提供", strings.TrimSpace(character.Name))
}
}
return nil
}
func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error {
if target == 0 {
return nil
}
if target != 5 && target != 10 && target != 15 && target != 30 {
return fmt.Errorf("不支持的单镜头时长:%d 秒", target)
}
for index, shot := range plan.Shots {
if shot.Duration != target {
return fmt.Errorf("镜头 %d 的时长必须是 %d 秒", index+1, target)
}
}
return nil
}
func validateStoryboardPlan(plan *agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard, assets []storyboardAsset) error {
if plan == nil {
return errors.New("分镜方案不能为空")
}
if utf8.RuneCountInString(strings.TrimSpace(plan.StyleGuide)) > 120 {
return errors.New("styleGuide 最多 120 个中文字符")
}
if err := validateStoryboardShotDuration(*plan, shotDuration); err != nil {
return err
}
if err := validateStoryboardShotCount(*plan, shotCount); err != nil {
return err
}
normalizeStoryboardCharacterReferences(plan, characters)
if err := validateStoryboardAssetRefs(*plan, assets); err != nil {
return err
}
return nil
}
func validateStoryboardAssetRefs(plan agentStoryboardPlan, assets []storyboardAsset) error {
allowed := make(map[string]storyboardAsset, len(assets))
for _, asset := range assets {
allowed[asset.ID] = asset
}
validRoles := map[string]bool{
"character": true, "environment": true, "wardrobe": true, "prop": true,
"weapon": true, "style": true, "motion": true, "audio": true,
}
for shotIndex, shot := range plan.Shots {
if len(shot.AssetRefs) > 6 {
return fmt.Errorf("镜头 %d 最多关联 6 个画布资产", shotIndex+1)
}
seen := make(map[string]bool, len(shot.AssetRefs))
for _, ref := range shot.AssetRefs {
asset, ok := allowed[ref.NodeID]
if !ok {
return fmt.Errorf("镜头 %d 引用了不在当前画布资产目录中的 nodeId:%s", shotIndex+1, ref.NodeID)
}
if seen[ref.NodeID] {
return fmt.Errorf("镜头 %d 重复引用画布资产:%s", shotIndex+1, ref.NodeID)
}
seen[ref.NodeID] = true
if !validRoles[ref.Role] {
return fmt.Errorf("镜头 %d 的资产 %s 使用了不支持的角色类型:%s", shotIndex+1, asset.Title, ref.Role)
}
if ref.Priority < 0 || ref.Priority > 100 {
return fmt.Errorf("镜头 %d 的资产 %s 优先级必须在 0 到 100 之间", shotIndex+1, asset.Title)
}
}
}
return nil
}
func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error {
if target == 0 {
return nil
}
if target < 1 || target > 10 {
return fmt.Errorf("分镜数量必须在 1 到 10 之间")
}
if len(plan.Shots) != target {
return fmt.Errorf("分镜数量必须是 %d,实际生成 %d", target, len(plan.Shots))
}
return nil
}
func validateStoryboardComplexity(plan agentStoryboardPlan) error {
issues := make([]string, 0)
for index, shot := range plan.Shots {
shotNumber := index + 1
characterCount := len(shot.CharacterIDs) + len(shot.CharacterNames)
if characterCount > 2 {
issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, characterCount))
}
if len(shot.MustHave) > 3 {
issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个必须完成项,最多 3 个", shotNumber, len(shot.MustHave)))
}
if beats := storyboardBeatCount(shot.TimeBeats); beats > 3 {
issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个时间节拍,最多 3 个", shotNumber, beats))
}
if movements := storyboardCameraMovementCount(shot.Motion); movements > 1 {
issues = append(issues, fmt.Sprintf("镜头 %d 包含 %d 种主运镜,最多 1 种", shotNumber, movements))
}
dialogueLimit := max(24, shot.Duration*5)
if dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)); dialogueLength > dialogueLimit {
issues = append(issues, fmt.Sprintf("镜头 %d 台词 %d 字,%d 秒镜头最多约 %d 字", shotNumber, dialogueLength, shot.Duration, dialogueLimit))
}
}
if len(issues) == 0 {
return nil
}
return fmt.Errorf("镜头复杂度超限:%s", strings.Join(issues, ";"))
}
func normalizeAutomaticStoryboardDurations(plan *agentStoryboardPlan, target int) {
if plan == nil || target != 0 {
return
}
for index := range plan.Shots {
shot := &plan.Shots[index]
dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue))
requiredDuration := (dialogueLength + 4) / 5
shot.Duration = min(60, max(1, shot.Duration, requiredDuration))
}
}
func normalizeStoryboardCharacterReferences(plan *agentStoryboardPlan, characters []storyboardCharacterCard) {
if plan == nil {
return
}
byID := make(map[string]storyboardCharacterCard, len(characters))
byName := make(map[string]storyboardCharacterCard, len(characters))
for _, character := range characters {
character.AssetID = strings.TrimSpace(character.AssetID)
character.VersionID = strings.TrimSpace(character.VersionID)
character.Name = strings.TrimSpace(character.Name)
if character.AssetID != "" && character.VersionID != "" {
byID[character.AssetID] = character
}
nameKey := normalizeStoryboardCharacterName(character.Name)
if nameKey == "" {
continue
}
current, exists := byName[nameKey]
if !exists || (current.AssetID == "" && character.AssetID != "") {
byName[nameKey] = character
}
}
for shotIndex := range plan.Shots {
shot := &plan.Shots[shotIndex]
references := append(append([]string{}, shot.CharacterIDs...), shot.CharacterNames...)
resolvedIDs := make([]string, 0, len(references))
resolvedNames := make([]string, 0, len(references))
seen := make(map[string]bool, len(references))
seenLabels := make(map[string]bool, len(references))
labels := make([]string, 0, len(references))
for _, rawReference := range references {
reference := strings.TrimSpace(rawReference)
if reference == "" {
continue
}
if character, ok := byID[reference]; ok {
appendStoryboardCharacterID(&resolvedIDs, seen, character.AssetID)
appendStoryboardCharacterLabel(&labels, seenLabels, character.Name)
continue
}
if character, ok := byName[normalizeStoryboardCharacterName(reference)]; ok {
if character.AssetID != "" && character.VersionID != "" {
appendStoryboardCharacterID(&resolvedIDs, seen, character.AssetID)
} else {
appendStoryboardCharacterName(&resolvedNames, seen, character.Name)
}
appendStoryboardCharacterLabel(&labels, seenLabels, character.Name)
continue
}
appendStoryboardCharacterName(&resolvedNames, seen, reference)
appendStoryboardCharacterLabel(&labels, seenLabels, reference)
}
shot.CharacterIDs = resolvedIDs
shot.CharacterNames = resolvedNames
shot.CharacterLabels = labels
}
}
func storyboardRowCharacters(shot agentStoryboardShot, characters []storyboardCharacterCard) []map[string]any {
byID := make(map[string]storyboardCharacterCard, len(characters))
for _, character := range characters {
if strings.TrimSpace(character.AssetID) != "" && strings.TrimSpace(character.VersionID) != "" {
byID[strings.TrimSpace(character.AssetID)] = character
}
}
result := make([]map[string]any, 0, len(shot.CharacterIDs)+len(shot.CharacterNames))
seenNames := make(map[string]bool, len(shot.CharacterIDs)+len(shot.CharacterNames))
for _, assetID := range shot.CharacterIDs {
character, ok := byID[assetID]
if !ok {
continue
}
seenNames[normalizeStoryboardCharacterName(character.Name)] = true
result = append(result, map[string]any{
"characterName": character.Name,
"characterAssetId": character.AssetID,
"characterVersionId": character.VersionID,
})
}
for _, name := range shot.CharacterNames {
name = strings.TrimSpace(name)
nameKey := normalizeStoryboardCharacterName(name)
if nameKey == "" || seenNames[nameKey] {
continue
}
seenNames[nameKey] = true
result = append(result, map[string]any{"characterName": name})
}
return result
}
func appendStoryboardCharacterID(values *[]string, seen map[string]bool, assetID string) {
key := "id:" + assetID
if seen[key] {
return
}
seen[key] = true
*values = append(*values, assetID)
}
func appendStoryboardCharacterName(values *[]string, seen map[string]bool, name string) {
name = strings.TrimSpace(name)
key := normalizeStoryboardCharacterName(name)
if key == "" || seen["name:"+key] {
return
}
seen["name:"+key] = true
*values = append(*values, name)
}
func appendStoryboardCharacterLabel(values *[]string, seen map[string]bool, name string) {
name = strings.TrimSpace(name)
key := normalizeStoryboardCharacterName(name)
if key == "" || seen[key] {
return
}
seen[key] = true
*values = append(*values, name)
}
func normalizeStoryboardCharacterName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func storyboardBeatCount(value string) int {
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' || r == '\n' })
count := 0
for _, part := range parts {
if strings.TrimSpace(part) != "" {
count++
}
}
timecodeCount := len(storyboardTimecodePattern.FindAllString(value, -1))
return max(count, timecodeCount)
}
var storyboardTimecodePattern = regexp.MustCompile(`\d+(?:\.\d+)?\s*[-~—至到]\s*\d+(?:\.\d+)?\s*秒`)
func storyboardCameraMovementCount(value string) int {
movements := []string{"推进", "推近", "拉远", "摇摄", "横移", "侧移", "跟拍", "跟随", "升降", "上升", "下降", "环绕", "俯冲", "变焦", "甩镜", "穿越"}
count := 0
for _, movement := range movements {
if strings.Contains(value, movement) {
count++
}
}
return count
}
func extractJSONText(raw string) (string, error) {
// Text models may prepend an explanation or append a Markdown fence despite
// the JSON-only contract. Scan complete JSON values instead of pairing the
// first opening brace with the final closing brace, which can merge prose and
// make an otherwise valid character breakdown fail validation.
for start := 0; start < len(raw); start++ {
if raw[start] != '{' && raw[start] != '[' {
continue
}
end := jsonValueEnd(raw, start)
if end < start {
continue
}
candidate := raw[start : end+1]
var decoded interface{}
if json.Unmarshal([]byte(candidate), &decoded) == nil {
return candidate, nil
}
}
return "", errors.New("模型返回的不是 JSON")
}
// extractPreferredJSONText 与 extractJSONText 一样逐个扫描完整的 JSON 值,但会优先返回顶层对象且
// 包含 preferKey 的候选。模型常在给出契约对象前先用正文列举一遍内容(例如先写一段角色名数组),
// 只取第一个可解析值会命中这些旁枝片段,导致后续校验拿到完全无关的结构。
// 找不到偏好候选时回退到第一个可解析值,保证与 extractJSONText 的默认行为一致。
func extractPreferredJSONText(raw string, preferKey string) (string, error) {
fallback := ""
for start := 0; start < len(raw); start++ {
if raw[start] != '{' && raw[start] != '[' {
continue
}
end := jsonValueEnd(raw, start)
if end < start {
continue
}
candidate := raw[start : end+1]
var decoded interface{}
if json.Unmarshal([]byte(candidate), &decoded) != nil {
continue
}
if obj, ok := decoded.(map[string]interface{}); ok {
if _, ok := obj[preferKey]; ok {
return candidate, nil
}
}
if fallback == "" {
fallback = candidate
}
}
if fallback != "" {
return fallback, nil
}
return "", errors.New("模型返回的不是 JSON")
}
func jsonValueEnd(source string, start int) int {
stack := make([]byte, 0, 8)
inString := false
escaped := false
for index := start; index < len(source); index++ {
value := source[index]
if inString {
if escaped {
escaped = false
} else if value == '\\' {
escaped = true
} else if value == '"' {
inString = false
}
continue
}
switch value {
case '"':
inString = true
case '{', '[':
stack = append(stack, value)
case '}', ']':
if len(stack) == 0 {
return -1
}
opener := stack[len(stack)-1]
if (value == '}' && opener != '{') || (value == ']' && opener != '[') {
return -1
}
stack = stack[:len(stack)-1]
if len(stack) == 0 {
return index
}
}
}
return -1
}
func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset {
rawNodes, _ := snapshot["nodes"].([]interface{})
assets := make([]storyboardAsset, 0, len(rawNodes))
for _, raw := range rawNodes {
node, _ := raw.(map[string]interface{})
if node == nil {
continue
}
metadata, _ := node["metadata"].(map[string]interface{})
if metadata == nil {
metadata = map[string]interface{}{}
}
nodeType := stringValue(node["type"])
workflowKind := stringValue(metadata["workflowKind"])
isCharacterCard := workflowKind == "character" && stringValue(metadata["characterAssetId"]) != "" && stringValue(metadata["characterVersionId"]) != ""
isMedia := nodeType == "image" || nodeType == "video" || nodeType == "audio"
if (!isMedia && !isCharacterCard) || workflowKind == "shot" || workflowKind == "action_board" || workflowKind == "final" {
continue
}
id := stringValue(node["id"])
if id == "" {
continue
}
tags := stringSlice(metadata["assetTags"])
prompt := stringValue(metadata["prompt"])
content := stringValue(metadata["content"])
if len(tags) == 0 && prompt == "" && content == "" && !isCharacterCard {
continue
}
assets = append(assets, storyboardAsset{
ID: id,
Title: defaultString(stringValue(node["title"]), "未命名资产"),
Type: defaultString(nodeType, "reference"),
Category: stringValue(metadata["assetCategory"]),
Tags: tags,
Prompt: prompt,
CharacterAssetID: stringValue(metadata["characterAssetId"]),
CharacterVersionID: stringValue(metadata["characterVersionId"]),
})
if len(assets) >= 60 {
break
}
}
return assets
}
func resolveStoryboardAssets(assets []storyboardAsset, refs []storyboardAssetRef) []storyboardAsset {
byID := make(map[string]storyboardAsset, len(assets))
for _, asset := range assets {
byID[asset.ID] = asset
}
resolved := make([]storyboardAsset, 0, len(refs))
for _, ref := range refs {
if asset, ok := byID[ref.NodeID]; ok {
resolved = append(resolved, asset)
}
}
return resolved
}
func normalizeStoryboardAssets(input []storyboardAsset) []storyboardAsset {
allowedTypes := map[string]bool{"image": true, "video": true, "audio": true, "character": true}
seenIDs := make(map[string]bool, len(input))
assets := make([]storyboardAsset, 0, min(len(input), 60))
for _, asset := range input {
id := strings.TrimSpace(asset.ID)
assetType := strings.ToLower(strings.TrimSpace(asset.Type))
if id == "" || utf8.RuneCountInString(id) > 160 || seenIDs[id] || !allowedTypes[assetType] {
continue
}
seenIDs[id] = true
tags := make([]string, 0, min(len(asset.Tags), 12))
seenTags := make(map[string]bool, len(asset.Tags))
for _, value := range asset.Tags {
tag := clipStoryboardAssetText(value, 64)
if tag == "" || seenTags[tag] {
continue
}
seenTags[tag] = true
tags = append(tags, tag)
if len(tags) == 12 {
break
}
}
assets = append(assets, storyboardAsset{
ID: id,
Title: defaultString(clipStoryboardAssetText(asset.Title, 120), "未命名资产"),
Type: assetType,
Category: clipStoryboardAssetText(asset.Category, 40),
Tags: tags,
Prompt: clipStoryboardAssetText(asset.Prompt, 600),
CharacterAssetID: clipStoryboardAssetText(asset.CharacterAssetID, 160),
CharacterVersionID: clipStoryboardAssetText(asset.CharacterVersionID, 160),
})
if len(assets) == 60 {
break
}
}
return assets
}
func clipStoryboardAssetText(value string, limit int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= limit {
return string(runes)
}
return string(runes[:limit])
}
func listContent(title string, items []string) string {
if len(items) == 0 {
return title + "\n\n- 暂无明确内容。"
}
lines := []string{title, ""}
for _, item := range items {
if strings.TrimSpace(item) != "" {
lines = append(lines, "- "+item)
}
}
return strings.Join(lines, "\n")
}
func storyboardAssetsContent(assets []storyboardAsset) string {
if len(assets) == 0 {
return "当前画布暂无可用图片资产。建议先给角色、环境、道具图片添加资产标签。"
}
lines := make([]string, 0, len(assets))
for _, asset := range assets {
line := asset.Title + "\nID: " + asset.ID
if len(asset.Tags) > 0 {
line += "\n标签: " + strings.Join(asset.Tags, "、")
}
if asset.Prompt != "" {
line += "\n原提示词: " + asset.Prompt
}
lines = append(lines, line)
}
return strings.Join(lines, "\n\n")
}
func shotDescription(shot agentStoryboardShot) string {
parts := []string{shot.Description}
if strings.TrimSpace(shot.VisualPrompt) != "" {
parts = append(parts, "画面提示词:"+shot.VisualPrompt)
}
if strings.TrimSpace(shot.Camera) != "" {
parts = append(parts, "镜头:"+shot.Camera)
}
if strings.TrimSpace(shot.Motion) != "" {
parts = append(parts, "运动:"+shot.Motion)
}
if strings.TrimSpace(shot.TimeBeats) != "" {
parts = append(parts, "时间节拍:"+shot.TimeBeats)
}
filtered := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) != "" {
filtered = append(filtered, part)
}
}
return strings.Join(filtered, "\n\n")
}
func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string {
negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质")
return map[string]string{
"项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide),
"首帧构图": compactPromptText(storyboardCharacterPromptPrefix(shot)+shot.VisualPrompt+";光影:"+shot.Lighting, 360),
"表演起始状态": compactPromptText(shot.Performance, 180),
"负面要求": compactPromptText(negative, 140),
}
}
func buildStoryboardImagePrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string {
definition, _ := promptDefinition(promptOperationStoryboardFirstFrame)
prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardImagePromptValues(projectStyle, styleGuide, shot))
return prompt
}
func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string {
camera := defaultString(strings.TrimSpace(shot.Camera), strings.TrimSpace(shot.ShotSize)+",平视机位,中等焦段,主体与环境保持空间层次")
motion := defaultString(strings.TrimSpace(shot.Motion), "固定机位,主体在画面内完成动作")
timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description)))
negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬")
values := map[string]string{
"项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide),
"镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150),
"首帧构图": compactPromptText(storyboardCharacterPromptPrefix(shot)+shot.VisualPrompt+";光影:"+shot.Lighting, 280),
"表演与调度": compactPromptText(shot.Performance, 180),
"摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220),
"时间节拍": compactPromptText(timeBeats, 240),
"运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240),
"声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160),
"负面要求": compactPromptText(negative, 160),
}
if len(shot.MustHave) > 0 {
priority := "必须完成:" + strings.Join(shot.MustHave, ";")
if len(shot.Optional) > 0 {
priority += "。可以简化:" + strings.Join(shot.Optional, ";")
}
values["执行优先级"] = compactPromptText(priority, 140)
}
return values
}
func storyboardCharacterPromptPrefix(shot agentStoryboardShot) string {
if len(shot.CharacterLabels) == 0 {
return ""
}
return "镜头角色:" + strings.Join(shot.CharacterLabels, "、") + ";"
}
func buildStoryboardVideoPrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string {
definition, _ := promptDefinition(promptOperationStoryboardVideo)
prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardVideoPromptValues(projectStyle, styleGuide, shot))
return prompt
}
func storyboardProjectVisualSummary(projectStyle string, styleGuide string) string {
identity := ""
for _, line := range strings.Split(projectStyle, "\n") {
if strings.TrimSpace(line) != "" {
identity = strings.TrimSpace(line)
break
}
}
parts := make([]string, 0, 2)
if identity != "" {
parts = append(parts, identity)
}
if strings.TrimSpace(styleGuide) != "" {
parts = append(parts, strings.TrimSpace(styleGuide))
}
return compactPromptText(strings.Join(parts, ";"), 180)
}
func compactPromptText(value string, limit int) string {
text := strings.TrimSpace(value)
if utf8.RuneCountInString(text) <= limit {
return text
}
runes := []rune(text)
return strings.TrimSpace(string(runes[:limit])) + "。"
}
func shotComposerContent(prompt string, assets []storyboardAsset) string {
if len(assets) == 0 {
return prompt
}
lines := []string{"参考素材:"}
for _, asset := range assets {
label := asset.Title
if len(asset.Tags) > 0 {
label += "(" + strings.Join(asset.Tags, "、") + ")"
}
lines = append(lines, "- "+label+":@[node:"+asset.ID+"]")
}
lines = append(lines, "", "分镜视频提示词:", prompt)
return strings.Join(lines, "\n")
}