This commit is contained in:
2026-06-30 15:02:20 +08:00
commit 3948b5a48a
306 changed files with 77275 additions and 0 deletions
@@ -0,0 +1,341 @@
# 分镜图相机角度视角 + 四宫格序列图 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 修复分镜图生成时背景角度固定的问题(让相机 angle 字段驱动背景透视),并新增四宫格序列图生成模式(通过特殊提示词一次生成含4个画面的分镜参考图)。
**Architecture:**
- 功能一:在 `framePromptService.js``buildStoryboardContext()` 中新增 `expandAngleDescription()` 辅助函数,将原始 angle 值扩展为含透视含义的详细描述,注入 AI 上下文,让 AI 生成帧提示词时考虑相机视角。
- 功能二:利用 `image_generations.frame_type` 已有字段存储 `'quad_grid'` 标志,在 `imageService.processImageGeneration()` 中检测并走四宫格分支:先串行生成 4 个帧提示词,再拼成四宫格格式提示词,最后生成一张图。前端新增全局开关控制是否启用四宫格模式。
**Tech Stack:** Node.js (Express), better-sqlite3, Vue 3, Element Plus
---
## Task 1expandAngleDescription — 角度扩展注入
**Files:**
- Modify: `backend-node/src/services/framePromptService.js`(在 `buildStoryboardContext` 前添加辅助函数并调用)
**Step 1: 在 `buildStoryboardContext` 之前添加 `expandAngleDescription` 函数**
在文件第 45 行(`function buildStoryboardContext` 前)插入:
```javascript
function expandAngleDescription(angle) {
if (!angle) return null;
const a = angle.toString().trim().toLowerCase();
// 中文 angle 值(来自分镜生成)
if (a === '平视' || a === 'eye-level' || a === 'eye level') {
return '平视视角(eye-level shot):水平视角,正常透视,背景与人物同高度展开';
}
if (a === '仰视' || a === 'low-angle' || a === 'low angle') {
return '仰视视角(low-angle shot):从下往上仰拍,背景呈现天空/天花板/建筑顶部的仰视透视,地平线偏低';
}
if (a === '俯视' || a === 'high-angle' || a === 'high angle') {
return '俯视视角(high-angle shot):从上往下俯拍,背景呈现地面/场景的鸟瞰俯视透视,地平线偏高';
}
if (a === '侧面' || a === 'side') {
return '侧面视角(side angle shot):从侧面拍摄,背景呈侧向延伸的构图';
}
if (a === '背面' || a === 'back') {
return '背面视角(rear/back shot):从角色背后拍摄,角色背对镜头,背景场景在角色前方延伸展开';
}
// 未匹配到预设值时原样保留
return `相机角度:${angle}`;
}
```
**Step 2: 在 `buildStoryboardContext` 中调用 `expandAngleDescription`**
找到当前的 angle 处理部分(约第 73-75 行):
```javascript
if (sb.angle) {
parts.push(promptI18n.formatUserPrompt(cfg, 'angle_label', sb.angle));
}
```
替换为:
```javascript
if (sb.angle) {
const angleDesc = expandAngleDescription(sb.angle);
if (angleDesc) {
parts.push(promptI18n.formatUserPrompt(cfg, 'angle_label', angleDesc));
} else {
parts.push(promptI18n.formatUserPrompt(cfg, 'angle_label', sb.angle));
}
}
```
**Step 3: 手动验证(无自动化测试框架,目视检查)**
启动后端,生成一个 angle='仰视' 的分镜的帧提示词,检查日志中传给 AI 的 userPrompt 是否包含 "低角度仰拍" 等扩展描述。
**Step 4: Commit**
```bash
git add backend-node/src/services/framePromptService.js
git commit -m "feat: expand camera angle to perspective description in frame prompt context"
```
---
## Task 2processImageGeneration 四宫格分支
**Files:**
- Modify: `backend-node/src/services/imageService.js`(在 `processImageGeneration` 中新增 quad_grid 分支)
- Modify: `backend-node/src/services/imageService.js`(新增 `buildQuadGridPrompt` 辅助函数)
**Step 1: 在 `imageService.js` 中引入 framePromptService**
在文件顶部(约第 56-59 行,现有 require 后)追加:
```javascript
const framePromptService = require('./framePromptService');
const loadConfig = require('../config').loadConfig;
```
注意:`loadConfig``processImageGeneration` 内部已有局部 require,改为顶部引入(删除函数内的重复 require)。
**Step 2: 新增 `buildQuadGridPrompt` 辅助函数**
`processImageGeneration` 函数之前添加:
```javascript
/**
* 四宫格模式:为分镜生成 4 帧提示词,拼成 2×2 grid 格式的单张图生成提示词
*/
async function buildQuadGridPrompt(db, log, storyboardId, model) {
let cfg = loadConfig();
// 复用 framePromptService 内的辅助函数
const sb = framePromptService.loadStoryboard(db, storyboardId);
if (!sb) throw new Error('分镜不存在');
// 读取 drama style
try {
const epRow = db.prepare(
'SELECT drama_id FROM episodes WHERE id = (SELECT episode_id FROM storyboards WHERE id = ? AND deleted_at IS NULL) AND deleted_at IS NULL'
).get(Number(storyboardId));
if (epRow && epRow.drama_id) {
const dramaRow = db.prepare('SELECT style, metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(epRow.drama_id);
if (dramaRow) {
const styleOverrides = {};
if (dramaRow.style && String(dramaRow.style).trim()) {
styleOverrides.default_style = String(dramaRow.style).trim();
}
if (dramaRow.metadata) {
try {
const meta = typeof dramaRow.metadata === 'string' ? JSON.parse(dramaRow.metadata) : dramaRow.metadata;
if (meta && meta.aspect_ratio) {
styleOverrides.default_image_ratio = meta.aspect_ratio;
}
} catch (_) {}
}
if (Object.keys(styleOverrides).length > 0) {
cfg = { ...cfg, style: { ...(cfg?.style || {}), ...styleOverrides } };
}
}
}
} catch (_) {}
const scene = framePromptService.loadScene(db, sb.scene_id);
const characterNames = framePromptService.loadStoryboardCharacterNames(db, storyboardId);
log.info('[四宫格] 开始生成4帧提示词', { storyboard_id: storyboardId });
const [first, key1, key2, last] = await Promise.all([
framePromptService.generateSingleFrameForQuadGrid(db, log, cfg, sb, scene, characterNames, model, 'first'),
framePromptService.generateSingleFrameForQuadGrid(db, log, cfg, sb, scene, characterNames, model, 'key'),
framePromptService.generateSingleFrameForQuadGrid(db, log, cfg, sb, scene, characterNames, model, 'key'),
framePromptService.generateSingleFrameForQuadGrid(db, log, cfg, sb, scene, characterNames, model, 'last'),
]);
log.info('[四宫格] 4帧提示词生成完成', { storyboard_id: storyboardId });
const style = cfg?.style?.default_style || '';
const styleHint = style ? `, art style: ${style}` : '';
const quadPrompt =
`Generate a 2x2 storyboard grid image (four panels showing action sequence progression${styleHint}). ` +
`Each panel is clearly separated by a thin border. ` +
`Panel 1 (top-left, initial state): ${first.prompt}. ` +
`Panel 2 (top-right, action begins): ${key1.prompt}. ` +
`Panel 3 (bottom-left, action climax): ${key2.prompt}. ` +
`Panel 4 (bottom-right, final state): ${last.prompt}. ` +
`Consistent character appearance and scene across all panels. Cinematic quality.`;
return quadPrompt;
}
```
**Step 3: 在 `framePromptService.js` 中导出 `generateSingleFrame`**
`generateSingleFrame` 当前是 `framePromptService.js` 内的私有函数,需要将其导出(或新增一个包装导出)。
`framePromptService.js` 末尾 `module.exports` 中追加:
```javascript
module.exports = {
generateFramePrompt,
loadStoryboard,
loadStoryboardCharacterNames,
loadScene,
getFramePrompts: (db, storyboardId) => storyboardService.getFramePrompts(db, storyboardId),
// 供 imageService 的四宫格模式调用
generateSingleFrameForQuadGrid: generateSingleFrame,
};
```
**Step 4: 在 `processImageGeneration` 中插入四宫格分支**
`processImageGeneration` 函数中,找到 Step 4(调用图生 API)之前的 Step 3(计算尺寸)后面,找到:
```javascript
// ── Step 4: 调用图生 API ─────────────────────────────────────────
log.info('[图生] Step4 调用图生 API →', { id: imageGenId, elapsed: elapsed() });
const tApi = Date.now();
const result = await imageClient.callImageApi(db, log, {
prompt: row.prompt,
```
在 Step 4 开始前插入四宫格提示词覆盖逻辑(注意:使用 `let` 声明覆盖变量,需在 `try` 块内,位于 Step 3 结束后):
```javascript
// ── Step 3.5: 四宫格模式 — 用 AI 生成的4帧内容替换 prompt ────────
let finalPrompt = row.prompt;
if (row.frame_type === 'quad_grid' && row.storyboard_id) {
log.info('[图生] Step3.5 四宫格模式,生成组合提示词', { id: imageGenId });
try {
finalPrompt = await buildQuadGridPrompt(db, log, row.storyboard_id, row.model);
log.info('[图生] Step3.5 四宫格提示词生成完成', {
id: imageGenId,
prompt_preview: finalPrompt.slice(0, 120),
});
} catch (qErr) {
log.warn('[图生] Step3.5 四宫格提示词生成失败,回退到原始 prompt', { error: qErr.message });
// 回退到原始 prompt,不中断流程
}
}
```
然后在 Step 4 的 `callImageApi` 调用中将 `prompt: row.prompt` 改为 `prompt: finalPrompt`
```javascript
const result = await imageClient.callImageApi(db, log, {
prompt: finalPrompt,
```
**Step 5: Commit**
```bash
git add backend-node/src/services/imageService.js
git add backend-node/src/services/framePromptService.js
git commit -m "feat: add quad-grid storyboard image generation via combined frame prompts"
```
---
## Task 3:前端四宫格全局开关 UI
**Files:**
- Modify: `frontweb/src/views/FilmCreate.vue`
**Step 1: 添加 `quadGridMode` 响应式变量**
`FilmCreate.vue``<script setup>` 区域,找到现有的 `storyboardCount``videoDuration` ref 声明处(约第 1432 行附近),追加:
```javascript
const quadGridMode = ref(false)
```
**Step 2: 在分镜生成配置区添加开关 UI**
找到 `sb-config-row` 区域(约第 511-523 行),在最后一个 `<label>` 配置项之后,在 `</div>` 关闭前添加:
```html
<span class="sb-config-divider"></span>
<label class="sb-config-item">
<span class="sb-config-label">四宫格序列图</span>
<el-switch v-model="quadGridMode" />
<span class="sb-config-hint">开启后生成含4帧画面的序列参考图</span>
</label>
```
**Step 3: 修改 `onGenerateSbImage` 传参**
找到 `onGenerateSbImage` 函数(约第 1744 行),将:
```javascript
const res = await imagesAPI.create({
storyboard_id: sb.id,
drama_id: dramaId.value,
prompt: sb.image_prompt || sb.description || '',
model: undefined,
style: getSelectedStyle()
})
```
改为:
```javascript
const res = await imagesAPI.create({
storyboard_id: sb.id,
drama_id: dramaId.value,
prompt: sb.image_prompt || sb.description || '',
model: undefined,
style: getSelectedStyle(),
frame_type: quadGridMode.value ? 'quad_grid' : undefined
})
```
**Step 4: 修改批量生成分镜图逻辑(`startBatchImageGeneration`**
找到 `startBatchImageGeneration` 函数(约第 1527 行),在其内部调用 `onGenerateSbImage``imagesAPI.create` 的地方确认是否已封装调用 `onGenerateSbImage`
查找批量图片生成的实际循环逻辑,找到类似:
```javascript
await imagesAPI.create({ storyboard_id: sb.id, ... })
```
或间接调用 `onGenerateSbImage`
如果批量生成直接复用了 `onGenerateSbImage`,则无需修改(全局 `quadGridMode` 会自动传递)。
如果批量生成有独立的 `imagesAPI.create` 调用,同样追加 `frame_type: quadGridMode.value ? 'quad_grid' : undefined`
**Step 5: 验证 UI 显示**
启动前端,在分镜生成区看到"四宫格序列图"开关,切换后开关状态正常。
**Step 6: Commit**
```bash
git add frontweb/src/views/FilmCreate.vue
git commit -m "feat: add quad-grid mode global toggle in storyboard section UI"
```
---
## Task 4:端到端验证
**验证步骤:**
1. **验证功能一(角度视角):**
- 创建一个 angle='仰视' 的分镜,触发生成帧提示词
- 查看后端日志,确认传给 AI 的 userPrompt 包含 "低角度仰拍" 等扩展描述
- 生成的 image_prompt 应包含仰视视角相关描述
2. **验证功能二(四宫格):**
- 开启前端"四宫格序列图"开关
- 点击某个分镜的"生成分镜"按钮
- 查看后端日志,确认出现 `[四宫格]` 日志行
- 最终生成的图片为 2×2 四格图
3. **回归验证(确保原有流程不受影响):**
- 关闭四宫格开关,正常生成分镜图,确保行为与修改前一致
- angle='平视' 的分镜,生成的提示词包含平视描述
**Step: Final Commit(如有遗漏修改)**
```bash
git add -A
git commit -m "feat: storyboard angle perspective + quad-grid sequence image generation"
```
@@ -0,0 +1,237 @@
# 分镜角度视角 + 四宫格序列图 实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 修复分镜图背景角度固定问题,并支持四宫格序列图生成模式。
**Architecture:**
- 功能一:在 `framePromptService.js``buildStoryboardContext()` 中,将 `angle` 字段翻译为带透视含义的完整描述注入上下文,让 AI 根据相机角度生成正确视角的背景。
- 功能二:前端增加全局"四宫格序列图"开关;开启后图片生成时传 `frame_type: 'quad_grid'`;后端 `imageService.js` 检测到该标记后先用 AI 生成 4 个帧提示词,拼成四宫格格式提示词,再调用图片生成 API 生成一张四宫格图。
- 利用现有 `frame_type` 字段存储标记,无需 DB migration。
**Tech Stack:** Node.js, better-sqlite3, Vue 3, Element Plus
---
## Task 1:角度描述扩展(功能一)
**Files:**
- Modify: `backend-node/src/services/framePromptService.js`
**Step 1: 在 `buildStoryboardContext()` 前新增角度映射辅助函数**
在文件顶部(`const loadConfig` 之后)新增:
```js
function expandAngleDescription(angle, isEnglish) {
if (!angle) return null;
const a = String(angle).trim().toLowerCase();
if (isEnglish) {
if (a.includes('low') || a === '仰视') return 'camera angle: low-angle upward shot, scene background shows sky/ceiling/treetops from below, strong upward perspective';
if (a.includes('high') || a === '俯视') return 'camera angle: high-angle downward shot, bird\'s eye view perspective, background shows ground/floor/scene from above';
if (a.includes('side') || a === '侧面') return 'camera angle: side-angle shot, profile composition, background extends laterally';
if (a.includes('back') || a === '背面') return 'camera angle: rear shot, character\'s back to camera, background scene stretches ahead';
return `camera angle: eye-level horizontal shot, normal perspective`;
} else {
if (a.includes('仰') || a.includes('low')) return '相机角度:低角度仰拍,背景呈现天空/天花板/树冠的仰视透视效果,视角向上倾斜';
if (a.includes('俯') || a.includes('high')) return '相机角度:高角度俯拍,鸟瞰视角,背景呈现地面/场景从上方向下看的俯视透视效果';
if (a.includes('侧') || a.includes('side')) return '相机角度:侧面视角,侧向构图,背景向两侧延展';
if (a.includes('背') || a.includes('back')) return '相机角度:从角色背后拍摄,角色背对镜头,背景场景在角色前方延伸';
return '相机角度:平视水平拍摄,正常透视构图';
}
}
```
**Step 2: 修改 `buildStoryboardContext()` 中 angle 处理部分**
找到原来的:
```js
if (sb.angle) {
parts.push(promptI18n.formatUserPrompt(cfg, 'angle_label', sb.angle));
}
```
替换为:
```js
if (sb.angle) {
const isEn = promptI18n.isEnglish(cfg);
const angleDesc = expandAngleDescription(sb.angle, isEn);
if (angleDesc) parts.push(angleDesc);
}
```
**Step 3: 同样修改 `episodeStoryboardService.js` 的 `generateImagePrompt()`**
`episodeStoryboardService.js` 中的 `generateImagePrompt()` 是生成初始 image_prompt 的函数,也需要加入角度信息。找到:
```js
function generateImagePrompt(sb, style) {
const parts = [];
if (sb.location) {
let locationDesc = sb.location;
if (sb.time) locationDesc += ', ' + sb.time;
parts.push(locationDesc);
}
...
const styleText = style && String(style).trim();
if (styleText) parts.push(styleText + ', first frame');
else parts.push('first frame');
return parts.length ? parts.join(', ') : (styleText ? styleText + ', first frame' : 'first frame');
}
```
`parts.push(locationDesc)` 之后,加入角度信息:
```js
if (sb.angle) {
const a = String(sb.angle).trim().toLowerCase();
if (a.includes('仰') || a.includes('low')) parts.push('low-angle upward shot');
else if (a.includes('俯') || a.includes('high')) parts.push('high-angle downward shot, bird\'s eye view');
else if (a.includes('侧') || a.includes('side')) parts.push('side-angle shot');
else if (a.includes('背') || a.includes('back')) parts.push('rear shot from behind character');
else parts.push('eye-level shot');
}
```
**Step 4: 提交**
```bash
git add backend-node/src/services/framePromptService.js backend-node/src/services/episodeStoryboardService.js
git commit -m "feat: expand camera angle to perspective description in storyboard image prompts"
```
---
## Task 2:四宫格图片生成(功能二后端)
**Files:**
- Modify: `backend-node/src/services/imageService.js`
**Step 1: 在 `imageService.js` 中新增 `buildQuadGridPrompt()` 函数**
在文件顶部 `const path = require('path')` 之后,引入 framePromptService(注意避免循环依赖,在函数内部 require)。
`processImageGeneration` 函数之前添加:
```js
async function buildQuadGridPrompt(db, log, cfg, storyboardId, model) {
const framePromptService = require('./framePromptService');
const sb = framePromptService.loadStoryboard(db, storyboardId);
if (!sb) return null;
const scene = framePromptService.loadScene(db, sb.scene_id);
const characterNames = framePromptService.loadStoryboardCharacterNames(db, storyboardId);
const [first, key1, key2, last] = await Promise.all([
framePromptService.generateSingleFrameExported(db, log, cfg, sb, scene, characterNames, model, 'first'),
framePromptService.generateSingleFrameExported(db, log, cfg, sb, scene, characterNames, model, 'key'),
framePromptService.generateSingleFrameExported(db, log, cfg, sb, scene, characterNames, model, 'key'),
framePromptService.generateSingleFrameExported(db, log, cfg, sb, scene, characterNames, model, 'last'),
]);
const style = cfg?.style?.default_style || '';
const styleNote = style ? `, consistent style: ${style}` : '';
return `Generate a 2x2 four-panel storyboard grid image (comic strip layout, clear panel borders). Each panel shows a different moment in sequence${styleNote}:
Panel 1 (top-left, first frame): ${first.prompt}
Panel 2 (top-right, key moment): ${key1.prompt}
Panel 3 (bottom-left, key moment): ${key2.prompt}
Panel 4 (bottom-right, last frame): ${last.prompt}
All panels have consistent character appearance and scene. Clear visible borders between panels. Sequential action progression.`;
}
```
**Step 2: 在 `framePromptService.js` 中导出 `generateSingleFrame`**
`framePromptService.js``module.exports` 中添加:
```js
generateSingleFrameExported: generateSingleFrame,
```
**Step 3: 在 `processImageGeneration()` 中添加四宫格分支**
`processImageGeneration` 中,找到 `// ── Step 1: 获取 AI 配置 ──` 之前,添加四宫格处理:
```js
// ── 四宫格模式:先生成4帧提示词,再拼装组合提示词 ──────────────────
if (row.frame_type === 'quad_grid' && row.storyboard_id) {
try {
const quadPrompt = await buildQuadGridPrompt(db, log, cfg, row.storyboard_id, row.model);
if (quadPrompt) {
db.prepare('UPDATE image_generations SET prompt = ?, updated_at = ? WHERE id = ?')
.run(quadPrompt, new Date().toISOString(), imageGenId);
row.prompt = quadPrompt;
}
} catch (quadErr) {
log.warn('[图生] 四宫格提示词生成失败,使用原始提示词', { error: quadErr.message });
}
}
```
这段代码放在 `// ── Step 1: 获取 AI 配置 ──` 注释之前。
**Step 4: 提交**
```bash
git add backend-node/src/services/imageService.js backend-node/src/services/framePromptService.js
git commit -m "feat: add quad-grid storyboard image generation support"
```
---
## Task 3:四宫格前端 UI(功能二前端)
**Files:**
- Modify: `frontweb/src/views/FilmCreate.vue`
**Step 1: 添加 `quadGridMode` 响应式变量**
在文件中找到 `const resourcePanelCollapsed = ref(false)` 附近(约第 1432 行),添加:
```js
const quadGridMode = ref(false)
```
**Step 2: 在分镜配置行添加四宫格开关 UI**
找到分镜配置区(约 511 行的 `<div class="sb-config-row">`),在最后一个 `</label>` 之后、`</div>` 之前添加:
```html
<span class="sb-config-divider"></span>
<label class="sb-config-item">
<span class="sb-config-label">四宫格序列图</span>
<el-switch v-model="quadGridMode" />
<span class="sb-config-hint">生成含4帧的序列参考图</span>
</label>
```
**Step 3: 修改 `onGenerateSbImage()` 传参**
找到 `onGenerateSbImage` 函数(约 1744 行),修改 `imagesAPI.create()` 调用:
```js
const res = await imagesAPI.create({
storyboard_id: sb.id,
drama_id: dramaId.value,
prompt: sb.image_prompt || sb.description || '',
model: undefined,
style: getSelectedStyle(),
frame_type: quadGridMode.value ? 'quad_grid' : undefined,
})
```
**Step 4: 修改批量生成分镜图也透传 quad_grid**
找到 `startBatchImageGeneration` 函数(约 1527 行),找到其中调用 `imagesAPI.create` 的地方,同样加上:
```js
frame_type: quadGridMode.value ? 'quad_grid' : undefined,
```
**Step 5: 提交**
```bash
git add frontweb/src/views/FilmCreate.vue
git commit -m "feat: add quad-grid mode switch to storyboard UI"
```
---
## 验证步骤
1. 重启后端服务
2. 打开一个剧集的分镜页
3. **验证角度**:检查一个带有"俯视"角度的分镜,点击生成分镜图,观察 AI 生成的图片背景是否呈现俯视透视
4. **验证四宫格**:打开四宫格开关,点击某个分镜的"生成分镜"按钮,等待后确认生成的图片是 2×2 四格布局
5. **验证批量**:开启四宫格模式后点击"批量生成分镜图",确认所有分镜均生成四宫格图
@@ -0,0 +1,136 @@
# 分镜图生成质量提升计划
**目标:** 解决分镜图生成时出现宫格布局、角色外貌不一致、背景不一致等问题,并规划后续质量提升路径。
**技术栈:** Node.js (Express), better-sqlite3, Vue 3, Element Plus, Gemini / NanoBanana 图片 API
---
## 已完成(2026-03-14
### 问题一:场景四视图生成后不显示
**原因:** `imageClient.createAndGenerateImage` 是为角色图片设计的函数,缺少 `scene_id` 参数支持,导致:
- 图片存储路径 hardcode 为 `characters/`(应为 `scenes/`
- `image_generations` 表不写入 `scene_id`
- 生成完成后不回写 `scenes` 表的 `image_url` / `local_path`
**修复:**
- `imageClient.js``createAndGenerateImage` 新增 `scene_id` 参数,动态判断存储目录(`scenes` / `characters` / `images`),生成完成后回写 `scenes`
- `sceneService.js`:调用时传入 `scene_id: sceneId`
---
### 问题二:分镜图生成结果仍为宫格布局
**原因:** 传入的场景/角色参考图是 2×2 四视图合图,图片 AI 看到宫格参考图后会模仿其布局格式输出宫格图,且提示词中没有明确的"单张输出"约束。
**修复:**
- `imageService.js`:单张分镜生成时,在 Step 2.5 记录参考图映射,通过 Gemini parts 结构传递说明
- `imageClient.js`:重构 Gemini 多模态输入结构为正确顺序:
```
[总说明文字] → [参考1说明] → [参考图1] → [参考2说明] → [参考图2] → [生成指令+主提示词]
```
而非原来的"prompt 在前,图片附后"的错误顺序
---
### 问题三:角色外貌不一致(参考图格式干扰)
**原因:** 作为参考图传入的是 2×2 四视图合图(含头像/正面/侧面/背面 4 格),图片 AI 难以从合图中准确提取角色外貌,且四格合图的视觉格式会干扰输出布局。
**修复:**
- `imageService.js``splitQuadGridToImages`):INSERT 时增加 `character_id` 字段,让角色拆分面板可按 ID 查询
- `imageService.js`(Step 2 参考图解析):优先查询拆分后的单张面板作为参考:
- 场景:`quad_panel_0`(左上格 = 建立远景,最能代表环境)
- 角色:`quad_panel_1`(右上格 = 正面全身图,最能代表角色外貌)
- 无拆分面板时 fallback 到四视图合图
- 角色最多取 3 个(避免超出模型参考图限制),Gemini 参考图上限从 3 改为 4(场景1 + 角色3)
---
### 问题四:参考图标签与实际传图数量不对齐
**原因:** 当角色有 `extra_images` 且无主图时,`extra_images` 仍会被推入 refs 但无对应主标签,导致后续角色编号错乱;`refLabels` 数量可能超过 Gemini 实际接收的图片数,让模型产生困惑。
**修复:**
- `extra_images` 推入逻辑移入 `if (primaryRef)` 块内,确保无主图时不产生孤立的 extra 标签
- `refLabels.slice(0, refs.length)` 强制对齐条数
- Gemini parts 构建时,按实际传入图片数裁剪标签
---
## 待完成(优先级排序)
### P1:分镜 prompt 二次优化
**背景:** 当前分镜描述(由文本 AI 生成的首帧/关键帧/尾帧提示词)是针对"分镜内容描述"设计的,直接发给图片 AI 效果打折。
**方案:** 在 `imageService.js` Step 3 之前增加一个文本 AI 优化步骤:
- 输入:原始分镜提示词 + 风格 + 宽高比 + 参考资产名称列表
- 输出:针对图片生成模型优化的提示词(增强细节描述、统一风格词汇、加入参考资产名称映射)
- 模型:使用轻量文本模型(如 deepseek-v3),成本低
- 缓存:优化后的 prompt 回写 `image_generations.prompt`,同一分镜重新生成时可复用
**预期收益:** 图片细节更丰富,风格更一致,角色名称与参考图映射更清晰
---
### P2:批量宫格生成(同一分镜多帧)
**背景:** 当前每一帧独立调用图片 API,导致同一分镜的首帧/关键帧/尾帧在风格、光线、色调上可能不一致。
**方案:** 复用现有 `quad_grid` / `nine_grid` 基础设施:
- 在分镜图生成时,将同一分镜的多帧组合为宫格提示词,一次生成宫格图
- 生成完成后调用 `splitQuadGridToImages` 自动拆分为独立帧
- API 调用次数从 N 次降为 1 次
**预期收益:** 同一分镜内视觉一致性大幅提升,API 成本降低
---
### P3:参考图智能过滤
**背景:** 当前把分镜绑定的所有角色/场景参考图都传入,部分角色/场景可能在当前帧中根本未出现,增加无效噪声。
**方案:** 在 Step 2 解析参考图后,调用文本 AI 判断:
- 输入:分镜描述 + 候选资产列表(名称 + 简介)
- 输出:与本帧内容直接相关的资产列表
- 只传相关资产的参考图
**预期收益:** 减少无关参考图干扰,提高角色/场景还原度
---
### P4:参考图压缩
**背景:** 传参考图前无压缩处理,可能触发 API 请求大小限制。
**方案:** 在 `callGeminiImageApi` 中对参考图 buffer 做压缩:
- 单张目标:≤ 3MBJPEG quality 递减压缩)
- 总大小:≤ 10MB(按大小降序优先压缩大图)
- 使用 `sharp` 处理(项目已依赖)
---
### P5:多 Agent 大纲生成
**背景:** 当前大纲/剧本生成为单次 AI 调用,无法做到自动审核、迭代修改。
**方案:** 基于 Vercel AI SDK 的 Tool Use,实现三 Agent 协作:
- **故事师**:分析输入主题,生成故事线(存 `storylines` 表)
- **大纲师**:根据故事线生成各集大纲(存 `outlines` 表)
- **导演**:审核大纲与故事线一致性,提出修改建议并执行
工具调用链:`主Agent → 故事师 → saveStoryline → 大纲师 → saveOutline → 导演 → updateOutline`
---
### P6:分镜自动生成 AgentshotAgent
**背景:** 当前分镜需要用户手动拆分,或依赖单次 AI 调用,缺少自动化流程。
**方案:**
- **segmentAgent**:从剧本中自动拆分场景片段(含情绪、动作描述)
- **shotAgent**:根据片段生成分镜提示词(含景别、角度、运镜、角色、对话)
- 前端通过 WebSocket 接收流式进度更新
@@ -0,0 +1,278 @@
# 漫剧画布工作流(LibTV 式)实施计划
> 目标:在 LocalMiniDrama 现有 `project.json` / SQLite 数据之上,增加 LibTV 风格的无限画布视图;列表模式(FilmCreate)与画布模式双视图、单数据源。
**最后更新**2026-06-15(阶段 D:交互增强、媒体对齐、全能模式)
## 设计原则
1. **真源不变**:角色、分镜、图片、视频仍存现有表与 `project.json` 结构。
2. **画布是视图层**:额外持久化 `drama.metadata.canvas_layout`(坐标、视口)与 `workflow_groups`(工作流组)。
3. **旧 JSON 兼容**:无 `canvas_layout` 时自动布局;导入/导出忽略未知 metadata 字段不影响旧版。
4. **列表模式对齐**:画布读取分镜图/视频/首尾帧/全能词的逻辑与 `FilmCreate.vue` 一致(通过 `/images``/videos` API + `storyboardMedia.js`)。
5. **技术栈**Vue 3 + `@vue-flow/core` ^1.48(与 Element Plus / Pinia 一致)。
---
## 阶段 A:只读画布 MVP(已完成)
### 交付物
- [x] 路由 `/film/:id/canvas`
- [x] `FilmCreate` / `DramaDetail` 顶部「画布模式」入口
- [x] `dramaCanvasAdapter.js``drama` API 数据 → nodes/edges 自动布局
- [x] `DramaCanvas.vue`:平移缩放、小地图、集数筛选
- [x] 双击分镜节点 → 跳转列表模式并定位集数
---
## 阶段 B:布局持久化 + 素材侧栏交互(已完成)
### 交付物
- [x] 节点可拖动,debounce 保存 `metadata.canvas_layout`PUT `/dramas/:id/canvas-layout`
- [x] 导出 ZIP 时 `canvas_layout` 写入 `drama.metadata`
- [x] 左侧素材库点击高亮关联分镜与连线
- [x] 画布 ↔ 列表模式双向入口,保留集数筛选 query
- [x] 分镜节点展示生成状态,生成中自动轮询刷新
---
## 阶段 C:工作流编排(整组重跑)(已完成)
### 交付物
- [x] 框选 / Ctrl 多选分镜 →「创建工作流」
- [x] `metadata.workflow_groups` 持久化(随项目导出)
- [x] 流水线配置:生图 → 生视频 → 配音(可勾选)
- [x] 「整组重跑」按组内分镜顺序依次执行
- [x] 分镜节点显示所属工作流标签
### 数据结构(实际实现)
```json
{
"canvas_layout": {
"version": 1,
"viewport": { "x": 0, "y": 0, "zoom": 0.75 },
"nodes": { "sb:12": { "x": 360, "y": 144 } },
"updated_at": "2026-06-15T12:00:00.000Z"
},
"workflow_groups": [
{
"id": "wg-1700000000-abc123",
"title": "第一场批量",
"storyboard_ids": [12, 13, 14],
"pipeline": ["image", "video", "audio"],
"created_at": "2026-06-15T12:00:00.000Z"
}
]
}
```
> 注:早期草案中的 `node_refs` 已改为 `storyboard_ids`(仅存分镜 ID,媒体节点由 adapter 动态生成)。
---
## 阶段 D:交互与媒体对齐(2026-06-15,已完成)
### 交付物
#### 1. 连线与布局
- [x] 连线改为 Vue Flow 贝塞尔曲线(`type: default``curvature: 0.62`
- [x] **默认布局改为竖排**:每个分镜占一行,自上而下;单行内仍为「分镜 → 媒体链」横向展开
- [x] 分镜顺序链改为上下连接(`chain-out` / `chain-in` 锚点)
#### 2. 节点内操作面板(无需切列表模式)
- [x] 单击分镜 / 媒体 / 素材节点,下方展开操作面板;单击空白画布关闭
- [x] 分镜面板:编辑动作、对白、提示词;保存 / 润色 / 生图 / 生视频 / 配音
- [x] 媒体面板:预览 + 对应步骤重跑
- [x] 素材面板:信息展示 + 生成参考图 + 高亮关联分镜
- [x] 面板区域 `nodrag nopan`,避免与画布拖拽冲突
#### 3. 媒体读取与列表模式对齐
- [x] `useCanvasStoryboardMedia.js`:进入画布时按集批量拉取 `/images``/videos`
- [x] `storyboardMedia.js`:首帧 / 尾帧 / 主图 / 视频解析(对齐 `FilmCreate.getSbFirstImage` 等)
- [x] 后端 `dramaService.rowToStoryboard` 补全 `first_frame_*``last_frame_*` 字段
- [x] 首尾帧模式:画布展示 **首帧** + **尾帧** 两个图节点(`sbimg-first` / `sbimg-last`
- [x] 画布内生成视频时传递 `first_frame_url` / `last_frame_url`
#### 4. 全能模式(`creation_mode === 'universal'`
- [x] 不展示空的分镜图节点
- [x] 流水线:`分镜 → 全能分镜词 → 视频`(节点 `sbuni:{id}`kind `universal`
- [x] 分镜卡片显示「全能」徽章;操作面板编辑 `universal_segment_text`,隐藏生图入口
#### 5. 框选与工作流交互修复
- [x] Vue Flow 1.48 移除 `selection-on-drag`,改为 `:selection-key-code="true"` 实现左键框选
- [x] `:pan-on-drag="[1, 2]"`:左键框选,中键/右键平移画布
---
## 默认布局规则(2026-06-15
```
┌─────────────────────────────────────────────────────────────────┐
│ 顶栏:列表模式 | 集数 | 工作流条(创建/选择/整组重跑/删除) │
├──────────────┬──────────────────────────────────────────────────┤
│ 素材库 │ 第1集 │
│ 👤 角色 │ [SB#1] ─→ [文本] ─→ [首帧] ─→ [尾帧] ─→ [视频] │
│ 🏞 场景 │ [SB#2] ─→ ... (竖排,每镜一行) │
│ 🎭 道具 │ [SB#3] ─→ [全能分镜词] ─→ [视频] (全能模式) │
│ 工作流列表 │ │
└──────────────┴──────────────────────────────────────────────────┘
```
- 左栏(x≈48):角色 / 场景 / 道具 + 工作流列表
- 右栏(x≥360):每集标题 + 分镜竖排;单行内媒体节点横向排列
- 虚线(绿色):素材 → 分镜
- 实线(紫色/蓝):分镜 → 媒体、分镜 ↓ 分镜(顺序链)
- 曲线:所有边为贝塞尔曲线
> 已有 `canvas_layout` 的项目仍使用已保存坐标;清除 metadata 中 `canvas_layout.nodes` 可恢复新默认竖排。
---
## 节点 ID 规范
| ID 格式 | 含义 |
|---------|------|
| `char:{id}` | 角色 |
| `scene:{id}` | 场景 |
| `prop:{id}` | 道具 |
| `episode:{id}` | 集标题 |
| `drama:header` | 项目标题 |
| `sb:{id}` | 分镜 |
| `sbtxt:{id}` | 分镜文本摘要(经典模式) |
| `sbuni:{id}` | 全能分镜词(全能模式) |
| `sbimg:{id}` | 分镜图(经典单图) |
| `sbimg-first:{id}` | 首帧图(首尾帧模式) |
| `sbimg-last:{id}` | 尾帧图(首尾帧模式) |
| `sbvid:{id}` | 分镜视频 |
| `sbaud:{id}:dialogue` | 对白音频 |
---
## 用户使用说明
### 画布基本操作
| 操作 | 效果 |
|------|------|
| 左键在**空白处**拖拽 | 框选多个分镜 |
| Ctrl + 点击分镜 | 多选 / 取消选择 |
| 中键 / 右键拖拽 | 平移画布 |
| 滚轮 | 缩放 |
| 单击节点 | 展开下方操作面板 |
| 单击空白 | 关闭操作面板 |
| 双击分镜 | 跳转列表模式并定位 |
| 拖动节点 | 保存布局(debounce |
### 工作流(批量生成)
1. 框选或 Ctrl 选中多个**分镜节点**(带 `#N` 的卡片,不是媒体子节点)
2. 勾选步骤:生图 / 生视频 / 配音(全能模式分镜建议只勾生视频)
3.**创建工作流**,输入名称
4.**选择工作流** 下拉框(或左侧列表)选中该组
5.**整组重跑**:按组内分镜顺序依次执行;某一镜失败则停止
6. **删除工作流** 仅删除分组配置,不删除分镜与媒体
### 经典 / 首尾帧 / 全能 三种流水线展示
| 模式 | 画布媒体链 |
|------|------------|
| 经典 | 文本 → 分镜图 → 视频 →(音频) |
| 首尾帧(`metadata.storyboard_use_first_last_frame`) | 文本 → 首帧 → 尾帧 → 视频 →(音频) |
| 全能(`creation_mode: universal`) | 全能分镜词 → 视频 →(音频) |
---
## 文件结构
```
frontweb/src/
views/DramaCanvas.vue
composables/
useCanvasContext.js # provide/inject 画布上下文
useCanvasStoryboardMedia.js # 批量加载 images/videos
useCanvasWorkflowRunner.js # 单步/整组生成(export runImageStep 等)
utils/
dramaCanvasAdapter.js # drama → Vue Flow 图
canvasLayout.js # layout 解析/持久化
canvasWorkflow.js # workflow_groups CRUD
storyboardMedia.js # 首帧/尾帧/视频 URL 解析(对齐列表模式)
mediaUrl.js
components/dramaCanvas/
CanvasAssetNode.vue
CanvasAssetPanel.vue
CanvasEpisodeNode.vue
CanvasDramaHeaderNode.vue
CanvasLabelNode.vue
CanvasStoryboardNode.vue
CanvasStoryboardPanel.vue
CanvasMediaNode.vue
CanvasMediaPanel.vue
backend-node/src/services/
dramaService.js # rowToStoryboard 含首尾帧字段
```
### API
- `GET /api/v1/dramas/:id` — 项目数据(含 metadata
- `PUT /api/v1/dramas/:id/canvas-layout` — 保存 `canvas_layout` 和/或 `workflow_groups`
- `GET /api/v1/images?storyboard_id=` — 画布加载分镜图列表
- `GET /api/v1/videos?storyboard_id=` — 画布加载分镜视频列表
---
## Vue Flow 配置要点
```vue
<VueFlow
:selection-key-code="true"
:pan-on-drag="[1, 2]"
:pan-on-scroll="true"
:elements-selectable="true"
/>
```
> **勿使用** 已废弃的 `selection-on-drag`@vue-flow/core 1.41+ 已移除,配置了也不生效)。
---
## 风险与规避
| 风险 | 规避 |
|------|------|
| 分镜过多画布过长 | 集数筛选 + fitView + 小地图 + 竖排一镜一行 |
| metadata 合并覆盖 | update 时 merge 现有 metadata |
| 画布与列表媒体不一致 | 统一走 `storyboardMedia.js` + images/videos API |
| 全能模式误触生图 | 全能分镜隐藏生图;工作流勾选项需用户自行判断 |
| 框选无效 | 必须在空白处拖拽;或 Ctrl 点击多选 |
| Vue Flow 包体积 | 画布路由按需加载 |
---
## 后续可选 / TODO
### 画布与工作流
- [ ] 全能模式画布内润色 / 流式编辑全能词
- [ ] 工作流组内分镜顺序可视化拖拽调整
- [ ] 多集同时展示时的节点虚拟化(仅渲染视口内)
- [ ] 画布内首尾帧单独生图(走 frame-prompt 模块,对齐列表模式完整能力)
- [ ] 顶栏工作流区域增加简短帮助 tooltip
### 场景与素材
- [ ] **场景图 → 全景图**:基于已有场景参考图/背景图,AI 扩展或生成超宽/360° 全景图;可用于场景库展示、分镜大景别运镜参考,以及全能模式 `@图片N` 的环境图;需评估与现有 `scenes` 表、`generateImage` / 四视图流程的衔接方式
### 其他
- [ ] 分镜参考图自由上传(列表模式已有部分能力,画布侧统一入口)
- [ ] 参考图自由选择(生成分镜图时手动指定角色/场景参考)