Files
localMiniDrama/docs/plans/2026-03-09-storyboard-angle-and-quad-grid.md
T
2026-06-30 15:07:31 +08:00

342 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 分镜图相机角度视角 + 四宫格序列图 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"
```