部分模型不遵守 JSON Schema:直接返回顶层数组、数组外再包一层、每个角色单独包一层 characters、或正文先列出角色名数组再给出契约对象。原解析只取第一个可解析 JSON 值,容易命中旁枝片段并被判为解析失败。 - prompt_template.go: extractPreferredJSONText 优先选取包含 preferKey 的顶层对象候选;角色卡校验按形态归一处理,结果为空仍提示未识别到角色,字段缺失报出具体角色序号与字段名 - storyboard.go: normalizeStoryboardRootJSON 兼容单元素 plan 数组解包与镜头对象数组自动包装为完整 plan - canvas-character-reference.ts: 前端角色卡提取同步统一归一逻辑 - 补充前后端形态兼容回归测试与 CHANGELOG
863 lines
32 KiB
Go
863 lines
32 KiB
Go
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")
|
||
}
|