Merge remote-tracking branch 'origin/main'

# Conflicts:
#	huobao-source/backend/src/db/sqlite-schema.ts
This commit is contained in:
Codex
2026-11-12 10:40:50 +08:00
16 changed files with 161 additions and 14 deletions
+8 -2
View File
@@ -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 }
@@ -12,6 +12,8 @@ import { logTaskProgress, logTaskSuccess } from '../../utils/task-logger.js'
import { getDramaId, getEpisodeId } from '../context.js'
import { previousEpisodeContext } from '../../services/continuity.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
@@ -125,6 +127,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' }
@@ -222,6 +228,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,
@@ -288,6 +297,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))
@@ -363,7 +381,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 }
},
})
+2
View File
@@ -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'),
@@ -396,6 +396,9 @@ export function initSqliteSchema(sqlite: Database.Database) {
for (const [name, type] of Object.entries({ continuity_mode: "TEXT DEFAULT 'none'", continuity_source_id: 'INTEGER', continuity_start: 'TEXT', continuity_end: 'TEXT', continuity_offset_ms: 'INTEGER DEFAULT 200' })) {
if (!storyboardColumns.has(name)) sqlite.exec(`ALTER TABLE storyboards ADD COLUMN ${name} ${type}`)
}
// 改写稿来源指纹:旧库补可空字段,已有剧本不受影响(无指纹时不判过期)
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))
+10 -2
View File
@@ -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 会检查)
- 纯环境段落、空镜、物件特写可以传空数组
## 道具绑定规则
+5 -1
View File
@@ -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)
}
+4 -1
View File
@@ -27,8 +27,11 @@ size=8+len(encoded)+len(padding)
archive.write_bytes(struct.pack('<4I',4,size,size-4,len(encoded))+encoded+padding+data)
frontend=root/'huobao-source/frontend/.output/public'
shutil.copytree(frontend,stage/'resources/frontend',dirs_exist_ok=True)
for directory in ('prompts', 'skills'):
shutil.copytree(root/'huobao-source/backend/workspace'/directory,
stage/'resources/workspace-template'/directory,dirs_exist_ok=True)
manifest={'upstream_version':'4.0.11','source_commit':'b48e6b2312166e25b1ff141832d2e82e5d63c142',
'custom_features':['persistent-data','local-video-pool','multi-gpu-images','vdn-hd-versions','per-model-gpu-selection','fixed-gpu-models','cancel-and-regenerate','per-shot-routing','direct-comfy-panels','confirmed-worker-labels','asset-image-references','durable-local-task-tracking','cross-episode-continuity','tail-frame-dependency-queue'],
'custom_features':['persistent-data','local-video-pool','multi-gpu-images','vdn-hd-versions','per-model-gpu-selection','fixed-gpu-models','cancel-and-regenerate','per-shot-routing','direct-comfy-panels','confirmed-worker-labels','asset-image-references','durable-local-task-tracking','cross-episode-continuity','tail-frame-dependency-queue','storyboard-timeline-validation'],
'archive_sha256':hashlib.sha256(archive.read_bytes()).hexdigest()}
(stage/'resources/huobao-local-build.json').write_text(json.dumps(manifest,indent=2))
print('Staged 4.0.11 with persistent data and VDN HD:',manifest['archive_sha256'])