From 04e6b7cba78448bda1d15c3ef3d587707d2de37b Mon Sep 17 00:00:00 2001 From: gr Date: Mon, 9 Nov 2026 17:08:56 +0800 Subject: [PATCH] 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 --- huobao-source/backend/src/agents/index.ts | 10 ++- .../backend/src/agents/tools/script-tools.ts | 5 +- .../src/agents/tools/storyboard-tools.ts | 26 +++++++- huobao-source/backend/src/db/schema.ts | 2 + huobao-source/backend/src/db/sqlite-schema.ts | 3 + huobao-source/backend/src/routes/episodes.ts | 12 +++- .../backend/src/utils/script-source.ts | 17 +++++ .../backend/src/utils/storyboard-checks.ts | 62 +++++++++++++++++++ .../workspace/prompts/prompt_generator.en.md | 2 +- .../workspace/prompts/prompt_generator.md | 2 +- .../prompts/storyboard_breaker.en.md | 8 ++- .../workspace/prompts/storyboard_breaker.md | 8 ++- .../skills/storyboard-breaker/SKILL.en.md | 2 +- .../skills/storyboard-breaker/SKILL.md | 5 +- huobao-source/desktop/src/main.ts | 6 +- 15 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 huobao-source/backend/src/utils/script-source.ts create mode 100644 huobao-source/backend/src/utils/storyboard-checks.ts diff --git a/huobao-source/backend/src/agents/index.ts b/huobao-source/backend/src/agents/index.ts index 27dbcfb..94816d9 100644 --- a/huobao-source/backend/src/agents/index.ts +++ b/huobao-source/backend/src/agents/index.ts @@ -105,13 +105,19 @@ export const DEFAULT_PROMPTS: Record { 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 } diff --git a/huobao-source/backend/src/agents/tools/storyboard-tools.ts b/huobao-source/backend/src/agents/tools/storyboard-tools.ts index d0ed29a..6ddfc2b 100644 --- a/huobao-source/backend/src/agents/tools/storyboard-tools.ts +++ b/huobao-source/backend/src/agents/tools/storyboard-tools.ts @@ -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 } }, }) diff --git a/huobao-source/backend/src/db/schema.ts b/huobao-source/backend/src/db/schema.ts index ef61246..d9ea8c5 100644 --- a/huobao-source/backend/src/db/schema.ts +++ b/huobao-source/backend/src/db/schema.ts @@ -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'), diff --git a/huobao-source/backend/src/db/sqlite-schema.ts b/huobao-source/backend/src/db/sqlite-schema.ts index 108fa62..f06f7a2 100644 --- a/huobao-source/backend/src/db/sqlite-schema.ts +++ b/huobao-source/backend/src/db/sqlite-schema.ts @@ -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)) diff --git a/huobao-source/backend/src/routes/episodes.ts b/huobao-source/backend/src/routes/episodes.ts index 934ffee..a8d838f 100644 --- a/huobao-source/backend/src/routes/episodes.ts +++ b/huobao-source/backend/src/routes/episodes.ts @@ -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 = { 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 }, diff --git a/huobao-source/backend/src/utils/script-source.ts b/huobao-source/backend/src/utils/script-source.ts new file mode 100644 index 0000000..15d2b05 --- /dev/null +++ b/huobao-source/backend/src/utils/script-source.ts @@ -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) +} diff --git a/huobao-source/backend/src/utils/storyboard-checks.ts b/huobao-source/backend/src/utils/storyboard-checks.ts new file mode 100644 index 0000000..9712d18 --- /dev/null +++ b/huobao-source/backend/src/utils/storyboard-checks.ts @@ -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)] +} diff --git a/huobao-source/backend/workspace/prompts/prompt_generator.en.md b/huobao-source/backend/workspace/prompts/prompt_generator.en.md index 1fb181f..681b0d2 100644 --- a/huobao-source/backend/workspace/prompts/prompt_generator.en.md +++ b/huobao-source/backend/workspace/prompts/prompt_generator.en.md @@ -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) diff --git a/huobao-source/backend/workspace/prompts/prompt_generator.md b/huobao-source/backend/workspace/prompts/prompt_generator.md index 70eb3b1..ab81128 100644 --- a/huobao-source/backend/workspace/prompts/prompt_generator.md +++ b/huobao-source/backend/workspace/prompts/prompt_generator.md @@ -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 等一律不传) diff --git a/huobao-source/backend/workspace/prompts/storyboard_breaker.en.md b/huobao-source/backend/workspace/prompts/storyboard_breaker.en.md index 9795aa3..67d08ab 100644 --- a/huobao-source/backend/workspace/prompts/storyboard_breaker.en.md +++ b/huobao-source/backend/workspace/prompts/storyboard_breaker.en.md @@ -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 diff --git a/huobao-source/backend/workspace/prompts/storyboard_breaker.md b/huobao-source/backend/workspace/prompts/storyboard_breaker.md index 3f9103a..b23c1a7 100644 --- a/huobao-source/backend/workspace/prompts/storyboard_breaker.md +++ b/huobao-source/backend/workspace/prompts/storyboard_breaker.md @@ -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 返回的角色列表;无角色的空镜段落可传空数组 diff --git a/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.en.md b/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.en.md index f978802..2f682af 100644 --- a/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.en.md +++ b/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.en.md @@ -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 diff --git a/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.md b/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.md index 6cc87e8..4d39c38 100644 --- a/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.md +++ b/huobao-source/backend/workspace/skills/storyboard-breaker/SKILL.md @@ -64,7 +64,10 @@ description: 分镜拆解专业规范 — 将剧本拆分为可承载多个子 - `character_ids` 必须从 `read_storyboard_context` 返回的角色列表中选择 - 一个段落可以没有角色,也可以绑定多个角色 -- 只要段落里有明确出场、被看见、发生动作或说话的角色,都应绑定进去 +- 画面里看得见的角色才绑定:明确出场、被看见、发生动作的角色都要绑定;只露手、脚、头发、背影等局部也算出镜 +- 只有声音的角色(画外音、隔墙、门外、电话里)不绑定,video_prompt 里不写 @,写成「角色名(画外):「台词」」 +- 同一人物有多个造型版本(如「张三」与「张三(戏服)」)时,按剧本此刻的造型选版本,换装前后不要混用 +- video_prompt 里出现的每个 @角色名 都必须在 character_ids 里(save_storyboards 会检查) - 纯环境段落、空镜、物件特写可以传空数组 ## 道具绑定规则 diff --git a/huobao-source/desktop/src/main.ts b/huobao-source/desktop/src/main.ts index 5fc28bb..f8e24da 100644 --- a/huobao-source/desktop/src/main.ts +++ b/huobao-source/desktop/src/main.ts @@ -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) }