This commit is contained in:
2026-06-30 15:02:20 +08:00
commit 3948b5a48a
306 changed files with 77275 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
/example_drama/三十六计之借刀杀人_古风_竖屏.zip filter=lfs diff=lfs merge=lfs -text
/example_drama/我的姐夫他是太子.zip filter=lfs diff=lfs merge=lfs -text
/项目截图/1.mp4 filter=lfs diff=lfs merge=lfs -text
/项目截图/2.mp4 filter=lfs diff=lfs merge=lfs -text
/项目截图/3.mp4 filter=lfs diff=lfs merge=lfs -text
/example_drama/衣服设计天才302.zip filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
+2
View File
@@ -0,0 +1,2 @@
# 暂不接受赞赏,保留此文件以备将来使用
# Not accepting sponsorship for now; keeping this file for future use
+46
View File
@@ -0,0 +1,46 @@
---
name: Bug 报告 / Bug Report
about: 报告一个问题帮助我们改进 / Report a bug to help us improve
title: "[Bug] "
labels: bug
assignees: ''
---
## 问题描述 / Description
<!-- 简洁清晰地描述这个 Bug / A clear and concise description of the bug -->
## 复现步骤 / Steps to Reproduce
1.
2.
3.
## 预期行为 / Expected Behavior
<!-- 你期望发生什么 / What you expected to happen -->
## 实际行为 / Actual Behavior
<!-- 实际发生了什么 / What actually happened -->
## 截图 / Screenshots
<!-- 如有截图,请粘贴在此 / If applicable, add screenshots -->
## 环境信息 / Environment
| 项目 | 内容 |
|------|------|
| 软件版本 / Version | v |
| 操作系统 / OS | Windows 10 / 11 |
| 运行方式 / Mode | exe 直接运行 / 开发模式 |
## 日志 / Logs
<!-- 如有报错日志,请粘贴在此(可在软件界面的错误提示中复制)/ Paste any error logs if available -->
```
(粘贴日志 / Paste logs here
```
## 补充信息 / Additional Context
<!-- 其他任何有助于定位问题的信息 / Any other context about the problem -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 微信交流群 / WeChat Group
url: https://github.com/xuanyustudio/LocalMiniDrama#-联系--社区
about: 扫码加入用户交流群,获取使用帮助 / Scan to join the community group
- name: 📖 使用文档 / Documentation
url: https://github.com/xuanyustudio/LocalMiniDrama/tree/main/docs
about: 查阅快速开始、AI配置等文档 / Read quickstart and configuration docs
+27
View File
@@ -0,0 +1,27 @@
---
name: 功能建议 / Feature Request
about: 为这个项目提出新功能或改进建议 / Suggest a new feature or improvement
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 功能描述 / Feature Description
<!-- 简洁清晰地描述你希望添加的功能 / A clear description of the feature you'd like -->
## 使用场景 / Use Case
<!-- 描述这个功能在什么场景下有用,解决了什么问题 / Describe when and why this feature would be useful -->
## 期望的实现方式 / Proposed Solution
<!-- 如果有想法,描述你希望如何实现 / If you have ideas, describe how you'd like this to work -->
## 替代方案 / Alternatives Considered
<!-- 你考虑过哪些其他解决方案 / Any alternative solutions or features you've considered -->
## 补充信息 / Additional Context
<!-- 截图、参考链接或其他有助于理解需求的内容 / Screenshots, links, or other context -->
+42
View File
@@ -0,0 +1,42 @@
## 改动说明 / Description
<!-- 简要描述本次 PR 的目的和改动内容 / Briefly describe the purpose and changes -->
## 改动类型 / Type of Change
<!-- 请勾选适用的类型 / Check the type(s) that apply -->
- [ ] 🐛 Bug 修复 / Bug fix
- [ ] ✨ 新功能 / New feature
- [ ] ♻️ 代码重构 / Refactoring (no functional change)
- [ ] 📝 文档更新 / Documentation update
- [ ] 🎨 UI/样式调整 / UI / style change
- [ ] ⚡ 性能优化 / Performance improvement
- [ ] 🔧 构建/配置变更 / Build / config change
## 关联 Issue / Related Issue
<!-- 如果有关联的 Issue,请填写 / Link to related issue if applicable -->
Closes #
## 测试说明 / Testing
<!-- 描述你如何测试这些改动 / Describe how you tested these changes -->
- [ ] 本地开发模式测试通过 / Tested in dev mode
- [ ] 打包 exe 测试通过 / Tested with packaged exe
- [ ] 相关功能无明显回归 / No obvious regression
## 截图 / Screenshots
<!-- 如涉及 UI 改动,请提供前后对比截图 / If UI changes are involved, add before/after screenshots -->
## 检查清单 / Checklist
- [ ] 代码符合项目现有风格(纯 JavaScript,无 TypeScript/ Code follows project style (vanilla JS)
- [ ] 没有引入不必要的依赖 / No unnecessary new dependencies
- [ ] 如有必要,已更新相关文档 / Documentation updated if needed
+81
View File
@@ -0,0 +1,81 @@
name: Release
on:
push:
tags:
- 'v*.*.*' # 推送 vX.X.X 格式的 tag 时触发
permissions:
contents: write
jobs:
build-windows:
name: Build Windows Installer
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: desktop/package-lock.json
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install frontend dependencies
working-directory: frontweb
run: npm ci
- name: Build frontend
working-directory: frontweb
run: npm run build
- name: Install backend dependencies
working-directory: backend-node
env:
NODE_TLS_REJECT_UNAUTHORIZED: '0'
run: npm ci
- name: Install desktop dependencies & rebuild native modules
working-directory: desktop
env:
NODE_TLS_REJECT_UNAUTHORIZED: '0'
run: npm ci
- name: Copy frontend dist
working-directory: desktop
run: node scripts/copy-front.js
- name: Copy backend
working-directory: desktop
run: node scripts/copy-backend.js
# 标准版(含示例数据)
- name: Build Standard Installer
working-directory: desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx electron-builder --win --publish never
- name: Upload Standard Build Artifacts
uses: actions/upload-artifact@v4
with:
name: windows-release
path: desktop/release/*.exe
retention-days: 7
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: desktop/release/*.exe
generate_release_notes: true
draft: true # 先创建草稿,人工审核后手动发布
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+24
View File
@@ -0,0 +1,24 @@
参考前端/
.trae
backend-node/scripts
backend-node/configs/ai-configs-stary.json
backend-node/configs/ai-configs-qudao.json
backend-node/configs/ai-configs-2026-03-12 (5).json
data/drama_generator.db
data/drama_generator.db-shm
data/drama_generator.db-wal
.opencode
.cursor
desktop/dist/win-unpacked
server
desktop/backend-app-secure/configs/ai-configs-qudao.json
desktop/backend-app-secure/configs/ai-configs-外发.json
desktop/backend-app-secure/configs/config.yaml
.DS_Store
backend-node/deploy-pack
desktop/backend-app-secure
desktop/release-icon-test
desktop/release-icon-test-7
desktop/backend-app-secure
backend-node/tools/ffmpeg/ffmpeg.exe
backend-node/tools/ffmpeg/ffprobe.exe
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="12e8d7f7-c98f-4af7-8398-c9272957e304" name="更改" comment="init">
<change beforePath="$PROJECT_DIR$/.gitattributes" beforeDir="false" afterPath="$PROJECT_DIR$/.gitattributes" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 0
}]]></component>
<component name="ProjectId" id="3FqQrDyz3GnRiuw7DKMD4uAUKaV" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "master",
"kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/huade/IdeaProjects/LocalMiniDrama",
"nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-30f59d01ecdd-37e91769500f-intellij.indexing.shared.core-IU-261.24374.151" />
<option value="bundled-js-predefined-d6986cc7102b-31caf2ab9e3c-JavaScript-IU-261.24374.151" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="12e8d7f7-c98f-4af7-8398-c9272957e304" name="更改" comment="" />
<created>1782800317217</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1782800317217</updated>
<workItem from="1782800318513" duration="1172000" />
</task>
<task id="LOCAL-00001" summary="init">
<option name="closed" value="true" />
<created>1782800442057</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1782800442057</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="init" />
<option name="LAST_COMMIT_MESSAGE" value="init" />
</component>
</project>
+44
View File
@@ -0,0 +1,44 @@
# AGENTS.md
## Cursor Cloud specific instructions
### Project Overview
LocalMiniDrama (本地短剧助手) — an AI-powered local short drama creation tool. Single product, three sub-projects sharing one repo (no monorepo tooling).
### Services
| Service | Directory | Port | Start Command |
|---------|-----------|------|---------------|
| Backend (Express + SQLite) | `backend-node/` | 5679 | `npm run dev` |
| Frontend (Vite + Vue 3) | `frontweb/` | 3013 | `npm run dev` |
Frontend proxies `/api` and `/static` to backend via Vite config.
### Running Tests
```bash
# Backend tests (Node.js built-in test runner)
cd backend-node && node --test test/*.test.js
# Frontend tests (ESM, Node.js built-in test runner)
cd frontweb && node --test test/*.test.js
```
No ESLint or other lint tool is configured in this codebase.
### Building
```bash
cd frontweb && npm run build
```
### Key Development Notes
- Pure JavaScript (no TypeScript) throughout.
- Backend uses `node --watch` for hot reloading in dev mode (`npm run dev`).
- Database is SQLite (embedded via `better-sqlite3`), auto-created in `backend-node/data/`.
- Migrations run automatically on backend startup (`ensureColumns()`); explicit `npm run migrate` only needed for first-time setup or after adding new migration SQL files.
- Config file at `backend-node/configs/config.yaml` already exists in the repo — no need to copy from example.
- AI content generation requires external API keys (configured via the app's "AI 配置" page), but the app fully functions without them for development/testing purposes.
- The backend also serves the built frontend from `frontweb/dist/` at port 5679 when the dist folder exists; during development, use the Vite dev server at port 3013 instead.
+397
View File
@@ -0,0 +1,397 @@
# Changelog
所有版本的重要改动记录在此文件中,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
**官方仓库:**
[![GitHub](https://img.shields.io/badge/GitHub-xuanyustudio%2FLocalMiniDrama-181717?logo=github)](https://github.com/xuanyustudio/LocalMiniDrama)
[![Gitee](https://img.shields.io/badge/Gitee-bi__shang__a%2Flocalminidrama-C71D23?logo=gitee)](https://gitee.com/bi_shang_a/localminidrama)
---
## [1.2.7] - 2026-06-02
### 新增
- **尾帧衔接**:分镜视频区「尾帧衔接」按钮;后端 `tailFrameLinkService`**ffmpeg** 提取当前镜已完成视频的末帧,写入 `image_generations` 并设为**下一镜首帧**`first_frame_image_id` / `image_url` / `local_path`),便于镜间画面连续
- **分镜首帧 / 尾帧独立绑定**`storyboardFrameBinding` 将尾帧写入 `last_frame_*` 字段,避免尾帧图污染分镜主图或历史记录;支持 `storyboard_first` / `storyboard_last` 等别名归一化
- **导出分镜表**:制作页一键导出当前集分镜为 **HTML 表格**`exportStoryboardSheet`),含镜号、景别、运镜、场景/角色/道具、对白、解说、提示词、全能片段等列,便于审阅与对外协作
- **统一生成任务进度**:新增 `generationTaskStore``useGenerationTaskSync`,角色/场景/道具/分镜图(含首帧、尾帧)/分镜视频/流水线等异步任务共用轮询、去重与超时清理;刷新页面后可恢复进行中的任务状态
- **全能片段多子分镜版式统一**`universalOmniMultiBeatFormat` 与批量分镜生成、「生成 / 润色全能提示词」共用同一套 **分镜1 / 分镜2…** 段落格式与 `@图片1` 环境约束说明
- **Seedance 2.0 角色素材守护**`seedance2AssetGuards` 在角色主图变更时自动将已认证 `seedance2_asset` / 音色参考标为 **stale**,避免视频引用过期素材
- **媒体画幅规格**`mediaAspectRatioSpec` 统一图片 / 视频请求的宽高比解析与归一化
- **故事生成 composable**`useStoryGeneration` 抽离「从梗概生成剧本」流程;`scriptEpisodes` 辅助多集剧本分段
### 优化
- **全能模式生视频校验**:单条「生成 / 重新生成」视频前检测 AI 配置是否为 **`kling_omni`**,或 **`volcengine_omni` + Seedance 2.x 模型**;不匹配时弹窗说明并可选 **强制继续**(降级为仅场景图或分镜主图参考,不再走多图 Omni)
- **传统模式缺图拦截**:经典分镜在无分镜参考图时弹窗提示「需先生成或上传分镜图片」,不再提供纯文案强行生成
- **分镜 / 视频 / 导入导出**:`episodeStoryboardService``storyboardService``framePromptService``dramaImportService` / `dramaExportService` 等与全能字段、尾帧、提示词清洗(`framePromptSanitize`)联动优化
- **工程结构**:桌面壳统一使用仓库内 `backend-node`,移除重复的 `desktop/backend-app-secure` 副本目录
### 文档
- 根目录 `README.md``docs/en.md``index.html`、各子包 README 同步 **v1.2.7**(版本徽章、下载链接示例、最新亮点)
- `frontweb` / `backend-node` / `desktop``package.json` 与 lock 文件顶层 **version** 统一为 **1.2.7**
---
## [1.2.6] - 2026-04-12
### 文档
- 根目录 `README.md``docs/en.md`、桌面/后端/前端 README 同步 **v1.2.6**(版本徽章、示例 exe 路径、「最新亮点」标题)
- `frontweb` / `backend-node` / `desktop``package.json` 与各自 `package-lock.json` 顶层 **version** 统一为 **1.2.6**
### 说明
-**v1.2.5** 相比无新增功能;主要为**桌面安装包/便携 exe 显示与产物版本号**提升至 **1.2.6**,并与仓库内各包版本对齐
---
## [1.2.5] - 2026-04-09
### 新增
- **火山方舟 Seedance 2.0 视频**:后端 `videoClient.js` 支持方舟「全能 / 多参考图」链路;**AI 配置 → 视频** 可选接口规范 **`volcengine_omni`**,模型填控制台接入点(如 `doubao-seedance-2-0-260128``doubao-seedance-2-0-fast-260128`,以控制台为准);参考图按 `role: reference_image` 提交,Seedance **2.x** 时长自动吸附到 **415 秒**
- **分镜「全能模式」**:制作页分镜可在「经典分镜」与「全能模式」间切换;全能模式中间为**片段描述**(独立字段 `universal_segment_text` 落库),可用「根据分镜生成提示词」由文本模型生成含运镜、机位等的描述;生视频时若片段描述非空则**仅提交该段**,不拼接下方结构化「视频提示词」,避免覆盖 `@图片N` 编排
- **多图参考与 `@图片1`…**:全能模式下列出场景、角色、物品、分镜主图等为参考图(顺序与界面说明一致,方舟侧最多 **9** 张);提示词中用 **`@图片1`**、**`@图片2`**… 引用(`@图片N` 后建议加半角空格);可与 **`kling_omni`**(可灵 Omni)或 **`volcengine_omni`**(火山 Seedance 2.0 等)配合使用
### 文档
- 根目录 `README.md``docs/en.md`、桌面/后端/前端 README、`docs/configuration.md` 同步 **v1.2.5** 说明(Seedance 2.0、全能模式、接口规范)
- `frontweb` / `backend-node` / `desktop``package.json` 版本号统一为 **1.2.5**
---
## [1.2.3] - 2026-03-24
### 新增
- **分镜解说旁白(narration)**:分镜生成请求支持 `include_narration`;数据库 `storyboards` 表新增 `narration` 字段;提示词要求与角色对白 `dialogue` 分离的纪录片式/第三人称解说文案
- **导出解说 SRT**:前端按分镜顺序与 `duration` 累计时间轴,导出非空解说为 SubRip 文件;项目 `metadata.storyboard_include_narration` 持久化勾选状态
- **解说 TTS**:分镜视频区在存在解说文案时提供「解说配音」按钮,沿用现有音频合成接口
### 修复
- **首镜(及前几镜)解说永久为空**:流式增量写入会先插入不完整对象;原逻辑在最终 `saveStoryboards` 时跳过已插入行且不再更新,导致 `narration` 等后出字段无法落库;改为对增量已写入的 `storyboard_number` 用最终解析结果执行 `UPDATE` 合并(`deriveStoryboardFieldsFromAi` + `updateStoryboardRowFromDerived`
- **解说漏写**:用户提示与系统提示增加最高优先级说明(首镜开场解说、全镜非空等),减少模型将建立镜头留空
### 优化
- **解说相关 UI**`FilmCreate.vue` 中解说多行输入框、复选框说明与「导出解说 SRT」按钮在浅色/深色主题下的字色与背景对比度;导出按钮白字紫底
### 文档
- 根目录 `README.md``docs/en.md` 版本徽章与「最新亮点」同步至 v1.2.3
- `frontweb` / `backend-node` / `desktop``package.json` 版本号统一为 1.2.3
---
## [1.2.2] - 2026-03-17
### 新增
- **视频帧连贯性(连贯帧模式)**:批量生成分镜视频新增「连贯帧模式」开关;启用后强制顺序生成,每条视频完成后自动用浏览器 Canvas 提取末帧,上传后作为下一条视频的 `first_frame_url` 参考图,有效减少视频片段间的跳跃感;tooltip 详细说明支持的模型(kling-video、wan2.2-kf2v-flash 等)及不支持模型的静默降级行为
- **小说/长文章节导入**:故事生成区域新增「导入小说」按钮;支持粘贴文本或上传 `.txt/.md` 文件;后端基于正则识别章节标题自动分割,可选 AI 改写为剧本格式;返回章节列表自动填入剧本编辑区,每章对应一集(`novelImportService.js`
- **场景 AI 生成 tooltip**:场景 AI 生成按钮悬停提示改为「多角度图一张(正/侧/俯/仰)」,原重复的「多视角」独立按钮已移除
- **ffmpeg 自动解压**:安装包首次启动时自动将内置的 `ffmpeg.exe`/`ffprobe.exe``resources/ffmpeg/` 复制到 userData 工作目录,无需用户手动配置;已存在则跳过,支持用户手动替换版本;`electron-builder-lite.json` 通过 `extraResources``backend-node/tools/ffmpeg` 打包进安装包
### 修复
- **doubao/火山引擎模型分镜 JSON 解析失败**:修复 doubao-1-5-pro 等模型将 JSON 数组包装成 `{"storyboards":[...]}` 对象格式、叠加 max_tokens 截断导致全部修复策略失效的问题;新增 `extractWrappedArrayStr()` 函数,检测到包装对象后提取内部数组候选串,再走截断修复 → jsonrepair 兜底流水线;同样适用于流式增量保存路径(`tryIncrementalSave`
- **分镜截断后续写内容重复**:续写 prompt 原只携带末尾 5 条分镜,AI 不知道前面已覆盖哪些情节,导致母亲关怀、雪儿致歉等段落反复出现;改为将全量已生成分镜标题列表(`镜号. [段落] 标题`)一并传入,明确禁止 AI 重复,续写连贯性大幅提升
- **分镜生成默认 max_tokens 过小**:默认不传 max_tokens 导致 doubao 等模型使用 4096 token 默认上限,12000 字符即截断;改为默认传 `max_tokens: 16384`;若模型返回参数错误(HTTP 4xx 含 max_tokens/length/token 关键字),自动降级为不传 max_tokens 重试,所有尝试均记录日志
- **JSON 字符串内原始换行符**:中文 AI 模型在对话/描述字段直接输出换行字节(非 `\n` 转义),导致 `JSON.parse` 报 "Unterminated string";在 `safeParseAIJSON` 预处理阶段新增 `escapeNewlinesInStrings()` 字符级状态机扫描,将字符串值内的 `\n`/`\r`/`\t` 原始字节转义,修复后所有截断修复策略均可正常执行
### 优化
- **火山引擎默认文本模型**:一键配置和手动选择时,文本/对话默认模型由 doubao-1-5-pro-32k 改为 deepseek-v3-2-251201,生成质量更稳定
- **首页隐藏「自由创作」和「素材库」按钮**:功能待完善,暂时注释隐藏,路由与页面代码保留
---
## [1.2.1] - 2026-03-17
### 新增
- **可灵 Kling AI 接入**:新增可灵图片生成协议(kling-image / kling-omni-image)及视频生成协议(kling-video / kling-omni-video / kling-motion-control),AI 配置页可直接选择可灵作为服务商,Base URL / 端点自动填充
- **场景/道具"加入本集"**:场景库和道具库弹窗新增「加入本集」按钮,与角色库体验对齐;后端 `createScene` / `create`prop)补充保存 `image_url``local_path` 字段,确保素材图片 URL 正确保存
- **视频历史记录与主视频选择**:分镜视频重新生成后保留历史版本,下方缩略图条带一览可选;点击历史缩略图即切换主视频,并将选择持久化到分镜记录的 `video_url`;合成视频时后端优先使用用户选定版本,兜底取最新生成记录
- **参考图独立字段(ref_image)**:角色、场景、道具各自新增 `ref_image` 数据库字段,专门存储用户手动上传的参考图,与 AI 生成的主图(`image_url`/`local_path`)完全分离,互不干扰;`migrate.js` 自动迁移
- **编辑弹窗参考图区域(角色/道具/场景)**:添加与编辑模式均显示参考图上传区;编辑时优先展示已保存的 `ref_image`,其次半透明展示主图;上传新参考图后点击保存自动上传并持久化到 `ref_image` 字段;支持"移除参考图"操作
- **从参考图提取描述**:参考图存在时一键调用视觉 AI 提取角色外貌/场景/道具描述,直接填入对应文本框;`resolveEntityImageSource` 优先使用 `ref_image`(高于主图和 `extra_images`
### 修复
- **合成视频主视频不对**`getVideoUrlForStoryboard` 调整为优先读 `storyboard.video_url`(用户选定主视频),再兜底 `video_generations ORDER BY created_at DESC`,修复合成时始终取最新生成记录、忽略用户已选定历史视频的问题
- **重新生成视频后主视频混乱**`onGenerateSbVideo` / `startBatchVideoGeneration` 在提交新生成任务前自动清除 `storyboard.video_url` 及前端 `sbSelectedVideoId`,确保新视频生成完成后合成使用最新记录
- **视觉 AI 返回空内容(o4-mini**`max_tokens: 400` 过小导致推理模型(o4-mini)推理过程耗尽 token 而输出为空;改为 `max_tokens: 2000`;同时检测模型名是否以 `o数字` 开头,推理模型改用 `max_completion_tokens`(不能同时传两个参数),并跳过 `temperature` 参数(推理模型不支持)
- **视觉 API system 消息兼容性**:推理模型(o1/o3/o4 系列)不识别 `system` role,改为将 system prompt 合并到 user 消息前缀传入
- **提取描述后保存的参考图覆盖主图**:修复原先将参考图存为 `image_url/local_path`(覆盖 AI 生成主图)的问题,改为存入独立的 `ref_image` 字段;`putImage` 路由调整为只有明确传入 `image_url` 时才更新主图
- **场景导入重复**:工程导入时按 `location|time` 去重,避免多次导入同名场景累积重复条目
### 优化
- **视觉提示词重构**:角色外貌提取提示词改为"角色造型设计"语境(cosplay/概念图),明确要求描述发型/五官/体型/服装四维度,忽略背景,并加入推断指引和拒绝检测(`isRefusalResponse`);场景/道具提示词同步优化
- **提示词单一来源**`EXTRACT_PROMPTS` 常量统一在 `aiClient.js` 定义并导出,`characterLibraryService``sceneService``propService` 直接引用,消除多处重复维护
### 文档
- README 新增「AI 生成实拍效果」章节,展示即梦 1.0 生成的 3 段连续分镜视频,验证跨镜头角色一致性
- README 新增 3 张界面截图(角色管理、专业分镜参数、场景库加入本集)
- AI 服务商表格加入可灵 Kling AI(图片 + 视频)
---
## [1.2.0] - 2026-03-14
### 新增
- **角色图生提示词(polished_prompt)**:提取角色后异步自动生成 AI 润色的最终图像提示词并保存到数据库;编辑弹窗展示该提示词,支持手动编辑与一键重新生成;生成图片时直接使用该提示词,无需临时拼接
- **道具图生提示词(prompt)**:同上,道具提取后异步生成专业英文图像提示词,展示在编辑弹窗,可编辑和重新生成
- **场景四视图提示词(polished_prompt)**:场景提取后异步生成完整四视图图像提示词,展示在编辑弹窗,与角色/道具体验一致
- **结构化镜头角度三元组**:新增 `angleService.js`,定义 8 水平方向 × 4 仰俯角度 × 3 景别共 96 种组合,分镜表新增 `angle_h``angle_v``angle_s` 字段;分镜编辑区原单行文本输入替换为三个下拉选择器(景别 / 俯仰 / 方向),旁边实时显示中文标签(如「特写·俯拍·正面」)
- **分镜道具自动关联**:分镜 AI 生成时自动提取该镜头使用的道具 ID,写入 `storyboard_props` 表;分镜卡片显示关联道具,编辑弹窗可手动调整
- **分镜段落分组(segment)**:分镜生成时 AI 自动分配幕次/段落(`segment_index` + `segment_title`),前端以段落标题分组展示(如「第一幕:相遇」)
- **角色身份类型下拉**:编辑角色弹窗将「身份/定位」由文本框改为下拉选择器(主角 / 配角 / 次要角色),与 AI 提取的固定值 `main/supporting/minor` 对齐;角色卡片名称旁显示对应颜色 Tag
- **风格双语提示词**:24 种创作风格每项新增 `promptEn`(英文)字段,`prompt` 保留中文说明;图像/视频 AI 调用时自动使用英文版(效果更好),中文版用于界面展示;`getSelectedStylePrompt()` 返回英文,`getSelectedStylePromptZh()` 返回中文
- **分镜图片/视频提示词全中文**:重构 `generateImagePrompt` / `generateVideoPrompt`,输出全中文提示词,角度部分使用中文标签(`特写·俯拍·正面`),视频提示词同时附上英文括号说明兼容双语模型
- **配置文件统一**:合并 `config.yaml``config.example.yaml` 为单一 `config.yaml`,简化配置管理;Electron 打包与开发环境均只依赖 `config.yaml`
### 优化
- **编辑弹窗体验**:角色、道具、场景编辑弹窗宽度从固定 720px 改为屏幕 75%;所有多行文本框改为 `autosize` 自适应高度(最少 3~5 行,内容多时自动撑高最多 16 行),提示词框不再需要手动滚动
- **默认风格质量描述**`config.yaml``default_role_style``default_scene_style``default_prop_style` 改为风格无关的通用画质描述词,不再预置特定艺术风格,避免覆盖用户在 UI 选择的风格
### 修复
- **场景 polished_prompt 前端轮询不结束**:修正 Axios 拦截器自动解包 `data` 层导致的路径多嵌套问题(`res?.data?.scene``res?.scene`),轮询现可正确检测到生成完成
- **分镜段落生成后刷新丢失**`dramaService.rowToStoryboard``storyboardService` 补充返回 `segment_index``segment_title``angle_h``angle_v``angle_s` 字段,刷新页面后分组和角度信息不再丢失
- **分镜道具批量保存缺失**`saveStoryboards`(整批保存路径)补充道具关联写入逻辑,与 `insertOneStoryboard`(流式逐条路径)保持一致
- **风格切换不生效**:修复 `backgroundExtractionService.js` 未将请求 `style` 参数透传给异步 `generateScenePromptOnly` 的问题;修复 `generationStyleOptions` value 字段曾改为长描述导致旧项目 v-model 不匹配的问题
### 架构
- 新增 `angleService.js`:结构化角度定义、`toChineseLabel()``toPromptFragment()``parseFromLegacyText()` 方法
- 新增迁移文件:`15_storyboard_angle_structured.sql``16_character_polished_prompt.sql``migrate.js` 自动执行)
- `characterLibraryService` 新增 `generateCharacterPromptOnly()``sceneService` 新增 `generateScenePromptOnly()``propService` 新增 `generatePropPromptOnly()`
- 新增 API 路由:`GET /characters/:id``POST /characters/:id/generate-prompt``GET /scenes/:id``POST /scenes/:id/generate-prompt``GET /props/:id``POST /props/:id/generate-prompt`
---
## [1.1.16] - 2026-03-14
### 修复
- **场景四视图生成后不显示**`createAndGenerateImage` 新增 `scene_id` 参数支持,图片存储目录从 hardcode `characters/` 改为动态判断(`scenes/` / `characters/`),生成成功后自动回写 `scenes.image_url` / `scenes.local_path`
- **分镜图生成结果仍为宫格布局**:修正 Gemini 多模态输入结构,参考图说明文字与图片数据严格交替排列(`[说明] → [图] → [说明] → [图] → [生成指令]`),移除错误的 `systemInstruction` 字段,在生成指令中明确要求输出单张图
- **角色参考图干扰分镜布局**:优先使用拆分后的单张面板作为参考(场景取 `quad_panel_0` 建立远景,角色取 `quad_panel_1` 正面全身),无拆分面板时 fallback 到四视图合图
- **拆分角色面板无法按 ID 查询**:`splitQuadGridToImages` INSERT 时补充 `character_id` 字段,确保面板图片可关联到对应角色
- **参考图标签与传图数量不对齐**`extra_images` 推入逻辑移入主图存在分支,`refLabels` 强制裁剪到 `refs.length`Gemini parts 构建时同步对齐
### 架构
- `imageClient.js``callGeminiImageApi` 重构多模态 parts 构建逻辑;`MAX_GEMINI_REF_IMAGES` 从 3 提升至 4(支持场景参考图 1 张 + 角色参考图最多 3 张);`createAndGenerateImage` 支持 `scene_id`,回写 `scenes`
- `imageService.js``splitQuadGridToImages` INSERT 增加 `character_id`;Step 2 参考图解析优先取拆分面板;移除 Step 2.5 冗余的 `CRITICAL OUTPUT REQUIREMENT` 文字注入;`callImageApi` 调用时传入 `system_prompt`(含参考图标签映射)
- `sceneService.js``createAndGenerateImage` 调用时传入 `scene_id`
---
## [1.1.15] - 2026-02-28
### 新增
- **多集剧本生成**:故事生成区新增「生成集数」下拉(1 / 2 / 3 / 4 / 5 / 6 集,默认 1),AI 一次性输出对应集数的连续剧本;返回格式统一为 JSON 数组,每集含 `episode`(序号)、`title`(标题)、`content`(约 800 字正文),多集剧情前后衔接、结尾留悬念;前端自动将所有集数保存到项目并默认选中第 1 集
- **标签优化**:故事生成区「风格」改为「故事风格」、「类型」改为「剧本类型」,语义更清晰
- **AI 并发生成(图片 & 视频)**:「AI 配置 → 生成设置」新增「图片并发数」和「视频并发数」选项(默认各 3,可选 1/2/3/5/8/10 或自定义);一键生成流水线(角色图 → 场景图 → 分镜图 → 分镜视频)及「补全并生成」均采用 `runConcurrently()` 并发执行,不再串行等待
- **实时任务进度**:流水线运行时底部状态栏同步展示当前正在执行的所有并发任务标签(如「分镜图 #3」「角色图 #1」),含脉冲动画
- **可视化风格选择器(StylePickerButton)**:一键生成视频的「生成风格」从普通下拉框升级为图文选择器弹窗,每种风格显示缩略图(本地 `public/style-thumbs/`)与梯度色块兜底,支持按分类浏览和名称搜索,弹窗尺寸为 `90vw`(最大 1100px),可一次预览更多风格
- **AI JSON 输出强化**:分镜生成、角色提取、场景提取、道具提取全面启用 `json_mode: true`(向兼容模型发送 `response_format: { type: "json_object" }` 约束),从模型层面减少非法 JSON 输出概率
- **jsonrepair 自动修复**`safeParseAIJSON` 集成 `jsonrepair` 库作为兜底修复策略,自动处理未引号字符串值、括号内容、尾逗号等 AI 常见畸形 JSON;修复时输出 WARN 日志记录修复策略、成功挽救的条目数、原文长度等,方便统计破损率
- **`min_max_tokens` 机制**`aiClient.generateText` 新增 `min_max_tokens` 参数,调用方可声明最低 token 需求;若用户 AI 配置的 `settings.max_tokens` 低于此需求,自动提升并打 WARN 日志,确保多集剧本等长输出任务不被截断
- **全局设置持久化**:后端新增 `global_settings` 表与 `settingsService``getGlobalSetting` / `setGlobalSetting`),并暴露 `GET/PUT /settings/generation` 接口,持久化并发数等全局生成配置
- **AI 配置端点预览**:AI 配置弹窗选择厂商/协议后自动显示实际请求 URL(图片提交地址、视频提交地址),方便排查配置是否正确;特别处理 Google Gemini 的端点拼接规则
### 修复
- **供应商锁定 `api_protocol` 丢失**`applyVendorLock``INSERT` 语句补充 `api_protocol` 字段,修复锁定厂商的打包 exe 中视频 API 协议路由错误(如 Vidu 接口返回 `images is required`
- **导入配置 `api_protocol` 未恢复**`importConfigs` 中的 `aiAPI.create` 调用补充 `api_protocol`,修复导入旧配置后协议字段丢失问题
- **打包 exe 分镜图片 `fetch failed`**`uploadService.js` 中图片下载从 Node.js 原生 `fetch` 改为自定义 `downloadBufferViaNodeHttp``http`/`https` 模块),支持 3 次重试、30s 超时、自动跟随重定向和 `User-Agent`,解决 Electron 打包环境网络兼容性问题
- **`no such table: storyboard_characters` 警告**`migrate.js` 补充 `CREATE TABLE IF NOT EXISTS storyboard_characters`,消除九宫格提示词生成时的数据库报错
- **端点预览 URL 重复 `/v1`**OpenAI 图片端点和 MiniMax 视频端点的预览 URL 去除重复拼接的 `/v1`
### 架构
- **后端**`safeJson.js` 引入 `jsonrepair` 包;`migrate.js` 新增 `storyboard_characters``global_settings` 表;`settingsService.js` 新增全局 KV 设置读写;`routes/settings.js` 暴露并发数 API`routes/index.js` 注册新路由并向 `settingsRoutes` 传递 `db``storyGenerationService.js` 重写为多集 JSON 数组模式;`aiClient.js` 支持 `min_max_tokens`;分镜/角色/背景/道具服务统一启用 `json_mode`
- **前端**:新增 `StylePickerButton.vue` 可视化风格选择器组件;`FilmCreate.vue` 新增 `runConcurrently()` 并发工具函数、`pipelineActiveTasks` 任务进度集合、`storyEpisodeCount` 集数控制、多集 `onGenerateStory` 逻辑;`AIConfigContent.vue` 新增「生成设置」Tab(图片/视频并发数)及端点预览面板;`api/prompts.js` 新增 `generationSettingsAPI``public/style-thumbs/` 新增 30 张本地风格缩略图
---
## [1.1.14] - 2026-02-28
### 新增
- **官方仓库链接**`README.md``backend-node/README.md``CHANGELOG.md` 均新增 GitHub 与 Gitee 官方仓库徽章链接,方便用户直接提交 Issue 或 PR
- **文档规范化**`backend-node/README.md` 顶部新增官方仓库说明及 Issue 反馈引导
---
## [1.1.13] - 2026-03-09
### 新增
- **分镜图相机角度视角修正**`framePromptService.js` 新增 `expandAngleDescription()`,将分镜的 `angle` 字段(平视/仰视/俯视/侧面/背面)翻译为完整的相机透视描述,注入图像提示词上下文,使 AI 生成的背景视角与镜头角度一致
- **四宫格序列图模式(后端拆分)**:分镜配置区新增全局「四宫格序列图」开关。开启后:
- 生成分镜图时传 `frame_type: 'quad_grid'`,后端并行生成首帧/关键帧×2/尾帧共 4 个帧提示词,拼装为 2×2 象限布局提示词调用一次图片 API
- 图片保存到本地后,后端使用 `sharp` 自动将整图拆分为 4 张子图(左上/右上/左下/右下),每张子图左上角叠加位置标签,分别存为独立的 `image_generation` 记录(`frame_type = quad_panel_0~3`
- 4 张子图与普通生成图完全一致,支持点击缩略图切换主图、重新生成自动更新、历史记录保留
- 主图选择持久化到 `storyboard.image_url / local_path`,刷新页面后自动从后端恢复
### 修复
- **分镜主图刷新后恢复**`dramaService.rowToStoryboard()``storyboardService.getStoryboardById()` 均补充返回 `image_url``local_path``main_panel_idx` 字段,前端 `restoreSelectionsFromBackend()` 可正确从后端数据比对恢复主图选中状态
- **四宫格生成无变化**:移除前端 Canvas 拆分逻辑后,重新生成触发新的后端拆分,不再受旧内存缓存影响
- **四宫格图片白框**prompt 改为"NO borders of any color (black, white, gray)panels must be seamlessly adjacent with no gaps",杜绝任何颜色边框
### 架构
- **后端**`imageService.js` 新增 `splitQuadGridToImages()`(依赖 `sharp`),Step 7 自动触发;`buildQuadGridPrompt()` 组装四宫格提示词;`storyboards` 表新增 `image_url``local_path``main_panel_idx` 列(migrate.js 自动迁移)
- **前端**:删除全部 Canvas 拆分相关代码(`sbQuadPanels``splitImageIntoQuadrants``triggerSplitQuadGrid``_persistPanelToBackend` 等约 120 行),四宫格子图完全复用普通单张图片的展示与选择流程;缩略图条对 `quad_panel_*` 类型图片自动显示位置标签
---
## [1.1.11] - 2026-03-06
### 新增
- **批量生成分镜图 / 批量生成分镜视频**:在「重新生成分镜」按钮右侧新增两个右对齐批量按钮,支持一键为所有缺图分镜生成图片、为所有缺视频分镜生成视频,含实时进度、错误日志和随时停止功能
- **角色/场景影响分镜面板**:角色、场景卡片描述下方新增「影响的分镜:#XX #ZZ」标签行及「↻ 重新生成分镜图」按钮,点击可批量重新生成与该资源关联的所有分镜图片,含确认弹窗和实时进度显示
- **多并发 AI 生成转圈**:同时点击多个角色/道具/场景的「AI生成」或「重新生成」按钮,每个按钮独立保持转圈状态,互不干扰(底层由 `ref(null)` 改为 `reactive(new Set())` 实现)
- **提示词管理动态同步**`promptOverrides.js` 中的 `default_body``locked_suffix` 改为从 `promptI18n.js` 动态读取,新增 `getDefaultPromptBody(key)``getLockedSuffix(key)` 导出函数,UI 展示内容与运行时提示词始终一致,彻底消除双维护问题
- **userData 路径统一**`desktop/main.js` 将开发模式与打包 exe 的用户数据目录统一固定为 `localminidrama-desktop`,并在首次运行时自动迁移旧路径 `LocalMiniDrama` 下的数据,彻底解决开发/发布切换时数据丢失问题
### 修复
- **手动选择角色不进入分镜生成**`FilmCreate.vue``onStoryboardCharacterChange` / `onStoryboardSceneChange` 函数原来为空,导致用户在分镜卡片上手动多选角色或切换场景后,选择不会持久化到后端。现已实现调用 `storyboardsAPI.update`,确保分镜脚本生成时使用用户手动指定的角色/场景
- **道具/角色参考图不生效**:修复 `imageClient.js``resolveImageRef` 函数的 `isLocalhost` 判断逻辑,使其同时检测 URL 字符串本身是否包含 `localhost/127.0.0.1`;修复 `imageService.js` 在构建分镜参考图列表时未读取 `extra_images` 字段的问题
- **分镜数量控制优化**:当用户指定分镜数量时,在系统提示词末尾动态追加 HIGHEST PRIORITY 级别的数量约束覆盖指令,防止系统提示词中的「独立动作数量匹配」规则与用户数量约束冲突
- **角色数量与分镜动作不一致**:强化 `promptI18n.js` 中的 `character_constraint``getStoryboardUserPromptSuffix` 及系统提示词,明确要求 `characters` 数组只填写在本镜头 `action/dialogue` 中有实际描写行为的角色,数量必须与动作描述中出现的人物一致
### 架构
- `promptI18n.js` 新增 `getDefaultPromptBody(key)` / `getLockedSuffix(key)` 两个导出函数,作为提示词默认内容的唯一来源
- `promptOverrides.js` 精简为只维护提示词元数据(key / label / description),彻底去除内容冗余副本
---
## [1.1.10] - 2026-03-05
### 新增
- **Google Gemini 图片生成支持**:新增 `callGeminiImageApi`,使用 `generateContent` 接口,支持 `gemini-2.5-flash-image``gemini-3.1-flash-image-preview``gemini-3-pro-image-preview` 等模型
- **Google Gemini (Veo) 视频生成支持**:新增 `callGeminiVideoApi`,支持 `veo-3.1-generate-preview``veo-3.0-generate-preview``veo-3.0-fast-generate-preview` 等模型,含异步任务轮询
- **Gemini 参考图支持(图床方案)**:分镜图片生成时,参考图先上传至中转图床获取公开 URL,再通过 `fileData.fileUri` 传给 Gemini,彻底解决 `inlineData` base64 导致的 503 内存溢出问题
- **图床上传缓存**:新增 `image_proxy_cache` 表,本地图片路径与图床 URL 一一映射,相同图片只上传一次,命中缓存时跳过上传(附 `migrations/12_image_proxy_cache.sql`
- **API 接口规范字段**:数据库新增 `api_protocol` 列(`migrations/11_add_api_protocol.sql`),可为每条 AI 配置显式指定接口类型(`openai` / `volcengine` / `dashscope` / `gemini` / `nano_banana`),优先级高于厂商自动推断,解决中转站自定义配置走错接口的问题
- **AI 配置页面「接口规范」字段**:自定义厂商时显示下拉框供用户选择接口类型;预设厂商自动填充,无需手动选
- **Gemini 作为分镜图片生成厂商**:在 AI 配置页面,分镜图片生成 (`storyboard_image`) 服务类型增加 Gemini 系列模型选项
- **Gemini 作为视频生成厂商**:在 AI 配置页面,视频生成 (`video`) 服务类型增加 Google Gemini (Veo) 系列模型选项
- **图片/视频风格扩展**:在 `DramaDetail.vue``FilmCreate.vue``FilmList.vue` 三处将风格选项从 8 个扩展至 29 个,按写实、动漫、中国风、绘画、幻想、数字六大类使用 `el-option-group` 分组展示
- **新增 3:4 竖版比例**:画面比例选项新增「3:4 竖版」
- **分镜生成数量上限提升**:前端 `storyboardCount` 最大值从 50 提升至 200
- **全链路生成日志**:图片生成全链路(接收请求 → 解析参考图 → 图床上传 → Gemini API → 保存图片)均打印带计时的结构化日志,便于排查耗时瓶颈
- **`max_tokens` 自适应上限**`aiClient.generateText` 读取 AI 配置 `settings.max_tokens` 作为上限,调用方传入值超出时自动截断并打印警告,避免不同模型因上限差异导致 400 错误
### 修复
- **修复 Gemini `MALFORMED_FUNCTION_CALL` 错误**`generateContent` 接口的请求体中,`aspectRatio` / `numberOfImages` 必须直接放在 `generationConfig` 顶层,而非嵌套在 `imageGenerationConfig`(该字段为 Imagen 独立接口专属),嵌套写法会干扰模型内部 `google:image_gen` 工具调用
- **修复分镜生成 `max_tokens` 超限 400 错误**:移除 `episodeStoryboardService.js` 中写死的 `32768`,由 AI 配置的 `settings.max_tokens` 控制或由模型使用默认值
- **修复分镜生成静默失败**`onGenerateStoryboard` 轮询超时时间从 6 分钟延长至 15 分钟;正确检查 `pollRes.status` 只在 `completed` 时显示成功提示;超时/失败给出明确提示
- **修复 HTTP 500 错误信息不清晰**:`request.js` Axios 拦截器将后端具体错误信息写回 `error.message`,消除「Request failed with status code 500」的模糊提示
- **图床上传重试机制**`uploadToImageProxy` 上传失败时自动重试最多 3 次,每次均打印尝试序号和耗时
### 架构
- 确认 `desktop/backend-app` 为构建时由 `copy-backend.js` 自动从 `backend-node` 生成,无需手动同步,日常只需维护 `backend-node`
---
## [1.1.9] - 2026-02-xx
### 新增
- **厂商锁定模式**`config.yaml` 新增 `vendor_lock` 配置项,启用后强制使用指定 AI 厂商配置,用户仅可修改 API Key 和默认模型,无法新增/删除配置;打包的 exe 每次启动自动同步锁定策略
- **全页面 UI 美化**:四个页面(首页/剧集管理/制作页/AI配置)统一升级为极光渐变背景 + 毛玻璃 Header + 玻璃拟态卡片;Header 改为 `sticky` 吸顶
- **品牌标识双行 Logo**:左上角改为「本地短剧助手 / LocalMiniDrama」双行设计,紫色渐变文字
- **面包屑导航**:剧集管理页和制作页 Header 新增 `` 分隔符 + 项目名标签;返回按钮移至项目名右侧
- **NanoBanana 图片厂商**:新增 NanoBanana 作为独立图片生成厂商,支持 nano-banana-2 / nano-banana-pro / nano-banana 三个模型
- **AI 配置导出 / 导入**:一键导出全部 AI 配置为 JSON 文件,换机或团队共享配置直接导入
- **端点字段可配置**:图片、分镜、视频类型配置均可手动填写「提交端点」和「查询端点」
### 修复
- **角色提取优化**:移除错误的固定数量限制,改为提取剧本中所有有名字的角色;去除无实际用途的 `personality` 字段,加强中文输出约束,速度提升约 40%
- **分镜截断修复**`max_tokens` 从 8192 提升至 32768,新增 `repairTruncatedJsonArray` 智能修复截断 JSON
- **分镜参考图优化**:角色图和场景参考图优先读取本地文件并转为 Base64 传给图片 API
- **doubao-seedream 参数修正**:参考图字段名由 `imageUrls` 修正为官方规范 `image`,自动移除 `n` 参数并关闭水印
---
## [1.1.8] - 2026-02-xx
### 新增
- **提示词高级设置**:AI 配置页新增「高级设置(提示词)」Tab,支持自定义 9 个核心提示词,修改后立即生效;JSON 输出格式部分加锁保护;随时一键恢复默认
- **AI 厂商自定义选项**:厂商下拉菜单底部新增「自定义」选项
### 修复
- **多项 UI/UX 优化**:Aurora 渐变背景、玻璃拟态卡片、双行 LogoDramaDetail / FilmCreate / AiConfig 页面风格统一
- **提示词持久化**:自定义提示词通过 SQLite 持久存储(`prompt_overrides` 表),后端内存缓存加速读取
---
## [1.1.6] - 2026-01-xx
### 新增
- **工程导出/导入**:完整打包工程为 ZIP(含图片、视频、文字、配置),换机或分享一包搞定
- **画面比例设置**:新建项目时选定比例(16:9 / 9:16 / 1:1 / 4:3 等),后续生成全程自动适配
- **视频参数扩展**:视频生成支持 `resolution``seed``camera_fixed``watermark` 等参数
- **视频合并进度展示**:合成完整剧集视频时,前端实时展示合并进度
### 修复
- **图片生成去水印**:火山引擎图片生成默认传入 `watermark: false`
- **导出 ZIP 修复**:修复导出文件只有 9 字节的问题
- **导入数据关联修复**:导入时正确创建 `episode_characters`,修复导入后看不到角色/场景/道具的问题
---
## [1.1.4] - 2026-01-xx
### 新增
- **剧集管理页**:新增独立的剧集管理页面(`/drama/:id`),统一管理剧集信息、本剧资源库与分集列表
- **资源库分层**:本剧资源库(按剧过滤)与全局素材库严格隔离
- **素材库导入**:在剧集管理页可一键从全局素材库导入角色/场景/道具
- **明暗主题切换**:支持暗色/浅色模式,偏好持久保存
---
## [1.1.x] - 早期版本
- 一键生成流水线:自动跳过已有内容,失败自动重试最多 3 次
- 实时进度展示:流水线执行中实时显示步骤与错误日志
- 视频/图片提示词编辑:每个分镜可单独查看和修改提示词
- AI 配置优化:支持多种服务商连接测试
---
## [1.0.x] - 2026-01-xx
- 项目立项与基础架构搭建(Vue 3 + Node.js + Electron
- 剧本生成、角色/场景/道具提取
- 分镜生成与图片/视频生成核心流程
- SQLite 数据持久化
- Windows exe 打包
+48
View File
@@ -0,0 +1,48 @@
# 行为准则 / Code of Conduct
## 我们的承诺 / Our Pledge
为了营造开放、友好的社区环境,我们承诺让每一位参与者——无论年龄、体型、残障状况、民族、性别认同、经验水平、国籍、外貌、种族、宗教或性取向——都能获得无骚扰的参与体验。
In the interest of fostering an open and welcoming environment, we pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, level of experience, nationality, personal appearance, race, religion, or sexual identity.
---
## 行为标准 / Our Standards
**鼓励的行为 / Examples of positive behavior:**
- 使用友好、包容的语言 / Using welcoming and inclusive language
- 尊重不同的观点和经历 / Being respectful of differing viewpoints and experiences
- 优雅地接受建设性批评 / Gracefully accepting constructive criticism
- 关注对社区最有利的事情 / Focusing on what is best for the community
- 对其他社区成员表示同情 / Showing empathy towards other community members
**不可接受的行为 / Unacceptable behavior:**
- 使用性化语言或图像,以及不受欢迎的性关注 / Sexualized language or imagery and unwelcome sexual attention
- 挑衅、侮辱性/贬损性评论,以及人身或政治攻击 / Trolling, insulting/derogatory comments, and personal or political attacks
- 公开或私下骚扰 / Public or private harassment
- 未经明确许可发布他人私人信息 / Publishing others' private information without explicit permission
- 其他在专业环境中可被合理认定为不当的行为 / Other conduct which could reasonably be considered inappropriate
---
## 执行责任 / Enforcement
项目维护者有责任明确行为标准,并对不当行为采取适当的纠正措施。
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate corrective action in response to any unacceptable behavior.
---
## 报告 / Reporting
如遇到违反行为准则的情况,请通过 README 中的联系方式私下联系项目维护者。
If you experience or witness unacceptable behavior, please contact the project maintainer privately via the contact information in the README.
---
## 归属 / Attribution
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) v1.4。
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 1.4.
+160
View File
@@ -0,0 +1,160 @@
# 贡献指南 / Contributing Guide
感谢你对 LocalMiniDrama 的关注!无论是报告 Bug、提功能建议,还是贡献代码,都非常欢迎。
> Thank you for your interest in LocalMiniDrama! All forms of contribution are welcome — bug reports, feature suggestions, or code.
---
## 目录 / Table of Contents
- [行为准则](#行为准则--code-of-conduct)
- [报告 Bug](#报告-bug--reporting-bugs)
- [功能建议](#功能建议--feature-requests)
- [贡献代码](#贡献代码--contributing-code)
- [开发环境搭建](#开发环境搭建--development-setup)
- [代码风格](#代码风格--code-style)
- [提交规范](#提交规范--commit-convention)
---
## 行为准则 / Code of Conduct
参与本项目即表示你同意遵守 [行为准则](CODE_OF_CONDUCT.md)。
By participating, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
---
## 报告 Bug / Reporting Bugs
1. 先在 [Issues](../../issues) 搜索确认是否已有相同问题
2. 如果没有,点击 [新建 Issue](../../issues/new/choose) 并选择 **Bug 报告** 模板
3. 尽量填写完整的复现步骤、环境信息和截图
---
## 功能建议 / Feature Requests
1. 在 [Issues](../../issues) 中搜索是否已有相似建议
2. 选择 **功能建议** 模板新建 Issue
3. 描述清楚使用场景,有助于我们评估优先级
---
## 贡献代码 / Contributing Code
### 基本流程
```bash
# 1. Fork 本仓库,然后克隆你的 Fork
git clone https://github.com/你的用户名/LocalMiniDrama.git
cd LocalMiniDrama
# 2. 创建功能分支(从 main 分支切出)
git checkout -b feature/your-feature-name
# 或 bugfix/your-bug-description
# 3. 完成开发并提交
git add .
git commit -m "feat: 简短描述改动"
# 4. 推送分支
git push origin feature/your-feature-name
# 5. 在 GitHub 上创建 Pull Request
```
### PR 要求
- 基于 `main` 分支创建
- 一个 PR 只做一件事,避免混合无关改动
- 填写 PR 模板中的各项信息
- 本地测试通过后再提交
---
## 开发环境搭建 / Development Setup
> 需要 Node.js >= 18 / Requires Node.js >= 18
### 启动后端
```bash
cd backend-node
npm install
cp configs/config.example.yaml configs/config.yaml
# 编辑 config.yaml,填入你的 AI API 配置
npm run migrate # 首次运行,初始化数据库
npm start # 默认端口 5679
```
### 启动前端
```bash
cd frontweb
npm install
npm run dev # 默认端口 3013
```
浏览器访问 `http://localhost:3013`
### 一键启动(Windows
双击根目录的 `run_dev.bat` 可同时启动前端和后端。
### 桌面端开发(Electron
```bash
cd desktop
npm install
npm start
```
> Electron 开发需要安装 Python 3 和 Visual Studio C++ 生成工具(用于编译 better-sqlite3)。
> 详见 [快速开始文档](docs/quickstart.md)。
---
## 代码风格 / Code Style
- **语言**:纯 JavaScript,不使用 TypeScript
- **前端**Vue 3 Composition API + Element Plus,遵循现有组件结构
- **后端**:Express 路由模块化,保持与 `src/routes/` 现有风格一致
- **命名**:变量/函数使用 camelCase,文件名使用 kebab-case
- **注释**:关键逻辑用中文或英文注释均可
---
## 提交规范 / Commit Convention
遵循 [Conventional Commits](https://www.conventionalcommits.org/) 格式:
```
<type>: <简短描述>
[可选正文]
```
常用类型:
| type | 说明 |
|------|------|
| `feat` | 新功能 |
| `fix` | Bug 修复 |
| `docs` | 文档更新 |
| `style` | 代码格式(不影响逻辑) |
| `refactor` | 重构 |
| `perf` | 性能优化 |
| `chore` | 构建/依赖/配置变更 |
示例:
```
feat: 新增批量生成分镜功能
fix: 修复导出 ZIP 时视频文件丢失的问题
docs: 更新 AI 配置指南中的火山引擎配置说明
```
---
再次感谢你的贡献!有任何疑问欢迎在 Issue 中提问或加入微信群交流。
Thanks again for contributing! Feel free to open an issue or join the WeChat group if you have any questions.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 xuanyustudio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+328
View File
@@ -0,0 +1,328 @@
<div align="center">
# 🎬 本地短剧助手
**本地 AI 短剧 & 漫剧生成工具 —— 下载即用,完全开源,数据不出本机**
*LocalMiniDrama · AI-powered short drama creator*
[![version](https://img.shields.io/badge/version-1.2.7-blue?style=flat-square)](https://github.com/xuanyustudio/LocalMiniDrama/releases)
[![license](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![platform](https://img.shields.io/badge/platform-Windows-lightgrey?style=flat-square)](#-快速开始)
[![stack](https://img.shields.io/badge/Vue3%20%2B%20Node.js%20%2B%20Electron-informational?style=flat-square)](#-项目架构)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/xuanyustudio/LocalMiniDrama/pulls)
**[English](docs/en.md) · 简体中文 · [作者故事](docs/story.md)**
[![GitHub](https://img.shields.io/badge/GitHub-xuanyustudio%2FLocalMiniDrama-181717?logo=github&style=flat-square)](https://github.com/xuanyustudio/LocalMiniDrama)
[![Gitee](https://img.shields.io/badge/Gitee-bi__shang__a%2Flocalminidrama-C71D23?logo=gitee&style=flat-square)](https://gitee.com/bi_shang_a/localminidrama)
[**⬇️ 下载 Release**](https://github.com/xuanyustudio/LocalMiniDrama/releases) · [**🚀 快速开始**](#-快速开始) · [**📖 配置 AI**](docs/configuration.md) · [**🗺 画布文档**](docs/plans/2026-06-15-drama-canvas-workflow-plan.md)
</div>
---
<table>
<tr>
<td width="25%" align="center"><b>🔒 本地优先</b><br/>SQLite + 本地文件,素材不上云</td>
<td width="25%" align="center"><b>🎬 全流程</b><br/>剧本 → 角色/场景 → 分镜 → 视频合成</td>
<td width="25%" align="center"><b>🤖 多模型</b><br/>通义 / 火山 / 可灵 / Gemini 等</td>
<td width="25%" align="center"><b>🗺 双视图</b><br/>列表精细编辑 + 画布批量编排</td>
</tr>
</table>
市面上 AI 短剧工具不少,但真正能**本地离线运行、开箱即用、素材不上云**的几乎没有。
本项目用纯 JavaScript 从零搭建,接入你自己的 AI API,打开即可生成完整 AI 短剧。
> ✅ 无订阅费 · ✅ 数据本地存储 · ✅ 支持多家 AI 服务商 · ✅ 完全开源可二次开发
---
## 📌 最新动态(v1.2.7
- 🆕 **画布工作流**:无限画布总览分镜流水线,节点内编辑/生成,工作流整组重跑 → [文档](docs/plans/2026-06-15-drama-canvas-workflow-plan.md)
- 🆕 **Seedance 2.0 全能模式**`@图片N` 多图参考 + `universal_segment_text` 片段描述
- 🆕 **尾帧衔接** · **导出分镜表** · **统一任务进度**(刷新可恢复)
完整记录 → **[CHANGELOG.md](CHANGELOG.md)**
---
## 目录
- [界面预览](#-界面预览)
- [核心功能](#-核心功能)
- [快速开始](#-快速开始)
- [AI 服务商](#-ai-服务商支持)
- [项目架构](#-项目架构)
- [后续计划](#-后续计划-roadmap)
- [参与贡献](#-参与贡献)
- [联系社区](#-联系--社区)
---
## 📸 界面预览
<div align="center">
<img src="项目截图/首页截图.png" alt="首页 · 项目列表" width="960"/><br/>
<sub>首页 · 项目卡片一览,亮色模式</sub>
</div>
<br/>
<div align="center">
<img src="项目截图/画布模式.png" alt="画布工作流 · 分镜流水线" width="960"/><br/>
<sub>🆕 画布模式 · 分镜流水线可视化 · 节点内编辑/生成 · 工作流整组重跑</sub>
</div>
<br/>
<table>
<tr>
<td align="center"><img src="项目截图/武侠.png" alt="剧集管理页" width="480"/><br/><sub>剧集管理 · 分集 + 资源库</sub></td>
<td align="center"><img src="项目截图/武侠分镜.png" alt="分镜编辑页" width="480"/><br/><sub>分镜制作 · 图片 + 视频一键生成</sub></td>
</tr>
<tr>
<td align="center"><img src="项目截图/新版本4宫格分镜.png" alt="角色管理页" width="480"/><br/><sub>角色生成 · AI 自动提取并生成角色形象图</sub></td>
<td align="center"><img src="项目截图/专业分镜.png" alt="专业分镜参数" width="480"/><br/><sub>分镜制作 · 专业视频参数(景别 / 运镜 / 灯光 / 景深)</sub></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="项目截图/本剧场景库.png" alt="本剧场景库" width="720"/><br/><sub>场景库 · 一键「加入本集」,复用已有场景素材</sub></td>
</tr>
</table>
---
## 🎬 AI 生成实拍效果
> 以下 3 段视频由**本软件自动工作流选择即梦 1.0**生成,展示连续分镜下角色外貌一致性。
<table>
<tr>
<td align="center">
<video src="项目截图/1.mp4" controls width="300"></video><br/>
<sub>分镜 1 · 即梦 1.0</sub>
</td>
<td align="center">
<video src="项目截图/2.mp4" controls width="300"></video><br/>
<sub>分镜 2 · 服装一致</sub>
</td>
<td align="center">
<video src="项目截图/3.mp4" controls width="300"></video><br/>
<sub>分镜 3 · 人物统一</sub>
</td>
</tr>
</table>
> 💡 同时支持火山 **Seedance 2.0**、通义万相、Vidu、可灵 Kling(含 Omni)等,模型越新效果通常越好。
---
## ✨ 核心功能
<details open>
<summary><b>🔄 完整创作流程(点击展开/收起)</b></summary>
| 步骤 | 功能 | 说明 |
|:----:|------|------|
| 1 | **故事生成** | 输入梗概 + 风格,AI 自动生成多集剧本 |
| 2 | **剧本编辑** | 分集管理,剧本文本可自由编辑 |
| 3 | **角色生成** | AI 提取角色列表,逐个生成角色形象图 |
| 4 | **场景生成** | 从剧本自动提取场景,生成场景背景图 |
| 5 | **道具生成** | 从剧本提取/手动添加道具,生成道具图 |
| 6 | **分镜生成** | 按集自动生成分镜脚本(含景别/运镜/台词) |
| 7 | **图片/视频生成** | 逐镜生成静帧图与视频片段 |
| 8 | **合成视频** | 所有分镜视频自动合成为完整剧集文件 |
</details>
<details>
<summary><b>⚡ 一键流水线 · 项目管理 · 分镜编辑</b></summary>
- **一键生成 / 补全并生成**:从角色到合成视频全自动;智能跳过已有内容
- **失败自动重试**:每步最多 3 次,应对限流;实时进度与错误日志
- **工程 ZIP 导出/导入** · **全局素材库** · **16:9 / 9:16 / 1:1 画幅**
- **经典 / 全能分镜** · **`@图片N` 多图参考** · **尾帧衔接** · **导出分镜表 HTML**
- **图片/视频提示词**全文编辑 · 手动上传/拖拽替换参考图
</details>
### 🗺 画布工作流(LibTV 式)
制作页 / 剧集详情 → **画布模式**`/film/:id/canvas`),与列表模式**同源数据**
| 能力 | 说明 |
|------|------|
| 竖排流水线 | 每镜一行:经典「文本→首帧/尾帧→视频」;全能「全能分镜词→视频」 |
| 节点操作面板 | 单击节点下方编辑/生成,无需频繁切列表 |
| 工作流组 | 框选分镜 → 创建工作流 → **整组重跑**(生图/视频/配音可勾选) |
| 布局持久化 | 拖动保存坐标;曲线连线;左键框选、中键/右键平移 |
界面预览见 [上方截图](#-界面预览) · 📖 [画布工作流完整文档](docs/plans/2026-06-15-drama-canvas-workflow-plan.md)
### 🤖 AI 配置 · 🌓 亮/暗主题 · 自定义提示词
三类模型独立配置(图/视频/文本);一键配置通义/火山;9 类提示词可自定义覆盖。
---
## 🚀 快速开始
### 方式一:下载 exe(推荐)
前往 **[Releases 下载页](https://github.com/xuanyustudio/LocalMiniDrama/releases)**
| 版本 | 说明 | 适合 |
|------|------|------|
| `本地短剧助手 x.x.x.exe` | 标准版,**含示例项目** | 新手入门 |
| `本地短剧助手-Lite-x.x.x.exe` | Lite 版,体积更小 | 熟悉流程后 |
双击运行 → 「AI 配置」填入 API Key → 开始创作。
> 首次运行配置:`%APPDATA%\LocalMiniDrama\backend\configs\config.yaml`
### 方式二:源码开发
> Node.js ≥ 18
```bash
git clone https://github.com/xuanyustudio/LocalMiniDrama.git
cd LocalMiniDrama
# 后端(端口 5679
cd backend-node && npm install
cp configs/config.example.yaml configs/config.yaml # 填入 API Key
npm run migrate && npm start
# 前端(端口 3013,新终端)
cd frontweb && npm install && npm run dev
```
浏览器打开 `http://localhost:3013`,或双击根目录 **`run_dev.bat`** 一键启动。
📖 [详细开发/打包/Docker 指南](docs/quickstart.md) · [AI 配置指南](docs/configuration.md)
---
## 🤖 AI 服务商支持
| 服务商 | 文本 | 图片 | 视频 |
|--------|:----:|:----:|:----:|
| 阿里云 DashScope(通义) | ✅ | ✅ | ✅ |
| 火山引擎 Volcengine(豆包 / Seedance 2.0 | ✅ | ✅ | ✅ |
| 可灵 Kling AI(含 Omni | — | ✅ | ✅ |
| Google GeminiImagen / Veo | — | ✅ | ✅ |
| Vidu 生数科技 | — | — | ✅ |
| NanoBanana(含代理) | — | ✅ | — |
| 本地 Ollama 等 OpenAI 兼容 | ✅ | — | — |
| 其他 OpenAI 兼容接口 | ✅ | ✅ | — |
---
## 🏗 项目架构
```
LocalMiniDrama/
├── backend-node/ # Express + SQLite,生成/合成/导入导出
├── frontweb/ # Vue 3 + Element Plus + @vue-flow/core
│ └── views/ # FilmList · DramaDetail · FilmCreate · DramaCanvas
├── desktop/ # Electron 打包 exe
└── docs/ # 文档与计划
```
| 层 | 技术 |
|----|------|
| 前端 | Vue 3 · Vite · Element Plus · Pinia · @vue-flow/core |
| 后端 | Node.js · Express · SQLite (better-sqlite3) |
| 桌面 | Electron 28 · electron-builder |
---
## 🗺 后续计划 Roadmap
| 状态 | 计划 | 说明 |
|:----:|------|------|
| ✅ | Seedance 2.0 + 全能模式 | 多图 `@图片N` · `universal_segment_text` |
| ✅ | 画布工作流 | 列表/画布双视图 · 整组重跑 · 节点面板 |
| 📋 | **场景图 → 全景图** | 由场景参考图 AI 扩展超宽/360° 全景,供大景别运镜与场景库 |
| 📋 | 分镜参考图自由上传 | 任意图片作为分镜参考 |
| 📋 | 参考图自由选择 | 生图时手动指定角色/场景参考 |
| 📋 | 宫格图生成视频 | 多帧合图作为视频输入(部分模型已支持) |
> 认领功能或提建议 → [New Issue](https://github.com/xuanyustudio/LocalMiniDrama/issues/new)
<details>
<summary><b>📋 更多历史版本亮点(v1.2.3 及更早)</b></summary>
- **v1.2.3** 分镜解说旁白 · 导出解说 SRT
- **v1.2.2** 连贯帧模式 · 小说/长文导入 · ffmpeg 自动解压
- **v1.2.1** 可灵 Kling · 视频历史版本 · 场景/道具「加入本集」
- **v1.1.x** 多集剧本 · AI 并发 · 四宫格 · 批量生图/视频 …
详见 **[CHANGELOG.md](CHANGELOG.md)**
</details>
---
## 🎯 适合谁
| 用户 | 场景 |
|------|------|
| 📹 内容创作者 | 批量生产 AI 短剧 / 漫剧 |
| 🔒 隐私敏感 | 素材与剧本完全留在本机 |
| 🛠 开发者 | 二次开发、接入新 AI 服务商 |
| 🌱 入门探索 | 低成本体验 AI 视频全流程 |
---
## 🤝 参与贡献
- 🐛 [报告 Bug](https://github.com/xuanyustudio/LocalMiniDrama/issues/new)
- 💡 [功能建议](https://github.com/xuanyustudio/LocalMiniDrama/issues/new)
- 🔧 Fork → PR
-**Star** 帮助更多人发现本项目
**GitHub 仓库建议 Topics**(在仓库 Settings → Topics 添加,便于搜索):
`ai-video` `short-drama` `storyboard` `vue3` `electron` `local-first` `seedance` `comic-drama`
---
<details>
<summary><b>☕ 一杯咖啡的鼓励</b></summary>
项目完全开源、无订阅。若对你有帮助,欢迎随缘打赏(自愿,不影响 Issue/PR 处理):
<table>
<tr>
<td align="center"><img src="项目截图/weixinpay.jpg" alt="微信赞赏码" width="200"/><br/><sub>微信支付</sub></td>
<td align="center"><img src="项目截图/ali.jpg" alt="支付宝收款码" width="200"/><br/><sub>支付宝</sub></td>
</tr>
</table>
</details>
---
## 💬 联系 & 社区
[作者故事 & 碎碎念](docs/story.md) · 微信交流 / 用户群(二维码见仓库 `项目截图/` 目录)
> 群二维码约 7 天有效,过期请加作者微信拉群。
---
## 📄 License
[MIT](LICENSE)
---
<div align="center">
**如果这个项目对你有帮助,请点 ⭐ Star —— 这是对作者最大的鼓励!**
[⬇️ 立即下载](https://github.com/xuanyustudio/LocalMiniDrama/releases) · [📖 快速开始文档](docs/quickstart.md) · [🗺 画布文档](docs/plans/2026-06-15-drama-canvas-workflow-plan.md)
</div>
+41
View File
@@ -0,0 +1,41 @@
# 安全政策 / Security Policy
## 支持的版本 / Supported Versions
我们只对最新发布版本提供安全修复。
Security fixes are only provided for the latest release.
| 版本 / Version | 支持状态 / Support |
|---------------|-------------------|
| 最新版 / Latest | ✅ 支持 / Supported |
| 旧版本 / Older | ❌ 不支持 / Not supported |
## 报告漏洞 / Reporting a Vulnerability
**请勿通过公开 Issue 报告安全漏洞。**
**Please do NOT report security vulnerabilities via public Issues.**
### 联系方式 / Contact
如果你发现了安全漏洞,请通过以下方式私下联系我们:
If you discover a security vulnerability, please contact us privately:
- **GitHub Security Advisory**:点击仓库页面的 [Security](../../security/advisories/new) 标签 → Report a vulnerability
- **微信 / WeChat**:通过 README 中的二维码添加作者微信私信
### 响应流程 / Response Process
1. 收到报告后我们会在 **3 个工作日**内确认收到
2. 评估漏洞严重程度,制定修复计划
3. 修复完成后发布新版本,在 Changelog 中说明(不披露细节)
4. 感谢报告者(如果你愿意,会在 Changelog 中致谢)
### 注意事项 / Notes
本项目是**本地离线桌面应用**,不涉及服务端数据传输。用户的 AI API Key 和项目数据均存储在本地,不经过任何第三方服务器。主要安全风险集中在:
- 本地文件读写权限
- 对接第三方 AI API 时的网络请求
- 依赖包的已知漏洞
This is a **local desktop application**. No user data or API keys are transmitted through any third-party server. Security risks are mainly related to local file access, outbound AI API requests, and known dependency vulnerabilities.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
data/
*.db
.env
+3
View File
@@ -0,0 +1,3 @@
registry=https://registry.npmmirror.com
strict-ssl=false
better_sqlite3_binary_host_mirror=https://npmmirror.com/mirrors/better-sqlite3
+410
View File
@@ -0,0 +1,410 @@
# LocalMiniDrama 后端服务
**Node.js + Express + SQLite · 纯 JavaScript · 无 TypeScript**
→ [项目主页](../README.md) | [快速开始](../docs/quickstart.md) | [AI 配置](../docs/configuration.md) | [版本历史](../docs/changelog.md) | [作者故事](../docs/story.md) | [English](../docs/en.md)
**官方仓库:**
[![GitHub](https://img.shields.io/badge/GitHub-xuanyustudio%2FLocalMiniDrama-181717?logo=github)](https://github.com/xuanyustudio/LocalMiniDrama)
[![Gitee](https://img.shields.io/badge/Gitee-bi__shang__a%2Flocalminidrama-C71D23?logo=gitee)](https://gitee.com/bi_shang_a/localminidrama)
> 遇到问题或有功能建议,欢迎在 [GitHub Issues](https://github.com/xuanyustudio/LocalMiniDrama/issues) 或 [Gitee Issues](https://gitee.com/bi_shang_a/localminidrama/issues) 提交反馈。
> **本包版本:** `1.2.7`(与仓库根目录 [CHANGELOG](../CHANGELOG.md)、前端与桌面 `package.json` 对齐)
---
## 目录
- [环境要求](#环境要求)
- [安装与启动](#安装与启动)
- [目录结构](#目录结构)
- [配置文件](#配置文件)
- [API 接口总览](#api-接口总览)
- [数据库说明](#数据库说明)
- [各大平台中转站示例配置](#各大平台中转站示例配置)
- [AI 服务集成](#ai-服务集成)
- [开发说明](#开发说明)
---
## 环境要求
| 依赖 | 版本 |
|------|------|
| Node.js | >= 18 |
| npm | 随 Node.js 附带 |
---
## 安装与启动
```bash
cd backend-node
# 安装依赖
npm install
# 复制配置文件
cp configs/config.example.yaml configs/config.yaml
# Windows: copy configs\config.example.yaml configs\config.yaml
# 编辑 config.yaml,填入 AI API 配置(也可通过前端「AI 配置」页面管理)
# 首次运行:初始化数据库表
npm run migrate
# 生产启动(默认端口 5679
npm start
# 开发模式(nodemon 热重载)
npm run dev
```
启动成功后终端输出:
```
Server started on port 5679
```
---
## 目录结构
```
backend-node/
├── configs/
│ ├── config.example.yaml # 配置模板(提交到 Git)
│ └── config.yaml # 实际配置(不提交,自行创建)
├── data/
│ ├── drama_generator.db # SQLite 数据库
│ └── storage/ # 生成的图片/视频本地文件
│ ├── images/ # 分镜生成图
│ ├── characters/ # 角色图
│ ├── scenes/ # 场景图
│ ├── videos/ # 生成的视频片段
│ └── merged/ # 合成后的完整视频
├── migrations/
│ ├── 01_init.sql # 初始建表
│ ├── 02_local_path.sql # 本地路径字段
│ └── 03_async_tasks_frame_prompts.sql
├── src/
│ ├── app.js # Express 应用(路由注册、中间件)
│ ├── server.js # HTTP 服务入口
│ ├── logger.js # 日志(pino
│ ├── response.js # 统一响应格式工具
│ ├── config/
│ │ └── index.js # YAML 配置加载
│ ├── db/
│ │ ├── index.js # better-sqlite3 连接
│ │ └── migrate.js # 启动时自动补列(ensureColumns
│ ├── routes/
│ │ ├── index.js # 路由总入口
│ │ ├── drama.js # 剧本 / 导出 / 导入
│ │ ├── videos.js # 视频生成任务
│ │ ├── images.js # 图片生成任务
│ │ ├── tasks.js # 异步任务查询
│ │ ├── aiConfig.js # AI 服务商配置 CRUD
│ │ ├── settings.js # 全局设置
│ │ └── static.js # 静态文件服务(/static
│ └── services/
│ ├── dramaService.js # 剧本 CRUD 与数据组装
│ ├── episodeStoryboardService.js # 分镜生成核心逻辑
│ ├── imageService.js # 图片生成任务处理
│ ├── videoService.js # 视频生成任务处理
│ ├── videoMergeService.js # 视频合并(ffmpeg
│ ├── videoClient.js # 视频 API 调用(Volcengine 等)
│ ├── imageClient.js # 图片 API 调用(DashScope 等)
│ ├── characterGenerationService.js # 角色提取与生成
│ ├── characterLibraryService.js # 角色库管理
│ ├── backgroundExtractionService.js # 场景背景提取
│ ├── propExtractionService.js # 道具提取
│ ├── propImageGenerationService.js # 道具图片生成
│ ├── framePromptService.js # 首/尾帧提示词生成
│ ├── dramaExportService.js # 工程导出为 ZIP
│ ├── dramaImportService.js # ZIP 工程导入
│ ├── promptI18n.js # 多语言提示词模板
│ └── uploadService.js # 本地文件存储管理
└── tools/ # 辅助脚本(数据迁移等)
```
---
## 配置文件
`configs/config.yaml` 主要配置项:
```yaml
server:
port: 5679 # HTTP 服务端口
database:
path: ./data/drama_generator.db # SQLite 文件路径
storage:
local_path: ./data/storage # 图片/视频本地存储根目录
language: zh # 提示词语言(zh / en
style:
default_style: realistic # 默认绘图风格
default_image_ratio: "16:9" # 默认图片比例
default_video_ratio: "16:9" # 默认视频比例
```
> AI 服务配置(API Key、模型名、端点 URL)通过前端「AI 配置」页面管理,存储于数据库 `ai_service_configs` 表,无需手动编辑 YAML。
---
## 各大平台中转站示例配置
仓库根目录(与 `backend-node` 同级)下的 [`各大平台中转站配置/`](https://github.com/xuanyustudio/LocalMiniDrama/tree/main/%E5%90%84%E5%A4%A7%E5%B9%B3%E5%8F%B0%E4%B8%AD%E8%BD%AC%E7%AB%99%E9%85%8D%E7%BD%AE) 提供多家常见中转站的 **完整 JSON 示例**,可直接对照导入前端「AI 配置」,再改为自己的 Key 与地址。
| 文件 | 说明 |
|------|------|
| `使用说明.txt` | 导入后需将 **每一条** 服务里的 `api_key` 换成自己的 |
| `302ai-302.json` | 302.AI 示例 |
| `云雾ai.json` | 云雾 AI 示例 |
| `向量.json` | 向量平台示例 |
| `飞儿api-ffir.cn.json` | 飞儿 API 示例 |
**示意图(与 JSON 互补):**
- [官方即梦 2.0 配置](https://github.com/xuanyustudio/LocalMiniDrama/blob/main/%E5%90%84%E5%A4%A7%E5%B9%B3%E5%8F%B0%E4%B8%AD%E8%BD%AC%E7%AB%99%E9%85%8D%E7%BD%AE/%E5%AE%98%E6%96%B9%E5%8D%B3%E6%A2%A62.0%E9%85%8D%E7%BD%AE.png)
- [本地反向代理即梦 Free API 配置](https://github.com/xuanyustudio/LocalMiniDrama/blob/main/%E5%90%84%E5%A4%A7%E5%B9%B3%E5%8F%B0%E4%B8%AD%E8%BD%AC%E7%AB%99%E9%85%8D%E7%BD%AE/%E8%B0%83%E7%94%A8%E6%9C%AC%E5%9C%B0%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86%E5%8D%B3%E6%A2%A6freeapi%E7%9A%84%E9%85%8D%E7%BD%AE.png)
若中转商控制台中的商品名(如「即梦 2.0」)与示例里的 **模型 ID**(如 `doubao-seedream-4-0-250828`)表述不一致,以对方实际开放的模型 ID 为准,并在本系统中与 JSON 保持一致。更多面向最终用户的说明见仓库根目录 [项目主页 `index.html`](../index.html) 中的「AI 与各大平台中转站」区块(发布页展示用)。
---
## API 接口总览
所有接口前缀:`/api/v1`
响应统一格式:
```json
{
"success": true,
"data": { ... },
"timestamp": "2026-01-01T00:00:00.000Z"
}
```
错误响应:
```json
{
"success": false,
"error": { "code": "INTERNAL_ERROR", "message": "..." },
"timestamp": "..."
}
```
### 剧集(Drama
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/dramas` | 获取剧集列表(分页) |
| POST | `/dramas` | 创建新剧集 |
| GET | `/dramas/:id` | 获取剧集详情(含集数、角色、场景等) |
| PUT | `/dramas/:id` | 更新剧集信息 |
| DELETE | `/dramas/:id` | 软删除剧集 |
| GET | `/dramas/:id/export` | 导出工程 ZIP |
| POST | `/dramas/import` | 导入工程 ZIPmultipart/form-data,字段名 `file` |
| GET | `/dramas/stats` | 统计信息 |
### 集数(Episode
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/episodes/:id` | 获取集数详情 |
| PUT | `/episodes/:id` | 更新集数(剧本内容、标题等) |
| POST | `/dramas/:id/episodes` | 新增集数 |
| DELETE | `/episodes/:id` | 删除集数 |
### 分镜(Storyboard
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/episodes/:id/storyboards` | 获取集数所有分镜 |
| POST | `/episodes/:id/generate-storyboard` | 触发分镜生成任务 |
| PUT | `/storyboards/:id` | 更新分镜字段 |
### 图片生成(Image
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/images` | 创建图片生成任务 |
| GET | `/images/:id` | 查询任务状态 |
| GET | `/storyboards/:id/images` | 获取分镜所有图片 |
| POST | `/storyboards/:id/images/upload` | 手动上传图片 |
### 视频生成(Video
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/videos` | 创建视频生成任务 |
| GET | `/videos/:id` | 查询任务状态 |
| POST | `/episodes/:id/merge-video` | 触发视频合并 |
| GET | `/episodes/:id/merge-status` | 查询合并进度 |
### AI 配置(AI Config
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/ai-configs` | 获取所有 AI 配置 |
| POST | `/ai-configs` | 新增配置 |
| PUT | `/ai-configs/:id` | 修改配置 |
| DELETE | `/ai-configs/:id` | 删除配置 |
| POST | `/ai-configs/:id/test` | 测试连接 |
| POST | `/ai-configs/preset/dashscope` | 一键创建通义预设 |
| POST | `/ai-configs/preset/volcengine` | 一键创建火山预设 |
### 异步任务(Task
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/tasks/:id` | 查询任务状态与进度 |
| GET | `/tasks` | 获取任务列表 |
### 角色与素材库
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/dramas/:id/characters` | 获取剧集角色 |
| POST | `/dramas/:id/characters/extract` | 从剧本提取角色(触发任务) |
| POST | `/characters/:id/generate-image` | 生成角色图片 |
| GET | `/dramas/:id/scenes` | 获取剧集场景 |
| POST | `/episodes/:id/extract-backgrounds` | 提取场景背景(触发任务) |
| GET | `/dramas/:id/props` | 获取剧集道具 |
| POST | `/episodes/:id/extract-props` | 从剧本提取道具(触发任务) |
| POST | `/props/:id/generate-image` | 生成道具图片 |
### 静态文件
| 路径 | 说明 |
|------|------|
| `GET /static/*` | 访问 `data/storage/` 下的图片/视频文件 |
---
## 数据库说明
使用 better-sqlite3(同步 API),数据库文件为单个 SQLite 文件。
**主要数据表:**
| 表名 | 说明 |
|------|------|
| `dramas` | 剧集基本信息(标题、类型、风格、metadata) |
| `episodes` | 集数(所属剧集、剧本内容) |
| `storyboards` | 分镜(所属集数、台词、动作、提示词等) |
| `characters` | 角色(所属剧集、外貌描述、图片路径) |
| `episode_characters` | 角色-集数关联表(多对多) |
| `scenes` | 场景(所属剧集/集数、位置、时间、提示词) |
| `props` | 道具(所属剧集/集数、类型、描述) |
| `image_generations` | 图片生成任务记录(状态、本地路径) |
| `video_generations` | 视频生成任务记录(状态、本地路径、参数) |
| `video_merges` | 视频合并任务记录 |
| `ai_service_configs` | AI 服务商配置(API Key、模型、端点) |
| `async_tasks` | 通用异步任务(分镜生成、角色提取等) |
| `character_libraries` | 全局/剧集角色素材库 |
| `scene_libraries` | 全局/剧集场景素材库 |
| `prop_libraries` | 全局/剧集道具素材库 |
**字段约定:**
- `deleted_at IS NULL` — 所有查询均过滤软删除记录
- `metadata TEXT` — JSON 字符串,存储扩展属性(如 `aspect_ratio``video_clip_duration`
- `local_path TEXT` — 相对于 `storage/` 根目录的相对路径
**数据库迁移:**
- `npm run migrate` — 运行 `migrations/` 目录下的 SQL 文件
- 每次服务启动时自动执行 `ensureColumns()`,确保所有列存在(支持旧数据库升级)
---
## AI 服务集成
### 图片生成流程
1. 前端调用 `POST /images` → 创建 `image_generations` 记录(status=pending
2. `imageService.js` 异步处理:调用配置好的图片 API → 下载图片到本地 → 更新记录(status=completed, local_path
3. 前端轮询 `GET /images/:id` 直到 status=completed
**支持的图片 API**
- DashScope(通义万象):`POST /api/v1/services/aigc/text2image/image-synthesis`
- Volcengine(豆包):`POST /api/v3/images/generations`OpenAI 兼容格式)
### 即梦(SeedreamVolcengine 图生图与文生图
- 分镜 **图生图**`storyboard_image`)与 **文生图**`image`)中,凡使用 `doubao-seedream-*` 系列模型,均走 `imageClient.js`**Volcengine 图片协议**分支;配置项 **`api_protocol`** 须为 `volcengine``base_url``endpoint` 需与所用中转站文档一致,勿与纯 OpenAI 兼容条目混用。
- 中转商界面若标注为「即梦 2.0」等名称,请以对方返回的 **模型 ID** 为准(示例中常见 `doubao-seedream-4-0-250828``doubao-seedream-4-5-251128` 等),与 [各大平台中转站示例配置](#各大平台中转站示例配置) 中的 JSON 对照修改即可。
- **Seedream 4.5+** 对输出像素有下限(约 1920×1920 等效面积);`imageClient.js` 会在请求前对过小尺寸做等比放大(`fixSeedreamSize`)。若仍报错,请结合中转站错误信息检查模型与尺寸。
**图片尺寸:** 系统根据项目 `metadata.aspect_ratio` 自动计算符合服务商最低要求的分辨率(最低 3,686,400 像素)。
### 视频生成流程
1. 前端调用 `POST /videos` → 创建 `video_generations` 记录
2. `videoService.js` 异步处理:调用视频 API → 轮询任务状态 → 下载到本地
3. 前端轮询 `GET /videos/:id` 直到 status=completed
**视频参数(Volcengine 经典 Seedance 单链路,示例):**
```json
{
"model": "doubao-seedance-1-0-pro-250528",
"content": [{ "type": "text", "text": "..." }],
"ratio": "16:9",
"duration": 5,
"resolution": "720p",
"seed": null,
"camera_fixed": false,
"watermark": false
}
```
**火山方舟 Seedance 2.0 · 全能 / 多参考图(`volcengine_omni`):**
- 在前端「AI 配置 → 视频生成」选择接口规范 **`volcengine_omni`**,厂商仍为火山引擎;**Base URL** 一般为 `https://ark.cn-beijing.volces.com/api/v3`**模型**填控制台接入点(如 `doubao-seedance-2-0-260128``doubao-seedance-2-0-fast-260128`)。
- 与制作页分镜 **「全能模式」** 配合:首条为文本提示,其余参考图为场景/角色/道具/分镜主图等,每张 **`role: reference_image`**;方舟侧最多取 **9** 张。
- **Seedance 2.x** 请求时长会在后端吸附到 **415 秒**;默认走 `POST /v1/videos/generations`(可用配置 **Endpoint** 覆盖)。实现见 `videoClient.js``volcengine_omni` 分支)。
**可灵 Omni`kling_omni`** 同样支持分镜全能模式的多图参考与片段描述-only 提交逻辑,配置方式见前端 AI 配置页说明。
### 提示词国际化
`promptI18n.js` 管理所有提示词模板,支持中文(zh)和英文(en)两套模板,通过 `config.yaml` 中的 `language` 字段切换。
---
## 开发说明
### 添加新的 AI 服务商
1.`imageClient.js``videoClient.js` 中添加新的 `provider` 分支
2. 实现对应的 API 调用逻辑
3. 在前端「AI 配置」页面新增服务商选项(`AIConfigContent.vue`
### Jimeng AI API(自建即梦 OpenAI 兼容服务)
若使用「视频 → 厂商 **Jimeng AI API(自建即梦免费 API**」:
1. 自行克隆并启动第三方即梦逆向/兼容服务项目(如 `jimeng-free-api-all`),按对方 README 安装依赖与 Chromium,默认监听端口以对方文档为准(常见 `8000`)。
2. 在本系统 AI 配置中填写 **Base URL**(如 `http://127.0.0.1:8000`)、**API Key** 填即梦 **Session**(多个用英文逗号分隔)。
3. 后端会请求对方 `POST /v1/videos/generations`(可用配置项 **Endpoint** 覆盖路径),Seedance 多图场景需分镜带参考图;返回为同步 `data[0].url`,无需轮询。
字段级对照可参考仓库 [`各大平台中转站配置/调用本地反向代理即梦freeapi的配置.png`](https://github.com/xuanyustudio/LocalMiniDrama/blob/main/%E5%90%84%E5%A4%A7%E5%B9%B3%E5%8F%B0%E4%B8%AD%E8%BD%AC%E7%AB%99%E9%85%8D%E7%BD%AE/%E8%B0%83%E7%94%A8%E6%9C%AC%E5%9C%B0%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86%E5%8D%B3%E6%A2%A6freeapi%E7%9A%84%E9%85%8D%E7%BD%AE.png)。该路径为 **OpenAI 兼容的本地即梦代理(视频为主)**,与上文「即梦(SeedreamVolcengine 图生图与文生图」所描述的 **`volcengine` 直连中转图 API** 不是同一套协议,请按实际接入分别配置、勿混用字段。
### 添加新的数据库字段
1.`migrate.js``ensureColumns()` 中添加新字段定义(类型 + 默认值)
2. 更新对应的 Service 文件中的 INSERT/SELECT 语句
### 日志级别
通过环境变量 `LOG_LEVEL` 控制:
```bash
LOG_LEVEL=debug npm run dev # 详细日志
LOG_LEVEL=info npm start # 生产日志(默认)
```
---
[← 返回项目主页](../README.md)
+48
View File
@@ -0,0 +1,48 @@
app:
name: LocalMiniDrama API
version: 1.0.0
debug: true
language: zh
server:
port: 5679
host: 0.0.0.0
cors_origins:
- http://localhost:3012
read_timeout: 600
write_timeout: 600
insecure_tls: true
database:
type: sqlite
path: ./data/drama_generator.db
max_idle: 10
max_open: 100
storage:
type: local
local_path: ./data/storage
base_url: http://localhost:5679/static
# 异步视频任务轮询总超时(分钟);省略或 ≤0 时默认 30
video:
generation_timeout_minutes: 30
ai:
default_text_provider: openai
default_image_provider: openai
default_video_provider: doubao
style:
default_style: ''
default_role_style: full body and face clearly visible, character centered, consistent facial features, high detail, masterpiece, best quality
default_scene_style: wide establishing shot, highly detailed environment, sharp focus, rich atmosphere, masterpiece, best quality
default_prop_style: object centered, clean simple background, studio lighting, sharp focus, high detail, masterpiece, best quality
default_image_ratio: '16:9'
default_video_ratio: '16:9'
default_prop_ratio: '1:1'
default_image_size: 1024x1024
vendor_lock:
enabled: false
config_file: ai-configs-qudao.json
image_proxy:
expire_hours: 2 # 按图床实际 TTL 调整
use_for_video: true
# 图床上传超时(秒),避免网络不通时长时间卡住
upload_timeout_seconds: 180
upload_max_attempts: 2
# upload_url: https://imageproxy.zhongzhuan.chat/api/upload
+300
View File
@@ -0,0 +1,300 @@
-- 最小初始表结构,与 backend-node 业务代码对齐(若无 backend-node/migrations 则使用本文件)
CREATE TABLE IF NOT EXISTS dramas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
description TEXT,
genre TEXT,
style TEXT DEFAULT 'realistic',
tags TEXT,
thumbnail TEXT,
total_episodes INTEGER DEFAULT 1,
total_duration INTEGER DEFAULT 0,
status TEXT DEFAULT 'draft',
metadata TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER NOT NULL,
episode_number INTEGER DEFAULT 0,
title TEXT DEFAULT '',
script_content TEXT,
description TEXT,
duration INTEGER DEFAULT 0,
video_url TEXT,
thumbnail TEXT,
status TEXT DEFAULT 'draft',
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS storyboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
episode_id INTEGER NOT NULL,
scene_id INTEGER,
storyboard_number INTEGER DEFAULT 0,
title TEXT,
description TEXT,
location TEXT,
time TEXT,
duration REAL,
dialogue TEXT,
action TEXT,
atmosphere TEXT,
image_prompt TEXT,
video_prompt TEXT,
characters TEXT,
shot_type TEXT,
angle TEXT,
movement TEXT,
video_url TEXT,
status TEXT DEFAULT 'draft',
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT,
description TEXT,
personality TEXT,
appearance TEXT,
image_url TEXT,
local_path TEXT,
voice_style TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS episode_characters (
episode_id INTEGER NOT NULL,
character_id INTEGER NOT NULL,
PRIMARY KEY (episode_id, character_id)
);
CREATE TABLE IF NOT EXISTS scenes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER NOT NULL,
episode_id INTEGER,
location TEXT,
time TEXT,
prompt TEXT,
image_url TEXT,
local_path TEXT,
storyboard_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'draft',
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS props (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '',
type TEXT,
description TEXT,
prompt TEXT,
image_url TEXT,
local_path TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS storyboard_props (
storyboard_id INTEGER NOT NULL,
prop_id INTEGER NOT NULL,
PRIMARY KEY (storyboard_id, prop_id)
);
CREATE TABLE IF NOT EXISTS frame_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storyboard_id INTEGER NOT NULL,
frame_type TEXT,
prompt TEXT,
description TEXT,
layout TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS ai_service_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_type TEXT NOT NULL,
provider TEXT DEFAULT '',
name TEXT DEFAULT '',
base_url TEXT DEFAULT '',
api_key TEXT,
model TEXT,
default_model TEXT,
endpoint TEXT,
query_endpoint TEXT,
priority INTEGER DEFAULT 0,
is_default INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
settings TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS async_tasks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER DEFAULT 0,
message TEXT,
resource_id TEXT,
created_at TEXT,
updated_at TEXT,
completed_at TEXT,
error TEXT,
result TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS image_generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storyboard_id INTEGER,
drama_id INTEGER,
scene_id INTEGER,
character_id INTEGER,
provider TEXT,
prompt TEXT,
negative_prompt TEXT,
model TEXT,
frame_type TEXT,
reference_images TEXT,
size TEXT,
quality TEXT,
image_url TEXT,
local_path TEXT,
status TEXT,
task_id TEXT,
completed_at TEXT,
error_msg TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS video_generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
storyboard_id INTEGER,
provider TEXT,
prompt TEXT,
model TEXT,
duration REAL,
aspect_ratio TEXT,
image_url TEXT,
first_frame_url TEXT,
last_frame_url TEXT,
reference_image_urls TEXT,
video_url TEXT,
local_path TEXT,
status TEXT,
task_id TEXT,
scene_id INTEGER,
completed_at TEXT,
error_msg TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS video_merges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
episode_id INTEGER,
drama_id INTEGER,
title TEXT,
provider TEXT,
model TEXT,
status TEXT,
scenes TEXT,
task_id TEXT,
created_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS character_libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
name TEXT NOT NULL DEFAULT '',
category TEXT,
image_url TEXT,
local_path TEXT,
description TEXT,
tags TEXT,
source_type TEXT,
source_id TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS scene_libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
location TEXT NOT NULL DEFAULT '',
time TEXT,
prompt TEXT,
description TEXT,
image_url TEXT,
local_path TEXT,
category TEXT,
tags TEXT,
source_type TEXT,
source_id TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS prop_libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
name TEXT NOT NULL DEFAULT '',
description TEXT,
prompt TEXT,
image_url TEXT,
local_path TEXT,
category TEXT,
tags TEXT,
source_type TEXT,
source_id TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
name TEXT,
type TEXT,
category TEXT,
url TEXT,
local_path TEXT,
file_size INTEGER,
mime_type TEXT,
width INTEGER,
height INTEGER,
duration REAL,
image_gen_id INTEGER,
video_gen_id INTEGER,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
@@ -0,0 +1,2 @@
-- 生成时使用的默认模型(在该配置的 model 列表中选一个)
ALTER TABLE ai_service_configs ADD COLUMN default_model TEXT;
@@ -0,0 +1,2 @@
-- 道具归属集:从某集剧本提取的道具记入该集,本集资源列表会展示
ALTER TABLE props ADD COLUMN episode_id INTEGER;
@@ -0,0 +1,4 @@
-- async_tasks 缺少 completed_at、error、result 时补上(与 taskService 一致)
ALTER TABLE async_tasks ADD COLUMN completed_at TEXT;
ALTER TABLE async_tasks ADD COLUMN error TEXT;
ALTER TABLE async_tasks ADD COLUMN result TEXT;
@@ -0,0 +1,3 @@
-- image_generations 缺少 completed_at / error_msg 时补上(imageClient 更新完成状态与错误信息用)
ALTER TABLE image_generations ADD COLUMN completed_at TEXT;
ALTER TABLE image_generations ADD COLUMN error_msg TEXT;
@@ -0,0 +1,2 @@
-- characters 表缺少 local_path 时补上(角色本地图片路径)
ALTER TABLE characters ADD COLUMN local_path TEXT;
@@ -0,0 +1,3 @@
-- scenes 表缺少 image_url / local_path 时补上(场景上传/生成图用)
ALTER TABLE scenes ADD COLUMN image_url TEXT;
ALTER TABLE scenes ADD COLUMN local_path TEXT;
@@ -0,0 +1,3 @@
-- video_generations 缺少 completed_at / error_msg 时补上(videoService 更新完成状态用)
ALTER TABLE video_generations ADD COLUMN completed_at TEXT;
ALTER TABLE video_generations ADD COLUMN error_msg TEXT;
@@ -0,0 +1,35 @@
-- 公共场景库、公共道具库(仿 character_libraries
CREATE TABLE IF NOT EXISTS scene_libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
location TEXT NOT NULL DEFAULT '',
time TEXT,
prompt TEXT,
description TEXT,
image_url TEXT,
local_path TEXT,
category TEXT,
tags TEXT,
source_type TEXT,
source_id TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS prop_libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
name TEXT NOT NULL DEFAULT '',
description TEXT,
prompt TEXT,
image_url TEXT,
local_path TEXT,
category TEXT,
tags TEXT,
source_type TEXT,
source_id TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
);
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS prompt_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -0,0 +1,4 @@
-- 新增 api_protocol 字段:显式指定接口规范,独立于 provider(厂商名)。
-- 优先级高于 provider 推断,便于中转站/自定义厂商明确接口格式。
-- 可选值:openai / volcengine / dashscope / gemini / nano_banana
ALTER TABLE ai_service_configs ADD COLUMN api_protocol TEXT DEFAULT '' NOT NULL;
@@ -0,0 +1,8 @@
-- 图床上传缓存表:记录本地文件与中转图床 URL 的对应关系,避免重复上传。
-- cache_key: 本地相对路径(如 scenes/ig_xxx.png)或 data URL 内容的 sha256 hash 前缀
CREATE TABLE IF NOT EXISTS image_proxy_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT NOT NULL UNIQUE,
proxy_url TEXT NOT NULL,
created_at TEXT NOT NULL
);
@@ -0,0 +1,12 @@
-- character_libraries 补充外貌字段(分镜生成时引用)
ALTER TABLE character_libraries ADD COLUMN appearance TEXT;
ALTER TABLE character_libraries ADD COLUMN identity_anchors TEXT;
ALTER TABLE character_libraries ADD COLUMN style_tokens TEXT;
ALTER TABLE character_libraries ADD COLUMN color_palette TEXT;
ALTER TABLE character_libraries ADD COLUMN four_view_image_url TEXT;
-- characters 补充锚点字段(AI 角色生成时提炼)
ALTER TABLE characters ADD COLUMN identity_anchors TEXT;
ALTER TABLE characters ADD COLUMN style_tokens TEXT;
ALTER TABLE characters ADD COLUMN color_palette TEXT;
ALTER TABLE characters ADD COLUMN four_view_image_url TEXT;
@@ -0,0 +1,19 @@
-- storyboards 表新增段落分组字段
ALTER TABLE storyboards ADD COLUMN segment_index INTEGER DEFAULT 0;
ALTER TABLE storyboards ADD COLUMN segment_title TEXT;
-- 模型路由表:按业务场景配置不同 AI 模型
-- key: 业务键(storyboard_gen / character_gen / frame_prompt / image_polish 等)
-- service_type: text / image
-- config_id: 对应 ai_service_configs.idNULL = 使用默认配置)
-- model_override: 可选,在该配置下使用指定模型名(NULL = 用配置默认模型)
CREATE TABLE IF NOT EXISTS ai_model_map (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
service_type TEXT NOT NULL DEFAULT 'text',
config_id INTEGER,
model_override TEXT,
description TEXT,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,8 @@
-- storyboards 表新增结构化视角字段(三元组)
-- angle_h: 水平方向(front / front_left / left / back_left / back / back_right / right / front_right
-- angle_v: 俯仰角度(worm / low / eye_level / high
-- angle_s: 景别 close_up / medium / wide
-- 保留原 angle 字段(旧文本)以兼容存量数据,新数据优先使用三元组字段
ALTER TABLE storyboards ADD COLUMN angle_h TEXT;
ALTER TABLE storyboards ADD COLUMN angle_v TEXT;
ALTER TABLE storyboards ADD COLUMN angle_s TEXT;
@@ -0,0 +1,3 @@
-- characters 表新增预生成的四视图图片提示词字段
-- polished_prompt: 经文字AI润色后的完整图片生成提示词,可由用户编辑,生成图片时直接使用
ALTER TABLE characters ADD COLUMN polished_prompt TEXT;
@@ -0,0 +1,2 @@
-- 角色多阶段造型支持(不同集不同外貌)
ALTER TABLE characters ADD COLUMN stages TEXT;
@@ -0,0 +1,2 @@
-- 分镜解说/旁白文案(TTS、成片旁轨),与角色对白 dialogue 分离
ALTER TABLE storyboards ADD COLUMN narration TEXT;
@@ -0,0 +1,3 @@
-- 分镜:经典参考图模式 / 全能创建模式(片段描述,独立字段)
ALTER TABLE storyboards ADD COLUMN creation_mode TEXT DEFAULT 'classic';
ALTER TABLE storyboards ADD COLUMN universal_segment_text TEXT;
@@ -0,0 +1,2 @@
-- Seedance 2.0 / 即梦素材库认证信息(JSON),与官方业务素材 API 字段一致
ALTER TABLE characters ADD COLUMN seedance2_asset TEXT;
@@ -0,0 +1,4 @@
-- 角色 / 场景 / 道具:可选负面提示词(显式指定生图 model 时与图生 API 合并传入)
ALTER TABLE characters ADD COLUMN negative_prompt TEXT;
ALTER TABLE scenes ADD COLUMN negative_prompt TEXT;
ALTER TABLE props ADD COLUMN negative_prompt TEXT;
@@ -0,0 +1,70 @@
ALTER TABLE character_libraries ADD COLUMN drama_id INTEGER;
ALTER TABLE scene_libraries ADD COLUMN drama_id INTEGER;
ALTER TABLE prop_libraries ADD COLUMN drama_id INTEGER;
ALTER TABLE character_libraries ADD COLUMN source_id TEXT;
ALTER TABLE scene_libraries ADD COLUMN source_id TEXT;
ALTER TABLE prop_libraries ADD COLUMN source_id TEXT;
UPDATE character_libraries
SET source_id = (
SELECT CAST(c.id AS TEXT)
FROM characters c
WHERE c.deleted_at IS NULL
AND character_libraries.source_type = 'character'
AND (character_libraries.drama_id IS NULL OR c.drama_id = character_libraries.drama_id)
AND (
(c.local_path IS NOT NULL AND c.local_path <> '' AND (
c.local_path = character_libraries.local_path
OR '/static/' || c.local_path = character_libraries.image_url
))
OR (c.image_url IS NOT NULL AND c.image_url <> '' AND c.image_url = character_libraries.image_url)
)
ORDER BY c.id ASC
LIMIT 1
)
WHERE source_id IS NULL
AND deleted_at IS NULL
AND source_type = 'character';
UPDATE scene_libraries
SET source_id = (
SELECT CAST(s.id AS TEXT)
FROM scenes s
WHERE s.deleted_at IS NULL
AND scene_libraries.source_type = 'scene'
AND (scene_libraries.drama_id IS NULL OR s.drama_id = scene_libraries.drama_id)
AND (
(s.local_path IS NOT NULL AND s.local_path <> '' AND (
s.local_path = scene_libraries.local_path
OR '/static/' || s.local_path = scene_libraries.image_url
))
OR (s.image_url IS NOT NULL AND s.image_url <> '' AND s.image_url = scene_libraries.image_url)
)
ORDER BY s.id ASC
LIMIT 1
)
WHERE source_id IS NULL
AND deleted_at IS NULL
AND source_type = 'scene';
UPDATE prop_libraries
SET source_id = (
SELECT CAST(p.id AS TEXT)
FROM props p
WHERE p.deleted_at IS NULL
AND prop_libraries.source_type = 'prop'
AND (prop_libraries.drama_id IS NULL OR p.drama_id = prop_libraries.drama_id)
AND (
(p.local_path IS NOT NULL AND p.local_path <> '' AND (
p.local_path = prop_libraries.local_path
OR '/static/' || p.local_path = prop_libraries.image_url
))
OR (p.image_url IS NOT NULL AND p.image_url <> '' AND p.image_url = prop_libraries.image_url)
)
ORDER BY p.id ASC
LIMIT 1
)
WHERE source_id IS NULL
AND deleted_at IS NULL
AND source_type = 'prop';
+2462
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "LocalMiniDrama-backend",
"version": "1.2.7",
"description": "Node.js backend for LocalMiniDrama",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"migrate": "node src/db/migrate.js",
"migrate:07": "node scripts/run-migration-07.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@volcengine/openapi": "^1.36.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"js-yaml": "^4.1.0",
"jsonrepair": "^3.13.3",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"sharp": "^0.34.5",
"uuid": "^10.0.0"
}
}
+113
View File
@@ -0,0 +1,113 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const { getDb } = require('./db/index.js');
const { loadConfig } = require('./config/index.js');
const logger = require('./logger.js');
const { setupRouter } = require('./routes/index.js');
function createApp() {
const config = loadConfig();
const db = getDb(config.database);
const { runMigrationsAndEnsure } = require('./db/migrate.js');
runMigrationsAndEnsure(db);
// 厂商锁定模式:在迁移完成后同步 vendor_lock 配置
const { applyVendorLock } = require('./services/aiConfigService');
applyVendorLock(db, logger, config);
const log = logger;
const { resumeProcessingVideoGenerations } = require('./services/videoService');
resumeProcessingVideoGenerations(db, log);
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(
cors({
origin: config.server.cors_origins && config.server.cors_origins.length
? config.server.cors_origins
: '*',
})
);
app.use((req, res, next) => {
log.info(req.method, req.path);
next();
});
// 静态资源目录:统一转为绝对路径(打包 exe 下相对路径可能解析异常)
const storageRoot = config.storage?.local_path
? (path.isAbsolute(config.storage.local_path)
? config.storage.local_path
: path.join(process.cwd(), config.storage.local_path))
: path.join(process.cwd(), 'data', 'storage');
try {
if (!fs.existsSync(storageRoot)) fs.mkdirSync(storageRoot, { recursive: true });
app.use('/static', express.static(storageRoot));
} catch (e) {
console.warn('Static storage mount skipped:', e.message);
}
app.get('/health', (req, res) => {
res.json({
status: 'ok',
app: config.app.name,
version: config.app.version,
});
});
app.use('/api/v1', setupRouter(config, db, log));
// 前端静态资源(sxyweb/dist);Electron 打包时可设 WEB_DIST_PATH
const webDist = process.env.WEB_DIST_PATH || path.join(process.cwd(), '..', 'frontweb', 'dist');
console.log('webDist', webDist);
if (fs.existsSync(webDist)) {
app.use('/assets', express.static(path.join(webDist, 'assets')));
// 服务 dist 根目录的静态文件(如 wx.jpg、favicon.ico 等)
app.use(express.static(webDist, { index: false }));
app.get('/favicon.ico', (req, res) => {
const fav = path.join(webDist, 'favicon.ico');
if (fs.existsSync(fav)) res.sendFile(fav);
else res.status(404).end();
});
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api')) return next();
const indexHtml = path.join(webDist, 'index.html');
if (fs.existsSync(indexHtml)) res.sendFile(indexHtml);
else next();
});
} else {
app.get('/', (req, res) => {
res.send(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>LocalMiniDrama</title></head><body>' +
'<h1>LocalMiniDrama API</h1><p>后端已启动。请先构建前端:</p>' +
'<pre>cd web &amp;&amp; pnpm install &amp;&amp; pnpm build</pre>' +
'<p>然后将 <code>web/dist</code> 放到与 backend-node 同级的 <code>web/dist</code>,或访问 <a href="/health">/health</a> 检查接口。</p></body></html>'
);
});
}
app.use((req, res) => {
if (req.path.startsWith('/api')) {
return res.status(404).json({ error: 'API endpoint not found' });
}
res.status(404).send('Not Found');
});
app.use((err, req, res, next) => {
log.errorw('Unhandled error', { error: err.message, path: req.path });
if (!res.headersSent) {
const isFileTooLarge = err.code === 'LIMIT_FILE_SIZE' || (err.message && err.message.includes('File too large'));
const status = isFileTooLarge ? 413 : 500;
const message = isFileTooLarge ? '图片大小不能超过 16MB,请压缩后重试' : (err.message || '服务器错误');
res.status(status).json({ success: false, error: { code: isFileTooLarge ? 'FILE_TOO_LARGE' : 'INTERNAL_ERROR', message }, timestamp: new Date().toISOString() });
}
});
return { app, config, db };
}
module.exports = { createApp };
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const configPaths = [
path.join(process.cwd(), 'configs', 'config.yaml'),
path.join(process.cwd(), 'config.yaml'),
path.join(__dirname, '..', '..', 'configs', 'config.yaml'),
];
function loadConfig() {
let raw = null;
for (const p of configPaths) {
if (fs.existsSync(p)) {
raw = fs.readFileSync(p, 'utf8');
break;
}
}
if (!raw) {
throw new Error('Config file not found: configs/config.yaml');
}
const parsed = yaml.load(raw);
if (!parsed?.app?.name) {
throw new Error('Invalid config: missing app section');
}
return parsed;
}
module.exports = { loadConfig };
@@ -0,0 +1,10 @@
/**
* 与 videoService 异步轮询一致:config.yaml 中 video.generation_timeout_minutes,缺省或非法时为 30。
*/
function resolveVideoGenerationTimeoutMinutes(cfg) {
if (!cfg) return 30;
const raw = Number(cfg.video?.generation_timeout_minutes);
return Number.isFinite(raw) && raw > 0 ? raw : 30;
}
module.exports = { resolveVideoGenerationTimeoutMinutes };
@@ -0,0 +1,58 @@
'use strict';
/**
* 与 frontweb/src/constants/styleOptions.js 的 value 选项一致。
* 当 DB 仅有 dramas.style(下拉 value)而无 metadata.style_prompt_* 时,后端据此展开为完整提示词。
* 若修改前端选项,请同步更新本文件。
*/
const PRESETS = [
['realistic', '超写实摄影风格,8K超清细节,精准自然光照,真实皮肤纹理,专业摄影机拍摄,RAW原片质感,超高清锐度,人物面部毛孔清晰可见', 'photorealistic, ultra-detailed, 8k uhd, sharp focus, natural lighting, real skin texture, hyperrealism, professional photography, RAW photo'],
['cinematic', '电影级大片画面,变形镜头压缩感,胶片颗粒质感,伦勃朗式戏剧性布光,浅景深虚化背景,专业调色风格,史诗级构图,35mm胶片美学,宽画幅银幕比例', 'cinematic movie still, anamorphic lens, film grain, dramatic rembrandt lighting, shallow depth of field, color graded, epic composition, professional cinematography, 35mm film, widescreen'],
['documentary', '纪录片摄影风格,自然可用光源,抓拍式真实瞬间,手持摄影机晃动感,新闻摄影美学,粗粝真实质感,颗粒感胶片,非摆拍自然状态', 'documentary photography style, natural available light, candid authentic moment, handheld camera look, photojournalism, raw gritty realism, grain texture, unposed'],
['noir', '黑色电影风格,高对比度黑白影调,强烈明暗光影雕刻,百叶窗投影光纹,1940年代侦探片氛围,悬疑神秘气质,烟雾缭绕与雨夜街景', 'film noir, dramatic high-contrast black and white, hard chiaroscuro shadows, venetian blind light patterns, moody 1940s detective aesthetic, mystery atmosphere, smoke and rain'],
['retro film', '复古胶片摄影美学,柯达色彩体系,漏光与光晕效果,浓重35mm胶片颗粒,褪色暖调色彩,模拟胶片质感,怀旧复古氛围,轻微过曝处理', 'vintage retro film photography, kodachrome color palette, light leaks, heavy 35mm grain, faded warm tones, analog film aesthetics, nostalgic atmosphere, slightly overexposed'],
['horror', '恐怖氛围渲染,阴暗压抑情绪,浓厚大气雾气,深重戏剧阴影,诡异冷色布光,令人不安的构图,哥特元素点缀,去饱和暗调色板,心理悬疑张力', 'horror atmosphere, dark ominous mood, dense atmospheric fog, deep dramatic shadows, eerie cold lighting, unsettling composition, gothic elements, desaturated dark palette, psychological tension'],
['anime style', '日本动漫画风,精细赛璐璐上色,清晰黑色线稿,高饱和鲜艳配色,极具表现力的角色设计,动画工作室级别质量,漫画美学影响,关键帧视觉插图风格', 'anime style, Japanese animation, clean cel shading, precise black linework, vibrant saturated colors, expressive character design, studio quality, manga influence, key visual illustration'],
['comic style', '欧美漫画风格,粗犷墨线勾勒,半调网点纹理,充满动感的动作构图,平涂鲜艳色彩,超级英雄插画美学,墨水上色分格效果', 'western comic book style, bold ink linework, halftone dot texture, dynamic action composition, flat vibrant colors, superhero illustration aesthetic, inked and colored panels'],
['cartoon', '卡通插画风格,简洁粗犷轮廓线,平涂纯色块面,夸张表情与肢体动作,活泼友好的设计感,欧美动画片风格,干净的矢量感画质', 'cartoon illustration, simple bold outlines, flat solid colors, exaggerated expressive features, playful friendly design, western animation style, clean vector-like quality'],
['2d animation', '二维动画风格,流畅动画单帧画面,干净平面设计感,粗犷轮廓线条,鲜艳饱和色彩,动画长片级别质量,关键帧插画美学', '2D animation style, smooth animated frame, clean flat design, bold outlines, vibrant colors, animated feature film quality, keyframe illustration'],
['realistic anime', '写实二次元风格,动漫角色比例与精致五官,真实皮肤与头发微细节,细腻赛璐璐与软写实混合上色,电影级体积光与环境反射,现代都市或室内真实场景,镜头感构图与浅景深,保留二次元清晰轮廓同时具备影视级材质质感,日漫与国漫高质量宣传视觉气质', 'realistic anime style, anime character proportions with refined facial features, realistic skin texture and detailed hair strands, hybrid cel shading and soft semi-realistic rendering, cinematic volumetric lighting and environment reflections, modern urban or interior real-world setting, cinematic composition with shallow depth of field, keep clean anime linework while preserving film-grade material realism, high-end Japanese and Chinese anime promotional visual aesthetic'],
['urban 3d', '都市三维风格,当代摩天楼与玻璃幕墙街景,钢混结构与金属反光,PBR物理材质与柔和全局光照,天空与建筑环境反射,轻微景深虚化车流与行人轮廓,干净偏写实三维渲染,国产都市剧与商业广告CG常见气质,高细节环境光遮蔽与体积雾点缀', 'urban contemporary 3D CGI, modern skyscrapers and glass curtain wall streetscape, steel concrete architecture with subtle metal reflections, PBR materials soft global illumination sky and building reflections, gentle depth of field for traffic and pedestrian silhouettes, clean semi-realistic 3D render Chinese urban drama and commercial CG aesthetic, high detail ambient occlusion light atmospheric haze'],
['ink wash', '中国传统水墨画风格,泼墨写意技法,单色笔墨晕染,竹毫笔触肌理,极简留白构图,宣纸纸张质感,诗意朦胧云雾氛围,国画工笔与写意结合', 'traditional Chinese ink wash painting, sumi-e style, monochrome brushwork, bamboo brush strokes, minimalist composition, generous negative space, xuan paper texture, poetic misty atmosphere, guohua style'],
['chinese style', '中国传统美学,精致汉服服饰,朱红描金器物,精工刺绣纹样,明清朝代设计元素,古典建筑与亭台楼阁,景深悠远的意境', 'Chinese traditional aesthetics, elegant hanfu costumes, red lacquer and gold ornaments, intricate embroidered patterns, Ming-Qing dynasty design elements, classical architecture, atmospheric depth'],
['historical', '中国历史古装剧风格,唐宋朝代电影美学,飘逸汉服广袖,皇宫殿宇建筑,古典园林景观,浓郁暖调色彩分级,高制作水准影视质感', 'Chinese historical drama, ancient China setting, Tang-Song dynasty cinematic aesthetic, flowing traditional hanfu robes, imperial palace architecture, classical garden, rich warm color grading, high production value'],
['wuxia', '武侠史诗画风,古代中国山河背景,丝绸长袍飞扬动感,云雾缥缈的山水胜景,戏剧性剑术对决姿态,水墨晕染氛围影响,侠客剑士英雄美学,史诗宽幅电影构图,烟雾光芒交织的悬疑气氛', 'wuxia martial arts epic, ancient China, flowing silk robes in dynamic motion, misty mountain landscape, dramatic sword fighting pose, atmospheric ink wash influence, hero and swordsman aesthetic, cinematic epic wide shot, moody fog and light rays'],
['watercolor', '水彩绘画风格,湿润叠色柔边,透明色彩晕染,流动颜料自然扩散,纸张纤维质感,印象派笔触,明亮柔和色调,精致手绘插画质量', 'watercolor painting, soft wet-on-wet edges, transparent color washes, flowing pigment blooms, delicate paper texture, impressionistic strokes, luminous pastel tones, fine art illustration'],
['oil painting', '布面油画风格,厚涂肌理质感,有力方向性笔触,深沉饱和色彩,古典大师明暗对比光法,博物馆级精品,文艺复兴美学传承', 'oil painting on canvas, rich impasto textures, thick directional brushwork, deep saturated colors, old master chiaroscuro lighting, museum quality fine art, classical Renaissance aesthetic'],
['sketch', '精细铅笔素描,石墨绘画质感,精准排线与交叉网线,明暗调子处理,美术速写本质量,黑白单色,原始艺术张力,炭笔纸面肌理', 'detailed pencil sketch, graphite drawing, precise hatching and crosshatching, tonal shading, fine art sketchbook quality, monochrome, raw artistic energy, charcoal texture'],
['woodblock print', '传统木刻版画风格,浮世绘美学,大块平涂色域,有限和谐色系,日本版画制作美学,图形化线条,北斋构图风格', 'traditional woodblock print, ukiyo-e inspired, bold flat color areas, limited harmonious palette, Japanese printmaking aesthetic, graphic linework, Hokusai style composition'],
['impressionist', '印象派油画风格,松散表现性笔触,斑驳阳光光影效果,鲜明互补色彩,莫奈雷诺阿风格,户外写生自然光,大气光色交融', 'impressionist oil painting, loose expressive brushstrokes, dappled sunlight effect, vibrant complementary colors, Monet-Renoir style, plein air outdoor painting, atmospheric light and color'],
['fantasy', '史诗奇幻数字艺术,神奇空灵大气,戏剧性黄金时刻光效,神话生物与魔法世界,壮阔全景风光,高度细腻概念艺术,绘画插图质量', 'epic fantasy digital art, magical ethereal atmosphere, dramatic golden hour lighting, mythical creatures and enchanted world, sweeping landscape, highly detailed concept art, painterly illustration quality'],
['dark fantasy', '黑暗奇幻艺术风格,哥特式阴郁氛围,压抑暗沉色调,戏剧性边缘补光,克苏鲁秘法元素,巴洛克繁复细节,严酷粗粝的世界观,恐怖奇幻交融', 'dark fantasy art, gothic ominous atmosphere, brooding dark palette, dramatic rim lighting, eldritch and arcane elements, baroque ornate detail, grim and gritty world, horror fantasy crossover'],
['sci-fi', '科幻概念艺术,未来科技元素,全息投影界面,先进文明设计美学,简洁科幻质感,太空时代材质,发光交互界面,硬科幻写实风格', 'science fiction concept art, futuristic technology, holographic displays, sleek advanced civilization design, clean sci-fi aesthetic, space age materials, glowing interfaces, hard sci-fi realism'],
['cyberpunk', '赛博朋克美学,霓虹浸润雨后街道,反乌托邦巨型都市,高科技低生活世界,发光广告牌林立,漆黑雨夜氛围,霓虹粉紫与电光蓝,银翼杀手黑色电影气质', 'cyberpunk aesthetic, neon-soaked rain-slicked streets, dystopian megacity, high tech low life, glowing advertising billboards, dark wet night, neon pink magenta and electric blue, blade runner noir atmosphere'],
['steampunk', '蒸汽朋克美学,维多利亚时代工业幻想,光亮黄铜齿轮与铜管构件,蒸汽驱动机械装置,棕褐色暖调,精巧机械装置,护目镜与礼帽造型,华丽钟表机芯细节', 'steampunk aesthetic, Victorian era industrial fantasy, polished brass gears and copper cogs, steam powered machinery, sepia warm tones, elaborate mechanical contraptions, goggles and top hats, ornate clockwork'],
['post-apocalyptic', '末世废土荒漠,文明崩塌遗迹,灰暗低饱和色调,生存末日氛围,腐朽建筑与废墟,尘埃与碎石漫天,强烈戏剧光照,疯狂麦克斯美学', 'post-apocalyptic wasteland, ruined crumbling civilization, harsh desaturated color palette, survival atmosphere, decayed architecture, dust and debris, harsh dramatic light, Mad Max aesthetic'],
['3d render', '三维CGI渲染,光线追踪全局光照,次表面散射写实质感,HDRI工作室照明,高精度多边形模型,物理渲染流程,Octane或Redshift级别品质,产品级可视化精度', '3D CGI render, ray tracing global illumination, photorealistic subsurface scattering, studio HDRI lighting, high polygon model, physically based rendering, Octane or Redshift quality, product visualization'],
['pixel art', '像素艺术风格,16位复古游戏美学,有限色板,清晰硬边像素颗粒,精灵图艺术质感,经典日式RPG视觉风格,等距或横版游戏画面', 'pixel art, 16-bit retro game aesthetic, limited color palette, crisp hard pixels, sprite art style, classic JRPG visual, isometric or side-scroll game art'],
['low poly', '低多边形几何艺术,平面三角形切面,极简多边形数量,干净彩色切面组合,现代几何美学,三维折纸风格,抽象数字艺术感', 'low poly geometric art, flat triangular faceted surfaces, minimal polygon count, clean colorful facets, modern geometric aesthetic, 3D origami style, abstract digital art'],
['minimalist', '极简主义设计美学,干净无杂乱构图,大量留白呼吸感,简洁几何形态,有限单色色系,包豪斯现代主义,优雅克制的简约美感', 'minimalist design, clean uncluttered composition, generous negative space, simple geometric forms, limited monochromatic palette, modern Bauhaus aesthetic, sophisticated elegant simplicity'],
['dreamy', '唯美梦幻美学,奶油色柔虚背景,粉彩柔和色调,空灵发光氛围,浪漫柔光打亮,细腻雾气与光晕,童话魔法质感,软焦梦境感', 'dreamy aesthetic, creamy soft bokeh background, pastel color palette, ethereal glowing atmosphere, romantic soft lighting, delicate haze and glow, fairy tale magical quality, soft focus dreamy'],
];
const byValue = new Map(PRESETS.map(([value, zh, en]) => [value, { zh, en }]));
/**
* @param {string} legacy dramas.style 或任意待解析串
* @returns {{ zh: string, en: string } | null} 仅当完全匹配预设 value 时返回
*/
function resolveStylePreset(legacy) {
const k = (legacy != null ? String(legacy) : '').trim();
if (!k) return null;
return byValue.get(k) || null;
}
module.exports = {
resolveStylePreset,
PRESET_VALUES: [...byValue.keys()],
};
+29
View File
@@ -0,0 +1,29 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
let db = null;
function getDb(config) {
if (db) return db;
const dbPath = config.path;
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(dbPath, {
verbose: config.type === 'sqlite' && process.env.DEBUG ? console.log : undefined,
});
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
return db;
}
function closeDb() {
if (db) {
db.close();
db = null;
}
}
module.exports = { getDb, closeDb };
+540
View File
@@ -0,0 +1,540 @@
const fs = require('fs');
const path = require('path');
const { getDb } = require('./index.js');
const { loadConfig } = require('../config/index.js');
function stripLeadingComments(sql) {
return sql
.split('\n')
.filter((line) => {
const t = line.trim();
return t.length > 0 && !t.startsWith('--');
})
.join('\n')
.trim();
}
function runOne(database, sql, file, index) {
const s = stripLeadingComments(sql);
if (!s) return;
try {
database.exec(s);
console.log('Ran migration:', file + (index >= 0 ? ' #' + (index + 1) : ''));
} catch (err) {
const msg = (err.message || '').toLowerCase();
if (err.code === 'SQLITE_ERROR' && (msg.includes('duplicate column') || msg.includes('already exists'))) {
console.log('Skip (already exists):', file + (index >= 0 ? ' #' + (index + 1) : ''));
} else if (err.code === 'SQLITE_ERROR' && msg.includes('no such table')) {
// ALTER TABLE 遇到表不存在时,记录警告并跳过(启动后 ensureAllColumns 会兜底建表补列)
console.warn('Skip migration (table not found, will be ensured later):', file, '-', err.message);
} else {
throw err;
}
}
}
function runMigrations(database) {
const migrationsDir = path.join(__dirname, '..', '..', 'migrations');
if (!fs.existsSync(migrationsDir)) {
console.log('Migrations dir missing, skipping:', migrationsDir);
return;
}
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
const fullPath = path.join(migrationsDir, file);
const sql = fs.readFileSync(fullPath, 'utf8');
const statements = sql
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (statements.length <= 1) {
runOne(database, sql, file, -1);
} else {
statements.forEach((stmt, i) => runOne(database, stmt + ';', file, i));
}
}
}
/**
* 通用:确保某张表存在指定列,不存在则 ALTER TABLE ADD COLUMN。
* @param {object} database - better-sqlite3 实例
* @param {string} table - 表名
* @param {Array<{name:string, type:string}>} columns - 要确保存在的列
*/
function ensureColumns(database, table, columns) {
let existing;
try {
existing = database.prepare(`PRAGMA table_info(${table})`).all();
} catch (err) {
if ((err.message || '').toLowerCase().includes('no such table')) {
console.log(`ensureColumns: table ${table} not found, skip`);
return;
}
throw err;
}
const names = new Set(existing.map((r) => r.name));
for (const col of columns) {
if (names.has(col.name)) continue;
try {
database.exec(`ALTER TABLE ${table} ADD COLUMN ${col.name} ${col.type}`);
console.log(`ensureColumns: added ${table}.${col.name} (${col.type})`);
} catch (e) {
if ((e.message || '').toLowerCase().includes('duplicate column')) {
// already exists (race / concurrent)
} else {
console.warn(`ensureColumns: failed to add ${table}.${col.name}:`, e.message);
}
}
}
}
/**
* 全量兜底补列:覆盖所有表的所有业务列。
* 对于旧数据库(用更早版本的 init 脚本创建、缺少部分列),
* 在每次启动时自动补齐,避免 "no such column" 运行时错误。
*
* SQLite 不支持 ALTER TABLE ADD COLUMN ... NOT NULL(无默认值),
* 所以原 schema 中 NOT NULL 的列在这里用 DEFAULT 兜底。
*/
function ensureAllColumns(database) {
// --- dramas ---
ensureColumns(database, 'dramas', [
{ name: 'title', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'description', type: 'TEXT' },
{ name: 'genre', type: 'TEXT' },
{ name: 'style', type: 'TEXT DEFAULT \'realistic\'' },
{ name: 'tags', type: 'TEXT' },
{ name: 'thumbnail', type: 'TEXT' },
{ name: 'total_episodes', type: 'INTEGER DEFAULT 1' },
{ name: 'total_duration', type: 'INTEGER DEFAULT 0' },
{ name: 'status', type: 'TEXT DEFAULT \'draft\'' },
{ name: 'metadata', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- episodes ---
ensureColumns(database, 'episodes', [
{ name: 'drama_id', type: 'INTEGER DEFAULT 0' },
{ name: 'episode_number', type: 'INTEGER DEFAULT 0' },
{ name: 'title', type: 'TEXT DEFAULT \'\'' },
{ name: 'script_content', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'duration', type: 'INTEGER DEFAULT 0' },
{ name: 'video_url', type: 'TEXT' },
{ name: 'thumbnail', type: 'TEXT' },
{ name: 'status', type: 'TEXT DEFAULT \'draft\'' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- storyboards ---
ensureColumns(database, 'storyboards', [
{ name: 'episode_id', type: 'INTEGER DEFAULT 0' },
{ name: 'scene_id', type: 'INTEGER' },
{ name: 'storyboard_number', type: 'INTEGER DEFAULT 0' },
{ name: 'title', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'layout_description', type: 'TEXT' }, // 画面布局与人物站位(首尾帧模式空间合同)
{ name: 'location', type: 'TEXT' },
{ name: 'time', type: 'TEXT' },
{ name: 'duration', type: 'REAL' },
{ name: 'dialogue', type: 'TEXT' },
{ name: 'narration', type: 'TEXT' },
{ name: 'action', type: 'TEXT' },
{ name: 'atmosphere', type: 'TEXT' },
{ name: 'image_prompt', type: 'TEXT' },
{ name: 'video_prompt', type: 'TEXT' },
{ name: 'characters', type: 'TEXT' },
{ name: 'shot_type', type: 'TEXT' },
{ name: 'angle', type: 'TEXT' },
{ name: 'movement', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'main_panel_idx', type: 'INTEGER' },
{ name: 'video_url', type: 'TEXT' },
{ name: 'composed_image', type: 'TEXT' },
{ name: 'result', type: 'TEXT' },
{ name: 'emotion', type: 'TEXT' }, // 当前情绪(兴奋/悲伤/紧张等)
{ name: 'emotion_intensity', type: 'INTEGER' }, // 情绪强度 3/2/1/0/-1
{ name: 'error_msg', type: 'TEXT' },
{ name: 'segment_index', type: 'INTEGER DEFAULT 0' }, // 剧情段落索引(0-based
{ name: 'segment_title', type: 'TEXT' }, // 剧情段落名称
{ name: 'angle_h', type: 'TEXT' }, // 水平方向(front/left/back/right...
{ name: 'angle_v', type: 'TEXT' }, // 俯仰角度(worm/low/eye_level/high
{ name: 'angle_s', type: 'TEXT' }, // 景别(close_up/medium/wide
{ name: 'lighting_style', type: 'TEXT' }, // 灯光风格(natural/side/dramatic/golden_hour 等)
{ name: 'depth_of_field', type: 'TEXT' }, // 景深(shallow/medium/deep/extreme_shallow
{ name: 'polished_prompt', type: 'TEXT' }, // 文字AI润色后的图片生成提示词(可编辑,生图时优先使用)
{ name: 'continuity_snapshot', type: 'TEXT' }, // JSON: 连戏状态快照 {characters:{name:{position,clothing,expression,props}},lighting}
{ name: 'audio_local_path', type: 'TEXT' }, // 对白 TTS 本地路径
{ name: 'narration_audio_local_path', type: 'TEXT' }, // 解说旁白 TTS 本地路径
{ name: 'creation_mode', type: 'TEXT DEFAULT \'classic\'' }, // classic | universal
{ name: 'universal_segment_text', type: 'TEXT' }, // 全能模式片段描述(@ 引用等)
{ name: 'first_frame_image_id', type: 'INTEGER' },
{ name: 'last_frame_image_id', type: 'INTEGER' },
{ name: 'last_frame_image_url', type: 'TEXT' },
{ name: 'last_frame_local_path', type: 'TEXT' },
{ name: 'status', type: 'TEXT DEFAULT \'draft\'' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- characters ---
ensureColumns(database, 'characters', [
{ name: 'drama_id', type: 'INTEGER DEFAULT 0' },
{ name: 'name', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'role', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'personality', type: 'TEXT' },
{ name: 'appearance', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'extra_images', type: 'TEXT' },
{ name: 'voice_style', type: 'TEXT' },
{ name: 'sort_order', type: 'INTEGER DEFAULT 0' },
{ name: 'error_msg', type: 'TEXT' },
{ name: 'identity_anchors', type: 'TEXT' }, // JSON: 6层视觉锚点(骨相/五官/辨识标记/色值/皮肤/发型)
{ name: 'style_tokens', type: 'TEXT' }, // 风格词 token 列表
{ name: 'color_palette', type: 'TEXT' }, // JSON: Hex 色值数组
{ name: 'four_view_image_url', type: 'TEXT' }, // 四视图参考图 URL
{ name: 'polished_prompt', type: 'TEXT' }, // 文字AI润色后的完整图片生成提示词(可编辑,生图时直接使用)
{ name: 'ref_image', type: 'TEXT' }, // 用户上传的参考图(本地相对路径或 URL),独立于 AI 生成的主图
{ name: 'stages', type: 'TEXT' }, // JSON: 多阶段造型 [{episode_range:[1,3], appearance:"..."}]
{ name: 'seedance2_asset', type: 'TEXT' }, // JSON: 即梦/Seedance2 素材库认证 hub_asset_id / asset_url 等
{ name: 'seedance2_voice_asset', type: 'TEXT' }, // JSON: Seedance 2.0 音色参考音频(仅 SD2 模型有效)
{ name: 'negative_prompt', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- scenes ---
ensureColumns(database, 'scenes', [
{ name: 'drama_id', type: 'INTEGER DEFAULT 0' },
{ name: 'episode_id', type: 'INTEGER' },
{ name: 'location', type: 'TEXT' },
{ name: 'time', type: 'TEXT' },
{ name: 'prompt', type: 'TEXT' },
{ name: 'polished_prompt', type: 'TEXT' }, // 文字AI润色后的完整四视图图片提示词,生图时直接使用
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'extra_images', type: 'TEXT' },
{ name: 'ref_image', type: 'TEXT' }, // 用户上传的参考图(本地相对路径或 URL)
{ name: 'negative_prompt', type: 'TEXT' },
{ name: 'storyboard_count', type: 'INTEGER DEFAULT 0' },
{ name: 'error_msg', type: 'TEXT' },
{ name: 'status', type: 'TEXT DEFAULT \'draft\'' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- props ---
ensureColumns(database, 'props', [
{ name: 'drama_id', type: 'INTEGER DEFAULT 0' },
{ name: 'episode_id', type: 'INTEGER' },
{ name: 'name', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'type', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'prompt', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'extra_images', type: 'TEXT' },
{ name: 'ref_image', type: 'TEXT' }, // 用户上传的参考图(本地相对路径或 URL)
{ name: 'negative_prompt', type: 'TEXT' },
{ name: 'error_msg', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- ai_service_configs ---(兜底建表:旧版 01_init.sql 可能未包含此表)
try {
database.exec(`CREATE TABLE IF NOT EXISTS ai_service_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_type TEXT NOT NULL DEFAULT 'text',
provider TEXT DEFAULT '',
name TEXT DEFAULT '',
base_url TEXT DEFAULT '',
api_key TEXT,
model TEXT,
default_model TEXT,
endpoint TEXT,
query_endpoint TEXT,
priority INTEGER DEFAULT 0,
is_default INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
settings TEXT,
created_at TEXT,
updated_at TEXT,
deleted_at TEXT
)`);
} catch (_) {}
ensureColumns(database, 'ai_service_configs', [
{ name: 'service_type', type: 'TEXT NOT NULL DEFAULT \'text\'' },
{ name: 'provider', type: 'TEXT DEFAULT \'\'' },
{ name: 'name', type: 'TEXT DEFAULT \'\'' },
{ name: 'base_url', type: 'TEXT DEFAULT \'\'' },
{ name: 'api_key', type: 'TEXT' },
{ name: 'model', type: 'TEXT' },
{ name: 'default_model', type: 'TEXT' },
{ name: 'endpoint', type: 'TEXT' },
{ name: 'query_endpoint', type: 'TEXT' },
{ name: 'priority', type: 'INTEGER DEFAULT 0' },
{ name: 'is_default', type: 'INTEGER DEFAULT 0' },
{ name: 'is_active', type: 'INTEGER DEFAULT 1' },
{ name: 'settings', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- async_tasks ---
ensureColumns(database, 'async_tasks', [
{ name: 'type', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'status', type: 'TEXT NOT NULL DEFAULT \'pending\'' },
{ name: 'progress', type: 'INTEGER DEFAULT 0' },
{ name: 'message', type: 'TEXT' },
{ name: 'resource_id', type: 'TEXT' },
{ name: 'completed_at', type: 'TEXT' },
{ name: 'error', type: 'TEXT' },
{ name: 'result', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- image_generations ---
ensureColumns(database, 'image_generations', [
{ name: 'storyboard_id', type: 'INTEGER' },
{ name: 'drama_id', type: 'INTEGER' },
{ name: 'episode_id', type: 'INTEGER' },
{ name: 'scene_id', type: 'INTEGER' },
{ name: 'character_id', type: 'INTEGER' },
{ name: 'provider', type: 'TEXT' },
{ name: 'prompt', type: 'TEXT' },
{ name: 'negative_prompt', type: 'TEXT' },
{ name: 'model', type: 'TEXT' },
{ name: 'frame_type', type: 'TEXT' },
{ name: 'reference_images', type: 'TEXT' },
{ name: 'use_first_frame_layout_lock', type: 'INTEGER' },
{ name: 'size', type: 'TEXT' },
{ name: 'quality', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'width', type: 'INTEGER' },
{ name: 'height', type: 'INTEGER' },
{ name: 'status', type: 'TEXT' },
{ name: 'task_id', type: 'TEXT' },
{ name: 'completed_at', type: 'TEXT' },
{ name: 'error_msg', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- video_generations ---
ensureColumns(database, 'video_generations', [
{ name: 'drama_id', type: 'INTEGER' },
{ name: 'storyboard_id', type: 'INTEGER' },
{ name: 'provider', type: 'TEXT' },
{ name: 'prompt', type: 'TEXT' },
{ name: 'model', type: 'TEXT' },
{ name: 'duration', type: 'REAL' },
{ name: 'aspect_ratio', type: 'TEXT' },
{ name: 'resolution', type: 'TEXT' },
{ name: 'seed', type: 'INTEGER' },
{ name: 'camera_fixed', type: 'INTEGER' },
{ name: 'watermark', type: 'INTEGER' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'first_frame_url', type: 'TEXT' },
{ name: 'last_frame_url', type: 'TEXT' },
{ name: 'reference_image_urls', type: 'TEXT' },
{ name: 'video_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'status', type: 'TEXT' },
{ name: 'task_id', type: 'TEXT' },
{ name: 'provider_task_id', type: 'TEXT' },
{ name: 'scene_id', type: 'INTEGER' },
{ name: 'completed_at', type: 'TEXT' },
{ name: 'error_msg', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- video_merges ---
ensureColumns(database, 'video_merges', [
{ name: 'episode_id', type: 'INTEGER' },
{ name: 'drama_id', type: 'INTEGER' },
{ name: 'title', type: 'TEXT' },
{ name: 'provider', type: 'TEXT' },
{ name: 'model', type: 'TEXT' },
{ name: 'status', type: 'TEXT' },
{ name: 'scenes', type: 'TEXT' },
{ name: 'merge_options', type: 'TEXT' },
{ name: 'task_id', type: 'TEXT' },
{ name: 'merged_url', type: 'TEXT' },
{ name: 'duration', type: 'INTEGER' },
{ name: 'completed_at', type: 'TEXT' },
{ name: 'error_msg', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- assets ---
ensureColumns(database, 'assets', [
{ name: 'drama_id', type: 'INTEGER' },
{ name: 'name', type: 'TEXT' },
{ name: 'type', type: 'TEXT' },
{ name: 'category', type: 'TEXT' },
{ name: 'url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'file_size', type: 'INTEGER' },
{ name: 'mime_type', type: 'TEXT' },
{ name: 'width', type: 'INTEGER' },
{ name: 'height', type: 'INTEGER' },
{ name: 'duration', type: 'REAL' },
{ name: 'image_gen_id', type: 'INTEGER' },
{ name: 'video_gen_id', type: 'INTEGER' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- character_libraries ---
ensureColumns(database, 'character_libraries', [
{ name: 'drama_id', type: 'INTEGER' }, // NULL = 全局素材库;有值 = 本剧专属
{ name: 'name', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'category', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'appearance', type: 'TEXT' },
{ name: 'tags', type: 'TEXT' },
{ name: 'source_type', type: 'TEXT' },
{ name: 'source_id', type: 'TEXT' },
{ name: 'identity_anchors', type: 'TEXT' }, // JSON: 6层视觉锚点(骨相/五官/辨识标记/色值/皮肤/发型)
{ name: 'style_tokens', type: 'TEXT' }, // 风格词 token 列表
{ name: 'color_palette', type: 'TEXT' }, // JSON: Hex 色值数组
{ name: 'four_view_image_url', type: 'TEXT' }, // 四视图参考图 URL(分镜图生图参考用)
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- scene_libraries ---
ensureColumns(database, 'scene_libraries', [
{ name: 'drama_id', type: 'INTEGER' }, // NULL = 全局素材库
{ name: 'location', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'time', type: 'TEXT' },
{ name: 'prompt', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'category', type: 'TEXT' },
{ name: 'tags', type: 'TEXT' },
{ name: 'source_type', type: 'TEXT' },
{ name: 'source_id', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- prop_libraries ---
ensureColumns(database, 'prop_libraries', [
{ name: 'drama_id', type: 'INTEGER' }, // NULL = 全局素材库
{ name: 'name', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'description', type: 'TEXT' },
{ name: 'prompt', type: 'TEXT' },
{ name: 'image_url', type: 'TEXT' },
{ name: 'local_path', type: 'TEXT' },
{ name: 'category', type: 'TEXT' },
{ name: 'tags', type: 'TEXT' },
{ name: 'source_type', type: 'TEXT' },
{ name: 'source_id', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT' },
{ name: 'updated_at', type: 'TEXT' },
{ name: 'deleted_at', type: 'TEXT' },
]);
// --- image_proxy_cache ---
try {
database.exec(`CREATE TABLE IF NOT EXISTS image_proxy_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT NOT NULL UNIQUE,
proxy_url TEXT NOT NULL,
created_at TEXT NOT NULL
)`);
} catch (_) {}
ensureColumns(database, 'image_proxy_cache', [
{ name: 'cache_key', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'proxy_url', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'created_at', type: 'TEXT NOT NULL DEFAULT \'\'' },
]);
// --- ai_model_map(业务场景→模型路由映射表) ---
try {
database.exec(`CREATE TABLE IF NOT EXISTS ai_model_map (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
service_type TEXT NOT NULL DEFAULT 'text',
config_id INTEGER,
model_override TEXT,
description TEXT,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`);
} catch (_) {}
ensureColumns(database, 'ai_model_map', [
{ name: 'key', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'service_type', type: 'TEXT NOT NULL DEFAULT \'text\'' },
{ name: 'config_id', type: 'INTEGER' },
{ name: 'model_override', type: 'TEXT' },
{ name: 'description', type: 'TEXT' },
{ name: 'created_at', type: 'TEXT NOT NULL DEFAULT \'\'' },
{ name: 'updated_at', type: 'TEXT NOT NULL DEFAULT \'\'' },
]);
// --- storyboard_characters(分镜与角色库的关联表) ---
try {
database.exec(`CREATE TABLE IF NOT EXISTS storyboard_characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storyboard_id INTEGER NOT NULL,
character_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT ''
)`);
} catch (_) {}
// --- global_settings(全局键值设置表) ---
try {
database.exec(`CREATE TABLE IF NOT EXISTS global_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`);
} catch (_) {}
}
/** 对已打开的 database 执行迁移与兜底补列(供 app 启动时调用) */
function runMigrationsAndEnsure(database) {
runMigrations(database);
ensureAllColumns(database);
}
function main() {
const config = loadConfig();
const database = getDb(config.database);
runMigrationsAndEnsure(database);
console.log('Migrations complete.');
}
if (require.main === module) {
main();
}
module.exports = { runMigrationsAndEnsure, ensureColumns };
+46
View File
@@ -0,0 +1,46 @@
const fs = require('fs');
const path = require('path');
// 简单 logger,和 Go 端行为接近;若设置 LOG_FILE 则同时追加到该文件(便于打包 exe 双击时查日志)
function log(level, msg, ...args) {
const time = new Date().toISOString();
let rest = '';
if (args.length && typeof args[0] === 'object' && args[0] !== null && !Array.isArray(args[0])) {
rest = ' ' + JSON.stringify(args[0]);
} else if (args.length) {
rest = ' ' + args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
}
const line = `${time} [${level}] ${msg}${rest}\n`;
try {
console.log(line.trimEnd());
} catch (_) {}
const logFile = process.env.LOG_FILE;
if (logFile) {
try {
const dir = path.dirname(logFile);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(logFile, line);
} catch (_) {}
}
}
module.exports = {
info(msg, ...args) {
log('INFO', msg, ...args);
},
infow(msg, ...args) {
log('INFO', msg, ...args);
},
warn(msg, ...args) {
log('WARN', msg, ...args);
},
warnw(msg, ...args) {
log('WARN', msg, ...args);
},
error(msg, ...args) {
log('ERROR', msg, ...args);
},
errorw(msg, ...args) {
log('ERROR', msg, ...args);
},
};
+61
View File
@@ -0,0 +1,61 @@
// 和 Go 端 pkg/response 保持一致,方便前端复用
function send(res, statusCode, body) {
const payload = {
...body,
timestamp: new Date().toISOString(),
};
res.status(statusCode).json(payload);
}
function success(res, data) {
send(res, 200, { success: true, data });
}
function created(res, data) {
send(res, 201, { success: true, data });
}
function successWithPagination(res, items, total, page, pageSize) {
const totalPages = Math.ceil(total / pageSize) || 0;
send(res, 200, {
success: true,
data: {
items,
pagination: { page, page_size: pageSize, total, total_pages: totalPages },
},
});
}
function error(res, statusCode, code, message, details) {
send(res, statusCode, {
success: false,
error: { code, message, ...(details && { details }) },
});
}
function badRequest(res, message) {
error(res, 400, 'BAD_REQUEST', message);
}
function notFound(res, message) {
error(res, 404, 'NOT_FOUND', message);
}
function forbidden(res, message) {
error(res, 403, 'FORBIDDEN', message);
}
function internalError(res, message) {
error(res, 500, 'INTERNAL_ERROR', message || '服务器错误');
}
module.exports = {
success,
created,
successWithPagination,
error,
badRequest,
notFound,
forbidden,
internalError,
};
+198
View File
@@ -0,0 +1,198 @@
const aiConfigService = require('../services/aiConfigService');
const response = require('../response');
function list(db) {
return (req, res) => {
const list = aiConfigService.listConfigs(db, req.query.service_type);
response.success(res, list);
};
}
function get(db) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的配置ID');
const config = aiConfigService.getConfig(db, id);
if (!config) return response.notFound(res, '配置不存在');
response.success(res, config);
};
}
function vendorLock(cfg) {
return (req, res) => {
const status = aiConfigService.getVendorLockStatus(cfg);
response.success(res, status);
};
}
function create(db, log, cfg) {
return (req, res) => {
if (aiConfigService.getVendorLockStatus(cfg).enabled) {
return response.badRequest(res, '当前为厂商锁定模式,不允许添加配置');
}
const body = req.body || {};
if (!body.service_type || !body.name || !body.provider || !body.base_url) {
return response.badRequest(res, '缺少必填字段: service_type, name, provider, base_url');
}
if (body.api_key === undefined || body.api_key === null) {
return response.badRequest(res, '缺少必填字段: api_key');
}
try {
const config = aiConfigService.createConfig(db, log, {
...body,
model: body.model ?? [],
});
response.created(res, config);
} catch (err) {
log.errorw('Create AI config failed', { error: err.message });
response.internalError(res, '创建失败');
}
};
}
function update(db, log, cfg) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的配置ID');
let body = req.body || {};
// 锁定模式下只允许修改 api_key、default_model、is_default
if (aiConfigService.getVendorLockStatus(cfg).enabled) {
const allowed = {};
if (body.api_key !== undefined) allowed.api_key = body.api_key;
if (body.default_model !== undefined) allowed.default_model = body.default_model;
if (body.is_default !== undefined) allowed.is_default = body.is_default;
body = allowed;
}
const config = aiConfigService.updateConfig(db, log, id, body);
if (!config) return response.notFound(res, '配置不存在');
response.success(res, config);
};
}
function remove(db, log, cfg) {
return (req, res) => {
if (aiConfigService.getVendorLockStatus(cfg).enabled) {
return response.badRequest(res, '当前为厂商锁定模式,不允许删除配置');
}
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的配置ID');
const ok = aiConfigService.deleteConfig(db, log, id);
if (!ok) return response.notFound(res, '配置不存在');
response.success(res, { message: '删除成功' });
};
}
function bulkUpdateKey(db, log, cfg) {
return (req, res) => {
if (!aiConfigService.getVendorLockStatus(cfg).enabled) {
return response.badRequest(res, '批量换Key仅在厂商锁定模式下可用');
}
const { api_key } = req.body || {};
if (!api_key || !api_key.trim()) {
return response.badRequest(res, '请提供新的 API Key');
}
try {
const count = aiConfigService.bulkUpdateApiKey(db, log, api_key.trim());
response.success(res, { updated: count, message: `已更新 ${count} 条配置的 API Key` });
} catch (err) {
log.error('Bulk update api_key failed', { error: err.message });
response.internalError(res, '批量换Key失败');
}
};
}
function testConnection(log) {
return async (req, res) => {
const body = req.body || {};
if (!body.base_url || !body.api_key) {
return response.badRequest(res, '缺少 base_url 或 api_key');
}
try {
await aiConfigService.testConnection({
base_url: body.base_url,
api_key: body.api_key,
model: body.model,
provider: body.provider,
endpoint: body.endpoint,
service_type: body.service_type,
settings: body.settings,
});
response.success(res, { message: '连接测试成功' });
} catch (err) {
log.error('AI config test connection failed', { error: err.message });
response.badRequest(res, '连接测试失败: ' + (err.message || '未知错误'));
}
};
}
/** ModelArk / 方舟私有资产库:代理调用 CreateAssetGroup、ListAssets 等(与官方 Action 名一致) */
function modelArkAsset(log) {
return async (req, res) => {
const body = req.body || {};
const action = (body.action || '').toString().trim();
try {
const modelArkAssetProxyService = require('../services/modelArkAssetProxyService');
const data = await modelArkAssetProxyService.callModelArkAsset(
{
base_url: body.base_url,
api_key: body.api_key,
action,
body: body.payload,
path_mode: body.path_mode,
http_method: body.http_method,
api_version: body.api_version,
auth_mode: body.auth_mode,
access_key_id: body.access_key_id,
secret_access_key: body.secret_access_key,
sign_region: body.sign_region,
sign_service: body.sign_service,
session_token: body.session_token,
project_name: body.project_name,
},
log
);
response.success(res, data);
} catch (err) {
log.error('model-ark-asset proxy failed', { error: err.message, action });
const status = err.status >= 400 && err.status < 600 ? err.status : 400;
return response.error(res, status, 'MODEL_ARK_ASSET', err.message || '请求失败', err.payload);
}
};
}
/** 即梦2角色认证:代理 GET 素材列表(表单未保存也可用当前填写的网关与 Token) */
function listJimeng2MaterialAssets(log) {
return async (req, res) => {
const body = req.body || {};
const base_url = (body.base_url || '').toString().trim().replace(/\/$/, '');
const { normalizeMaterialHubToken } = require('../services/jimengMaterialHubService');
let api_key = normalizeMaterialHubToken(body.api_key || '');
if (!base_url || !api_key) {
return response.badRequest(res, '请先填写网关 URL 与 Token');
}
const jimengMaterialHubService = require('../services/jimengMaterialHubService');
const ctx = { baseUrl: base_url, token: api_key };
const r = await jimengMaterialHubService.listAssets(ctx, { limit: body.limit, cursor: body.cursor }, log);
if (!r.ok) {
return response.badRequest(res, String(r.error || '列出素材失败').slice(0, 800));
}
response.success(res, r.data);
};
}
module.exports = function aiConfigRoutes(db, log, cfg) {
return {
list: list(db),
get: get(db),
vendorLock: vendorLock(cfg),
create: create(db, log, cfg),
update: update(db, log, cfg),
delete: remove(db, log, cfg),
testConnection: testConnection(log),
listJimeng2MaterialAssets: listJimeng2MaterialAssets(log),
modelArkAsset: modelArkAsset(log),
bulkUpdateKey: bulkUpdateKey(db, log, cfg),
};
};
+78
View File
@@ -0,0 +1,78 @@
const response = require('../response');
const assetService = require('../services/assetService');
function routes(db, log) {
return {
list: (req, res) => {
try {
const query = { ...req.query };
const { items, total, page, pageSize } = assetService.list(db, query);
response.successWithPagination(res, items, total, page, pageSize);
} catch (err) {
log.error('assets list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const item = assetService.create(db, log, req.body || {});
response.created(res, item);
} catch (err) {
log.error('assets create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = assetService.getById(db, req.params.id);
if (!item) return response.notFound(res, '资源不存在');
response.success(res, item);
} catch (err) {
log.error('assets get', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
try {
const item = assetService.update(db, log, req.params.id, req.body || {});
if (!item) return response.notFound(res, '资源不存在');
response.success(res, item);
} catch (err) {
log.error('assets update', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = assetService.deleteById(db, log, req.params.id);
if (!ok) return response.notFound(res, '资源不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('assets delete', { error: err.message });
response.internalError(res, err.message);
}
},
importImage: (req, res) => {
try {
const item = assetService.importFromImage(db, log, req.params.image_gen_id);
if (!item) return response.notFound(res, '图片生成记录不存在');
response.created(res, item);
} catch (err) {
log.error('assets import image', { error: err.message });
response.internalError(res, err.message);
}
},
importVideo: (req, res) => {
try {
const item = assetService.importFromVideo(db, log, req.params.video_gen_id);
if (!item) return response.notFound(res, '视频生成记录不存在');
response.created(res, item);
} catch (err) {
log.error('assets import video', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+104
View File
@@ -0,0 +1,104 @@
const response = require('../response');
const path = require('path');
function routes(db, log, cfg) {
function getStoragePath() {
const loadConfig = require('../config').loadConfig;
const c = (cfg && cfg.storage) ? cfg : loadConfig();
return path.isAbsolute(c.storage?.local_path)
? c.storage.local_path
: path.join(process.cwd(), c.storage?.local_path || './data/storage');
}
return {
/** 为单条分镜生成 TTS:对白 → audio_local_path;旁白 → narration_audio_local_pathbody.tts_kind === 'narration' */
extract: async (req, res) => {
const { storyboard_id, text, tts_kind } = req.body || {};
if (!text && !storyboard_id) return response.badRequest(res, '请提供 storyboard_id 或 text');
const kind = String(tts_kind || 'dialogue').toLowerCase() === 'narration' ? 'narration' : 'dialogue';
let ttsText = text;
if (kind === 'narration') {
if ((!ttsText || !String(ttsText).trim()) && storyboard_id) {
const row = db.prepare('SELECT narration FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(Number(storyboard_id));
ttsText = row?.narration;
}
if (!ttsText || !String(ttsText).trim()) {
return response.badRequest(res, '分镜解说旁白为空,无法合成语音');
}
} else {
if ((!ttsText || !String(ttsText).trim()) && storyboard_id) {
const row = db.prepare('SELECT dialogue FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(Number(storyboard_id));
ttsText = row?.dialogue;
}
if (!ttsText || !String(ttsText).trim()) {
return response.badRequest(res, '分镜对白为空,无法合成语音');
}
}
try {
const ttsService = require('../services/ttsService');
const result = await ttsService.synthesize(db, log, {
text: ttsText,
storyboard_id: storyboard_id || null,
storage_base: getStoragePath(),
});
if (storyboard_id && result.local_path) {
const now = new Date().toISOString();
try {
if (kind === 'narration') {
db.prepare('UPDATE storyboards SET narration_audio_local_path = ?, updated_at = ? WHERE id = ?').run(
result.local_path, now, Number(storyboard_id)
);
} else {
db.prepare('UPDATE storyboards SET audio_local_path = ?, updated_at = ? WHERE id = ?').run(
result.local_path, now, Number(storyboard_id)
);
}
} catch (_) {}
}
response.success(res, { local_path: result.local_path, url: result.local_path ? '/static/' + result.local_path : '', tts_kind: kind });
} catch (err) {
log.error('audio extract', { error: err.message });
response.internalError(res, err.message);
}
},
/** 批量为多条分镜生成 TTS */
extractBatch: async (req, res) => {
const { storyboard_ids } = req.body || {};
if (!Array.isArray(storyboard_ids) || storyboard_ids.length === 0) {
return response.badRequest(res, 'storyboard_ids 不能为空');
}
const results = [];
const storagePath = getStoragePath();
for (const sbId of storyboard_ids) {
const row = db.prepare('SELECT id, dialogue FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(Number(sbId));
if (!row || !row.dialogue?.trim()) {
results.push({ storyboard_id: sbId, error: '对白为空' });
continue;
}
try {
const ttsService = require('../services/ttsService');
const result = await ttsService.synthesize(db, log, {
text: row.dialogue,
storyboard_id: row.id,
storage_base: storagePath,
});
if (result.local_path) {
const now = new Date().toISOString();
try {
db.prepare('UPDATE storyboards SET audio_local_path = ?, updated_at = ? WHERE id = ?').run(
result.local_path, now, row.id
);
} catch (_) {}
}
results.push({ storyboard_id: sbId, local_path: result.local_path });
} catch (err) {
results.push({ storyboard_id: sbId, error: err.message });
}
}
response.success(res, results);
},
};
}
module.exports = routes;
@@ -0,0 +1,58 @@
const response = require('../response');
const characterLibraryService = require('../services/characterLibraryService');
function routes(db, cfg, log) {
return {
list: (req, res) => {
try {
const query = { page: req.query.page, page_size: req.query.page_size, drama_id: req.query.drama_id, global: req.query.global, category: req.query.category, source_type: req.query.source_type, source_id: req.query.source_id, source_ids: req.query.source_ids, keyword: req.query.keyword };
const { items, total, page, pageSize } = characterLibraryService.listLibraryItems(db, query);
response.successWithPagination(res, items, total, page, pageSize);
} catch (err) {
log.error('character-library list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const item = characterLibraryService.createLibraryItem(db, log, req.body || {});
response.created(res, item);
} catch (err) {
log.error('character-library create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = characterLibraryService.getLibraryItem(db, req.params.id);
if (!item) return response.notFound(res, '角色库项不存在');
response.success(res, item);
} catch (err) {
log.error('character-library get', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
try {
const item = characterLibraryService.updateLibraryItem(db, log, req.params.id, req.body || {});
if (!item) return response.notFound(res, '角色库项不存在');
response.success(res, item);
} catch (err) {
log.error('character-library update', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = characterLibraryService.deleteLibraryItem(db, log, req.params.id);
if (!ok) return response.notFound(res, '角色库项不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('character-library delete', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+405
View File
@@ -0,0 +1,405 @@
const fs = require('fs');
const path = require('path');
const response = require('../response');
const characterLibraryService = require('../services/characterLibraryService');
const storageLayout = require('../services/storageLayout');
const seedance2AssetGuards = require('../utils/seedance2AssetGuards');
function routes(db, cfg, log, uploadService) {
return {
getOne: (req, res) => {
try {
const row = db.prepare(
'SELECT id, drama_id, name, role, appearance, description, personality, voice_style, image_url, local_path, polished_prompt, four_view_image_url, identity_anchors, seedance2_asset, seedance2_voice_asset, negative_prompt, updated_at FROM characters WHERE id = ? AND deleted_at IS NULL'
).get(Number(req.params.id));
if (!row) return response.notFound(res, '角色不存在');
if (row.seedance2_asset) {
try {
row.seedance2_asset = JSON.parse(row.seedance2_asset);
} catch (_) {
row.seedance2_asset = null;
}
} else {
row.seedance2_asset = null;
}
if (row.seedance2_voice_asset) {
try {
row.seedance2_voice_asset = JSON.parse(row.seedance2_voice_asset);
} catch (_) {
row.seedance2_voice_asset = null;
}
} else {
row.seedance2_voice_asset = null;
}
response.success(res, { character: row });
} catch (err) {
log.error('characters getOne', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
try {
const out = characterLibraryService.updateCharacter(db, log, req.params.id, req.body || {});
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '保存成功' });
} catch (err) {
log.error('characters update', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const out = characterLibraryService.deleteCharacter(db, log, req.params.id);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('characters delete', { error: err.message });
response.internalError(res, err.message);
}
},
batchGenerateImages: (req, res) => {
try {
const body = req.body || {};
const characterIds = body.character_ids;
log.info('batch-generate-images request', { character_ids: characterIds, model: body.model, style: body.style });
if (!Array.isArray(characterIds) || characterIds.length === 0) {
return response.badRequest(res, 'character_ids 不能为空');
}
if (characterIds.length > 10) {
return response.badRequest(res, '单次最多生成10个角色');
}
const out = characterLibraryService.batchGenerateCharacterImages(
db,
log,
cfg,
characterIds,
body.model,
body.style
);
if (!out.ok) {
return response.badRequest(res, out.error);
}
response.success(res, {
message: '批量生成任务已提交',
count: out.count,
});
} catch (err) {
log.error('characters batch-generate-images', { error: err.message });
response.internalError(res, err.message);
}
},
generateImage: async (req, res) => {
try {
const body = req.body || {};
const out = await characterLibraryService.generateCharacterFourViewImage(
db,
log,
cfg,
req.params.id,
body.model,
body.style
);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
if (out.error === 'unauthorized') return response.notFound(res, '剧集不存在或无权限');
return response.badRequest(res, out.error);
}
response.success(res, {
message: '角色四视图生成任务已提交',
image_generation: out.image_generation,
});
} catch (err) {
log.error('characters generate-image', { error: err.message });
response.internalError(res, err.message);
}
},
uploadImage: (req, res) => {
if (!req.file || !req.file.buffer) {
return response.badRequest(res, '请选择文件');
}
try {
const rawStorage = cfg?.storage?.local_path || './data/storage';
const storagePath = path.isAbsolute(rawStorage)
? rawStorage
: path.join(process.cwd(), rawStorage);
const baseUrl = cfg?.storage?.base_url || '';
const charRow = db
.prepare('SELECT drama_id FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(Number(req.params.id));
const projectSubdir = storageLayout.getProjectStorageSubdir(db, charRow?.drama_id);
const { url, local_path } = uploadService.uploadFile(
storagePath,
baseUrl,
log,
req.file.buffer,
req.file.originalname || 'image.png',
req.file.mimetype,
'characters',
projectSubdir
);
const out = characterLibraryService.uploadCharacterImage(db, log, req.params.id, url);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '上传成功', url, local_path, filename: req.file.originalname, size: req.file.size });
} catch (err) {
log.error('characters upload-image', { error: err.message });
response.internalError(res, err.message);
}
},
putImage: (req, res) => {
try {
const body = req.body || {};
const charIdNum = Number(req.params.id);
const prevFull = db
.prepare('SELECT id, local_path, image_url, seedance2_asset FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(charIdNum);
if (!prevFull) return response.notFound(res, '角色不存在');
const nextImg = body.image_url !== undefined ? body.image_url : prevFull.image_url;
const nextLp = body.local_path !== undefined ? body.local_path : prevFull.local_path;
seedance2AssetGuards.markStaleOnCharacterMainImageDrift(db, log, prevFull, {
image_url: nextImg,
local_path: nextLp,
});
// 只有明确传了 image_url 时才更新主图,避免只传 ref_image 时清掉主图
if (body.image_url !== undefined) {
const out = characterLibraryService.uploadCharacterImage(db, log, req.params.id, body.image_url, {
skipStaleMark: true,
});
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
}
const extraFields = [];
const extraParams = [];
if (body.local_path !== undefined) { extraFields.push('local_path = ?'); extraParams.push(body.local_path ?? null); }
if (body.extra_images !== undefined) { extraFields.push('extra_images = ?'); extraParams.push(body.extra_images ?? null); }
if (body.ref_image !== undefined) { extraFields.push('ref_image = ?'); extraParams.push(body.ref_image ?? null); }
if (extraFields.length > 0) {
db.prepare(`UPDATE characters SET ${extraFields.join(', ')}, updated_at = ? WHERE id = ?`).run(
...extraParams, new Date().toISOString(), Number(req.params.id)
);
}
response.success(res, { message: '保存成功' });
} catch (err) {
log.error('characters put image', { error: err.message });
response.internalError(res, err.message);
}
},
imageFromLibrary: (req, res) => {
try {
const libraryId = (req.body || {}).library_id;
if (libraryId == null) return response.badRequest(res, '缺少 library_id');
const out = characterLibraryService.applyLibraryItemToCharacter(db, log, req.params.id, libraryId);
if (!out.ok) {
if (out.error === 'library item not found') return response.notFound(res, '角色库项不存在');
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '应用成功' });
} catch (err) {
log.error('characters image-from-library', { error: err.message });
response.internalError(res, err.message);
}
},
addToLibrary: (req, res) => {
try {
const category = (req.body || {}).category;
const out = characterLibraryService.addCharacterToLibrary(db, log, req.params.id, category);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '已加入本剧角色库', item: out.item });
} catch (err) {
log.error('characters add-to-library', { error: err.message });
response.internalError(res, err.message);
}
},
addToMaterialLibrary: (req, res) => {
try {
const out = characterLibraryService.addCharacterToMaterialLibrary(db, log, req.params.id);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '已加入全局素材库', item: out.item });
} catch (err) {
log.error('characters add-to-material-library', { error: err.message });
response.internalError(res, err.message);
}
},
extractAnchors: (req, res) => {
const charRow = db.prepare(
'SELECT id, appearance, identity_anchors FROM characters WHERE id = ? AND deleted_at IS NULL'
).get(Number(req.params.id));
if (!charRow) return response.notFound(res, '角色不存在');
if (!charRow.appearance) return response.badRequest(res, '角色缺少外貌描述,无法提炼锚点');
const { enrichIdentityAnchors } = require('../services/characterGenerationService');
setImmediate(() => {
enrichIdentityAnchors(db, log, charRow.id, charRow.appearance).catch(() => {});
});
response.success(res, { message: '锚点提炼已启动,请稍后刷新查看' });
},
generateFourViewImage: async (req, res) => {
try {
const body = req.body || {};
const modelName = body.model_name || body.model || undefined;
const style = body.style || undefined;
const out = await characterLibraryService.generateCharacterFourViewImage(db, log, cfg, req.params.id, modelName, style);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
if (out.error === 'unauthorized') return response.notFound(res, '剧集不存在或无权限');
return response.badRequest(res, out.error);
}
response.success(res, { message: '四视图生成任务已提交', image_generation: out.image_generation });
} catch (err) {
log.error('characters generate-four-view-image', { error: err.message });
response.internalError(res, err.message);
}
},
generatePrompt: async (req, res) => {
try {
const body = req.body || {};
const modelName = body.model_name || body.model || undefined;
const style = body.style || undefined;
const out = await characterLibraryService.generateCharacterPromptOnly(db, log, cfg, req.params.id, modelName, style);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '提示词已生成', polished_prompt: out.polished_prompt });
} catch (err) {
log.error('characters generate-prompt', { error: err.message });
response.internalError(res, err.message);
}
},
extractFromImage: async (req, res) => {
try {
const out = await characterLibraryService.extractAppearanceFromImage(db, log, cfg, req.params.id);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '外貌描述已提取', appearance: out.appearance });
} catch (err) {
log.error('characters extract-from-image', { error: err.message });
response.internalError(res, err.message);
}
},
/** 即梦素材库 asset 注册(Seedance 2.0 等视频引用 asset:// */
sd2Certify: async (req, res) => {
try {
const out = await characterLibraryService.registerCharacterJimengMaterialAsset(db, log, cfg, req.params.id);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: 'SD2 素材认证已更新', seedance2_asset: out.seedance2_asset });
} catch (err) {
log.error('characters sd2-certify', { error: err.message });
response.internalError(res, err.message);
}
},
sd2CertifyRefresh: async (req, res) => {
try {
const out = await characterLibraryService.refreshCharacterJimengMaterialAsset(db, log, cfg, req.params.id);
if (!out.ok) {
if (out.error === 'character not found') return response.notFound(res, '角色不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '认证状态已刷新', seedance2_asset: out.seedance2_asset });
} catch (err) {
log.error('characters sd2-certify-refresh', { error: err.message });
response.internalError(res, err.message);
}
},
/** Seedance 2.0 角色音色参考音频上传 */
sd2VoiceUpload: async (req, res) => {
try {
const charId = Number(req.params.id);
const charRow = db
.prepare('SELECT id, drama_id FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(charId);
if (!charRow) return response.notFound(res, '角色不存在');
if (!req.file) return response.badRequest(res, '请上传音频文件');
const allowedExt = ['.mp3', '.wav', '.m4a', '.ogg'];
const ext = path.extname(req.file.originalname || '').toLowerCase();
if (!allowedExt.includes(ext)) {
return response.badRequest(res, '仅支持 mp3/wav/m4a/ogg 格式');
}
const storageLocalPath = cfg?.storage?.local_path;
const storageRoot = storageLocalPath
? path.isAbsolute(storageLocalPath)
? storageLocalPath
: path.join(process.cwd(), storageLocalPath)
: path.join(process.cwd(), 'data', 'storage');
const relDir = `drama_${charRow.drama_id}/characters/voice`;
const absDir = path.join(storageRoot, relDir);
if (!fs.existsSync(absDir)) fs.mkdirSync(absDir, { recursive: true });
const safeName = `char_${charId}_voice_${Date.now()}${ext}`;
const absPath = path.join(absDir, safeName);
fs.writeFileSync(absPath, req.file.buffer);
const publicUrl = `/static/${relDir}/${safeName}`;
const now = new Date().toISOString();
const payload = {
status: 'active',
url: publicUrl,
local_path: `${relDir}/${safeName}`,
certified_at: now,
duration: null,
format: ext.replace('.', ''),
};
db.prepare('UPDATE characters SET seedance2_voice_asset = ?, updated_at = ? WHERE id = ?').run(
JSON.stringify(payload),
now,
charId
);
response.success(res, { message: 'Seedance 2.0 音色参考已保存', seedance2_voice_asset: payload });
} catch (err) {
log.error('characters sd2-voice-upload', { error: err.message });
response.internalError(res, err.message);
}
},
sd2VoiceRefresh: async (req, res) => {
try {
const charId = Number(req.params.id);
const row = db
.prepare('SELECT seedance2_voice_asset FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(charId);
if (!row) return response.notFound(res, '角色不存在');
let asset = null;
if (row.seedance2_voice_asset) {
try {
asset = JSON.parse(row.seedance2_voice_asset);
} catch (_) {
asset = null;
}
}
response.success(res, { message: '状态已刷新', seedance2_voice_asset: asset });
} catch (err) {
log.error('characters sd2-voice-refresh', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+305
View File
@@ -0,0 +1,305 @@
const dramaService = require('../services/dramaService');
const propService = require('../services/propService');
const response = require('../response');
const dramaExportService = require('../services/dramaExportService');
const dramaImportService = require('../services/dramaImportService');
function createDrama(db, log) {
return (req, res) => {
const body = req.body || {};
if (!body.title || String(body.title).trim() === '') {
return response.badRequest(res, '标题不能为空');
}
try {
const drama = dramaService.createDrama(db, log, body);
response.created(res, drama);
} catch (err) {
log.error('Create drama failed', { error: err.message, stack: err.stack });
response.internalError(res, err.message || '创建失败');
}
};
}
function getDrama(db, cfg) {
return (req, res) => {
const drama = dramaService.getDrama(db, req.params.id, cfg?.storage?.base_url);
if (!drama) return response.notFound(res, '剧本不存在');
response.success(res, drama);
};
}
function listDramas(db, log) {
return (req, res) => {
const page = req.query.page || 1;
const page_size = req.query.page_size || 20;
const status = req.query.status || '';
const genre = req.query.genre || '';
const keyword = req.query.keyword || '';
try {
const { dramas, total, page: p, pageSize: ps } = dramaService.listDramas(db, {
page,
page_size,
status,
genre,
keyword,
});
response.successWithPagination(res, dramas, total, p, ps);
} catch (err) {
log.errorw('List dramas failed', { error: err.message });
response.internalError(res, '获取列表失败');
}
};
}
function updateDrama(db, log) {
return (req, res) => {
const drama = dramaService.updateDrama(db, log, req.params.id, req.body || {});
if (!drama) return response.notFound(res, '剧本不存在');
response.success(res, drama);
};
}
function deleteDrama(db, log) {
return (req, res) => {
const ok = dramaService.deleteDrama(db, log, req.params.id);
if (!ok) return response.notFound(res, '剧本不存在');
response.success(res, { message: '删除成功' });
};
}
function getDramaStats(db, log) {
return (req, res) => {
try {
const stats = dramaService.getDramaStats(db);
response.success(res, stats);
} catch (err) {
log.errorw('Get drama stats failed', { error: err.message });
response.internalError(res, '获取统计失败');
}
};
}
function saveOutline(db, log) {
return (req, res) => {
const ok = dramaService.saveOutline(db, log, req.params.id, req.body || {});
if (!ok) return response.notFound(res, '剧本不存在');
response.success(res, { message: '保存成功' });
};
}
function getCharacters(db) {
return (req, res) => {
const characters = dramaService.getCharacters(db, req.params.id, req.query.episode_id);
if (characters === null) return response.notFound(res, '剧本或章节不存在');
response.success(res, characters);
};
}
function saveCharacters(db, log) {
return (req, res) => {
const body = req.body || {};
if (!Array.isArray(body.characters)) return response.badRequest(res, 'characters 必填且为数组');
const ok = dramaService.saveCharacters(db, log, req.params.id, body);
if (!ok) return response.notFound(res, '剧本或章节不存在');
response.success(res, { message: '保存成功' });
};
}
function saveEpisodes(db, log) {
return (req, res) => {
const body = req.body || {};
if (!Array.isArray(body.episodes)) return response.badRequest(res, 'episodes 必填且为数组');
const ok = dramaService.saveEpisodes(db, log, req.params.id, body);
if (!ok) return response.notFound(res, '剧本不存在');
response.success(res, { message: '保存成功' });
};
}
function saveProgress(db, log) {
return (req, res) => {
const body = req.body || {};
if (!body.current_step) return response.badRequest(res, 'current_step 必填');
const ok = dramaService.saveProgress(db, log, req.params.id, body);
if (!ok) return response.notFound(res, '剧本不存在');
response.success(res, { message: '保存成功' });
};
}
function saveCanvasLayout(db, log) {
return (req, res) => {
try {
const updated = dramaService.saveCanvasLayout(db, log, req.params.id, req.body || {});
if (!updated) return response.notFound(res, '剧本不存在');
response.success(res, updated);
} catch (err) {
if (err.code === 'BAD_REQUEST') return response.badRequest(res, err.message);
log.error('Save canvas layout failed', { error: err.message });
response.internalError(res, err.message || '保存画布布局失败');
}
};
}
function listProps(db) {
return (req, res) => {
const props = propService.listByDramaId(db, req.params.id);
response.success(res, props);
};
}
function finalizeEpisode(db, log, cfg) {
return (req, res) => {
const episodeId = req.params.episode_id;
if (!episodeId) return response.badRequest(res, 'episode_id不能为空');
const baseUrl = cfg?.storage?.base_url || '';
const result = dramaService.finalizeEpisode(db, log, episodeId, baseUrl, req.body || {});
if (!result) return response.notFound(res, '剧集不存在');
response.success(res, result);
};
}
function downloadEpisodeVideo(db) {
return (req, res) => {
const episodeId = req.params.episode_id;
if (!episodeId) return response.badRequest(res, 'episode_id不能为空');
const result = dramaService.downloadEpisodeVideo(db, episodeId);
if (!result) return response.notFound(res, '剧集不存在');
if (result.error) return response.badRequest(res, result.error);
response.success(res, result);
};
}
function exportDrama(db, cfg, log) {
return (req, res) => {
try {
const { buffer, title } = dramaExportService.exportDrama(db, cfg, log, req.params.id);
const safeName = (title || 'drama').replace(/[^\w\u4e00-\u9fff\-]/g, '_').slice(0, 50);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeName)}.zip`);
res.send(buffer);
} catch (err) {
log.error('Export drama failed', { error: err.message });
response.internalError(res, err.message || '导出失败');
}
};
}
function importDrama(db, cfg, log) {
return (req, res) => {
try {
if (!req.file || !req.file.buffer) {
return response.badRequest(res, '请上传 ZIP 文件');
}
const result = dramaImportService.importDrama(db, cfg, log, req.file.buffer);
response.created(res, result);
} catch (err) {
log.error('Import drama failed', { error: err.message });
if (err.message && (err.message.includes('格式') || err.message.includes('缺少') || err.message.includes('损坏'))) {
return response.badRequest(res, err.message);
}
response.internalError(res, err.message || '导入失败');
}
};
}
function getExampleDramaDir() {
const path = require('path');
const fs = require('fs');
if (process.env.EXAMPLE_DRAMA_PATH && fs.existsSync(process.env.EXAMPLE_DRAMA_PATH)) {
return process.env.EXAMPLE_DRAMA_PATH;
}
const devPath = path.join(__dirname, '..', '..', '..', 'example_drama');
if (fs.existsSync(devPath)) return devPath;
return null;
}
function listExamples(log) {
return (_req, res) => {
const fs = require('fs');
const dir = getExampleDramaDir();
if (!dir) return response.success(res, []);
try {
const files = fs.readdirSync(dir).filter(f => f.endsWith('.zip'));
const items = files.map(f => {
const name = f.replace(/\.zip$/, '');
return { filename: f, name };
});
response.success(res, items);
} catch (err) {
log.error('List examples failed', { error: err.message });
response.success(res, []);
}
};
}
function importExample(db, cfg, log) {
return (req, res) => {
const fs = require('fs');
const path = require('path');
const filename = req.body?.filename;
if (!filename) return response.badRequest(res, '请指定示例文件名');
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return response.badRequest(res, '文件名不合法');
}
const dir = getExampleDramaDir();
if (!dir) return response.badRequest(res, '示例目录不存在');
const filePath = path.join(dir, filename);
if (!fs.existsSync(filePath)) return response.notFound(res, '示例文件不存在');
try {
const buffer = fs.readFileSync(filePath);
const result = dramaImportService.importDrama(db, cfg, log, buffer);
response.created(res, result);
} catch (err) {
log.error('Import example failed', { error: err.message });
response.internalError(res, err.message || '导入示例失败');
}
};
}
function generateStoryboard(db, log) {
return async (req, res) => {
const body = req.body || {};
try {
// 显式处理 model 为空的情况,转为 undefined 以便 service 层触发默认逻辑
const model = (body.model && String(body.model).trim()) ? body.model : undefined;
log.info('Generate storyboard request', { episode_id: req.params.episode_id, storyboard_count: body.storyboard_count, video_duration: body.video_duration });
const resData = await dramaService.generateStoryboard(db, log, req.params.episode_id, {
model: model,
style: body.style,
storyboard_count: body.storyboard_count,
video_duration: body.video_duration,
aspect_ratio: body.aspect_ratio,
include_narration: body.include_narration,
universal_omni_storyboard: body.universal_omni_storyboard,
});
response.success(res, resData);
} catch (err) {
log.error('Generate storyboard failed', { error: err.message });
response.internalError(res, err.message || '生成分镜失败');
}
};
}
module.exports = function dramaRoutes(db, cfg, log) {
return {
createDrama: createDrama(db, log),
getDrama: getDrama(db, cfg),
listDramas: listDramas(db, log),
updateDrama: updateDrama(db, log),
deleteDrama: deleteDrama(db, log),
getDramaStats: getDramaStats(db, log),
saveOutline: saveOutline(db, log),
getCharacters: getCharacters(db),
saveCharacters: saveCharacters(db, log),
saveEpisodes: saveEpisodes(db, log),
saveProgress: saveProgress(db, log),
saveCanvasLayout: saveCanvasLayout(db, log),
listProps: listProps(db),
finalizeEpisode: finalizeEpisode(db, log, cfg),
downloadEpisodeVideo: downloadEpisodeVideo(db),
generateStoryboard: generateStoryboard(db, log),
exportDrama: exportDrama(db, cfg, log),
importDrama: importDrama(db, cfg, log),
listExamples: listExamples(log),
importExample: importExample(db, cfg, log),
};
};
+109
View File
@@ -0,0 +1,109 @@
const response = require('../response');
const imageService = require('../services/imageService');
const taskService = require('../services/taskService');
const backgroundExtractionService = require('../services/backgroundExtractionService');
function routes(db, cfg, log) {
return {
list: (req, res) => {
try {
const query = { ...req.query };
const { items, total, page, pageSize } = imageService.list(db, query);
response.successWithPagination(res, items, total, page, pageSize);
} catch (err) {
log.error('images list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const body = req.body || {};
const rec = imageService.create(db, log, body);
response.created(res, rec);
} catch (err) {
log.error('images create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = imageService.getById(db, req.params.id);
if (!item) return response.notFound(res, '记录不存在');
response.success(res, item);
} catch (err) {
log.error('images get', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = imageService.deleteById(db, log, req.params.id);
if (!ok) return response.notFound(res, '记录不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('images delete', { error: err.message });
response.internalError(res, err.message);
}
},
scene: (req, res) => {
try {
const task = taskService.createTask(db, log, 'image_generation', req.params.scene_id);
setTimeout(() => taskService.updateTaskResult(db, task.id, []), 100);
response.success(res, { task_id: task.id });
} catch (err) {
log.error('images scene', { error: err.message });
response.internalError(res, err.message);
}
},
episodeBackgrounds: (req, res) => {
try {
const list = imageService.getBackgroundsForEpisode(db, req.params.episode_id);
response.success(res, list);
} catch (err) {
log.error('images episode backgrounds', { error: err.message });
response.internalError(res, err.message);
}
},
episodeBackgroundsExtract: (req, res) => {
try {
const body = req.body || {};
const taskId = backgroundExtractionService.extractBackgroundsForEpisode(
db,
cfg,
log,
req.params.episode_id,
body.model,
body.style,
body.language
);
response.success(res, { task_id: taskId, status: 'pending', message: '场景提取任务已创建,正在后台处理...' });
} catch (err) {
log.error('images episode backgrounds extract', { error: err.message });
if (err.message && (err.message.includes('script content') || err.message.includes('not found'))) {
return response.badRequest(res, err.message);
}
response.internalError(res, err.message || '任务创建失败');
}
},
episodeBatch: (req, res) => {
try {
response.success(res, []);
} catch (err) {
log.error('images episode batch', { error: err.message });
response.internalError(res, err.message);
}
},
upload: (req, res) => {
try {
const body = req.body || {};
const item = imageService.upload(db, log, body);
response.created(res, item);
} catch (err) {
log.error('images upload', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+327
View File
@@ -0,0 +1,327 @@
const express = require('express');
const response = require('../response');
const dramaRoutes = require('./drama');
const taskRoutes = require('./task');
const settingsRoutes = require('./settings');
const aiConfigRoutes = require('./aiConfig');
const propRoutes = require('./prop');
const stubRoutes = require('./stub');
const characterLibraryRoutes = require('./characterLibrary');
const sceneLibraryRoutes = require('./sceneLibrary');
const propLibraryRoutes = require('./propLibrary');
const characterRoutes = require('./characters');
const uploadModule = require('./upload');
const sceneRoutes = require('./scenes');
const storyboardRoutes = require('./storyboards');
const tailFrameLinkRoutes = require('./storyboards_tail_link');
const imageRoutes = require('./images');
const videoRoutes = require('./videos');
const videoMergeRoutes = require('./videoMerges');
const assetRoutes = require('./assets');
const audioRoutes = require('./audio');
const promptOverridesRoutes = require('./promptOverrides');
const sceneModelMapRoutes = require('./sceneModelMap');
function setupRouter(cfg, db, log) {
const r = express.Router();
const drama = dramaRoutes(db, cfg, log);
const task = taskRoutes(db, log);
const settings = settingsRoutes(db, cfg, log);
const aiConfig = aiConfigRoutes(db, log, cfg);
const prop = propRoutes(db, log, cfg);
const stub = stubRoutes(db, cfg, log);
const sceneModelMap = sceneModelMapRoutes(db, log);
const uploadService = require('../services/uploadService');
const charLibrary = characterLibraryRoutes(db, cfg, log);
const sceneLibrary = sceneLibraryRoutes(db, cfg, log);
const propLibrary = propLibraryRoutes(db, cfg, log);
const characters = characterRoutes(db, cfg, log, uploadService);
const uploadHandlers = uploadModule.routes(cfg, log, db);
const scenes = sceneRoutes(db, log, cfg);
const storyboards = storyboardRoutes(db, log);
const tailFrameLink = tailFrameLinkRoutes(db, cfg, log);
const images = imageRoutes(db, cfg, log);
const videos = videoRoutes(db, log);
const videoMerges = videoMergeRoutes(db, log);
const assets = assetRoutes(db, log);
const audio = audioRoutes(db, log, cfg);
const promptOverrides = promptOverridesRoutes.routes(db, log);
// ---------- dramas ----------
r.get('/dramas', drama.listDramas);
r.post('/dramas', drama.createDrama);
r.get('/dramas/stats', drama.getDramaStats);
// 导出/导入(放在 :id 路由前,避免被 :id 捕获)
r.get('/dramas/:id/export', drama.exportDrama);
const multer = require('multer');
const importUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 } });
r.post('/dramas/import', importUpload.single('file'), drama.importDrama);
r.post('/dramas/import-novel', importUpload.single('file'), async (req, res) => {
try {
const novelImportService = require('../services/novelImportService');
let text = '';
if (req.file && req.file.buffer) {
text = req.file.buffer.toString('utf8');
} else if (req.body && req.body.text) {
text = req.body.text;
}
if (!text.trim()) return response.badRequest(res, '请上传小说文本文件或提供 text 参数');
const title = req.body?.title || '';
const maxChapters = Number(req.body?.max_chapters) || 20;
const aiSummarize = req.body?.ai_summarize === 'true' || req.body?.ai_summarize === true;
const result = await novelImportService.importNovel(db, log, { text, title, maxChapters, aiSummarize });
response.success(res, result);
} catch (err) {
log.error('dramas import-novel', { error: err.message });
response.internalError(res, err.message);
}
});
r.get('/dramas/examples', drama.listExamples);
r.post('/dramas/import-example', drama.importExample);
r.put('/dramas/:id/outline', drama.saveOutline);
r.get('/dramas/:id/characters', drama.getCharacters);
r.put('/dramas/:id/characters', drama.saveCharacters);
r.put('/dramas/:id/episodes', drama.saveEpisodes);
r.put('/dramas/:id/progress', drama.saveProgress);
r.put('/dramas/:id/canvas-layout', drama.saveCanvasLayout);
r.get('/dramas/:id/props', drama.listProps);
r.get('/dramas/:id', drama.getDrama);
r.put('/dramas/:id', drama.updateDrama);
r.delete('/dramas/:id', drama.deleteDrama);
// ---------- ai-configs ----------
r.get('/ai-configs', aiConfig.list);
r.post('/ai-configs', aiConfig.create);
r.post('/ai-configs/test', aiConfig.testConnection);
r.post('/ai-configs/jimeng2-list-assets', aiConfig.listJimeng2MaterialAssets);
r.post('/ai-configs/model-ark-asset', aiConfig.modelArkAsset);
r.get('/ai-configs/vendor-lock', aiConfig.vendorLock); // 必须在 /:id 之前
r.put('/ai-configs/bulk-update-key', aiConfig.bulkUpdateKey); // 必须在 /:id 之前
r.get('/ai-configs/:id', aiConfig.get);
r.put('/ai-configs/:id', aiConfig.update);
r.delete('/ai-configs/:id', aiConfig.delete);
// ---------- generation (角色生成:AI + 入库 + 任务结果) ----------
r.post('/generation/characters', (req, res) => {
const characterGenerationService = require('../services/characterGenerationService');
try {
const body = req.body || {};
if (!body.drama_id) {
return response.badRequest(res, 'drama_id 必填');
}
const taskId = characterGenerationService.generateCharacters(db, cfg, log, body);
response.success(res, { task_id: taskId, status: 'pending' });
} catch (err) {
log.error('generation/characters', { error: err.message });
response.internalError(res, err.message || '创建任务失败');
}
});
// 故事生成:根据梗概 + 风格/类型 生成扩展剧本正文(不创建项目)
r.post('/generation/story', async (req, res) => {
const storyGenerationService = require('../services/storyGenerationService');
try {
const body = req.body || {};
const result = await storyGenerationService.generateStory(db, log, body);
response.success(res, result);
} catch (err) {
log.error('generation/story', { error: err.message });
if (err.message && err.message.includes('未配置')) {
return response.badRequest(res, err.message);
}
response.internalError(res, err.message || '故事生成失败');
}
});
// ---------- character-library ----------
r.get('/character-library', charLibrary.list);
r.post('/character-library', charLibrary.create);
r.get('/character-library/:id', charLibrary.get);
r.put('/character-library/:id', charLibrary.update);
r.delete('/character-library/:id', charLibrary.delete);
// ---------- scene-library ----------
r.get('/scene-library', sceneLibrary.list);
r.post('/scene-library', sceneLibrary.create);
r.get('/scene-library/:id', sceneLibrary.get);
r.put('/scene-library/:id', sceneLibrary.update);
r.delete('/scene-library/:id', sceneLibrary.delete);
// ---------- prop-library ----------
r.get('/prop-library', propLibrary.list);
r.post('/prop-library', propLibrary.create);
r.get('/prop-library/:id', propLibrary.get);
r.put('/prop-library/:id', propLibrary.update);
r.delete('/prop-library/:id', propLibrary.delete);
// ---------- characters ----------
r.get('/characters/:id', characters.getOne);
r.put('/characters/:id', characters.update);
r.delete('/characters/:id', characters.delete);
r.post('/characters/batch-generate-images', characters.batchGenerateImages);
r.post('/characters/:id/generate-image', characters.generateImage);
r.post('/characters/:id/generate-four-view-image', characters.generateFourViewImage);
r.post('/characters/:id/generate-prompt', characters.generatePrompt);
r.post('/characters/:id/upload-image', uploadModule.multerSingle, characters.uploadImage);
r.put('/characters/:id/image', characters.putImage);
r.put('/characters/:id/image-from-library', characters.imageFromLibrary);
r.post('/characters/:id/add-to-library', characters.addToLibrary);
r.post('/characters/:id/add-to-material-library', characters.addToMaterialLibrary);
r.post('/characters/:id/sd2-certify', characters.sd2Certify);
r.post('/characters/:id/sd2-certify/refresh', characters.sd2CertifyRefresh);
r.post('/characters/:id/sd2-voice-upload', uploadModule.multerAudioSingle, characters.sd2VoiceUpload);
r.post('/characters/:id/sd2-voice-refresh', characters.sd2VoiceRefresh);
r.post('/characters/:id/extract-from-image', characters.extractFromImage);
r.post('/characters/:id/extract-anchors', characters.extractAnchors);
// ---------- props ----------
r.get('/props/:id', prop.getPropById);
r.post('/props', prop.createProp);
r.put('/props/:id', prop.updateProp);
r.delete('/props/:id', prop.deleteProp);
r.post('/props/:id/generate', prop.generateImage);
r.post('/props/:id/generate-prompt', prop.generatePropPrompt);
r.post('/props/:id/add-to-library', prop.addToLibrary);
r.post('/props/:id/add-to-material-library', prop.addToMaterialLibrary);
r.post('/props/:id/extract-from-image', prop.extractPropFromImage);
// ---------- vision: 从图片提取描述(不依赖已有实体 ID)----------
r.post('/extract-description-from-image', async (req, res) => {
const { image_url, entity_type, entity_name } = req.body || {};
if (!image_url) return response.badRequest(res, '缺少 image_url');
if (!['character', 'scene', 'prop'].includes(entity_type)) return response.badRequest(res, 'entity_type 需为 character/scene/prop');
try {
const { extractDescriptionFromImage } = require('../services/aiClient');
const out = await extractDescriptionFromImage(db, log, entity_type, image_url, entity_name);
if (!out.ok) return response.badRequest(res, out.error);
response.success(res, { description: out.description });
} catch (err) {
log.error('extract-description-from-image', { error: err.message });
response.internalError(res, err.message);
}
});
// ---------- upload ----------
r.post('/upload/image', uploadModule.multerSingle, uploadHandlers.uploadImage);
// ---------- episodes ----------
// 注意:drama.generateStoryboard 已处理所有逻辑(包括参数解析),这里统一使用 drama 模块的实现
// 之前可能有部分路由指向了 storyboards.episodeStoryboardsGenerate,这可能导致参数解析不一致
r.post('/episodes/:episode_id/storyboards', drama.generateStoryboard);
r.post('/episodes/:episode_id/props/extract', prop.extractProps);
r.post('/episodes/:episode_id/characters/extract', stub.episodeCharactersExtract);
r.get('/episodes/:episode_id/storyboards', storyboards.episodeStoryboardsGet);
r.post('/episodes/:episode_id/finalize', drama.finalizeEpisode);
r.get('/episodes/:episode_id/download', drama.downloadEpisodeVideo);
// ---------- tasks ----------
r.get('/tasks/:task_id', task.getTaskStatus);
r.get('/tasks', task.getResourceTasks);
// ---------- scenes ----------
r.get('/scenes/:scene_id', scenes.getOne);
r.post('/scenes/:scene_id/generate-prompt', scenes.generatePrompt);
r.put('/scenes/:scene_id', scenes.update);
r.put('/scenes/:scene_id/prompt', scenes.updatePrompt);
r.delete('/scenes/:scene_id', scenes.delete);
r.post('/scenes/generate-image', scenes.generateImage);
r.post('/scenes', scenes.create);
r.post('/scenes/:scene_id/generate-four-view-image', scenes.generateFourViewImage);
r.post('/scenes/:scene_id/add-to-library', scenes.addToLibrary);
r.post('/scenes/:scene_id/add-to-material-library', scenes.addToMaterialLibrary);
r.post('/scenes/:scene_id/extract-from-image', scenes.extractFromImage);
// ---------- images ----------
r.get('/images', images.list);
r.post('/images', images.create);
r.get('/images/episode/:episode_id/backgrounds', images.episodeBackgrounds);
r.post('/images/episode/:episode_id/backgrounds/extract', images.episodeBackgroundsExtract);
r.post('/images/episode/:episode_id/batch', images.episodeBatch);
r.post('/images/scene/:scene_id', images.scene);
r.post('/images/upload', images.upload);
r.get('/images/:id', images.get);
r.delete('/images/:id', images.delete);
// ---------- videos ----------
r.get('/videos', videos.list);
r.post('/videos', videos.create);
r.post('/videos/image/:image_gen_id', videos.fromImage);
r.post('/videos/episode/:episode_id/batch', videos.episodeBatch);
r.get('/videos/:id', videos.get);
r.delete('/videos/:id', videos.delete);
// ---------- video-merges ----------
r.get('/video-merges', videoMerges.list);
r.post('/video-merges', videoMerges.create);
r.get('/video-merges/:merge_id', videoMerges.get);
r.delete('/video-merges/:merge_id', videoMerges.delete);
// ---------- assets ----------
r.get('/assets', assets.list);
r.post('/assets', assets.create);
r.post('/assets/import/image/:image_gen_id', assets.importImage);
r.post('/assets/import/video/:video_gen_id', assets.importVideo);
r.get('/assets/:id', assets.get);
r.put('/assets/:id', assets.update);
r.delete('/assets/:id', assets.delete);
// ---------- storyboards ----------
r.get('/storyboards/episode/:episode_id/generate', storyboards.episodeStoryboardsGenerate);
r.post('/storyboards', storyboards.create);
r.post('/storyboards/:id/insert-before', storyboards.insertBefore);
r.get('/storyboards/:id', storyboards.getOne);
r.put('/storyboards/:id', storyboards.update);
r.delete('/storyboards/:id', storyboards.delete);
r.post('/storyboards/:id/props', prop.associateProps);
r.post('/storyboards/:id/frame-prompt', storyboards.framePrompt);
r.get('/storyboards/:id/frame-prompts', storyboards.framePromptsGet);
r.put('/storyboards/:id/frame-prompts/:frame_type', storyboards.framePromptSave);
r.post('/storyboards/:id/link-tail-frame', tailFrameLink.linkTailFrame);
r.post('/storyboards/:id/polish-prompt', storyboards.polishPrompt);
r.post('/storyboards/:id/universal-segment-polish-stream', storyboards.polishUniversalSegmentStream);
r.post('/storyboards/:id/classic-video-prompt-polish-stream', storyboards.polishClassicVideoPromptStream);
r.post('/storyboards/:id/universal-segment-prompt-stream', storyboards.generateUniversalSegmentStream);
r.post('/storyboards/:id/universal-segment-prompt', storyboards.generateUniversalSegmentPrompt);
r.post('/storyboards/batch-infer-params', storyboards.batchInferParams);
r.post('/storyboards/:id/upscale', storyboards.upscale);
r.post('/storyboards/:id/regenerate-layout-description', storyboards.regenerateLayoutDescription);
r.post('/storyboards/:id/rebuild-video-prompt', storyboards.rebuildVideoPrompt);
r.post('/storyboards/:id/split-by-audio', storyboards.splitByAudio);
// ---------- audio ----------
r.post('/audio/extract', audio.extract);
r.post('/audio/extract/batch', audio.extractBatch);
// ---------- settings ----------
r.get('/settings/language', settings.getLanguage);
r.put('/settings/language', settings.updateLanguage);
r.get('/settings/generation', settings.getGenerationSettings);
r.put('/settings/generation', settings.updateGenerationSettings);
// ---------- prompt overrides ----------
r.get('/settings/prompts', promptOverrides.list);
r.put('/settings/prompts/:key', promptOverrides.update);
r.delete('/settings/prompts/:key', promptOverrides.reset);
// ---------- scene model map ----------
r.get('/scene-model-map', sceneModelMap.list);
r.post('/scene-model-map', sceneModelMap.create);
r.get('/scene-model-map/:key', sceneModelMap.get);
r.put('/scene-model-map/:key', sceneModelMap.update);
r.delete('/scene-model-map/:key', sceneModelMap.delete);
// 启动时将已有的覆盖加载到 promptI18n 内存缓存
try {
const promptI18n = require('../services/promptI18n');
const promptOverridesService = require('../services/promptOverridesService');
const saved = promptOverridesService.listOverrides(db);
promptI18n.loadOverridesIntoCache(saved);
} catch (e) {
console.warn('Failed to load prompt overrides:', e.message);
}
return r;
}
module.exports = { setupRouter };
+125
View File
@@ -0,0 +1,125 @@
const promptOverridesService = require('../services/promptOverridesService');
const promptI18n = require('../services/promptI18n');
const response = require('../response');
// 提示词元数据:label / description 在此维护;内容(default_body / locked_suffix)从 promptI18n 动态读取
const PROMPT_META = [
{
key: 'story_expansion_system',
label: '故事生成提示词',
description: '控制 AI 如何将故事梗概扩写成完整剧本',
},
{
key: 'storyboard_system',
label: '分镜拆解提示词',
description: '控制 AI 如何将剧本拆分成分镜头方案(输出格式要求已锁定)',
},
{
key: 'character_extraction',
label: '角色提取提示词',
description: '控制 AI 如何从剧本中提取角色信息(输出格式要求已锁定)',
},
{
key: 'scene_extraction',
label: '场景提取提示词',
description: '控制 AI 如何从剧本中提取场景背景(风格/比例和输出格式已锁定)',
},
{
key: 'prop_extraction',
label: '道具提取提示词',
description: '控制 AI 如何从剧本中提取关键道具(风格/比例和输出格式已锁定)',
},
{
key: 'storyboard_user_suffix',
label: '分镜输出格式要求',
description: '追加在分镜拆解用户提示词末尾的详细要素说明(JSON 输出格式已锁定)',
},
{
key: 'first_frame_prompt',
label: '首帧图像提示词',
description: '控制 AI 如何生成分镜首帧(动作前静态画面)的图像提示词(风格/比例和 JSON 格式已锁定)',
},
{
key: 'key_frame_prompt',
label: '关键帧图像提示词',
description: '控制 AI 如何生成分镜关键帧(动作高潮瞬间)的图像提示词(风格/比例和 JSON 格式已锁定)',
},
{
key: 'last_frame_prompt',
label: '尾帧图像提示词',
description: '控制 AI 如何生成分镜尾帧(动作后静态画面)的图像提示词(风格/比例和 JSON 格式已锁定)',
},
];
// default_body 和 locked_suffix 从 promptI18n 动态读取,确保与运行时提示词始终一致
function getPromptDefinitions() {
return PROMPT_META.map((m) => ({
...m,
default_body: promptI18n.getDefaultPromptBody(m.key),
locked_suffix: promptI18n.getLockedSuffix(m.key),
}));
}
function routes(db, log) {
return {
list: (req, res) => {
try {
const defs = getPromptDefinitions();
const overrides = promptOverridesService.listOverrides(db);
const overrideMap = {};
for (const o of overrides) overrideMap[o.key] = o.content;
const prompts = defs.map((d) => ({
key: d.key,
label: d.label,
description: d.description,
default_body: d.default_body,
locked_suffix: d.locked_suffix,
current_body: overrideMap[d.key] || null,
is_customized: !!overrideMap[d.key],
}));
response.success(res, { prompts });
} catch (err) {
log.error('prompts list', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
const { key } = req.params;
const { content } = req.body || {};
const defs = getPromptDefinitions();
if (!defs.some((d) => d.key === key)) {
return response.badRequest(res, `未知的提示词 key: ${key}`);
}
if (!content || !content.trim()) {
return response.badRequest(res, 'content 不能为空');
}
try {
promptOverridesService.setOverride(db, key, content.trim());
promptI18n.setOverrideInMemory(key, content.trim());
log.info('prompt override updated', { key });
response.success(res, { ok: true, key });
} catch (err) {
log.error('prompts update', { error: err.message });
response.internalError(res, err.message);
}
},
reset: (req, res) => {
const { key } = req.params;
const defs = getPromptDefinitions();
if (!defs.some((d) => d.key === key)) {
return response.badRequest(res, `未知的提示词 key: ${key}`);
}
try {
promptOverridesService.deleteOverride(db, key);
promptI18n.clearOverrideInMemory(key);
log.info('prompt override reset', { key });
response.success(res, { ok: true, key });
} catch (err) {
log.error('prompts reset', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = { routes, getPromptDefinitions };
+181
View File
@@ -0,0 +1,181 @@
const propService = require('../services/propService');
const propLibraryService = require('../services/propLibraryService');
const response = require('../response');
function listProps(db) {
return (req, res) => {
const props = propService.listByDramaId(db, req.params.id);
response.success(res, props);
};
}
function createProp(db, log) {
return (req, res) => {
const body = req.body || {};
if (!body.drama_id || !body.name) return response.badRequest(res, 'drama_id 和 name 必填');
try {
const prop = propService.create(db, log, body);
response.created(res, prop);
} catch (err) {
log.errorw('Create prop failed', { error: err.message });
response.internalError(res, '创建失败');
}
};
}
function updateProp(db, log) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
const prop = propService.update(db, log, id, req.body || {});
if (!prop) return response.notFound(res, '道具不存在');
response.success(res, prop);
};
}
function deleteProp(db, log) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
const ok = propService.deleteById(db, log, id);
if (!ok) return response.notFound(res, '道具不存在');
response.success(res, { message: '删除成功' });
};
}
function generateImage(db, log) {
const propImageGenerationService = require('../services/propImageGenerationService');
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
const model = req.body?.model != null ? String(req.body.model).trim() || null : null;
const style = req.body?.style != null ? String(req.body.style).trim() || null : null;
try {
const taskId = propImageGenerationService.generatePropImage(db, log, id, { model, style });
response.success(res, { task_id: taskId });
} catch (err) {
if (err.message === '道具不存在') return response.notFound(res, err.message);
if (err.message === '道具没有图片提示词') return response.badRequest(res, err.message);
log.error('generatePropImage failed', { error: err.message });
response.internalError(res, err.message || '生成失败');
}
};
}
function extractProps(db, log, cfg) {
const propExtractionService = require('../services/propExtractionService');
return (req, res) => {
const episodeId = req.params.episode_id;
if (!episodeId) return response.badRequest(res, '缺少 episode_id');
try {
const taskId = propExtractionService.extractPropsForEpisode(db, log, episodeId, cfg);
response.success(res, { task_id: taskId });
} catch (err) {
if (err.message === 'episode not found' || err.message?.includes('剧本内容为空')) {
return response.badRequest(res, err.message);
}
log.error('extractProps failed', { error: err.message });
response.internalError(res, err.message || '提取失败');
}
};
}
function associateProps(db, log) {
return (req, res) => {
const storyboardId = parseInt(req.params.id, 10);
const propIds = Array.isArray(req.body?.prop_ids) ? req.body.prop_ids : [];
propService.associateWithStoryboard(db, log, storyboardId, propIds);
response.success(res, { message: '关联成功' });
};
}
function addToLibrary(db, log) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
const out = propLibraryService.addPropToLibrary(db, log, id);
if (!out.ok) {
if (out.error === 'prop not found') return response.notFound(res, '道具不存在');
if (out.error === 'unauthorized') return response.forbidden(res, '无权限');
return response.badRequest(res, out.error);
}
response.success(res, { message: '已加入本剧道具库', item: out.item });
};
}
function addToMaterialLibrary(db, log) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
const out = propLibraryService.addPropToMaterialLibrary(db, log, id);
if (!out.ok) {
if (out.error === 'prop not found') return response.notFound(res, '道具不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '已加入全局素材库', item: out.item });
};
}
function getPropById(db, log) {
return (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
const prop = propService.getById(db, id);
if (!prop) return response.notFound(res, '道具不存在');
response.success(res, { prop });
};
}
function generatePropPrompt(db, log, cfg) {
return async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
try {
const body = req.body || {};
const out = await propService.generatePropPromptOnly(db, log, cfg, id, body.model || undefined, body.style || undefined);
if (!out.ok) {
if (out.error === 'prop not found') return response.notFound(res, '道具不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '提示词已生成', prompt: out.prompt });
} catch (err) {
log.error('generatePropPrompt failed', { error: err.message });
response.internalError(res, err.message);
}
};
}
function extractPropFromImage(db, log, cfg) {
return async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return response.badRequest(res, '无效的ID');
try {
const out = await propService.extractPropFromImage(db, log, cfg, id);
if (!out.ok) {
if (out.error === 'prop not found') return response.notFound(res, '道具不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '道具描述已提取', description: out.description });
} catch (err) {
log.error('extractPropFromImage failed', { error: err.message });
response.internalError(res, err.message);
}
};
}
module.exports = function propRoutes(db, log, cfg) {
return {
listProps: listProps(db),
createProp: createProp(db, log),
updateProp: updateProp(db, log),
deleteProp: deleteProp(db, log),
getPropById: getPropById(db, log),
generateImage: generateImage(db, log),
generatePropPrompt: generatePropPrompt(db, log, cfg),
extractProps: extractProps(db, log, cfg),
associateProps: associateProps(db, log),
addToLibrary: addToLibrary(db, log),
addToMaterialLibrary: addToMaterialLibrary(db, log),
extractPropFromImage: extractPropFromImage(db, log, cfg),
};
};
+58
View File
@@ -0,0 +1,58 @@
const response = require('../response');
const propLibraryService = require('../services/propLibraryService');
function routes(db, cfg, log) {
return {
list: (req, res) => {
try {
const query = { page: req.query.page, page_size: req.query.page_size, drama_id: req.query.drama_id, global: req.query.global, category: req.query.category, source_type: req.query.source_type, source_id: req.query.source_id, source_ids: req.query.source_ids, keyword: req.query.keyword };
const { items, total, page, pageSize } = propLibraryService.listLibraryItems(db, query);
response.successWithPagination(res, items, total, page, pageSize);
} catch (err) {
log.error('prop-library list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const item = propLibraryService.createLibraryItem(db, log, req.body || {});
response.created(res, item);
} catch (err) {
log.error('prop-library create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = propLibraryService.getLibraryItem(db, req.params.id);
if (!item) return response.notFound(res, '道具库项不存在');
response.success(res, item);
} catch (err) {
log.error('prop-library get', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
try {
const item = propLibraryService.updateLibraryItem(db, log, req.params.id, req.body || {});
if (!item) return response.notFound(res, '道具库项不存在');
response.success(res, item);
} catch (err) {
log.error('prop-library update', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = propLibraryService.deleteLibraryItem(db, log, req.params.id);
if (!ok) return response.notFound(res, '道具库项不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('prop-library delete', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+58
View File
@@ -0,0 +1,58 @@
const response = require('../response');
const sceneLibraryService = require('../services/sceneLibraryService');
function routes(db, cfg, log) {
return {
list: (req, res) => {
try {
const query = { page: req.query.page, page_size: req.query.page_size, drama_id: req.query.drama_id, global: req.query.global, category: req.query.category, source_type: req.query.source_type, source_id: req.query.source_id, source_ids: req.query.source_ids, keyword: req.query.keyword };
const { items, total, page, pageSize } = sceneLibraryService.listLibraryItems(db, query);
response.successWithPagination(res, items, total, page, pageSize);
} catch (err) {
log.error('scene-library list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const item = sceneLibraryService.createLibraryItem(db, log, req.body || {});
response.created(res, item);
} catch (err) {
log.error('scene-library create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = sceneLibraryService.getLibraryItem(db, req.params.id);
if (!item) return response.notFound(res, '场景库项不存在');
response.success(res, item);
} catch (err) {
log.error('scene-library get', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
try {
const item = sceneLibraryService.updateLibraryItem(db, log, req.params.id, req.body || {});
if (!item) return response.notFound(res, '场景库项不存在');
response.success(res, item);
} catch (err) {
log.error('scene-library update', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = sceneLibraryService.deleteLibraryItem(db, log, req.params.id);
if (!ok) return response.notFound(res, '场景库项不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('scene-library delete', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+123
View File
@@ -0,0 +1,123 @@
const response = require('../response');
function list(db, log) {
return (req, res) => {
try {
const rows = db.prepare('SELECT * FROM ai_model_map ORDER BY key').all();
response.success(res, rows);
} catch (err) {
log.error('List scene model map failed', { error: err.message });
response.internalError(res, '获取场景模型映射失败');
}
};
}
function get(db, log) {
return (req, res) => {
const { key } = req.params;
try {
const row = db.prepare('SELECT * FROM ai_model_map WHERE key = ?').get(key);
if (!row) {
return response.notFound(res, '场景模型映射不存在');
}
response.success(res, row);
} catch (err) {
log.error('Get scene model map failed', { error: err.message, key });
response.internalError(res, '获取场景模型映射失败');
}
};
}
function create(db, log) {
return (req, res) => {
const body = req.body || {};
const { key, service_type = 'text', config_id, model_override, description } = body;
if (!key) {
return response.badRequest(res, '缺少必填字段: key');
}
const now = new Date().toISOString();
try {
// 检查 key 是否已存在
const existing = db.prepare('SELECT id FROM ai_model_map WHERE key = ?').get(key);
if (existing) {
return response.badRequest(res, '场景键已存在');
}
const result = db.prepare(`
INSERT INTO ai_model_map (key, service_type, config_id, model_override, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(key, service_type, config_id || null, model_override || null, description || '', now, now);
const row = db.prepare('SELECT * FROM ai_model_map WHERE id = ?').get(result.lastInsertRowid);
response.created(res, row);
} catch (err) {
log.error('Create scene model map failed', { error: err.message, key });
response.internalError(res, '创建场景模型映射失败');
}
};
}
function update(db, log) {
return (req, res) => {
const { key } = req.params;
const body = req.body || {};
const { service_type, config_id, model_override, description } = body;
const now = new Date().toISOString();
try {
const existing = db.prepare('SELECT id FROM ai_model_map WHERE key = ?').get(key);
if (!existing) {
return response.notFound(res, '场景模型映射不存在');
}
db.prepare(`
UPDATE ai_model_map
SET service_type = ?, config_id = ?, model_override = ?, description = ?, updated_at = ?
WHERE key = ?
`).run(
service_type || 'text',
config_id !== undefined ? config_id : null,
model_override !== undefined ? model_override : null,
description !== undefined ? description : '',
now,
key
);
const row = db.prepare('SELECT * FROM ai_model_map WHERE key = ?').get(key);
response.success(res, row);
} catch (err) {
log.error('Update scene model map failed', { error: err.message, key });
response.internalError(res, '更新场景模型映射失败');
}
};
}
function remove(db, log) {
return (req, res) => {
const { key } = req.params;
try {
const existing = db.prepare('SELECT id FROM ai_model_map WHERE key = ?').get(key);
if (!existing) {
return response.notFound(res, '场景模型映射不存在');
}
db.prepare('DELETE FROM ai_model_map WHERE key = ?').run(key);
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('Delete scene model map failed', { error: err.message, key });
response.internalError(res, '删除场景模型映射失败');
}
};
}
module.exports = function sceneModelMapRoutes(db, log) {
return {
list: list(db, log),
get: get(db, log),
create: create(db, log),
update: update(db, log),
delete: remove(db, log)
};
};
+158
View File
@@ -0,0 +1,158 @@
const response = require('../response');
const sceneService = require('../services/sceneService');
const sceneLibraryService = require('../services/sceneLibraryService');
const imageService = require('../services/imageService');
function routes(db, log, cfg) {
return {
getOne: (req, res) => {
try {
const scene = sceneService.getSceneById(db, Number(req.params.scene_id));
if (!scene) return response.notFound(res, '场景不存在');
response.success(res, { scene });
} catch (err) {
log.error('scenes getOne', { error: err.message });
response.internalError(res, err.message);
}
},
generatePrompt: async (req, res) => {
try {
const body = req.body || {};
const out = await sceneService.generateScenePromptOnly(
db, log, cfg, req.params.scene_id, body.model || undefined, body.style || undefined
);
if (!out.ok) {
if (out.error === 'scene not found') return response.notFound(res, '场景不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '提示词已生成', polished_prompt: out.polished_prompt });
} catch (err) {
log.error('scenes generatePrompt', { error: err.message });
response.internalError(res, err.message);
}
},
extractFromImage: async (req, res) => {
try {
const out = await sceneService.extractSceneFromImage(db, log, cfg, req.params.scene_id);
if (!out.ok) {
if (out.error === 'scene not found') return response.notFound(res, '场景不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '场景描述已提取', prompt: out.prompt });
} catch (err) {
log.error('scenes extract-from-image', { error: err.message });
response.internalError(res, err.message);
}
},
update: (req, res) => {
try {
const out = sceneService.updateScene(db, log, req.params.scene_id, req.body || {});
if (!out.ok) return response.notFound(res, '场景不存在');
response.success(res, { message: '保存成功' });
} catch (err) {
log.error('scenes update', { error: err.message });
response.internalError(res, err.message);
}
},
updatePrompt: (req, res) => {
try {
const out = sceneService.updateScenePrompt(db, log, req.params.scene_id, req.body || {});
if (!out.ok) return response.notFound(res, '场景不存在');
response.success(res, { message: '场景提示词已更新' });
} catch (err) {
log.error('scenes updatePrompt', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const out = sceneService.deleteScene(db, log, req.params.scene_id);
if (!out.ok) return response.notFound(res, '场景不存在');
response.success(res, { message: '场景已删除' });
} catch (err) {
log.error('scenes delete', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const body = req.body || {};
const dramaId = body.drama_id;
if (dramaId == null) return response.badRequest(res, '缺少 drama_id');
const scene = sceneService.createScene(db, log, dramaId, body);
response.created(res, scene);
} catch (err) {
log.error('scenes create', { error: err.message });
response.internalError(res, err.message);
}
},
generateImage: async (req, res) => {
try {
const body = req.body || {};
const sceneId = body.scene_id != null ? Number(body.scene_id) : null;
if (sceneId == null) return response.badRequest(res, '缺少 scene_id');
const out = await sceneService.generateSceneFourViewImage(
db, log, cfg, sceneId, body.model || undefined, body.style || undefined
);
if (!out.ok) {
if (out.error === 'scene not found') return response.notFound(res, '场景不存在');
if (out.error === 'unauthorized') return response.notFound(res, '剧集不存在或无权限');
return response.badRequest(res, out.error);
}
response.success(res, {
message: '场景四视图生成任务已提交',
image_generation: out.image_generation,
});
} catch (err) {
log.error('scenes generateImage', { error: err.message });
response.internalError(res, err.message);
}
},
addToLibrary: (req, res) => {
try {
const out = sceneLibraryService.addSceneToLibrary(db, log, req.params.scene_id);
if (!out.ok) {
if (out.error === 'scene not found') return response.notFound(res, '场景不存在');
if (out.error === 'unauthorized') return response.forbidden(res, '无权限');
return response.badRequest(res, out.error);
}
response.success(res, { message: '已加入本剧场景库', item: out.item });
} catch (err) {
log.error('scenes add-to-library', { error: err.message });
response.internalError(res, err.message);
}
},
addToMaterialLibrary: (req, res) => {
try {
const out = sceneLibraryService.addSceneToMaterialLibrary(db, log, req.params.scene_id);
if (!out.ok) {
if (out.error === 'scene not found') return response.notFound(res, '场景不存在');
return response.badRequest(res, out.error);
}
response.success(res, { message: '已加入全局素材库', item: out.item });
} catch (err) {
log.error('scenes add-to-material-library', { error: err.message });
response.internalError(res, err.message);
}
},
generateFourViewImage: async (req, res) => {
try {
const body = req.body || {};
const modelName = body.model_name || body.model || undefined;
const style = body.style || undefined;
const out = await sceneService.generateSceneFourViewImage(db, log, cfg, req.params.scene_id, modelName, style);
if (!out.ok) {
if (out.error === 'scene not found') return response.notFound(res, '场景不存在');
if (out.error === 'unauthorized') return response.notFound(res, '剧集不存在或无权限');
return response.badRequest(res, out.error);
}
response.success(res, { message: '场景四视图生成任务已提交', image_generation: out.image_generation });
} catch (err) {
log.error('scenes generate-four-view-image', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+72
View File
@@ -0,0 +1,72 @@
const settingsService = require('../services/settingsService');
const response = require('../response');
const { loadConfig } = require('../config');
const { resolveVideoGenerationTimeoutMinutes } = require('../config/videoGeneration');
function getLanguage(cfg) {
return (req, res) => {
const language = settingsService.getLanguage(cfg);
response.success(res, { language });
};
}
function updateLanguage(cfg, log) {
return (req, res) => {
const lang = req.body?.language;
if (lang !== 'zh' && lang !== 'en') {
return response.badRequest(res, '语言参数错误,只支持 zh 或 en');
}
const out = settingsService.updateLanguage(cfg, log, lang);
if (!out.ok) return response.badRequest(res, out.error);
const message = lang === 'en' ? 'Language switched to English' : '语言已切换为中文';
response.success(res, { message, language: lang });
};
}
/** GET /settings/generation — 获取生成相关全局设置 */
function getGenerationSettings(db) {
return (req, res) => {
const concurrency = settingsService.getGlobalSetting(db, 'pipeline_concurrency', 3);
const video_concurrency = settingsService.getGlobalSetting(db, 'pipeline_video_concurrency', 3);
const video_generation_timeout_minutes = resolveVideoGenerationTimeoutMinutes(loadConfig());
response.success(res, { concurrency, video_concurrency, video_generation_timeout_minutes });
};
}
/** PUT /settings/generation — 更新生成相关全局设置 */
function updateGenerationSettings(db) {
return (req, res) => {
const { concurrency, video_concurrency } = req.body || {};
if (concurrency !== undefined) {
const n = Number(concurrency);
if (!Number.isInteger(n) || n < 1 || n > 20) {
return response.badRequest(res, '图片并发数需为 1-20 之间的整数');
}
settingsService.setGlobalSetting(db, 'pipeline_concurrency', n);
}
if (video_concurrency !== undefined) {
const n = Number(video_concurrency);
if (!Number.isInteger(n) || n < 1 || n > 20) {
return response.badRequest(res, '视频并发数需为 1-20 之间的整数');
}
settingsService.setGlobalSetting(db, 'pipeline_video_concurrency', n);
}
const saved = settingsService.getGlobalSetting(db, 'pipeline_concurrency', 3);
const saved_video = settingsService.getGlobalSetting(db, 'pipeline_video_concurrency', 3);
const video_generation_timeout_minutes = resolveVideoGenerationTimeoutMinutes(loadConfig());
response.success(res, {
concurrency: saved,
video_concurrency: saved_video,
video_generation_timeout_minutes,
});
};
}
module.exports = function settingsRoutes(db, cfg, log) {
return {
getLanguage: getLanguage(cfg),
updateLanguage: updateLanguage(cfg, log),
getGenerationSettings: getGenerationSettings(db),
updateGenerationSettings: updateGenerationSettings(db),
};
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
const tailFrameLinkService = require('../services/tailFrameLinkService');
function routes(db, cfg, log) {
const service = tailFrameLinkService(db, cfg, log);
return {
linkTailFrame: service.linkTailFrame
};
}
module.exports = routes;
+154
View File
@@ -0,0 +1,154 @@
// 与 Go 路由一一对应的桩实现,保证前端可一键切换;后续可逐步替换为真实逻辑
const response = require('../response');
const taskService = require('../services/taskService');
const episodeStoryboardService = require('../services/episodeStoryboardService');
function stubSuccess(res, data = {}) {
response.success(res, data);
}
function stubCreated(res, data = {}) {
response.created(res, data);
}
module.exports = function stubRoutes(db, cfg, log) {
return {
// POST /generation/characters
generationCharacters: (req, res) => {
const body = req.body || {};
const task = taskService.createTask(db, log, 'character_generation', body.drama_id || '');
setTimeout(() => taskService.updateTaskResult(db, task.id, { characters: [], count: 0 }), 100);
response.success(res, { task_id: task.id, status: 'pending' });
},
// character-library
characterLibraryList: (req, res) => stubSuccess(res, []),
characterLibraryCreate: (req, res) => stubCreated(res, { id: 0, name: '', image_url: '', ...req.body }),
characterLibraryGet: (req, res) => stubSuccess(res, { id: req.params.id, name: '', image_url: '' }),
characterLibraryDelete: (req, res) => response.success(res, { message: '删除成功' }),
// characters
characterUpdate: (req, res) => response.success(res, { message: '保存成功' }),
characterDelete: (req, res) => response.success(res, { message: '删除成功' }),
characterBatchGenerateImages: (req, res) => {
const task = taskService.createTask(db, log, 'batch_character_image', '');
setTimeout(() => taskService.updateTaskResult(db, task.id, {}), 100);
response.success(res, { task_id: task.id });
},
characterGenerateImage: (req, res) => {
const task = taskService.createTask(db, log, 'character_image', req.params.id);
setTimeout(() => taskService.updateTaskResult(db, task.id, {}), 100);
response.success(res, { task_id: task.id });
},
characterUploadImage: (req, res) => response.success(res, { message: '上传成功' }),
characterPutImage: (req, res) => response.success(res, { message: '保存成功' }),
characterImageFromLibrary: (req, res) => response.success(res, { message: '应用成功' }),
characterAddToLibrary: (req, res) => response.success(res, { message: '已加入角色库' }),
// upload
uploadImage: (req, res) => response.success(res, { url: '', path: '' }),
// episodes (部分在 drama 里已实现 finalize, download)
episodeStoryboardsGenerate: (req, res) => {
const taskId = episodeStoryboardService.generateStoryboard(
db,
log,
req.params.episode_id,
req.query.model,
req.query.style
);
response.success(res, { task_id: taskId, status: 'pending', message: '分镜头生成任务已创建,正在后台处理...' });
},
episodeStoryboardsGet: (req, res) => {
const list = episodeStoryboardService.getStoryboardsForEpisode(db, req.params.episode_id);
response.success(res, list);
},
episodeCharactersExtract: (req, res) => {
const task = taskService.createTask(db, log, 'character_extraction', req.params.episode_id);
setTimeout(() => taskService.updateTaskResult(db, task.id, { characters: [], count: 0 }), 100);
response.success(res, { task_id: task.id });
},
// scenes
sceneUpdate: (req, res) => response.success(res, { message: '保存成功' }),
sceneUpdatePrompt: (req, res) => response.success(res, { message: '保存成功' }),
sceneDelete: (req, res) => response.success(res, { message: '删除成功' }),
sceneGenerateImage: (req, res) => response.success(res, { task_id: '', image_url: '' }),
sceneCreate: (req, res) => stubCreated(res, { id: 0, ...req.body }),
// images
imageList: (req, res) => response.success(res, []),
imageCreate: (req, res) => {
const task = taskService.createTask(db, log, 'image_generation', '');
setTimeout(() => taskService.updateTaskResult(db, task.id, { id: 0, status: 'pending' }), 100);
response.created(res, { id: 0, task_id: task.id, status: 'pending' });
},
imageGet: (req, res) => response.notFound(res, '记录不存在'),
imageDelete: (req, res) => response.success(res, { message: '删除成功' }),
imageScene: (req, res) => response.success(res, []),
imageUpload: (req, res) => response.created(res, { id: 0, image_url: '' }),
imageEpisodeBackgrounds: (req, res) => response.success(res, []),
imageEpisodeBackgroundsExtract: (req, res) => {
try {
const task = taskService.createTask(db, log, 'background_extraction', req.params.episode_id);
const taskId = task && task.id ? task.id : '';
setTimeout(() => {
try { taskService.updateTaskResult(db, taskId, { backgrounds: [] }); } catch (_) {}
}, 100);
if (!res.headersSent) {
response.success(res, { task_id: taskId, status: 'pending', message: '场景提取任务已创建,正在后台处理...' });
}
} catch (err) {
log.errorw('backgrounds/extract failed', { error: err.message });
if (!res.headersSent) response.internalError(res, err.message || '任务创建失败');
}
},
imageEpisodeBatch: (req, res) => response.success(res, []),
// videos
videoList: (req, res) => response.success(res, []),
videoCreate: (req, res) => {
const task = taskService.createTask(db, log, 'video_generation', '');
setTimeout(() => taskService.updateTaskResult(db, task.id, { id: 0, status: 'pending' }), 100);
response.created(res, { id: 0, task_id: task.id, status: 'pending' });
},
videoGet: (req, res) => response.notFound(res, '记录不存在'),
videoDelete: (req, res) => response.success(res, { message: '删除成功' }),
videoFromImage: (req, res) => {
const task = taskService.createTask(db, log, 'video_generation', '');
response.success(res, { task_id: task.id });
},
videoEpisodeBatch: (req, res) => response.success(res, []),
// video-merges
videoMergeList: (req, res) => response.success(res, []),
videoMergeCreate: (req, res) => {
const task = taskService.createTask(db, log, 'video_merge', req.body?.episode_id || '');
response.success(res, { merge_id: 0, task_id: task.id });
},
videoMergeGet: (req, res) => response.notFound(res, '记录不存在'),
videoMergeDelete: (req, res) => response.success(res, { message: '删除成功' }),
// assets
assetList: (req, res) => response.success(res, []),
assetCreate: (req, res) => stubCreated(res, { id: 0 }),
assetGet: (req, res) => response.notFound(res, '资源不存在'),
assetUpdate: (req, res) => response.success(res, { message: '保存成功' }),
assetDelete: (req, res) => response.success(res, { message: '删除成功' }),
assetImportImage: (req, res) => stubCreated(res, { id: 0 }),
assetImportVideo: (req, res) => stubCreated(res, { id: 0 }),
// storyboards (episode generate 已在上方;create/update/delete/frame-prompt)
storyboardCreate: (req, res) => stubCreated(res, { id: 0, ...req.body }),
storyboardUpdate: (req, res) => response.success(res, { message: '保存成功' }),
storyboardDelete: (req, res) => response.success(res, { message: '删除成功' }),
storyboardFramePrompt: (req, res) => {
const task = taskService.createTask(db, log, 'frame_prompt_generation', req.params.id);
response.success(res, task.id);
},
storyboardFramePromptsGet: (req, res) => response.success(res, []),
// audio
audioExtract: (req, res) => response.success(res, { url: '' }),
audioExtractBatch: (req, res) => response.success(res, []),
};
};
+31
View File
@@ -0,0 +1,31 @@
const taskService = require('../services/taskService');
const response = require('../response');
function getTaskStatus(db, log) {
return (req, res) => {
const task = taskService.getTask(db, req.params.task_id);
if (!task) return response.notFound(res, '任务不存在');
response.success(res, task);
};
}
function getResourceTasks(db, log) {
return (req, res) => {
const resourceId = req.query.resource_id;
if (!resourceId) return response.badRequest(res, '缺少resource_id参数');
try {
const tasks = taskService.getTasksByResource(db, resourceId);
response.success(res, tasks);
} catch (err) {
log.errorw('Get resource tasks failed', { error: err.message });
response.internalError(res, err.message);
}
};
}
module.exports = function taskRoutes(db, log) {
return {
getTaskStatus: getTaskStatus(db, log),
getResourceTasks: getResourceTasks(db, log),
};
};
+104
View File
@@ -0,0 +1,104 @@
const path = require('path');
const multer = require('multer');
const response = require('../response');
const uploadService = require('../services/uploadService');
const storageLayout = require('../services/storageLayout');
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
const maxSize = 16 * 1024 * 1024; // 16MB,单张图片上限
const MAX_SIZE_MB = 16;
const memoryStorage = multer.memoryStorage();
const upload = multer({
storage: memoryStorage,
limits: { fileSize: maxSize },
fileFilter: (req, file, cb) => {
const ct = file.mimetype || 'application/octet-stream';
if (!allowedTypes.includes(ct)) {
return cb(new Error('只支持图片格式 (jpg, png, gif, webp)'));
}
cb(null, true);
},
});
// Seedance 2.0 音色参考音频上传(支持常见音频格式)
const allowedAudioTypes = [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-wav',
'audio/mp4',
'audio/m4a',
'audio/ogg',
'audio/webm',
];
const audioMaxSize = 10 * 1024 * 1024; // 10MB
const audioUpload = multer({
storage: memoryStorage,
limits: { fileSize: audioMaxSize },
fileFilter: (req, file, cb) => {
const ct = file.mimetype || 'application/octet-stream';
if (!allowedAudioTypes.includes(ct)) {
return cb(new Error('只支持音频格式 (mp3, wav, m4a, ogg)'));
}
cb(null, true);
},
});
function routes(cfg, log, db) {
const singleUpload = upload.single('file');
return {
multerSingle: singleUpload,
uploadImage: (req, res) => {
if (!req.file || !req.file.buffer) {
return response.badRequest(res, '请选择文件');
}
try {
const rawStorage = cfg?.storage?.local_path || './data/storage';
const storagePath = path.isAbsolute(rawStorage)
? rawStorage
: path.join(process.cwd(), rawStorage);
const baseUrl = cfg?.storage?.base_url || '';
let projectSubdir = null;
if (db) {
const raw = req.body?.drama_id;
const did =
raw !== undefined && raw !== null && String(raw).trim() !== ''
? Number(raw)
: NaN;
if (Number.isFinite(did) && did > 0) {
projectSubdir = storageLayout.getProjectStorageSubdir(db, did);
}
}
const result = uploadService.uploadFile(
storagePath,
baseUrl,
log,
req.file.buffer,
req.file.originalname || 'image.png',
req.file.mimetype,
'uploads',
projectSubdir
);
response.success(res, {
url: result.url,
path: result.local_path,
local_path: result.local_path,
filename: req.file.originalname,
size: req.file.size,
});
} catch (err) {
log.error('upload image', { error: err.message });
response.internalError(res, err.message || '上传失败');
}
},
};
}
module.exports = {
routes,
upload,
multerSingle: upload.single('file'),
multerAudioSingle: audioUpload.single('file'),
MAX_IMAGE_SIZE_MB: MAX_SIZE_MB,
};
+49
View File
@@ -0,0 +1,49 @@
const response = require('../response');
const videoMergeService = require('../services/videoMergeService');
function routes(db, log) {
return {
list: (req, res) => {
try {
const query = { ...req.query };
const items = videoMergeService.list(db, query);
response.success(res, items);
} catch (err) {
log.error('video-merges list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const body = req.body || {};
const rec = videoMergeService.create(db, log, body);
response.success(res, { merge_id: rec.merge_id, task_id: rec.task_id, ...rec });
} catch (err) {
log.error('video-merges create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = videoMergeService.getById(db, req.params.merge_id);
if (!item) return response.notFound(res, '记录不存在');
response.success(res, item);
} catch (err) {
log.error('video-merges get', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = videoMergeService.deleteById(db, log, req.params.merge_id);
if (!ok) return response.notFound(res, '记录不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('video-merges delete', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+119
View File
@@ -0,0 +1,119 @@
const response = require('../response');
const videoService = require('../services/videoService');
const taskService = require('../services/taskService');
const { normalizeAspectRatioForApi } = require('../services/videoClient');
function routes(db, log) {
return {
list: (req, res) => {
try {
const query = { ...req.query };
const { items, total, page, pageSize } = videoService.list(db, query);
response.successWithPagination(res, items, total, page, pageSize);
} catch (err) {
log.error('videos list', { error: err.message });
response.internalError(res, err.message);
}
},
create: (req, res) => {
try {
const body = req.body || {};
const task = taskService.createTask(db, log, 'video_generation', String(body.drama_id || ''));
const now = new Date().toISOString();
const dramaId = Number(body.drama_id) || 0;
const storyboardId = body.storyboard_id != null ? Number(body.storyboard_id) : null;
const provider = body.provider || 'chatfire';
let prompt = body.prompt || '';
const style = (body.style || '').toString().trim();
if (style) {
const baseLower = String(prompt || '').toLowerCase();
const styleLower = style.toLowerCase();
if (!baseLower.includes(styleLower)) {
prompt = prompt ? `${prompt}. Style: ${style}` : `Style: ${style}`;
}
}
const model = body.model ?? null;
const duration = body.duration ?? null;
// 画幅:请求体归一化(全角冒号等)后写入 DB;未传则从 drama.metadata 读取并同样归一化
let aspectRatio = null;
if (body.aspect_ratio != null && String(body.aspect_ratio).trim() !== '') {
aspectRatio = normalizeAspectRatioForApi(body.aspect_ratio);
}
if (!aspectRatio && dramaId) {
try {
const dramaRow = db.prepare('SELECT metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(dramaId);
if (dramaRow && dramaRow.metadata) {
const meta = typeof dramaRow.metadata === 'string' ? JSON.parse(dramaRow.metadata) : dramaRow.metadata;
if (meta && meta.aspect_ratio) aspectRatio = normalizeAspectRatioForApi(meta.aspect_ratio);
}
} catch (_) {}
}
const resolution = body.resolution ?? null;
const seed = body.seed != null ? Number(body.seed) : null;
const cameraFixed = body.camera_fixed != null ? (body.camera_fixed ? 1 : 0) : null;
const watermark = body.watermark != null ? (body.watermark ? 1 : 0) : 0;
const imageUrl = body.image_url ?? null;
// 首尾帧:支持 URL 或本地路径(sxy,存到 first_frame_url / last_frame_url
const firstFrameUrl = body.first_frame_url ?? body.first_frame_local_path ?? null;
const lastFrameUrl = body.last_frame_url ?? body.last_frame_local_path ?? null;
// 多图模式:sxy,存 JSON 数组到 reference_image_urls
const refImagesJson =
body.reference_image_urls && Array.isArray(body.reference_image_urls)
? JSON.stringify(body.reference_image_urls.slice(0, 10))
: null;
db.prepare(
`INSERT INTO video_generations (drama_id, storyboard_id, provider, prompt, model, duration, aspect_ratio, resolution, seed, camera_fixed, watermark, image_url, first_frame_url, last_frame_url, reference_image_urls, status, task_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'processing', ?, ?, ?)`
).run(dramaId, storyboardId, provider, prompt, model, duration, aspectRatio, resolution, seed, cameraFixed, watermark, imageUrl, firstFrameUrl, lastFrameUrl, refImagesJson, task.id, now, now);
const videoGenId = db.prepare('SELECT last_insert_rowid() as id').get().id;
setImmediate(() => {
videoService.processVideoGeneration(db, log, videoGenId);
});
const item = videoService.getById(db, videoGenId);
response.created(res, item || { id: videoGenId, task_id: task.id, status: 'processing' });
} catch (err) {
log.error('videos create', { error: err.message });
response.internalError(res, err.message);
}
},
get: (req, res) => {
try {
const item = videoService.getById(db, req.params.id);
if (!item) return response.notFound(res, '记录不存在');
response.success(res, item);
} catch (err) {
log.error('videos get', { error: err.message });
response.internalError(res, err.message);
}
},
delete: (req, res) => {
try {
const ok = videoService.deleteById(db, log, req.params.id);
if (!ok) return response.notFound(res, '记录不存在');
response.success(res, { message: '删除成功' });
} catch (err) {
log.error('videos delete', { error: err.message });
response.internalError(res, err.message);
}
},
fromImage: (req, res) => {
try {
const task = taskService.createTask(db, log, 'video_generation', req.params.image_gen_id);
response.success(res, { task_id: task.id });
} catch (err) {
log.error('videos fromImage', { error: err.message });
response.internalError(res, err.message);
}
},
episodeBatch: (req, res) => {
try {
response.success(res, []);
} catch (err) {
log.error('videos episode batch', { error: err.message });
response.internalError(res, err.message);
}
},
};
}
module.exports = routes;
+42
View File
@@ -0,0 +1,42 @@
const { loadConfig } = require('./config/index.js');
const preConfig = loadConfig();
const tlsFlag = preConfig.server?.insecure_tls ?? preConfig.server?.INSECURE_TLS;
const insecureTlsOn =
tlsFlag === true ||
tlsFlag === 1 ||
tlsFlag === '1' ||
String(tlsFlag).toLowerCase() === 'true';
if (insecureTlsOn) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
console.warn('[config] server.insecure_tls 已启用:全局跳过 TLS 证书校验,仅用于测试');
}
const { createApp } = require('./app.js');
const { closeDb } = require('./db/index.js');
const logger = require('./logger.js');
const { app, config } = createApp();
const port = Number(process.env.PORT) || config.server?.port || 5679;
const host = config.server?.host || '0.0.0.0';
const server = app.listen(port, host, () => {
logger.info('Server starting', { port, host });
logger.info('Frontend: http://localhost:' + port);
logger.info('API: http://localhost:' + port + '/api/v1');
logger.info('Health: http://localhost:' + port + '/health');
logger.info('Server is ready!');
});
function shutdown() {
logger.info('Shutting down server...');
server.close(() => {
closeDb();
logger.info('Server exited');
process.exit(0);
});
setTimeout(() => process.exit(1), 5000);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+711
View File
@@ -0,0 +1,711 @@
// 与 Go pkg/ai + application/services/ai_service 对齐:读取 ai_service_configs,调用 OpenAI 兼容的 chat completions
const aiConfigService = require('./aiConfigService');
const { applyDeepSeekChatOptions } = require('./deepseekConfig');
const https = require('https');
const http = require('http');
/**
* 非流式 POST,发送 JSON body,等待完整 HTTP 响应后返回。
* 用于视觉分析等短请求,兼容 o-series 推理模型和各种第三方代理。
*/
function postJSONNonStream(url, headers, body, timeoutMs = 120000) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const mod = parsed.protocol === 'https:' ? https : http;
const bodyStr = JSON.stringify(body);
const reqHeaders = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
...headers,
};
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: reqHeaders,
};
const req = mod.request(options, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`));
}
try {
const json = JSON.parse(raw);
// 兼容标准 OpenAI 格式与推理模型
const content = json.choices?.[0]?.message?.content
|| json.choices?.[0]?.message?.reasoning_content
|| null;
resolve({ status: res.statusCode, body: content, raw });
} catch (_) {
resolve({ status: res.statusCode, body: null, raw });
}
});
res.on('error', reject);
});
const timer = setTimeout(() => { req.destroy(); reject(new Error(`Vision request timeout after ${timeoutMs}ms`)); }, timeoutMs);
req.on('error', (e) => { clearTimeout(timer); reject(e); });
req.on('close', () => clearTimeout(timer));
req.write(bodyStr);
req.end();
});
}
/**
* 图生等长耗时 JSON POST:使用 Node http(s) + 可配置超时(默认 10 分钟),
* 避免 undici fetch 在慢链路或大包体(多参考图 base64)下长时间挂起后以模糊的 fetch failed 结束。
* @returns {Promise<{ statusCode: number, raw: string }>}
*/
function postJSONWithTimeout(url, headers, body, timeoutMs = 600000) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const mod = parsed.protocol === 'https:' ? https : http;
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
const reqHeaders = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
...headers,
};
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: reqHeaders,
};
const req = mod.request(options, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
clearTimeout(timer);
const raw = Buffer.concat(chunks).toString('utf-8');
resolve({ statusCode: res.statusCode || 0, raw });
});
res.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
});
const timer = setTimeout(() => {
req.destroy();
reject(new Error(`Image generation HTTP timeout after ${timeoutMs}ms`));
}, timeoutMs);
req.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
req.write(bodyStr);
req.end();
});
}
/**
* 用 SSE 流式输出(stream: true)请求 OpenAI 兼容接口。
* 流式模式下 socket 每收到一个 token 就重置静默计时器,只要模型在生成就不会超时,
* 彻底解决分镜等长耗时任务的 "fetch failed / timeout" 问题。
* silenceTimeoutMs:连续多少毫秒无任何数据才判定超时(默认 60 秒)。
*/
function postJSONStream(url, headers, body, silenceTimeoutMs = 60000, onProgress = null) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const mod = parsed.protocol === 'https:' ? https : http;
// 强制开启流式输出
const streamBody = { ...body, stream: true };
const bodyStr = JSON.stringify(streamBody);
const reqHeaders = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
...headers,
};
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: reqHeaders,
};
let silenceTimer = null;
const resetSilenceTimer = () => {
if (silenceTimer) clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
req.destroy();
reject(new Error(`AI stream silence timeout after ${silenceTimeoutMs}ms`));
}, silenceTimeoutMs);
};
const req = mod.request(options, (res) => {
const statusCode = res.statusCode;
// 非 2xx 时先读完整 body 再报错(可能是 JSON 错误信息)
if (statusCode < 200 || statusCode >= 300) {
const errChunks = [];
res.on('data', (c) => errChunks.push(c));
res.on('end', () => {
clearTimeout(silenceTimer);
reject(new Error(`HTTP ${statusCode}: ${Buffer.concat(errChunks).toString('utf-8').slice(0, 500)}`));
});
return;
}
let accumulated = '';
let sseBuffer = '';
let firstToken = true;
resetSilenceTimer();
res.on('data', (chunk) => {
resetSilenceTimer();
sseBuffer += chunk.toString('utf-8');
// 按行解析 SSE
const lines = sseBuffer.split('\n');
sseBuffer = lines.pop(); // 保留不完整的最后一行
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const evt = JSON.parse(data);
const delta = evt.choices?.[0]?.delta?.content;
if (delta) {
if (firstToken) {
firstToken = false;
if (onProgress) onProgress(0, 'first_token', '');
}
accumulated += delta;
if (onProgress) onProgress(accumulated.length, null, accumulated);
}
} catch (_) { /* 忽略无法解析的行 */ }
}
});
res.on('end', () => {
clearTimeout(silenceTimer);
resolve({ status: statusCode, body: accumulated });
});
res.on('error', (e) => { clearTimeout(silenceTimer); reject(e); });
});
req.on('error', (e) => { clearTimeout(silenceTimer); reject(e); });
resetSilenceTimer(); // 连接建立阶段也需要计时
req.write(bodyStr);
req.end();
});
}
// 使用前端设置的「默认」与「优先级」:listConfigs 已按 is_default DESC, priority DESC 排序
function getDefaultConfig(db, serviceType) {
const configs = aiConfigService.listConfigs(db, serviceType);
const active = configs.filter((c) => c.is_active);
if (active.length === 0) return null;
const defaultOne = active.find((c) => c.is_default);
return defaultOne != null ? defaultOne : active[0];
}
function getConfigForModel(db, serviceType, modelName) {
const configs = aiConfigService.listConfigs(db, serviceType);
for (const config of configs) {
if (!config.is_active) continue;
const models = Array.isArray(config.model) ? config.model : [config.model];
if (models.includes(modelName)) return config;
}
return null;
}
function buildChatUrl(config) {
const base = (config.base_url || '').replace(/\/$/, '');
let ep = config.endpoint || '/chat/completions';
if (!ep.startsWith('/')) ep = '/' + ep;
return base + ep;
}
function getModelFromConfig(config, preferredModel) {
const models = Array.isArray(config.model) ? config.model : (config.model != null ? [config.model] : []);
if (preferredModel && models.includes(preferredModel)) return preferredModel;
if (config.default_model && models.includes(config.default_model)) return config.default_model;
return models[0] || 'gpt-3.5-turbo';
}
/**
* 从 ai_model_map 表查找业务场景对应的模型配置
* 返回 { config, modelOverride } 或 null(未配置时)
*/
function getConfigFromModelMap(db, sceneKey) {
try {
const row = db.prepare('SELECT * FROM ai_model_map WHERE key = ?').get(sceneKey);
if (!row) return null;
const configs = aiConfigService.listConfigs(db, row.service_type || 'text');
let config = null;
if (row.config_id) {
config = configs.find((c) => c.id === row.config_id && c.is_active) || null;
}
if (!config) {
config = configs.find((c) => c.is_active && c.is_default) || configs.find((c) => c.is_active) || null;
}
return config ? { config, modelOverride: row.model_override || null } : null;
} catch (_) {
return null;
}
}
async function generateText(db, log, serviceType, userPrompt, systemPrompt, options = {}) {
const { model: preferredModel, temperature = 0.7, json_mode = false, min_max_tokens = null, streamCallback = null, scene_key = null } = options;
// F2: 若传入 scene_key,优先从 ai_model_map 查找对应的模型路由配置
let config = null;
let routedModelOverride = null;
if (scene_key) {
const mapped = getConfigFromModelMap(db, scene_key);
if (mapped) {
config = mapped.config;
routedModelOverride = mapped.modelOverride;
log.info('AI generateText: scene_key routing', { scene_key, config_id: config.id, model_override: routedModelOverride });
}
}
if (!config) {
config = preferredModel
? getConfigForModel(db, serviceType, preferredModel)
: getDefaultConfig(db, serviceType);
}
if (!config && preferredModel === undefined) {
// 兜底:如果前端传了 undefined,且没找到默认,尝试重新找一下(可能 serviceType 传值问题,或者数据库问题)
config = getDefaultConfig(db, 'text');
}
if (!config) {
throw new Error(`未配置文本模型,请在「AI 配置」中添加 ${serviceType} 类型 且已启用的配置`);
}
// scene_key 路由的模型覆盖优先级 > preferredModel
const effectivePreferredModel = routedModelOverride || preferredModel;
const model = getModelFromConfig(config, effectivePreferredModel);
const url = buildChatUrl(config);
// 解析 settings 里的 max_tokens 上限(用户在 AI 配置里可设置 {"max_tokens": 8192}
let settingsMaxTokens = null;
try {
if (config.settings) {
const s = typeof config.settings === 'string' ? JSON.parse(config.settings) : config.settings;
if (s && typeof s.max_tokens === 'number' && s.max_tokens > 0) settingsMaxTokens = s.max_tokens;
}
} catch (_) {}
// 最终 max_tokens:优先取调用方传入值,但不超过 settings 里的上限;
// 若调用方未传,则使用 settings 值(有的话);两者都没有则不传(让模型用自己默认值)。
// min_max_tokens:调用方可声明一个最低需求量,确保多集生成等场景不被用户的小上限截断,
// 此时 finalMaxTokens = max(min_max_tokens, settingsMaxTokens ?? min_max_tokens)。
let finalMaxTokens = null;
if (options.max_tokens != null) {
finalMaxTokens = Number(options.max_tokens);
if (settingsMaxTokens != null && finalMaxTokens > settingsMaxTokens) {
log.warn('AI generateText: max_tokens 超过配置上限,已截断', {
requested: finalMaxTokens, capped_to: settingsMaxTokens, model,
});
finalMaxTokens = settingsMaxTokens;
}
} else if (settingsMaxTokens != null) {
finalMaxTokens = settingsMaxTokens;
}
// 确保不低于调用方声明的最低需求
if (min_max_tokens != null) {
const minVal = Number(min_max_tokens);
if (finalMaxTokens == null || finalMaxTokens < minVal) {
if (finalMaxTokens != null) {
log.warn('AI generateText: max_tokens 低于任务最低需求,已提升', {
was: finalMaxTokens, raised_to: minVal, model,
});
}
finalMaxTokens = minVal;
}
}
let body = {
model,
messages: [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
{ role: 'user', content: userPrompt },
],
temperature: Number(temperature),
...(finalMaxTokens != null ? { max_tokens: finalMaxTokens } : {}),
...(json_mode ? { response_format: { type: 'json_object' } } : {}),
};
body = applyDeepSeekChatOptions(config, body);
const startMs = Date.now();
log.info('AI generateText request', { url: url.slice(0, 60), model, max_tokens: finalMaxTokens ?? '(model default)', json_mode, stream: true });
const res = await postJSONStream(url, { Authorization: 'Bearer ' + (config.api_key || '') }, body, 60000, (receivedLen, event, accumulated) => {
if (event === 'first_token') {
log.info('AI stream first token', { model, ttft_ms: Date.now() - startMs });
} else if (receivedLen > 0 && receivedLen % 500 < 20) {
// 每积累约 500 字符记录一次进度
log.info('AI stream progress', { model, received_chars: receivedLen, elapsed_ms: Date.now() - startMs });
}
// 调用者提供的流式回调(如分镜增量解析),传入当前已积累的完整文本
if (streamCallback && accumulated) streamCallback(accumulated);
});
// 流式模式下 res.body 已是拼接好的完整文本内容(非 JSON)
const content = res.body;
const elapsedMs = Date.now() - startMs;
if (!content) {
throw new Error('AI 返回内容为空');
}
log.info('AI raw response received', { model, text_length: content.length, elapsed_ms: elapsedMs, text_preview: content.slice(0, 200) });
return content;
}
/**
* 与 generateText 相同的路由与鉴权,但将模型增量以 delta 回调给调用方;返回完整拼接文本。
* @param {(delta: string) => void} onDelta 仅增量片段(UTF-8 字符串)
*/
async function streamGenerateText(db, log, serviceType, userPrompt, systemPrompt, options = {}, onDelta) {
const { model: preferredModel, temperature = 0.7, json_mode = false, min_max_tokens = null, scene_key = null } = options;
let config = null;
let routedModelOverride = null;
if (scene_key) {
const mapped = getConfigFromModelMap(db, scene_key);
if (mapped) {
config = mapped.config;
routedModelOverride = mapped.modelOverride;
log.info('AI streamGenerateText: scene_key routing', { scene_key, config_id: config.id, model_override: routedModelOverride });
}
}
if (!config) {
config = preferredModel
? getConfigForModel(db, serviceType, preferredModel)
: getDefaultConfig(db, serviceType);
}
if (!config && preferredModel === undefined) {
config = getDefaultConfig(db, 'text');
}
if (!config) {
throw new Error(`未配置文本模型,请在「AI 配置」中添加 ${serviceType} 类型 且已启用的配置`);
}
const effectivePreferredModel = routedModelOverride || preferredModel;
const model = getModelFromConfig(config, effectivePreferredModel);
const url = buildChatUrl(config);
let settingsMaxTokens = null;
try {
if (config.settings) {
const s = typeof config.settings === 'string' ? JSON.parse(config.settings) : config.settings;
if (s && typeof s.max_tokens === 'number' && s.max_tokens > 0) settingsMaxTokens = s.max_tokens;
}
} catch (_) {}
let finalMaxTokens = null;
if (options.max_tokens != null) {
finalMaxTokens = Number(options.max_tokens);
if (settingsMaxTokens != null && finalMaxTokens > settingsMaxTokens) {
log.warn('AI streamGenerateText: max_tokens 超过配置上限,已截断', {
requested: finalMaxTokens,
capped_to: settingsMaxTokens,
model,
});
finalMaxTokens = settingsMaxTokens;
}
} else if (settingsMaxTokens != null) {
finalMaxTokens = settingsMaxTokens;
}
if (min_max_tokens != null) {
const minVal = Number(min_max_tokens);
if (finalMaxTokens == null || finalMaxTokens < minVal) {
if (finalMaxTokens != null) {
log.warn('AI streamGenerateText: max_tokens 低于任务最低需求,已提升', { was: finalMaxTokens, raised_to: minVal });
}
finalMaxTokens = minVal;
}
}
let body = {
model,
messages: [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
{ role: 'user', content: userPrompt },
],
temperature: Number(temperature),
...(finalMaxTokens != null ? { max_tokens: finalMaxTokens } : {}),
...(json_mode ? { response_format: { type: 'json_object' } } : {}),
};
body = applyDeepSeekChatOptions(config, body);
const silenceMs = options.silence_timeout_ms != null ? Number(options.silence_timeout_ms) : 120000;
const startMs = Date.now();
log.info('AI streamGenerateText request', {
url: url.slice(0, 60),
model,
max_tokens: finalMaxTokens ?? '(model default)',
json_mode,
stream: true,
});
let lastLen = 0;
const res = await postJSONStream(
url,
{ Authorization: 'Bearer ' + (config.api_key || '') },
body,
silenceMs,
(receivedLen, event, accumulated) => {
if (event === 'first_token') {
log.info('AI stream first token', { model, ttft_ms: Date.now() - startMs });
}
if (!accumulated || accumulated.length <= lastLen) return;
const delta = accumulated.slice(lastLen);
lastLen = accumulated.length;
if (onDelta && delta) onDelta(delta);
}
);
const content = res.body;
if (!content) {
throw new Error('AI 返回内容为空');
}
log.info('AI streamGenerateText done', { model, text_length: content.length, elapsed_ms: Date.now() - startMs });
return content;
}
/**
* 从 entity(角色/场景/道具)记录中找到一张可用图片,返回 { imageUrl, isLocal, localAbsPath }。
* 优先顺序:ref_image → local_path → image_url → extra_images[0]
*/
function resolveEntityImageSource(entity, cfg) {
const storagePath = (() => {
const raw = cfg?.storage?.local_path || './data/storage';
return require('path').isAbsolute(raw) ? raw : require('path').join(process.cwd(), raw);
})();
// 用户手动上传的参考图优先
if (entity.ref_image) {
const ref = String(entity.ref_image);
if (ref.startsWith('http')) return { imageUrl: ref, isLocal: false };
return { localAbsPath: require('path').join(storagePath, ref), isLocal: true };
}
if (entity.local_path) {
return { localAbsPath: require('path').join(storagePath, entity.local_path), isLocal: true };
}
if (entity.image_url && String(entity.image_url).startsWith('http')) {
return { imageUrl: entity.image_url, isLocal: false };
}
// 尝试 extra_images 第一张
try {
const extras = entity.extra_images
? (typeof entity.extra_images === 'string' ? JSON.parse(entity.extra_images) : entity.extra_images)
: [];
if (Array.isArray(extras) && extras[0]) {
const first = extras[0];
if (String(first).startsWith('http')) return { imageUrl: first, isLocal: false };
return { localAbsPath: require('path').join(storagePath, first), isLocal: true };
}
} catch (_) {}
return null;
}
/**
* 使用视觉模型(vision)分析图片内容,返回文本描述。
* imageSource: { localAbsPath: string } 或 { imageUrl: string }
* 使用 OpenAI vision 消息格式(兼容 GPT-4o / Gemini openai-compat / Qwen-VL 等)。
*/
async function generateTextWithVision(db, log, serviceType, userPrompt, systemPrompt, imageSource, options = {}) {
const fs = require('fs');
const path = require('path');
// 解析图片为 base64 data URL 或 HTTP URL
let imageUrlForApi;
let imageLogInfo = {};
if (imageSource.imageUrl) {
imageUrlForApi = imageSource.imageUrl;
if (imageUrlForApi.startsWith('data:')) {
// base64 data URL:只记录类型和大小,不记录内容
const mimeMatch = imageUrlForApi.match(/^data:([^;]+);base64,/);
const mime = mimeMatch ? mimeMatch[1] : 'unknown';
const b64Len = imageUrlForApi.length - (mimeMatch ? mimeMatch[0].length : 0);
imageLogInfo = { image_type: 'base64', image_mime: mime, image_size_kb: Math.round(b64Len * 0.75 / 1024) };
} else {
imageLogInfo = { image_type: 'url', image_url: imageUrlForApi.slice(0, 100) };
}
} else if (imageSource.localAbsPath) {
if (!fs.existsSync(imageSource.localAbsPath)) {
throw new Error(`图片文件不存在:${imageSource.localAbsPath}`);
}
const buf = fs.readFileSync(imageSource.localAbsPath);
const ext = path.extname(imageSource.localAbsPath).toLowerCase();
const mimeMap = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
const mime = mimeMap[ext] || 'image/jpeg';
imageUrlForApi = `data:${mime};base64,${buf.toString('base64')}`;
imageLogInfo = { image_type: 'local_file', image_path: imageSource.localAbsPath, image_size_kb: Math.round(buf.length / 1024), image_mime: mime };
} else {
throw new Error('imageSource 必须包含 imageUrl 或 localAbsPath');
}
// 复用 generateText 的配置查找逻辑
const { model: preferredModel, temperature = 0.3, max_tokens = 500 } = options;
let config = preferredModel
? getConfigForModel(db, serviceType, preferredModel)
: getDefaultConfig(db, serviceType);
if (!config) config = getDefaultConfig(db, 'text');
if (!config) throw new Error(`未配置文本模型,请在「AI 配置」中添加 ${serviceType} 类型的配置`);
const model = getModelFromConfig(config, preferredModel);
const url = buildChatUrl(config);
log.info('[Vision] 开始请求', {
config_id: config.id,
config_name: config.name,
api_protocol: config.api_protocol || 'openai',
base_url: config.base_url,
model,
is_reasoning_model: /^o\d/i.test(model),
max_tokens: Number(max_tokens),
...imageLogInfo,
});
const maxTok = Number(max_tokens);
// o1/o3/o4 系列推理模型不支持 temperature,且 system role 需改为 developer role
const isReasoningModel = /^o\d/i.test(model);
const systemRole = isReasoningModel ? 'developer' : 'system';
// 推理模型把 system 内容并入 user 消息前缀(部分代理不识别 developer role
const mergedUserText = (systemPrompt && isReasoningModel)
? `${systemPrompt}\n\n${userPrompt}`
: userPrompt;
// OpenAI vision 消息格式
// max_tokens 供旧版/普通模型使用;max_completion_tokens 供推理模型(o1/o3/o4)使用
const body = {
model,
messages: [
...(systemPrompt && !isReasoningModel ? [{ role: systemRole, content: systemPrompt }] : []),
{
role: 'user',
content: [
{ type: 'text', text: mergedUserText },
{ type: 'image_url', image_url: { url: imageUrlForApi } },
],
},
],
// 推理模型用 max_completion_tokens,普通模型用 max_tokens,不能同时传
...(isReasoningModel ? { max_completion_tokens: maxTok } : { max_tokens: maxTok }),
// 推理模型不支持 temperature,跳过
...(isReasoningModel ? {} : { temperature: Number(temperature) }),
};
const startMs = Date.now();
let res;
try {
// 使用非流式请求:视觉分析响应短,且流式对推理模型(o1/o3/o4)和部分代理兼容性差
res = await postJSONNonStream(url, { Authorization: 'Bearer ' + (config.api_key || '') }, body, 120000);
} catch (httpErr) {
log.error('[Vision] HTTP 请求失败', { model, url: url.slice(0, 80), error: httpErr.message });
throw httpErr;
}
const content = res.body;
if (!content) {
log.error('[Vision] 返回内容为空', {
model,
status: res.status,
raw_response: (res.raw || '').slice(0, 300),
});
throw new Error(`AI vision 返回内容为空(HTTP ${res.status}),原始响应:${(res.raw || '').slice(0, 200)}`);
}
log.info('[Vision] 请求成功', { model, elapsed_ms: Date.now() - startMs, result_len: content.length, result_preview: content.slice(0, 100) });
return content.trim();
}
const EXTRACT_PROMPTS = {
character: {
// 强调"角色概念设计图"而非"真实人物照片",绕开人物识别安全策略
system: `你是一位专业的影视/动漫角色美术设计师,正在处理一批角色造型参考素材。
你收到的图片是用于角色设计的造型参考图(cosplay 造型图、服装搭配参考图或角色概念图),图中展示的是虚构角色的视觉造型,不涉及任何真实人物身份。
你的任务:从视觉设计角度,提取图中可见的造型要素,撰写一份角色设定文案,供 AI 图像生成使用。
请描述以下内容(只描述人物本身,忽略背景):
- 发型:发色(如深棕、黑色、浅金等)、发质感、发型款式(长短、层次、刘海、发尾走向)
- 五官:脸型轮廓(瓜子/方/圆/椭圆)、眉形、眼型与眼距、鼻型、唇型与唇色、整体肤色
- 体型:身形比例(高挑/中等/娇小)、体型特征(纤细/匀称/壮实)
- 服装:款式、颜色、材质、层次搭配
注意:如果你无法看清某些细节,请根据可见信息做合理推断,不要拒绝或道歉。
输出要求:150-250字,直接输出描述,不加标题序号,像一份角色设定稿。`,
user: (name) => `这是角色${name ? `"${name}"` : ''}的造型参考图,请提取图中的造型视觉要素,生成角色外貌设定文案(忽略背景)。`,
},
scene: {
system: '你是一位专业的影视场景美术设计师,擅长将参考图转化为 AI 图像生成所需的场景描述。请用中文描述图中的视觉元素:地点类型、光线色调、时间氛围、环境细节、空间构成。80-150字,直接输出描述,不要加标题或前缀。',
user: (name) => `这是场景${name ? `"${name}"` : ''}的参考图,请提取图中的场景视觉特征,生成可用于 AI 图生的场景描述文字。`,
},
prop: {
system: '你是一位专业的道具/产品视觉描述师,擅长将参考图转化为 AI 图像生成所需的道具描述。请用中文描述图中物品的视觉特征:类型、形状、颜色、材质质感、细节特征。80-150字,直接输出描述,不要加标题或前缀。',
user: (name) => `这是道具${name ? `"${name}"` : ''}的参考图,请提取图中物品的视觉特征,生成可用于 AI 图生的道具描述文字。`,
},
};
/**
* 从图片 URL 或 base64 data URL 中提取实体描述(不依赖已有实体 ID)。
* entityType: 'character' | 'scene' | 'prop'
* imageUrl: http URL 或 data:image/xxx;base64,... 格式的 data URL
*/
async function extractDescriptionFromImage(db, log, entityType, imageUrl, entityName) {
const prompts = EXTRACT_PROMPTS[entityType];
if (!prompts) throw new Error(`不支持的实体类型:${entityType}`);
let imageSource;
if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) {
imageSource = { imageUrl };
} else {
throw new Error('imageUrl 必须是 http URL 或 base64 data URL');
}
try {
const result = await generateTextWithVision(
db, log, 'text',
prompts.user(entityName),
prompts.system,
imageSource,
{ max_tokens: 2000 },
);
// 检测模型因安全策略拒绝描述真人的回答
if (isRefusalResponse(result)) {
log.warn('[Vision] 模型拒绝描述,可能因真实人物照片触发安全策略', { entity_type: entityType, result });
return { ok: false, error: '模型因安全策略拒绝描述图中人物面部特征。建议:①使用 Gemini 模型(限制较少);②手动填写外貌描述;③上传卡通/插画风格的参考图。' };
}
return { ok: true, description: result };
} catch (err) {
log.error('[Vision] extractDescriptionFromImage 失败', {
entity_type: entityType,
raw_error: err.message,
});
const errMsg = /image|vision|visual|multimodal/i.test(err.message)
? `AI 模型不支持图片识别,请在「AI 配置」中使用支持视觉的模型(如 GPT-4o、Gemini 1.5 等)【原始错误:${err.message.slice(0, 120)}`
: `AI 分析失败:${err.message}`;
return { ok: false, error: errMsg };
}
}
/** 检测模型是否因安全策略拒绝了描述请求 */
function isRefusalResponse(text) {
if (!text) return false;
const refusalPatterns = [
/无法识别.*人物/,
/无法.*识别.*特征/,
/无法.*分析.*人物/,
/无法.*描述.*人物/,
/抱歉.*无法.*识别/,
/cannot identify/i,
/can't identify/i,
/unable to identify/i,
];
return refusalPatterns.some(p => p.test(text));
}
module.exports = {
getDefaultConfig,
getConfigForModel,
getConfigFromModelMap,
generateText,
streamGenerateText,
generateTextWithVision,
resolveEntityImageSource,
extractDescriptionFromImage,
EXTRACT_PROMPTS,
isRefusalResponse,
postJSONWithTimeout,
};
@@ -0,0 +1,582 @@
// AI 配置 CRUD,与 Go application/services/ai_service.go 对齐
const fs = require('fs');
const path = require('path');
const { normalizeMaterialHubToken } = require('./jimengMaterialHubService');
function normalizeApiKeyForService(serviceType, apiKey) {
if (serviceType === 'jimeng2_character_auth' && apiKey != null) {
return normalizeMaterialHubToken(apiKey);
}
return apiKey;
}
const { applyDeepSeekConnectivityOptions } = require('./deepseekConfig');
function modelToDb(model) {
if (model == null) return null;
if (Array.isArray(model)) return JSON.stringify(model);
if (typeof model === 'string') return JSON.stringify([model]);
return JSON.stringify([]);
}
function modelFromDb(val) {
if (val == null || val === '') return [];
try {
const arr = JSON.parse(val);
return Array.isArray(arr) ? arr : [String(arr)];
} catch {
return [String(val)];
}
}
/** 每种服务类型只保留一个默认:若有多个 is_default=1,只保留优先级最高(同优先级取 id 最小)的那条 */
function ensureSingleDefaultPerType(db) {
const types = ['text', 'image', 'storyboard_image', 'video', 'tts', 'jimeng2_character_auth'];
for (const st of types) {
const rows = db.prepare(
'SELECT id, priority FROM ai_service_configs WHERE deleted_at IS NULL AND service_type = ? AND is_default = 1 ORDER BY priority DESC, id ASC'
).all(st);
if (rows.length <= 1) continue;
const keepId = rows[0].id;
db.prepare(
'UPDATE ai_service_configs SET is_default = 0 WHERE deleted_at IS NULL AND service_type = ? AND id != ?'
).run(st, keepId);
}
}
function listConfigs(db, serviceType) {
ensureSingleDefaultPerType(db);
const order = 'ORDER BY is_default DESC, priority DESC, created_at DESC';
let sql = 'SELECT * FROM ai_service_configs WHERE deleted_at IS NULL ' + order;
const params = [];
if (serviceType) {
sql = 'SELECT * FROM ai_service_configs WHERE deleted_at IS NULL AND service_type = ? ' + order;
params.push(serviceType);
}
const rows = params.length ? db.prepare(sql).all(...params) : db.prepare(sql).all();
return rows.map(rowToConfig);
}
function clearOtherDefault(db, serviceType, exceptId) {
const stmt = db.prepare(
'UPDATE ai_service_configs SET is_default = 0 WHERE deleted_at IS NULL AND service_type = ? AND id != ?'
);
stmt.run(serviceType, exceptId);
}
function getConfig(db, id) {
const row = db.prepare('SELECT * FROM ai_service_configs WHERE id = ? AND deleted_at IS NULL').get(id);
return row ? rowToConfig(row) : null;
}
function createConfig(db, log, req) {
const now = new Date().toISOString();
const model = modelToDb(req.model);
let endpoint = req.endpoint || '';
let queryEndpoint = req.query_endpoint || '';
if (!endpoint && req.provider) {
const p = req.provider.toLowerCase();
const st = (req.service_type || 'text').toLowerCase();
if (p === 'openai') {
if (st === 'text') endpoint = '/chat/completions';
else if (st === 'image') endpoint = '/images/generations';
else if (st === 'video') {
endpoint = '/videos';
queryEndpoint = '/videos/{taskId}';
}
} else if (p === 'gemini' || p === 'google') {
endpoint = '/v1beta/models/{model}:generateContent';
} else if (p === 'dashscope' || p === 'qwen_image') {
if (st === 'image' || st === 'storyboard_image') endpoint = '/api/v1/services/aigc/multimodal-generation/generation';
else if (st === 'video' && p === 'dashscope') {
endpoint = '/api/v1/services/aigc/image2video/video-synthesis';
queryEndpoint = '/api/v1/tasks/{taskId}';
}
} else if (p === 'volces' || p === 'volcengine' || p === 'volc') {
if (st === 'video') {
endpoint = '/contents/generations/tasks';
queryEndpoint = '/contents/generations/tasks/{taskId}';
} else if (st === 'image' || st === 'storyboard_image') {
endpoint = '/images/generations';
}
} else if (p === 'nano_banana') {
if (st === 'image' || st === 'storyboard_image') {
endpoint = '/api/v1/nanobanana/generate-2';
queryEndpoint = '/api/v1/nanobanana/record-info';
}
} else if (p === 'agnes') {
if (st === 'text') endpoint = '/chat/completions';
else if (st === 'image' || st === 'storyboard_image') endpoint = '/images/generations';
else if (st === 'video') {
endpoint = '/videos';
queryEndpoint = '/videos/{taskId}';
}
}
}
const defaultModel = req.default_model != null ? String(req.default_model).trim() || null : null;
const info = db.prepare(
`INSERT INTO ai_service_configs (service_type, provider, api_protocol, name, base_url, api_key, model, default_model, endpoint, query_endpoint, priority, is_default, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`
).run(
req.service_type || 'text',
req.provider || '',
req.api_protocol || '',
req.name || '',
req.base_url || '',
normalizeApiKeyForService(req.service_type, req.api_key || ''),
model,
defaultModel,
endpoint,
queryEndpoint,
req.priority ?? 0,
req.is_default ? 1 : 0,
req.settings || null,
now,
now
);
log.info('AI config created', { config_id: info.lastInsertRowid, provider: req.provider });
const newId = info.lastInsertRowid;
if (req.is_default) clearOtherDefault(db, req.service_type || 'text', newId);
return getConfig(db, newId);
}
function updateConfig(db, log, id, req) {
const existing = getConfig(db, id);
if (!existing) return null;
const updates = [];
const params = [];
if (req.name != null) {
updates.push('name = ?');
params.push(req.name);
}
if (req.provider != null) {
updates.push('provider = ?');
params.push(req.provider);
}
if (req.api_protocol != null) {
updates.push('api_protocol = ?');
params.push(req.api_protocol);
}
if (req.base_url != null) {
updates.push('base_url = ?');
params.push(req.base_url);
}
if (req.api_key != null) {
updates.push('api_key = ?');
const st = req.service_type != null ? req.service_type : existing.service_type;
params.push(normalizeApiKeyForService(st, req.api_key));
}
if (req.model != null) {
updates.push('model = ?');
params.push(modelToDb(req.model));
}
if (req.default_model !== undefined) {
updates.push('default_model = ?');
params.push(req.default_model != null ? String(req.default_model).trim() || null : null);
}
if (req.priority != null) {
updates.push('priority = ?');
params.push(req.priority);
}
if (req.endpoint !== undefined) {
updates.push('endpoint = ?');
params.push(req.endpoint || '');
}
if (req.query_endpoint !== undefined) {
updates.push('query_endpoint = ?');
params.push(req.query_endpoint || '');
}
if (req.settings != null) {
updates.push('settings = ?');
params.push(req.settings);
}
if (typeof req.is_default === 'boolean') {
updates.push('is_default = ?');
params.push(req.is_default ? 1 : 0);
}
if (typeof req.is_active === 'boolean') {
updates.push('is_active = ?');
params.push(req.is_active ? 1 : 0);
}
if (updates.length === 0) return existing;
params.push(new Date().toISOString(), id);
db.prepare('UPDATE ai_service_configs SET ' + updates.join(', ') + ', updated_at = ? WHERE id = ?').run(...params);
if (req.is_default === true) clearOtherDefault(db, existing.service_type, id);
log.info('AI config updated', { config_id: id });
return getConfig(db, id);
}
function deleteConfig(db, log, id) {
const now = new Date().toISOString();
const result = db.prepare('UPDATE ai_service_configs SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL').run(now, id);
if (result.changes === 0) return false;
log.info('AI config deleted', { config_id: id });
return true;
}
function rowToConfig(r) {
const cfg = {
id: r.id,
service_type: r.service_type,
provider: r.provider,
api_protocol: r.api_protocol || '',
name: r.name,
base_url: r.base_url,
api_key: r.api_key,
model: modelFromDb(r.model),
default_model: r.default_model ? String(r.default_model).trim() : null,
endpoint: r.endpoint,
query_endpoint: r.query_endpoint,
priority: r.priority ?? 0,
is_default: !!r.is_default,
is_active: r.is_active == null ? true : !!r.is_active,
settings: r.settings,
created_at: r.created_at,
updated_at: r.updated_at,
};
// TTS 配置:从 settings JSON 展开 voice_id / group_id 供 ttsService 直接读取
if (r.service_type === 'tts' && r.settings) {
try {
const s = JSON.parse(r.settings);
if (s.voice_id) cfg.voice_id = s.voice_id;
if (s.group_id) cfg.group_id = s.group_id;
} catch (_) {}
}
return cfg;
}
/**
* 测试连接:与 Go AIService.TestConnection 对齐,根据 provider 发最小请求验证 base_url + api_key
* @param opts { base_url, api_key, model (string|string[]), provider?, endpoint?, settings? }
* @returns Promise<void> 成功 resolve,失败 reject(error)
*/
async function testConnection(opts) {
const base = (opts.base_url || '').replace(/\/$/, '');
if (!base) throw new Error('base_url 必填');
if (!opts.api_key) throw new Error('api_key 必填');
const models = Array.isArray(opts.model) ? opts.model : opts.model != null ? [opts.model] : [];
const model = models[0] || '';
if (!model && (opts.provider === 'gemini' || opts.provider === 'google')) throw new Error('model 必填');
const provider = (opts.provider || 'openai').toLowerCase();
const serviceType = (opts.service_type || '').toLowerCase();
let endpoint = opts.endpoint || '';
// --- NanoBanana ---
if (provider === 'nano_banana') {
// 用 record-info 查询一个不存在的 taskId401/403=key 无效,404=key 有效已联通
const url = base + '/api/v1/nanobanana/record-info?taskId=test-connectivity';
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: 'Bearer ' + (opts.api_key || '') },
});
if (res.status === 401 || res.status === 403) {
const text = await res.text();
let errMsg = `API Key 无效 (${res.status})`;
try { const j = JSON.parse(text); errMsg = j.msg || j.message || errMsg; } catch {}
throw new Error(errMsg);
}
return;
}
// --- Gemini ---
if (provider === 'gemini' || provider === 'google') {
endpoint = endpoint || '/v1beta/models/{model}:generateContent';
const path = endpoint.replace(/{model}/g, model || 'gemini-pro');
const url = base + (path.startsWith('/') ? path : '/' + path) + '?key=' + encodeURIComponent(opts.api_key || '');
const body = { contents: [{ parts: [{ text: 'Hello' }] }] };
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`请求失败: ${res.status} ${text.slice(0, 200)}`);
}
const data = await res.json().catch(() => ({}));
if (data.candidates == null && data.error != null) {
throw new Error(data.error.message || data.error || 'Gemini 返回错误');
}
return;
}
// --- TTS 语音合成 ---
if (serviceType === 'tts') {
// MiniMax T2A:用 /v1/models 或直接对 chat 端点做轻量探针
const ttsBase = base.includes('minimaxi.com') || base.includes('minimax') ? base : base;
// 尝试调用一个极简的 MiniMax T2A 请求(1 字,验证 key 合法性)
// 为避免真实扣费,使用非计费的 list-voices 或 models 接口
const probeUrl = ttsBase + '/text_to_speech';
const probeBody = JSON.stringify({ model: model || 'speech-02-hd', text: 'hi', stream: false });
const res = await fetch(probeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (opts.api_key || '') },
body: probeBody,
});
if (res.status === 401 || res.status === 403) {
const text = await res.text();
let errMsg = `API Key 无效 (${res.status})`;
try { const j = JSON.parse(text); errMsg = j.base_resp?.status_msg || j.error?.message || j.message || errMsg; } catch {}
throw new Error(errMsg);
}
// 其他状态(400 缺参数、404 端点不对等)说明网络通、key 疑似有效
return;
}
// service_type 作为主要判断信号
const isImageService = serviceType === 'image' || serviceType === 'storyboard_image';
const isVideoService = serviceType === 'video';
const hasImageEndpoint = !!(endpoint && endpoint.includes('/images/'));
const isDashscope = provider === 'dashscope' || provider === 'qwen_image';
const isVolcengine = provider === 'volces' || provider === 'volcengine' || provider === 'volc';
const modelLower = model.toLowerCase();
// 兜底识别图片/视频模型(service_type 未传时使用)
const looksLikeImageModel = /seedream|image2video|text2image|img2img|wanx|wan\d|flux|stable.?diff|dall.?e|imagen|agnes-image|-image$/i.test(modelLower)
|| (isVolcengine && /seedream|vision|image/i.test(modelLower));
const looksLikeVideoModel = /seedance|video.?gen|video2video|kf2v|cogvideo|sora|kling|agnes-video/i.test(modelLower);
// DashScope 图片/视频专用端点特征
const isDashscopeNonChatEndpoint = isDashscope && !!(endpoint && (endpoint.includes('aigc') || endpoint.includes('multimodal') || endpoint.includes('video')));
// 综合判断是否为图片服务
const treatAsImage = isImageService || hasImageEndpoint || isDashscopeNonChatEndpoint
|| looksLikeImageModel
|| (isVolcengine && !serviceType && !endpoint);
// --- DashScope 图片 / 视频 / 分镜 ---
// 通义万象 / WAN 系列:API key 通过 compatible-mode chat 接口验证即可(同一 key 通用)
if (isDashscope && (isImageService || isVideoService || looksLikeImageModel || looksLikeVideoModel || isDashscopeNonChatEndpoint)) {
const chatUrl = base.replace(/\/(api\/v1|compatible-mode)\/.*$/, '') + '/compatible-mode/v1/chat/completions';
const body = { model: 'qwen-turbo', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 };
console.log('[testConnection] DashScope 非文本服务,用 compatible chat 验证 key', { chatUrl, serviceType, model });
const res = await fetch(chatUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + opts.api_key },
body: JSON.stringify(body),
});
// 401/403 = key 无效,其他均视为联通
if (res.status === 401 || res.status === 403) {
const text = await res.text();
let errMsg = `API Key 无效 (${res.status})`;
try { const j = JSON.parse(text); errMsg = j.error?.message || j.message || errMsg; } catch {}
throw new Error(errMsg);
}
return;
}
// --- 视频生成服务(非 DashScope):通过 chat/completions 验证 key 合法性 ---
// 视频生成 API 调用代价高昂,无法直接测试;但同账号 chat 接口验证 key 有效性即可
if (isVideoService || looksLikeVideoModel) {
const chatPath = '/chat/completions';
const url = base + chatPath;
const body = { model: model || '', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 };
console.log('[testConnection] 视频服务,用 chat/completions 验证 key', { url, serviceType, model });
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (opts.api_key || '') },
body: JSON.stringify(body),
});
// 401/403 = key 无效;其他(400 模型不存在等)视为联通
if (res.status === 401 || res.status === 403) {
const text = await res.text();
let errMsg = `API Key 无效 (${res.status})`;
try { const j = JSON.parse(text); errMsg = j.error?.message || j.message || errMsg; } catch {}
throw new Error(errMsg);
}
return;
}
// --- OpenAI 兼容图片生成(volcengine、OpenAI DALL·E、其他)---
if (treatAsImage) {
endpoint = endpoint || '/images/generations';
const path = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
const url = base + path;
const body = { model: model || '', prompt: 'test connectivity', n: 1 };
console.log('[testConnection] 图片服务', { url, serviceType, model, body });
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + (opts.api_key || ''),
},
body: JSON.stringify(body),
});
// 401/403 = key 无效;其他状态(含 400 参数错误、429 限流等)表示已联通
if (res.status === 401 || res.status === 403) {
const text = await res.text();
let errMsg = `API Key 无效 (${res.status})`;
try {
const j = JSON.parse(text);
errMsg = j.error?.message || j.message || errMsg;
} catch {}
throw new Error(errMsg);
}
if (!res.ok) {
// 其他 4xx/5xx:如果能解析出明确的 auth 错误才拒绝,否则视为联通
const text = await res.text();
let parsed = null;
try { parsed = JSON.parse(text); } catch {}
const msg = parsed?.error?.message || parsed?.message || '';
const lmsg = msg.toLowerCase();
const isAuthErr = lmsg.includes('unauthorized') || lmsg.includes('invalid api key')
|| lmsg.includes('authentication') || lmsg.includes('forbidden');
if (isAuthErr) throw new Error(`API Key 无效: ${msg || res.status}`);
// 其他错误(如模型不支持某个 API 参数)说明网络通、key 有效
return;
}
return;
}
// --- OpenAI / 默认:chat completions ---
endpoint = endpoint || '/chat/completions';
const path = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
const url = base + path;
let body = {
model: model || 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 5,
};
body = applyDeepSeekConnectivityOptions(
{ provider, base_url: base, settings: opts.settings },
body
);
console.log('[testConnection] 文本/chat 服务', { url, serviceType, model });
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + (opts.api_key || ''),
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
let errMsg = `请求失败: ${res.status}`;
try {
const j = JSON.parse(text);
errMsg += ' - ' + (j.error?.message || j.message || j.error || text.slice(0, 150));
} catch {
if (text) errMsg += ' - ' + text.slice(0, 150);
}
throw new Error(errMsg);
}
const data = await res.json().catch(() => ({}));
if (data.choices == null && data.error != null) {
throw new Error(data.error.message || data.error || '接口返回错误');
}
}
/**
* 返回 vendor_lock 状态
*/
function getVendorLockStatus(cfg) {
const lock = cfg?.vendor_lock;
return {
enabled: !!(lock?.enabled),
config_file: lock?.config_file || '',
};
}
/**
* 启动时同步 vendor_lock 指定的配置文件到数据库。
* - 软删除所有现有配置,按文件重新导入
* - 若同 service_type + provider 在 DB 中已有记录,则保留用户修改过的 api_key
*/
function applyVendorLock(db, log, cfg) {
const status = getVendorLockStatus(cfg);
if (!status.enabled) return;
const configFile = status.config_file;
if (!configFile) {
log.warn && log.warn('vendor_lock enabled but config_file is empty');
return;
}
const candidates = [
path.join(process.cwd(), 'configs', configFile),
path.join(__dirname, '..', '..', 'configs', configFile),
];
let raw = null;
for (const p of candidates) {
if (fs.existsSync(p)) { raw = fs.readFileSync(p, 'utf8'); break; }
}
if (!raw) {
console.warn('[vendor_lock] config file not found:', configFile);
return;
}
let configs;
try {
configs = JSON.parse(raw);
if (!Array.isArray(configs)) throw new Error('config file must be a JSON array');
} catch (e) {
console.error('[vendor_lock] failed to parse config file:', e.message);
return;
}
// 保存现有 api_keykey: "service_type:provider"
const existing = db.prepare('SELECT service_type, provider, api_key FROM ai_service_configs WHERE deleted_at IS NULL').all();
const savedKeys = new Map();
for (const row of existing) {
savedKeys.set(`${row.service_type}:${row.provider}`, row.api_key);
}
const now = new Date().toISOString();
db.prepare('UPDATE ai_service_configs SET deleted_at = ? WHERE deleted_at IS NULL').run(now);
for (const item of configs) {
const mapKey = `${item.service_type}:${item.provider}`;
const apiKey = savedKeys.get(mapKey) ?? item.api_key ?? '';
const model = Array.isArray(item.model)
? JSON.stringify(item.model)
: item.model ? JSON.stringify([item.model]) : '[]';
db.prepare(
`INSERT INTO ai_service_configs
(service_type, provider, api_protocol, name, base_url, api_key, model, default_model, endpoint, query_endpoint, priority, is_default, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`
).run(
item.service_type || 'text',
item.provider || '',
item.api_protocol || '',
item.name || '',
item.base_url || '',
apiKey,
model,
item.default_model || null,
item.endpoint || '',
item.query_endpoint || '',
item.priority ?? 0,
item.is_default ? 1 : 0,
item.settings || null,
now,
now
);
}
for (const item of configs) {
console.log(`[vendor_lock] loaded: service_type=${item.service_type} provider=${item.provider} api_protocol=${item.api_protocol || '(auto)'} endpoint=${item.endpoint || '(auto)'}`);
}
console.log(`[vendor_lock] synced ${configs.length} configs from ${configFile}`);
}
/**
* 批量替换所有配置的 api_key(仅限锁定模式下使用)
*/
function bulkUpdateApiKey(db, log, newKey) {
const now = new Date().toISOString();
const info = db.prepare(
'UPDATE ai_service_configs SET api_key = ?, updated_at = ? WHERE deleted_at IS NULL'
).run(newKey, now);
log.info('Bulk update api_key', { updated: info.changes });
return info.changes;
}
module.exports = {
listConfigs,
getConfig,
createConfig,
updateConfig,
deleteConfig,
testConnection,
getVendorLockStatus,
applyVendorLock,
bulkUpdateApiKey,
};
+451
View File
@@ -0,0 +1,451 @@
/**
* angleService.js
* 结构化视角服务:8方向 × 4俯仰 × 3景别 = 96种视角组合
* 每种组合生成精确的英文镜头描述片段,供图片生成 prompt 使用。
*
* 字段说明(storyboards 表扩展字段):
* angle_h TEXT 水平方向(front/front_left/left/back_left/back/back_right/right/front_right
* angle_v TEXT 俯仰角度(worm/low/eye_level/high
* angle_s TEXT 景别(close_up/medium/wide
*/
// ─── 枚举定义 ────────────────────────────────────────────────────────────────
/** 水平方向:8方向 */
const HORIZONTAL = {
front: 'front', // 正面
front_left: 'front_left', // 前左斜(45°)
left: 'left', // 正侧面
back_left: 'back_left', // 后左斜(135°)
back: 'back', // 正背面
back_right: 'back_right', // 后右斜
right: 'right', // 正右侧
front_right: 'front_right', // 前右斜
};
/** 俯仰角度:4等级 */
const ELEVATION = {
worm: 'worm', // 极低角度仰拍(虫眼视角)
low: 'low', // 低角度仰拍
eye_level: 'eye_level', // 平视
high: 'high', // 高角度俯拍(鸟瞰)
};
/** 景别:3等级 */
const SHOT_SIZE = {
close_up: 'close_up', // 近景/特写
medium: 'medium', // 中景
wide: 'wide', // 远景/全景
};
// ─── 英文 prompt 片段生成 ─────────────────────────────────────────────────────
/**
* 水平方向描述
*/
const HORIZONTAL_DESC = {
front: 'shooting from the front',
front_left: 'shooting from front-left at 45-degree angle',
left: 'shooting from the left side, profile view',
back_left: 'shooting from back-left at 135-degree angle',
back: 'shooting from behind, character\'s back to camera',
back_right: 'shooting from back-right at 135-degree angle',
right: 'shooting from the right side, profile view',
front_right: 'shooting from front-right at 45-degree angle',
};
/**
* 俯仰角度描述
*/
const ELEVATION_DESC = {
worm: 'extreme low-angle worm\'s eye view, camera near ground pointing sharply upward, strong upward perspective distortion, background shows sky/ceiling',
low: 'low-angle upward shot, camera below eye-line, slight upward tilt, empowering perspective',
eye_level: 'eye-level shot, neutral perspective, natural horizontal framing',
high: 'high-angle bird\'s eye view, camera above looking down, background shows floor/ground with downward perspective distortion',
};
/**
* 景别描述(含构图建议)
*/
const SHOT_SIZE_DESC = {
close_up: 'close-up shot (face/bust framing), subject fills most of frame, shallow depth of field, background softly blurred',
medium: 'medium shot (waist-up to full body), character and immediate surroundings visible, moderate depth of field',
wide: 'wide shot (full body with environment), subject small relative to scene, deep depth of field, environment context prominent',
};
/**
* 生成完整的镜头描述英文片段
* @param {string} h - 水平方向(HORIZONTAL 枚举值)
* @param {string} v - 俯仰角度(ELEVATION 枚举值)
* @param {string} s - 景别(SHOT_SIZE 枚举值)
* @returns {string} 英文 prompt 片段
*/
function toPromptFragment(h, v, s) {
const hDesc = HORIZONTAL_DESC[h] || HORIZONTAL_DESC.front;
const vDesc = ELEVATION_DESC[v] || ELEVATION_DESC.eye_level;
const sDesc = SHOT_SIZE_DESC[s] || SHOT_SIZE_DESC.medium;
return `${sDesc}, ${vDesc}, ${hDesc}`;
}
// ─── 旧文本解析(向后兼容) ────────────────────────────────────────────────────
/**
* 中文关键字 → 枚举值映射(宽松匹配)
*/
const ZH_H_MAP = [
{ keys: ['背后', '背面', '从背', 'back'], val: 'back' },
{ keys: ['前左', '左前', 'front-left', 'front_left'], val: 'front_left' },
{ keys: ['前右', '右前', 'front-right', 'front_right'], val: 'front_right' },
{ keys: ['左侧', '正侧', '侧面', 'side', 'left'], val: 'left' },
{ keys: ['右侧', 'right'], val: 'right' },
{ keys: ['后左', '左后', 'back-left', 'back_left'], val: 'back_left' },
{ keys: ['后右', '右后', 'back-right', 'back_right'], val: 'back_right' },
{ keys: ['正面', '前方', '面向', 'front'], val: 'front' },
];
const ZH_V_MAP = [
{ keys: ['虫眼', '极低', 'worm'], val: 'worm' },
{ keys: ['仰', 'low angle', 'low-angle'], val: 'low' },
{ keys: ['俯', 'high angle', 'bird'], val: 'high' },
{ keys: ['平视', 'eye-level', 'eye level'], val: 'eye_level' },
];
const ZH_S_MAP = [
{ keys: ['特写', '近景', 'close'], val: 'close_up' },
{ keys: ['全景', '远景', '大全', 'wide', 'long shot', 'establishing'], val: 'wide' },
{ keys: ['中景', '半身', 'medium'], val: 'medium' },
];
function matchMap(text, map) {
const t = text.toLowerCase();
for (const entry of map) {
if (entry.keys.some(k => t.includes(k.toLowerCase()))) {
return entry.val;
}
}
return null;
}
/**
* 从旧版自由文本的 angle 字段解析出结构化三元组
* @param {string} angleText - 旧 angle 字段值(如 "俯拍中景"、"side low"
* @param {string} shotType - 旧 shot_type 字段值(可辅助景别判断)
* @returns {{ h: string, v: string, s: string }}
*/
function parseFromLegacyText(angleText, shotType = '') {
const combined = `${angleText || ''} ${shotType || ''}`;
const h = matchMap(combined, ZH_H_MAP) || 'front';
const v = matchMap(combined, ZH_V_MAP) || 'eye_level';
const s = matchMap(combined, ZH_S_MAP) || 'medium';
return { h, v, s };
}
/**
* 从旧版 angle 文本直接生成完整英文 prompt 片段(快捷方法)
* @param {string} angleText
* @param {string} shotType
* @returns {string}
*/
function fromLegacyText(angleText, shotType = '') {
const { h, v, s } = parseFromLegacyText(angleText, shotType);
return toPromptFragment(h, v, s);
}
/**
* 将结构化 angle 三元组转换为简短中文标签(用于前端展示)
* @param {string} h
* @param {string} v
* @param {string} s
* @returns {string}
*/
function toChineseLabel(h, v, s) {
const hLabel = { front:'正面', front_left:'前左', left:'左侧', back_left:'后左', back:'背面', back_right:'后右', right:'右侧', front_right:'前右' }[h] || '正面';
const vLabel = { worm:'虫眼仰', low:'仰拍', eye_level:'平视', high:'俯拍' }[v] || '平视';
const sLabel = { close_up:'特写', medium:'中景', wide:'远景' }[s] || '中景';
return `${sLabel}·${vLabel}·${hLabel}`;
}
/**
* 列出所有 96 种视角组合(用于管理后台展示)
* @returns {Array<{ h, v, s, label, prompt_fragment }>}
*/
function listAllAngles() {
const result = [];
for (const h of Object.values(HORIZONTAL)) {
for (const v of Object.values(ELEVATION)) {
for (const s of Object.values(SHOT_SIZE)) {
result.push({
h, v, s,
label: toChineseLabel(h, v, s),
prompt_fragment: toPromptFragment(h, v, s),
});
}
}
}
return result;
}
// ─── 镜头运动 ────────────────────────────────────────────────────────────────
/** 镜头运动枚举值 */
const MOVEMENT = {
static: 'static', // 固定
push: 'push', // 推镜
pull: 'pull', // 拉镜
pan: 'pan', // 横摇
tilt: 'tilt', // 纵摇(上下摇)
tracking: 'tracking', // 跟镜
crane_up: 'crane_up', // 升镜
crane_dn: 'crane_dn', // 降镜
orbit: 'orbit', // 环绕
handheld: 'handheld', // 手持
};
/** 镜头运动 → 英文 prompt */
const MOVEMENT_DESC = {
static: 'static locked shot, no camera movement, tripod-mounted',
push: 'slow push-in dolly shot, camera gradually moves closer to subject',
pull: 'pull-back dolly shot, camera gradually moves away from subject',
pan: 'horizontal pan shot, camera sweeps laterally from side to side',
tilt: 'vertical tilt shot, camera pivots up or down',
tracking: 'tracking shot, camera follows subject movement, smooth motion',
crane_up: 'crane up shot, camera rises vertically, revealing wider scene',
crane_dn: 'crane down shot, camera descends vertically',
orbit: 'orbiting arc shot, camera circles around subject',
handheld: 'handheld shot, subtle natural camera shake, documentary feel',
};
/** 中文关键字 → movement 枚举 */
const ZH_MOVEMENT_MAP = [
{ keys: ['固定', '不动', 'static', 'locked'], val: 'static' },
{ keys: ['推镜', '推进', '推', 'push in', 'dolly in', 'push'], val: 'push' },
{ keys: ['拉镜', '拉出', '拉', 'pull back', 'dolly out', 'pull'], val: 'pull' },
{ keys: ['横移', '横摇', '摇镜', '摇', 'pan'], val: 'pan' },
{ keys: ['纵摇', '上摇', '下摇', 'tilt'], val: 'tilt' },
{ keys: ['跟镜', '跟拍', '跟随', 'track'], val: 'tracking' },
{ keys: ['升镜', '向上', 'crane up'], val: 'crane_up' },
{ keys: ['降镜', '向下', 'crane down'], val: 'crane_dn' },
{ keys: ['环绕', '绕', 'orbit', 'arc'], val: 'orbit' },
{ keys: ['手持', 'handheld'], val: 'handheld' },
];
/**
* 将中文运动描述或枚举值转换为英文 prompt 片段
* @param {string} movement - 中文或枚举值
* @returns {string|null}
*/
function movementToPrompt(movement) {
if (!movement) return null;
const m = String(movement).trim();
// 先尝试直接枚举匹配
if (MOVEMENT_DESC[m]) return MOVEMENT_DESC[m];
// 再尝试中文关键字匹配
const lower = m.toLowerCase();
for (const entry of ZH_MOVEMENT_MAP) {
if (entry.keys.some(k => lower.includes(k.toLowerCase()))) {
return MOVEMENT_DESC[entry.val] || null;
}
}
return null;
}
// ─── 灯光风格 ────────────────────────────────────────────────────────────────
/** 灯光风格枚举值 */
const LIGHTING = {
natural: 'natural', // 自然光
front: 'front', // 顺光
side: 'side', // 侧光
backlit: 'backlit', // 逆光
top: 'top', // 顶光
under: 'under', // 底光
soft: 'soft', // 柔光
dramatic: 'dramatic', // 戏剧光(明暗对比)
golden_hour: 'golden_hour', // 黄金时段
blue_hour: 'blue_hour', // 蓝调时刻
night: 'night', // 夜景/低调光
neon: 'neon', // 霓虹/赛博朋克
};
/** 灯光风格 → 英文 prompt */
const LIGHTING_DESC = {
natural: 'natural ambient lighting, soft and even illumination',
front: 'flat front lighting, even illumination, minimal shadows',
side: 'dramatic side lighting, strong contrast between light and shadow',
backlit: 'backlit, rim lighting, subject silhouetted with halo edge light',
top: 'harsh overhead top lighting, strong downward shadows',
under: 'unsettling underlighting, upward low-key light source',
soft: 'soft diffused lighting, gentle shadows, flattering luminosity',
dramatic: 'high contrast chiaroscuro lighting, deep shadows, cinematic noir',
golden_hour: 'warm golden hour sunlight, long low shadows, amber glow',
blue_hour: 'cool blue hour twilight, moody atmospheric dusk light',
night: 'low key night lighting, isolated artificial light sources, deep shadows',
neon: 'vivid neon lighting, colored artificial lights, cyberpunk atmosphere',
};
/** 灯光中文 → 枚举 */
const ZH_LIGHTING_MAP = [
{ keys: ['自然光', '日光', 'natural'], val: 'natural' },
{ keys: ['顺光', '正面光', 'front light'], val: 'front' },
{ keys: ['侧光', 'side light'], val: 'side' },
{ keys: ['逆光', '背光', 'backlit', 'back light'], val: 'backlit' },
{ keys: ['顶光', '头顶光', 'top light'], val: 'top' },
{ keys: ['底光', '脚灯', 'under light'], val: 'under' },
{ keys: ['柔光', '散射', 'soft light'], val: 'soft' },
{ keys: ['戏剧', '明暗', '强对比', 'dramatic', 'chiaroscuro'], val: 'dramatic' },
{ keys: ['黄金时段', '黄昏', '金色光', 'golden hour'], val: 'golden_hour' },
{ keys: ['蓝调', '傍晚', 'blue hour'], val: 'blue_hour' },
{ keys: ['夜景', '夜晚', '低调', 'night'], val: 'night' },
{ keys: ['霓虹', '赛博', 'neon', 'cyberpunk'], val: 'neon' },
];
function lightingToPrompt(lighting) {
if (!lighting) return null;
const l = String(lighting).trim();
if (LIGHTING_DESC[l]) return LIGHTING_DESC[l];
const lower = l.toLowerCase();
for (const entry of ZH_LIGHTING_MAP) {
if (entry.keys.some(k => lower.includes(k.toLowerCase()))) {
return LIGHTING_DESC[entry.val] || null;
}
}
return null;
}
// ─── 景深 ─────────────────────────────────────────────────────────────────────
/** 景深枚举值 */
const DEPTH_OF_FIELD = {
extreme_shallow: 'extreme_shallow', // 极浅景深
shallow: 'shallow', // 浅景深
medium: 'medium', // 中景深
deep: 'deep', // 深景深(全焦)
};
/** 景深 → 英文 prompt */
const DOF_DESC = {
extreme_shallow: 'extreme shallow depth of field, razor-thin focus plane, heavy creamy bokeh background',
shallow: 'shallow depth of field, subject in sharp focus, background softly blurred with bokeh',
medium: 'moderate depth of field, subject and near surroundings in focus',
deep: 'deep focus, everything sharp from foreground to background, wide depth of field',
};
/** 景深中文 → 枚举 */
const ZH_DOF_MAP = [
{ keys: ['极浅', '大光圈', 'extreme shallow', 'razor thin'], val: 'extreme_shallow' },
{ keys: ['浅景深', '浅', 'shallow', 'bokeh'], val: 'shallow' },
{ keys: ['中景深', '适中', 'medium dof'], val: 'medium' },
{ keys: ['深景深', '全焦', '超焦', 'deep focus', 'deep dof'], val: 'deep' },
];
function dofToPrompt(dof) {
if (!dof) return null;
const d = String(dof).trim();
if (DOF_DESC[d]) return DOF_DESC[d];
const lower = d.toLowerCase();
for (const entry of ZH_DOF_MAP) {
if (entry.keys.some(k => lower.includes(k.toLowerCase()))) {
return DOF_DESC[entry.val] || null;
}
}
return null;
}
/**
* 生成完整摄影参数描述(景别/俯仰/方向 + 运动 + 灯光 + 景深)
*/
function toCinematicFragment(h, v, s, movement, lighting, dof) {
const parts = [toPromptFragment(h, v, s)];
const mvDesc = movementToPrompt(movement);
const ltDesc = lightingToPrompt(lighting);
const dofDesc = dofToPrompt(dof);
if (mvDesc) parts.push(mvDesc);
if (ltDesc) parts.push(ltDesc);
if (dofDesc) parts.push(dofDesc);
return parts.join(', ');
}
// ─── 快速推断(无 AI,基于现有字段关键字匹配)────────────────────────────────
/**
* 从分镜已有字段快速推断 movement / lighting_style / depth_of_field
* 用于老分镜批量补全,以及新分镜 AI 未输出该字段时的兜底。
* @param {object} sb - 分镜对象(含 atmosphere, time, angle_s, shot_type, movement 等)
* @returns {{ movement: string|null, lighting_style: string|null, depth_of_field: string|null }}
*/
function inferPhotographyParams(sb) {
const atm = (sb.atmosphere || '').toLowerCase();
const time = (sb.time || '').toLowerCase();
const desc = (sb.description || '').toLowerCase();
const action = (sb.action || '').toLowerCase();
const combined = `${atm} ${time} ${desc} ${action}`;
// ── 灯光推断(按优先级排列)──
let lighting = null;
if (/霓虹|赛博|neon|cyberpunk/.test(combined)) lighting = 'neon';
else if (/逆光|背光|backlit|轮廓光|rim light/.test(combined)) lighting = 'backlit';
else if (/戏剧|明暗|强对比|chiaroscuro|dramatic|noir/.test(combined)) lighting = 'dramatic';
else if (/黄金时段|黄昏|金色光|夕阳|落日|golden/.test(combined)) lighting = 'golden_hour';
else if (/蓝调|蓝光|暮色|blue hour|twilight/.test(combined)) lighting = 'blue_hour';
else if (/夜晚|夜景|深夜|午夜|night/.test(combined)) lighting = 'night';
else if (/顶光|头顶|top light/.test(combined)) lighting = 'top';
else if (/底光|脚灯|underlight/.test(combined)) lighting = 'under';
else if (/侧光|side light|侧面光/.test(combined)) lighting = 'side';
else if (/柔光|散射|soft light|soft/.test(combined)) lighting = 'soft';
else if (/顺光|正面光|front light/.test(combined)) lighting = 'front';
else if (/自然光|日光|阳光|natural light|sunlight/.test(combined)) lighting = 'natural';
else if (/白天|清晨|午后|daytime|morning|afternoon/.test(combined)) lighting = 'natural';
// ── 景深推断(依据景别)──
let dof = null;
const angleS = sb.angle_s || '';
const shotType = (sb.shot_type || '').toLowerCase();
if (angleS === 'close_up' || /特写|close.?up|extreme close/.test(shotType)) {
dof = 'shallow';
} else if (angleS === 'wide' || /大远景|远景|long shot|wide shot/.test(shotType)) {
dof = 'deep';
} else if (angleS === 'medium' || /中景|medium shot/.test(shotType)) {
dof = 'medium';
}
// ── 运镜推断(从 movement 中文兜底到枚举)──
let movement = null;
const rawMovement = (sb.movement || '').trim();
if (rawMovement) {
// 已经是枚举值直接用,否则尝试中文映射
movement = MOVEMENT_DESC[rawMovement] ? rawMovement : null;
if (!movement) {
const lower = rawMovement.toLowerCase();
for (const entry of ZH_MOVEMENT_MAP) {
if (entry.keys.some(k => lower.includes(k.toLowerCase()))) {
movement = entry.val;
break;
}
}
}
if (!movement) movement = rawMovement; // 保留原始中文,生图时动态翻译
}
return { movement: movement || null, lighting_style: lighting, depth_of_field: dof };
}
module.exports = {
HORIZONTAL,
ELEVATION,
SHOT_SIZE,
MOVEMENT,
LIGHTING,
DEPTH_OF_FIELD,
toPromptFragment,
toCinematicFragment,
movementToPrompt,
lightingToPrompt,
dofToPrompt,
inferPhotographyParams,
parseFromLegacyText,
fromLegacyText,
toChineseLabel,
listAllAngles,
};
+125
View File
@@ -0,0 +1,125 @@
function list(db, query) {
let sql = 'FROM assets WHERE deleted_at IS NULL';
const params = [];
if (query.drama_id) {
sql += ' AND drama_id = ?';
params.push(query.drama_id);
}
if (query.type) {
sql += ' AND type = ?';
params.push(query.type);
}
const countRow = db.prepare('SELECT COUNT(*) as total ' + sql).get(...params);
const total = countRow.total || 0;
const page = Math.max(1, parseInt(query.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(query.page_size, 10) || 20));
const offset = (page - 1) * pageSize;
const rows = db.prepare('SELECT * ' + sql + ' ORDER BY created_at DESC LIMIT ? OFFSET ?').all(...params, pageSize, offset);
return { items: rows.map(rowToItem), total, page, pageSize };
}
function rowToItem(r) {
return {
id: r.id,
drama_id: r.drama_id,
name: r.name,
type: r.type,
category: r.category,
url: r.url,
local_path: r.local_path,
duration: r.duration,
image_gen_id: r.image_gen_id,
video_gen_id: r.video_gen_id,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function getById(db, id) {
const r = db.prepare('SELECT * FROM assets WHERE id = ? AND deleted_at IS NULL').get(Number(id));
return r ? rowToItem(r) : null;
}
function create(db, log, req) {
const now = new Date().toISOString();
const info = db.prepare(
`INSERT INTO assets (drama_id, name, type, category, url, local_path, file_size, mime_type, width, height, duration, image_gen_id, video_gen_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
req.drama_id ?? null,
req.name || '未命名',
req.type || 'image',
req.category ?? null,
req.url || '',
req.local_path ?? null,
req.file_size ?? null,
req.mime_type ?? null,
req.width ?? null,
req.height ?? null,
req.duration ?? null,
req.image_gen_id ?? null,
req.video_gen_id ?? null,
now,
now
);
return getById(db, info.lastInsertRowid);
}
function update(db, log, id, req) {
const row = db.prepare('SELECT id FROM assets WHERE id = ? AND deleted_at IS NULL').get(Number(id));
if (!row) return null;
const updates = [];
const params = [];
['name', 'description', 'type', 'category', 'url', 'local_path', 'thumbnail_url', 'file_size', 'mime_type', 'width', 'height', 'duration', 'is_favorite'].forEach((key) => {
if (req[key] !== undefined) {
updates.push(key + ' = ?');
params.push(req[key]);
}
});
if (updates.length === 0) return getById(db, id);
params.push(new Date().toISOString(), id);
db.prepare('UPDATE assets SET ' + updates.join(', ') + ', updated_at = ? WHERE id = ?').run(...params);
return getById(db, id);
}
function deleteById(db, log, id) {
const now = new Date().toISOString();
const result = db.prepare('UPDATE assets SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL').run(now, Number(id));
return result.changes > 0;
}
function importFromImage(db, log, imageGenId) {
const img = db.prepare('SELECT * FROM image_generations WHERE id = ? AND deleted_at IS NULL').get(Number(imageGenId));
if (!img) return null;
return create(db, log, {
drama_id: img.drama_id,
name: `图片 ${imageGenId}`,
type: 'image',
url: img.image_url || '',
local_path: img.local_path,
image_gen_id: img.id,
});
}
function importFromVideo(db, log, videoGenId) {
const vid = db.prepare('SELECT * FROM video_generations WHERE id = ? AND deleted_at IS NULL').get(Number(videoGenId));
if (!vid) return null;
return create(db, log, {
drama_id: vid.drama_id,
name: `视频 ${videoGenId}`,
type: 'video',
url: vid.video_url || '',
local_path: vid.local_path,
video_gen_id: vid.id,
});
}
module.exports = {
list,
getById,
create,
update,
deleteById,
importFromImage,
importFromVideo,
};
@@ -0,0 +1,198 @@
// 与 Go ImageGenerationService.ExtractBackgroundsForEpisode + processBackgroundExtraction 对齐
const taskService = require('./taskService');
const aiClient = require('./aiClient');
const promptI18n = require('./promptI18n');
const sceneService = require('./sceneService');
const { safeParseAIJSON, extractFirstArray } = require('../utils/safeJson');
function normalizeLanguage(language) {
const lang = (language || '').toString().trim().toLowerCase();
return lang === 'zh' || lang === 'en' ? lang : '';
}
function hasChinese(text) {
return /[\u4e00-\u9fff]/.test(text || '');
}
function withLanguage(cfg, language) {
if (!language) return cfg;
return {
...cfg,
app: { ...(cfg?.app || {}), language },
};
}
async function translatePromptToChinese(db, log, model, prompt) {
const userPrompt =
'请将以下场景图像提示词翻译为中文,保留风格词或比例(如 realistic、16:9)原样,直接返回翻译后的中文提示词,不要解释:\n' +
prompt;
const text = await aiClient.generateText(db, log, 'text', userPrompt, '', {
scene_key: 'scene_extraction',
model: model || undefined,
temperature: 0.2,
max_tokens: 400,
});
return (text || '').toString().trim();
}
async function extractBackgroundsFromScript(db, cfg, log, scriptContent, dramaId, model, style) {
if (!scriptContent || !scriptContent.trim()) return [];
const systemPrompt = promptI18n.getSceneExtractionPrompt(cfg, style);
const prompt = (promptI18n.getLanguage(cfg) === 'en' ? '[Script Content]\n' : '【剧本内容】\n') + scriptContent;
console.log('systemPrompt', systemPrompt);
console.log('prompt', prompt);
const text = await aiClient.generateText(db, log, 'text', prompt, systemPrompt, { scene_key: 'scene_extraction', model: model || undefined, temperature: 0.7 });
let list = [];
try {
const parsed = safeParseAIJSON(text, log);
list = extractFirstArray(parsed) || [];
} catch (_) {
list = [];
}
return list.map((b) => ({
location: b.location || '',
time: b.time || '',
prompt: b.prompt || '',
atmosphere: b.atmosphere,
}));
}
async function processBackgroundExtraction(db, cfg, log, taskID, episodeId, model, style, language) {
taskService.updateTaskStatus(db, taskID, 'processing', 0, '正在提取场景信息...');
const episode = db.prepare('SELECT id, drama_id, script_content FROM episodes WHERE id = ? AND deleted_at IS NULL').get(Number(episodeId));
if (!episode) {
taskService.updateTaskStatus(db, taskID, 'failed', 0, '剧集信息不存在');
return;
}
const scriptContent = episode.script_content;
if (!scriptContent || !String(scriptContent).trim()) {
taskService.updateTaskStatus(db, taskID, 'failed', 0, '剧本内容为空');
return;
}
// 合并风格:显式 style 参数优先(一般为前端传来的英文 prompt);否则用剧集 metadata 中的完整提示词
let effectiveCfg = cfg;
try {
const dramaRow = db.prepare('SELECT style, metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(episode.drama_id);
const { mergeCfgStyleWithDrama } = require('../utils/dramaStyleMerge');
const paramStyle = (style && String(style).trim()) || '';
let next = { ...cfg, style: { ...(cfg?.style || {}) } };
if (dramaRow?.metadata) {
const meta = typeof dramaRow.metadata === 'string' ? JSON.parse(dramaRow.metadata) : dramaRow.metadata;
if (meta?.aspect_ratio) next.style.default_image_ratio = meta.aspect_ratio;
}
if (paramStyle) {
next.style = {
...next.style,
default_style_zh: paramStyle,
default_style_en: paramStyle,
default_style: paramStyle,
};
effectiveCfg = next;
} else {
effectiveCfg = mergeCfgStyleWithDrama(next, dramaRow);
}
style = paramStyle || effectiveCfg?.style?.default_style_en || effectiveCfg?.style?.default_style || style;
} catch (_) {}
const requestedLanguage = normalizeLanguage(language);
const configuredLanguage = normalizeLanguage(promptI18n.getLanguage(effectiveCfg));
let effectiveLanguage = requestedLanguage || configuredLanguage;
if (!requestedLanguage && effectiveLanguage === 'en' && hasChinese(scriptContent)) {
effectiveLanguage = 'zh';
}
const cfgForPrompt = withLanguage(effectiveCfg, effectiveLanguage);
let backgroundsInfo;
try {
backgroundsInfo = await extractBackgroundsFromScript(
db,
cfgForPrompt, // 已包含 effectiveCfg + language
log,
String(scriptContent),
episode.drama_id,
model,
style // 作为 prompt 追加(extractBackgroundsFromScript 内部会用到)
);
} catch (err) {
log.error('Background extraction AI failed', { error: err.message, task_id: taskID });
taskService.updateTaskStatus(db, taskID, 'failed', 0, 'AI提取场景失败: ' + err.message);
return;
}
if (effectiveLanguage === 'zh') {
const translated = await Promise.all(
(backgroundsInfo || []).map(async (bg) => {
const original = (bg.prompt || '').toString().trim();
if (!original || hasChinese(original)) return bg;
try {
const translatedPrompt = await translatePromptToChinese(db, log, model, original);
if (!translatedPrompt) return bg;
return { ...bg, prompt: translatedPrompt };
} catch (err) {
log.warn('Background prompt translate failed', { error: err.message, task_id: taskID });
return bg;
}
})
);
backgroundsInfo = translated;
}
sceneService.deleteScenesByEpisodeId(db, log, episodeId);
const scenes = [];
for (const bg of backgroundsInfo) {
const scene = sceneService.createSceneForEpisode(db, log, episode.drama_id, episodeId, {
location: bg.location,
time: bg.time,
prompt: bg.prompt,
});
if (scene) {
scenes.push(scene);
// polished_prompt 是完整四视图图片提示词,提取后始终为空,需要异步预生成
if (effectiveCfg) {
const capturedStyle = style;
setImmediate(() => {
sceneService.generateScenePromptOnly(db, log, effectiveCfg, scene.id, undefined, capturedStyle).catch((err) => {
log.warn('[提取场景] 预生成polished_prompt失败', { scene_id: scene.id, error: err.message });
});
});
}
}
}
taskService.updateTaskResult(db, taskID, {
scenes,
count: scenes.length,
episode_id: episodeId,
drama_id: episode.drama_id,
});
log.info('Background extraction completed', { task_id: taskID, episode_id: episodeId, count: scenes.length });
}
function extractBackgroundsForEpisode(db, cfg, log, episodeId, model, style, language) {
const episode = db.prepare('SELECT id, drama_id, script_content FROM episodes WHERE id = ? AND deleted_at IS NULL').get(Number(episodeId));
if (!episode) throw new Error('episode not found');
if (!episode.script_content || !String(episode.script_content).trim()) {
throw new Error('episode has no script content');
}
// 读取项目的 aspect_ratio,覆盖全局 cfg 中的 default_image_ratio,使 promptI18n 生成正确比例的提示词
let runCfg = cfg;
if (episode.drama_id) {
try {
const dramaRow = db.prepare('SELECT metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(episode.drama_id);
if (dramaRow && dramaRow.metadata) {
const meta = typeof dramaRow.metadata === 'string' ? JSON.parse(dramaRow.metadata) : dramaRow.metadata;
if (meta && meta.aspect_ratio) {
runCfg = { ...cfg, style: { ...(cfg?.style || {}), default_image_ratio: meta.aspect_ratio } };
}
}
} catch (_) {}
}
const task = taskService.createTask(db, log, 'background_extraction', String(episodeId));
setImmediate(() => {
processBackgroundExtraction(db, runCfg, log, task.id, episodeId, model, style, language).catch((err) => {
log.error('processBackgroundExtraction fatal', { error: err.message, task_id: task.id });
});
});
return task.id;
}
module.exports = {
extractBackgroundsForEpisode,
};
@@ -0,0 +1,213 @@
const taskService = require('./taskService');
const aiClient = require('./aiClient');
const promptI18n = require('./promptI18n');
const { safeParseAIJSON, extractFirstArray } = require('../utils/safeJson');
const characterLibraryService = require('./characterLibraryService');
const { mergeCfgStyleWithDrama } = require('../utils/dramaStyleMerge');
/**
* 从角色外貌描述中提炼 6层视觉锚点,写入 characters.identity_anchors
* 异步后台执行,不阻塞角色生成主流程
*/
async function enrichIdentityAnchors(db, log, characterId, appearance) {
if (!appearance || !String(appearance).trim()) return;
try {
const systemPrompt = promptI18n.getIdentityAnchorsPrompt();
const userPrompt = `Character appearance description:\n${appearance}`;
const raw = await aiClient.generateText(db, log, 'text', userPrompt, systemPrompt, {
scene_key: 'identity_anchors',
max_tokens: 800,
temperature: 0.1,
});
const anchors = safeParseAIJSON(raw, log);
if (!anchors || typeof anchors !== 'object') return;
const colorPalette = anchors.color_anchors ? JSON.stringify(Object.values(anchors.color_anchors)) : null;
db.prepare(
'UPDATE characters SET identity_anchors = ?, color_palette = ?, updated_at = ? WHERE id = ?'
).run(JSON.stringify(anchors), colorPalette, new Date().toISOString(), characterId);
log.info('[锚点] identity_anchors 提炼完成', { character_id: characterId });
} catch (err) {
log.warn('[锚点] identity_anchors 提炼失败', { character_id: characterId, error: err.message });
}
}
async function processCharacterGeneration(db, cfg, log, taskID, req) {
taskService.updateTaskStatus(db, taskID, 'processing', 0, '正在生成角色...');
let outlineText = req.outline || '';
// 读取剧的 style 和 metadata.aspect_ratio,覆盖全局 cfg
let effectiveCfg = cfg;
const dramaRow = db.prepare('SELECT id, title, description, genre, style, metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(Number(req.drama_id));
if (!dramaRow) {
taskService.updateTaskStatus(db, taskID, 'failed', 0, '剧本信息不存在');
return;
}
try {
let next = { ...cfg, style: { ...(cfg?.style || {}) } };
if (dramaRow.metadata) {
const meta = typeof dramaRow.metadata === 'string' ? JSON.parse(dramaRow.metadata) : dramaRow.metadata;
if (meta && meta.aspect_ratio) {
next.style.default_image_ratio = meta.aspect_ratio;
}
}
effectiveCfg = mergeCfgStyleWithDrama(next, dramaRow);
} catch (_) {}
if (!outlineText) {
outlineText = promptI18n.formatUserPrompt(
effectiveCfg,
'drama_info_template',
dramaRow.title || '',
dramaRow.description || '',
dramaRow.genre || ''
);
}
const userPrompt = promptI18n.formatUserPrompt(effectiveCfg, 'character_request', outlineText);
const systemPrompt = promptI18n.getCharacterExtractionPrompt(effectiveCfg);
const temperature = req.temperature != null ? req.temperature : 0.7;
// 固定 6000 tokens:足够约 10-12 个角色(每角色约 400-500 tokens
// repairTruncatedJsonArray 兜底处理极端截断情况
const maxTokensForChars = 6000;
let text;
try {
text = await aiClient.generateText(db, log, 'text', userPrompt, systemPrompt, {
scene_key: 'role_extraction',
model: req.model || undefined,
temperature,
max_tokens: maxTokensForChars,
});
} catch (err) {
log.error('Character generation AI failed', { error: err.message, task_id: taskID });
taskService.updateTaskStatus(db, taskID, 'failed', 0, 'AI生成失败: ' + err.message);
return;
}
console.log('[角色生成] AI 原始返回:\n' + text);
let result;
try {
const parsed = safeParseAIJSON(text, log);
result = extractFirstArray(parsed) || [];
} catch (err) {
log.error('Character generation parse failed', { error: err.message, task_id: taskID });
console.error('[角色生成] JSON解析失败,原始内容:\n' + text);
taskService.updateTaskStatus(db, taskID, 'failed', 0, '解析AI返回结果失败');
return;
}
const dramaId = Number(req.drama_id);
const now = new Date().toISOString();
// 再次「从剧本提取角色」时先清空本集已关联角色,避免与旧数据累加;仅软删除不再被任何分集引用的角色行
if (req.episode_id) {
const episodeId = Number(req.episode_id);
const linkedRows = db.prepare('SELECT character_id FROM episode_characters WHERE episode_id = ?').all(episodeId);
for (const row of linkedRows) {
const cid = Number(row.character_id);
const other = db
.prepare('SELECT COUNT(*) AS n FROM episode_characters WHERE character_id = ? AND episode_id != ?')
.get(cid, episodeId);
if (other && other.n === 0) {
db.prepare('UPDATE characters SET deleted_at = ? WHERE id = ? AND drama_id = ? AND deleted_at IS NULL').run(
now,
cid,
dramaId
);
}
}
db.prepare('DELETE FROM episode_characters WHERE episode_id = ?').run(episodeId);
}
const characters = [];
for (const char of result) {
const name = (char.name || '').trim();
if (!name) continue;
const existing = db.prepare('SELECT id, name FROM characters WHERE drama_id = ? AND name = ? AND deleted_at IS NULL').get(dramaId, name);
if (existing) {
characters.push({
id: existing.id,
drama_id: dramaId,
name: existing.name,
role: null,
description: null,
personality: null,
appearance: null,
voice_style: null,
});
continue;
}
const info = db.prepare(
`INSERT INTO characters (drama_id, name, role, description, personality, appearance, voice_style, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
).run(
dramaId,
name,
char.role ?? null,
char.description ?? null,
char.personality ?? null,
char.appearance ?? null,
char.voice_style ?? null,
now,
now
);
const newCharId = info.lastInsertRowid;
// 异步后台提炼视觉锚点 + 预生成图片提示词,不阻塞主流程
if (char.appearance) {
setImmediate(() => {
enrichIdentityAnchors(db, log, newCharId, char.appearance).catch(() => {});
characterLibraryService.generateCharacterPromptOnly(db, log, effectiveCfg, newCharId, undefined, undefined).catch((err) => {
log.warn('[提取角色] 预生成polished_prompt失败', { character_id: newCharId, error: err.message });
});
});
}
characters.push({
id: newCharId,
drama_id: dramaId,
name,
role: char.role ?? null,
description: char.description ?? null,
personality: char.personality ?? null,
appearance: char.appearance ?? null,
voice_style: char.voice_style ?? null,
});
}
if (req.episode_id && characters.length > 0) {
const episodeId = Number(req.episode_id);
for (const c of characters) {
try {
db.prepare('INSERT OR IGNORE INTO episode_characters (episode_id, character_id) VALUES (?, ?)').run(episodeId, c.id);
} catch (_) {}
}
}
taskService.updateTaskResult(db, taskID, { characters, count: characters.length });
log.info('Character generation completed', { task_id: taskID, drama_id: req.drama_id, character_count: characters.length });
}
function generateCharacters(db, cfg, log, req) {
const dramaId = String(req.drama_id || '');
if (!dramaId) throw new Error('drama_id 必填');
const task = taskService.createTask(db, log, 'character_generation', dramaId);
setImmediate(() => {
processCharacterGeneration(db, cfg, log, task.id, {
drama_id: req.drama_id,
episode_id: req.episode_id,
outline: req.outline,
temperature: req.temperature,
model: req.model,
}).catch((err) => {
log.error('processCharacterGeneration fatal', { error: err.message, task_id: task.id });
});
});
return task.id;
}
module.exports = {
generateCharacters,
enrichIdentityAnchors,
};
@@ -0,0 +1,978 @@
// 角色库:与 Go character_library_service 对齐
const path = require('path');
const crypto = require('crypto');
const imageClient = require('./imageClient');
const { aspectRatioToSize } = require('./imageService');
const aiClient = require('./aiClient');
const promptI18n = require('./promptI18n');
const { mergeCfgStyleWithDrama } = require('../utils/dramaStyleMerge');
const jimengMaterialHubService = require('./jimengMaterialHubService');
const uploadService = require('./uploadService');
const seedance2AssetGuards = require('../utils/seedance2AssetGuards');
const {
appendSourceIdFilters,
findExistingLibraryItem,
insertLibraryItem,
normalizeSourceId,
updateLibraryItem: updateExistingLibraryItem,
} = require('./libraryDedup');
function applyStyleOverrideToCfg(cfg, styleOverride) {
const o = (styleOverride || '').toString().trim();
if (!o) return cfg;
return {
...cfg,
style: {
...(cfg?.style || {}),
default_style_zh: o,
default_style_en: o,
default_style: o,
},
};
}
function appendPrompt(base, extra) {
const add = (extra || '').toString().trim();
if (!add) return base;
const current = (base || '').toString().trim();
if (!current) return add;
const lowerCurrent = current.toLowerCase();
const lowerAdd = add.toLowerCase();
if (lowerCurrent.includes(lowerAdd)) return current;
return current + ', ' + add;
}
function generateCharacterImage(db, log, cfg, characterId, modelName, style) {
const charRow = db.prepare(
'SELECT id, drama_id, name, appearance, description, negative_prompt FROM characters WHERE id = ? AND deleted_at IS NULL'
).get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const drama = db.prepare('SELECT id, style, metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!drama) return { ok: false, error: 'unauthorized' };
let effectiveCfg = { ...cfg, style: { ...(cfg?.style || {}) } };
try {
const meta = drama.metadata ? (typeof drama.metadata === 'string' ? JSON.parse(drama.metadata) : drama.metadata) : null;
if (meta && meta.aspect_ratio) {
effectiveCfg.style.default_image_ratio = meta.aspect_ratio;
}
} catch (_) {}
effectiveCfg = mergeCfgStyleWithDrama(effectiveCfg, drama);
effectiveCfg = applyStyleOverrideToCfg(effectiveCfg, style);
let prompt = '';
if (charRow.appearance && String(charRow.appearance).trim()) {
prompt = String(charRow.appearance);
} else if (charRow.description && String(charRow.description).trim()) {
prompt = String(charRow.description);
} else {
prompt = charRow.name || '';
}
const styleForImage = (effectiveCfg?.style?.default_style_en || effectiveCfg?.style?.default_style || '').trim();
prompt = appendPrompt(prompt, styleForImage);
if (!(style && String(style).trim())) {
prompt = appendPrompt(prompt, effectiveCfg?.style?.default_role_style || '');
}
const ratioText = effectiveCfg?.style?.default_role_ratio
? String(effectiveCfg.style.default_role_ratio)
: (effectiveCfg?.style?.default_image_ratio ? 'image ratio: ' + effectiveCfg.style.default_image_ratio : '');
prompt = appendPrompt(prompt, ratioText);
// 根据项目 aspect_ratio 动态计算图片尺寸,兜底 1920x1920
let imageSize = null;
try {
const meta = drama.metadata ? (typeof drama.metadata === 'string' ? JSON.parse(drama.metadata) : drama.metadata) : null;
if (meta && meta.aspect_ratio) imageSize = aspectRatioToSize(meta.aspect_ratio);
} catch (_) {}
imageSize = imageSize || '1920x1920';
const userNeg = imageClient.resolveAssetUserNegativeForApi(modelName, charRow.negative_prompt);
const imageGen = imageClient.createAndGenerateImage(db, log, {
drama_id: charRow.drama_id,
character_id: charRow.id,
prompt,
model: modelName || undefined,
size: imageSize,
quality: 'standard',
provider: 'openai',
user_negative_prompt: userNeg || undefined,
});
return { ok: true, image_generation: imageGen };
}
function listLibraryItems(db, query) {
let sql = 'FROM character_libraries WHERE deleted_at IS NULL';
const params = [];
if (query.global === '1' || query.global === 1) {
// 仅全局素材库(drama_id IS NULL
sql += ' AND drama_id IS NULL';
} else if (query.drama_id != null && query.drama_id !== '') {
// 本剧资源库
sql += ' AND drama_id = ?';
params.push(Number(query.drama_id));
}
if (query.category) {
sql += ' AND category = ?';
params.push(query.category);
}
if (query.source_type) {
sql += ' AND source_type = ?';
params.push(query.source_type);
}
sql = appendSourceIdFilters(query, sql, params);
if (query.keyword) {
sql += ' AND (name LIKE ? OR description LIKE ?)';
const k = '%' + query.keyword + '%';
params.push(k, k);
}
const countRow = db.prepare('SELECT COUNT(*) as total ' + sql).get(...params);
const total = countRow.total || 0;
const page = Math.max(1, parseInt(query.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(query.page_size, 10) || 20));
const offset = (page - 1) * pageSize;
const rows = db.prepare('SELECT * ' + sql + ' ORDER BY created_at DESC LIMIT ? OFFSET ?').all(...params, pageSize, offset);
return { items: rows.map(rowToItem), total, page, pageSize };
}
function createLibraryItem(db, log, req) {
const now = new Date().toISOString();
const sourceType = req.source_type || 'generated';
const info = insertLibraryItem(db, 'character_libraries', {
drama_id: req.drama_id ?? null,
name: req.name || '',
category: req.category ?? null,
image_url: req.image_url || '',
local_path: req.local_path ?? null,
description: req.description ?? null,
tags: req.tags ?? null,
source_type: sourceType,
source_id: normalizeSourceId(req.source_id) || null,
created_at: now,
updated_at: now,
});
log.info('Library item created', { item_id: info.lastInsertRowid });
return getLibraryItem(db, String(info.lastInsertRowid));
}
function getLibraryItem(db, id) {
const row = db.prepare('SELECT * FROM character_libraries WHERE id = ? AND deleted_at IS NULL').get(Number(id));
return row ? rowToItem(row) : null;
}
function updateLibraryItem(db, log, id, req) {
const row = db.prepare('SELECT id FROM character_libraries WHERE id = ? AND deleted_at IS NULL').get(Number(id));
if (!row) return null;
const updates = [];
const params = [];
if (req.name != null) { updates.push('name = ?'); params.push(req.name); }
if (req.category != null) { updates.push('category = ?'); params.push(req.category); }
if (req.description != null) { updates.push('description = ?'); params.push(req.description); }
if (req.tags != null) { updates.push('tags = ?'); params.push(req.tags); }
if (req.image_url != null) { updates.push('image_url = ?'); params.push(req.image_url); }
if (req.local_path != null) { updates.push('local_path = ?'); params.push(req.local_path); }
if (req.source_type != null) { updates.push('source_type = ?'); params.push(req.source_type); }
if (req.source_id != null) { updates.push('source_id = ?'); params.push(normalizeSourceId(req.source_id)); }
if (updates.length === 0) return getLibraryItem(db, id);
params.push(new Date().toISOString(), Number(id));
db.prepare('UPDATE character_libraries SET ' + updates.join(', ') + ', updated_at = ? WHERE id = ?').run(...params);
log.info('Library item updated', { item_id: id });
return getLibraryItem(db, id);
}
function deleteLibraryItem(db, log, id) {
const now = new Date().toISOString();
const result = db.prepare('UPDATE character_libraries SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL').run(now, Number(id));
if (result.changes === 0) return false;
log.info('Library item deleted', { item_id: id });
return true;
}
function applyLibraryItemToCharacter(db, log, characterId, libraryItemId) {
const item = getLibraryItem(db, libraryItemId);
if (!item) return { ok: false, error: 'library item not found' };
const charRow = db
.prepare('SELECT id, drama_id, local_path, image_url, seedance2_asset FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const drama = db.prepare('SELECT id FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!drama) return { ok: false, error: 'unauthorized' };
seedance2AssetGuards.markStaleOnCharacterMainImageDrift(db, log, charRow, {
image_url: item.image_url || null,
local_path: item.local_path || null,
});
const now = new Date().toISOString();
db.prepare('UPDATE characters SET image_url = ?, local_path = ?, updated_at = ? WHERE id = ?').run(
item.image_url || null,
item.local_path || null,
now,
Number(characterId)
);
log.info('Library item applied to character', { character_id: characterId, library_item_id: libraryItemId });
return { ok: true };
}
function uploadCharacterImage(db, log, characterId, imageUrl, opts = {}) {
const charRow = db
.prepare('SELECT id, drama_id, local_path, image_url, seedance2_asset FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const drama = db.prepare('SELECT id FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!drama) return { ok: false, error: 'unauthorized' };
if (!opts.skipStaleMark) {
seedance2AssetGuards.markStaleOnCharacterMainImageDrift(db, log, charRow, { image_url: imageUrl });
}
const now = new Date().toISOString();
db.prepare('UPDATE characters SET image_url = ?, updated_at = ? WHERE id = ?').run(imageUrl || null, now, Number(characterId));
log.info('Character image uploaded', { character_id: characterId });
return { ok: true };
}
/** local_path → image_url 兜底:避免旧库 NOT NULL 约束报错 */
function resolveImageUrl(image_url, local_path) {
if (image_url && !image_url.startsWith('data:')) return image_url;
if (local_path) return `/static/${local_path}`;
return image_url || null;
}
// 加入本剧资源库(带 drama_id)
function addCharacterToLibrary(db, log, characterId, category) {
const charRow = db.prepare('SELECT * FROM characters WHERE id = ? AND deleted_at IS NULL').get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const drama = db.prepare('SELECT id FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!drama) return { ok: false, error: 'unauthorized' };
if (!charRow.image_url && !charRow.local_path) return { ok: false, error: '角色还没有形象图片' };
const now = new Date().toISOString();
const imageUrl = resolveImageUrl(charRow.image_url, charRow.local_path);
const fields = {
drama_id: charRow.drama_id,
name: charRow.name,
category: category ?? null,
image_url: imageUrl,
local_path: charRow.local_path || null,
description: charRow.description || null,
source_type: 'character',
source_id: normalizeSourceId(charRow.id),
updated_at: now,
};
const existing = findExistingLibraryItem(db, 'character_libraries', {
dramaId: charRow.drama_id,
sourceType: 'character',
sourceId: charRow.id,
imageUrl,
localPath: charRow.local_path,
});
if (existing) {
updateExistingLibraryItem(db, 'character_libraries', existing.id, fields);
log.info('Character library item reused', { character_id: characterId, drama_id: charRow.drama_id, library_item_id: existing.id });
return { ok: true, item: getLibraryItem(db, String(existing.id)), duplicated: true };
}
const info = insertLibraryItem(db, 'character_libraries', { ...fields, created_at: now });
log.info('Character added to drama library', { character_id: characterId, drama_id: charRow.drama_id, library_item_id: info.lastInsertRowid });
return { ok: true, item: getLibraryItem(db, String(info.lastInsertRowid)), duplicated: false };
}
// 加入全局素材库(drama_id = NULL
function addCharacterToMaterialLibrary(db, log, characterId) {
const charRow = db.prepare('SELECT * FROM characters WHERE id = ? AND deleted_at IS NULL').get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
if (!charRow.image_url && !charRow.local_path) return { ok: false, error: '角色还没有形象图片' };
const now = new Date().toISOString();
const imageUrl = resolveImageUrl(charRow.image_url, charRow.local_path);
const fields = {
drama_id: null,
name: charRow.name,
image_url: imageUrl,
local_path: charRow.local_path || null,
description: charRow.description || null,
source_type: 'character',
source_id: normalizeSourceId(charRow.id),
updated_at: now,
};
const existing = findExistingLibraryItem(db, 'character_libraries', {
dramaId: null,
sourceType: 'character',
sourceId: charRow.id,
imageUrl,
localPath: charRow.local_path,
});
if (existing) {
updateExistingLibraryItem(db, 'character_libraries', existing.id, fields);
log.info('Character material library item reused', { character_id: characterId, library_item_id: existing.id });
return { ok: true, item: getLibraryItem(db, String(existing.id)), duplicated: true };
}
const info = insertLibraryItem(db, 'character_libraries', { ...fields, created_at: now });
log.info('Character added to material library (global)', { character_id: characterId, library_item_id: info.lastInsertRowid });
return { ok: true, item: getLibraryItem(db, String(info.lastInsertRowid)), duplicated: false };
}
function updateCharacter(db, log, characterId, req) {
const charRow = db
.prepare('SELECT id, drama_id, local_path, image_url, seedance2_asset FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const drama = db.prepare('SELECT id FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!drama) return { ok: false, error: 'unauthorized' };
const updates = [];
const params = [];
if (req.name != null) { updates.push('name = ?'); params.push(req.name); }
if (req.role != null) { updates.push('role = ?'); params.push(req.role); }
if (req.appearance != null) { updates.push('appearance = ?'); params.push(req.appearance); }
if (req.personality != null) { updates.push('personality = ?'); params.push(req.personality); }
if (req.description != null) { updates.push('description = ?'); params.push(req.description); }
if (req.image_url != null) { updates.push('image_url = ?'); params.push(req.image_url); }
if (req.local_path != null) { updates.push('local_path = ?'); params.push(req.local_path); }
if (req.polished_prompt != null) { updates.push('polished_prompt = ?'); params.push(req.polished_prompt); }
if (req.stages != null) { updates.push('stages = ?'); params.push(typeof req.stages === 'string' ? req.stages : JSON.stringify(req.stages)); }
if (req.negative_prompt !== undefined) { updates.push('negative_prompt = ?'); params.push(req.negative_prompt); }
if (updates.length === 0) return { ok: true };
if (req.image_url != null || req.local_path != null) {
seedance2AssetGuards.markStaleOnCharacterMainImageDrift(db, log, charRow, {
image_url: req.image_url != null ? req.image_url : charRow.image_url,
local_path: req.local_path != null ? req.local_path : charRow.local_path,
});
}
params.push(new Date().toISOString(), characterId);
db.prepare('UPDATE characters SET ' + updates.join(', ') + ', updated_at = ? WHERE id = ?').run(...params);
log.info('Character updated', { character_id: characterId });
return { ok: true };
}
function deleteCharacter(db, log, characterId) {
const charRow = db.prepare('SELECT id, drama_id FROM characters WHERE id = ? AND deleted_at IS NULL').get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const drama = db.prepare('SELECT id FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!drama) return { ok: false, error: 'unauthorized' };
const now = new Date().toISOString();
db.prepare('UPDATE characters SET deleted_at = ? WHERE id = ?').run(now, Number(characterId));
log.info('Character deleted', { id: characterId });
return { ok: true };
}
/**
* 批量生成角色图片(与 Go BatchGenerateCharacterImages 对齐:为每个角色单独起一个异步任务并发生成)
*/
function batchGenerateCharacterImages(db, log, cfg, characterIds, modelName, style) {
const ids = Array.isArray(characterIds) ? characterIds.map((id) => String(id)) : [];
if (ids.length === 0) return { ok: false, error: 'character_ids 不能为空' };
if (ids.length > 10) return { ok: false, error: '单次最多生成10个角色' };
log.info('Starting batch character four-view generation', { count: ids.length, model: modelName, character_ids: ids });
// 每个角色单独起一个异步任务,不阻塞响应
for (const characterId of ids) {
const charId = characterId;
setImmediate(async () => {
try {
const out = await generateCharacterFourViewImage(db, log, cfg, charId, modelName, style);
if (!out.ok) {
log.warn('Batch character four-view skip', { character_id: charId, error: out.error });
return;
}
log.info('Batch character four-view submitted', { character_id: charId, image_gen_id: out.image_generation ? out.image_generation.id : null });
} catch (err) {
log.error('Batch character four-view failed', { character_id: charId, error: err.message });
}
});
}
log.info('Batch character four-view tasks queued', { total: ids.length });
return { ok: true, count: ids.length };
}
function rowToItem(r) {
return {
id: r.id,
drama_id: r.drama_id ?? null,
name: r.name,
category: r.category,
image_url: r.image_url,
local_path: r.local_path,
description: r.description,
tags: r.tags,
source_type: r.source_type || 'generated',
source_id: r.source_id || null,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
/**
* 角色四视图生成:两步流程
* Step 1: 文本AI将 appearance 转换为标准四视图绘图描述
* Step 2: 图片AI根据描述生成 16:9 四格角色参考图
*/
/**
* 组装最终图片生成 prompt(布局指令 + 角色描述 + 风格 + 硬性要求)
* 这是实际发给图片AI的完整 prompt,与 polished_prompt 字段内容一致。
*/
/**
* 从描述文本中识别性别,用于在英文约束里强调,防止图片 AI 生成错误性别。
* @returns {'MALE'|'FEMALE'|null}
*/
function detectGenderFromDescription(text) {
if (!text) return null;
const t = text;
// ── 第1层:最明确的性别词 ───────────────────────────────────────────
if (/男性|男生|男孩|男人|帅哥|先生/.test(t)) return 'MALE';
if (/女性|女生|女孩|女人|美女|小姐|女士/.test(t)) return 'FEMALE';
// ── 第2层:亲属/称谓(复合词,误判率极低)────────────────────────
// 男:哥哥 大哥 二哥 老哥 / 兄长 兄弟 / 弟弟 老弟 小弟 /
// 爸爸 父亲 老爸 / 爷爷 老爷 大爷 / 叔叔 伯伯 舅舅
if (/哥哥|大哥|二哥|老哥|小哥|兄长|兄弟|弟弟|老弟|小弟|爸爸|父亲|老爸|爷爷|老爷|叔叔|伯伯|舅舅/.test(t)) return 'MALE';
// 女:姐姐 大姐 二姐 / 妹妹 小妹 / 妈妈 母亲 老妈 /
// 奶奶 姑姑 婶婶 阿姨
if (/姐姐|大姐|二姐|老姐|小姐姐|妹妹|小妹|大妹|妈妈|母亲|老妈|奶奶|姑姑|婶婶|阿姨/.test(t)) return 'FEMALE';
// ── 第3层:角色定位词 ──────────────────────────────────────────────
if (/男主|男二|男三|男配|男反|男一号/.test(t)) return 'MALE';
if (/女主|女二|女三|女配|女反|女一号/.test(t)) return 'FEMALE';
// ── 第4层:常见中文名字模式 ───────────────────────────────────────
// 「小/大/老/阿 + 典型男性用字」
// 典型男性字:明刚强磊军勇鹏龙伟超豪杰浩宇轩博远志峰涛
if (/小明|小刚|小强|小磊|小军|小勇|小鹏|小龙|小伟|小超|小豪|小杰|小浩|小宇|小轩|小博|小远|小志|小峰|小涛|大壮|阿强|阿勇|阿明|阿刚|阿豪|老刚|老强/.test(t)) return 'MALE';
// 「小/大/老/阿 + 典型女性用字」
// 典型女性字:美红花丽燕芳英敏静娟慧梅香秀玲萍云雪莹晴
if (/小美|小红|小花|小丽|小燕|小芳|小英|小敏|小静|小娟|小慧|小梅|小香|小秀|小玲|小萍|小云|小雪|小莹|小晴|阿美|阿花|阿丽|阿燕|阿芳|阿英|阿梅/.test(t)) return 'FEMALE';
// ── 第5层:单字称谓(放最后,避免误判)───────────────────────────
// 只匹配单独作称谓出现的情况(前后有汉字边界或标点)
if (/[(【「\s:]哥[)】」\s,,。!!]|^哥[,,。]|[他]哥\b/.test(t)) return 'MALE';
// ── 第6层:英文兜底 ────────────────────────────────────────────────
if (/\b(male|man|boy|gentleman|he|his)\b/i.test(t)) return 'MALE';
if (/\b(female|woman|girl|lady|she|her)\b/i.test(t)) return 'FEMALE';
return null;
}
/**
* @param {string} fourViewDescription 文本AI润色后的角色四格描述
* @param {string} [styleEn] default_style_en 或 fallback default_style
* @param {string} [styleZh] default_style_zh(可与 en 相同;相同时不重复输出英文行)
*/
function buildFourViewImagePrompt(fourViewDescription, styleEn, styleZh) {
const imageLayoutInstruction = promptI18n.getRoleGenerateImagePrompt();
const zh = (styleZh || '').trim();
const en = (styleEn || '').trim();
const styleLines = [];
if (zh) styleLines.push(`【画风·最高优先级】四格统一:${zh}`);
if (en && en !== zh) styleLines.push(`MANDATORY ART STYLE (all 4 panels): ${en}.`);
else if (en && !zh) styleLines.push(`MANDATORY ART STYLE (all 4 panels): ${en}.`);
const styleHeader = styleLines.length ? `${styleLines.join('\n')}\n\n` : '';
const gender = detectGenderFromDescription(fourViewDescription);
const genderEnforcement = gender === 'MALE'
? 'GENDER: male only — masculine build and facial features; do not feminize.'
: gender === 'FEMALE'
? 'GENDER: female only — feminine build and facial features; do not masculinize.'
: '';
const tailParts = [];
if (genderEnforcement) tailParts.push(genderEnforcement);
if (zh || en) tailParts.push(`Reiterate: same art style as above (${en || zh}).`);
const tail = tailParts.length ? `\n\n---\n\n${tailParts.join(' ')}` : '';
return `${styleHeader}${imageLayoutInstruction}\n\n---\n\n${fourViewDescription}${tail}`;
}
/**
* 仅生成(并保存)角色四视图提示词,不触发图片生成。
* 供前端「生成提示词」按钮调用,或提取角色后后台异步调用。
* @returns {{ ok: boolean, polished_prompt?: string, error?: string }}
*/
async function generateCharacterPromptOnly(db, log, cfg, characterId, modelName, style) {
const charRow = db.prepare(
'SELECT id, drama_id, name, appearance, description FROM characters WHERE id = ? AND deleted_at IS NULL'
).get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const dramaFull = db.prepare('SELECT id, style, metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
let mergedCfg = mergeCfgStyleWithDrama(cfg, dramaFull || {});
mergedCfg = applyStyleOverrideToCfg(mergedCfg, style);
let appearanceText = '';
if (charRow.appearance && String(charRow.appearance).trim()) {
appearanceText = String(charRow.appearance).trim();
} else if (charRow.description && String(charRow.description).trim()) {
appearanceText = String(charRow.description).trim();
} else {
appearanceText = charRow.name || '';
}
const systemPrompt = promptI18n.getRolePolishPrompt(mergedCfg);
const userPrompt = `角色名称:${charRow.name}\n\n角色描述:\n${appearanceText}`;
log.info('[四视图提示词] 开始生成', { character_id: characterId, name: charRow.name });
let fourViewDescription;
try {
fourViewDescription = await aiClient.generateText(db, log, 'text', userPrompt, systemPrompt, {
scene_key: 'role_image_polish',
model: modelName || undefined,
max_tokens: 4000,
});
} catch (err) {
log.error('[四视图提示词] 文本AI失败,降级为外貌描述', { error: err.message });
fourViewDescription = appearanceText;
}
const styleEn = (mergedCfg.style.default_style_en || mergedCfg.style.default_style || '').trim();
const styleZh = (mergedCfg.style.default_style_zh || '').trim();
const polishedPrompt = buildFourViewImagePrompt(fourViewDescription, styleEn, styleZh);
// 保存到 characters.polished_prompt
db.prepare('UPDATE characters SET polished_prompt = ?, updated_at = ? WHERE id = ?').run(
polishedPrompt, new Date().toISOString(), Number(characterId)
);
log.info('[四视图提示词] 生成并保存完成', { character_id: characterId, length: polishedPrompt.length });
return { ok: true, polished_prompt: polishedPrompt };
}
async function generateCharacterFourViewImage(db, log, cfg, characterId, modelName, style) {
const charRow = db.prepare(
'SELECT id, drama_id, name, appearance, description, polished_prompt, negative_prompt FROM characters WHERE id = ? AND deleted_at IS NULL'
).get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const dramaFull = db.prepare('SELECT id, style, metadata FROM dramas WHERE id = ? AND deleted_at IS NULL').get(charRow.drama_id);
if (!dramaFull) return { ok: false, error: 'unauthorized' };
let mergedCfg = mergeCfgStyleWithDrama(cfg, dramaFull);
mergedCfg = applyStyleOverrideToCfg(mergedCfg, style);
let imagePrompt;
if (charRow.polished_prompt && String(charRow.polished_prompt).trim()) {
// 直接使用已保存的提示词(用户可能已编辑过)
imagePrompt = String(charRow.polished_prompt).trim();
log.info('[四视图] 使用已保存的 polished_prompt,跳过文字AI', { character_id: characterId });
} else {
// 没有预生成提示词,临时生成(与 generateCharacterPromptOnly 同逻辑)
let appearanceText = '';
if (charRow.appearance && String(charRow.appearance).trim()) {
appearanceText = String(charRow.appearance).trim();
} else if (charRow.description && String(charRow.description).trim()) {
appearanceText = String(charRow.description).trim();
} else {
appearanceText = charRow.name || '';
}
const systemPrompt = promptI18n.getRolePolishPrompt(mergedCfg);
const userPrompt = `角色名称:${charRow.name}\n\n角色描述:\n${appearanceText}`;
log.info('[四视图] Step1 开始生成四视图提示词', { character_id: characterId, name: charRow.name });
let fourViewDescription;
try {
fourViewDescription = await aiClient.generateText(db, log, 'text', userPrompt, systemPrompt, {
scene_key: 'role_image_polish',
model: modelName || undefined,
max_tokens: 4000,
});
} catch (err) {
log.error('[四视图] Step1 文本AI失败,降级为直接使用外貌描述', { error: err.message });
fourViewDescription = appearanceText;
}
const styleEn = (mergedCfg.style.default_style_en || mergedCfg.style.default_style || '').trim();
const styleZh = (mergedCfg.style.default_style_zh || '').trim();
imagePrompt = buildFourViewImagePrompt(fourViewDescription, styleEn, styleZh);
// 顺带保存,供下次复用
try {
db.prepare('UPDATE characters SET polished_prompt = ?, updated_at = ? WHERE id = ?').run(
imagePrompt, new Date().toISOString(), Number(characterId)
);
} catch (_) {}
log.info('[四视图] Step1 完成,开始Step2生图', { character_id: characterId });
}
const userNeg = imageClient.resolveAssetUserNegativeForApi(modelName, charRow.negative_prompt);
const imageGen = imageClient.createAndGenerateImage(db, log, {
drama_id: charRow.drama_id,
character_id: charRow.id,
prompt: imagePrompt,
model: modelName || undefined,
size: '1792x1024',
quality: 'standard',
provider: 'openai',
user_negative_prompt: userNeg || undefined,
});
log.info('[四视图] Step2 图片生成任务已提交', { character_id: characterId, image_gen_id: imageGen?.id });
return { ok: true, image_generation: imageGen };
}
/**
* 从角色现有图片中反向提取外貌描述,更新 appearance 字段。
*/
async function extractAppearanceFromImage(db, log, cfg, characterId) {
const { generateTextWithVision, resolveEntityImageSource, EXTRACT_PROMPTS } = require('./aiClient');
const charRow = db.prepare(
'SELECT id, name, image_url, local_path, extra_images, ref_image FROM characters WHERE id = ? AND deleted_at IS NULL'
).get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const imgSrc = resolveEntityImageSource(charRow, cfg);
if (!imgSrc) return { ok: false, error: '该角色暂无参考图片,请先上传图片' };
const { system: systemPrompt, user: userFn } = EXTRACT_PROMPTS.character;
const userPrompt = userFn(charRow.name);
const { isRefusalResponse } = require('./aiClient');
let appearance;
try {
appearance = await generateTextWithVision(db, log, 'text', userPrompt, systemPrompt, imgSrc, { max_tokens: 2000 });
} catch (err) {
log.error('[extractAppearanceFromImage] AI 调用失败', { characterId, error: err.message });
const errMsg = /image|vision|visual|multimodal/i.test(err.message)
? `AI 模型不支持图片识别,请在「AI 配置」中使用支持视觉的模型(如 GPT-4o、Gemini 1.5 等)【原始错误:${err.message.slice(0, 120)}`
: `AI 分析失败:${err.message}`;
return { ok: false, error: errMsg };
}
if (isRefusalResponse(appearance)) {
log.warn('[extractAppearanceFromImage] 模型拒绝描述真人', { characterId, result: appearance });
return { ok: false, error: '模型因安全策略拒绝描述图中人物面部特征。建议:①使用 Gemini 模型(限制较少);②手动填写外貌描述;③上传卡通/插画风格的参考图。' };
}
db.prepare('UPDATE characters SET appearance = ?, updated_at = ? WHERE id = ?')
.run(appearance, new Date().toISOString(), Number(characterId));
log.info('[extractAppearanceFromImage] 外貌提取成功', { characterId, appearance_len: appearance.length });
return { ok: true, appearance };
}
/**
* 组成素材库可拉取的 http(s) 图片 URL:优先角色主图已为直链;否则用 storage.base_url + local_path 拼出(与图床/即梦回传直链二选一逻辑一致)
*/
function buildCharacterPublicImageUrlForHub(charRow, cfg) {
const img = (charRow.image_url || '').toString().trim();
const lp = (charRow.local_path || '').toString().trim();
const baseRaw = (cfg?.storage?.base_url || '').toString().trim();
const publicBase = baseRaw.replace(/\/$/, '');
if (/^https?:\/\//i.test(img)) {
return { ok: true, url: img };
}
if (!publicBase) {
return {
ok: false,
error:
'角色主图非 http(s) 直链且未配置 storage.base_url,无法组成素材库可拉取的图片 URL(请将主图设为图床/即梦返回地址,或配置本服务静态资源公网 base_url',
};
}
if (lp) {
const pathPart = lp.replace(/^\/+/, '');
return { ok: true, url: `${publicBase}/${pathPart}` };
}
if (img.startsWith('/')) {
if (publicBase.endsWith('/static') && img.startsWith('/static/')) {
return { ok: true, url: publicBase + img.slice('/static'.length) };
}
const m = publicBase.match(/^(https?:\/\/[^/]+)/i);
if (m) return { ok: true, url: m[1] + img };
}
const fallback = resolveImageUrl(charRow.image_url, charRow.local_path);
if (/^https?:\/\//i.test(fallback)) return { ok: true, url: fallback };
return { ok: false, error: '角色缺少素材库可用的图片(需 http(s) 图链或 local_path + 公网 base_url' };
}
function storageRootPath(cfg) {
const raw = (cfg?.storage?.local_path || './data/storage').toString();
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
}
/** 云端素材库无法拉取:非 http(s)、data:、localhost、常见内网等 */
function isNonPublicMaterialHubUrl(url) {
const s = String(url || '').trim();
if (!s) return true;
if (s.startsWith('data:')) return true;
if (!/^https?:\/\//i.test(s)) return true;
try {
const { hostname } = new URL(s);
const h = String(hostname || '').toLowerCase();
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' || h === '[::1]' || h === '::1') return true;
if (/^192\.168\./.test(h)) return true;
if (/^10\./.test(h)) return true;
const m = /^172\.(\d+)\./.exec(h);
if (m) {
const n = parseInt(m[1], 10);
if (n >= 16 && n <= 31) return true;
}
} catch (_) {
return true;
}
return false;
}
/** 与 image_proxy_cache 约定一致:有 local_path 用相对路径作 key;否则用 URL 哈希避免冲突 */
function materialHubProxyCacheKey(charRow, imageUrl) {
const lp = (charRow.local_path || '').toString().trim().replace(/^\/+/, '');
if (lp) return lp;
return `sd2char:url:${crypto.createHash('sha256').update(String(imageUrl)).digest('hex').slice(0, 48)}`;
}
function isHubDownloadMediaError(msg) {
return /DownloadFailed|download media|accessible|拉取|下载|tos: request error|fetch-object/i.test(String(msg || ''));
}
function isHubAuthTokenError(msg) {
return /无效的\s*token|invalid\s*token|unauthorized|401/i.test(String(msg || ''));
}
function formatSd2HubError(errMsg, hubCtx) {
let out = String(errMsg || '素材库创建素材失败');
if (!isHubAuthTokenError(out)) return out;
const diag = hubCtx?.hubAuthDiag || {};
const parts = [
`即梦2素材库拒绝了当前 Token${out})。`,
'请在「AI 配置」→「即梦2角色认证」中重新粘贴与 curl 测试完全相同的密钥并点击保存(勿带 Bearer 前缀、勿多空格)。',
'保存前可用「列出素材」验证;若列出成功而 SD2 仍失败,说明未保存或存在多条配置未设为默认。',
];
if (diag.db_config_id != null) parts.push(`当前读取的配置:id=${diag.db_config_id}${diag.db_config_name ? `${diag.db_config_name}` : ''}`);
const fp = diag.token_fingerprint || hubCtx?.tokenFingerprint;
if (fp) parts.push(`Token 指纹:${fp}(请与 curl 测试通过时 Bearer 密钥的首尾字符对照是否一致)。`);
return parts.join('');
}
/**
* localhost / 内网 / 相对 URL 等:先查 image_proxy_cache,未命中则读本地文件上传图床,供即梦素材库拉取。
* @param {{ forceLocalProxy?: boolean }} [opts] - 为 true 时跳过直链,强制用 local_path 上传图床(网关拉取火山/TOS 等失败时重试)
*/
async function ensurePublicRegisterImageUrlForMaterialHub(db, log, cfg, charRow, imageUrl, opts = {}) {
const forceLocalProxy = !!opts.forceLocalProxy;
if (!forceLocalProxy && !isNonPublicMaterialHubUrl(imageUrl)) {
return { ok: true, url: imageUrl, via: 'direct' };
}
const cacheKey = materialHubProxyCacheKey(charRow, imageUrl);
const cached = await imageClient.getProxyCacheValidated(db, cacheKey, log, `sd2_char_${charRow.id}`);
if (cached) {
log.info('[SD2认证] 使用图床缓存 URL', { character_id: charRow.id, cache_key: cacheKey });
return { ok: true, url: cached, via: 'cache' };
}
const storagePath = storageRootPath(cfg);
const localRef = (charRow.local_path || '').toString().trim() || imageUrl;
const proxyUrl = await uploadService.uploadLocalImageToProxy(storagePath, localRef, log, `sd2_char_${charRow.id}`);
if (!proxyUrl) {
return {
ok: false,
error:
'角色图为本机或内网地址,已尝试上传到中转图床失败(请确认 storage.local_path 下文件存在,且 image_proxy 配置可用)',
};
}
imageClient.setProxyCache(db, cacheKey, proxyUrl);
log.info('[SD2认证] 已上传图床供素材库拉取', { character_id: charRow.id, cache_key: cacheKey });
return { ok: true, url: proxyUrl, via: 'upload' };
}
function readSeedance2AssetJson(text) {
if (!text) return null;
try {
return typeof text === 'string' ? JSON.parse(text) : text;
} catch (_) {
return null;
}
}
/**
* 调用即梦素材库(官方兼容 /api/business/v1/assets)注册角色主图,并轮询至 active / failed(或超时保留 processing
*/
async function registerCharacterJimengMaterialAsset(db, log, cfg, characterId) {
const materialHub = jimengMaterialHubService;
const hubCtx = materialHub.buildHubContext(cfg, db, log);
if (!hubCtx.token) {
return {
ok: false,
error:
'未配置即梦2角色认证:请在「AI 配置」中新增一条「即梦2角色认证」,填写网关 URL 与 Token(或设置环境变量 JIMENG2_CHARACTER_AUTH_*;兼容旧 config',
};
}
const charRow = db.prepare('SELECT * FROM characters WHERE id = ? AND deleted_at IS NULL').get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
if (!charRow.image_url && !charRow.local_path) {
return { ok: false, error: '角色还没有形象图片' };
}
const urlOut = buildCharacterPublicImageUrlForHub(charRow, cfg);
if (!urlOut.ok) return urlOut;
const imageUrl = urlOut.url;
if (String(imageUrl).startsWith('data:')) {
return { ok: false, error: '不支持 base64 图片注册,请先使用上传或外网图链' };
}
const pub = await ensurePublicRegisterImageUrlForMaterialHub(db, log, cfg, charRow, imageUrl);
if (!pub.ok) return pub;
let registerImageUrl = pub.url;
const assetName = String(charRow.name || 'role').replace(/\s+/g, '').slice(0, 12) || 'role';
const registerUrlLooksPrivate = isNonPublicMaterialHubUrl(imageUrl);
log.info('[SD2认证] 请求参数摘要', {
character_id: Number(characterId),
character_name: charRow.name,
drama_id: charRow.drama_id,
image_url_db: charRow.image_url ? String(charRow.image_url).slice(0, 240) : null,
local_path: charRow.local_path || null,
resolved_register_image_url: String(registerImageUrl).slice(0, 500),
pre_proxy_image_url: registerUrlLooksPrivate ? String(imageUrl).slice(0, 240) : null,
public_image_via: pub.via,
storage_base_url: (cfg?.storage?.base_url || '').toString().slice(0, 160),
hub_gateway: hubCtx.baseUrl,
hub_auth_diag: hubCtx.hubAuthDiag || null,
asset_name: assetName,
register_url_looks_private_host: registerUrlLooksPrivate,
hint: registerUrlLooksPrivate && pub.via !== 'direct'
? '本地/内网图片已自动经中转图床生成公网 URL 后提交素材库'
: registerUrlLooksPrivate
? '素材库在云端拉取图片失败多为 URL 不可达:请换图床/公网 https 直链,或将 storage.base_url 改为公网可访问的静态资源地址'
: '若仍失败,请用浏览器或 curl 在无 VPN 的机器上访问 resolved_register_image_url 确认 200 且 Content-Type 为图片',
});
let createRes = await materialHub.createImageAsset(hubCtx, { url: registerImageUrl, name: assetName }, log);
if (!createRes.ok && isHubDownloadMediaError(createRes.error) && pub.via === 'direct' && charRow.local_path) {
const proxyRetry = await ensurePublicRegisterImageUrlForMaterialHub(db, log, cfg, charRow, imageUrl, {
forceLocalProxy: true,
});
if (proxyRetry.ok && proxyRetry.url && proxyRetry.url !== registerImageUrl) {
log.info('[SD2认证] 网关无法拉取原图直链,已改用图床 URL 重试', {
character_id: Number(characterId),
public_image_via: proxyRetry.via,
retry_url_head: String(proxyRetry.url).slice(0, 120),
});
registerImageUrl = proxyRetry.url;
createRes = await materialHub.createImageAsset(hubCtx, { url: registerImageUrl, name: assetName }, log);
}
}
if (!createRes.ok) {
log.warn('[SD2认证] create asset 失败', {
character_id: Number(characterId),
http_status: createRes.status,
error: createRes.error,
resolved_register_image_url: registerImageUrl,
hub_auth_diag: hubCtx.hubAuthDiag || null,
});
let errMsg = formatSd2HubError(createRes.error, hubCtx);
if (isHubDownloadMediaError(createRes.error)) {
errMsg +=
' 【说明】素材库会从云端访问你提交的「图片 URL」。火山引擎/即梦临时链常无法被网关拉取,本服务已尝试用本地图上传中转图床;若仍失败请检查 local_path 文件是否存在、图床是否可用,或换百度图床等公网直链。';
}
return { ok: false, error: errMsg };
}
const created = createRes.data;
const assetId = created.id;
if (!assetId) {
return { ok: false, error: '素材库返回缺少素材 id' };
}
const now = new Date().toISOString();
const certifiedLp = seedance2AssetGuards.normalizeStorageRelPath(charRow.local_path || '') || null;
const certifiedImg = (charRow.image_url || '').toString().trim() || null;
const basePayload = {
hub_asset_id: assetId,
asset_url: created.asset_url || null,
status: created.status || 'processing',
source_image_url: registerImageUrl,
/** 仅当参考图与认证时主图路径一致时才在视频中替换为 asset://(换主图后须重新认证) */
certified_local_path: certifiedLp,
certified_image_url: certifiedImg,
character_display: {
name: charRow.name || '',
appearance: (charRow.appearance || '').slice(0, 500) || null,
description: (charRow.description || '').slice(0, 500) || null,
},
updated_at: now,
};
db.prepare('UPDATE characters SET seedance2_asset = ?, updated_at = ? WHERE id = ?').run(
JSON.stringify(basePayload),
now,
Number(characterId)
);
const poll = await materialHub.pollAssetUntilSettled(hubCtx, assetId, {
maxMs: hubCtx.poll_max_ms != null ? Number(hubCtx.poll_max_ms) : 120000,
intervalMs: hubCtx.poll_interval_ms != null ? Number(hubCtx.poll_interval_ms) : 2000,
log,
});
if (!poll.ok) {
log.warn('即梦素材库 poll asset 失败', { characterId, assetId, error: poll.error });
return { ok: false, error: poll.error };
}
const settled = poll.asset || created;
const nextPayload = {
...basePayload,
asset_url: settled.asset_url ?? basePayload.asset_url,
status: settled.status || basePayload.status,
hub_url: settled.url || created.url || null,
poll_timed_out: !!poll.timedOut,
updated_at: new Date().toISOString(),
};
db.prepare('UPDATE characters SET seedance2_asset = ?, updated_at = ? WHERE id = ?').run(
JSON.stringify(nextPayload),
nextPayload.updated_at,
Number(characterId)
);
log.info('即梦素材库 seedance2 素材已登记', { characterId, hub_asset_id: assetId, status: nextPayload.status });
return { ok: true, seedance2_asset: nextPayload };
}
async function refreshCharacterJimengMaterialAsset(db, log, cfg, characterId) {
const materialHub = jimengMaterialHubService;
const hubCtx = materialHub.buildHubContext(cfg, db, log);
if (!hubCtx.token) {
return { ok: false, error: '未配置即梦2角色认证:请在「AI 配置」中填写 Token' };
}
const charRow = db.prepare('SELECT id, seedance2_asset FROM characters WHERE id = ? AND deleted_at IS NULL').get(Number(characterId));
if (!charRow) return { ok: false, error: 'character not found' };
const prev = readSeedance2AssetJson(charRow.seedance2_asset);
const assetId = prev?.hub_asset_id;
if (!assetId) {
return { ok: false, error: '暂未取得素材 id,请先完成 SD2 认证' };
}
const r = await materialHub.getAsset(hubCtx, assetId, log);
if (!r.ok) {
log.warn('[SD2认证] refresh getAsset 失败', {
character_id: Number(characterId),
http_status: r.status,
error: r.error,
hub_auth_diag: hubCtx.hubAuthDiag || null,
});
return { ok: false, error: r.error };
}
const settled = r.data;
const now = new Date().toISOString();
const nextPayload = {
...(prev && typeof prev === 'object' ? prev : {}),
hub_asset_id: assetId,
asset_url: settled.asset_url ?? prev?.asset_url ?? null,
status: settled.status || prev?.status || 'processing',
hub_url: settled.url ?? prev?.hub_url ?? null,
updated_at: now,
};
db.prepare('UPDATE characters SET seedance2_asset = ?, updated_at = ? WHERE id = ?').run(
JSON.stringify(nextPayload),
now,
Number(characterId)
);
return { ok: true, seedance2_asset: nextPayload };
}
module.exports = {
listLibraryItems,
createLibraryItem,
getLibraryItem,
updateLibraryItem,
deleteLibraryItem,
applyLibraryItemToCharacter,
uploadCharacterImage,
addCharacterToLibrary,
addCharacterToMaterialLibrary,
updateCharacter,
deleteCharacter,
generateCharacterImage,
batchGenerateCharacterImages,
generateCharacterFourViewImage,
generateCharacterPromptOnly,
extractAppearanceFromImage,
registerCharacterJimengMaterialAsset,
refreshCharacterJimengMaterialAsset,
};
+115
View File
@@ -0,0 +1,115 @@
const OFFICIAL_HOST_RE = /(^|\.)api\.deepseek\.com$/i;
const LEGACY_MODEL_OPTIONS = {
'deepseek-chat': { model: 'deepseek-v4-flash', thinking: 'disabled' },
'deepseek-reasoner': { model: 'deepseek-v4-flash', thinking: 'enabled' },
};
function parseSettings(settings) {
if (!settings) return {};
if (typeof settings === 'object') return settings;
try {
const parsed = JSON.parse(settings);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function isDeepSeekOfficialConfig(config = {}) {
const provider = String(config.provider || '').trim().toLowerCase();
if (provider === 'deepseek') return true;
const rawBase = String(config.base_url || '').trim();
if (!rawBase) return false;
try {
const url = new URL(rawBase);
return OFFICIAL_HOST_RE.test(url.hostname);
} catch (_) {
return rawBase.toLowerCase().includes('api.deepseek.com');
}
}
function normalizeThinking(value) {
if (value == null || value === '') return null;
if (typeof value === 'boolean') return value ? 'enabled' : 'disabled';
const v = String(value).trim().toLowerCase();
if (v === 'enabled' || v === 'enable' || v === 'on' || v === 'true' || v === 'thinking') return 'enabled';
if (v === 'disabled' || v === 'disable' || v === 'off' || v === 'false' || v === 'non-thinking') return 'disabled';
return null;
}
function normalizeReasoningEffort(value) {
if (value == null || value === '') return null;
const v = String(value).trim().toLowerCase();
if (v === 'max' || v === 'xhigh') return 'max';
if (v === 'high' || v === 'medium' || v === 'low') return 'high';
return null;
}
function resolveDeepSeekOptions(config = {}, model) {
const modelName = String(model || '').trim();
const legacy = LEGACY_MODEL_OPTIONS[modelName.toLowerCase()] || null;
const settings = parseSettings(config.settings);
const nested = settings.deepseek && typeof settings.deepseek === 'object' ? settings.deepseek : {};
const explicitThinking = normalizeThinking(
settings.deepseek_thinking
?? settings.thinking
?? nested.thinking
?? nested.type
);
const reasoningEffort = normalizeReasoningEffort(
settings.deepseek_reasoning_effort
?? settings.reasoning_effort
?? nested.reasoning_effort
?? nested.effort
);
return {
model: legacy ? legacy.model : modelName,
thinking: explicitThinking || legacy?.thinking || null,
reasoning_effort: reasoningEffort,
};
}
function applyDeepSeekChatOptions(config, body) {
if (!isDeepSeekOfficialConfig(config)) return body;
const opts = resolveDeepSeekOptions(config, body?.model);
const next = {
...body,
model: opts.model || body.model,
};
if (opts.thinking) {
next.thinking = { type: opts.thinking };
}
if (opts.thinking === 'enabled') {
if (opts.reasoning_effort) next.reasoning_effort = opts.reasoning_effort;
delete next.temperature;
} else {
delete next.reasoning_effort;
}
return next;
}
function applyDeepSeekConnectivityOptions(config, body) {
if (!isDeepSeekOfficialConfig(config)) return body;
const next = applyDeepSeekChatOptions(config, body);
if (!next.thinking) {
next.thinking = { type: 'disabled' };
}
delete next.reasoning_effort;
return next;
}
module.exports = {
applyDeepSeekChatOptions,
applyDeepSeekConnectivityOptions,
isDeepSeekOfficialConfig,
parseSettings,
resolveDeepSeekOptions,
};
@@ -0,0 +1,450 @@
// 项目导出服务:将剧集所有数据和媒体文件打包为 ZIP
const fs = require('fs');
const path = require('path');
const AdmZip = require('adm-zip');
const EXPORT_VERSION = '1.4'; // 1.4: 完整导出分镜图片历史(含首尾帧 first/last 绑定)、frame_prompts、layout_description 等,支持导入后恢复首尾帧模式数据
function getStoragePath(cfg) {
const raw = cfg?.storage?.local_path || './data/storage';
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
}
function safeReadFile(filePath) {
try {
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
} catch (_) {}
return null;
}
function localPathToAbs(storagePath, relPath) {
if (!relPath) return null;
return path.join(storagePath, relPath);
}
function extOf(relPath) {
if (!relPath) return '.jpg';
return path.extname(relPath) || '.jpg';
}
/** 解析 extra_images JSON 字段,返回本地路径数组 */
function parseExtraImages(raw) {
if (!raw) return [];
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(arr) ? arr.filter(Boolean) : [];
} catch (_) { return []; }
}
const EXPORT_FIRST_FRAME_TYPES = ['storyboard_first', 'first', 'first_frame'];
const EXPORT_LAST_FRAME_TYPES = ['storyboard_last', 'last', 'tail', 'last_frame'];
/** frame_prompts 表无记录时,从首尾帧图生历史补全导出(避免仅生过图、未单独存帧提示词时丢失) */
function supplementFramePromptsFromImageGens(db, sbId, fps) {
const out = Array.isArray(fps) ? [...fps] : [];
const hasType = (t) => out.some((f) => f && f.frame_type === t);
const pickPrompt = (types) => {
const ph = types.map(() => '?').join(',');
const row = db.prepare(
`SELECT prompt FROM image_generations WHERE storyboard_id = ? AND deleted_at IS NULL
AND frame_type IN (${ph}) AND prompt IS NOT NULL AND TRIM(prompt) != ''
ORDER BY created_at DESC LIMIT 1`
).get(sbId, ...types);
return (row?.prompt || '').trim();
};
const now = new Date().toISOString();
if (!hasType('first')) {
const p = pickPrompt(EXPORT_FIRST_FRAME_TYPES);
if (p) out.push({ frame_type: 'first', prompt: p, description: null, layout: null, created_at: now, updated_at: now });
}
if (!hasType('last')) {
const p = pickPrompt(EXPORT_LAST_FRAME_TYPES);
if (p) out.push({ frame_type: 'last', prompt: p, description: null, layout: null, created_at: now, updated_at: now });
}
return out;
}
/** 解析 storyboard.characters JSON 字段,返回 ID 数组 */
function parseSbChars(raw) {
if (!raw) return [];
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(arr) ? arr.map(Number).filter(n => !isNaN(n)) : [];
} catch (_) { return []; }
}
/**
* 导出一个剧集为 ZIP Buffer
* @returns {Buffer}
*/
function exportDrama(db, cfg, log, dramaId) {
const storagePath = getStoragePath(cfg);
// ---- 1. 读取 drama 基本信息 ----
const drama = db.prepare('SELECT * FROM dramas WHERE id = ? AND deleted_at IS NULL').get(Number(dramaId));
if (!drama) throw new Error('剧本不存在');
let metadata = {};
try { metadata = drama.metadata ? (typeof drama.metadata === 'string' ? JSON.parse(drama.metadata) : drama.metadata) : {}; } catch (_) {}
// ---- 2. 读取所有剧集 ----
const episodes = db.prepare(
'SELECT * FROM episodes WHERE drama_id = ? AND deleted_at IS NULL ORDER BY episode_number'
).all(Number(dramaId));
// ---- 3. 读取各集分镜 ----
const episodeIds = episodes.map(e => e.id);
const storyboardsByEp = {};
for (const ep of episodes) {
storyboardsByEp[ep.id] = db.prepare(
'SELECT * FROM storyboards WHERE episode_id = ? AND deleted_at IS NULL ORDER BY storyboard_number'
).all(ep.id);
}
// ---- 4. 读取分镜图(完整历史 + 首尾帧 first/last)和视频(取最新完成的) ----
const allSbIds = Object.values(storyboardsByEp).flat().map(s => s.id);
const allImagesBySb = {}; // sbId -> 所有 image_generations 记录(用于导出历史和首尾帧绑定)
const videosBySb = {};
for (const sbId of allSbIds) {
// 导出所有非删除的图片生成记录(含历史、首尾帧、各种 frame_type),仅打包有 local_path 的文件
const igs = db.prepare(
"SELECT * FROM image_generations WHERE storyboard_id = ? AND deleted_at IS NULL ORDER BY created_at ASC"
).all(sbId);
allImagesBySb[sbId] = igs.filter(ig => ig && ig.local_path);
const vg = db.prepare(
"SELECT video_url, local_path FROM video_generations WHERE storyboard_id = ? AND status = 'completed' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1"
).get(sbId);
if (vg) videosBySb[sbId] = vg;
}
// 收集需要打包的分镜图片文件(完整历史)
const imageFilesToPack = [];
for (const [sbIdStr, igs] of Object.entries(allImagesBySb)) {
const sbId = Number(sbIdStr);
for (const ig of igs) {
if (!ig.local_path) continue;
const zipPath = `media/storyboards/sb_${sbId}_gen_${ig.id}${extOf(ig.local_path)}`;
imageFilesToPack.push({ localRelPath: ig.local_path, zipPath });
}
}
// 预查询各分镜的帧提示词(首尾帧专用提示词编辑器内容,必须导出否则导入后丢失)
const framePromptsBySb = {};
for (const sbId of allSbIds) {
try {
const fps = db.prepare('SELECT frame_type, prompt, description, layout, created_at, updated_at FROM frame_prompts WHERE storyboard_id = ? ORDER BY created_at ASC').all(sbId);
framePromptsBySb[sbId] = supplementFramePromptsFromImageGens(db, sbId, fps);
} catch (_) { framePromptsBySb[sbId] = []; }
}
// ---- 5. 读取角色 ----
const characters = db.prepare(
'SELECT * FROM characters WHERE drama_id = ? AND deleted_at IS NULL ORDER BY sort_order, id'
).all(Number(dramaId));
// ---- 6. 读取场景 ----
const scenes = db.prepare(
'SELECT * FROM scenes WHERE drama_id = ? AND deleted_at IS NULL ORDER BY id'
).all(Number(dramaId));
// ---- 7. 读取道具 ----
const props = db.prepare(
'SELECT * FROM props WHERE drama_id = ? AND deleted_at IS NULL ORDER BY id'
).all(Number(dramaId));
// ---- 场景去重(数据库中可能存在同 location+time 的重复记录,导出时只保留第一条)----
const seenSceneKeys = new Set();
const dedupedScenes = [];
for (const s of scenes) {
const key = `${(s.location || '').trim()}|${(s.time || '').trim()}`;
if (seenSceneKeys.has(key)) continue;
seenSceneKeys.add(key);
dedupedScenes.push(s);
}
// 为去重后被丢弃的重复场景 ID 建立到保留场景的映射,确保分镜 scene_index 仍指向保留的场景
const sceneDedupeIdMap = new Map(); // 原 ID → 保留后的同 key 首个 ID
for (const s of scenes) {
const key = `${(s.location || '').trim()}|${(s.time || '').trim()}`;
const kept = dedupedScenes.find(d => `${(d.location||'').trim()}|${(d.time||'').trim()}` === key);
if (kept) sceneDedupeIdMap.set(s.id, kept.id);
}
// ---- 构建 ID → 导出数组下标 的映射(用于分镜 characters/scene_id/prop_ids 跨项目还原) ----
const charIdToIndex = {};
characters.forEach((c, idx) => { charIdToIndex[c.id] = idx; });
const sceneIdToIndex = {};
dedupedScenes.forEach((s, idx) => { sceneIdToIndex[s.id] = idx; });
// 去重丢弃的重复场景 ID 也指向保留场景的下标
for (const [origId, keptId] of sceneDedupeIdMap.entries()) {
if (!(origId in sceneIdToIndex)) sceneIdToIndex[origId] = sceneIdToIndex[keptId];
}
const propIdToIndex = {};
props.forEach((p, idx) => { propIdToIndex[p.id] = idx; });
// ---- 读取所有分镜的道具关联(storyboard_props ----
const allSbIdsForProps = Object.values(storyboardsByEp).flat().map(s => s.id);
const sbPropIds = {}; // storyboard_id → prop_id[]
if (allSbIdsForProps.length > 0) {
const placeholders = allSbIdsForProps.map(() => '?').join(',');
const spRows = db.prepare(
`SELECT storyboard_id, prop_id FROM storyboard_props WHERE storyboard_id IN (${placeholders})`
).all(...allSbIdsForProps);
for (const row of spRows) {
if (!sbPropIds[row.storyboard_id]) sbPropIds[row.storyboard_id] = [];
sbPropIds[row.storyboard_id].push(row.prop_id);
}
}
// ---- 8. 组装 project.json ----
// 收集 extra_images 需要打包的文件:{ localRelPath, zipPath }
const extraFilesToPack = [];
const zipData = {
version: EXPORT_VERSION,
exported_at: new Date().toISOString(),
drama: {
title: drama.title,
description: drama.description,
genre: drama.genre,
style: drama.style,
status: drama.status,
tags: drama.tags,
metadata,
},
episodes: episodes.map(ep => {
const sbs = storyboardsByEp[ep.id] || [];
return {
episode_number: ep.episode_number,
title: ep.title,
description: ep.description,
script_content: ep.script_content,
duration: ep.duration,
storyboards: sbs.map(sb => {
const igsForThis = allImagesBySb[sb.id] || [];
// 兼容:仍提供 image_file(指向首帧或最新一张),旧版导入器可继续工作
let mainIg = igsForThis.find(g => g.id === sb.first_frame_image_id) || igsForThis[igsForThis.length - 1];
const sbImageFile = mainIg ? `media/storyboards/sb_${sb.id}_gen_${mainIg.id}${extOf(mainIg.local_path)}` : null;
const vg = videosBySb[sb.id];
const sbVideoFile = vg && vg.local_path ? `media/videos/sb_${sb.id}${extOf(vg.local_path)}` : null;
const sbAudioFile = sb.audio_local_path
? `media/audio/sb_${sb.id}${extOf(sb.audio_local_path)}`
: null;
const sbNarrationAudioFile = sb.narration_audio_local_path
? `media/audio/sb_${sb.id}_narration${extOf(sb.narration_audio_local_path)}`
: null;
// characters: 存储角色在导出列表中的下标(而非原 ID),方便跨项目恢复
const charIds = parseSbChars(sb.characters);
const characterIndices = charIds
.map(id => charIdToIndex[id])
.filter(idx => idx !== undefined);
// scene_id: 存储场景在导出列表中的下标
const sceneIndex = sb.scene_id != null ? (sceneIdToIndex[sb.scene_id] ?? null) : null;
// prop_ids: 存储道具在导出列表中的下标(storyboard_props 关联)
const sbPropIdList = sbPropIds[sb.id] || [];
const propIndices = sbPropIdList
.map(id => propIdToIndex[id])
.filter(idx => idx !== undefined);
return {
storyboard_number: sb.storyboard_number,
title: sb.title,
description: sb.description,
location: sb.location,
time: sb.time,
dialogue: sb.dialogue,
narration: sb.narration || null,
action: sb.action,
atmosphere: sb.atmosphere,
result: sb.result,
shot_type: sb.shot_type,
angle: sb.angle,
angle_h: sb.angle_h || null,
angle_v: sb.angle_v || null,
angle_s: sb.angle_s || null,
movement: sb.movement,
lighting_style: sb.lighting_style || null,
depth_of_field: sb.depth_of_field || null,
image_prompt: sb.image_prompt,
polished_prompt: sb.polished_prompt || null,
video_prompt: sb.video_prompt,
duration: sb.duration,
emotion: sb.emotion,
emotion_intensity: sb.emotion_intensity,
segment_index: sb.segment_index ?? 0,
segment_title: sb.segment_title || null,
continuity_snapshot: sb.continuity_snapshot || null,
creation_mode: sb.creation_mode === 'universal' ? 'universal' : 'classic',
universal_segment_text: sb.universal_segment_text || null,
layout_description: sb.layout_description || null,
// 用 original_id 记录首尾帧绑定的 image_generations 旧ID,导入时映射回新ID
first_frame_image_original_id: sb.first_frame_image_id ?? null,
last_frame_image_original_id: sb.last_frame_image_id ?? null,
last_frame_image_url: sb.last_frame_image_url || null,
last_frame_local_path: sb.last_frame_local_path || null,
character_indices: characterIndices,
scene_index: sceneIndex,
prop_indices: propIndices,
image_file: sbImageFile,
video_file: sbVideoFile,
audio_file: sbAudioFile,
narration_audio_file: sbNarrationAudioFile,
// 完整分镜图片历史(含首尾帧),导入后可恢复 getSbAllImages + 绑定
image_generations: igsForThis.map(ig => ({
original_id: ig.id,
provider: ig.provider || 'imported',
prompt: ig.prompt || null,
negative_prompt: ig.negative_prompt || null,
model: ig.model || null,
frame_type: ig.frame_type || null,
size: ig.size || null,
quality: ig.quality || null,
status: ig.status || 'completed',
error_msg: ig.error_msg || null,
created_at: ig.created_at || null,
updated_at: ig.updated_at || null,
completed_at: ig.completed_at || null,
zip_file: `media/storyboards/sb_${sb.id}_gen_${ig.id}${extOf(ig.local_path)}`,
})),
// 首尾帧提示词编辑器保存的专业提示词(含 layout)
frame_prompts: framePromptsBySb[sb.id] || [],
};
}),
};
}),
characters: characters.map((c, idx) => {
// 收集 extra_images 文件
const extras = parseExtraImages(c.extra_images);
const extraFiles = extras.map((relPath, i) => {
const zipPath = `media/characters/extra_char_${c.id}_${i}${extOf(relPath)}`;
extraFilesToPack.push({ localRelPath: relPath, zipPath });
return zipPath;
});
return {
name: c.name,
role: c.role,
description: c.description,
personality: c.personality,
appearance: c.appearance,
voice_style: c.voice_style,
polished_prompt: c.polished_prompt || null,
image_file: c.local_path ? `media/characters/char_${c.id}${extOf(c.local_path)}` : null,
extra_image_files: extraFiles,
};
}),
scenes: dedupedScenes.map(s => {
const epIdx = episodeIds.indexOf(s.episode_id);
const extras = parseExtraImages(s.extra_images);
const extraFiles = extras.map((relPath, i) => {
const zipPath = `media/scenes/extra_scene_${s.id}_${i}${extOf(relPath)}`;
extraFilesToPack.push({ localRelPath: relPath, zipPath });
return zipPath;
});
return {
location: s.location,
time: s.time,
prompt: s.prompt,
polished_prompt: s.polished_prompt || null,
episode_index: epIdx >= 0 ? epIdx : null,
image_file: s.local_path ? `media/scenes/scene_${s.id}${extOf(s.local_path)}` : null,
extra_image_files: extraFiles,
};
}),
props: props.map(p => {
const epIdx = episodeIds.indexOf(p.episode_id);
const extras = parseExtraImages(p.extra_images);
const extraFiles = extras.map((relPath, i) => {
const zipPath = `media/props/extra_prop_${p.id}_${i}${extOf(relPath)}`;
extraFilesToPack.push({ localRelPath: relPath, zipPath });
return zipPath;
});
return {
name: p.name,
type: p.type,
description: p.description,
prompt: p.prompt,
episode_index: epIdx >= 0 ? epIdx : null,
image_file: p.local_path ? `media/props/prop_${p.id}${extOf(p.local_path)}` : null,
extra_image_files: extraFiles,
};
}),
};
// ---- 9. 打包 ZIP ----
const zip = new AdmZip();
zip.addFile('project.json', Buffer.from(JSON.stringify(zipData, null, 2), 'utf8'));
// 分镜图片完整历史(含首尾帧 first/last 专用图 + 所有历史生成)
for (const { localRelPath, zipPath } of imageFilesToPack) {
const abs = localPathToAbs(storagePath, localRelPath);
const buf = safeReadFile(abs);
if (buf) zip.addFile(zipPath, buf);
}
// 分镜视频
for (const [sbId, vg] of Object.entries(videosBySb)) {
if (vg.local_path) {
const abs = localPathToAbs(storagePath, vg.local_path);
const buf = safeReadFile(abs);
if (buf) zip.addFile(`media/videos/sb_${sbId}${extOf(vg.local_path)}`, buf);
}
}
// 分镜对白 TTS / 解说旁白 TTS(分字段存储)
for (const ep of episodes) {
for (const sb of storyboardsByEp[ep.id] || []) {
if (sb.audio_local_path) {
const abs = localPathToAbs(storagePath, sb.audio_local_path);
const buf = safeReadFile(abs);
if (buf) zip.addFile(`media/audio/sb_${sb.id}${extOf(sb.audio_local_path)}`, buf);
}
if (sb.narration_audio_local_path) {
const abs = localPathToAbs(storagePath, sb.narration_audio_local_path);
const buf = safeReadFile(abs);
if (buf) zip.addFile(`media/audio/sb_${sb.id}_narration${extOf(sb.narration_audio_local_path)}`, buf);
}
}
}
// 角色主图
for (const c of characters) {
if (c.local_path) {
const abs = localPathToAbs(storagePath, c.local_path);
const buf = safeReadFile(abs);
if (buf) zip.addFile(`media/characters/char_${c.id}${extOf(c.local_path)}`, buf);
}
}
// 场景主图
for (const s of dedupedScenes) {
if (s.local_path) {
const abs = localPathToAbs(storagePath, s.local_path);
const buf = safeReadFile(abs);
if (buf) zip.addFile(`media/scenes/scene_${s.id}${extOf(s.local_path)}`, buf);
}
}
// 道具主图
for (const p of props) {
if (p.local_path) {
const abs = localPathToAbs(storagePath, p.local_path);
const buf = safeReadFile(abs);
if (buf) zip.addFile(`media/props/prop_${p.id}${extOf(p.local_path)}`, buf);
}
}
// extra_images(角色/场景/道具的额外参考图)
for (const { localRelPath, zipPath } of extraFilesToPack) {
const abs = localPathToAbs(storagePath, localRelPath);
const buf = safeReadFile(abs);
if (buf) zip.addFile(zipPath, buf);
}
log.info('Drama exported', { drama_id: dramaId, title: drama.title });
return { buffer: zip.toBuffer(), title: drama.title };
}
module.exports = { exportDrama };
@@ -0,0 +1,459 @@
// 项目导入服务:解析 ZIP,还原剧集数据和媒体文件
const fs = require('fs');
const path = require('path');
const AdmZip = require('adm-zip');
const { randomUUID } = require('crypto');
const storageLayout = require('./storageLayout');
function getStoragePath(cfg) {
const raw = cfg?.storage?.local_path || './data/storage';
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
/**
* 解析 ZIP Buffer,返回 project.json 内容和媒体文件 Map
* @returns {{ data: object, files: Map<string,Buffer> }}
*/
function parseZip(zipBuffer) {
let zip;
try {
zip = new AdmZip(zipBuffer);
} catch (e) {
throw new Error('ZIP 文件损坏,无法解析');
}
const projectEntry = zip.getEntry('project.json');
if (!projectEntry) {
throw new Error('ZIP 格式不正确:缺少 project.json');
}
let data;
try {
data = JSON.parse(projectEntry.getData().toString('utf8'));
} catch (e) {
throw new Error('project.json 格式错误,无法解析 JSON');
}
if (!data.drama || !data.drama.title) {
throw new Error('project.json 格式不正确:缺少 drama.title 字段');
}
// 读取所有媒体文件到 Map
const files = new Map();
for (const entry of zip.getEntries()) {
if (!entry.isDirectory && entry.entryName !== 'project.json') {
files.set(entry.entryName, entry.getData());
}
}
return { data, files };
}
/**
* 生成不重名的剧集标题
*/
function resolveTitle(db, baseTitle) {
const existing = db.prepare('SELECT title FROM dramas WHERE deleted_at IS NULL').all().map(r => r.title);
if (!existing.includes(baseTitle)) return baseTitle;
let i = 1;
while (existing.includes(`${baseTitle} 导入${i}`)) i++;
return `${baseTitle} 导入${i}`;
}
/**
* 保存媒体文件到 storage,返回相对路径
* @param {string} projectDir 如 projects/0001_20250324_剧名,与工程内其它媒体一致
*/
function saveMediaFile(storagePath, projectDir, category, files, zipPath, prefix) {
if (!zipPath) return null;
const buf = files.get(zipPath);
if (!buf) return null;
const ext = path.extname(zipPath) || '.jpg';
const categoryPath = path.join(storagePath, projectDir, category);
ensureDir(categoryPath);
const name = `${prefix}_${randomUUID().slice(0, 8)}${ext}`;
const abs = path.join(categoryPath, name);
fs.writeFileSync(abs, buf);
return `${projectDir}/${category}/${name}`.replace(/\\/g, '/');
}
/**
* 批量保存 extra_image_files 数组,返回本地路径 JSON 字符串
*/
const IMPORT_FIRST_FRAME_TYPES = ['storyboard_first', 'first', 'first_frame'];
const IMPORT_LAST_FRAME_TYPES = ['storyboard_last', 'last', 'tail', 'last_frame'];
/** 老版 ZIP 或未写入 frame_prompts 时,从已导入的首尾帧图生记录回填提示词 */
function restoreFramePromptsFromImageGens(db, sbId, now, log) {
const insFp = db.prepare(
'INSERT INTO frame_prompts (storyboard_id, frame_type, prompt, description, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
for (const [types, frameType] of [[IMPORT_FIRST_FRAME_TYPES, 'first'], [IMPORT_LAST_FRAME_TYPES, 'last']]) {
const has = db.prepare('SELECT id FROM frame_prompts WHERE storyboard_id = ? AND frame_type = ?').get(sbId, frameType);
if (has) continue;
const ph = types.map(() => '?').join(',');
const ig = db.prepare(
`SELECT prompt FROM image_generations WHERE storyboard_id = ? AND deleted_at IS NULL
AND frame_type IN (${ph}) AND prompt IS NOT NULL AND TRIM(prompt) != ''
ORDER BY created_at DESC LIMIT 1`
).get(sbId, ...types);
if (ig?.prompt?.trim()) {
insFp.run(sbId, frameType, ig.prompt.trim(), null, null, now, now);
try { log?.info?.('[导入] 从分镜图历史恢复帧提示词', { storyboard_id: sbId, frame_type: frameType }); } catch (_) {}
}
}
}
function saveExtraImages(storagePath, projectDir, category, files, zipPaths, prefix) {
if (!Array.isArray(zipPaths) || zipPaths.length === 0) return null;
const localPaths = [];
for (const zipPath of zipPaths) {
const localPath = saveMediaFile(storagePath, projectDir, category, files, zipPath, prefix);
if (localPath) localPaths.push(localPath);
}
return localPaths.length > 0 ? JSON.stringify(localPaths) : null;
}
/**
* 导入 ZIP,创建剧集并还原所有数据
* @param {Buffer} zipBuffer
* @returns {{ drama_id: number, title: string }}
*/
function importDrama(db, cfg, log, zipBuffer) {
const storagePath = getStoragePath(cfg);
const { data, files } = parseZip(zipBuffer);
const d = data.drama;
const title = resolveTitle(db, d.title || '导入项目');
const now = new Date().toISOString();
let metadata = d.metadata || {};
if (typeof metadata === 'string') {
try {
metadata = JSON.parse(metadata);
} catch (_) {
metadata = {};
}
}
metadata.storage_folder_label = storageLayout.sanitizeFolderLabel(title);
const metaStr = JSON.stringify(metadata);
// 用事务包裹全部写入:任何步骤失败时整体回滚,避免部分导入
let result;
const runImport = db.transaction(() => {
result = _doImport(db, storagePath, files, data, d, title, metaStr, now, log);
});
runImport();
return result;
}
function _doImport(db, storagePath, files, data, d, title, metaStr, now, log) {
// ---- 创建 drama ----
const dramaInfo = db.prepare(
`INSERT INTO dramas (title, description, genre, style, status, tags, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
title,
d.description || null,
d.genre || null,
d.style || null,
d.status || 'draft',
d.tags || null,
metaStr,
now,
now
);
const dramaId = dramaInfo.lastInsertRowid;
const projectDir = storageLayout.buildProjectRelativeDir({
id: dramaId,
title,
created_at: now,
metadata: metaStr,
});
// ---- 导入角色 ----
const charNewIds = []; // 按导出顺序保存新角色 id,用于恢复分镜 character_indices
for (let i = 0; i < (data.characters || []).length; i++) {
const c = data.characters[i];
if (!c.name) { charNewIds.push(null); continue; }
const localPath = saveMediaFile(storagePath, projectDir, 'characters', files, c.image_file, 'char_imp');
const extraImagesJson = saveExtraImages(storagePath, projectDir, 'characters', files, c.extra_image_files, 'char_extra_imp');
const info = db.prepare(
`INSERT INTO characters (drama_id, name, role, description, personality, appearance, voice_style, polished_prompt, local_path, extra_images, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(dramaId, c.name, c.role || null, c.description || null, c.personality || null, c.appearance || null, c.voice_style || null, c.polished_prompt || null, localPath, extraImagesJson, i, now, now);
charNewIds.push(info.lastInsertRowid);
}
// ---- 导入剧集(先建好所有集,再关联角色/场景/道具) ----
const episodeIdList = []; // 按顺序保存新集 id
for (const ep of (data.episodes || [])) {
const epInfo = db.prepare(
`INSERT INTO episodes (drama_id, episode_number, title, description, script_content, duration, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(dramaId, ep.episode_number || 1, ep.title || `${ep.episode_number || 1}`, ep.description || null, ep.script_content || null, ep.duration || 0, now, now);
episodeIdList.push(epInfo.lastInsertRowid);
}
// ---- 关联角色到所有集(episode_characters ----
if (charNewIds.length > 0 && episodeIdList.length > 0) {
const insEC = db.prepare('INSERT OR IGNORE INTO episode_characters (episode_id, character_id) VALUES (?, ?)');
for (const charId of charNewIds) {
if (!charId) continue;
for (const epId of episodeIdList) {
try { insEC.run(epId, charId); } catch (_) {}
}
}
}
// ---- 导入场景(带 episode_id,按 location+time 去重:同名场景只创建一条记录)----
const sceneNewIds = []; // 按导出顺序保存新场景 id(含去重后的映射),用于恢复分镜 scene_index
const sceneDedupeMap = new Map(); // key: "location|time" → 已创建的 scene id
for (let i = 0; i < (data.scenes || []).length; i++) {
const s = data.scenes[i];
const dedupeKey = `${(s.location || '').trim()}|${(s.time || '').trim()}`;
if (sceneDedupeMap.has(dedupeKey)) {
// 同 location+time 已存在,直接复用,不重复插入
sceneNewIds.push(sceneDedupeMap.get(dedupeKey));
continue;
}
const epIdx = s.episode_index;
const epId = (epIdx != null && epIdx >= 0 && episodeIdList[epIdx])
? episodeIdList[epIdx]
: (episodeIdList[0] || null);
const localPath = saveMediaFile(storagePath, projectDir, 'scenes', files, s.image_file, 'scene_imp');
const extraImagesJson = saveExtraImages(storagePath, projectDir, 'scenes', files, s.extra_image_files, 'scene_extra_imp');
const info = db.prepare(
`INSERT INTO scenes (drama_id, episode_id, location, time, prompt, polished_prompt, local_path, extra_images, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(dramaId, epId, s.location || '', s.time || '', s.prompt || '', s.polished_prompt || null, localPath, extraImagesJson, now, now);
sceneNewIds.push(info.lastInsertRowid);
sceneDedupeMap.set(dedupeKey, info.lastInsertRowid);
}
// ---- 导入道具(带 episode_id ----
const propNewIds = []; // 按导出顺序保存新道具 id,用于恢复 storyboard_props
for (const p of (data.props || [])) {
if (!p.name) { propNewIds.push(null); continue; }
const epIdx = p.episode_index;
const epId = (epIdx != null && epIdx >= 0 && episodeIdList[epIdx])
? episodeIdList[epIdx]
: (episodeIdList[0] || null);
const localPath = saveMediaFile(storagePath, projectDir, 'props', files, p.image_file, 'prop_imp');
const extraImagesJson = saveExtraImages(storagePath, projectDir, 'props', files, p.extra_image_files, 'prop_extra_imp');
const pInfo = db.prepare(
`INSERT INTO props (drama_id, episode_id, name, type, description, prompt, local_path, extra_images, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(dramaId, epId, p.name, p.type || null, p.description || null, p.prompt || null, localPath, extraImagesJson, now, now);
propNewIds.push(pInfo.lastInsertRowid);
}
// ---- 导入分镜 ----
for (let epIdx = 0; epIdx < (data.episodes || []).length; epIdx++) {
const ep = data.episodes[epIdx];
const episodeId = episodeIdList[epIdx];
if (!episodeId) continue;
for (const sb of (ep.storyboards || [])) {
const sbAudioPath = saveMediaFile(storagePath, projectDir, 'audio', files, sb.audio_file, 'sb_audio_imp');
const sbNarrationAudioPath = saveMediaFile(storagePath, projectDir, 'audio', files, sb.narration_audio_file, 'sb_narr_audio_imp');
// 还原 characters:从导出时记录的下标映射回新 ID
const charIndices = Array.isArray(sb.character_indices) ? sb.character_indices : [];
const sbCharIds = charIndices
.map(idx => charNewIds[idx])
.filter(id => id != null);
const charactersJson = JSON.stringify(sbCharIds);
// 还原 scene_id:从导出时记录的下标映射回新 ID
const sbSceneId = (sb.scene_index != null && sceneNewIds[sb.scene_index])
? sceneNewIds[sb.scene_index]
: null;
// 还原 prop_ids:从导出时记录的下标映射回新 ID
const propIndices = Array.isArray(sb.prop_indices) ? sb.prop_indices : [];
const sbPropNewIds = propIndices
.map(idx => propNewIds[idx])
.filter(id => id != null);
// 先插入分镜(首尾帧绑定ID、layout 稍后更新;image_url/local_path 由绑定逻辑设置)
// 使用并行数组维护列名与值,确保列数与传参数量永远一致,避免“44 values for 43 columns”类错误
const sbCols = [
'episode_id', 'scene_id', 'storyboard_number', 'title', 'description', 'location', 'time',
'dialogue', 'narration', 'action', 'atmosphere', 'result', 'shot_type', 'angle', 'angle_h', 'angle_v', 'angle_s',
'movement', 'lighting_style', 'depth_of_field', 'image_prompt', 'polished_prompt', 'video_prompt', 'duration',
'emotion', 'emotion_intensity', 'segment_index', 'segment_title', 'continuity_snapshot', 'creation_mode',
'universal_segment_text', 'layout_description', 'first_frame_image_id', 'last_frame_image_id',
'last_frame_image_url', 'last_frame_local_path', 'image_url', 'local_path', 'characters',
'audio_local_path', 'narration_audio_local_path', 'created_at', 'updated_at'
];
const sbVals = [
episodeId,
sbSceneId,
sb.storyboard_number || 1,
sb.title || null,
sb.description || null,
sb.location || null,
sb.time || null,
sb.dialogue || null,
sb.narration || null,
sb.action || null,
sb.atmosphere || null,
sb.result || null,
sb.shot_type || null,
sb.angle || null,
sb.angle_h || null,
sb.angle_v || null,
sb.angle_s || null,
sb.movement || null,
sb.lighting_style || null,
sb.depth_of_field || null,
sb.image_prompt || null,
sb.polished_prompt || null,
sb.video_prompt || null,
sb.duration || 0,
sb.emotion || null,
sb.emotion_intensity != null ? sb.emotion_intensity : null,
sb.segment_index ?? 0,
sb.segment_title || null,
sb.continuity_snapshot || null,
sb.creation_mode === 'universal' ? 'universal' : 'classic',
sb.universal_segment_text || null,
sb.layout_description || null,
null, // first_frame_image_id 后设
null, // last_frame_image_id 后设
sb.last_frame_image_url || null,
sb.last_frame_local_path || null,
null, // image_url 由首帧绑定设置
null, // local_path 由首帧绑定设置
charactersJson,
sbAudioPath || null,
sbNarrationAudioPath || null,
now,
now
];
if (sbCols.length !== sbVals.length) {
throw new Error(`storyboards 导入列数不匹配: cols=${sbCols.length}, vals=${sbVals.length}`);
}
const sbInfo = db.prepare(
`INSERT INTO storyboards (${sbCols.join(', ')})
VALUES (${sbCols.map(() => '?').join(', ')})`
).run(...sbVals);
const sbId = sbInfo.lastInsertRowid;
// 还原 storyboard_props(分镜与道具的关联)
if (sbPropNewIds.length > 0) {
const insSP = db.prepare('INSERT OR IGNORE INTO storyboard_props (storyboard_id, prop_id) VALUES (?, ?)');
for (const pid of sbPropNewIds) insSP.run(sbId, pid);
}
// 还原帧提示词(首尾帧/关键帧专用提示词 + layout 合同,必须恢复)
if (Array.isArray(sb.frame_prompts) && sb.frame_prompts.length > 0) {
const insFp = db.prepare('INSERT INTO frame_prompts (storyboard_id, frame_type, prompt, description, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const fp of sb.frame_prompts) {
insFp.run(sbId, fp.frame_type || 'first', fp.prompt || '', fp.description || null, fp.layout || null, fp.created_at || now, fp.updated_at || now);
}
try { require('../logger').info?.('[导入] 已恢复帧提示词', { storyboard_id: sbId, count: sb.frame_prompts.length }); } catch (_) {}
}
// 导入分镜图片完整历史(新版 v1.4+ 的 image_generations 数组;老版回退单张)
const genOldToNew = new Map(); // original_id -> {newId, localPath}
if (Array.isArray(sb.image_generations) && sb.image_generations.length > 0) {
for (const gen of sb.image_generations) {
const genLocalPath = saveMediaFile(storagePath, projectDir, 'images', files, gen.zip_file || gen.file, 'sb_imp_gen');
if (genLocalPath) {
const genInfo = db.prepare(
`INSERT INTO image_generations (drama_id, storyboard_id, provider, prompt, negative_prompt, model, frame_type, size, quality, status, error_msg, local_path, created_at, updated_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
dramaId,
sbId,
gen.provider || 'imported',
gen.prompt || sb.image_prompt || '',
gen.negative_prompt || null,
gen.model || null,
gen.frame_type || null,
gen.size || null,
gen.quality || null,
gen.status || 'completed',
gen.error_msg || null,
genLocalPath,
gen.created_at || now,
now,
gen.completed_at || now
);
const newGenId = genInfo.lastInsertRowid;
if (gen.original_id != null) {
genOldToNew.set(Number(gen.original_id), { newId: newGenId, localPath: genLocalPath });
}
}
}
} else {
// 老版兼容:仅单张 image_file(导入后只有这一个历史图,首尾帧绑定丢失是旧行为)
const sbImagePath = saveMediaFile(storagePath, projectDir, 'images', files, sb.image_file, 'sb_imp');
if (sbImagePath) {
db.prepare(
`INSERT INTO image_generations (drama_id, storyboard_id, provider, prompt, status, local_path, created_at, updated_at)
VALUES (?, ?, 'imported', ?, 'completed', ?, ?, ?)`
).run(dramaId, sbId, sb.image_prompt || '', sbImagePath, now, now);
}
}
// 导入视频(仍保持单条最新,视频首尾帧 URL 由生成时绑定)
if (sb.video_file) {
const videoLocalPath = saveMediaFile(storagePath, projectDir, 'videos', files, sb.video_file, 'vid_imp');
if (videoLocalPath) {
db.prepare(
`INSERT INTO video_generations (drama_id, storyboard_id, provider, prompt, status, local_path, created_at, updated_at)
VALUES (?, ?, 'imported', ?, 'completed', ?, ?, ?)`
).run(dramaId, sbId, sb.video_prompt || '', videoLocalPath, now, now);
}
}
// 绑定首尾帧到 storyboards(关键:恢复 first_frame_image_id + image_url/local_path,以及 last_*
const now2 = new Date().toISOString();
const firstOld = sb.first_frame_image_original_id ?? sb.first_frame_image_id;
const lastOld = sb.last_frame_image_original_id ?? sb.last_frame_image_id;
let boundFirst = false, boundLast = false;
if (firstOld != null && genOldToNew.has(Number(firstOld))) {
const { newId, localPath } = genOldToNew.get(Number(firstOld));
db.prepare(
`UPDATE storyboards SET image_url = ?, local_path = ?, first_frame_image_id = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`
).run(null, localPath, newId, now2, sbId);
boundFirst = true;
}
if (lastOld != null && genOldToNew.has(Number(lastOld))) {
const { newId, localPath } = genOldToNew.get(Number(lastOld));
db.prepare(
`UPDATE storyboards SET last_frame_image_url = ?, last_frame_local_path = ?, last_frame_image_id = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`
).run(null, localPath, newId, now2, sbId);
boundLast = true;
}
if ((sb.image_generations && sb.image_generations.length) || boundFirst || boundLast) {
try {
require('../logger').info?.('[导入] 分镜图片历史+首尾帧绑定完成', {
storyboard_id: sbId,
gens_restored: genOldToNew.size,
first_bound: boundFirst,
last_bound: boundLast,
had_original_first: firstOld != null,
had_original_last: lastOld != null
});
} catch (_) {}
}
// 兼容老工程:ZIP 无 frame_prompts 时,用已导入的首/尾帧图生 prompt 回填
restoreFramePromptsFromImageGens(db, sbId, now2, log);
}
}
log.info('Drama imported', { drama_id: dramaId, title });
return { drama_id: dramaId, title };
}
module.exports = { importDrama, parseZip };
+873
View File
@@ -0,0 +1,873 @@
// 对应 Go application/services/drama_service.go
const storageLayout = require('./storageLayout');
const { resolveStylePreset } = require('../constants/generationStylePresets');
const seedance2AssetGuards = require('../utils/seedance2AssetGuards');
/**
* 清理 image_url:如果数据库中存储的是 base64 data URL,则返回 null。
* 图片应通过 local_path → /static/{local_path} 访问,base64 不应通过 API 透传(会严重膨胀响应体)。
*/
function sanitizeImageUrl(url) {
if (!url) return null;
if (String(url).startsWith('data:')) return null;
return url;
}
function parseJsonColumn(value) {
if (value == null || value === '') return null;
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch (_) {
return null;
}
}
function createDrama(db, log, req) {
const now = new Date().toISOString();
let meta = {};
if (req.metadata) {
try {
meta =
typeof req.metadata === 'string'
? JSON.parse(req.metadata)
: { ...req.metadata };
} catch (_) {
meta = {};
}
}
if (!meta.storage_folder_label) {
meta.storage_folder_label = storageLayout.sanitizeFolderLabel(req.title || '');
}
const metadataStr = Object.keys(meta).length ? JSON.stringify(meta) : null;
const stmt = db.prepare(`
INSERT INTO dramas (title, description, genre, style, metadata, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?)
`);
const info = stmt.run(
req.title || '',
req.description || null,
req.genre || null,
req.style || 'realistic',
metadataStr,
now,
now
);
const id = info.lastInsertRowid;
log.info('Drama created', { drama_id: id });
return getDramaById(db, id);
}
function getDramaById(db, id) {
const row = db.prepare('SELECT * FROM dramas WHERE id = ? AND deleted_at IS NULL').get(id);
return row ? rowToDrama(row) : null;
}
function getDrama(db, dramaId, baseUrl) {
const drama = getDramaById(db, Number(dramaId));
if (!drama) return null;
// 加载 episodes、characters、scenes、props、storyboards(简化:只查当前 drama 的)
const episodes = db.prepare(
'SELECT * FROM episodes WHERE drama_id = ? AND deleted_at IS NULL ORDER BY episode_number ASC'
).all(drama.id);
drama.episodes = episodes.map((e) => rowToEpisode(e));
const { dedupeStoryboardRowsByNumber } = require('./episodeStoryboardService');
for (const ep of drama.episodes) {
const storyboards = dedupeStoryboardRowsByNumber(
db.prepare(
'SELECT * FROM storyboards WHERE episode_id = ? AND deleted_at IS NULL ORDER BY storyboard_number ASC, id ASC'
).all(ep.id)
);
ep.storyboards = storyboards.map((s) => rowToStoryboard(s));
// 批量加载 storyboard_props,附加到对应分镜
try {
const sbIds = ep.storyboards.map((s) => s.id);
if (sbIds.length > 0) {
const placeholders = sbIds.map(() => '?').join(',');
const spRows = db.prepare(`SELECT storyboard_id, prop_id FROM storyboard_props WHERE storyboard_id IN (${placeholders})`).all(...sbIds);
const spMap = {};
for (const row of spRows) {
if (!spMap[row.storyboard_id]) spMap[row.storyboard_id] = [];
spMap[row.storyboard_id].push(row.prop_id);
}
for (const sb of ep.storyboards) {
sb.prop_ids = spMap[sb.id] || [];
}
}
} catch (_) {}
ep.duration = ep.storyboards.reduce((sum, s) => sum + (s.duration || 0), 0);
if (ep.duration > 0) ep.duration = Math.ceil(ep.duration / 60); // 转为分钟
// 本集关联的角色(与 Go Preload("Episodes.Characters") 一致)
try {
const epChars = db.prepare(
`SELECT c.* FROM characters c
INNER JOIN episode_characters ec ON c.id = ec.character_id
WHERE ec.episode_id = ? AND c.deleted_at IS NULL
ORDER BY c.sort_order ASC, c.name ASC`
).all(ep.id);
ep.characters = epChars.map((c) => rowToCharacter(c));
} catch (_) {
ep.characters = [];
}
// 本集关联的场景(与 Go Preload("Episodes.Scenes") 一致,用于提取完成后展示)
try {
const epScenes = db.prepare(
'SELECT * FROM scenes WHERE episode_id = ? AND deleted_at IS NULL ORDER BY id ASC'
).all(ep.id);
ep.scenes = epScenes.map((s) => rowToScene(s));
} catch (_) {
ep.scenes = [];
}
// 本集关联的道具:本集提取的(episode_id=本集)+ 本集分镜中出现的(storyboard_props),合并去重
try {
const byEpisode = db.prepare(
'SELECT * FROM props WHERE episode_id = ? AND deleted_at IS NULL ORDER BY id ASC'
).all(ep.id);
const byStoryboard = db.prepare(
`SELECT DISTINCT p.* FROM props p
INNER JOIN storyboard_props sp ON p.id = sp.prop_id
INNER JOIN storyboards sb ON sb.id = sp.storyboard_id AND sb.episode_id = ? AND sb.deleted_at IS NULL
WHERE p.deleted_at IS NULL ORDER BY p.id ASC`
).all(ep.id);
const seen = new Set();
ep.props = [];
for (const p of byEpisode) {
if (!seen.has(p.id)) {
seen.add(p.id);
ep.props.push(rowToProp(p));
}
}
for (const p of byStoryboard) {
if (!seen.has(p.id)) {
seen.add(p.id);
ep.props.push(rowToProp(p));
}
}
ep.props.sort((a, b) => a.id - b.id);
} catch (_) {
ep.props = [];
}
}
const characters = db.prepare(
'SELECT * FROM characters WHERE drama_id = ? AND deleted_at IS NULL ORDER BY sort_order ASC, name ASC'
).all(drama.id);
drama.characters = characters.map((c) => rowToCharacter(c));
const scenes = db.prepare(
'SELECT * FROM scenes WHERE drama_id = ? AND deleted_at IS NULL ORDER BY id ASC'
).all(drama.id);
drama.scenes = scenes.map((s) => rowToScene(s));
const props = db.prepare(
'SELECT * FROM props WHERE drama_id = ? AND deleted_at IS NULL ORDER BY id ASC'
).all(drama.id);
drama.props = props.map((p) => rowToProp(p));
return drama;
}
function listDramas(db, query) {
let sql = 'FROM dramas WHERE deleted_at IS NULL';
const params = [];
if (query.status) {
sql += ' AND status = ?';
params.push(query.status);
}
if (query.genre) {
sql += ' AND genre = ?';
params.push(query.genre);
}
if (query.keyword) {
sql += ' AND (title LIKE ? OR description LIKE ?)';
const k = '%' + query.keyword + '%';
params.push(k, k);
}
const countRow = db.prepare('SELECT COUNT(*) as total ' + sql).get(...params);
const total = countRow.total || 0;
const page = Math.max(1, parseInt(query.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(query.page_size, 10) || 20));
const offset = (page - 1) * pageSize;
const list = db.prepare(
'SELECT * ' + sql + ' ORDER BY updated_at DESC LIMIT ? OFFSET ?'
).all(...params, pageSize, offset);
const dramas = list.map((r) => rowToDrama(r));
for (const d of dramas) {
const episodes = db.prepare(
'SELECT * FROM episodes WHERE drama_id = ? AND deleted_at IS NULL ORDER BY episode_number ASC'
).all(d.id);
d.episodes = episodes.map((e) => {
const ep = rowToEpisode(e);
const { dedupeStoryboardRowsByNumber } = require('./episodeStoryboardService');
const storyboards = dedupeStoryboardRowsByNumber(
db.prepare(
'SELECT * FROM storyboards WHERE episode_id = ? AND deleted_at IS NULL ORDER BY storyboard_number ASC, id ASC'
).all(ep.id)
);
ep.storyboards = storyboards.map((s) => rowToStoryboard(s));
try {
const sbIds = ep.storyboards.map((s) => s.id);
if (sbIds.length > 0) {
const placeholders = sbIds.map(() => '?').join(',');
const spRows = db.prepare(`SELECT storyboard_id, prop_id FROM storyboard_props WHERE storyboard_id IN (${placeholders})`).all(...sbIds);
const spMap = {};
for (const row of spRows) {
if (!spMap[row.storyboard_id]) spMap[row.storyboard_id] = [];
spMap[row.storyboard_id].push(row.prop_id);
}
for (const sb of ep.storyboards) sb.prop_ids = spMap[sb.id] || [];
}
} catch (_) {}
ep.duration = ep.storyboards.reduce((sum, s) => sum + (s.duration || 0), 0);
if (ep.duration > 0) ep.duration = Math.ceil(ep.duration / 60);
return ep;
});
}
return { dramas, total, page, pageSize };
}
function updateDrama(db, log, dramaId, req) {
const drama = getDramaById(db, Number(dramaId));
if (!drama) return null;
const updates = [];
const params = [];
if (req.title != null) {
updates.push('title = ?');
params.push(req.title);
}
if (req.description != null) {
updates.push('description = ?');
params.push(req.description || null);
}
if (req.genre != null) {
updates.push('genre = ?');
params.push(req.genre || null);
}
if (req.status != null) {
updates.push('status = ?');
params.push(req.status);
}
if (updates.length === 0) return drama;
params.push(new Date().toISOString(), dramaId);
db.prepare(
'UPDATE dramas SET ' + updates.join(', ') + ', updated_at = ? WHERE id = ?'
).run(...params);
log.info('Drama updated', { drama_id: dramaId });
return getDramaById(db, dramaId);
}
function generateStoryboard(db, log, episodeId, options) {
const episodeStoryboardService = require('./episodeStoryboardService');
const { model, style, storyboard_count, video_duration, aspect_ratio, include_narration, universal_omni_storyboard } = options || {};
// 转换可能为字符串的数字
const count = storyboard_count ? Number(storyboard_count) : undefined;
const duration = video_duration ? Number(video_duration) : undefined;
return episodeStoryboardService.generateStoryboard(
db,
log,
episodeId,
model || undefined,
style,
count,
duration,
aspect_ratio,
include_narration,
universal_omni_storyboard
);
}
function deleteDrama(db, log, dramaId) {
const result = db.prepare('UPDATE dramas SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL').run(
new Date().toISOString(),
Number(dramaId)
);
if (result.changes === 0) return false;
log.info('Drama deleted', { drama_id: dramaId });
return true;
}
function getDramaStats(db) {
const total = db.prepare('SELECT COUNT(*) as c FROM dramas WHERE deleted_at IS NULL').get().c;
const byStatus = db.prepare(
'SELECT status, COUNT(*) as count FROM dramas WHERE deleted_at IS NULL GROUP BY status'
).all();
return { total, by_status: byStatus };
}
function rowToDrama(r) {
let metadata = r.metadata;
if (typeof metadata === 'string') {
try {
metadata = JSON.parse(metadata);
} catch (e) {
metadata = {};
}
}
return {
id: r.id,
title: r.title,
description: r.description,
genre: r.genre,
style: r.style || 'realistic',
total_episodes: r.total_episodes ?? 1,
total_duration: r.total_duration ?? 0,
status: r.status || 'draft',
thumbnail: r.thumbnail,
tags: r.tags,
metadata: metadata || {},
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function rowToEpisode(r) {
return {
id: r.id,
drama_id: r.drama_id,
episode_number: r.episode_number,
title: r.title,
script_content: r.script_content,
description: r.description,
duration: r.duration ?? 0,
status: r.status || 'draft',
video_url: r.video_url,
thumbnail: r.thumbnail,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function parseStoryboardCharacters(charactersStr) {
if (!charactersStr || typeof charactersStr !== 'string') return [];
try {
const parsed = JSON.parse(charactersStr);
if (!Array.isArray(parsed)) return [];
return parsed.map((c) => (typeof c === 'object' && c != null && c.id != null ? Number(c.id) : Number(c))).filter((n) => Number.isFinite(n));
} catch (_) {
return [];
}
}
function rowToStoryboard(r) {
return {
id: r.id,
episode_id: r.episode_id,
scene_id: r.scene_id,
storyboard_number: r.storyboard_number,
title: r.title,
description: r.description,
location: r.location,
time: r.time,
duration: r.duration ?? 0,
dialogue: r.dialogue,
narration: r.narration ?? null,
action: r.action,
result: r.result ?? null,
atmosphere: r.atmosphere,
image_prompt: r.image_prompt,
polished_prompt: r.polished_prompt ?? null,
continuity_snapshot: r.continuity_snapshot ?? null,
video_prompt: r.video_prompt,
shot_type: r.shot_type ?? null,
angle: r.angle ?? null,
angle_h: r.angle_h ?? null,
angle_v: r.angle_v ?? null,
angle_s: r.angle_s ?? null,
movement: r.movement ?? null,
lighting_style: r.lighting_style ?? null,
depth_of_field: r.depth_of_field ?? null,
segment_index: r.segment_index ?? 0,
segment_title: r.segment_title ?? null,
creation_mode: r.creation_mode === 'universal' ? 'universal' : 'classic',
universal_segment_text: r.universal_segment_text ?? null,
first_frame_image_id: r.first_frame_image_id ?? null,
last_frame_image_id: r.last_frame_image_id ?? null,
last_frame_image_url: sanitizeImageUrl(r.last_frame_image_url),
last_frame_local_path: r.last_frame_local_path ?? null,
characters: parseStoryboardCharacters(r.characters),
composed_image: r.composed_image,
image_url: sanitizeImageUrl(r.image_url),
local_path: r.local_path ?? null,
main_panel_idx: r.main_panel_idx != null ? Number(r.main_panel_idx) : null,
video_url: r.video_url,
audio_local_path: r.audio_local_path ?? null,
narration_audio_local_path: r.narration_audio_local_path ?? null,
status: r.status || 'pending',
error_msg: r.error_msg,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function rowToCharacter(r) {
return {
id: r.id,
drama_id: r.drama_id,
name: r.name,
role: r.role,
description: r.description,
appearance: r.appearance,
personality: r.personality,
voice_style: r.voice_style,
image_url: sanitizeImageUrl(r.image_url),
local_path: r.local_path,
extra_images: r.extra_images || null,
ref_image: r.ref_image || null,
reference_images: r.reference_images,
seed_value: r.seed_value,
sort_order: r.sort_order ?? 0,
error_msg: r.error_msg,
polished_prompt: r.polished_prompt || null,
negative_prompt: r.negative_prompt || null,
four_view_image_url: r.four_view_image_url || null,
seedance2_asset: parseJsonColumn(r.seedance2_asset),
seedance2_voice_asset: parseJsonColumn(r.seedance2_voice_asset),
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function rowToScene(r) {
return {
id: r.id,
drama_id: r.drama_id,
location: r.location,
time: r.time,
prompt: r.prompt,
polished_prompt: r.polished_prompt || null,
negative_prompt: r.negative_prompt || null,
storyboard_count: r.storyboard_count ?? 1,
image_url: sanitizeImageUrl(r.image_url),
local_path: r.local_path,
extra_images: r.extra_images || null,
ref_image: r.ref_image || null,
status: r.status || 'pending',
error_msg: r.error_msg,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function rowToProp(r) {
return {
id: r.id,
drama_id: r.drama_id,
name: r.name,
type: r.type,
description: r.description,
prompt: r.prompt,
image_url: sanitizeImageUrl(r.image_url),
local_path: r.local_path,
extra_images: r.extra_images || null,
ref_image: r.ref_image || null,
negative_prompt: r.negative_prompt || null,
error_msg: r.error_msg,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
function saveOutline(db, log, dramaId, req) {
const drama = getDramaById(db, Number(dramaId));
if (!drama) return false;
const now = new Date().toISOString();
const tagsStr = Array.isArray(req.tags) ? JSON.stringify(req.tags) : null;
// Merge new metadata with existing metadata
let existingMetadata = {};
if (drama.metadata) {
try {
existingMetadata = typeof drama.metadata === 'string' ? JSON.parse(drama.metadata) : drama.metadata;
} catch (e) {
existingMetadata = {};
}
}
let newMetadata = {};
if (req.metadata) {
try {
newMetadata = typeof req.metadata === 'string' ? JSON.parse(req.metadata) : req.metadata;
} catch (e) {
newMetadata = {};
}
}
const mergedMetadata = { ...existingMetadata, ...newMetadata };
// 与 mergeCfgStyleWithDrama 一致:提示词优先读 metadata.style_prompt_*。仅改 dramas.style 而不带画风长文案时,
// 若仍保留旧的 metadata 画风,会出现「列表/首页 badge 已是新 style,角色提示词却仍用旧画风」。
if (req.style !== undefined) {
const styleVal = String(req.style || '').trim();
const hasExplicitStylePrompt =
req.metadata &&
typeof req.metadata === 'object' &&
!Array.isArray(req.metadata) &&
('style_prompt_zh' in req.metadata || 'style_prompt_en' in req.metadata);
if (!hasExplicitStylePrompt && styleVal) {
const preset = resolveStylePreset(styleVal);
if (preset) {
mergedMetadata.style_prompt_zh = preset.zh;
mergedMetadata.style_prompt_en = preset.en;
}
}
}
const metadataStr = JSON.stringify(mergedMetadata);
db.prepare(
`UPDATE dramas SET title = ?, description = ?, genre = ?, tags = ?, style = ?, metadata = ?, updated_at = ? WHERE id = ?`
).run(
req.title || drama.title,
req.summary ?? drama.description,
req.genre !== undefined ? req.genre : drama.genre,
tagsStr,
req.style !== undefined ? req.style : drama.style,
metadataStr,
now,
dramaId
);
log.info('Outline saved', { drama_id: dramaId, style: req.style, genre: req.genre, metadata: mergedMetadata });
return true;
}
function getCharacters(db, dramaId, episodeId) {
const did = Number(dramaId);
const drama = getDramaById(db, did);
if (!drama) return null;
let rows;
if (episodeId) {
const exists = db.prepare('SELECT 1 FROM episodes WHERE id = ? AND drama_id = ?').get(episodeId, did);
if (!exists) return null;
rows = db.prepare(
`SELECT c.* FROM characters c
INNER JOIN episode_characters ec ON ec.character_id = c.id
WHERE ec.episode_id = ? AND c.deleted_at IS NULL ORDER BY c.sort_order ASC, c.name ASC`
).all(episodeId);
} else {
rows = db.prepare(
'SELECT * FROM characters WHERE drama_id = ? AND deleted_at IS NULL ORDER BY sort_order ASC, name ASC'
).all(did);
}
const characters = rows.map((r) => rowToCharacter(r));
for (const c of characters) {
const img = db.prepare(
'SELECT status, error_msg FROM image_generations WHERE character_id = ? ORDER BY created_at DESC LIMIT 1'
).get(c.id);
if (img && ['pending', 'processing', 'failed'].includes(img.status)) {
c.image_generation_status = img.status;
if (img.error_msg) c.image_generation_error = img.error_msg;
}
}
return characters;
}
function saveCharacters(db, log, dramaId, req) {
const did = Number(dramaId);
const drama = getDramaById(db, did);
if (!drama) return false;
if (req.episode_id) {
const ep = db.prepare('SELECT 1 FROM episodes WHERE id = ? AND drama_id = ?').get(req.episode_id, did);
if (!ep) return false;
}
const characterIds = [];
const chars = req.characters || [];
for (const char of chars) {
if (char.id) {
const ex = db.prepare('SELECT id FROM characters WHERE id = ? AND drama_id = ?').get(char.id, did);
if (ex) {
characterIds.push(ex.id);
// 只更新文本字段;image_url / local_path 仅在调用方显式传入时才覆盖,防止漏传字段清空已有图片
const imgFields = [];
const imgParams = [];
if ('image_url' in char) { imgFields.push('image_url = ?'); imgParams.push(char.image_url ?? null); }
if ('local_path' in char) { imgFields.push('local_path = ?'); imgParams.push(char.local_path ?? null); }
if (imgFields.length > 0) {
const prevC = db
.prepare('SELECT id, local_path, image_url, seedance2_asset FROM characters WHERE id = ? AND deleted_at IS NULL')
.get(char.id);
if (prevC) {
seedance2AssetGuards.markStaleOnCharacterMainImageDrift(db, log, prevC, {
image_url: 'image_url' in char ? char.image_url : prevC.image_url,
local_path: 'local_path' in char ? char.local_path : prevC.local_path,
});
}
}
const imgSql = imgFields.length > 0 ? ', ' + imgFields.join(', ') : '';
let setCore = 'name = ?, role = ?, description = ?, personality = ?, appearance = ?';
const coreParams = [char.name, char.role ?? null, char.description ?? null, char.personality ?? null, char.appearance ?? null];
if ('negative_prompt' in char) {
setCore += ', negative_prompt = ?';
coreParams.push(char.negative_prompt ?? null);
}
db.prepare(
`UPDATE characters SET ${setCore}${imgSql}, updated_at = ? WHERE id = ?`
).run(...coreParams, ...imgParams, new Date().toISOString(), char.id);
continue;
}
}
const byName = db.prepare('SELECT id FROM characters WHERE drama_id = ? AND name = ?').get(did, char.name);
if (byName) {
characterIds.push(byName.id);
// 如果通过名字找到已存在的角色(包含软删除的),也要更新它的信息并复活
const imgFieldsN = [];
const imgParamsN = [];
if ('image_url' in char) { imgFieldsN.push('image_url = ?'); imgParamsN.push(char.image_url ?? null); }
if ('local_path' in char) { imgFieldsN.push('local_path = ?'); imgParamsN.push(char.local_path ?? null); }
if (imgFieldsN.length > 0) {
const prevN = db
.prepare('SELECT id, local_path, image_url, seedance2_asset FROM characters WHERE id = ?')
.get(byName.id);
if (prevN) {
seedance2AssetGuards.markStaleOnCharacterMainImageDrift(db, log, prevN, {
image_url: 'image_url' in char ? char.image_url : prevN.image_url,
local_path: 'local_path' in char ? char.local_path : prevN.local_path,
});
}
}
const imgSqlN = imgFieldsN.length > 0 ? ', ' + imgFieldsN.join(', ') : '';
let setCoreN = 'role = ?, description = ?, personality = ?, appearance = ?';
const coreParamsN = [char.role ?? null, char.description ?? null, char.personality ?? null, char.appearance ?? null];
if ('negative_prompt' in char) {
setCoreN += ', negative_prompt = ?';
coreParamsN.push(char.negative_prompt ?? null);
}
db.prepare(
`UPDATE characters SET ${setCoreN}${imgSqlN}, updated_at = ?, deleted_at = NULL WHERE id = ?`
).run(...coreParamsN, ...imgParamsN, new Date().toISOString(), byName.id);
continue;
}
const now = new Date().toISOString();
const info = db.prepare(
`INSERT INTO characters (drama_id, name, role, description, personality, appearance, image_url, local_path, negative_prompt, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
).run(did, char.name, char.role ?? null, char.description ?? null, char.personality ?? null, char.appearance ?? null, char.image_url ?? null, char.local_path ?? null, char.negative_prompt ?? null, now, now);
characterIds.push(info.lastInsertRowid);
}
if (req.episode_id && characterIds.length > 0) {
db.prepare('DELETE FROM episode_characters WHERE episode_id = ?').run(req.episode_id);
const ins = db.prepare('INSERT OR IGNORE INTO episode_characters (episode_id, character_id) VALUES (?, ?)');
for (const cid of characterIds) ins.run(req.episode_id, cid);
}
db.prepare('UPDATE dramas SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), did);
log.info('Characters saved', { drama_id: dramaId, count: chars.length });
return true;
}
function saveEpisodes(db, log, dramaId, req) {
const did = Number(dramaId);
const drama = getDramaById(db, did);
if (!drama) return false;
const episodes = req.episodes || [];
const now = new Date().toISOString();
// 按 episode_number upsert:保留已有分集的 id,避免关联数据(角色/场景/道具/分镜)孤岛化
const keptNumbers = new Set();
for (const ep of episodes) {
const num = ep.episode_number ?? 0;
keptNumbers.add(num);
// 查找已有的(包含软删除的,以防重新激活)
const existing = db.prepare(
'SELECT id FROM episodes WHERE drama_id = ? AND episode_number = ? ORDER BY deleted_at IS NOT NULL ASC, id ASC LIMIT 1'
).get(did, num);
if (existing) {
// 更新已有分集,保留 id
db.prepare(
`UPDATE episodes SET title = ?, script_content = ?, description = ?, duration = ?, deleted_at = NULL, updated_at = ? WHERE id = ?`
).run(ep.title || '', ep.script_content ?? null, ep.description ?? null, ep.duration ?? 0, now, existing.id);
} else {
// 新增
db.prepare(
`INSERT INTO episodes (drama_id, episode_number, title, script_content, description, duration, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?)`
).run(did, num, ep.title || '', ep.script_content ?? null, ep.description ?? null, ep.duration ?? 0, now, now);
}
}
// 软删除本次未提交的分集(如用户删掉了某一集)
const liveEpisodes = db.prepare(
'SELECT id, episode_number FROM episodes WHERE drama_id = ? AND deleted_at IS NULL'
).all(did);
for (const row of liveEpisodes) {
if (!keptNumbers.has(row.episode_number)) {
db.prepare('UPDATE episodes SET deleted_at = ? WHERE id = ?').run(now, row.id);
}
}
db.prepare('UPDATE dramas SET updated_at = ? WHERE id = ?').run(now, did);
log.info('Episodes saved', { drama_id: dramaId, count: episodes.length });
return true;
}
function saveProgress(db, log, dramaId, req) {
const drama = getDramaById(db, Number(dramaId));
if (!drama) return false;
// getDramaById 已通过 rowToDrama 把 metadata 解析为对象,不能对 object 再 JSON.parse,否则进 catch 得到 {} 会整表覆盖掉画风等字段
const meta = storageLayout.parseMetadata(drama.metadata);
meta.current_step = req.current_step;
if (req.step_data) meta.step_data = req.step_data;
const now = new Date().toISOString();
db.prepare('UPDATE dramas SET metadata = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(meta), now, dramaId);
log.info('Progress saved', { drama_id: dramaId, step: req.current_step });
return true;
}
/** 保存画布布局 / 工作流组到 metadata(合并现有 metadata */
function saveCanvasLayout(db, log, dramaId, req) {
const drama = getDramaById(db, Number(dramaId));
if (!drama) return null;
const layout = req?.canvas_layout;
const workflowGroups = req?.workflow_groups;
if (
(layout == null || typeof layout !== 'object' || Array.isArray(layout)) &&
workflowGroups === undefined
) {
const err = new Error('请提供 canvas_layout 或 workflow_groups');
err.code = 'BAD_REQUEST';
throw err;
}
if (layout != null && (typeof layout !== 'object' || Array.isArray(layout))) {
const err = new Error('canvas_layout 必须为对象');
err.code = 'BAD_REQUEST';
throw err;
}
if (workflowGroups !== undefined && !Array.isArray(workflowGroups)) {
const err = new Error('workflow_groups 必须为数组');
err.code = 'BAD_REQUEST';
throw err;
}
const meta = storageLayout.parseMetadata(drama.metadata);
if (layout) meta.canvas_layout = layout;
if (workflowGroups !== undefined) meta.workflow_groups = workflowGroups;
const now = new Date().toISOString();
db.prepare('UPDATE dramas SET metadata = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(meta), now, dramaId);
log.info('Canvas state saved', {
drama_id: dramaId,
node_count: layout ? Object.keys(layout.nodes || {}).length : undefined,
workflow_group_count: workflowGroups ? workflowGroups.length : undefined,
});
return getDramaById(db, Number(dramaId));
}
/**
* 取某分镜的视频地址:优先使用用户手动选定的 storyboard.video_url,否则取最新完成的 video_generations 记录
*/
function getVideoUrlForStoryboard(db, storyboardId, baseUrl) {
// 1. 获取 storyboard 表中的视频信息(代表用户选定或上次同步的结果)
const sb = db.prepare('SELECT video_url, local_path, updated_at FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(storyboardId);
// 2. 获取 video_generations 表中最新完成的记录
const vg = db.prepare(
"SELECT video_url, local_path, completed_at, updated_at, created_at FROM video_generations WHERE storyboard_id = ? AND status = 'completed' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1"
).get(storyboardId);
// 辅助函数:构造完整 URL,优先使用本地路径(避免远程URL过期导致无法合并)
const buildUrl = (videoUrl, localPath) => {
if (localPath && String(localPath).trim() && baseUrl) {
const base = (baseUrl || '').replace(/\/$/, '');
const p = String(localPath).replace(/^\//, '');
return p ? base + '/' + p : null;
}
if (videoUrl && String(videoUrl).trim()) return videoUrl;
return null;
};
const sbUrl = sb ? buildUrl(sb.video_url, sb.local_path) : null;
const vgUrl = vg ? buildUrl(vg.video_url, vg.local_path) : null;
// 策略:比较时间,取最新的
// 如果只有其中一个有 URL,直接用那个
if (sbUrl && !vgUrl) return sbUrl;
if (!sbUrl && vgUrl) return vgUrl;
if (!sbUrl && !vgUrl) return null;
// 都有 URL,比较时间
// sb 使用 updated_at
// vg 使用 completed_at > updated_at > created_at
const sbTime = sb.updated_at || '1970-01-01';
const vgTime = vg.completed_at || vg.updated_at || vg.created_at || '1970-01-01';
// 如果生成记录的时间比分镜更新时间还晚(说明是重新生成的,且可能没回写),则优先用生成记录
if (vgTime > sbTime) {
return vgUrl;
}
// 否则依然以 storyboard 为准(可能是用户手动修改过,或者已经回写过)
return sbUrl;
}
function finalizeEpisode(db, log, episodeId, baseUrl, body = {}) {
const ep = db.prepare('SELECT id, drama_id, episode_number FROM episodes WHERE id = ? AND deleted_at IS NULL').get(episodeId);
if (!ep) return null;
const drama = db.prepare('SELECT title FROM dramas WHERE id = ? AND deleted_at IS NULL').get(ep.drama_id);
const storyboards = db.prepare(
'SELECT id, storyboard_number, duration FROM storyboards WHERE episode_id = ? AND deleted_at IS NULL ORDER BY storyboard_number ASC'
).all(episodeId);
const videoMergeService = require('./videoMergeService');
const scenes = [];
for (let i = 0; i < storyboards.length; i++) {
const sb = storyboards[i];
const videoUrl = getVideoUrlForStoryboard(db, sb.id, baseUrl);
if (!videoUrl) {
log.warn('Finalize skip storyboard (no video)', { storyboard_id: sb.id, storyboard_number: sb.storyboard_number });
continue;
}
scenes.push({
scene_id: sb.id,
video_url: videoUrl,
duration: Number(sb.duration) || 5,
order: i,
});
}
if (scenes.length === 0) {
log.warn('Finalize no scenes with video', { episode_id: episodeId });
return { message: '本集没有可合成的视频片段', merge_id: null, episode_id: episodeId, scenes_count: 0, task_id: null };
}
const title = drama && drama.title ? `${drama.title} - 第${ep.episode_number ?? episodeId}` : null;
const mergeReq = {
episode_id: episodeId,
drama_id: ep.drama_id,
title,
scenes,
provider: 'ffmpeg',
merge_options: {
burn_narration_subtitles: !!(body && body.burn_narration_subtitles),
burn_dialogue_audio: !!(body && body.burn_dialogue_audio),
watermark_text: (body && body.watermark_text != null)
? String(body.watermark_text).trim().slice(0, 200)
: '',
},
};
const created = videoMergeService.create(db, log, mergeReq);
const mergeId = created.merge_id || created.id;
db.prepare('UPDATE episodes SET status = ? WHERE id = ?').run('processing', episodeId);
setImmediate(() => {
videoMergeService.processVideoMerge(db, log, mergeId, baseUrl);
});
return {
message: '视频合成任务已创建,正在后台处理',
merge_id: mergeId,
episode_id: episodeId,
scenes_count: scenes.length,
task_id: created.task_id,
};
}
function downloadEpisodeVideo(db, episodeId) {
const ep = db.prepare('SELECT id, title, episode_number, video_url FROM episodes WHERE id = ? AND deleted_at IS NULL').get(episodeId);
if (!ep) return null;
if (!ep.video_url) return { error: '该剧集还没有生成视频' };
return { video_url: ep.video_url, title: ep.title, episode_number: ep.episode_number };
}
module.exports = {
createDrama,
getDrama,
getDramaById,
listDramas,
updateDrama,
deleteDrama,
getDramaStats,
saveOutline,
getCharacters,
saveCharacters,
saveEpisodes,
saveProgress,
saveCanvasLayout,
finalizeEpisode,
downloadEpisodeVideo,
generateStoryboard,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,700 @@
// 与 Go application/services/frame_prompt_service.go 对齐:生成首帧/关键帧/尾帧/分镜板/动作序列提示词
const loadConfig = require('../config').loadConfig;
const promptI18n = require('./promptI18n');
const aiClient = require('./aiClient');
const taskService = require('./taskService');
const { safeParseAIJSON } = require('../utils/safeJson');
const storyboardService = require('./storyboardService');
const angleService = require('./angleService');
const {
parseNamesFromAnchorLines,
sanitizeFramePrompt,
} = require('../utils/framePromptSanitize');
/**
* 将分镜角度值扩展为带透视含义的完整描述注入图像提示词上下文
* 优先使用结构化三元组angle_h/angle_v/angle_s降级到旧文本解析
*/
function expandAngleDescription(angle, isEn, angleH, angleV, angleS) {
if (angleH && angleV && angleS) {
return isEn
? angleService.toPromptFragment(angleH, angleV, angleS)
: `相机角度:${angleService.toChineseLabel(angleH, angleV, angleS)}`;
}
if (angle) {
if (isEn) return angleService.fromLegacyText(angle, '');
const { h, v, s } = angleService.parseFromLegacyText(angle, '');
return `相机角度:${angleService.toChineseLabel(h, v, s)}`;
}
return null;
}
/** 旧版兼容:仅传 angle 文本时的快捷调用(保持向后兼容) */
function expandAngleDescriptionLegacy(angle, isEn) {
if (!angle) return null;
const a = String(angle).trim().toLowerCase();
if (isEn) {
if (a.includes('low') || a.includes('仰')) {
return "camera angle: low-angle upward shot, background shows sky/ceiling/treetops from below, strong upward perspective distortion";
}
if (a.includes('high') || a.includes('俯')) {
return "camera angle: high-angle downward shot, bird's eye view perspective, background shows ground/floor/scene from above with downward perspective distortion";
}
if (a.includes('side') || a.includes('侧')) {
return "camera angle: side-angle shot, profile composition, background extends laterally";
}
if (a.includes('back') || a.includes('背')) {
return "camera angle: rear shot from behind character, character's back to camera, background scene stretches ahead into the distance";
}
return "camera angle: eye-level horizontal shot, normal perspective, straight-on composition";
} 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 '相机角度:平视水平拍摄,正常透视构图,正面取景';
}
}
const FRAME_TYPES = ['first', 'key', 'last', 'panel', 'action'];
function loadStoryboard(db, storyboardId) {
const row = db.prepare('SELECT * FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(Number(storyboardId));
return row
? {
id: row.id,
description: row.description,
location: row.location,
time: row.time,
dialogue: row.dialogue,
narration: row.narration,
action: row.action,
atmosphere: row.atmosphere,
result: row.result,
scene_id: row.scene_id,
shot_type: row.shot_type,
angle: row.angle,
angle_h: row.angle_h,
angle_v: row.angle_v,
angle_s: row.angle_s,
movement: row.movement,
lighting_style: row.lighting_style,
depth_of_field: row.depth_of_field,
layout_description: row.layout_description || null, // 画面布局与人物站位合同(首尾帧强制一致核心)
}
: null;
}
/**
* identity_anchors JSON 转换为适合注入分镜提示词的结构化描述
* 优先使用结构化锚点无锚点时 fallback appearance 文本
*/
function cleanAppearanceForIdentity(appText) {
if (!appText) return '';
let t = String(appText).trim();
// 去除服装/衣着/配饰等可变描述(中英文常见表述)—— 保留固定身份特征(脸型、发型、肤质、眼神、气质等)
const clothingPatterns = [
/身穿[^,。;\n]*/g,
/穿着[^,。;\n]*/g,
/衣着[^,。;\n]*/g,
/手持[^,。;\n]*/g,
/戴着[^,。;\n]*/g,
/围[^,。;\n]*巾/g,
/服装[^,。;\n]*/g,
/服饰[^,。;\n]*/g,
/着装[^,。;\n]*/g,
/ dressed in [^,。;\n]*/gi,
/ wearing [^,。;\n]*/gi,
/ holding [^,。;\n]*/gi,
/着[^,。;\n]*鞋/g,
];
clothingPatterns.forEach((re) => {
t = t.replace(re, '');
});
// 清理多余标点和空格,保留核心描述
t = t.replace(/[,、;]\s*[,、;]+/g, '').replace(/^[,、;\s]+|[,、;\s]+$/g, '').replace(/\s+/g, ' ').trim();
return t;
}
function buildCharacterAnchorText(name, anchors, appearance) {
if (anchors && typeof anchors === 'object' && Object.keys(anchors).length > 0) {
const parts = [`Character: ${name}`];
if (anchors.face_shape && anchors.face_shape !== 'unspecified') {
parts.push(`Face: ${anchors.face_shape}`);
}
if (anchors.facial_features && anchors.facial_features !== 'unspecified') {
parts.push(`Features: ${anchors.facial_features}`);
}
if (anchors.hair_style && anchors.hair_style !== 'unspecified') {
parts.push(`Hair: ${anchors.hair_style}`);
}
if (anchors.skin_texture && anchors.skin_texture !== 'unspecified') {
parts.push(`Skin: ${anchors.skin_texture}`);
}
if (anchors.color_anchors && typeof anchors.color_anchors === 'object') {
const colors = Object.entries(anchors.color_anchors)
.filter(([, v]) => v && v !== 'unspecified')
.map(([k, v]) => `${k}=${v}`)
.join(', ');
if (colors) parts.push(`Colors: ${colors}`);
}
if (anchors.unique_marks && anchors.unique_marks !== 'none' && anchors.unique_marks !== 'unspecified') {
parts.push(`Marks: ${anchors.unique_marks}`);
}
return parts.join('; ');
}
// fallback: 清洗 appearance,只保留固定身份特征,彻底剔除服装/配饰等可变描述
const cleaned = cleanAppearanceForIdentity(appearance);
if (cleaned) {
return `${name}${cleaned})—— 以上为该角色固定视觉身份锚点,生成画面时必须严格以此为基础,禁止添加任何未在此列出的外貌细节(发型/颜色/脸型/气质等)`;
}
return name;
}
function loadStoryboardCharacterNames(db, storyboardId) {
const sid = Number(storyboardId);
let ids = [];
let usedExplicitCharactersColumn = false;
// 以 storyboards.characters(前端勾选)为权威;仅未配置时才回退 storyboard_characters
try {
const sbRow = db.prepare('SELECT characters FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(sid);
if (sbRow?.characters != null && String(sbRow.characters).trim() !== '') {
const parsed = JSON.parse(sbRow.characters);
if (Array.isArray(parsed)) {
usedExplicitCharactersColumn = true;
for (const item of parsed) {
if (typeof item === 'object' && item != null && item.id != null) {
ids.push(Number(item.id));
} else if (typeof item === 'number' || (typeof item === 'string' && /^\d+$/.test(item))) {
ids.push(Number(item));
}
}
}
}
} catch (_) {}
if (!usedExplicitCharactersColumn) {
const links = db.prepare('SELECT character_id FROM storyboard_characters WHERE storyboard_id = ?').all(sid);
if (links.length) {
ids = links.map((r) => r.character_id);
}
}
if (!ids.length) {
if (usedExplicitCharactersColumn) return [];
// 最后兜底:尝试按名称模糊匹配(某些老数据可能只存了名字)
try {
const sbRow = db.prepare('SELECT characters FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(sid);
if (sbRow?.characters) {
const raw = String(sbRow.characters);
// 尝试提取可能的名字(简单处理)
const nameMatches = raw.match(/[\u4e00-\u9fa5]{2,4}/g) || [];
if (nameMatches.length) {
const namePlaceholders = nameMatches.map(() => '?').join(',');
const nameRows = db.prepare(
`SELECT id, name, appearance, identity_anchors FROM characters
WHERE name IN (${namePlaceholders}) AND deleted_at IS NULL
AND drama_id = (SELECT drama_id FROM episodes WHERE id = (SELECT episode_id FROM storyboards WHERE id = ?))`
).all(...nameMatches, sid);
if (nameRows.length) {
return nameRows.map((r) => {
let anchors = null;
if (r.identity_anchors) { try { anchors = JSON.parse(r.identity_anchors); } catch (_) {} }
return buildCharacterAnchorText(r.name, anchors, r.appearance);
});
}
}
}
} catch (_) {}
return [];
}
const placeholders = ids.map(() => '?').join(',');
let rows = db.prepare(
`SELECT id, name, appearance, identity_anchors FROM characters WHERE id IN (${placeholders}) AND deleted_at IS NULL`
).all(...ids);
if (!rows || rows.length === 0) {
rows = db.prepare(
`SELECT id, name, appearance, identity_anchors FROM character_libraries WHERE id IN (${placeholders}) AND deleted_at IS NULL`
).all(...ids);
}
return rows.map((r) => {
let anchors = null;
if (r.identity_anchors) {
try { anchors = JSON.parse(r.identity_anchors); } catch (_) {}
}
return buildCharacterAnchorText(r.name, anchors, r.appearance);
});
}
/** 本剧全部角色名(用于从帧提示词中剔除未勾选出场的人物) */
function loadDramaCharacterNamesForStoryboard(db, storyboardId) {
try {
const rows = db.prepare(
`SELECT name FROM characters
WHERE drama_id = (
SELECT e.drama_id FROM episodes e
INNER JOIN storyboards s ON s.episode_id = e.id
WHERE s.id = ? AND s.deleted_at IS NULL AND e.deleted_at IS NULL
) AND deleted_at IS NULL`
).all(Number(storyboardId));
return rows.map((r) => String(r.name || '').trim()).filter(Boolean);
} catch (_) {
return [];
}
}
function loadScene(db, sceneId) {
if (sceneId == null) return null;
const row = db.prepare('SELECT id, location, time FROM scenes WHERE id = ? AND deleted_at IS NULL').get(Number(sceneId));
return row ? { id: row.id, location: row.location, time: row.time } : null;
}
function buildStoryboardContext(cfg, sb, scene, characterNames) {
const parts = [];
const styleZh = (cfg?.style?.default_style_zh || '').toString().trim();
const styleEn = (cfg?.style?.default_style_en || cfg?.style?.default_style || '').toString().trim();
const isEn = promptI18n.isEnglish(cfg);
if (isEn) {
if (styleEn) parts.push(`MANDATORY ART STYLE: ${styleEn}`);
else if (styleZh) parts.push(`MANDATORY ART STYLE: ${styleZh}`);
} else if (styleZh) {
parts.push(`【画风·最高优先级】${styleZh}`);
} else if (styleEn) {
parts.push(`【画风·最高优先级】${styleEn}`);
}
// 【最高优先级空间合同 + 尺度绝对覆盖】layout_description + 时代自适应尺度铁律
if (sb.layout_description && String(sb.layout_description).trim()) {
const ld = String(sb.layout_description).trim();
if (isEn) {
parts.unshift(`【SPATIAL LAYOUT CONTRACT — HIGHEST PRIORITY + CINEMATIC BREATHING ROOM FOR MOVEMENT】
${ld}
HARD LOCK (must stay 100% consistent)
- Main character(s) basic screen placement (left/center/right third, facing direction, spatial relationship to key props).
- Realistic physical scale and relative proportions of all major props (only props actually present in the shot; sizes must match the story's era/setting; nothing exaggerated or distorted).
- Overall visual weight balance (character remains the clear focal point; all props stay secondary environmental elements).
ALLOWED + ENCOURAGED CINEMATIC EVOLUTION (5-15s videos support declared movement with meaningful change)
- The last frame MUST show visible, cumulative framing evolution driven by the declared camera_movement over the full clip duration (typically 5-15 seconds). Evolution should be noticeable, not just tiny micro-adjustments.
- Examples (larger duration larger allowed change):
- Slow push-in over 8-12s+: last frame noticeably tighter on the character (higher screen occupancy), background more compressed.
- Handheld / tracking: natural framing drift, slight imperfect composition shifts, and movement-induced offsets are desirable and expected.
- Pan / orbit / follow: clear natural entry/exit of elements on sides or minor camera drift.
- Hard lock remains: core character placement (no L/R swap), realistic physical sizes of all props, basic spatial relationships, and perspective must stay consistent.
- Goal: First and last frames must read as the same continuous physical scene and take, but with enough visual progression that the 5-15s video generated from them actually feels dynamic and realizes the declared movement, instead of looking nearly static or locked-off.
Violating hard lock = failure. Insufficient evolution that suppresses the movement = also bad result.`);
} else {
parts.unshift(`【空间布局合同 — 最高优先级铁律 + 运镜呼吸空间(必须严格遵守核心,但允许电影化微调)】
${ld}
核心锁定必须100%一致
- 主要角色在画面中的基本站位画面左//右三分朝向与主要道具的相对空间关系
- 所有主要道具的真实物理尺寸与相对比例仅写本分镜实际出现的道具尺度须符合剧本时代背景所有物件不得夸大或失真古代场景严禁出现智能手机遥控器等现代物品
- 整体画面重心与基本平衡感主角仍是视觉焦点所有道具均为次要环境元素
允许且推荐的电影化演化5-15秒视频强烈支持 declared movement
- 尾帧必须根据本分镜的 movement 和视频时长通常5-15进行有意义的取景演化而非微调
- 示例时长越长演化幅度可越大
- 缓推slow push-in5-10+尾帧人物画面占比应明显大于首帧背景明显更被压缩取景更紧
- 手持跟拍允许自然的取景晃动轻微不完美偏移以及随运动产生的构图漂移
- 横摇/环绕/跟拍画面可有清晰的左右进入/退出变化或机位自然漂移
- 硬锁主要角色核心站位不左右互换主要道具真实尺寸与基本相对位置不变所有物体保持真实物理尺度与透视
- 目标首尾帧之间要有足够视觉差异让基于它们的5-15秒视频真正起来充分实现声明的运镜效果而不是几乎定格
违背硬锁 = 生成失败演化幅度过小导致运镜失效也属于不理想结果`);
}
}
if (sb.description) {
parts.push(promptI18n.formatUserPrompt(cfg, 'shot_description_label', sb.description));
}
if (scene) {
parts.push(promptI18n.formatUserPrompt(cfg, 'scene_label', scene.location, scene.time));
} else if (sb.location || sb.time) {
parts.push(promptI18n.formatUserPrompt(cfg, 'scene_label', sb.location || '', sb.time || ''));
}
const allowedCharNames = parseNamesFromAnchorLines(characterNames);
if (allowedCharNames.length) {
const rosterLine = isEn
? `【ALLOWED CHARACTERS IN THIS SHOT — ONLY these may appear; NO other people】\n${allowedCharNames.join(', ')}`
: `【本分镜允许出场的角色(仅此名单,严禁出现名单外的任何其他人物)】\n${allowedCharNames.join('、')}`;
parts.unshift(rosterLine);
}
if (characterNames.length) {
// 强化角色视觉锚点注入(针对首尾帧一致性问题)
if (isEn) {
parts.push(`【CHARACTER VISUAL ANCHORS - MUST USE EXACTLY, DO NOT HALLUCINATE】\n${characterNames.join('\n')}`);
} else {
parts.unshift(`【角色视觉锚点 - 最高优先级铁律,必须严格遵守,禁止任何脑补或添加未提供的外貌细节】\n${characterNames.join('\n')}`);
}
}
if (sb.action) {
parts.push(promptI18n.formatUserPrompt(cfg, 'action_label', sb.action));
}
if (sb.result) {
parts.push(promptI18n.formatUserPrompt(cfg, 'result_label', sb.result));
}
if (sb.dialogue) {
parts.push(promptI18n.formatUserPrompt(cfg, 'dialogue_label', sb.dialogue));
}
if (sb.atmosphere) {
parts.push(promptI18n.formatUserPrompt(cfg, 'atmosphere_label', sb.atmosphere));
}
if (sb.shot_type) {
parts.push(promptI18n.formatUserPrompt(cfg, 'shot_type_label', sb.shot_type));
}
if (sb.angle || (sb.angle_h && sb.angle_v && sb.angle_s)) {
const isEn = promptI18n.isEnglish(cfg);
const angleDesc = expandAngleDescription(sb.angle, isEn, sb.angle_h, sb.angle_v, sb.angle_s);
if (angleDesc) parts.push(angleDesc);
}
if (sb.movement) {
parts.push(promptI18n.formatUserPrompt(cfg, 'movement_label', sb.movement));
}
return parts.join('\n');
}
function frameKindSuffix(cfg, frameKind) {
const isEn = promptI18n.isEnglish(cfg);
if (isEn) {
if (frameKind === 'first') return 'first frame, static shot';
if (frameKind === 'key') return 'key frame, dynamic action';
return 'last frame, final state';
}
if (frameKind === 'first') return '首帧静止画面,动作发生前的初始状态';
if (frameKind === 'key') return '关键帧,动作高潮瞬间';
return '尾帧静止画面,动作完成后的最终状态';
}
function buildFallbackPrompt(cfg, scene, frameKind) {
const parts = [];
const isEn = promptI18n.isEnglish(cfg);
if (scene) {
const loc = [scene.location, scene.time].filter(Boolean).join(isEn ? ', ' : '');
if (loc) parts.push(loc);
}
const style = isEn
? (cfg?.style?.default_style_en || cfg?.style?.default_style || '').toString().trim()
: (cfg?.style?.default_style_zh || cfg?.style?.default_style || '').toString().trim();
if (style) parts.push(style);
parts.push(frameKindSuffix(cfg, frameKind));
return parts.join(isEn ? ', ' : '');
}
function parseFramePromptJSON(log, aiResponse) {
try {
const data = safeParseAIJSON(aiResponse, {}, log);
if (data && typeof data.prompt === 'string') {
return { prompt: data.prompt, description: data.description || '' };
}
} catch (e) {
log.warn('Frame prompt JSON parse failed', { error: e.message, response_head: (aiResponse || '').slice(0, 200) });
}
return null;
}
function saveFramePrompt(db, log, storyboardId, frameType, prompt, description, layout) {
const now = new Date().toISOString();
db.prepare('DELETE FROM frame_prompts WHERE storyboard_id = ? AND frame_type = ?').run(Number(storyboardId), frameType);
db.prepare(
`INSERT INTO frame_prompts (storyboard_id, frame_type, prompt, description, layout, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(Number(storyboardId), frameType, prompt, description ?? null, layout ?? null, now, now);
log.info('Frame prompt saved', { storyboard_id: storyboardId, frame_type: frameType });
}
async function generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, frameKind, sanitizeOpts = {}) {
const context = buildStoryboardContext(cfg, sb, scene, characterNames);
const allowedCharNames = parseNamesFromAnchorLines(characterNames);
const allDramaNames = sanitizeOpts.allDramaNames || allowedCharNames;
const systemKey = frameKind === 'first' ? 'getFirstFramePrompt' : frameKind === 'key' ? 'getKeyFramePrompt' : 'getLastFramePrompt';
const userKey = frameKind === 'first' ? 'frame_info' : frameKind === 'key' ? 'key_frame_info' : 'last_frame_info';
const systemPrompt = promptI18n[systemKey](cfg);
const userPrompt = promptI18n.formatUserPrompt(cfg, userKey, context);
// ── 调试日志:打印完整提示词,方便确认角度/视角是否正确注入 ──
log.info('[帧提示词] ===== generateSingleFrame DEBUG =====', {
frame_kind: frameKind,
storyboard_id: sb?.id,
angle: sb?.angle,
shot_type: sb?.shot_type,
movement: sb?.movement,
});
log.info('[帧提示词] CONTEXT (角色/场景/角度上下文):\n' + context);
log.info('[帧提示词] SYSTEM PROMPT:\n' + systemPrompt);
log.info('[帧提示词] USER PROMPT:\n' + userPrompt);
log.info('[帧提示词] ==========================================');
let aiResponse;
try {
aiResponse = await aiClient.generateText(db, log, 'text', userPrompt, systemPrompt, {
model: model || undefined,
max_tokens: 2400,
});
} catch (err) {
log.warn('Frame prompt AI failed, using fallback', { error: err.message });
const prompt = buildFallbackPrompt(cfg, scene, frameKind);
const desc =
frameKind === 'first'
? '镜头开始的静态画面,展示初始状态'
: frameKind === 'key'
? '动作高潮瞬间,展示关键动作'
: '镜头结束画面,展示最终状态和结果';
return { prompt, description: desc };
}
log.info('[帧提示词] AI RAW RESPONSE:\n' + (aiResponse || '(empty)'));
const parsed = parseFramePromptJSON(log, aiResponse);
if (parsed) {
const cleanedPrompt = sanitizeFramePrompt(parsed.prompt, allowedCharNames, allDramaNames, {
log,
source: 'frame_prompt_generation',
storyboard_id: sb?.id,
frame_kind: frameKind,
});
log.info('[帧提示词] PARSED RESULT prompt:\n' + cleanedPrompt);
return { ...parsed, prompt: cleanedPrompt };
}
const fallback = buildFallbackPrompt(cfg, scene, frameKind);
log.warn('[帧提示词] JSON 解析失败,使用 FALLBACK prompt:\n' + fallback);
return {
prompt: fallback,
description: frameKind === 'last' ? '镜头结束画面,展示最终状态和结果' : frameKind === 'key' ? '动作高潮瞬间,展示关键动作' : '镜头开始的静态画面,展示初始状态',
};
}
async function processFramePromptGeneration(db, log, taskId, storyboardId, frameType, panelCount, model) {
let cfg = loadConfig();
taskService.updateTaskStatus(db, taskId, 'processing', 0, '正在生成帧提示词...');
const sb = loadStoryboard(db, storyboardId);
if (!sb) {
taskService.updateTaskError(db, taskId, '分镜信息不存在');
log.error('Frame prompt: storyboard not found', { storyboard_id: storyboardId });
return;
}
// 通过 storyboard → episode → drama 链路读取项目 style 和 aspect_ratio
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 { mergeCfgStyleWithDrama } = require('../utils/dramaStyleMerge');
let next = { ...cfg, style: { ...(cfg?.style || {}) } };
if (dramaRow.metadata) {
const meta = typeof dramaRow.metadata === 'string' ? JSON.parse(dramaRow.metadata) : dramaRow.metadata;
if (meta && meta.aspect_ratio) {
next.style.default_image_ratio = meta.aspect_ratio;
next.style.default_video_ratio = meta.aspect_ratio;
}
}
cfg = mergeCfgStyleWithDrama(next, dramaRow);
}
}
} catch (_) {}
const scene = loadScene(db, sb.scene_id);
const characterNames = loadStoryboardCharacterNames(db, storyboardId);
const allDramaNames = loadDramaCharacterNamesForStoryboard(db, storyboardId);
const sanitizeOpts = { allDramaNames };
// 强调试日志:确认角色视觉锚点是否成功加载(用于排查“黑发扎马尾”等脑补问题)
log.info('[帧提示词] 角色视觉锚点加载结果', {
storyboard_id: storyboardId,
character_count: characterNames.length,
characters_preview: characterNames.length ? characterNames.map(c => c.substring(0, 120) + (c.length > 120 ? '...' : '')).join(' | ') : '(无关联角色或加载失败)'
});
const storyboardIdStr = String(storyboardId);
let combinedPrompt = '';
let description = '';
let layout = '';
try {
if (frameType === 'first' || frameType === 'key' || frameType === 'last') {
const frameKind = frameType;
const single = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, frameKind, sanitizeOpts);
saveFramePrompt(db, log, storyboardId, frameType, single.prompt, single.description, '');
combinedPrompt = single.prompt;
description = single.description;
} else if (frameType === 'panel') {
const count = panelCount || 3;
layout = `horizontal_${count}`;
const prompts = [];
if (count === 3) {
const first = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'first', sanitizeOpts);
const key = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts);
const last = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'last', sanitizeOpts);
prompts.push(first.prompt, key.prompt, last.prompt);
description = '分镜板组合提示词';
} else if (count === 4) {
const first = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'first', sanitizeOpts);
const key1 = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts);
const key2 = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts);
const last = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'last', sanitizeOpts);
prompts.push(first.prompt, key1.prompt, key2.prompt, last.prompt);
description = '分镜板组合提示词';
} else {
prompts.push((await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'first', sanitizeOpts)).prompt);
for (let i = 0; i < count - 2; i++) {
prompts.push((await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts)).prompt);
}
prompts.push((await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'last', sanitizeOpts)).prompt);
description = '分镜板组合提示词';
}
combinedPrompt = prompts.join('\n---\n');
saveFramePrompt(db, log, storyboardId, frameType, combinedPrompt, description, layout);
} else if (frameType === 'action') {
layout = 'horizontal_5';
const first = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'first', sanitizeOpts);
const key1 = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts);
const key2 = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts);
const key3 = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'key', sanitizeOpts);
const last = await generateSingleFrame(db, log, cfg, sb, scene, characterNames, model, 'last', sanitizeOpts);
combinedPrompt = [first.prompt, key1.prompt, key2.prompt, key3.prompt, last.prompt].join('\n---\n');
description = '动作序列组合提示词';
saveFramePrompt(db, log, storyboardId, frameType, combinedPrompt, description, layout);
} else {
taskService.updateTaskError(db, taskId, '不支持的帧类型');
log.error('Frame prompt: unsupported frame_type', { frame_type: frameType });
return;
}
taskService.updateTaskResult(db, taskId, {
storyboard_id: storyboardIdStr,
frame_type: frameType,
response: { frame_type: frameType, single_frame: combinedPrompt ? { prompt: combinedPrompt, description } : undefined, layout: layout || undefined },
});
log.info('Frame prompt generation completed', { task_id: taskId, storyboard_id: storyboardId, frame_type: frameType });
} catch (err) {
log.error('Frame prompt generation error', { task_id: taskId, error: err.message });
taskService.updateTaskError(db, taskId, err.message || '生成失败');
}
}
function generateFramePrompt(db, log, storyboardId, frameType, panelCount, model) {
const sid = Number(storyboardId);
const sb = db.prepare('SELECT id FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(sid);
if (!sb) {
throw new Error('分镜不存在');
}
const validTypes = FRAME_TYPES.includes(frameType);
if (!validTypes) {
throw new Error('不支持的 frame_type,可选: first, key, last, panel, action');
}
const task = taskService.createTask(db, log, 'frame_prompt_generation', String(storyboardId));
setImmediate(() => {
processFramePromptGeneration(db, log, task.id, storyboardId, frameType, panelCount || 0, model);
});
log.info('Frame prompt task created', { task_id: task.id, storyboard_id: storyboardId, frame_type: frameType });
return task.id;
}
module.exports = {
generateFramePrompt,
saveFramePrompt,
loadStoryboard,
loadStoryboardCharacterNames,
loadDramaCharacterNamesForStoryboard,
loadScene,
buildCharacterAnchorText,
getFramePrompts: (db, storyboardId) => storyboardService.getFramePrompts(db, storyboardId),
generateSingleFrameExported: generateSingleFrame,
expandAngleDescription,
regenerateLayoutDescription,
};
/**
* 一键重新生成/优化单个分镜的 layout_description空间布局合同
* 自动参考上下分镜保证前后连贯性
* @returns {string} 新的 layout_description 文本
*/
async function regenerateLayoutDescription(db, log, storyboardId) {
const sid = Number(storyboardId);
const sb = db.prepare('SELECT * FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(sid);
if (!sb) throw new Error('分镜不存在');
// 取前后分镜(用于连贯性)
let prevSb = null, nextSb = null;
if (sb.episode_id != null && sb.storyboard_number != null) {
prevSb = db.prepare(`
SELECT storyboard_number, action, result, layout_description
FROM storyboards
WHERE episode_id = ? AND storyboard_number < ? AND deleted_at IS NULL
ORDER BY storyboard_number DESC LIMIT 1
`).get(sb.episode_id, sb.storyboard_number);
nextSb = db.prepare(`
SELECT storyboard_number, action, result, layout_description
FROM storyboards
WHERE episode_id = ? AND storyboard_number > ? AND deleted_at IS NULL
ORDER BY storyboard_number ASC LIMIT 1
`).get(sb.episode_id, sb.storyboard_number);
}
// 角色信息(用于站位描述)
const characterNames = loadStoryboardCharacterNames(db, sid);
const cfg = require('../config').loadConfig();
const systemPrompt = promptI18n.getRegenerateLayoutDescriptionPrompt(cfg);
const userLines = [
`CURRENT_SHOT #${sb.storyboard_number || sid}`,
sb.action ? `ACTION: ${sb.action}` : null,
sb.result ? `RESULT: ${sb.result}` : null,
sb.dialogue ? `DIALOGUE: ${sb.dialogue}` : null,
sb.shot_type ? `SHOT_TYPE: ${sb.shot_type}` : null,
characterNames.length ? `CHARACTERS: ${characterNames.join('')}` : null,
prevSb ? `PREV_SHOT #${prevSb.storyboard_number} LAYOUT: ${prevSb.layout_description || '(none)'}` : 'PREV_SHOT: (first shot)',
nextSb ? `NEXT_SHOT #${nextSb.storyboard_number} LAYOUT: ${nextSb.layout_description || '(none)'}` : 'NEXT_SHOT: (last shot)',
'请严格按照系统提示要求,只输出优化后的 layout_description 文本。',
].filter(Boolean);
const userPrompt = userLines.join('\n');
log.info('[布局重生成] 开始', { storyboard_id: sid, has_prev: !!prevSb, has_next: !!nextSb });
const raw = await aiClient.generateText(db, log, 'text', userPrompt, systemPrompt, {
max_tokens: 300,
temperature: 0.35,
});
let newLayout = (raw || '').trim()
.replace(/^```[a-z]*\s*/i, '')
.replace(/\s*```$/, '')
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
.trim();
// 极简清洗:去掉明显的前缀
newLayout = newLayout.replace(/^(布局描述|layout_description|空间布局|画面布局)[:]\s*/i, '').trim();
if (!newLayout || newLayout.length < 8) {
throw new Error('AI 返回的布局描述过短或无效');
}
// 写回数据库
const now = new Date().toISOString();
db.prepare('UPDATE storyboards SET layout_description = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL')
.run(newLayout, now, sid);
log.info('[布局重生成] 完成', { storyboard_id: sid, new_layout_preview: newLayout.slice(0, 80) });
return newLayout;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
'use strict';
/**
* 即梦2角色认证 业务侧素材管理HTTP API与官方路径一致 /api/business/v1/assets
* 网关 URL Token AI 配置service_type = jimeng2_character_auth读取可选兼容旧版 config 中的 jimeng_material_hub / silvamux_hub
* 参考https://83zi.com/sd2realperson.html
*/
function loadAiJimeng2AuthRow(db) {
if (!db) return null;
try {
return db
.prepare(
`SELECT id, name, base_url, api_key FROM ai_service_configs
WHERE deleted_at IS NULL AND service_type = ? AND is_active = 1
ORDER BY is_default DESC, priority DESC, id ASC LIMIT 1`
)
.get('jimeng2_character_auth');
} catch (_) {
return null;
}
}
function legacyYamlHubSection(cfg) {
return cfg?.jimeng_material_hub || cfg?.silvamux_hub || {};
}
/** 与 routes/aiConfig.js listJimeng2MaterialAssets 一致:存库/环境变量里若含「Bearer 」前缀,hubJson 会再拼 Bearer,需先去重 */
function normalizeMaterialHubToken(raw) {
let s = String(raw || '').trim();
if (/^bearer\s+/i.test(s)) s = s.replace(/^bearer\s+/i, '').trim();
// 兼容误填为 "token" / 'token' 的场景
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1).trim();
}
// 去除不可见空白,避免网关把 header 判定为无效
s = s.replace(/[\r\n\t\u200b-\u200d\ufeff]/g, '').trim();
// 全角空格等
s = s.replace(/\u00a0/g, ' ').trim();
return s;
}
/** 日志/报错用:首尾片段,便于与 curl 测试密钥对照(不含完整密钥) */
function tokenFingerprint(tok) {
const s = String(tok || '').trim();
if (!s) return '';
if (s.length <= 12) return '(过短)';
return `${s.slice(0, 7)}${s.slice(-4)}`;
}
/**
* 解析即梦2角色认证调用上下文供素材注册 API 使用
* @param {object} cfg - 应用 config.yaml
* @param {object|null} db - better-sqlite3可选用于读 AI 配置表
* @param {object|null} [log] - 可选 logger传入时打一条不含密钥原文的鉴权诊断
* @returns {{ baseUrl: string, token: string, poll_max_ms?: number, poll_interval_ms?: number, hubAuthDiag?: object }}
*/
function buildHubContext(cfg, db, log) {
const row = loadAiJimeng2AuthRow(db);
let base_url = (row?.base_url || '').toString().trim();
let token = (row?.api_key || '').toString().trim();
let poll_max_ms;
let poll_interval_ms;
if (!base_url || !token) {
const y = legacyYamlHubSection(cfg);
if (!base_url) base_url = (y.base_url || '').toString().trim();
if (!token) token = (y.token || '').toString().trim();
if (poll_max_ms == null && y.poll_max_ms != null) poll_max_ms = Number(y.poll_max_ms);
if (poll_interval_ms == null && y.poll_interval_ms != null) poll_interval_ms = Number(y.poll_interval_ms);
}
const baseUrl = (
process.env.JIMENG2_CHARACTER_AUTH_URL ||
base_url ||
process.env.JIMENG_MATERIAL_HUB_BASE_URL ||
process.env.SILVAMUX_HUB_BASE_URL ||
'https://silvamux.tingyutech.com'
)
.toString()
.trim()
.replace(/\/$/, '');
const rawTokJoined = (
process.env.JIMENG2_CHARACTER_AUTH_TOKEN ||
token ||
process.env.JIMENG_MATERIAL_HUB_TOKEN ||
process.env.SILVAMUX_HUB_TOKEN ||
process.env.HUB_TOKEN ||
''
)
.toString()
.trim();
const hadLeadingBearer = /^bearer\s+/i.test(rawTokJoined);
const tok = normalizeMaterialHubToken(rawTokJoined);
const env2 = !!String(process.env.JIMENG2_CHARACTER_AUTH_TOKEN || '').trim();
const envMat = !!String(process.env.JIMENG_MATERIAL_HUB_TOKEN || '').trim();
const envSilva = !!String(process.env.SILVAMUX_HUB_TOKEN || '').trim();
const envHub = !!String(process.env.HUB_TOKEN || '').trim();
const dbKeyLen = String(row?.api_key || '').trim().length;
let winningTokenSource = 'none';
if (env2) winningTokenSource = 'env:JIMENG2_CHARACTER_AUTH_TOKEN';
else if (String(token || '').trim()) {
winningTokenSource = dbKeyLen ? 'db:ai_service_configs(jimeng2_character_auth.api_key)' : 'yaml:jimeng_material_hub|silvamux_hub.token';
} else if (envMat) winningTokenSource = 'env:JIMENG_MATERIAL_HUB_TOKEN';
else if (envSilva) winningTokenSource = 'env:SILVAMUX_HUB_TOKEN';
else if (envHub) winningTokenSource = 'env:HUB_TOKEN';
const hubAuthDiag = {
winning_token_source: winningTokenSource,
raw_token_chars_before_normalize: rawTokJoined.length,
token_chars_in_bearer_payload: tok.length,
raw_had_leading_bearer_prefix: hadLeadingBearer,
leading_bearer_prefix_stripped: hadLeadingBearer,
env_token_flags: {
JIMENG2_CHARACTER_AUTH_TOKEN: env2,
JIMENG_MATERIAL_HUB_TOKEN: envMat,
SILVAMUX_HUB_TOKEN: envSilva,
HUB_TOKEN: envHub,
},
db_jimeng2_active_row_found: !!row,
db_config_id: row?.id ?? null,
db_config_name: row?.name ?? null,
db_api_key_field_chars: dbKeyLen,
token_fingerprint: tokenFingerprint(tok),
request_header_shape: 'Authorization: Bearer <token>',
note:
'若 raw_had_leading_bearer_prefix 为 true,旧版会发出 Bearer Bearer…;现已规范化。环境变量 JIMENG2_CHARACTER_AUTH_TOKEN 优先于数据库 api_key。请求头仅发送 Authorization(勿重复 authorization,部分 model_ark 网关会判为无效 Token)。',
};
if (log && typeof log.info === 'function') {
log.info('[JimengMaterialHub] buildHubContext 鉴权诊断(不含密钥原文)', {
hub_gateway: baseUrl,
token_present: !!tok,
...hubAuthDiag,
});
}
return { baseUrl, token: tok, poll_max_ms, poll_interval_ms, hubAuthDiag, tokenFingerprint: tokenFingerprint(tok) };
}
/** model_ark 等网关在拉取图片失败时仍返回 HTTP 200 + { error: "..." },无 id */
function hubBusinessErrorMessage(json) {
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
const err = json.error ?? json.Error;
if (typeof err === 'string' && err.trim()) return err.trim();
if (json.success === false) {
return String(json.message || json.msg || json.detail || '网关业务失败').slice(0, 2000);
}
return null;
}
function pickAssetId(obj) {
if (!obj || typeof obj !== 'object') return '';
const id = obj.id ?? obj.asset_id ?? obj.assetId;
return id != null ? String(id).trim() : '';
}
function looksLikeAssetView(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
const id = pickAssetId(obj);
if (!id) return false;
return (
obj.status != null ||
obj.asset_url != null ||
obj.asset_type != null ||
obj.url != null ||
obj.name != null
);
}
/** 兼容顶层 AssetView、{ data: {...} }、{ items: [one] } 等包裹格式 */
function unwrapMaterialHubAssetView(payload, depth = 0) {
if (depth > 5 || payload == null || typeof payload !== 'object') return null;
if (looksLikeAssetView(payload)) {
const id = pickAssetId(payload);
return {
...payload,
id,
asset_url: payload.asset_url ?? payload.assetUrl ?? null,
status: payload.status ?? null,
};
}
if (Array.isArray(payload)) {
for (const item of payload) {
const found = unwrapMaterialHubAssetView(item, depth + 1);
if (found) return found;
}
return null;
}
for (const key of ['data', 'result', 'asset', 'item', 'record', 'body', 'payload']) {
if (payload[key] != null) {
const found = unwrapMaterialHubAssetView(payload[key], depth + 1);
if (found) return found;
}
}
if (Array.isArray(payload.items) && payload.items.length === 1) {
return unwrapMaterialHubAssetView(payload.items[0], depth + 1);
}
return null;
}
async function hubJson(path, ctx, { method, body, log } = {}) {
const base = ctx.baseUrl;
const token = ctx.token;
if (!token) {
return {
ok: false,
error:
'未配置即梦2角色认证:请在「AI 配置」中添加类型为「即梦2角色认证」的一条配置,填写网关 URL 与 Token(或设置环境变量 JIMENG2_CHARACTER_AUTH_*;兼容旧 config / SILVAMUX_*',
};
}
const url = `${base}/api/business/v1${path}`;
const init = {
method: method || 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
};
if (body != null) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
if (log && typeof log.info === 'function' && method === 'POST' && path === '/assets' && body?.url) {
log.info('[JimengMaterialHub] POST /api/business/v1/assets', {
hub_gateway: base,
register_image_url: body.url,
asset_name: body.name,
asset_type: body.asset_type,
bearer_token_payload_chars: token.length,
});
}
if (log && typeof log.info === 'function' && method === 'GET' && String(path || '').startsWith('/assets')) {
log.info('[JimengMaterialHub] GET /api/business/v1/assets', {
hub_gateway: base,
path_query: String(path).includes('?') ? String(path).split('?')[1]?.slice(0, 120) : '',
bearer_token_payload_chars: token.length,
});
}
const res = await fetch(url, init);
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : {};
} catch (_) {
json = { _raw: text };
}
if (!res.ok) {
const detail = json?.detail || json?.title || json?.message || text || res.statusText;
const detailStr = typeof detail === 'string' ? detail : JSON.stringify(detail);
if (log && typeof log.warn === 'function') {
const baseWarn = {
path,
method: method || 'GET',
httpStatus: res.status,
hub_gateway: base,
register_image_url: body && body.url ? body.url : undefined,
response_preview: detailStr.slice(0, 2000),
bearer_token_payload_chars: token.length,
};
if (res.status === 401) {
baseWarn.hint401 =
'invalid token 常见原因:密钥与网关不匹配;机器上 JIMENG2_CHARACTER_AUTH_TOKEN 等环境变量覆盖数据库配置;配置里写了「Bearer xxx」导致旧版双重 Bearer(请看 buildHubContext 日志 raw_had_leading_bearer_prefix';
}
log.warn('[JimengMaterialHub] HTTP 错误', baseWarn);
}
return { ok: false, status: res.status, error: detailStr };
}
const bizErr = hubBusinessErrorMessage(json);
if (bizErr) {
if (log && typeof log.warn === 'function') {
log.warn('[JimengMaterialHub] HTTP 200 但业务失败(常见于图片 URL 无法被网关拉取)', {
path,
method: method || 'GET',
httpStatus: res.status,
hub_gateway: base,
register_image_url: body && body.url ? body.url : undefined,
response_preview: bizErr.slice(0, 2000),
});
}
return { ok: false, status: res.status, error: bizErr };
}
return { ok: true, data: json, status: res.status };
}
async function createImageAsset(ctx, params, log) {
const name = String(params.name || 'c').replace(/\s+/g, '').slice(0, 12) || 'c';
const r = await hubJson('/assets', ctx, {
method: 'POST',
body: { url: params.url, asset_type: 'Image', name },
log,
});
if (!r.ok) return r;
const asset = unwrapMaterialHubAssetView(r.data);
if (asset?.id) return { ok: true, data: asset, status: r.status };
const keys =
r.data && typeof r.data === 'object' && !Array.isArray(r.data) ? Object.keys(r.data).join(', ') : typeof r.data;
if (log && typeof log.warn === 'function') {
log.warn('[JimengMaterialHub] POST 成功但无法解析素材 id', {
response_keys: keys,
response_preview: JSON.stringify(r.data).slice(0, 800),
});
}
return {
ok: false,
status: r.status,
error: `素材库未返回素材 id(响应字段:${keys || '空'}`,
};
}
/**
* 列出组织下素材分页
* @see https://83zi.com/sd2realperson.html
*/
async function listAssets(ctx, opts = {}, log) {
const limitRaw = opts.limit != null ? Number(opts.limit) : 20;
const limit = Math.min(100, Math.max(1, Number.isFinite(limitRaw) ? limitRaw : 20));
const q = new URLSearchParams();
q.set('limit', String(limit));
if (opts.cursor) q.set('cursor', String(opts.cursor));
const path = `/assets?${q.toString()}`;
return hubJson(path, ctx, { method: 'GET', log });
}
async function getAsset(ctx, assetId, log) {
const id = encodeURIComponent(String(assetId || '').trim());
if (!id) return { ok: false, error: '缺少 asset id' };
const r = await hubJson(`/assets/${id}`, ctx, { method: 'GET', log });
if (!r.ok) return r;
const asset = unwrapMaterialHubAssetView(r.data);
if (asset?.id) return { ok: true, data: asset, status: r.status };
return { ok: true, data: r.data, status: r.status };
}
async function pollAssetUntilSettled(ctx, assetId, options = {}) {
const maxMs = options.maxMs ?? 120000;
const intervalMs = options.intervalMs ?? 2000;
const log = options.log;
const deadline = Date.now() + maxMs;
let last;
while (Date.now() < deadline) {
const r = await getAsset(ctx, assetId, log);
if (!r.ok) return { ok: false, error: r.error };
last = r.data;
const st = (last && last.status) || '';
if (st === 'active' || st === 'failed') {
return { ok: true, asset: last };
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return { ok: true, asset: last, timedOut: true };
}
function hubToken(cfg, db) {
return buildHubContext(cfg, db).token;
}
module.exports = {
buildHubContext,
hubToken,
normalizeMaterialHubToken,
tokenFingerprint,
hubBusinessErrorMessage,
unwrapMaterialHubAssetView,
createImageAsset,
listAssets,
getAsset,
pollAssetUntilSettled,
};
+83
View File
@@ -0,0 +1,83 @@
/**
* 可灵官方 OpenAPI JWT与文档 commonInfo 及官方示例一致
* @see https://klingai.com/document-api/apiReference/commonInfo
* @see https://app.klingai.com/cn/dev/document-api/apiReference/commonInfo
*
* Header: alg=HS256, typ=JWT
* Payload: iss=AccessKey, exp, nbfnbf 默认 now-300s 以容忍本机时钟快于服务端避免 1003可用 KLING_JWT_NBF_SKEW_SECONDS 覆盖
*/
const jwt = require('jsonwebtoken');
/** 客户端时钟快于服务端时,nbf 过「新」会触发 1003 Authorization is not active;默认放宽到 5 分钟 */
const DEFAULT_NBF_SKEW_SEC = (() => {
const n = parseInt(process.env.KLING_JWT_NBF_SKEW_SECONDS || '', 10);
return Number.isFinite(n) && n >= 0 ? n : 300;
})();
/** 去掉首尾空白与常见零宽字符(避免复制密钥时签名校验失败) */
function normalizeKlingCredential(s) {
return String(s || '')
.replace(/^\uFEFF/, '')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim();
}
/**
* @param {string} accessKey
* @param {string} secretKey
* @param {{ ttlSeconds?: number, secretEncoding?: 'utf8'|'base64' } | number} [opts] 兼容旧调用 signKlingOfficialJwt(ak, sk, 1800)
*/
function signKlingOfficialJwt(accessKey, secretKey, opts = {}) {
const options =
typeof opts === 'number' ? { ttlSeconds: opts } : opts && typeof opts === 'object' ? opts : {};
const ttlSeconds = options.ttlSeconds ?? 1800;
const secretEncoding = options.secretEncoding === 'base64' ? 'base64' : 'utf8';
const ak = normalizeKlingCredential(accessKey);
const sk = normalizeKlingCredential(secretKey);
if (!ak || !sk) throw new Error('AccessKey 与 SecretKey 不能为空');
let signingSecret = sk;
if (secretEncoding === 'base64') {
const buf = Buffer.from(sk, 'base64');
if (!buf.length) throw new Error('SecretKey 按 Base64 解码后为空,请检查是否勾选错误或粘贴内容');
signingSecret = buf;
}
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: ak,
exp: now + ttlSeconds,
nbf: now - DEFAULT_NBF_SKEW_SEC,
};
return jwt.sign(payload, signingSecret, {
algorithm: 'HS256',
header: { alg: 'HS256', typ: 'JWT' },
noTimestamp: true,
});
}
/** 调试:解码 payload,不校验签名(勿记录完整 token) */
function unsafeDecodeKlingJwtPayload(token) {
try {
return jwt.decode(token, { complete: false });
} catch (_) {
return null;
}
}
/** JWT 三段 base64url 长度,用于对照是否截断 */
function jwtPartLengths(token) {
if (!token || typeof token !== 'string') return null;
const parts = token.split('.');
if (parts.length !== 3) return { invalid: true, part_count: parts.length };
return { header: parts[0].length, payload: parts[1].length, signature: parts[2].length };
}
module.exports = {
signKlingOfficialJwt,
normalizeKlingCredential,
unsafeDecodeKlingJwtPayload,
jwtPartLengths,
};
+145
View File
@@ -0,0 +1,145 @@
const crypto = require('crypto');
const KNOWN_TABLES = new Set([
'character_libraries',
'scene_libraries',
'prop_libraries',
]);
const columnCache = new WeakMap();
function assertKnownTable(table) {
if (!KNOWN_TABLES.has(table)) {
throw new Error(`Unknown library table: ${table}`);
}
}
function hasColumn(db, table, column) {
assertKnownTable(table);
let tableCache = columnCache.get(db);
if (!tableCache) {
tableCache = new Map();
columnCache.set(db, tableCache);
}
const key = `${table}.${column}`;
if (tableCache.has(key)) return tableCache.get(key);
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
const found = columns.some((row) => row.name === column);
tableCache.set(key, found);
return found;
}
function normalizeSourceId(id) {
if (id == null) return '';
return String(id).trim();
}
function normalizePathRef(value) {
let s = String(value || '').trim();
if (!s) return '';
s = s.replace(/\\/g, '/');
s = s.replace(/^\/?static\//i, '');
s = s.replace(/^\/+/, '');
return s;
}
function refKey(value) {
const s = String(value || '').trim();
if (!s) return '';
if (s.startsWith('data:')) {
return `data:${crypto.createHash('sha256').update(s).digest('hex')}`;
}
if (/^https?:\/\//i.test(s)) return `url:${s}`;
const pathRef = normalizePathRef(s);
return pathRef ? `path:${pathRef}` : '';
}
function identityKeys(row) {
const keys = new Set();
const sourceId = normalizeSourceId(row.source_id);
if (sourceId && row.source_type) keys.add(`source:${row.source_type}:${sourceId}`);
const imageKey = refKey(row.image_url);
const pathKey = refKey(row.local_path);
if (imageKey) keys.add(imageKey);
if (pathKey) keys.add(pathKey);
return keys;
}
function findExistingLibraryItem(db, table, { dramaId, sourceType, sourceId, imageUrl, localPath }) {
assertKnownTable(table);
const scopeSql = dramaId == null ? 'drama_id IS NULL' : 'drama_id = ?';
const params = dramaId == null ? [sourceType] : [sourceType, Number(dramaId)];
const rows = db.prepare(
`SELECT * FROM ${table} WHERE deleted_at IS NULL AND source_type = ? AND ${scopeSql} ORDER BY id ASC`
).all(...params);
const wanted = identityKeys({
source_type: sourceType,
source_id: normalizeSourceId(sourceId),
image_url: imageUrl,
local_path: localPath,
});
if (wanted.size === 0) return null;
return rows.find((row) => {
const existing = identityKeys(row);
for (const key of wanted) {
if (existing.has(key)) return true;
}
return false;
}) || null;
}
function insertLibraryItem(db, table, fields) {
assertKnownTable(table);
const entries = Object.entries(fields)
.filter(([name, value]) => value !== undefined && (name !== 'source_id' || hasColumn(db, table, 'source_id')));
const names = entries.map(([name]) => name);
const placeholders = names.map(() => '?').join(', ');
const values = entries.map(([, value]) => value);
return db.prepare(
`INSERT INTO ${table} (${names.join(', ')}) VALUES (${placeholders})`
).run(...values);
}
function updateLibraryItem(db, table, id, fields) {
assertKnownTable(table);
const entries = Object.entries(fields)
.filter(([name, value]) => value !== undefined && (name !== 'source_id' || hasColumn(db, table, 'source_id')));
if (entries.length === 0) return;
const assignments = entries.map(([name]) => `${name} = ?`);
const values = entries.map(([, value]) => value);
values.push(Number(id));
db.prepare(`UPDATE ${table} SET ${assignments.join(', ')} WHERE id = ?`).run(...values);
}
function parseSourceIds(value) {
if (Array.isArray(value)) {
return value.map(normalizeSourceId).filter(Boolean);
}
return String(value || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
}
function appendSourceIdFilters(query, sql, params) {
if (query.source_id) {
sql += ' AND source_id = ?';
params.push(normalizeSourceId(query.source_id));
}
const sourceIds = parseSourceIds(query.source_ids);
if (sourceIds.length > 0) {
sql += ` AND source_id IN (${sourceIds.map(() => '?').join(', ')})`;
params.push(...sourceIds);
}
return sql;
}
module.exports = {
appendSourceIdFilters,
findExistingLibraryItem,
insertLibraryItem,
updateLibraryItem,
normalizeSourceId,
};
@@ -0,0 +1,103 @@
/**
* 媒体生成画幅/比例官方参数说明与归一化图片 + 视频
*
*
* Google Gemini 图片 generateContentgenerationConfig
* 官方字段aspectRatiocamelCase字符串枚举
* 枚举GEMINI_IMAGE_ASPECT_RATIOS
* 文档https://ai.google.dev/gemini-api/docs/image-generation │
*
* Google Gemini 视频 Veo predictLongRunningparameters
* 官方字段aspectRatiocamelCase
* 文档与所用 Veo 模型版本说明一致
*
* Vidu POST /ent/v2/text2video | img2video
* 官方字段aspect_ratiosnake "16:9"
* resolution"540p"|"720p"|"1080p"依模型/时长
* 文档https://platform.vidu.com/docs/text-to-video │
*
* OpenAI Images POST /v1/images/generations
* 官方字段size DALL·E 3"1024x1024","1792x1024","1024x1792"
* 无标准顶层 aspect_ratio部分 OpenAI 兼容中转会额外识别 aspect_ratio
*
* 本项目火山/OpenAI 风格视频contents + ratio
* 当前使用ratio部分网关同时识别 aspect_ratio
*
*/
/** Gemini 图片官方枚举(与 Google 文档一致的可选 aspectRatio */
const GEMINI_IMAGE_ASPECT_RATIOS = new Set([
'1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9',
]);
/** Vidu 文生视频文档列出的 aspect_ratio(21:9 以项目 UI 为准,官方文挡以接口返回为准) */
const VIDU_ASPECT_RATIOS = new Set(['16:9', '9:16', '3:4', '4:3', '1:1', '21:9']);
/**
* 将任意比例标签限制在 Gemini 图片官方枚举内未知则 16:9
*/
function clampToGeminiImageAspectRatio(ratio) {
const r = String(ratio || '').trim();
if (GEMINI_IMAGE_ASPECT_RATIOS.has(r)) return r;
return '16:9';
}
/**
* 将比例限制在 Vidu 常见枚举内未知则 16:9
*/
function clampToViduAspectRatio(ratio) {
const r = String(ratio || '').trim();
if (VIDU_ASPECT_RATIOS.has(r)) return r;
return '16:9';
}
/**
* "2560x1440" / "1440*2560" 推断比例标签 imageClient.geminiAspectRatio 桶一致 OpenAI 兼容层附加 aspect_ratio
*/
function aspectRatioLabelFromPixelSize(size) {
if (!size || typeof size !== 'string') return '16:9';
const s = String(size).trim().toLowerCase().replace(/\s/g, '');
const ratioSet = new Set(['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3', '5:4', '4:5', '21:9']);
if (ratioSet.has(s)) return s;
const match = s.match(/^(\d+)[x*](\d+)$/);
if (!match) return '16:9';
const w = parseInt(match[1], 10);
const h = parseInt(match[2], 10);
if (!w || !h) return '16:9';
const r = w / h;
if (r > 2) return '21:9';
if (r >= 1.6) return '16:9';
if (r >= 1.2) return '4:3';
if (r >= 0.9) return '1:1';
if (r >= 0.7) return '3:4';
if (r >= 0.55) return '4:5';
return '9:16';
}
/**
* Vidu resolution 归一化img2video + q2 系模型官方常见仅 720p/1080p540p 易报错则抬到 720p
*/
function pickViduResolutionParam(resolution, modelName, hasImage) {
let r = String(resolution || '').trim().toLowerCase();
if (r === '480p') r = '540p';
const allowed = new Set(['540p', '720p', '1080p']);
if (!allowed.has(r)) r = '720p';
const m = String(modelName || '').toLowerCase();
const q2FamilyImg = hasImage && /viduq2|vidu2\.0|viduq1/i.test(m);
if (q2FamilyImg && r === '540p') r = '720p';
return r;
}
function isGeminiOfficialHost(baseUrl) {
return /generativelanguage\.googleapis\.com/i.test(String(baseUrl || ''));
}
module.exports = {
GEMINI_IMAGE_ASPECT_RATIOS,
VIDU_ASPECT_RATIOS,
clampToGeminiImageAspectRatio,
clampToViduAspectRatio,
aspectRatioLabelFromPixelSize,
pickViduResolutionParam,
isGeminiOfficialHost,
};
@@ -0,0 +1,446 @@
/**
* 整集合并后的后处理对白 TTS 解说旁白轨+SRT右下角文字水印可组合
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { getFfmpegPath, getFfprobePath } = require('../utils/ffmpegPath');
function ffprobeDurationSec(filePath) {
const probe = getFfprobePath();
const r = spawnSync(
probe,
['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath],
{ encoding: 'utf8', maxBuffer: 1024 * 1024 }
);
if (r.status !== 0) return null;
const d = parseFloat(String(r.stdout || '').trim());
return Number.isFinite(d) && d > 0 ? d : null;
}
function formatSrtTimestamp(ms) {
if (!Number.isFinite(ms) || ms < 0) ms = 0;
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
const z = Math.floor(ms % 1000);
const p2 = (n) => String(n).padStart(2, '0');
return `${p2(h)}:${p2(m)}:${p2(s)},${String(z).padStart(3, '0')}`;
}
function buildAtempoChain(factor) {
if (!Number.isFinite(factor) || factor <= 0) return null;
if (Math.abs(factor - 1) < 0.002) return null;
const parts = [];
let f = factor;
while (f > 2.001) {
parts.push('atempo=2');
f /= 2;
}
while (f < 0.499) {
parts.push('atempo=0.5');
f /= 0.5;
}
parts.push(`atempo=${Math.min(2, Math.max(0.5, f))}`);
return parts.join(',');
}
function escapeFfmpegPath(absPath) {
let s = path.resolve(absPath).replace(/\\/g, '/');
if (/^[A-Za-z]:/.test(s)) s = s.replace(/^([A-Za-z]):/, '$1\\:');
return s.replace(/'/g, "\\'");
}
function runFfmpeg(args, log, tag) {
const bin = getFfmpegPath();
const r = spawnSync(bin, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
if (r.error) {
log.warn('merged post: ffmpeg spawn', { tag, error: r.error.message });
return false;
}
if (r.status !== 0) {
log.warn('merged post: ffmpeg failed', { tag, stderr: r.stderr?.slice(-1000) });
return false;
}
return true;
}
function writeSilenceMp3(slotSec, outPath, log) {
return runFfmpeg(
['-y', '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '6', outPath],
log,
'silence'
);
}
function fitAudioToSlot(inputPath, slotSec, outPath, log) {
const d = ffprobeDurationSec(inputPath);
if (d == null || d <= 0.01) return false;
const eps = 0.06;
if (d > slotSec + eps) {
const factor = d / slotSec;
const chain = buildAtempoChain(factor);
const af = chain || 'anull';
return runFfmpeg(
['-y', '-i', inputPath, '-af', af, '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'fit_speed'
);
}
if (d < slotSec - eps) {
const pad = slotSec - d;
return runFfmpeg(
['-y', '-i', inputPath, '-af', `apad=pad_dur=${pad}`, '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'fit_pad'
);
}
try {
fs.copyFileSync(inputPath, outPath);
return true;
} catch (_) {
return runFfmpeg(
['-y', '-i', inputPath, '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'fit_copy'
);
}
}
function concatMp3List(segmentPaths, outPath, log) {
const listFile = path.join(path.dirname(outPath), `mix_concat_${Date.now()}.txt`);
try {
const lines = segmentPaths.map((p) => {
const normalized = path.resolve(p).replace(/\\/g, '/');
return `file '${normalized.replace(/'/g, "'\\''")}'`;
});
fs.writeFileSync(listFile, lines.join('\n'), 'utf8');
return runFfmpeg(
['-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'concat_mix'
);
} finally {
try {
if (fs.existsSync(listFile)) fs.unlinkSync(listFile);
} catch (_) {}
}
}
function alignAudioToVideoDuration(inMp3, videoDur, outPath, log) {
const n = ffprobeDurationSec(inMp3);
if (n == null || !Number.isFinite(videoDur) || videoDur <= 0.1) return false;
const eps = 0.08;
if (n > videoDur + eps) {
const factor = n / videoDur;
const chain = buildAtempoChain(factor);
if (!chain) {
try {
fs.copyFileSync(inMp3, outPath);
return true;
} catch (_) {
return false;
}
}
return runFfmpeg(
['-y', '-i', inMp3, '-af', chain, '-t', String(videoDur), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'align_speed'
);
}
if (n < videoDur - eps) {
const pad = videoDur - n;
return runFfmpeg(
['-y', '-i', inMp3, '-af', `apad=pad_dur=${pad}`, '-t', String(videoDur), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'align_pad'
);
}
try {
fs.copyFileSync(inMp3, outPath);
return true;
} catch (_) {
return false;
}
}
function amixTwoTracks(pathA, pathB, slotSec, outPath, log) {
return runFfmpeg(
[
'-y', '-i', pathA, '-i', pathB,
'-filter_complex', `[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=2[aout]`,
'-map', '[aout]',
'-t', String(slotSec),
'-c:a', 'libmp3lame', '-q:a', '4',
outPath,
],
log,
'amix_seg'
);
}
function getDrawtextFontOption() {
const candidates = [];
if (process.platform === 'win32') {
const root = process.env.SystemRoot || 'C:\\Windows';
candidates.push(
path.join(root, 'Fonts', 'msyh.ttc'),
path.join(root, 'Fonts', 'msyhbd.ttc'),
path.join(root, 'Fonts', 'simhei.ttf')
);
}
candidates.push('/System/Library/Fonts/PingFang.ttc', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf');
for (const p of candidates) {
if (p && fs.existsSync(p)) {
return `:fontfile='${escapeFfmpegPath(p)}'`;
}
}
return '';
}
/**
* @param {object} mergeOpts burn_dialogue_audio, burn_narration_subtitles, watermark_text
*/
async function runMergedEpisodePostProcess(db, log, opts) {
const { mergedAbsPath, storageRoot, scenes, episodeId, mergeOpts = {} } = opts;
const wantDial = !!mergeOpts.burn_dialogue_audio;
const wantNarr = !!mergeOpts.burn_narration_subtitles;
const watermarkText = (mergeOpts.watermark_text && String(mergeOpts.watermark_text).trim())
? String(mergeOpts.watermark_text).trim().slice(0, 200)
: '';
if (!mergedAbsPath || !fs.existsSync(mergedAbsPath) || !Array.isArray(scenes) || scenes.length === 0) {
return { ok: false, error: '无效合成参数' };
}
const needAudio = wantDial || wantNarr;
if (!needAudio && !watermarkText) {
return { ok: false, error: 'NO_POST_OPTS' };
}
const videoDur = ffprobeDurationSec(mergedAbsPath);
if (videoDur == null) {
return { ok: false, error: '无法读取合成视频时长' };
}
const tempRoot = path.join(require('os').tmpdir(), 'drama-merged-post', String(episodeId || 0), String(Date.now()));
fs.mkdirSync(tempRoot, { recursive: true });
const ttsService = require('./ttsService');
try {
let alignedAudioPath = null;
let srtPath = null;
let srtLines = [];
if (needAudio) {
let tMs = 0;
let srtIdx = 1;
const segmentFiles = [];
for (let i = 0; i < scenes.length; i++) {
const sc = scenes[i];
const sbId = Number(sc.scene_id);
const slotSec = Math.max(0.2, Number(sc.duration) || 5);
const row = db.prepare(
'SELECT dialogue, narration, audio_local_path, narration_audio_local_path FROM storyboards WHERE id = ? AND deleted_at IS NULL'
).get(sbId);
const narrText = (row?.narration && String(row.narration).trim()) ? String(row.narration).trim() : '';
if (wantNarr && narrText) {
const durMs = Math.round(slotSec * 1000);
srtLines.push(String(srtIdx++), `${formatSrtTimestamp(tMs)} --> ${formatSrtTimestamp(tMs + durMs)}`, narrText, '');
}
tMs += Math.round(slotSec * 1000);
const diaFit = path.join(tempRoot, `dia_fit_${i}.mp3`);
const narrFit = path.join(tempRoot, `narr_fit_${i}.mp3`);
const segOut = path.join(tempRoot, `seg_mix_${i}.mp3`);
if (wantDial) {
const rel = row?.audio_local_path && String(row.audio_local_path).trim();
const srcAbs = rel ? path.join(storageRoot, rel.replace(/\//g, path.sep)) : null;
if (srcAbs && fs.existsSync(srcAbs)) {
if (!fitAudioToSlot(srcAbs, slotSec, diaFit, log)) {
return { ok: false, error: `对白配音时长对齐失败 #${i}` };
}
} else if (!writeSilenceMp3(slotSec, diaFit, log)) {
return { ok: false, error: `对白静音片段失败 #${i}` };
}
}
if (wantNarr) {
if (!narrText) {
if (!writeSilenceMp3(slotSec, narrFit, log)) {
return { ok: false, error: `旁白静音片段失败 #${i}` };
}
} else {
const segRaw = path.join(tempRoot, `narr_raw_${i}.mp3`);
let synth;
try {
synth = await ttsService.synthesize(db, log, {
text: narrText,
storyboard_id: null,
storage_base: storageRoot,
});
} catch (e) {
log.warn('merged post: narration TTS failed', { segment: i, error: e.message });
return { ok: false, error: `解说旁白 TTS 失败:${e.message}` };
}
const narrAbs = path.join(storageRoot, synth.local_path.replace(/\//g, path.sep));
if (!fs.existsSync(narrAbs)) {
return { ok: false, error: `旁白 TTS 文件不存在` };
}
try {
fs.copyFileSync(narrAbs, segRaw);
} catch (_) {
return { ok: false, error: '复制旁白 TTS 失败' };
}
if (!fitAudioToSlot(segRaw, slotSec, narrFit, log)) {
return { ok: false, error: `旁白时长对齐失败 #${i}` };
}
}
}
if (wantDial && wantNarr) {
if (!amixTwoTracks(diaFit, narrFit, slotSec, segOut, log)) {
return { ok: false, error: `对白与旁白混音失败 #${i}` };
}
} else if (wantDial) {
try {
fs.copyFileSync(diaFit, segOut);
} catch (_) {
return { ok: false, error: `对白片段复制失败 #${i}` };
}
} else if (wantNarr) {
try {
fs.copyFileSync(narrFit, segOut);
} catch (_) {
return { ok: false, error: `旁白片段复制失败 #${i}` };
}
}
segmentFiles.push(segOut);
}
const concatOut = path.join(tempRoot, 'full_mix.mp3');
if (!concatMp3List(segmentFiles, concatOut, log)) {
return { ok: false, error: '音轨拼接失败' };
}
alignedAudioPath = path.join(tempRoot, 'aligned_mix.mp3');
if (!alignAudioToVideoDuration(concatOut, videoDur, alignedAudioPath, log)) {
return { ok: false, error: '音轨与视频总时长对齐失败' };
}
if (wantNarr && srtLines.length > 0) {
const baseName = path.basename(mergedAbsPath, path.extname(mergedAbsPath));
srtPath = path.join(path.dirname(mergedAbsPath), `${baseName}_narration.srt`);
fs.writeFileSync(srtPath, `\uFEFF${srtLines.join('\n')}\n`, 'utf8');
}
}
const baseName = path.basename(mergedAbsPath, path.extname(mergedAbsPath));
const outAbs = path.join(path.dirname(mergedAbsPath), `${baseName}_post.mp4`);
const hasSubs = !!(srtPath && fs.existsSync(srtPath));
const hasWm = !!watermarkText;
const vfParts = [];
if (hasSubs) {
const subEsc = escapeFfmpegPath(srtPath);
vfParts.push(`subtitles='${subEsc}':charenc=UTF-8`);
}
if (hasWm) {
const wmFile = path.join(tempRoot, 'watermark.txt');
fs.writeFileSync(wmFile, watermarkText, 'utf8');
const wmEsc = escapeFfmpegPath(wmFile);
const fontOpt = getDrawtextFontOption();
vfParts.push(
`drawtext=textfile='${wmEsc}':reload=1${fontOpt}:x=w-tw-16:y=h-th-16:fontsize=22:fontcolor=white@0.82:borderw=2:bordercolor=black@0.55`
);
}
let filterComplex = '';
if (vfParts.length === 1) {
filterComplex = `[0:v]${vfParts[0]}[vout]`;
} else if (vfParts.length === 2) {
filterComplex = `[0:v]${vfParts[0]}[vx];[vx]${vfParts[1]}[vout]`;
}
if (needAudio) {
if (!alignedAudioPath || !fs.existsSync(alignedAudioPath)) {
return { ok: false, error: '内部错误:缺少对齐音轨' };
}
const args = ['-y', '-i', mergedAbsPath, '-i', alignedAudioPath];
if (filterComplex) {
args.push('-filter_complex', filterComplex, '-map', '[vout]', '-map', '1:a');
} else {
args.push('-map', '0:v', '-map', '1:a');
}
args.push(
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-shortest', outAbs
);
if (!runFfmpeg(args, log, 'mux_av')) {
return { ok: false, error: '烧录字幕/水印或混音失败(请确认 ffmpeg 含 libx264' };
}
} else {
if (!filterComplex) {
return { ok: false, error: '内部错误:仅水印但无滤镜链' };
}
const args = ['-y', '-i', mergedAbsPath, '-filter_complex', filterComplex, '-map', '[vout]'];
if (ffprobeHasAudio(mergedAbsPath)) {
args.push('-map', '0:a', '-c:a', 'copy');
} else {
args.push('-an');
}
args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-movflags', '+faststart', outAbs);
if (!runFfmpeg(args, log, 'watermark_only')) {
return { ok: false, error: '水印烧录失败' };
}
}
if (!fs.existsSync(outAbs)) {
return { ok: false, error: '输出文件未生成' };
}
const relFromRoot = path.relative(storageRoot, outAbs).replace(/\\/g, '/');
try {
if (fs.existsSync(mergedAbsPath) && outAbs !== mergedAbsPath) {
fs.unlinkSync(mergedAbsPath);
}
} catch (e) {
log.warn('merged post: could not remove intermediate', { error: e.message });
}
log.info('merged post: done', { episode_id: episodeId, video: relFromRoot });
return { ok: true, relativePath: relFromRoot };
} catch (e) {
log.warn('merged post: exception', { error: e.message });
return { ok: false, error: e.message || String(e) };
} finally {
try {
for (const p of fs.readdirSync(tempRoot)) {
try {
fs.unlinkSync(path.join(tempRoot, p));
} catch (_) {}
}
fs.rmdirSync(tempRoot);
} catch (_) {}
}
}
function ffprobeHasAudio(filePath) {
const probe = getFfprobePath();
const r = spawnSync(
probe,
['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', filePath],
{ encoding: 'utf8', maxBuffer: 1024 * 1024 }
);
return r.status === 0 && String(r.stdout || '').trim().length > 0;
}
module.exports = {
runMergedEpisodePostProcess,
ffprobeDurationSec,
};
@@ -0,0 +1,269 @@
'use strict';
const querystring = require('querystring');
const { Signer } = require('@volcengine/openapi');
const ALLOWED_ACTIONS = new Set([
'CreateAssetGroup',
'CreateAsset',
'ListAssetGroups',
'ListAssets',
'GetAsset',
'GetAssetGroup',
'UpdateAssetGroup',
'UpdateAsset',
'DeleteAsset',
'DeleteAssetGroup',
]);
function normalizeBaseUrl(raw) {
let s = String(raw || '').trim().replace(/\/$/, '');
if (!s) throw new Error('缺少 base_url');
if (!/^https?:\/\//i.test(s)) throw new Error('base_url 须以 http:// 或 https:// 开头');
return s;
}
/**
* 仅主机无路径时补全 /api/v3与控制台 OpenAPI 一致否则 IAM/路由可能不按预期解析 ProjectName
*/
function ensureArkOpenApiBasePath(raw) {
const s0 = String(raw || '').trim();
if (!s0) return s0;
let u;
try {
u = new URL(s0.replace(/\/+$/, ''));
} catch {
return s0;
}
const path = (u.pathname || '/').replace(/\/+$/, '') || '/';
const host = (u.host || '').toLowerCase();
const looksArk =
/(^|\.)ark\./.test(host) ||
host.includes('byteplus') ||
host.includes('volces.com');
if (looksArk && (path === '' || path === '/')) {
u.pathname = '/api/v3';
return u.toString().replace(/\/+$/, '');
}
return s0.replace(/\/+$/, '');
}
function normalizeBearerToken(raw) {
let k = String(raw || '').trim();
if (!k) return '';
if (/^bearer\s+/i.test(k)) k = k.replace(/^bearer\s+/i, '').trim();
return k;
}
function inferSignRegion(host, explicit) {
if (explicit && String(explicit).trim()) return String(explicit).trim();
const h = String(host || '').toLowerCase();
if (h.includes('bytepluses') || h.includes('byteplus')) return 'ap-southeast-1';
if (h.includes('ap-southeast')) return 'ap-southeast-1';
if (h.includes('cn-beijing') || h.includes('volces.com')) return 'cn-beijing';
return 'cn-beijing';
}
/**
* 转发 ModelArk / 方舟私有资产库请求
*
* - open_api_queryPOST {base}?Action=&Version=JSON body
* 控制面接口须使用 **auth_mode: volc_sign**Access Key 签名推理用的 ARK API Key + Bearer 会报 Invalid Authorization
* - asset_subpath / flat部分中转仍可用 Bearer
*/
function buildRequestUrl(base, pathMode, act, apiVersion, projectName) {
const ver = (apiVersion || '2024-01-01').toString().trim() || '2024-01-01';
if (pathMode === 'flat') {
return `${base}/${encodeURIComponent(act)}`;
}
if (pathMode === 'asset_subpath') {
return `${base}/asset/${encodeURIComponent(act)}`;
}
let u;
try {
u = new URL(base);
} catch (e) {
throw new Error('base_url 不是合法 URL');
}
u.searchParams.set('Action', act);
u.searchParams.set('Version', ver);
const pn = (projectName || '').toString().trim();
if (pn) u.searchParams.set('ProjectName', pn);
return u.toString();
}
function extractUpstreamMessage(data, text) {
const m =
data &&
data.ResponseMetadata &&
data.ResponseMetadata.Error &&
data.ResponseMetadata.Error.Message;
if (m) return String(m);
if (data && data.message) return String(data.message);
if (data && data.Message) return String(data.Message);
return `HTTP 错误: ${text ? text.slice(0, 500) : ''}`;
}
function parseSignedOpenApiUrl(base) {
const u = new URL(base);
const protocol = u.protocol || 'https:';
const host = u.host;
let pathname = u.pathname || '/';
if (!pathname || pathname === '') pathname = '/';
return { protocol, host, pathname };
}
async function fetchSignedOpenApi({
base,
action,
apiVersion,
bodyObj,
accessKeyId,
secretKey,
sessionToken,
signRegion,
signService,
projectName,
}) {
const ver = (apiVersion || '2024-01-01').toString().trim() || '2024-01-01';
const { protocol, host, pathname } = parseSignedOpenApiUrl(base);
const bodyStr = JSON.stringify(bodyObj && typeof bodyObj === 'object' ? bodyObj : {});
const params = { Action: action, Version: ver };
const pn = (projectName || '').toString().trim();
if (pn) params.ProjectName = pn;
const request = {
region: inferSignRegion(host, signRegion),
method: 'POST',
pathname,
params,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: bodyStr,
};
const signer = new Signer(request, (signService || 'ark').toString().trim() || 'ark');
signer.addAuthorization({
accessKeyId: accessKeyId.trim(),
secretKey: secretKey.trim(),
sessionToken: (sessionToken || '').trim(),
});
const qs = querystring.stringify(request.params);
const url = `${protocol}//${host}${pathname}?${qs}`;
const res = await fetch(url, {
method: 'POST',
headers: request.headers,
body: bodyStr,
redirect: 'manual',
});
return res;
}
async function fetchBearer(url, method, token, bodyObj) {
const headers = {
Authorization: `Bearer ${token}`,
};
const init = {
method: String(method || 'POST').toUpperCase(),
headers,
redirect: 'manual',
};
if (init.method !== 'GET' && init.method !== 'HEAD') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(bodyObj && typeof bodyObj === 'object' ? bodyObj : {});
}
return fetch(url, init);
}
async function callModelArkAsset(opts, log) {
const {
base_url,
api_key,
action,
body,
path_mode,
http_method,
api_version,
auth_mode,
access_key_id,
secret_access_key,
sign_region,
sign_service,
session_token,
project_name,
} = opts;
if (!action || typeof action !== 'string') throw new Error('缺少 action');
const act = action.trim();
if (!ALLOWED_ACTIONS.has(act)) throw new Error('不支持的 action: ' + act);
const base = normalizeBaseUrl(ensureArkOpenApiBasePath(base_url));
const pathMode = (path_mode || 'open_api_query').toString();
const modeAuth = (auth_mode || 'bearer').toString();
const method = String(http_method || 'POST').toUpperCase();
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
throw new Error('不支持的 http_method');
}
const pnScope = (project_name || '').toString().trim();
let bodyObj = body && typeof body === 'object' ? { ...body } : {};
if (pnScope && (bodyObj.ProjectName === undefined || bodyObj.ProjectName === null)) {
bodyObj.ProjectName = pnScope;
}
let res;
if (modeAuth === 'volc_sign') {
const ak = String(access_key_id || '').trim();
const sk = String(secret_access_key || '').trim();
if (!ak || !sk) {
throw new Error('控制面 OpenAPI 须填写 Access Key ID 与 Secret Access Key(控制台 IAM 密钥,非推理 API Key');
}
if (pathMode !== 'open_api_query') {
throw new Error('AK/SK 签名仅支持与「官方 OpenAPI」路径模式(Query 中带 Action)一起使用');
}
res = await fetchSignedOpenApi({
base,
action: act,
apiVersion: api_version,
bodyObj,
accessKeyId: ak,
secretKey: sk,
sessionToken: session_token,
signRegion: sign_region,
signService: sign_service,
projectName: pnScope,
});
} else {
const token = normalizeBearerToken(api_key);
if (!token) throw new Error('缺少 api_key');
const url = buildRequestUrl(base, pathMode, act, api_version, pnScope);
res = await fetchBearer(url, method, token, bodyObj);
}
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = { _raw: text };
}
if (!res.ok) {
const msg = extractUpstreamMessage(data, text) || `HTTP ${res.status}`;
const err = new Error(String(msg).slice(0, 2000));
err.status = res.status;
err.payload = data;
if (log) log.warn('modelArkAsset proxy upstream error', { action: act, status: res.status });
throw err;
}
return data;
}
module.exports = {
callModelArkAsset,
ALLOWED_ACTIONS,
};
@@ -0,0 +1,343 @@
/**
* 合成后处理解说旁白 SRT按分镜时长生成/加速/补齐旁白 TTS烧录字幕并与旁白音轨 mux
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { getFfmpegPath, getFfprobePath } = require('../utils/ffmpegPath');
function ffprobeDurationSec(filePath) {
const probe = getFfprobePath();
const r = spawnSync(
probe,
['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath],
{ encoding: 'utf8', maxBuffer: 1024 * 1024 }
);
if (r.status !== 0) return null;
const d = parseFloat(String(r.stdout || '').trim());
return Number.isFinite(d) && d > 0 ? d : null;
}
function formatSrtTimestamp(ms) {
if (!Number.isFinite(ms) || ms < 0) ms = 0;
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
const z = Math.floor(ms % 1000);
const p2 = (n) => String(n).padStart(2, '0');
return `${p2(h)}:${p2(m)}:${p2(s)},${String(z).padStart(3, '0')}`;
}
/** 构造 atempo 链,总倍率 = factor>1 加速变短) */
function buildAtempoChain(factor) {
if (!Number.isFinite(factor) || factor <= 0) return null;
if (Math.abs(factor - 1) < 0.002) return null;
const parts = [];
let f = factor;
while (f > 2.001) {
parts.push('atempo=2');
f /= 2;
}
while (f < 0.499) {
parts.push('atempo=0.5');
f /= 0.5;
}
parts.push(`atempo=${Math.min(2, Math.max(0.5, f))}`);
return parts.join(',');
}
function escapeSubtitlesPathForFfmpeg(absPath) {
let s = path.resolve(absPath).replace(/\\/g, '/');
if (/^[A-Za-z]:/.test(s)) s = s.replace(/^([A-Za-z]):/, '$1\\:');
return s.replace(/'/g, "\\'");
}
function runFfmpeg(args, log, tag) {
const bin = getFfmpegPath();
const r = spawnSync(bin, args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
if (r.error) {
log.warn('narration post: ffmpeg spawn', { tag, error: r.error.message });
return false;
}
if (r.status !== 0) {
log.warn('narration post: ffmpeg failed', { tag, stderr: r.stderr?.slice(-800) });
return false;
}
return true;
}
function writeSilenceMp3(slotSec, outPath, log) {
return runFfmpeg(
[
'-y',
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
'-t', String(slotSec),
'-c:a', 'libmp3lame', '-q:a', '6',
outPath,
],
log,
'silence'
);
}
/**
* 将单段音频调整为精确时长 slotSec过长则加速过短则尾部静音补齐
*/
function fitAudioToSlot(inputPath, slotSec, outPath, log) {
const d = ffprobeDurationSec(inputPath);
if (d == null || d <= 0.01) return false;
const eps = 0.06;
if (d > slotSec + eps) {
const factor = d / slotSec;
const chain = buildAtempoChain(factor);
const af = chain || 'anull';
return runFfmpeg(
['-y', '-i', inputPath, '-af', af, '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'fit_speed'
);
}
if (d < slotSec - eps) {
const pad = slotSec - d;
return runFfmpeg(
['-y', '-i', inputPath, '-af', `apad=pad_dur=${pad}`, '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'fit_pad'
);
}
try {
fs.copyFileSync(inputPath, outPath);
return true;
} catch (_) {
return runFfmpeg(
['-y', '-i', inputPath, '-t', String(slotSec), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'fit_copy'
);
}
}
function concatMp3List(segmentPaths, outPath, log) {
const listFile = path.join(path.dirname(outPath), `narr_concat_${Date.now()}.txt`);
try {
const lines = segmentPaths.map((p) => {
const normalized = path.resolve(p).replace(/\\/g, '/');
return `file '${normalized.replace(/'/g, "'\\''")}'`;
});
fs.writeFileSync(listFile, lines.join('\n'), 'utf8');
return runFfmpeg(
['-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'concat_narr'
);
} finally {
try {
if (fs.existsSync(listFile)) fs.unlinkSync(listFile);
} catch (_) {}
}
}
/**
* 将整条旁白轨对齐到视频时长视频偏短则整体加速
*/
function alignNarrationToVideoDuration(narrMp3, videoDur, outPath, log) {
const n = ffprobeDurationSec(narrMp3);
if (n == null || !Number.isFinite(videoDur) || videoDur <= 0.1) return false;
const eps = 0.08;
if (n > videoDur + eps) {
const factor = n / videoDur;
const chain = buildAtempoChain(factor);
if (!chain) {
try {
fs.copyFileSync(narrMp3, outPath);
return true;
} catch (_) {
return false;
}
}
return runFfmpeg(
['-y', '-i', narrMp3, '-af', chain, '-t', String(videoDur), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'align_speed'
);
}
if (n < videoDur - eps) {
const pad = videoDur - n;
return runFfmpeg(
['-y', '-i', narrMp3, '-af', `apad=pad_dur=${pad}`, '-t', String(videoDur), '-c:a', 'libmp3lame', '-q:a', '4', outPath],
log,
'align_pad'
);
}
try {
fs.copyFileSync(narrMp3, outPath);
return true;
} catch (_) {
return false;
}
}
function burnSubtitlesAndMux(mergedVideoPath, narrAlignedMp3, srtPath, outPath, log) {
const sub = escapeSubtitlesPathForFfmpeg(srtPath);
const vf = `subtitles='${sub}':charenc=UTF-8`;
const args = [
'-y',
'-i', mergedVideoPath,
'-i', narrAlignedMp3,
'-filter_complex', `[0:v]${vf}[v]`,
'-map', '[v]',
'-map', '1:a',
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+faststart',
'-shortest',
outPath,
];
return runFfmpeg(args, log, 'burn_mux');
}
/**
* @returns {Promise<{ ok: boolean, relativePath?: string, error?: string }>}
*/
async function runNarrationSubtitlePostProcess(db, log, opts) {
const {
mergedAbsPath,
mergedRelativePath,
projectSubdir,
storageRoot,
scenes,
episodeId,
} = opts;
if (!mergedAbsPath || !fs.existsSync(mergedAbsPath) || !Array.isArray(scenes) || scenes.length === 0) {
return { ok: false, error: '无效合成参数' };
}
const videoDur = ffprobeDurationSec(mergedAbsPath);
if (videoDur == null) {
return { ok: false, error: '无法读取合成视频时长' };
}
let tMs = 0;
const srtLines = [];
let srtIdx = 1;
const segmentFiles = [];
const tempRoot = path.join(require('os').tmpdir(), 'drama-narr-post', String(episodeId || 0), String(Date.now()));
fs.mkdirSync(tempRoot, { recursive: true });
const ttsService = require('./ttsService');
try {
for (let i = 0; i < scenes.length; i++) {
const sc = scenes[i];
const sbId = Number(sc.scene_id);
const slotSec = Math.max(0.2, Number(sc.duration) || 5);
const row = db.prepare('SELECT narration FROM storyboards WHERE id = ? AND deleted_at IS NULL').get(sbId);
const text = (row?.narration && String(row.narration).trim()) ? String(row.narration).trim() : '';
if (text) {
const durMs = Math.round(slotSec * 1000);
const start = formatSrtTimestamp(tMs);
const end = formatSrtTimestamp(tMs + durMs);
srtLines.push(String(srtIdx++), `${start} --> ${end}`, text, '');
}
tMs += Math.round(slotSec * 1000);
const segFit = path.join(tempRoot, `seg_${i}_fit.mp3`);
if (!text) {
if (!writeSilenceMp3(slotSec, segFit, log)) {
return { ok: false, error: `生成静音片段失败 #${i}` };
}
} else {
const segRaw = path.join(tempRoot, `seg_${i}_raw.mp3`);
let synth;
try {
synth = await ttsService.synthesize(db, log, {
text,
storyboard_id: null,
storage_base: storageRoot,
});
} catch (e) {
log.warn('narration post: TTS failed', { segment: i, error: e.message });
return { ok: false, error: `旁白 TTS 失败:${e.message}` };
}
const srcAbs = path.join(storageRoot, synth.local_path.replace(/\//g, path.sep));
if (!fs.existsSync(srcAbs)) {
return { ok: false, error: `TTS 文件不存在:${synth.local_path}` };
}
try {
fs.copyFileSync(srcAbs, segRaw);
} catch (_) {
return { ok: false, error: '复制 TTS 文件失败' };
}
if (!fitAudioToSlot(segRaw, slotSec, segFit, log)) {
return { ok: false, error: `旁白时长对齐失败 #${i}` };
}
}
segmentFiles.push(segFit);
}
if (srtLines.length === 0) {
log.info('narration post: skip (no narration text in merged scenes)', { episode_id: episodeId });
return { ok: false, error: 'NO_NARRATION' };
}
const narrConcat = path.join(tempRoot, 'narr_concat.mp3');
if (!concatMp3List(segmentFiles, narrConcat, log)) {
return { ok: false, error: '旁白拼接失败' };
}
const narrAligned = path.join(tempRoot, 'narr_aligned.mp3');
if (!alignNarrationToVideoDuration(narrConcat, videoDur, narrAligned, log)) {
return { ok: false, error: '旁白与视频总时长对齐失败' };
}
const baseName = path.basename(mergedAbsPath, path.extname(mergedAbsPath));
const srtPath = path.join(path.dirname(mergedAbsPath), `${baseName}_narration.srt`);
fs.writeFileSync(srtPath, `\uFEFF${srtLines.join('\n')}\n`, 'utf8');
const outAbs = path.join(path.dirname(mergedAbsPath), `${baseName}_subs.mp4`);
if (!burnSubtitlesAndMux(mergedAbsPath, narrAligned, srtPath, outAbs, log)) {
return { ok: false, error: '烧录字幕或混音失败(请确认已安装 ffmpeg 且支持 libx264' };
}
if (!fs.existsSync(outAbs)) {
return { ok: false, error: '输出文件未生成' };
}
const relFromRoot = path.relative(storageRoot, outAbs).replace(/\\/g, '/');
const subRel = path.relative(storageRoot, srtPath).replace(/\\/g, '/');
try {
if (fs.existsSync(mergedAbsPath) && outAbs !== mergedAbsPath) {
fs.unlinkSync(mergedAbsPath);
}
} catch (e) {
log.warn('narration post: could not remove intermediate merge', { error: e.message });
}
log.info('narration post: done', { episode_id: episodeId, video: relFromRoot, srt: subRel });
return { ok: true, relativePath: relFromRoot, srtRelativePath: subRel };
} catch (e) {
log.warn('narration post: exception', { error: e.message });
return { ok: false, error: e.message || String(e) };
} finally {
try {
for (const p of fs.readdirSync(tempRoot)) {
try {
fs.unlinkSync(path.join(tempRoot, p));
} catch (_) {}
}
fs.rmdirSync(tempRoot);
} catch (_) {}
}
}
module.exports = {
runNarrationSubtitlePostProcess,
ffprobeDurationSec,
};
@@ -0,0 +1,110 @@
/**
* 小说/长文章节导入服务
* 功能上传 txt/docx 内容 AI 识别章节分割 自动填充各集剧本
*/
const aiClient = require('./aiClient');
const { safeParseAIJSON } = require('../utils/safeJson');
/**
* 简单的章节检测不调用 AI基于规则
* 识别常见章节标题格式
*/
function detectChaptersByRules(text) {
const lines = text.split(/\r?\n/);
const chapterPatterns = [
/^第[零一二三四五六七八九十百千\d]+章/,
/^第[零一二三四五六七八九十百千\d]+节/,
/^Chapter\s+\d+/i,
/^CHAPTER\s+\d+/,
/^\d+[\.、]\s*.{2,20}$/,
/^【.{1,30}】$/,
/^「.{1,30}」$/,
];
const chapters = [];
let currentStart = 0;
let currentTitle = '序章';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const isChapter = chapterPatterns.some((p) => p.test(line));
if (isChapter) {
if (i > currentStart) {
const content = lines.slice(currentStart, i).join('\n').trim();
if (content.length > 20) {
chapters.push({ title: currentTitle, content });
}
}
currentTitle = line;
currentStart = i + 1;
}
}
// 最后一章
const lastContent = lines.slice(currentStart).join('\n').trim();
if (lastContent.length > 20) {
chapters.push({ title: currentTitle, content: lastContent });
}
return chapters;
}
/**
* AI 将章节内容摘要为剧本形式
*/
async function summarizeChapterToScript(db, log, chapterTitle, chapterContent, dramaTitle) {
const maxLen = 2000;
const truncated = chapterContent.length > maxLen ? chapterContent.slice(0, maxLen) + '...' : chapterContent;
const userPrompt = `小说名称:${dramaTitle || '未知'}
章节标题${chapterTitle}
章节原文部分
${truncated}
请将上述章节内容改写为短剧剧本格式包含场景描述角色对话动作说明输出为中文纯文本不需要 JSON 格式长度200-500`;
try {
const result = await aiClient.generateText(db, log, 'text', userPrompt, null, {
scene_key: 'novel_import',
max_tokens: 800,
temperature: 0.7,
});
return result || chapterContent.slice(0, 500);
} catch (err) {
log.warn('[小说导入] AI改写章节失败,使用原文截断', { error: err.message });
return chapterContent.slice(0, 500);
}
}
/**
* 主入口解析小说文本返回章节列表
* @returns {{ chapters: Array<{title, content, script}> }}
*/
async function importNovel(db, log, { text, title, maxChapters, aiSummarize }) {
if (!text || !text.trim()) throw new Error('小说内容不能为空');
const chapters = detectChaptersByRules(text);
if (chapters.length === 0) {
// 没有检测到章节,整个文本作为一章
chapters.push({ title: title || '第一集', content: text.trim() });
}
const limit = Math.min(maxChapters || 20, chapters.length);
const result = [];
for (let i = 0; i < limit; i++) {
const ch = chapters[i];
let script = ch.content;
if (aiSummarize) {
script = await summarizeChapterToScript(db, log, ch.title, ch.content, title);
}
result.push({
index: i + 1,
title: ch.title,
content: ch.content.slice(0, 300),
script,
});
}
return { chapters: result, total: chapters.length };
}
module.exports = { importNovel, detectChaptersByRules };
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More