commit f831bab4862a10aaaf4c6b0f89f0b544b747c055 Author: Codex Date: Mon Nov 9 12:10:05 2026 +0800 Initial import of Huobao multi-GPU customizations diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df66861 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto +*.sh text eol=lf +*.py text eol=lf +*.ts text eol=lf +*.vue text eol=lf +*.md text eol=lf +*.json text eol=lf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6aacc3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# Only source code, maintenance scripts and documentation belong in this repo. +/* +!/.gitignore +!/.gitattributes +!/README.md +!/一键启动火宝.cmd +!/升级与高清制作说明.txt +!/huobao-source/ +!/model-bridge/ +!/integration-work/ + +# Preserve the upstream checkout locally; never embed it as a Git submodule. +huobao-source/.git/ + +# Bridge source and reusable style presets; local credentials/reports stay local. +/model-bridge/* +!/model-bridge/.gitignore +!/model-bridge/*.py +!/model-bridge/*.ps1 +!/model-bridge/*.cjs +!/model-bridge/*.cmd +!/model-bridge/*.example.json +!/model-bridge/接入说明.txt +!/model-bridge/style-live-action.json +!/model-bridge/style-xianxia.json + +# Keep maintenance source, not staged applications, fixtures, logs or audit data. +/integration-work/* +!/integration-work/*.py +!/integration-work/*.ps1 +!/integration-work/*.cjs +!/integration-work/browser-test/ +/integration-work/browser-test/* +!/integration-work/browser-test/*.cjs +/integration-work/inspect_shot05_*.py +/integration-work/transcribe_shot05.py + +# Dependencies and generated application bundles. +node_modules/ +__pycache__/ +*.py[cod] +.nuxt/ +.output/ +dist/ +build/ +release/ +coverage/ +*.asar +*.exe +*.msi +*.zip +*.7z + +# Never version video output (including uppercase extensions). +*.[mM][pP]4 +*.[mM][kK][vV] +*.[mM][oO][vV] +*.[aA][vV][iI] +*.[wW][eE][bB][mM] +*.[mM]4[vV] +*.[mM][pP][eE][gG] +*.[mM][pP][gG] +*.[mM]2[tT][sS] +*.[mM][tT][sS] +*.[fF][lL][vV] +*.[wW][mM][vV] +*.3[gG][pP] +*.[oO][gG][vV] + +# Runtime databases, secrets and local configuration. +*.db +*.db-* +*.sqlite +*.sqlite-* +*.sqlite3 +*.sqlite3-* +*.log +*.bak +*.pem +*.key +*.p12 +*.pfx +.env +.env.* +!.env.example +!.env.sample +client.json +grok-client.json +config.local.json +secrets.json +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..786358e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# 火宝短剧 · 本地多显卡版 + +基于 [chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama) 4.0.11 的本地定制代码。上游源码基线为 `b48e6b2312166e25b1ff141832d2e82e5d63c142`,原项目说明和许可证保留在 `huobao-source/`。 + +## 已集成功能 + +- 模型桥接与多显卡图片、视频调度;可按单个分镜选择显卡重新生成。 +- 显示实际执行节点:GPU1 / ComfyUI 8189、GPU2 / ComfyUI 8188、GPU3 / ComfyUI 8191。 +- MiniMax H3 视频及 VDN-H3 2K/4K 高清版本,保留原版供对比。 +- 角色、场景、道具的图片参考图上传与编辑接口。 +- 本地长任务持续查询、重启后恢复查询和原任务结果恢复。 +- 固定数据目录、一键启动、升级备份与真人实拍、仙侠风格预设。 + +## 目录 + +| 目录或文件 | 用途 | +| --- | --- | +| `huobao-source/` | 前端、后端、Electron 桌面端源码及上游文档 | +| `model-bridge/` | 模型桥接、启动、打包、升级、数据检查和测试脚本 | +| `integration-work/*.py`、`*.ps1`、`*.cjs` | 服务器适配、部署、迁移及验证脚本;部分为历史维护步骤,运行前阅读脚本 | +| `integration-work/browser-test/` | 浏览器界面验证脚本 | +| `一键启动火宝.cmd` | 当前 Windows 工作站的启动入口 | +| `升级与高清制作说明.txt` | 本地升级和高清制作说明 | + +## 本地数据与配置 + +此仓库保存代码,**不包含项目数据库、模型密钥、生成素材、视频、安装包和备份**。现有机器上的这些文件不会因为加入 `.gitignore` 而被删除。 + +- `huobao-data/`:项目、模型配置、素材和生成记录,必须单独备份。 +- `HuobaoDrama/`:安装后的桌面应用,不随代码上传。 +- `model-bridge/client.json`、`grok-client.json`:本机连接配置和令牌,不上传。 +- `integration-work/backups/`、`previous-app-*/`、`stage-4.0.11/`:升级备份及打包产物,不上传。 +- 所有常见视频扩展名均已加入忽略规则;TypeScript 的 `.ts` 源码正常跟踪。 + +在新机器上仅克隆代码不会恢复原项目和模型配置,需要另外恢复数据备份并配置私有接口。维护脚本含当前工作站路径和内网节点约定,迁移前需按部署环境检查。 + +## 开发与构建 + +按 `huobao-source/README.md` 和各目录的 `package.json` 安装对应依赖并构建。当前本地升级流程还使用 `model-bridge/build_upgrade.py` 与 `switch_upgrade.ps1`,需要准备已验证的 4.0.11 桌面运行包;安装包与暂存包不存入 Git。 + +`一键启动火宝.cmd` 面向已经安装、已有数据的工作站,不是全新安装程序。升级前先保存编辑并完成数据库、素材和配置备份。 + +## Git 同步 + +```powershell +git pull --ff-only origin main +git status +git add <需要提交的代码路径> +git commit -m "说明本次修改" +git push origin main +``` + +拉取代码不会自动替换正在运行的桌面程序。构建和升级需单独执行。当前工作站 `huobao-source/.git` 中的上游历史保留在本地,不作为子模块提交;新克隆会直接得到完整源码。 diff --git a/huobao-source/.dockerignore b/huobao-source/.dockerignore new file mode 100644 index 0000000..e6753a5 --- /dev/null +++ b/huobao-source/.dockerignore @@ -0,0 +1,23 @@ +# 版本控制与产物 +.git +**/node_modules +**/.nuxt +**/.output +frontend/dist +backend/dist +desktop/build +desktop/dist +desktop/release +release + +# 运行时数据(绝不能进镜像:SQLite + 生成的媒体文件) +data + +# 本地环境 +.env +*.log +.DS_Store + +# 桌面发布与密钥配置不参与服务端镜像构建 +desktop +**/.env* diff --git a/huobao-source/.env.example b/huobao-source/.env.example new file mode 100644 index 0000000..24d90bc --- /dev/null +++ b/huobao-source/.env.example @@ -0,0 +1,6 @@ +# Docker 部署环境变量(复制为 .env 后修改) +# Watchtower HTTP API 令牌,app 与 watchtower 两侧必须一致;生产环境务必修改 +WATCHTOWER_TOKEN=please-change-me + +# 构建镜像时注入的版本号(发布时指定,如 1.2.0;本地构建保持 dev 即可) +HUOBAO_VERSION=dev diff --git a/huobao-source/.gitignore b/huobao-source/.gitignore new file mode 100644 index 0000000..30c4a72 --- /dev/null +++ b/huobao-source/.gitignore @@ -0,0 +1,60 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Data (database and uploaded files) +data/drama_generator.db +data/drama_generator.db-wal +data/drama_generator.db-shm +data/huobao_drama.db +data/huobao_drama.db-wal +data/huobao_drama.db-shm +data/*.sqlite3 +data/*.sqlite3-wal +data/*.sqlite3-shm +data/public.sql +data/*.bak* +data/static/ +data/storage/ + +# Config +configs/config.yaml + +# Misc +tmp/ +temp/ +.superpowers/ +/CLAUDE.md +/AGENTS.md + +# Agent workspace (skills/prompts 为策划模板保持跟踪,其余产出忽略) +backend/workspace/* +!backend/workspace/skills/ +!backend/workspace/prompts/ + +# Desktop (Electron) build artifacts +desktop/node_modules/ +desktop/build/ +desktop/dist/ +desktop/release/ +desktop/resources/ diff --git a/huobao-source/Dockerfile b/huobao-source/Dockerfile new file mode 100644 index 0000000..9a72b4f --- /dev/null +++ b/huobao-source/Dockerfile @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1 + +# ===== 前端构建:Nuxt generate 产出静态站点 ===== +FROM node:22-bookworm-slim AS frontend-build +WORKDIR /build/frontend +COPY frontend/package.json frontend/package-lock.json ./ +# lockfile 的 resolved 可能指向带鉴权的私有 registry(开发者本机 .npmrc),镜像内会 401。 +# 构建期删除 resolved 字段(integrity 校验不受影响),让 npm 统一走公共 registry +RUN node -e "const fs=require('fs');const l=JSON.parse(fs.readFileSync('package-lock.json'));for(const p of Object.values(l.packages||{}))delete p.resolved;fs.writeFileSync('package-lock.json',JSON.stringify(l,null,2))" \ + && npm ci --no-audit --no-fund --registry=https://registry.npmjs.org +COPY frontend/ ./ +RUN npm run generate + +# ===== 后端构建:安装依赖(含原生模块编译) ===== +FROM node:22-bookworm AS backend-build +WORKDIR /build/backend +COPY backend/package.json backend/package-lock.json ./ +RUN node -e "const fs=require('fs');const l=JSON.parse(fs.readFileSync('package-lock.json'));for(const p of Object.values(l.packages||{}))delete p.resolved;fs.writeFileSync('package-lock.json',JSON.stringify(l,null,2))" \ + && npm ci --no-audit --no-fund --registry=https://registry.npmjs.org +COPY backend/ ./ +# 运行时与既有服务器部署一致走 tsx(源码存在 bundler 风格无扩展名 import,tsc 产物 node 直跑不可行); +# tsx 是 devDependency,prune 后单独补装 +RUN npm prune --omit=dev && npm i tsx@^4.21.0 --no-save --no-audit --no-fund --registry=https://registry.npmjs.org + +# ===== 运行时 ===== +FROM node:22-bookworm-slim +ARG HUOBAO_VERSION=dev +ENV NODE_ENV=production \ + HUOBAO_VERSION=${HUOBAO_VERSION} \ + PORT=5679 \ + FFMPEG_BIN=/usr/bin/ffmpeg \ + FFPROBE_BIN=/usr/bin/ffprobe \ + HUOBAO_DATA_DIR=/app/data \ + SQLITE_PATH=/app/data/huobao.sqlite3 \ + WORKSPACE_PATH=/app/data/workspace \ + FRONTEND_DIST=/app/frontend-dist + +# ffprobe-static 不包含 Linux ARM64 二进制;两个架构统一使用系统媒体工具。 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=backend-build /build/backend/src ./backend/src +COPY --from=backend-build /build/backend/node_modules ./backend/node_modules +COPY --from=backend-build /build/backend/package.json ./backend/package.json +COPY --from=backend-build /build/backend/tsconfig.json ./backend/tsconfig.json +# workspace 模板(skills/prompts),entrypoint copy-once 到数据卷后可在线编辑 +COPY backend/workspace ./workspace-template +COPY --from=frontend-build /build/frontend/.output/public ./frontend-dist +COPY docker/entrypoint.sh ./entrypoint.sh +RUN chmod +x ./entrypoint.sh && mkdir -p /app/data + +# 数据卷:SQLite + 生成的静态文件 + 可编辑 workspace +VOLUME ["/app/data"] +EXPOSE 5679 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD node -e "fetch('http://localhost:'+(process.env.PORT||5679)+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/huobao-source/LICENSE b/huobao-source/LICENSE new file mode 100644 index 0000000..7cdbe0b --- /dev/null +++ b/huobao-source/LICENSE @@ -0,0 +1,438 @@ +Attribution-NonCommercial-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International +Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial-ShareAlike 4.0 International Public License +("Public License"). To the extent this Public License may be +interpreted as a contract, You are granted the Licensed Rights in +consideration of Your acceptance of these terms and conditions, and the +Licensor grants You such rights in consideration of benefits the +Licensor receives from making the Licensed Material available under +these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-NC-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution, NonCommercial, and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + l. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + m. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + n. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-NC-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/huobao-source/README.en.md b/huobao-source/README.en.md new file mode 100644 index 0000000..e2da374 --- /dev/null +++ b/huobao-source/README.en.md @@ -0,0 +1,643 @@ +# 🎬 Huobao Drama - AI Short Drama Generation Platform + +
+ +**A full-stack TypeScript platform for automated AI short-drama production** + +[![Node Version](https://img.shields.io/badge/Node.js-20+-339933?style=flat&logo=node.js)](https://nodejs.org) +[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) +[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) +[![Download](https://img.shields.io/github/v/release/chatfire-AI/huobao-drama?style=flat&logo=github&label=Download)](https://github.com/chatfire-AI/huobao-drama/releases/latest) + +[中英双语](README.md) | **English** | [简体中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) + +[Features](#-features) • [Quick Start](#-quick-start) • [Walkthrough](#-visual-walkthrough) • [Desktop App](#-desktop-app-recommended) • [Deployment](#-deployment) + +

🔑 Get a Huobao API Key 👉 Get started

+ +**Text, image, and video AI capabilities — one key unlocks everything** + +After deploying, paste the key in "Settings → Huobao Quick Setup" to write three recommended configs in one click + +

📥 Download Desktop App (macOS / Windows)

+

🌐 Official Website

+ +
+ +--- + +## 📖 Overview + +Huobao Drama is an AI-powered short-drama production platform that automates the entire pipeline: script generation, character design, storyboard breakdown, and video compositing. + +### 🎯 Core Value + +- **🤖 AI-Driven**: LLMs parse scripts and extract characters, scenes, and storyboard information +- **🎨 Intelligent Creation**: AI image generation for character designs and scene backgrounds +- **📹 Video Generation**: Text-to-video and image-to-video models automatically produce storyboard clips +- **🔄 End-to-End Workflow**: A complete pipeline from idea to finished episode + +### 🛠️ Architecture + +``` +frontend/ — Nuxt 3 + Vue 3 + TypeScript (pure CSS, no UI framework) +backend/ — Hono + Drizzle ORM + Mastra AI Agents + better-sqlite3 +backend/workspace/skills/ — Agent skill definitions (SKILL.md, editable in the UI) +desktop/ — Electron desktop app (main process + esbuild + electron-builder dmg/exe) +data/ — Generated assets and the SQLite database +``` + +--- + +## ✨ Features + +### 🎭 Character Management + +- ✅ AI-generated character designs +- ✅ Batch character generation +- ✅ Character image upload and management + +### 🎬 Video Tasks + +- ✅ Automatic AI video-task generation +- ✅ Scene descriptions and video prompt generation +- ✅ Batch video generation per task + +### 🎥 Video Generation + +- ✅ Text-to-video generation +- ✅ FFmpeg per-shot compositing and subtitle handling +- ✅ Full-episode stitching and export + +### 📦 Asset Management + +- ✅ Unified asset library +- ✅ Local storage support +- ✅ Task progress tracking + +### 🤖 AI Agents + +Four built-in Mastra agents with database-backed configuration and Skill extensions: + +| Agent | Role | +|---|---| +| `script_rewriter` | Novel → formatted script rewriting | +| `extractor` | Intelligent extraction and dedup of characters / scenes / props | +| `storyboard_breaker` | Script → storyboard sequence breakdown | +| `prompt_generator` | Image prompts for characters/scenes/props + storyboard video prompts | + +### 🌐 Multi-Language UI + +The interface ships in **中文 / English / 日本語 / 한국어**, with a global setting for AI-generated content language. + +### 🔌 Multi-Provider Support + +| Type | Providers | +|---|---| +| **Text** | OpenAI (compatible APIs), Gemini | +| **Image** | OpenAI, Gemini, Volcano Engine | +| **Video** | Volcano Engine Seedance 2.0 (Standard / Fast / Mini), MiniMax H3, Alibaba Bailian Wan 3.0 (Prime / Standard) | + +--- + +## 🚀 Quick Start + +### 📋 Requirements + +| Software | Version | Notes | +|---|---|---| +| **Node.js** | 20+ | Runtime for frontend and backend | +| **npm** | 9+ | Package manager | + +> **Zero-install database**: Bundled SQLite (single file in the project data directory) — no database server required. +> **No FFmpeg install needed**: Binaries ship via the `ffmpeg-static` / `ffprobe-static` npm packages — works out of the box. + +### ⚙️ Environment Variables + +No config files — everything is set via environment variables (all have defaults; local dev needs zero configuration): + +| Variable | Default | Description | +|---|---|---| +| `SQLITE_PATH` | `/data/huobao.sqlite3` | SQLite database file location | +| `PORT` | `5679` | Backend service port | +| `STORAGE_PATH` | `/data/static` | Generated-file storage directory | +| `HUOBAO_DATA_DIR` | — | Injected by the Electron main process (userData data root) | +| `WORKSPACE_PATH` | `backend/workspace` | Agent skills/prompts directory (desktop: writable copy under userData) | +| `FRONTEND_DIST` | `frontend/dist` | Frontend static build directory | +| `FFMPEG_BIN` / `FFPROBE_BIN` | bundled npm binaries | Custom ffmpeg/ffprobe executable paths | +| `PUBLIC_BASE_URL` | — | Public URL Seedance needs to reference local assets (server deployments) | + +> **Note**: AI service API keys, base URLs, and model parameters are all configured in the web UI "Settings" page and stored in the database — never in config files or environment variables. + +### 📥 Installation + +```bash +# Clone the repository +git clone https://github.com/chatfire-AI/huobao-drama.git +cd huobao-drama + +# Install backend dependencies +cd backend && npm install + +# Install frontend dependencies +cd ../frontend && npm install +``` + +### 🎯 Running + +#### Option 1: Development mode (recommended) + +Frontend and backend run separately with hot reload: + +```bash +# Terminal 1: backend +cd backend +npm run dev + +# Terminal 2: frontend +cd frontend +npm run dev +``` + +- Frontend: `http://localhost:3013` +- Backend API: `http://localhost:5679/api/v1` +- The frontend automatically proxies `/api` and `/static` to the backend + +#### Option 2: Single-service mode + +The backend serves both the API and the frontend static files: + +```bash +# 1. Build the frontend +cd frontend && npm run generate + +# 2. Copy the build output where the backend expects it +# (generate outputs to .output/public; the backend reads frontend/dist) +cp -r .output/public dist + +# 3. Start the backend +cd ../backend && npm start +``` + +Visit: `http://localhost:5679` + +### 🗄️ Database + +Bundled SQLite (`better-sqlite3` + WAL mode). Tables are created automatically on first launch (idempotent DDL replay + seed data). Default file: `data/huobao.sqlite3`, overridable via `SQLITE_PATH`. The desktop app stores data in the user-data directory (`~/Library/Application Support/HuobaoDrama/data/`). + +Migrating data from a legacy MySQL deployment: + +**Automatic migration on startup (recommended)**: When MySQL is explicitly configured (`DATABASE_URL` or `MYSQL_HOST`) and the SQLite database is empty, the backend automatically detects and imports all tables once (per-table row-count validation, single-transaction atomic writes, automatic rollback with retry on next launch, and a `.mysql-imported` marker to avoid re-importing). Set `MYSQL_AUTO_IMPORT=false` to disable. + +```bash +# Or run manually (non-empty target requires --force; automatic backup before writing) +cd backend && npx tsx scripts/import-mysql-to-sqlite.ts +``` + +> Migration covers database rows only; media files (images/videos) under the old deployment's `data/static/` must be copied manually, or historical assets won't load. + +### 🔑 First Use: Configure AI Services + +All AI features (text/image/video) require model services to be configured first — a banner at the top of the page guides you until then: + +1. Open the "Settings" page +2. Paste your Huobao API key in "Huobao Quick Setup" ([get one at api.firemux.com](https://api.firemux.com)) to write three recommended configs (text, image, video) in one click +3. Or add providers one by one via "Manual Templates", with connectivity testing + +Once configured, the banner disappears and you can start producing episodes. + +--- + +## 📖 Visual Walkthrough + +The complete pipeline from novel to finished episode. The left progress rail always shows where you are. + +### Step 1 · Create a Project + +On the home page click "New Project", pick a **aspect ratio** (16:9 landscape / 9:16 portrait, fixed after creation) and a **visual style** (3D, realistic, … — injected into every image prompt). + +

+ Create a project +

+ +

+ Project list +

+ +### Step 2 · Configure AI Services (first run) + +Paste an API key in Settings → "Huobao Quick Setup" to write the three recommended configs at once, or add providers manually. The current model can be switched any time from the top bar (see Step 5). + +

+ AI service setup +

+ +### Step 3 · Script Stage + +Paste your **source novel** into the workbench, then hit "AI Rewrite" to produce a shooting script — split by episode with scenes and characters annotated. You can switch text models and tone while rewriting. + +

+ Script stage +

+ +### Step 4 · Assets + +Run **extraction** on the script to get the character / scene / prop list, then generate a consistent reference image for each (or in batch). These images are injected as reference material when generating videos. + +

+ Asset production +

+ +### Step 5 · Storyboard & Videos + +On the "Video Production" page, first run **storyboard breakdown** (AI splits shots and writes video prompts). Then: + +- Pick the **video model** in the top bar (Seedance / Wan 3.0 / MiniMax…); resolution and duration tiers follow the model +- Review and tweak each shot's prompt on the right (`@character` references map to reference images automatically) +- Click "Batch Generate Videos"; failed tasks can be retried in one click + +

+ Storyboard breakdown +

+ +

+ Video generation +

+ +### Step 6 · Merge & Export + +Select shots (hover to preview each clip), click "Start Merging" and FFmpeg assembles the full episode — play online or download. Click "Mark Done" when finished to light up the progress rail. + +

+ Merge & export +

+ +The episode list shows the production status of every episode — click "Enter Studio" to continue: + +

+ Episode list +

+ +--- + +## 📦 Deployment + +### 🖥️ Desktop App (recommended) + +**⬇️ Prebuilt installers: [GitHub Releases](https://github.com/chatfire-AI/huobao-drama/releases/latest) · [Mirror for China (Tencent COS)](https://installer.firemux.site/huobao-drama/v4.0.11/)** + +| Platform | File to download | +|---|---| +| macOS (Apple Silicon, M-series) | `HuobaoDrama-4.0.5-arm64.dmg` | +| macOS (Intel) | `HuobaoDrama-4.0.5.dmg` | +| Windows | `HuobaoDrama.Setup.4.0.5.exe` | + +> China users: use the COS mirror above (GitHub is slow/unreachable in mainland China). The in-app updater also checks the COS mirror first, then falls back to GitHub. + +**Command-line install (recommended, no Gatekeeper fix needed)**: downloading via curl never sets macOS's quarantine attribute, so the app opens cleanly with no "damaged" prompt (use the `-arm64.dmg` for Apple Silicon, the plain dmg for Intel): + +```bash +curl -L -o /tmp/HuobaoDrama.dmg https://installer.firemux.site/huobao-drama/v4.0.11/HuobaoDrama-4.0.11-arm64.dmg \ + && hdiutil attach -nobrowse /tmp/HuobaoDrama.dmg \ + && cp -R /Volumes/HuobaoDrama*/HuobaoDrama.app /Applications/ \ + && hdiutil detach /Volumes/HuobaoDrama* +``` + +No build required — download the dmg/exe and install. Installed clients auto-update via the built-in updater. (To package from source instead, see the commands below.) + +Double-click to install, works out of the box (macOS + Windows): the SQLite database, generated media, and Agent skills all live in the user-data directory — uninstalling the app does not affect your data. + +```bash +# One-command packaging (frontend generate → backend esbuild → electron-builder) +npm run dist # macOS dmg (arm64 + Intel) +npm run dist:win # Windows NSIS installer (win-x64, cross-buildable on macOS) + +# Artifacts +# desktop/release/HuobaoDrama--arm64.dmg (Apple Silicon) +# desktop/release/HuobaoDrama-.dmg (Intel) +# desktop/release/HuobaoDrama Setup .exe (Windows) +``` + +Installation notes: + +- The macOS build is unsigned — on first launch you may see "App is damaged and can't be opened" (common on Apple Silicon). This is Gatekeeper's quarantine attribute, not actual file damage. Two ways to fix it: + 1. **The dmg bundles a fix script**: after dragging the app into Applications, double-click the "如提示已损坏请双击我.command" script at the bottom of the dmg window — it removes the quarantine attribute automatically; + 2. Or run `sudo xattr -cr /Applications/HuobaoDrama.app` in Terminal. + + The fix is one-time only — the app then launches normally, and in-app auto-updates are not affected. +- The Windows build is unsigned — SmartScreen will prompt "More info → Run anyway" +- User-data directory: `~/Library/Application Support/HuobaoDrama/` (database, generated media, writable copies of online-edited skills) +- FFmpeg/FFprobe binaries are bundled — no system install needed +- Electron is pinned to 37.x: better-sqlite3's win32 prebuilds max out at that ABI (the key to compilation-free cross-packaging) +- External links open in the system browser (e.g. "Get a key at api.firemux.com") + +#### 🔄 In-App Updates (no Apple signing required) + +The desktop app ships with a built-in updater (same class of solution as Tauri: directory replacement on macOS / silent installer on Windows, with local sha256 verification). To publish a new release: + +```bash +# 1. Bump version in desktop/package.json, then package +npm run dist # macOS (produces dmg + update zip) +npm run dist:win # Windows (produces Setup.exe) + +# 2. Generate the release manifest release/latest.json (with per-platform sha256) +cd desktop && npm run feed + +# 3. Publish: upload latest.json + installers + zips to a GitHub Release (tag like v1.0.1) +``` + +Installed clients check the manifest automatically after launch (manual check available in "Settings → About & Updates") and prompt to download and install when a new version is found. Override the manifest URL with the `HUOBAO_UPDATE_FEED` environment variable. + +Desktop development: + +```bash +npm run build:frontend # frontend static output (frontend/.output/public) +cd desktop && npm run dev # bundle the backend and run in an Electron window +``` + +> Known limitation: Seedance video models need a `PUBLIC_BASE_URL` public address to reference local assets; the desktop app has no public entry point, so that scenario produces a clear error message. Text-to-video, image generation, and all other capabilities are unaffected. + +--- + +### 🏭 Server Deployment + +```bash +# 1. Build the frontend +cd frontend && npm run generate + +# 2. Copy the build output (generate outputs to frontend/.output/public; the backend reads +# frontend/dist — skip this step and the API works but pages 404) +cp -r .output/public dist && cd .. + +# 3. Start the backend +cd backend && npm start +``` + +Files to upload to the server: + +``` +backend/ # backend source + node_modules +backend/workspace/skills/ # Agent skill files +frontend/dist/ # frontend build output +data/ # data directory (auto-created on first run) +``` + +#### Nginx Reverse Proxy + +```nginx +server { + listen 80; + server_name your-domain.com; + + # Max 50MB for reference video/audio uploads + client_max_body_size 100m; + + # Generated images/videos served straight from disk, bypassing Node: + # sendfile zero-copy + long-lived caching + # (files are uuid-named and immutable, so immutable caching is safe) + location /static/ { + alias /path/to/huobao-drama/data/static/; + sendfile on; + tcp_nopush on; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://localhost:5679; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +> Media loading optimization: the backend automatically generates 400px thumbnails (`*_thumb.webp`) for list pages and extracts poster frames (`*_poster.jpg`) as video covers — the frontend only loads original files when opening the full image or playing. To backfill historical files, run `npm run backfill-artwork` under `backend/`. + +### 🐳 Docker Deployment (with in-app updates) + +**Option A — prebuilt image (no clone, no build):** multi-arch (`linux/amd64` + `linux/arm64`), x86 servers and ARM devices match automatically + +```bash +docker pull huobao/huobao-drama:4.0.6 + +docker run -d \ + --name huobao-drama \ + -p 5679:5679 \ + -v huobao-data:/app/data \ + --restart unless-stopped \ + huobao/huobao-drama:4.0.6 +``` + +**Option B — docker compose (source build + Watchtower in-app updates):** the repo root provides an all-in-one `Dockerfile` (three stages: frontend generate + backend dependencies + runtime; the backend runs via tsx just like server deployment) and `docker-compose.yml` (app + Watchtower): + +```bash +# 1. Configure the environment (Watchtower token — must match on the app and watchtower sides) +cp .env.example .env # edit WATCHTOWER_TOKEN + +# 2. Build and start (inject a version at publish time for "About & Updates" comparison) +HUOBAO_VERSION=4.0.5 docker compose up -d --build + +# 3. Visit http://localhost:5679 +``` + +- **Data persistence**: the named volume `huobao-data` mounts `/app/data` (SQLite + generated images/videos + workspace/skills) — image updates don't lose data +- **In-app updates**: the compose file ships a [Watchtower](https://containrrr.dev/watchtower/) sidecar (`--label-enable` only updates labeled containers, `--cleanup` removes old images, daily self-check). "Settings → About & Updates" can check for new versions and "Update Now" — the backend triggers it via the Watchtower HTTP API, which pulls the new image and rebuilds the container; refresh the page after a few minutes +- **Manual mode**: remove the app's two `HUOBAO_WATCHTOWER_*` env vars (or the whole watchtower service) from `docker-compose.yml` — "About & Updates" then degrades to a new-version notice + manual `docker compose pull && docker compose up -d` +- **Publishing images**: `docker buildx build --platform linux/amd64,linux/arm64 --build-arg HUOBAO_VERSION=x.y.z -t huobao/huobao-drama:x.y.z -t huobao/huobao-drama:latest --push .` — the version manifest is shared with the desktop app via `latest.json` on GitHub Releases (overridable with `HUOBAO_UPDATE_FEED`) + +--- + +## 🎨 Tech Stack + +### Backend + +- **Runtime**: Node.js 20+ +- **Web framework**: Hono +- **ORM**: Drizzle ORM + better-sqlite3 (WAL mode) +- **AI Agents**: Mastra + AI SDK (OpenAI compatible) +- **Video processing**: FFmpeg (fluent-ffmpeg + bundled binaries) +- **Image processing**: Sharp + +### Desktop + +- **Shell**: Electron (utilityProcess hosts the backend; BrowserWindow loads same-origin) +- **Packaging**: esbuild (single-file backend bundle) + electron-builder (dmg arm64/x64, NSIS win-x64) + +### Frontend + +- **Framework**: Nuxt 3 (SPA mode) +- **Language**: Vue 3 + TypeScript +- **Routing**: File-based routing (Vue Router 4) +- **Styling**: Pure CSS + CSS Variables +- **Icons**: Lucide Vue +- **i18n**: vue-i18n (中文 / English / 日本語 / 한국어) + +--- + +## 📝 FAQ + +### Q: Where does the desktop app store data? + +A: `~/Library/Application Support/HuobaoDrama/data/` (SQLite database + generated images/videos); writable copies of online-edited skills live in the sibling `workspace/` directory. In development mode the repo's `data/` directory is used instead. + +### Q: How do I migrate legacy MySQL data to SQLite? + +A: Keep MySQL reachable (environment variables or `backend/.env`), then run `cd backend && npx tsx scripts/import-mysql-to-sqlite.ts`. The script creates tables automatically, imports table-by-table, and validates row counts (non-empty targets require `--force`; a backup is made before writing). + +### Q: FFmpeg not installed or not found? + +A: No install needed. The project bundles `ffmpeg-static` / `ffprobe-static` binaries (carried along in the desktop package). A system `PATH` FFmpeg won't conflict either, and you can point explicitly via `FFMPEG_BIN`/`FFPROBE_BIN`. + +### Q: The top of the page says "No model configured"? + +A: That's the normal first-deploy guidance. Go to "Settings" and use "Huobao Quick Setup" to paste an API key and write configs in one click, or add providers via "Manual Templates". The banner disappears once text, image, and video all have an enabled config. + +### Q: The frontend can't reach the backend API? + +A: Check that the backend is running and the port is correct. In dev mode the proxy config lives in `frontend/nuxt.config.ts`. + +### Q: Database tables not created? + +A: The backend creates all tables automatically on first launch — check the logs to confirm initialization succeeded. + +--- + +## 📋 Changelog + +### v4.0.0 (2026-08) + +#### 🖥️ Desktop App + Database Migration + +- Electron desktop app (macOS dmg, arm64/x64 dual architecture) + - Double-click install, works out of the box: automatic port selection, single-instance lock, crash-isolated backend subprocess + - User-data isolation: SQLite database / generated media / skill copies all live in the userData directory + - FFmpeg/FFprobe bundled; workspace skill templates copied on first launch, upgrades only fill gaps without overwriting +- Database fully migrated from MySQL to SQLite (better-sqlite3 + WAL) + - Zero business-code changes (the Drizzle query layer is naturally portable); idempotent DDL replay + - New one-shot import script `import-mysql-to-sqlite.ts` (per-table row-count validation + automatic backup) +- Backend bundled to a single file with esbuild (externals: sharp/better-sqlite3/ffmpeg binary packages) +- Removed Docker/MySQL deployment (recoverable from git history) + +### v3.1.0 (2026-09) + +- Added Alibaba Bailian Wan 3.0 video models (Prime / Standard, official `input.media`/`parameters` payloads) +- Resolution selector in the workbench top bar with native tiers per provider (Seedance 480p/720p, MiniMax 768P/2K, Wan 480P/720P/1080P) +- Default video model changed to Seedance 2.0 Mini +- Fixed generation errors caused by provider/model mismatch when switching video models +- Batch video: selection mode + pre-generation confirmation (shot count / total duration / model / resolution), one-click retry for failed tasks +- Storyboard duration now editable directly in the video-generation params area, applied to single and batch generation +- Content-moderation failures (real-person / sensitive content) now prompt to switch models and retry + +### v3.0.0 (2026-08) + +#### 🚀 Deployment & Experience Improvements + +- Docker deployment readiness + - MySQL / app health checks; the app waits for the database before starting + - Database initialization retries — zero manual intervention on first containerized deploy + - Removed the system FFmpeg dependency; bundled binaries everywhere + - Agent skills directory volume persistence (online edits in Settings survive) + - New `docker/init.sql` and export scripts (DBA review / pre-created tables) +- First-use guidance + - Site-wide banner guiding to Settings when no AI service is configured + - New "Huobao Quick Setup" in Settings: one key writes three recommended configs (text/image/video) + - Unconfigured-model errors localized with pointers to Settings +- Default video model changed to Seedance 2.0 Fast +- Provider consolidation: OpenAI / Gemini / Volcano Engine only +- Workbench: task-list drawer, pipeline stage status, selective stitching (validates video files exist before merging) +- Asset library redesign, @mention improvements, episode list rework + +### v2.0.0 (2026-04) + +#### 🚀 Major Update + +- Full migration to a TypeScript stack + - Backend: Hono + Drizzle ORM + mysql2 + - Frontend: Nuxt 3 + Vue 3 + - AI Agents: Mastra framework +- Episode workbench UI and production flow rebuilt + - More compact console layout + - Storyboard editing area rebuilt + - Shot image, video, compositing, and export screens rebuilt +- Docker deployment support — frontend and backend merged into a single image +- Runtime Skill loading mechanism +- Expanded multi-provider media adapters + - Image: OpenAI, Gemini, Volcano Engine, Alibaba + - Video: Volcano Engine/Seedance, Vidu, Alibaba +- Improved local file handling and on-demand reference-image transcoding + +### v1.0.4 (2026-01-27) + +- Local storage strategy to avoid dead external asset links +- Base64 reference-image embedded transport +- Fixed shot-switch state reset +- Scene migration to chapters + +### v1.0.3 (2026-01-16) + +- Database concurrency performance improvements +- Docker cross-platform support for host.docker.internal + +### v1.0.2 (2026-01-14) + +- Fixed video-generation API response parsing +- OpenAI Sora video endpoint configuration +- Improved error handling and logging + +--- + +## 📄 License + +This project is licensed under **[CC BY-NC-SA 4.0](LICENSE)** (Attribution-NonCommercial-ShareAlike 4.0 International). + +- ✅ Personal use, learning, and non-commercial projects are welcome +- ✅ Modifications and redistribution allowed under the same license with attribution +- ❌ **Commercial use is prohibited** — you may not use this project, in whole or in part, for any commercial purpose (including paid services, commercial deployments, or resale) without prior written permission from the author + +Full license text: see [LICENSE](LICENSE). + +--- + +## 🤝 Contributing + +Issues and Pull Requests are welcome! + +1. Fork the project +2. Create a feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +Common checks: + +```bash +cd backend && npm run typecheck +cd ../frontend && npm run build +``` + +--- + +## 💬 WeChat Group + +Scan the QR code to join the WeChat group: + +
+ WeChat group QR code +
+ +--- + +> _"Let AI do the creating with us"_ + +## 🔗 Links + +This project has been recognized with a link from the [LINUX DO](https://linux.do/) community. + +- [LINUX DO](https://linux.do/) — a genuine open-source spirit, a community built on sharing + +--- + +## Star History + +[![Star History Chart](https://api.star-history.com/chart?repos=chatfire-ai/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/?repos=chatfire-ai%2Fhuobao-drama&type=date&legend=top-left) diff --git a/huobao-source/README.ja.md b/huobao-source/README.ja.md new file mode 100644 index 0000000..d140c9b --- /dev/null +++ b/huobao-source/README.ja.md @@ -0,0 +1,640 @@ +# 🎬 Huobao Drama - AI ショートドラマ生成プラットフォーム + +
+ +**TypeScript フルスタックの AI ショートドラマ自動制作プラットフォーム** + +[![Node Version](https://img.shields.io/badge/Node.js-20+-339933?style=flat&logo=node.js)](https://nodejs.org) +[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) +[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) +[![ダウンロード](https://img.shields.io/github/v/release/chatfire-AI/huobao-drama?style=flat&logo=github&label=%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89)](https://github.com/chatfire-AI/huobao-drama/releases/latest) + +[中英双语](README.md) | [English](README.en.md) | [简体中文](README.zh-CN.md) | **日本語** | [한국어](README.ko.md) + +[機能](#-機能) • [クイックスタート](#-クイックスタート) • [チュートリアル](#-チュートリアル画像付き) • [デスクトップ版](#-デスクトップアプリ推奨) • [デプロイ](#-デプロイ) + +

🔑 Huobao API Key を取得 👉 今すぐ見る

+ +**テキスト・画像・動画のすべての AI 機能を、1 つの Key で有効化** + +デプロイ後、「設定 → 火宝クイック設定」に Key を貼るだけで、3 つの推奨設定をワンクリックで書き込み + +

📥 デスクトップ版をダウンロード(macOS / Windows)

+

🌐 公式サイト

+ +
+ +--- + +## 📖 プロジェクト概要 + +Huobao Drama は AI ベースのショートドラマ自動制作プラットフォームです。脚本生成、キャラクターデザイン、分镜制作から動画合成まで、全工程を自動化します。 + +### 🎯 コアバリュー + +- **🤖 AI 駆動**:大規模言語モデルで脚本を解析し、キャラクター・シーン・分镜情報を抽出 +- **🎨 インテリジェント制作**:AI 画像生成でキャラクター形象とシーン背景を作成 +- **📹 動画生成**:テキスト→動画・画像→動画モデルで分镜動画を自動生成 +- **🔄 ワークフロー**:アイデアから完成作品までのショートドラマ制作をワンストップで完結 + +### 🛠️ 技術アーキテクチャ + +``` +frontend/ — Nuxt 3 + Vue 3 + TypeScript (純粋 CSS、UI フレームワークなし) +backend/ — Hono + Drizzle ORM + Mastra AI Agents + better-sqlite3 +backend/workspace/skills/ — Agent スキル定義 (SKILL.md、UI からオンライン編集可能) +desktop/ — Electron デスクトップ版(メインプロセス + esbuild バンドル + electron-builder で dmg/exe) +data/ — 生成アセットと SQLite データベース +``` + +--- + +## ✨ 機能 + +### 🎭 キャラクター管理 + +- ✅ AI によるキャラクター形象生成 +- ✅ キャラクターの一括生成 +- ✅ キャラクター画像のアップロードと管理 + +### 🎬 動画タスク + +- ✅ AI による動画タスクの自動生成 +- ✅ シーン説明と動画プロンプトの生成 +- ✅ タスク単位の一括動画生成 + +### 🎥 動画生成 + +- ✅ テキスト→動画の自動生成 +- ✅ FFmpeg によるショット単位の合成と字幕処理 +- ✅ エピソード全体の結合と書き出し + +### 📦 アセット管理 + +- ✅ 素材ライブラリの一元管理 +- ✅ ローカルストレージ対応 +- ✅ タスク進捗トラッキング + +### 🤖 AI Agents + +4 つの Mastra Agent を内蔵。データベース設定と Skill 拡張に対応: + +| Agent | 役割 | +|---|---| +| `script_rewriter` | 小説 → フォーマット済み脚本へのリライト | +| `extractor` | キャラクター / シーン / 小道具のインテリジェント抽出と重複排除 | +| `storyboard_breaker` | 脚本 → 分镜シーケンスへの分解 | +| `prompt_generator` | キャラクター/シーン/小道具の画像プロンプト + 分镜動画プロンプト生成 | + +### 🌐 多言語 UI + +インターフェースは **中文 / English / 日本語 / 한국어** を内蔵し、AI 生成コンテンツの言語をグローバルに設定できます。 + +### 🔌 マルチプロバイダー対応 + +| タイプ | 対応プロバイダー | +|---|---| +| **テキスト** | OpenAI(互換 API)、Gemini | +| **画像** | OpenAI、Gemini、Volcano Engine | +| **動画** | Volcano Engine Seedance 2.0(Standard / Fast / Mini)、MiniMax H3、Alibaba Bailian Wan 3.0(Prime / Standard) | + +--- + +## 🚀 クイックスタート + +### 📋 動作環境 + +| ソフトウェア | バージョン | 説明 | +|---|---|---| +| **Node.js** | 20+ | フロントエンド・バックエンド実行環境 | +| **npm** | 9+ | パッケージマネージャー | + +> **データベース不要**:SQLite 内蔵(プロジェクトのデータディレクトリに単一ファイル)。データベースサーバーのインストールは不要です。 +> **FFmpeg 不要**:`ffmpeg-static` / `ffprobe-static` npm パッケージにバイナリを内蔵。すぐに使えます。 + +### ⚙️ 環境変数 + +設定ファイルは不要。環境変数で設定します(すべてデフォルト値あり、ローカル開発はゼロ設定で起動可能): + +| 変数 | デフォルト値 | 説明 | +|---|---|---| +| `SQLITE_PATH` | `<リポジトリ>/data/huobao.sqlite3` | SQLite データベースファイルの場所 | +| `PORT` | `5679` | バックエンドサービスのポート | +| `STORAGE_PATH` | `<リポジトリ>/data/static` | 生成ファイルの保存ディレクトリ | +| `HUOBAO_DATA_DIR` | — | デスクトップ版で Electron メインプロセスが注入(userData データルート) | +| `WORKSPACE_PATH` | `backend/workspace` | Agent スキル/プロンプトディレクトリ(デスクトップ版は userData の書き込み可能なコピー) | +| `FRONTEND_DIST` | `frontend/dist` | フロントエンド静的ビルドディレクトリ | +| `FFMPEG_BIN` / `FFPROBE_BIN` | npm 内蔵バイナリ | カスタム ffmpeg/ffprobe の実行ファイルパス | +| `PUBLIC_BASE_URL` | — | Seedance がローカル参照リソースを使う際に必要な公開 URL(サーバーデプロイ用) | + +> **説明**:AI サービスの API Key、Base URL、モデルパラメータはすべて Web UI の「設定」ページで設定してデータベースに保存します。設定ファイルや環境変数では管理しません。 + +### 📥 インストール + +```bash +# リポジトリをクローン +git clone https://github.com/chatfire-AI/huobao-drama.git +cd huobao-drama + +# バックエンドの依存をインストール +cd backend && npm install + +# フロントエンドの依存をインストール +cd ../frontend && npm install +``` + +### 🎯 起動 + +#### 方法 1:開発モード(推奨) + +フロントエンドとバックエンドを分離し、ホットリロード対応: + +```bash +# ターミナル 1:バックエンド +cd backend +npm run dev + +# ターミナル 2:フロントエンド +cd frontend +npm run dev +``` + +- フロントエンド:`http://localhost:3013` +- バックエンド API:`http://localhost:5679/api/v1` +- フロントエンドは `/api` と `/static` をバックエンドへ自動プロキシ + +#### 方法 2:シングルサービスモード + +バックエンドが API とフロントエンド静的ファイルの両方を提供: + +```bash +# 1. フロントエンドをビルド +cd frontend && npm run generate + +# 2. ビルド成果物をバックエンドが読むディレクトリへコピー +# (generate の出力は .output/public、バックエンドは frontend/dist を読む) +cp -r .output/public dist + +# 3. バックエンドを起動 +cd ../backend && npm start +``` + +アクセス:`http://localhost:5679` + +### 🗄️ データベース + +SQLite 内蔵(`better-sqlite3` + WAL モード)。初回起動時にテーブルを自動作成(冪等な DDL リプレイ + シードデータ)。デフォルトのファイルは `data/huobao.sqlite3`、`SQLITE_PATH` で変更可能。デスクトップ版はユーザーデータディレクトリ(`~/Library/Application Support/HuobaoDrama/data/`)に保存します。 + +旧 MySQL からのデータ移行: + +**起動時の自動移行(推奨)**:MySQL が明示的に設定されており(`DATABASE_URL` または `MYSQL_HOST`)、SQLite が空の場合、バックエンド起動時に自動検出して全テーブルを一度だけインポートします(テーブルごとの行数検証、単一トランザクションの原子書き込み、失敗時は自動ロールバックして次回起動時に再試行、成功後は `.mysql-imported` マーカーを書き込み重複を防止)。`MYSQL_AUTO_IMPORT=false` で無効化できます。 + +```bash +# 手動実行も可能(対象 DB が空でない場合は --force が必要、書き込み前に自動バックアップ) +cd backend && npx tsx scripts/import-mysql-to-sqlite.ts +``` + +> 移行はデータベースの行のみをカバーします。旧デプロイの `data/static/` 配下の画像・動画などのメディアファイルは手動でコピーしてください。コピーしないと過去の素材にアクセスできません。 + +### 🔑 初回利用:AI サービスの設定 + +起動後、すべての AI 機能(テキスト/画像/動画)を使うには、先にモデルサービスの設定が必要です。未設定の場合、ページ上部にバナーで案内されます: + +1. 「設定」ページを開く +2. 「火宝クイック設定」に Huobao API Key を貼る([api.firemux.com で取得](https://api.firemux.com))。テキスト・画像・動画の 3 つの推奨設定をワンクリックで書き込み +3. または「手動テンプレート」でプロバイダーごとに追加。接続テストに対応 + +設定が完了するとバナーは自動で消え、エピソード制作を開始できます。 + +--- + +## 📖 チュートリアル(画像付き) + +小説から完成エピソードまでの全工程。左の進捗バーが常に現在のステージを示します。 + +### ステップ 1 · プロジェクト作成 + +ホームで「新規プロジェクト」をクリックし、**アスペクト比**(16:9 横 / 9:16 縦、作成後は変更不可)と**作画スタイル**(3D / リアル系など、全生図プロンプトに注入)を選択します。 + +

+ プロジェクト作成 +

+ +

+ プロジェクト一覧 +

+ +### ステップ 2 · AI サービス設定(初回) + +設定ページの「火宝クイック設定」に API キーを貼り付けると推奨 3 設定を一括書き込み。手動テンプレートでの追加も可能です。使用モデルは上部バーでいつでも切り替えられます(ステップ 5 参照)。 + +

+ AI サービス設定 +

+ +### ステップ 3 · 脚本ステージ + +制作画面に**原文(小説)**を貼り付け、「AI リライト」で撮影用脚本を生成 — エピソード単位の分割とシーン・キャラクターの注記付き。モデルやトーンの切り替えも可能です。 + +

+ 脚本ステージ +

+ +### ステップ 4 · アセット制作 + +脚本に**抽出**を実行するとキャラクター / シーン / 小道具の一覧を自動取得。各項目の「イメージ生成」で一貫性のある参照画像を作成します(一括も可)。これらの画像は動画生成時に参照素材として注入されます。 + +

+ アセット制作 +

+ +### ステップ 5 · 絵コンテと動画 + +「動画制作」ページでまず**絵コンテ分割**を実行(AI がカット割りし動画プロンプトを生成)。その後: + +- 上部バーで**動画モデル**を選択(Seedance / Wan 3.0 / MiniMax など)。解像度と尺の選択肢はモデルに連動 +- 右側で各カットのプロンプトを確認・調整(`@キャラ名` 参照は自動で参照画像にマッピング) +- 「一括動画生成」でタスク開始。失敗タスクはワンクリックでリトライ + +

+ 絵コンテ分割 +

+ +

+ 動画生成 +

+ +### ステップ 6 · 連結と書き出し + +カットを選択(ホバーで各クリップをプレビュー)し、「連結開始」をクリックすると FFmpeg が完全なエピソードを自動合成。オンライン再生・ダウンロード可能です。完了したら「完了にする」で進捗バーを点灯。 + +

+ 連結と書き出し +

+ +エピソード一覧では各話の制作状況を確認でき、「制作を開く」から再開できます: + +

+ エピソード一覧 +

+ +--- + +## 📦 デプロイ + +### 🖥️ デスクトップアプリ(推奨) + +**⬇️ ビルド済みインストーラー: [GitHub Releases](https://github.com/chatfire-AI/huobao-drama/releases/latest) · [中国向けミラー(Tencent COS)](https://installer.firemux.site/huobao-drama/v4.0.11/)** + +| プラットフォーム | ダウンロードファイル | +|---|---| +| macOS(Apple Silicon、M シリーズ) | `HuobaoDrama-4.0.5-arm64.dmg` | +| macOS(Intel) | `HuobaoDrama-4.0.5.dmg` | +| Windows | `HuobaoDrama.Setup.4.0.5.exe` | + +> 中国本土では GitHub が不安定なため、Tencent COS ミラーをご利用ください。アプリ内アップデーターも COS を優先し、GitHub にフォールバックします。 + +**コマンドラインインストール(推奨・修復不要)**:curl でのダウンロードは macOS の隔離属性が付かないため、「壊れている」警告なしでそのまま起動できます(Apple Silicon は `-arm64.dmg`、Intel は無印の dmg): + +```bash +curl -L -o /tmp/HuobaoDrama.dmg https://installer.firemux.site/huobao-drama/v4.0.11/HuobaoDrama-4.0.11-arm64.dmg \ + && hdiutil attach -nobrowse /tmp/HuobaoDrama.dmg \ + && cp -R /Volumes/HuobaoDrama*/HuobaoDrama.app /Applications/ \ + && hdiutil detach /Volumes/HuobaoDrama* +``` + +ビルド不要 —— dmg/exe をダウンロードしてそのままインストールできます。インストール済みクライアントは内蔵アップデーターで自動更新されます。(ソースから自分でパッケージングする場合は以下のコマンドを参照。) + +ダブルクリックでインストール、すぐに使えるデスクトップ版(macOS + Windows)。データベース(SQLite)、生成メディア、Agent スキルはすべてユーザーデータディレクトリに保存され、アプリをアンインストールしてもデータに影響しません。 + +```bash +# ワンコマンドパッケージ(フロントエンド generate → バックエンド esbuild → electron-builder) +npm run dist # macOS dmg(arm64 + Intel) +npm run dist:win # Windows NSIS インストーラー(win-x64、macOS 上でクロスビルド可能) + +# 成果物 +# desktop/release/HuobaoDrama-<バージョン>-arm64.dmg (Apple Silicon) +# desktop/release/HuobaoDrama-<バージョン>.dmg (Intel) +# desktop/release/HuobaoDrama Setup <バージョン>.exe (Windows) +``` + +インストール時の注意: + +- macOS 未署名パッケージは初回起動時に「Appが壊れているため開けません」と表示される場合があります(Apple Silicon で多発)。これは Gatekeeper の隔離属性によるもので、ファイルは壊れていません。修復方法は 2 つあります: + 1. **dmg に修復スクリプトを同梱**:アプリを「アプリケーション」にドラッグした後、dmg ウィンドウ下部の「如提示已损坏请双击我.command」をダブルクリックすれば自動で修復されます; + 2. またはターミナルで `sudo xattr -cr /Applications/HuobaoDrama.app` を実行。 + + 修復は初回の 1 回だけ。その後は普通に起動でき、アプリ内自動更新にも影響しません。 +- Windows 未署名パッケージは SmartScreen で「詳細情報 → 実行」を選択 +- ユーザーデータディレクトリ:`~/Library/Application Support/HuobaoDrama/`(データベース、生成メディア、オンライン編集したスキルのコピー) +- FFmpeg/FFprobe バイナリ内蔵。システムへのインストール不要 +- Electron は 37.x に固定:better-sqlite3 の win32 プリビルドがこの ABI までしか対応していないため(クロスパッケージでコンパイル不要にする鍵) +- アプリ内の外部リンク(「api.firemux.com で Key を取得」など)はシステムブラウザで直接開きます + +#### 🔄 アプリ内更新(Apple 署名不要) + +デスクトップ版には更新機能を内蔵(Tauri と同種の方式:macOS はディレクトリ置換 / Windows はサイレントインストール、ローカルで sha256 検証)。新バージョンのリリース手順: + +```bash +# 1. desktop/package.json の version を変更してパッケージ +npm run dist # macOS(dmg + 更新用 zip を出力) +npm run dist:win # Windows(Setup.exe を出力) + +# 2. バージョンマニフェスト release/latest.json を生成(各プラットフォームの sha256 を含む) +cd desktop && npm run feed + +# 3. 公開:latest.json + インストーラー + zip を GitHub Release にアップロード(タグは v1.0.1 形式) +``` + +インストール済みのクライアントは起動後にマニフェストを自動チェックし(「設定 → 关于更新」から手動チェックも可能)、新バージョンを検出するとダウンロードとインストールを促します。マニフェストの URL は `HUOBAO_UPDATE_FEED` 環境変数で変更可能です。 + +デスクトップ版の開発デバッグ: + +```bash +npm run build:frontend # フロントエンド静的成果物(frontend/.output/public) +cd desktop && npm run dev # バックエンドをバンドルして Electron ウィンドウで実行 +``` + +> 既知の制限:Seedance 動画モデルがローカル参照リソースを使うには `PUBLIC_BASE_URL` の公開アドレスが必要です。デスクトップ版には公開エントリがないため、このシナリオでは明確なエラーメッセージが表示されます。テキスト→動画・画像生成など他の機能には影響しません。 + +--- + +### 🏭 サーバーデプロイ + +```bash +# 1. フロントエンドをビルド +cd frontend && npm run generate + +# 2. ビルド成果物をコピー(generate の出力は frontend/.output/public、バックエンドは +# frontend/dist を読む。この手順を省くと API は動くがページが 404 になる) +cp -r .output/public dist && cd .. + +# 3. バックエンドを起動 +cd backend && npm start +``` + +サーバーにアップロードするファイル: + +``` +backend/ # バックエンドソース + node_modules +backend/workspace/skills/ # Agent スキルファイル +frontend/dist/ # フロントエンドビルド成果物 +data/ # データディレクトリ(初回実行時に自動作成) +``` + +#### Nginx リバースプロキシ + +```nginx +server { + listen 80; + server_name your-domain.com; + + # 参照動画/音声アップロードは最大 50MB + client_max_body_size 100m; + + # 生成画像/動画はディスクから直接配信(Node を経由しない): + # sendfile ゼロコピー + 長期キャッシュ + # (ファイルは uuid 命名で内容不変のため immutable キャッシュが安全) + location /static/ { + alias /path/to/huobao-drama/data/static/; + sendfile on; + tcp_nopush on; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://localhost:5679; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +> メディア読み込みの最適化:画像生成時にバックエンドが 400px サムネイル(`*_thumb.webp`)を自動生成してリストページで使用し、動画はポスターフレーム(`*_poster.jpg`)をカバーとして抽出します。フロントエンドは画像を開く/再生する時だけ元ファイルを読み込みます。過去の既存ファイルは `backend/` で `npm run backfill-artwork` を実行して一括補完できます。 + +### 🐳 Docker デプロイ(アプリ内更新付き) + +**方法 A — ビルド済みイメージ(クローン・ビルド不要)**:マルチアーキテクチャ(`linux/amd64` + `linux/arm64`)、x86 サーバーと ARM デバイスの両方に自動対応 + +```bash +docker pull huobao/huobao-drama:4.0.6 + +docker run -d \ + --name huobao-drama \ + -p 5679:5679 \ + -v huobao-data:/app/data \ + --restart unless-stopped \ + huobao/huobao-drama:4.0.6 +``` + +**方法 B — docker compose(ソースビルド + Watchtower アプリ内更新)**:リポジトリルートにオールインワンの `Dockerfile`(フロントエンド generate + バックエンド依存/ランタイムの 3 ステージ。バックエンドはサーバーデプロイと同様に tsx で実行)と `docker-compose.yml`(アプリ + Watchtower)を用意: + +```bash +# 1. 環境を設定(Watchtower トークン。app と watchtower の両側で一致必須) +cp .env.example .env # WATCHTOWER_TOKEN を変更 + +# 2. ビルドして起動(リリース時にバージョンを注入し、「关于更新」での比較に使用) +HUOBAO_VERSION=4.0.5 docker compose up -d --build + +# 3. http://localhost:5679 にアクセス +``` + +- **データ永続化**:名前付きボリューム `huobao-data` を `/app/data` にマウント(SQLite + 生成画像/動画 + workspace/skills)。イメージ更新でもデータを失いません +- **アプリ内更新**:compose に [Watchtower](https://containrrr.dev/watchtower/) sidecar を同梱(`--label-enable` でラベル付きコンテナのみ更新、`--cleanup` で旧イメージ削除、毎日セルフチェック)。「設定 → 关于更新」で新バージョンの確認と「今すぐ更新」が可能。バックエンドが Watchtower HTTP API 経由でトリガーし、新イメージを pull してコンテナを再構築。数分後にページを更新すれば完了です +- **手動モード**:`docker-compose.yml` から app の `HUOBAO_WATCHTOWER_*` の 2 つの環境変数(または watchtower サービス全体)を削除すると、「关于更新」は新バージョン通知 + 手動コマンド `docker compose pull && docker compose up -d` に退化します +- **イメージの公開**:`docker buildx build --platform linux/amd64,linux/arm64 --build-arg HUOBAO_VERSION=x.y.z -t huobao/huobao-drama:x.y.z -t huobao/huobao-drama:latest --push .`。バージョンマニフェストはデスクトップ版と GitHub Releases の `latest.json` を共用(`HUOBAO_UPDATE_FEED` で上書き可能) + +--- + +## 🎨 技術スタック + +### バックエンド + +- **ランタイム**:Node.js 20+ +- **Web フレームワーク**:Hono +- **ORM**:Drizzle ORM + better-sqlite3(WAL モード) +- **AI Agent**:Mastra + AI SDK(OpenAI 互換) +- **動画処理**:FFmpeg(fluent-ffmpeg + 内蔵バイナリ) +- **画像処理**:Sharp + +### デスクトップ + +- **シェル**:Electron(utilityProcess がバックエンドをホスト、BrowserWindow は同一オリジンで読み込み) +- **パッケージ**:esbuild(バックエンド単一ファイルバンドル)+ electron-builder(dmg arm64/x64、NSIS win-x64) + +### フロントエンド + +- **フレームワーク**:Nuxt 3(SPA モード) +- **言語**:Vue 3 + TypeScript +- **ルーティング**:ファイルルーティング(Vue Router 4) +- **スタイル**:純粋 CSS + CSS Variables +- **アイコン**:Lucide Vue +- **i18n**:vue-i18n(中文 / English / 日本語 / 한국어) + +--- + +## 📝 よくある質問 + +### Q: デスクトップ版のデータはどこに保存されますか? + +A: `~/Library/Application Support/HuobaoDrama/data/`(SQLite データベース + 生成画像/動画)。オンライン編集したスキルのコピーは同階層の `workspace/` ディレクトリにあります。開発モードではリポジトリの `data/` ディレクトリを使用します。 + +### Q: 旧 MySQL データを SQLite に移行するには? + +A: MySQL に接続できる状態(環境変数または `backend/.env`)を保ち、`cd backend && npx tsx scripts/import-mysql-to-sqlite.ts` を実行。スクリプトが自動でテーブルを作成し、テーブルごとにインポートして行数を検証します(対象 DB が空でない場合は `--force` が必要。書き込み前に自動バックアップ)。 + +### Q: FFmpeg が未インストール/見つからない? + +A: インストール不要です。プロジェクトに `ffmpeg-static` / `ffprobe-static` バイナリを内蔵(デスクトップ版も同梱)。システム `PATH` の FFmpeg と競合することもありません。`FFMPEG_BIN`/`FFPROBE_BIN` で明示的に指定することも可能です。 + +### Q: ページ上部に「モデル未設定」と表示される? + +A: 初回デプロイ時の正常な案内です。「設定」ページで「火宝クイック設定」に API Key を貼ってワンクリックで書き込むか、「手動テンプレート」でプロバイダーを追加してください。テキスト・画像・動画の 3 類すべてに有効な設定があるとバナーは自動で消えます。 + +### Q: フロントエンドがバックエンド API に接続できない? + +A: バックエンドが起動しているか、ポートが正しいかを確認してください。開発モードのプロキシ設定は `frontend/nuxt.config.ts` にあります。 + +### Q: データベーステーブルが作成されない? + +A: バックエンドは初回起動時に全テーブルを自動作成します。ログで初期化が成功したか確認してください。 + +--- + +## 📋 更新履歴 + +### v4.0.0 (2026-08) + +#### 🖥️ デスクトップアプリ + データベース移行 + +- Electron デスクトップ版(macOS dmg、arm64/x64 デュアルアーキテクチャ) + - ダブルクリックでインストール、すぐ使える:自動ポート選択、シングルインスタンスロック、クラッシュ分離されたバックエンドサブプロセス + - ユーザーデータ分離:SQLite DB / 生成メディア / スキルコピーはすべて userData ディレクトリに保存 + - FFmpeg/FFprobe を同梱。workspace スキルテンプレートは初回起動時にコピー、アップグレード時は不足分のみ補完して上書きしない +- データベースを MySQL から SQLite へ完全移行(better-sqlite3 + WAL) + - ビジネスコードの変更ゼロ(Drizzle クエリ層は本質的に移植可能)、冪等な DDL リプレイ + - 一度きりのインポートスクリプト `import-mysql-to-sqlite.ts` を追加(テーブルごとの行数検証 + 自動バックアップ) +- バックエンドを esbuild で単一ファイルにバンドル(externals:sharp/better-sqlite3/ffmpeg バイナリパッケージ) +- Docker/MySQL デプロイを削除(git 履歴から復元可能) + +### v3.1.0 (2026-09) + +- Alibaba Bailian Wan 3.0 動画モデルを追加(Prime / 標準、公式 input.media/parameters パラメータ対応) +- ワークベンチ上部バーに解像度セレクターを追加、プロバイダーごとにネイティブ段階を表示(Seedance 480p/720p、MiniMax 768P/2K、Wan 480P/720P/1080P) +- デフォルトの動画モデルを Seedance 2.0 Mini に変更 +- 動画モデル切替時のプロバイダー/モデル不一致による生成エラーを修正 +- バッチ動画:選択モード + 生成前確認(ショット数/合計尺/モデル/解像度)、失敗タスクのワンクリック再試行 +- 分镜尺は動画生成パラメータ領域で直接編集・保存可能、単発/バッチ生成に統一適用 +- 実写/センシティブ内容の審査失敗時にモデル切替再試行を案内 + +### v3.0.0 (2026-08) + + +#### 🚀 デプロイと体験の最適化 + +- Docker デプロイ対応の整備 + - MySQL / アプリのヘルスチェック。データベース準備完了を待ってからアプリ起動 + - データベース初期化にリトライを追加。コンテナオーケストレーションでの初回デプロイが無人で完了 + - システム FFmpeg 依存を削除し、内蔵バイナリに全面移行 + - Agent skills ディレクトリの volume 永続化(設定ページでのオンライン編集が失われない) + - `docker/init.sql` とエクスポートスクリプトを追加(DBA レビュー / 事前テーブル作成) +- 初回利用ガイド + - AI サービス未設定時にサイト上部にバナーを表示し設定ページへ誘導 + - 設定ページに「火宝クイック設定」を追加:1 つの Key でテキスト/画像/動画の 3 つの推奨設定を書き込み + - モデル未設定エラーの中国語化と設定ページへの誘導 +- 動画モデルのデフォルトを Seedance 2.0 Fast に変更 +- プロバイダー集約:OpenAI / Gemini / Volcano Engine のみ残存 +- ワークベンチ:タスクリストドロワー、パイプライン大セクション状態、選択的結合(結合前に動画ファイルの存在を検証) +- 素材ライブラリ刷新、@メンション最適化、エピソードリスト再構築 + +### v2.0.0 (2026-04) + +#### 🚀 大型アップデート + +- プロジェクトを TypeScript 技術スタックへ全面移行 + - バックエンド:Hono + Drizzle ORM + mysql2 + - フロントエンド:Nuxt 3 + Vue 3 + - AI Agent:Mastra フレームワーク +- エピソードワークベンチ UI と制作フローを刷新 + - よりコンパクトなコンソールレイアウト + - 分镜編集エリアを刷新 + - ショット画像、動画、合成、書き出し画面を刷新 +- Docker デプロイ対応を追加。フロントエンドとバックエンドを単一イメージに統合 +- ランタイム Skill ロード機構を追加 +- マルチプロバイダーメディアアダプターを拡張 + - 画像:OpenAI、Gemini、Volcano Engine、Alibaba + - 動画:Volcano Engine/Seedance、Vidu、Alibaba +- ローカルファイル処理と参照画像のオンデマンドトランスコードを最適化 + +### v1.0.4 (2026-01-27) + +- ローカルストレージ戦略を導入し、外部リソースリンク切れを回避 +- Base64 参照画像の埋め込み転送 +- ショット切り替え時の状態リセット問題を修正 +- シーンのチャプターへの移行を追加 + +### v1.0.3 (2026-01-16) + +- データベース同時アクセス性能を最適化 +- Docker クロスプラットフォームで host.docker.internal に対応 + +### v1.0.2 (2026-01-14) + +- 動画生成 API レスポンスの解析問題を修正 +- OpenAI Sora 動画エンドポイント設定を追加 +- エラーハンドリングとログ出力を最適化 + +--- + +## 📄 ライセンス + +本プロジェクトは **[CC BY-NC-SA 4.0](LICENSE)**(表示-非営利-継承 4.0 国際)ライセンスを採用しています。 + +- ✅ 個人利用、学習・研究、非営利プロジェクトでの使用は自由です +- ✅ 改変と再配布は可能ですが、クレジット表示と同一ライセンスでの共有が必要です +- ❌ **商用利用は禁止**——作者の書面による許可なく、本プロジェクトの全部または一部をいかなる商業目的(有料サービス、商用デプロイ、転売など)にも使用することはできません + +ライセンス全文は [LICENSE](LICENSE) を参照してください。 + +--- + +## 🤝 コントリビューション + +Issue と Pull Request を歓迎します! + +1. 本プロジェクトを Fork +2. フィーチャーブランチを作成(`git checkout -b feature/AmazingFeature`) +3. 変更をコミット(`git commit -m 'Add some AmazingFeature'`) +4. ブランチをプッシュ(`git push origin feature/AmazingFeature`) +5. Pull Request を作成 + +よく使うチェックコマンド: + +```bash +cd backend && npm run typecheck +cd ../frontend && npm run build +``` + +--- + +## 💬 WeChat グループ + +QR コードをスキャンして WeChat グループに参加: + +
+ WeChat グループ QR コード +
+ +--- + +> _"AI にもっと創造的なことを手伝ってもらおう"_ + +## 🔗 関連リンク + +本プロジェクトは [LINUX DO](https://linux.do/) コミュニティからリンクの承認を得ています。 + +- [LINUX DO](https://linux.do/) — 真のオープンソース精神、共に作り共有する技術コミュニティ + +--- diff --git a/huobao-source/README.ko.md b/huobao-source/README.ko.md new file mode 100644 index 0000000..82f4883 --- /dev/null +++ b/huobao-source/README.ko.md @@ -0,0 +1,640 @@ +# 🎬 Huobao Drama - AI 숏드라마 생성 플랫폼 + +
+ +**TypeScript 풀스택 기반 AI 숏드라마 자동 제작 플랫폼** + +[![Node Version](https://img.shields.io/badge/Node.js-20+-339933?style=flat&logo=node.js)](https://nodejs.org) +[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) +[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) +[![다운로드](https://img.shields.io/github/v/release/chatfire-AI/huobao-drama?style=flat&logo=github&label=%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C)](https://github.com/chatfire-AI/huobao-drama/releases/latest) + +[中英双语](README.md) | [English](README.en.md) | [简体中文](README.zh-CN.md) | [日本語](README.ja.md) | **한국어** + +[기능](#-기능) • [빠른 시작](#-빠른-시작) • [튜토리얼](#-그림-튜토리얼) • [데스크톱 앱](#-데스크톱-앱-권장) • [배포](#-배포) + +

🔑 Huobao API Key 받기 👉 바로 보기

+ +**텍스트 · 이미지 · 영상 모든 AI 기능, Key 하나로 활성화** + +배포 후 「설정 → 火宝快捷 설정」에 Key 를 붙여넣으면 추천 설정 3개가 한 번에 입력됩니다 + +

📥 데스크톱 앱 다운로드 (macOS / Windows)

+

🌐 공식 웹사이트

+ +
+ +--- + +## 📖 프로젝트 소개 + +Huobao Drama는 AI 기반 숏드라마 자동 제작 플랫폼으로, 각본 생성, 캐릭터 디자인, 스토리보드 제작부터 영상 합성까지 전 과정을 자동화합니다. + +### 🎯 핵심 가치 + +- **🤖 AI 기반**:대규모 언어 모델로 각본을 분석하여 캐릭터, 장면, 스토리보드 정보를 추출 +- **🎨 지능형 창작**:AI 이미지 생성으로 캐릭터 이미지와 장면 배경 제작 +- **📹 영상 생성**:텍스트→영상 및 이미지→영상 모델로 스토리보드 영상 자동 생성 +- **🔄 워크플로**:아이디어부터 완성 영상까지 숏드라마 제작 전 과정을 한 곳에서 완료 + +### 🛠️ 기술 아키텍처 + +``` +frontend/ — Nuxt 3 + Vue 3 + TypeScript (순수 CSS, UI 프레임워크 없음) +backend/ — Hono + Drizzle ORM + Mastra AI Agents + better-sqlite3 +backend/workspace/skills/ — Agent 스킬 정의 (SKILL.md, UI에서 온라인 편집 가능) +desktop/ — Electron 데스크톱 버전 (메인 프로세스 + esbuild 번들 + electron-builder로 dmg/exe 생성) +data/ — 생성된 에셋과 SQLite 데이터베이스 +``` + +--- + +## ✨ 기능 + +### 🎭 캐릭터 관리 + +- ✅ AI 캐릭터 이미지 생성 +- ✅ 캐릭터 일괄 생성 +- ✅ 캐릭터 이미지 업로드 및 관리 + +### 🎬 영상 작업 + +- ✅ AI 영상 작업 자동 생성 +- ✅ 장면 설명 및 영상 프롬프트 생성 +- ✅ 작업별 영상 일괄 생성 + +### 🎥 영상 생성 + +- ✅ 텍스트→영상 자동 생성 +- ✅ FFmpeg 샷 단위 합성 및 자막 처리 +- ✅ 에피소드 전체 병합 및 납출 + +### 📦 에셋 관리 + +- ✅ 소재 라이브러리 통합 관리 +- ✅ 로컬 스토리지 지원 +- ✅ 작업 진행 상황 추적 + +### 🤖 AI Agents + +4개의 Mastra Agent 내장, 데이터베이스 설정과 Skill 확장 지원: + +| Agent | 역할 | +|---|---| +| `script_rewriter` | 소설 → 형식화된 각본 리라이팅 | +| `extractor` | 캐릭터 / 장면 / 소품 지능형 추출 및 중복 제거 | +| `storyboard_breaker` | 각본 → 스토리보드 시퀀스 분해 | +| `prompt_generator` | 캐릭터/장면/소품 이미지 프롬프트 + 스토리보드 영상 프롬프트 생성 | + +### 🌐 다국어 UI + +인터페이스는 **中文 / English / 日本語 / 한국어** 4개 언어를 내장하고, AI 생성 콘텐츠의 언어를 전역으로 설정할 수 있습니다. + +### 🔌 멀티 프로바이더 지원 + +| 유형 | 지원 프로바이더 | +|---|---| +| **텍스트** | OpenAI(호환 API), Gemini | +| **이미지** | OpenAI, Gemini, Volcano Engine | +| **영상** | Volcano Engine Seedance 2.0(Standard / Fast / Mini), MiniMax H3, Alibaba Bailian Wan 3.0(Prime / Standard) | + +--- + +## 🚀 빠른 시작 + +### 📋 환경 요구 사항 + +| 소프트웨어 | 버전 | 설명 | +|---|---|---| +| **Node.js** | 20+ | 프런트엔드/백엔드 실행 환경 | +| **npm** | 9+ | 패키지 관리 도구 | + +> **데이터베이스 설치 불필요**:SQLite 내장(프로젝트 데이터 디렉터리의 단일 파일)으로 데이터베이스 서버 설치가 필요 없습니다. +> **FFmpeg 설치 불필요**:`ffmpeg-static` / `ffprobe-static` npm 패키지에 바이너리가 내장되어 바로 사용할 수 있습니다. + +### ⚙️ 환경 변수 + +설정 파일 없이 환경 변수로 설정합니다(모두 기본값이 있어 로컬 개발은 무설정으로 시작 가능): + +| 변수 | 기본값 | 설명 | +|---|---|---| +| `SQLITE_PATH` | `<저장소>/data/huobao.sqlite3` | SQLite 데이터베이스 파일 위치 | +| `PORT` | `5679` | 백엔드 서비스 포트 | +| `STORAGE_PATH` | `<저장소>/data/static` | 생성 파일 저장 디렉터리 | +| `HUOBAO_DATA_DIR` | — | 데스크톱 버전에서 Electron 메인 프로세스가 주입(userData 데이터 루트) | +| `WORKSPACE_PATH` | `backend/workspace` | Agent 스킬/프롬프트 디렉터리(데스크톱 버전은 userData의 쓰기 가능한 복사본) | +| `FRONTEND_DIST` | `frontend/dist` | 프런트엔드 정적 빌드 디렉터리 | +| `FFMPEG_BIN` / `FFPROBE_BIN` | npm 내장 바이너리 | 사용자 지정 ffmpeg/ffprobe 실행 파일 경로 | +| `PUBLIC_BASE_URL` | — | Seedance가 로컬 참조 리소스를 사용할 때 필요한 공개 주소(서버 배포용) | + +> **설명**:AI 서비스의 API Key, Base URL, 모델 파라미터는 모두 웹 UI의 「설정」 페이지에서 설정하여 데이터베이스에 저장합니다. 설정 파일이나 환경 변수로 관리하지 않습니다. + +### 📥 설치 + +```bash +# 저장소 클론 +git clone https://github.com/chatfire-AI/huobao-drama.git +cd huobao-drama + +# 백엔드 의존성 설치 +cd backend && npm install + +# 프런트엔드 의존성 설치 +cd ../frontend && npm install +``` + +### 🎯 실행 + +#### 방법 1: 개발 모드(권장) + +프런트엔드와 백엔드를 분리하여 핫 리로드 지원: + +```bash +# 터미널 1: 백엔드 +cd backend +npm run dev + +# 터미널 2: 프런트엔드 +cd frontend +npm run dev +``` + +- 프런트엔드 주소: `http://localhost:3013` +- 백엔드 API: `http://localhost:5679/api/v1` +- 프런트엔드가 `/api`와 `/static`을 백엔드로 자동 프록시 + +#### 방법 2: 단일 서비스 모드 + +백엔드가 API와 프런트엔드 정적 파일을 모두 제공: + +```bash +# 1. 프런트엔드 빌드 +cd frontend && npm run generate + +# 2. 빌드 산출물을 백엔드가 읽는 디렉터리로 복사 +# (generate 산출물은 .output/public, 백엔드는 frontend/dist를 읽음) +cp -r .output/public dist + +# 3. 백엔드 시작 +cd ../backend && npm start +``` + +접속: `http://localhost:5679` + +### 🗄️ 데이터베이스 + +SQLite 내장(`better-sqlite3` + WAL 모드). 최초 시작 시 테이블 자동 생성(멱등 DDL 재생 + 시드 데이터). 기본 파일은 `data/huobao.sqlite3`이며 `SQLITE_PATH`로 변경 가능. 데스크톱 버전의 데이터는 사용자 데이터 디렉터리(`~/Library/Application Support/HuobaoDrama/data/`)에 저장됩니다. + +구버전 MySQL에서 데이터 마이그레이션: + +**시작 시 자동 마이그레이션(권장)**: MySQL이 명시적으로 설정되어 있고(`DATABASE_URL` 또는 `MYSQL_HOST`) SQLite가 비어 있으면, 백엔드 시작 시 자동으로 감지하여 모든 테이블을 한 번만 가져옵니다(테이블별 행 수 검증, 단일 트랜잭션 원자적 쓰기, 실패 시 자동 롤백 후 다음 시작 때 재시도, 성공 시 `.mysql-imported` 마커를 기록하여 중복 방지). `MYSQL_AUTO_IMPORT=false`로 비활성화할 수 있습니다. + +```bash +# 수동 실행도 가능(대상 DB가 비어 있지 않으면 --force 필요, 쓰기 전 자동 백업) +cd backend && npx tsx scripts/import-mysql-to-sqlite.ts +``` + +> 마이그레이션은 데이터베이스 행만 포함합니다. 구 배포의 `data/static/` 아래 이미지/영상 등 미디어 파일은 수동으로 복사해야 하며, 그렇지 않으면 기존 소재에 접근할 수 없습니다. + +### 🔑 첫 사용: AI 서비스 설정 + +시작 후 모든 AI 기능(텍스트/이미지/영상)을 사용하려면 먼저 모델 서비스를 설정해야 합니다. 미설정 시 페이지 상단에 배너로 안내합니다: + +1. 「설정」 페이지 열기 +2. 「火宝快捷 설정」에 Huobao API Key 붙여넣기([api.firemux.com에서 발급](https://api.firemux.com)). 텍스트, 이미지, 영상 추천 설정 3개가 한 번에 입력됩니다 +3. 또는 「수동 템플릿」으로 프로바이더별 추가. 연결 테스트 지원 + +설정이 완료되면 배너가 자동으로 사라지고 에피소드 제작을 시작할 수 있습니다. + +--- + +## 📖 그림 튜토리얼 + +소설부터 완성 에피소드까지의 전체 제작 흐름입니다. 왼쪽 진행 표시줄이 항상 현재 단계를 보여줍니다. + +### 1단계 · 프로젝트 생성 + +홈에서 "새 프로젝트"를 클릭하고 **화면 비율**(16:9 가로 / 9:16 세로, 생성 후 변경 불가)과 **화풍**(3D / 실사 등, 모든 이미지 프롬프트에 주입)을 선택합니다. + +

+ 프로젝트 생성 +

+ +

+ 프로젝트 목록 +

+ +### 2단계 · AI 서비스 설정 (최초) + +설정 페이지의 "휘바오 빠른 설정"에 API 키를 붙여넣으면 추천 설정 3종을 한 번에 등록합니다. 수동 템플릿으로 직접 추가할 수도 있습니다. 사용 모델은 상단 바에서 언제든 전환 가능합니다(5단계 참고). + +

+ AI 서비스 설정 +

+ +### 3단계 · 대본 단계 + +제작 화면에 **원문(소설)**을 붙여넣고 "AI 재작성"을 실행하면 촬영용 대본이 생성됩니다 — 에피소드별 분할 및 장면·캐릭터 주석 포함. 모델과 톤을 바꿔가며 재작성할 수 있습니다. + +

+ 대본 단계 +

+ +### 4단계 · 에셋 제작 + +대본에 **추출**을 실행하면 캐릭터 / 장면 / 소품 목록이 자동 생성됩니다. 각 항목의 "이미지 생성"으로 일관성 있는 참조 이미지를 만들 수 있습니다(일괄 생성 지원). 이 이미지들은 영상 생성 시 참조 소재로 주입됩니다. + +

+ 에셋 제작 +

+ +### 5단계 · 스토리보드와 영상 + +"영상 제작" 페이지에서 먼저 **스토리보드 분할**을 실행하세요(AI가 컷을 나누고 영상 프롬프트 생성). 그 후: + +- 상단 바에서 **영상 모델** 선택(Seedance / Wan 3.0 / MiniMax 등). 해상도와 길이 옵션은 모델에 연동됩니다 +- 오른쪽에서 각 컷의 프롬프트를 확인·조정(`@캐릭터명` 참조는 자동으로 참조 이미지에 매핑) +- "일괄 영상 생성"으로 작업 시작. 실패한 작업은 원클릭 재시도 + +

+ 스토리보드 분할 +

+ +

+ 영상 생성 +

+ +### 6단계 · 병합과 내보내기 + +컷을 선택(호버로 각 클립 미리보기)하고 "병합 시작"을 누르면 FFmpeg이 완성 에피소드를 자동 합성합니다. 온라인 재생·다운로드 가능. 완료 후 "완료 표시"를 누르면 진행 표시줄이 켜집니다. + +

+ 병합과 내보내기 +

+ +에피소드 목록에서 각 화의 제작 현황을 확인하고 "제작 열기"로 이어서 작업할 수 있습니다: + +

+ 에피소드 목록 +

+ +--- + +## 📦 배포 + +### 🖥️ 데스크톱 앱(권장) + +**⬇️ 빌드된 설치 파일: [GitHub Releases](https://github.com/chatfire-AI/huobao-drama/releases/latest) · [중국용 미러(Tencent COS)](https://installer.firemux.site/huobao-drama/v4.0.11/)** + +| 플랫폼 | 다운로드 파일 | +|---|---| +| macOS(Apple Silicon, M 시리즈) | `HuobaoDrama-4.0.5-arm64.dmg` | +| macOS(Intel) | `HuobaoDrama-4.0.5.dmg` | +| Windows | `HuobaoDrama.Setup.4.0.5.exe` | + +> 중국 본토에서는 GitHub이 불안정하므로 Tencent COS 미러를 이용하세요. 앱 내 업데이터도 COS를 우선하고 GitHub으로 폴백합니다. + +**커맨드라인 설치(권장, 복구 불필요)**: curl로 다운로드하면 macOS 격리 속성이 붙지 않아 "손상됨" 경고 없이 바로 실행됩니다(Apple Silicon은 `-arm64.dmg`, Intel은 일반 dmg 사용): + +```bash +curl -L -o /tmp/HuobaoDrama.dmg https://installer.firemux.site/huobao-drama/v4.0.11/HuobaoDrama-4.0.11-arm64.dmg \ + && hdiutil attach -nobrowse /tmp/HuobaoDrama.dmg \ + && cp -R /Volumes/HuobaoDrama*/HuobaoDrama.app /Applications/ \ + && hdiutil detach /Volumes/HuobaoDrama* +``` + +빌드 불필요 — dmg/exe를 다운로드해 바로 설치하면 됩니다. 설치된 클라이언트는 내장 업데이터로 자동 업데이트됩니다. (소스에서 직접 패키징하려면 아래 명령을 참조하세요.) + +더블클릭 설치로 바로 사용할 수 있는 데스크톱 버전(macOS + Windows). 데이터베이스(SQLite), 생성된 미디어 파일, Agent 스킬은 모두 사용자 데이터 디렉터리에 저장되며 앱을 삭제필필도 데이터에 영향이 없습니다. + +```bash +# 원커맨드 패키징(프런트엔드 generate → 백엔드 esbuild → electron-builder) +npm run dist # macOS dmg(arm64 + Intel) +npm run dist:win # Windows NSIS 설치 프로그램(win-x64, macOS에서 크로스 빌드 가능) + +# 산출물 +# desktop/release/HuobaoDrama-<버전>-arm64.dmg (Apple Silicon) +# desktop/release/HuobaoDrama-<버전>.dmg (Intel) +# desktop/release/HuobaoDrama Setup <버전>.exe (Windows) +``` + +설치 안내: + +- macOS 미서명 패키지는 첫 실행 시 "App이 손상되어 열 수 없습니다"라는 메시지가 뜰 수 있습니다(Apple Silicon에서 흔함). 이는 Gatekeeper 격리 속성 때문이며 파일이 실제로 손상된 것은 아닙니다. 복구 방법은 두 가지입니다: + 1. **dmg에 복구 스크립트 포함**: 앱을 "응용 프로그램"으로 드래그한 후 dmg 창 하단의 "如提示已损坏请双击我.command"를 더블클릭하면 자동으로 복구됩니다; + 2. 또는 터미널에서 `sudo xattr -cr /Applications/HuobaoDrama.app` 실행. + + 복구는 처음 한 번만 필요하며, 이후에는 정상 실행되고 앱 내 자동 업데이트에도 영향이 없습니다. +- Windows 미서명 패키지는 SmartScreen에서 「추가 정보 → 실행」 선택 +- 사용자 데이터 디렉터리: `~/Library/Application Support/HuobaoDrama/`(데이터베이스, 생성된 미디어, 온라인 편집한 스킬 복사본) +- FFmpeg/FFprobe 바이너리 내장, 시스템 설치 불필요 +- Electron은 37.x로 고정: better-sqlite3의 win32 프리빌드가 해당 ABI까지만 지원(크로스 패키징 시 컴파일 불필요의 핵심) +- 앱 내 외부 링크(「api.firemux.com에서 Key 받기」 등)는 시스템 브라우저로 바로 열립니다 + +#### 🔄 앱 내 업데이트(Apple 서명 불필요) + +데스크톱 버전에는 업데이터가 내장되어 있습니다(Tauri와 동류 방식: macOS 디렉터리 교체 / Windows 사일런트 설치, 로컬 sha256 검증). 새 버전 릴리스 절차: + +```bash +# 1. desktop/package.json의 version을 수정한 후 패키징 +npm run dist # macOS(dmg + 업데이트용 zip 산출) +npm run dist:win # Windows(Setup.exe 산출) + +# 2. 버전 매니페스트 release/latest.json 생성(플랫폼별 sha256 포함) +cd desktop && npm run feed + +# 3. 릴리스: latest.json + 설치 패키지 + zip을 GitHub Release에 업로드(태그는 v1.0.1 형식) +``` + +설치된 클라이언트는 시작 후 매니페스트를 자동으로 확인하고(「설정 → 정보 및 업데이트」에서 수동 확인도 가능), 새 버전을 발견하면 다운로드 및 설치를 안내합니다. 매니페스트 주소는 `HUOBAO_UPDATE_FEED` 환경 변수로 변경할 수 있습니다. + +데스크톱 버전 개발 디버깅: + +```bash +npm run build:frontend # 프런트엔드 정적 산출물(frontend/.output/public) +cd desktop && npm run dev # 백엔드 번들 후 Electron 창으로 실행 +``` + +> 알려진 제한: Seedance 영상 모델이 로컬 참조 리소스를 사용하려면 `PUBLIC_BASE_URL` 공개 주소가 필요합니다. 데스크톱 버전에는 공개 진입점이 없어 해당 시나리오에서는 명확한 오류 메시지가 표시됩니다. 텍스트→영상/이미지 생성 등 나머지 기능에는 영향이 없습니다. + +--- + +### 🏭 서버 배포 + +```bash +# 1. 프런트엔드 빌드 +cd frontend && npm run generate + +# 2. 빌드 산출물 복사(generate 산출물은 frontend/.output/public, 백엔드는 +# frontend/dist를 읽음. 이 단계를 생략하면 API는 정상이지만 페이지가 404) +cp -r .output/public dist && cd .. + +# 3. 백엔드 시작 +cd backend && npm start +``` + +서버에 업로드할 파일: + +``` +backend/ # 백엔드 소스 + node_modules +backend/workspace/skills/ # Agent 스킬 파일 +frontend/dist/ # 프런트엔드 빌드 산출물 +data/ # 데이터 디렉터리(최초 실행 시 자동 생성) +``` + +#### Nginx 리버스 프록시 + +```nginx +server { + listen 80; + server_name your-domain.com; + + # 참조 영상/오디오 업로드 최대 50MB + client_max_body_size 100m; + + # 생성된 이미지/영상은 디스크에서 직접 서빙(Node를 거치지 않음): + # sendfile 제로 카피 + 장기 캐시 + # (파일은 uuid 명명, 내용 불변이므로 immutable 캐시 안전) + location /static/ { + alias /path/to/huobao-drama/data/static/; + sendfile on; + tcp_nopush on; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://localhost:5679; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +> 미디어 로딩 최적화: 이미지 생성 시 백엔드가 목록 페이지용 400px 썸네일(`*_thumb.webp`)을 자동 생성하고, 영상은 포스터 프레임(`*_poster.jpg`)을 커버로 추출합니다. 프런트엔드는 큰 이미지를 열거나 재생할 때만 원본 파일을 로드합니다. 기존 파일은 `backend/`에서 `npm run backfill-artwork`를 실행해 일괄 보완할 수 있습니다. + +### 🐳 Docker 배포(앱 내 업데이트 포함) + +**방법 A — 사전 빌드 이미지(클론·빌드 불필요)**: 멀티 아키텍처(`linux/amd64` + `linux/arm64`), x86 서버와 ARM 디바이스 자동 매칭 + +```bash +docker pull huobao/huobao-drama:4.0.6 + +docker run -d \ + --name huobao-drama \ + -p 5679:5679 \ + -v huobao-data:/app/data \ + --restart unless-stopped \ + huobao/huobao-drama:4.0.6 +``` + +**방법 B — docker compose(소스 빌드 + Watchtower 인앱 업데이트)**: 저장소 루트에 올인원 `Dockerfile`(프런트엔드 generate + 백엔드 의존성/런타임 3단계, 백엔드는 서버 배포와 동일하게 tsx로 실행)과 `docker-compose.yml`(앱 + Watchtower)을 제공합니다: + +```bash +# 1. 환경 설정(Watchtower 토큰, app과 watchtower 양쪽이 일치해야 함) +cp .env.example .env # WATCHTOWER_TOKEN 수정 + +# 2. 빌드 및 시작(릴리스 시 버전을 주입하여 「정보 및 업데이트」 비교에 사용) +HUOBAO_VERSION=4.0.5 docker compose up -d --build + +# 3. http://localhost:5679 접속 +``` + +- **데이터 영속화**: 네임드 볼륨 `huobao-data`를 `/app/data`에 마운트(SQLite + 생성된 이미지/영상 + workspace/skills). 이미지 업데이트 시에도 데이터 유지 +- **앱 내 업데이트**: compose에 [Watchtower](https://containrrr.dev/watchtower/) 사이드카 포함(`--label-enable`로 라벨이 지정된 컨테이너만 업데이트, `--cleanup`으로 구 이미지 정리, 매일 자체 점검). 「설정 → 정보 및 업데이트」에서 새 버전 확인 및 「지금 업데이트」 가능 — 백엔드가 Watchtower HTTP API를 통해 트리거하여 새 이미지를 가져와 컨테이너를 재생성합니다. 몇 분 후 페이지를 새로고침하면 완료 +- **수동 모드**: `docker-compose.yml`에서 app의 `HUOBAO_WATCHTOWER_*` 환경 변수 2개(또는 watchtower 서비스 전체)를 삭제하면 「정보 및 업데이트」는 새 버전 알림 + 수동 명령 `docker compose pull && docker compose up -d`으로 전환됩니다 +- **이미지 릴리스**: `docker buildx build --platform linux/amd64,linux/arm64 --build-arg HUOBAO_VERSION=x.y.z -t huobao/huobao-drama:x.y.z -t huobao/huobao-drama:latest --push .`. 버전 매니페스트는 데스크톱 버전과 GitHub Releases의 `latest.json`을 공유(`HUOBAO_UPDATE_FEED`로 재정의 가능) + +--- + +## 🎨 기술 스택 + +### 백엔드 + +- **런타임**: Node.js 20+ +- **웹 프레임워크**: Hono +- **ORM**: Drizzle ORM + better-sqlite3(WAL 모드) +- **AI Agent**: Mastra + AI SDK(OpenAI 호환) +- **영상 처리**: FFmpeg(fluent-ffmpeg + 내장 바이너리) +- **이미지 처리**: Sharp + +### 데스크톱 + +- **셸**: Electron(utilityProcess가 백엔드를 호스팅, BrowserWindow는 동일 출처로 로드) +- **패키징**: esbuild(백엔드 단일 파일 번들) + electron-builder(dmg arm64/x64, NSIS win-x64) + +### 프런트엔드 + +- **프레임워크**: Nuxt 3(SPA 모드) +- **언어**: Vue 3 + TypeScript +- **라우팅**: 파일 라우팅(Vue Router 4) +- **스타일**: 순수 CSS + CSS Variables +- **아이콘**: Lucide Vue +- **i18n**: vue-i18n(中文 / English / 日本語 / 한국어) + +--- + +## 📝 자주 묻는 질문 + +### Q: 데스크톱 버전의 데이터는 어디에 저장되나요? + +A: `~/Library/Application Support/HuobaoDrama/data/`(SQLite 데이터베이스 + 생성된 이미지/영상)에 저장되며, 온라인 편집한 스킬의 복사본은 같은 수준의 `workspace/` 디렉터리에 있습니다. 개발 모드에서는 저장소의 `data/` 디렉터리를 사용합니다. + +### Q: 구버전 MySQL 데이터를 SQLite로 마이그레이션하려면? + +A: MySQL에 연결 가능한 상태(환경 변수 또는 `backend/.env`)를 유지하고 `cd backend && npx tsx scripts/import-mysql-to-sqlite.ts`를 실행하세요. 스크립트가 자동으로 테이블을 생성하고 테이블별로 가져오며 행 수를 검증합니다(대상 DB가 비어 있지 않으면 `--force` 필요, 쓰기 전 자동 백업). + +### Q: FFmpeg가 설치되지 않았거나 찾을 수 없다고 나옵니다? + +A: 설치할 필요가 없습니다. 프로젝트에 `ffmpeg-static` / `ffprobe-static` 바이너리가 내장되어 있습니다(데스크톱 버전에도 포함). 시스템 `PATH`의 FFmpeg와 충돌하지 않으며, `FFMPEG_BIN`/`FFPROBE_BIN`으로 명시적으로 지정할 수도 있습니다. + +### Q: 페이지 상단에 「모델 미설정」이 표시됩니다? + +A: 정상적인 첫 배포 안내입니다. 「설정」 페이지에서 「火宝快捷 설정」에 API Key를 붙여넣어 한 번에 입력하거나, 「수동 템플릿」으로 프로바이더를 추가하세요. 텍스트, 이미지, 영상 3가지 모두에 활성화된 설정이 있으면 배너가 자동으로 사라집니다. + +### Q: 프런트엔드가 백엔드 API에 연결되지 않습니다? + +A: 백엔드가 시작되었는지, 포트가 올바른지 확인하세요. 개발 모드의 프록시 설정은 `frontend/nuxt.config.ts`에 있습니다. + +### Q: 데이터베이스 테이블이 생성되지 않았습니다? + +A: 백엔드는 최초 시작 시 모든 테이블을 자동으로 생성합니다. 로그에서 초기화 성공 여부를 확인하세요. + +--- + +## 📋 업데이트 로그 + +### v4.0.0 (2026-08) + +#### 🖥️ 데스크톱 앱 + 데이터베이스 마이그레이션 + +- Electron 데스크톱 버전(macOS dmg, arm64/x64 듀얼 아키텍처) + - 더블클릭 설치, 즉시 사용: 자동 포트 선택, 단일 인스턴스 잠금, 충돌 분리된 백엔드 서브프로세스 + - 사용자 데이터 분리: SQLite DB / 생성된 미디어 / 스킬 복사본 모두 userData 디렉터리에 저장 + - FFmpeg/FFprobe 포함 배포. workspace 스킬 템플릿은 첫 시작 시 복사, 업그레이드 시 누락분만 보충하고 덮어쓰지 않음 +- 데이터베이스를 MySQL에서 SQLite로 완전 마이그레이션(better-sqlite3 + WAL) + - 비즈니스 코드 변경 제로(Drizzle 쿼리 계층은 본질적으로 이식 가능), 멱등 DDL 재생 + - 1회용 가져오기 스크립트 `import-mysql-to-sqlite.ts` 추가(테이블별 행 수 검증 + 자동 백업) +- 백엔드 esbuild 단일 파일 번들(externals: sharp/better-sqlite3/ffmpeg 바이너리 패키지) +- Docker/MySQL 배포 방식 제거(git 히스토리에서 복구 가능) + +### v3.1.0 (2026-09) + +- Alibaba Bailian Wan 3.0 동영상 모델 추가(Prime / 표준, 공식 input.media/parameters 파라미터 지원) +- 작업대 상단 바에 해상도 선택기 추가, 제공사별 네이티브 단계 표시(Seedance 480p/720p, MiniMax 768P/2K, Wan 480P/720P/1080P) +- 기본 동영상 모델을 Seedance 2.0 Mini로 변경 +- 동영상 모델 전환 시 제공사/모델 불일치로 인한 생성 오류 수정 +- 배치 동영상: 선택 모드 + 생성 전 확인(숏 수/총 길이/모델/해상도), 실패 작업 원클릭 재시도 +- 스토리보드 길이를 동영상 생성 파라미터 영역에서 직접 편집·저장, 단건/배치 생성에 통일 적용 +- 실사/민감 콘텐츠 심사 실패 시 모델 전환 후 재시도 안내 + +### v3.0.0 (2026-08) + + +#### 🚀 배포 및 경험 최적화 + +- Docker 배포 준비 개선 + - MySQL / 앱 헬스 체크, 데이터베이스 준비 후 앱 시작 + - 데이터베이스 초기화에 재시도 추가, 컨테이너 오케스트레이션에서 첫 배포 무인 완료 + - 시스템 FFmpeg 의존 제거, 내장 바이너리로 전면 전환 + - Agent skills 디렉터리 볼륨 영속화(설정 페이지 온라인 편집 유실 방지) + - `docker/init.sql` 및 납출 스크립트 추가(DBA 검토 / 사전 테이블 생성) +- 첫 사용 가이드 + - AI 서비스 미설정 시 사이트 상단 배너로 설정 페이지 안내 + - 설정 페이지에 「火宝快捷 설정」 추가: Key 하나로 텍스트/이미지/영상 추천 설정 3개 입력 + - 모델 미설정 오류의 중국어화 및 설정 페이지 안내 +- 영상 모델 기본값을 Seedance 2.0 Fast로 조정 +- 프로바이더 통합: OpenAI / Gemini / Volcano Engine만 유지 +- 워크벤치: 작업 목록 드로어, 파이프라인 대섹션 상태, 선택적 병합(병합 전 영상 파일 존재 검증) +- 소재 라이브러리 개편, @멘션 최적화, 에피소드 목록 재구성 + +### v2.0.0 (2026-04) + +#### 🚀 대규모 업데이트 + +- 프로젝트를 TypeScript 기술 스택으로 전면 마이그레이션 + - 백엔드: Hono + Drizzle ORM + mysql2 + - 프런트엔드: Nuxt 3 + Vue 3 + - AI Agent: Mastra 프레임워크 +- 에피소드 워크벤치 UI와 제작 플로우 재구축 + - 더 컴팩트한 콘솔 레이아웃 + - 스토리보드 편집 영역 재구축 + - 샷 이미지, 영상, 합성, 납출 화면 재구축 +- Docker 배포 지원 추가, 프런트엔드와 백엔드를 단일 이미지로 통합 +- 런타임 Skill 로딩 메커니즘 추가 +- 멀티 프로바이더 미디어 어댑터 확장 + - 이미지: OpenAI, Gemini, Volcano Engine, Alibaba + - 영상: Volcano Engine/Seedance, Vidu, Alibaba +- 로컬 파일 처리 및 참조 이미지 온디맨드 트랜스코딩 최적화 + +### v1.0.4 (2026-01-27) + +- 로컬 스토리지 전략 도입으로 외부 리소스 링크 실효 회피 +- Base64 참조 이미지 임베디드 전송 +- 샷 전환 상태 리셋 문제 수정 +- 장면의 챕터 마이그레이션 추가 + +### v1.0.3 (2026-01-16) + +- 데이터베이스 동시 접근 성능 최적화 +- Docker 크로스 플랫폼 host.docker.internal 지원 + +### v1.0.2 (2026-01-14) + +- 영상 생성 API 응답 파싱 문제 수정 +- OpenAI Sora 영상 엔드포인트 설정 추가 +- 오류 처리 및 로그 출력 최적화 + +--- + +## 📄 라이선스 + +이 프로젝트는 **[CC BY-NC-SA 4.0](LICENSE)**(저작자표시-비영리-동일조걳경변경허락 4.0 국제) 라이선스를 채택하고 있습니다. + +- ✅ 개인 사용, 학습·연구, 비영리 프로젝트는 자유롭게 사용 가능 +- ✅ 수정 및 재배포 가능, 단 저작자 표시와 동일 라이선스로 공유 필요 +- ❌ **상업적 사용 금지** — 저작자의 서면 허가 없이 이 프로젝트의 전부 또는 일부를 어떠한 상업적 목적(유료 서비스, 상업 배포, 재판매 등)으로도 사용할 수 없습니다 + +라이선스 전문은 [LICENSE](LICENSE)를 참조하세요. + +--- + +## 🤝 기여 가이드 + +Issue와 Pull Request를 환영합니다! + +1. 이 프로젝트를 Fork +2. 기능 브랜치 생성(`git checkout -b feature/AmazingFeature`) +3. 변경 사항 커밋(`git commit -m 'Add some AmazingFeature'`) +4. 브랜치 푸시(`git push origin feature/AmazingFeature`) +5. Pull Request 생성 + +자주 쓰는 검사 명령: + +```bash +cd backend && npm run typecheck +cd ../frontend && npm run build +``` + +--- + +## 💬 WeChat 그룹 + +QR 코드를 스캔하여 WeChat 그룹에 참여하세요: + +
+ WeChat 그룹 QR 코드 +
+ +--- + +> _"AI가 더 창의적인 일을 돕게 하자"_ + +## 🔗 관련 링크 + +이 프로젝트는 [LINUX DO](https://linux.do/) 커뮤니티의 링크 승인을 받았습니다. + +- [LINUX DO](https://linux.do/) — 진정한 오픈소스 정신, 함께 만들고 공유하는 기술 커뮤니티 + +--- diff --git a/huobao-source/README.md b/huobao-source/README.md new file mode 100644 index 0000000..578c85e --- /dev/null +++ b/huobao-source/README.md @@ -0,0 +1,916 @@ +# 🎬 Huobao Drama — AI 短剧生成平台 / AI Short Drama Generation Platform + +
+ +**基于 TypeScript 全栈的 AI 短剧自动化生产平台**
+**A full-stack TypeScript platform for automated AI short-drama production** + +[![Node Version](https://img.shields.io/badge/Node.js-20+-339933?style=flat&logo=node.js)](https://nodejs.org) +[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) +[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) +[![Download](https://img.shields.io/github/v/release/chatfire-AI/huobao-drama?style=flat&logo=github&label=Download)](https://github.com/chatfire-AI/huobao-drama/releases/latest) + +**中英双语 / Bilingual** | [简体中文](README.zh-CN.md) | [English](README.en.md) | [日本語](README.ja.md) | [한국어](README.ko.md) + +

+ + 立即获取 FireMux API Key / Get your API key + +

+ +**文本 · 图片 · 视频全部 AI 能力,一个 Key 即可开通**
+**Text, image, and video AI capabilities — one key unlocks everything** + +部署完成后,在「设置 → 火宝快捷配置」粘贴 Key,一键写入三条推荐配置。
+After deploying, paste the key in "Settings → Huobao Quick Setup" to write three recommended configs in one click. + +

+ + 立即下载桌面版 / Download Desktop App (macOS / Windows) + +

+

+ + 访问 FireMux 官网 / Visit the official website + +

+ +
+ +--- + +## 📖 项目简介 / Overview + +Huobao Drama 是一个基于 AI 的短剧自动化生产平台,实现从剧本生成、角色设计、分镜制作到视频合成的全流程自动化。
+Huobao Drama is an AI-powered short-drama production platform that automates the entire pipeline: script generation, character design, storyboard breakdown, and video compositing. + +### 🎯 核心价值 / Core Value + +- **🤖 AI 驱动**:使用大语言模型解析剧本,提取角色、场景和分镜信息
+ **🤖 AI-Driven**: LLMs parse scripts and extract characters, scenes, and storyboard information + +- **🎨 智能创作**:AI 绘图生成角色形象和场景背景
+ **🎨 Intelligent Creation**: AI image generation for character designs and scene backgrounds + +- **📹 视频生成**:基于文生视频和图生视频模型自动生成分镜视频
+ **📹 Video Generation**: Text-to-video and image-to-video models automatically produce storyboard clips + +- **🔄 工作流**:完整的短剧制作工作流,从创意到成片一站式完成
+ **🔄 End-to-End Workflow**: A complete pipeline from idea to finished episode + +### 🛠️ 技术架构 / Architecture + +``` +frontend/ — Nuxt 3 + Vue 3 + TypeScript(纯 CSS,无 UI 框架 / pure CSS, no UI framework) +backend/ — Hono + Drizzle ORM + Mastra AI Agents + better-sqlite3 +backend/workspace/skills/ — Agent 技能定义,支持界面在线编辑 / Agent skill definitions (SKILL.md), editable in the UI +desktop/ — Electron 桌面版 / Electron desktop app(主进程 + esbuild + electron-builder / main process + esbuild + electron-builder) +data/ — 生成资源文件与 SQLite 数据库 / Generated assets and the SQLite database +``` + +--- + +## ✨ 功能特性 / Features + +### 🎭 角色管理 / Character Management + +- ✅ AI 生成角色形象
+ ✅ AI-generated character designs + +- ✅ 批量角色生成
+ ✅ Batch character generation + +- ✅ 角色图片上传和管理
+ ✅ Character image upload and management + +### 🎬 视频任务 / Video Tasks + +- ✅ AI 自动生成视频任务
+ ✅ Automatic AI video-task generation + +- ✅ 场景描述和视频提示词生成
+ ✅ Scene descriptions and video prompt generation + +- ✅ 按任务批量生成视频
+ ✅ Batch video generation per task + +### 🎥 视频生成 / Video Generation + +- ✅ 文生视频自动生成
+ ✅ Text-to-video generation + +- ✅ FFmpeg 单镜头合成与字幕处理
+ ✅ FFmpeg per-shot compositing and subtitle handling + +- ✅ 整集拼接导出
+ ✅ Full-episode stitching and export + +### 📦 资源管理 / Asset Management + +- ✅ 素材库统一管理
+ ✅ Unified asset library + +- ✅ 本地存储支持
+ ✅ Local storage support + +- ✅ 任务进度追踪
+ ✅ Task progress tracking + +### 🤖 AI Agents + +内置 4 个 Mastra Agent,支持数据库配置和 Skill 扩展:
+Four built-in Mastra agents with database-backed configuration and Skill extensions: + +| Agent |职责
Role| +|---|---| +| `script_rewriter` |小说 → 格式化剧本改写
Novel → formatted script rewriting| +| `extractor` |角色 / 场景 / 道具智能提取与去重
Intelligent extraction and dedup of characters / scenes / props| +| `storyboard_breaker` |剧本 → 分镜序列拆解
Script → storyboard sequence breakdown| +| `prompt_generator` |角色/场景/道具图片提示词 + 分镜视频提示词生成
Image prompts for characters/scenes/props + storyboard video prompts| + +### 🌐 多语言界面 / Multi-Language UI + +界面内置 **中文 / English / 日本語 / 한국어** 四种语言,并可全局设置 AI 生成内容的语言。
+The interface ships in **中文 / English / 日本語 / 한국어**, with a global setting for AI-generated content language. + +### 🔌 多厂商适配 / Multi-Provider Support + +|类型
Type|支持厂商
Providers| +|---|---| +|**文本**
**Text**|OpenAI(兼容接口)、Gemini
OpenAI (compatible APIs), Gemini| +|**图片**
**Image**|OpenAI、Gemini、火山引擎
OpenAI, Gemini, Volcano Engine| +|**视频**
**Video**|火山引擎 Seedance 2.0(标准 / Fast / Mini)、MiniMax H3、阿里云百炼 Wan 3.0 (Prime / 标准)
Volcano Engine Seedance 2.0 (Standard / Fast / Mini), MiniMax H3, Alibaba Bailian Wan 3.0 (Prime / Standard)| + +--- + +## 🚀 快速开始 / Quick Start + +### 📋 环境要求 / Requirements + +|软件
Software|版本要求
Version|说明
Notes| +|---|---|---| +| **Node.js** | 20+ |前后端运行环境
Runtime for frontend and backend| +| **npm** | 9+ |包管理工具
Package manager| + +> **数据库零安装**:内置 SQLite(单文件,随项目数据目录存放),无需安装任何数据库服务。
+> **Zero-install database**: Bundled SQLite (single file in the project data directory) — no database server required. + +> **FFmpeg 无需安装**:项目通过 `ffmpeg-static` / `ffprobe-static` npm 包内置二进制,开箱即用。
+> **No FFmpeg install needed**: Binaries ship via the `ffmpeg-static` / `ffprobe-static` npm packages — works out of the box. + +### ⚙️ 环境变量 / Environment Variables + +无需配置文件,通过环境变量设置(均有默认值,本地开发可零配置启动):
+No config files — everything is set via environment variables (all have defaults; local dev needs zero configuration): + +|变量
Variable|默认值
Default|说明
Description| +|---|---|---| +| `SQLITE_PATH` |`<仓库根>/data/huobao.sqlite3`
`/data/huobao.sqlite3`|SQLite 数据库文件位置
SQLite database file location| +| `PORT` | `5679` |后端服务端口
Backend service port| +| `STORAGE_PATH` |`<仓库根>/data/static`
`/data/static`|生成文件存储目录
Generated-file storage directory| +| `HUOBAO_DATA_DIR` | — |桌面版由 Electron 主进程注入(userData 数据根)
Injected by the Electron main process (userData data root)| +| `WORKSPACE_PATH` | `backend/workspace` |Agent 技能/提示词目录(桌面版指向 userData 可写副本)
Agent skills/prompts directory (desktop: writable copy under userData)| +| `FRONTEND_DIST` | `frontend/dist` |前端静态产物目录
Frontend static build directory| +| `FFMPEG_BIN` / `FFPROBE_BIN` |npm 内置二进制
bundled npm binaries|自定义 ffmpeg/ffprobe 可执行文件路径
Custom ffmpeg/ffprobe executable paths| +| `PUBLIC_BASE_URL` | — |Seedance 引用本地参考资源时所需的公网地址(服务器部署用)
Public URL Seedance needs to reference local assets (server deployments)| + +> **说明**:AI 服务的 API Key、Base URL 和模型参数全部在 Web 界面的「设置」页配置并入库,不在配置文件/环境变量中维护。
+> **Note**: AI service API keys, base URLs, and model parameters are all configured in the web UI "Settings" page and stored in the database — never in config files or environment variables. + +### 📥 安装依赖 / Installation + +```bash +# 克隆项目 / Clone the repository +git clone https://github.com/chatfire-AI/huobao-drama.git +cd huobao-drama + +# 安装后端依赖 / Install backend dependencies +cd backend && npm install + +# 安装前端依赖 / Install frontend dependencies +cd ../frontend && npm install +``` + +### 🎯 启动项目 / Running + +#### 方式一:开发模式(推荐) / Option 1: Development mode (recommended) + +前后端分离,支持热重载:
+Frontend and backend run separately with hot reload: + +```bash +# 终端1:启动后端 / Terminal 1: backend +cd backend +npm run dev + +# 终端2:启动前端 / Terminal 2: frontend +cd frontend +npm run dev +``` + +- 前端地址: `http://localhost:3013`
+ Frontend: `http://localhost:3013` + +- 后端 API: `http://localhost:5679/api/v1`
+ Backend API: `http://localhost:5679/api/v1` + +- 前端自动代理 `/api` 和 `/static` 到后端
+ The frontend automatically proxies `/api` and `/static` to the backend + +#### 方式二:单服务模式 / Option 2: Single-service mode + +后端同时提供 API 和前端静态文件:
+The backend serves both the API and the frontend static files: + +```bash +# 1. 构建前端 / Build the frontend +cd frontend && npm run generate + +# 2. 复制构建产物到后端读取的目录 / Copy the build output where the backend expects it +# generate 产物在 .output/public,后端只读取 frontend/dist +# generate outputs to .output/public; the backend reads frontend/dist +cp -r .output/public dist + +# 3. 启动后端 / Start the backend +cd ../backend && npm start +``` + +访问: `http://localhost:5679`
+Visit: `http://localhost:5679` + +### 🗄️ 数据库 / Database + +内置 SQLite(`better-sqlite3` + WAL 模式),数据库表在首次启动时自动创建(幂等重放 DDL 与种子数据),默认文件位于 `data/huobao.sqlite3`,可通过 `SQLITE_PATH` 重定向。桌面版数据存放在用户数据目录(`~/Library/Application Support/HuobaoDrama/data/`)。
+Bundled SQLite (`better-sqlite3` + WAL mode). Tables are created automatically on first launch (idempotent DDL replay + seed data). Default file: `data/huobao.sqlite3`, overridable via `SQLITE_PATH`. The desktop app stores data in the user-data directory (`~/Library/Application Support/HuobaoDrama/data/`). + +从旧版 MySQL 迁移数据:
+Migrating data from a legacy MySQL deployment: + +**启动时自动迁移(推荐)**:显式配置了 MySQL(`DATABASE_URL` 或 `MYSQL_HOST`)且 SQLite 为空库时,后端启动会自动探测并一次性导入全部表(行数逐表校验、单事务原子写入、失败自动回滚并在下次启动重试、成功后写 `.mysql-imported` 标记避免重复)。设 `MYSQL_AUTO_IMPORT=false` 可关闭。
+**Automatic migration on startup (recommended)**: When MySQL is explicitly configured (`DATABASE_URL` or `MYSQL_HOST`) and the SQLite database is empty, the backend automatically detects and imports all tables once (per-table row-count validation, single-transaction atomic writes, automatic rollback with retry on next launch, and a `.mysql-imported` marker to avoid re-importing). Set `MYSQL_AUTO_IMPORT=false` to disable. + +```bash +# 也可手动执行(目标库非空需加 --force,写入前自动备份) / Or run manually (non-empty target requires --force; automatic backup before writing) +cd backend && npx tsx scripts/import-mysql-to-sqlite.ts +``` + +> 迁移只覆盖数据库行;旧部署 `data/static/` 下的图片/视频等媒体文件需手动拷贝,否则历史素材无法访问。
+> Migration covers database rows only; media files (images/videos) under the old deployment's `data/static/` must be copied manually, or historical assets won't load. + +### 🔑 首次使用:配置 AI 服务 / First Use: Configure AI Services + +启动后所有 AI 功能(文本/生图/视频)都需要先配置模型服务,未配置时页面顶部会有横幅引导:
+All AI features (text/image/video) require model services to be configured first — a banner at the top of the page guides you until then: + +1. 打开「设置」页
+ Open the "Settings" page + +2. 在「火宝快捷配置」中粘贴 Huobao API Key([前往 api.firemux.cn 获取](https://api.firemux.cn)),一键写入文本、图片、视频三条推荐配置
+ Paste your Huobao API key in "Huobao Quick Setup" ([get one at api.firemux.com](https://api.firemux.com)) to write three recommended configs (text, image, video) in one click + +3. 或使用「手动模板」按厂商逐个添加,支持连通性测试
+ Or add providers one by one via "Manual Templates", with connectivity testing + +配置完成横幅自动消失,即可开始创建剧集生产。
+Once configured, the banner disappears and you can start producing episodes. + +--- + +## 📖 图文教程 / Visual Walkthrough + +从小说到成片的完整制作流程。左侧进度栏始终标示当前所处阶段,跟着走即可。
+The complete pipeline from novel to finished episode. The left progress rail always shows where you are. + +### 第 1 步 · 创建项目 / Step 1 · Create a Project + +首页点「新建项目」,填写剧名并选择**画面比例**(横屏 16:9 / 竖屏 9:16,创建后不可改)与**画面风格**(3D / 真实感等,影响全局生图提示词)。
+On the home page click "New Project", pick a **aspect ratio** (16:9 landscape / 9:16 portrait, fixed after creation) and a **visual style** (3D, realistic, … — injected into every image prompt). + +

+ 新建项目 / Create a project +

+ +项目卡片即项目列表,随时点入续作: + +

+ 项目列表 / Project list +

+ +### 第 2 步 · 配置 AI 服务(首次) / Step 2 · Configure AI Services (first run) + +设置页「火宝快捷配置」粘贴 API Key 一键写入三条推荐配置;或用「手动模板」按厂商自选模型(顶栏可随时切换当前模型,见第 5 步)。
+Paste an API key in Settings → "Huobao Quick Setup" to write the three recommended configs at once, or add providers manually. The current model can be switched any time from the top bar (see Step 5). + +

+ AI 服务配置 / AI service setup +

+ +### 第 3 步 · 剧本阶段 / Step 3 · Script Stage + +进入剧集工作台,先粘贴**原始内容**(小说文本),再点「AI 改写」生成拍摄剧本——按集拆分、标注场景与角色,改写时可换文本模型、调语气。满意后「保存并进入制作」。
+Paste your **source novel** into the workbench, then hit "AI Rewrite" to produce a shooting script — split by episode with scenes and characters annotated. You can switch text models and tone while rewriting. + +

+ 剧本阶段 / Script stage +

+ +### 第 4 步 · 资产制作 / Step 4 · Assets + +对剧本执行**提取**,自动获得角色 / 场景 / 道具清单;逐个点「生成形象」产出一致性参考图(也可批量)。生成的资产图会在后续生视频时作为参考素材注入。
+Run **extraction** on the script to get the character / scene / prop list, then generate a consistent reference image for each (or in batch). These images are injected as reference material when generating videos. + +

+ 资产制作 / Asset production +

+ +### 第 5 步 · 分镜与视频 / Step 5 · Storyboard & Videos + +「视频制作」页先**拆分分镜**(AI 按镜头节奏切分并生成视频提示词),然后:
+On the "Video Production" page, first run **storyboard breakdown** (AI splits shots and writes video prompts). Then: + +- 顶栏选择本次使用的**视频模型**(Seedance / Wan 3.0 / MiniMax…),分辨率与时长档位随之联动
+ Pick the **video model** in the top bar (Seedance / Wan 3.0 / MiniMax…); resolution and duration tiers follow the model + +- 右侧检查 / 微调每个分镜的提示词(`@角色名` 自动映射参考图)
+ Review and tweak each shot's prompt on the right (`@character` references map to reference images automatically) + +- 点「批量生成视频」发起任务,失败任务可一键「重试失败」
+ Click "Batch Generate Videos"; failed tasks can be retried in one click + +

+ 分镜拆分 / Storyboard breakdown +

+ +

+ 视频生成 / Video generation +

+ +### 第 6 步 · 拼接导出 / Step 6 · Merge & Export + +勾选镜头(悬停可预览单个镜头视频),点「开始拼接」,FFmpeg 自动合成为完整剧集成片,支持在线播放与下载。完成后点「标记完成」,左侧进度栏点亮。
+Select shots (hover to preview each clip), click "Start Merging" and FFmpeg assembles the full episode — play online or download. Click "Mark Done" when finished to light up the progress rail. + +

+ 拼接导出 / Merge & export +

+ +剧集列表随时展示各集制作状态,点「进入制作」继续未完成的集:
+The episode list shows the production status of every episode — click "Enter Studio" to continue: + +

+ 剧集列表 / Episode list +

+ +--- + +## 📦 部署指南 / Deployment + +### 🖥️ 桌面应用(推荐) / Desktop App (recommended) + +**⬇️ 预编译安装包下载:[国内镜像(腾讯云,推荐)](https://installer.firemux.site/huobao-drama/v4.0.11/) · [GitHub Releases(海外)](https://github.com/chatfire-AI/huobao-drama/releases/latest)**
+**⬇️ Prebuilt installers: [GitHub Releases](https://github.com/chatfire-AI/huobao-drama/releases/latest) · [Mirror for China (Tencent COS)](https://installer.firemux.site/huobao-drama/v4.0.11/)** + +|平台
Platform|下载文件
File to download| +|---|---| +|macOS(Apple Silicon,M 系列)
macOS (Apple Silicon, M-series)| `HuobaoDrama-4.0.5-arm64.dmg` | +|macOS(Intel)
macOS (Intel)| `HuobaoDrama-4.0.5.dmg` | +| Windows | `HuobaoDrama.Setup.4.0.5.exe` | + +> 国内用户请用腾讯云镜像直链下载(GitHub 在国内访问不稳定)。应用内更新器同样国内源优先、GitHub 兜底。
+> China users: use the COS mirror above (GitHub is slow/unreachable in mainland China). The in-app updater also checks the COS mirror first, then falls back to GitHub. + +**命令行安装(推荐,免修复)**:用 curl 下载不会触发 macOS 隔离属性,安装后双击即开,不会遇到「已损坏」提示(Apple Silicon 把 `arm64` 文件名换成对应版本,Intel 用无后缀 dmg):
+**Command-line install (recommended, no Gatekeeper fix needed)**: downloading via curl never sets macOS's quarantine attribute, so the app opens cleanly with no "damaged" prompt (use the `-arm64.dmg` for Apple Silicon, the plain dmg for Intel): + +```bash +curl -L -o /tmp/HuobaoDrama.dmg https://installer.firemux.site/huobao-drama/v4.0.11/HuobaoDrama-4.0.11-arm64.dmg \ + && hdiutil attach -nobrowse /tmp/HuobaoDrama.dmg \ + && cp -R /Volumes/HuobaoDrama*/HuobaoDrama.app /Applications/ \ + && hdiutil detach /Volumes/HuobaoDrama* +``` + +无需构建 —— 下载 dmg/exe 直接安装即可,已安装客户端会通过内置更新器自动升级。(如需从源码自行打包,见下方命令。)
+No build required — download the dmg/exe and install. Installed clients auto-update via the built-in updater. (To package from source instead, see the commands below.) + +双击安装、开箱即用的桌面版(macOS + Windows):数据库(SQLite)、生成的媒体文件、Agent 技能全部存放在用户数据目录,卸载应用不影响数据。
+Double-click to install, works out of the box (macOS + Windows): the SQLite database, generated media, and Agent skills all live in the user-data directory — uninstalling the app does not affect your data. + +```bash +# 一键打包(前端 generate → 后端 esbuild → electron-builder) / One-command packaging (frontend generate → backend esbuild → electron-builder) +npm run dist # macOS dmg(arm64 + Intel) / macOS dmg (arm64 + Intel) +npm run dist:win # Windows NSIS 安装器(win-x64,可在 macOS 上交叉打包) / Windows NSIS installer (win-x64, cross-buildable on macOS) + +# 产物 / Artifacts +# desktop/release/HuobaoDrama-<版本>-arm64.dmg (Apple Silicon) / desktop/release/HuobaoDrama--arm64.dmg (Apple Silicon) +# desktop/release/HuobaoDrama-<版本>.dmg (Intel) / desktop/release/HuobaoDrama-.dmg (Intel) +# desktop/release/HuobaoDrama Setup <版本>.exe (Windows) / desktop/release/HuobaoDrama Setup .exe (Windows) +``` + +安装说明:
+Installation notes: + +- macOS 未签名包首次打开可能提示「App 已损坏,无法打开」(Apple Silicon 常见),这是 Gatekeeper 隔离属性导致,并非文件损坏。两种修复方式任选:
+ The macOS build is unsigned — on first launch you may see "App is damaged and can't be opened" (common on Apple Silicon). This is Gatekeeper's quarantine attribute, not actual file damage. Two ways to fix it: + + 1. **dmg 内自带修复脚本**:把 app 拖入「应用程序」后,双击 dmg 窗口底部的「如提示已损坏请双击我.command」,自动完成修复;
+ **The dmg bundles a fix script**: after dragging the app into Applications, double-click the "如提示已损坏请双击我.command" script at the bottom of the dmg window — it removes the quarantine attribute automatically; + + 2. 或终端执行 `sudo xattr -cr /Applications/HuobaoDrama.app`。
+ Or run `sudo xattr -cr /Applications/HuobaoDrama.app` in Terminal. + + 修复只需一次,之后双击图标即可启动,应用内自动更新也不受影响。
+ The fix is one-time only — the app then launches normally, and in-app auto-updates are not affected. + +- Windows 未签名包 SmartScreen 会提示「更多信息 → 仍要运行」
+ The Windows build is unsigned — SmartScreen will prompt "More info → Run anyway" + +- 用户数据目录:`~/Library/Application Support/HuobaoDrama/`(数据库、生成的媒体、技能在线编辑的副本)
+ User-data directory: `~/Library/Application Support/HuobaoDrama/` (database, generated media, writable copies of online-edited skills) + +- 内置 FFmpeg/FFprobe 二进制,无需系统安装
+ FFmpeg/FFprobe binaries are bundled — no system install needed + +- Electron 锁定 37.x:better-sqlite3 的 win32 预编译最高覆盖到该版本的 ABI(交叉打包免编译的关键)
+ Electron is pinned to 37.x: better-sqlite3's win32 prebuilds max out at that ABI (the key to compilation-free cross-packaging) + +- 应用内点击外部链接(如「前往 api.firemux.cn 获取 Key」)直接用系统浏览器打开
+ External links open in the system browser (e.g. "Get a key at api.firemux.com") + +#### 🔄 应用内更新(无需 Apple 签名) / In-App Updates (no Apple signing required) + +桌面版内置更新器(与 Tauri 同类方案:macOS 目录替换 / Windows 静默安装,本地 sha256 校验)。发布新版流程:
+The desktop app ships with a built-in updater (same class of solution as Tauri: directory replacement on macOS / silent installer on Windows, with local sha256 verification). To publish a new release: + +```bash +# 1. 改 desktop/package.json 的 version,然后打包 / 1. Bump version in desktop/package.json, then package +npm run dist # macOS(产出 dmg + 更新用 zip) / macOS (produces dmg + update zip) +npm run dist:win # Windows(产出 Setup.exe) / Windows (produces Setup.exe) + +# 2. 生成版本清单 release/latest.json(含各平台产物 sha256) / 2. Generate the release manifest release/latest.json (with per-platform sha256) +cd desktop && npm run feed + +# 3. 发布:把 latest.json + 安装包 + zip 上传到 GitHub Release(tag 形如 v1.0.1) / 3. Publish: upload latest.json + installers + zips to a GitHub Release (tag like v1.0.1) +``` + +已安装的客户端会在启动后自动检查清单(也可在「设置 → 关于更新」手动检查),发现新版即提示下载安装。自定义清单地址:`HUOBAO_UPDATE_FEED` 环境变量。
+Installed clients check the manifest automatically after launch (manual check available in "Settings → About & Updates") and prompt to download and install when a new version is found. Override the manifest URL with the `HUOBAO_UPDATE_FEED` environment variable. + +桌面版开发调试:
+Desktop development: + +```bash +npm run build:frontend # 前端静态产物(frontend/.output/public) / frontend static output (frontend/.output/public) +cd desktop && npm run dev # 打包后端 bundle 并以 Electron 窗口运行 / bundle the backend and run in an Electron window +``` + +> 已知限制:Seedance 视频模型引用本地参考资源时需要 `PUBLIC_BASE_URL` 公网地址,桌面版无公网入口,该场景会得到明确的中文报错;文生视频/图片等其余能力不受影响。
+> Known limitation: Seedance video models need a `PUBLIC_BASE_URL` public address to reference local assets; the desktop app has no public entry point, so that scenario produces a clear error message. Text-to-video, image generation, and all other capabilities are unaffected. + +--- + +### 🏭 服务器部署方式 / Server Deployment + +```bash +# 1. 构建前端 / Build the frontend +cd frontend && npm run generate + +# 2. 复制构建产物 / Copy the build output +# generate 产物在 frontend/.output/public,后端只读取 frontend/dist,缺此步 API 正常但页面 404 +# generate outputs to frontend/.output/public; the backend reads frontend/dist — skip this step and the API works but pages 404 +cp -r .output/public dist && cd .. + +# 3. 启动后端 / Start the backend +cd backend && npm start +``` + +需要上传到服务器的文件:
+Files to upload to the server: + +``` +backend/ # 后端源码 + node_modules / backend source + node_modules +backend/workspace/skills/ # Agent 技能文件 / Agent skill files +frontend/dist/ # 前端构建产物 / frontend build output +data/ # 数据目录(首次运行自动创建) / data directory (auto-created on first run) +``` + +#### Nginx 反向代理 / Nginx Reverse Proxy + +```nginx +server { + listen 80; + server_name your-domain.com; + + # 参考视频/音频上传最大 50MB / Max 50MB for reference video/audio uploads + client_max_body_size 100m; + + # 生成的图片/视频直连磁盘,不经过 Node / Generated images/videos served straight from disk, bypassing Node + # sendfile 零拷贝 + 长缓存 / sendfile zero-copy + long-lived caching + # 产物按 uuid 命名、内容不变,可安全 immutable 缓存 / Files are uuid-named and immutable, so immutable caching is safe + location /static/ { + alias /path/to/huobao-drama/data/static/; + sendfile on; + tcp_nopush on; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://localhost:5679; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +> 媒体加载优化:生成图片时后端会自动产出 400px 缩略图(`*_thumb.webp`)供列表页加载,视频会抽取海报帧(`*_poster.jpg`)作为封面,前端仅在点开大图/播放时才加载原文件。历史存量文件可在 `backend/` 下执行 `npm run backfill-artwork` 一次性补齐。
+> Media loading optimization: the backend automatically generates 400px thumbnails (`*_thumb.webp`) for list pages and extracts poster frames (`*_poster.jpg`) as video covers — the frontend only loads original files when opening the full image or playing. To backfill historical files, run `npm run backfill-artwork` under `backend/`. + +### 🐳 Docker 部署(含应用内更新) / Docker Deployment (with in-app updates) + +**方式一:预构建镜像(免克隆、免构建)**:多架构镜像(`linux/amd64` + `linux/arm64`),x86 服务器与 ARM 设备自动匹配
+**Option A — prebuilt image (no clone, no build):** multi-arch (`linux/amd64` + `linux/arm64`), x86 servers and ARM devices match automatically + +```bash +docker pull huobao/huobao-drama:4.0.6 + +docker run -d \ + --name huobao-drama \ + -p 5679:5679 \ + -v huobao-data:/app/data \ + --restart unless-stopped \ + huobao/huobao-drama:4.0.6 +``` + +**方式二:docker compose(源码构建 + Watchtower 应用内更新)**:根目录提供一体化 `Dockerfile`(前端 generate + 后端依赖/运行时三阶段,后端与服务器部署一致走 tsx)与 `docker-compose.yml`(应用 + Watchtower):
+**Option B — docker compose (source build + Watchtower in-app updates):** the repo root provides an all-in-one `Dockerfile` (three stages: frontend generate + backend dependencies + runtime; the backend runs via tsx just like server deployment) and `docker-compose.yml` (app + Watchtower): + +```bash +# 1. 配置环境(Watchtower 令牌,app 与 watchtower 两侧必须一致) / 1. Configure the environment (Watchtower token — must match on the app and watchtower sides) +cp .env.example .env # 修改 WATCHTOWER_TOKEN / edit WATCHTOWER_TOKEN + +# 2. 构建并启动(发布时注入版本号,供「关于更新」比对) / 2. Build and start (inject a version at publish time for "About & Updates" comparison) +HUOBAO_VERSION=4.0.5 docker compose up -d --build + +# 3. 访问 http://localhost:5679 / 3. Visit http://localhost:5679 +``` + +- **数据持久化**:命名卷 `huobao-data` 挂载 `/app/data`(SQLite + 生成的图片/视频 + workspace/skills,更新镜像不丢数据)
+ **Data persistence**: the named volume `huobao-data` mounts `/app/data` (SQLite + generated images/videos + workspace/skills) — image updates don't lose data + +- **应用内更新**:compose 自带 [Watchtower](https://containrrr.dev/watchtower/) sidecar(`--label-enable` 只更新标记容器,`--cleanup` 清旧镜像,每天自检一次)。设置页「关于更新」可检查新版本并「立即更新」——后端经 Watchtower HTTP API 触发,拉新镜像重建容器,几分钟后刷新页面即可
+ **In-app updates**: the compose file ships a [Watchtower](https://containrrr.dev/watchtower/) sidecar (`--label-enable` only updates labeled containers, `--cleanup` removes old images, daily self-check). "Settings → About & Updates" can check for new versions and "Update Now" — the backend triggers it via the Watchtower HTTP API, which pulls the new image and rebuilds the container; refresh the page after a few minutes + +- **手动模式**:`docker-compose.yml` 中删除 app 的 `HUOBAO_WATCHTOWER_*` 两个环境变量(或整个 watchtower 服务)后,「关于更新」退化为新版本提示 + 手动命令 `docker compose pull && docker compose up -d`
+ **Manual mode**: remove the app's two `HUOBAO_WATCHTOWER_*` env vars (or the whole watchtower service) from `docker-compose.yml` — "About & Updates" then degrades to a new-version notice + manual `docker compose pull && docker compose up -d` + +- **发布镜像**:`docker buildx build --platform linux/amd64,linux/arm64 --build-arg HUOBAO_VERSION=x.y.z -t huobao/huobao-drama:x.y.z -t huobao/huobao-drama:latest --push .`,版本清单与桌面版共用 GitHub Releases 的 `latest.json`(可用 `HUOBAO_UPDATE_FEED` 覆盖)
+ **Publishing images**: `docker buildx build --platform linux/amd64,linux/arm64 --build-arg HUOBAO_VERSION=x.y.z -t huobao/huobao-drama:x.y.z -t huobao/huobao-drama:latest --push .` — the version manifest is shared with the desktop app via `latest.json` on GitHub Releases (overridable with `HUOBAO_UPDATE_FEED`) + +--- + +## 🎨 技术栈 / Tech Stack + +### 后端 / Backend + +- **运行时**: Node.js 20+
+ **Runtime**: Node.js 20+ + +- **Web 框架**: Hono
+ **Web framework**: Hono + +- **ORM**: Drizzle ORM + better-sqlite3(WAL 模式)
+ **ORM**: Drizzle ORM + better-sqlite3 (WAL mode) + +- **AI Agent**: Mastra + AI SDK (OpenAI compatible)
+ **AI Agents**: Mastra + AI SDK (OpenAI compatible) + +- **视频处理**: FFmpeg (fluent-ffmpeg + 内置二进制)
+ **Video processing**: FFmpeg (fluent-ffmpeg + bundled binaries) + +- **图片处理**: Sharp
+ **Image processing**: Sharp + +### 桌面端 / Desktop + +- **壳**: Electron(utilityProcess 承载后端,BrowserWindow 同源加载)
+ **Shell**: Electron (utilityProcess hosts the backend; BrowserWindow loads same-origin) + +- **打包**: esbuild(后端单文件 bundle)+ electron-builder(dmg arm64/x64,NSIS win-x64)
+ **Packaging**: esbuild (single-file backend bundle) + electron-builder (dmg arm64/x64, NSIS win-x64) + +### 前端 / Frontend + +- **框架**: Nuxt 3 (SPA 模式)
+ **Framework**: Nuxt 3 (SPA mode) + +- **语言**: Vue 3 + TypeScript
+ **Language**: Vue 3 + TypeScript + +- **路由**: 文件路由 (Vue Router 4)
+ **Routing**: File-based routing (Vue Router 4) + +- **样式**: 纯 CSS + CSS Variables
+ **Styling**: Pure CSS + CSS Variables + +- **图标**: Lucide Vue
+ **Icons**: Lucide Vue + +- **国际化**: vue-i18n(中文 / English / 日本語 / 한국어)
+ **i18n**: vue-i18n (中文 / English / 日本語 / 한국어) + +--- + +## 📝 常见问题 / FAQ + +### Q: 桌面版数据存在哪里? / Q: Where does the desktop app store data? + +A: `~/Library/Application Support/HuobaoDrama/data/`(SQLite 数据库 + 生成的图片/视频),技能在线编辑的副本在同级 `workspace/` 目录。开发模式下则使用仓库 `data/` 目录。
+A: `~/Library/Application Support/HuobaoDrama/data/` (SQLite database + generated images/videos); writable copies of online-edited skills live in the sibling `workspace/` directory. In development mode the repo's `data/` directory is used instead. + +### Q: 旧版 MySQL 数据怎么迁移到 SQLite? / Q: How do I migrate legacy MySQL data to SQLite? + +A: 保持 MySQL 可连接(环境变量或 `backend/.env`),执行 `cd backend && npx tsx scripts/import-mysql-to-sqlite.ts`,脚本会自动建表、逐表导入并校验行数(目标库非空需加 `--force`,写入前自动备份)。
+A: Keep MySQL reachable (environment variables or `backend/.env`), then run `cd backend && npx tsx scripts/import-mysql-to-sqlite.ts`. The script creates tables automatically, imports table-by-table, and validates row counts (non-empty targets require `--force`; a backup is made before writing). + +### Q: FFmpeg 未安装或找不到? / Q: FFmpeg not installed or not found? + +A: 无需安装。项目内置 `ffmpeg-static` / `ffprobe-static` 二进制(桌面版随包携带)。系统 `PATH` 中的 FFmpeg 也不会冲突,也可通过 `FFMPEG_BIN`/`FFPROBE_BIN` 显式指定。
+A: No install needed. The project bundles `ffmpeg-static` / `ffprobe-static` binaries (carried along in the desktop package). A system `PATH` FFmpeg won't conflict either, and you can point explicitly via `FFMPEG_BIN`/`FFPROBE_BIN`. + +### Q: 页面顶部提示「尚未配置模型」? / Q: The top of the page says "No model configured"? + +A: 这是正常的首次部署引导。前往「设置」页,用「火宝快捷配置」粘贴 API Key 一键写入,或通过「手动模板」按厂商添加。文本、图片、视频三类均有启用中的配置后横幅自动消失。
+A: That's the normal first-deploy guidance. Go to "Settings" and use "Huobao Quick Setup" to paste an API key and write configs in one click, or add providers via "Manual Templates". The banner disappears once text, image, and video all have an enabled config. + +### Q: 前端无法连接后端 API? / Q: The frontend can't reach the backend API? + +A: 检查后端是否启动,端口是否正确。开发模式下前端代理配置在 `frontend/nuxt.config.ts`。
+A: Check that the backend is running and the port is correct. In dev mode the proxy config lives in `frontend/nuxt.config.ts`. + +### Q: 数据库表未创建? / Q: Database tables not created? + +A: 后端会在首次启动时自动创建所有表,检查日志确认初始化是否成功。
+A: The backend creates all tables automatically on first launch — check the logs to confirm initialization succeeded. + +--- + +## 📋 更新日志 / Changelog + +### v4.0.0 (2026-08) + +#### 🖥️ 桌面应用 + 数据库迁移 / Desktop App + Database Migration + +- Electron 桌面版(macOS dmg,arm64/x64 双架构)
+ Electron desktop app (macOS dmg, arm64/x64 dual architecture) + + - 双击安装、开箱即用:自动选择端口、单实例锁、崩溃隔离的后端子进程
+ Double-click install, works out of the box: automatic port selection, single-instance lock, crash-isolated backend subprocess + + - 用户数据隔离:SQLite 库 / 生成媒体 / 技能副本均存放于 userData 目录
+ User-data isolation: SQLite database / generated media / skill copies all live in the userData directory + + - 内置 FFmpeg/FFprobe 随包分发;workspace 技能模板首启动拷贝、升级只补缺不覆盖
+ FFmpeg/FFprobe bundled; workspace skill templates copied on first launch, upgrades only fill gaps without overwriting + +- 数据库从 MySQL 完全迁移到 SQLite(better-sqlite3 + WAL)
+ Database fully migrated from MySQL to SQLite (better-sqlite3 + WAL) + + - 业务代码零改动(Drizzle 查询层天然可移植),DDL 幂等重放
+ Zero business-code changes (the Drizzle query layer is naturally portable); idempotent DDL replay + + - 新增一次性导入脚本 `import-mysql-to-sqlite.ts`(逐表行数校验 + 自动备份)
+ New one-shot import script `import-mysql-to-sqlite.ts` (per-table row-count validation + automatic backup) + +- 后端 esbuild 单文件打包(externals:sharp/better-sqlite3/ffmpeg 二进制包)
+ Backend bundled to a single file with esbuild (externals: sharp/better-sqlite3/ffmpeg binary packages) + +- 移除 Docker/MySQL 部署方式(git 历史可找回)
+ Removed Docker/MySQL deployment (recoverable from git history) + +### v3.1.0 (2026-09) + +- 新增阿里云百炼 Wan 3.0 视频模型(Prime / 标准,支持官方 input.media/parameters 入参)
+ Added Alibaba Bailian Wan 3.0 video models (Prime / Standard, official `input.media`/`parameters` payloads) + +- 工作台顶栏新增分辨率选择器,按厂商显示原生档位(Seedance 480p/720p、MiniMax 768P/2K、Wan 480P/720P/1080P)
+ Resolution selector in the workbench top bar with native tiers per provider (Seedance 480p/720p, MiniMax 768P/2K, Wan 480P/720P/1080P) + +- 默认视频模型调整为 Seedance 2.0 Mini
+ Default video model changed to Seedance 2.0 Mini + +- 修复切换视频模型时厂商/模型错配导致的生成报错
+ Fixed generation errors caused by provider/model mismatch when switching video models + +- 批量视频:选择模式 + 生成前确认(镜头数/总时长/模型/分辨率),失败任务一键重试
+ Batch video: selection mode + pre-generation confirmation (shot count / total duration / model / resolution), one-click retry for failed tasks + +- 分镜时长在视频生成参数区直接编辑保存,单次/批量生成统一生效
+ Storyboard duration now editable directly in the video-generation params area, applied to single and batch generation + +- 真人/敏感内容审核失败时提示切换模型重试
+ Content-moderation failures (real-person / sensitive content) now prompt to switch models and retry + +### v3.0.0 (2026-08) + +#### 🚀 部署与体验优化 / Deployment & Experience Improvements + +- Docker 部署就绪改造
+ Docker deployment readiness + + - MySQL / 应用健康检查,应用等待数据库就绪后启动
+ MySQL / app health checks; the app waits for the database before starting + + - 数据库初始化增加重试,容器编排下首次部署零人工干预
+ Database initialization retries — zero manual intervention on first containerized deploy + + - 移除系统 FFmpeg 依赖,全面使用内置二进制
+ Removed the system FFmpeg dependency; bundled binaries everywhere + + - Agent skills 目录 volume 持久化(设置页在线编辑不丢失)
+ Agent skills directory volume persistence (online edits in Settings survive) + + - 新增 `docker/init.sql` 及导出脚本(DBA 审核 / 预建表)
+ New `docker/init.sql` and export scripts (DBA review / pre-created tables) + +- 首次使用引导
+ First-use guidance + + - 未配置 AI 服务时全站顶部横幅提示并引导至设置页
+ Site-wide banner guiding to Settings when no AI service is configured + + - 设置页新增「火宝快捷配置」:一个 Key 写入文本/图片/视频三条推荐配置
+ New "Huobao Quick Setup" in Settings: one key writes three recommended configs (text/image/video) + + - 未配置模型的报错中文化并指引设置页
+ Unconfigured-model errors localized with pointers to Settings + +- 视频模型默认调整为 Seedance 2.0 Fast
+ Default video model changed to Seedance 2.0 Fast + +- 厂商收敛:仅保留 OpenAI / Gemini / 火山引擎
+ Provider consolidation: OpenAI / Gemini / Volcano Engine only + +- 工作台:任务列表抽屉、流水线大环节状态、选择性拼接(拼接前校验视频文件存在)
+ Workbench: task-list drawer, pipeline stage status, selective stitching (validates video files exist before merging) + +- 素材库改版、@提及优化、剧集列表重构
+ Asset library redesign, @mention improvements, episode list rework + +### v2.0.0 (2026-04) + +#### 🚀 重大更新 / Major Update + +- 项目全面迁移至 TypeScript 技术栈
+ Full migration to a TypeScript stack + + - 后端:Hono + Drizzle ORM + mysql2
+ Backend: Hono + Drizzle ORM + mysql2 + + - 前端:Nuxt 3 + Vue 3
+ Frontend: Nuxt 3 + Vue 3 + + - AI Agent:Mastra 框架
+ AI Agents: Mastra framework + +- 重做单集工作台 UI 和生产流程
+ Episode workbench UI and production flow rebuilt + + - 更紧凑的控制台布局
+ More compact console layout + + - 重做分镜编辑区
+ Storyboard editing area rebuilt + + - 重做镜头图、视频、合成、导出界面
+ Shot image, video, compositing, and export screens rebuilt + +- 新增 Docker 部署支持,前后端合并为单镜像
+ Docker deployment support — frontend and backend merged into a single image + +- 增加运行时 Skill 加载机制
+ Runtime Skill loading mechanism + +- 扩展多厂商媒体 Adapter
+ Expanded multi-provider media adapters + + - 图片:OpenAI、Gemini、火山引擎、阿里
+ Image: OpenAI, Gemini, Volcano Engine, Alibaba + + - 视频:火山引擎/Seedance、Vidu、阿里
+ Video: Volcano Engine/Seedance, Vidu, Alibaba + +- 优化本地文件处理与参考图按需转码
+ Improved local file handling and on-demand reference-image transcoding + +### v1.0.4 (2026-01-27) + +- 引入本地存储策略,规避外部资源链接失效
+ Local storage strategy to avoid dead external asset links + +- Base64 参考图嵌入式传输
+ Base64 reference-image embedded transport + +- 修复镜头切换状态重置问题
+ Fixed shot-switch state reset + +- 添加场景迁移至章节
+ Scene migration to chapters + +### v1.0.3 (2026-01-16) + +- 优化数据库并发访问性能
+ Database concurrency performance improvements + +- Docker 跨平台支持 host.docker.internal
+ Docker cross-platform support for host.docker.internal + +### v1.0.2 (2026-01-14) + +- 修复视频生成 API 响应解析问题
+ Fixed video-generation API response parsing + +- 添加 OpenAI Sora 视频端点配置
+ OpenAI Sora video endpoint configuration + +- 优化错误处理和日志输出
+ Improved error handling and logging + +--- + +## 📄 许可证 / License + +本项目采用 **[CC BY-NC-SA 4.0](LICENSE)**(署名-非商业性使用-相同方式共享 4.0 国际)许可证。
+This project is licensed under **[CC BY-NC-SA 4.0](LICENSE)** (Attribution-NonCommercial-ShareAlike 4.0 International). + +- ✅ 个人使用、学习研究、非商业项目均可自由使用
+ ✅ Personal use, learning, and non-commercial projects are welcome + +- ✅ 允许修改与再分发,但须署名并以相同许可证共享
+ ✅ Modifications and redistribution allowed under the same license with attribution + +- ❌ **禁止商用**——未经作者书面许可,不得将本项目整体或部分用于任何商业目的(包括付费服务、商业部署、转售等)
+ ❌ **Commercial use is prohibited** — you may not use this project, in whole or in part, for any commercial purpose (including paid services, commercial deployments, or resale) without prior written permission from the author + +许可证全文见 [LICENSE](LICENSE)。
+Full license text: see [LICENSE](LICENSE). + +--- + +## 🤝 贡献指南 / Contributing + +欢迎提交 Issue 和 Pull Request!
+Issues and Pull Requests are welcome! + +1. Fork 本项目
+ Fork the project + +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
+ Create a feature branch (`git checkout -b feature/AmazingFeature`) + +3. 提交改动 (`git commit -m 'Add some AmazingFeature'`)
+ Commit your changes (`git commit -m 'Add some AmazingFeature'`) + +4. 推送到分支 (`git push origin feature/AmazingFeature`)
+ Push to the branch (`git push origin feature/AmazingFeature`) + +5. 开启 Pull Request
+ Open a Pull Request + +常用检查命令:
+Common checks: + +```bash +cd backend && npm run typecheck +cd ../frontend && npm run build +``` + +--- + +## 💬 微信群 / WeChat Group + +扫码加入微信群交流:
+Scan the QR code to join the WeChat group: + +
+ 微信群二维码 / WeChat group QR code +
+ +--- + +> _"让 AI 帮我们做更有创造力的事"_
+> _"Let AI do the creating with us"_ + +## 🔗 友情链接 / Links + +本项目已获得 [LINUX DO](https://linux.do/) 社区链接认可。
+This project has been recognized with a link from the [LINUX DO](https://linux.do/) community. + +- [LINUX DO](https://linux.do/) — 真正的开源精神,共建共享的技术社区
+ [LINUX DO](https://linux.do/) — a genuine open-source spirit, a community built on sharing + +--- + +## Star History + +[![Star History Chart](https://api.star-history.com/chart?repos=chatfire-ai/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/?repos=chatfire-ai%2Fhuobao-drama&type=date&legend=top-left) diff --git a/huobao-source/README.zh-CN.md b/huobao-source/README.zh-CN.md new file mode 100644 index 0000000..14527f2 --- /dev/null +++ b/huobao-source/README.zh-CN.md @@ -0,0 +1,638 @@ +# 🎬 Huobao Drama - AI 短剧生成平台 + +
+ +**基于 TypeScript 全栈的 AI 短剧自动化生产平台** + +[![Node Version](https://img.shields.io/badge/Node.js-20+-339933?style=flat&logo=node.js)](https://nodejs.org) +[![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) +[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) +[![下载](https://img.shields.io/github/v/release/chatfire-AI/huobao-drama?style=flat&logo=github&label=%E4%B8%8B%E8%BD%BD)](https://github.com/chatfire-AI/huobao-drama/releases/latest) + +[中英双语](README.md) | **简体中文** | [English](README.en.md) | [日本語](README.ja.md) | [한국어](README.ko.md) + +[功能特性](#-功能特性) • [快速开始](#-快速开始) • [图文教程](#-图文教程) • [桌面版](#-桌面应用推荐) • [部署指南](#-部署指南) + +

🔑 获取 Huobao API Key 👉 立即查看

+ +**文本 · 图片 · 视频全部 AI 能力,一个 Key 即可开通** + +部署完成后在「设置 → 火宝快捷配置」粘贴 Key,一键写入三条推荐配置,开箱即用 + +

📥 下载桌面版(macOS / Windows)

+

🌐 官方网站

+ +
+ +--- + +## 📖 项目简介 + +Huobao Drama 是一个基于 AI 的短剧自动化生产平台,实现从剧本生成、角色设计、分镜制作到视频合成的全流程自动化。 + +### 🎯 核心价值 + +- **🤖 AI 驱动**:使用大语言模型解析剧本,提取角色、场景和分镜信息 +- **🎨 智能创作**:AI 绘图生成角色形象和场景背景 +- **📹 视频生成**:基于文生视频和图生视频模型自动生成分镜视频 +- **🔄 工作流**:完整的短剧制作工作流,从创意到成片一站式完成 + +### 🛠️ 技术架构 + +``` +frontend/ — Nuxt 3 + Vue 3 + TypeScript (纯 CSS,无 UI 框架) +backend/ — Hono + Drizzle ORM + Mastra AI Agents + better-sqlite3 +backend/workspace/skills/ — Agent 技能定义 (SKILL.md,支持界面在线编辑) +desktop/ — Electron 桌面版(主进程 + esbuild 打包 + electron-builder 出 dmg/exe) +data/ — 生成资源文件与 SQLite 数据库 +``` + +--- + +## ✨ 功能特性 + +### 🎭 角色管理 + +- ✅ AI 生成角色形象 +- ✅ 批量角色生成 +- ✅ 角色图片上传和管理 + +### 🎬 视频任务 + +- ✅ AI 自动生成视频任务 +- ✅ 场景描述和视频提示词生成 +- ✅ 按任务批量生成视频 + +### 🎥 视频生成 + +- ✅ 文生视频自动生成 +- ✅ FFmpeg 单镜头合成与字幕处理 +- ✅ 整集拼接导出 + +### 📦 资源管理 + +- ✅ 素材库统一管理 +- ✅ 本地存储支持 +- ✅ 任务进度追踪 + +### 🤖 AI Agents + +内置 4 个 Mastra Agent,支持数据库配置和 Skill 扩展: + +| Agent | 职责 | +|---|---| +| `script_rewriter` | 小说 → 格式化剧本改写 | +| `extractor` | 角色 / 场景 / 道具智能提取与去重 | +| `storyboard_breaker` | 剧本 → 分镜序列拆解 | +| `prompt_generator` | 角色/场景/道具图片提示词 + 分镜视频提示词生成 | + +### 🌐 多语言界面 + +界面内置 **中文 / English / 日本語 / 한국어** 四种语言,并可全局设置 AI 生成内容的语言。 + +### 🔌 多厂商适配 + +| 类型 | 支持厂商 | +|---|---| +| **文本** | OpenAI(兼容接口)、Gemini | +| **图片** | OpenAI、Gemini、火山引擎 | +| **视频** | 火山引擎 Seedance 2.0(标准 / Fast / Mini)、MiniMax H3、阿里云百炼 Wan 3.0 (Prime / 标准) | + +--- + +## 🚀 快速开始 + +### 📋 环境要求 + +| 软件 | 版本要求 | 说明 | +|---|---|---| +| **Node.js** | 20+ | 前后端运行环境 | +| **npm** | 9+ | 包管理工具 | + +> **数据库零安装**:内置 SQLite(单文件,随项目数据目录存放),无需安装任何数据库服务。 +> **FFmpeg 无需安装**:项目通过 `ffmpeg-static` / `ffprobe-static` npm 包内置二进制,开箱即用。 + +### ⚙️ 环境变量 + +无需配置文件,通过环境变量设置(均有默认值,本地开发可零配置启动): + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `SQLITE_PATH` | `<仓库根>/data/huobao.sqlite3` | SQLite 数据库文件位置 | +| `PORT` | `5679` | 后端服务端口 | +| `STORAGE_PATH` | `<仓库根>/data/static` | 生成文件存储目录 | +| `HUOBAO_DATA_DIR` | — | 桌面版由 Electron 主进程注入(userData 数据根) | +| `WORKSPACE_PATH` | `backend/workspace` | Agent 技能/提示词目录(桌面版指向 userData 可写副本) | +| `FRONTEND_DIST` | `frontend/dist` | 前端静态产物目录 | +| `FFMPEG_BIN` / `FFPROBE_BIN` | npm 内置二进制 | 自定义 ffmpeg/ffprobe 可执行文件路径 | +| `PUBLIC_BASE_URL` | — | Seedance 引用本地参考资源时所需的公网地址(服务器部署用) | + +> **说明**:AI 服务的 API Key、Base URL 和模型参数全部在 Web 界面的「设置」页配置并入库,不在配置文件/环境变量中维护。 + +### 📥 安装依赖 + +```bash +# 克隆项目 +git clone https://github.com/chatfire-AI/huobao-drama.git +cd huobao-drama + +# 安装后端依赖 +cd backend && npm install + +# 安装前端依赖 +cd ../frontend && npm install +``` + +### 🎯 启动项目 + +#### 方式一:开发模式(推荐) + +前后端分离,支持热重载: + +```bash +# 终端1:启动后端 +cd backend +npm run dev + +# 终端2:启动前端 +cd frontend +npm run dev +``` + +- 前端地址: `http://localhost:3013` +- 后端 API: `http://localhost:5679/api/v1` +- 前端自动代理 `/api` 和 `/static` 到后端 + +#### 方式二:单服务模式 + +后端同时提供 API 和前端静态文件: + +```bash +# 1. 构建前端 +cd frontend && npm run generate + +# 2. 复制构建产物到后端读取的目录(generate 产物在 .output/public,后端只读取 frontend/dist) +cp -r .output/public dist + +# 3. 启动后端 +cd ../backend && npm start +``` + +访问: `http://localhost:5679` + +### 🗄️ 数据库 + +内置 SQLite(`better-sqlite3` + WAL 模式),数据库表在首次启动时自动创建(幂等重放 DDL 与种子数据),默认文件位于 `data/huobao.sqlite3`,可通过 `SQLITE_PATH` 重定向。桌面版数据存放在用户数据目录(`~/Library/Application Support/HuobaoDrama/data/`)。 + +从旧版 MySQL 迁移数据: + +**启动时自动迁移(推荐)**:显式配置了 MySQL(`DATABASE_URL` 或 `MYSQL_HOST`)且 SQLite 为空库时,后端启动会自动探测并一次性导入全部表(行数逐表校验、单事务原子写入、失败自动回滚并在下次启动重试、成功后写 `.mysql-imported` 标记避免重复)。设 `MYSQL_AUTO_IMPORT=false` 可关闭。 + +```bash +# 也可手动执行(目标库非空需加 --force,写入前自动备份) +cd backend && npx tsx scripts/import-mysql-to-sqlite.ts +``` + +> 迁移只覆盖数据库行;旧部署 `data/static/` 下的图片/视频等媒体文件需手动拷贝,否则历史素材无法访问。 + +### 🔑 首次使用:配置 AI 服务 + +启动后所有 AI 功能(文本/生图/视频)都需要先配置模型服务,未配置时页面顶部会有横幅引导: + +1. 打开「设置」页 +2. 在「火宝快捷配置」中粘贴 Huobao API Key([前往 api.firemux.cn 获取](https://api.firemux.cn)),一键写入文本、图片、视频三条推荐配置 +3. 或使用「手动模板」按厂商逐个添加,支持连通性测试 + +配置完成横幅自动消失,即可开始创建剧集生产。 + +--- + +## 📖 图文教程 + +从小说到成片的完整制作流程。左侧进度栏始终标示当前所处阶段,跟着走即可。 + +### 第 1 步 · 创建项目 + +首页点「新建项目」,填写剧名并选择**画面比例**(横屏 16:9 / 竖屏 9:16,创建后不可改)与**画面风格**(3D / 真实感等,影响全局生图提示词)。 + +

+ 新建项目 +

+ +项目卡片即项目列表,随时点入续作: + +

+ 项目列表 +

+ +### 第 2 步 · 配置 AI 服务(首次) + +设置页「火宝快捷配置」粘贴 API Key 一键写入三条推荐配置;或用「手动模板」按厂商自选模型(顶栏可随时切换当前模型,见第 5 步)。 + +

+ AI 服务配置 +

+ +### 第 3 步 · 剧本阶段 + +进入剧集工作台,先粘贴**原始内容**(小说文本),再点「AI 改写」生成拍摄剧本——按集拆分、标注场景与角色,改写时可换文本模型、调语气。满意后「保存并进入制作」。 + +

+ 剧本阶段 +

+ +### 第 4 步 · 资产制作 + +对剧本执行**提取**,自动获得角色 / 场景 / 道具清单;逐个点「生成形象」产出一致性参考图(也可批量)。生成的资产图会在后续生视频时作为参考素材注入。 + +

+ 资产制作 +

+ +### 第 5 步 · 分镜与视频 + +「视频制作」页先**拆分分镜**(AI 按镜头节奏切分并生成视频提示词),然后: + +- 顶栏选择本次使用的**视频模型**(Seedance / Wan 3.0 / MiniMax…),分辨率与时长档位随之联动 +- 右侧检查 / 微调每个分镜的提示词(`@角色名` 自动映射参考图) +- 点「批量生成视频」发起任务,失败任务可一键「重试失败」 + +

+ 分镜拆分 +

+ +

+ 视频生成 +

+ +### 第 6 步 · 拼接导出 + +勾选镜头(悬停可预览单个镜头视频),点「开始拼接」,FFmpeg 自动合成为完整剧集成片,支持在线播放与下载。完成后点「标记完成」,左侧进度栏点亮。 + +

+ 拼接导出 +

+ +剧集列表随时展示各集制作状态,点「进入制作」继续未完成的集: + +

+ 剧集列表 +

+ +--- + +## 📦 部署指南 + +### 🖥️ 桌面应用(推荐) + +**⬇️ 预编译安装包下载:[国内镜像(腾讯云,推荐)](https://installer.firemux.site/huobao-drama/v4.0.11/) · [GitHub Releases(海外)](https://github.com/chatfire-AI/huobao-drama/releases/latest)** + +| 平台 | 下载文件 | +|---|---| +| macOS(Apple Silicon,M 系列) | `HuobaoDrama-4.0.5-arm64.dmg` | +| macOS(Intel) | `HuobaoDrama-4.0.5.dmg` | +| Windows | `HuobaoDrama.Setup.4.0.5.exe` | + +> 国内用户请用腾讯云镜像直链下载(GitHub 在国内访问不稳定)。应用内更新器同样国内源优先、GitHub 兜底。 + +**命令行安装(推荐,免修复)**:用 curl 下载不会触发 macOS 隔离属性,安装后双击即开,不会遇到「已损坏」提示(Apple Silicon 把 `arm64` 文件名换成对应版本,Intel 用无后缀 dmg): + +```bash +curl -L -o /tmp/HuobaoDrama.dmg https://installer.firemux.site/huobao-drama/v4.0.11/HuobaoDrama-4.0.11-arm64.dmg \ + && hdiutil attach -nobrowse /tmp/HuobaoDrama.dmg \ + && cp -R /Volumes/HuobaoDrama*/HuobaoDrama.app /Applications/ \ + && hdiutil detach /Volumes/HuobaoDrama* +``` + +无需构建 —— 下载 dmg/exe 直接安装即可,已安装客户端会通过内置更新器自动升级。(如需从源码自行打包,见下方命令。) + +双击安装、开箱即用的桌面版(macOS + Windows):数据库(SQLite)、生成的媒体文件、Agent 技能全部存放在用户数据目录,卸载应用不影响数据。 + +```bash +# 一键打包(前端 generate → 后端 esbuild → electron-builder) +npm run dist # macOS dmg(arm64 + Intel) +npm run dist:win # Windows NSIS 安装器(win-x64,可在 macOS 上交叉打包) + +# 产物 +# desktop/release/HuobaoDrama-<版本>-arm64.dmg (Apple Silicon) +# desktop/release/HuobaoDrama-<版本>.dmg (Intel) +# desktop/release/HuobaoDrama Setup <版本>.exe (Windows) +``` + +安装说明: + +- macOS 未签名包首次打开可能提示「App 已损坏,无法打开」(Apple Silicon 常见),这是 Gatekeeper 隔离属性导致,并非文件损坏。两种修复方式任选: + 1. **dmg 内自带修复脚本**:把 app 拖入「应用程序」后,双击 dmg 窗口底部的「如提示已损坏请双击我.command」,自动完成修复; + 2. 或终端执行 `sudo xattr -cr /Applications/HuobaoDrama.app`。 + + 修复只需一次,之后双击图标即可启动,应用内自动更新也不受影响。 +- Windows 未签名包 SmartScreen 会提示「更多信息 → 仍要运行」 +- 用户数据目录:`~/Library/Application Support/HuobaoDrama/`(数据库、生成的媒体、技能在线编辑的副本) +- 内置 FFmpeg/FFprobe 二进制,无需系统安装 +- Electron 锁定 37.x:better-sqlite3 的 win32 预编译最高覆盖到该版本的 ABI(交叉打包免编译的关键) +- 应用内点击外部链接(如「前往 api.firemux.cn 获取 Key」)直接用系统浏览器打开 + +#### 🔄 应用内更新(无需 Apple 签名) + +桌面版内置更新器(与 Tauri 同类方案:macOS 目录替换 / Windows 静默安装,本地 sha256 校验)。发布新版流程: + +```bash +# 1. 改 desktop/package.json 的 version,然后打包 +npm run dist # macOS(产出 dmg + 更新用 zip) +npm run dist:win # Windows(产出 Setup.exe) + +# 2. 生成版本清单 release/latest.json(含各平台产物 sha256) +cd desktop && npm run feed + +# 3. 发布:把 latest.json + 安装包 + zip 上传到 GitHub Release(tag 形如 v1.0.1) +``` + +已安装的客户端会在启动后自动检查清单(也可在「设置 → 关于更新」手动检查),发现新版即提示下载安装。自定义清单地址:`HUOBAO_UPDATE_FEED` 环境变量。 + +桌面版开发调试: + +```bash +npm run build:frontend # 前端静态产物(frontend/.output/public) +cd desktop && npm run dev # 打包后端 bundle 并以 Electron 窗口运行 +``` + +> 已知限制:Seedance 视频模型引用本地参考资源时需要 `PUBLIC_BASE_URL` 公网地址,桌面版无公网入口,该场景会得到明确的中文报错;文生视频/图片等其余能力不受影响。 + +--- + +### 🏭 服务器部署方式 + +```bash +# 1. 构建前端 +cd frontend && npm run generate + +# 2. 复制构建产物(generate 产物在 frontend/.output/public,后端只读取 frontend/dist,缺此步 API 正常但页面 404) +cp -r .output/public dist && cd .. + +# 3. 启动后端 +cd backend && npm start +``` + +需要上传到服务器的文件: + +``` +backend/ # 后端源码 + node_modules +backend/workspace/skills/ # Agent 技能文件 +frontend/dist/ # 前端构建产物 +data/ # 数据目录(首次运行自动创建) +``` + +#### Nginx 反向代理 + +```nginx +server { + listen 80; + server_name your-domain.com; + + # 参考视频/音频上传最大 50MB + client_max_body_size 100m; + + # 生成的图片/视频直连磁盘,不经过 Node:sendfile 零拷贝 + 长缓存 + # (产物按 uuid 命名、内容不变,可安全 immutable 缓存) + location /static/ { + alias /path/to/huobao-drama/data/static/; + sendfile on; + tcp_nopush on; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://localhost:5679; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +> 媒体加载优化:生成图片时后端会自动产出 400px 缩略图(`*_thumb.webp`)供列表页加载,视频会抽取海报帧(`*_poster.jpg`)作为封面,前端仅在点开大图/播放时才加载原文件。历史存量文件可在 `backend/` 下执行 `npm run backfill-artwork` 一次性补齐。 + +### 🐳 Docker 部署(含应用内更新) + +**方式一:预构建镜像(免克隆、免构建)**:多架构镜像(`linux/amd64` + `linux/arm64`),x86 服务器与 ARM 设备自动匹配 + +```bash +docker pull huobao/huobao-drama:4.0.6 + +docker run -d \ + --name huobao-drama \ + -p 5679:5679 \ + -v huobao-data:/app/data \ + --restart unless-stopped \ + huobao/huobao-drama:4.0.6 +``` + +**方式二:docker compose(源码构建 + Watchtower 应用内更新)**:根目录提供一体化 `Dockerfile`(前端 generate + 后端依赖/运行时三阶段,后端与服务器部署一致走 tsx)与 `docker-compose.yml`(应用 + Watchtower): + +```bash +# 1. 配置环境(Watchtower 令牌,app 与 watchtower 两侧必须一致) +cp .env.example .env # 修改 WATCHTOWER_TOKEN + +# 2. 构建并启动(发布时注入版本号,供「关于更新」比对) +HUOBAO_VERSION=4.0.5 docker compose up -d --build + +# 3. 访问 http://localhost:5679 +``` + +- **数据持久化**:命名卷 `huobao-data` 挂载 `/app/data`(SQLite + 生成的图片/视频 + workspace/skills,更新镜像不丢数据) +- **应用内更新**:compose 自带 [Watchtower](https://containrrr.dev/watchtower/) sidecar(`--label-enable` 只更新标记容器,`--cleanup` 清旧镜像,每天自检一次)。设置页「关于更新」可检查新版本并「立即更新」——后端经 Watchtower HTTP API 触发,拉新镜像重建容器,几分钟后刷新页面即可 +- **手动模式**:`docker-compose.yml` 中删除 app 的 `HUOBAO_WATCHTOWER_*` 两个环境变量(或整个 watchtower 服务)后,「关于更新」退化为新版本提示 + 手动命令 `docker compose pull && docker compose up -d` +- **发布镜像**:`docker buildx build --platform linux/amd64,linux/arm64 --build-arg HUOBAO_VERSION=x.y.z -t huobao/huobao-drama:x.y.z -t huobao/huobao-drama:latest --push .`,版本清单与桌面版共用 GitHub Releases 的 `latest.json`(可用 `HUOBAO_UPDATE_FEED` 覆盖) + +--- + +## 🎨 技术栈 + +### 后端 + +- **运行时**: Node.js 20+ +- **Web 框架**: Hono +- **ORM**: Drizzle ORM + better-sqlite3(WAL 模式) +- **AI Agent**: Mastra + AI SDK (OpenAI compatible) +- **视频处理**: FFmpeg (fluent-ffmpeg + 内置二进制) +- **图片处理**: Sharp + +### 桌面端 + +- **壳**: Electron(utilityProcess 承载后端,BrowserWindow 同源加载) +- **打包**: esbuild(后端单文件 bundle)+ electron-builder(dmg arm64/x64,NSIS win-x64) + +### 前端 + +- **框架**: Nuxt 3 (SPA 模式) +- **语言**: Vue 3 + TypeScript +- **路由**: 文件路由 (Vue Router 4) +- **样式**: 纯 CSS + CSS Variables +- **图标**: Lucide Vue +- **国际化**: vue-i18n(中文 / English / 日本語 / 한국어) + +--- + +## 📝 常见问题 + +### Q: 桌面版数据存在哪里? + +A: `~/Library/Application Support/HuobaoDrama/data/`(SQLite 数据库 + 生成的图片/视频),技能在线编辑的副本在同级 `workspace/` 目录。开发模式下则使用仓库 `data/` 目录。 + +### Q: 旧版 MySQL 数据怎么迁移到 SQLite? + +A: 保持 MySQL 可连接(环境变量或 `backend/.env`),执行 `cd backend && npx tsx scripts/import-mysql-to-sqlite.ts`,脚本会自动建表、逐表导入并校验行数(目标库非空需加 `--force`,写入前自动备份)。 + +### Q: FFmpeg 未安装或找不到? + +A: 无需安装。项目内置 `ffmpeg-static` / `ffprobe-static` 二进制(桌面版随包携带)。系统 `PATH` 中的 FFmpeg 也不会冲突,也可通过 `FFMPEG_BIN`/`FFPROBE_BIN` 显式指定。 + +### Q: 页面顶部提示「尚未配置模型」? + +A: 这是正常的首次部署引导。前往「设置」页,用「火宝快捷配置」粘贴 API Key 一键写入,或通过「手动模板」按厂商添加。文本、图片、视频三类均有启用中的配置后横幅自动消失。 + +### Q: 前端无法连接后端 API? + +A: 检查后端是否启动,端口是否正确。开发模式下前端代理配置在 `frontend/nuxt.config.ts`。 + +### Q: 数据库表未创建? + +A: 后端会在首次启动时自动创建所有表,检查日志确认初始化是否成功。 + +--- + +## 📋 更新日志 + +### v4.0.0 (2026-08) + +#### 🖥️ 桌面应用 + 数据库迁移 + +- Electron 桌面版(macOS dmg,arm64/x64 双架构) + - 双击安装、开箱即用:自动选择端口、单实例锁、崩溃隔离的后端子进程 + - 用户数据隔离:SQLite 库 / 生成媒体 / 技能副本均存放于 userData 目录 + - 内置 FFmpeg/FFprobe 随包分发;workspace 技能模板首启动拷贝、升级只补缺不覆盖 +- 数据库从 MySQL 完全迁移到 SQLite(better-sqlite3 + WAL) + - 业务代码零改动(Drizzle 查询层天然可移植),DDL 幂等重放 + - 新增一次性导入脚本 `import-mysql-to-sqlite.ts`(逐表行数校验 + 自动备份) +- 后端 esbuild 单文件打包(externals:sharp/better-sqlite3/ffmpeg 二进制包) +- 移除 Docker/MySQL 部署方式(git 历史可找回) + +### v3.1.0 (2026-09) + +- 新增阿里云百炼 Wan 3.0 视频模型(Prime / 标准,支持官方 input.media/parameters 入参) +- 工作台顶栏新增分辨率选择器,按厂商显示原生档位(Seedance 480p/720p、MiniMax 768P/2K、Wan 480P/720P/1080P) +- 默认视频模型调整为 Seedance 2.0 Mini +- 修复切换视频模型时厂商/模型错配导致的生成报错 +- 批量视频:选择模式 + 生成前确认(镜头数/总时长/模型/分辨率),失败任务一键重试 +- 分镜时长在视频生成参数区直接编辑保存,单次/批量生成统一生效 +- 真人/敏感内容审核失败时提示切换模型重试 + +### v3.0.0 (2026-08) + +#### 🚀 部署与体验优化 + +- Docker 部署就绪改造 + - MySQL / 应用健康检查,应用等待数据库就绪后启动 + - 数据库初始化增加重试,容器编排下首次部署零人工干预 + - 移除系统 FFmpeg 依赖,全面使用内置二进制 + - Agent skills 目录 volume 持久化(设置页在线编辑不丢失) + - 新增 `docker/init.sql` 及导出脚本(DBA 审核 / 预建表) +- 首次使用引导 + - 未配置 AI 服务时全站顶部横幅提示并引导至设置页 + - 设置页新增「火宝快捷配置」:一个 Key 写入文本/图片/视频三条推荐配置 + - 未配置模型的报错中文化并指引设置页 +- 视频模型默认调整为 Seedance 2.0 Fast +- 厂商收敛:仅保留 OpenAI / Gemini / 火山引擎 +- 工作台:任务列表抽屉、流水线大环节状态、选择性拼接(拼接前校验视频文件存在) +- 素材库改版、@提及优化、剧集列表重构 + +### v2.0.0 (2026-04) + +#### 🚀 重大更新 + +- 项目全面迁移至 TypeScript 技术栈 + - 后端:Hono + Drizzle ORM + mysql2 + - 前端:Nuxt 3 + Vue 3 + - AI Agent:Mastra 框架 +- 重做单集工作台 UI 和生产流程 + - 更紧凑的控制台布局 + - 重做分镜编辑区 + - 重做镜头图、视频、合成、导出界面 +- 新增 Docker 部署支持,前后端合并为单镜像 +- 增加运行时 Skill 加载机制 +- 扩展多厂商媒体 Adapter + - 图片:OpenAI、Gemini、火山引擎、阿里 + - 视频:火山引擎/Seedance、Vidu、阿里 +- 优化本地文件处理与参考图按需转码 + +### v1.0.4 (2026-01-27) + +- 引入本地存储策略,规避外部资源链接失效 +- Base64 参考图嵌入式传输 +- 修复镜头切换状态重置问题 +- 添加场景迁移至章节 + +### v1.0.3 (2026-01-16) + +- 优化数据库并发访问性能 +- Docker 跨平台支持 host.docker.internal + +### v1.0.2 (2026-01-14) + +- 修复视频生成 API 响应解析问题 +- 添加 OpenAI Sora 视频端点配置 +- 优化错误处理和日志输出 + +--- + +## 📄 许可证 + +本项目采用 **[CC BY-NC-SA 4.0](LICENSE)**(署名-非商业性使用-相同方式共享 4.0 国际)许可证。 + +- ✅ 个人使用、学习研究、非商业项目均可自由使用 +- ✅ 允许修改与再分发,但须署名并以相同许可证共享 +- ❌ **禁止商用**——未经作者书面许可,不得将本项目整体或部分用于任何商业目的(包括付费服务、商业部署、转售等) + +许可证全文见 [LICENSE](LICENSE)。 + +--- + +## 🤝 贡献指南 + +欢迎提交 Issue 和 Pull Request! + +1. Fork 本项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交改动 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +常用检查命令: + +```bash +cd backend && npm run typecheck +cd ../frontend && npm run build +``` + +--- + +## 💬 微信群 + +扫码加入微信群交流: + +
+ 微信群二维码 +
+ +--- + +> _"让 AI 帮我们做更有创造力的事"_ + +## 🔗 友情链接 + +本项目已获得 [LINUX DO](https://linux.do/) 社区链接认可。 + +- [LINUX DO](https://linux.do/) — 真正的开源精神,共建共享的技术社区 + +--- diff --git a/huobao-source/backend/.env.example b/huobao-source/backend/.env.example new file mode 100644 index 0000000..2e42d4e --- /dev/null +++ b/huobao-source/backend/.env.example @@ -0,0 +1,25 @@ +PORT=5679 + +# Optional. SQLite 数据库文件位置(默认仓库根 data/huobao.sqlite3) +# SQLITE_PATH=/path/to/huobao.sqlite3 + +# Optional. 数据根目录(默认仓库根 data/;桌面版由 Electron 注入 HUOBAO_DATA_DIR) +# HUOBAO_DATA_DIR=/path/to/data +# STORAGE_PATH=/path/to/data/static + +# Optional. MySQL → SQLite 启动自动迁移(老用户升级):显式配置 DATABASE_URL 或 +# MYSQL_HOST 且 SQLite 为空库时,启动自动一次性导入全部表(原子事务 + 行数校验 + +# 成功后写 .mysql-imported 标记)。关闭:MYSQL_AUTO_IMPORT=false +# DATABASE_URL=mysql://huobao:huobao@127.0.0.1:3306/huobao_drama + +# Optional. Public base URL of this backend. Required when passing locally +# uploaded reference videos/audios/files to an upstream video API (local +# /static paths are resolved as PUBLIC_BASE_URL + path). +# PUBLIC_BASE_URL=https://your-domain.com + +# Optional. 关闭文本模型思考(thinking)模式,默认 true。 +# 对 new-api 类中转站在请求体注入关思考参数,规避中转站强制回传 +# reasoning_content 导致的 400;官方 OpenAI/Gemini 端点自动跳过。 +# AI_DISABLE_THINKING=false +# 可选:自定义注入的 OpenAI 风格参数(JSON),适配不同中转站 +# AI_THINKING_OFF_PATCH={"thinking":{"type":"disabled"},"enable_thinking":false} diff --git a/huobao-source/backend/.gitignore b/huobao-source/backend/.gitignore new file mode 100644 index 0000000..191b54c --- /dev/null +++ b/huobao-source/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.js +*.d.ts +*.map diff --git a/huobao-source/backend/.npmrc b/huobao-source/backend/.npmrc new file mode 100644 index 0000000..f00e882 --- /dev/null +++ b/huobao-source/backend/.npmrc @@ -0,0 +1 @@ +package-manager=npm diff --git a/huobao-source/backend/package-lock.json b/huobao-source/backend/package-lock.json new file mode 100644 index 0000000..a8b3b06 --- /dev/null +++ b/huobao-source/backend/package-lock.json @@ -0,0 +1,5758 @@ +{ + "name": "server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@ai-sdk/google": "^3.0.53", + "@ai-sdk/openai": "^3.0.48", + "@hono/node-server": "^1.19.11", + "@mastra/core": "^1.17.0", + "better-sqlite3": "^12.4.1", + "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.1", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "hono": "^4.12.9", + "pino": "^10.3.1", + "sharp": "^0.34.5", + "uuid": "^13.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/fluent-ffmpeg": "^2.1.28", + "@types/node": "^25.5.0", + "@types/uuid": "^10.0.0", + "drizzle-kit": "^0.31.10", + "mysql2": "^3.22.5", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + } + }, + "node_modules/@a2a-js/sdk": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.2.5.tgz", + "integrity": "sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g==", + "dependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.23", + "body-parser": "^2.2.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@a2a-js/sdk/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.53.tgz", + "integrity": "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.48", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.48.tgz", + "integrity": "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.21.tgz", + "integrity": "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v5": { + "name": "@ai-sdk/provider-utils", + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.20.tgz", + "integrity": "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v5/node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils-v6": { + "name": "@ai-sdk/provider-utils", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.0.tgz", + "integrity": "sha512-HyCyOls9I3a3e38+gtvOJOEjuw9KRcvbBnCL5GBuSmJvS9Jh9v3fz7pRC6ha1EUo/ZH1zwvLWYXBMtic8MTguA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.0", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v6/node_modules/@ai-sdk/provider": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.0.tgz", + "integrity": "sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-v5": { + "name": "@ai-sdk/provider", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-v6": { + "name": "@ai-sdk/provider", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.5.tgz", + "integrity": "sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", + "integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mastra/core": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.17.0.tgz", + "integrity": "sha512-szJgYEXGj9ux5cM1wpi2HdouvKBugbnt3TEHEPvW9fI6fLvZTiYG0Nt0y8DPQgDK4nH0BPdL9HFxOf3PnevZ8Q==", + "license": "Apache-2.0", + "dependencies": { + "@a2a-js/sdk": "~0.2.5", + "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.20", + "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.0", + "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.1", + "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.5", + "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", + "@isaacs/ttlcache": "^2.1.4", + "@lukeed/uuid": "^2.0.1", + "@mastra/schema-compat": "1.2.7", + "@modelcontextprotocol/sdk": "^1.27.1", + "@sindresorhus/slugify": "^2.2.1", + "@standard-schema/spec": "^1.1.0", + "ajv": "^8.18.0", + "dotenv": "^17.3.1", + "execa": "^9.6.1", + "gray-matter": "^4.0.3", + "hono": "^4.12.8", + "hono-openapi": "^1.3.0", + "ignore": "^7.0.5", + "js-tiktoken": "^1.0.21", + "json-schema": "^0.4.0", + "lru-cache": "^11.2.7", + "p-map": "^7.0.4", + "p-retry": "^7.1.1", + "picomatch": "^4.0.3", + "radash": "^12.1.1", + "ws": "^8.19.0", + "xxhash-wasm": "^1.1.0" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@mastra/core/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mastra/core/node_modules/@ai-sdk/ui-utils-v5": { + "name": "@ai-sdk/ui-utils", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@mastra/core/node_modules/@ai-sdk/ui-utils-v5/node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@mastra/core/node_modules/@standard-community/standard-openapi": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@standard-community/standard-openapi/-/standard-openapi-0.2.9.tgz", + "integrity": "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@standard-community/standard-json": "^0.3.5", + "@standard-schema/spec": "^1.0.0", + "arktype": "^2.1.20", + "effect": "^3.17.14", + "openapi-types": "^12.1.3", + "sury": "^10.0.0", + "typebox": "^1.0.0", + "valibot": "^1.1.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-openapi": "^4" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-openapi": { + "optional": true + } + } + }, + "node_modules/@mastra/core/node_modules/hono-openapi": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/hono-openapi/-/hono-openapi-1.3.0.tgz", + "integrity": "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig==", + "license": "MIT", + "peerDependencies": { + "@hono/standard-validator": "^0.2.0", + "@standard-community/standard-json": "^0.3.5", + "@standard-community/standard-openapi": "^0.2.9", + "@types/json-schema": "^7.0.15", + "hono": "^4.8.3", + "openapi-types": "^12.1.3" + }, + "peerDependenciesMeta": { + "@hono/standard-validator": { + "optional": true + }, + "hono": { + "optional": true + } + } + }, + "node_modules/@mastra/schema-compat": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.2.7.tgz", + "integrity": "sha512-t63E0f5HcH8neXPfs3D5x4qqQM6Pf/pbhFUVk0cTC0bFo6609sT/+189I+2HY4sbAF3uzurOgy2fXIS4vfMkOA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema-to-zod": "^2.7.0", + "zod-from-json-schema": "^0.5.2", + "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-community/standard-json": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@standard-community/standard-json/-/standard-json-0.3.5.tgz", + "integrity": "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/json-schema": "^7.0.15", + "@valibot/to-json-schema": "^1.3.0", + "arktype": "^2.1.20", + "effect": "^3.16.8", + "quansync": "^0.2.11", + "sury": "^10.0.0", + "typebox": "^1.0.17", + "valibot": "^1.1.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependenciesMeta": { + "@valibot/to-json-schema": { + "optional": true + }, + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz", + "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ffprobe-static": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", + "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==", + "license": "MIT" + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-network-error": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-to-zod": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.7.0.tgz", + "integrity": "sha512-eW59l3NQ6sa3HcB+Ahf7pP6iGU7MY4we5JsPqXQ2ZcIPF8QxSg/lkY8lN0Js/AG0NjMbk+nZGUfHlceiHF+bwQ==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/radash": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/radash/-/radash-12.1.1.tgz", + "integrity": "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==", + "license": "MIT", + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-from-json-schema": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.5.2.tgz", + "integrity": "sha512-/dNaicfdhJTOuUd4RImbLUE2g5yrSzzDjI/S6C2vO2ecAGZzn9UcRVgtyLSnENSmAOBRiSpUdzDS6fDWX3Z35g==", + "license": "MIT", + "dependencies": { + "zod": "^4.0.17" + } + }, + "node_modules/zod-from-json-schema-v3": { + "name": "zod-from-json-schema", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.0.5.tgz", + "integrity": "sha512-zYEoo86M1qpA1Pq6329oSyHLS785z/mTwfr9V1Xf/ZLhuuBGaMlDGu/pDVGVUe4H4oa1EFgWZT53DP0U3oT9CQ==", + "license": "MIT", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/zod-from-json-schema-v3/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/huobao-source/backend/package.json b/huobao-source/backend/package.json new file mode 100644 index 0000000..28755a2 --- /dev/null +++ b/huobao-source/backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "backfill-artwork": "tsx scripts/backfill-artwork.ts" + }, + "keywords": [], + "author": "", + "license": "CC BY-NC-SA-4.0", + "type": "module", + "dependencies": { + "@ai-sdk/google": "^3.0.53", + "@ai-sdk/openai": "^3.0.48", + "@hono/node-server": "^1.19.11", + "@mastra/core": "^1.17.0", + "better-sqlite3": "^12.4.1", + "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.1", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "hono": "^4.12.9", + "pino": "^10.3.1", + "sharp": "^0.34.5", + "uuid": "^13.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/fluent-ffmpeg": "^2.1.28", + "@types/node": "^25.5.0", + "@types/uuid": "^10.0.0", + "drizzle-kit": "^0.31.10", + "mysql2": "^3.22.5", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "ffmpeg-static", + "ffprobe-static" + ] + } +} diff --git a/huobao-source/backend/scripts/backfill-artwork.ts b/huobao-source/backend/scripts/backfill-artwork.ts new file mode 100644 index 0000000..5b52aa1 --- /dev/null +++ b/huobao-source/backend/scripts/backfill-artwork.ts @@ -0,0 +1,60 @@ +/** + * 存量文件回填脚本 — 为历史生成的图片/视频补生成缩略图和海报帧 + * + * 用法:cd backend && npx tsx scripts/backfill-artwork.ts + * + * 扫描 data/static 下: + * images/ → 为缺少 *_thumb.webp 的图片生成缩略图(宽 400 WebP) + * videos/、merged/ → 为缺少 *_poster.jpg 的视频抽取海报帧 + * 已存在的跳过,可重复执行。 + */ +import fs from 'fs' +import path from 'path' +import { generateImageThumb, thumbPathFor, getAbsolutePath } from '../src/utils/storage.js' +import { extractVideoPoster, posterPathFor } from '../src/utils/video-poster.js' +import { STORAGE_ROOT } from '../src/utils/paths.js' + +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']) +const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov']) + +function listFiles(subDir: string): string[] { + const dir = path.join(STORAGE_ROOT, subDir) + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter(f => fs.statSync(path.join(dir, f)).isFile()) +} + +async function backfillImages(subDir: string) { + let done = 0, skipped = 0, failed = 0 + for (const file of listFiles(subDir)) { + const ext = path.extname(file).toLowerCase() + if (!IMAGE_EXTS.has(ext) || file.endsWith('_thumb.webp')) continue + const rel = `static/${subDir}/${file}` + if (fs.existsSync(getAbsolutePath(thumbPathFor(rel)))) { skipped++; continue } + const ok = await generateImageThumb(rel) + ok ? done++ : failed++ + if (ok) console.log(` thumb ✓ ${rel}`) + else console.warn(` thumb ✗ ${rel}`) + } + console.log(`[${subDir}] 缩略图:新生成 ${done},已存在 ${skipped},失败 ${failed}`) +} + +async function backfillVideos(subDir: string) { + let done = 0, skipped = 0, failed = 0 + for (const file of listFiles(subDir)) { + const ext = path.extname(file).toLowerCase() + if (!VIDEO_EXTS.has(ext)) continue + const rel = `static/${subDir}/${file}` + if (fs.existsSync(getAbsolutePath(posterPathFor(rel)))) { skipped++; continue } + const ok = await extractVideoPoster(rel) + ok ? done++ : failed++ + if (ok) console.log(` poster ✓ ${rel}`) + else console.warn(` poster ✗ ${rel}`) + } + console.log(`[${subDir}] 海报帧:新生成 ${done},已存在 ${skipped},失败 ${failed}`) +} + +console.log(`回填目录:${STORAGE_ROOT}`) +await backfillImages('images') +await backfillVideos('videos') +await backfillVideos('merged') +console.log('回填完成') diff --git a/huobao-source/backend/scripts/import-mysql-to-sqlite.ts b/huobao-source/backend/scripts/import-mysql-to-sqlite.ts new file mode 100644 index 0000000..2c608b2 --- /dev/null +++ b/huobao-source/backend/scripts/import-mysql-to-sqlite.ts @@ -0,0 +1,74 @@ +/** + * MySQL → SQLite 一次性数据迁移脚本(CLI) + * + * 用法:cd backend && npx tsx scripts/import-mysql-to-sqlite.ts [--force] + * + * - 源:MySQL(读取 DATABASE_URL 或 MYSQL_* 环境变量,兼容 backend/.env) + * - 目标:SQLITE_PATH 环境变量,默认仓库根 data/huobao.sqlite3 + * - 表结构由 initSqliteSchema 幂等创建;数据显式带 id 写入(AUTOINCREMENT 表 + * 插入后 sqlite_sequence 自动对齐 max(id)) + * - 目标库已有业务数据时须 --force 才允许写入;写入前自动备份 .bak + * - 逐表行数对照,不一致则非零退出 + * + * 注意:服务启动时已内置同逻辑的自动迁移(src/db/mysql-import.ts, + * 显式配置 MySQL + 空库时触发),本脚本用于手动/强制场景。 + */ +import 'dotenv/config' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import mysql from 'mysql2/promise' +import Database from 'better-sqlite3' +import { initSqliteSchema } from '../src/db/sqlite-schema.js' +import { importMysqlData, mysqlUrlFromEnv, sqliteBusinessRowCount } from '../src/db/mysql-import.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const force = process.argv.includes('--force') + +const repoRoot = path.resolve(__dirname, '../..') +const sqlitePath = process.env.SQLITE_PATH || path.join(repoRoot, 'data', 'huobao.sqlite3') + +async function main() { + const url = mysqlUrlFromEnv() + if (!url) { + console.error('未检测到 MySQL 配置(DATABASE_URL 或 MYSQL_HOST)') + process.exit(1) + } + const mysqlPool = mysql.createPool({ uri: url, connectionLimit: 4, charset: 'utf8mb4' }) + + // 备份已有目标库(仅当文件存在且非空库时才需要 --force,备份无条件做) + if (fs.existsSync(sqlitePath)) { + const backup = `${sqlitePath}.bak-${Date.now()}` + fs.copyFileSync(sqlitePath, backup) + console.log(`已备份现有库 → ${backup}`) + } + fs.mkdirSync(path.dirname(sqlitePath), { recursive: true }) + const sqlite = new Database(sqlitePath) + sqlite.pragma('journal_mode = WAL') + sqlite.pragma('busy_timeout = 5000') + initSqliteSchema(sqlite) + + // 非空目标库须 --force + const existing = sqliteBusinessRowCount(sqlite) + if (existing > 0 && !force) { + console.error(`目标库已含 ${existing} 行业务数据。确认覆盖请加 --force(已自动备份)`) + process.exit(1) + } + + const { totalSrc, totalDst } = await importMysqlData(mysqlPool, sqlite) + + // 自增序列对齐检查(显式 id 插入后 sqlite_sequence 应 >= max(id)) + const seq = sqlite.prepare(`SELECT name, seq FROM sqlite_sequence`).all() as { name: string; seq: number }[] + console.log(`\nsqlite_sequence 已登记 ${seq.length} 张自增表`) + + await mysqlPool.end() + sqlite.close() + + console.log(`\n合计: mysql=${totalSrc} sqlite=${totalDst}`) + console.log('迁移完成 ✓') +} + +main().catch(err => { + console.error('迁移失败:', err) + process.exit(1) +}) diff --git a/huobao-source/backend/src/agents/context.ts b/huobao-source/backend/src/agents/context.ts new file mode 100644 index 0000000..5ea4bf2 --- /dev/null +++ b/huobao-source/backend/src/agents/context.ts @@ -0,0 +1,43 @@ +/** + * Agent 请求上下文 — 通过 Mastra RequestContext 按请求注入 + * 路由层 build → generate({ requestContext }) → 工具 execute 内读取 + */ +import { RequestContext } from '@mastra/core/request-context' +import { getContentLanguage, type ContentLanguage } from '../services/app-settings.js' + +export interface AgentRequestContextValues { + episodeId: number + dramaId: number + modelOverride?: string + textConfigId?: number + /** AI 内容语言;缺省时读全局设置(app_settings.content_language) */ + language?: ContentLanguage +} + +export function buildAgentRequestContext(values: AgentRequestContextValues): RequestContext { + const rc = new RequestContext() + rc.set('episodeId', values.episodeId) + rc.set('dramaId', values.dramaId) + if (values.modelOverride) rc.set('modelOverride', values.modelOverride) + if (values.textConfigId) rc.set('textConfigId', values.textConfigId) + // 语言在构建处统一解析:4 条链路(chat/提取/图片提示词/视频提示词)都经过这里, + // 全局设置一处生效;显式传入 values.language 可按请求覆盖 + rc.set('language', values.language ?? getContentLanguage()) + return rc +} + +export function getEpisodeId(requestContext: RequestContext | undefined): number | null { + const v = requestContext?.get('episodeId' as never) + return typeof v === 'number' ? v : null +} + +export function getDramaId(requestContext: RequestContext | undefined): number | null { + const v = requestContext?.get('dramaId' as never) + return typeof v === 'number' ? v : null +} + +/** 读取请求上下文中的内容语言;未设置返回 null(调用方按 zh 处理) */ +export function getContentLanguageFromRC(requestContext: RequestContext | undefined): ContentLanguage | null { + const v = requestContext?.get('language' as never) + return typeof v === 'string' && v ? (v as ContentLanguage) : null +} diff --git a/huobao-source/backend/src/agents/index.ts b/huobao-source/backend/src/agents/index.ts new file mode 100644 index 0000000..27dbcfb --- /dev/null +++ b/huobao-source/backend/src/agents/index.ts @@ -0,0 +1,392 @@ +/** + * Mastra Agent 注册表 + * 启动时注册静态 Agent;instructions/model 用 DynamicArgument 按请求解析 + * (workspace/prompts/.md 文件 + RequestContext 中的 model/config_id 覆盖), + * episodeId/dramaId 由工具从 RequestContext 读取 + */ +import { Agent } from '@mastra/core/agent' +import type { RequestContext } from '@mastra/core/request-context' +import { createGoogleGenerativeAI } from '@ai-sdk/google' +import { createOpenAI } from '@ai-sdk/openai' +import { getTextConfig, getTextProviderBaseUrl, getConfigById } from '../services/ai.js' +import { logTaskProgress } from '../utils/task-logger.js' +import { scriptTools } from './tools/script-tools.js' +import { extractTools } from './tools/extract-tools.js' +import { storyboardTools } from './tools/storyboard-tools.js' +import { imagePromptTools } from './tools/image-prompt-tools.js' +import { loadAgentSkills, skillWorkspaces } from './skills.js' +import { loadAgentPromptFile, loadBasePromptFile } from './prompts.js' +import { buildLanguageDirective } from './language.js' +import { getContentLanguageFromRC } from './context.js' +import { getVoiceReferenceInstructions } from './voice-reference-instructions.js' + +// Default prompts (used when workspace/prompts/.md 文件缺失时兜底) +export const DEFAULT_PROMPTS: Record = { + script_rewriter: { + name: '剧本改写', + instructions: `你是专业编剧,擅长将小说改编为短剧剧本。 + +工作流程: +1. 调用 read_episode_script 读取原始内容 +2. 根据读取到的内容,自己进行改写(输出格式化剧本格式) +3. 调用 save_script 保存改写后的完整剧本 + +格式化剧本格式: +- 场景头:## S编号 | 内景/外景 · 地点 | 时间段 +- 动作描写:自然段落,不包含镜头语言 +- 对白:角色名:(状态/表情)台词内容 +- 每个场景 30-60 秒内容 + +注意:你必须自己完成改写工作,不要只返回指令。读取内容后直接输出改写结果并保存。`, + }, + extractor: { + name: '角色场景提取', + instructions: `你是制片助理,擅长从剧本中提取角色、场景和道具信息,并在提取时与项目已有数据进行智能去重。 + +工作流程: +1. 调用 read_script_for_extraction 读取格式化剧本 +2. 调用 read_existing_characters 读取项目中已存在的角色列表,以及当前集已关联角色 +3. 调用 read_existing_scenes 读取项目中已存在的场景列表,以及当前集已关联场景 +4. 调用 read_existing_props 读取项目中已存在的道具列表,以及当前集已关联道具 +5. 优先围绕当前集剧本,分析本集实际出现的角色、场景和道具 +6. 对每个角色:若同名已存在则合并更新,若不存在则新增 +7. 调用 save_dedup_characters 保存角色(去重合并,自动处理新增和更新,并关联到当前集) +8. 分析剧本内容,提取本集涉及的所有场景信息 +9. 对每个场景:若同地点+时间段已存在则复用,若不存在则新增 +10. 调用 save_dedup_scenes 保存场景(去重合并,自动处理新增和复用,并关联到当前集) +11. 提取本集的关键道具——必须同时满足以下两条,缺一不可: + a) 直接推动剧情:该物品的出现、交接、损坏或发现会引发情节转折(如凶器、信物、关键文件、定情礼物、证据); + b) 值得单独生成图片:后续分镜会给它特写或反复出现,需要固定外观。 + 判定三问(自问自答,任一答"否"即放弃该道具):① 删掉它剧情是否依然成立?成立 → 不提取;② 它只是角色随手使用的日常物品(手机、筷子、杯子、烟)吗?是 → 不提取;③ 它是场景陈设的一部分(桌椅、灯具、门窗、装饰)吗?是 → 不提取。 + 宁可少提,不要多提:一集通常 0-3 个关键道具,超过 3 个时按剧情重要性排序只保留前 3 个;没有符合条件的道具就一个都不要提取 +12. 对每个道具:若同名已存在则合并更新,若不存在则新增 +13. 调用 save_dedup_props 保存道具(去重合并,自动处理新增和更新,并关联到当前集);若没有需要提取的道具,调用时传空数组即可,不要强行凑数 + +去重规则: +- 角色/道具:按名字精确匹配,同名保留现有(合并信息);名称带括号定位或别名时按括号前主体比较(如「林小雨(主角)」与「林小雨」视为同一角色,优先复用项目已有,不要重复创建)。read_existing_characters / read_existing_props 返回的 normalized_name 即归一化后的名字,可据此判断 +- 场景:按【地点+时间段】精确匹配(地点忽略空白/大小写);同地点不同时段视为新场景 + +提取要求: +- 只提取当前集真实出现或被明确提及、且对当前集叙事有效的角色、场景和道具 +- 角色只需要两个核心描述字段:appearance(样貌:年龄感、五官、体态、气质等,角色的性格特点要转化为外在气质与神态融入样貌描写,不要单独输出性格字段)和 styling(妆造:发型、服装、妆面、配饰等) +- 场景只需要两个核心描述字段:prompt(场景描述:空间、陈设、年代质感、关键视觉元素等)和 lighting(场景光影:光源、色调、明暗、氛围等) +- 道具字段:name(道具名)、type(类型:日常/武器/交通/装饰/文件等)、description(物品外貌:只描写物品本身的物理外观——材质、颜色、形状、大小、新旧程度、磨损痕迹等,不要写剧情用途,不要涉及与角色或其他事物的关联)。道具不需要输出图片提示词,最终提示词由提示词生成 Agent 后续专门生成 +- 不要遗漏任何有台词或重要动作的角色`, + }, + storyboard_breaker: { + name: '分镜拆解', + instructions: `你是资深影视分镜师,擅长将剧本拆解为分镜方案,并直接产出可供视频生成使用的提示词。 + +核心定义:一个分镜 = 一个「分镜段落」= 一个视频生成任务。每个段落 8-15 秒,内部承载 2-4 个子镜头;子镜头之间可以切镜(换景别/角度/对象),但不跨场景。 + +工作流程: +1. 调用 read_storyboard_context 读取剧本、角色列表、场景列表、道具列表 +2. 先识别剧本的叙事节拍(如【开场】【触发】【高潮】【收尾】等标记或叙事转折点),节拍边界强制切段;再将每个节拍拆为 1 到多个分镜段落,总体保持剧情完整连续 +3. 为每个段落一并补全生产字段:description(画面描述)与 video_prompt(视频提示词)同步产出,规则分别见下 +4. 分批调用 save_storyboards 保存全部分镜段落:第一批调用必须带 replace_existing: true(先清空该集旧分镜再写入,保证整集重新生成时不留旧镜头),后续每批省略 replace_existing(追加保存)。每批最多 8 个段落,shot_number 必须按顺序递增;全部段落保存完成前不要结束(不要只保存部分段落就停止) + +硬约束(必须遵守): +- 不要输出任何规划、分析、推理或解释性文本,不要复述剧本,不要写「我正在…」「首先我需要…」这类话——思考留在模型内部,输出只允许工具调用 +- 每个输出步骤必须是工具调用(或完成后的简短结束语),禁止先输出大段文字再调用工具 +- 若因内容过多需要分多批,直接在连续的工具调用中完成全部批次,中间不要插入文字 + +每个段落需要填写以下字段: +- character_ids:当前段落涉及的角色 ID 列表,可以为空,也可以包含多个角色;必须从 characters 中选择 +- prop_ids:当前段落出现的关键道具 ID 列表(道具在画面中被看到、使用或特写时绑定),可以为空;必须从 props 中选择 +- scene_id:若可匹配到 scenes 中已有场景,必须填写正确 scene_id;无匹配时置空 +- duration:段落总时长 8-15 秒 +- description:画面描述,按【镜头1】【镜头2】…逐子镜头描述观众实际看到和听到的内容——画面(谁+具体动作+肢体细节+表情)写在前;该子镜头有台词时以「角色名说:「台词」」写在对应【镜头N】内,旁白写「旁白:内容」 +- atmosphere:氛围、光线、色调、环境感受 +- video_prompt:该段落的视频生成提示词(规则见下) + +时长规则(硬约束): +- 总量锚定:目标总时长 = 剧本字数 ÷ 500字/分钟,段落数 ≈ 目标总时长 ÷ 12秒,允许 ±20% 浮动 +- 节奏分层:过渡段(赶路/空镜/转场)8-10 秒;叙事段 10-15 秒;爆点段(特写/规则揭示/情感爆发/反转)12-15 秒且子镜头节奏放慢 +- 台词下限:段落时长 ≥ 段内台词与旁白总字数(写在 description 中的部分)÷ 4.5字/秒 + 2秒表演余量,装不下的台词拆到下一个段落 + +video_prompt 规则(硬约束): +- 按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),切镜点对齐【镜头N】结构 +- 每段先写画面(谁+动作+景别/角度),再写该段时间内的台词/旁白——台词从 description 对应【镜头N】内提取,不要创作 description 之外的新台词 +- 提到场景用 @场景名、提到角色用 @角色名,名字必须与 read_storyboard_context 返回的列表完全一致(用于挂接参考素材图片) +- 氛围、光线描述取自该段 atmosphere +- 一个段落内允许切镜(换景别/角度/对象),但不跨场景 +- 用户消息中会告知本次视频模型,按该模型的特性与时长限制调整写法;未告知时按通用视频模型写法 + +额外要求: +- 优先复用 read_storyboard_context 返回的 scene_id,不要凭空创造新场景 +- 段落角色绑定必须来自 read_storyboard_context 返回的角色列表;无角色的空镜段落可传空数组 +- 段落道具绑定必须来自 read_storyboard_context 返回的道具列表;道具被使用、特写、交接或在画面中明显可见时绑定,与剧情无关的背景物品不要绑定;没有道具出现可传空数组 +- 段落描述必须能支撑后续视频生成和导出流程 +- 若一个段落没有台词,description 中不写台词即可,但画面描述与 atmosphere 仍必须完整 +- 如果已有 existing_storyboards,仅在用户明确要求增量修改时参考;默认按当前剧本重新完整生成并保存整集分镜。`, + }, + prompt_generator: { + name: '提示词', + instructions: `你是专业的 AI 提示词工程师,负责两类提示词的创作与保存: +1. 角色/场景/道具的「最终提示词」,供生图直接使用 +2. 分镜的「视频提示词」(video_prompt),供视频生成直接使用 + +## 图片最终提示词 + +用户请求会告知要为哪些角色、场景或道具生成最终提示词(附带 character_id / scene_id / prop_id)。 + +工作流程: +1. 调用 read_characters / read_scenes / read_props 读取资产信息 +2. 按对应资产的技能规范(角色三视图 / 场景固定视角 / 道具白底单品)创作最终提示词 +3. 调用 save_character_final_prompt / save_scene_final_prompt / save_prop_final_prompt 逐个保存 + +## 视频提示词 + +用户请求会告知要为哪个分镜生成视频提示词(附带分镜 ID)。 + +工作流程: +1. 调用 read_storyboard_context 读取该分镜的 description(含【镜头N】子镜头与台词/旁白)、atmosphere、duration 及绑定的场景/角色 +2. 据此生成 video_prompt:按 3 秒为一段、每段单独一行换行分隔;description 的每个【镜头N】映射为 1-2 个连续 3 秒段(顺序一致、不遗漏、不新增子镜头),台词/旁白从对应【镜头N】内的「角色名说:「…」」「旁白:…」提取,不要创作 description 之外的新台词;提到场景用 @场景名、提到角色用 @角色名(名字必须与列表完全一致);氛围光线取自 atmosphere。一个分镜段落内允许切镜(换景别/角度/对象),段与段之间可以是不同镜头,但不跨场景;切镜点对齐分镜 description 的【镜头N】结构 +3. 生成时会自动把 @名字 替换为对应参考图片标记(如 @小明 → @图片1小明),因此名字必须精确匹配场景/角色列表,不要缩写或加额外符号 +4. 调用 update_storyboard 保存时参数只传两个键:storyboard_id 和 video_prompt。不要回传该分镜的其他任何字段(title、description、scene_id 等一律不传) + +通用规范: +- 所有提示词使用本次会话语言指令指定的目标语言输出,单段连贯描述,不要分点,不要混入无关词汇 +- 项目设定的视觉风格描述会由工具在保存图片提示词时自动注入到最终提示词的最前方,不要自行添加风格词 +- 必须实际调用保存工具,不要只在回复中给出提示词`, + }, +} + +export const validAgentTypes = Object.keys(DEFAULT_PROMPTS) + +// Agent 每一步都会重新解析模型,相同端点只打一次日志避免刷屏 +let lastLoggedTextEndpointKey = '' + +/** + * 关闭思考(thinking)模式 + * + * 背景:new-api 类中转站对 thinking 模型强制要求多轮请求回传 reasoning_content, + * 而 Agent 多轮工具调用无法回传,会被中转站 400 拒绝 + * ("The `reasoning_content` in the thinking mode must be passed back to the API")。 + * 这里在请求体注入各厂商风格的关思考参数,让模型不产出 reasoning_content。 + * + * - 默认开启;AI_DISABLE_THINKING=false 可关闭注入 + * - 官方 OpenAI / Gemini 端点跳过(官方 API 会拒绝未知参数) + * - AI_THINKING_OFF_PATCH 可传 JSON 覆盖注入的 OpenAI 风格参数(适配不同中转站) + */ +const thinkingOffEnabled = (process.env.AI_DISABLE_THINKING ?? 'true').toLowerCase() !== 'false' + +function isOfficialTextHost(baseURL: string) { + return /api\.openai\.com|generativelanguage\.googleapis\.com/.test(baseURL) +} + +function openaiThinkingOffPatch(): Record { + const fallback = { + thinking: { type: 'disabled' }, // new-api 通用 / DeepSeek + enable_thinking: false, // Qwen / 阿里系 + reasoning_effort: 'none', // OpenAI 风格枚举(Gemini 渠道映射为 budget 0) + } + const raw = process.env.AI_THINKING_OFF_PATCH + if (!raw) return fallback + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : fallback + } catch { + return fallback + } +} + +function createThinkingOffFetch(providerName: string, baseURL: string): typeof fetch | undefined { + if (!thinkingOffEnabled || isOfficialTextHost(baseURL)) return undefined + const openaiPatch = openaiThinkingOffPatch() + + return async (input: any, init?: any) => { + try { + if (init?.body && typeof init.body === 'string') { + const body = JSON.parse(init.body) + if (providerName === 'gemini' && Array.isArray(body?.contents)) { + // Gemini 原生格式。Gemini 3 系列思考参数改名 thinkingLevel(low/high), + // 旧参数 thinkingBudget 会被 400 拒绝("requires thinkingLevel, not thinkingBudget"); + // 2.x 及更早仍用 thinkingBudget: 0。模型名从 URL(/models/:)或 body 嗅探 + const url = String(typeof input === 'string' ? input : input?.url || '') + const isGemini3 = /gemini-3/i.test(url) || /gemini-3/i.test(String(body?.model || '')) + body.generationConfig = { + ...(body.generationConfig || {}), + thinkingConfig: isGemini3 + ? { thinkingLevel: 'low' } + : { thinkingBudget: 0, includeThoughts: false }, + } + init = { ...init, body: JSON.stringify(body) } + } else if (Array.isArray(body?.messages)) { + // OpenAI 兼容格式 + Object.assign(body, openaiPatch) + init = { ...init, body: JSON.stringify(body) } + } + } + } catch { /* 解析失败则原样透传 */ } + return fetch(input, init) + } +} + +/** + * 在请求体中写入配置的温度 + * + * 背景:部分模型服务端强制固定温度(如 kimi-k2 系只允许 0.6, + * 报 "invalid temperature: only 0.6 is allowed for this model"), + * 需要在文本服务配置里显式指定并随每个请求下发。 + * inner 传 thinking-off fetch 时可链式叠加两个补丁。 + */ +function createTemperatureFetch(providerName: string, temperature: number, inner?: typeof fetch): typeof fetch { + const base = inner || fetch + return async (input: any, init?: any) => { + try { + if (init?.body && typeof init.body === 'string') { + const body = JSON.parse(init.body) + if (providerName === 'gemini' && Array.isArray(body?.contents)) { + // Gemini 原生格式 + body.generationConfig = { ...(body.generationConfig || {}), temperature } + init = { ...init, body: JSON.stringify(body) } + } else if (Array.isArray(body?.messages)) { + // OpenAI 兼容格式 + body.temperature = temperature + init = { ...init, body: JSON.stringify(body) } + } + } + } catch { /* 解析失败则原样透传 */ } + return base(input, init) + } +} + +/** + * 在请求体中注入输出上限 + * + * 背景:Agent 输出可能包含大段规划文本 + 工具调用(尤其分批保存时), + * 而服务商默认 max_tokens 很小(如 DeepSeek 默认 4096/8192), + * 模型写作到一半被截断、工具调用从未生成,表现为「Agent 正常结束但什么都没保存」。 + * 这里显式抬高输出上限,给足模型完整生成工具调用的空间。 + * AI_MAX_TOKENS 可覆盖默认值(如某些中转站限制更严)。 + * + * 官方 OpenAI 端点不注入:reasoning 模型(o 系/gpt-5 系)拒绝 max_tokens + * (要求 max_completion_tokens),且官方默认输出上限足够大, + * 截断问题主要出现在中转站/DeepSeek 类端点。 + */ +const defaultMaxTokens = Number(process.env.AI_MAX_TOKENS || 16384) + +function isOfficialOpenAIHost(baseURL: string) { + return /api\.openai\.com/.test(baseURL) +} + +function createMaxTokensFetch(providerName: string, inner?: typeof fetch): typeof fetch { + const base = inner || fetch + return async (input: any, init?: any) => { + try { + if (init?.body && typeof init.body === 'string') { + const body = JSON.parse(init.body) + if (providerName === 'gemini' && Array.isArray(body?.contents)) { + // Gemini 原生格式 + body.generationConfig = { ...(body.generationConfig || {}), maxOutputTokens: defaultMaxTokens } + init = { ...init, body: JSON.stringify(body) } + } else if (Array.isArray(body?.messages)) { + // OpenAI 兼容格式 + body.max_tokens = defaultMaxTokens + init = { ...init, body: JSON.stringify(body) } + } + } + } catch { /* 解析失败则原样透传 */ } + return base(input, init) + } +} + +async function getModel(fileModel: string | undefined, modelOverride?: string, textConfigId?: number) { + // 请求可指定文本配置(含其 provider/baseUrl/apiKey),否则回退到当前启用配置 + const textConfig = (textConfigId ? await getConfigById(textConfigId) : null) || await getTextConfig() + const modelName = modelOverride || fileModel || textConfig.model + const providerName = textConfig.provider.toLowerCase() + const resolvedBaseURL = getTextProviderBaseUrl(textConfig) + const temperature = textConfig.temperature ?? null + const endpointKey = `${providerName}|${resolvedBaseURL}|${modelName}|t=${temperature ?? 'default'}` + if (endpointKey !== lastLoggedTextEndpointKey) { + lastLoggedTextEndpointKey = endpointKey + logTaskProgress('AIConfig', 'text-model-endpoint', { + provider: textConfig.provider, + baseUrl: resolvedBaseURL, + model: modelName, + ...(temperature !== null ? { temperature } : {}), + }) + } + + // 叠加请求补丁:thinking-off(非官方端点)+ 配置温度 + 输出上限(非官方 OpenAI) + const thinkingOffFetch = createThinkingOffFetch(providerName, resolvedBaseURL) + const tempFetch = temperature !== null + ? createTemperatureFetch(providerName, temperature, thinkingOffFetch) + : thinkingOffFetch + const fetchImpl = isOfficialOpenAIHost(resolvedBaseURL) + ? tempFetch + : createMaxTokensFetch(providerName, tempFetch) + + if (providerName === 'gemini') { + const googleProvider = createGoogleGenerativeAI({ + apiKey: textConfig.apiKey, + baseURL: resolvedBaseURL, + fetch: fetchImpl, + }) + return googleProvider(modelName) + } + + const provider = createOpenAI({ + baseURL: resolvedBaseURL, + apiKey: textConfig.apiKey, + fetch: fetchImpl, + } as any) + return provider.chat(modelName) +} + +const AGENT_TOOLS: Record> = { + script_rewriter: scriptTools, + extractor: extractTools, + storyboard_breaker: storyboardTools, + prompt_generator: { + ...imagePromptTools, + readStoryboardContext: storyboardTools.readStoryboardContext, + updateStoryboard: storyboardTools.updateStoryboard, + }, +} + +/** instructions 按请求解析:prompt 文件(或默认)+ 技能全文拼接 + 目标语言指令块 + * prompt/skill 文本随内容语言切换语言变体(..md / SKILL..md),缺失回退中文版 */ +function buildInstructions(type: string) { + return async ({ requestContext }: { requestContext?: RequestContext }) => { + const defaults = DEFAULT_PROMPTS[type] + const lang = getContentLanguageFromRC(requestContext) + const promptFile = await loadAgentPromptFile(type, lang) + const baseInstructions = promptFile?.instructions || defaults.instructions + const skillInstructions = await loadAgentSkills(type, lang) + const languageDirective = buildLanguageDirective(lang) + const voiceInstructions = getVoiceReferenceInstructions(type, lang) + return [baseInstructions, skillInstructions, voiceInstructions, languageDirective] + .filter(Boolean) + .join('\n\n') + } +} + +/** model 按请求解析:基础版 prompt 文件 frontmatter + RequestContext 的 modelOverride/textConfigId 覆盖 + * (model 只认基础版 prompts/.md,语言变体不参与 model 解析) */ +function buildModel(type: string) { + return async ({ requestContext }: { requestContext?: RequestContext }) => { + const promptFile = await loadBasePromptFile(type) + const modelOverride = requestContext?.get('modelOverride' as never) as string | undefined + const textConfigId = requestContext?.get('textConfigId' as never) as number | undefined + return getModel(promptFile?.model || undefined, modelOverride, textConfigId) + } +} + +/** 启动时注册的静态 Agent 表(供 Mastra 实例挂载) */ +export const agentRegistry: Record = Object.fromEntries( + validAgentTypes.map(type => [ + type, + new Agent({ + id: type, + name: DEFAULT_PROMPTS[type].name, + instructions: buildInstructions(type), + model: buildModel(type), + tools: AGENT_TOOLS[type], + workspace: skillWorkspaces[type], + skillsFormat: 'markdown', + }), + ]), +) diff --git a/huobao-source/backend/src/agents/language.ts b/huobao-source/backend/src/agents/language.ts new file mode 100644 index 0000000..9720a99 --- /dev/null +++ b/huobao-source/backend/src/agents/language.ts @@ -0,0 +1,32 @@ +/** + * AI 内容语言指令块 — 按 RequestContext 中的目标语言生成,追加到 Agent instructions 末尾 + * + * 设计要点: + * - zh / 未设置时返回空串:默认行为与历史完全一致,零 token 开销 + * - 指令用英文书写(多语言模型遵循度最好),目标语言用原生名标注 + * - 必须自带「最高优先级」声明:桌面版已安装用户的 workspace 模板不会被覆盖 + * (cpSync force:false),旧 SKILL.md 中的「只输出中文」约束只能靠这里的显式覆盖压制 + */ + +const LANGUAGE_NATIVE_NAMES: Record = { + en: 'English', + ja: '日本語 (Japanese)', + ko: '한국어 (Korean)', +} + +export function buildLanguageDirective(lang?: string | null): string { + if (!lang || lang === 'zh') return '' + const native = LANGUAGE_NATIVE_NAMES[lang] || lang + return [ + '## Output Language (HIGHEST PRIORITY)', + '', + `ALL user-facing content you produce (scripts, dialogue, extracted fields, storyboard descriptions, atmosphere, image prompts, video prompts, asset names for NEW assets) MUST be written in ${native}.`, + '', + 'This instruction has the highest priority and OVERRIDES any conflicting language requirement anywhere else in these instructions or skills — including requirements such as "output must be pure Chinese / 只输出中文". Ignore those.', + '', + 'Exceptions:', + '- When referencing EXISTING assets with @mentions (e.g. in video prompts), the name after @ must EXACTLY match the asset name as it appears in the provided asset lists — do NOT translate or rewrite existing asset names.', + '- Character voice references (e.g. #Alex) are literal tokens: copy the complete voice_reference value exactly, including the existing name, regardless of output language. Do not add a suffix.', + '- The visual style prefix of image prompts is injected automatically by the system (in English). Do not translate, rewrite, or duplicate it.', + ].join('\n') +} diff --git a/huobao-source/backend/src/agents/prompts.ts b/huobao-source/backend/src/agents/prompts.ts new file mode 100644 index 0000000..ec158dc --- /dev/null +++ b/huobao-source/backend/src/agents/prompts.ts @@ -0,0 +1,75 @@ +/** + * Agent prompt 文件加载 — workspace/prompts/.md + * 文件格式(参照 SKILL.md 惯例): + * --- + * name: 分镜拆解 + * model: "" # 可选,覆盖文本模型;空 = 用 AI 服务默认 + * --- + * <系统提示词正文> + * 文件即唯一事实来源;文件缺失时由调用方回退到代码默认值。 + */ +import { skillsManagerWorkspace } from './skills.js' + +export interface AgentPromptFile { + name: string + model: string + instructions: string +} + +const fsm = () => skillsManagerWorkspace.filesystem! +/** prompt 文件路径;lang 为 en/ja/ko 时返回语言变体(..md),zh/空为基础版 */ +export const promptFilePath = (agentType: string, lang?: string | null) => + `prompts/${agentType}${lang && lang !== 'zh' ? `.${lang}` : ''}.md` + +/** 解析 prompt 文件(frontmatter 仅支持 name/model 两个标量字段,无需 yaml 依赖) */ +export function parsePromptFile(raw: string): AgentPromptFile { + const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/) + let name = '' + let model = '' + let body = raw + if (fmMatch) { + const fm = fmMatch[1] + const get = (key: string) => { + const m = fm.match(new RegExp(`^${key}:\\s*"?([^"\\n]*)"?\\s*$`, 'm')) + return (m?.[1] || '').trim() + } + name = get('name') + model = get('model') + body = raw.slice(fmMatch[0].length) + } + return { name, model, instructions: body.trim() } +} + +/** 序列化为 prompt 文件内容(供保存接口使用) */ +export function serializePromptFile(file: AgentPromptFile): string { + return `---\nname: ${file.name}\nmodel: "${file.model}"\n---\n\n${file.instructions.trim()}\n` +} + +/** 读取指定路径的 prompt 文件;文件不存在或解析失败返回 null */ +async function readPromptFile(path: string): Promise { + try { + if (!await fsm().exists(path)) return null + const raw = String(await fsm().readFile(path, { encoding: 'utf-8' })) + const parsed = parsePromptFile(raw) + return parsed.instructions ? parsed : null + } catch { + return null + } +} + +/** 读取基础版(中文)prompt 文件 — model 字段永远只从基础版解析,避免多语言文件漂移 */ +export function loadBasePromptFile(agentType: string): Promise { + return readPromptFile(promptFilePath(agentType)) +} + +/** + * 读取 Agent 的 prompt 文件;lang 非 zh 时优先语言变体(..md),缺失回退基础版。 + * 变体的 model 字段无效:返回时 model 始终取基础版的值。 + */ +export async function loadAgentPromptFile(agentType: string, lang?: string | null): Promise { + const base = await loadBasePromptFile(agentType) + if (!lang || lang === 'zh') return base + const localized = await readPromptFile(promptFilePath(agentType, lang)) + if (!localized) return base + return { ...localized, model: base?.model || '' } +} diff --git a/huobao-source/backend/src/agents/skills.ts b/huobao-source/backend/src/agents/skills.ts new file mode 100644 index 0000000..7008177 --- /dev/null +++ b/huobao-source/backend/src/agents/skills.ts @@ -0,0 +1,166 @@ +/** + * Agent 工作区(Workspace)— Mastra 原生能力 + * 每个 Agent 一个 Workspace: + * - filesystem:jail 到 backend/workspace/ 目录,Agent 获得文件读写工具 + * - skills:从 workspace/skills/ 下注册各 Agent 专属的 SKILL.md + * 注入 instructions 时仍拼接技能全文(原生注入只有元数据,全文注入保证行为一致) + * + * 注意:filesystem instructions 刻意覆写为无 "workspace" 字样的中文描述—— + * 默认文案含绝对路径(路径里有 workspace 目录名),Gemini 系低思考档位下 + * 偶发把其脑补成 Google Workspace 然后以"组织政策"为由拒绝调用工具。 + */ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { Workspace, LocalFilesystem } from '@mastra/core/workspace' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +// 桌面版由 Electron 主进程注入 WORKSPACE_PATH(userData 下的可写副本);dev 锚定 backend/workspace +const WORKSPACE_DIR = process.env.WORKSPACE_PATH + ? path.resolve(process.env.WORKSPACE_PATH) + : path.resolve(__dirname, '../../workspace') +const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills') + +// 覆写默认 filesystem instructions(默认文案带绝对路径,暴露 workspace 字样) +const FILESYSTEM_INSTRUCTIONS = + '本地文件目录:用于读写技能定义、提示词等项目文件。相对路径均以此目录为根解析;文件访问仅限此目录之内。' +const localFilesystem = () => new LocalFilesystem({ basePath: WORKSPACE_DIR, instructions: FILESYSTEM_INSTRUCTIONS }) + +// 启动时确保工作目录存在(Agent 文件读写的 jail 根) +// 桌面版打包后模块可能仍从只读位置加载,失败不阻断启动(路由层会给出明确报错) +try { + fs.mkdirSync(SKILLS_DIR, { recursive: true }) +} catch (err) { + console.warn(`[skills] 工作目录创建失败(只读环境?): ${(err as Error).message}`) +} + +/** 每个 Agent 注册的 skill 目录(相对 workspace/skills/,含子规范目录;目录名需符合 Agent Skills 规范:小写+连字符) */ +const AGENT_SKILL_MAP: Record = { + script_rewriter: ['script-rewriter'], + extractor: ['extractor'], + storyboard_breaker: ['storyboard-breaker'], + prompt_generator: [ + 'prompt-generator/character-prompt', + 'prompt-generator/scene-prompt', + 'prompt-generator/prop-prompt', + 'prompt-generator/video-prompt', + ], +} + +/** 每个 Agent 的 Workspace(filesystem 工作目录 + 原生技能注册) + * skills 用动态解析器按目录前缀匹配:设置页新建的子技能无需重启即可被发现 */ +export const skillWorkspaces: Record = Object.fromEntries( + Object.entries(AGENT_SKILL_MAP).map(([agentType, prefixes]) => [ + agentType, + new Workspace({ + id: `workspace-${agentType}`, + name: `${agentType} workspace`, + filesystem: localFilesystem(), + skills: () => scanSkillPaths().filter(p => + prefixes.some(prefix => p === `skills/${prefix}` || p.startsWith(`skills/${prefix}/`))), + }), + ]), +) + +/** 递归扫描 workspace/skills/ 下所有含 SKILL.md 的目录(相对 workspace 根的路径) */ +function scanSkillPaths(): string[] { + const found: string[] = [] + const walk = (dir: string, prefix: string) => { + if (!fs.existsSync(dir)) return + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + const full = path.join(dir, entry.name) + if (fs.existsSync(path.join(full, 'SKILL.md'))) found.push(`skills/${rel}`) + walk(full, rel) + } + } + walk(SKILLS_DIR, '') + return found +} + +/** + * 技能管理 Workspace(设置页 CRUD 用) + * skills 用动态解析器,新建/删除技能目录后无需重启即可发现 + */ +export const skillsManagerWorkspace = new Workspace({ + id: 'workspace-skills-manager', + name: 'skills manager', + filesystem: localFilesystem(), + skills: () => scanSkillPaths(), +}) + +function formatSkillSection(skillId: string, content: string): string { + return [`## Skill: ${skillId}`, content].join('\n') +} + +/** 剥离 SKILL.md 风格文件的 frontmatter,返回正文(供语言变体直读使用) */ +function stripFrontmatter(raw: string): string { + const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) + return (m ? raw.slice(m[0].length) : raw).trim() +} + +/** 读取技能的语言变体正文(SKILL..md,直读绕缓存永远最新);不存在返回 null */ +async function readLocalizedSkill(relPath: string, lang: string): Promise { + const fsm = skillsManagerWorkspace.filesystem! + const p = `skills/${relPath}/SKILL.${lang}.md` + try { + if (!await fsm.exists(p)) return null + const body = stripFrontmatter(String(await fsm.readFile(p, { encoding: 'utf-8' }))) + return body || null + } catch { + return null + } +} + +/** 读取 Agent 专属技能全文(经 workspace.skills API,保持原注入格式) + * AGENT_SKILL_MAP 的目录按前缀匹配:目录自身及其子目录下所有 SKILL.md 都会注入, + * 因此设置页新建的子技能(如 storyboard-breaker/xxx)无需改代码即可生效。 + * lang 非 zh 时逐技能优先 SKILL..md,缺失回退基础版(中文) */ +export async function loadAgentSkills(agentType: string, lang?: string | null): Promise { + const workspace = skillWorkspaces[agentType] + const prefixes = AGENT_SKILL_MAP[agentType] || [] + if (!workspace || !prefixes.length) return '' + + const allPaths = scanSkillPaths().map(p => p.replace(/^skills\//, '')) + const relPaths = allPaths.filter(p => + prefixes.some(prefix => p === prefix || p.startsWith(prefix + '/'))) + + const useLocalized = Boolean(lang && lang !== 'zh') + const contents: string[] = [] + for (const relPath of relPaths) { + let body: string | null | undefined + if (useLocalized) body = await readLocalizedSkill(relPath, lang!) + if (!body) { + const skill = await workspace.skills?.get(`skills/${relPath}`) + body = skill?.instructions?.trim() + } + if (body) contents.push(formatSkillSection(relPath, body)) + } + + if (!contents.length) return '' + + return [ + '以下是该 Agent 专属的项目技能规范(SKILL.md)。', + '不同 Agent 会加载不同 skill;你只需要遵守当前注入的这些技能。', + '你必须在不违背当前工具边界的前提下优先遵守这些规范;若与用户明确要求冲突,以用户要求为准。', + '', + contents.join('\n\n'), + ].join('\n') +} + +/** + * 强制重新扫描全部 Agent 的技能(SKILL.md 编辑后调用) + * maybeRefresh 负责感知目录增删(动态 resolver 路径变化),refresh 负责内容更新 + * (目录 mtime 不会因文件内容编辑而更新,单靠 maybeRefresh 的 staleness 检查不可靠) + */ +export async function refreshSkillWorkspaces(): Promise { + await Promise.all( + [...Object.values(skillWorkspaces), skillsManagerWorkspace] + .map(async workspace => { + await workspace.skills?.maybeRefresh() + await workspace.skills?.refresh() + }), + ) +} diff --git a/huobao-source/backend/src/agents/tools/extract-tools.ts b/huobao-source/backend/src/agents/tools/extract-tools.ts new file mode 100644 index 0000000..0b11fec --- /dev/null +++ b/huobao-source/backend/src/agents/tools/extract-tools.ts @@ -0,0 +1,426 @@ +/** + * 角色/场景提取 Agent 工具 + * 模块级单例 — episodeId + dramaId 通过 RequestContext 按请求注入 + * + * 单 Agent 一步流程: + * 1. 读取剧本内容 + * 2. 读取项目中已存在的角色/场景(用于去重) + * 3. 提取角色/场景并智能去重后直接保存 + */ +import { createTool } from '@mastra/core/tools' +import type { ToolExecutionContext } from '@mastra/core/tools' +import { z } from 'zod' +import { db, getInsertId, schema } from '../../db/index.js' +import { eq, and } from 'drizzle-orm' +import { now } from '../../utils/response.js' +import { logTaskProgress, logTaskSuccess } from '../../utils/task-logger.js' +import { getDramaId, getEpisodeId } from '../context.js' + +// ─── 关联辅助 ──────────────────────────────────────────────── +async function linkCharToEpisode(episodeId: number, characterId: number) { + const ts = now() + const existing = await db.select().from(schema.episodeCharacters) + .where(and(eq(schema.episodeCharacters.episodeId, episodeId), eq(schema.episodeCharacters.characterId, characterId))) + + if (!existing.length) { + await db.insert(schema.episodeCharacters).values({ episodeId, characterId, createdAt: ts }) + } +} + +async function linkSceneToEpisode(episodeId: number, sceneId: number) { + const ts = now() + const existing = await db.select().from(schema.episodeScenes) + .where(and(eq(schema.episodeScenes.episodeId, episodeId), eq(schema.episodeScenes.sceneId, sceneId))) + + if (!existing.length) { + await db.insert(schema.episodeScenes).values({ episodeId, sceneId, createdAt: ts }) + } +} + +async function linkPropToEpisode(episodeId: number, propId: number) { + const ts = now() + const existing = await db.select().from(schema.episodeProps) + .where(and(eq(schema.episodeProps.episodeId, episodeId), eq(schema.episodeProps.propId, propId))) + + if (!existing.length) { + await db.insert(schema.episodeProps).values({ episodeId, propId, createdAt: ts }) + } +} + +// ─── 名字归一化(近名去重) ─────────────────────────────────── +// 提取时同一角色/道具可能出现不同书写形式(如「林小雨」与「林小雨(主角)」、 +// 「林小雨(女主)」、「林小雨 」)。归一化后做精确比较,命中即复用已有,不重复创建。 +function normalizeName(name: string): string { + return (name || '') + .replace(/[((][^(())]*[))]/g, '') // 去掉成对的中/英文括号内容(角色定位/别名) + .replace(/[((].*$/, '') // 兜底:未闭合括号从首个括号截断 + .replace(/[\s ]+/g, '') // 去空白与全角空格 + .toLowerCase() + .trim() +} + +// 场景地点只做空白归一化(不删括号:如「火车站(候车厅)」与「火车站(站台)」是不同场景) +function normalizeLocation(loc: string): string { + return (loc || '').replace(/[\s ]+/g, '').toLowerCase().trim() +} + +type ToolContext = ToolExecutionContext | undefined + +function requireIds(context: ToolContext): { episodeId: number; dramaId: number } | { error: string } { + const episodeId = getEpisodeId(context?.requestContext) + const dramaId = getDramaId(context?.requestContext) + if (!episodeId || !dramaId) return { error: 'Missing episodeId/dramaId in request context' } + return { episodeId, dramaId } +} + +// 1. 读取剧本内容 +const readScriptForExtraction = createTool({ + id: 'read_script_for_extraction', + description: 'Read the formatted screenplay for character/scene extraction.', + inputSchema: z.object({}), + execute: async (_input, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const [ep] = await db.select().from(schema.episodes) + .where(eq(schema.episodes.id, episodeId)) + if (!ep) return { error: 'Episode not found' } + const content = ep.scriptContent || ep.content + if (!content) return { error: 'Episode has no script content' } + logTaskSuccess('ExtractTool', 'read-script', { episodeId, dramaId, scriptLength: content.length }) + return { script: content } + }, +}) + +// 2. 读取项目中已存在的角色(用于去重判断) +const readExistingCharacters = createTool({ + id: 'read_existing_characters', + description: 'Read all characters already existing in this drama project (for deduplication).', + inputSchema: z.object({}), + execute: async (_input, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const links = await db.select().from(schema.episodeCharacters) + .where(eq(schema.episodeCharacters.episodeId, episodeId)) + const linkedIds = new Set(links.map(link => link.characterId)) + const chars = (await db.select().from(schema.characters) + .where(eq(schema.characters.dramaId, dramaId))) + .filter(c => !c.deletedAt) + const visibleChars = chars.map(c => ({ + id: c.id, + name: c.name, + role: c.role || '', + appearance: c.appearance || '', + styling: c.styling || '', + normalized_name: normalizeName(c.name), // 用于判断近名复用:括号定位/别名视为同名 + })) + const payload = { + count: visibleChars.length, + characters: visibleChars, + current_episode_characters: visibleChars.filter(c => linkedIds.has(c.id)), + } + logTaskSuccess('ExtractTool', 'read-characters', { + episodeId, + dramaId, + projectCharacters: payload.count, + episodeCharacters: payload.current_episode_characters.length, + }) + return payload + }, +}) + +// 3. 读取项目中已存在的场景(用于去重判断) +const readExistingScenes = createTool({ + id: 'read_existing_scenes', + description: 'Read all scenes already existing in this drama project (for deduplication).', + inputSchema: z.object({}), + execute: async (_input, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const links = await db.select().from(schema.episodeScenes) + .where(eq(schema.episodeScenes.episodeId, episodeId)) + const linkedIds = new Set(links.map(link => link.sceneId)) + const scenes = (await db.select().from(schema.scenes) + .where(eq(schema.scenes.dramaId, dramaId))) + .filter(s => !s.deletedAt) + const visibleScenes = scenes.map(s => ({ + id: s.id, + location: s.location, + time: s.time || '', + prompt: s.prompt || '', + lighting: s.lighting || '', + normalized_location: normalizeLocation(s.location), // 用于判断近名复用(仅空白/大小写) + })) + const payload = { + count: visibleScenes.length, + scenes: visibleScenes, + current_episode_scenes: visibleScenes.filter(s => linkedIds.has(s.id)), + } + logTaskSuccess('ExtractTool', 'read-scenes', { + episodeId, + dramaId, + projectScenes: payload.count, + episodeScenes: payload.current_episode_scenes.length, + }) + return payload + }, +}) + +// 4. 智能保存角色(按名字去重,与现有数据合并) +const saveDedupCharacters = createTool({ + id: 'save_dedup_characters', + description: 'Save extracted characters with deduplication. Existing characters (same name) are merged/updated; new ones are created. All are linked to the current episode.', + inputSchema: z.object({ + characters: z.array(z.object({ + name: z.string(), + role: z.string().optional(), + description: z.string().optional(), + appearance: z.string().optional(), + styling: z.string().optional(), + })), + }), + execute: async ({ characters }, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const ts = now() + const results = { created: 0, merged: 0 } + logTaskProgress('ExtractTool', 'save-characters-begin', { + episodeId, + dramaId, + names: characters.map(char => char.name).join(','), + }) + + for (const char of characters) { + const charsInProject = (await db.select().from(schema.characters) + .where(eq(schema.characters.dramaId, dramaId))) + .filter(c => !c.deletedAt) + const exact = charsInProject.find(c => c.name === char.name) + const normName = normalizeName(char.name) + const existing = exact || (normName ? charsInProject.find(c => c.name && normalizeName(c.name) === normName) : undefined) + const matchedViaNorm = !!existing && existing !== exact + + if (existing) { + // 归一化命中:括号定位/别名等近名视作同一角色复用,避免重复创建 + if (matchedViaNorm) logTaskProgress('ExtractTool', 'save-characters-reuse', { episodeId, dramaId, extracted: char.name, reused: existing.name }) + // 已存在:合并信息,保留 ID + await db.update(schema.characters).set({ + role: char.role || existing.role, + description: char.description || existing.description, + appearance: char.appearance || existing.appearance, + styling: char.styling || char.description || existing.styling, + updatedAt: ts, + }).where(eq(schema.characters.id, existing.id)) + await linkCharToEpisode(episodeId, existing.id) + results.merged++ + } else { + // 新增角色 + const res = await db.insert(schema.characters).values({ + name: char.name, + role: char.role || '', + description: char.description || '', + appearance: char.appearance || '', + styling: char.styling || char.description || '', + dramaId, + createdAt: ts, + updatedAt: ts, + }) + const charId = getInsertId(res) + await linkCharToEpisode(episodeId, charId) + results.created++ + } + } + + const payload = { + message: `角色保存完成:新增 ${results.created},合并更新 ${results.merged}`, + ...results, + } + logTaskSuccess('ExtractTool', 'save-characters-complete', { episodeId, ...results }) + return payload + }, +}) + +// 5. 智能保存场景(按地点+时间段去重,与现有数据合并) +const saveDedupScenes = createTool({ + id: 'save_dedup_scenes', + description: 'Save extracted scenes with deduplication. Existing scenes (same location+time) are reused; new ones are created. All are linked to the current episode.', + inputSchema: z.object({ + scenes: z.array(z.object({ + location: z.string(), + time: z.string().optional(), + prompt: z.string().optional(), + description: z.string().optional(), + lighting: z.string().optional(), + })), + }), + execute: async ({ scenes }, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const ts = now() + const results = { created: 0, reused: 0 } + logTaskProgress('ExtractTool', 'save-scenes-begin', { + episodeId, + dramaId, + scenes: scenes.map(scene => `${scene.location}@${scene.time || ''}`).join(','), + }) + + for (const scene of scenes) { + // 按地点+时间段精确匹配;地点仅做空白/大小写归一化(不删括号,避免误合并) + const scenesInProject = (await db.select().from(schema.scenes) + .where(eq(schema.scenes.dramaId, dramaId))) + .filter(s => !s.deletedAt) + const normLocation = normalizeLocation(scene.location) + const existing = scenesInProject.find(s => s.location === scene.location && s.time === (scene.time || '')) + || (normLocation ? scenesInProject.find(s => normalizeLocation(s.location) === normLocation && s.time === (scene.time || '')) : undefined) + + if (existing) { + // 已存在完全匹配的场景:关联并补齐描述/光影 + await db.update(schema.scenes).set({ + prompt: scene.prompt || scene.description || existing.prompt, + lighting: scene.lighting || existing.lighting, + updatedAt: ts, + }).where(eq(schema.scenes.id, existing.id)) + await linkSceneToEpisode(episodeId, existing.id) + results.reused++ + } else { + const res = await db.insert(schema.scenes).values({ + dramaId, + location: scene.location, + time: scene.time || '', + prompt: scene.prompt || scene.description || scene.location, + lighting: scene.lighting || '', + createdAt: ts, + updatedAt: ts, + }) + const sceneId = getInsertId(res) + await linkSceneToEpisode(episodeId, sceneId) + results.created++ + } + } + + const payload = { + message: `场景保存完成:新增 ${results.created},复用已有 ${results.reused}`, + ...results, + } + logTaskSuccess('ExtractTool', 'save-scenes-complete', { episodeId, ...results }) + return payload + }, +}) + +// 6. 读取项目中已存在的道具(用于去重判断) +const readExistingProps = createTool({ + id: 'read_existing_props', + description: 'Read all props already existing in this drama project (for deduplication).', + inputSchema: z.object({}), + execute: async (_input, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const links = await db.select().from(schema.episodeProps) + .where(eq(schema.episodeProps.episodeId, episodeId)) + const linkedIds = new Set(links.map(link => link.propId)) + const props = (await db.select().from(schema.props) + .where(eq(schema.props.dramaId, dramaId))) + .filter(p => !p.deletedAt) + const visibleProps = props.map(p => ({ + id: p.id, + name: p.name, + type: p.type || '', + description: p.description || '', + normalized_name: normalizeName(p.name), // 用于判断近名复用:括号定位/别名视为同名 + })) + const payload = { + count: visibleProps.length, + props: visibleProps, + current_episode_props: visibleProps.filter(p => linkedIds.has(p.id)), + } + logTaskSuccess('ExtractTool', 'read-props', { + episodeId, + dramaId, + projectProps: payload.count, + episodeProps: payload.current_episode_props.length, + }) + return payload + }, +}) + +// 7. 智能保存道具(按名字去重,与现有数据合并) +const saveDedupProps = createTool({ + id: 'save_dedup_props', + description: 'Save extracted props with deduplication. Existing props (same name) are merged/updated; new ones are created. All are linked to the current episode.', + inputSchema: z.object({ + props: z.array(z.object({ + name: z.string(), + type: z.string().optional(), + description: z.string().optional(), + })), + }), + execute: async ({ props }, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const ts = now() + const results = { created: 0, merged: 0 } + logTaskProgress('ExtractTool', 'save-props-begin', { + episodeId, + dramaId, + names: props.map(prop => prop.name).join(','), + }) + + for (const prop of props) { + const propsInProject = (await db.select().from(schema.props) + .where(eq(schema.props.dramaId, dramaId))) + .filter(p => !p.deletedAt) + const exact = propsInProject.find(p => p.name === prop.name) + const normName = normalizeName(prop.name) + const existing = exact || (normName ? propsInProject.find(p => p.name && normalizeName(p.name) === normName) : undefined) + const matchedViaNorm = !!existing && existing !== exact + + if (existing) { + // 归一化命中:括号定位/别名等近名视作同一道具复用 + if (matchedViaNorm) logTaskProgress('ExtractTool', 'save-props-reuse', { episodeId, dramaId, extracted: prop.name, reused: existing.name }) + // 已存在:合并信息,保留 ID;物品外貌变更后旧的最终提示词失效 + await db.update(schema.props).set({ + type: prop.type || existing.type, + description: prop.description || existing.description, + finalPrompt: prop.description ? null : existing.finalPrompt, + updatedAt: ts, + }).where(eq(schema.props.id, existing.id)) + await linkPropToEpisode(episodeId, existing.id) + results.merged++ + } else { + const res = await db.insert(schema.props).values({ + name: prop.name, + type: prop.type || '', + description: prop.description || '', + dramaId, + createdAt: ts, + updatedAt: ts, + }) + const propId = getInsertId(res) + await linkPropToEpisode(episodeId, propId) + results.created++ + } + } + + const payload = { + message: `道具保存完成:新增 ${results.created},合并更新 ${results.merged}`, + ...results, + } + logTaskSuccess('ExtractTool', 'save-props-complete', { episodeId, ...results }) + return payload + }, +}) + +export const extractTools = { + readScriptForExtraction, + readExistingCharacters, + readExistingScenes, + readExistingProps, + saveDedupCharacters, + saveDedupScenes, + saveDedupProps, +} diff --git a/huobao-source/backend/src/agents/tools/image-prompt-tools.ts b/huobao-source/backend/src/agents/tools/image-prompt-tools.ts new file mode 100644 index 0000000..017acf3 --- /dev/null +++ b/huobao-source/backend/src/agents/tools/image-prompt-tools.ts @@ -0,0 +1,188 @@ +/** + * 提示词生成 Agent 工具 + * 模块级单例 — dramaId 通过 RequestContext 按请求注入 + * + * Agent 负责创作最终提示词: + * 1. 角色 → 三视图(character turnaround:正面/侧面/背面) + * 2. 场景 → 固定视角 + 前景/中景/后景分层构图 + * 3. 道具 → 白底单品静物(single product shot on pure white background) + * 工具负责读取资产信息与保存 Agent 产出的最终提示词(保存时注入项目视觉风格) + */ +import { createTool } from '@mastra/core/tools' +import { z } from 'zod' +import { db, schema } from '../../db/index.js' +import { eq } from 'drizzle-orm' +import { now } from '../../utils/response.js' +import { getDramaStylePrompt } from '../../services/style-preset.js' +import { getDramaId } from '../context.js' + +// ─── 角色提示词 ─────────────────────────────────────── + +const readCharacters = createTool({ + id: 'read_characters', + description: '读取当前剧集中的所有角色信息,用于生成角色三视图最终提示词。', + inputSchema: z.object({}), + execute: async (_input, context) => { + const dramaId = getDramaId(context?.requestContext) + if (!dramaId) return { error: 'Missing dramaId in request context' } + const chars = (await db.select().from(schema.characters) + .where(eq(schema.characters.dramaId, dramaId))) + .filter(c => !c.deletedAt) + return { + characters: chars.map(c => ({ + id: c.id, + name: c.name, + role: c.role || '', + description: c.description || '', + appearance: c.appearance || '', + styling: c.styling || '', + final_prompt: c.finalPrompt || '', + })), + } + }, +}) + +const saveCharacterFinalPrompt = createTool({ + id: 'save_character_final_prompt', + description: '保存为角色创作的三视图最终提示词。项目视觉风格会由工具自动拼接,prompt 参数中不要包含风格词。', + inputSchema: z.object({ + character_id: z.number(), + prompt: z.string().describe('角色三视图最终提示词(使用会话语言指令指定的目标语言,不含风格词)'), + }), + execute: async ({ character_id, prompt }, context) => { + const dramaId = getDramaId(context?.requestContext) + if (!dramaId) return { error: 'Missing dramaId in request context' } + const [c] = await db.select().from(schema.characters) + .where(eq(schema.characters.id, character_id)) + if (!c) return { error: 'Character not found' } + + const stylePrompt = await getDramaStylePrompt(dramaId) + const finalPrompt = stylePrompt ? `${stylePrompt}, ${prompt}` : prompt + await db.update(schema.characters) + .set({ finalPrompt, updatedAt: now() }) + .where(eq(schema.characters.id, character_id)) + + return { + character_id: c.id, + character_name: c.name, + saved: true, + final_prompt: finalPrompt, + } + }, +}) + +// ─── 场景提示词 ─────────────────────────────────────── + +const readScenes = createTool({ + id: 'read_scenes', + description: '读取当前剧集中的所有场景信息,用于生成场景固定视角最终提示词。', + inputSchema: z.object({}), + execute: async (_input, context) => { + const dramaId = getDramaId(context?.requestContext) + if (!dramaId) return { error: 'Missing dramaId in request context' } + const scenes = (await db.select().from(schema.scenes) + .where(eq(schema.scenes.dramaId, dramaId))) + .filter(s => !s.deletedAt) + return { + scenes: scenes.map(s => ({ + id: s.id, + location: s.location, + time: s.time || '', + prompt: s.prompt || '', + lighting: s.lighting || '', + final_prompt: s.finalPrompt || '', + })), + } + }, +}) + +const saveSceneFinalPrompt = createTool({ + id: 'save_scene_final_prompt', + description: '保存为场景创作的固定视角(前景/中景/后景)最终提示词。项目视觉风格会由工具自动拼接,prompt 参数中不要包含风格词。', + inputSchema: z.object({ + scene_id: z.number(), + prompt: z.string().describe('场景固定视角最终提示词(使用会话语言指令指定的目标语言,不含风格词)'), + }), + execute: async ({ scene_id, prompt }, context) => { + const dramaId = getDramaId(context?.requestContext) + if (!dramaId) return { error: 'Missing dramaId in request context' } + const [s] = await db.select().from(schema.scenes) + .where(eq(schema.scenes.id, scene_id)) + if (!s) return { error: 'Scene not found' } + + const stylePrompt = await getDramaStylePrompt(dramaId) + const finalPrompt = stylePrompt ? `${stylePrompt}, ${prompt}` : prompt + await db.update(schema.scenes) + .set({ finalPrompt, updatedAt: now() }) + .where(eq(schema.scenes.id, scene_id)) + + return { + scene_id: s.id, + location: s.location, + saved: true, + final_prompt: finalPrompt, + } + }, +}) + +// ─── 道具提示词 ─────────────────────────────────────── + +const readProps = createTool({ + id: 'read_props', + description: '读取当前剧集项目中的所有道具信息,用于生成道具白底单品最终提示词。', + inputSchema: z.object({}), + execute: async (_input, context) => { + const dramaId = getDramaId(context?.requestContext) + if (!dramaId) return { error: 'Missing dramaId in request context' } + const props = (await db.select().from(schema.props) + .where(eq(schema.props.dramaId, dramaId))) + .filter(p => !p.deletedAt) + return { + props: props.map(p => ({ + id: p.id, + name: p.name, + type: p.type || '', + description: p.description || '', + final_prompt: p.finalPrompt || '', + })), + } + }, +}) + +const savePropFinalPrompt = createTool({ + id: 'save_prop_final_prompt', + description: '保存为道具创作的白底单品最终提示词。项目视觉风格会由工具自动拼接,prompt 参数中不要包含风格词。', + inputSchema: z.object({ + prop_id: z.number(), + prompt: z.string().describe('道具白底单品最终提示词(使用会话语言指令指定的目标语言,不含风格词)'), + }), + execute: async ({ prop_id, prompt }, context) => { + const dramaId = getDramaId(context?.requestContext) + if (!dramaId) return { error: 'Missing dramaId in request context' } + const [p] = await db.select().from(schema.props) + .where(eq(schema.props.id, prop_id)) + if (!p) return { error: 'Prop not found' } + + const stylePrompt = await getDramaStylePrompt(dramaId) + const finalPrompt = stylePrompt ? `${stylePrompt}, ${prompt}` : prompt + await db.update(schema.props) + .set({ finalPrompt, updatedAt: now() }) + .where(eq(schema.props.id, prop_id)) + + return { + prop_id: p.id, + prop_name: p.name, + saved: true, + final_prompt: finalPrompt, + } + }, +}) + +export const imagePromptTools = { + readCharacters, + saveCharacterFinalPrompt, + readScenes, + saveSceneFinalPrompt, + readProps, + savePropFinalPrompt, +} diff --git a/huobao-source/backend/src/agents/tools/script-tools.ts b/huobao-source/backend/src/agents/tools/script-tools.ts new file mode 100644 index 0000000..2aa4d84 --- /dev/null +++ b/huobao-source/backend/src/agents/tools/script-tools.ts @@ -0,0 +1,78 @@ +/** + * 剧本改写 Agent 工具 + * 模块级单例 — episodeId 通过 RequestContext 按请求注入 + */ +import { createTool } from '@mastra/core/tools' +import { z } from 'zod' +import { db, schema } from '../../db/index.js' +import { eq } from 'drizzle-orm' +import { now } from '../../utils/response.js' +import { getEpisodeId } from '../context.js' + +const readEpisodeScript = createTool({ + id: 'read_episode_script', + description: 'Read the script content of the current episode.', + inputSchema: z.object({}), + execute: async (_input, context) => { + const episodeId = getEpisodeId(context?.requestContext) + if (!episodeId) return { error: 'Missing episodeId in request context' } + const [ep] = await db.select().from(schema.episodes) + .where(eq(schema.episodes.id, episodeId)) + if (!ep) return { error: `Episode not found (id=${episodeId})` } + const content = ep.content || ep.scriptContent + if (!content) return { error: `Episode has no content (id=${episodeId})` } + return { content, word_count: content.length, episode_id: episodeId } + }, +}) + +const rewriteToScreenplay = createTool({ + id: 'rewrite_to_screenplay', + description: 'Read the original content for AI rewriting. Returns the source text with formatting instructions.', + inputSchema: z.object({ + instructions: z.string().optional().describe('Additional rewrite instructions'), + }), + execute: async ({ instructions }, context) => { + const episodeId = getEpisodeId(context?.requestContext) + if (!episodeId) return { error: 'Missing episodeId in request context' } + const [ep] = await db.select().from(schema.episodes) + .where(eq(schema.episodes.id, episodeId)) + if (!ep) return { error: `Episode not found` } + const source = ep.content || ep.scriptContent + if (!source) return { error: `Episode has no content to rewrite` } + + return { + source_content: source, + instruction: `请将以下内容改写为格式化剧本。 + +格式规范: +- 场景头:## S编号 | 内景/外景 · 地点 | 时间段 +- 动作描写:自然段落,不包含镜头语言 +- 对白:角色名:(状态/表情)台词内容 +- 每个场景 30-60 秒内容 + +${instructions || ''} + +【原始内容】 +${source}`, + } + }, +}) + +const saveScript = createTool({ + id: 'save_script', + description: 'Save the rewritten screenplay content to the current episode.', + inputSchema: z.object({ + content: z.string().describe('The formatted screenplay content to save'), + }), + execute: async ({ content }, context) => { + const episodeId = getEpisodeId(context?.requestContext) + if (!episodeId) return { error: 'Missing episodeId in request context' } + await db.update(schema.episodes) + .set({ scriptContent: content, updatedAt: now() }) + .where(eq(schema.episodes.id, episodeId)) + + return { message: `Script saved`, word_count: content.length } + }, +}) + +export const scriptTools = { readEpisodeScript, rewriteToScreenplay, saveScript } diff --git a/huobao-source/backend/src/agents/tools/storyboard-tools.ts b/huobao-source/backend/src/agents/tools/storyboard-tools.ts new file mode 100644 index 0000000..d0ed29a --- /dev/null +++ b/huobao-source/backend/src/agents/tools/storyboard-tools.ts @@ -0,0 +1,458 @@ +/** + * 分镜拆解 Agent 工具 + * 模块级单例 — episodeId + dramaId 通过 RequestContext 按请求注入 + */ +import { createTool } from '@mastra/core/tools' +import type { ToolExecutionContext } from '@mastra/core/tools' +import { z } from 'zod' +import { db, getInsertId, schema } from '../../db/index.js' +import { and, eq, isNull } from 'drizzle-orm' +import { now } from '../../utils/response.js' +import { logTaskProgress, logTaskSuccess } from '../../utils/task-logger.js' +import { getDramaId, getEpisodeId } from '../context.js' +import { resolveVoiceMentions } from '../../services/character-voice-references.js' + +async function validateVideoPromptVoices(prompt: string | undefined, dramaId: number, characterIds: number[] = []) { + if (!prompt?.includes('#')) return + const characters = await db.select({ id: schema.characters.id, name: schema.characters.name, voiceAudioUrl: schema.characters.voiceAudioUrl }).from(schema.characters) + .where(and(eq(schema.characters.dramaId, dramaId), isNull(schema.characters.deletedAt))) + // Validate the model's references, but persist the original editable #tokens. + resolveVoiceMentions(prompt, characters.map(c => ({ ...c, bound: characterIds.includes(c.id) }))) +} + +async function syncStoryboardCharacters(storyboardId: number, characterIds: number[]) { + await db.delete(schema.storyboardCharacters) + .where(eq(schema.storyboardCharacters.storyboardId, storyboardId)) + + + const uniqueIds = [...new Set(characterIds.filter(Boolean))] + if (!uniqueIds.length) return + + for (const characterId of uniqueIds) { + await db.insert(schema.storyboardCharacters).values({ + storyboardId, + characterId, + }) + } +} + +async function syncStoryboardProps(storyboardId: number, propIds: number[]) { + await db.delete(schema.storyboardProps) + .where(eq(schema.storyboardProps.storyboardId, storyboardId)) + + const uniqueIds = [...new Set(propIds.filter(Boolean))] + if (!uniqueIds.length) return + + for (const propId of uniqueIds) { + await db.insert(schema.storyboardProps).values({ + storyboardId, + propId, + }) + } +} + +async function getEpisodeSceneIds(episodeId: number) { + const links = await db.select().from(schema.episodeScenes) + .where(eq(schema.episodeScenes.episodeId, episodeId)) + return new Set(links.map(link => link.sceneId)) +} + +async function getEpisodeCharacterIds(episodeId: number) { + const links = await db.select().from(schema.episodeCharacters) + .where(eq(schema.episodeCharacters.episodeId, episodeId)) + return new Set(links.map(link => link.characterId)) +} + +async function getEpisodePropIds(episodeId: number) { + const links = await db.select().from(schema.episodeProps) + .where(eq(schema.episodeProps.episodeId, episodeId)) + return new Set(links.map(link => link.propId)) +} + +async function validateStoryboardBindings(episodeId: number, dramaId: number, sceneId: number | null | undefined, characterIds: number[] | undefined, propIds?: number[] | undefined) { + const episodeSceneIds = await getEpisodeSceneIds(episodeId) + const episodeCharacterIds = await getEpisodeCharacterIds(episodeId) + const episodePropIds = await getEpisodePropIds(episodeId) + + // 场景/角色/道具属于本剧但尚未关联到当前集时,自动补关联(拆分时即完成绑定) + if (sceneId != null && !episodeSceneIds.has(sceneId)) { + const [scene] = await db.select().from(schema.scenes).where(eq(schema.scenes.id, sceneId)) + if (!scene || scene.dramaId !== dramaId || scene.deletedAt) { + throw new Error(`scene_id ${sceneId} 不属于当前项目`) + } + await db.insert(schema.episodeScenes).values({ episodeId, sceneId, createdAt: now() }) + } + + const uniqueCharacterIds = [...new Set((characterIds || []).filter(Boolean))] + for (const characterId of uniqueCharacterIds) { + if (episodeCharacterIds.has(characterId)) continue + const [character] = await db.select().from(schema.characters).where(eq(schema.characters.id, characterId)) + if (!character || character.dramaId !== dramaId || character.deletedAt) { + throw new Error(`character_id ${characterId} 不属于当前项目`) + } + await db.insert(schema.episodeCharacters).values({ episodeId, characterId, createdAt: now() }) + } + + const uniquePropIds = [...new Set((propIds || []).filter(Boolean))] + for (const propId of uniquePropIds) { + if (episodePropIds.has(propId)) continue + const [prop] = await db.select().from(schema.props).where(eq(schema.props.id, propId)) + if (!prop || prop.dramaId !== dramaId || prop.deletedAt) { + throw new Error(`prop_id ${propId} 不属于当前项目`) + } + await db.insert(schema.episodeProps).values({ episodeId, propId, createdAt: now() }) + } +} + +type ToolContext = ToolExecutionContext | undefined + +function requireIds(context: ToolContext): { episodeId: number; dramaId: number } | { error: string } { + const episodeId = getEpisodeId(context?.requestContext) + const dramaId = getDramaId(context?.requestContext) + if (!episodeId || !dramaId) return { error: 'Missing episodeId/dramaId in request context' } + return { episodeId, dramaId } +} + +const readStoryboardContext = createTool({ + id: 'read_storyboard_context', + description: 'Read the screenplay, characters, scenes, and props for storyboard breakdown.', + inputSchema: z.object({}), + execute: async (_input, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const [ep] = await db.select().from(schema.episodes) + .where(eq(schema.episodes.id, episodeId)) + if (!ep) return { error: 'Episode not found' } + const script = ep.scriptContent || ep.content + if (!script) return { error: 'Episode has no script' } + + const charLinks = await db.select().from(schema.episodeCharacters) + .where(eq(schema.episodeCharacters.episodeId, episodeId)) + const sceneLinks = await db.select().from(schema.episodeScenes) + .where(eq(schema.episodeScenes.episodeId, episodeId)) + const propLinks = await db.select().from(schema.episodeProps) + .where(eq(schema.episodeProps.episodeId, episodeId)) + + const linkedCharacterIds = new Set(charLinks.map(link => link.characterId)) + const linkedSceneIds = new Set(sceneLinks.map(link => link.sceneId)) + const linkedPropIds = new Set(propLinks.map(link => link.propId)) + + const chars = await db.select().from(schema.characters) + .where(eq(schema.characters.dramaId, dramaId)) + const scns = await db.select().from(schema.scenes) + .where(eq(schema.scenes.dramaId, dramaId)) + const prps = await db.select().from(schema.props) + .where(eq(schema.props.dramaId, dramaId)) + const existingStoryboards = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + + const characters = chars + .filter(c => !c.deletedAt) + .filter(c => !linkedCharacterIds.size || linkedCharacterIds.has(c.id)) + .map(c => ({ + id: c.id, + name: c.name, + role: c.role || '', + description: c.description || '', + appearance: c.appearance || '', + styling: c.styling || '', + image_url: c.imageUrl || '', + reference_images: c.referenceImages || '', + has_voice: Boolean(c.voiceAudioUrl?.trim()), + voice_reference: c.voiceAudioUrl?.trim() ? `#${c.name}` : null, + })) + + const scenes = scns + .filter(s => !s.deletedAt) + .filter(s => !linkedSceneIds.size || linkedSceneIds.has(s.id)) + .map(s => ({ + id: s.id, + location: s.location, + time: s.time, + prompt: s.prompt || '', + lighting: s.lighting || '', + image_url: s.imageUrl || '', + storyboard_count: s.storyboardCount || 0, + })) + + const props = prps + .filter(p => !p.deletedAt) + .filter(p => !linkedPropIds.size || linkedPropIds.has(p.id)) + .map(p => ({ + id: p.id, + name: p.name, + type: p.type || '', + description: p.description || '', + image_url: p.imageUrl || '', + })) + + const existingStoryboardPayload = await Promise.all(existingStoryboards + .filter(sb => !sb.deletedAt) + .map(async (sb) => { + const links = await db.select().from(schema.storyboardCharacters) + .where(eq(schema.storyboardCharacters.storyboardId, sb.id)) + const sbPropLinks = await db.select().from(schema.storyboardProps) + .where(eq(schema.storyboardProps.storyboardId, sb.id)) + return { + id: sb.id, + shot_number: sb.storyboardNumber, + title: sb.title || '', + scene_id: sb.sceneId, + character_ids: links.map(link => link.characterId), + available_voice_references: characters + .filter(c => c.has_voice && links.some(link => link.characterId === c.id)) + .map(c => ({ character_id: c.id, name: c.name, voice_reference: c.voice_reference })), + prop_ids: sbPropLinks.map(link => link.propId), + shot_type: sb.shotType || '', + duration: sb.duration || 0, + description: sb.description || '', + atmosphere: sb.atmosphere || '', + video_prompt: sb.videoPrompt || '', + } + })) + + const payload = { + episode: { + id: ep.id, + title: ep.title, + episode_number: ep.episodeNumber, + description: ep.description || '', + }, + script, + characters, + scenes, + props, + existing_storyboards: existingStoryboardPayload, + } + logTaskSuccess('StoryboardTool', 'read-context', { + episodeId, + dramaId, + characters: characters.length, + scenes: scenes.length, + props: props.length, + existingStoryboards: payload.existing_storyboards.length, + scriptLength: script.length, + }) + return payload + }, +}) + +const storyboardFields = z.object({ + shot_number: z.number(), + title: z.string().optional(), + shot_type: z.string().optional(), + angle: z.string().optional(), + movement: z.string().optional(), + location: z.string().optional(), + time: z.string().optional(), + description: z.string().optional(), + result: z.string().optional(), + atmosphere: z.string().optional(), + image_prompt: z.string().optional(), + video_prompt: z.string().optional(), + bgm_prompt: z.string().optional(), + sound_effect: z.string().optional(), + duration: z.number().optional(), + scene_id: z.number().nullable().optional(), + character_ids: z.array(z.number()).optional(), + prop_ids: z.array(z.number()).optional(), +}) + +const saveStoryboards = createTool({ + id: 'save_storyboards', + description: 'Save storyboards for this episode. Call in batches of at most 8 storyboards: the first batch must set replace_existing: true (clears all old storyboards for the episode, then writes), every following batch omits replace_existing (appends). Rows are upserted by shot_number, so overlapping batches and retries never create duplicates.', + inputSchema: z.object({ + replace_existing: z.boolean().optional(), + storyboards: z.array(storyboardFields), + }), + execute: async ({ storyboards, replace_existing }, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const ts = now() + logTaskProgress('StoryboardTool', 'save-begin', { + episodeId, + dramaId, + replaceExisting: replace_existing === true, + count: storyboards.length, + shotNumbers: storyboards.map(sb => sb.shot_number).join(','), + }) + // Check all voice references before replace_existing can remove the previous storyboards. + for (const sb of storyboards) { + await validateVideoPromptVoices(sb.video_prompt, dramaId, sb.character_ids) + } + if (replace_existing === true) { + const existingStoryboardRows = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + for (const storyboardId of existingStoryboardRows.map(sb => sb.id)) { + await db.delete(schema.storyboardCharacters) + .where(eq(schema.storyboardCharacters.storyboardId, storyboardId)) + await db.delete(schema.storyboardProps) + .where(eq(schema.storyboardProps.storyboardId, storyboardId)) + } + await db.delete(schema.storyboards).where(eq(schema.storyboards.episodeId, episodeId)) + } + + // shot_number → id 索引(含本次调用内新增的行),保证按分镜号幂等 upsert + const existingRows = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + const shotToId = new Map( + existingRows.filter(sb => !sb.deletedAt).map(sb => [sb.storyboardNumber, sb.id]), + ) + + for (const sb of storyboards) { + await validateStoryboardBindings(episodeId, dramaId, sb.scene_id, sb.character_ids, sb.prop_ids) + const existingId = shotToId.get(sb.shot_number) + if (existingId !== undefined) { + await db.update(schema.storyboards).set({ + title: sb.title, shotType: sb.shot_type, + angle: sb.angle, movement: sb.movement, + location: sb.location, time: sb.time, + description: sb.description, result: sb.result, + atmosphere: sb.atmosphere, imagePrompt: sb.image_prompt, + videoPrompt: sb.video_prompt, bgmPrompt: sb.bgm_prompt, + soundEffect: sb.sound_effect, + sceneId: sb.scene_id, duration: sb.duration || 10, + updatedAt: ts, + }).where(eq(schema.storyboards.id, existingId)) + await syncStoryboardCharacters(existingId, sb.character_ids || []) + await syncStoryboardProps(existingId, sb.prop_ids || []) + } else { + const res = await db.insert(schema.storyboards).values({ + episodeId, + storyboardNumber: sb.shot_number, + title: sb.title, shotType: sb.shot_type, + angle: sb.angle, movement: sb.movement, + location: sb.location, time: sb.time, + description: sb.description, result: sb.result, + atmosphere: sb.atmosphere, imagePrompt: sb.image_prompt, + videoPrompt: sb.video_prompt, bgmPrompt: sb.bgm_prompt, + soundEffect: sb.sound_effect, + sceneId: sb.scene_id, duration: sb.duration || 10, + createdAt: ts, updatedAt: ts, + }) + const newId = getInsertId(res) + shotToId.set(sb.shot_number, newId) + await syncStoryboardCharacters(newId, sb.character_ids || []) + await syncStoryboardProps(newId, sb.prop_ids || []) + } + } + + // 整集时长 = 当前全部存活分镜时长之和(分批保存时不能再按单批累加) + const allRows = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + const totalDuration = allRows + .filter(sb => !sb.deletedAt) + .reduce((sum, sb) => sum + (sb.duration || 0), 0) + + await db.update(schema.episodes) + .set({ duration: Math.ceil(totalDuration / 60), updatedAt: ts }) + .where(eq(schema.episodes.id, episodeId)) + + logTaskSuccess('StoryboardTool', 'save-complete', { + episodeId, + count: storyboards.length, + totalDuration, + }) + return { message: `Saved ${storyboards.length} storyboards`, count: storyboards.length, total_duration: totalDuration } + }, +}) + +const updateStoryboard = createTool({ + id: 'update_storyboard', + description: 'Update a specific storyboard shot.', + inputSchema: z.object({ + storyboard_id: z.number(), + title: z.string().optional(), + shot_type: z.string().optional(), + angle: z.string().optional(), + movement: z.string().optional(), + location: z.string().optional(), + time: z.string().optional(), + result: z.string().optional(), + atmosphere: z.string().optional(), + image_prompt: z.string().optional(), + video_prompt: z.string().optional(), + bgm_prompt: z.string().optional(), + sound_effect: z.string().optional(), + description: z.string().optional(), + scene_id: z.number().nullable().optional(), + character_ids: z.array(z.number()).optional(), + prop_ids: z.array(z.number()).optional(), + duration: z.number().optional(), + }), + execute: async ({ storyboard_id, ...fields }, context) => { + const ids = requireIds(context) + if ('error' in ids) return ids + const { episodeId, dramaId } = ids + const [storyboard] = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, storyboard_id)) + if (!storyboard || storyboard.episodeId !== episodeId || storyboard.deletedAt) return { error: `Storyboard ${storyboard_id} not found in this episode` } + + // 过滤模型回传的垃圾值:视频提示词 Agent 常把整行字段回传, + // 拿不准的字符串字段写成 "null"/"undefined",直接覆盖会毁掉已有内容 + for (const key of Object.keys(fields) as (keyof typeof fields)[]) { + const v = fields[key] + if (typeof v === 'string' && (v === 'null' || v === 'undefined' || v === 'NULL' || v === 'Null')) { + delete fields[key] + } + } + + logTaskProgress('StoryboardTool', 'update-begin', { + episodeId, + storyboardId: storyboard_id, + fields: Object.keys(fields), + }) + + const currentCharacterIds = 'character_ids' in fields + ? fields.character_ids + : (await db.select().from(schema.storyboardCharacters) + .where(eq(schema.storyboardCharacters.storyboardId, storyboard_id))) + .map(link => link.characterId) + + const currentPropIds = 'prop_ids' in fields + ? fields.prop_ids + : (await db.select().from(schema.storyboardProps) + .where(eq(schema.storyboardProps.storyboardId, storyboard_id))) + .map(link => link.propId) + + await validateVideoPromptVoices(fields.video_prompt, dramaId, currentCharacterIds) + await validateStoryboardBindings( + episodeId, + dramaId, + 'scene_id' in fields ? fields.scene_id : storyboard.sceneId, + currentCharacterIds, + currentPropIds, + ) + + const updates: Record = { updatedAt: now() } + if ('title' in fields) updates.title = fields.title + if ('shot_type' in fields) updates.shotType = fields.shot_type + if ('angle' in fields) updates.angle = fields.angle + if ('movement' in fields) updates.movement = fields.movement + if ('location' in fields) updates.location = fields.location + if ('time' in fields) updates.time = fields.time + if ('result' in fields) updates.result = fields.result + if ('atmosphere' in fields) updates.atmosphere = fields.atmosphere + if ('image_prompt' in fields) updates.imagePrompt = fields.image_prompt + if ('video_prompt' in fields) updates.videoPrompt = fields.video_prompt + if ('bgm_prompt' in fields) updates.bgmPrompt = fields.bgm_prompt + if ('sound_effect' in fields) updates.soundEffect = fields.sound_effect + if ('description' in fields) updates.description = fields.description + if ('scene_id' in fields) updates.sceneId = fields.scene_id + if ('duration' in fields) updates.duration = fields.duration + await db.update(schema.storyboards).set(updates).where(eq(schema.storyboards.id, storyboard_id)) + if ('character_ids' in fields) await syncStoryboardCharacters(storyboard_id, fields.character_ids || []) + if ('prop_ids' in fields) await syncStoryboardProps(storyboard_id, fields.prop_ids || []) + logTaskSuccess('StoryboardTool', 'update-complete', { + episodeId, + storyboardId: storyboard_id, + updatedFields: Object.keys(updates), + characterIds: 'character_ids' in fields ? (fields.character_ids || []).join(',') : undefined, + propIds: 'prop_ids' in fields ? (fields.prop_ids || []).join(',') : undefined, + }) + return { message: `Storyboard ${storyboard_id} updated` } + }, +}) + +export const storyboardTools = { readStoryboardContext, saveStoryboards, updateStoryboard } diff --git a/huobao-source/backend/src/agents/voice-reference-instructions.ts b/huobao-source/backend/src/agents/voice-reference-instructions.ts new file mode 100644 index 0000000..54be122 --- /dev/null +++ b/huobao-source/backend/src/agents/voice-reference-instructions.ts @@ -0,0 +1,46 @@ +/** Shared by first-time breakdown and single/batch video prompt generation. + * Inject at runtime so existing/custom desktop prompt files also see the voice contract. + */ +const instructions: Record = { + zh: `## 视频提示词中的角色音色引用 +- read_storyboard_context 的角色提供 has_voice 与 voice_reference;已有分镜提供 available_voice_references。只有 has_voice 为 true 的角色才有可用音色。 +- 为某个分镜生成或重写 video_prompt 时,只使用该分镜 available_voice_references 中的音色;首次拆分时,只使用本段 character_ids 对应角色的 voice_reference。 +- 先从 description 的台词确定说话角色。画面动作使用 @角色名;有台词且有可用音色时,在对应对白的说话人位置直接使用 #角色名,紧接语气、发声动作与台词。例如:0-3秒:@林小雨抬头,#林小雨轻声说:「早上好。」 +- 音色引用必须与对白写在同一个时间段内,不新增独立的「音色:」信息头。每次对白都明确标出自己的 #说话人;多人对话逐句对应,不能把所有音色集中列在开头。 +- 直接复制 voice_reference 的完整值(如 #林小雨),禁止改名、缩写、翻译或追加“音色”后缀。@角色名引用形象,#角色名引用音色,两者分别放在画面动作与发声位置。 +- 没有上传音色的角色、未绑定角色、已移除音色和不说话的角色,不添加 # 引用。旁白只有明确对应某个已绑定、已有音色的角色时才可引用,不借用其他角色的音色。 +- 音频仅用于参考音色,台词内容与说话时机遵循 description;不要为了使用音色新增台词、人物或镜头。 +- 保存前检查 @图片引用、#音色引用和说话角色的对应关系。video_prompt 中保留原始 #角色名 标签,不写音频 URL、Base64、音频1 等上游编号;编号由视频生成接口自动映射。`, + en: `## Character voice references in video_prompt +- read_storyboard_context provides has_voice and voice_reference for each character, plus available_voice_references for each existing storyboard. +- For an existing shot, use only its available_voice_references. During initial breakdown, use only voice_reference values belonging to this segment's character_ids. +- Identify speakers from description. Use @CharacterName for visual actions and #CharacterName directly as the speaker before delivery and dialogue, e.g. "0-3s: @Alex looks up; #Alex softly says: 'Good morning.'" +- Keep voice references inline with each utterance in its time segment. Do not add a separate voice header or collect all voices at the start. Mark the correct #speaker for every utterance, including repeated speakers and multi-person dialogue. +- Copy voice_reference exactly (e.g. #Alex). Do not translate, abbreviate or add a suffix. @CharacterName is the image; #CharacterName is the voice. +- Do not add # references for unbound characters, missing/removed uploads, or silent characters. Narration may use a voice only when its speaker is explicitly a bound character with that voice; never borrow another character's voice. +- Reference audio supplies timbre only: preserve the original dialogue and timing from description, and do not add dialogue, characters or shots just to use a voice. +- Before saving, check speaker/image/voice bindings. Save the original #CharacterName tokens, never audio URLs, Base64 or provider audio numbers; video generation resolves the indices.`, + ja: `## video_prompt のキャラクター音声参照 +- read_storyboard_context は各キャラクターの has_voice と voice_reference、既存ショットの available_voice_references を返す。 +- 既存ショットではその available_voice_references のみ使用する。初回分割では、その段落の character_ids に含まれるキャラクターの voice_reference のみ使用する。 +- description のセリフから話者を確認する。画面の動作は @名前、音声がある話者の発声は #名前 を直接使う。例:「0-3秒:@太郎が顔を上げ、#太郎が優しく『おはよう』と言う。」 +- 音声参照は各時間帯のセリフ内に記載する。独立した音声ヘッダーを追加せず、冒頭に音声をまとめない。発話ごとに正しい #話者 を指定する。 +- voice_reference(例:#太郎)を完全一致でコピーし、翻訳・省略・接尾辞の追加をしない。@名前 は画像、#名前 は音声の参照。 +- 未紐付け・未アップロード・削除済み音声・無言の人物には # 参照を付けない。ナレーションは話者が音声付きの紐付け済みキャラクターと明示されている場合のみ参照し、別人の声を流用しない。 +- 音声は声質の参照のみ。description のセリフと発話タイミングを守り、音声を使うためのセリフ・人物・カットを追加しない。 +- 保存前に話者・画像・音声の対応を確認する。video_prompt には元の #名前 を保存し、音声URL、Base64、音声番号を書かない。番号への変換は動画生成側が行う。`, + ko: `## video_prompt의 캐릭터 음색 참조 +- read_storyboard_context는 캐릭터별 has_voice와 voice_reference, 기존 샷별 available_voice_references를 제공한다. +- 기존 샷은 해당 available_voice_references만 사용한다. 최초 분할은 해당 구간 character_ids에 속하는 캐릭터의 voice_reference만 사용한다. +- description의 대사에서 화자를 확인한다. 화면 동작은 @이름, 음색이 있는 화자의 발화는 #이름을 직접 사용한다. 예: "0-3초: @민수가 고개를 들고, #민수가 부드럽게 '안녕'이라고 말한다." +- 각 시간 구간의 대사 안에 음색 참조를 넣는다. 별도의 음색 헤더를 추가하거나 처음에 음색을 모아 쓰지 않는다. 대사마다 정확한 #화자를 표시한다. +- voice_reference(예: #민수)를 그대로 복사한다. 번역, 축약, 접미사 추가를 하지 않는다. @이름은 이미지, #이름은 음색 참조다. +- 미연결 캐릭터, 미업로드/삭제된 음색, 말하지 않는 캐릭터에는 # 참조를 추가하지 않는다. 내레이션은 화자가 음색이 있는 연결 캐릭터로 명시된 경우에만 참조하며 다른 캐릭터의 음색을 빌리지 않는다. +- 오디오는 음색만 참고한다. description의 대사와 발화 시점을 유지하고 음색 사용을 위해 대사, 인물, 샷을 추가하지 않는다. +- 저장 전 화자/이미지/음색 연결을 확인한다. video_prompt에는 원래 #이름 토큰을 저장한다. 오디오 URL, Base64, 공급자 오디오 번호는 쓰지 않는다. 번호 변환은 영상 생성 단계에서 수행한다.`, +} + +export function getVoiceReferenceInstructions(agentType: string, language?: string | null): string { + if (agentType !== 'prompt_generator' && agentType !== 'storyboard_breaker') return '' + return instructions[language || 'zh'] || instructions.zh +} diff --git a/huobao-source/backend/src/db/index.ts b/huobao-source/backend/src/db/index.ts new file mode 100644 index 0000000..f047edf --- /dev/null +++ b/huobao-source/backend/src/db/index.ts @@ -0,0 +1,48 @@ +import 'dotenv/config' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import * as schema from './schema.js' +import { initSqliteSchema } from './sqlite-schema.js' +import { maybeAutoImportMysql } from './mysql-import.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +// src/db → 上三级为仓库根(与 utils/paths.ts 的推断层级一致) +const repoRoot = path.resolve(__dirname, '../../..') + +// 桌面版由 Electron 主进程注入 SQLITE_PATH(userData 下);dev 默认仓库根 data/ +// 注意:勿沿用旧文件名 huobao_drama.db —— 那是早期 SQLite 时代的遗留库,表名重叠但列不同 +export const dbPath = process.env.SQLITE_PATH || path.join(repoRoot, 'data', 'huobao.sqlite3') +fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + +const sqlite = new Database(dbPath) + +// WAL:生成任务轮询与页面读并发时不互相阻塞;busy_timeout 兜底写锁竞争 +sqlite.pragma('journal_mode = WAL') +sqlite.pragma('busy_timeout = 5000') +sqlite.pragma('synchronous = NORMAL') + +/** 启动建表(DDL 幂等重放 + 种子补缺)。SQLite 无连接就绪问题,无需重试 */ +export function initDb() { + initSqliteSchema(sqlite) +} + +initDb() + +// MySQL 老用户一次性自动迁移:仅在显式配置 MySQL + 空库 + 无标记时触发(详见 mysql-import.ts 头注释) +await maybeAutoImportMysql(sqlite, dbPath) + +/** better-sqlite3 的 lastInsertRowid 可能是 bigint,统一转 number */ +export function getInsertId(result: unknown) { + const res = result as { lastInsertRowid?: number | bigint } | undefined + if (res?.lastInsertRowid === undefined || res.lastInsertRowid === null) { + throw new Error('SQLite insert did not return lastInsertRowid') + } + return Number(res.lastInsertRowid) +} + +export const db = drizzle(sqlite, { schema }) +export { schema } +export type DB = typeof db diff --git a/huobao-source/backend/src/db/mysql-import.ts b/huobao-source/backend/src/db/mysql-import.ts new file mode 100644 index 0000000..5003b9c --- /dev/null +++ b/huobao-source/backend/src/db/mysql-import.ts @@ -0,0 +1,175 @@ +/** + * MySQL → SQLite 数据导入(启动自动迁移 + CLI 脚本共用核心) + * + * 自动迁移触发条件(全部满足): + * 1. 未设 MYSQL_AUTO_IMPORT=false + * 2. 显式配置了 MySQL(DATABASE_URL 或 MYSQL_HOST;纯默认值不触发,避免桌面/新装用户误连) + * 3. SQLite 业务表为空(style_presets 种子不计) + * 4. 无完成标记文件 .mysql-imported(导入成功后写入;MySQL 不可达时不写,下次启动重试) + * + * 导入为两段式:先从 MySQL 全量读出到内存,再在单个 SQLite 事务内写入, + * 任一步失败整体回滚,不留半迁移状态。 + */ +import fs from 'fs' +import mysql from 'mysql2/promise' +import type { Database as SqliteDB } from 'better-sqlite3' + +/** 依赖序:被引用表先写(SQLite 未开外键时仅为可读性约定,保持导入顺序清晰) */ +export const MIGRATE_TABLES = [ + 'dramas', + 'characters', + 'props', + 'scenes', + 'episodes', + 'storyboards', + 'episode_characters', + 'episode_scenes', + 'episode_props', + 'storyboard_characters', + 'storyboard_props', + 'ai_service_configs', + 'ai_service_providers', + 'style_presets', + 'sys_task', + 'video_merges', + 'assets', +] + +/** MySQL 行 → SQLite 可绑定值(boolean→0/1,undefined→null) */ +export function sanitize(v: unknown): string | number | bigint | null { + if (v === undefined || v === null) return null + if (typeof v === 'boolean') return v ? 1 : 0 + if (v instanceof Date) return v.toISOString() + if (typeof v === 'number' || typeof v === 'string' || typeof v === 'bigint') return v + return String(v) +} + +/** + * 仅在显式配置 MySQL 时返回连接串(DATABASE_URL 或 MYSQL_HOST)。 + * 纯默认值不触发,避免桌面/新装用户误连本机无关 MySQL。 + */ +export function mysqlUrlFromEnv(): string | null { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL + if (!process.env.MYSQL_HOST) return null + const host = process.env.MYSQL_HOST + const port = process.env.MYSQL_PORT || '3306' + const user = encodeURIComponent(process.env.MYSQL_USER || 'huobao') + const password = encodeURIComponent(process.env.MYSQL_PASSWORD || 'huobao') + const database = process.env.MYSQL_DATABASE || 'huobao_drama' + return `mysql://${user}:${password}@${host}:${port}/${database}` +} + +/** SQLite 业务表是否为空(style_presets 启动种子不计) */ +export function sqliteBusinessRowCount(sqlite: SqliteDB): number { + return MIGRATE_TABLES + .filter(t => t !== 'style_presets') + .reduce((sum, t) => { + const { n } = sqlite.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get() as { n: number } + return sum + n + }, 0) +} + +/** + * 两段式导入:先全量读出,再单事务写入;行数不一致抛错(事务随之回滚)。 + */ +export async function importMysqlData( + pool: mysql.Pool, + sqlite: SqliteDB, + log: (msg: string) => void = console.log, +): Promise<{ totalSrc: number; totalDst: number }> { + // 第一段:读出 + const dump: Record[]> = {} + for (const table of MIGRATE_TABLES) { + const [rows] = await pool.query(`SELECT * FROM \`${table}\``) + dump[table] = rows as Record[] + } + + // 第二段:单事务写入 + let totalSrc = 0 + let totalDst = 0 + const mismatch: string[] = [] + + sqlite.transaction(() => { + for (const table of MIGRATE_TABLES) { + const data = dump[table] + const columns = data.length ? Object.keys(data[0]) : null + + // 导入前清空目标表:style_presets 的初始化种子会撞 UNIQUE(value);重复执行保持幂等 + if (data.length) { + sqlite.prepare(`DELETE FROM "${table}"`).run() + } + + if (columns) { + const colList = columns.map(c => `"${c}"`).join(', ') + const placeholders = columns.map(() => '?').join(', ') + const stmt = sqlite.prepare(`INSERT INTO "${table}" (${colList}) VALUES (${placeholders})`) + for (const row of data) { + stmt.run(...columns.map(c => sanitize(row[c]))) + } + } + + const dst = (sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as { n: number }).n + totalSrc += data.length + totalDst += dst + log(`${data.length === dst ? '✓' : '✗'} ${table}: mysql=${data.length} sqlite=${dst}`) + if (data.length !== dst) mismatch.push(table) + } + })() + + if (mismatch.length) { + throw new Error(`行数不一致: ${mismatch.join(', ')}`) + } + return { totalSrc, totalDst } +} + +/** + * 启动时自动迁移(条件见文件头注释)。永不抛出:失败仅告警并回滚,不阻塞服务启动。 + */ +export async function maybeAutoImportMysql(sqlite: SqliteDB, dbPath: string): Promise { + const tag = '[mysql-import]' + if ((process.env.MYSQL_AUTO_IMPORT || 'true').toLowerCase() === 'false') return + + const url = mysqlUrlFromEnv() + if (!url) return + + const marker = `${dbPath}.mysql-imported` + if (fs.existsSync(marker)) return + + if (sqliteBusinessRowCount(sqlite) > 0) return // 已有业务数据,静默跳过 + + let pool: mysql.Pool | null = null + try { + pool = mysql.createPool({ uri: url, connectionLimit: 4, charset: 'utf8mb4', connectTimeout: 3000 }) + await pool.query('SELECT 1') + } catch (err) { + console.warn(`${tag} 已配置 MySQL 但连接失败(${(err as Error).message}),本次跳过自动迁移,下次启动重试`) + await pool?.end().catch(() => {}) + return + } + + try { + // MySQL 侧无业务数据 → 写标记,避免每次启动探测 + const [rows] = await pool.query('SELECT (SELECT COUNT(*) FROM `dramas`) + (SELECT COUNT(*) FROM `ai_service_configs`) AS n') + const mysqlRows = Number((rows as { n: number }[])[0]?.n || 0) + if (mysqlRows === 0) { + fs.writeFileSync(marker, `mysql source empty at ${new Date().toISOString()}\n`) + return + } + + console.log(`${tag} 检测到 MySQL 旧数据(${mysqlRows} 行关键表记录),开始自动迁移到 SQLite…`) + if (fs.existsSync(dbPath)) { + const backup = `${dbPath}.bak-${Date.now()}` + fs.copyFileSync(dbPath, backup) + console.log(`${tag} 已备份现有库 → ${backup}`) + } + + const { totalSrc, totalDst } = await importMysqlData(pool, sqlite, msg => console.log(`${tag} ${msg}`)) + fs.writeFileSync(marker, `imported ${totalSrc} rows at ${new Date().toISOString()}\n`) + console.log(`${tag} 迁移完成 ✓(mysql=${totalSrc} sqlite=${totalDst})`) + console.log(`${tag} 提示:若旧部署的 data/static 媒体文件不在本机,请手动拷贝,否则历史素材将无法访问`) + } catch (err) { + console.error(`${tag} 自动迁移失败(已回滚,不影响本次启动,下次启动将重试):`, (err as Error).message) + } finally { + await pool.end().catch(() => {}) + } +} diff --git a/huobao-source/backend/src/db/schema.ts b/huobao-source/backend/src/db/schema.ts new file mode 100644 index 0000000..ef61246 --- /dev/null +++ b/huobao-source/backend/src/db/schema.ts @@ -0,0 +1,298 @@ +/** + * Drizzle schema - SQLite column mappings. + * 自 MySQL 迁移:varchar(x)→text(SQLite 不校验长度)、int→integer、 + * boolean→integer boolean mode、时间戳仍为 text 存 ISO 字符串,表/列名不变。 + */ +import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core' + +export const dramas = sqliteTable('dramas', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + description: text('description'), + genre: text('genre'), + style: text('style').default('3d'), + aspectRatio: text('aspect_ratio').default('16:9'), + totalEpisodes: integer('total_episodes').default(1), + totalDuration: integer('total_duration').default(0), + status: text('status').notNull().default('draft'), + thumbnail: text('thumbnail'), + tags: text('tags'), + metadata: text('metadata'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +export const episodes = sqliteTable('episodes', { + id: integer('id').primaryKey({ autoIncrement: true }), + dramaId: integer('drama_id').notNull(), + episodeNumber: integer('episode_number').notNull(), + title: text('title').notNull(), + content: text('content'), + scriptContent: text('script_content'), + description: text('description'), + duration: integer('duration').default(0), + status: text('status').default('draft'), + videoUrl: text('video_url'), + thumbnail: text('thumbnail'), + imageConfigId: integer('image_config_id'), + videoConfigId: integer('video_config_id'), + resolution: text('resolution').default('720p'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +export const characters = sqliteTable('characters', { + id: integer('id').primaryKey({ autoIncrement: true }), + dramaId: integer('drama_id').notNull(), + name: text('name').notNull(), + role: text('role'), + description: text('description'), + appearance: text('appearance'), + styling: text('styling'), + finalPrompt: text('final_prompt'), + personality: text('personality'), + imageUrl: text('image_url'), + voiceAudioUrl: text('voice_audio_url'), + voiceAudioName: text('voice_audio_name'), + referenceImages: text('reference_images'), + seedValue: text('seed_value'), + sortOrder: integer('sort_order'), + localPath: text('local_path'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +// Episode-Character many-to-many +export const episodeCharacters = sqliteTable('episode_characters', { + id: integer('id').primaryKey({ autoIncrement: true }), + episodeId: integer('episode_id').notNull(), + characterId: integer('character_id').notNull(), + createdAt: text('created_at').notNull(), +}) + +// Episode-Scene many-to-many +export const episodeScenes = sqliteTable('episode_scenes', { + id: integer('id').primaryKey({ autoIncrement: true }), + episodeId: integer('episode_id').notNull(), + sceneId: integer('scene_id').notNull(), + createdAt: text('created_at').notNull(), +}) + +// Episode-Prop many-to-many +export const episodeProps = sqliteTable('episode_props', { + id: integer('id').primaryKey({ autoIncrement: true }), + episodeId: integer('episode_id').notNull(), + propId: integer('prop_id').notNull(), + createdAt: text('created_at').notNull(), +}) + +export const scenes = sqliteTable('scenes', { + id: integer('id').primaryKey({ autoIncrement: true }), + dramaId: integer('drama_id').notNull(), + episodeId: integer('episode_id'), + location: text('location').notNull(), + time: text('time').notNull(), + prompt: text('prompt').notNull(), + lighting: text('lighting'), + finalPrompt: text('final_prompt'), + storyboardCount: integer('storyboard_count').default(1), + imageUrl: text('image_url'), + referenceImages: text('reference_images'), + status: text('status').default('pending'), + localPath: text('local_path'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +export const storyboards = sqliteTable('storyboards', { + id: integer('id').primaryKey({ autoIncrement: true }), + episodeId: integer('episode_id').notNull(), + sceneId: integer('scene_id'), + storyboardNumber: integer('storyboard_number').notNull(), + title: text('title'), + location: text('location'), + time: text('time'), + shotType: text('shot_type'), + angle: text('angle'), + movement: text('movement'), + result: text('result'), + atmosphere: text('atmosphere'), + imagePrompt: text('image_prompt'), + videoPrompt: text('video_prompt'), + bgmPrompt: text('bgm_prompt'), + soundEffect: text('sound_effect'), + description: text('description'), + duration: integer('duration').default(0), + composedImage: text('composed_image'), + firstFrameImage: text('first_frame_image'), + lastFrameImage: text('last_frame_image'), + referenceImages: text('reference_images'), + videoUrl: text('video_url'), + subtitleUrl: text('subtitle_url'), + composedVideoUrl: text('composed_video_url'), + status: text('status').default('pending'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +export const storyboardCharacters = sqliteTable('storyboard_characters', { + storyboardId: integer('storyboard_id').notNull(), + characterId: integer('character_id').notNull(), +}, (table) => [ + primaryKey({ columns: [table.storyboardId, table.characterId] }), +]) + +export const storyboardProps = sqliteTable('storyboard_props', { + storyboardId: integer('storyboard_id').notNull(), + propId: integer('prop_id').notNull(), +}, (table) => [ + primaryKey({ columns: [table.storyboardId, table.propId] }), +]) + +export const aiServiceConfigs = sqliteTable('ai_service_configs', { + id: integer('id').primaryKey({ autoIncrement: true }), + serviceType: text('service_type').notNull(), + provider: text('provider'), + name: text('name').notNull(), + baseUrl: text('base_url').notNull(), + apiKey: text('api_key').notNull(), + model: text('model'), + endpoint: text('endpoint'), + queryEndpoint: text('query_endpoint'), + priority: integer('priority').default(0), + isDefault: integer('is_default', { mode: 'boolean' }).default(false), + isActive: integer('is_active', { mode: 'boolean' }).default(true), + settings: text('settings'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + // 注意: 此表无 deleted_at +}) + +export const aiServiceProviders = sqliteTable('ai_service_providers', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + displayName: text('display_name'), + serviceType: text('service_type').notNull(), + provider: text('provider').notNull(), + defaultUrl: text('default_url'), + presetModels: text('preset_models'), + description: text('description'), + isActive: integer('is_active', { mode: 'boolean' }).default(true), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}) + +export const stylePresets = sqliteTable('style_presets', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + value: text('value').notNull(), + prompt: text('prompt').notNull(), + description: text('description'), + sortOrder: integer('sort_order').default(0), + isActive: integer('is_active', { mode: 'boolean' }).default(true), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + // 注意: 此表无 deleted_at(硬删除),value 列有唯一索引(见 sqlite-schema.ts DDL) +}) + +// 统一生成任务表:图片/视频生成共用,type 区分,生成参数存 params(JSON) +export const sysTask = sqliteTable('sys_task', { + id: integer('id').primaryKey({ autoIncrement: true }), + configId: integer('config_id'), + configBaseUrl: text('config_base_url'), + stage: text('stage'), + errorCode: text('error_code'), + type: text('type').notNull(), // image | video + storyboardId: integer('storyboard_id'), + dramaId: integer('drama_id'), + sceneId: integer('scene_id'), + characterId: integer('character_id'), + propId: integer('prop_id'), + provider: text('provider'), + prompt: text('prompt'), + model: text('model'), + // image: {size, frameType, referenceImages[]} + // video: {referenceMode, firstFrameUrl, lastFrameUrl, referenceImageUrls[], referenceVideoUrls[], referenceAudioUrls[], referenceFileUrl, referenceLinkUrl, generateAudio, duration, aspectRatio, resolution, seed, promptExtend, watermark} + params: text('params'), + taskId: text('task_id'), + resultUrl: text('result_url'), + localPath: text('local_path'), + status: text('status').default('processing'), + errorMsg: text('error_msg'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + completedAt: text('completed_at'), +}) + +export const videoMerges = sqliteTable('video_merges', { + id: integer('id').primaryKey({ autoIncrement: true }), + episodeId: integer('episode_id'), + dramaId: integer('drama_id'), + title: text('title'), + provider: text('provider'), + model: text('model'), + status: text('status').default('pending'), + scenes: text('scenes'), // JSON + mergedUrl: text('merged_url'), + duration: integer('duration'), + taskId: text('task_id'), + errorMsg: text('error_msg'), + createdAt: text('created_at').notNull(), + completedAt: text('completed_at'), + deletedAt: text('deleted_at'), +}) + +export const props = sqliteTable('props', { + id: integer('id').primaryKey({ autoIncrement: true }), + dramaId: integer('drama_id').notNull(), + name: text('name').notNull(), + type: text('type'), + description: text('description'), + prompt: text('prompt'), + finalPrompt: text('final_prompt'), + imageUrl: text('image_url'), + referenceImages: text('reference_images'), + localPath: text('local_path'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +export const assets = sqliteTable('assets', { + id: integer('id').primaryKey({ autoIncrement: true }), + dramaId: integer('drama_id'), + episodeId: integer('episode_id'), + storyboardId: integer('storyboard_id'), + storyboardNum: integer('storyboard_num'), + name: text('name'), + description: text('description'), + type: text('type'), + category: text('category'), + url: text('url'), + thumbnailUrl: text('thumbnail_url'), + localPath: text('local_path'), + fileSize: integer('file_size'), + mimeType: text('mime_type'), + width: integer('width'), + height: integer('height'), + duration: integer('duration'), + format: text('format'), + imageGenId: integer('image_gen_id'), + videoGenId: integer('video_gen_id'), + isFavorite: integer('is_favorite', { mode: 'boolean' }).default(false), + viewCount: integer('view_count').default(0), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), +}) + +export const appSettings = sqliteTable('app_settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: text('updated_at').notNull(), +}) diff --git a/huobao-source/backend/src/db/sqlite-schema.ts b/huobao-source/backend/src/db/sqlite-schema.ts new file mode 100644 index 0000000..108fa62 --- /dev/null +++ b/huobao-source/backend/src/db/sqlite-schema.ts @@ -0,0 +1,413 @@ +/** + * SQLite 启动建表 DDL + 种子数据(自 mysql-schema.ts 翻译) + * - AUTO_INCREMENT → AUTOINCREMENT;删除 ENGINE/CHARSET;TINYINT(1) → INTEGER + * - MySQL 内联 INDEX 拆为独立 CREATE INDEX IF NOT EXISTS + * - 种子语句去掉 FROM DUAL(SQLite 非法),幂等语义(WHERE NOT EXISTS)保留 + */ +import type Database from 'better-sqlite3' + +export const sqliteSchemaStatements = [ + `CREATE TABLE IF NOT EXISTS dramas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + genre TEXT, + style TEXT DEFAULT '3d', + aspect_ratio TEXT DEFAULT '16:9', + total_episodes INTEGER DEFAULT 1, + total_duration INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'draft', + thumbnail TEXT, + tags TEXT, + metadata TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + )`, + + `CREATE TABLE IF NOT EXISTS episodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drama_id INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + script_content TEXT, + description TEXT, + duration INTEGER DEFAULT 0, + status TEXT DEFAULT 'draft', + video_url TEXT, + thumbnail TEXT, + image_config_id INTEGER, + video_config_id INTEGER, + resolution TEXT DEFAULT '720p', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + )`, + + `CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drama_id INTEGER NOT NULL, + name TEXT NOT NULL, + role TEXT, + description TEXT, + appearance TEXT, + styling TEXT, + final_prompt TEXT, + personality TEXT, + image_url TEXT, + voice_audio_url TEXT, + voice_audio_name TEXT, + reference_images TEXT, + seed_value TEXT, + sort_order INTEGER, + local_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + )`, + + `CREATE TABLE IF NOT EXISTS scenes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drama_id INTEGER NOT NULL, + episode_id INTEGER, + location TEXT NOT NULL, + time TEXT NOT NULL, + prompt TEXT NOT NULL, + lighting TEXT, + final_prompt TEXT, + storyboard_count INTEGER DEFAULT 1, + image_url TEXT, + reference_images TEXT, + status TEXT DEFAULT 'pending', + local_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + 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 NOT NULL, + title TEXT, + location TEXT, + time TEXT, + shot_type TEXT, + angle TEXT, + movement TEXT, + result TEXT, + atmosphere TEXT, + image_prompt TEXT, + video_prompt TEXT, + bgm_prompt TEXT, + sound_effect TEXT, + description TEXT, + duration INTEGER DEFAULT 0, + composed_image TEXT, + first_frame_image TEXT, + last_frame_image TEXT, + reference_images TEXT, + video_url TEXT, + subtitle_url TEXT, + composed_video_url TEXT, + status TEXT DEFAULT 'pending', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + )`, + + `CREATE TABLE IF NOT EXISTS episode_characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + character_id INTEGER NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_episode_characters_episode_id ON episode_characters (episode_id)`, + `CREATE INDEX IF NOT EXISTS idx_episode_characters_character_id ON episode_characters (character_id)`, + + `CREATE TABLE IF NOT EXISTS episode_scenes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + scene_id INTEGER NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_episode_scenes_episode_id ON episode_scenes (episode_id)`, + `CREATE INDEX IF NOT EXISTS idx_episode_scenes_scene_id ON episode_scenes (scene_id)`, + + `CREATE TABLE IF NOT EXISTS episode_props ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER NOT NULL, + prop_id INTEGER NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_episode_props_episode_id ON episode_props (episode_id)`, + `CREATE INDEX IF NOT EXISTS idx_episode_props_prop_id ON episode_props (prop_id)`, + + `CREATE TABLE IF NOT EXISTS storyboard_characters ( + storyboard_id INTEGER NOT NULL, + character_id INTEGER NOT NULL, + PRIMARY KEY (storyboard_id, character_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_storyboard_characters_character_id ON storyboard_characters (character_id)`, + + `CREATE TABLE IF NOT EXISTS storyboard_props ( + storyboard_id INTEGER NOT NULL, + prop_id INTEGER NOT NULL, + PRIMARY KEY (storyboard_id, prop_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_storyboard_props_prop_id ON storyboard_props (prop_id)`, + + `CREATE TABLE IF NOT EXISTS ai_service_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_type TEXT NOT NULL, + provider TEXT, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + api_key TEXT NOT NULL, + 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 NOT NULL, + updated_at TEXT NOT NULL + )`, + + `CREATE TABLE IF NOT EXISTS ai_service_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + display_name TEXT, + service_type TEXT NOT NULL, + provider TEXT NOT NULL, + default_url TEXT, + preset_models TEXT, + description TEXT, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + + `CREATE TABLE IF NOT EXISTS style_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + value TEXT NOT NULL, + prompt TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (value) + )`, + + `CREATE TABLE IF NOT EXISTS sys_task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + storyboard_id INTEGER, + drama_id INTEGER, + scene_id INTEGER, + character_id INTEGER, + prop_id INTEGER, + provider TEXT, + prompt TEXT, + model TEXT, + params TEXT, + task_id TEXT, + result_url TEXT, + local_path TEXT, + status TEXT DEFAULT 'processing', + error_msg TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_sys_task_type ON sys_task (type)`, + `CREATE INDEX IF NOT EXISTS idx_sys_task_drama_id ON sys_task (drama_id)`, + `CREATE INDEX IF NOT EXISTS idx_sys_task_storyboard_id ON sys_task (storyboard_id)`, + + `CREATE TABLE IF NOT EXISTS video_merges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + episode_id INTEGER, + drama_id INTEGER, + title TEXT, + provider TEXT NOT NULL, + model TEXT NOT NULL, + status TEXT DEFAULT 'pending', + scenes TEXT, + merged_url TEXT, + duration INTEGER, + task_id TEXT, + error_msg TEXT, + created_at TEXT NOT NULL, + completed_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, + type TEXT, + description TEXT, + prompt TEXT, + final_prompt TEXT, + image_url TEXT, + reference_images TEXT, + local_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + )`, + + `CREATE TABLE IF NOT EXISTS assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drama_id INTEGER, + episode_id INTEGER, + storyboard_id INTEGER, + storyboard_num INTEGER, + name TEXT, + description TEXT, + type TEXT, + category TEXT, + url TEXT, + thumbnail_url TEXT, + local_path TEXT, + file_size INTEGER, + mime_type TEXT, + width INTEGER, + height INTEGER, + duration INTEGER, + format TEXT, + image_gen_id INTEGER, + video_gen_id INTEGER, + is_favorite INTEGER DEFAULT 0, + view_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + )`, + + // 应用级全局设置(key-value,如 AI 内容语言 content_language) + `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, +] + +/** + * 风格预设种子数据 — value 存入 dramas.style,prompt 注入生图提示词(作为前缀拼接) + * + * prompt 统一按多维结构书写,保证跨模型/跨镜头的风格控制力: + * 核心媒介与渲染 → 线条/造型 → 上色/材质 → 光影 → 色彩调性 → 背景处理 → 画质锚点 → avoid 禁忌项 + */ +export const stylePresetSeeds = [ + { + name: '3D 漫剧', value: '3d', sortOrder: 1, + prompt: 'high-quality 3D CG animation still, modern game-engine cinematic render, Unreal Engine and Pixar grade quality, semi-realistic stylized characters with refined facial features, clean sculpted anatomy, detailed skin shader with subtle subsurface scattering, PBR materials with crisp detailed textures, volumetric cinematic lighting with soft rim light, rich depth of field, polished film color grading, detailed environment art, sharp focus, consistent character design across shots, avoid flat lighting, avoid plastic waxy skin, avoid low-poly blurry look, avoid 2D flat cel shading, avoid anime line art', + description: '游戏引擎级 3D 渲染,半写实角色,当前短剧主流的 3D 漫剧质感', + }, + { + name: '日漫赛璐璐', value: 'anime', sortOrder: 2, + prompt: 'Japanese TV anime style, clean cel shading with hard-edged shadow shapes, crisp uniform black line art, vivid saturated color palette, expressive large-eyed character design with on-model proportions, detailed hand-painted anime backgrounds, dramatic anime key lighting with screentone highlights, key-visual poster quality, consistent character design across shots, avoid 3D CGI look, avoid painterly soft blending, avoid watercolor texture, avoid photorealism, avoid thick western comic outlines', + description: '日式赛璐璐动画风格', + }, + { + name: '吉卜力手绘', value: 'ghibli', sortOrder: 3, + prompt: 'Studio Ghibli hand-drawn animation style, soft painterly brushwork with organic hand-crafted line quality, lush warm watercolor painted backgrounds, gentle natural daylight with nostalgic warm glow, muted earthy natural color palette, whimsical cozy storybook atmosphere, subtle film-grain softness, theatrical background art quality, consistent character design across shots, avoid hard cel shading, avoid 3D render look, avoid neon over-saturated colors, avoid sharp digital edges, avoid photorealism', + description: '吉卜力手绘治愈风', + }, + { + name: '水彩绘本', value: 'watercolor', sortOrder: 4, + prompt: 'delicate watercolor storybook illustration, soft translucent color washes, visible cold-press paper texture, fluid hand-painted brushstrokes with gentle pigment bleeds, light airy atmosphere, harmonious pastel palette, whimsical children book charm, loose expressive edges, consistent character design across shots, avoid bold black outlines, avoid digital airbrush look, avoid harsh contrast, avoid 3D rendering, avoid photorealism', + description: '水彩插画质感', + }, + { + name: '美式漫画', value: 'comic', sortOrder: 5, + prompt: 'Western graphic-novel comic book style, bold confident black ink outlines, halftone dot shading and screentone gradients, dynamic saturated colors with dramatic contrast, dramatic spotlight lighting, flat graphic print look, sharp inking details, dynamic cinematic composition, consistent character design across shots, avoid painterly soft blending, avoid watercolor washes, avoid photorealistic rendering, avoid 3D CGI look, avoid anime cel shading', + description: '美式漫画粗线条风格', + }, + { + name: '国风 2.5D', value: 'guofeng', sortOrder: 7, + prompt: 'Chinese guofeng 2.5D illustration style, semi-realistic donghua-quality character art, elegant flowing line work, rich traditional Chinese aesthetic elements, layered ink-wash inspired atmospheric backgrounds, refined silk and fabric textures, soft luminous lighting with gentle haze, sophisticated muted jewel-tone palette, xianxia drama poster quality, consistent character design across shots, avoid flat cel shading, avoid western comic ink style, avoid photorealism, avoid plastic 3D look, avoid modern clothing and props unless specified', + description: '国风动画/仙侠剧质感,2.5D 半写实', + }, + { + name: '韩系网漫', value: 'webtoon', sortOrder: 8, + prompt: 'Korean webtoon manhwa style, clean digital painting with soft gradient shading, slim elegant character proportions, large expressive eyes with detailed highlights, soft glowing skin rendering, romantic dreamy lighting, modern pastel-to-vivid color palette, detailed fashion and fabric rendering, webtoon key visual quality, consistent character design across shots, avoid heavy black ink outlines, avoid halftone dots, avoid 3D render look, avoid watercolor paper texture, avoid chibi proportions', + description: '韩国条漫/网漫精致上色风', + }, + { + name: '黑白漫画', value: 'noir', sortOrder: 9, + prompt: 'black and white manga illustration, high-contrast monochrome ink work, dynamic hatching and cross-hatching shading, bold solid blacks with dramatic negative space, screentone gray gradation, expressive confident ink linework, cinematic noir lighting, professional manga page quality, consistent character design across shots, strictly no color, avoid grayscale blur smudging, avoid painterly soft edges, avoid photorealism, avoid 3D render look', + description: '黑白漫/ Noir 高对比墨水风', + }, +] + +/** + * 旧版种子 prompt(v1 一句话风格描述)— 用于内容寻址升级: + * 仅当库中行的 prompt 仍等于旧种子值(未被用户在设置页编辑过)才覆盖为新 prompt + */ +const LEGACY_SEED_PROMPTS: Record = { + '3d': '3D CG animation style, game-engine quality render, semi-realistic stylized characters, refined facial features, detailed materials and textures, cinematic lighting, high detail', + anime: 'Japanese anime style, cel shading, clean crisp line art, vivid saturated colors, expressive character designs, detailed painted backgrounds', + ghibli: 'Studio Ghibli style, hand-drawn animation, soft watercolor painted backgrounds, warm nostalgic lighting, gentle natural palette, whimsical cozy atmosphere', + watercolor: 'watercolor illustration style, soft translucent washes, visible paper texture, delicate fluid brushwork, light airy atmosphere, hand-painted storybook feel', + comic: 'Western comic book style, bold black ink outlines, halftone dot shading, dynamic saturated colors, dramatic contrast lighting, flat graphic novel look', +} + +/** + * 已下架的种子预设 — 内容寻址删除:仅当库中行的 prompt 仍是种子原文 + * (未被用户编辑过)才删除;用户改过的同名行视为用户数据保留。 + * live(真人写实):真人影像过不了平台真人内容审核,下架。 + */ +const REMOVED_SEED_PROMPTS: Record = { + live: 'ultra-realistic cinematic live-action look, professional film photography, natural skin tones with detailed pores and realistic texture, true human anatomy and proportions, shallow depth of field with creamy bokeh, cinematic three-point lighting, subtle film grain, 35mm lens cinematic framing, true-to-life color grading, detailed real-world environments, consistent actor appearance across shots, avoid cartoon or anime features, avoid 3D render look, avoid illustration style, avoid plastic waxy skin, avoid over-smoothing beauty filter', +} + +// INSERT ... SELECT WHERE NOT EXISTS → 幂等:只补缺失行,不覆盖用户编辑 +// (SQLite 无 FROM DUAL,无 FROM 的 SELECT 合法) +const SEED_SQL = 'INSERT INTO style_presets ("name", "value", "prompt", "description", "sort_order", "is_active", "created_at", "updated_at") SELECT ?, ?, ?, ?, ?, 1, ?, ? WHERE NOT EXISTS (SELECT 1 FROM style_presets WHERE value = ?)' +// 内容寻址升级:命中旧种子原文才更新(用户在设置页改过的行不动) +const UPGRADE_SQL = 'UPDATE style_presets SET "name" = ?, "prompt" = ?, "description" = ?, "sort_order" = ?, "updated_at" = ? WHERE "value" = ? AND "prompt" = ?' +// 内容寻址下架:命中下架种子原文才删除 +const REMOVE_SQL = 'DELETE FROM style_presets WHERE "value" = ? AND "prompt" = ?' + +export function initSqliteSchema(sqlite: Database.Database) { + for (const statement of sqliteSchemaStatements) { + sqlite.exec(statement) + } + // 兼容已安装的桌面版;仅增加任务恢复元数据,不改写历史生成结果。 + const taskColumns = new Set((sqlite.prepare('PRAGMA table_info(sys_task)').all() as { name: string }[]).map(c => c.name)) + for (const [name, type] of Object.entries({ config_id: 'INTEGER', config_base_url: 'TEXT', stage: 'TEXT', error_code: 'TEXT' })) { + if (!taskColumns.has(name)) sqlite.exec(`ALTER TABLE sys_task ADD COLUMN ${name} ${type}`) + } + // 角色音色由用户上传;旧库补充可空字段,不修改已有角色和图片。 + const characterColumns = new Set((sqlite.prepare('PRAGMA table_info(characters)').all() as { name: string }[]).map(c => c.name)) + for (const name of ['voice_audio_url', 'voice_audio_name']) { + if (!characterColumns.has(name)) sqlite.exec(`ALTER TABLE characters ADD COLUMN ${name} TEXT`) + } + const insertSeed = sqlite.prepare(SEED_SQL) + // Reference inputs are independent of generated outputs; preserve all existing rows. + const sceneColumns = new Set((sqlite.prepare('PRAGMA table_info(scenes)').all() as { name: string }[]).map(c => c.name)) + if (!sceneColumns.has('reference_images')) sqlite.exec('ALTER TABLE scenes ADD COLUMN reference_images TEXT') + const upgradeSeed = sqlite.prepare(UPGRADE_SQL) + const removeSeed = sqlite.prepare(REMOVE_SQL) + for (const s of stylePresetSeeds) { + const ts = new Date().toISOString() + insertSeed.run(s.name, s.value, s.prompt, s.description, s.sortOrder, ts, ts, s.value) + const legacyPrompt = LEGACY_SEED_PROMPTS[s.value] + if (legacyPrompt) { + const res = upgradeSeed.run(s.name, s.prompt, s.description, s.sortOrder, ts, s.value, legacyPrompt) + if (res.changes > 0) console.log(`🎨 风格预设「${s.name}」已升级为结构化提示词`) + } + } + for (const [value, prompt] of Object.entries(REMOVED_SEED_PROMPTS)) { + const res = removeSeed.run(value, prompt) + if (res.changes > 0) console.log(`🗑️ 风格预设「${value}」已下架`) + } +} diff --git a/huobao-source/backend/src/index.ts b/huobao-source/backend/src/index.ts new file mode 100644 index 0000000..2c40539 --- /dev/null +++ b/huobao-source/backend/src/index.ts @@ -0,0 +1,93 @@ +import 'dotenv/config' +import { serve } from '@hono/node-server' +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import path from 'path' +import { fileURLToPath } from 'url' + +import dramas from './routes/dramas.js' +import episodes from './routes/episodes.js' +import storyboards from './routes/storyboards.js' +import scenes from './routes/scenes.js' +import characters from './routes/characters.js' +import tasks from './routes/tasks.js' +import hd from './routes/hd.js' +import upload from './routes/upload.js' +import aiConfigs, { aiProviders } from './routes/aiConfigs.js' +import stylePresets from './routes/stylePresets.js' +import prompts from './routes/prompts.js' +import agent from './routes/agent.js' +import merge from './routes/merge.js' +import skills from './routes/skills.js' +import props from './routes/props.js' +import settings from './routes/settings.js' +import storage from './routes/storage.js' +import serverUpdate from './routes/serverUpdate.js' +import { requestLogger, errorHandler } from './middleware/logger.js' +import { resumeInterruptedTasks } from './services/generation.js' +import { DATA_ROOT } from './utils/paths.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(__dirname, '../..') + +const app = new Hono() + +// Middleware +app.use('*', cors({ + origin: ['http://localhost:3013', 'http://localhost:5679'], + credentials: true, +})) +app.use('*', requestLogger) +app.use('*', errorHandler) + +// Health check(version 供部署巡检/更新检查核对当前运行版本) +app.get('/api/v1/health', (c) => c.json({ + status: 'ok', + version: process.env.HUOBAO_VERSION || undefined, + timestamp: new Date().toISOString(), +})) + +// API routes +const api = new Hono() +api.route('/dramas', dramas) +api.route('/episodes', episodes) +api.route('/storyboards', storyboards) +api.route('/scenes', scenes) +api.route('/characters', characters) +api.route('/tasks', tasks) +api.route('/hd', hd) +api.route('/upload', upload) +api.route('/ai-configs', aiConfigs) +api.route('/ai-providers', aiProviders) +api.route('/style-presets', stylePresets) +api.route('/prompts', prompts) +api.route('/agent', agent) +api.route('/merge', merge) +api.route('/skills', skills) +api.route('/props', props) +api.route('/storage', storage) +api.route('/settings', settings) +api.route('/server-update', serverUpdate) + +app.route('/api/v1', api) + +// Serve static files (storage) +// 生成的图片/视频按 uuid 命名、内容不变,标记为 immutable 让浏览器长缓存 +app.use('/static/*', async (c, next) => { + await next() + if (c.res.ok) c.header('Cache-Control', 'public, max-age=31536000, immutable') +}) +app.use('/static/*', serveStatic({ root: DATA_ROOT })) + +// Serve frontend (production build) — 桌面版由主进程注入 FRONTEND_DIST(resources/frontend) +const distPath = process.env.FRONTEND_DIST || path.join(projectRoot, 'frontend', 'dist') +app.use('*', serveStatic({ root: distPath })) +app.get('*', serveStatic({ root: distPath, path: 'index.html' })) + +const port = Number(process.env.PORT || 5679) +console.log(`🚀 Huobao Drama TS server on http://localhost:${port}`) + +await resumeInterruptedTasks() + +serve({ fetch: app.fetch, port }) diff --git a/huobao-source/backend/src/mastra/index.ts b/huobao-source/backend/src/mastra/index.ts new file mode 100644 index 0000000..23d2d87 --- /dev/null +++ b/huobao-source/backend/src/mastra/index.ts @@ -0,0 +1,11 @@ +/** + * Mastra 中央实例 — Agent 注册表挂载点 + * 不用 Mastra logger(项目已有 pino + task-logger) + */ +import { Mastra } from '@mastra/core/mastra' +import { agentRegistry } from '../agents/index.js' + +export const mastra = new Mastra({ + agents: agentRegistry, + logger: false, +}) diff --git a/huobao-source/backend/src/middleware/logger.ts b/huobao-source/backend/src/middleware/logger.ts new file mode 100644 index 0000000..412b644 --- /dev/null +++ b/huobao-source/backend/src/middleware/logger.ts @@ -0,0 +1,70 @@ +import type { MiddlewareHandler } from 'hono' + +const colors = { + reset: '\x1b[0m', + dim: '\x1b[2m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', +} + +function statusColor(status: number): string { + if (status >= 500) return colors.red + if (status >= 400) return colors.yellow + if (status >= 300) return colors.cyan + return colors.green +} + +function formatTime(): string { + return new Date().toLocaleTimeString('zh-CN', { hour12: false }) +} + +/** + * 全局日志中间件 — 打印请求方法/路径/状态/耗时/请求体 + */ +export const requestLogger: MiddlewareHandler = async (c, next) => { + const method = c.req.method + const path = c.req.path + const start = performance.now() + + // 打印请求 + const time = formatTime() + let bodyInfo = '' + // 上传文件不克隆/读取请求体:避免记录音频内容,也让路由的大小限制先执行。 + const isFileUpload = c.req.header('content-type')?.toLowerCase().startsWith('multipart/form-data') + if (!isFileUpload && ['POST', 'PUT', 'PATCH'].includes(method)) { + try { + const clone = c.req.raw.clone() + const text = await clone.text() + if (text) { + const truncated = text.length > 500 ? text.slice(0, 500) + '...' : text + bodyInfo = `\n ${colors.dim}body: ${truncated}${colors.reset}` + } + } catch {} + } + + console.log(`${colors.dim}${time}${colors.reset} ${colors.cyan}${method}${colors.reset} ${path}${bodyInfo}`) + + await next() + + const ms = (performance.now() - start).toFixed(0) + const status = c.res.status + const sc = statusColor(status) + console.log(`${colors.dim}${time}${colors.reset} ${colors.cyan}${method}${colors.reset} ${path} ${sc}${status}${colors.reset} ${colors.dim}${ms}ms${colors.reset}`) +} + +/** + * 全局错误处理 — 捕获未处理异常,打印完整堆栈 + */ +export const errorHandler: MiddlewareHandler = async (c, next) => { + try { + await next() + } catch (err: any) { + const status = err.status || 500 + console.error(`${colors.red}[ERROR]${colors.reset} ${c.req.method} ${c.req.path}`) + console.error(err.stack || err.message || err) + return c.json({ code: status, message: err.message || 'Internal Server Error' }, status) + } +} diff --git a/huobao-source/backend/src/routes/agent.ts b/huobao-source/backend/src/routes/agent.ts new file mode 100644 index 0000000..e7b301d --- /dev/null +++ b/huobao-source/backend/src/routes/agent.ts @@ -0,0 +1,117 @@ +/** + * Agent 聊天路由 — 非流式版本 + */ +import { Hono } from 'hono' +import { validAgentTypes } from '../agents/index.js' +import { buildAgentRequestContext } from '../agents/context.js' +import { mastra } from '../mastra/index.js' +import { success, badRequest } from '../utils/response.js' +import { logTaskError, logTaskPayload, logTaskProgress, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +const app = new Hono() + +// Mastra v1.17 的 ToolCallChunk / ToolResultChunk 结构: +// { type: 'tool-call', payload: { toolCallId, toolName, args } } +// { type: 'tool-result', payload: { toolCallId, toolName, result, isError } } +function normalizeToolName(entry: any) { + return entry?.payload?.toolName + || entry?.toolName + || entry?.tool?.toolName + || entry?.tool?.id + || entry?.name + || entry?.type + || null +} + +function normalizeToolResult(entry: any) { + const result = entry?.payload?.result ?? entry?.result ?? entry?.payload?.output ?? entry?.output ?? entry?.data ?? null + return typeof result === 'string' ? result : JSON.stringify(result) +} + +// POST /agent/:type/chat — 非流式 Agent 对话 +app.post('/:type/chat', async (c) => { + const agentType = c.req.param('type') + if (!validAgentTypes.includes(agentType)) { + return badRequest(c, `无效的 Agent 类型:${agentType}`) + } + + const body = await c.req.json() + const { message, drama_id, episode_id } = body + + logTaskStart('Agent', agentType, { + dramaId: drama_id, + episodeId: episode_id, + message, + }) + logTaskPayload('Agent', `${agentType} input`, body) + + if (!episode_id || !drama_id) { + logTaskError('Agent', agentType, { reason: 'missing drama_id or episode_id' }) + return badRequest(c, '需要 drama_id 与 episode_id') + } + + const agent = mastra.getAgent(agentType) + if (!agent) { + logTaskError('Agent', agentType, { reason: 'agent not found' }) + return badRequest(c, 'Agent 不存在') + } + + const requestContext = buildAgentRequestContext({ + episodeId: episode_id, + dramaId: drama_id, + modelOverride: body.model || undefined, + textConfigId: body.config_id || undefined, + }) + + const startTime = performance.now() + + try { + const result = await agent.generate( + [{ role: 'user', content: message }], + { maxSteps: 20, requestContext }, + ) + + const elapsed = ((performance.now() - startTime) / 1000).toFixed(1) + logTaskSuccess('Agent', agentType, { elapsedSeconds: elapsed }) + + // 收集所有 tool calls 和 results + const toolCalls = result.toolCalls || [] + const toolResults = result.toolResults || [] + const normalizedToolCalls = toolCalls.map((tc: any) => ({ + toolName: normalizeToolName(tc), + args: tc?.payload?.args ?? tc?.args ?? tc?.input ?? null, + })) + const normalizedToolResults = toolResults.map((tr: any) => ({ + toolName: normalizeToolName(tr), + result: normalizeToolResult(tr), + })) + + logTaskProgress('Agent', 'tool-summary', { + agentType, + toolCalls: normalizedToolCalls.map((tc: any) => tc.toolName), + toolResults: normalizedToolResults.map((tr: any) => tr.toolName), + }) + logTaskPayload('Agent', `${agentType} tool-results`, normalizedToolResults) + + return success(c, { + type: 'done', + text: result.text || '', + toolCalls: normalizedToolCalls, + toolResults: normalizedToolResults, + }) + } catch (err: any) { + const elapsed = ((performance.now() - startTime) / 1000).toFixed(1) + logTaskError('Agent', agentType, { elapsedSeconds: elapsed, error: err.message }) + console.error(err.stack || err) + return badRequest(c, err.message || 'Agent 执行失败') + } +}) + +// GET /agent/:type/debug +app.get('/:type/debug', async (c) => { + const agentType = c.req.param('type') + if (!validAgentTypes.includes(agentType)) return badRequest(c, '无效的 Agent 类型') + return success(c, { agent_type: agentType, valid: true }) +}) + +export default app diff --git a/huobao-source/backend/src/routes/aiConfigs.ts b/huobao-source/backend/src/routes/aiConfigs.ts new file mode 100644 index 0000000..b3e8c2e --- /dev/null +++ b/huobao-source/backend/src/routes/aiConfigs.ts @@ -0,0 +1,321 @@ +import { Hono } from 'hono' +import { eq } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, notFound, created, badRequest, now } from '../utils/response.js' +import { toSnakeCase } from '../utils/transform.js' +import { joinProviderUrl } from '../services/adapters/url.js' +import { isOfficialProvider, parseConfigTemperature, parseConfigGpu } from '../services/ai.js' +import { redactUrl, logTaskError, logTaskProgress, logTaskSuccess } from '../utils/task-logger.js' + +const app = new Hono() + +/** 归一化 temperature 入参:null=未设置;合法值 0~2;非法抛错 */ +function normalizeTemperature(v: any): number | null { + if (v === null || v === undefined || v === '') return null + const n = Number(v) + if (!Number.isFinite(n) || n < 0 || n > 2) throw new Error('invalid temperature') + return n +} + +/** 把 settings JSON 中的 temperature 透出为顶层字段,便于前端直接读写 */ +function withParsedFields(r: any) { + return { + ...toSnakeCase(r), + model: r.model ? JSON.parse(r.model) : [], + temperature: parseConfigTemperature(r.settings), + gpu: parseConfigGpu(r.settings), + } +} + +function bearerHeaders(apiKey?: string, withJson = false) { + const headers: Record = {} + if (apiKey) headers.Authorization = `Bearer ${apiKey}` + if (withJson) headers['Content-Type'] = 'application/json' + return headers +} + +function geminiHeaders(apiKey?: string, withJson = false) { + const headers: Record = {} + if (apiKey) { + headers['x-goog-api-key'] = apiKey + } + if (withJson) headers['Content-Type'] = 'application/json' + return headers +} + +function buildProbe(serviceType: string, provider: string, baseUrl: string, model?: string, apiKey?: string) { + const p = provider.toLowerCase() + const m = model || '' + + if (p === 'gemini') { + // 探针统一走 generateContent:文本运行时(AI SDK)走的就是它,官方与中转站都支持; + // interactions 端点很多中转站未配置,探它会误报 500。 + // 用最小合法请求体而非空体——空体在部分中转站会触发上游认证失败的误报 + const modelName = m || 'gemini-3.1-pro-preview' + const url = new URL(joinProviderUrl(baseUrl, '/v1beta', `/models/${modelName}:generateContent`)) + if (apiKey) url.searchParams.set('key', apiKey) + return { + method: 'POST', + url: url.toString(), + headers: geminiHeaders(apiKey, true), + body: { contents: [{ parts: [{ text: 'hi' }] }] }, + } + } + + if (p === 'openai' || p === 'localvideo') { + return { + method: 'GET', + url: joinProviderUrl(baseUrl, '/v1', '/models'), + headers: bearerHeaders(apiKey), + body: undefined, + } + } + + if (p === 'volcengine') { + const path = serviceType === 'video' + ? '/contents/generations/tasks' + : serviceType === 'text' + ? '/chat/completions' + : '/images/generations' + return { + method: 'POST', + url: joinProviderUrl(baseUrl, '/api/v3', path), + headers: bearerHeaders(apiKey, true), + body: {}, + } + } + + if (p === 'minimax') { + // MiniMax 仅提供视频服务,空请求体探测鉴权/端点连通性 + return { + method: 'POST', + url: joinProviderUrl(baseUrl, '/v2', '/video_generation'), + headers: bearerHeaders(apiKey, true), + body: {}, + } + } + + if (p === 'aliyun') { + // Wan 3.0 仅支持异步提交;空请求体不会创建计费任务,仅用于验证地域端点与鉴权是否可达。 + return { + method: 'POST', + url: joinProviderUrl(baseUrl, '/api/v1', '/services/aigc/video-generation/video-synthesis'), + headers: { + ...bearerHeaders(apiKey, true), + 'X-DashScope-Async': 'enable', + }, + body: {}, + } + } + + return { + method: 'GET', + url: joinProviderUrl(baseUrl, '', m ? `/${m}` : '/'), + headers: bearerHeaders(apiKey), + body: undefined, + } +} + +// GET /ai-configs?service_type=text +app.get('/', async (c) => { + const serviceType = c.req.query('service_type') + let rows = await db.select().from(schema.aiServiceConfigs) + if (serviceType) rows = rows.filter(r => r.serviceType === serviceType) + + const parsed = rows.map(withParsedFields) + return success(c, parsed) +}) + +// POST /ai-configs +app.post('/', async (c) => { + const body = await c.req.json() + const ts = now() + if ('gpu' in body && !['auto', 'gpu1', 'gpu2', 'gpu3'].includes(body.gpu)) return badRequest(c, '显卡须为 auto、gpu1、gpu2 或 gpu3') + + // 验证必填字段 + if (!body.service_type || !body.provider) { + return badRequest(c, '需要 service_type 与 provider') + } + if (!isOfficialProvider(body.service_type, body.provider)) { + return badRequest(c, '不支持的 service_type/provider') + } + + let temperature: number | null = null + if ('temperature' in body) { + try { + temperature = normalizeTemperature(body.temperature) + } catch { + return badRequest(c, 'temperature 须为 0 到 2 之间的数字') + } + } + + const res = await db.insert(schema.aiServiceConfigs).values({ + serviceType: body.service_type, + provider: body.provider, + name: body.name || `${body.provider}-${body.service_type}`, + baseUrl: body.base_url || '', + apiKey: body.api_key || '', + model: JSON.stringify(body.model || []), + priority: body.priority || 0, + isActive: true, + settings: temperature !== null || body.gpu && body.gpu !== 'auto' + ? JSON.stringify({ ...(temperature !== null ? { temperature } : {}), ...(body.gpu && body.gpu !== 'auto' ? { gpu: body.gpu } : {}) }) : null, + createdAt: ts, + updatedAt: ts, + }) + + const [row] = await db.select().from(schema.aiServiceConfigs) + .where(eq(schema.aiServiceConfigs.id, getInsertId(res))) + + return created(c, withParsedFields(row)) +}) + +// POST /ai-configs/test +app.post('/test', async (c) => { + const body = await c.req.json() + if (!body.service_type || !body.provider || !body.base_url) { + return badRequest(c, '需要 service_type、provider 与 base_url') + } + if (!isOfficialProvider(body.service_type, body.provider)) { + return badRequest(c, '不支持的 service_type/provider') + } + + const model = Array.isArray(body.model) ? body.model[0] : body.model + const probe = buildProbe(body.service_type, body.provider, body.base_url, model, body.api_key) + const probeUrl = redactUrl(probe.url) + + logTaskProgress('AIConfig', 'probe-start', { + serviceType: body.service_type, + provider: body.provider, + method: probe.method, + url: probeUrl, + }) + + try { + const resp = await fetch(probe.url, { + method: probe.method, + headers: probe.headers, + body: probe.body ? JSON.stringify(probe.body) : undefined, + }) + const text = await resp.text() + const reachable = [200, 204, 400, 401, 403].includes(resp.status) + const payload = { + ok: resp.ok, + reachable, + status: resp.status, + status_text: resp.statusText, + method: probe.method, + url: probeUrl, + message: reachable + ? (resp.ok ? '端点可访问,认证与路径基本正常' : '端点已响应,请根据状态码判断认证或路径是否正确') + : '端点未按预期响应,请检查 Base URL 和代理前缀', + response_preview: text.slice(0, 240), + } + if (reachable) { + logTaskSuccess('AIConfig', 'probe-done', { + provider: body.provider, + status: resp.status, + url: probeUrl, + }) + } else { + logTaskError('AIConfig', 'probe-unexpected', { + provider: body.provider, + status: resp.status, + url: probeUrl, + }) + } + return success(c, payload) + } catch (error: any) { + logTaskError('AIConfig', 'probe-failed', { + provider: body.provider, + url: probeUrl, + error: error.message, + }) + return success(c, { + ok: false, + reachable: false, + method: probe.method, + url: probeUrl, + message: error.message || '请求失败', + response_preview: '', + }) + } +}) + +// GET /ai-configs/:id +app.get('/:id', async (c) => { + const id = Number(c.req.param('id')) + const [row] = await db.select().from(schema.aiServiceConfigs).where(eq(schema.aiServiceConfigs.id, id)) + if (!row) return notFound(c) + return success(c, withParsedFields(row)) +}) + +// PUT /ai-configs/:id +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [existing] = await db.select().from(schema.aiServiceConfigs).where(eq(schema.aiServiceConfigs.id, id)) + if (!existing) return notFound(c) + + const serviceType = 'service_type' in body ? body.service_type : existing.serviceType + const provider = 'provider' in body ? body.provider : existing.provider + if (!isOfficialProvider(serviceType, provider)) { + return badRequest(c, '不支持的 service_type/provider') + } + + const updates: Record = { updatedAt: now() } + if ('gpu' in body && !['auto', 'gpu1', 'gpu2', 'gpu3'].includes(body.gpu)) return badRequest(c, '显卡须为 auto、gpu1、gpu2 或 gpu3') + + if ('service_type' in body) updates.serviceType = body.service_type + if ('provider' in body) updates.provider = body.provider + if ('name' in body) updates.name = body.name + if ('base_url' in body) updates.baseUrl = body.base_url + if ('api_key' in body) updates.apiKey = body.api_key + if ('model' in body) updates.model = JSON.stringify(body.model) + if ('priority' in body) updates.priority = body.priority + if ('is_active' in body) updates.isActive = body.is_active + if ('temperature' in body) { + let temperature: number | null + try { + temperature = normalizeTemperature(body.temperature) + } catch { + return badRequest(c, 'temperature 须为 0 到 2 之间的数字') + } + // 与已有 settings 合并,清空的 temperature 从 JSON 中移除 + let settings: Record = {} + try { settings = existing.settings ? JSON.parse(existing.settings) : {} } catch { settings = {} } + if (temperature === null) delete settings.temperature + else settings.temperature = temperature + updates.settings = Object.keys(settings).length ? JSON.stringify(settings) : null + } + if ('gpu' in body) { + let settings: Record = {} + try { settings = JSON.parse(('settings' in updates ? updates.settings : existing.settings) || '{}') } catch { settings = {} } + if (body.gpu === 'auto') delete settings.gpu + else settings.gpu = body.gpu + updates.settings = Object.keys(settings).length ? JSON.stringify(settings) : null + } + + await db.update(schema.aiServiceConfigs).set(updates).where(eq(schema.aiServiceConfigs.id, id)) + return success(c) +}) + +// DELETE /ai-configs/:id +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + await db.delete(schema.aiServiceConfigs).where(eq(schema.aiServiceConfigs.id, id)) + return success(c) +}) + +// GET /ai-providers +export const aiProviders = new Hono() +aiProviders.get('/', async (c) => { + const rows = await db.select().from(schema.aiServiceProviders) + const parsed = rows.map(r => ({ + ...toSnakeCase(r), + preset_models: r.presetModels ? JSON.parse(r.presetModels) : [], + })) + return success(c, parsed) +}) + +export default app diff --git a/huobao-source/backend/src/routes/asset-references.ts b/huobao-source/backend/src/routes/asset-references.ts new file mode 100644 index 0000000..5a178b3 --- /dev/null +++ b/huobao-source/backend/src/routes/asset-references.ts @@ -0,0 +1,90 @@ +import type { Hono } from 'hono' +import { bodyLimit } from 'hono/body-limit' +import { and, eq, isNull } from 'drizzle-orm' +import fs from 'node:fs/promises' +import sharp from 'sharp' +import { db, schema } from '../db/index.js' +import { badRequest, notFound, now, success } from '../utils/response.js' +import { generateImageThumb, getAbsolutePath, saveUploadedFile, thumbPathFor } from '../utils/storage.js' + +const MAX_REFERENCES = 6 +const MAX_BYTES = 20 * 1024 * 1024 +type AssetTable = typeof schema.characters | typeof schema.scenes | typeof schema.props + +export function assetReferenceImages(raw: string | null | undefined): string[] { + if (!raw) return [] + const refs: unknown = JSON.parse(raw) + if (!Array.isArray(refs) || refs.some(ref => typeof ref !== 'string' || !ref.trim())) { + throw new Error('参考图记录异常,请重新设置参考图') + } + if (refs.length > MAX_REFERENCES) throw new Error('参考图最多 6 张') + return [...new Set(refs)] +} + +/** Append/remove only this asset's reference inputs; output images remain untouched. */ +export function registerAssetReferenceRoutes(app: Hono, table: AssetTable): void { + app.post('/:id/reference-images', bodyLimit({ maxSize: MAX_BYTES + 64 * 1024, + onError: c => badRequest(c, '参考图不能超过 20MB'), + }), async c => { + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) return badRequest(c, '资产编号无效') + const condition = and(eq(table.id, id), isNull(table.deletedAt)) + const asset = db.select({ referenceImages: table.referenceImages }).from(table).where(condition).get() + if (!asset) return notFound(c, '资产不存在') + let uploadedPath: string | undefined + try { + if (assetReferenceImages(asset.referenceImages).length >= MAX_REFERENCES) return badRequest(c, '参考图最多 6 张') + const body = await c.req.parseBody() + const file = body.file + if (!(file instanceof File) || !file.size) return badRequest(c, '请选择参考图片') + if (file.size > MAX_BYTES) return badRequest(c, '参考图不能超过 20MB') + const bytes = await file.arrayBuffer() + const image = sharp(Buffer.from(bytes), { limitInputPixels: 40_000_000, failOn: 'error' }) + let extension: string + try { + const info = await image.metadata() + extension = ({ jpeg: 'jpg', png: 'png', webp: 'webp' } as Record)[info.format || ''] + if (!extension || (info.pages || 1) > 1) return badRequest(c, '参考图仅支持静态 JPG、PNG、WebP') + await image.stats() // Decode before accepting a damaged or mislabeled file. + } catch { return badRequest(c, '参考图片无法读取,请使用 JPG、PNG 或 WebP') } + uploadedPath = await saveUploadedFile(bytes, 'references', `reference.${extension}`) + await generateImageThumb(uploadedPath) + const refs = db.transaction(tx => { + const current = tx.select({ referenceImages: table.referenceImages }).from(table).where(condition).get() + if (!current) throw new Error('资产不存在') + const list = assetReferenceImages(current.referenceImages) + if (list.length >= MAX_REFERENCES) throw new Error('参考图最多 6 张') + list.push(uploadedPath!) + tx.update(table).set({ referenceImages: JSON.stringify(list), updatedAt: now() }).where(condition).run() + return list + }) + return success(c, { reference_images: refs }) + } catch (error) { + if (uploadedPath) { + await fs.unlink(getAbsolutePath(uploadedPath)).catch(() => {}) + await fs.unlink(getAbsolutePath(thumbPathFor(uploadedPath))).catch(() => {}) + } + return badRequest(c, (error as Error).message) + } + }) + + app.delete('/:id/reference-images', async c => { + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) return badRequest(c, '资产编号无效') + const body = await c.req.json() + if (typeof body.path !== 'string') return badRequest(c, '请选择需要移除的参考图') + const condition = and(eq(table.id, id), isNull(table.deletedAt)) + try { + const refs = db.transaction(tx => { + const asset = tx.select({ referenceImages: table.referenceImages }).from(table).where(condition).get() + if (!asset) return null + const list = assetReferenceImages(asset.referenceImages).filter(ref => ref !== body.path) + tx.update(table).set({ referenceImages: JSON.stringify(list), updatedAt: now() }).where(condition).run() + return list + }) + if (refs === null) return notFound(c, '资产不存在') + // Submitted task snapshots and other assets may still need these files. + return success(c, { reference_images: refs }) + } catch (error) { return badRequest(c, (error as Error).message) } + }) +} diff --git a/huobao-source/backend/src/routes/characters.ts b/huobao-source/backend/src/routes/characters.ts new file mode 100644 index 0000000..ea850c9 --- /dev/null +++ b/huobao-source/backend/src/routes/characters.ts @@ -0,0 +1,197 @@ +import { Hono } from 'hono' +import { bodyLimit } from 'hono/body-limit' +import { and, eq, isNull } from 'drizzle-orm' +import fs from 'node:fs/promises' +import { db, getInsertId, schema } from '../db/index.js' +import { success, created, badRequest, notFound, now } from '../utils/response.js' +import { AUDIO_MAX_BYTES, AudioUploadError, saveAudioUpload } from '../utils/audio-upload.js' +import { getAbsolutePath } from '../utils/storage.js' +import { toSnakeCase } from '../utils/transform.js' +import { generateImage } from '../services/generation.js' +import { getDramaStylePrompt } from '../services/style-preset.js' +import { ensureCharacterFinalPrompt } from '../services/final-prompt.js' +import { logTaskError, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +import { assetReferenceImages, registerAssetReferenceRoutes } from './asset-references.js' + +const app = new Hono() +registerAssetReferenceRoutes(app, schema.characters) +const CHARACTER_IMAGE_SIZE = '1920x1080' + +// 用户上传的角色音色样本独立于生图和 AI 提取,跨剧集复用同一个角色绑定。 +app.post('/:id/voice', bodyLimit({ maxSize: AUDIO_MAX_BYTES + 64 * 1024, + onError: c => badRequest(c, '音频文件大小不能超过 20MB'), +}), async c => { + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) return badRequest(c, '角色编号无效') + const condition = and(eq(schema.characters.id, id), isNull(schema.characters.deletedAt)) + const char = db.select({ id: schema.characters.id }).from(schema.characters).where(condition).get() + if (!char) return notFound(c, '角色不存在') + let uploadedPath: string | undefined + try { + const body = await c.req.parseBody() + const audio = await saveAudioUpload(body.file) + uploadedPath = audio.path + const updated = db.update(schema.characters) + .set({ voiceAudioUrl: audio.path, voiceAudioName: audio.name, updatedAt: now() }) + .where(condition).returning({ id: schema.characters.id }).get() + if (!updated) { + await fs.unlink(getAbsolutePath(audio.path)) + return notFound(c, '角色不存在') + } + return success(c, { voice_audio_url: audio.path, voice_audio_name: audio.name }) + } catch (error) { + if (uploadedPath) await fs.unlink(getAbsolutePath(uploadedPath)).catch(() => {}) + if (error instanceof AudioUploadError) return badRequest(c, error.message) + throw error + } +}) + +// 只移除角色绑定,不删除可能仍被引用的媒体文件。 +app.delete('/:id/voice', async c => { + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) return badRequest(c, '角色编号无效') + const updated = db.update(schema.characters) + .set({ voiceAudioUrl: null, voiceAudioName: null, updatedAt: now() }) + .where(and(eq(schema.characters.id, id), isNull(schema.characters.deletedAt))) + .returning({ id: schema.characters.id }).get() + if (!updated) return notFound(c, '角色不存在') + return success(c, { voice_audio_url: null, voice_audio_name: null }) +}) + +// POST /characters — 手动新增角色(传入 episode_id 时关联到该集) +app.post('/', async (c) => { + const body = await c.req.json() + if (!body.drama_id) return badRequest(c, 'drama_id 必填') + if (!body.name?.trim()) return badRequest(c, '名称必填') + const ts = now() + const res = await db.insert(schema.characters).values({ + name: body.name.trim(), + role: body.role || '', + description: body.description || '', + appearance: body.appearance || '', + styling: body.styling || '', + dramaId: body.drama_id, + createdAt: ts, + updatedAt: ts, + }) + const charId = getInsertId(res) + if (body.episode_id) { + const existing = await db.select().from(schema.episodeCharacters) + .where(and(eq(schema.episodeCharacters.episodeId, Number(body.episode_id)), eq(schema.episodeCharacters.characterId, charId))) + if (!existing.length) { + await db.insert(schema.episodeCharacters).values({ episodeId: Number(body.episode_id), characterId: charId, createdAt: ts }) + } + } + const [row] = await db.select().from(schema.characters).where(eq(schema.characters.id, charId)) + return created(c, toSnakeCase(row)) +}) + +function characterImagePrompt(char: typeof schema.characters.$inferSelect, stylePrompt = '') { + return [ + stylePrompt || '', + char.name, + char.appearance || char.description || '人物立绘', + char.styling || '', + '16:9 横版角色定妆照', + '半身角色海报构图', + '正面', + '高质量', + '白色背景', + ].filter(Boolean).join(', ') +} + +// PUT /characters/:id +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const updates: Record = { updatedAt: now() } + for (const key of ['name', 'role', 'description', 'appearance', 'styling', 'imageUrl', 'localPath']) { + const snakeKey = key.replace(/[A-Z]/g, m => '_' + m.toLowerCase()) + if (snakeKey in body) updates[key] = body[snakeKey] + else if (key in body) updates[key] = body[key] + } + // 手动编辑最终提示词时以传入值为准;未传入则保留原值(修改信息时不再自动置空) + if (body.final_prompt !== undefined) updates.finalPrompt = body.final_prompt || null + else if (body.finalPrompt !== undefined) updates.finalPrompt = body.finalPrompt || null + await db.update(schema.characters).set(updates).where(eq(schema.characters.id, id)) + return success(c) +}) + +// DELETE /characters/:id +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + await db.update(schema.characters).set({ deletedAt: now() }).where(eq(schema.characters.id, id)) + return success(c) +}) + +// POST /characters/:id/generate-image +app.post('/:id/generate-image', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [char] = await db.select().from(schema.characters).where(eq(schema.characters.id, id)) + if (!char) return badRequest(c, '角色不存在') + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + + const stylePrompt = await getDramaStylePrompt(char.dramaId) + const finalPrompt = await ensureCharacterFinalPrompt(char, ep.id, false, { model: body.text_model, configId: body.text_config_id ?? undefined }) + const prompt = finalPrompt || characterImagePrompt(char, stylePrompt) + try { + logTaskStart('CharacterImage', 'generate', { characterId: id, episodeId: ep.id, dramaId: char.dramaId }) + const genId = await generateImage({ characterId: id, dramaId: char.dramaId, prompt, referenceImages: assetReferenceImages(char.referenceImages), model: body.model, size: CHARACTER_IMAGE_SIZE, configId: body.config_id ?? ep.imageConfigId ?? undefined }) + logTaskSuccess('CharacterImage', 'generate', { characterId: id, generationId: genId }) + return success(c, { image_generation_id: genId }) + } catch (err: any) { + logTaskError('CharacterImage', 'generate', { characterId: id, error: err.message }) + return badRequest(c, err.message) + } +}) + +// POST /characters/:id/generate-prompt — 独立生成/重新生成三视图最终提示词(不生图) +app.post('/:id/generate-prompt', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [char] = await db.select().from(schema.characters).where(eq(schema.characters.id, id)) + if (!char) return badRequest(c, '角色不存在') + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + + logTaskStart('FinalPrompt', 'character-generate', { characterId: id, episodeId: ep.id, force: !!body.force }) + const finalPrompt = await ensureCharacterFinalPrompt(char, ep.id, !!body.force, { model: body.text_model, configId: body.text_config_id ?? undefined }) + if (!finalPrompt) { + logTaskError('FinalPrompt', 'character-generate', { characterId: id, error: 'agent returned empty prompt' }) + return badRequest(c, '最终提示词生成失败,请重试') + } + logTaskSuccess('FinalPrompt', 'character-generate', { characterId: id }) + return success(c, { final_prompt: finalPrompt }) +}) + +// POST /characters/batch-generate-images +app.post('/batch-generate-images', async (c) => { + const body = await c.req.json() + const ids: number[] = body.character_ids || [] + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + const results: number[] = [] + const stylePrompt = await getDramaStylePrompt(ep.dramaId) + for (const cid of ids) { + const [char] = await db.select().from(schema.characters).where(eq(schema.characters.id, cid)) + if (!char) continue + const finalPrompt = await ensureCharacterFinalPrompt(char, ep.id, false, { model: body.text_model, configId: body.text_config_id ?? undefined }) + const prompt = finalPrompt || characterImagePrompt(char, stylePrompt) + try { + const genId = await generateImage({ characterId: cid, dramaId: char.dramaId, prompt, referenceImages: assetReferenceImages(char.referenceImages), model: body.model, size: CHARACTER_IMAGE_SIZE, configId: body.config_id ?? ep.imageConfigId ?? undefined }) + results.push(genId) + } catch {} + } + logTaskSuccess('CharacterImage', 'batch-generate', { episodeId: ep.id, requested: ids.length, started: results.length }) + return success(c, { count: results.length, ids: results }) +}) + +export default app diff --git a/huobao-source/backend/src/routes/dramas.ts b/huobao-source/backend/src/routes/dramas.ts new file mode 100644 index 0000000..cf44236 --- /dev/null +++ b/huobao-source/backend/src/routes/dramas.ts @@ -0,0 +1,178 @@ +import { Hono } from 'hono' +import { and, eq, isNull, like, desc } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, badRequest, notFound, created, now } from '../utils/response.js' +import { toSnakeCase, toSnakeCaseArray } from '../utils/transform.js' + +const app = new Hono() + +// GET /dramas - List dramas +app.get('/', async (c) => { + const page = Number(c.req.query('page') || 1) + const pageSize = Number(c.req.query('page_size') || 20) + const status = c.req.query('status') + const keyword = c.req.query('keyword') + + const allRows = await db.select().from(schema.dramas) + .where(isNull(schema.dramas.deletedAt)) + .orderBy(desc(schema.dramas.updatedAt)) + let filtered = allRows + + if (status) filtered = filtered.filter(d => d.status === status) + if (keyword) filtered = filtered.filter(d => d.title.includes(keyword)) + + const total = filtered.length + const items = filtered.slice((page - 1) * pageSize, page * pageSize) + + // Attach episode/character/scene counts + const enriched = await Promise.all(items.map(async (drama) => { + const eps = await db.select().from(schema.episodes) + .where(and(eq(schema.episodes.dramaId, drama.id), isNull(schema.episodes.deletedAt))) + const chars = await db.select().from(schema.characters) + .where(eq(schema.characters.dramaId, drama.id)) + const scns = await db.select().from(schema.scenes) + .where(eq(schema.scenes.dramaId, drama.id)) + return { + ...toSnakeCase(drama), + tags: drama.tags ? JSON.parse(drama.tags) : [], + total_episodes: eps.length, + episodes: toSnakeCaseArray(eps), + characters: toSnakeCaseArray(chars), + scenes: toSnakeCaseArray(scns), + } + })) + + return success(c, { + items: enriched, + pagination: { page, page_size: pageSize, total, total_pages: Math.ceil(total / pageSize) }, + }) +}) + +// POST /dramas - Create drama +app.post('/', async (c) => { + const body = await c.req.json() + const ts = now() + const res = await db.insert(schema.dramas).values({ + title: body.title, + description: body.description, + genre: body.genre, + style: body.style, + aspectRatio: body.aspect_ratio || '16:9', + tags: body.tags ? JSON.stringify(body.tags) : null, + metadata: body.metadata, + status: 'draft', + createdAt: ts, + updatedAt: ts, + }) + + const [result] = await db.select().from(schema.dramas) + .where(eq(schema.dramas.id, getInsertId(res))) + + // 不再预建集 — 用户通过「添加集」流程创建(该流程会锁定图片/视频生成配置) + return created(c, toSnakeCase(result)) +}) + + +// GET /dramas/stats — must be before /:id +app.get('/stats', async (c) => { + const all = await db.select().from(schema.dramas).where(isNull(schema.dramas.deletedAt)) + const byStatus = Object.entries( + all.reduce((acc, d) => { + acc[d.status || 'draft'] = (acc[d.status || 'draft'] || 0) + 1 + return acc + }, {} as Record) + ).map(([status, count]) => ({ status, count })) + return success(c, { total: all.length, by_status: byStatus }) +}) + +// GET /dramas/:id - Get drama detail +app.get('/:id', async (c) => { + const id = Number(c.req.param('id')) + const [drama] = await db.select().from(schema.dramas).where(eq(schema.dramas.id, id)) + if (!drama) return notFound(c, '剧本不存在') + + const eps = await db.select().from(schema.episodes) + .where(and(eq(schema.episodes.dramaId, id), isNull(schema.episodes.deletedAt))) + const chars = await db.select().from(schema.characters) + .where(eq(schema.characters.dramaId, id)) + const scns = await db.select().from(schema.scenes) + .where(eq(schema.scenes.dramaId, id)) + const prps = await db.select().from(schema.props) + .where(eq(schema.props.dramaId, id)) + + return success(c, { + ...toSnakeCase(drama), + tags: drama.tags ? JSON.parse(drama.tags) : [], + episodes: toSnakeCaseArray(eps), + characters: toSnakeCaseArray(chars), + scenes: toSnakeCaseArray(scns), + props: toSnakeCaseArray(prps), + }) +}) + +// PUT /dramas/:id - Update drama +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const updates: Record = { updatedAt: now() } + if (body.title !== undefined) updates.title = body.title + if (body.description !== undefined) updates.description = body.description + if (body.genre !== undefined) updates.genre = body.genre + if (body.style !== undefined) updates.style = body.style + if (body.aspect_ratio !== undefined) updates.aspectRatio = body.aspect_ratio + if (body.status !== undefined) updates.status = body.status + if (body.tags !== undefined) updates.tags = JSON.stringify(body.tags) + if (body.metadata !== undefined) updates.metadata = body.metadata + await db.update(schema.dramas).set(updates).where(eq(schema.dramas.id, id)) + return success(c) +}) + +// DELETE /dramas/:id - Soft delete +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + await db.update(schema.dramas).set({ deletedAt: now() }).where(eq(schema.dramas.id, id)) + return success(c) +}) + +// PUT /dramas/:id/characters - Save characters +app.put('/:id/characters', async (c) => { + const dramaId = Number(c.req.param('id')) + const body = await c.req.json() + const chars = body.characters || [] + const ts = now() + + for (const char of chars) { + if (char.id) { + await db.update(schema.characters).set({ ...char, updatedAt: ts }).where(eq(schema.characters.id, char.id)) + } else { + await db.insert(schema.characters).values({ ...char, dramaId, createdAt: ts, updatedAt: ts }) + } + } + return success(c) +}) + +// PUT /dramas/:id/episodes - Save episodes +app.put('/:id/episodes', async (c) => { + const dramaId = Number(c.req.param('id')) + const body = await c.req.json() + const episodes = body.episodes || [] + const ts = now() + + for (const ep of episodes) { + if (ep.id) { + await db.update(schema.episodes).set({ ...ep, updatedAt: ts }).where(eq(schema.episodes.id, ep.id)) + } else { + await db.insert(schema.episodes).values({ + ...ep, + dramaId, + episodeNumber: ep.episode_number || ep.episodeNumber || 1, + title: ep.title || '未命名', + createdAt: ts, + updatedAt: ts, + }) + } + } + return success(c) +}) + +export default app diff --git a/huobao-source/backend/src/routes/episodes.ts b/huobao-source/backend/src/routes/episodes.ts new file mode 100644 index 0000000..934ffee --- /dev/null +++ b/huobao-source/backend/src/routes/episodes.ts @@ -0,0 +1,294 @@ +import { Hono } from 'hono' +import { taskRouting } from '../services/worker-routing.js' +import { and, eq, isNull } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, notFound, badRequest, now } from '../utils/response.js' +import { toSnakeCaseArray, toSnakeCase } from '../utils/transform.js' +import { getActiveConfigId } from '../services/ai.js' +import { EXTRACT_TARGETS, getExtractionStatus, startExtraction, type ExtractTarget } from '../services/extraction.js' +import { getVideoPromptBatchStatus, startVideoPromptBatch } from '../services/video-prompts.js' + +const app = new Hono() + +// POST /episodes — Create a new episode +app.post('/', async (c) => { + const body = await c.req.json() + if (!body.drama_id) return badRequest(c, 'drama_id 必填') + + // 图片/视频配置:显式传入优先,缺省时自动锁定当前启用的最高优先级官方配置 + const imageConfigId = body.image_config_id ?? await getActiveConfigId('image') + const videoConfigId = body.video_config_id ?? await getActiveConfigId('video') + if (!imageConfigId) return badRequest(c, '未找到启用的图片生成配置,请先在设置中心添加') + if (!videoConfigId) return badRequest(c, '未找到启用的视频生成配置,请先在设置中心添加') + const ts = now() + + // Get next episode number(忽略已软删的集,删除中间集后新集号可复用空位之后的最大值) + const existing = await db.select().from(schema.episodes) + .where(and(eq(schema.episodes.dramaId, body.drama_id), isNull(schema.episodes.deletedAt))) + .orderBy(schema.episodes.episodeNumber) + const nextNum = existing.length ? Math.max(...existing.map(e => e.episodeNumber)) + 1 : 1 + + const res = await db.insert(schema.episodes).values({ + dramaId: body.drama_id, + episodeNumber: nextNum, + title: body.title || `第${nextNum}集`, + imageConfigId, + videoConfigId, + // 视频分辨率在创建集时固定(480p/720p/1080p),后续可通过 PUT 修改;各视频适配器再映射为厂商档位 + resolution: ['480p', '720p', '1080p'].includes(body.resolution) ? body.resolution : '720p', + createdAt: ts, + updatedAt: ts, + }) + + const [ep] = await db.select().from(schema.episodes) + .where(eq(schema.episodes.id, getInsertId(res))) + return success(c, { + id: ep.id, + episode_number: ep.episodeNumber, + title: ep.title, + image_config_id: ep.imageConfigId, + video_config_id: ep.videoConfigId, + resolution: ep.resolution, + }) +}) + +// PUT /episodes/:id - Update episode fields +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + + const allowed = ['content', 'script_content', 'title', 'description', 'status', 'resolution'] + const updates: Record = {} + for (const key of allowed) { + if (key in body) updates[key] = body[key] + } + if (Object.keys(updates).length === 0) return badRequest(c, '没有可更新的字段') + if ('resolution' in updates && !['480p', '720p', '1080p'].includes(updates.resolution)) { + return badRequest(c, 'resolution 只支持 480p / 720p / 1080p') + } + + // Map snake_case to camelCase for drizzle + const drizzleUpdates: Record = { updatedAt: now() } + if ('content' in updates) drizzleUpdates.content = updates.content + if ('script_content' in updates) drizzleUpdates.scriptContent = updates.script_content + if ('title' in updates) drizzleUpdates.title = updates.title + if ('description' in updates) drizzleUpdates.description = updates.description + if ('status' in updates) drizzleUpdates.status = updates.status + if ('resolution' in updates) drizzleUpdates.resolution = updates.resolution + + await db.update(schema.episodes).set(drizzleUpdates).where(eq(schema.episodes.id, id)) + return success(c) +}) + +// DELETE /episodes/:id - Soft delete episode(其分镜/生成记录保留但不可达) +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, id)) + if (!ep) return notFound(c, '剧集不存在') + await db.update(schema.episodes).set({ deletedAt: now(), updatedAt: now() }) + .where(eq(schema.episodes.id, id)) + return success(c) +}) + +// GET /episodes/:id/characters — characters linked to this episode +app.get('/:id/characters', async (c) => { + const episodeId = Number(c.req.param('id')) + const links = await db.select().from(schema.episodeCharacters) + .where(eq(schema.episodeCharacters.episodeId, episodeId)) + const charIds = links.map(l => l.characterId) + if (!charIds.length) return success(c, []) + const allChars = await db.select().from(schema.characters) + const result = allChars.filter(ch => charIds.includes(ch.id) && !ch.deletedAt) + return success(c, toSnakeCaseArray(result)) +}) + +// GET /episodes/:id/scenes — scenes linked to this episode +app.get('/:id/scenes', async (c) => { + const episodeId = Number(c.req.param('id')) + const links = await db.select().from(schema.episodeScenes) + .where(eq(schema.episodeScenes.episodeId, episodeId)) + const sceneIds = links.map(l => l.sceneId) + if (!sceneIds.length) return success(c, []) + const allScenes = await db.select().from(schema.scenes) + const result = allScenes.filter(sc => sceneIds.includes(sc.id) && !sc.deletedAt) + return success(c, toSnakeCaseArray(result)) +}) + +// GET /episodes/:id/props — props linked to this episode +app.get('/:id/props', async (c) => { + const episodeId = Number(c.req.param('id')) + const links = await db.select().from(schema.episodeProps) + .where(eq(schema.episodeProps.episodeId, episodeId)) + const propIds = links.map(l => l.propId) + if (!propIds.length) return success(c, []) + const allProps = await db.select().from(schema.props) + const result = allProps.filter(p => propIds.includes(p.id) && !p.deletedAt) + return success(c, toSnakeCaseArray(result)) +}) + +// POST /episodes/:id/extract — 异步提取资产(target: characters | scenes | props),立即返回,前端轮询状态 +app.post('/:id/extract', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const target = body.target as ExtractTarget + if (!EXTRACT_TARGETS.includes(target)) return badRequest(c, 'target 必须是 characters / scenes / props') + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, id)) + if (!ep) return notFound(c, '剧集不存在') + const started = startExtraction(ep.id, ep.dramaId, target, { model: body.model || undefined, configId: body.config_id ?? undefined }) + return success(c, { target, status: 'running', already_running: !started }) +}) + +// GET /episodes/:id/extract-status — 查询三类资产提取任务状态 +app.get('/:id/extract-status', async (c) => { + const id = Number(c.req.param('id')) + return success(c, getExtractionStatus(id)) +}) + +// POST /episodes/:id/generate-video-prompts — 异步批量为缺少视频提示词的分镜生成(立即返回,前端轮询状态) +app.post('/:id/generate-video-prompts', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json().catch(() => ({})) + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, id)) + if (!ep) return notFound(c, '剧集不存在') + const storyboardIds = Array.isArray(body.storyboard_ids) + ? body.storyboard_ids.map(Number).filter((n: number) => Number.isInteger(n) && n > 0) + : undefined + const result = await startVideoPromptBatch(ep.id, ep.dramaId, { model: body.model || undefined, configId: body.config_id ?? undefined }, storyboardIds) + if (result.total === -1) return success(c, { status: 'running', already_running: true }) + if (!result.started) return success(c, { status: 'idle', total: 0 }) + return success(c, { status: 'running', total: result.total }) +}) + +// GET /episodes/:id/video-prompts-status — 查询批量视频提示词任务状态 +app.get('/:id/video-prompts-status', async (c) => { + const id = Number(c.req.param('id')) + return success(c, getVideoPromptBatchStatus(id)) +}) + +// GET /episodes/:episode_id/storyboards +app.get('/:episode_id/storyboards', async (c) => { + const episodeId = Number(c.req.param('episode_id')) + const rows = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + .orderBy(schema.storyboards.storyboardNumber) + + const links = await db.select().from(schema.storyboardCharacters) + const charIdsByStoryboard = new Map() + for (const link of links) { + const arr = charIdsByStoryboard.get(link.storyboardId) || [] + arr.push(link.characterId) + charIdsByStoryboard.set(link.storyboardId, arr) + } + + const propLinks = await db.select().from(schema.storyboardProps) + const propIdsByStoryboard = new Map() + for (const link of propLinks) { + const arr = propIdsByStoryboard.get(link.storyboardId) || [] + arr.push(link.propId) + propIdsByStoryboard.set(link.storyboardId, arr) + } + + const episodeCharLinks = await db.select().from(schema.episodeCharacters) + .where(eq(schema.episodeCharacters.episodeId, episodeId)) + const episodeCharIds = episodeCharLinks.map(link => link.characterId) + const allChars = (await db.select().from(schema.characters)) + .filter(ch => episodeCharIds.includes(ch.id) && !ch.deletedAt) + + const episodePropLinks = await db.select().from(schema.episodeProps) + .where(eq(schema.episodeProps.episodeId, episodeId)) + const episodePropIds = episodePropLinks.map(link => link.propId) + const allProps = (await db.select().from(schema.props)) + .filter(p => episodePropIds.includes(p.id) && !p.deletedAt) + + return success(c, rows.map((row) => ({ + ...toSnakeCase(row), + character_ids: charIdsByStoryboard.get(row.id) || [], + prop_ids: propIdsByStoryboard.get(row.id) || [], + characters: allChars + .filter(ch => (charIdsByStoryboard.get(row.id) || []).includes(ch.id)) + .map(ch => toSnakeCase(ch)), + props: allProps + .filter(p => (propIdsByStoryboard.get(row.id) || []).includes(p.id)) + .map(p => toSnakeCase(p)), + }))) +}) + +// GET /episodes/:id/pipeline-status — 流水线进度 +// GET /episodes/:id/generation-tasks — 按集聚合 sys_task + video_merges +// sys_task 无 episode_id,通过 storyboard/scene/character/prop 关联键归属到当前集 +app.get('/:id/generation-tasks', async (c) => { + const episodeId = Number(c.req.param('id')) + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, episodeId)) + if (!ep) return notFound(c, '剧集不存在') + + const sbs = await db.select().from(schema.storyboards).where(eq(schema.storyboards.episodeId, episodeId)) + const storyboardIds = new Set(sbs.map(s => s.id)) + + const epScenes = await db.select().from(schema.episodeScenes).where(eq(schema.episodeScenes.episodeId, episodeId)) + const sceneIds = new Set(epScenes.map(r => r.sceneId)) + // 兼容 scenes.episodeId 直挂的旧数据 + const directScenes = await db.select().from(schema.scenes).where(eq(schema.scenes.episodeId, episodeId)) + directScenes.forEach(s => sceneIds.add(s.id)) + + const epChars = await db.select().from(schema.episodeCharacters).where(eq(schema.episodeCharacters.episodeId, episodeId)) + const characterIds = new Set(epChars.map(r => r.characterId)) + + const dramaProps = await db.select().from(schema.props).where(eq(schema.props.dramaId, ep.dramaId)) + const propIds = new Set(dramaProps.map(p => p.id)) + + const allTasks = await db.select().from(schema.sysTask).where(eq(schema.sysTask.dramaId, ep.dramaId)) + const tasks = allTasks + .filter(t => + (t.storyboardId && storyboardIds.has(t.storyboardId)) || + (t.sceneId && sceneIds.has(t.sceneId)) || + (t.characterId && characterIds.has(t.characterId)) || + (t.propId && propIds.has(t.propId)) + ) + .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')) + + const merges = (await db.select().from(schema.videoMerges) + .where(and(eq(schema.videoMerges.episodeId, episodeId), isNull(schema.videoMerges.deletedAt)))) + .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')) + .slice(0, 20) + + return success(c, { + tasks: toSnakeCaseArray(tasks.map(task => ({ ...task, routing: taskRouting(task) }))), + merges: toSnakeCaseArray(merges), + }) +}) + +app.get('/:id/pipeline-status', async (c) => { + const episodeId = Number(c.req.param('id')) + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, episodeId)) + if (!ep) return notFound(c, '剧集不存在') + + const chars = await db.select().from(schema.characters).where(eq(schema.characters.dramaId, ep.dramaId)) + const scenes = await db.select().from(schema.scenes).where(eq(schema.scenes.dramaId, ep.dramaId)) + const sbs = await db.select().from(schema.storyboards).where(eq(schema.storyboards.episodeId, episodeId)) + const merges = await db.select().from(schema.videoMerges).where(eq(schema.videoMerges.episodeId, episodeId)) + + const sbsWithImage = sbs.filter(s => s.composedImage) + const sbsWithVideo = sbs.filter(s => s.videoUrl) + const latestMerge = merges[merges.length - 1] + + function stepStatus(done: boolean, partial?: boolean) { + if (done) return 'done' + if (partial) return 'partial' + return 'pending' + } + + return success(c, { + episode_id: episodeId, + steps: { + script_rewrite: { status: ep.scriptContent ? 'done' : (ep.content ? 'ready' : 'pending') }, + extract_characters: { status: stepStatus(chars.length > 0), count: chars.length }, + extract_scenes: { status: stepStatus(scenes.length > 0), count: scenes.length }, + extract_storyboards: { status: stepStatus(sbs.length > 0), count: sbs.length }, + generate_images: { status: stepStatus(sbsWithImage.length === sbs.length && sbs.length > 0, sbsWithImage.length > 0), completed: sbsWithImage.length, total: sbs.length }, + generate_videos: { status: stepStatus(sbsWithVideo.length === sbs.length && sbs.length > 0, sbsWithVideo.length > 0), completed: sbsWithVideo.length, total: sbs.length }, + merge_episode: { status: latestMerge?.status === 'completed' ? 'done' : (latestMerge ? latestMerge.status : 'pending'), merged_url: latestMerge?.mergedUrl }, + }, + }) +}) + +export default app diff --git a/huobao-source/backend/src/routes/hd.ts b/huobao-source/backend/src/routes/hd.ts new file mode 100644 index 0000000..0393c40 --- /dev/null +++ b/huobao-source/backend/src/routes/hd.ts @@ -0,0 +1,116 @@ +import { Hono } from 'hono' +import { and, asc, eq } from 'drizzle-orm' +import fs from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { db, schema } from '../db/index.js' +import { success, created, badRequest, now } from '../utils/response.js' +import { getAbsolutePath } from '../utils/storage.js' +import { STORAGE_ROOT } from '../utils/paths.js' +import { ffmpeg, checkFfmpegSuite } from '../utils/ffmpeg.js' +import { generateVideo } from '../services/generation.js' +import { getDramaStylePrompt } from '../services/style-preset.js' +import { safeTaskError } from '../services/video-download.js' +import { parseConfigGpu } from '../services/ai.js' +import { taskRouting } from '../services/worker-routing.js' + +const app = new Hono() +const MODEL = 'MiniMax-H3-VDN-H3' +type HdParams = { variant?: string; originalVideoUrl?: string; resolution?: string; gpu?: string } +function params(raw: string | null): HdParams { + try { return JSON.parse(raw || '{}') } catch { return {} } +} +function localVideo(url: string) { + const absolute = getAbsolutePath(url) + const relative = path.relative(STORAGE_ROOT, absolute) + if (!url.startsWith('static/videos/') || relative.startsWith('..') || path.isAbsolute(relative) || !fs.existsSync(absolute)) throw new Error('原视频尚未保存到本地,请先完成视频制作') + return absolute +} +async function versions(id: number) { + return db.select().from(schema.sysTask).where(and(eq(schema.sysTask.storyboardId, id), eq(schema.sysTask.type, 'video'))).orderBy(asc(schema.sysTask.id)) +} +async function context(id: number) { + const [sb] = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, id)) + if (!sb) throw new Error('分镜不存在') + const tasks = await versions(id) + const original = tasks.map(t => params(t.params).originalVideoUrl).find(Boolean) || sb.videoUrl + return { sb, tasks, original } +} + +app.get('/episodes/:id', async c => { + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) return badRequest(c, '集 ID 无效') + const boards = await db.select().from(schema.storyboards).where(eq(schema.storyboards.episodeId, id)) + const configs = (await db.select().from(schema.aiServiceConfigs)).filter(row => row.isActive && row.serviceType === 'video' && row.provider === 'localvideo' && row.model?.includes(MODEL)).sort((a, b) => (b.priority || 0) - (a.priority || 0)) + return success(c, { + configured: configs.length > 0, + gpu: parseConfigGpu(configs[0]?.settings), + configs: configs.map(row => ({ id: row.id, name: row.name, gpu: parseConfigGpu(row.settings) })), + items: await Promise.all(boards.map(async sb => { + const { tasks, original } = await context(sb.id) + return { id: sb.id, title: sb.title, order: sb.storyboardNumber, current: sb.videoUrl, original, + busy: tasks.some(t => t.status === 'processing'), + busyTaskId: [...tasks].reverse().find(t => t.status === 'processing')?.id, + activeTask: [...tasks].reverse().filter(t => params(t.params).variant === 'hd').map(t => ({ id: t.id, status: t.status, model: t.model, configId: t.configId, gpu: params(t.params).gpu, routing: taskRouting(t) }))[0], + versions: tasks.filter(t => params(t.params).variant === 'hd').map(t => ({ id: t.id, status: t.status, + resolution: params(t.params).resolution, gpu: params(t.params).gpu, routing: taskRouting(t), url: t.localPath, error: t.errorMsg ? safeTaskError(t.errorMsg) : null, createdAt: t.createdAt })) } + })), + }) +}) + +const submitting = new Set() +app.post('/storyboards/:id', async c => { + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) return badRequest(c, '分镜 ID 无效') + if (submitting.has(id)) return badRequest(c, '该分镜正在提交,请勿重复点击') + submitting.add(id) + try { + const body: { resolution?: string; config_id?: number; gpu?: 'auto' | 'gpu1' | 'gpu2' | 'gpu3'; replace_task_id?: number } = await c.req.json() + if (body.resolution !== '2K' && body.resolution !== '4K') throw new Error('请选择 2K 或 4K') + const { sb, tasks, original } = await context(id) + if (!original) throw new Error('请先完成该分镜的视频制作') + if (tasks.some(t => t.status === 'processing' && t.id !== body.replace_task_id)) throw new Error('该分镜仍有任务运行,请等待完成') + const source = localVideo(original) + const config = (await db.select().from(schema.aiServiceConfigs)).sort((a, b) => (b.priority || 0) - (a.priority || 0)).find(row => row.isActive && row.serviceType === 'video' && row.provider === 'localvideo' && row.model?.includes(MODEL) && (body.config_id === undefined || row.id === body.config_id)) + if (!config) throw new Error('请先启用 MiniMax H3-VDN-H3 高清服务') + const [episode] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, sb.episodeId)) + const originalTask = tasks.find(t => t.localPath === original) + const prompt = originalTask?.prompt || [await getDramaStylePrompt(episode?.dramaId), sb.videoPrompt || sb.description].filter(Boolean).join('\n') + if (!prompt?.trim()) throw new Error('请先填写该分镜的视频提示词') + const suite = await checkFfmpegSuite() + if (!suite.ffmpeg || !suite.ffprobe) throw new Error('视频工具不可用,无法读取原视频参考画面') + const meta = await new Promise((resolve, reject) => ffmpeg.ffprobe(source, (err, data) => err ? reject(err) : resolve(data))) + const stream = meta.streams.find(s => s.codec_type === 'video') + if (!stream?.width || !stream.height) throw new Error('原视频尺寸无法读取') + const ratio = stream.width / stream.height + if (Math.min(Math.abs(ratio - 16 / 9), Math.abs(ratio - 9 / 16)) > 0.08) throw new Error('高清制作目前支持 16:9 横屏或 9:16 竖屏视频') + const duration = Number(meta.format.duration) + if (!Number.isFinite(duration) || duration < 1 || duration > 15.2) throw new Error('高清制作支持 1–15 秒的分镜视频') + const ref = `static/images/hd-reference-${randomUUID()}.png` + fs.mkdirSync(path.dirname(getAbsolutePath(ref)), { recursive: true }) + await new Promise((resolve, reject) => ffmpeg(source).seekInput(0).frames(1).output(getAbsolutePath(ref)).on('end', () => resolve()).on('error', reject).run()) + const taskId = await generateVideo({ storyboardId: id, dramaId: episode?.dramaId, prompt, + gpu: body.gpu, replaceTaskId: body.replace_task_id, + configId: config.id, model: MODEL, variant: 'hd', originalVideoUrl: original, + referenceImageUrls: [ref], referenceMode: 'reference', resolution: body.resolution, + aspectRatio: ratio > 1 ? '16:9' : '9:16', duration: Math.min(15, Math.max(4, Math.round(duration))), forceNew: true }) + return created(c, { id: taskId }) + } catch (error) { return badRequest(c, safeTaskError(error)) } + finally { submitting.delete(id) } +}) + +app.post('/storyboards/:id/select', async c => { + try { + const id = Number(c.req.param('id')) + const body: { task_id?: number; original?: boolean } = await c.req.json() + const { tasks, original } = await context(id) + if (tasks.some(t => t.status === 'processing')) throw new Error('请等待当前视频任务完成后再切换导出版本') + const selected = tasks.find(t => t.id === body.task_id && t.status === 'completed' && params(t.params).variant === 'hd') + const url = body.original === true ? original : selected?.localPath + if (!url) throw new Error('所选视频版本尚未完成或不存在') + localVideo(url) + await db.update(schema.storyboards).set({ videoUrl: url, updatedAt: now() }).where(eq(schema.storyboards.id, id)) + return success(c, { video_url: url }) + } catch (error) { return badRequest(c, safeTaskError(error)) } +}) +export default app diff --git a/huobao-source/backend/src/routes/merge.ts b/huobao-source/backend/src/routes/merge.ts new file mode 100644 index 0000000..3c0c700 --- /dev/null +++ b/huobao-source/backend/src/routes/merge.ts @@ -0,0 +1,58 @@ +import { Hono } from 'hono' +import { and, eq, isNull } from 'drizzle-orm' +import { db, schema } from '../db/index.js' +import { success, badRequest } from '../utils/response.js' +import { mergeEpisodeVideos } from '../services/ffmpeg-merge.js' +import { toSnakeCase } from '../utils/transform.js' +import { logTaskError, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +const app = new Hono() + +// POST /episodes/:id/merge — 拼接镜头视频(body.storyboard_ids 可选,只拼所选) +app.post('/episodes/:id/merge', async (c) => { + const episodeId = Number(c.req.param('id')) + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, episodeId)) + if (!ep) return badRequest(c, '剧集不存在') + + let storyboardIds: number[] | undefined + try { + const body = await c.req.json() + if (Array.isArray(body?.storyboard_ids)) { + storyboardIds = body.storyboard_ids.map(Number).filter(Boolean) + } + } catch { /* 无 body 时拼接全部 */ } + + try { + logTaskStart('MergeAPI', 'episode-merge', { episodeId, dramaId: ep.dramaId, storyboardIds }) + const mergeId = await mergeEpisodeVideos(episodeId, ep.dramaId, storyboardIds) + logTaskSuccess('MergeAPI', 'episode-merge', { episodeId, mergeId }) + return success(c, { merge_id: mergeId, status: 'processing' }) + } catch (err: any) { + logTaskError('MergeAPI', 'episode-merge', { episodeId, error: err.message }) + return badRequest(c, err.message) + } +}) + +// GET /episodes/:id/merge — 查询最新拼接状态 +app.get('/episodes/:id/merge', async (c) => { + const episodeId = Number(c.req.param('id')) + const merges = await db.select().from(schema.videoMerges) + .where(eq(schema.videoMerges.episodeId, episodeId)) + + + const latest = merges[merges.length - 1] + if (!latest) return success(c, null) + + return success(c, toSnakeCase(latest)) +}) + +// GET /episodes/:id/merges — 成片列表(全部拼接记录,新的在前) +app.get('/episodes/:id/merges', async (c) => { + const episodeId = Number(c.req.param('id')) + const merges = await db.select().from(schema.videoMerges) + .where(and(eq(schema.videoMerges.episodeId, episodeId), isNull(schema.videoMerges.deletedAt))) + merges.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')) + return success(c, merges.slice(0, 30).map(toSnakeCase)) +}) + +export default app diff --git a/huobao-source/backend/src/routes/prompts.ts b/huobao-source/backend/src/routes/prompts.ts new file mode 100644 index 0000000..e28a377 --- /dev/null +++ b/huobao-source/backend/src/routes/prompts.ts @@ -0,0 +1,96 @@ +/** + * Agent prompt 管理路由 — 读写 workspace/prompts/[.].md + * 文件操作 jail 在 backend/workspace/ 目录内(仿 skills 路由) + * ?lang=en/ja/ko 读写语言变体;zh/缺省为基础版(历史行为) + */ +import { Hono } from 'hono' +import { success, badRequest } from '../utils/response.js' +import { skillsManagerWorkspace } from '../agents/skills.js' +import { validAgentTypes, DEFAULT_PROMPTS } from '../agents/index.js' +import { loadAgentPromptFile, serializePromptFile, promptFilePath } from '../agents/prompts.js' + +const app = new Hono() +const fsm = () => skillsManagerWorkspace.filesystem! + +const checkType = (type: string) => validAgentTypes.includes(type) +const LANGS = ['zh', 'en', 'ja', 'ko'] +const normalizeLang = (v?: string) => (v && LANGS.includes(v) ? v : 'zh') + +// GET /prompts — 列出全部 Agent 的 prompt 状态(基础版口径;name/model 只属于基础版) +app.get('/', async (c) => { + const list = await Promise.all(validAgentTypes.map(async (type) => { + const file = await loadAgentPromptFile(type) + return { + agent_type: type, + name: file?.name || DEFAULT_PROMPTS[type].name, + model: file?.model || '', + is_default: !file, + } + })) + return success(c, list) +}) + +// GET /prompts/:type?lang= — 有效内容(语言变体优先,缺失回退基础版,再缺失回退代码默认) +// is_default=true 表示当前语言无独立文件(内容为回退版本),前端据此提示「跟随中文」 +app.get('/:type', async (c) => { + const type = c.req.param('type') + if (!checkType(type)) return badRequest(c, '未知的 Agent 类型') + const lang = normalizeLang(c.req.query('lang')) + const [file, base] = await Promise.all([ + loadAgentPromptFile(type, lang), + lang === 'zh' ? Promise.resolve(null) : loadAgentPromptFile(type), + ]) + // 非 zh 且变体不存在时 file 即基础版;通过路径再确认一次变体存在性 + const variantExists = lang !== 'zh' && await fsm().exists(promptFilePath(type, lang)) + if (file) { + return success(c, { + agent_type: type, + lang, + name: base?.name || file.name || DEFAULT_PROMPTS[type].name, + model: base?.model || file.model, + system_prompt: file.instructions, + is_default: lang === 'zh' ? false : !variantExists, + }) + } + return success(c, { + agent_type: type, + lang, + name: DEFAULT_PROMPTS[type].name, + model: '', + system_prompt: DEFAULT_PROMPTS[type].instructions, + is_default: true, + }) +}) + +// PUT /prompts/:type?lang= — 保存为 prompt 文件(lang 非 zh 写语言变体;model 只随基础版保存) +app.put('/:type', async (c) => { + const type = c.req.param('type') + if (!checkType(type)) return badRequest(c, '未知的 Agent 类型') + const body = await c.req.json() + const lang = normalizeLang(c.req.query('lang') ?? body.lang) + const instructions = String(body.system_prompt ?? '').trim() + if (!instructions) return badRequest(c, 'system_prompt 必填') + if (lang === 'zh') { + const name = String(body.name || DEFAULT_PROMPTS[type].name) + const model = String(body.model ?? '').trim() + await fsm().writeFile(promptFilePath(type), serializePromptFile({ name, model, instructions }), { recursive: true }) + return success(c, { agent_type: type, lang, name, model, is_default: false }) + } + // 语言变体:model 不从变体读取,name 仅作展示;保留变体自己的 name(缺省用基础版/默认名) + const base = await loadAgentPromptFile(type) + const name = String(body.name || base?.name || DEFAULT_PROMPTS[type].name) + await fsm().writeFile(promptFilePath(type, lang), serializePromptFile({ name, model: '', instructions }), { recursive: true }) + return success(c, { agent_type: type, lang, name, is_default: false }) +}) + +// POST /prompts/:type/reset?lang= — 删除对应语言文件,回退(变体→基础版;基础版→代码默认) +app.post('/:type/reset', async (c) => { + const type = c.req.param('type') + if (!checkType(type)) return badRequest(c, '未知的 Agent 类型') + const lang = normalizeLang(c.req.query('lang')) + const path = promptFilePath(type, lang) + if (await fsm().exists(path)) await fsm().deleteFile(path) + return success(c, { agent_type: type, lang, is_default: true }) +}) + +export default app diff --git a/huobao-source/backend/src/routes/props.ts b/huobao-source/backend/src/routes/props.ts new file mode 100644 index 0000000..cc50bd9 --- /dev/null +++ b/huobao-source/backend/src/routes/props.ts @@ -0,0 +1,131 @@ +import { Hono } from 'hono' +import { and, eq } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, created, badRequest, now } from '../utils/response.js' +import { toSnakeCase } from '../utils/transform.js' +import { generateImage } from '../services/generation.js' +import { getDramaStylePrompt } from '../services/style-preset.js' +import { ensurePropFinalPrompt } from '../services/final-prompt.js' +import { logTaskError, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +import { assetReferenceImages, registerAssetReferenceRoutes } from './asset-references.js' + +const app = new Hono() +registerAssetReferenceRoutes(app, schema.props) +// 道具图:白底单品静物,方形画布 +const PROP_IMAGE_SIZE = '1024x1024' + +// POST /props — 手动新增道具(传入 episode_id 时关联到该集) +app.post('/', async (c) => { + const body = await c.req.json() + if (!body.drama_id) return badRequest(c, 'drama_id 必填') + if (!body.name?.trim()) return badRequest(c, '名称必填') + const ts = now() + const res = await db.insert(schema.props).values({ + name: body.name.trim(), + type: body.type || '', + description: body.description || '', + dramaId: body.drama_id, + createdAt: ts, + updatedAt: ts, + }) + const propId = getInsertId(res) + if (body.episode_id) { + const existing = await db.select().from(schema.episodeProps) + .where(and(eq(schema.episodeProps.episodeId, Number(body.episode_id)), eq(schema.episodeProps.propId, propId))) + if (!existing.length) { + await db.insert(schema.episodeProps).values({ episodeId: Number(body.episode_id), propId, createdAt: ts }) + } + } + const [row] = await db.select().from(schema.props).where(eq(schema.props.id, propId)) + return created(c, toSnakeCase(row)) +}) + +// DELETE /props/:id — 软删除 +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + await db.update(schema.props).set({ deletedAt: now(), updatedAt: now() }).where(eq(schema.props.id, id)) + return success(c) +}) + +// PUT /props/:id — 更新道具(物品外貌/类型/最终提示词) +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const updates: Record = { updatedAt: now() } + if (body.name !== undefined) updates.name = body.name + if (body.type !== undefined) updates.type = body.type + if (body.description !== undefined) updates.description = body.description + // 用户上传道具图:直接写入图片地址与本地路径 + if (body.image_url !== undefined) updates.imageUrl = body.image_url + else if (body.imageUrl !== undefined) updates.imageUrl = body.imageUrl + if (body.local_path !== undefined) updates.localPath = body.local_path + else if (body.localPath !== undefined) updates.localPath = body.localPath + // 手动编辑最终提示词时以传入值为准;未传入则保留原值(修改信息时不再自动置空) + if (body.final_prompt !== undefined) updates.finalPrompt = body.final_prompt || null + else if (body.finalPrompt !== undefined) updates.finalPrompt = body.finalPrompt || null + await db.update(schema.props).set(updates).where(eq(schema.props.id, id)) + return success(c) +}) + +/** 本地兜底提示词:白底单品,不掺杂其他元素 */ +function propImagePrompt(prop: typeof schema.props.$inferSelect, stylePrompt = '') { + return [ + stylePrompt || '', + `single product photo of ${prop.name}`, + prop.description || '', + 'isolated on a pure white background', + 'no other objects, no people, no scenery', + 'soft even studio lighting', + 'high detail', + 'no text, no watermark', + ].filter(Boolean).join(', ') +} + +// POST /props/:id/generate-prompt — 独立生成/重新生成白底单品最终提示词(不生图) +app.post('/:id/generate-prompt', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [prop] = await db.select().from(schema.props).where(eq(schema.props.id, id)) + if (!prop) return badRequest(c, '道具不存在') + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + + logTaskStart('FinalPrompt', 'prop-generate', { propId: id, episodeId: ep.id, force: !!body.force }) + const finalPrompt = await ensurePropFinalPrompt(prop, ep.id, !!body.force, { model: body.text_model, configId: body.text_config_id ?? undefined }) + if (!finalPrompt) { + logTaskError('FinalPrompt', 'prop-generate', { propId: id, error: 'agent returned empty prompt' }) + return badRequest(c, '最终提示词生成失败,请重试') + } + logTaskSuccess('FinalPrompt', 'prop-generate', { propId: id }) + return success(c, { final_prompt: finalPrompt }) +}) + +// POST /props/:id/generate-image — 生成道具白底单品图 +app.post('/:id/generate-image', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [prop] = await db.select().from(schema.props).where(eq(schema.props.id, id)) + if (!prop) return badRequest(c, '道具不存在') + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + + const stylePrompt = await getDramaStylePrompt(prop.dramaId) + const finalPrompt = await ensurePropFinalPrompt(prop, ep.id, false, { model: body.text_model, configId: body.text_config_id ?? undefined }) + const prompt = finalPrompt || propImagePrompt(prop, stylePrompt) + try { + logTaskStart('PropImage', 'generate', { propId: id, episodeId: ep.id, dramaId: prop.dramaId }) + const genId = await generateImage({ propId: id, dramaId: prop.dramaId, prompt, referenceImages: assetReferenceImages(prop.referenceImages), model: body.model, size: PROP_IMAGE_SIZE, configId: body.config_id ?? ep.imageConfigId ?? undefined }) + logTaskSuccess('PropImage', 'generate', { propId: id, generationId: genId }) + return success(c, { image_generation_id: genId }) + } catch (err: any) { + logTaskError('PropImage', 'generate', { propId: id, error: err.message }) + return badRequest(c, err.message) + } +}) + +export default app diff --git a/huobao-source/backend/src/routes/scenes.ts b/huobao-source/backend/src/routes/scenes.ts new file mode 100644 index 0000000..c3d39ae --- /dev/null +++ b/huobao-source/backend/src/routes/scenes.ts @@ -0,0 +1,131 @@ +import { Hono } from 'hono' +import { and, eq } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, created, badRequest, now } from '../utils/response.js' +import { generateImage } from '../services/generation.js' +import { getDramaStylePrompt } from '../services/style-preset.js' +import { ensureSceneFinalPrompt } from '../services/final-prompt.js' +import { logTaskError, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +import { assetReferenceImages, registerAssetReferenceRoutes } from './asset-references.js' + +const app = new Hono() +registerAssetReferenceRoutes(app, schema.scenes) + +// POST /scenes — 手动新增场景(传入 episode_id 时关联到该集) +app.post('/', async (c) => { + const body = await c.req.json() + if (!body.drama_id) return badRequest(c, 'drama_id 必填') + if (!body.location?.trim()) return badRequest(c, '地点必填') + const ts = now() + const res = await db.insert(schema.scenes).values({ + dramaId: body.drama_id, + episodeId: body.episode_id, + location: body.location.trim(), + time: body.time || '', + prompt: body.prompt || body.description || body.location, + lighting: body.lighting || '', + createdAt: ts, + updatedAt: ts, + }) + const sceneId = getInsertId(res) + if (body.episode_id) { + const existing = await db.select().from(schema.episodeScenes) + .where(and(eq(schema.episodeScenes.episodeId, Number(body.episode_id)), eq(schema.episodeScenes.sceneId, sceneId))) + if (!existing.length) { + await db.insert(schema.episodeScenes).values({ episodeId: Number(body.episode_id), sceneId, createdAt: ts }) + } + } + const [result] = await db.select().from(schema.scenes) + .where(eq(schema.scenes.id, sceneId)) + return created(c, result) +}) + +// PUT /scenes/:id +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const updates: Record = { updatedAt: now() } + if (body.location !== undefined) updates.location = body.location + if (body.time !== undefined) updates.time = body.time + if (body.prompt !== undefined) updates.prompt = body.prompt + if (body.description !== undefined) updates.prompt = body.description + if (body.lighting !== undefined) updates.lighting = body.lighting + // 用户上传场景图:直接写入图片地址与本地路径 + const uploadedImage = body.image_url ?? body.imageUrl + if (uploadedImage !== undefined) { + updates.imageUrl = uploadedImage + if (uploadedImage) updates.status = 'completed' + } + if (body.local_path !== undefined) updates.localPath = body.local_path + else if (body.localPath !== undefined) updates.localPath = body.localPath + // 手动编辑最终提示词时以传入值为准;未传入则保留原值(修改信息时不再自动置空) + if (body.final_prompt !== undefined) updates.finalPrompt = body.final_prompt || null + else if (body.finalPrompt !== undefined) updates.finalPrompt = body.finalPrompt || null + await db.update(schema.scenes).set(updates).where(eq(schema.scenes.id, id)) + return success(c) +}) + +// POST /scenes/:id/generate-image +app.post('/:id/generate-image', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [scene] = await db.select().from(schema.scenes).where(eq(schema.scenes.id, id)) + if (!scene) return badRequest(c, '场景不存在') + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + + const stylePrompt = await getDramaStylePrompt(scene.dramaId) + const finalPrompt = await ensureSceneFinalPrompt(scene, ep.id, false, { model: body.text_model, configId: body.text_config_id ?? undefined }) + // 回退拼接也要守住无人物约束:场景描述(prompt)可能含人物活动,直接拼会让人混进图里 + const prompt = finalPrompt || [ + stylePrompt || '', + scene.location, + scene.time || '', + scene.prompt || '高质量场景', + scene.lighting || '电影感光影', + '画面中没有任何人物,空场景,只有场景本身', + ].filter(Boolean).join(', ') + try { + logTaskStart('SceneImage', 'generate', { sceneId: id, episodeId: ep.id, dramaId: scene.dramaId, location: scene.location }) + await db.update(schema.scenes).set({ status: 'processing', updatedAt: now() }).where(eq(schema.scenes.id, id)) + const genId = await generateImage({ sceneId: id, dramaId: scene.dramaId, prompt, referenceImages: assetReferenceImages(scene.referenceImages), model: body.model, configId: body.config_id ?? ep.imageConfigId ?? undefined }) + logTaskSuccess('SceneImage', 'generate', { sceneId: id, generationId: genId }) + return success(c, { image_generation_id: genId }) + } catch (err: any) { + logTaskError('SceneImage', 'generate', { sceneId: id, error: err.message }) + await db.update(schema.scenes).set({ status: 'failed', updatedAt: now() }).where(eq(schema.scenes.id, id)) + return badRequest(c, err.message) + } +}) + +// POST /scenes/:id/generate-prompt — 独立生成/重新生成固定视角最终提示词(不生图) +app.post('/:id/generate-prompt', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [scene] = await db.select().from(schema.scenes).where(eq(schema.scenes.id, id)) + if (!scene) return badRequest(c, '场景不存在') + if (!body.episode_id) return badRequest(c, 'episode_id 必填') + + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, Number(body.episode_id))) + if (!ep) return badRequest(c, '剧集不存在') + + logTaskStart('FinalPrompt', 'scene-generate', { sceneId: id, episodeId: ep.id, force: !!body.force }) + const finalPrompt = await ensureSceneFinalPrompt(scene, ep.id, !!body.force, { model: body.text_model, configId: body.text_config_id ?? undefined }) + if (!finalPrompt) { + logTaskError('FinalPrompt', 'scene-generate', { sceneId: id, error: 'agent returned empty prompt' }) + return badRequest(c, '最终提示词生成失败,请重试') + } + logTaskSuccess('FinalPrompt', 'scene-generate', { sceneId: id }) + return success(c, { final_prompt: finalPrompt }) +}) + +// DELETE /scenes/:id — 软删除(保留历史生成记录) +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + await db.update(schema.scenes).set({ deletedAt: now(), updatedAt: now() }).where(eq(schema.scenes.id, id)) + return success(c) +}) + +export default app diff --git a/huobao-source/backend/src/routes/serverUpdate.ts b/huobao-source/backend/src/routes/serverUpdate.ts new file mode 100644 index 0000000..2ffc7a8 --- /dev/null +++ b/huobao-source/backend/src/routes/serverUpdate.ts @@ -0,0 +1,26 @@ +/** + * 服务器 / Docker 部署的版本检查与更新路由(桌面版更新走 Electron 桥,不经此路由) + */ +import { Hono } from 'hono' +import { getServerUpdateState, checkServerUpdate, triggerWatchtowerUpdate } from '../services/server-update.js' +import { success, badRequest } from '../utils/response.js' + +const app = new Hono() + +// GET /state — 当前版本 + 更新模式 + 上次检查结果 +app.get('/state', (c) => success(c, getServerUpdateState())) + +// POST /check — 拉取发布清单比较版本 +app.post('/check', async (c) => success(c, await checkServerUpdate())) + +// POST /apply — 触发 Watchtower 拉新镜像并重建本容器(仅 watchtower 模式可用) +app.post('/apply', async (c) => { + try { + await triggerWatchtowerUpdate() + return success(c, { triggered: true }) + } catch (err) { + return badRequest(c, (err as Error).message) + } +}) + +export default app diff --git a/huobao-source/backend/src/routes/settings.ts b/huobao-source/backend/src/routes/settings.ts new file mode 100644 index 0000000..9f19757 --- /dev/null +++ b/huobao-source/backend/src/routes/settings.ts @@ -0,0 +1,40 @@ +/** + * 应用设置路由 — 全局配置的读写入口(当前:AI 内容语言) + */ +import { Hono } from 'hono' +import { getContentLanguage, setContentLanguage, getToursSeen, setToursSeen, CONTENT_LANGUAGES, type ContentLanguage } from '../services/app-settings.js' +import { success, badRequest } from '../utils/response.js' + +const app = new Hono() + +// GET /content-language — 当前 AI 内容语言 +app.get('/content-language', async (c) => { + return success(c, { language: await getContentLanguage() }) +}) + +// PUT /content-language — 设置 AI 内容语言(body: { language: 'zh'|'en'|'ja'|'ko' }) +app.put('/content-language', async (c) => { + const body = await c.req.json().catch(() => null) + const language = body?.language + if (!(CONTENT_LANGUAGES as readonly string[]).includes(language)) { + return badRequest(c, `language 必须是 ${CONTENT_LANGUAGES.join(' / ')} 之一`) + } + const saved = await setContentLanguage(language as ContentLanguage) + return success(c, { language: saved }) +}) + +// GET /tours-seen — 已看过的引导漫游 id 列表 +app.get('/tours-seen', async (c) => { + return success(c, { seen: await getToursSeen() }) +}) + +// PUT /tours-seen — 写入已看过的引导漫游 id 列表(body: { seen: string[] }) +app.put('/tours-seen', async (c) => { + const body = await c.req.json().catch(() => null) + if (!Array.isArray(body?.seen) || !body.seen.every((v: unknown) => typeof v === 'string')) { + return badRequest(c, 'seen 必须是字符串数组') + } + return success(c, { seen: await setToursSeen(body.seen) }) +}) + +export default app diff --git a/huobao-source/backend/src/routes/skills.ts b/huobao-source/backend/src/routes/skills.ts new file mode 100644 index 0000000..4ee2f6b --- /dev/null +++ b/huobao-source/backend/src/routes/skills.ts @@ -0,0 +1,99 @@ +/** + * 技能管理路由 — 全部经 Mastra Workspace API 读写 + * 文件操作 jail 在 backend/workspace/ 目录内 + */ +import { Hono } from 'hono' +import { success, badRequest } from '../utils/response.js' +import { refreshSkillWorkspaces, skillsManagerWorkspace } from '../agents/skills.js' + +const app = new Hono() +const fsm = () => skillsManagerWorkspace.filesystem! +const LANGS = ['zh', 'en', 'ja', 'ko'] +const normalizeLang = (v?: string) => (v && LANGS.includes(v) ? v : 'zh') +/** 技能文件路径;lang 非 zh 时为语言变体 SKILL..md */ +const skillFile = (id: string, lang?: string) => `skills/${id}/SKILL${lang && lang !== 'zh' ? `.${lang}` : ''}.md` +const SKILL_ID_SEGMENT = /^[a-z0-9-]+$/ + +// GET /skills?lang= — List all skills (经 workspace.skills 原生发现) +// lang 非 zh 且变体存在时,名称/描述取自语言变体 frontmatter(缺失则回退基础版) +app.get('/', async (c) => { + const lang = normalizeLang(c.req.query('lang')) + const metas = await skillsManagerWorkspace.skills?.list() || [] + const out = [] + for (const meta of metas) { + const id = meta.path.replace(/^skills\//, '') + let name = meta.name + let description = meta.description || '' + if (lang !== 'zh' && await fsm().exists(skillFile(id, lang))) { + const raw = String(await fsm().readFile(skillFile(id, lang), { encoding: 'utf-8' })) + const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] || '' + const n = /^name:\s*(.+)$/m.exec(fm)?.[1]?.trim() + const d = /^description:\s*(.+)$/m.exec(fm)?.[1]?.trim() + if (n) name = n + if (d) description = d + } + out.push({ id, name, description }) + } + return success(c, out) +}) + +// GET /skills/:id?lang= — Get skill content (raw, 含 frontmatter 供编辑) +// lang 非 zh 时读语言变体;变体缺失回退基础版内容并标记 is_default=true(前端提示「跟随中文」) +app.get('/*', async (c) => { + const id = c.req.path.slice('/api/v1/skills/'.length) + const lang = normalizeLang(c.req.query('lang')) + if (!await fsm().exists(skillFile(id))) return badRequest(c, '技能不存在') + const variantExists = lang !== 'zh' && await fsm().exists(skillFile(id, lang)) + const content = await fsm().readFile(skillFile(id, variantExists ? lang : undefined), { encoding: 'utf-8' }) + return success(c, { id, lang, content, is_default: lang !== 'zh' && !variantExists }) +}) + +// PUT /skills/:id?lang= — Update skill content(lang 非 zh 写语言变体;基础版变更需刷新技能缓存) +app.put('/*', async (c) => { + const id = c.req.path.slice('/api/v1/skills/'.length) + const body = await c.req.json() + const lang = normalizeLang(c.req.query('lang') ?? body.lang) + if (lang !== 'zh' && !await fsm().exists(skillFile(id))) return badRequest(c, '技能不存在') + await fsm().writeFile(skillFile(id, lang), body.content, { recursive: true }) + // 目录 mtime 不会因文件内容编辑而更新(APFS),显式刷新技能缓存;语言变体直读无需刷新 + if (lang === 'zh') await refreshSkillWorkspaces() + return success(c) +}) + +// POST /skills — Create new skill directory +app.post('/', async (c) => { + const body = await c.req.json() + const { id, description } = body + if (!id) return badRequest(c, 'Skill id 必填') + // Mastra 技能规范:frontmatter name 必须与目录名一致,且只允许小写字母/数字/连字符 + const segments = String(id).split('/') + if (!segments.every((seg: string) => SKILL_ID_SEGMENT.test(seg))) { + return badRequest(c, 'Skill id 每段只能包含小写字母、数字和连字符') + } + if (await fsm().exists(skillFile(id))) return badRequest(c, '技能已存在') + + const name = segments[segments.length - 1] + const content = `--- +name: ${name} +description: ${description || ''} +--- + +# ${name} + +Write your skill content here. +` + await fsm().writeFile(skillFile(id), content, { recursive: true, overwrite: false }) + await refreshSkillWorkspaces() + return success(c, { id, name, description: description || '' }) +}) + +// DELETE /skills/:id — Delete skill directory +app.delete('/*', async (c) => { + const id = c.req.path.slice('/api/v1/skills/'.length) + if (!await fsm().exists(`skills/${id}`)) return badRequest(c, '技能不存在') + await fsm().rmdir(`skills/${id}`, { recursive: true }) + await refreshSkillWorkspaces() + return success(c) +}) + +export default app diff --git a/huobao-source/backend/src/routes/storage.ts b/huobao-source/backend/src/routes/storage.ts new file mode 100644 index 0000000..5158250 --- /dev/null +++ b/huobao-source/backend/src/routes/storage.ts @@ -0,0 +1,68 @@ +/** + * 存储位置信息路由 — 设置页「存储位置」卡片数据源 + * + * GET / → 当前模式(desktop/server)、目录路径、磁盘占用(分桶)与剩余空间。 + * 占用计算较重(全目录 walk):内存缓存 60s TTL + stale-while-revalidate, + * 过期时立即返回旧值并后台单飞重算(computing 防并发);首次无值才同步等待。 + */ +import { Hono } from 'hono' +import { DATA_ROOT, STORAGE_ROOT } from '../utils/paths.js' +import { dbPath } from '../db/index.js' +import { dirUsage, volumeFreeBytes } from '../utils/dirsize.js' +import { success } from '../utils/response.js' + +const app = new Hono() + +const USAGE_TTL_MS = 60_000 + +interface UsageCache { + usage: Awaited> | null + computedAt: string | null + computing: boolean +} +const cache: UsageCache = { usage: null, computedAt: null, computing: false } + +async function computeUsage() { + if (cache.computing) return + cache.computing = true + try { + const usage = await dirUsage(DATA_ROOT, dbPath) + cache.usage = usage + cache.computedAt = new Date().toISOString() + } finally { + cache.computing = false + } +} + +// 启动 10s 后后台预热一次,用户进设置页时大概率已命中缓存 +const warmup = setTimeout(() => { void computeUsage() }, 10_000) +warmup.unref?.() + +app.get('/', async (c) => { + const fresh = cache.computedAt && (Date.now() - new Date(cache.computedAt).getTime()) < USAGE_TTL_MS + let usageStale = false + if (!fresh) { + if (cache.usage) { + // stale-while-revalidate:先回旧值,后台重算 + usageStale = true + void computeUsage() + } else { + // 首次无值,同步等待首算(保证进入页面必有数据) + await computeUsage() + } + } + + const mode = process.env.HUOBAO_DESKTOP === '1' ? 'desktop' : 'server' + return success(c, { + mode, + dataDir: DATA_ROOT, + storageRoot: STORAGE_ROOT, + sqlitePath: dbPath, + usage: cache.usage, + usageStale: usageStale || cache.computing, + computedAt: cache.computedAt, + freeBytes: await volumeFreeBytes(DATA_ROOT), + }) +}) + +export default app diff --git a/huobao-source/backend/src/routes/storyboards.ts b/huobao-source/backend/src/routes/storyboards.ts new file mode 100644 index 0000000..a690c51 --- /dev/null +++ b/huobao-source/backend/src/routes/storyboards.ts @@ -0,0 +1,175 @@ +import { Hono } from 'hono' +import { eq } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, created, now, badRequest } from '../utils/response.js' +import { toSnakeCase } from '../utils/transform.js' +import { logTaskPayload, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +const app = new Hono() + +async function syncStoryboardCharacters(storyboardId: number, characterIds: number[]) { + await db.delete(schema.storyboardCharacters) + .where(eq(schema.storyboardCharacters.storyboardId, storyboardId)) + + + const uniqueIds = [...new Set((characterIds || []).filter(Boolean))] + if (!uniqueIds.length) return + + for (const characterId of uniqueIds) { + await db.insert(schema.storyboardCharacters).values({ + storyboardId, + characterId, + }) + } +} + +async function getStoryboardCharacterIds(storyboardId: number) { + const links = await db.select().from(schema.storyboardCharacters) + .where(eq(schema.storyboardCharacters.storyboardId, storyboardId)) + return links.map(link => link.characterId) +} + +async function syncStoryboardProps(storyboardId: number, propIds: number[]) { + await db.delete(schema.storyboardProps) + .where(eq(schema.storyboardProps.storyboardId, storyboardId)) + + const uniqueIds = [...new Set((propIds || []).filter(Boolean))] + if (!uniqueIds.length) return + + for (const propId of uniqueIds) { + await db.insert(schema.storyboardProps).values({ + storyboardId, + propId, + }) + } +} + +async function getStoryboardPropIds(storyboardId: number) { + const links = await db.select().from(schema.storyboardProps) + .where(eq(schema.storyboardProps.storyboardId, storyboardId)) + return links.map(link => link.propId) +} + +async function validateStoryboardBindings(episodeId: number, sceneId: number | null | undefined, characterIds: number[] | undefined, propIds?: number[] | undefined) { + const sceneLinks = await db.select().from(schema.episodeScenes) + .where(eq(schema.episodeScenes.episodeId, episodeId)) + const episodeSceneIds = new Set(sceneLinks.map(link => link.sceneId)) + const characterLinks = await db.select().from(schema.episodeCharacters) + .where(eq(schema.episodeCharacters.episodeId, episodeId)) + const episodeCharacterIds = new Set(characterLinks.map(link => link.characterId)) + const propLinks = await db.select().from(schema.episodeProps) + .where(eq(schema.episodeProps.episodeId, episodeId)) + const episodePropIds = new Set(propLinks.map(link => link.propId)) + + if (sceneId != null && !episodeSceneIds.has(sceneId)) { + throw new Error('scene_id 必须来自当前集已关联场景') + } + + const invalidCharacterIds = (characterIds || []).filter(id => !episodeCharacterIds.has(id)) + if (invalidCharacterIds.length) { + throw new Error('character_ids 必须来自当前集已关联角色') + } + + const invalidPropIds = (propIds || []).filter(id => !episodePropIds.has(id)) + if (invalidPropIds.length) { + throw new Error('prop_ids 必须来自当前集已关联道具') + } +} + +// POST /storyboards +app.post('/', async (c) => { + const body = await c.req.json() + const ts = now() + logTaskStart('StoryboardAPI', 'create', { + episodeId: body.episode_id, + shotNumber: body.storyboard_number || 1, + sceneId: body.scene_id, + characterIds: body.character_ids, + }) + logTaskPayload('StoryboardAPI', 'create body', body) + await validateStoryboardBindings(body.episode_id, body.scene_id, body.character_ids, body.prop_ids) + const res = await db.insert(schema.storyboards).values({ + episodeId: body.episode_id, + storyboardNumber: body.storyboard_number || 1, + title: body.title, + description: body.description, + sceneId: body.scene_id, + duration: body.duration || 10, + createdAt: ts, + updatedAt: ts, + }) + await syncStoryboardCharacters(getInsertId(res), body.character_ids || []) + await syncStoryboardProps(getInsertId(res), body.prop_ids || []) + const [result] = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.id, getInsertId(res))) + logTaskSuccess('StoryboardAPI', 'create', { + storyboardId: result.id, + episodeId: result.episodeId, + shotNumber: result.storyboardNumber, + }) + return created(c, { + ...toSnakeCase(result), + character_ids: await getStoryboardCharacterIds(result.id), + prop_ids: await getStoryboardPropIds(result.id), + }) +}) + +// PUT /storyboards/:id +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [storyboard] = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, id)) + if (!storyboard) return badRequest(c, '镜头不存在') + logTaskStart('StoryboardAPI', 'update', { + storyboardId: id, + episodeId: storyboard.episodeId, + fields: Object.keys(body), + }) + logTaskPayload('StoryboardAPI', 'update body', body) + + const fieldMap: Record = { + title: 'title', description: 'description', shot_type: 'shotType', + angle: 'angle', movement: 'movement', duration: 'duration', + video_prompt: 'videoPrompt', + image_prompt: 'imagePrompt', scene_id: 'sceneId', location: 'location', + time: 'time', atmosphere: 'atmosphere', result: 'result', + bgm_prompt: 'bgmPrompt', sound_effect: 'soundEffect', + video_url: 'videoUrl', + } + + const updates: Record = { updatedAt: now() } + for (const [snakeKey, camelKey] of Object.entries(fieldMap)) { + if (snakeKey in body) updates[camelKey] = body[snakeKey] + } + + await validateStoryboardBindings( + storyboard.episodeId, + 'scene_id' in body ? body.scene_id : storyboard.sceneId, + 'character_ids' in body ? body.character_ids : await getStoryboardCharacterIds(id), + 'prop_ids' in body ? body.prop_ids : await getStoryboardPropIds(id), + ) + + await db.update(schema.storyboards).set(updates).where(eq(schema.storyboards.id, id)) + if ('character_ids' in body) await syncStoryboardCharacters(id, body.character_ids || []) + if ('prop_ids' in body) await syncStoryboardProps(id, body.prop_ids || []) + logTaskSuccess('StoryboardAPI', 'update', { + storyboardId: id, + updatedFields: Object.keys(updates), + characterIds: body.character_ids, + propIds: body.prop_ids, + }) + return success(c) +}) + +// DELETE /storyboards/:id +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + logTaskStart('StoryboardAPI', 'delete', { storyboardId: id }) + await db.delete(schema.storyboardCharacters).where(eq(schema.storyboardCharacters.storyboardId, id)) + await db.delete(schema.storyboardProps).where(eq(schema.storyboardProps.storyboardId, id)) + await db.delete(schema.storyboards).where(eq(schema.storyboards.id, id)) + logTaskSuccess('StoryboardAPI', 'delete', { storyboardId: id }) + return success(c) +}) + +export default app diff --git a/huobao-source/backend/src/routes/stylePresets.ts b/huobao-source/backend/src/routes/stylePresets.ts new file mode 100644 index 0000000..ff2b3ca --- /dev/null +++ b/huobao-source/backend/src/routes/stylePresets.ts @@ -0,0 +1,80 @@ +import { Hono } from 'hono' +import { eq, and } from 'drizzle-orm' +import { db, getInsertId, schema } from '../db/index.js' +import { success, created, badRequest, notFound, now } from '../utils/response.js' +import { toSnakeCase } from '../utils/transform.js' + +const app = new Hono() + +const VALUE_PATTERN = /^[a-z0-9][a-z0-9-]*$/ + +// GET /style-presets — 默认只返回启用项,?all=1 返回全部 +app.get('/', async (c) => { + const all = c.req.query('all') === '1' + const rows = await db.select().from(schema.stylePresets) + const filtered = all ? rows : rows.filter(r => r.isActive) + filtered.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0) || a.id - b.id) + return success(c, filtered.map(r => toSnakeCase(r))) +}) + +// POST /style-presets — 新增风格预设 +app.post('/', async (c) => { + const body = await c.req.json() + if (!body.name?.trim()) return badRequest(c, '风格名称必填') + if (!body.value?.trim()) return badRequest(c, '风格 key 必填') + if (!VALUE_PATTERN.test(body.value.trim())) return badRequest(c, '风格 key 仅支持小写字母、数字、中划线') + if (!body.prompt?.trim()) return badRequest(c, '提示词片段必填') + + const value = body.value.trim() + const [dup] = await db.select().from(schema.stylePresets) + .where(eq(schema.stylePresets.value, value)) + if (dup) return badRequest(c, '风格 key 已存在') + + const ts = now() + const res = await db.insert(schema.stylePresets).values({ + name: body.name.trim(), + value, + prompt: body.prompt.trim(), + description: body.description || null, + sortOrder: Number(body.sort_order ?? body.sortOrder ?? 0), + isActive: body.is_active === false || body.is_active === 0 ? false : true, + createdAt: ts, + updatedAt: ts, + }) + const [row] = await db.select().from(schema.stylePresets) + .where(eq(schema.stylePresets.id, getInsertId(res))) + return created(c, toSnakeCase(row)) +}) + +// PUT /style-presets/:id — 更新(value 创建后不可修改) +app.put('/:id', async (c) => { + const id = Number(c.req.param('id')) + const body = await c.req.json() + const [row] = await db.select().from(schema.stylePresets) + .where(eq(schema.stylePresets.id, id)) + if (!row) return notFound(c, '风格预设不存在') + + const updates: Record = { updatedAt: now() } + if (body.name !== undefined) updates.name = String(body.name).trim() + if (body.prompt !== undefined) updates.prompt = String(body.prompt).trim() + if (body.description !== undefined) updates.description = body.description || null + if (body.sort_order !== undefined || body.sortOrder !== undefined) { + updates.sortOrder = Number(body.sort_order ?? body.sortOrder) + } + if (body.is_active !== undefined || body.isActive !== undefined) { + const v = body.is_active ?? body.isActive + updates.isActive = !(v === false || v === 0) + } + await db.update(schema.stylePresets).set(updates) + .where(eq(schema.stylePresets.id, id)) + return success(c) +}) + +// DELETE /style-presets/:id — 硬删除(已使用此风格的项目保留 key,无害) +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + await db.delete(schema.stylePresets).where(eq(schema.stylePresets.id, id)) + return success(c) +}) + +export default app diff --git a/huobao-source/backend/src/routes/tasks.ts b/huobao-source/backend/src/routes/tasks.ts new file mode 100644 index 0000000..3d26834 --- /dev/null +++ b/huobao-source/backend/src/routes/tasks.ts @@ -0,0 +1,278 @@ +import { Hono } from 'hono' +import { and, desc, eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' +import { success, created, badRequest } from '../utils/response.js' +import { generateImage, generateVideo, recoverGenerationTask } from '../services/generation.js' +import { safeTaskError } from '../services/video-download.js' +import { taskRouting } from '../services/worker-routing.js' +import { getActiveConfig, getConfigById } from '../services/ai.js' +import { getDramaStylePrompt } from '../services/style-preset.js' +import { resolveStoryboardVoiceReferences } from '../services/character-voice-references.js' +import { logTaskError, logTaskPayload, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +const app = new Hono() + +app.post('/:id/recover', async c => { + try { + await recoverGenerationTask(Number(c.req.param('id'))) + return success(c, { id: Number(c.req.param('id')) }) + } catch (error) { return badRequest(c, safeTaskError(error)) } +}) + +app.get('/:id/diagnostics', async c => { + const [task] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, Number(c.req.param('id')))) + if (!task) return badRequest(c, '任务不存在') + return success(c, { id: task.id, upstreamTaskId: task.taskId, provider: task.provider, model: task.model, + status: task.status, stage: task.stage, errorCode: task.errorCode, error: safeTaskError(task.errorMsg), + hasResult: !!task.resultUrl, hasLocalFile: !!task.localPath, createdAt: task.createdAt, updatedAt: task.updatedAt, + version: process.env.HUOBAO_VERSION || 'development' }) +}) + +type TaskType = 'image' | 'video' + +const WAN_MEDIA_TYPES = new Set([ + 'first_frame', + 'last_frame', + 'reference_image', + 'reference_video', + 'reference_audio', + 'file', + 'link', +]) + +/** + * 兼容项目原有扁平入参,也支持 Wan 3.0 官方 model/input/parameters 结构。 + */ +function normalizeVideoRequest(body: any) { + const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input) ? body.input : {} + const parameters = body.parameters && typeof body.parameters === 'object' && !Array.isArray(body.parameters) + ? body.parameters + : {} + const media = Array.isArray(input.media) ? input.media : [] + + const mediaUrls = (type: string) => media + .filter((item: any) => item?.type === type) + .map((item: any) => item?.url) + + return { + ...body, + prompt: body.prompt ?? input.prompt ?? '', + reference_image_urls: body.reference_image_urls ?? mediaUrls('reference_image'), + reference_video_urls: body.reference_video_urls ?? mediaUrls('reference_video'), + reference_audio_urls: body.reference_audio_urls ?? mediaUrls('reference_audio'), + first_frame_url: body.first_frame_url ?? mediaUrls('first_frame')[0], + last_frame_url: body.last_frame_url ?? mediaUrls('last_frame')[0], + file_url: body.file_url ?? mediaUrls('file')[0], + link_url: body.link_url ?? mediaUrls('link')[0], + generate_audio: body.generate_audio ?? parameters.audio, + duration: body.duration ?? parameters.duration, + aspect_ratio: body.aspect_ratio ?? parameters.ratio, + resolution: body.resolution ?? parameters.resolution, + seed: body.seed ?? parameters.seed, + prompt_extend: body.prompt_extend ?? parameters.prompt_extend, + watermark: body.watermark ?? parameters.watermark, + official_media: media, + } +} + +function validateVideoRequest(body: any, provider?: string): string | null { + for (const key of ['reference_image_urls', 'reference_video_urls', 'reference_audio_urls']) { + if (body[key] !== undefined && !Array.isArray(body[key])) return `${key} 必须为数组` + if (Array.isArray(body[key]) && body[key].some((url: any) => typeof url !== 'string' || !url.trim())) { + return `${key} 中的每个 URL 都必须为非空字符串` + } + } + + if (body.official_media.length) { + for (const item of body.official_media) { + if (!item || typeof item !== 'object' || !WAN_MEDIA_TYPES.has(item.type) || typeof item.url !== 'string' || !item.url.trim()) { + return 'input.media 中的每项都必须包含官方支持的 type 和非空 url' + } + } + for (const type of ['first_frame', 'last_frame', 'file', 'link']) { + if (body.official_media.filter((item: any) => item.type === type).length > 1) { + return `Wan 3.0 input.media 中 ${type} 最多 1 项` + } + } + } + + const imgs = body.reference_image_urls.length + const vids = body.reference_video_urls.length + const auds = body.reference_audio_urls.length + const first = Boolean(body.first_frame_url) + const last = Boolean(body.last_frame_url) + const file = Boolean(body.file_url) + const link = Boolean(body.link_url) + + if ((provider || '').toLowerCase() === 'aliyun') { + if (imgs > 10 || vids > 5 || auds > 5) return 'Wan 3.0 参考素材超限:图片≤10、视频≤5、音频≤5' + if (last && !first) return 'Wan 3.0 尾帧必须与首帧同时传入' + if (file && link) return 'Wan 3.0 file 与 link 不能同时传入' + if ((first || last) && (imgs + vids + auds > 0 || file || link)) { + return 'Wan 3.0 的 first_frame/last_frame 不能与 reference_image/reference_video/reference_audio/file/link 混用' + } + const total = imgs + vids + auds + Number(first) + Number(last) + Number(file) + Number(link) + if (total > 20) return 'Wan 3.0 input.media 最多 20 项' + } else { + if (imgs > 9 || vids > 3 || auds > 3) return '参考素材超限:图片≤9、视频≤3、音频≤3' + if (auds > 0 && imgs + vids === 0) return '参考音频需要至少 1 个参考图片或视频' + } + + if (!String(body.prompt || '').trim() && imgs + vids + auds === 0 && !first && !last && !file && !link) { + return '视频生成需要至少一个参考素材或 prompt' + } + return null +} + +// POST /tasks — 发起生成任务(body.type: image | video) +app.post('/', async (c) => { + const body = await c.req.json() + const type = body.type as TaskType + if (type !== 'image' && type !== 'video') return badRequest(c, 'type 必须为 image 或 video') + + if (type === 'image') { + if (!body.prompt) return badRequest(c, '提示词必填') + } + + const videoBody = type === 'video' ? normalizeVideoRequest(body) : null + + try { + if (type === 'video' && body.storyboard_id) { + const [latest] = await db.select().from(schema.sysTask).where(and( + eq(schema.sysTask.type, 'video'), eq(schema.sysTask.storyboardId, Number(body.storyboard_id)) + )).orderBy(desc(schema.sysTask.id)).limit(1) + if (latest?.status === 'processing' && body.replace_task_id !== latest.id) return badRequest(c, '该分镜已有运行中的任务,请等待结果') + if (latest?.status === 'failed' && body.force_new !== true) { + return badRequest(c, '请先恢复原任务;重新生成需明确确认可能再次计费') + } + } + // 请求显式指定 config_id(工作台模型下拉跨厂商切换)时优先; + // 未指定才回退到集锁定配置,避免锁定配置与所选模型错配(如锁定 Seedance 却传 MiniMax 模型名) + let configId: number | undefined = body.config_id + let episodeResolution: string | undefined + let storyboardDramaId: number | undefined + if (body.storyboard_id) { + const [sb] = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, Number(body.storyboard_id))) + if (sb) { + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, sb.episodeId)) + const locked = type === 'image' ? ep?.imageConfigId : ep?.videoConfigId + if (locked != null && configId == null) configId = locked + if (type === 'video' && ep?.resolution) episodeResolution = ep.resolution + storyboardDramaId = ep?.dramaId ?? undefined + } + } + + if (type === 'video' && videoBody) { + const effectiveConfig = configId + ? (await getConfigById(configId)) ?? await getActiveConfig('video') + : await getActiveConfig('video') + const validationError = validateVideoRequest(videoBody, effectiveConfig?.provider) + if (validationError) return badRequest(c, validationError) + const voices = await resolveStoryboardVoiceReferences(String(videoBody.prompt || ''), Number(body.storyboard_id) || undefined, videoBody.reference_audio_urls) + videoBody.prompt = voices.prompt + videoBody.reference_audio_urls = voices.audioUrls + if (voices.hasVoiceReferences && videoBody.generate_audio === false) return badRequest(c, '引用角色音色时需要开启视频声音') + const referenceError = validateVideoRequest(videoBody, effectiveConfig?.provider) + if (referenceError) return badRequest(c, referenceError) + } + + logTaskStart('TaskAPI', 'generate', { + type, + storyboardId: body.storyboard_id, + sceneId: body.scene_id, + characterId: body.character_id, + dramaId: body.drama_id, + }) + logTaskPayload('TaskAPI', 'request body', body) + + // 视频生成时把项目视觉风格词注入提示词最前方(与图片侧的自动注入保持一致口径) + let videoPrompt = videoBody?.prompt + if (type === 'video' && String(videoPrompt || '').trim()) { + const dramaId = body.drama_id ?? storyboardDramaId ?? null + const stylePrompt = await getDramaStylePrompt(dramaId) + if (stylePrompt) videoPrompt = `${stylePrompt},\n${videoPrompt}` + } + + const id = type === 'image' + ? await generateImage({ + storyboardId: body.storyboard_id, + dramaId: body.drama_id, + sceneId: body.scene_id, + characterId: body.character_id, + prompt: body.prompt, + model: body.model, + size: body.size, + referenceImages: body.reference_images, + frameType: body.frame_type, + configId, + }) + : await generateVideo({ + gpu: body.gpu, + replaceTaskId: body.replace_task_id, + forceNew: body.force_new === true, + storyboardId: body.storyboard_id, + dramaId: body.drama_id, + prompt: videoPrompt, + model: videoBody!.model, + referenceMode: 'reference', + imageUrl: videoBody!.image_url, + firstFrameUrl: videoBody!.first_frame_url, + lastFrameUrl: videoBody!.last_frame_url, + referenceImageUrls: videoBody!.reference_image_urls, + referenceVideoUrls: videoBody!.reference_video_urls, + referenceAudioUrls: videoBody!.reference_audio_urls, + referenceFileUrl: videoBody!.file_url, + referenceLinkUrl: videoBody!.link_url, + generateAudio: videoBody!.generate_audio, + duration: videoBody!.duration, + aspectRatio: videoBody!.aspect_ratio, + resolution: episodeResolution || videoBody!.resolution, + seed: videoBody!.seed, + promptExtend: videoBody!.prompt_extend, + watermark: videoBody!.watermark, + configId, + }) + + const [record] = await db.select().from(schema.sysTask) + .where(eq(schema.sysTask.id, id)) + logTaskSuccess('TaskAPI', 'generate', { taskId: id, type, provider: record?.provider }) + return created(c, { ...record, routing: taskRouting(record) }) + } catch (err: any) { + logTaskError('TaskAPI', 'generate', { type, error: err.message }) + return badRequest(c, err.message) + } +}) + +// GET /tasks/:id — 轮询任务状态 +app.get('/:id', async (c) => { + const id = Number(c.req.param('id')) + const [row] = await db.select().from(schema.sysTask) + .where(eq(schema.sysTask.id, id)) + return success(c, row ? { ...row, routing: taskRouting(row) } : null) +}) + +// GET /tasks — 按 type / storyboard_id / drama_id 过滤 +app.get('/', async (c) => { + const type = c.req.query('type') + const storyboardId = c.req.query('storyboard_id') + const dramaId = c.req.query('drama_id') + + let rows = await db.select().from(schema.sysTask) + + if (type) rows = rows.filter(r => r.type === type) + if (storyboardId) rows = rows.filter(r => r.storyboardId === Number(storyboardId)) + if (dramaId) rows = rows.filter(r => r.dramaId === Number(dramaId)) + + return success(c, rows.map(row => ({ ...row, routing: taskRouting(row) }))) +}) + +// DELETE /tasks/:id +app.delete('/:id', async (c) => { + const id = Number(c.req.param('id')) + const [task] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + if (task?.status === 'processing') return badRequest(c, '任务运行中,暂不能删除') + await db.delete(schema.sysTask).where(eq(schema.sysTask.id, id)) + return success(c) +}) + +export default app diff --git a/huobao-source/backend/src/routes/upload.ts b/huobao-source/backend/src/routes/upload.ts new file mode 100644 index 0000000..9a8053d --- /dev/null +++ b/huobao-source/backend/src/routes/upload.ts @@ -0,0 +1,77 @@ +import { Hono } from 'hono' +import { bodyLimit } from 'hono/body-limit' +import { success, badRequest } from '../utils/response.js' +import { saveUploadedFile, generateImageThumb } from '../utils/storage.js' +import { AUDIO_MAX_BYTES, AudioUploadError, saveAudioUpload } from '../utils/audio-upload.js' + +const app = new Hono() + +// POST /upload/image +app.post('/image', async (c) => { + const body = await c.req.parseBody() + const file = body['file'] + + if (!file || !(file instanceof File)) { + return badRequest(c, '文件必填') + } + + const buffer = await file.arrayBuffer() + const path = await saveUploadedFile(buffer, 'uploads', file.name) + // 同步生成列表页缩略图,上传图与生图走同一套展示链路(失败不影响上传结果) + await generateImageThumb(path) + return success(c, { url: `/${path}`, path }) +}) + +const VIDEO_EXT = new Set(['.mp4', '.mov', '.webm', '.m4v']) +const VIDEO_MIME = new Set(['video/mp4', 'video/quicktime', 'video/webm', 'video/x-m4v']) +const VIDEO_MAX = 50 * 1024 * 1024 // 50MB + +function extOf(name: string): string { + const i = name.lastIndexOf('.') + return i >= 0 ? name.slice(i).toLowerCase() : '' +} + +async function saveMediaUpload( + c: any, + kind: 'video' | 'audio', + allowedExt: Set, + allowedMime: Set, + maxBytes: number, +) { + const body = await c.req.parseBody() + const file = body['file'] + if (!file || !(file instanceof File)) { + return badRequest(c, '文件必填') + } + const label = kind === 'video' ? '视频' : '音频' + const ext = extOf(file.name) + // MIME 可伪造,扩展名兜底;空 / octet-stream 视为未知类型,仅按扩展名校验 + const mimeKnown = file.type && file.type !== 'application/octet-stream' + if (!allowedExt.has(ext) || (mimeKnown && !allowedMime.has(file.type))) { + return badRequest(c, `仅支持 ${Array.from(allowedExt).join('/')} 格式的${label}文件`) + } + const buffer = await file.arrayBuffer() + if (buffer.byteLength > maxBytes) { + return badRequest(c, `${label}文件大小不能超过 ${Math.round(maxBytes / 1024 / 1024)}MB`) + } + const path = await saveUploadedFile(buffer, 'uploads', file.name) + return success(c, { url: `/${path}`, path }) +} + +// POST /upload/video — 参考视频上传(Seedance 多模态参考用) +app.post('/video', async (c) => saveMediaUpload(c, 'video', VIDEO_EXT, VIDEO_MIME, VIDEO_MAX)) + +// POST /upload/audio — 参考音频上传(Seedance 多模态参考用) +app.post('/audio', bodyLimit({ maxSize: AUDIO_MAX_BYTES + 64 * 1024, + onError: c => badRequest(c, '音频文件大小不能超过 20MB'), +}), async (c) => { + try { + const body = await c.req.parseBody() + return success(c, await saveAudioUpload(body.file)) + } catch (error) { + if (error instanceof AudioUploadError) return badRequest(c, error.message) + throw error + } +}) + +export default app diff --git a/huobao-source/backend/src/services/adapters/aliyun-wan-video.ts b/huobao-source/backend/src/services/adapters/aliyun-wan-video.ts new file mode 100644 index 0000000..cd1b6b4 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/aliyun-wan-video.ts @@ -0,0 +1,262 @@ +/** + * 阿里云百炼万相 Wan 3.0 视频生成 Adapter。 + * + * 官方协议: + * - POST /api/v1/services/aigc/video-generation/video-synthesis + * - GET /api/v1/tasks/{task_id} + * - 仅支持 wan3.0-video-prime / wan3.0-video + * - 请求体为 { model, input: { prompt, media }, parameters } + * - 异步响应为 { output: { task_id, task_status }, request_id } + */ +import type { + AIConfig, + ProviderRequest, + VideoGenerationRecord, + VideoGenResponse, + VideoPollResponse, + VideoProviderAdapter, +} from './types' +import { joinProviderUrl } from './url' + +const SUPPORTED_MODELS = new Set(['wan3.0-video-prime', 'wan3.0-video']) +const DEFAULT_MODEL = 'wan3.0-video' +const PROMPT_MAX_CHARS = 20_000 +const MAX_SEED = 2_147_483_647 +const REF_LIMITS = { images: 10, videos: 5, audios: 5, total: 20 } as const +const VALID_RATIOS = new Set(['adaptive', '16:9', '4:3', '1:1', '3:4', '9:16']) +const VALID_RESOLUTIONS = new Set(['480P', '720P', '1080P']) + +type WanMediaType = + | 'first_frame' + | 'last_frame' + | 'reference_image' + | 'reference_video' + | 'reference_audio' + | 'file' + | 'link' + +interface WanMedia { + type: WanMediaType + url: string +} + +function parseUrlArray(raw?: string | null): string[] { + if (!raw) return [] + try { + const value = JSON.parse(raw) + if (!Array.isArray(value)) return [] + return value + .filter((url): url is string => typeof url === 'string') + .map(url => url.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +function cleanUrl(value?: string | null): string { + return String(value || '').trim() +} + +function validateMediaUrl(type: WanMediaType, url: string) { + const isWebUrl = /^https?:\/\//i.test(url) + const isOssUrl = /^oss:\/\//i.test(url) + const isImageData = /^data:image\/(?:jpeg|jpg|png|bmp|webp);base64,/i.test(url) + if (type === 'first_frame' || type === 'last_frame' || type === 'reference_image') { + if (isWebUrl || isOssUrl || isImageData) return + throw new Error(`Wan 3.0 ${type} 需要 HTTP(S)/OSS 图片 URL 或官方支持的图片 Base64`) + } + if (type === 'link') { + if (isWebUrl) return + throw new Error('Wan 3.0 link 仅支持无需登录的 HTTP(S) 公开网页') + } + if (isWebUrl || isOssUrl) return + throw new Error(`Wan 3.0 ${type} 仅支持 HTTP(S) 或 OSS URL`) +} + +function booleanValue(value: number | boolean | null | undefined, fallback: boolean): boolean { + if (value === null || value === undefined) return fallback + return value !== false && value !== 0 +} + +function errorMessage(result: any, fallback: string): string { + const output = result?.output && typeof result.output === 'object' ? result.output : {} + const code = output.code || result?.code + const message = output.message || result?.message || fallback + const requestId = result?.request_id + return `${code ? `[${code}] ` : ''}${message}${requestId ? ` (request_id: ${requestId})` : ''}` +} + +export class AliyunWanVideoAdapter implements VideoProviderAdapter { + provider = 'aliyun' + + buildGenerateRequest(config: AIConfig, record: VideoGenerationRecord): ProviderRequest { + const model = cleanUrl(record.model || config.model || DEFAULT_MODEL) + if (!SUPPORTED_MODELS.has(model)) { + throw new Error(`Wan 3.0 仅支持 wan3.0-video-prime 或 wan3.0-video,当前: ${model}`) + } + + // 官方对超过 20000 字符的部分自动截断;同时将项目内部 @图片N 标记转为官方的 图N 引用。 + const prompt = cleanUrl(record.prompt).replace(/@图片(\d+)/g, '图$1').slice(0, PROMPT_MAX_CHARS) + + const refImages = parseUrlArray(record.referenceImageUrls) + const refVideos = parseUrlArray(record.referenceVideoUrls) + const refAudios = parseUrlArray(record.referenceAudioUrls) + const firstFrame = cleanUrl(record.firstFrameUrl || record.imageUrl) + const lastFrame = cleanUrl(record.lastFrameUrl) + const file = cleanUrl(record.referenceFileUrl) + const link = cleanUrl(record.referenceLinkUrl) + + this.validateMedia({ refImages, refVideos, refAudios, firstFrame, lastFrame, file, link }) + + const media: WanMedia[] = [] + if (firstFrame) media.push({ type: 'first_frame', url: firstFrame }) + if (lastFrame) media.push({ type: 'last_frame', url: lastFrame }) + for (const url of refImages) media.push({ type: 'reference_image', url }) + for (const url of refVideos) media.push({ type: 'reference_video', url }) + for (const url of refAudios) media.push({ type: 'reference_audio', url }) + if (file) media.push({ type: 'file', url: file }) + if (link) media.push({ type: 'link', url: link }) + for (const item of media) validateMediaUrl(item.type, item.url) + + if (!prompt && media.length === 0) { + throw new Error('Wan 3.0 的 input.prompt 和 input.media 至少需要提供一项') + } + + const input: { prompt?: string; media?: WanMedia[] } = {} + if (prompt) input.prompt = prompt + if (media.length) input.media = media + + const parameters: Record = { + resolution: this.normalizeResolution(record.resolution), + ratio: this.normalizeRatio(record.aspectRatio), + duration: this.normalizeDuration(record.duration), + audio: booleanValue(record.generateAudio, true), + prompt_extend: booleanValue(record.promptExtend, true), + watermark: booleanValue(record.watermark, false), + } + + if (record.seed !== null && record.seed !== undefined) { + const seed = Number(record.seed) + if (!Number.isInteger(seed) || (seed !== -1 && (seed < 0 || seed > MAX_SEED))) { + throw new Error(`Wan 3.0 seed 必须为 -1 或 0~${MAX_SEED} 的整数`) + } + parameters.seed = seed + } + + return { + url: joinProviderUrl(config.baseUrl, '/api/v1', '/services/aigc/video-generation/video-synthesis'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + 'X-DashScope-Async': 'enable', + }, + body: { model, input, parameters }, + } + } + + parseGenerateResponse(result: any): VideoGenResponse { + const taskId = result?.output?.task_id + if (taskId) return { isAsync: true, taskId: String(taskId) } + throw new Error(errorMessage(result, 'Wan 3.0 响应中缺少 output.task_id')) + } + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest { + return { + url: joinProviderUrl(config.baseUrl, '/api/v1', `/tasks/${encodeURIComponent(taskId)}`), + method: 'GET', + headers: { 'Authorization': `Bearer ${config.apiKey}` }, + body: undefined, + } + } + + parsePollResponse(result: any): VideoPollResponse { + const output = result?.output && typeof result.output === 'object' ? result.output : {} + switch (output.task_status) { + case 'PENDING': + return { status: 'pending' } + case 'RUNNING': + return { status: 'processing' } + case 'SUCCEEDED': + if (!output.video_url) { + return { status: 'failed', error: errorMessage(result, 'Wan 3.0 任务成功但响应中缺少 output.video_url') } + } + { + const duration = Number(result?.usage?.output_video_duration ?? result?.usage?.duration) + return { + status: 'completed', + videoUrl: output.video_url, + ...(Number.isFinite(duration) ? { duration } : {}), + } + } + case 'FAILED': + return { status: 'failed', error: errorMessage(result, 'Wan 3.0 视频生成失败') } + case 'CANCELED': + return { status: 'failed', error: errorMessage(result, 'Wan 3.0 任务已取消') } + case 'UNKNOWN': + return { status: 'failed', error: errorMessage(result, 'Wan 3.0 任务不存在或已超过 24 小时查询有效期') } + default: + if (result?.code || result?.message || output.code || output.message) { + return { status: 'failed', error: errorMessage(result, 'Wan 3.0 任务查询失败') } + } + return { status: 'processing' } + } + } + + extractVideoUrl(result: any): string | null { + return result?.output?.video_url || null + } + + private validateMedia(input: { + refImages: string[] + refVideos: string[] + refAudios: string[] + firstFrame: string + lastFrame: string + file: string + link: string + }) { + const { refImages, refVideos, refAudios, firstFrame, lastFrame, file, link } = input + if (refImages.length > REF_LIMITS.images || refVideos.length > REF_LIMITS.videos || refAudios.length > REF_LIMITS.audios) { + throw new Error(`Wan 3.0 参考素材超限:图片≤${REF_LIMITS.images}、视频≤${REF_LIMITS.videos}、音频≤${REF_LIMITS.audios}`) + } + if (lastFrame && !firstFrame) throw new Error('Wan 3.0 尾帧必须与首帧同时传入') + if (file && link) throw new Error('Wan 3.0 file 与 link 不能同时传入') + + const hasFrameMode = Boolean(firstFrame || lastFrame) + const hasReferenceMode = Boolean(refImages.length || refVideos.length || refAudios.length || file || link) + if (hasFrameMode && hasReferenceMode) { + throw new Error('Wan 3.0 的 first_frame/last_frame 不能与 reference_image/reference_video/reference_audio/file/link 混用') + } + + const total = refImages.length + refVideos.length + refAudios.length + (firstFrame ? 1 : 0) + + (lastFrame ? 1 : 0) + (file ? 1 : 0) + (link ? 1 : 0) + if (total > REF_LIMITS.total) throw new Error(`Wan 3.0 input.media 最多 ${REF_LIMITS.total} 项`) + } + + private normalizeDuration(duration?: number | null): number { + if (duration === null || duration === undefined) return 5 + const value = Number(duration) + if (!Number.isInteger(value) || (value !== -1 && (value < 2 || value > 30))) { + throw new Error('Wan 3.0 duration 必须为 -1 或 2~30 的整数') + } + return value + } + + private normalizeResolution(resolution?: string | null): string { + const value = cleanUrl(resolution).toUpperCase() || '1080P' + if (!VALID_RESOLUTIONS.has(value)) { + throw new Error('Wan 3.0 resolution 仅支持 480P、720P 或 1080P') + } + return value + } + + private normalizeRatio(ratio?: string | null): string { + const value = cleanUrl(ratio) || 'adaptive' + if (!VALID_RATIOS.has(value)) { + throw new Error('Wan 3.0 ratio 仅支持 adaptive、16:9、4:3、1:1、3:4 或 9:16') + } + return value + } +} diff --git a/huobao-source/backend/src/services/adapters/gemini-image.ts b/huobao-source/backend/src/services/adapters/gemini-image.ts new file mode 100644 index 0000000..dfda373 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/gemini-image.ts @@ -0,0 +1,199 @@ +/** + * Gemini 图片生成 Adapter + * 认证: URL Query 参数 ?key= 或 Header x-goog-api-key + * 请求: Google REST 风格的 contents[].parts[] 结构 + * 响应: base64 编码在 inlineData.data 中,无 URL + */ +import type { + ImageProviderAdapter, + ProviderRequest, + AIConfig, + ImageGenerationRecord, + ImageGenResponse, + ImagePollResponse, +} from './types' +import { joinProviderUrl } from './url' +import { parseDataUrl } from '../../utils/storage.js' + +export class GeminiImageAdapter implements ImageProviderAdapter { + provider = 'gemini' + + buildGenerateRequest(config: AIConfig, record: ImageGenerationRecord): ProviderRequest { + // Gemini 模型名格式: "models/gemini-3-pro-image" 或直接 "gemini-3-pro-image" + const modelName = record.model || config.model || 'gemini-3-pro-image' + // interactions 端点仅官方 API 可用;中转站(new-api 类)普遍未配置该端点类型, + // 会报 "endpointType=GEMINI_INTERACTIONS 未启用",此类端点回退到 generateContent 分支 + const isGemini3Image = /gemini-3.*image/.test(modelName) + const isOfficialHost = /generativelanguage\.googleapis\.com/.test(config.baseUrl || '') + + if (isGemini3Image && isOfficialHost) { + return { + url: joinProviderUrl(config.baseUrl, '/v1beta', '/interactions'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': config.apiKey, + }, + body: { + model: modelName.replace(/^models\//, ''), + input: record.prompt || 'Generate an image', + response_format: { + type: 'image', + aspect_ratio: this.parseAspectRatio(record.size), + image_size: this.parseImageSize(record.size), + }, + }, + } + } + + const model = modelName.startsWith('models/') ? modelName : `models/${modelName}` + + // Google REST 风格请求体 + const parts: any[] = [] + if (record.referenceImages) { + try { + const refs = JSON.parse(record.referenceImages) + for (const ref of refs) { + const parsed = parseDataUrl(String(ref || '')) + if (parsed) { + parts.push({ + inline_data: { + mime_type: parsed.mimeType, + data: parsed.data, + }, + }) + } + } + } catch {} + } + parts.push({ text: record.prompt || 'Generate an image' }) + + const body = { + contents: [{ + parts, + }], + generationConfig: { + responseModalities: ['IMAGE', 'TEXT'], + imageConfig: { + // 解析 size 如 "1920x1080" -> aspectRatio + aspectRatio: this.parseAspectRatio(record.size), + imageSize: this.parseImageSize(record.size), + }, + }, + } + + const url = new URL(joinProviderUrl(config.baseUrl, '/v1beta', `/${model}:generateContent`)) + url.searchParams.set('key', config.apiKey) + + return { + url: url.toString(), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': config.apiKey, + }, + body, + } + } + + parseGenerateResponse(result: any): ImageGenResponse { + const firstCandidate = result?.candidates?.[0] + const finishReason = firstCandidate?.finishReason || firstCandidate?.finish_reason + const finishMessage = firstCandidate?.finishMessage || firstCandidate?.finish_message + + if (finishReason && finishReason !== 'STOP' && finishReason !== 'MAX_TOKENS') { + throw new Error(finishMessage || `Gemini generation stopped: ${finishReason}`) + } + + if (this.extractImageUrl(result)) { + return { isAsync: false, imageUrl: this.extractImageUrl(result) || undefined } + } + + if (this.extractImageBase64(result)) { + return { isAsync: false, imageUrl: undefined } + } + + if (result.task_id || result.id) { + return { isAsync: true, taskId: result.task_id || result.id } + } + + if (result.error) { + throw new Error(result.error.message || 'Gemini generation failed') + } + throw new Error('No image data in Gemini response') + } + + parsePollResponse(result: any): ImagePollResponse { + // Gemini 是同步的,通常不会走到这里 + return { status: 'completed' } + } + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest { + // Gemini 不需要轮询,但实现接口以保持一致 + const url = new URL(joinProviderUrl(config.baseUrl, '/v1beta', `/${taskId}`)) + url.searchParams.set('key', config.apiKey) + return { + url: url.toString(), + method: 'GET', + headers: { + 'x-goog-api-key': config.apiKey, + }, + body: undefined, + } + } + + extractImageUrl(result: any): string | null { + return result?.data?.[0]?.url + || result?.image_url + || result?.url + || null + } + + extractImageBase64(result: any): { data: string; mimeType: string } | null { + const b64 = result?.data?.[0]?.b64_json + if (b64) { + return { data: b64, mimeType: 'image/png' } + } + + if (result?.output_image?.data) { + return { + data: result.output_image.data, + mimeType: result.output_image.mime_type || result.output_image.mimeType || 'image/png', + } + } + + const parts = result.candidates?.[0]?.content?.parts || [] + for (const part of parts) { + if (part.inlineData || part.inline_data) { + const inline = part.inlineData || part.inline_data + return { + data: inline.data, + mimeType: inline.mimeType || inline.mime_type || 'image/png', + } + } + } + return null + } + + private parseAspectRatio(size?: string | null): string { + if (!size) return '16:9' + const [w, h] = size.split('x').map(Number) + if (!w || !h) return '16:9' + const gcd = this.gcd(w, h) + return `${w / gcd}:${h / gcd}` + } + + private parseImageSize(size?: string | null): string { + if (!size) return '1K' + const [w] = size.split('x').map(Number) + if (!w) return '1K' + if (w >= 2048) return '4K' + if (w >= 1024) return '2K' + if (w >= 512) return '1K' + return '512' + } + + private gcd(a: number, b: number): number { + return b === 0 ? a : this.gcd(b, a % b) + } +} diff --git a/huobao-source/backend/src/services/adapters/local-video.ts b/huobao-source/backend/src/services/adapters/local-video.ts new file mode 100644 index 0000000..ca663e0 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/local-video.ts @@ -0,0 +1,24 @@ +import { MiniMaxVideoAdapter } from './minimax-video' +import type { AIConfig, ProviderRequest, VideoGenerationRecord } from './types' + +const MODELS = new Set(['MiniMax-H3', 'MiniMax-H3-Hybrid', 'MiniMax-H3-R2V-4Step', 'LTX-2.3', 'LTX-2.3-Text-Preserve', 'Wan2.2-I2V', 'MiniMax-H3-VDN-H3']) + +/** Local workers share the async H3 protocol; preserve each model and resolution. */ +export class LocalVideoAdapter extends MiniMaxVideoAdapter { + override provider = 'localvideo' + override buildGenerateRequest(config: AIConfig, record: VideoGenerationRecord): ProviderRequest { + const model = record.model || config.model || 'MiniMax-H3' + if (!MODELS.has(model)) throw new Error(`本地视频任务池不支持模型:${model}`) + if (record.referenceFileUrl || record.referenceLinkUrl) throw new Error('本地视频暂不支持 file / link 参考素材') + const request = super.buildGenerateRequest(config, { ...record, model: 'MiniMax-H3' }) + const body = request.body as Record + const resolution = String(record.resolution || '768P').toUpperCase() + if (!['480P', '720P', '768P', '1080P', '2K', '4K'].includes(resolution)) throw new Error('不支持的本地视频分辨率') + if (resolution === '4K' && model !== 'MiniMax-H3-VDN-H3') throw new Error('4K 需要 VDN-H3 高清制作模型') + body.model = model + body.resolution = resolution + if (config.gpu && config.gpu !== 'auto') body.gpu = config.gpu + if (record.seed != null) body.seed = record.seed + return request + } +} diff --git a/huobao-source/backend/src/services/adapters/minimax-video.ts b/huobao-source/backend/src/services/adapters/minimax-video.ts new file mode 100644 index 0000000..ab4aa39 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/minimax-video.ts @@ -0,0 +1,176 @@ +/** + * MiniMax H3 视频生成 Adapter + * 端点: POST /v2/video_generation -> { task_id } + * 轮询: GET /v2/query/video_generation/{task_id} -> { task: { status, content.url, error } } + * + * 仅支持 MiniMax-H3 系列模型。content[] 多模态结构: + * - text 提示词(必填,≤7000 字符) + * - image_url role: first_frame / last_frame / reference_image(参考图 ≤9) + * - video_url role: reference_video(≤3) + * - audio_url 参考音频(≤3),混合总数 ≤12 + * + * 注意: + * - 文生视频 ratio 必填且不能为 adaptive;有首帧图(图生视频)时恒为 adaptive,省略 ratio + * - H3 原生音画同步生成,官方文档无独立 generate_audio 开关,该字段忽略 + */ +import type { + VideoProviderAdapter, + ProviderRequest, + AIConfig, + VideoGenerationRecord, + VideoGenResponse, + VideoPollResponse, +} from './types' +import { joinProviderUrl } from './url' +import { parseWorkerRouting } from '../worker-routing' + +/** 仅支持 MiniMax-H3 系列(前缀匹配,兼容未来 H3.x 变体) */ +const H3_MODEL_PREFIX = 'minimax-h3' +const DEFAULT_MODEL = 'MiniMax-H3' + +/** 多模态参考素材上限:图片 9、视频 3、音频 3,混合总数 12 */ +const REF_LIMITS = { images: 9, videos: 3, audios: 3, total: 12 } as const + +const PROMPT_MAX_CHARS = 7000 + +/** MiniMax 支持的宽高比 */ +const VALID_RATIOS = new Set(['16:9', '9:16', '1:1', '4:3', '3:4', '21:9']) + +function parseUrlArray(raw?: string | null): string[] { + if (!raw) return [] + try { + const arr = JSON.parse(raw) + return Array.isArray(arr) ? arr.filter((u) => typeof u === 'string' && u.trim()) : [] + } catch { + return [] + } +} + +export class MiniMaxVideoAdapter implements VideoProviderAdapter { + provider = 'minimax' + + buildGenerateRequest(config: AIConfig, record: VideoGenerationRecord): ProviderRequest { + const model = record.model || config.model || DEFAULT_MODEL + if (!model.toLowerCase().startsWith(H3_MODEL_PREFIX)) { + throw new Error(`仅支持 MiniMax H3 系列模型(MiniMax-H3*),当前: ${model}`) + } + + const prompt = (record.prompt || '').trim() + const refImages = parseUrlArray(record.referenceImageUrls) + const refVideos = parseUrlArray(record.referenceVideoUrls) + const refAudios = parseUrlArray(record.referenceAudioUrls) + const firstFrame = (record.firstFrameUrl || record.imageUrl || '').trim() + const lastFrame = (record.lastFrameUrl || '').trim() + + if (!prompt) throw new Error('MiniMax H3 要求必须提供提示词(text content)') + if (prompt.length > PROMPT_MAX_CHARS) { + throw new Error(`提示词超长:MiniMax H3 上限 ${PROMPT_MAX_CHARS} 字符,当前 ${prompt.length}`) + } + + const totalRefs = refImages.length + refVideos.length + refAudios.length + (firstFrame ? 1 : 0) + (lastFrame ? 1 : 0) + if (refImages.length > REF_LIMITS.images || refVideos.length > REF_LIMITS.videos || refAudios.length > REF_LIMITS.audios || totalRefs > REF_LIMITS.total) { + throw new Error(`参考素材超限:图片≤${REF_LIMITS.images}、视频≤${REF_LIMITS.videos}、音频≤${REF_LIMITS.audios}、总数≤${REF_LIMITS.total}`) + } + + const content: any[] = [{ type: 'text', text: prompt }] + if (firstFrame) content.push({ type: 'image_url', image_url: { url: firstFrame }, role: 'first_frame' }) + if (lastFrame) content.push({ type: 'image_url', image_url: { url: lastFrame }, role: 'last_frame' }) + for (const url of refImages) { + content.push({ type: 'image_url', image_url: { url }, role: 'reference_image' }) + } + for (const url of refVideos) { + content.push({ type: 'video_url', video_url: { url }, role: 'reference_video' }) + } + for (const url of refAudios) { + content.push({ type: 'audio_url', audio_url: { url }, role: 'reference_audio' }) + } + + const body: any = { + model, + content, + duration: this.normalizeDuration(record.duration), + resolution: this.normalizeResolution(record.resolution), + } + if (config.gpu && config.gpu !== 'auto') body.gpu = config.gpu + + // 图生视频(有首帧)ratio 恒为 adaptive,省略;文生视频 ratio 必填 + if (!firstFrame) { + const ratio = (record.aspectRatio || '').trim() + body.ratio = VALID_RATIOS.has(ratio) ? ratio : '16:9' + } + + return { + url: joinProviderUrl(config.baseUrl, '/v2', '/video_generation'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body, + } + } + + parseGenerateResponse(result: any): VideoGenResponse { + const taskId = result.task_id ?? result.task?.id + const state = result.task?.status ?? result.status + const videoUrl = result.task?.content?.url || result.content?.url || result.video_url + if (videoUrl && (!state || state === 'succeeded')) { + return { isAsync: false, videoUrl, taskId: taskId == null ? undefined : String(taskId), duration: result.task?.duration ?? result.duration } + } + if (taskId != null) return { isAsync: true, taskId: String(taskId), routing: parseWorkerRouting(result.routing) } + throw new Error('No task_id or video url in response') + } + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest { + return { + url: joinProviderUrl(config.baseUrl, '/v2', `/query/video_generation/${taskId}`), + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: undefined, + } + } + + parsePollResponse(result: any): VideoPollResponse { + // 官方响应为 { task: { status, content: { url }, error } },兼容顶层平铺 + const task = result.task && typeof result.task === 'object' ? result.task : result + const status = task.status + + if (status === 'succeeded') { + return { + status: 'completed', + videoUrl: task.content?.url || task.video_url, + duration: task.duration, + routing: parseWorkerRouting(task.routing), + } + } + if (status === 'failed' || status === 'cancelled') { + const err = task.error + const msg = typeof err === 'string' ? err : (err?.message || JSON.stringify(err) || 'Video generation failed') + const code = err && typeof err === 'object' && err.code ? `[${err.code}] ` : '' + return { status: 'failed', error: `${code}${msg}` } + } + const workerStage = ['queued', 'running', 'checking', 'missing'].includes(task.worker_state) ? task.worker_state : undefined + return { status: 'processing', stage: task.delivery?.status === 'transferring' ? 'transferring' : workerStage, routing: parseWorkerRouting(task.routing) } + } + + extractVideoUrl(result: any): string | null { + const task = result.task && typeof result.task === 'object' ? result.task : result + return task.content?.url || task.video_url || null + } + + private normalizeDuration(duration?: number | null): number { + const parsed = Math.round(Number(duration || 5)) + if (!Number.isFinite(parsed)) return 5 + // MiniMax H3 支持 4-15 秒 + return Math.min(15, Math.max(4, parsed)) + } + + /** MiniMax 仅 768P / 2K 两档;项目侧 480p/720p 统一归到 768P,1080p/2K 归到 2K */ + private normalizeResolution(resolution?: string | null): string { + const r = (resolution || '').toLowerCase() + if (r === '2k' || r === '1080p') return '2K' + return '768P' + } +} diff --git a/huobao-source/backend/src/services/adapters/openai-image.ts b/huobao-source/backend/src/services/adapters/openai-image.ts new file mode 100644 index 0000000..e43bf9e --- /dev/null +++ b/huobao-source/backend/src/services/adapters/openai-image.ts @@ -0,0 +1,194 @@ +/** + * OpenAI DALL-E 图片生成 Adapter + * 文生图端点: /v1/images/generations (注意 /v1 前缀) + * 参考生图端点: /v1/images/edits (multipart/form-data, image[] 文件上传) + * 响应格式: { data: [{ url: "..." }] } 或 { data: [{ b64_json: "..." }] } + */ +import type { + ImageProviderAdapter, + ProviderRequest, + AIConfig, + ImageGenerationRecord, + ImageGenResponse, + ImagePollResponse, +} from './types' +import { joinProviderUrl } from './url' +import { parseDataUrl } from '../../utils/storage.js' + +export class OpenAIImageAdapter implements ImageProviderAdapter { + provider = 'openai' + + buildGenerateRequest(config: AIConfig, record: ImageGenerationRecord): ProviderRequest { + const model = record.model || config.model || 'gpt-image-2' + const isGptImage = model.startsWith('gpt-image-') + const isGptImage2 = model === 'gpt-image-2' + const size = isGptImage2 + ? this.normalizeGptImage2Size(record.size) + : isGptImage + ? this.normalizeGptImageSize(record.size) + : record.size || '1024x1024' + + // 有参考图 → 走 edits(multipart 上传图片文件);无参考图 → 纯文生图 generations + const refs = this.parseReferenceImages(record.referenceImages) + if (refs.length) { + return this.buildEditsRequest(config, model, record.prompt, size, refs) + } + + const body: any = { + model, + prompt: record.prompt, + size, + n: 1, + } + + if (!isGptImage) { + body.response_format = 'url' + } + if (model === 'qwen_image_2_1' && config.gpu && config.gpu !== 'auto') body.gpu = config.gpu + + return { + url: joinProviderUrl(config.baseUrl, '/v1', '/images/generations'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body, + } + } + + private parseReferenceImages(raw?: string | null): string[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : [] + } catch { + return [] + } + } + + /** + * /v1/images/edits:参考图以 multipart/form-data 的 image[] 文件上传。 + * 参考图在 generation.ts 已归一化为 data URL(本地压缩 / 远程下载), + * 这里只做 base64 → Blob 的转换;multipart 边界由 fetch 自动设置,不要手填 Content-Type。 + */ + private buildEditsRequest( + config: AIConfig, + model: string, + prompt: string | null | undefined, + size: string, + refs: string[], + ): ProviderRequest { + const form = new FormData() + form.append('model', model) + form.append('prompt', prompt || '') + form.append('size', size) + form.append('n', '1') + if (model === 'qwen_image_2_1' && config.gpu && config.gpu !== 'auto') form.append('gpu', config.gpu) + + let appended = 0 + for (const ref of refs) { + const parsed = parseDataUrl(ref) + if (!parsed) continue + const buffer = Buffer.from(parsed.data, 'base64') + const ext = parsed.mimeType === 'image/png' ? 'png' : parsed.mimeType === 'image/webp' ? 'webp' : 'jpg' + form.append('image[]', new Blob([buffer], { type: parsed.mimeType }), `reference-${++appended}.${ext}`) + } + if (!appended) { + throw new Error('参考图片无法解析为可上传的文件(仅支持 data URL 形式的图片)') + } + + return { + url: joinProviderUrl(config.baseUrl, '/v1', '/images/edits'), + method: 'POST', + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: form, + } + } + + private normalizeGptImageSize(size?: string | null): string { + const allowed = ['1024x1024', '1536x1024', '1024x1536', 'auto'] + if (!size) return '1024x1024' + + const normalized = size.toLowerCase() + if (allowed.includes(normalized)) return normalized + + const [width, height] = normalized.split('x').map(Number) + if (!width || !height) return 'auto' + if (width === height) return '1024x1024' + return width > height ? '1536x1024' : '1024x1536' + } + + private normalizeGptImage2Size(size?: string | null): string { + if (!size) return '1024x1024' + const normalized = size.toLowerCase() + if (normalized === 'auto') return 'auto' + + const [rawWidth, rawHeight] = normalized.split('x').map(Number) + if (!rawWidth || !rawHeight) return 'auto' + + const width = this.roundToMultiple(Math.min(4096, Math.max(256, rawWidth)), 16) + const height = this.roundToMultiple(Math.min(4096, Math.max(256, rawHeight)), 16) + return `${width}x${height}` + } + + private roundToMultiple(value: number, multiple: number): number { + return Math.max(multiple, Math.round(value / multiple) * multiple) + } + + parseGenerateResponse(result: any): ImageGenResponse { + // OpenAI DALL-E 3 目前是同步返回,但规范上也有异步 task 模式 + if (result.task_id || result.id) { + return { isAsync: true, taskId: result.task_id || result.id } + } + const imageUrl = result.data?.[0]?.url || result.url + if (imageUrl) { + return { isAsync: false, imageUrl } + } + // b64_json 模式 + const b64 = result.data?.[0]?.b64_json + if (b64) { + // 对于 base64,返回特殊标记,实际处理在 extractImageBase64 + return { isAsync: false, imageUrl: undefined } + } + throw new Error('No image URL in response') + } + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest { + return { + url: joinProviderUrl(config.baseUrl, '/v1', `/images/task/${taskId}`), + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: undefined, + } + } + + parsePollResponse(result: any): ImagePollResponse { + if (result.status === 'completed') { + return { + status: 'completed', + imageUrl: result.image_url || result.data?.[0]?.url || null, + } + } + if (result.status === 'failed') { + return { status: 'failed', error: result.error?.message || 'Generation failed' } + } + return { status: result.status || 'processing', stage: ['queued', 'running', 'checking', 'missing'].includes(result.worker_state) ? result.worker_state : undefined } + } + + extractImageUrl(result: any): string | null { + return result.data?.[0]?.url || result.image_url || null + } + + extractImageBase64(result: any): { data: string; mimeType: string } | null { + const b64 = result.data?.[0]?.b64_json + if (b64) { + return { data: b64, mimeType: 'image/png' } + } + return null + } +} diff --git a/huobao-source/backend/src/services/adapters/registry.ts b/huobao-source/backend/src/services/adapters/registry.ts new file mode 100644 index 0000000..00f5e22 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/registry.ts @@ -0,0 +1,49 @@ +/** + * Provider Adapter 注册表 + * 根据 provider 名称返回对应的 Adapter 实例 + */ +import { OpenAIImageAdapter } from './openai-image' +import { GeminiImageAdapter } from './gemini-image' +import { VolcEngineImageAdapter } from './volcengine-image' +import { VolcEngineVideoAdapter } from './volcengine-video' +import { MiniMaxVideoAdapter } from './minimax-video' +import { LocalVideoAdapter } from './local-video' +import { AliyunWanVideoAdapter } from './aliyun-wan-video' +import type { ImageProviderAdapter, VideoProviderAdapter } from './types' + +// 图片 Adapter 注册表 +export const imageAdapters: Record = { + openai: new OpenAIImageAdapter(), + gemini: new GeminiImageAdapter(), + volcengine: new VolcEngineImageAdapter(), +} + +// 视频 Adapter 注册表 +export const videoAdapters: Record = { + volcengine: new VolcEngineVideoAdapter(), + minimax: new MiniMaxVideoAdapter(), + localvideo: new LocalVideoAdapter(), + aliyun: new AliyunWanVideoAdapter(), +} + +/** + * 获取图片 Adapter + * @param provider 厂商名称 + * @returns 对应的 Adapter + */ +export function getImageAdapter(provider: string): ImageProviderAdapter { + const adapter = imageAdapters[provider.toLowerCase()] + if (!adapter) throw new Error(`Unsupported image provider: ${provider}`) + return adapter +} + +/** + * 获取视频 Adapter + * @param provider 厂商名称 + * @returns 对应的 Adapter + */ +export function getVideoAdapter(provider: string): VideoProviderAdapter { + const adapter = videoAdapters[provider.toLowerCase()] + if (!adapter) throw new Error(`Unsupported video provider: ${provider}`) + return adapter +} diff --git a/huobao-source/backend/src/services/adapters/types.ts b/huobao-source/backend/src/services/adapters/types.ts new file mode 100644 index 0000000..f9e1ff8 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/types.ts @@ -0,0 +1,147 @@ +/** + * 图片生成 Provider Adapter 接口 + */ +export interface ImageProviderAdapter { + /** 厂商标识 */ + provider: string + + /** + * 构建图片生成请求 + * @param config AI 配置 { baseUrl, apiKey, model } + * @param record 图片生成记录 + */ + buildGenerateRequest(config: AIConfig, record: ImageGenerationRecord): ProviderRequest + + /** + * 解析生成响应,判断是同步还是异步 + */ + parseGenerateResponse(result: any): ImageGenResponse + + /** + * 构建轮询请求 + * @param config AI 配置 + * @param taskId 任务 ID + */ + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest + + /** + * 解析轮询响应 + */ + parsePollResponse(result: any): ImagePollResponse + + /** + * 从响应中提取图片 URL(用于直接下载) + * 返回 null 表示图片数据是 base64 格式,需要用 extractImageBase64 处理 + */ + extractImageUrl(result: any): string | null + + /** + * 从响应中提取 base64 图片数据 + * 仅用于 Gemini 等只返回 base64 的厂商 + */ + extractImageBase64(result: any): { data: string; mimeType: string } | null +} + +/** + * 视频生成 Provider Adapter 接口 + */ +export interface VideoProviderAdapter { + provider: string + + buildGenerateRequest(config: AIConfig, record: VideoGenerationRecord): ProviderRequest + + parseGenerateResponse(result: any): VideoGenResponse + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest + + parsePollResponse(result: any): VideoPollResponse + + extractVideoUrl(result: any): string | null +} + +// ============ 通用类型 ============ + +export interface ProviderRequest { + url: string + method: string + headers: Record + /** + * 普通请求为可 JSON 序列化的对象(generation.ts 统一 JSON.stringify); + * multipart 上传(如 OpenAI /v1/images/edits)直接返回 FormData, + * 此时 headers 不要设置 Content-Type,边界由 fetch 自动生成。 + */ + body: any +} + +export interface AIConfig { + id?: number + provider: string + baseUrl: string + apiKey: string + model: string + gpu?: 'auto' | 'gpu1' | 'gpu2' | 'gpu3' +} + +export interface ImageGenerationRecord { + id: number + model?: string | null + prompt?: string | null + size?: string | null + frameType?: string | null + referenceImages?: string | null + // ... 其他字段 +} + +export interface VideoGenerationRecord { + id: number + model?: string | null + prompt?: string | null + referenceMode?: string | null + imageUrl?: string | null + firstFrameUrl?: string | null + lastFrameUrl?: string | null + referenceImageUrls?: string | null + referenceVideoUrls?: string | null + referenceAudioUrls?: string | null + referenceFileUrl?: string | null + referenceLinkUrl?: string | null + generateAudio?: number | boolean | null + duration?: number | null + aspectRatio?: string | null + resolution?: string | null + seed?: number | null + promptExtend?: number | boolean | null + watermark?: number | boolean | null + // ... 其他字段 +} + +export interface ImageGenResponse { + isAsync: boolean + taskId?: string + /** 同步模式下直接返回的图片 URL */ + imageUrl?: string +} + +export interface ImagePollResponse { + stage?: 'queued' | 'running' | 'checking' | 'missing' + status: 'pending' | 'processing' | 'completed' | 'failed' + imageUrl?: string + error?: string +} + +export interface VideoGenResponse { + routing?: import('../worker-routing').WorkerRouting | null + isAsync: boolean + taskId?: string + videoUrl?: string + duration?: number +} + +export interface VideoPollResponse { + routing?: import('../worker-routing').WorkerRouting | null + status: 'pending' | 'processing' | 'completed' | 'failed' + stage?: 'transferring' | 'queued' | 'running' | 'checking' | 'missing' + videoUrl?: string + duration?: number + error?: string +} diff --git a/huobao-source/backend/src/services/adapters/url.ts b/huobao-source/backend/src/services/adapters/url.ts new file mode 100644 index 0000000..335cc0d --- /dev/null +++ b/huobao-source/backend/src/services/adapters/url.ts @@ -0,0 +1,30 @@ +export function joinProviderUrl(baseUrl: string, requiredPrefix: string, path: string) { + const normalizedBase = (baseUrl || '').replace(/\/+$/, '') + const normalizedPrefix = normalizeSegment(requiredPrefix) + const normalizedPath = normalizeSegment(path) + + if (!normalizedBase) { + return `${normalizedPrefix}${normalizedPath}` + } + + try { + const url = new URL(normalizedBase) + const currentPath = url.pathname.replace(/\/+$/, '') + const mergedPrefix = currentPath.endsWith(normalizedPrefix) + ? currentPath + : `${currentPath}${normalizedPrefix}` + + url.pathname = `${mergedPrefix}${normalizedPath}`.replace(/\/{2,}/g, '/') + return url.toString() + } catch { + const basePath = normalizedBase.endsWith(normalizedPrefix) + ? normalizedBase + : `${normalizedBase}${normalizedPrefix}` + return `${basePath}${normalizedPath}` + } +} + +function normalizeSegment(segment: string) { + if (!segment) return '' + return segment.startsWith('/') ? segment : `/${segment}` +} diff --git a/huobao-source/backend/src/services/adapters/volcengine-image.ts b/huobao-source/backend/src/services/adapters/volcengine-image.ts new file mode 100644 index 0000000..b44241b --- /dev/null +++ b/huobao-source/backend/src/services/adapters/volcengine-image.ts @@ -0,0 +1,97 @@ +/** + * 火山引擎 veImageX 图片生成 Adapter + * 端点: /api/v3/images/generations (注意 /api/v3 前缀) + * 响应格式: { data: [{ url: "..." }] } + */ +import type { + ImageProviderAdapter, + ProviderRequest, + AIConfig, + ImageGenerationRecord, + ImageGenResponse, + ImagePollResponse, +} from './types' +import { joinProviderUrl } from './url' + +export class VolcEngineImageAdapter implements ImageProviderAdapter { + provider = 'volcengine' + + buildGenerateRequest(config: AIConfig, record: ImageGenerationRecord): ProviderRequest { + // 火山引擎使用 seedream 模型 + const model = record.model || config.model || 'doubao-seedream-5-0-260128' + + const body: any = { + model, + prompt: record.prompt, + } + + // 尺寸参数 + if (record.size) { + const [w, h] = record.size.split('x') + if (w && h) { + body.width = parseInt(w) + body.height = parseInt(h) + } + } + + return { + url: joinProviderUrl(config.baseUrl, '/api/v3', '/images/generations'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body, + } + } + + parseGenerateResponse(result: any): ImageGenResponse { + // 火山引擎可能返回 task_id 进行轮询 + if (result.task_id || result.id) { + return { isAsync: true, taskId: result.task_id || result.id } + } + // 同步返回 + const imageUrl = result.data?.[0]?.url || result.url + if (imageUrl) { + return { isAsync: false, imageUrl } + } + throw new Error('No image URL in response') + } + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest { + return { + url: joinProviderUrl(config.baseUrl, '/api/v3', `/images/generations/${taskId}`), + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: undefined, + } + } + + parsePollResponse(result: any): ImagePollResponse { + const status = result.status + if (status === 'succeeded') { + return { + status: 'completed', + imageUrl: result.data?.[0]?.url || result.image_url, + } + } + if (status === 'failed') { + // 上游 error 可能是对象 { code, message },规范成字符串 + const err = result.error + const msg = typeof err === 'string' ? err : (err?.message || JSON.stringify(err) || 'Generation failed') + const code = err && typeof err === 'object' && err.code ? `[${err.code}] ` : '' + return { status: 'failed', error: `${code}${msg}` } + } + return { status: status || 'processing' } + } + + extractImageUrl(result: any): string | null { + return result.data?.[0]?.url || result.image_url || null + } + + extractImageBase64(result: any): { data: string; mimeType: string } | null { + return null + } +} diff --git a/huobao-source/backend/src/services/adapters/volcengine-video.ts b/huobao-source/backend/src/services/adapters/volcengine-video.ts new file mode 100644 index 0000000..ab2b6e8 --- /dev/null +++ b/huobao-source/backend/src/services/adapters/volcengine-video.ts @@ -0,0 +1,147 @@ +/** + * 火山引擎 Seedance 2.0 视频生成 Adapter + * 端点: /api/v3/contents/generations/tasks (注意 /api/v3 前缀) + * 响应: { id: "task-xxx" } -> 轮询获取状态 + * + * 仅支持 Doubao Seedance 2.0+ 系列模型,生成模式只保留多模态参考: + * - reference 多模态参考(≤9 reference_image + ≤3 reference_video + ≤3 reference_audio + 可选文本) + * 有参考音频时至少包含 1 个参考图片或视频 + */ +import type { + VideoProviderAdapter, + ProviderRequest, + AIConfig, + VideoGenerationRecord, + VideoGenResponse, + VideoPollResponse, +} from './types' +import { joinProviderUrl } from './url' + +/** 仅支持 Seedance 2.0+ 系列(前缀匹配,兼容未来 2.0.x 变体) */ +const SEEDANCE2_MODEL_PREFIX = 'doubao-seedance-2-0' +const DEFAULT_MODEL = 'doubao-seedance-2-0-mini-260615' + +/** 多模态参考素材上限:图片 9、视频 3、音频 3 */ +const REF_LIMITS = { images: 9, videos: 3, audios: 3 } as const + +function parseUrlArray(raw?: string | null): string[] { + if (!raw) return [] + try { + const arr = JSON.parse(raw) + return Array.isArray(arr) ? arr.filter((u) => typeof u === 'string' && u.trim()) : [] + } catch { + return [] + } +} + +export class VolcEngineVideoAdapter implements VideoProviderAdapter { + provider = 'volcengine' + + buildGenerateRequest(config: AIConfig, record: VideoGenerationRecord): ProviderRequest { + const model = record.model || config.model || DEFAULT_MODEL + if (!model.startsWith(SEEDANCE2_MODEL_PREFIX)) { + throw new Error(`仅支持 Seedance 2.0 系列模型(${SEEDANCE2_MODEL_PREFIX}-*),当前: ${model}`) + } + + const prompt = (record.prompt || '').trim() + const refImages = parseUrlArray(record.referenceImageUrls) + const refVideos = parseUrlArray(record.referenceVideoUrls) + const refAudios = parseUrlArray(record.referenceAudioUrls) + + if (refImages.length > REF_LIMITS.images || refVideos.length > REF_LIMITS.videos || refAudios.length > REF_LIMITS.audios) { + throw new Error(`参考素材超限:图片≤${REF_LIMITS.images}、视频≤${REF_LIMITS.videos}、音频≤${REF_LIMITS.audios}`) + } + if (refAudios.length > 0 && refImages.length + refVideos.length === 0) { + throw new Error('参考音频需要至少 1 个参考图片或视频') + } + if (!prompt && !refImages.length && !refVideos.length && !refAudios.length) { + throw new Error('多模态参考模式需要至少一个参考素材或 prompt') + } + + const content: any[] = [] + if (prompt) content.push({ type: 'text', text: prompt }) + for (const url of refImages) { + content.push({ type: 'image_url', image_url: { url }, role: 'reference_image' }) + } + for (const url of refVideos) { + content.push({ type: 'video_url', video_url: { url }, role: 'reference_video' }) + } + for (const url of refAudios) { + content.push({ type: 'audio_url', audio_url: { url }, role: 'reference_audio' }) + } + + const body: any = { + model, + content, + generate_audio: record.generateAudio !== 0 && record.generateAudio !== false, + ratio: record.aspectRatio || 'adaptive', + duration: this.normalizeDuration(record.duration), + // Seedance 2.0 仅 480p/720p 两档,1080p 收敛到 720p + resolution: record.resolution === '480p' ? '480p' : '720p', + watermark: false, + } + + return { + url: joinProviderUrl(config.baseUrl, '/api/v3', '/contents/generations/tasks'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body, + } + } + + parseGenerateResponse(result: any): VideoGenResponse { + if (result.id) { + return { isAsync: true, taskId: result.id } + } + // 同步返回 + const videoUrl = result.video_url || result.content?.video_url || result.data?.video_url + if (videoUrl) { + return { isAsync: false, videoUrl } + } + throw new Error('No task_id or video_url in response') + } + + buildPollRequest(config: AIConfig, taskId: string): ProviderRequest { + return { + url: joinProviderUrl(config.baseUrl, '/api/v3', `/contents/generations/tasks/${taskId}`), + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: undefined, + } + } + + parsePollResponse(result: any): VideoPollResponse { + const status = result.status + if (status === 'succeeded') { + const videoUrl = result.video_url || result.content?.video_url || result.data?.video_url + return { + status: 'completed', + videoUrl, + } + } + if (status === 'failed') { + // 上游 error 可能是对象 { code, message }(如 OutputVideoSensitiveContentDetected),规范成字符串 + const err = result.error + const msg = typeof err === 'string' ? err : (err?.message || JSON.stringify(err) || 'Video generation failed') + const code = err && typeof err === 'object' && err.code ? `[${err.code}] ` : '' + return { status: 'failed', error: `${code}${msg}` } + } + return { status: status || 'processing' } + } + + extractVideoUrl(result: any): string | null { + return result.video_url || result.content?.video_url || result.data?.video_url || null + } + + private normalizeDuration(duration?: number | null): number { + const parsed = Math.round(Number(duration || 5)) + if (!Number.isFinite(parsed)) return 5 + // Seedance 2.0 支持 4-15 秒 + return Math.min(15, Math.max(4, parsed)) + } +} diff --git a/huobao-source/backend/src/services/ai.ts b/huobao-source/backend/src/services/ai.ts new file mode 100644 index 0000000..a027729 --- /dev/null +++ b/huobao-source/backend/src/services/ai.ts @@ -0,0 +1,162 @@ +/** + * AI 服务抽象层 — 从数据库配置中获取 provider 和 API key + */ +import { db, schema } from '../db/index.js' +import { eq } from 'drizzle-orm' +import { logTaskProgress, logTaskWarn } from '../utils/task-logger.js' +import { joinProviderUrl } from './adapters/url.js' + +export type ServiceType = 'text' | 'image' | 'video' + +export interface AIConfig { + id?: number + provider: string + baseUrl: string + apiKey: string + model: string + /** 采样温度,null 表示不设置(跟随服务商默认)。存于 ai_service_configs.settings JSON */ + temperature?: number | null + gpu?: 'auto' | 'gpu1' | 'gpu2' | 'gpu3' +} + +export function parseConfigGpu(settingsRaw: string | null | undefined): 'auto' | 'gpu1' | 'gpu2' | 'gpu3' { + try { + const gpu = JSON.parse(settingsRaw || '{}')?.gpu + return gpu === 'gpu1' || gpu === 'gpu2' || gpu === 'gpu3' ? gpu : 'auto' + } catch { return 'auto' } +} + +/** 从 settings JSON 解析 temperature;非法值一律视为未设置 */ +export function parseConfigTemperature(settingsRaw: string | null | undefined): number | null { + if (!settingsRaw) return null + try { + const t = JSON.parse(settingsRaw)?.temperature + return typeof t === 'number' && Number.isFinite(t) ? t : null + } catch { + return null + } +} + +export const officialProviders: Record = { + text: ['openai', 'gemini', 'volcengine'], + image: ['openai', 'gemini', 'volcengine'], + video: ['volcengine', 'minimax', 'aliyun', 'localvideo'], +} + +export function isOfficialProvider(serviceType?: string | null, provider?: string | null): boolean { + const providers = officialProviders[serviceType as ServiceType] + return !!providers && providers.includes((provider || '').toLowerCase()) +} + +export function getTextProviderBaseUrl(config: AIConfig) { + const provider = config.provider.toLowerCase() + + if (provider === 'openai') { + return joinProviderUrl(config.baseUrl, '/v1', '') + } + + if (provider === 'gemini') { + return joinProviderUrl(config.baseUrl, '/v1beta', '') + } + + if (provider === 'volcengine') { + return joinProviderUrl(config.baseUrl, '/api/v3', '') + } + + return config.baseUrl +} + +// Agent 多步循环会逐步重复解析同一配置,相同配置只打一次日志避免刷屏 +const lastLoggedActiveConfigKey = new Map() +const lastLoggedConfigByIdKey = new Map() + +export async function getActiveConfig(serviceType: ServiceType): Promise { + const rows = (await db.select().from(schema.aiServiceConfigs) + .where(eq(schema.aiServiceConfigs.serviceType, serviceType)) + ) + .filter(r => r.isActive && isOfficialProvider(serviceType, r.provider)) + .sort((a, b) => (b.priority || 0) - (a.priority || 0)) // 高优先级优先 + + const active = rows[0] + if (!active) { + logTaskWarn('AIConfig', 'active-config-missing', { serviceType }) + return null + } + + const models = active.model ? JSON.parse(active.model) : [] + const logKey = `${active.id}:${models[0] || ''}` + if (lastLoggedActiveConfigKey.get(serviceType) !== logKey) { + lastLoggedActiveConfigKey.set(serviceType, logKey) + logTaskProgress('AIConfig', 'active-config-selected', { + serviceType, + configId: active.id, + provider: active.provider, + model: models[0] || '', + priority: active.priority, + }) + } + return { + id: active.id, + provider: active.provider || '', + baseUrl: active.baseUrl, + apiKey: active.apiKey, + model: models[0] || '', + temperature: parseConfigTemperature(active.settings), + gpu: parseConfigGpu(active.settings), + } +} + +export async function getTextConfig(): Promise { + const config = await getActiveConfig('text') + if (!config) throw new Error('未配置文本模型,请先到「设置」页添加并启用 AI 服务') + return config +} + +/** + * 取某服务类型当前启用且优先级最高的官方配置 ID(创建集时自动锁定用) + */ +export async function getActiveConfigId(serviceType: ServiceType): Promise { + const rows = (await db.select().from(schema.aiServiceConfigs) + .where(eq(schema.aiServiceConfigs.serviceType, serviceType)) + ) + .filter(r => r.isActive && isOfficialProvider(serviceType, r.provider)) + .sort((a, b) => (b.priority || 0) - (a.priority || 0)) + return rows[0]?.id ?? null +} + +export async function getConfigById(id: number): Promise { + const [row] = await db.select().from(schema.aiServiceConfigs) + .where(eq(schema.aiServiceConfigs.id, id)) + if (!row || !row.isActive) { + logTaskWarn('AIConfig', 'config-by-id-missing', { configId: id }) + return null + } + if (!isOfficialProvider(row.serviceType as ServiceType, row.provider)) { + logTaskWarn('AIConfig', 'config-by-id-unsupported-provider', { + configId: id, + serviceType: row.serviceType, + provider: row.provider, + }) + return null + } + const models = row.model ? JSON.parse(row.model) : [] + const logKey = `${row.provider}:${models[0] || ''}:${row.serviceType}` + if (lastLoggedConfigByIdKey.get(id) !== logKey) { + lastLoggedConfigByIdKey.set(id, logKey) + logTaskProgress('AIConfig', 'config-by-id-selected', { + configId: id, + provider: row.provider, + model: models[0] || '', + serviceType: row.serviceType, + }) + } + return { + id: row.id, + provider: row.provider || '', + baseUrl: row.baseUrl, + apiKey: row.apiKey, + model: models[0] || '', + temperature: parseConfigTemperature(row.settings), + gpu: parseConfigGpu(row.settings), + } +} diff --git a/huobao-source/backend/src/services/app-settings.ts b/huobao-source/backend/src/services/app-settings.ts new file mode 100644 index 0000000..f56678d --- /dev/null +++ b/huobao-source/backend/src/services/app-settings.ts @@ -0,0 +1,71 @@ +/** + * 应用级全局设置(app_settings key-value 表) + * AI 内容语言:所有 Agent 产出(剧本/提取/分镜/提示词)统一使用的目标语言 + * 层次约束:本模块只依赖 db,严禁反向 import agents/*(agents/context.ts 会引用本模块) + * + * 注意:刻意做成同步 API(better-sqlite3 驱动的 .get()/.run()), + * 供 agents/context.ts 的同步 buildAgentRequestContext 内部直接调用 + */ +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' +import { now } from '../utils/response.js' + +export const CONTENT_LANGUAGES = ['zh', 'en', 'ja', 'ko'] as const +export type ContentLanguage = typeof CONTENT_LANGUAGES[number] + +const CONTENT_LANGUAGE_KEY = 'content_language' + +function isContentLanguage(v: unknown): v is ContentLanguage { + return typeof v === 'string' && (CONTENT_LANGUAGES as readonly string[]).includes(v) +} + +/** 读取全局内容语言;未设置或值非法时回退 'zh'(保持历史默认行为) */ +export function getContentLanguage(): ContentLanguage { + const row = db.select().from(schema.appSettings) + .where(eq(schema.appSettings.key, CONTENT_LANGUAGE_KEY)) + .get() + return isContentLanguage(row?.value) ? row.value : 'zh' +} + +/** 写入全局内容语言(upsert) */ +export function setContentLanguage(lang: ContentLanguage): ContentLanguage { + if (!isContentLanguage(lang)) throw new Error(`Invalid content language: ${lang}`) + db.insert(schema.appSettings) + .values({ key: CONTENT_LANGUAGE_KEY, value: lang, updatedAt: now() }) + .onConflictDoUpdate({ + target: schema.appSettings.key, + set: { value: lang, updatedAt: now() }, + }) + .run() + return lang +} + +const TOURS_SEEN_KEY = 'tours_seen' +const MAX_TOURS_SEEN = 64 + +function sanitizeTourIds(ids: unknown): string[] { + if (!Array.isArray(ids)) return [] + return [...new Set(ids.filter(v => typeof v === 'string' && v.length > 0 && v.length <= 100))].slice(0, MAX_TOURS_SEEN) +} + +/** 已看过的引导漫游 id 列表(JSON 数组存储);桌面端端口随启动变化,localStorage 不可靠,故落库 */ +export function getToursSeen(): string[] { + const row = db.select().from(schema.appSettings) + .where(eq(schema.appSettings.key, TOURS_SEEN_KEY)) + .get() + try { return sanitizeTourIds(JSON.parse(row?.value || '[]')) } catch { return [] } +} + +/** 写入已看过的引导漫游 id 列表(upsert) */ +export function setToursSeen(ids: unknown): string[] { + const clean = sanitizeTourIds(ids) + const value = JSON.stringify(clean) + db.insert(schema.appSettings) + .values({ key: TOURS_SEEN_KEY, value, updatedAt: now() }) + .onConflictDoUpdate({ + target: schema.appSettings.key, + set: { value, updatedAt: now() }, + }) + .run() + return clean +} diff --git a/huobao-source/backend/src/services/character-voice-references.ts b/huobao-source/backend/src/services/character-voice-references.ts new file mode 100644 index 0000000..1b1ccfc --- /dev/null +++ b/huobao-source/backend/src/services/character-voice-references.ts @@ -0,0 +1,53 @@ +import { and, eq, isNull } from 'drizzle-orm' +import { db, schema } from '../db/index.js' + +type VoiceCharacter = { name: string; voiceAudioUrl: string | null; bound?: boolean } +const voiceToken = /#[^\s#@,。!?;、:,.!?;:「」“”"'()()[\]{}<>]{1,100}/u + +/** Only explicitly mentioned voices are sent; indices follow the audio array, not image order. */ +export function resolveVoiceMentions(prompt: string, characters: VoiceCharacter[], existingAudio: string[] = []) { + const audioUrls = [...new Set(existingAudio.map(url => url.trim()))] + const names = [...new Set(characters.map(c => c.name).filter(Boolean))].sort((a, b) => b.length - a.length) + let hasVoiceReferences = false + let result = '' + for (let pos = 0; pos < prompt.length;) { + if (prompt[pos] !== '#') { result += prompt[pos++]; continue } + const tail = prompt.slice(pos) + const name = names.find(name => tail.startsWith(`#${name}`)) + if (name === undefined) { + const unknown = tail.match(voiceToken) + // Numeric shot labels such as #01 are not voice mentions. + if (unknown?.index === 0 && !/^#\d+$/.test(unknown[0])) throw new Error(`音色引用「${unknown[0]}」无效,请选择当前分镜已绑定角色的音色`) + result += prompt[pos++] + continue + } + const matches = characters.filter(c => c.name === name && c.bound !== false) + if (!matches.length) throw new Error(`音色引用「#${name}」无效,该角色未绑定到当前分镜`) + if (matches.length > 1) throw new Error(`当前分镜存在同名角色「${name}」,请先修改角色名称再引用音色`) + const url = matches[0].voiceAudioUrl?.trim() + if (!url) throw new Error(`角色「${name}」尚未上传音色或音色已移除,请重新上传后生成`) + let index = audioUrls.indexOf(url) + if (index < 0) { index = audioUrls.length; audioUrls.push(url) } + hasVoiceReferences = true + result += `${name}(音色参考音频${index + 1})` + pos += name.length + 1 + // Keep saved prompts using the previous #Name音色 spelling functional. + if (prompt.slice(pos).startsWith('音色')) pos += 2 + } + return { prompt: result, audioUrls, hasVoiceReferences } +} + +export async function resolveStoryboardVoiceReferences(prompt: string, storyboardId: number | undefined, existingAudio: string[]) { + if (!prompt.includes('#')) return { prompt, audioUrls: existingAudio, hasVoiceReferences: false } + if (!storyboardId) { + if ([...prompt.matchAll(new RegExp(voiceToken, 'gu'))].some(match => !/^#\d+$/.test(match[0]))) throw new Error('引用角色音色时必须指定当前分镜') + return { prompt, audioUrls: existingAudio, hasVoiceReferences: false } + } + const characters = await db.select({ name: schema.characters.name, voiceAudioUrl: schema.characters.voiceAudioUrl, boundId: schema.storyboardCharacters.characterId }) + .from(schema.storyboards) + .innerJoin(schema.episodes, eq(schema.storyboards.episodeId, schema.episodes.id)) + .innerJoin(schema.characters, eq(schema.characters.dramaId, schema.episodes.dramaId)) + .leftJoin(schema.storyboardCharacters, and(eq(schema.storyboardCharacters.storyboardId, schema.storyboards.id), eq(schema.storyboardCharacters.characterId, schema.characters.id))) + .where(and(eq(schema.storyboards.id, storyboardId), isNull(schema.storyboards.deletedAt), isNull(schema.episodes.deletedAt), isNull(schema.characters.deletedAt))) + return resolveVoiceMentions(prompt, characters.map(c => ({ ...c, bound: c.boundId !== null })), existingAudio) +} diff --git a/huobao-source/backend/src/services/extraction.ts b/huobao-source/backend/src/services/extraction.ts new file mode 100644 index 0000000..4b0f8a1 --- /dev/null +++ b/huobao-source/backend/src/services/extraction.ts @@ -0,0 +1,89 @@ +/** + * 资产提取任务 — 异步执行,按「集 × 类型」粒度跟踪 + * 角色 / 场景 / 道具 可分别单独提取,同一集的不同类型可并行 + * 任务状态为进程内内存态:后端重启后运行中的任务状态丢失(Agent 调用本身已被中断) + */ +import { mastra } from '../mastra/index.js' +import { buildAgentRequestContext } from '../agents/context.js' +import { logTaskError, logTaskProgress, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +export type ExtractTarget = 'characters' | 'scenes' | 'props' +export const EXTRACT_TARGETS: ExtractTarget[] = ['characters', 'scenes', 'props'] + +export interface ExtractTask { + status: 'running' | 'done' | 'error' + started_at: string + finished_at?: string + error?: string +} + +const tasks = new Map() +const keyOf = (episodeId: number, target: string) => `${episodeId}:${target}` + +/** 每类资产的提取指令:限定只提取该类型,并要求与已有数据去重合并 */ +const EXTRACT_MESSAGES: Record = { + characters: '请从本集剧本中提取所有角色信息(外貌需融合性格、含妆造),先用 read_existing_characters 读取项目已有角色,同名或近名(带括号定位/别名,如「林小雨(主角)」与「林小雨」)直接复用已有、不要重复创建,再用 save_dedup_characters 保存。本次只提取角色,不要提取场景和道具。', + scenes: '请从本集剧本中提取所有场景信息(地点、时间、光影等),先用 read_existing_scenes 读取已有场景去重,再用 save_dedup_scenes 保存。本次只提取场景,不要提取角色和道具。', + props: '请从本集剧本中提取关键道具——必须同时满足:① 直接推动剧情(出现/交接/损坏/发现会引发情节转折,如凶器、信物、关键文件、定情礼物、证据);② 值得单独生成白底单品图(分镜会给它特写或反复出现)。判定三问任一答"否"即放弃:删掉它剧情依然成立吗?它只是随手使用的日常物品(手机、筷子、水杯)吗?它是场景陈设(桌椅、灯具)吗?宁可少提不要多提,一集通常 0-3 个,超过 3 个只保留最重要的 3 个,没有就一个都不提取(save_dedup_props 传空数组)。description 只记录物品外貌。先用 read_existing_props 读取已有道具去重,再用 save_dedup_props 保存。本次只提取道具,不要提取角色和场景。', +} + +/** 启动异步提取任务(立即返回);同集同类型已在运行时返回 false;可指定文本模型覆盖 */ +export function startExtraction(episodeId: number, dramaId: number, target: ExtractTarget, opts: { model?: string; configId?: number } = {}): boolean { + const key = keyOf(episodeId, target) + if (tasks.get(key)?.status === 'running') return false + + const task: ExtractTask = { status: 'running', started_at: new Date().toISOString() } + tasks.set(key, task) + + logTaskStart('Extract', target, { episodeId, dramaId, model: opts.model || undefined, configId: opts.configId || undefined }) + ;(async () => { + const agent = mastra.getAgent('extractor') + if (!agent) throw new Error('提取 Agent 不可用') + const requestContext = buildAgentRequestContext({ + episodeId, + dramaId, + modelOverride: opts.model || undefined, + textConfigId: opts.configId || undefined, + }) + return agent.generate([{ role: 'user', content: EXTRACT_MESSAGES[target] }], { + maxSteps: 20, + requestContext, + // 逐步打印 Agent 进展:调用了哪些工具、输出了什么 + onStepFinish: (step: any) => { + const tools = (step?.toolCalls || []) + .map((t: any) => t?.toolName || t?.payload?.toolName) + .filter(Boolean) + logTaskProgress('Extract', `${target}-step`, { + episodeId, + tools: tools.length ? tools.join(',') : undefined, + text: (step?.text || '').slice(0, 200) || undefined, + }) + }, + }) + })() + .then((result: any) => { + task.status = 'done' + task.finished_at = new Date().toISOString() + const toolNames = (result?.toolCalls || []).map((t: any) => t?.toolName).filter(Boolean) + logTaskSuccess('Extract', target, { + episodeId, + steps: result?.steps?.length, + toolCalls: toolNames.join(',') || undefined, + reply: (result?.text || '').slice(0, 300) || undefined, + }) + }) + .catch((err: any) => { + task.status = 'error' + task.finished_at = new Date().toISOString() + task.error = err?.message || '提取失败' + logTaskError('Extract', target, { episodeId, error: err?.message }) + }) + return true +} + +/** 查询某集三类资产的提取任务状态(未启动过的类型为 null) */ +export function getExtractionStatus(episodeId: number): Record { + const result = {} as Record + for (const target of EXTRACT_TARGETS) result[target] = tasks.get(keyOf(episodeId, target)) || null + return result +} diff --git a/huobao-source/backend/src/services/ffmpeg-merge.ts b/huobao-source/backend/src/services/ffmpeg-merge.ts new file mode 100644 index 0000000..a98a038 --- /dev/null +++ b/huobao-source/backend/src/services/ffmpeg-merge.ts @@ -0,0 +1,169 @@ +/** + * FFmpeg 多镜头拼接 — 将所有生成后的镜头视频拼接为一集 + */ +import fs from 'fs' +import path from 'path' +import { v4 as uuid } from 'uuid' +import { db, getInsertId, schema } from '../db/index.js' +import { eq } from 'drizzle-orm' +import { now } from '../utils/response.js' +import { logTaskError, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' +import { extractVideoPoster } from '../utils/video-poster.js' +import { ffmpeg, checkFfmpegSuite } from '../utils/ffmpeg.js' +import { DATA_ROOT, STORAGE_ROOT } from '../utils/paths.js' + +function toAbsPath(relativePath: string): string { + if (path.isAbsolute(relativePath)) return relativePath + if (relativePath.startsWith('static/')) return path.join(DATA_ROOT, relativePath) + return path.join(STORAGE_ROOT, relativePath) +} + +/** + * 拼接一集的镜头视频。 + * 优先使用视频生成产物,兼容历史的 composedVideoUrl 数据。 + * 传入 storyboardIds 时只拼接所选镜头(仍按镜号顺序)。 + */ +export async function mergeEpisodeVideos(episodeId: number, dramaId: number, storyboardIds?: number[]): Promise { + let storyboards = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + .orderBy(schema.storyboards.storyboardNumber) + + if (storyboardIds?.length) { + const allow = new Set(storyboardIds.map(Number)) + storyboards = storyboards.filter(sb => allow.has(sb.id)) + } + + // 允许部分拼接:按镜号顺序拼接已生成的镜头,未生成的跳过 + const clips = storyboards + .map(sb => ({ sb, url: sb.videoUrl || sb.composedVideoUrl })) + .filter(c => Boolean(c.url)) as { sb: typeof storyboards[number]; url: string }[] + + if (clips.length === 0) throw new Error('所选镜头还没有可拼接的视频') + + // 拼接前探测 ffmpeg:二进制损坏时 fluent-ffmpeg 的同步 EFTYPE 会崩掉整个进程, + // 这里提前拦截并给出可操作的修复指引(路由层会作为 400 返回前端) + const suite = await checkFfmpegSuite() + if (!suite.ffmpeg || !suite.ffprobe) { + throw new Error('本机 ffmpeg 不可用,无法拼接视频(常见于 node_modules 跨平台拷贝或 ffmpeg-static 下载损坏)。请删除 node_modules 后在本机重新 npm install,或设置 FFMPEG_BIN 指向有效的 ffmpeg 可执行文件后重启服务') + } + + // 校验视频文件真实存在:DB 里的 video_url 可能指向已被清理的文件, + // 直接拼会得到 ffmpeg 的 "No such file or directory" 晦涩报错 + const missing = clips.filter(c => !fs.existsSync(toAbsPath(c.url))) + if (missing.length > 0) { + const nums = missing.map(c => `S${c.sb.storyboardNumber}`).join('、') + throw new Error(`镜头 ${nums} 的视频文件已丢失(本地文件不存在),请重新生成这些镜头的视频,或在拼接时取消勾选`) + } + + const videos = clips.map(c => c.url) + + logTaskStart('MergeTask', 'episode-merge', { episodeId, dramaId, clips: videos.length }) + + // 创建 merge 记录 + const ts = now() + const res = await db.insert(schema.videoMerges).values({ + episodeId, + dramaId, + title: `Episode ${episodeId} Merge`, + provider: 'ffmpeg', + model: 'ffmpeg-concat-h264-aac', + status: 'processing', + scenes: JSON.stringify(videos), + createdAt: ts, + }) + const mergeId = getInsertId(res) + + // 异步执行 + doMerge(mergeId, episodeId, videos).catch(async err => { + logTaskError('MergeTask', 'episode-merge', { mergeId, episodeId, error: err.message }) + console.error(`[Merge] Failed:`, err) + await db.update(schema.videoMerges) + .set({ status: 'failed', errorMsg: err.message }) + .where(eq(schema.videoMerges.id, mergeId)) + }) + + return mergeId +} + +async function doMerge(mergeId: number, episodeId: number, videos: string[]) { + // 生成 concat 列表文件 + const listDir = path.join(STORAGE_ROOT, 'temp') + fs.mkdirSync(listDir, { recursive: true }) + const listPath = path.join(listDir, `${uuid()}.txt`) + + const listContent = videos + .map(v => `file '${toAbsPath(v)}'`) + .join('\n') + fs.writeFileSync(listPath, listContent, 'utf-8') + + // 输出文件 + const outputDir = path.join(STORAGE_ROOT, 'merged') + fs.mkdirSync(outputDir, { recursive: true }) + const outputFilename = `${uuid()}.mp4` + const outputPath = path.join(outputDir, outputFilename) + + // Mixed original/HD selections must not inherit the first clip's lower size. + const dimensions = await Promise.all(videos.map(v => new Promise<{ width: number; height: number }>((resolve, reject) => { + ffmpeg.ffprobe(toAbsPath(v), (err, meta) => { + if (err) return reject(err) + const stream = meta.streams.find(s => s.codec_type === 'video') + if (!stream?.width || !stream.height) return reject(new Error('无法读取拼接视频尺寸')) + resolve({ width: stream.width, height: stream.height }) + }) + }))) + const target = dimensions.reduce((a, b) => b.width * b.height > a.width * a.height ? b : a) + + await new Promise((resolve, reject) => { + ffmpeg() + .input(listPath) + .inputOptions(['-f', 'concat', '-safe', '0']) + .outputOptions([ + '-fflags', '+genpts', + '-vf', `scale=${target.width}:${target.height}:force_original_aspect_ratio=decrease,pad=${target.width}:${target.height}:(ow-iw)/2:(oh-ih)/2,setsar=1`, + '-c:v', 'libx264', + '-preset', 'medium', + '-crf', '23', + '-c:a', 'aac', + '-ar', '48000', + '-b:a', '192k', + '-movflags', '+faststart', + ]) + .output(outputPath) + .on('end', () => resolve()) + .on('error', (err) => reject(err)) + .run() + + }) + + // 清理临时文件 + fs.unlinkSync(listPath) + + // 获取时长 + const duration = await getVideoDuration(outputPath) + + const mergedRelative = `static/merged/${outputFilename}` + + // 成片海报帧(导出页封面用) + await extractVideoPoster(mergedRelative) + + // 更新 merge 记录 + await db.update(schema.videoMerges) + .set({ status: 'completed', mergedUrl: mergedRelative, duration, completedAt: now() }) + .where(eq(schema.videoMerges.id, mergeId)) + + // 更新 episode + await db.update(schema.episodes) + .set({ videoUrl: mergedRelative, updatedAt: now() }) + .where(eq(schema.episodes.id, episodeId)) + + logTaskSuccess('MergeTask', 'episode-merge', { mergeId, episodeId, output: mergedRelative, duration, clips: videos.length }) +} + +function getVideoDuration(filePath: string): Promise { + return new Promise((resolve) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) { resolve(0); return } + resolve(Math.round(metadata.format.duration || 0)) + }) + }) +} diff --git a/huobao-source/backend/src/services/final-prompt.ts b/huobao-source/backend/src/services/final-prompt.ts new file mode 100644 index 0000000..aa3feeb --- /dev/null +++ b/huobao-source/backend/src/services/final-prompt.ts @@ -0,0 +1,77 @@ +/** + * 最终提示词服务 + * 生图前确保角色/场景/道具已有「最终提示词」: + * - 角色 → 三视图(character turnaround:正面/侧面/背面) + * - 场景 → 固定视角 + 前景/中景/后景 + * - 道具 → 白底单品静物(single product shot on pure white background) + * 缺失时运行 prompt_generator Agent 创作并保存;失败返回 '',由调用方回退到本地拼接提示词 + */ +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' +import { mastra } from '../mastra/index.js' +import { buildAgentRequestContext } from '../agents/context.js' +import { logTaskError, logTaskProgress } from '../utils/task-logger.js' + +type CharacterRow = typeof schema.characters.$inferSelect +type SceneRow = typeof schema.scenes.$inferSelect +type PropRow = typeof schema.props.$inferSelect + +/** 顶栏选择的文本模型/配置覆盖(不传则跟随 Agent 与文本配置默认) */ +export interface PromptAgentOptions { model?: string; configId?: number } + +async function runPromptAgent(episodeId: number, dramaId: number, message: string, opts?: PromptAgentOptions) { + const agent = mastra.getAgent('prompt_generator') + if (!agent) throw new Error('图片提示词 Agent 不可用') + const requestContext = buildAgentRequestContext({ + episodeId, + dramaId, + modelOverride: opts?.model || undefined, + textConfigId: opts?.configId || undefined, + }) + await agent.generate([{ role: 'user', content: message }], { maxSteps: 12, requestContext }) +} + +/** 确保角色拥有三视图最终提示词,返回最终提示词(失败返回 '');force 时忽略已有提示词强制重新生成 */ +export async function ensureCharacterFinalPrompt(char: CharacterRow, episodeId: number, force = false, opts?: PromptAgentOptions): Promise { + if (char.finalPrompt && !force) return char.finalPrompt + try { + logTaskProgress('FinalPrompt', 'character-generate', { characterId: char.id, episodeId }) + await runPromptAgent(episodeId, char.dramaId, + `为角色「${char.name}」(character_id=${char.id}) 生成三视图最终提示词,并调用 save_character_final_prompt 保存。`, opts) + const [fresh] = await db.select().from(schema.characters).where(eq(schema.characters.id, char.id)) + return fresh?.finalPrompt || '' + } catch (err: any) { + logTaskError('FinalPrompt', 'character-generate', { characterId: char.id, error: err.message }) + return '' + } +} + +/** 确保场景拥有固定视角(前中后景)最终提示词,返回最终提示词(失败返回 '');force 时忽略已有提示词强制重新生成 */ +export async function ensureSceneFinalPrompt(scene: SceneRow, episodeId: number, force = false, opts?: PromptAgentOptions): Promise { + if (scene.finalPrompt && !force) return scene.finalPrompt + try { + logTaskProgress('FinalPrompt', 'scene-generate', { sceneId: scene.id, episodeId }) + await runPromptAgent(episodeId, scene.dramaId, + `为场景「${scene.location}」(scene_id=${scene.id}) 生成固定视角(前景/中景/后景)最终提示词,并调用 save_scene_final_prompt 保存。注意:这是无人物空镜——场景图中不能出现任何的人(含背影、剪影、倒影、照片里的人),即使场景描述提到人物活动也必须剔除,只保留场景本身。`, opts) + const [fresh] = await db.select().from(schema.scenes).where(eq(schema.scenes.id, scene.id)) + return fresh?.finalPrompt || '' + } catch (err: any) { + logTaskError('FinalPrompt', 'scene-generate', { sceneId: scene.id, error: err.message }) + return '' + } +} + +/** 确保道具拥有白底单品最终提示词,返回最终提示词(失败返回 '');force 时忽略已有提示词强制重新生成 */ +export async function ensurePropFinalPrompt(prop: PropRow, episodeId: number, force = false, opts?: PromptAgentOptions): Promise { + if (prop.finalPrompt && !force) return prop.finalPrompt + try { + logTaskProgress('FinalPrompt', 'prop-generate', { propId: prop.id, episodeId }) + await runPromptAgent(episodeId, prop.dramaId, + `为道具「${prop.name}」(prop_id=${prop.id}) 生成白底单品最终提示词,并调用 save_prop_final_prompt 保存。`, opts) + const [fresh] = await db.select().from(schema.props).where(eq(schema.props.id, prop.id)) + return fresh?.finalPrompt || '' + } catch (err: any) { + logTaskError('FinalPrompt', 'prop-generate', { propId: prop.id, error: err.message }) + return '' + } +} diff --git a/huobao-source/backend/src/services/generation.ts b/huobao-source/backend/src/services/generation.ts new file mode 100644 index 0000000..dc59f5d --- /dev/null +++ b/huobao-source/backend/src/services/generation.ts @@ -0,0 +1,858 @@ +/** + * 统一生成任务服务 — 图片/视频生成共用 sys_task 表与同一条生命周期: + * 创建(processing) → 适配器构建请求 → 同步完成或异步轮询 → 下载落盘 → 回写业务表 + */ +import { db, getInsertId, schema } from '../db/index.js' +import { and, desc, eq, gt, ne, or, isNull } from 'drizzle-orm' +import fs from 'node:fs' +import { getAbsolutePath } from '../utils/storage.js' +import { downloadVideo, safeTaskError } from './video-download.js' +import { prepareReferenceAudio, resolveReferenceAudioUrls } from './reference-audio.js' +import { getActiveConfig, getConfigById } from './ai.js' +import { parseWorkerRouting, type WorkerRouting } from './worker-routing.js' +import { isLocalQueuedTask, taskPollingProfile } from './local-task-tracking.js' +import { now } from '../utils/response.js' +import { downloadFile, fetchImageAsCompressedDataUrl, generateImageThumb, readImageAsCompressedDataUrl, readImageAsDataUrl, saveBase64Image } from '../utils/storage.js' +import { extractVideoPoster } from '../utils/video-poster.js' +import { ffmpeg } from '../utils/ffmpeg.js' +import { getImageAdapter, getVideoAdapter } from './adapters/registry' +import type { AIConfig } from './adapters/types' +import { logTaskError, logTaskPayload, logTaskProgress, logTaskStart, logTaskSuccess, logTaskWarn, redactUrl } from '../utils/task-logger.js' + +type TaskType = 'image' | 'video' + +const activeTasks = new Set() +const videoSubmissions = new Set() +const cancellingTasks = new Set() +function runBackground(id: number, job: () => Promise) { + if (activeTasks.has(id)) return + activeTasks.add(id) + void job().catch(error => failTask(id, safeTaskError(error))).finally(() => activeTasks.delete(id)) +} + +const taskLabel = (type: TaskType) => (type === 'image' ? 'ImageTask' : 'VideoTask') + +interface GenerateImageParams { + storyboardId?: number + dramaId?: number + sceneId?: number + characterId?: number + propId?: number + prompt: string + model?: string + size?: string + referenceImages?: string[] + frameType?: string + configId?: number +} + +interface GenerateVideoParams { + gpu?: AIConfig['gpu'] + replaceTaskId?: number + variant?: 'hd' + originalVideoUrl?: string + storyboardId?: number + dramaId?: number + prompt: string + model?: string + referenceMode?: string + imageUrl?: string + firstFrameUrl?: string + lastFrameUrl?: string + referenceImageUrls?: string[] + referenceVideoUrls?: string[] + referenceAudioUrls?: string[] + referenceFileUrl?: string + referenceLinkUrl?: string + generateAudio?: boolean + duration?: number + aspectRatio?: string + resolution?: string + seed?: number + promptExtend?: boolean + watermark?: boolean + configId?: number + forceNew?: boolean +} + +export async function generateImage(params: GenerateImageParams): Promise { + // 指定配置(集锁定)可能已停用/删除/厂商收敛,失效时回退到当前启用配置,避免生成被旧引用卡死 + const config = params.configId + ? (await getConfigById(params.configId)) ?? await getActiveConfig('image') + : await getActiveConfig('image') + if (!config) throw new Error('未配置图片模型,请先到「设置」页添加并启用 AI 服务') + + const id = await createTask('image', config, { + storyboardId: params.storyboardId, + dramaId: params.dramaId, + sceneId: params.sceneId, + characterId: params.characterId, + propId: params.propId, + prompt: params.prompt, + model: params.model || config.model, + }, { + size: params.size || '1920x1080', + frameType: params.frameType, + referenceImages: params.referenceImages, + }) + + logTaskStart('ImageTask', 'enqueue', { + id, + provider: config.provider, + storyboardId: params.storyboardId, + sceneId: params.sceneId, + characterId: params.characterId, + frameType: params.frameType, + model: params.model || config.model, + }) + logTaskPayload('ImageTask', 'enqueue params', { + id, + config: { provider: config.provider, model: config.model, baseUrl: config.baseUrl }, + params, + }) + return id +} + +export async function generateVideo(params: GenerateVideoParams): Promise { + const storyboardId = params.storyboardId + if (storyboardId && videoSubmissions.has(storyboardId)) throw new Error('该分镜正在提交任务,请勿重复点击') + if (storyboardId) videoSubmissions.add(storyboardId) + try { + if (params.gpu !== undefined && !['auto', 'gpu1', 'gpu2', 'gpu3'].includes(params.gpu)) throw new Error('请选择自动分配、GPU1、GPU2 或 GPU3') + if (params.replaceTaskId !== undefined && (!storyboardId || !Number.isSafeInteger(params.replaceTaskId) || params.replaceTaskId <= 0)) throw new Error('被替换的任务 ID 无效') + // 指定配置(集锁定)可能已停用/删除/厂商收敛,失效时回退到当前启用配置 + const selectedConfig = params.configId + ? await getConfigById(params.configId) + : await getActiveConfig('video') + const config = selectedConfig ? { ...selectedConfig, gpu: params.gpu ?? selectedConfig.gpu } : null + if (!config) throw new Error('未配置视频模型,请先到「设置」页添加并启用 AI 服务') + if (params.gpu !== undefined && !['localvideo', 'minimax'].includes(config.provider)) throw new Error('所选模型不支持指定本地显卡') + if (params.variant === 'hd' && (config.id !== params.configId || config.provider !== 'localvideo')) throw new Error('高清服务配置已停用或变更,请恢复 VDN-H3 配置') + + params.referenceAudioUrls = await prepareReferenceAudio(params.referenceAudioUrls, config.provider) + + // Validate the new request before touching the previous job. + const validatedRequest = getVideoAdapter(config.provider).buildGenerateRequest(config, { ...params, id: 0, + referenceImageUrls: JSON.stringify(params.referenceImageUrls || []), + referenceVideoUrls: JSON.stringify(params.referenceVideoUrls || []), + referenceAudioUrls: JSON.stringify(params.referenceAudioUrls || []) }) + if (params.replaceTaskId && ['minimax', 'localvideo'].includes(config.provider)) { + const target = new URL(config.baseUrl) + if (target.hostname === '192.168.110.111' && ['18792', '18794'].includes(target.port)) { + const response = await fetch(`${config.baseUrl.replace(/\/$/, '')}/v2/video_generation/validate`, { + method: 'POST', headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(validatedRequest.body), signal: AbortSignal.timeout(35_000) }) + if (!response.ok) throw new Error(`新显卡未就绪,原任务保持运行:${safeTaskError(await response.text())}`) + } + } + if (storyboardId) { + const tasks = await db.select().from(schema.sysTask).where(and(eq(schema.sysTask.type, 'video'), eq(schema.sysTask.storyboardId, storyboardId))) + .orderBy(desc(schema.sysTask.id)) + const latest = tasks[0] + if (params.replaceTaskId !== undefined && latest?.id !== params.replaceTaskId) throw new Error('任务已变化,请刷新后再操作;未取消任何其他任务') + if (tasks.some(t => t.status === 'processing' && t.id !== params.replaceTaskId)) throw new Error('该分镜已有运行中的任务,请等待结果') + if (latest?.status === 'processing') await cancelVideoForReplacement(latest) + if (latest?.status === 'failed' && params.forceNew !== true) throw new Error('请先恢复原任务;重新生成需确认可能再次计费') + } + + const id = await createTask('video', config, { + storyboardId: params.storyboardId, + dramaId: params.dramaId, + prompt: params.prompt, + model: params.model || config.model, + }, { + referenceMode: params.referenceMode || 'reference', + variant: params.variant, + originalVideoUrl: params.originalVideoUrl, + replacesTaskId: params.replaceTaskId, + imageUrl: params.imageUrl, + firstFrameUrl: params.firstFrameUrl, + lastFrameUrl: params.lastFrameUrl, + referenceImageUrls: params.referenceImageUrls, + referenceVideoUrls: params.referenceVideoUrls, + referenceAudioUrls: params.referenceAudioUrls, + referenceFileUrl: params.referenceFileUrl, + referenceLinkUrl: params.referenceLinkUrl, + generateAudio: params.generateAudio === false ? 0 : 1, + duration: params.duration, + aspectRatio: params.aspectRatio, + // 统一存为项目内部格式,各适配器再转换为官方大小写与枚举。 + resolution: normalizeStoredVideoResolution(params.resolution), + seed: params.seed, + promptExtend: params.promptExtend, + watermark: params.watermark, + }) + + logTaskStart('VideoTask', 'enqueue', { + id, + provider: config.provider, + storyboardId: params.storyboardId, + dramaId: params.dramaId, + referenceMode: params.referenceMode || 'reference', + duration: params.duration || 5, + }) + logTaskPayload('VideoTask', 'enqueue params', { + id, + config: { provider: config.provider, model: config.model, baseUrl: config.baseUrl }, + params, + }) + return id + } finally { + if (storyboardId) videoSubmissions.delete(storyboardId) + } +} + +async function cancelVideoForReplacement(record: SysTaskRecord) { + if (!record.taskId || record.stage === 'submitting') throw new Error('原任务尚未返回编号,请稍后再换卡') + if (record.resultUrl || ['downloading', 'saving', 'transferring'].includes(record.stage || '')) throw new Error('原视频正在保存,请完成后再重新生成') + const config = await recoveryConfig(record) + if (!['minimax', 'localvideo'].includes(config.provider)) throw new Error('原模型不支持按任务取消') + const base = config.baseUrl.replace(/\/$/, '') + const headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' } + cancellingTasks.add(record.id) + try { + const request = async (cancel: boolean) => { + const path = cancel ? `/v2/video_generation/${encodeURIComponent(record.taskId!)}/cancel` : `/v2/query/video_generation/${encodeURIComponent(record.taskId!)}` + const response = await fetch(base + path, { method: cancel ? 'POST' : 'GET', headers, + ...(cancel ? { body: '{}' } : {}), signal: AbortSignal.timeout(35_000) }) + if (!response.ok) throw new Error('原任务取消未确认,请稍后重试;未提交新任务') + const result = await response.json() as any + if (result.task?.id !== record.taskId) throw new Error('取消回执任务编号不匹配;未提交新任务') + return result.task.status as string + } + let status = await request(true) + const deadline = Date.now() + 45_000 + while (!['cancelled', 'failed', 'succeeded'].includes(status) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 1000)) + status = await request(false) + } + if (status === 'succeeded') throw new Error('原视频已经生成,正在保存;完成后可以重新生成') + if (!['cancelled', 'failed'].includes(status)) throw new Error('原任务仍在停止中,请稍后再点击;未提交新任务') + await db.update(schema.sysTask).set({ status: 'failed', stage: 'failed', errorCode: 'user_cancelled', + errorMsg: '已取消原任务并请求重新生成,历史记录保留', updatedAt: now() }) + .where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing'))) + } finally { cancellingTasks.delete(record.id) } +} + +async function createTask( + type: TaskType, + config: AIConfig, + fields: { + storyboardId?: number + dramaId?: number + sceneId?: number + characterId?: number + propId?: number + prompt: string + model?: string | null + }, + params: Record, +): Promise { + const ts = now() + const res = await db.insert(schema.sysTask).values({ + type, + ...fields, + provider: config.provider, + configId: config.id, + configBaseUrl: config.baseUrl, + stage: 'submitting', + params: JSON.stringify({ ...params, gpu: config.gpu || 'auto' }), + status: 'processing', + createdAt: ts, + updatedAt: ts, + }) + + const id = getInsertId(res) + runBackground(id, () => processTask(id, config)) + return id +} + +function parseTaskParams(raw: string | null | undefined): Record { + if (!raw) return {} + try { + return JSON.parse(raw) || {} + } catch { + return {} + } +} + +async function processTask(id: number, config: AIConfig) { + try { + const [record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + if (!record) return + const type = record.type as TaskType + const label = taskLabel(type) + const params = parseTaskParams(record.params) + logTaskProgress(label, 'build-request', { + id, + provider: config.provider, + storyboardId: record.storyboardId, + sceneId: record.sceneId, + characterId: record.characterId, + }) + + let url: string, method: string, headers: Record, body: unknown + + if (type === 'image') { + const adapter = getImageAdapter(config.provider) + const resolvedReferenceImages = await normalizeReferenceImages(params.referenceImages) + ;({ url, method, headers, body } = adapter.buildGenerateRequest(config, { + id: record.id, + model: record.model, + prompt: record.prompt, + size: params.size, + frameType: params.frameType, + referenceImages: resolvedReferenceImages.length ? JSON.stringify(resolvedReferenceImages) : null, + })) + } else { + const adapter = getVideoAdapter(config.provider) + const resolvedImageUrl = await normalizeVideoReferenceUrl(params.imageUrl) + const resolvedFirstFrameUrl = await normalizeVideoReferenceUrl(params.firstFrameUrl) + const resolvedLastFrameUrl = await normalizeVideoReferenceUrl(params.lastFrameUrl) + const resolvedReferenceImageUrls = await normalizeVideoReferenceUrls(params.referenceImageUrls) + // 视频使用公网地址;短音色按厂商能力使用 data URL 或公网地址。 + const resolvedReferenceVideoUrls = resolvePublicMediaUrls(params.referenceVideoUrls, 'video') + const resolvedReferenceAudioUrls = await resolveReferenceAudioUrls(params.referenceAudioUrls, config.provider) + const resolvedReferenceFileUrl = resolvePublicMediaUrl(params.referenceFileUrl, 'file') + ;({ url, method, headers, body } = adapter.buildGenerateRequest(config, { + id: record.id, + model: record.model, + prompt: record.prompt, + referenceMode: params.referenceMode, + imageUrl: resolvedImageUrl, + firstFrameUrl: resolvedFirstFrameUrl, + lastFrameUrl: resolvedLastFrameUrl, + referenceImageUrls: resolvedReferenceImageUrls.length ? JSON.stringify(resolvedReferenceImageUrls) : null, + referenceVideoUrls: resolvedReferenceVideoUrls.length ? JSON.stringify(resolvedReferenceVideoUrls) : null, + referenceAudioUrls: resolvedReferenceAudioUrls.length ? JSON.stringify(resolvedReferenceAudioUrls) : null, + referenceFileUrl: resolvedReferenceFileUrl, + referenceLinkUrl: params.referenceLinkUrl, + generateAudio: params.generateAudio, + duration: params.duration, + aspectRatio: params.aspectRatio, + resolution: params.resolution, + seed: params.seed, + promptExtend: params.promptExtend, + watermark: params.watermark, + })) + } + + logTaskProgress(label, 'request', { + id, + provider: config.provider, + method, + url: redactUrl(url), + model: record.model, + }) + + const isMultipart = body instanceof FormData + logTaskPayload(label, 'request payload', { + id, method, url, headers, + // multipart 表单(如 OpenAI /v1/images/edits)无法 JSON 化,记录字段摘要 + body: isMultipart ? `[multipart/form-data: ${[...(body as FormData).keys()].join(', ')}]` : body, + }) + + const resp = await fetch(url, { + method, + headers, + body: isMultipart ? (body as FormData) : JSON.stringify(body), + signal: AbortSignal.timeout(600_000), + }) + + // Firemux 在响应头提供内部任务编号;即使 JSON 响应体中断也能查询原任务。 + const gatewayTaskId = resp.headers.get('X-Firemux-Task-Id') || resp.headers.get('X-Huobao-Task-Id') + if (type === 'video' && gatewayTaskId) { + await markPolling(id, gatewayTaskId) + record.taskId = gatewayTaskId + } + + if (!resp.ok) throw new Error(`API error ${resp.status}: ${await resp.text()}`) + const result = await resp.json() as any + logTaskPayload(label, 'response payload', { id, provider: config.provider, result }) + + if (type === 'image') { + const adapter = getImageAdapter(config.provider) + const { isAsync, taskId, imageUrl } = adapter.parseGenerateResponse(result) + + if (!isAsync && imageUrl) { + logTaskProgress(label, 'sync-complete', { id, imageUrl }) + await handleImageComplete(record, imageUrl) + return + } + + if (!isAsync && !imageUrl) { + // 同步模式但无 URL(Gemini 等返回 base64) + const b64 = adapter.extractImageBase64(result) + if (b64) { + logTaskProgress(label, 'sync-base64-complete', { id, mimeType: b64.mimeType }) + await handleImageCompleteBase64(record, b64.data, b64.mimeType) + return + } + throw new Error('No image URL or base64 data in response') + } + + await markPolling(id, taskId) + await pollTask(record, config, taskId!) + return + } + + const adapter = getVideoAdapter(config.provider) + const { isAsync, taskId, videoUrl, duration, routing } = adapter.parseGenerateResponse(result) + if (routing) await saveRouting(id, routing) + if (taskId) { + await markPolling(id, taskId) + record.taskId = taskId + } + + if (!isAsync && videoUrl) { + logTaskProgress(label, 'sync-complete', { id, videoUrl }) + await handleVideoComplete(record, videoUrl, duration ?? params.duration) + return + } + + await markPolling(id, taskId) + await pollTask(record, config, taskId!) + } catch (err: any) { + const [current] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + const uncertain = current?.type === 'video' && !current.taskId && !current.resultUrl + && (err instanceof TypeError || /timeout|abort|No task_id|API error 5/i.test(err.message)) + await failTask(id, safeTaskError(err), uncertain ? 'submission_unknown' : undefined) + } +} + +async function markPolling(id: number, taskId: string | undefined) { + await db.update(schema.sysTask) + .set({ taskId, stage: 'polling', status: 'processing', updatedAt: now() }) + .where(eq(schema.sysTask.id, id)) + logTaskProgress('SysTask', 'poll-start', { id, taskId }) +} + +async function saveRouting(id: number, routing: WorkerRouting) { + const checked = parseWorkerRouting(routing) + if (!checked) return + const [record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + if (!record || record.status !== 'processing') return + await db.update(schema.sysTask).set({ params: JSON.stringify({ ...parseTaskParams(record.params), routing: checked }), updatedAt: now() }) + .where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing'))) +} + +async function failTask(id: number, message: string, code?: string) { + message = safeTaskError(message) + logTaskError('SysTask', 'failed', { id, error: message }) + await db.update(schema.sysTask) + .set({ status: 'failed', errorMsg: safeTaskError(message), ...(code ? { errorCode: code } : {}), updatedAt: now() }) + .where(and(eq(schema.sysTask.id, id), ne(schema.sysTask.status, 'completed'), + or(isNull(schema.sysTask.errorCode), ne(schema.sysTask.errorCode, 'user_cancelled')))) +} + +type SysTaskRecord = typeof schema.sysTask.$inferSelect + +async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string) { + const type = record.type as TaskType + const label = taskLabel(type) + const local = isLocalQueuedTask(config, taskId) + const profile = taskPollingProfile(type, local, parseTaskParams(record.params).variant === 'hd') + const adapter = type === 'image' ? getImageAdapter(config.provider) : getVideoAdapter(config.provider) + const startedAt = Date.now() + let delayMs = profile.intervalMs + let missingChecks = 0 + + for (let i = 0; i < profile.attempts; i++) { + if (profile.maxDurationMs && Date.now() - startedAt >= profile.maxDurationMs) { + await failTask(record.id, 'Timeout: Polling exceeded 10 minutes') + return + } + await new Promise(r => setTimeout(r, delayMs)) + const [current] = await db.select({ status: schema.sysTask.status }).from(schema.sysTask).where(eq(schema.sysTask.id, record.id)) + if (!current || current.status !== 'processing') return + try { + const { url, method, headers } = adapter.buildPollRequest(config, taskId) + logTaskProgress(label, 'poll-request', { + id: record.id, + taskId, + provider: config.provider, + method, + url: redactUrl(url), + attempt: i + 1, + }) + const remainingMs = local ? 30_000 : profile.maxDurationMs + ? Math.max(1_000, profile.maxDurationMs - (Date.now() - startedAt)) + : 600_000 + const resp = await fetch(url, { + method, + headers, + signal: AbortSignal.timeout(remainingMs), + }) + if (!resp.ok) throw new Error(`Polling HTTP ${resp.status}`) + const result = await resp.json() as any + + if (cancellingTasks.has(record.id)) continue + const [afterPoll] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)) + if (!afterPoll || afterPoll.status !== 'processing') return + + // 图片/视频 PollResponse 结构不同,这里统一按 any 取值后按 type 分支 + const pollResp: any = adapter.parsePollResponse(result) + delayMs = profile.intervalMs + if (local && pollResp.status !== 'completed' && pollResp.status !== 'failed') { + missingChecks = pollResp.stage === 'missing' ? missingChecks + 1 : 0 + if (missingChecks >= 3) { + await failTask(record.id, '显卡节点已找不到此任务,请核对节点重启记录后重新生成', 'upstream_missing') + return + } + const stage = pollResp.stage === 'missing' ? 'checking' : pollResp.stage || 'polling' + await db.update(schema.sysTask).set({ stage, updatedAt: now() }) + .where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing'))) + } + if (type === 'video' && pollResp.routing) await saveRouting(record.id, pollResp.routing) + if (type === 'video' && pollResp.stage === 'transferring') { + await db.update(schema.sysTask).set({ stage: 'transferring', updatedAt: now() }).where(eq(schema.sysTask.id, record.id)) + } + + if (pollResp.status === 'completed') { + if (type === 'image') { + if (pollResp.imageUrl) { + logTaskSuccess(label, 'poll-complete', { id: record.id, taskId, imageUrl: pollResp.imageUrl }) + await handleImageComplete(record, pollResp.imageUrl) + return + } + if (adapter.provider === 'gemini') { + // Gemini 可能返回 base64 + const b64 = (adapter as ReturnType).extractImageBase64(result) + if (b64) { + logTaskSuccess(label, 'poll-base64-complete', { id: record.id, taskId, mimeType: b64.mimeType }) + await handleImageCompleteBase64(record, b64.data, b64.mimeType) + return + } + } + } else if (pollResp.videoUrl) { + logTaskSuccess(label, 'poll-complete', { id: record.id, taskId, videoUrl: pollResp.videoUrl }) + await handleVideoComplete(record, pollResp.videoUrl, pollResp.duration) + return + } + } + if (pollResp.status === 'failed') { + // 上游明确失败(如内容审核拦截)属终态:立即落库,不重试不等待超时 + const deliveryFailed = /video_transfer_failed/.test(pollResp.error || '') + if (deliveryFailed) await db.update(schema.sysTask).set({ stage: 'transferring' }).where(eq(schema.sysTask.id, record.id)) + await failTask(record.id, pollResp.error || 'Generation failed', deliveryFailed ? 'delivery_failed' : 'generation_failed') + return + } + } catch (err: any) { + if (local) { + // A transient transport failure cannot cancel an accepted GPU job. + missingChecks = 0 + delayMs = Math.min(60_000, Math.max(profile.intervalMs, delayMs * 2)) + await db.update(schema.sysTask).set({ stage: 'reconnecting', updatedAt: now() }) + .where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing'))) + logTaskWarn(label, 'poll-reconnect', { id: record.id, taskId, error: err.message }) + continue + } + const exhausted = i === profile.attempts - 1 + || (profile.maxDurationMs != null && Date.now() - startedAt >= profile.maxDurationMs) + if (exhausted) { + await failTask(record.id, `Timeout: ${err.message}`) + return + } + logTaskWarn(label, 'poll-retry', { id: record.id, taskId, attempt: i + 1, error: err.message }) + } + } + await failTask(record.id, 'Timeout: polling attempts exhausted') +} + +async function handleImageComplete(record: SysTaskRecord, imageUrl: string) { + const localPath = await downloadFile(imageUrl, 'images') + // 列表页缩略图(前端按命名约定推导地址,失败不影响主流程) + await generateImageThumb(localPath) + + await db.update(schema.sysTask) + .set({ resultUrl: imageUrl, localPath, status: 'completed', stage: 'completed', errorCode: null, errorMsg: null, completedAt: now(), updatedAt: now() }) + .where(eq(schema.sysTask.id, record.id)) + + logTaskSuccess('ImageTask', 'downloaded', { id: record.id, provider: record.provider, localPath }) + + await writeBackImageAssets(record, localPath) +} + +async function handleImageCompleteBase64(record: SysTaskRecord, base64Data: string, mimeType: string) { + const localPath = await saveBase64Image(base64Data, mimeType, 'images') + await generateImageThumb(localPath) + + await db.update(schema.sysTask) + .set({ localPath, status: 'completed', stage: 'completed', errorCode: null, errorMsg: null, completedAt: now(), updatedAt: now() }) + .where(eq(schema.sysTask.id, record.id)) + + logTaskSuccess('ImageTask', 'saved-base64', { id: record.id, provider: record.provider, mimeType, localPath }) + + await writeBackImageAssets(record, localPath) +} + +// 图片完成后回写业务表:分镜(按 frameType)、角色、场景、道具 +async function writeBackImageAssets(record: SysTaskRecord, localPath: string) { + const params = parseTaskParams(record.params) + // A late result is kept in task history, never substituted for a newer attempt. + const newer = await db.select().from(schema.sysTask).where(and(eq(schema.sysTask.type, 'image'), gt(schema.sysTask.id, record.id), or( + eq(schema.sysTask.characterId, record.characterId || -1), eq(schema.sysTask.sceneId, record.sceneId || -1), + eq(schema.sysTask.propId, record.propId || -1), eq(schema.sysTask.storyboardId, record.storyboardId || -1)))) + if (newer.some(task => !record.storyboardId || parseTaskParams(task.params).frameType === params.frameType)) return + if (record.storyboardId) { + const sbUpdate: Record = { updatedAt: now() } + if (params.frameType === 'first_frame') sbUpdate.firstFrameImage = localPath + else if (params.frameType === 'last_frame') sbUpdate.lastFrameImage = localPath + else sbUpdate.composedImage = localPath + await db.update(schema.storyboards).set(sbUpdate).where(eq(schema.storyboards.id, record.storyboardId)) + } + if (record.characterId) { + await db.update(schema.characters).set({ imageUrl: localPath, updatedAt: now() }).where(eq(schema.characters.id, record.characterId)) + } + if (record.sceneId) { + await db.update(schema.scenes).set({ imageUrl: localPath, status: 'completed', updatedAt: now() }).where(eq(schema.scenes.id, record.sceneId)) + } + if (record.propId) { + await db.update(schema.props).set({ imageUrl: localPath, updatedAt: now() }).where(eq(schema.props.id, record.propId)) + } +} + +async function handleVideoComplete(record: SysTaskRecord, videoUrl: string, duration: number | null | undefined) { + const [current] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)) + if (!current || current.errorCode === 'user_cancelled' || cancellingTasks.has(record.id)) return + // 先落库地址与实际时长;后续下载失败仍可恢复,且不重新提交生成。 + const params: Record = { ...parseTaskParams(current.params), resultDuration: duration } + await db.update(schema.sysTask).set({ resultUrl: videoUrl, params: JSON.stringify(params), + stage: 'downloading', status: 'processing', errorCode: null, errorMsg: null, updatedAt: now() }) + .where(eq(schema.sysTask.id, record.id)) + try { + const localPath = record.localPath && fs.existsSync(getAbsolutePath(record.localPath)) + ? record.localPath : await downloadVideo(videoUrl, record.id) + if (params.variant === 'hd') { + const readDimensions = () => new Promise<{ width?: number; height?: number }>((resolve, reject) => { + ffmpeg.ffprobe(getAbsolutePath(localPath), (error, meta) => { + if (error) return reject(error) + resolve(meta.streams.find(stream => stream.codec_type === 'video') || {}) + }) + }) + let dimensions = await readDimensions() + const expected = params.resolution === '4K' ? [3840, 2160] : [2560, 1440] + if (params.aspectRatio === '9:16') expected.reverse() + // H3 aligns its latent dimensions to 32 pixels. Trim only that padding; + // never upscale a lower-resolution render and label it as native HD. + const aligned = expected.map(size => Math.ceil(size / 32) * 32) + if ((dimensions.width !== expected[0] || dimensions.height !== expected[1]) && dimensions.width === aligned[0] && dimensions.height === aligned[1]) { + const absolute = getAbsolutePath(localPath) + const cropped = `${absolute}.hd-crop.mp4` + try { + await new Promise((resolve, reject) => ffmpeg(absolute) + .outputOptions(['-vf', `crop=${expected[0]}:${expected[1]}:0:0`, '-c:v', 'libx264', '-preset', 'fast', '-crf', '18', '-c:a', 'copy', '-movflags', '+faststart']) + .output(cropped).on('end', () => resolve()).on('error', reject).run()) + await fs.promises.rename(cropped, absolute) + } finally { await fs.promises.rm(cropped, { force: true }) } + dimensions = await readDimensions() + } + if (dimensions.width !== expected[0] || dimensions.height !== expected[1]) throw new Error(`高清结果尺寸不匹配:需要 ${expected.join('×')},收到 ${dimensions.width}×${dimensions.height};原版保持不变`) + } + await db.update(schema.sysTask).set({ localPath, stage: 'saving', updatedAt: now() }).where(eq(schema.sysTask.id, record.id)) + await extractVideoPoster(localPath) + // 完成状态与业务回写原子提交;恢复旧任务不能覆盖用户后来生成的视频。 + db.transaction(tx => { + const newer = record.storyboardId ? tx.select({ id: schema.sysTask.id }).from(schema.sysTask) + .where(and(eq(schema.sysTask.storyboardId, record.storyboardId), eq(schema.sysTask.type, 'video'), gt(schema.sysTask.id, record.id))).get() : null + if (record.storyboardId && !newer && parseTaskParams(record.params).variant !== 'hd') tx.update(schema.storyboards) + .set({ videoUrl: localPath, duration: duration || undefined, updatedAt: now() }) + .where(eq(schema.storyboards.id, record.storyboardId)).run() + tx.update(schema.sysTask).set({ localPath, status: 'completed', stage: 'completed', errorCode: null, + errorMsg: null, completedAt: now(), updatedAt: now() }).where(eq(schema.sysTask.id, record.id)).run() + }) + } catch (error) { + await failTask(record.id, safeTaskError(error), 'delivery_failed') + } +} + +async function recoveryConfig(record: SysTaskRecord): Promise { + if (record.configId) { + const config = await getConfigById(record.configId) + if (config && config.provider === record.provider && config.baseUrl === record.configBaseUrl) return config + throw new Error('原生成服务配置已删除、停用或地址变更,请恢复原配置') + } + // 旧任务只在同厂商唯一配置时恢复,绝不任意换服务商查询。 + const rows = (await db.select().from(schema.aiServiceConfigs)).filter(c => c.isActive && c.serviceType === record.type && c.provider === record.provider) + if (rows.length === 1) { + const config = await getConfigById(rows[0].id) + if (config) return config + } + throw new Error('历史任务无法确定原服务配置,请先核对配置') +} + +export async function recoverVideoTask(id: number): Promise { + const [record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + if (!record || record.type !== 'video') throw new Error('视频任务不存在') + if (record.errorCode === 'user_cancelled') throw new Error('原任务已取消,请选择模型和显卡重新生成') + if (activeTasks.has(id) || record.status === 'completed') return + if (!record.resultUrl && !record.taskId) throw new Error('未保存上游任务编号,请先核对上游记录,勿重复生成') + if (record.errorCode === 'generation_failed') throw new Error('上游已确认生成失败,无法恢复原任务') + // 在任何异步配置读取前获取锁,重复点击不会启动多个下载。 + activeTasks.add(id) + try { + const config = record.taskId && !record.resultUrl ? await recoveryConfig(record) : null + await db.update(schema.sysTask).set({ status: 'processing', errorMsg: null, errorCode: null, + stage: record.resultUrl ? 'downloading' : record.stage === 'transferring' || record.errorCode === 'delivery_failed' ? 'transferring' : 'polling', updatedAt: now() }).where(eq(schema.sysTask.id, id)) + void (async () => { + if (record.resultUrl) { + await handleVideoComplete(record, record.resultUrl, parseTaskParams(record.params).resultDuration) + const [fresh] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + if (fresh?.status !== 'failed' || !record.taskId) return + // 签名链接可能过期:重新查询原任务拿新地址。 + } + const originalConfig = config ?? await recoveryConfig(record) + // Firemux 的交付恢复是独立接口;只在其明确返回交付失败时调用。 + if (record.provider === 'minimax' && (record.errorCode === 'delivery_failed' || record.stage === 'transferring') && !record.resultUrl) { + const req = getVideoAdapter('minimax').buildPollRequest(originalConfig, record.taskId!) + const resp = await fetch(`${req.url}/recover`, { method: 'POST', headers: req.headers, signal: AbortSignal.timeout(30_000) }) + if (!resp.ok) throw new Error(`Video delivery recovery HTTP ${resp.status}`) + } + await markPolling(id, record.taskId!) + await pollTask(record, originalConfig, record.taskId!) + })().catch(error => failTask(id, safeTaskError(error), 'recovery_failed')).finally(() => activeTasks.delete(id)) + } catch (error) { + activeTasks.delete(id) + throw error + } +} + +export async function recoverGenerationTask(id: number): Promise { + const [record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)) + if (record?.type === 'video') return recoverVideoTask(id) + if (!record || record.type !== 'image') throw new Error('生成任务不存在') + if (['generation_failed', 'user_cancelled'].includes(record.errorCode || '')) throw new Error('原任务已结束,无法恢复') + if (activeTasks.has(id) || record.status === 'completed') return + if (!record.taskId) throw new Error('未保存上游任务编号,无法恢复') + activeTasks.add(id) + try { + const config = await recoveryConfig(record) + await db.update(schema.sysTask).set({ status: 'processing', errorMsg: null, errorCode: null, stage: 'polling', updatedAt: now() }).where(eq(schema.sysTask.id, id)) + void pollTask(record, config, record.taskId).catch(error => failTask(id, safeTaskError(error), 'recovery_failed')).finally(() => activeTasks.delete(id)) + } catch (error) { activeTasks.delete(id); throw error } +} + +export async function resumeInterruptedTasks() { + const candidates = await db.select().from(schema.sysTask).where(or(eq(schema.sysTask.status, 'processing'), eq(schema.sysTask.status, 'failed'))).orderBy(desc(schema.sysTask.id)) + const tasks = candidates.filter(task => task.status === 'processing' || (!task.errorCode && /^Timeout: (polling attempts exhausted|Polling exceeded 10 minutes)/.test(task.errorMsg || '') + && isLocalQueuedTask({ provider: task.provider || '', baseUrl: task.configBaseUrl || '' }, task.taskId))) + for (const task of tasks) { + if ((task.type === 'video' && (task.taskId || task.resultUrl)) || (task.type === 'image' && isLocalQueuedTask({ provider: task.provider || '', baseUrl: task.configBaseUrl || '' }, task.taskId))) { + try { await recoverGenerationTask(task.id) } catch (error) { await failTask(task.id, safeTaskError(error), 'recovery_failed') } + } else { + await failTask(task.id, task.type === 'video' ? '服务中断,提交结果待确认,请先核对上游记录' : '服务重启,生成任务中断,请重试', + task.type === 'video' ? 'submission_unknown' : 'interrupted') + } + } +} + +// ─── 参考素材归一化 ─────────────────────────────────────────────── + +async function normalizeReferenceImages(refs: string[] | null | undefined): Promise { + if (!Array.isArray(refs) || !refs.length) return [] + + const deduped = Array.from( + new Set( + refs + .map((item) => String(item || '').trim()) + .filter(Boolean), + ), + ) + + const normalized = await Promise.all(deduped.map(async (value) => { + if (value.startsWith('data:image/')) return value + if (value.startsWith('static/') || value.startsWith('/static/')) { + const localPath = value.startsWith('/static/') ? value.slice(1) : value + try { + return await readImageAsCompressedDataUrl(localPath, { + maxWidth: 768, + maxHeight: 768, + quality: 68, + }) + } catch (err) { + logTaskWarn('ImageTask', 'reference-read-failed', { path: localPath, error: (err as Error).message }) + throw new Error('参考图读取失败,请重新上传后再生成') + } + } + // 远程 URL:下载压缩为 data URL,保证 multipart 上传(OpenAI edits)/ inline_data(Gemini)都可用 + if (/^https?:\/\//.test(value)) { + try { + return await fetchImageAsCompressedDataUrl(value, { + maxWidth: 768, + maxHeight: 768, + quality: 68, + }) + } catch (err) { + logTaskWarn('ImageTask', 'reference-fetch-failed', { url: value, error: (err as Error).message }) + throw new Error('参考图读取失败,请重新上传后再生成') + } + } + throw new Error('参考图地址无效,请重新上传后再生成') + })) + + if (normalized.length > 6) throw new Error('参考图最多 6 张') + return normalized +} + +async function normalizeVideoReferenceUrl(value: string | null | undefined): Promise { + const raw = String(value || '').trim() + if (!raw) return null + if (raw.startsWith('data:image/')) return raw + if (raw.startsWith('static/') || raw.startsWith('/static/')) { + const localPath = raw.startsWith('/static/') ? raw.slice(1) : raw + try { + // 视频参考图直接编码原文件,保留原始尺寸、格式、透明通道和画质。 + return readImageAsDataUrl(localPath) + } catch (err) { + logTaskWarn('VideoTask', 'reference-read-failed', { path: localPath, error: (err as Error).message }) + return null + } + } + return raw +} + +async function normalizeVideoReferenceUrls(refs: string[] | null | undefined): Promise { + if (!Array.isArray(refs) || !refs.length) return [] + const normalized = await Promise.all( + Array.from(new Set(refs.map((item) => String(item || '').trim()).filter(Boolean))).map((item) => normalizeVideoReferenceUrl(item)), + ) + return normalized.filter((item): item is string => !!item) +} + +/** + * 将参考视频/音频解析为 Seedance API 可访问的 URL。 + * http(s)/dataURL 直通;本地 static 路径需要 PUBLIC_BASE_URL 拼成公网地址, + * 未配置时抛出可操作的中文错误(落入 catch 写入 error_msg 供前端展示)。 + */ +function resolvePublicMediaUrl(value: string | null | undefined, kind: 'video' | 'audio' | 'file'): string | null { + const raw = String(value || '').trim() + if (!raw) return null + if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('data:')) return raw + if (raw.startsWith('static/') || raw.startsWith('/static/')) { + const base = (process.env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '') + if (!base) { + const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '文件' + throw new Error( + `参考${label}为本地路径 ${raw},但后端未配置 PUBLIC_BASE_URL,上游视频生成 API 无法访问内网地址。` + + `请在 backend/.env 配置 PUBLIC_BASE_URL(如 https://your-domain.com)后重试,或改用公网 URL。`, + ) + } + const p = raw.startsWith('/') ? raw : `/${raw}` + return `${base}${p}` + } + return raw +} + +function resolvePublicMediaUrls(refs: string[] | null | undefined, kind: 'video' | 'audio'): string[] { + if (!Array.isArray(refs) || !refs.length) return [] + const items = Array.from(new Set(refs.map((item) => String(item || '').trim()).filter(Boolean))) + return items.map((item) => resolvePublicMediaUrl(item, kind)).filter((item): item is string => !!item) +} + +function normalizeStoredVideoResolution(resolution: string | null | undefined): string | undefined { + const value = String(resolution || '').trim().toLowerCase() + if (value === '480p' || value === '720p' || value === '1080p') return value + if (value === '2k') return '2K' + if (value === '4k') return '4K' + return undefined +} diff --git a/huobao-source/backend/src/services/local-task-tracking.ts b/huobao-source/backend/src/services/local-task-tracking.ts new file mode 100644 index 0000000..b17069f --- /dev/null +++ b/huobao-source/backend/src/services/local-task-tracking.ts @@ -0,0 +1,18 @@ +import { taskRouting } from './worker-routing.js' +import type { AIConfig } from './adapters/types.js' + +export function isLocalQueuedTask(config: Pick, taskId?: string | null): boolean { + try { + const url = new URL(config.baseUrl) + return url.hostname === '192.168.110.111' && ['18792', '18794'].includes(url.port) + && !!taskRouting({ provider: config.provider, taskId }) + } catch { return false } +} + +export function taskPollingProfile(type: 'image' | 'video', local: boolean, hd = false) { + // Queue waiting and confirmed execution are not generation failures. These + // services retain job IDs, so tracking can resume without submitting again. + if (local) return { attempts: Infinity, intervalMs: 10_000, maxDurationMs: null } + if (type === 'image') return { attempts: 120, intervalMs: 5000, maxDurationMs: 600_000 } + return { attempts: hd ? 1080 : 300, intervalMs: 10_000, maxDurationMs: null } +} diff --git a/huobao-source/backend/src/services/reference-audio.ts b/huobao-source/backend/src/services/reference-audio.ts new file mode 100644 index 0000000..1267b36 --- /dev/null +++ b/huobao-source/backend/src/services/reference-audio.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import { STORAGE_ROOT } from '../utils/paths.js' +import { ffmpeg, checkFfmpegSuite } from '../utils/ffmpeg.js' + +const MAX_BYTES = 15 * 1024 * 1024 +const INLINE_PROVIDERS = new Set(['volcengine', 'minimax']) + +function localAudioPath(url: string): string | null { + if (!/^\/?static\//.test(url)) return null + const file = path.resolve(STORAGE_ROOT, url.replace(/^\/?static\//, '')) + if (!file.startsWith(path.resolve(STORAGE_ROOT) + path.sep)) throw new Error('无效的本地音色路径') + return file +} + +function publicAudioUrl(url: string): string { + const base = (process.env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '') + if (!/^https?:\/\//.test(base)) { + throw new Error('当前视频模型需要公网音色地址。请配置后端 PUBLIC_BASE_URL,或切换至支持本地音色的 Seedance 2.0 / MiniMax H3。') + } + return `${base}/${url.replace(/^\//, '')}` +} + +/** Validate before creating a paid task. M4A/AAC samples are converted without trimming the voice. */ +export async function prepareReferenceAudio(refs: string[] | undefined, provider: string): Promise { + const urls = [...new Set((refs || []).map(url => url.trim()).filter(Boolean))] + const minSeconds = provider === 'aliyun' ? 1 : 2 + let durationSum = 0 + const prepared: string[] = [] + for (const url of urls) { + const file = localAudioPath(url) + if (!file) { prepared.push(url); continue } + if (!INLINE_PROVIDERS.has(provider)) publicAudioUrl(url) + let size: number + try { size = (await fs.stat(file)).size } catch { throw new Error('角色音色文件不存在,请重新上传音色') } + const ext = path.extname(file).toLowerCase() + if (!['.mp3', '.wav', '.m4a', '.aac'].includes(ext)) throw new Error('参考音色仅支持 MP3、WAV、M4A 或 AAC') + if (size > 20 * 1024 * 1024) throw new Error('音色文件过大,请上传不超过 20MB 的音频') + const suite = await checkFfmpegSuite() + if (!suite.ffprobe) throw new Error('音色校验需要 FFprobe,请修复后端 FFprobe 配置后重试') + const duration = await new Promise((resolve, reject) => { + ffmpeg.ffprobe(file, (error, metadata) => { + if (error || !metadata.streams.some(stream => stream.codec_type === 'audio')) { + reject(new Error('无法读取音色文件,请上传有效的人声音频')); return + } + resolve(Number(metadata.format.duration)) + }) + }) + if (!Number.isFinite(duration) || duration < minSeconds || duration > 15) { + throw new Error(`当前模型要求每段参考音色为 ${minSeconds}–15 秒,请裁剪或重新上传`) + } + durationSum += duration + if (durationSum > 15.05) throw new Error('本次引用的音色总时长不能超过 15 秒,请减少引用或缩短音色样本') + if (ext === '.wav' || ext === '.mp3') { + if (size > MAX_BYTES) throw new Error('当前模型要求每段参考音色不超过 15MB,请压缩或重新上传') + prepared.push(url.replace(/^\//, '')) + continue + } + if (!suite.ffmpeg) throw new Error('M4A / AAC 音色需要 FFmpeg 转换,请修复配置或上传 WAV / MP3') + const hash = createHash('sha256').update(await fs.readFile(file)).digest('hex') + const relative = `static/voice-references/${hash}.wav` + const target = localAudioPath(relative)! + await fs.mkdir(path.dirname(target), { recursive: true }) + try { await fs.access(target) } catch { + const temporary = `${target}.${randomUUID()}.wav` + try { + await new Promise((resolve, reject) => { + ffmpeg(file).noVideo().audioChannels(1).audioFrequency(48000).audioCodec('pcm_s16le') + .format('wav').on('end', () => resolve()).on('error', () => reject(new Error('音色转换失败,请改用 WAV / MP3 文件'))).save(temporary) + }) + await fs.rename(temporary, target) + } finally { await fs.rm(temporary, { force: true }) } + } + prepared.push(relative) + } + return prepared +} + +/** Seedance 2.0 / MiniMax H3 accept audio data URLs; Wan requires a public/OSS URL. + * https://docs.volcengine.com/docs/ark/create-video-generation-task-api + * https://platform.minimax.cn/docs/api-reference/video-generation-v2-create + * https://help.aliyun.com/en/model-studio/wan3-video-generation-api-reference + */ +export async function resolveReferenceAudioUrls(refs: string[] | undefined, provider: string): Promise { + return Promise.all((refs || []).map(async url => { + const file = localAudioPath(url) + if (!file) return url + if (!INLINE_PROVIDERS.has(provider)) return publicAudioUrl(url) + const bytes = await fs.readFile(file) + if (bytes.length > MAX_BYTES) throw new Error('参考音色不能超过 15MB') + const format = path.extname(file).toLowerCase() === '.mp3' ? 'mp3' : 'wav' + return `data:audio/${format};base64,${bytes.toString('base64')}` + })) +} diff --git a/huobao-source/backend/src/services/server-update.ts b/huobao-source/backend/src/services/server-update.ts new file mode 100644 index 0000000..09d528f --- /dev/null +++ b/huobao-source/backend/src/services/server-update.ts @@ -0,0 +1,120 @@ +/** + * 服务器 / Docker 部署的版本检查与更新触发 + * + * - 版本检查:复用桌面版同一份发布清单(HUOBAO_UPDATE_FEED 的 latest.json) + * - 当前版本:构建时注入 HUOBAO_VERSION(Docker ARG / 环境变量),缺省回退 package.json + * - 一键更新:仅在配置 Watchtower(HUOBAO_WATCHTOWER_URL)时可用——容器不可变, + * 容器内自更新是反模式,正确路径是由 Watchtower 拉新镜像并重建本容器; + * 未配置时前端展示手动更新指引(docker compose pull && up -d) + */ +import { readFileSync } from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +// 双源:COS(国内直连)优先,GitHub(海外)兜底;HUOBAO_UPDATE_FEED 可整体覆盖 +const FEED_URLS = process.env.HUOBAO_UPDATE_FEED + ? [process.env.HUOBAO_UPDATE_FEED] + : [ + 'https://installer.firemux.site/huobao-drama/latest.json', + 'https://github.com/chatfire-AI/huobao-drama/releases/latest/download/latest.json', + ] + +const WATCHTOWER_URL = process.env.HUOBAO_WATCHTOWER_URL?.replace(/\/+$/, '') +const WATCHTOWER_TOKEN = process.env.HUOBAO_WATCHTOWER_TOKEN || '' + +export type ServerUpdateMode = 'watchtower' | 'manual' + +export interface ServerUpdateState { + status: 'idle' | 'up-to-date' | 'available' | 'error' + currentVersion: string + latestVersion?: string + notes?: string + error?: string + updateMode: ServerUpdateMode +} + +let lastState: Omit | null = null + +function currentVersion(): string { + if (process.env.HUOBAO_VERSION) return process.env.HUOBAO_VERSION.replace(/^v/, '') + try { + const pkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json') + return JSON.parse(readFileSync(pkg, 'utf-8')).version || '0.0.0' + } catch { + return '0.0.0' + } +} + +export function updateMode(): ServerUpdateMode { + return WATCHTOWER_URL ? 'watchtower' : 'manual' +} + +/** 与桌面版 updater.ts 同规则的三段数字比较 */ +function compareVersions(a: string, b: string): number { + const pa = a.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0) + const pb = b.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0) + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0) + } + return 0 +} + +/** GET /state — 当前版本 + 更新模式 + 上次检查结果(不触发网络请求) */ +export function getServerUpdateState(): ServerUpdateState { + return { + status: lastState?.status ?? 'idle', + currentVersion: currentVersion(), + latestVersion: lastState?.latestVersion, + notes: lastState?.notes, + error: lastState?.error, + updateMode: updateMode(), + } +} + +/** POST /check — 拉取发布清单比较版本(双源依次尝试) */ +export async function checkServerUpdate(): Promise { + try { + let feed: any + let lastError: unknown + for (const url of FEED_URLS) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + feed = await res.json() + if (!feed?.version) throw new Error('版本清单格式不正确') + break + } catch (err) { + lastError = err + } + } + if (!feed) throw new Error(`版本清单请求失败(已尝试 ${FEED_URLS.length} 个源):${(lastError as Error)?.message ?? ''}`) + const latestVersion = String(feed.version).replace(/^v/, '') + lastState = { + status: compareVersions(latestVersion, currentVersion()) > 0 ? 'available' : 'up-to-date', + currentVersion: currentVersion(), + latestVersion, + notes: feed.notes, + } + } catch (err) { + lastState = { + status: 'error', + currentVersion: currentVersion(), + error: (err as Error).message, + } + } + return getServerUpdateState() +} + +/** + * POST /apply — 触发 Watchtower 立即检查并重建本容器 + * Watchtower 异步执行:拉取新镜像 → 重建容器,本进程随后被替换,调用方只能拿到"已触发" + */ +export async function triggerWatchtowerUpdate(): Promise { + if (!WATCHTOWER_URL) throw new Error('未配置 Watchtower(HUOBAO_WATCHTOWER_URL),无法应用内更新') + const res = await fetch(`${WATCHTOWER_URL}/v1/update`, { + method: 'POST', + headers: WATCHTOWER_TOKEN ? { Authorization: `Bearer ${WATCHTOWER_TOKEN}` } : {}, + signal: AbortSignal.timeout(15_000), + }) + if (!res.ok) throw new Error(`Watchtower 触发失败(HTTP ${res.status})`) +} diff --git a/huobao-source/backend/src/services/style-preset.ts b/huobao-source/backend/src/services/style-preset.ts new file mode 100644 index 0000000..c53a235 --- /dev/null +++ b/huobao-source/backend/src/services/style-preset.ts @@ -0,0 +1,16 @@ +/** + * 风格预设服务 — 将项目绑定的视觉风格解析为英文提示词片段 + * dramas.style 存 style_presets.value;查不到/已停用时返回空串(调用方走兜底) + */ +import { and, eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' + +/** 查询项目绑定的风格预设英文提示词片段;查不到返回 '' */ +export async function getDramaStylePrompt(dramaId: number | null | undefined): Promise { + if (!dramaId) return '' + const [drama] = await db.select().from(schema.dramas).where(eq(schema.dramas.id, dramaId)) + if (!drama?.style) return '' + const [preset] = await db.select().from(schema.stylePresets) + .where(and(eq(schema.stylePresets.value, drama.style), eq(schema.stylePresets.isActive, true))) + return preset?.prompt || '' +} diff --git a/huobao-source/backend/src/services/video-download.ts b/huobao-source/backend/src/services/video-download.ts new file mode 100644 index 0000000..7588e7a --- /dev/null +++ b/huobao-source/backend/src/services/video-download.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { STORAGE_ROOT } from '../utils/paths.js' + +export class VideoDownloadError extends Error { + constructor(public code: string, message: string, public retryable = false) { super(message) } +} + +/** 不把 API key、签名链接带到诊断文本、日志或剪贴板。 */ +export function safeTaskError(error: unknown): string { + return String(error instanceof Error ? error.message : error || 'Unknown failure') + .replace(/https?:\/\/[^\s"'<>]+/gi, '[URL omitted]') + .replace(/(Bearer\s+)\S+/gi, '$1[redacted]') + .replace(/((?:api[_-]?key|signature|token|secret)\s*["']?\s*[:=]\s*["']?)[^\s,"'}&]+/gi, '$1[redacted]') + .replace(/\bsk-[\w-]+/g, '[redacted]') + .slice(0, 600) +} + +/** 下载到临时文件,校验容器头后原子改名;同任务重复恢复不会新增媒体文件。 */ +export async function downloadVideo(url: string, taskId: number, options: { + root?: string; attempts?: number; timeoutMs?: number; retryDelayMs?: number; maxBytes?: number +} = {}): Promise { + const parsed = new URL(url) + if (!['http:', 'https:'].includes(parsed.protocol)) throw new VideoDownloadError('download_url', 'Invalid video URL protocol') + const root = options.root ?? STORAGE_ROOT + const dir = path.join(root, 'videos') + await fs.promises.mkdir(dir, { recursive: true }) + const partial = path.join(dir, `task-${taskId}.part`) + const attempts = options.attempts ?? 3 + const maxBytes = options.maxBytes ?? 512 * 1024 * 1024 + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const signal = AbortSignal.timeout(options.timeoutMs ?? 120_000) + const resp = await fetch(url, { signal }) + if (!resp.ok) { + await resp.body?.cancel() + throw new VideoDownloadError('download_http', `Video download HTTP ${resp.status}`, [408, 429].includes(resp.status) || resp.status >= 500) + } + if (Number(resp.headers.get('content-length')) > maxBytes) { + await resp.body?.cancel() + throw new VideoDownloadError('download_size', 'Video exceeds download size limit') + } + if (!resp.body) throw new VideoDownloadError('download_empty', 'Empty video response', true) + let bytes = 0 + let head = Buffer.alloc(0) + const validator = new Transform({ transform(chunk, _encoding, callback) { + bytes += chunk.length + if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]) + if (bytes > maxBytes) return callback(new VideoDownloadError('download_size', 'Video exceeds download size limit')) + callback(null, chunk) + } }) + await pipeline(Readable.fromWeb(resp.body as any), validator, fs.createWriteStream(partial), { signal }) + const mp4 = head.length >= 12 && head.toString('ascii', 4, 8) === 'ftyp' + const webm = head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3 + if (!mp4 && !webm) throw new VideoDownloadError('download_format', 'Response is not an MP4 or WebM video') + const name = `task-${taskId}.${webm ? 'webm' : 'mp4'}` + await fs.promises.rename(partial, path.join(dir, name)) + return `static/videos/${name}` + } catch (error) { + await fs.promises.rm(partial, { force: true }) + const retryable = error instanceof VideoDownloadError ? error.retryable + : error instanceof TypeError || ['TimeoutError', 'AbortError'].includes((error as Error).name) + if (!retryable || attempt === attempts - 1) throw error + await new Promise(resolve => setTimeout(resolve, (options.retryDelayMs ?? 1000) * 2 ** attempt)) + } + } + throw new VideoDownloadError('download_failed', 'Video download failed') +} diff --git a/huobao-source/backend/src/services/video-prompts.ts b/huobao-source/backend/src/services/video-prompts.ts new file mode 100644 index 0000000..4b6bf09 --- /dev/null +++ b/huobao-source/backend/src/services/video-prompts.ts @@ -0,0 +1,108 @@ +/** + * 批量视频提示词任务 — 异步为缺少 video_prompt 的分镜逐个运行 prompt_generator Agent + * 进程内内存态:按集跟踪一份任务,运行中不重复启动;重启后状态丢失 + */ +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' +import { mastra } from '../mastra/index.js' +import { buildAgentRequestContext } from '../agents/context.js' +import { logTaskError, logTaskProgress, logTaskStart, logTaskSuccess } from '../utils/task-logger.js' + +export interface VideoPromptBatchStatus { + status: 'running' | 'done' | 'error' + total: number + completed: number + failed: number + current_storyboard_id?: number + started_at: string + finished_at?: string + error?: string +} + +const tasks = new Map() + +/** 启动批量生成(立即返回);运行中返回 started:false,total:-1;无待生成分镜返回 started:false,total:0; + * 传入 storyboardIds 时只处理所选分镜(即使已有提示词也重新生成),否则处理全部缺失提示词的分镜 */ +export async function startVideoPromptBatch( + episodeId: number, + dramaId: number, + opts: { model?: string; configId?: number } = {}, + storyboardIds?: number[], +): Promise<{ started: boolean; total: number }> { + if (tasks.get(episodeId)?.status === 'running') return { started: false, total: -1 } + + const sbs = await db.select().from(schema.storyboards) + .where(eq(schema.storyboards.episodeId, episodeId)) + .orderBy(schema.storyboards.storyboardNumber) + const pending = storyboardIds?.length + ? sbs.filter(sb => storyboardIds.includes(sb.id)) + : sbs.filter(sb => !(sb.videoPrompt || '').trim()) + if (!pending.length) return { started: false, total: 0 } + + // 视频模型标签:跟随该集锁定的视频配置,供 Agent 按模型特性生成 + const [ep] = await db.select().from(schema.episodes).where(eq(schema.episodes.id, episodeId)) + let videoLabel = '默认' + if (ep?.videoConfigId) { + const [cfg] = await db.select().from(schema.aiServiceConfigs).where(eq(schema.aiServiceConfigs.id, ep.videoConfigId)) + if (cfg) videoLabel = `${cfg.name} (${cfg.provider})` + } + + const task: VideoPromptBatchStatus = { + status: 'running', + total: pending.length, + completed: 0, + failed: 0, + started_at: new Date().toISOString(), + } + tasks.set(episodeId, task) + + logTaskStart('VideoPrompt', 'batch', { episodeId, dramaId, total: pending.length, model: opts.model || undefined }) + ;(async () => { + const agent = mastra.getAgent('prompt_generator') + if (!agent) throw new Error('视频提示词 Agent 不可用') + const requestContext = buildAgentRequestContext({ + episodeId, + dramaId, + modelOverride: opts.model || undefined, + textConfigId: opts.configId || undefined, + }) + for (const sb of pending) { + task.current_storyboard_id = sb.id + logTaskProgress('VideoPrompt', 'batch-shot', { episodeId, storyboardId: sb.id, index: task.completed + task.failed + 1, total: task.total }) + try { + await agent.generate([{ + role: 'user', + content: `请为分镜 #${sb.storyboardNumber}(ID:${sb.id})生成视频提示词(video_prompt)。视频模型:${videoLabel},请根据该模型的特性和时长限制生成。 +请先调用 read_storyboard_context 获取该分镜的画面描述(含【镜头N】子镜头与台词/旁白)、氛围及时长,据此生成 video_prompt(第一行是信息头:列出该分镜出场的人物与场景,用 @角色名/@场景名 引用;之后按 3 秒分段换行、用 @角色名/@场景名/@道具名 引用参考素材;段落内允许多镜头切镜,段与段可以是不同景别/角度/对象,但不跨场景,切镜点对齐分镜 description 的【镜头N】结构),然后调用 update_storyboard 保存到分镜 ID:${sb.id}。update_storyboard 参数只传 storyboard_id 和 video_prompt 两个键,不要回传该分镜的其他任何字段,不要重新拆分整集。`, + }], { maxSteps: 8, requestContext }) + // 以实际落库为准判定成败 + const [fresh] = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, sb.id)) + if ((fresh?.videoPrompt || '').trim()) task.completed++ + else { + task.failed++ + logTaskError('VideoPrompt', 'batch-shot', { storyboardId: sb.id, error: 'agent finished but video_prompt is empty' }) + } + } catch (err: any) { + task.failed++ + logTaskError('VideoPrompt', 'batch-shot', { storyboardId: sb.id, error: err?.message }) + } + } + })() + .then(() => { + task.status = 'done' + task.finished_at = new Date().toISOString() + task.current_storyboard_id = undefined + logTaskSuccess('VideoPrompt', 'batch', { episodeId, total: task.total, completed: task.completed, failed: task.failed }) + }) + .catch((err: any) => { + task.status = 'error' + task.finished_at = new Date().toISOString() + task.error = err?.message || '批量生成失败' + logTaskError('VideoPrompt', 'batch', { episodeId, error: err?.message }) + }) + return { started: true, total: pending.length } +} + +export function getVideoPromptBatchStatus(episodeId: number): VideoPromptBatchStatus | null { + return tasks.get(episodeId) || null +} diff --git a/huobao-source/backend/src/services/worker-routing.ts b/huobao-source/backend/src/services/worker-routing.ts new file mode 100644 index 0000000..8f840b9 --- /dev/null +++ b/huobao-source/backend/src/services/worker-routing.ts @@ -0,0 +1,23 @@ +/** Stable worker IDs keep old jobs on their original queue after a routing upgrade. */ +export interface WorkerRouting { gpu: 'gpu1' | 'gpu2' | 'gpu3'; port: number | null; providerId: string; legacy: boolean } +const workers: Record = { + default: { gpu: 'gpu2', port: 8188, providerId: 'default', legacy: false }, + gpu1_8189: { gpu: 'gpu1', port: 8189, providerId: 'gpu1_8189', legacy: false }, + gpu3: { gpu: 'gpu3', port: 8191, providerId: 'gpu3', legacy: false }, + gpu1: { gpu: 'gpu1', port: null, providerId: 'gpu1', legacy: true }, +} +export function parseWorkerRouting(value: any): WorkerRouting | null { + const expected = workers[value?.providerId] + return expected && value.gpu === expected.gpu && value.port === expected.port ? { ...expected } : null +} +export function taskRouting(task: { params?: string | null; taskId?: string | null; provider?: string | null }): WorkerRouting | null { + if (!['minimax', 'localvideo', 'openai'].includes(task.provider || '')) return null + try { const route = parseWorkerRouting(JSON.parse(task.params || '{}').routing); if (route) return route } catch {} + try { + const encoded = (task.taskId || '').replace(/^h3[av][12]\./, '') + const decoded = Buffer.from(encoded, 'base64url').toString('utf8') + const [providerId, id] = decoded.split(':') + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id || '')) return null + return workers[providerId] ? { ...workers[providerId] } : null + } catch { return null } +} diff --git a/huobao-source/backend/src/utils/audio-upload.ts b/huobao-source/backend/src/utils/audio-upload.ts new file mode 100644 index 0000000..e36df84 --- /dev/null +++ b/huobao-source/backend/src/utils/audio-upload.ts @@ -0,0 +1,22 @@ +import { saveUploadedFile } from './storage.js' + +export const AUDIO_MAX_BYTES = 20 * 1024 * 1024 +const AUDIO_EXT = new Set(['.mp3', '.wav', '.m4a', '.aac']) +const AUDIO_MIME = new Set(['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/x-aac']) + +export class AudioUploadError extends Error {} + +/** 音色与视频参考音频共用上传规则,文件名仅用于显示,磁盘使用 UUID。 */ +export async function saveAudioUpload(file: unknown) { + if (!(file instanceof File)) throw new AudioUploadError('文件必填') + const name = file.name.split(/[\\/]/).pop()!.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 255) + const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase() + const mime = file.type.toLowerCase() + if (!AUDIO_EXT.has(ext) || (mime && mime !== 'application/octet-stream' && !AUDIO_MIME.has(mime))) { + throw new AudioUploadError('仅支持 .mp3/.wav/.m4a/.aac 格式的音频文件') + } + if (!file.size) throw new AudioUploadError('音频文件不能为空') + if (file.size > AUDIO_MAX_BYTES) throw new AudioUploadError('音频文件大小不能超过 20MB') + const path = await saveUploadedFile(await file.arrayBuffer(), 'uploads', `voice${ext}`) + return { path, name, url: `/${path}` } +} diff --git a/huobao-source/backend/src/utils/dirsize.ts b/huobao-source/backend/src/utils/dirsize.ts new file mode 100644 index 0000000..cdbc81a --- /dev/null +++ b/huobao-source/backend/src/utils/dirsize.ts @@ -0,0 +1,86 @@ +/** + * 数据目录占用统计 + 磁盘剩余空间 + * + * - dirUsage:单次异步递归 walk(fs.promises,不阻塞事件循环),按相对路径归桶 + * - 数据库文件(huobao.sqlite3/-wal/-shm)不在 walk 中重复计数,单独 stat + * (server 模式 STORAGE_PATH 覆盖时 SQLITE_PATH 可能位于数据根之外) + */ +import type { Dirent } from 'fs' +import fsp from 'fs/promises' +import path from 'path' + +export interface UsageBuckets { + db: number + images: number + videos: number + merged: number + uploads: number + temp: number + other: number + total: number +} + +const DB_FILE_BASE = 'huobao.sqlite3' + +/** 递归 walk,对每个普通文件回调(完整路径 + 大小);目录不可读/文件竞态静默跳过 */ +async function walkFiles(dir: string, onFile: (filePath: string, size: number) => void): Promise { + let entries: Dirent[] + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walkFiles(full, onFile) + } else if (entry.isFile()) { + try { + const st = await fsp.stat(full) + onFile(full, st.size) + } catch { /* 文件在 walk 期间消失等竞态,忽略 */ } + } + } +} + +/** 统计数据目录占用(按用途分桶);db 桶 = SQLITE_PATH 主库 + -wal/-shm 三文件 */ +export async function dirUsage(dataRoot: string, sqlitePath: string): Promise { + const usage: UsageBuckets = { db: 0, images: 0, videos: 0, merged: 0, uploads: 0, temp: 0, other: 0, total: 0 } + const add = (bucket: keyof UsageBuckets, size: number) => { + usage[bucket] += size + usage.total += size + } + + // db 桶:直接 stat 三个文件(无论是否在 dataRoot 内,walk 侧会跳过避免双计) + for (const suffix of ['', '-wal', '-shm']) { + try { + const st = await fsp.stat(sqlitePath + suffix) + if (st.isFile()) add('db', st.size) + } catch { /* 文件不存在(如未开 WAL 时 -wal 缺失)*/ } + } + + await walkFiles(dataRoot, (filePath, size) => { + const rel = path.relative(dataRoot, filePath) + if (rel === DB_FILE_BASE || rel.startsWith(DB_FILE_BASE + '-')) return // 已单独计入 db 桶 + const parts = rel.split(path.sep) + if (parts[0] === 'static' && parts.length >= 2) { + const sub = parts[1] + if (sub === 'images' || sub === 'videos' || sub === 'merged' || sub === 'uploads' || sub === 'temp') { + add(sub, size) + return + } + } + add('other', size) + }) + return usage +} + +/** 目标目录所在卷的剩余字节;statfs 不可用时返回 null(调用方跳过空间校验) */ +export async function volumeFreeBytes(dir: string): Promise { + try { + const st = await fsp.statfs(dir) + return st.bavail * st.bsize + } catch { + return null + } +} diff --git a/huobao-source/backend/src/utils/ffmpeg.ts b/huobao-source/backend/src/utils/ffmpeg.ts new file mode 100644 index 0000000..d63433d --- /dev/null +++ b/huobao-source/backend/src/utils/ffmpeg.ts @@ -0,0 +1,87 @@ +/** + * FFmpeg 统一配置与可用性探测 + * + * 背景:Windows 上若 node_modules 跨平台拷贝、或 ffmpeg-static postinstall + * 下载损坏(HTML 错误页被存成 ffmpeg.exe),spawn 非 PE 文件会同步抛 + * EFTYPE;该异常发生在 fluent-ffmpeg 的延迟回调里,外层 try/catch 接不住, + * 直接崩掉整个 Node 进程。这里在业务调用前主动 spawn -version 探测, + * 不可用时由业务侧优雅降级(海报帧跳过 / 拼接返回明确错误)。 + */ +import { spawn } from 'child_process' +import fs from 'fs' +import ffmpeg from 'fluent-ffmpeg' +import { createRequire } from 'module' + +// ffmpeg-static / ffprobe-static 均为可选依赖:npm 安装时提供内置二进制; +// 桌面版打包不携带这两个 npm 包(二进制随 resources/bin 分发),缺失时走 FFMPEG_BIN/FFPROBE_BIN +const req = createRequire(import.meta.url) + +function resolveStatic(moduleName: string): string | null { + try { + const mod = req(moduleName) + return (moduleName === 'ffprobe-static' ? mod?.path : mod) ?? null + } catch { + return null + } +} + +const ffmpegPath = process.env.FFMPEG_BIN || resolveStatic('ffmpeg-static') +const ffprobePath = process.env.FFPROBE_BIN || resolveStatic('ffprobe-static') + +// 系统未安装 ffmpeg 时使用项目内置二进制 +if (ffmpegPath) ffmpeg.setFfmpegPath(ffmpegPath) +if (ffprobePath) ffmpeg.setFfprobePath(ffprobePath) + +export { ffmpeg } + +/** spawn 一次 -version 验证二进制真实可执行(含同步 EFTYPE 兜底) */ +function probeBinary(binPath: string | null): Promise { + if (!binPath || !fs.existsSync(binPath)) return Promise.resolve(false) + return new Promise((resolve) => { + let settled = false + const finish = (ok: boolean) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(ok) + } + const timer = setTimeout(() => finish(false), 10_000) + let child + try { + child = spawn(binPath, ['-version'], { stdio: 'ignore', windowsHide: true }) + } catch { + // Windows 上 spawn 非 PE 文件(如下载损坏的 ffmpeg.exe)会同步抛 EFTYPE + finish(false) + return + } + child.on('error', () => finish(false)) + child.on('close', (code) => finish(code === 0)) + }) +} + +export interface FfmpegSuite { + ffmpeg: boolean + ffprobe: boolean +} + +// 成功的探测结果缓存(进程内只探测一次);失败不缓存,修复二进制后无需重启即可恢复 +let okCache: FfmpegSuite | null = null +let warned = false + +/** 探测内置 ffmpeg/ffprobe 是否可用 */ +export async function checkFfmpegSuite(): Promise { + if (okCache) return okCache + const [ffmpegOk, ffprobeOk] = await Promise.all([probeBinary(ffmpegPath), probeBinary(ffprobePath)]) + const suite = { ffmpeg: ffmpegOk, ffprobe: ffprobeOk } + if (ffmpegOk && ffprobeOk) { + okCache = suite + } else if (!warned) { + warned = true + console.warn( + `[ffmpeg] 内置二进制不可用 (ffmpeg=${ffmpegOk ? 'ok' : 'FAIL'}, ffprobe=${ffprobeOk ? 'ok' : 'FAIL'})。` + + `海报帧提取与视频拼接将跳过/失败。修复:删除 node_modules 后在本机重新 npm install,` + + `或设置 FFMPEG_BIN/FFPROBE_BIN 指向有效的可执行文件` + ) + } + return suite +} diff --git a/huobao-source/backend/src/utils/paths.ts b/huobao-source/backend/src/utils/paths.ts new file mode 100644 index 0000000..f6626dc --- /dev/null +++ b/huobao-source/backend/src/utils/paths.ts @@ -0,0 +1,23 @@ +/** + * 数据目录统一解析 — 全项目唯一的路径锚点 + * + * dev:锚定仓库根 data/(__dirname 相对推断,src 与 tsc 产物深度一致)。 + * Electron 桌面版:主进程注入 HUOBAO_DATA_DIR 指向 userData 可写目录 + * (打包后 asar 内的 __dirname 相对推断不可用,写路径必须落在包外)。 + * + * STORAGE_PATH 语义保持不变:仅覆盖 static 存储根;未注入 HUOBAO_DATA_DIR + * 时以其父目录为数据根,保证 getAbsolutePath('static/...') 的既有行为。 + */ +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +// src/utils → 上三级为仓库根 +const repoRoot = path.resolve(__dirname, '../../..') + +export const DATA_ROOT = process.env.HUOBAO_DATA_DIR + ?? (process.env.STORAGE_PATH + ? path.dirname(path.resolve(process.env.STORAGE_PATH)) + : path.join(repoRoot, 'data')) + +export const STORAGE_ROOT = process.env.STORAGE_PATH ?? path.join(DATA_ROOT, 'static') diff --git a/huobao-source/backend/src/utils/response.ts b/huobao-source/backend/src/utils/response.ts new file mode 100644 index 0000000..3a71714 --- /dev/null +++ b/huobao-source/backend/src/utils/response.ts @@ -0,0 +1,25 @@ +import type { Context } from 'hono' + +export function success(c: Context, data: any = null) { + return c.json({ code: 200, data, message: 'success' }) +} + +export function created(c: Context, data: any = null) { + return c.json({ code: 201, data, message: 'created' }, 201) +} + +export function badRequest(c: Context, message = '请求参数错误') { + return c.json({ code: 400, message }, 400) +} + +export function notFound(c: Context, message = '资源不存在') { + return c.json({ code: 404, message }, 404) +} + +export function serverError(c: Context, message = '服务器内部错误') { + return c.json({ code: 500, message }, 500) +} + +export function now() { + return new Date().toISOString() +} diff --git a/huobao-source/backend/src/utils/storage.ts b/huobao-source/backend/src/utils/storage.ts new file mode 100644 index 0000000..ffb055d --- /dev/null +++ b/huobao-source/backend/src/utils/storage.ts @@ -0,0 +1,199 @@ +/** + * 文件存储工具 — 下载远程文件到本地 + */ +import fs from 'fs' +import path from 'path' +import sharp from 'sharp' +import { v4 as uuid } from 'uuid' +import { STORAGE_ROOT } from './paths.js' + +/** + * 下载远程文件到本地存储 + */ +export async function downloadFile(url: string, subDir: string): Promise { + const dir = path.join(STORAGE_ROOT, subDir) + fs.mkdirSync(dir, { recursive: true }) + + const ext = getExtFromUrl(url) + const filename = `${uuid()}${ext}` + const filePath = path.join(dir, filename) + + const resp = await fetch(url) + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`) + + const buffer = Buffer.from(await resp.arrayBuffer()) + fs.writeFileSync(filePath, buffer) + + // 返回相对路径(供 API 返回给前端) + return `static/${subDir}/${filename}` +} + +/** + * 保存上传的文件 + */ +export async function saveUploadedFile(data: ArrayBuffer, subDir: string, originalName: string): Promise { + const dir = path.join(STORAGE_ROOT, subDir) + fs.mkdirSync(dir, { recursive: true }) + + const ext = path.extname(originalName) || '.bin' + const filename = `${uuid()}${ext}` + const filePath = path.join(dir, filename) + + fs.writeFileSync(filePath, Buffer.from(data)) + return `static/${subDir}/${filename}` +} + +function getExtFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname + const ext = path.extname(pathname) + if (ext && ext.length <= 5) return ext + } catch {} + return '.bin' +} + +/** + * 获取本地文件的绝对路径 + */ +export function getAbsolutePath(relativePath: string): string { + if (relativePath.startsWith('static/')) { + return path.join(STORAGE_ROOT, '..', relativePath) + } + return path.join(STORAGE_ROOT, relativePath) +} + +/** + * 保存 Base64 编码的图片数据到本地存储 + * 用于 Gemini 等只返回 base64 数据的厂商 + */ +export async function saveBase64Image(base64Data: string, mimeType: string, subDir: string): Promise { + const dir = path.join(STORAGE_ROOT, subDir) + fs.mkdirSync(dir, { recursive: true }) + + // 从 mimeType 推断文件扩展名 + const ext = mimeTypeToExt(mimeType) + const filename = `${uuid()}${ext}` + const filePath = path.join(dir, filename) + + const buffer = Buffer.from(base64Data, 'base64') + fs.writeFileSync(filePath, buffer) + + return `static/${subDir}/${filename}` +} + +/** 由图片相对路径推导缩略图路径:static/images/x.png → static/images/x_thumb.webp */ +export function thumbPathFor(relativePath: string): string { + return relativePath.replace(/\.[^./]+$/, '_thumb.webp') +} + +/** + * 为已落盘图片生成列表页缩略图(宽 400 WebP,与原图同目录)。 + * 失败(文件损坏/格式异常等)返回 null,不阻断主流程。 + */ +export async function generateImageThumb(relativePath: string): Promise { + try { + const thumbRel = thumbPathFor(relativePath) + await sharp(getAbsolutePath(relativePath)) + .rotate() + .resize({ width: 400, withoutEnlargement: true }) + .webp({ quality: 78 }) + .toFile(getAbsolutePath(thumbRel)) + return thumbRel + } catch (err) { + console.warn(`[storage] 缩略图生成失败 ${relativePath}:`, (err as Error).message) + return null + } +} + +export function readImageAsDataUrl(relativePath: string): string { + const filePath = getAbsolutePath(relativePath) + const buffer = fs.readFileSync(filePath) + const ext = path.extname(filePath).toLowerCase() + const mimeType = extToMimeType(ext) + return `data:${mimeType};base64,${buffer.toString('base64')}` +} + +export async function readImageAsCompressedDataUrl( + relativePath: string, + options: { + maxWidth?: number + maxHeight?: number + quality?: number + } = {}, +): Promise { + return compressImageAsDataUrl(getAbsolutePath(relativePath), options) +} + +/** + * 下载远程图片并压缩为 data URL(远程参考图归一化,供 multipart/base64 上传类厂商使用) + */ +export async function fetchImageAsCompressedDataUrl( + url: string, + options: { + maxWidth?: number + maxHeight?: number + quality?: number + } = {}, +): Promise { + const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) }) + if (!resp.ok) throw new Error(`下载参考图失败: HTTP ${resp.status}`) + return compressImageAsDataUrl(Buffer.from(await resp.arrayBuffer()), options) +} + +/** sharp 输入 → 压缩 JPEG data URL(本地路径与远程下载共享同一压缩管线) */ +async function compressImageAsDataUrl( + input: string | Buffer, + options: { + maxWidth?: number + maxHeight?: number + quality?: number + } = {}, +): Promise { + const maxWidth = options.maxWidth ?? 768 + const maxHeight = options.maxHeight ?? 768 + const quality = options.quality ?? 68 + + const resized = sharp(input).rotate().resize({ + width: maxWidth, + height: maxHeight, + fit: 'inside', + withoutEnlargement: true, + }) + const metadata = await resized.metadata() + const output = metadata.hasAlpha + ? await resized.flatten({ background: '#ffffff' }).jpeg({ quality, mozjpeg: true }).toBuffer() + : await resized.jpeg({ quality, mozjpeg: true }).toBuffer() + const mimeType = 'image/jpeg' + return `data:${mimeType};base64,${output.toString('base64')}` +} + +export function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | null { + const match = String(dataUrl || '').match(/^data:([^;]+);base64,(.+)$/) + if (!match) return null + return { + mimeType: match[1], + data: match[2], + } +} + +function mimeTypeToExt(mimeType: string): string { + const map: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/webp': '.webp', + 'image/gif': '.gif', + } + return map[mimeType] || '.png' +} + +function extToMimeType(ext: string): string { + const map: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + } + return map[ext] || 'image/png' +} diff --git a/huobao-source/backend/src/utils/task-logger.ts b/huobao-source/backend/src/utils/task-logger.ts new file mode 100644 index 0000000..fa22bd6 --- /dev/null +++ b/huobao-source/backend/src/utils/task-logger.ts @@ -0,0 +1,146 @@ +type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' + +const C = { + reset: '\x1b[0m', + dim: '\x1b[2m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', + blue: '\x1b[34m', +} + +function colorFor(level: LogLevel) { + if (level === 'SUCCESS') return C.green + if (level === 'WARN') return C.yellow + if (level === 'ERROR') return C.red + return C.cyan +} + +function timeText() { + return new Date().toLocaleTimeString('zh-CN', { hour12: false }) +} + +function safeValue(value: unknown) { + if (value == null) return value + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function formatMeta(meta?: Record) { + if (!meta) return '' + const entries = Object.entries(meta) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${safeValue(value)}`) + return entries.length ? ` | ${entries.join(' ')}` : '' +} + +export function redactUrl(rawUrl: string) { + if (rawUrl.startsWith('data:')) return `${rawUrl.slice(0, rawUrl.indexOf(',') + 1)}` + try { + const url = new URL(rawUrl) + for (const key of [...url.searchParams.keys()]) { + if (/key|token|sign|credential|secret/i.test(key)) { + url.searchParams.set(key, '***') + } + } + if (url.username) url.username = '***' + if (url.password) url.password = '***' + return url.toString() + } catch { + return rawUrl + .replace(/([?&](?:key|api_key|apikey|token|access_token|sign|signature|sig)=)[^&]+/gi, '$1***') + } +} + +function sanitizeValue(value: unknown): unknown { + if (value == null) return value + if (typeof value === 'string') return truncateString(value) + if (typeof value === 'number' || typeof value === 'boolean') return value + + if (Array.isArray(value)) { + return value.map(item => sanitizeValue(item)) + } + + if (typeof value === 'object') { + const out: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + const lower = key.toLowerCase() + if (['authorization', 'api_key', 'apikey', 'apiKey', 'token', 'access_token'].includes(key) || + lower.includes('authorization') || lower.includes('token') || lower.includes('apikey') || lower.includes('api_key')) { + out[key] = '***' + continue + } + + if (typeof raw === 'string' && (lower === 'url' || lower.endsWith('url'))) { + out[key] = redactUrl(raw) + continue + } + + if (typeof raw === 'string' && ( + lower === 'data' || + lower === 'b64_json' || + lower.includes('base64') || + lower.includes('audiohex') || + lower.includes('inline') || + raw.startsWith('data:image/') + )) { + out[key] = truncateString(raw, 48) + continue + } + + out[key] = sanitizeValue(raw) + } + return out + } + + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return String(value) + } +} + +function truncateString(value: string, edge = 120) { + if (value.length <= edge * 2 + 24) return value + return `${value.slice(0, edge)}......${value.slice(-edge)}` +} + +export function logTask(scope: string, action: string, meta?: Record, level: LogLevel = 'INFO') { + const color = colorFor(level) + console.log(`${C.dim}${timeText()}${C.reset} ${color}[${scope}]${C.reset} ${action}${formatMeta(meta)}`) +} + +export function logTaskStart(scope: string, action: string, meta?: Record) { + logTask(scope, `START ${action}`, meta, 'INFO') +} + +export function logTaskProgress(scope: string, action: string, meta?: Record) { + logTask(scope, action, meta, 'INFO') +} + +export function logTaskSuccess(scope: string, action: string, meta?: Record) { + logTask(scope, `DONE ${action}`, meta, 'SUCCESS') +} + +export function logTaskWarn(scope: string, action: string, meta?: Record) { + logTask(scope, action, meta, 'WARN') +} + +export function logTaskError(scope: string, action: string, meta?: Record) { + logTask(scope, `ERROR ${action}`, meta, 'ERROR') +} + +export function logTaskPayload(scope: string, action: string, payload: unknown) { + const sanitized = sanitizeValue(payload) + const serialized = typeof sanitized === 'string' + ? sanitized + : JSON.stringify(sanitized, null, 2) + console.log(`${C.dim}${timeText()}${C.reset} ${C.blue}[${scope}]${C.reset} ${action}\n${serialized}`) +} diff --git a/huobao-source/backend/src/utils/transform.ts b/huobao-source/backend/src/utils/transform.ts new file mode 100644 index 0000000..eb8a7e3 --- /dev/null +++ b/huobao-source/backend/src/utils/transform.ts @@ -0,0 +1,16 @@ +/** + * 将 Drizzle 返回的 camelCase 对象转换为 snake_case + * 保持前端 API 兼容(和旧 Go 后端一致) + */ +export function toSnakeCase(obj: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/[A-Z]/g, m => '_' + m.toLowerCase()) + result[snakeKey] = value + } + return result +} + +export function toSnakeCaseArray(arr: Record[]): Record[] { + return arr.map(toSnakeCase) +} diff --git a/huobao-source/backend/src/utils/video-poster.ts b/huobao-source/backend/src/utils/video-poster.ts new file mode 100644 index 0000000..981fc17 --- /dev/null +++ b/huobao-source/backend/src/utils/video-poster.ts @@ -0,0 +1,42 @@ +/** + * 视频海报帧提取 — 供