refactor(storyboard): 分镜字段收敛,action/dialogue 并入 description 后删列

- storyboards 表删除 action/dialogue 两列;启动迁移带守卫(SHOW COLUMNS),
  先把存量数据合并进 description(action 直接追加、dialogue 以「对白:」前缀追加)再 DROP
- 画面描述统一按【镜头N】承载子镜头画面与台词/旁白(「角色名说:「…」」「旁白:…」)
- storyboard_breaker prompt/skill 字段清单收敛为 description+atmosphere,
  对白下限改为按 description 中台词字数计算的台词时长下限
- video-prompt skill 与 prompt_generator 系统提示词新增映射规则:
  每个【镜头N】→ 1-2 个连续 3 秒段,台词从 description 原位提取,不新创台词
- 工作台分镜面板移除「动作」编辑框与「添加对白/旁白」入口,只保留画面描述与氛围

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Connor
2026-08-08 23:07:08 +08:00
parent bad95b8b16
commit 9998c3a769
11 changed files with 56 additions and 284 deletions
+4 -28
View File
@@ -1,23 +1,18 @@
/**
* 导出初始化 SQL — 把 src/db/mysql-schema.ts 中的内嵌 DDL/DML 生成为独立 init.sql
* 导出初始化 SQL — 把 src/db/mysql-schema.ts 中的内嵌 DDL/种子生成为独立 init.sql
*
* 用法: npx tsx scripts/export-init-sql.ts [输出路径]
* 默认输出: <repo>/docker/init.sql
*
* 说明:
* - 应用启动时本就会自动执行同样的初始化(initMySqlSchema),该文件是可选产物,
* - 应用启动时本就会自动执行同样的初始化(initMySqlSchema: 建表 + 种子),该文件是可选产物,
* 用于 DBA 审核、预建表或挂到 MySQL 容器的 /docker-entrypoint-initdb.d/
* - 列补齐(backfill)语句在代码里是幂等的(SHOW COLUMNS 检查后才 ALTER),
* MySQL 不支持 ADD COLUMN IF NOT EXISTS,且新建表已包含全部列,故仅以注释形式导出
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import {
mysqlSchemaStatements,
mysqlColumnBackfillStatements,
mysqlDropTableStatements,
mysqlDataCleanupStatements,
mysqlDataSeedStatements,
} from '../src/db/mysql-schema.js'
@@ -58,31 +53,12 @@ ${mysqlSchemaStatements.map(s => `${s};`).join('\n\n')}
`)
sections.push(`-- ----------------------------------------------------------------------------
-- 2. 废弃表清理
-- ----------------------------------------------------------------------------
${mysqlDropTableStatements.map(s => `${s};`).join('\n')}
`)
sections.push(`-- ----------------------------------------------------------------------------
-- 3. 历史数据迁移清理(对全新数据库为 no-op,与应用启动行为保持一致)
-- ----------------------------------------------------------------------------
${mysqlDataCleanupStatements.map(s => `${inlineParams(s.sql, s.params)};`).join('\n')}
`)
sections.push(`-- ----------------------------------------------------------------------------
-- 4. 种子数据: 风格预设(幂等,只补缺失行)
-- 2. 种子数据: 风格预设(幂等,只补缺失行)
-- ----------------------------------------------------------------------------
${mysqlDataSeedStatements.map(s => `${inlineParams(s.sql, s.params)};`).join('\n')}
`)
sections.push(`-- ----------------------------------------------------------------------------
-- 附: 列补齐语句(仅用于老库升级;新建表已包含全部列,无需执行)
-- 代码中通过 SHOW COLUMNS 检查后按需执行,MySQL 不支持 ADD COLUMN IF NOT EXISTS
-- ----------------------------------------------------------------------------
${mysqlColumnBackfillStatements.map(s => `-- ${s.sql};`).join('\n')}
`)
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, sections.join('\n'), 'utf8')
console.log(`✅ 已导出: ${outPath}`)
console.log(` 建表 ${mysqlSchemaStatements.length} 条, 清理 ${mysqlDataCleanupStatements.length} 条, 种子 ${mysqlDataSeedStatements.length}`)
console.log(` 建表 ${mysqlSchemaStatements.length} 条, 种子 ${mysqlDataSeedStatements.length}`)
+5 -7
View File
@@ -87,22 +87,20 @@ export const DEFAULT_PROMPTS: Record<string, { name: string; instructions: strin
- prop_ids:当前段落出现的关键道具 ID 列表(道具在画面中被看到、使用或特写时绑定),可以为空;必须从 props 中选择
- scene_id:若可匹配到 scenes 中已有场景,必须填写正确 scene_id;无匹配时置空
- duration:段落总时长 8-15 秒
- action:按【镜头1】【镜头2】…逐子镜头写角色动作与表演(谁+怎么做+肢体细节+表情)
- description:按【镜头N】逐子镜头描述观众实际看到的画面内容
- dialogue:该段落实际发生的对白或旁白;旁白可写为“旁白:内容”
- description:画面描述,按【镜头1】【镜头2】…逐子镜头描述观众实际看到和听到的内容——画面(谁+具体动作+肢体细节+表情)写在前;该子镜头有台词时以「角色名说:「台词」」写在对应【镜头N】内,旁白写「旁白:内容」
- atmosphere:氛围、光线、色调、环境感受
时长规则(硬约束):
- 总量锚定:目标总时长 = 剧本字数 ÷ 500字/分钟,段落数 ≈ 目标总时长 ÷ 12秒,允许 ±20% 浮动
- 节奏分层:过渡段(赶路/空镜/转场)8-10 秒;叙事段 10-15 秒;爆点段(特写/规则揭示/情感爆发/反转)12-15 秒且子镜头节奏放慢
- 对白下限:段落时长 ≥ 段内对白总字数 ÷ 4.5字/秒 + 2秒表演余量,装不下的对白拆到下一个段落
- 台词下限:段落时长 ≥ 段内台词与旁白总字数(写在 description 中的部分)÷ 4.5字/秒 + 2秒表演余量,装不下的台词拆到下一个段落
额外要求:
- 优先复用 read_storyboard_context 返回的 scene_id,不要凭空创造新场景
- 段落角色绑定必须来自 read_storyboard_context 返回的角色列表;无角色的空镜段落可传空数组
- 段落道具绑定必须来自 read_storyboard_context 返回的道具列表;道具被使用、特写、交接或在画面中明显可见时绑定,与剧情无关的背景物品不要绑定;没有道具出现可传空数组
- 段落描述必须能支撑后续视频生成和导出流程
- 若一个段落没有对白,可将 dialogue 置空,但 description / action / atmosphere 仍必须完整
- 若一个段落没有台词,description 中不写台词即可,但画面描述与 atmosphere 仍必须完整
- 如果已有 existing_storyboards,仅在用户明确要求增量修改时参考;默认按当前剧本重新完整生成并保存整集分镜。`,
},
prompt_generator: {
@@ -125,8 +123,8 @@ export const DEFAULT_PROMPTS: Record<string, { name: string; instructions: strin
用户请求会告知要为哪个分镜生成视频提示词(附带分镜 ID)。
工作流程:
1. 调用 read_storyboard_context 读取该分镜的 action、description、dialogue、atmosphere、duration 及绑定的场景/角色
2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);内容覆盖动作、画面描述、对白/旁白、氛围。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 action 的【镜头N】结构
1. 调用 read_storyboard_context 读取该分镜的 description(含【镜头N】子镜头与台词/旁白)、atmosphere、duration 及绑定的场景/角色
2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),台词/旁白从对应【镜头N】内的「角色名说:「…」」「旁白:…」提取,不要创作 description 之外的新台词;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);氛围光线取自 atmosphere。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 description 的【镜头N】结构
3. 生成时会自动把 @名字 替换为对应参考图片标记(如 @志远 → @图片1志远),因此名字必须精确匹配场景/角色列表,不要缩写或加额外符号
4. 调用 update_storyboard 仅更新该分镜的 video_prompt 字段,不要改动其他字段,不要重新拆分整集
@@ -192,9 +192,7 @@ const readStoryboardContext = createTool({
prop_ids: sbPropLinks.map(link => link.propId),
shot_type: sb.shotType || '',
duration: sb.duration || 0,
action: sb.action || '',
description: sb.description || '',
dialogue: sb.dialogue || '',
atmosphere: sb.atmosphere || '',
video_prompt: sb.videoPrompt || '',
}
@@ -238,8 +236,6 @@ const saveStoryboards = createTool({
movement: z.string().optional(),
location: z.string().optional(),
time: z.string().optional(),
action: z.string().optional(),
dialogue: z.string().optional(),
description: z.string().optional(),
result: z.string().optional(),
atmosphere: z.string().optional(),
@@ -284,7 +280,6 @@ const saveStoryboards = createTool({
title: sb.title, shotType: sb.shot_type,
angle: sb.angle, movement: sb.movement,
location: sb.location, time: sb.time,
action: sb.action, dialogue: sb.dialogue,
description: sb.description, result: sb.result,
atmosphere: sb.atmosphere, imagePrompt: sb.image_prompt,
videoPrompt: sb.video_prompt, bgmPrompt: sb.bgm_prompt,
@@ -321,7 +316,6 @@ const updateStoryboard = createTool({
movement: z.string().optional(),
location: z.string().optional(),
time: z.string().optional(),
action: z.string().optional(),
result: z.string().optional(),
atmosphere: z.string().optional(),
image_prompt: z.string().optional(),
@@ -329,7 +323,6 @@ const updateStoryboard = createTool({
bgm_prompt: z.string().optional(),
sound_effect: z.string().optional(),
description: z.string().optional(),
dialogue: z.string().optional(),
scene_id: z.number().nullable().optional(),
character_ids: z.array(z.number()).optional(),
prop_ids: z.array(z.number()).optional(),
@@ -374,7 +367,6 @@ const updateStoryboard = createTool({
if ('movement' in fields) updates.movement = fields.movement
if ('location' in fields) updates.location = fields.location
if ('time' in fields) updates.time = fields.time
if ('action' in fields) updates.action = fields.action
if ('result' in fields) updates.result = fields.result
if ('atmosphere' in fields) updates.atmosphere = fields.atmosphere
if ('image_prompt' in fields) updates.imagePrompt = fields.image_prompt
@@ -382,7 +374,6 @@ const updateStoryboard = createTool({
if ('bgm_prompt' in fields) updates.bgmPrompt = fields.bgm_prompt
if ('sound_effect' in fields) updates.soundEffect = fields.sound_effect
if ('description' in fields) updates.description = fields.description
if ('dialogue' in fields) updates.dialogue = fields.dialogue
if ('scene_id' in fields) updates.sceneId = fields.scene_id
if ('duration' in fields) updates.duration = fields.duration
await db.update(schema.storyboards).set(updates).where(eq(schema.storyboards.id, storyboard_id))
+6 -114
View File
@@ -6,7 +6,7 @@ export const mysqlSchemaStatements = [
title TEXT NOT NULL,
description TEXT,
genre TEXT,
style VARCHAR(64) DEFAULT 'realistic',
style VARCHAR(64) DEFAULT '3d',
aspect_ratio VARCHAR(16) DEFAULT '16:9',
total_episodes INT DEFAULT 1,
total_duration INT DEFAULT 0,
@@ -88,14 +88,12 @@ export const mysqlSchemaStatements = [
shot_type TEXT,
angle TEXT,
movement TEXT,
action TEXT,
result TEXT,
atmosphere TEXT,
image_prompt TEXT,
video_prompt TEXT,
bgm_prompt TEXT,
sound_effect TEXT,
dialogue TEXT,
description TEXT,
duration INT DEFAULT 0,
composed_image TEXT,
@@ -285,106 +283,15 @@ export const mysqlSchemaStatements = [
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
]
export const mysqlColumnBackfillStatements = [
{ table: 'dramas', column: 'aspect_ratio', sql: "ALTER TABLE `dramas` ADD COLUMN `aspect_ratio` VARCHAR(16) DEFAULT '16:9'" },
{ table: 'episodes', column: 'resolution', sql: "ALTER TABLE `episodes` ADD COLUMN `resolution` VARCHAR(16) DEFAULT '720p'" },
{ table: 'characters', column: 'styling', sql: 'ALTER TABLE `characters` ADD COLUMN `styling` TEXT' },
{ table: 'characters', column: 'final_prompt', sql: 'ALTER TABLE `characters` ADD COLUMN `final_prompt` TEXT' },
{ table: 'characters', column: 'personality', sql: 'ALTER TABLE `characters` ADD COLUMN `personality` TEXT' },
{ table: 'props', column: 'final_prompt', sql: 'ALTER TABLE `props` ADD COLUMN `final_prompt` TEXT' },
{ table: 'scenes', column: 'lighting', sql: 'ALTER TABLE `scenes` ADD COLUMN `lighting` TEXT' },
{ table: 'scenes', column: 'final_prompt', sql: 'ALTER TABLE `scenes` ADD COLUMN `final_prompt` TEXT' },
]
// 废弃表清理:image_generations / video_generations 已并入 sys_task(不迁移历史);
// ai_voices 为 TTS 功能移除后的孤儿表
export const mysqlDropTableStatements = [
'DROP TABLE IF EXISTS `image_generations`',
'DROP TABLE IF EXISTS `video_generations`',
'DROP TABLE IF EXISTS `ai_voices`',
]
export const mysqlDataCleanupStatements = [
// 悬空配置引用清理:集锁定的 image/video 配置已被删除时置空,让生成回退到当前启用配置
{
sql: 'UPDATE `episodes` e LEFT JOIN `ai_service_configs` c ON e.`image_config_id` = c.`id` SET e.`image_config_id` = NULL WHERE e.`image_config_id` IS NOT NULL AND c.`id` IS NULL',
params: [],
},
{
sql: 'UPDATE `episodes` e LEFT JOIN `ai_service_configs` c ON e.`video_config_id` = c.`id` SET e.`video_config_id` = NULL WHERE e.`video_config_id` IS NOT NULL AND c.`id` IS NULL',
params: [],
},
// 厂商收敛:彻底移除 minimax(含历史遗留的 audio 配置,按 provider 全量清理)
{
sql: 'UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`image_config_id` = c.`id` SET e.`image_config_id` = NULL WHERE c.`provider` = ?',
params: ['minimax'],
},
{
sql: 'UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`video_config_id` = c.`id` SET e.`video_config_id` = NULL WHERE c.`provider` = ?',
params: ['minimax'],
},
{
sql: 'DELETE FROM `ai_service_configs` WHERE `provider` = ?',
params: ['minimax'],
},
{
sql: 'DELETE FROM `ai_service_providers` WHERE `provider` = ?',
params: ['minimax'],
},
// 厂商收敛:仅保留 openai / gemini / volcengine,移除 deepseek、ali、vidu
{
sql: 'UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`image_config_id` = c.`id` SET e.`image_config_id` = NULL WHERE c.`provider` IN (?, ?)',
params: ['ali', 'vidu'],
},
{
sql: 'UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`video_config_id` = c.`id` SET e.`video_config_id` = NULL WHERE c.`provider` IN (?, ?)',
params: ['ali', 'vidu'],
},
{
sql: 'DELETE FROM `ai_service_configs` WHERE `provider` IN (?, ?, ?)',
params: ['deepseek', 'ali', 'vidu'],
},
{
sql: 'DELETE FROM `ai_service_providers` WHERE `provider` IN (?, ?, ?)',
params: ['deepseek', 'ali', 'vidu'],
},
// 旧硬编码风格值归并:cinematic 并入写实电影
{
sql: 'UPDATE `dramas` SET `style` = ? WHERE `style` = ?',
params: ['realistic', 'cinematic'],
},
// 视频提示词格式收敛:旧的 <n> 时间段分隔符统一替换为换行
{
sql: "UPDATE `storyboards` SET `video_prompt` = REPLACE(`video_prompt`, '<n>', CHAR(10)) WHERE `video_prompt` LIKE '%<n>%'",
params: [],
},
// agent_configs 表已废弃:Agent prompt 改为 workspace/prompts/*.md 文件维护,清理老库
{
sql: 'DROP TABLE IF EXISTS `agent_configs`',
params: [],
},
// 视频模型收敛:Seedance 2.0 三个官方型号,默认 doubao-seedance-2-0-fast-260128(数组首位即生效模型)
{
sql: 'UPDATE `ai_service_configs` SET `model` = ? WHERE `service_type` = ? AND `provider` = ? AND `model` NOT LIKE ?',
params: [
'["doubao-seedance-2-0-fast-260128","doubao-seedance-2-0-260128","doubao-seedance-2-0-mini-260615"]',
'video',
'volcengine',
'%doubao-seedance-2-0%',
],
},
]
/**
* 风格预设种子数据 — value 存入 dramas.styleprompt 注入生图提示词
*/
export const stylePresetSeeds = [
{ name: '3D', value: '3d', sortOrder: 1, prompt: '3D render, Pixar-style animation, soft studio lighting, high detail, subsurface scattering', description: '三维渲染卡通质感,适合轻松明快的短剧' },
{ name: '动漫', value: 'anime', sortOrder: 2, prompt: 'anime style, cel shading, vibrant colors, clean line art, Japanese animation', description: '日式赛璐璐动画风格' },
{ name: '写实电影', value: 'realistic', sortOrder: 3, prompt: 'photorealistic, cinematic film still, 35mm, natural lighting, shallow depth of field, high detail', description: '电影级真人写实质感' },
{ name: '吉卜力', value: 'ghibli', sortOrder: 4, prompt: 'Studio Ghibli style, hand-painted, soft watercolor background, warm nostalgic tone', description: '吉卜力手绘治愈风' },
{ name: '水彩', value: 'watercolor', sortOrder: 5, prompt: 'watercolor illustration, soft washes, visible paper texture, delicate brush strokes', description: '水彩插画质感' },
{ name: '漫画', value: 'comic', sortOrder: 6, prompt: 'comic book style, bold outlines, halftone shading, dynamic colors, flat graphic look', description: '美式漫画粗线条风格' },
{ name: '3D 漫剧', value: '3d', sortOrder: 1, prompt: '3D CG animation style, game-engine quality render, semi-realistic stylized characters, refined facial features, detailed materials and textures, cinematic lighting, high detail', description: '游戏引擎级 3D 渲染,半写实角色,当前短剧主流的 3D 漫剧质感' },
{ name: '日漫赛璐璐', value: 'anime', sortOrder: 2, prompt: 'Japanese anime style, cel shading, clean crisp line art, vivid saturated colors, expressive character designs, detailed painted backgrounds', description: '日式赛璐璐动画风格' },
{ name: '吉卜力手绘', value: 'ghibli', sortOrder: 3, prompt: 'Studio Ghibli style, hand-drawn animation, soft watercolor painted backgrounds, warm nostalgic lighting, gentle natural palette, whimsical cozy atmosphere', description: '吉卜力手绘治愈风' },
{ name: '水彩绘本', value: 'watercolor', sortOrder: 4, prompt: 'watercolor illustration style, soft translucent washes, visible paper texture, delicate fluid brushwork, light airy atmosphere, hand-painted storybook feel', description: '水彩插画质感' },
{ name: '美式漫画', value: 'comic', sortOrder: 5, prompt: 'Western comic book style, bold black ink outlines, halftone dot shading, dynamic saturated colors, dramatic contrast lighting, flat graphic novel look', description: '美式漫画粗线条风格' },
]
// INSERT ... SELECT WHERE NOT EXISTS → 幂等:只补缺失行,不覆盖用户编辑,
@@ -394,25 +301,10 @@ export const mysqlDataSeedStatements = stylePresetSeeds.map((s) => ({
params: [s.name, s.value, s.prompt, s.description, s.sortOrder, new Date().toISOString(), new Date().toISOString(), s.value],
}))
export async function ensureMySqlColumn(pool: Pool, table: string, column: string, alterSql: string) {
const [rows] = await pool.query(`SHOW COLUMNS FROM \`${table}\` LIKE ?`, [column]) as any[]
if (Array.isArray(rows) && rows.length) return
await pool.query(alterSql)
}
export async function initMySqlSchema(pool: Pool) {
for (const statement of mysqlSchemaStatements) {
await pool.query(statement)
}
for (const statement of mysqlColumnBackfillStatements) {
await ensureMySqlColumn(pool, statement.table, statement.column, statement.sql)
}
for (const statement of mysqlDropTableStatements) {
await pool.query(statement)
}
for (const cleanup of mysqlDataCleanupStatements) {
await pool.query(cleanup.sql, cleanup.params)
}
for (const seed of mysqlDataSeedStatements) {
await pool.query(seed.sql, seed.params)
}
+1 -3
View File
@@ -8,7 +8,7 @@ export const dramas = mysqlTable('dramas', {
title: text('title').notNull(),
description: text('description'),
genre: text('genre'),
style: varchar('style', { length: 64 }).default('realistic'),
style: varchar('style', { length: 64 }).default('3d'),
aspectRatio: varchar('aspect_ratio', { length: 16 }).default('16:9'),
totalEpisodes: int('total_episodes').default(1),
totalDuration: int('total_duration').default(0),
@@ -114,14 +114,12 @@ export const storyboards = mysqlTable('storyboards', {
shotType: text('shot_type'),
angle: text('angle'),
movement: text('movement'),
action: text('action'),
result: text('result'),
atmosphere: text('atmosphere'),
imagePrompt: text('image_prompt'),
videoPrompt: text('video_prompt'),
bgmPrompt: text('bgm_prompt'),
soundEffect: text('sound_effect'),
dialogue: text('dialogue'),
description: text('description'),
duration: int('duration').default(0),
composedImage: text('composed_image'),
+2 -4
View File
@@ -93,8 +93,6 @@ app.post('/', async (c) => {
storyboardNumber: body.storyboard_number || 1,
title: body.title,
description: body.description,
action: body.action,
dialogue: body.dialogue,
sceneId: body.scene_id,
duration: body.duration || 10,
createdAt: ts,
@@ -131,8 +129,8 @@ app.put('/:id', async (c) => {
const fieldMap: Record<string, string> = {
title: 'title', description: 'description', shot_type: 'shotType',
angle: 'angle', movement: 'movement', action: 'action',
dialogue: 'dialogue', duration: 'duration', video_prompt: 'videoPrompt',
angle: 'angle', movement: 'movement', duration: 'duration',
video_prompt: 'videoPrompt',
image_prompt: 'imagePrompt', scene_id: 'sceneId', location: 'location',
time: 'time', atmosphere: 'atmosphere', result: 'result',
bgm_prompt: 'bgmPrompt', sound_effect: 'soundEffect',
+1 -1
View File
@@ -73,7 +73,7 @@ export async function startVideoPromptBatch(
await agent.generate([{
role: 'user',
content: `请为分镜 #${sb.storyboardNumber}(ID:${sb.id})生成视频提示词(video_prompt)。视频模型:${videoLabel},请根据该模型的特性和时长限制生成。
请先调用 read_storyboard_context 获取该分镜的画面描述、动作、氛围、对白/旁白及时长,据此生成 video_prompt(按 3 秒分段换行、用 @角色名/@场景名/@道具名 引用参考素材;段落内允许多镜头切镜,段与段可以是不同景别/角度/对象,但不跨场景,切镜点对齐分镜 action 的【镜头N】结构),然后调用 update_storyboard 保存到分镜 ID:${sb.id}。只更新 video_prompt 字段,不要改动其他字段,不要重新拆分整集。`,
请先调用 read_storyboard_context 获取该分镜的画面描述(含【镜头N】子镜头与台词/旁白)、氛围及时长,据此生成 video_prompt(按 3 秒分段换行、用 @角色名/@场景名/@道具名 引用参考素材;段落内允许多镜头切镜,段与段可以是不同景别/角度/对象,但不跨场景,切镜点对齐分镜 description 的【镜头N】结构),然后调用 update_storyboard 保存到分镜 ID:${sb.id}。只更新 video_prompt 字段,不要改动其他字段,不要重新拆分整集。`,
}], { maxSteps: 8, requestContext })
// 以实际落库为准判定成败
const [fresh] = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, sb.id))
@@ -5,7 +5,7 @@ description: 视频提示词规范 — 根据分镜段落内容生成按时间
# 视频提示词(分镜段落 → video_prompt
根据单个分镜段落的 action / description / dialogue / atmosphere / duration,生成驱动 AI 视频生成的 `video_prompt`。**一个分镜段落 = 一段 8-15 秒的视频,内部允许切镜**:段与段之间可以是不同镜头(换景别/角度/对象),用硬切衔接;但**全程不跨场景**、不闪回。
根据单个分镜段落的 description(含【镜头N】子镜头结构与台词/旁白)/ atmosphere / duration,生成驱动 AI 视频生成的 `video_prompt`。**一个分镜段落 = 一段 8-15 秒的视频,内部允许切镜**:段与段之间可以是不同镜头(换景别/角度/对象),用硬切衔接;但**全程不跨场景**、不闪回。
## 格式
@@ -17,6 +17,14 @@ description: 视频提示词规范 — 根据分镜段落内容生成按时间
6-9秒:切回中景,@小红微笑着走向小明坐下,小明说:「你终于来了。」
```
## 与分镜描述的映射
`description` 是 video_prompt 的唯一内容来源(画面、动作、台词、旁白都在其中),转换规则:
- `description` 的每个 `【镜头N】` 映射为 **1-2 个连续的 3 秒段**,顺序一致、不遗漏、不合并、不新增子镜头
- 台词/旁白从对应 `【镜头N】` 内的「角色名说:「…」」「旁白:…」提取,分配到该子镜头映射的段;**不要创作 description 之外的新台词**
- 画面动作以 `description` 为准;`atmosphere` 只用于补各段的光线、色调与氛围描写
## 段内结构
每一段按此顺序组织内容(可省略无内容的项,但动作/画面必须有):
@@ -24,7 +32,7 @@ description: 视频提示词规范 — 根据分镜段落内容生成按时间
**时间范围 + 场景@引用 + 景别/运镜 + 角色@引用+主体动作·表情 + 对白/旁白 + 氛围光线**
- **第一段必须建立空间**:场景 + 机位 + 角色的位置与状态,让观众一眼知道在哪、看谁
- **切镜**:切镜后的段开头用"切到/切回"等衔接词,并重新交代景别与主体;切镜点应对齐分镜 `action` 中的 `【镜头N】` 结构
- **切镜**:切镜后的段开头用"切到/切回"等衔接词,并重新交代景别与主体;切镜点应对齐分镜 `description` 中的 `【镜头N】` 结构
- **景别/运镜**:每段一个镜头状态(近景/中景/全景/特写;固定/推/拉/摇/跟);单个子镜头内运镜连续,切镜后可更换运镜方式
- **动作**:每段一个主动作,动词具体可见(走、转身、抬头、攥紧、停顿)
- **情绪全部转为可见描写**:不要"他很伤心/气氛紧张"这类抽象词,写成"他低下头、手指攥紧杯沿、呼吸变重"
@@ -31,11 +31,11 @@ description: 分镜拆解专业规范 — 将剧本拆分为可承载多个子
| 叙事段 | 10-15 秒 | 常规剧情推进、对话 |
| 爆点段 | 12-15 秒 | 特写、规则揭示、情感爆发、反转;子镜头节奏放慢,单个子镜头可停留 4-6 秒 |
## 对白时长下限(硬规则)
## 台词时长下限(硬规则)
**段落时长 ≥ 段内对白总字数 ÷ 4.5字/秒 + 2秒表演余量**
**段落时长 ≥ 段内台词与旁白总字数(写在 description 中的部分)÷ 4.5字/秒 + 2秒表演余量**
装不下的对白必须拆到下一个段落,不允许把演不完的对白塞进一个段落。
装不下的台词必须拆到下一个段落,不允许把演不完的台词塞进一个段落。
## 镜头要素
@@ -45,15 +45,13 @@ description: 分镜拆解专业规范 — 将剧本拆分为可承载多个子
4. **景别**:段落内主导景别;多景别段落写组合,如"中景+特写"
5. **角度**:平视/仰视/俯视/侧面/背面
6. **运镜**:固定/推镜/拉镜/摇镜/跟镜/移镜(段落内不同子镜头可不同)
7. **动作** `action`:按 `【镜头1】…【镜头2】…` 逐子镜头组织,每个子镜头写清谁 + 具体怎么做 + 肢体细节 + 表情
8. **对话** `dialogue`段落的完整对话
9. **画面结果** `result`:段落结尾的即时后果 + 视觉细节
10. **氛围** `atmosphere`:光线 + 色调 + 声音 + 整体氛围
11. **时长** `duration`:段落总时长 8-15 秒,且满足对白时长下限
12. **画面描述** `description`:按 `【镜头N】` 逐子镜头描述观众实际看到的画面
13. **场景关联**若能匹配已有场景,必须填写 `scene_id`
14. **角色关联**:填写 `character_ids`,绑定当前段落涉及的 0 到多个角色
15. **道具关联**:填写 `prop_ids`,绑定当前段落出现的关键道具(0 到多个)
7. **画面描述** `description`:按 `【镜头1】…【镜头2】…` 逐子镜头描述观众实际看到和听到的内容——画面(谁 + 具体动作 + 肢体细节 + 表情)写在前;该子镜头有台词时以「角色名说:「台词」」写在对应 `【镜头N】` 内,旁白写「旁白:内容」
8. **画面结果** `result`:段落结尾的即时后果 + 视觉细节
9. **氛围** `atmosphere`:光线 + 色调 + 声音 + 整体氛围
10. **时长** `duration`:段落总时长 8-15 秒,且满足台词时长下限
11. **场景关联**:若能匹配已有场景,必须填写 `scene_id`
12. **角色关联**:填写 `character_ids`,绑定当前段落涉及的 0 到多个角色
13. **道具关联**填写 `prop_ids`,绑定当前段落出现的关键道具(0 到多个)
## 场景关联规则
@@ -79,8 +77,7 @@ description: 分镜拆解专业规范 — 将剧本拆分为可承载多个子
## 质量要求
- `description` 要适合人读,按子镜头详细描述观众实际看到的画面内容
- `description` 要适合人读,按子镜头详细描述观众实际看到和听到的内容;台词/旁白直接写在对应 `【镜头N】`
- `image_prompt` 要突出单帧构图、角色外观、环境和光线(对应段落第一个子镜头)
- `bgm_prompt``sound_effect` 用简洁短语即可,但不能空泛到只有“紧张”“悲伤”
- 若存在旁白,统一写入 `dialogue`,格式为 `旁白:内容`
- 如需调整,调用 `update_storyboard` 修改具体段落
+8 -49
View File
@@ -1,7 +1,7 @@
-- ============================================================================
-- Huobao Drama 初始化 SQL
-- 由 backend/scripts/export-init-sql.ts 从 backend/src/db/mysql-schema.ts 生成
-- 生成时间: 2026-08-08T14:35:02.119Z
-- 生成时间: 2026-08-08T15:00:54.313Z
--
-- 注意: 应用启动时会自动执行同等初始化(幂等),本文件不是部署必需,
-- 仅供 DBA 审核或在应用外预建表使用
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS dramas (
title TEXT NOT NULL,
description TEXT,
genre TEXT,
style VARCHAR(64) DEFAULT 'realistic',
style VARCHAR(64) DEFAULT '3d',
aspect_ratio VARCHAR(16) DEFAULT '16:9',
total_episodes INT DEFAULT 1,
total_duration INT DEFAULT 0,
@@ -99,14 +99,12 @@ CREATE TABLE IF NOT EXISTS storyboards (
shot_type TEXT,
angle TEXT,
movement TEXT,
action TEXT,
result TEXT,
atmosphere TEXT,
image_prompt TEXT,
video_prompt TEXT,
bgm_prompt TEXT,
sound_effect TEXT,
dialogue TEXT,
description TEXT,
duration INT DEFAULT 0,
composed_image TEXT,
@@ -296,49 +294,10 @@ CREATE TABLE IF NOT EXISTS assets (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ----------------------------------------------------------------------------
-- 2. 废弃表清理
-- 2. 种子数据: 风格预设(幂等,只补缺失行)
-- ----------------------------------------------------------------------------
DROP TABLE IF EXISTS `image_generations`;
DROP TABLE IF EXISTS `video_generations`;
DROP TABLE IF EXISTS `ai_voices`;
-- ----------------------------------------------------------------------------
-- 3. 历史数据迁移清理(对全新数据库为 no-op,与应用启动行为保持一致)
-- ----------------------------------------------------------------------------
UPDATE `episodes` e LEFT JOIN `ai_service_configs` c ON e.`image_config_id` = c.`id` SET e.`image_config_id` = NULL WHERE e.`image_config_id` IS NOT NULL AND c.`id` IS NULL;
UPDATE `episodes` e LEFT JOIN `ai_service_configs` c ON e.`video_config_id` = c.`id` SET e.`video_config_id` = NULL WHERE e.`video_config_id` IS NOT NULL AND c.`id` IS NULL;
UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`image_config_id` = c.`id` SET e.`image_config_id` = NULL WHERE c.`provider` = 'minimax';
UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`video_config_id` = c.`id` SET e.`video_config_id` = NULL WHERE c.`provider` = 'minimax';
DELETE FROM `ai_service_configs` WHERE `provider` = 'minimax';
DELETE FROM `ai_service_providers` WHERE `provider` = 'minimax';
UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`image_config_id` = c.`id` SET e.`image_config_id` = NULL WHERE c.`provider` IN ('ali', 'vidu');
UPDATE `episodes` e JOIN `ai_service_configs` c ON e.`video_config_id` = c.`id` SET e.`video_config_id` = NULL WHERE c.`provider` IN ('ali', 'vidu');
DELETE FROM `ai_service_configs` WHERE `provider` IN ('deepseek', 'ali', 'vidu');
DELETE FROM `ai_service_providers` WHERE `provider` IN ('deepseek', 'ali', 'vidu');
UPDATE `dramas` SET `style` = 'realistic' WHERE `style` = 'cinematic';
UPDATE `storyboards` SET `video_prompt` = REPLACE(`video_prompt`, '<n>', CHAR(10)) WHERE `video_prompt` LIKE '%<n>%';
DROP TABLE IF EXISTS `agent_configs`;
UPDATE `ai_service_configs` SET `model` = '["doubao-seedance-2-0-fast-260128","doubao-seedance-2-0-260128","doubao-seedance-2-0-mini-260615"]' WHERE `service_type` = 'video' AND `provider` = 'volcengine' AND `model` NOT LIKE '%doubao-seedance-2-0%';
-- ----------------------------------------------------------------------------
-- 4. 种子数据: 风格预设(幂等,只补缺失行)
-- ----------------------------------------------------------------------------
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '3D', '3d', '3D render, Pixar-style animation, soft studio lighting, high detail, subsurface scattering', '三维渲染卡通质感,适合轻松明快的短剧', 1, 1, '2026-08-08T14:35:02.118Z', '2026-08-08T14:35:02.119Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = '3d');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '动漫', 'anime', 'anime style, cel shading, vibrant colors, clean line art, Japanese animation', '日式赛璐璐动画风格', 2, 1, '2026-08-08T14:35:02.119Z', '2026-08-08T14:35:02.119Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'anime');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '写实电影', 'realistic', 'photorealistic, cinematic film still, 35mm, natural lighting, shallow depth of field, high detail', '电影级真人写实质感', 3, 1, '2026-08-08T14:35:02.119Z', '2026-08-08T14:35:02.119Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'realistic');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '吉卜力', 'ghibli', 'Studio Ghibli style, hand-painted, soft watercolor background, warm nostalgic tone', '吉卜力手绘治愈风', 4, 1, '2026-08-08T14:35:02.119Z', '2026-08-08T14:35:02.119Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'ghibli');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '水彩', 'watercolor', 'watercolor illustration, soft washes, visible paper texture, delicate brush strokes', '水彩插画质感', 5, 1, '2026-08-08T14:35:02.119Z', '2026-08-08T14:35:02.119Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'watercolor');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '漫画', 'comic', 'comic book style, bold outlines, halftone shading, dynamic colors, flat graphic look', '美式漫画粗线条风格', 6, 1, '2026-08-08T14:35:02.119Z', '2026-08-08T14:35:02.119Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'comic');
-- ----------------------------------------------------------------------------
-- 附: 列补齐语句(仅用于老库升级;新建表已包含全部列,无需执行)
-- 代码中通过 SHOW COLUMNS 检查后按需执行,MySQL 不支持 ADD COLUMN IF NOT EXISTS
-- ----------------------------------------------------------------------------
-- ALTER TABLE `dramas` ADD COLUMN `aspect_ratio` VARCHAR(16) DEFAULT '16:9';
-- ALTER TABLE `episodes` ADD COLUMN `resolution` VARCHAR(16) DEFAULT '720p';
-- ALTER TABLE `characters` ADD COLUMN `styling` TEXT;
-- ALTER TABLE `characters` ADD COLUMN `final_prompt` TEXT;
-- ALTER TABLE `characters` ADD COLUMN `personality` TEXT;
-- ALTER TABLE `props` ADD COLUMN `final_prompt` TEXT;
-- ALTER TABLE `scenes` ADD COLUMN `lighting` TEXT;
-- ALTER TABLE `scenes` ADD COLUMN `final_prompt` TEXT;
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '3D 漫剧', '3d', '3D CG animation style, game-engine quality render, semi-realistic stylized characters, refined facial features, detailed materials and textures, cinematic lighting, high detail', '游戏引擎级 3D 渲染,半写实角色,当前短剧主流的 3D 漫剧质感', 1, 1, '2026-08-08T15:00:54.312Z', '2026-08-08T15:00:54.313Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = '3d');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '日漫赛璐璐', 'anime', 'Japanese anime style, cel shading, clean crisp line art, vivid saturated colors, expressive character designs, detailed painted backgrounds', '日式赛璐璐动画风格', 2, 1, '2026-08-08T15:00:54.313Z', '2026-08-08T15:00:54.313Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'anime');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '吉卜力手绘', 'ghibli', 'Studio Ghibli style, hand-drawn animation, soft watercolor painted backgrounds, warm nostalgic lighting, gentle natural palette, whimsical cozy atmosphere', '吉卜力手绘治愈风', 3, 1, '2026-08-08T15:00:54.313Z', '2026-08-08T15:00:54.313Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'ghibli');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '水彩绘本', 'watercolor', 'watercolor illustration style, soft translucent washes, visible paper texture, delicate fluid brushwork, light airy atmosphere, hand-painted storybook feel', '水彩插画质感', 4, 1, '2026-08-08T15:00:54.313Z', '2026-08-08T15:00:54.313Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'watercolor');
INSERT INTO `style_presets` (`name`, `value`, `prompt`, `description`, `sort_order`, `is_active`, `created_at`, `updated_at`) SELECT '美式漫画', 'comic', 'Western comic book style, bold black ink outlines, halftone dot shading, dynamic saturated colors, dramatic contrast lighting, flat graphic novel look', '美式漫画粗线条风格', 5, 1, '2026-08-08T15:00:54.313Z', '2026-08-08T15:00:54.313Z' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `style_presets` WHERE `value` = 'comic');
@@ -510,7 +510,6 @@
<span v-else class="shot-avatars-empty">0 角色</span>
</div>
<div class="shot-flags">
<span class="shot-flag flag-dialogue" :class="{ on: !!sb.dialogue }" :title="sb.dialogue ? '有对白' : '无对白'"><i class="dot"></i></span>
<span class="shot-flag flag-video" :class="{ on: hasVid(sb) }" :title="hasVid(sb) ? '已生成视频' : '未生成视频'"><i class="dot"></i></span>
</div>
</div>
@@ -569,27 +568,13 @@
<span class="detail-section-title">分镜描述</span>
</div>
<label class="field">
<span class="field-label">画面描述</span>
<textarea :value="selectedSb.description || ''" class="textarea" rows="4" @blur="updateField(selectedSb, 'description', $event.target.value)" placeholder="分镜画面描述" />
<span class="field-label">画面描述 <span class="dim">(镜头1镜头2逐子镜头描述台词写角色名说台词旁白写旁白内容)</span></span>
<textarea :value="selectedSb.description || ''" class="textarea" rows="8" @blur="updateField(selectedSb, 'description', $event.target.value)" placeholder="分镜画面描述" />
</label>
<div class="field-grid field-grid-2">
<label class="field">
<span class="field-label">动作</span>
<textarea :value="selectedSb.action || ''" class="textarea" rows="3" @blur="updateField(selectedSb, 'action', $event.target.value)" placeholder="角色动作与表演" />
</label>
<label class="field">
<span class="field-label">氛围</span>
<textarea :value="selectedSb.atmosphere || ''" class="textarea" rows="3" @blur="updateField(selectedSb, 'atmosphere', $event.target.value)" placeholder="光线、色调、空气感、环境氛围" />
</label>
</div>
<label v-if="dialogueOpen || selectedSb.dialogue" class="field">
<span class="field-label">对白 / 旁白</span>
<textarea :value="selectedSb.dialogue || ''" class="textarea" rows="2" @blur="updateField(selectedSb, 'dialogue', $event.target.value)" placeholder="角色名:台词内容 或 旁白:内容" />
<label class="field">
<span class="field-label">氛围</span>
<textarea :value="selectedSb.atmosphere || ''" class="textarea" rows="3" @blur="updateField(selectedSb, 'atmosphere', $event.target.value)" placeholder="光线、色调、空气感、环境氛围" />
</label>
<button v-else type="button" class="dialogue-add" @click="dialogueOpen = true">
<MessageSquarePlus :size="12" />
添加对白 / 旁白
</button>
</div>
<div class="detail-section">
@@ -605,7 +590,7 @@
{{ (selectedSb.video_prompt || selectedSb.videoPrompt) ? '重新生成' : 'AI 生成' }}
</button>
</div>
<div class="detail-section-copy">根据当前分镜的画面描述动作氛围以及对白 / 旁白生成</div>
<div class="detail-section-copy">根据当前分镜的画面描述含台词/旁白与氛围生成</div>
<MentionTextarea
:model-value="selectedSb.video_prompt || selectedSb.videoPrompt || ''"
:options="mentionOptions"
@@ -1535,7 +1520,7 @@
import { toast } from 'vue-sonner'
import {
Users, Video, FileText, FolderKanban, Clapperboard, Download, Loader2,
MapPin, Play, MessageSquarePlus, Plus, X, ListTodo,
MapPin, Play, Plus, X, ListTodo,
} from 'lucide-vue-next'
import { dramaAPI, episodeAPI, storyboardAPI, characterAPI, sceneAPI, propAPI, taskAPI, mergeAPI, aiConfigAPI, uploadAPI } from '~/composables/useApi'
import { useAgent } from '~/composables/useAgent'
@@ -2507,7 +2492,6 @@ const sceneOptions = computed(() => [
...scenes.value.map(s => ({ label: `${s.location} · ${s.time || '未设时间'}`, value: s.id })),
])
const dialogueOpen = ref(false)
function sceneShotCount(sceneId) {
return sbs.value.filter(sb => String(sb?.scene_id || sb?.sceneId || '') === String(sceneId)).length
@@ -2743,7 +2727,7 @@ function genVideoPrompt(sb) {
该分镜信息:时长 ${sb.duration || 10}s;场景:${getSceneName(sb) || '未绑定'};角色:${charNames};道具:${propNames}
请先调用 read_storyboard_context 获取该分镜的画面描述、动作、氛围、对白/旁白,据此生成 video_prompt(按 3 秒分段换行、用 @角色名/@场景名/@道具名 引用参考素材),然后调用 update_storyboard 保存到分镜 ID:${sb.id}。只更新 video_prompt 字段,不要改动其他字段,不要重新拆分整集。`, dramaId, epId.value, refresh, chatModelOverride(), chatConfigId())
请先调用 read_storyboard_context 获取该分镜的画面描述(含【镜头N】子镜头与台词/旁白)、氛围及时长,据此生成 video_prompt(按 3 秒分段换行、用 @角色名/@场景名/@道具名 引用参考素材;段落内允许多镜头切镜,但不跨场景,切镜点对齐 description 的【镜头N】结构),然后调用 update_storyboard 保存到分镜 ID:${sb.id}。只更新 video_prompt 字段,不要改动其他字段,不要重新拆分整集。`, dramaId, epId.value, refresh, chatModelOverride(), chatConfigId())
}
function sleep(ms) {
@@ -3147,7 +3131,6 @@ watch(selectedSb, (sb) => {
videoRefAudioUrls.value = []
videoRefImageUrls.value = []
videoDuration.value = Number(sb?.duration || 10)
dialogueOpen.value = false
})
function pickFile(accept, cb) {
@@ -4128,27 +4111,6 @@ onMounted(async () => { await refresh(); loadConfigs(); syncExtractStatus() })
}
.storyboard-ref-thumb:disabled { cursor: default; }
.storyboard-ref-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* 对白折叠入口 */
.dialogue-add {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 9px 12px;
border: 1px dashed var(--surface-outline);
border-radius: var(--radius);
background: transparent;
color: var(--text-3);
font-size: 12px;
cursor: pointer;
transition: color 0.16s var(--ease-out), border-color 0.16s var(--ease-out), background 0.16s var(--ease-out);
}
.dialogue-add:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-bg);
}
.storyboard-ref-main { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.storyboard-ref-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
.storyboard-ref-name {
@@ -4268,14 +4230,7 @@ onMounted(async () => { await refresh(); loadConfigs(); syncExtractStatus() })
}
.shot-flag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--bg-3); flex-shrink: 0; }
.shot-flag.on { color: var(--text-2); }
.shot-flag.flag-dialogue.on .dot { background: var(--warning); }
.shot-flag.flag-video.on .dot { background: var(--info); }
.shot-dialogue {
font-size: 10px; color: var(--text-3); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding-left: 2px; border-left: 2px solid var(--border);
padding-left: 6px;
}
.detail-panel { flex: 1; display: flex; flex-direction: column; overflow-y: auto; min-width: 0; }
.detail-head { display: flex; align-items: center; gap: 8px; padding: 9px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; }