# 分镜图相机角度视角 + 四宫格序列图 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 1:expandAngleDescription — 角度扩展注入 **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 2:processImageGeneration 四宫格分支 **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` 的 `