Files
ttstd-infinite-canvas/backend/internal/service/storyboard_prompt.go

228 lines
13 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"
"fmt"
"strings"
"infinite-canvas/backend/internal/model"
)
type StoryboardPromptTemplateRequest struct {
Name string `json:"name"`
Content string `json:"content"`
Enabled *bool `json:"enabled"`
}
type StoryboardPromptVariable struct {
Label string `json:"label"`
Placeholder string `json:"placeholder"`
}
var storyboardPromptVariables = []StoryboardPromptVariable{
{Label: "剧情", Placeholder: "{{剧情}}"},
{Label: "用户要求", Placeholder: "{{用户要求}}"},
{Label: "画布资产", Placeholder: "{{画布资产}}"},
}
func (s *Service) EnsureDefaultStoryboardPromptTemplate() error {
count, err := s.repo.StoryboardPromptTemplateCount()
if err != nil {
return err
}
if count > 0 {
// 只升级仍保留旧占位符的内置模板,避免覆盖管理员创建或修改的版本。
templates, err := s.repo.StoryboardPromptTemplates()
if err != nil {
return err
}
for index := range templates {
template := &templates[index]
if template.Name == "默认影视分镜提示词" && strings.Contains(template.Content, "\n....\n") {
template.Content = defaultStoryboardPromptTemplate()
return s.repo.SaveStoryboardPromptTemplate(template)
}
}
return nil
}
return s.repo.SaveStoryboardPromptTemplate(&model.StoryboardPromptTemplate{
ID: newID(),
Name: "默认影视分镜提示词",
Content: defaultStoryboardPromptTemplate(),
Enabled: true,
})
}
func (s *Service) AdminStoryboardPromptTemplates(actor *model.User) ([]model.StoryboardPromptTemplate, []StoryboardPromptVariable, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, nil, err
}
templates, err := s.repo.StoryboardPromptTemplates()
if err != nil {
return nil, nil, err
}
return templates, storyboardPromptVariables, nil
}
func (s *Service) CreateStoryboardPromptTemplate(actor *model.User, req StoryboardPromptTemplateRequest) (*model.StoryboardPromptTemplate, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
template, err := storyboardPromptTemplateFromRequest(req, model.StoryboardPromptTemplate{ID: newID(), CreatedBy: actor.ID})
if err != nil {
return nil, err
}
if req.Enabled == nil {
template.Enabled = false
}
if err := s.repo.SaveStoryboardPromptTemplate(&template); err != nil {
return nil, err
}
return &template, nil
}
func (s *Service) UpdateStoryboardPromptTemplate(actor *model.User, id string, req StoryboardPromptTemplateRequest) (*model.StoryboardPromptTemplate, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
template, err := s.repo.StoryboardPromptTemplate(id)
if err != nil {
return nil, err
}
if template.Enabled && req.Enabled != nil && !*req.Enabled {
return nil, BadAuthRequest("至少需要保留一个启用的分镜提示词")
}
next, err := storyboardPromptTemplateFromRequest(req, *template)
if err != nil {
return nil, err
}
if err := s.repo.SaveStoryboardPromptTemplate(&next); err != nil {
return nil, err
}
return &next, nil
}
func (s *Service) DeleteStoryboardPromptTemplate(actor *model.User, id string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
template, err := s.repo.StoryboardPromptTemplate(id)
if err != nil {
return err
}
if template.Enabled {
return BadAuthRequest("启用中的分镜提示词不能删除,请先启用其他版本")
}
return s.repo.DeleteStoryboardPromptTemplate(id)
}
func (s *Service) buildAgentStoryboardPlannerPrompt(brief string, requirements string, assets []storyboardAsset, shotDuration int, shotCount int) string {
template := defaultStoryboardPromptTemplate()
if active, err := s.repo.ActiveStoryboardPromptTemplate(); err == nil && strings.TrimSpace(active.Content) != "" {
template = active.Content
}
return renderStoryboardPromptTemplate(template, brief, requirements, assets) + "\n\n" + storyboardCinematicQualityContract(shotDuration, shotCount)
}
func storyboardCinematicQualityContract(shotDuration int, shotCount int) string {
durationRule := "单个镜头时长由剧情节奏决定,必须是 1 到 60 秒的整数。"
if shotDuration == 5 || shotDuration == 10 || shotDuration == 15 || shotDuration == 30 {
durationRule = fmt.Sprintf("本次生成单个镜头时长必须严格等于 %d 秒;模型只决定镜头数量和总时长,不得修改单镜头时长。", shotDuration)
}
countRule := "镜头数量由模型按剧情节奏自动决定。"
if shotCount >= 1 && shotCount <= 10 {
countRule = fmt.Sprintf("shots 数组必须严格输出 %d 个镜头,不得多于或少于 %d 个;该规则覆盖模板中的默认镜头数量范围。", shotCount, shotCount)
}
contract := `强制电影化质量契约(优先级高于用户 brief 中的泛化叙述):
- ` + durationRule + `
- ` + countRule + `
- 不要把剧情段落直接改写成镜头摘要。每一行必须是一个可执行、可拍摄的连续镜头,必须写清主体在画面中的具体动作、空间关系、视觉焦点和可见结果。
- 每个镜头必须有摄影机设计:shotSize、camera(机位高度/角度/焦段/景别/构图)、motion(推拉摇移跟/环绕/升降/手持及起止方向)。镜头之间要有远近景、角度或运动变化,避免同质中景。
- timeBeats 必须按时间码拆解该镜头内部动作,例如“0-1.2秒:...;1.2-4.5秒:...;4.5-8秒:...”,覆盖完整 durationSeconds,包含开始画面、中段变化和结尾停点。
- description 只写可见画面与动作,不使用“意识到、回忆起、感到、关系建立、命运转折”等不可直接拍摄的剧情总结;这些内容必须转译成眼神、停顿、手部动作、走位、道具反应或环境变化。
- visualPrompt 必须包含主体、前中后景、构图、光线、材质、色彩和真实摄影质感;videoPrompt 必须补充主体运动、环境运动、连续性、时长和转场逻辑。
- styleGuide、visualPrompt 和 videoPrompt 禁止写入 2.39:1、16:9 等具体画幅比例,也不要讨论画幅配置。
- negativePrompt 必须针对本镜头列出换脸、服装变化、手部错误、乱码、闪烁、风格突变、动作僵硬等风险,不要只写“无”。
- 只返回 JSON。shots 中每项必须包含:title、description、durationSeconds、dialogue、shotSize、emotion、lightingAndAtmosphere、audioEffects、visualPrompt、videoPrompt、camera、motion、timeBeats、negativePrompt、assetTags。`
return contract + "\n\n" + storyboardCameraLanguageGuide()
}
func storyboardCameraLanguageGuide() string {
return `分镜镜头语言规则(必须融入每个镜头的设计,服务叙事而非炫技):
- 景别必须从以下六类中选择并写入 shotSize:S01 大远景 ELS(环境/宏大空间)、S02 远景 LS(人物全身与处境)、S03 全景 FS(完整动作)、S04 中景 MS(自然叙事/对话首选)、S05 近景 CU(表情与反应)、S06 特写 ECU(眼睛、手或关键细节)。同一场景按叙事需要渐进变化,避免无逻辑跳跃。
- 机位角度按叙事需要选择:A01 平视 Eye Level(中性客观)、A02 仰角 Low Angle(强大/压迫)、A03 俯角 High Angle(弱小/脆弱)、A04 鸟瞰 Bird's Eye/Top Down(全知/构图)、A05 倾斜角 Dutch Angle(失衡/不安)、A06 蜗牛视角 Worm's Eye(极度崇高或压迫)。无动机不得突变视角。
- 运镜从以下体系中按情绪和空间目的选择,并在 motion 写清起止状态:M01 推、M02 拉、M03 横移、M04 水平摇、M05 垂直倾斜、M06 升降、M07 环绕、M08 甩镜、M09 手持、M10 稳定器跟拍、M11 主观跟进、M12 航拍推进、M13 航拍拉升、M14 变焦、M15 希区柯克变焦、M16 过肩。运动镜头必须有动机;以静制动,固定镜头与运动镜头要形成对比。
- 构图优先从以下方法选择并写入 camera 或 visualPrompt:C01 三分法、C02 框中框、C03 引导线、C04 对称构图、C05 负空间、C06 前景叠层。必须交代主体、前景/中景/远景和视觉焦点。
- 叙事手法按需要使用:N01 主观镜头、N02 反应镜头、N03 空镜/插入、N04 匹配剪辑、N05 交叉剪辑、N06 跳切。对话优先 MS + OTS(过肩)+ 反应镜头,不要用复杂运动掩盖表演。
- 节奏约束:推/拉/横移/升降等运动镜头通常保持 5-8 秒以上;甩镜和希区柯克变焦全片各不超过 1-2 次;手持只用于纪实、紧迫或混乱等明确情绪;航拍主要用于开篇或高潮;ECU 每场景最多 2 个。
- 禁止每 3-5 秒更换一次运镜,禁止同类技法连续重复,禁止无叙事动机的角度突变;希区柯克变焦仅用于顿悟、恐惧或心理扭转等关键时刻。`
}
func storyboardPromptTemplateFromRequest(req StoryboardPromptTemplateRequest, template model.StoryboardPromptTemplate) (model.StoryboardPromptTemplate, error) {
name := strings.TrimSpace(req.Name)
content := strings.TrimSpace(req.Content)
if name == "" {
return template, BadAuthRequest("请填写提示词名称")
}
if content == "" {
return template, BadAuthRequest("请填写提示词内容")
}
template.Name = name
template.Content = content
if req.Enabled != nil {
template.Enabled = *req.Enabled
}
return template, nil
}
func renderStoryboardPromptTemplate(template string, brief string, requirements string, assets []storyboardAsset) string {
assetJSON, _ := json.MarshalIndent(assets, "", " ")
replacer := strings.NewReplacer(
"{{剧情}}", strings.TrimSpace(brief),
"{{用户brief}}", strings.TrimSpace(brief),
"{{用户要求}}", strings.TrimSpace(requirements),
"{{画布资产}}", string(assetJSON),
"{{获取当前画布资产}}", string(assetJSON),
)
return replacer.Replace(template)
}
func defaultStoryboardPromptTemplate() string {
codeFence := "```"
return `你是影视分镜导演和 AI 视频提示词专家。
请根据用户剧情 brief 和当前画布资产标签,生成可直接用于 AI 视频生成的分镜 JSON。
创作方法参考成熟分镜提示词实践:
- 先理解故事目标、人物动机、冲突、情绪曲线和结尾。
- 每个镜头都要明确主体、动作、场景、景别、构图、镜头焦段、机位、运镜、光线、色彩、质感、时长感和转场。
- durationSeconds 必须是 1 到 60 的整数;没有台词、旁白或音效时返回空字符串。
- 需要保持角色、服装、道具、空间和风格一致;能复用画布资产时,在 assetTags 中引用对应标签。
- characters 必须逐角色输出,格式固定为“角色名:剧情定位、外貌、服装、体态、道具、性格动机和跨镜头一致性约束”,不要把多个角色合并成一项。
- videoPrompt 必须是完整中文视频生成提示词,不要只写关键词。
- videoPrompt 必须包含镜头时长、开始画面、中段变化、结尾画面、摄影机运动、主体运动、环境运动、可信光源、色彩基调、真实摄影质感和负面视觉约束。
- 优先使用真实电影机语言:低机位/平视/俯拍、焦段、景深、自然曝光、空气介质、胶片颗粒、真实高光滚降。
- 不要把主体完整居中平铺;需要有前景/中景/远景和人、物、空间尺度参照。
- 不要在 visualPrompt 或 videoPrompt 中使用 3D动漫、动画、二次元、游戏CG、游戏截图、角色原画、概念设计图、手办感、卡通渲染、插画风等媒介限定词。
- assetTags 只能引用当前画布资产里已有的标签或标签值;没有可复用资产时返回空数组。
用户brief:
` + codeFence + `
{{剧情}}
` + codeFence + `
用户要求:
` + codeFence + `
{{用户要求}}
` + codeFence + `
当前画布资产:{{画布资产}}
格式:
{"title":"项目标题","logline":"一句话故事","styleGuide":"整体摄影、光线、色彩、质感和一致性规则","characters":["张三:男主角,28岁,短黑发,深灰夹克,清瘦挺拔,随身旧怀表,克制果断;所有镜头保持五官、发型、服装和怀表一致"],"locations":["场景与空间描述"],"shots":[{"title":"镜头标题","description":"可拍摄的主体动作、空间关系和可见结果","durationSeconds":8,"dialogue":"台词或旁白","shotSize":"中近景","emotion":"克制紧张","lightingAndAtmosphere":"侧逆光与薄雾","audioEffects":"环境风声","visualPrompt":"包含前中后景、构图、光线、材质和真实摄影质感的图片提示词","videoPrompt":"补充主体运动、环境运动、连续性、时长和转场逻辑","camera":"平视略低机位,50mm,中近景,前景遮挡,三分法构图","motion":"0-1秒固定,1-5秒缓慢推近,5-8秒轻微横移后停住","timeBeats":"0-1秒:建立画面;1-5秒:主体完成关键动作;5-8秒:反应和结尾停点","negativePrompt":"禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变、动作僵硬","assetTags":["角色:张三"]}]}
特别注意:
- 只返回 JSON,不要 markdown,不要解释。
- shots 输出 3 到 8 个;如果后续强制电影化质量契约指定了镜头数量,以契约为准。`
}