Fix storyboard breaker: full timelines, binding checks, stale scripts
The storyboard breaker produced half-length episodes and unusable prompts. Root causes and fixes: - The prompt mapped each shot to 1-2 three-second lines, so a 3-shot segment always stopped at 9 s regardless of its duration. The rule now requires the timeline to run from 0 s to the segment duration, and save_storyboards rejects any batch whose timeline is incomplete or discontinuous. - Total length was only a sentence in the prompt. read_storyboard_context now returns target_total_seconds (chars / 500 per minute) and save_storyboards reports progress against it so the agent keeps splitting to the end of the script. - Editing the source text after a rewrite/skip left script_content stale and storyboards were split from the old version silently. Episodes now store script_source_hash; pipeline-status reports "stale" and read_storyboard_context refuses with an explanation. - @Name mentions were never checked against character_ids (e.g. "@Name" while the costume variant was bound), so references silently failed. Saves now reject unbound mentions; rules clarify that partial on-screen appearances are bound and voice-only characters are not. - No rule kept on-screen text (rules, captions, red text) out of image and video prompts; video models render it as garbled glyphs. Added the rule plus a save-time check that ignores negated phrasing. Template version bumped to 5; built-in skills are now re-synced on upgrade alongside prompts (user-created skills are untouched). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,13 +105,19 @@ export const DEFAULT_PROMPTS: Record<string, { name: string; instructions: strin
|
||||
- 台词下限:段落时长 ≥ 段内台词与旁白总字数(写在 description 中的部分)÷ 4.5字/秒 + 2秒表演余量,装不下的台词拆到下一个段落
|
||||
|
||||
video_prompt 规则(硬约束):
|
||||
- 按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),切镜点对齐【镜头N】结构
|
||||
- 按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1 到多个连续时间段(顺序一致、不遗漏、不新增子镜头);时间轴必须从 0 秒连续写到段落 duration,最后一行的结束时间必须等于 duration(例如 12 秒段落写满 0-3、3-6、6-9、9-12 秒),不许只写到 9 秒把剩下的时间留给模型自由发挥,切镜点对齐【镜头N】结构
|
||||
- 每段先写画面(谁+动作+景别/角度),再写该段时间内的台词/旁白——台词从 description 对应【镜头N】内提取,不要创作 description 之外的新台词
|
||||
- 提到场景用 @场景名、提到角色用 @角色名,名字必须与 read_storyboard_context 返回的列表完全一致(用于挂接参考素材图片)
|
||||
- 氛围、光线描述取自该段 atmosphere
|
||||
- 一个段落内允许切镜(换景别/角度/对象),但不跨场景
|
||||
- 用户消息中会告知本次视频模型,按该模型的特性与时长限制调整写法;未告知时按通用视频模型写法
|
||||
|
||||
角色、屏幕文字与总量规则(硬约束,save_storyboards 会机械检查,不合格整批退回):
|
||||
- 角色绑定:画面里看得见的角色才绑定;只露手、脚、头发、背影等局部也算出镜,必须绑定。只有声音的角色(画外音、隔墙、门外、电话里)不绑定、不写 @,写成「角色名(画外):「台词」」。video_prompt 里每个 @角色名 都必须在 character_ids 里
|
||||
- 同一人物有多个造型版本(如「张三」与「张三(戏服)」)时,按剧本此刻的造型选对应版本,换装前后不要混用
|
||||
- 屏幕文字:剧本里的规则字、系统提示、字幕、红字,以及纸条、黑板、手机屏幕上的可读文字,一律由后期叠加,不写进 video_prompt / image_prompt;对应画面只写人物看到时的反应,并写明「画面中不出现任何文字」
|
||||
- 总量:read_storyboard_context 返回 target_total_seconds,全部段落时长之和要接近它(±20%);必须一直拆到剧本最后一句再结束,save_storyboards 的返回会告诉你还差多少
|
||||
|
||||
额外要求:
|
||||
- 优先复用 read_storyboard_context 返回的 scene_id,不要凭空创造新场景
|
||||
- 段落角色绑定必须来自 read_storyboard_context 返回的角色列表;无角色的空镜段落可传空数组
|
||||
@@ -141,7 +147,7 @@ video_prompt 规则(硬约束):
|
||||
|
||||
工作流程:
|
||||
1. 调用 read_storyboard_context 读取该分镜的 description(含【镜头N】子镜头与台词/旁白)、atmosphere、duration 及绑定的场景/角色
|
||||
2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),台词/旁白从对应【镜头N】内的「角色名说:「…」」「旁白:…」提取,不要创作 description 之外的新台词;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);氛围光线取自 atmosphere。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 description 的【镜头N】结构
|
||||
2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1 到多个连续时间段(顺序一致、不遗漏、不新增子镜头);时间轴必须从 0 秒连续写到段落 duration,最后一行的结束时间必须等于 duration(例如 12 秒段落写满 0-3、3-6、6-9、9-12 秒),不许只写到 9 秒把剩下的时间留给模型自由发挥,台词/旁白从对应【镜头N】内的「角色名说:「…」」「旁白:…」提取,不要创作 description 之外的新台词;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);氛围光线取自 atmosphere。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 description 的【镜头N】结构
|
||||
3. 生成时会自动把 @名字 替换为对应参考图片标记(如 @小明 → @图片1小明),因此名字必须精确匹配场景/角色列表,不要缩写或加额外符号
|
||||
4. 调用 update_storyboard 保存时参数只传两个键:storyboard_id 和 video_prompt。不要回传该分镜的其他任何字段(title、description、scene_id 等一律不传)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { db, schema } from '../../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { now } from '../../utils/response.js'
|
||||
import { getEpisodeId } from '../context.js'
|
||||
import { scriptSourceHash } from '../../utils/script-source.js'
|
||||
|
||||
const readEpisodeScript = createTool({
|
||||
id: 'read_episode_script',
|
||||
@@ -67,8 +68,10 @@ const saveScript = createTool({
|
||||
execute: async ({ content }, context) => {
|
||||
const episodeId = getEpisodeId(context?.requestContext)
|
||||
if (!episodeId) return { error: 'Missing episodeId in request context' }
|
||||
const [ep] = await db.select({ content: schema.episodes.content }).from(schema.episodes)
|
||||
.where(eq(schema.episodes.id, episodeId))
|
||||
await db.update(schema.episodes)
|
||||
.set({ scriptContent: content, updatedAt: now() })
|
||||
.set({ scriptContent: content, scriptSourceHash: scriptSourceHash(ep?.content), updatedAt: now() })
|
||||
.where(eq(schema.episodes.id, episodeId))
|
||||
|
||||
return { message: `Script saved`, word_count: content.length }
|
||||
|
||||
@@ -11,6 +11,8 @@ import { now } from '../../utils/response.js'
|
||||
import { logTaskProgress, logTaskSuccess } from '../../utils/task-logger.js'
|
||||
import { getDramaId, getEpisodeId } from '../context.js'
|
||||
import { resolveVoiceMentions } from '../../services/character-voice-references.js'
|
||||
import { isScriptStale, targetEpisodeSeconds } from '../../utils/script-source.js'
|
||||
import { storyboardSegmentProblems } from '../../utils/storyboard-checks.js'
|
||||
|
||||
async function validateVideoPromptVoices(prompt: string | undefined, dramaId: number, characterIds: number[] = []) {
|
||||
if (!prompt?.includes('#')) return
|
||||
@@ -124,6 +126,10 @@ const readStoryboardContext = createTool({
|
||||
const [ep] = await db.select().from(schema.episodes)
|
||||
.where(eq(schema.episodes.id, episodeId))
|
||||
if (!ep) return { error: 'Episode not found' }
|
||||
// 原始内容在改写/跳过改写之后又改过:改写稿已过期,不能静默拿旧稿拆分镜
|
||||
if (isScriptStale(ep)) {
|
||||
return { error: '原始内容在 AI 改写(或跳过改写)之后又修改过,格式化剧本已过期。请先重新「AI 改写」或「跳过改写」,再拆分镜。' }
|
||||
}
|
||||
const script = ep.scriptContent || ep.content
|
||||
if (!script) return { error: 'Episode has no script' }
|
||||
|
||||
@@ -220,6 +226,9 @@ const readStoryboardContext = createTool({
|
||||
description: ep.description || '',
|
||||
},
|
||||
script,
|
||||
// 总量锚定的确定性数值(剧本去空白字数 ÷ 500 字/分钟),save_storyboards 会按它回报进度
|
||||
target_total_seconds: targetEpisodeSeconds(script),
|
||||
target_segment_count: Math.max(1, Math.round(targetEpisodeSeconds(script) / 12)),
|
||||
characters,
|
||||
scenes,
|
||||
props,
|
||||
@@ -282,6 +291,15 @@ const saveStoryboards = createTool({
|
||||
for (const sb of storyboards) {
|
||||
await validateVideoPromptVoices(sb.video_prompt, dramaId, sb.character_ids)
|
||||
}
|
||||
// 时间轴铺满、@角色已绑定、画面不写字——任何一段不合格整批不落库,把问题清单回给 Agent 改了重存
|
||||
const dramaCharacters = (await db.select({ id: schema.characters.id, name: schema.characters.name, deletedAt: schema.characters.deletedAt })
|
||||
.from(schema.characters).where(eq(schema.characters.dramaId, dramaId))).filter(c => !c.deletedAt)
|
||||
const segmentProblems = storyboards.flatMap(sb =>
|
||||
storyboardSegmentProblems(sb, dramaCharacters).map(p => `段落 ${sb.shot_number}:${p}`))
|
||||
if (segmentProblems.length) {
|
||||
logTaskProgress('StoryboardTool', 'save-rejected', { episodeId, problems: segmentProblems.length })
|
||||
return { error: `本批未保存,请逐条修正后用同样的 shot_number 重新调用 save_storyboards:\n${segmentProblems.join('\n')}` }
|
||||
}
|
||||
if (replace_existing === true) {
|
||||
const existingStoryboardRows = await db.select().from(schema.storyboards)
|
||||
.where(eq(schema.storyboards.episodeId, episodeId))
|
||||
@@ -355,7 +373,13 @@ const saveStoryboards = createTool({
|
||||
count: storyboards.length,
|
||||
totalDuration,
|
||||
})
|
||||
return { message: `Saved ${storyboards.length} storyboards`, count: storyboards.length, total_duration: totalDuration }
|
||||
// 回报离目标总时长还差多少,避免模型拆到一半就自认为完成
|
||||
const [epRow] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, episodeId))
|
||||
const target = targetEpisodeSeconds(epRow?.scriptContent || epRow?.content || '')
|
||||
const progress = target > 0 && totalDuration < target * 0.8
|
||||
? `已保存 ${totalDuration} 秒,目标约 ${target} 秒(剧本字数 ÷ 500 字/分钟)。剧本还没拆完,继续按顺序保存后续段落,直到剧本最后一句。`
|
||||
: `已保存 ${totalDuration} 秒,目标约 ${target} 秒。`
|
||||
return { message: `Saved ${storyboards.length} storyboards. ${progress}`, count: storyboards.length, total_duration: totalDuration, target_total_seconds: target }
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export const episodes = sqliteTable('episodes', {
|
||||
title: text('title').notNull(),
|
||||
content: text('content'),
|
||||
scriptContent: text('script_content'),
|
||||
// 生成 script_content 时原始内容的指纹;原文之后再改过即可判定改写稿过期
|
||||
scriptSourceHash: text('script_source_hash'),
|
||||
description: text('description'),
|
||||
duration: integer('duration').default(0),
|
||||
status: text('status').default('draft'),
|
||||
|
||||
@@ -391,6 +391,9 @@ export function initSqliteSchema(sqlite: Database.Database) {
|
||||
for (const name of ['voice_audio_url', 'voice_audio_name']) {
|
||||
if (!characterColumns.has(name)) sqlite.exec(`ALTER TABLE characters ADD COLUMN ${name} TEXT`)
|
||||
}
|
||||
// 改写稿来源指纹:旧库补可空字段,已有剧本不受影响(无指纹时不判过期)
|
||||
const episodeColumns = new Set((sqlite.prepare('PRAGMA table_info(episodes)').all() as { name: string }[]).map(c => c.name))
|
||||
if (!episodeColumns.has('script_source_hash')) sqlite.exec('ALTER TABLE episodes ADD COLUMN script_source_hash TEXT')
|
||||
const insertSeed = sqlite.prepare(SEED_SQL)
|
||||
// Reference inputs are independent of generated outputs; preserve all existing rows.
|
||||
const sceneColumns = new Set((sqlite.prepare('PRAGMA table_info(scenes)').all() as { name: string }[]).map(c => c.name))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db, getInsertId, schema } from '../db/index.js'
|
||||
import { success, notFound, badRequest, now } from '../utils/response.js'
|
||||
import { toSnakeCaseArray, toSnakeCase } from '../utils/transform.js'
|
||||
import { getActiveConfigId } from '../services/ai.js'
|
||||
import { isScriptStale, scriptSourceHash } from '../utils/script-source.js'
|
||||
import { EXTRACT_TARGETS, getExtractionStatus, startExtraction, type ExtractTarget } from '../services/extraction.js'
|
||||
import { getVideoPromptBatchStatus, startVideoPromptBatch } from '../services/video-prompts.js'
|
||||
|
||||
@@ -70,7 +71,14 @@ app.put('/:id', async (c) => {
|
||||
// Map snake_case to camelCase for drizzle
|
||||
const drizzleUpdates: Record<string, any> = { updatedAt: now() }
|
||||
if ('content' in updates) drizzleUpdates.content = updates.content
|
||||
if ('script_content' in updates) drizzleUpdates.scriptContent = updates.script_content
|
||||
if ('script_content' in updates) {
|
||||
drizzleUpdates.scriptContent = updates.script_content
|
||||
// 记录这份改写稿基于哪版原文(跳过改写/手动编辑同样适用),原文之后再改即判过期
|
||||
const [cur] = await db.select({ content: schema.episodes.content }).from(schema.episodes).where(eq(schema.episodes.id, id))
|
||||
drizzleUpdates.scriptSourceHash = updates.script_content
|
||||
? scriptSourceHash('content' in updates ? updates.content : cur?.content)
|
||||
: null
|
||||
}
|
||||
if ('title' in updates) drizzleUpdates.title = updates.title
|
||||
if ('description' in updates) drizzleUpdates.description = updates.description
|
||||
if ('status' in updates) drizzleUpdates.status = updates.status
|
||||
@@ -280,7 +288,7 @@ app.get('/:id/pipeline-status', async (c) => {
|
||||
return success(c, {
|
||||
episode_id: episodeId,
|
||||
steps: {
|
||||
script_rewrite: { status: ep.scriptContent ? 'done' : (ep.content ? 'ready' : 'pending') },
|
||||
script_rewrite: { status: isScriptStale(ep) ? 'stale' : ep.scriptContent ? 'done' : (ep.content ? 'ready' : 'pending') },
|
||||
extract_characters: { status: stepStatus(chars.length > 0), count: chars.length },
|
||||
extract_scenes: { status: stepStatus(scenes.length > 0), count: scenes.length },
|
||||
extract_storyboards: { status: stepStatus(sbs.length > 0), count: sbs.length },
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** 格式化剧本(script_content)基于哪一版原始内容(content)生成——用内容指纹记录,原文再改过就能识别改写稿已过期 */
|
||||
export function scriptSourceHash(content: string | null | undefined): string {
|
||||
return createHash('sha1').update(String(content ?? '').replace(/\r\n/g, '\n').trim()).digest('hex')
|
||||
}
|
||||
|
||||
/** 有改写稿、记录过来源指纹、且当前原文与之不一致 → 过期。旧数据没有指纹时不判过期,避免误伤 */
|
||||
export function isScriptStale(ep: { content?: string | null, scriptContent?: string | null, scriptSourceHash?: string | null }): boolean {
|
||||
if (!ep.scriptContent?.trim() || !ep.scriptSourceHash) return false
|
||||
return scriptSourceHash(ep.content) !== ep.scriptSourceHash
|
||||
}
|
||||
|
||||
/** 目标总时长(秒):剧本去空白字数 ÷ 500 字/分钟——与分镜拆解提示词的总量锚定同一口径 */
|
||||
export function targetEpisodeSeconds(script: string): number {
|
||||
return Math.round(String(script).replace(/\s/g, '').length / 500 * 60)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 分镜段落保存前的确定性检查(save_storyboards 调用)。
|
||||
* 只查能机械判定的三类问题,查出来就把问题清单回给 Agent 让它改了重存:
|
||||
* 1. video_prompt 的时间轴必须从 0 秒连续铺到段落时长——旧规则「每个【镜头N】映射 1-2 个 3 秒段」
|
||||
* 让 3 个镜头的段落永远只写到 9 秒,12 秒的段落后 3 秒没人管,模型自己补戏。
|
||||
* 2. video_prompt 里 @到的角色必须绑定进 character_ids——参考图只取绑定角色的主图,@了不绑等于没参考图。
|
||||
* 3. 不许把屏幕文字(规则字、系统提示、字幕)写进画面——视频模型画字必乱码,屏幕文字统一后期叠加。
|
||||
*/
|
||||
|
||||
const RANGE = /(\d+(?:\.\d+)?)\s*[-–—~~至到]\s*(\d+(?:\.\d+)?)\s*(?:秒|s(?![a-z])|sec)/gi
|
||||
|
||||
export function promptTimeline(prompt: string): Array<[number, number]> {
|
||||
return [...String(prompt || '').matchAll(RANGE)].map(m => [Number(m[1]), Number(m[2])])
|
||||
}
|
||||
|
||||
/** 「画面里不出现任何文字」这类否定说明不算违规 */
|
||||
const NEGATED = /(不|无|没有|禁止|不要|不得)[^,。;\n]{0,6}(出现|显示|写|画)?[^,。;\n]{0,4}(任何)?(文字|字幕|字样|标识|水印)/g
|
||||
const SCREEN_TEXT = /(宋体|楷体|黑体)字|血红[^,。\n]{0,4}字|(浮现|浮出|显示|弹出|打出)[^,。\n]{0,10}(字|文字|字幕|规则|提示)|字幕[「“"]|屏幕上(写|显示)/
|
||||
|
||||
export interface SegmentLike {
|
||||
shot_number: number
|
||||
duration?: number
|
||||
video_prompt?: string
|
||||
image_prompt?: string
|
||||
character_ids?: number[]
|
||||
}
|
||||
|
||||
export function storyboardSegmentProblems(sb: SegmentLike, characters: Array<{ id: number, name: string }>): string[] {
|
||||
const problems: string[] = []
|
||||
const prompt = sb.video_prompt || ''
|
||||
const duration = Number(sb.duration || 10)
|
||||
|
||||
const ranges = promptTimeline(prompt)
|
||||
if (ranges.length) {
|
||||
let cursor = 0
|
||||
for (const [a, b] of ranges) {
|
||||
if (Math.abs(a - cursor) > 0.01 || b <= a) {
|
||||
problems.push(`时间轴不连续:在 ${cursor} 秒处接的是「${a}-${b}秒」,应从 ${cursor} 秒接着写`)
|
||||
break
|
||||
}
|
||||
cursor = b
|
||||
}
|
||||
const end = ranges[ranges.length - 1][1]
|
||||
if (Math.abs(end - duration) > 0.01) {
|
||||
problems.push(`时间轴只写到 ${end} 秒,段落时长是 ${duration} 秒——每行 2-4 秒,最后一行必须结束在 ${duration} 秒(加子镜头或拉长停留,不要留空白给模型自由发挥)`)
|
||||
}
|
||||
}
|
||||
|
||||
const bound = new Set(sb.character_ids || [])
|
||||
const names = [...characters].sort((a, b) => b.name.length - a.name.length)
|
||||
for (const m of prompt.matchAll(/@([^\s@#,。、,;;::]+)/g)) {
|
||||
const hit = names.find(c => m[1].startsWith(c.name))
|
||||
if (hit && !bound.has(hit.id)) problems.push(`@${hit.name} 出现在视频提示词里但没有绑定到 character_ids(参考图只取已绑定角色)`)
|
||||
}
|
||||
|
||||
for (const [field, text] of [['video_prompt', prompt], ['image_prompt', sb.image_prompt || '']] as const) {
|
||||
const cleaned = text.replace(NEGATED, '')
|
||||
const hit = cleaned.match(SCREEN_TEXT)
|
||||
if (hit) problems.push(`${field} 要求画面里出现文字(「${hit[0]}」)——规则字、系统提示、字幕一律后期叠加,画面只写人物反应,并注明画面中不出现任何文字`)
|
||||
}
|
||||
return [...new Set(problems)]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ The user request will tell you which storyboard to generate a video prompt for (
|
||||
|
||||
Workflow:
|
||||
1. Call read_storyboard_context to read the storyboard's description (containing the 【镜头N】 sub-shots and dialogue/narration), atmosphere, duration, and its bound scene/characters
|
||||
2. Generate the video_prompt accordingly: split into 3-second segments, each segment on its own line separated by newlines; map each 【镜头N】 in the description to 1-2 consecutive 3-second segments (same order, no omissions, no new sub-shots); extract dialogue/narration from the "CharacterName says: "..."" / "Narration: ..." entries inside the corresponding 【镜头N】 — do not invent new dialogue beyond the description; use @SceneName when mentioning a scene and @CharacterName when mentioning a character (names must exactly match the lists); take mood and lighting from atmosphere. Cutting between shots within a storyboard segment is allowed (change of shot size/angle/subject); consecutive segments may be different shots, but never cross scenes; cut points align with the 【镜头N】 structure of the storyboard description
|
||||
2. Generate the video_prompt accordingly: split into 3-second segments, each segment on its own line separated by newlines; map each 【镜头N】 in the description to one or more consecutive time segments; the timeline must run continuously from 0 s to the segment duration and the last line must end exactly at duration (a 12-second segment covers 0-3, 3-6, 6-9, 9-12 s) — never stop at 9 s and leave the rest to the model (same order, no omissions, no new sub-shots); extract dialogue/narration from the "CharacterName says: "..."" / "Narration: ..." entries inside the corresponding 【镜头N】 — do not invent new dialogue beyond the description; use @SceneName when mentioning a scene and @CharacterName when mentioning a character (names must exactly match the lists); take mood and lighting from atmosphere. Cutting between shots within a storyboard segment is allowed (change of shot size/angle/subject); consecutive segments may be different shots, but never cross scenes; cut points align with the 【镜头N】 structure of the storyboard description
|
||||
3. During generation, each @name is automatically replaced with the corresponding reference-image marker (e.g. @Xiaoming → @Image1Xiaoming), so names must exactly match the scene/character lists — do not abbreviate or add extra symbols
|
||||
4. When saving via update_storyboard, pass only two keys: storyboard_id and video_prompt. Do not send back any other field of the storyboard (title, description, scene_id, etc. — none of them)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ model: ""
|
||||
|
||||
工作流程:
|
||||
1. 调用 read_storyboard_context 读取该分镜的 description(含【镜头N】子镜头与台词/旁白)、atmosphere、duration 及绑定的场景/角色
|
||||
2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),台词/旁白从对应【镜头N】内的「角色名说:「…」」「旁白:…」提取,不要创作 description 之外的新台词;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);氛围光线取自 atmosphere。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 description 的【镜头N】结构
|
||||
2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1 到多个连续时间段(顺序一致、不遗漏、不新增子镜头);时间轴必须从 0 秒连续写到段落 duration,最后一行的结束时间必须等于 duration(例如 12 秒段落写满 0-3、3-6、6-9、9-12 秒),不许只写到 9 秒把剩下的时间留给模型自由发挥,台词/旁白从对应【镜头N】内的「角色名说:「…」」「旁白:…」提取,不要创作 description 之外的新台词;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);氛围光线取自 atmosphere。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 description 的【镜头N】结构
|
||||
3. 生成时会自动把 @名字 替换为对应参考图片标记(如 @小明 → @图片1小明),因此名字必须精确匹配场景/角色列表,不要缩写或加额外符号
|
||||
4. 调用 update_storyboard 保存时参数只传两个键:storyboard_id 和 video_prompt。不要回传该分镜的其他任何字段(title、description、scene_id 等一律不传)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Duration rules (hard constraints):
|
||||
- Dialogue floor: segment duration ≥ total character count of dialogue and narration within the segment (the part written in description) ÷ 4.5 characters/second + 2 seconds of performance headroom; dialogue that does not fit must be moved to the next segment
|
||||
|
||||
video_prompt rules (hard constraints):
|
||||
- Split into 3-second segments, each segment on its own line separated by newlines; map each 【镜头N】 in the description to 1-2 consecutive 3-second segments (same order, no omissions, no new sub-shots); cut points align with the 【镜头N】 structure
|
||||
- Split into 3-second segments, each segment on its own line separated by newlines; map each 【镜头N】 in the description to one or more consecutive time segments; the timeline must run continuously from 0 s to the segment duration and the last line must end exactly at duration (a 12-second segment covers 0-3, 3-6, 6-9, 9-12 s) — never stop at 9 s and leave the rest to the model (same order, no omissions, no new sub-shots); cut points align with the 【镜头N】 structure
|
||||
- In each segment, write the visuals first (who + action + shot size/angle), then the dialogue/narration occurring within that time span — dialogue is extracted from the corresponding 【镜头N】 in the description; do not invent new dialogue beyond the description
|
||||
- Use @SceneName when mentioning a scene and @CharacterName when mentioning a character; names must exactly match the lists returned by read_storyboard_context (used to attach reference-asset images)
|
||||
- Mood and lighting descriptions come from the segment's atmosphere
|
||||
@@ -47,3 +47,9 @@ Additional requirements:
|
||||
- The segment description must be able to support the downstream video-generation and export pipeline
|
||||
- If a segment has no dialogue, simply write no dialogue in the description, but the visual description and atmosphere must still be complete
|
||||
- If existing_storyboards are present, refer to them only when the user explicitly requests incremental edits; by default, regenerate and save the complete episode storyboard from the current script.
|
||||
|
||||
Character, on-screen text and length rules (hard constraints; save_storyboards checks them mechanically and rejects the whole batch on failure):
|
||||
- Bind a character only when visible on screen; a partial appearance (hands, feet, hair, back view) still counts and must be bound. Voice-only characters (off-screen, behind a wall, outside the door, on the phone) are not bound and get no @ — write "Name (off-screen): 'line'". Every @Name in video_prompt must be in character_ids
|
||||
- When one person has several look variants (e.g. "Zhang San" and "Zhang San (costume)"), pick the variant matching the script at that moment; never mix them across a costume change
|
||||
- On-screen text: rule text, system prompts, subtitles, red text, and readable writing on notes, blackboards or phone screens are all added in post — never put them in video_prompt / image_prompt; describe only the characters' reaction and state "no text appears in the frame"
|
||||
- Length: read_storyboard_context returns target_total_seconds; the sum of segment durations should be close to it (±20%); keep going until the last line of the script — save_storyboards tells you how much is left
|
||||
|
||||
@@ -33,13 +33,19 @@ model: ""
|
||||
- 台词下限:段落时长 ≥ 段内台词与旁白总字数(写在 description 中的部分)÷ 4.5字/秒 + 2秒表演余量,装不下的台词拆到下一个段落
|
||||
|
||||
video_prompt 规则(硬约束):
|
||||
- 按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),切镜点对齐【镜头N】结构
|
||||
- 按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1 到多个连续时间段(顺序一致、不遗漏、不新增子镜头);时间轴必须从 0 秒连续写到段落 duration,最后一行的结束时间必须等于 duration(例如 12 秒段落写满 0-3、3-6、6-9、9-12 秒),不许只写到 9 秒把剩下的时间留给模型自由发挥,切镜点对齐【镜头N】结构
|
||||
- 每段先写画面(谁+动作+景别/角度),再写该段时间内的台词/旁白——台词从 description 对应【镜头N】内提取,不要创作 description 之外的新台词
|
||||
- 提到场景用 @场景名、提到角色用 @角色名,名字必须与 read_storyboard_context 返回的列表完全一致(用于挂接参考素材图片)
|
||||
- 氛围、光线描述取自该段 atmosphere
|
||||
- 一个段落内允许切镜(换景别/角度/对象),但不跨场景
|
||||
- 用户消息中会告知本次视频模型,按该模型的特性与时长限制调整写法;未告知时按通用视频模型写法
|
||||
|
||||
角色、屏幕文字与总量规则(硬约束,save_storyboards 会机械检查,不合格整批退回):
|
||||
- 角色绑定:画面里看得见的角色才绑定;只露手、脚、头发、背影等局部也算出镜,必须绑定。只有声音的角色(画外音、隔墙、门外、电话里)不绑定、不写 @,写成「角色名(画外):「台词」」。video_prompt 里每个 @角色名 都必须在 character_ids 里
|
||||
- 同一人物有多个造型版本(如「张三」与「张三(戏服)」)时,按剧本此刻的造型选对应版本,换装前后不要混用
|
||||
- 屏幕文字:剧本里的规则字、系统提示、字幕、红字,以及纸条、黑板、手机屏幕上的可读文字,一律由后期叠加,不写进 video_prompt / image_prompt;对应画面只写人物看到时的反应,并写明「画面中不出现任何文字」
|
||||
- 总量:read_storyboard_context 返回 target_total_seconds,全部段落时长之和要接近它(±20%);必须一直拆到剧本最后一句再结束,save_storyboards 的返回会告诉你还差多少
|
||||
|
||||
额外要求:
|
||||
- 优先复用 read_storyboard_context 返回的 scene_id,不要凭空创造新场景
|
||||
- 段落角色绑定必须来自 read_storyboard_context 返回的角色列表;无角色的空镜段落可传空数组
|
||||
|
||||
@@ -64,7 +64,7 @@ Dialogue that does not fit must be moved to the next segment; cramming unperform
|
||||
|
||||
- `character_ids` must be chosen from the character list returned by `read_storyboard_context`
|
||||
- A segment may have no characters, or may bind multiple characters
|
||||
- Any character with a clear appearance in the segment — seen, acting, or speaking — should be bound
|
||||
- Bind only characters visible on screen (partial appearances such as hands, feet, hair or a back view count). Voice-only characters (off-screen, behind a wall, on the phone) are not bound and get no @ — write "Name (off-screen): ...". Pick the look variant (e.g. "Name (costume)") that matches the script at that moment. Every @Name in video_prompt must be in character_ids.
|
||||
- Pure-environment segments, empty shots, and object close-ups may pass an empty array
|
||||
|
||||
## Prop Binding Rules
|
||||
|
||||
@@ -64,7 +64,10 @@ description: 分镜拆解专业规范 — 将剧本拆分为可承载多个子
|
||||
|
||||
- `character_ids` 必须从 `read_storyboard_context` 返回的角色列表中选择
|
||||
- 一个段落可以没有角色,也可以绑定多个角色
|
||||
- 只要段落里有明确出场、被看见、发生动作或说话的角色,都应绑定进去
|
||||
- 画面里看得见的角色才绑定:明确出场、被看见、发生动作的角色都要绑定;只露手、脚、头发、背影等局部也算出镜
|
||||
- 只有声音的角色(画外音、隔墙、门外、电话里)不绑定,video_prompt 里不写 @,写成「角色名(画外):「台词」」
|
||||
- 同一人物有多个造型版本(如「张三」与「张三(戏服)」)时,按剧本此刻的造型选版本,换装前后不要混用
|
||||
- video_prompt 里出现的每个 @角色名 都必须在 character_ids 里(save_storyboards 会检查)
|
||||
- 纯环境段落、空镜、物件特写可以传空数组
|
||||
|
||||
## 道具绑定规则
|
||||
|
||||
@@ -30,7 +30,7 @@ let backendBundle = BACKEND_BUNDLE
|
||||
let runtimeResourcesDir = ''
|
||||
|
||||
/** workspace 模板版本:内置模板更新时递增,触发向用户目录补缺失文件 */
|
||||
const TEMPLATE_VERSION = '4'
|
||||
const TEMPLATE_VERSION = '5'
|
||||
const STORAGE_CONFIG_FILE = 'storage-config.json'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
@@ -170,6 +170,10 @@ function syncWorkspaceTemplate(templateDir: string, destDir: string) {
|
||||
if (fs.existsSync(marker) && fs.readFileSync(marker, 'utf8').trim() === TEMPLATE_VERSION) return
|
||||
fs.cpSync(templateDir, destDir, { recursive: true, force: false, errorOnExist: false })
|
||||
fs.cpSync(path.join(templateDir, 'prompts'), path.join(destDir, 'prompts'), { recursive: true, force: true })
|
||||
// 内置技能随模板版本一起更新(用户在设置页新建的子技能不在模板里,不受影响)
|
||||
for (const skill of fs.readdirSync(path.join(templateDir, 'skills'))) {
|
||||
fs.cpSync(path.join(templateDir, 'skills', skill), path.join(destDir, 'skills', skill), { recursive: true, force: true })
|
||||
}
|
||||
fs.writeFileSync(marker, TEMPLATE_VERSION)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user