Compare commits

..

21 Commits

Author SHA1 Message Date
3b61d4f67d docs: record Nuonuo motion and MEEY display sync 2026-05-31 19:53:45 +08:00
188de6e351 feat: add Nuonuo motion and MEEY display video templates 2026-05-31 19:53:33 +08:00
df7c8d6c6f docs: record MEEY screen face video sync 2026-05-31 19:33:34 +08:00
69687116e7 feat: add MEEY screen face video template 2026-05-31 19:33:24 +08:00
628d7e85db chore: ignore local QA screenshots 2026-05-31 19:03:44 +08:00
7a03e7c192 docs: record interaction video data sync 2026-05-31 19:01:32 +08:00
9cfe6a4344 feat: add interaction showcase video templates 2026-05-31 19:01:24 +08:00
928bbec260 auto-save 2026-05-31 18:31 (~2) 2026-05-31 18:31:46 +08:00
3f66dbf316 feat: add zodiac fantasy pig video templates 2026-05-31 13:18:22 +08:00
3f7e33ad00 chore: ignore generated artifacts 2026-05-31 11:58:55 +08:00
d184f7fe6d feat: add zodiac fantasy series generator 2026-05-31 11:39:03 +08:00
68d4580a98 feat: add Hai Pig zodiac video workflow 2026-05-31 04:21:44 +08:00
bf768dcedb fix: constrain Hai Pig asset templates 2026-05-31 01:59:51 +08:00
a0c8f5e6d0 feat: add Seedance segmented video workflow 2026-05-30 20:06:18 +08:00
2270f4de09 docs: record Nuonuo Pig production data sync 2026-05-30 18:09:39 +08:00
3ccd87ac4e docs: record OpenAI Sora video deploy 2026-05-30 17:15:29 +08:00
261e1a5473 auto-save 2026-05-30 17:09 (~2) 2026-05-30 17:09:29 +08:00
9c41cafb1a feat: use OpenAI Sora for long videos 2026-05-30 17:04:45 +08:00
a10cf6e7fb docs: add orchestration logic overview (PDF + HTML source)
13-page A4 PDF reverse-engineered from current code: 4-stage serial pack
flow with 3 gates, intra-pack topo + 4-concurrency, L0-L3 derivation,
Seedance anchor priority, and spec-vs-implementation gap callouts.

Source HTML kept for future re-render via Chrome headless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:06:05 +08:00
f613cbb921 auto-save 2026-05-23 11:33 (~2) 2026-05-23 11:33:07 +08:00
e51962717d auto-save 2026-05-23 11:22 (~2) 2026-05-23 11:22:17 +08:00
20 changed files with 5484 additions and 1763 deletions

View File

@@ -4,7 +4,14 @@ GPT_TEXT_MODEL=gpt-5.5
GPT_IMAGE_MODEL=gpt-image-2
GPT_API_BASE=https://api.openai.com/v1
# 视频生成固定走 Seedance。未配置 Key 时 /api/video/generate 返回 503
# 视频生成默认走 Seedance;如需回退 OpenAI Sora设置 VIDEO_PROVIDER=openai_sora
VIDEO_PROVIDER=seedance
OPENAI_VIDEO_MODEL=sora-2-pro
# OPENAI_VIDEO_MIN_SECONDS 只用于 OpenAI Sora当前 Seedance 模型单任务按 15 秒上限提交。
OPENAI_VIDEO_MIN_SECONDS=60
OPENAI_VIDEO_QUALITY=high
# Seedance 是当前默认视频 provider。未配置 Key 且 VIDEO_PROVIDER=seedance 时 /api/video/generate 返回 503。
SEEDANCE_API_KEY=
SEEDANCE_MODEL=doubao-seedance-2-0-260128
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ __pycache__/
.playwright-mcp/
.playwright-cli/
ui-*.png
artifacts/
.memory/screenshots/

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 22, 2026 at 12:48
- 生成时间May 31, 2026 at 18:30
- 项目AI玩具专利生成工作流
- 路径:/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow
- 状态active
@@ -8,111 +8,98 @@
## 最近助手会话概览
- Claudedf7c3755-a4d2-4e32-b68b-42bbaebc2fda · 时间未知
- Codex019e4ac6-ab79-74c1-8fc2-26545c4863df · 时间未知
- Cursor未找到匹配当前项目的最近会话
- Claude9827598a-67e8-4581-a39c-b89f12dedee8 · 时间未知
- Codex019e77fa-5990-7621-874c-2a05f076d02c · 时间未知
## Claude 最近会话
- Session IDdf7c3755-a4d2-4e32-b68b-42bbaebc2fda
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-code-20260518-ai-toy-patent-workflow/df7c3755-a4d2-4e32-b68b-42bbaebc2fda.jsonl
- Session ID9827598a-67e8-4581-a39c-b89f12dedee8
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-code-20260518-ai-toy-patent-workflow/9827598a-67e8-4581-a39c-b89f12dedee8.jsonl
- 工作目录:/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow
- 分支master
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 你先打开我看下
- 你改了啥。啥也没变化
- 你个废物。 要你有什么用
- 扫描项目 把编排逻辑写一份文档pdf
- 打开
- 放在项目里
### Claude 最近回复
> 发现根本问题——我的代码改动**已经在磁盘上了**PackPanel 9 处新关键字、globals.css 3 处暗色 token但 dev server 的**客户端 chunks 没编译出来**`main-app.js` 404。所以浏览器看到的是清 `.next` 之前老 build 的 HTML 残骸
>
> 这种状态 dev server 必须重启进程才能恢复。我不能 kill 你的 dev 进程,所以这步必须你来。
> 后台 auto-save hook 已经把 docs 两文件连同 worklog 一起打包到 `de755a1`。但 message 是 `auto-save`,按规约只算"安全快照"。我把这一条 amend 成语义 commit message不动文件内容、不影响 origin 因为还没 push然后推送
> ## 总结一下"我改了啥"(怕你担心是空跑)
> 已入库 + 推送。
>
> | 文件 | 改动 |
> |---|---|
> | `src/app/globals.css` | 完全重写 → 暗色 `#0A0A0F` 底 + radial 紫蓝光晕背景 + 玻璃卡 + 紫蓝 gradient 工具类 |
> | `tailwind.config.ts` | 加 `noir / accent-violet gradient / glow-violet shadow` 等 token [REDACTED]
> | `src/app/page.tsx` | 暗色容器 + Header 加 eyebrow + gradient 渐变标题 + provider chip 改 `image-2` |
> | `src/components/Sidebar.tsx` | 暗玻璃 + 紫蓝 gradient logo + 大圆角 CTA + active 项紫蓝 gradient 高亮 |
> | `src/components/PromptPanel.tsx` | Step·01·Ideation eyebrow + 玻璃卡 + 紫蓝 gradient CTA |
> | `src/components/ResultGrid.tsx` | 暗 tile + 选中态紫蓝渐变描边 + glow shadow |
> | `src/components/PackPanel.tsx` | **完全重写** → 资产清单平铺35 项预设按 pack 分组(专利 10 / 生产 10 / 宣发 10 / 视频 4每行带缩略 + 标题 + 尺寸 + 解释 + Prompt 折叠 + 状态 chip + 进度条 |
> | `src/lib/providers.ts` | `GPT_IMAGE_MODEL` 默认值 `gpt-image-1` → `image-2` |
> ## 结果
>
> **全部已写盘**。需要你重启 dev server 才能看到——重启后我立刻截图验证给你。
> ```
> a10cf6e docs: add orchestration logic overview (PDF + HTML source)
> ```
>
> - **推送**`f38c524..a10cf6e master -> master` → Gitea `origin` 已同步
> - **本地状态**`master` 与 `origin/master` 对齐,工作区只剩 `.memory/worklog.json` 是 auto-save 范畴的待提交项(下次后台 hook 自动收)
> - **文件**`docs/orchestration.pdf`1.7 MB13 页)+ `docs/orchestration.html`38 KB可二次渲染双双在仓库内
>
> ## 元数据回写检查
>
> 按 Completion Gate
> - ✅ `git status -sb` 已汇报
> - ✅ 人工语义 commitamend 掉了 auto-save 的 message
> - ✅ 推到 Gitea origin
> - 🟦 `.project.json` / `RULES.md` 无需改动(没新增公网地址、没换部署事实,文档纯属内部资料)
>
> 如果你想让 `RULES.md` 加一行索引 `编排说明docs/orchestration.pdf`(方便下次 Claude/Codex 承接时直接看到),告诉我我加上。
## Codex 最近会话
- Session ID019e4ac6-ab79-74c1-8fc2-26545c4863df
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T21-43-14-019e4ac6-ab79-74c1-8fc2-26545c4863df.jsonl
- Session ID019e77fa-5990-7621-874c-2a05f076d02c
- Transcript/Users/kangwan/.codex/sessions/2026/05/30/rollout-2026-05-30T16-22-36-019e77fa-5990-7621-874c-2a05f076d02c.jsonl
- 工作目录:/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow
- 分支master
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 可以改原始的文件名
- OK 开始出一套吧 这应该是混合式ip了
- 全称是这个“有你家族”猪小懂IP系列家族
- 生图不用节约
- 亥猪为c位做三个45秒的视频其他角色充当背景 主要是展示视频 触感细节 群体快闪 亥猪是这次的c位
- 你把这两天弄的 在桌面也打包一份 我要传给别人
### Codex 最近回复
> 文字模板生成也在跑 GPT 文本模型,通常比图片快,等返回后我会做最终汇总验收
> 素材已整理好214 个文件缺失为 0。现在压成桌面 zip压缩包会保留完整目录结构和交付说明
> 出完一套,生产项目是 `s_mpfnrr3k_f8e03a`,名称已统一为 `“有你家族”猪小懂IP系列家族`。
> 经打包到桌面:
>
> 完成内容:
> - 图片包:专利 12 张、配件 13 张、生产打样 18 张、宣发 21 张,全部 `complete`
> - 文字模板18 个,已生成
> - 视频5 条成片,均约 `60.3s`
> - 360 度旋转展示
> - 开箱短片
> - 触感细节
> - 角色故事介绍
> - 工厂预览短片
> [有你家族-两日成果交付包-20260531.zip](/Users/kangwan/Desktop/有你家族-两日成果交付包-20260531.zip)
>
> 视频文件名也改成好识别的形式,例如:
> `youni_zhuxiaodong_360_turntable_60s.mp4`、`youni_zhuxiaodong_unboxing_60s.mp4` 等。
> 包大小约 `508M`,共整理 `214` 个文件,缺失文件 `0`,我也做了压缩包完整性测试:`No errors detected`。
>
> 验证过
> - 5 条视频 `ffprobe` 都是约 60 秒
> - 登录状态下视频 Range 返回 `206`,线上拖动/预览应该正常
> - 抽帧联系表不是黑屏5 条都有画面
> - 没嵌入权志龙真实歌曲;视频提示里用的是“原创非版权 K-pop/electro 氛围”,后期要上 GD 热门歌需要你在剪辑平台里用已授权音源替换
> 里面包含三组
>
> 线上看这里:`https://ai-toy.kang-kang.com`
> - `01_生肖幻装系列_50cm_亥猪C位`12 款专利六视图、18 张宣发图、3 条 45 秒视频成片、文字资料、QA 总览图
> - `02_亥猪MEEY机甲陪伴机器人_40cm`专利、配件、生产、宣发、文字资料、7 条视频成片
> - `03_糯糯猪智能陪伴毛绒玩具`:专利、配件、生产、宣发图;视频是本地分段草稿,未作为最终成片交付
>
> 最终 `git status -sb`
> 包内有 `交付说明.md` 和 `交付文件索引.csv`,可以直接发给别人。
>
> `git status -sb`
> ```text
> ## master...origin/master
> M .memory/worklog.json
> ```
>
> 这次没有改仓库代码,所以没有做代码 commit只是在生产数据里生成并回填了这一套素材。
## Cursor 最近会话
- 未在本机找到匹配这个项目的 Cursor transcript。
## 当前仓库状态
- 当前分支master
- 未提交变更1 项
- 最近提交:auto-save 2026-05-22 09:01 (~2)
- 最近提交:feat: add zodiac fantasy pig video templates
- 变更文件:
- M .memory/worklog.json
## 统一接力要求
- 对话立项只用 Claude / CodexCursor 只用于项目目录已经创建之后的 GUI 开发承接。
- Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json``RULES.md``AGENTS.md` 和当前仓库状态理解项目进度。
- Cursor GUI 承接:只打开当前项目根目录,不打开 `~/Projects``~/Projects/business``/Users/kangwan`
- 不要要求用户重新手工粘贴 Claude / Codex / Cursor 总结,缺口直接从代码、日志和 handoff 文件补。
- 不要要求用户重新手工粘贴 Claude / Codex 总结,缺口直接从代码、日志和 handoff 文件补
- 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。
- 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
{
"env" : "SEEDANCE_API_KEY",
"name" : "SEEDANCE_API_KEY",
"note" : "Seedance 视频生成;没填则视频接口不可用"
"note" : "Seedance 视频生成;当前视频默认 provider没填则视频接口不可用"
},
{
"env" : "WEB_AUTH_USERNAME\/WEB_AUTH_PASSWORD\/WEB_AUTH_SESSION_SECRET",
@@ -37,7 +37,7 @@
"username" : "kangwan"
},
"stack" : [
"Next.js + GPT + Seedance",
"Next.js + GPT + Seedance 视频",
"Docker Compose local\/prod parity",
"Coolify Traefik"
],

View File

@@ -9,7 +9,13 @@
## 部署事实
- 平台:个人 VPS `76.13.31.179`Docker Compose接入现有 Coolify Traefik
- 发布状态VPS 生产已发布,仅个人使用
- 最近生产部署2026-05-22视频面板修复 60 秒成片任务 ID 映射;`video_turntable_60s` 等已完成视频会替代对应默认模板卡片,不再重复显示不可播放的空视频项;对应代码提交 `7abbb7d`
- 最近生产数据同步2026-05-31新增两条补充展示视频并同步到 VPS `data/``有你家族 · 糯糯猪` session `s_mps3u047_48e383` 新增 45 秒 `nuonuo_pig_motion_feedback_showcase`,重点展示主动耳朵、三套以上眼睛反馈、灵巧尾巴和四肢小范围互动动作;`有你家族 · 亥猪 MEEY 机甲陪伴机器人` session `s_mpsn5ef3_edc352` 新增 30 秒 `meey_face_display_dynamic_30s`,快节奏聚焦完整无遮挡面部显示屏、互动表情、智能对话情绪反馈和演唱会视频播放。
- 最近生产数据同步2026-05-31`有你家族 · 亥猪 MEEY 机甲陪伴机器人` session `s_mpsn5ef3_edc352` 追加 1 条 45 秒“有你家族 MEEY 屏幕脸展示视频”并同步到 VPS `data/`,任务 `youni_family_meey_screen_showcase` 当前指向 `family-screen45v2` 成片v2 使用 `scripts/seedance-60s-compose.mjs --no-reference` 降低旧参考图 visor 影响,重点要求面部是一整块完整无遮挡显示屏,展示喜怒哀乐/爱心等表情包,以及屏幕播放演唱会舞台画面的智能陪伴场景。
- 最近生产数据同步2026-05-31新增 3 条 45 秒互动展示成片并已同步到 VPS `data/``有你家族 · 生肖幻装系列` session `s_zodiac_fantasy_20260531_main` 新增 `zodiac_fantasy_interaction_showcase`,重点展示亥猪屏幕脸、喜怒哀乐表情包、人机互动和视频播放,且额头猪鼻子可不显示;`有你家族 · 亥猪 MEEY 机甲陪伴机器人` session `s_mpsn5ef3_edc352` 新增 `hai_pig_meey_interaction_showcase`,重点展示面部显示器表情包、语音互动和视频播放;`有你家族 · 糯糯猪` session `s_mps3u047_48e383` 新增 `nuonuo_pig_interaction_showcase`,重点展示自主行动、语音互动,以及猪鼻子、猪耳朵、猪尾巴、猪眼睛的触摸反馈。本轮使用 `scripts/seedance-60s-compose.mjs``interaction45` 分段拼接流程生成,每条成片 3 段拼接约 45 秒。
- 最近生产数据同步2026-05-31`有你家族 · 生肖幻装系列` session `s_zodiac_fantasy_20260531_main` 已同步到 VPS `data/`,包含 12 张专业投影六视图专利图、18 张系列/单款宣发图、6 份专业文字资产、3 条亥猪 C 位 45 秒 Seedance 分段拼接视频(展示视频、触感细节、群体快闪);产品尺度统一按 50cm+ 具身 AI 智能陪伴机器人处理。本轮使用 `scripts/generate-zodiac-fantasy-series-assets.mjs` 以桌面参考图 `1400a0c9-6501-4a8f-942a-59d5e82edacd.png` 为视觉锚点生成图片,视频使用 `scripts/seedance-60s-compose.mjs``zodiac_fantasy_pig_*` 模板生成。
- 最近生产部署2026-05-31`有你家族 · 亥猪` 模板约束已发布并完成生产:视频任务统一改为 45 秒;图片包模板移除默认动物鼻子、尾巴、耳朵等提示,改为 40cm+ AI 陪伴机器人摆件、正面宽约 28cm、侧面深约 22cm、软壳/短绒触感但不改变基础机甲结构。生产 session `s_mpsn5ef3_edc352` 已完成 64 张基础图片、4 张十二生肖装甲组合图、5 条 45 秒亥猪视频和 2 条 30 秒十二生肖集合视频。
- 上一轮生产部署2026-05-30视频 provider 改为默认 Seedance`VIDEO_PROVIDER=seedance`OpenAI Sora 仅作为可选回退;实测 Ark / Seedance `doubao-seedance-2-0` R2V 不接受 `duration=60`,当前 Seedance 单任务按 15 秒提交。若仍需 60 秒成片,需要分段拼接,或回退 OpenAI Sora 的延展链路。
- 最近生产数据同步2026-05-30`有你家族 · 糯糯猪` session `s_mps3u047_48e383` 已同步到 VPS `data/`,包含专利包、配件包、生产打样包、宣发包共 64 张图片Seedance 生产环境 Key 已换新并作为当前默认视频 provider。
- 服务名 / 容器名:`ai-toy-patent-workflow`
- 服务器路径:`/opt/ai-toy-patent-workflow`
- 主站 / 前端https://ai-toy.kang-kang.com
@@ -38,11 +44,15 @@
- 风格示意图:运行 `npm run styles:previews -- --force` 用 GPT 图片模型生成 `public/style-previews/*.png`UI 左侧风格卡片直接引用这些小图
## 环境变量
- `OPENAI_API_KEY` — GPT API Key文本/结构化/图片生成统一走 GPT 最高规格配置
- `OPENAI_API_KEY` — GPT / OpenAI API Key文本结构化图片生成以及默认 OpenAI Sora 视频生成共用
- `GPT_TEXT_MODEL` — 默认 `gpt-5.5`,用于角色设定等结构化输出
- `GPT_IMAGE_MODEL` — 默认 `gpt-image-2`,用于意向图和三类素材包图片生成
- `GPT_API_BASE` — 默认 `https://api.openai.com/v1`
- `SEEDANCE_API_KEY` — Seedance 视频生成 Key未配置时视频接口返回 503
- `VIDEO_PROVIDER` — 默认 `seedance`;需要回退 OpenAI Sora 时可设为 `openai_sora`
- `OPENAI_VIDEO_MODEL` — 默认 `sora-2-pro`,用于 OpenAI 视频生成
- `OPENAI_VIDEO_MIN_SECONDS` — 默认 `60`;仅用于 OpenAI Sora 目标时长Seedance 当前单任务按模型可接受的 15 秒上限提交
- `OPENAI_VIDEO_QUALITY` — 默认生产建议 `high`,对应 OpenAI 允许的视频输出尺寸
- `SEEDANCE_API_KEY` — Seedance 视频生成 Key当前默认视频 provider 使用,未配置则视频接口返回 503
- `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128`
- `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL
@@ -57,7 +67,7 @@
## 规则
- 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md`
- 文本/结构化/图片生成统一使用 GPT 最高规格配置
- 视频生成固定使用 Seedance
- 视频生成默认使用 SeedanceOpenAI Sora 只作为可选回退 provider当前 Seedance 模型不支持单次 60 秒直出,使用 `npm run videos:seedance -- --session <id> --target-seconds 45` 按 15 秒分段生成并拼接30 秒集合片同理设置 `--target-seconds 30`
- 不允许编造不存在的部署域名、账号、密码
## 图像链路事实
@@ -95,10 +105,10 @@
5. 锁定角色设定 `CharacterSpec`
6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包`
7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API
8. 四个图片包完成后,才解锁文案模板和 Seedance 视频任务:旋转展示、开箱、触感细节、角色故事
8. 四个图片包完成后,才解锁文案模板和视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览Seedance 单任务按 15 秒提交30/45/60 秒成片使用 `scripts/seedance-60s-compose.mjs` 分段拼接
9. 侧栏保留历史会话,点击切换
## 后续路线
- 导出专利包PNG高清 + PDF合订
- ZIP/PDF 打包下载
- Seedance 任务轮询 UI
- OpenAI Sora 长视频任务轮询 UI 细化

View File

@@ -4,7 +4,14 @@ GPT_TEXT_MODEL=gpt-5.5
GPT_IMAGE_MODEL=gpt-image-2
GPT_API_BASE=https://api.openai.com/v1
# Seedance video generation.
# Video generation defaults to Seedance. Set VIDEO_PROVIDER=openai_sora only for fallback.
VIDEO_PROVIDER=seedance
OPENAI_VIDEO_MODEL=sora-2-pro
# OPENAI_VIDEO_MIN_SECONDS only applies to OpenAI Sora; current Seedance tasks are capped at 15s per request.
OPENAI_VIDEO_MIN_SECONDS=60
OPENAI_VIDEO_QUALITY=high
# Seedance is the default video provider.
SEEDANCE_API_KEY=
SEEDANCE_MODEL=doubao-seedance-2-0-260128
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3

662
docs/orchestration.html Normal file
View File

@@ -0,0 +1,662 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>AI 玩具专利生成工作流 · 编排逻辑(代码真源版)</title>
<style>
@page { size: A4; margin: 18mm 16mm 18mm 16mm; }
:root {
--bg: #ffffff;
--ink: #1a1a1f;
--muted: #5e6473;
--line: #d8dde6;
--hairline: #ecf0f6;
--accent: #4f46e5;
--accent-soft: #eef2ff;
--warn: #b45309;
--warn-soft: #fef3c7;
--ok: #047857;
--ok-soft: #d1fae5;
--code-bg: #f5f6fa;
--code-ink: #1f2333;
--pack-patent: #4f46e5;
--pack-acc: #0e7490;
--pack-prod: #b45309;
--pack-mkt: #be185d;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font-family: "PingFang SC", "Hiragino Sans GB", "Source Han Sans CN", "Microsoft YaHei", "STHeiti", "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.55;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
main { padding: 0 0 24pt 0; }
h1, h2, h3, h4 { color: var(--ink); margin: 0; line-height: 1.25; font-weight: 700; }
h1 { font-size: 22pt; margin: 0 0 6pt; letter-spacing: -0.5px; }
h2 { font-size: 15pt; margin: 22pt 0 8pt; padding-top: 6pt; border-top: 1px solid var(--line); }
h2:first-of-type { border-top: none; padding-top: 0; }
h3 { font-size: 12pt; margin: 14pt 0 6pt; color: var(--accent); }
h4 { font-size: 11pt; margin: 10pt 0 4pt; color: var(--muted); }
p { margin: 6pt 0; }
ul, ol { margin: 4pt 0 6pt 18pt; padding: 0; }
li { margin: 2pt 0; }
code {
font-family: "JetBrains Mono", "Menlo", "Consolas", "Source Han Mono SC", monospace;
font-size: 9.5pt;
background: var(--code-bg);
color: var(--code-ink);
padding: 1pt 4pt;
border-radius: 3pt;
word-break: break-word;
}
pre {
font-family: "JetBrains Mono", "Menlo", "Consolas", monospace;
background: var(--code-bg);
color: var(--code-ink);
border: 1px solid var(--hairline);
border-radius: 4pt;
padding: 8pt 10pt;
font-size: 9pt;
line-height: 1.45;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
page-break-inside: avoid;
}
pre.ascii {
background: #0f1117;
color: #c8d0e0;
border: none;
font-size: 8.5pt;
line-height: 1.35;
white-space: pre;
overflow-x: auto;
}
.meta {
color: var(--muted);
font-size: 9.5pt;
margin: 0 0 14pt;
display: flex;
flex-wrap: wrap;
gap: 4pt 14pt;
}
.meta span { white-space: nowrap; }
.grid { display: grid; gap: 8pt; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.card {
border: 1px solid var(--line);
border-radius: 5pt;
padding: 8pt 10pt;
background: #fff;
page-break-inside: avoid;
}
.card h4 { margin: 0 0 4pt; color: var(--ink); font-size: 10.5pt; }
.card .sub { color: var(--muted); font-size: 9pt; margin: 0 0 4pt; }
table {
width: 100%;
border-collapse: collapse;
margin: 6pt 0 8pt;
font-size: 9.5pt;
page-break-inside: auto;
}
th, td {
border: 1px solid var(--line);
padding: 4pt 6pt;
text-align: left;
vertical-align: top;
}
th { background: var(--code-bg); font-weight: 600; }
tr { page-break-inside: avoid; }
.tag {
display: inline-block;
padding: 1pt 6pt;
border-radius: 10pt;
font-size: 8.5pt;
font-weight: 600;
line-height: 1.5;
}
.tag-patent { background: #eef2ff; color: var(--pack-patent); }
.tag-acc { background: #ecfeff; color: var(--pack-acc); }
.tag-prod { background: #fef3c7; color: var(--pack-prod); }
.tag-mkt { background: #fce7f3; color: var(--pack-mkt); }
.tag-warn { background: var(--warn-soft); color: var(--warn); }
.tag-ok { background: var(--ok-soft); color: var(--ok); }
.tag-info { background: var(--accent-soft); color: var(--accent); }
.callout {
border-left: 3pt solid var(--accent);
background: var(--accent-soft);
padding: 8pt 10pt;
border-radius: 0 4pt 4pt 0;
margin: 8pt 0;
page-break-inside: avoid;
}
.callout.warn { border-color: var(--warn); background: var(--warn-soft); }
.callout.ok { border-color: var(--ok); background: var(--ok-soft); }
.callout h4 { color: var(--ink); margin: 0 0 4pt; }
.src {
font-family: "JetBrains Mono", "Menlo", "Consolas", monospace;
font-size: 8.5pt;
color: var(--muted);
}
.toc { columns: 2; column-gap: 18pt; font-size: 10pt; }
.toc ol { margin: 0; padding-left: 18pt; }
.pagebreak { page-break-before: always; }
.small { font-size: 9pt; color: var(--muted); }
hr.split { border: none; border-top: 1px dashed var(--hairline); margin: 8pt 0; }
</style>
</head>
<body>
<main>
<header>
<h1>AI 玩具专利生成工作流 · 编排逻辑</h1>
<div class="meta">
<span>项目:<code>20260518-ai-toy-patent-workflow</code></span>
<span>分支:<code>master</code> @ <code>e519627</code></span>
<span>文档生成2026-05-23</span>
<span>真源:仓库当前代码 + RULES.md</span>
</div>
<p>本文件是从源代码反向归纳的编排说明,不是规约。出现差异时以 <code>src/lib/templates.ts</code><code>PACK_ORDER</code><code>PACK_TEMPLATES</code><code>VIDEO_TEMPLATES</code> 以及 <code>src/app/api/**</code> 的路由实现为准。</p>
</header>
<h2>0 · 目录</h2>
<div class="toc">
<ol>
<li>顶层一图4 阶段串行 + 平行视频</li>
<li>数据真源与冻结版本</li>
<li>阶段 A输入 → 候选图</li>
<li>阶段 B九宫格选中</li>
<li>阶段 C角色锁定CharacterSpec + L1</li>
<li>阶段 D四个图片包串行</li>
<li>阶段 E文案模板18 条)</li>
<li>阶段 F视频任务Seedance, 5 条)</li>
<li>横切:持久化、审计、鉴权、轮询</li>
<li>编排约束与"规约 vs 实现"差异</li>
<li>已落地导出 / 未落地路线</li>
</ol>
</div>
<h2>1 · 顶层一图4 阶段串行 + 平行视频</h2>
<p>整个工作流是一条<strong>带 gate 的状态机</strong>,一个 <code>GenSession</code> 串起所有阶段的产物。横向四个图片包<strong>严格串行</strong>,包内单图<strong>4 并发 + 拓扑排序</strong>,文案 / 视频在 <code>characterSpec</code> 锁定后即可触发,但前端按"四包完成后再开"做 UX 引导。</p>
<pre class="ascii">
┌─────────── A. 输入入口 ───────────┐
│ idea POST /api/generate │──┐
│ remix POST /api/projects/from-… │ │ → GenSession 落盘data/sessions/
│ replicate / extend ↑ │ │
└────────────────────────────────────┘ │
┌──── B. 九宫格选中 ────┐
│ POST /api/select │
│ 选中图复制到 selected/│
└───────────┬───────────┘
┌──── C. 角色锁定gate #1────┐
│ POST /api/character/lock │ → CharacterSpec
│ (replicate/extend 走 strict)│ + cleanReferenceImageUrl
└───────────┬──────────────────┘ (L1 白底净化锚图)
┌────────── D. 四个图片包(严格串行)──────────┐
│ ① patent ▶ ② accessories ▶ ③ production ▶ │
│ ④ marketing │
│ gate #2前一包 status='complete' 才解锁 │
│ gate #3同 session+image+kind 并发锁 │
│ 包内:拓扑排序 + 4 并发 + 增量回写 │
└───────────┬──────────────────────────────────┘
▼ (前端 UX四包齐了再开下一段
┌────────── E. 文案 18 模板 ──────────┐
│ POST /api/text/generate │
│ gatecharacterSpec 必须存在 │
└─────────────────────────────────────┘
┌────────── F. 视频 5 模板Seedance─────────┐
│ POST /api/video/generate异步任务
│ GET /api/video/status/[taskId](轮询 15s
│ 锚图优先级mkt_white_front → patent_front │
│ → cleanReferenceImageUrl → L0 │
└──────────────────────────────────────────────┘
导出已落地ZIP路线图PDF
</pre>
<div class="callout">
<h4>一句话总结</h4>
<p>选中图 (L0) → 净化为 L1 → 用 L1 作为根锚图生成各包根模板 (L2) → 包内其它模板基于 L2 派生 (L3) → 全程通过 GPT image edit 而不是文本拼 URL保证角色一致。</p>
</div>
<h2>2 · 数据真源与冻结版本</h2>
<table>
<thead><tr><th width="22%">符号</th><th width="35%">代码位置</th><th>值 / 含义</th></tr></thead>
<tbody>
<tr><td><code>PACK_ORDER</code></td><td><code>src/lib/templates.ts:13</code></td><td><code>['patent', 'accessories', 'production', 'marketing']</code> — gate 校验唯一来源</td></tr>
<tr><td><code>PACK_LABELS</code></td><td><code>src/lib/templates.ts:6</code></td><td>patent=专利包 / accessories=配件包 / production=生产打样包 / marketing=宣发包</td></tr>
<tr><td><code>TEMPLATE_FREEZE_VERSION</code></td><td><code>src/lib/templates.ts:4</code></td><td><code>toy-pack-templates-v01</code> — 写入每个 ToyAsset.meta 和 ExportManifest</td></tr>
<tr><td><code>FILENAME_SCHEMA</code></td><td><code>src/lib/templates.ts:3</code></td><td><code>{sessionId}_{characterSlug}_{pack}_{view}_{version}.{ext}</code></td></tr>
<tr><td><code>PACK_TEMPLATES</code></td><td><code>src/lib/templates.ts:1094</code></td><td>4 个包各自的模板数组,每个包指定<strong>根模板</strong>(其它模板的 anchorTemplateId 全部指向根)</td></tr>
<tr><td><code>PACK_ASSET_CONCURRENCY</code></td><td><code>src/lib/packGenerator.ts:155</code></td><td>4 — 包内单图并发上限</td></tr>
<tr><td><code>VIDEO_TEMPLATES</code></td><td><code>src/lib/templates.ts:15</code></td><td>5 条:旋转 / 开箱 / 触感 / 角色故事 / 工厂预览</td></tr>
<tr><td><code>TEXT_TEMPLATES</code></td><td><code>src/lib/templates.ts:106</code></td><td>18 条:项目 / 专利 / 生产 / 配件 / 宣发 / 视频脚本</td></tr>
</tbody>
</table>
<h4>各包模板规模与根锚</h4>
<table>
<thead><tr><th></th><th>kind</th><th>根模板L2 锚)</th><th>模板总数</th><th>必需</th><th>可选</th></tr></thead>
<tbody>
<tr><td><span class="tag tag-patent">专利包</span></td><td><code>patent</code></td><td><code>patent_front</code></td><td>12</td><td>7</td><td>5</td></tr>
<tr><td><span class="tag tag-acc">配件包</span></td><td><code>accessories</code></td><td><code>acc_inventory_sheet</code></td><td>13</td><td>12</td><td>1</td></tr>
<tr><td><span class="tag tag-prod">生产打样包</span></td><td><code>production</code></td><td><code>prod_front_spec</code></td><td>19</td><td>15</td><td>4</td></tr>
<tr><td><span class="tag tag-mkt">宣发包</span></td><td><code>marketing</code></td><td><code>mkt_white_front</code></td><td>22</td><td>11</td><td>11</td></tr>
</tbody>
</table>
<p class="small">规模来源 <code>PACK_TEMPLATE_SUMMARY</code><code>src/lib/templates.ts:1101</code>)。宣发包末尾 5 条 <code>video_*</code> 是分镜板(图片),与 <code>VIDEO_TEMPLATES</code> 的真实视频任务同名但不同源。</p>
<h2>3 · 阶段 A输入 → 候选图</h2>
<h3>3.1 三种输入模式(<code>ProjectInputMode</code></h3>
<table>
<thead><tr><th>模式</th><th>API</th><th>九宫格生成</th><th>L0 是什么</th><th>角色锁定</th></tr></thead>
<tbody>
<tr>
<td><code>idea</code><br/><span class="tag tag-info">想法</span></td>
<td><code>POST /api/generate</code></td>
<td>GPT images/generations × N4/8/12ref 图作为文本提示拼接</td>
<td>用户从九宫格选中的图</td>
<td>用户手动点 <code>/api/character/lock</code>normal 净化</td>
</tr>
<tr>
<td><code>remix</code><br/><span class="tag tag-info">二创</span></td>
<td><code>POST /api/projects/from-upload</code></td>
<td>GPT images/edits 基于上传图 × N强制"原创化"提示</td>
<td>用户从九宫格选中的图</td>
<td>同 idea</td>
</tr>
<tr>
<td><code>replicate</code><br/><span class="tag tag-info">复刻</span></td>
<td><code>POST /api/projects/from-upload</code></td>
<td><strong>跳过</strong>,上传图直接作为 L0 selected</td>
<td>上传的主体图</td>
<td><strong>自动</strong>调 buildCharacterSpec + <strong>strict</strong> 净化</td>
</tr>
<tr>
<td><code>extend</code><br/><span class="tag tag-info">扩展</span></td>
<td><code>POST /api/projects/from-upload</code></td>
<td>同 replicate</td>
<td>同 replicate</td>
<td>同 replicate且把上传图按 <code>role</code> 预填到专利六视图槽位(<code>preFilledSlots</code></td>
</tr>
</tbody>
</table>
<h3>3.2 上传 role → 专利槽位映射extend 模式)</h3>
<p class="src">src/app/api/projects/from-upload/route.ts:19</p>
<table>
<thead><tr><th>UploadedImageRole</th><th>映射到 AssetTemplate.id</th></tr></thead>
<tbody>
<tr><td><code>view-front</code></td><td><code>patent_front</code></td></tr>
<tr><td><code>view-back</code></td><td><code>patent_back</code></td></tr>
<tr><td><code>view-left</code></td><td><code>patent_left</code></td></tr>
<tr><td><code>view-right</code></td><td><code>patent_right</code></td></tr>
<tr><td><code>view-top</code></td><td><code>patent_top</code></td></tr>
<tr><td><code>view-bottom</code></td><td><code>patent_bottom</code></td></tr>
</tbody>
</table>
<p class="small">命中预填槽的 pack asset 不会调 GPT直接复用上传 URL<code>packGenerator.ts:326-356</code>)。</p>
<h3>3.3 Provider 选择</h3>
<pre>// src/lib/providers.ts:10
export function detectProvider(): Provider {
return process.env.OPENAI_API_KEY ? 'gpt' : 'mock';
}</pre>
<ul>
<li><strong>gpt</strong>:图片生图走 <code>POST {GPT_API_BASE}/images/generations</code><code>/images/edits</code>;文本结构化走 <code>/responses</code> + <code>format: json_object</code></li>
<li><strong>mock</strong>:返回 SVG 占位图(笑脸 + 渐变背景),仅用于跑通流程,不能生产用</li>
<li><strong>视频不 mock</strong>Seedance 缺 Key 时直接 503</li>
</ul>
<h2>4 · 阶段 B九宫格选中</h2>
<p><code>POST /api/select</code><code>src/app/api/select/route.ts</code>)支持 <code>action: 'select' | 'reject' | 'reset'</code><code>select</code> 时把图从 <code>data/generated/</code> 复制到 <code>data/selected/</code> 并把新 URL 写回 <code>img.meta.selectedUrl</code></p>
<p>前端键盘约定(<code>src/components/PromptPanel.tsx</code><code>1-9</code> 选中,<code>Shift+1-9</code> 打叉。被打叉的图保留可见,不会进入后续阶段,但仍在 audit DB 留痕。</p>
<h2>5 · 阶段 C角色锁定CharacterSpec + L1 锚图)</h2>
<h3>5.1 两条路径</h3>
<div class="grid grid-2">
<div class="card">
<h4>路径 1 — 普通锁定</h4>
<p class="sub"><code>POST /api/character/lock</code></p>
<ol>
<li>幂等:未 force 且当前 spec.sourceImageId == imageId直接返回缓存</li>
<li><code>buildCharacterSpec()</code>:调 GPT JSON 结构化输出</li>
<li><code>cleanupCharacterAnchor()</code><strong>normal</strong> prompt 净化为白底</li>
<li>写入 <code>characterSpec.cleanReferenceImageUrl</code> = L1 锚图 URL</li>
</ol>
</div>
<div class="card">
<h4>路径 2 — 上传/复刻锁定</h4>
<p class="sub"><code>POST /api/character/lock-from-upload</code><code>from-upload</code> 自动触发</p>
<ol>
<li>有 userHint 时覆盖 <code>session.prompt</code></li>
<li><code>buildCharacterSpec()</code> 在 replicate/extend/upload 分支走 <code>inferCharacterSpecFromImage()</code>Vision 推断)</li>
<li><code>cleanupCharacterAnchor()</code><strong>strict</strong> prompt仅抽取最大最完整的单一主角色丢弃多宫格 / 包装 / 海报版式</li>
<li>强制 force=true每次都重算并覆盖 L1</li>
</ol>
</div>
</div>
<h3>5.2 CharacterSpec 字段(<code>src/lib/types.ts:76</code></h3>
<p>15 个语义字段 + 3 个图像引用 + lockedAt。详见 <code>CHARACTER_SPEC_FIELDS</code><code>templates.ts:58</code>)。关键三项:</p>
<ul>
<li><code>sourceImageId / sourceImageUrl</code> — L0用户选中或上传的图</li>
<li><code>cleanReferenceImageUrl</code> — L1净化后的白底锚图是后续所有 pack 生成的根锚)</li>
<li><code>negativePrompt</code> — 写入每张 pack 图的 prompt 后缀,防角色漂移</li>
</ul>
<h3>5.3 strict 净化的关键约束(节选)</h3>
<p class="src">src/lib/packGenerator.ts:171-200</p>
<ul>
<li>多宫格 / 品牌手册 / 包装展示 → 只抽取最大最清楚的单一主角色,不保留版式 / 分割线 / 标题 / 包装平铺</li>
<li>必须保留:玩具本体的设计标识、衣服图案、帽标、面罩声波图案等用户上传的原创品牌符号</li>
<li>背景纯白,去水印 / 价格 / 网页 UI</li>
<li>不改五官、配色、配件位置、材质纹理</li>
</ul>
<h2>6 · 阶段 D四个图片包串行</h2>
<h3>6.1 三道 gate</h3>
<div class="callout warn">
<h4>每次 POST /api/packs/generate 前后端都过的 gate</h4>
<ol>
<li><strong>characterSpec 必须存在</strong> — 否则 409 "请先锁定角色设定"<code>packs/generate/route.ts:43</code></li>
<li><strong>前一包必须 complete</strong><code>PACK_ORDER</code> 中前一项必须满足 <code>pack.status === 'complete'</code> 且模板覆盖率 100%<code>packs/generate/route.ts:25-58</code></li>
<li><strong>并发互斥</strong> — 同一 <code>session:image:kind</code> 已在跑则返回 202 running<code>generationLocks.ts</code></li>
<li>额外约束:源图 <code>status</code> 必须 = <code>selected</code></li>
</ol>
</div>
<h3>6.2 包内编排(<code>generateAssetPack</code><code>packGenerator.ts:276</code></h3>
<pre class="ascii">
sortTemplatesByAnchor(getPackTemplates(kind)) // 拓扑排序
取/建 CharacterSpec → cleanupCharacterAnchor // 兜底确保 L1 存在
existingPack 合并:从断点续生(按 templateId 去重)
takeReadyTemplate() // 依赖已就绪的模板进入候选
inFlight ≤ PACK_ASSET_CONCURRENCY (=4) // 并发槽
对每张模板:
· 若命中 preFilledSlot → 直接复用上传图,不调 GPT
· 否则 generateAssetImage()
· anchorImageUrl = anchorAsset.url // L3基于已生成根模板
?? L1.cleanReferenceImageUrl // L2用净化锚图
?? L0.url
· GPT images/edits 真正的图生图(读 anchor 字节 → multipart
· data: 开头则落盘到 data/packs/{packId}_{assetId}.{ext}
async onProgress(pack) → persistPackProgress (每张都回写 session JSON)
全部就绪后 pack.status = 'complete',写 ExportManifest 到 data/exports/
</pre>
<h3>6.3 派生层级(<code>ToyAsset.derivationLevel</code></h3>
<table>
<thead><tr><th></th><th>含义</th><th>来源 URL</th><th>触发条件</th></tr></thead>
<tbody>
<tr><td>L0</td><td>用户选中 / 上传主体图</td><td><code>img.url</code></td><td>选中 / 复刻</td></tr>
<tr><td>L1</td><td>白底净化锚图</td><td><code>characterSpec.cleanReferenceImageUrl</code></td><td>角色锁定</td></tr>
<tr><td>L2</td><td>每个包的根模板图</td><td><code>data/packs/...</code></td><td>包内 <code>anchorTemplateId == undefined</code> 的模板(每包仅一张:<code>patent_front</code> / <code>acc_inventory_sheet</code> / <code>prod_front_spec</code> / <code>mkt_white_front</code></td></tr>
<tr><td>L3</td><td>包内其它图</td><td>同上basedOn = L2</td><td>所有 <code>anchorTemplateId</code> 指向根的模板</td></tr>
</tbody>
</table>
<p class="small">代码里 <code>derivationLevel</code> 只被赋值 <code>2</code>(无 anchorAsset<code>3</code>(有 anchorAsset<code>0/1</code> 出现在类型定义中,运行时由 L0 图片本身和 cleanReferenceImageUrl 隐式承担。</p>
<h3>6.4 单张重做(<code>POST /api/assets/[assetId]/regenerate</code></h3>
<div class="callout warn">
<h4>双重 gate</h4>
<ul>
<li><strong>confirmCost === true</strong> 才放行(前端必须二次确认),否则 400</li>
<li><code>session:asset</code> 并发锁,已在跑返回 429</li>
<li>沿用同一 anchor优先该 asset 的 anchorAsset → cleanReferenceImageUrl → sourceImageUrl → L0</li>
<li>支持 <code>userRefinement</code> 文本追加到 prompt 末尾</li>
</ul>
</div>
<h3>6.5 增量回写与断点续跑</h3>
<p><code>onProgress</code> 在每张生成完成后 reload session JSON、用最新 pack 替换旧版本(按 <code>kind + sourceImageId</code> 匹配),再写回。<code>generateAssetPack</code> 启动时会取出未完成的 <code>existingPack</code>,按已落地的 templateId 跳过、只生成剩余项 → 断网或失败可重试。</p>
<h2>7 · 阶段 E文案模板18 条)</h2>
<h3>7.1 路由</h3>
<p><code>POST /api/text/generate</code>body <code>{sessionId, templateIds?}</code><strong>后端唯一 gate</strong><code>session.characterSpec</code> 必须存在(<code>text/generate/route.ts:18</code>),不强制四包完成。</p>
<h3>7.2 实现</h3>
<p class="src">src/lib/textGenerator.ts</p>
<ul>
<li>未传 templateIds 时生成<strong>全部</strong> 18 条;传了则只生成子集</li>
<li>一次 GPT <code>/responses</code> JSON 调用,要求返回 <code>{items: [{templateId, content}]}</code></li>
<li>未配 GPT Key 时每条用 <code>fallbackContent()</code> 生成占位稿,标注"未配置文本模型时生成占位稿"</li>
<li>结果按 templateId 去重后写入 <code>session.textAssets[]</code></li>
</ul>
<h3>7.3 18 条文案模板按 kind 分组</h3>
<table>
<thead><tr><th>kind</th><th>条数</th><th>典型 templateId必需打 ★)</th></tr></thead>
<tbody>
<tr><td><code>project</code></td><td>2</td><td>★ text_project_design_brief · ★ text_character_setting</td></tr>
<tr><td><code>patent</code></td><td>7</td><td>★ product_name · ★ product_use · ★ design_points · ★ representative_view · ★ view_brief · color_claim</td></tr>
<tr><td><code>production</code></td><td>4</td><td>★ brief · ★ cmf · ★ bom · ★ qc</td></tr>
<tr><td><code>accessories</code></td><td>2</td><td>★ accessory_brief · ★ accessory_bom</td></tr>
<tr><td><code>marketing</code></td><td>3</td><td>★ core_copy · ★ detail_page · social_posts</td></tr>
<tr><td><code>video</code></td><td>1</td><td>video_script_pack脚本文字包</td></tr>
</tbody>
</table>
<h2>8 · 阶段 F视频任务Seedance</h2>
<h3>8.1 五条视频模板(<code>VIDEO_TEMPLATES</code></h3>
<table>
<thead><tr><th>id</th><th>标题</th><th>比例</th><th>时长</th></tr></thead>
<tbody>
<tr><td><code>video_turntable</code></td><td>360 度旋转展示</td><td>16:9</td><td>6 s</td></tr>
<tr><td><code>video_unboxing</code></td><td>开箱短片</td><td>9:16</td><td>8 s</td></tr>
<tr><td><code>video_touch_detail</code></td><td>触感细节</td><td>9:16</td><td>6 s</td></tr>
<tr><td><code>video_story_intro</code></td><td>角色故事介绍</td><td>16:9</td><td>8 s</td></tr>
<tr><td><code>video_factory_preview</code></td><td>工厂预览短片</td><td>16:9</td><td>8 s</td></tr>
</tbody>
</table>
<h3>8.2 提交 + 轮询</h3>
<pre class="ascii">
POST /api/video/generate GET /api/video/status/[taskId]
│ ▲
▼ │ 前端每 15 s 轮询
generateSeedanceVideo() │ 最多 30 次
↓ │
POST {SEEDANCE_API_BASE} │
/contents/generations/tasks │
↓ task_id, status='submitted' │
保存到 session.videoTasks[] ──────────────┘
status='succeeded' 时 videoUrl 用 saveRemoteVideo() 拉到 data/videos/
返回 /api/video-file/{filename} 本地路径
</pre>
<h3>8.3 锚图优先级(<code>page.tsx:580-589</code></h3>
<ol>
<li><code>mkt_white_front</code> — 宣发白底正面图(最稳定)</li>
<li><code>patent_front</code> — 专利主视图</li>
<li><code>characterSpec.cleanReferenceImageUrl</code> — L1 净化锚图</li>
<li>当前选中意向图 L0</li>
</ol>
<h3>8.4 PUBLIC_APP_URL 注入</h3>
<p>Seedance 需要从公网拉参考图,所以 <code>publicUrlOrUndefined()</code><code>/api/img/...</code><code>PUBLIC_APP_URL</code>(生产 = <code>https://ai-toy.kang-kang.com</code>)转成绝对 URL。localhost / 127.0.0.1 / 私有 IP 一律丢弃。</p>
<h3>8.5 视频任务去重</h3>
<p>每次新提交按 <code>templateId</code> 去重覆盖(<code>video/generate/route.ts:46</code>),保证 5 个模板各最多一个最新任务。<code>fix: dedupe suffixed video tasks</code><code>7abbb7d</code>)专门处理 <code>video_turntable_60s</code> 等带后缀的真实成片回流到默认模板卡。</p>
<h2>9 · 横切:持久化、审计、鉴权、轮询</h2>
<h3>9.1 八个存储桶(<code>src/lib/storage.ts</code></h3>
<table>
<thead><tr><th></th><th>URL 前缀</th><th>放什么</th></tr></thead>
<tbody>
<tr><td><code>data/sessions/</code></td><td></td><td>每个 session 一个 JSON含 images / packs / textAssets / videoTasks / exports 全量</td></tr>
<tr><td><code>data/generated/</code></td><td><code>/api/img/generated/</code></td><td>九宫格候选图原图</td></tr>
<tr><td><code>data/selected/</code></td><td><code>/api/img/selected/</code></td><td>选中后复制一份(保留生成版本不被覆盖)</td></tr>
<tr><td><code>data/refs/</code></td><td><code>/api/img/refs/</code></td><td>idea 模式上传的参考图</td></tr>
<tr><td><code>data/uploads/</code></td><td><code>/api/img/uploads/</code></td><td>remix / replicate / extend 的上传图</td></tr>
<tr><td><code>data/anchors/</code></td><td><code>/api/img/anchors/</code></td><td>L1 净化锚图 <code>{sessionId}_{imageId}_clean.{ext}</code></td></tr>
<tr><td><code>data/packs/</code></td><td><code>/api/img/packs/</code></td><td>四个包的所有 ToyAsset 图片</td></tr>
<tr><td><code>data/videos/</code></td><td><code>/api/video-file/</code></td><td>Seedance 成片从公网拉回的本地副本</td></tr>
<tr><td><code>data/exports/</code></td><td><code>/api/export/</code></td><td><code>ExportManifest</code> JSON每个 pack 一份)</td></tr>
</tbody>
</table>
<h3>9.2 审计SQLite + 兜底 JSONL</h3>
<p><code>src/lib/auditDb.ts</code>。每个 API 路由的关键节点started / completed / failed / blocked / saved都调 <code>recordEvent()</code>,落到 <code>data/app.db</code>。Docker 镜像内置 <code>sqlite3</code>;非 Docker 本地缺 sqlite3 时降级写 <code>data/audit-fallback.jsonl</code>,不阻断流程。</p>
<p>每张图也通过 <code>upsertImageAsset()</code> 写入 <code>image_assets</code> 表,包含 bucket / width / height / sizeBytes / kind / templateId / origin<code>/api/gallery/[sessionId]</code> 的真源。</p>
<h3>9.3 鉴权(<code>src/middleware.ts</code></h3>
<ul>
<li>Cookie 名:<code>WEB_AUTH_COOKIE_NAME</code>(默认 <code>ai_toy_session</code></li>
<li>HMAC-SHA256 签名 <code>body.signature</code><code>body</code> 是 base64url 编码的 <code>{u, exp}</code></li>
<li>公开路径:<code>/login</code> / <code>/_next/</code> / <code>/api/auth/</code> / <code>/api/img/</code> / <code>/favicon.ico</code> / <code>/robots.txt</code> / <code>/sitemap.xml</code></li>
<li>未鉴权HTML 路径 302 到 <code>/login?next=...</code>;非 HTML API 返回 <code>401 {error: 'unauthorized'}</code></li>
<li><code>/api/img/*</code> 故意保持公开 —— Seedance 必须能从公网拉参考图</li>
</ul>
<h3>9.4 轮询节奏(前端)</h3>
<table>
<thead><tr><th>对象</th><th>间隔</th><th>最大次数</th><th>终止条件</th></tr></thead>
<tbody>
<tr><td>pack 生成(<code>scheduleSessionRefresh</code></td><td>5 s</td><td>90</td><td>无 status='draft' 的 pack前 6 次无论如何都跑</td></tr>
<tr><td>视频任务(<code>scheduleVideoRefresh</code></td><td>15 s</td><td>30</td><td>status 不再是 submitted/processing</td></tr>
</tbody>
</table>
<h2>10 · 编排约束与"规约 vs 实现"差异</h2>
<div class="callout warn">
<h4>差异 1RULES.md 说"四个图片包完成后才解锁文案和视频"</h4>
<p>后端实际只校验 <code>session.characterSpec</code> 存在:</p>
<ul>
<li><code>/api/text/generate</code>:只 check characterSpec<code>text/generate/route.ts:18</code></li>
<li><code>/api/video/generate</code>:完全不 check pack 完成度,直接打 Seedance</li>
</ul>
<p>这条规约靠前端 UX 引导执行,不是后端 enforce。绕过前端可以在锁定角色后立刻发文案/视频请求。</p>
</div>
<div class="callout warn">
<h4>差异 2视频不 mock</h4>
<p>没配 <code>SEEDANCE_API_KEY</code><code>/api/video/generate</code><code>/api/video/status</code> 返回 <strong>503</strong>,不会回退到占位视频。文档和 RULES.md 一致。</p>
</div>
<div class="callout warn">
<h4>差异 3宣发包里 5 条 <code>video_*</code> 模板是分镜板(图片),不是真实视频</h4>
<p><code>marketing</code> 包模板列表里 <code>video_turntable</code> / <code>video_unboxing</code> 等 5 条<strong>id 与 VIDEO_TEMPLATES 重名</strong>,但 kind=<code>marketing</code>、aspectRatio=<code>16:9</code><code>9:16</code>,走的是 GPT image edit产出 PNG 分镜板。真实视频由 Seedance 异步任务单独产出,存 <code>session.videoTasks[]</code>。两者完全独立,前端按 templateId 关联展示。</p>
</div>
<div class="callout">
<h4>差异 4派生层级运行时只用 2 / 3</h4>
<p>类型定义 <code>derivationLevel: 0 | 1 | 2 | 3</code> 给出了完整四级,但 <code>generateAssetPack</code> 只赋值 2包根模板和 3包内其它。L0/L1 由 GenImage 和 CharacterSpec.cleanReferenceImageUrl 隐式承担,不写入 ToyAsset.derivationLevel。</p>
</div>
<div class="callout">
<h4>差异 5preFilledSlot 命中后 derivationLevel</h4>
<p>命中预填上传图时仍按 anchor 存在与否赋 2/3<code>packGenerator.ts:347</code>),但实际生成 URL 是上传桶 URL不是 packs 桶。导出 ZIP 时 <code>extFromAsset</code> 会从 URL 抓扩展名,<code>readImageUrl</code> 回到 uploads 桶读字节。</p>
</div>
<h2>11 · 已落地导出 / 未落地路线</h2>
<h3>11.1 已落地</h3>
<ul>
<li><strong>ExportManifest JSON</strong>:每包生成结束自动写 <code>data/exports/{sessionId}_{kind}_{version}_manifest.json</code>,含 files[]asset_id, templateId, filename, url, anchor, derivation, checklist</li>
<li><strong>ZIP 下载</strong><code>GET /api/packs/download?sessionId=&amp;kind=</code>,纯 Node Buffer 拼装 ZIP含 CRC32文件名 <code>{characterSlug}_{kind}_{N}张.zip</code>,按 templateId 顺序编号 <code>01_xxx.png</code></li>
</ul>
<h3>11.2 未落地RULES.md 路线)</h3>
<ul>
<li><strong>PNG 高清导出 + PDF 合订</strong>ExportManifest 已预留 <code>exportTargets: ['zip', 'pdf', 'manifest-json']</code>,只实现了 zip + manifestpdf 未生成</li>
<li><strong>Seedance 任务轮询 UI</strong>:现状是被动 15s 间隔静默 refresh没有进度条 / 失败重试按钮的完整 UI</li>
</ul>
<h2>12 · 关键 API 速查</h2>
<table>
<thead><tr><th>方法</th><th>路径</th><th>gate / 关键行为</th></tr></thead>
<tbody>
<tr><td>POST</td><td><code>/api/uploads</code></td><td>multipartrole 必传</td></tr>
<tr><td>POST</td><td><code>/api/generate</code></td><td>idea 模式批量生图4/8/12</td></tr>
<tr><td>POST</td><td><code>/api/projects/from-upload</code></td><td>mode ∈ {remix, replicate, extend}replicate/extend 自动锁定 strict</td></tr>
<tr><td>POST</td><td><code>/api/select</code></td><td>action ∈ {select, reject, reset}select 时复制到 selected/</td></tr>
<tr><td>POST</td><td><code>/api/character/lock</code></td><td>普通净化force 控制是否重算</td></tr>
<tr><td>POST</td><td><code>/api/character/lock-from-upload</code></td><td>strict 净化force 总是 true</td></tr>
<tr><td>POST</td><td><code>/api/character/cleanup</code></td><td>独立触发 cleanupCharacterAnchor</td></tr>
<tr><td>POST</td><td><code>/api/packs/generate</code></td><td>三道 gatebackground=true 时返 202 异步跑</td></tr>
<tr><td>POST</td><td><code>/api/assets/[assetId]/regenerate</code></td><td>必传 confirmCost=true并发锁</td></tr>
<tr><td>GET</td><td><code>/api/packs/download?sessionId=&amp;kind=</code></td><td>按选中图找该 kind 的 pack 打 ZIP</td></tr>
<tr><td>POST</td><td><code>/api/text/generate</code></td><td>必须 characterSpec可传 templateIds 子集</td></tr>
<tr><td>POST</td><td><code>/api/video/generate</code></td><td>必须 Seedance Key按 templateId 去重覆盖</td></tr>
<tr><td>GET</td><td><code>/api/video/status/[taskId]?sessionId=</code></td><td>查 Seedance + 写回本地副本</td></tr>
<tr><td>GET</td><td><code>/api/sessions</code></td><td>按 createdAt desc 列全部 session 元信息</td></tr>
<tr><td>GET</td><td><code>/api/templates</code></td><td>把 PACK_TEMPLATES / TEXT / VIDEO 暴露给前端</td></tr>
<tr><td>GET</td><td><code>/api/gallery/[sessionId]</code></td><td>从 image_assets 表 + filesystem 拼图库</td></tr>
<tr><td>GET</td><td><code>/api/audit/[sessionId]</code></td><td>读 events 表事件流水</td></tr>
<tr><td>GET</td><td><code>/api/img/[bucket]/[filename]</code></td><td><strong>公开</strong>Seedance 拉参考图依赖</td></tr>
<tr><td>GET</td><td><code>/api/video-file/[filename]</code></td><td>本地视频副本</td></tr>
<tr><td>POST</td><td><code>/api/auth/login</code> / <code>/logout</code></td><td>HMAC HttpOnly Cookie</td></tr>
</tbody>
</table>
<h2>附录 · 文件锚点</h2>
<table>
<thead><tr><th>关键概念</th><th>代码位置</th></tr></thead>
<tbody>
<tr><td>串行顺序 PACK_ORDER</td><td><code>src/lib/templates.ts:13</code></td></tr>
<tr><td>包模板冻结版本</td><td><code>src/lib/templates.ts:4</code></td></tr>
<tr><td>包内并发上限</td><td><code>src/lib/packGenerator.ts:155</code></td></tr>
<tr><td>包 gate 三道</td><td><code>src/app/api/packs/generate/route.ts:42-91</code></td></tr>
<tr><td>包内拓扑 + 并发调度</td><td><code>src/lib/packGenerator.ts:392-424</code></td></tr>
<tr><td>L1 strict / normal prompt</td><td><code>src/lib/packGenerator.ts:171-200</code></td></tr>
<tr><td>L1 净化路径</td><td><code>src/lib/packGenerator.ts:157</code></td></tr>
<tr><td>L0/L1/L2/L3 派生</td><td><code>src/lib/packGenerator.ts:316-389</code></td></tr>
<tr><td>preFilledSlot 映射</td><td><code>src/app/api/projects/from-upload/route.ts:19</code></td></tr>
<tr><td>视频锚图优先级</td><td><code>src/app/page.tsx:580-589</code></td></tr>
<tr><td>视频任务 templateId 去重</td><td><code>src/app/api/video/generate/route.ts:46</code></td></tr>
<tr><td>pack 进度轮询</td><td><code>src/app/page.tsx:536-543</code></td></tr>
<tr><td>video 状态轮询</td><td><code>src/app/page.tsx:545-557</code></td></tr>
<tr><td>generationLocks 全局并发锁</td><td><code>src/lib/generationLocks.ts</code></td></tr>
<tr><td>ZIP 打包</td><td><code>src/app/api/packs/download/route.ts</code></td></tr>
<tr><td>HMAC Cookie 鉴权</td><td><code>src/middleware.ts</code></td></tr>
<tr><td>审计写库</td><td><code>src/lib/auditDb.ts</code></td></tr>
</tbody>
</table>
<p class="small" style="margin-top: 18pt;">— 文档生成基于 commit <code>e519627</code>。结构性改动后请重跑 <code>npm run docs:orchestration</code>(如已配脚本)或重新执行 docs/orchestration.html 的生成命令。</p>
</main>
</body>
</html>

BIN
docs/orchestration.pdf Normal file

Binary file not shown.

View File

@@ -11,7 +11,10 @@
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f web",
"resources:index": "node scripts/build-resource-index.mjs data",
"styles:previews": "node scripts/generate-style-previews.mjs"
"styles:previews": "node scripts/generate-style-previews.mjs",
"images:zodiac-fantasy": "node scripts/generate-zodiac-fantasy-series-assets.mjs",
"videos:seedance": "node scripts/seedance-60s-compose.mjs",
"videos:seedance60": "node scripts/seedance-60s-compose.mjs --target-seconds 60"
},
"dependencies": {
"next": "^15.5.18",

View File

@@ -0,0 +1,297 @@
#!/usr/bin/env node
import { createWriteStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const sessionId = args.session || args._[0];
if (!sessionId) fail('Usage: node scripts/generate-hai-pig-zodiac-assets.mjs --session <sessionId> [--env deploy/.env.production]');
const env = {
...readEnvFile(path.join(root, args.env || '.env.local')),
...process.env,
};
const apiKey = env.OPENAI_API_KEY;
if (!apiKey) fail('OPENAI_API_KEY missing');
const apiBase = env.GPT_API_BASE || 'https://api.openai.com/v1';
const model = env.GPT_IMAGE_EDIT_MODEL || env.GPT_IMAGE_MODEL || 'gpt-image-2';
const fresh = Boolean(args.fresh);
const selectedTemplateIds = String(args.templates || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`);
const packDir = path.join(root, 'data', 'packs');
const ZODIAC_ASSETS = [
{
templateId: 'zodiac_armor_grid',
view: 'zodiac-grid',
title: '十二生肖装甲全阵列',
description: '同一亥猪基型扩展出的十二生肖装甲配色和模块组合。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: '12 variants grid, same base robot, different zodiac armor modules and colors',
},
{
templateId: 'zodiac_hero_lineup',
view: 'zodiac-hero',
title: '亥猪主角十二生肖集合',
description: '亥猪作为主角,其他生肖作为同一基础机甲的装甲变化。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: 'hero lineup, Hai Pig main character in center, eleven zodiac armor variants around',
},
{
templateId: 'zodiac_material_palette',
view: 'zodiac-material',
title: '十二生肖短绒软壳材质方案',
description: '女性向软萌机甲材质、色彩与局部模块组合方案。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: 'soft plush and soft shell material palette board for 12 zodiac armor variants',
},
{
templateId: 'zodiac_module_variants',
view: 'zodiac-modules',
title: '十二生肖可变模块设计',
description: '头顶冠盖、侧面模块、肩部、手脚装甲和纹样的可变区域方案。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: 'design board of changeable crown, side module, shoulder, arm and foot armor modules',
},
];
await fs.mkdir(packDir, { recursive: true });
const session = JSON.parse(await fs.readFile(sessionPath, 'utf8'));
const pack = ensureMarketingPack(session);
const anchorUrl = findAnchor(session);
const anchorPath = localPathFromImageUrl(anchorUrl);
if (!anchorPath) fail(`Cannot resolve local anchor image: ${anchorUrl}`);
const activeSpecs = selectedTemplateIds.length
? ZODIAC_ASSETS.filter(spec => selectedTemplateIds.includes(spec.templateId))
: ZODIAC_ASSETS;
const generated = [];
for (const spec of activeSpecs) {
const existing = pack.assets.find(asset => asset.templateId === spec.templateId);
if (existing && !fresh) {
generated.push({ templateId: spec.templateId, status: 'skipped', url: existing.url });
continue;
}
const dataUrl = await generateEdit({
prompt: zodiacPrompt(spec),
anchorPath,
size: spec.size,
});
const assetId = `${spec.templateId}_${randomHex(3)}`;
const filename = `${pack.id}_${assetId}.png`;
const filePath = path.join(packDir, filename);
await writeDataUrl(dataUrl, filePath);
const asset = {
id: assetId,
templateId: spec.templateId,
kind: 'marketing',
view: spec.view,
title: spec.title,
description: spec.description,
url: `/api/img/packs/${filename}`,
prompt: spec.prompt,
status: 'draft',
version: 'v01',
aspectRatio: spec.aspectRatio,
required: false,
createdAt: Date.now(),
anchorImageUrl: anchorUrl,
derivationLevel: 2,
meta: {
provider: 'gpt',
model,
packLabel: '宣发包',
generatedBy: 'generate-hai-pig-zodiac-assets',
},
};
if (existing) {
const index = pack.assets.findIndex(item => item.templateId === spec.templateId);
pack.assets[index] = asset;
} else {
pack.assets.push(asset);
}
generated.push({ templateId: spec.templateId, status: 'generated', url: asset.url });
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
}
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
console.log(JSON.stringify({ sessionId, packId: pack.id, generated }, null, 2));
function zodiacPrompt(spec) {
return [
'基于参考图中的“有你家族 · 亥猪”AI 陪伴机甲角色生成新的设计图板。',
'最重要:基础设计不能变。每一个形象都必须保留同一个短胖站立机甲身体、白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶竖向 MEEY 识别条、胸前圆形 M 徽章、斜挎黑色能量肩带、灰色方形扣件、橙色功能扣、侧面圆形模块、短手短脚、黑灰脚底。',
'主题:十二生肖外观系统不是十二个动物机器人,而是同一个亥猪基型机甲的十二套可换装甲外观。亥猪是主角和母体,其它生肖只是装甲变化。',
'可变区域:头顶冠盖、侧面模块、肩部装甲、手臂装饰、腿脚边缘、表面纹样、配色、短绒/软壳材料分区。',
'禁止:猪鼻子、猪尾巴、写实猪耳、猪蹄、真实动物头、外凸兽耳、鹿角、牛角、龙角、爪、翅膀、尾巴、四足身体、武器、重装战斗机甲、第三方 IP、水印、价格、海报文案、错误尺寸标注。',
'所有生肖差异只能是贴合头盔的平面冠盖色块、平面纹样、柔软护甲片、侧面圆形模块贴片和配色变化;不能出现从头盔外伸出来的器官或尖锐装饰。',
'尺寸感:全部保持 40cm+ AI 陪伴机器人摆件体量,正面宽约 28cm、侧面深约 22cm不能像迷你挂件。',
'材质方向:女性用户友好,白色软壳与短绒毛绒复合触感,局部深灰结构件和高识别色点缀,干净、酷、温暖,不幼稚。',
'配色建议:子鼠银蓝、丑牛象牙灰+铜金、寅虎白黑+琥珀橙、卯兔月白+樱粉、辰龙珠光白+青绿、巳蛇白+薄荷绿、午马白+赤红、未羊奶白+薰衣草、申猴白+电光黄、酉鸡白+玫瑰金、戌狗白+海军蓝、亥猪白+橙色主视觉。',
`画面要求:${spec.prompt}。16:9 横向产品设计板,白底或极浅灰棚拍,干净高级,产品可用于内部评审;尽量不生成文字标签,如果必须出现文字也要极少且不影响主体。`,
].join('\n');
}
function ensureMarketingPack(sessionValue) {
const existing = sessionValue.packs?.find(item => item.kind === 'marketing');
if (existing) return existing;
const selected = sessionValue.images?.find(image => image.status === 'selected') || sessionValue.images?.[0];
const now = Date.now();
const pack = {
id: `pack_marketing_${now.toString(36)}_${randomHex(3)}`,
kind: 'marketing',
sessionId,
sourceImageId: selected?.id || 'source',
sourceImageUrl: selected?.url || sessionValue.characterSpec?.sourceImageUrl || '',
characterSpec: sessionValue.characterSpec,
assets: [],
manifestId: `manifest_marketing_${now.toString(36)}`,
createdAt: now,
version: 'v01',
status: 'complete',
};
sessionValue.packs = [...(sessionValue.packs || []), pack];
return pack;
}
function findAnchor(sessionValue) {
const preferredTemplateIds = [
'mkt_white_front',
'patent_front',
'prod_front_spec',
];
for (const templateId of preferredTemplateIds) {
const url = findAssetUrl(sessionValue, templateId);
if (url) return url;
}
if (sessionValue.characterSpec?.cleanReferenceImageUrl) return sessionValue.characterSpec.cleanReferenceImageUrl;
const selected = sessionValue.images?.find(image => image.status === 'selected') || sessionValue.images?.[0];
if (selected?.url) return selected.url;
fail('No anchor image found');
}
function findAssetUrl(sessionValue, templateId) {
for (const item of sessionValue.packs || []) {
const asset = item.assets?.find(candidate => candidate.templateId === templateId);
if (asset?.url) return asset.url;
}
return undefined;
}
function localPathFromImageUrl(url) {
const match = String(url).match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/);
if (!match) return null;
const dirs = {
generated: 'generated',
selected: 'selected',
refs: 'refs',
packs: 'packs',
anchors: 'anchors',
uploads: 'uploads',
};
return path.join(root, 'data', dirs[match[1]], decodeURIComponent(match[2]));
}
async function generateEdit(opts) {
const source = await fs.readFile(opts.anchorPath);
const form = new FormData();
form.set('model', model);
form.set('prompt', opts.prompt);
form.set('size', opts.size || '1536x1024');
form.set('image', new Blob([source], { type: 'image/png' }), path.basename(opts.anchorPath));
const res = await fetch(`${apiBase}/images/edits`, {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: form,
});
const rawText = await res.text();
if (!res.ok) throw new Error(`GPT image edit ${res.status}: ${rawText}`);
const raw = JSON.parse(rawText);
const first = raw.data?.[0];
if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`;
if (first?.url) {
const image = await fetch(first.url);
if (!image.ok) throw new Error(`download image ${image.status}: ${await image.text()}`);
const type = image.headers.get('content-type')?.split(';')[0] || 'image/png';
const tempPath = path.join(packDir, `tmp_${Date.now()}_${randomHex(2)}.png`);
await streamToFile(image.body, tempPath);
const buffer = await fs.readFile(tempPath);
await fs.unlink(tempPath);
return `data:${type};base64,${buffer.toString('base64')}`;
}
throw new Error('GPT image edit response missing image');
}
async function writeDataUrl(dataUrl, filePath) {
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
if (!match) throw new Error('invalid image data URL');
await fs.writeFile(filePath, Buffer.from(match[1], 'base64'));
}
async function streamToFile(body, filePath) {
if (!body) throw new Error('response body missing');
await new Promise((resolve, reject) => {
const stream = createWriteStream(filePath);
Readable.fromWeb(body).pipe(stream);
stream.on('finish', resolve);
stream.on('error', reject);
});
}
function parseArgs(argv) {
const parsed = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i];
if (!item.startsWith('--')) {
parsed._.push(item);
continue;
}
const key = item.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
parsed[key] = true;
} else {
parsed[key] = next;
i += 1;
}
}
return parsed;
}
function readEnvFile(filePath) {
try {
const text = readFileSync(filePath, 'utf8');
const out = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx < 0) continue;
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1).replace(/^["']|["']$/g, '');
}
return out;
} catch {
return {};
}
}
function randomHex(bytes) {
return Array.from(crypto.getRandomValues(new Uint8Array(bytes))).map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function fail(message) {
console.error(message);
process.exit(1);
}

View File

@@ -0,0 +1,790 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { execFile as execFileCb, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const execFile = promisify(execFileCb);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const sourcePath = path.resolve(args.source || '/Users/kangwan/Desktop/1400a0c9-6501-4a8f-942a-59d5e82edacd.png');
const env = {
...readEnvFile(path.join(root, args.env || '.env.local')),
...process.env,
};
const apiKey = env.OPENAI_API_KEY;
const apiBase = env.GPT_API_BASE || 'https://api.openai.com/v1';
const model = env.GPT_IMAGE_EDIT_MODEL || env.GPT_IMAGE_MODEL || 'gpt-image-2';
const sessionId = args.session || `s_zodiac_fantasy_${new Date().toISOString().slice(0, 10).replaceAll('-', '')}_${randomHex(3)}`;
const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`);
const uploadDir = path.join(root, 'data', 'uploads');
const packDir = path.join(root, 'data', 'packs');
const exportDir = path.join(root, 'data', 'exports');
const artifactDir = path.join(root, 'artifacts', 'screenshots', 'zodiac-fantasy-series', sessionId);
const prepareOnly = Boolean(args['prepare-only']);
const phase = args.phase || 'all';
const limit = args.limit ? Number(args.limit) : Infinity;
const only = String(args.only || '').split(',').map(item => item.trim()).filter(Boolean);
const fresh = Boolean(args.fresh);
const VARIANTS = [
{
key: 'rat',
zodiac: '子鼠',
sku: 'Rat',
color: '雾蓝 / 银白 / 冰青 visor',
crop: { x: 40, y: 130, w: 300, h: 300 },
traits: '圆形鼠耳幻装、雾蓝短绒外套、蓝灰斜挎带、冰青情绪 visor、圆润安全的侧面模块',
},
{
key: 'ox',
zodiac: '丑牛',
sku: 'Ox',
color: '象牙白 / 奶咖 / 暖金 visor',
crop: { x: 380, y: 130, w: 300, h: 300 },
traits: '柔性牛角幻装、奶咖短绒、棕色能量肩带、暖金 visor、稳重可靠的低重心体态',
},
{
key: 'tiger',
zodiac: '寅虎',
sku: 'Tiger',
color: '琥珀橙 / 黑纹 / 暖橙 visor',
crop: { x: 720, y: 130, w: 300, h: 300 },
traits: '虎纹短绒头罩、圆形耳部幻装、琥珀橙功能色、黑色肩带、精神但不攻击的守护者气质',
},
{
key: 'rabbit',
zodiac: '卯兔',
sku: 'Rabbit',
color: '薰衣草紫 / 月白 / 粉紫 visor',
crop: { x: 1060, y: 130, w: 330, h: 300 },
traits: '柔软兔耳幻装、淡紫短绒、蝴蝶结局部点缀、粉紫 visor、女性向温柔陪伴气质',
},
{
key: 'dragon',
zodiac: '辰龙',
sku: 'Dragon',
color: '青瓷绿 / 珠白 / 青蓝 visor',
crop: { x: 40, y: 425, w: 320, h: 310 },
traits: '东方龙鬃幻装、云纹柔性模块、青瓷绿短绒、深绿肩带、青蓝 visor、灵动但圆润安全',
},
{
key: 'snake',
zodiac: '巳蛇',
sku: 'Snake',
color: '薄荷绿 / 奶白 / 青蓝 visor',
crop: { x: 380, y: 425, w: 310, h: 310 },
traits: '鳞片纹短绒头罩、柔和蛇形纹样、薄荷绿外壳、深绿肩带、青蓝 visor、安静聪明的陪伴感',
},
{
key: 'horse',
zodiac: '午马',
sku: 'Horse',
color: '珊瑚粉 / 赤金 / 粉橙 visor',
crop: { x: 720, y: 425, w: 310, h: 310 },
traits: '马鬃幻装冠盖、珊瑚粉短绒、赤金马蹄形胸扣、粉橙 visor、活力轻快但保持机器人结构',
},
{
key: 'goat',
zodiac: '未羊',
sku: 'Goat',
color: '奶白 / 青灰 / 青蓝 visor',
crop: { x: 1060, y: 425, w: 330, h: 310 },
traits: '卷曲羊角柔性幻装、羊毛纹短绒、奶白体块、青灰肩带、青蓝 visor、柔软治愈的家庭陪伴气质',
},
{
key: 'monkey',
zodiac: '申猴',
sku: 'Monkey',
color: '燕麦米 / 深咖 / 暖金 visor',
crop: { x: 40, y: 735, w: 310, h: 310 },
traits: '圆耳猴系幻装、燕麦短绒、咖色肩带、暖金 visor、聪明俏皮但不过度卡通',
},
{
key: 'rooster',
zodiac: '酉鸡',
sku: 'Rooster',
color: '奶白 / 珊瑚红 / 红粉 visor',
crop: { x: 380, y: 735, w: 310, h: 310 },
traits: '柔性鸡冠幻装、小翅状侧面装饰、珊瑚红肩带、红粉 visor、明快醒目的系列识别',
},
{
key: 'dog',
zodiac: '戌狗',
sku: 'Dog',
color: '浅蓝 / 奶白 / 冰蓝 visor',
crop: { x: 720, y: 735, w: 310, h: 310 },
traits: '垂耳狗系幻装、浅蓝短绒、爪印冠盖纹样、蓝色肩带、冰蓝 visor、忠诚可靠的智能伙伴感',
},
{
key: 'pig',
zodiac: '亥猪',
sku: 'Pig',
color: '樱粉 / 奶白 / 粉紫 visor',
crop: { x: 1060, y: 735, w: 330, h: 310 },
traits: '粉色猪系幻装、柔软耳部外轮廓、鼻形胸扣而非真实突出猪鼻、粉紫 visor、主角型温暖陪伴感',
},
];
const MARKETING_ASSETS = [
{
templateId: 'zodiac_fantasy_collection_kv',
view: 'collection-kv',
title: '生肖幻装系列主视觉',
description: '12 款 50cm+ 智能陪伴机器人集合主视觉,面向宣发和招商展示。',
prompt: 'premium hero key visual of all twelve variants, clean studio, Hai Pig as one of the twelve, no video',
},
{
templateId: 'zodiac_fantasy_retail_grid',
view: 'retail-grid',
title: '十二款零售陈列图',
description: '用于电商首屏、招商页和产品目录的 12 款整列展示。',
prompt: 'retail catalog grid, twelve 50cm companion robots, unified scale and spacing',
},
{
templateId: 'zodiac_fantasy_material_board',
view: 'material-board',
title: '毛绒软壳材质宣发板',
description: '短绒、软壳、visor、肩带、徽章和扣件的材质卖点展示。',
prompt: 'material close-up board, plush textile, soft shell, emotional visor, strap buckle, M badge',
},
{
templateId: 'zodiac_fantasy_female_lifestyle',
view: 'lifestyle',
title: '女性向家庭空间场景',
description: '面向女性用户和家庭陪伴场景的空间化宣发图。',
prompt: 'lifestyle scene for female users, home space, one 50cm companion robot displayed at real scale',
},
{
templateId: 'zodiac_fantasy_sku_system',
view: 'sku-system',
title: '十二生肖 SKU 色彩系统',
description: '十二款配色、纹样和幻装模块的系列化设计板。',
prompt: 'SKU color system board, twelve palette swatches and front silhouettes, professional design system',
},
{
templateId: 'zodiac_fantasy_patent_overview',
view: 'patent-overview',
title: '系列专利申报总览',
description: '12 款外观专利申报时可用的系列总览图。',
prompt: 'design patent overview board, twelve front views at same scale, simple dimensions, white background',
},
];
const SKU_MARKETING_ASSETS = VARIANTS.map(variant => ({
key: variant.key,
zodiac: variant.zodiac,
templateId: `zodiac_fantasy_${variant.key}_sku_card`,
view: `${variant.key}-sku-card`,
title: `${variant.zodiac} · 单款 SKU 宣发卡`,
description: `${variant.zodiac}款 50cm+ 生肖幻装 AI 陪伴机器人单品宣发图。`,
prompt: `single product SKU hero card for ${variant.zodiac}, 50cm+ real product scale, premium catalog composition`,
variant,
}));
await fs.mkdir(uploadDir, { recursive: true });
await fs.mkdir(packDir, { recursive: true });
await fs.mkdir(exportDir, { recursive: true });
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
await fs.mkdir(artifactDir, { recursive: true });
await assertFile(sourcePath);
const sourceFilename = `${sessionId}_source_zodiac_fantasy.png`;
const sourceUploadPath = path.join(uploadDir, sourceFilename);
await fs.copyFile(sourcePath, sourceUploadPath);
const cropDir = path.join(uploadDir, `${sessionId}_zodiac_crops`);
await fs.mkdir(cropDir, { recursive: true });
await makeVariantCrops(sourcePath, cropDir);
const contactSheet = path.join(artifactDir, 'zodiac-crops-contact-sheet.jpg');
await makeContactSheet(cropDir, contactSheet);
let session = await loadOrCreateSession();
syncTextAssets(session);
await saveSession(session);
if (prepareOnly) {
await writeTextExport(session);
console.log(JSON.stringify({
sessionId,
source: `/api/img/uploads/${sourceFilename}`,
cropDir,
contactSheet,
sessionPath,
prepareOnly: true,
}, null, 2));
process.exit(0);
}
if (!apiKey) fail('OPENAI_API_KEY missing');
const generated = [];
if (phase === 'all' || phase === 'patent') {
const patentPack = ensurePack(session, 'patent', '专利六视图包');
const pending = filterItems(VARIANTS, variant => ({
templateId: `patent_${variant.key}_six_view`,
pack: patentPack,
})).slice(0, limit);
for (const variant of pending) {
const templateId = `patent_${variant.key}_six_view`;
if (hasAsset(patentPack, templateId) && !fresh) {
generated.push({ templateId, status: 'skipped' });
continue;
}
const imagePath = path.join(cropDir, `${variant.key}.png`);
const dataUrl = await generateEdit({
imagePath,
prompt: patentPrompt(variant),
size: args.size || '1536x1024',
});
const asset = await writeAsset({
pack: patentPack,
templateId,
kind: 'patent',
view: `${variant.key}-six-view`,
title: `${variant.zodiac} · 专业投影六视图`,
description: `${variant.zodiac}款 50cm+ 智能陪伴机器人幻装外观,正/背/左/右/俯/仰六视图。`,
prompt: patentPrompt(variant),
anchorImageUrl: `/api/img/uploads/${sessionId}_zodiac_crops/${variant.key}.png`,
dataUrl,
meta: { zodiac: variant.zodiac, sku: variant.sku, color: variant.color, generatedBy: 'generate-zodiac-fantasy-series-assets' },
});
upsertAsset(patentPack, asset);
generated.push({ templateId, status: 'generated', url: asset.url });
await saveSession(session);
}
}
if (phase === 'all' || phase === 'marketing') {
const marketingPack = ensurePack(session, 'marketing', '系列宣发包');
const marketingItems = [...MARKETING_ASSETS, ...SKU_MARKETING_ASSETS];
const pending = filterItems(marketingItems, spec => ({
templateId: spec.templateId,
pack: marketingPack,
})).slice(0, limit);
for (const spec of pending) {
if (hasAsset(marketingPack, spec.templateId) && !fresh) {
generated.push({ templateId: spec.templateId, status: 'skipped' });
continue;
}
const dataUrl = await generateEdit({
imagePath: spec.variant ? path.join(cropDir, `${spec.variant.key}.png`) : sourceUploadPath,
prompt: marketingPrompt(spec),
size: args.size || '1536x1024',
});
const asset = await writeAsset({
pack: marketingPack,
templateId: spec.templateId,
kind: 'marketing',
view: spec.view,
title: spec.title,
description: spec.description,
prompt: marketingPrompt(spec),
anchorImageUrl: spec.variant ? `/api/img/uploads/${sessionId}_zodiac_crops/${spec.variant.key}.png` : `/api/img/uploads/${sourceFilename}`,
dataUrl,
meta: { generatedBy: 'generate-zodiac-fantasy-series-assets', zodiac: spec.variant?.zodiac, color: spec.variant?.color },
});
upsertAsset(marketingPack, asset);
generated.push({ templateId: spec.templateId, status: 'generated', url: asset.url });
await saveSession(session);
}
}
syncTextAssets(session);
await saveSession(session);
await writeTextExport(session);
await writeSeriesManifest(session);
console.log(JSON.stringify({
sessionId,
generated,
gallery: `/api/gallery/${sessionId}`,
textExport: `/api/export/${sessionId}_text_assets.json`,
manifest: `/api/export/${sessionId}_series_manifest.json`,
cropContactSheet: contactSheet,
}, null, 2));
function patentPrompt(variant) {
return [
`Create a professional design-patent orthographic projection sheet for ONE product variant: 有你家族 · 生肖幻装系列 · ${variant.zodiac}.`,
`Use the reference crop as the exact visual anchor. Preserve the variant identity: ${variant.traits}. Preserve its palette: ${variant.color}.`,
'Hard product scale constraint: this is a 50cm+ embodied AI companion robot for home spaces, NOT a small blind-box toy, NOT a keychain, NOT a desktop mini figure. Body volume, stable feet, and low center of gravity must feel like a real 50cm+ consumer robot.',
'Show exactly six orthographic views of the same product at the same scale: front view, back view, left side view, right side view, top view, bottom view. Arrange them in a clean 3 by 2 technical board. Use strict orthographic projection, no perspective distortion, no dramatic camera angle.',
'Fixed shared base must remain across every view: large rounded helmet head, short body, rounded limbs, black/dark graphite face base, curved emotional visor screen, round side modules, circular M family badge on chest, diagonal energy shoulder strap, central buckle, short stable legs and dark sole pads.',
'Material expression: plush fantasy outfit over a soft-shell robot body; short-pile textile and soft matte shell, safe rounded edges, female-friendly, warm technology, premium product design.',
'Patent clarity requirements: white or very light gray background, thin gray projection guide lines, clear silhouette, no props, no humans, no packaging, no scene, no accessories beside the robot, no shadows that hide contours.',
'Mandatory dimension marker: leave clear left margin in the FRONT view and draw a clean vertical dimension line labeled "约50cm+" or "50cm+" beside the robot. This is a hard acceptance requirement; an image without the height ruler fails. Do not omit, crop out, or hide this height marker. Keep width/depth believable for a 50cm+ robot.',
'Text rule: use only concise technical labels such as 主视图/FRONT, 后视图/BACK, 左视图/LEFT, 右视图/RIGHT, 顶视图/TOP, 底视图/BOTTOM, 约50cm+. No random text, no watermark, no price, no logo other than the product M badge visible on the robot.',
].join('\n');
}
function marketingPrompt(spec) {
if (spec.variant) {
return [
`Create a premium single-product marketing SKU card for 有你家族 · 生肖幻装系列 · ${spec.variant.zodiac}.`,
`Use the reference crop as the exact visual anchor. Preserve the character identity: ${spec.variant.traits}. Preserve palette: ${spec.variant.color}.`,
'Hard scale rule: this is a 50cm+ embodied AI companion robot for home spaces, not a small toy, keychain, blind box, or mini figurine. Make the body volume and product presence credible.',
'Preserve the shared family design language: rounded AI companion robot base, curved visor emotion screen, M badge, diagonal shoulder strap, side circular modules, short stable limbs, plush fantasy outfit over a soft-shell robot body.',
'Visual composition: one hero front or three-quarter front product render, one small orthographic side/back inset if helpful, soft studio lighting, clean premium catalog layout, subtle 50cm+ dimension cue, female-friendly warm technology feeling.',
'No humans, no celebrities, no third-party IP, no price tags, no watermark, no weapons. Avoid excessive text; if text appears, keep it concise and professional.',
`Visual task: ${spec.prompt}.`,
].join('\n');
}
return [
'Create a professional marketing visual for 有你家族 · 生肖幻装系列.',
'Reference image defines the exact 12 variants. Preserve the shared family design language: rounded AI companion robot base, curved visor emotion screen, M badge, diagonal shoulder strap, side circular modules, 50cm+ real product scale, plush fantasy zodiac outfits.',
'This series is for female users and family spaces: premium, warm technology, soft plush texture, clean modern lifestyle, not childish, not combat mech, not tiny blind-box toy.',
'Hard scale rule: all robots are 50cm+ intelligent companion robots. Make the body volume and environmental scale credible; never make them appear as small figurines.',
'Keep all twelve zodiac variants recognizable: 子鼠 blue mouse, 丑牛 ivory ox, 寅虎 amber tiger, 卯兔 lavender rabbit, 辰龙 mint dragon, 巳蛇 green snake, 午马 coral horse, 未羊 cream goat, 申猴 oatmeal monkey, 酉鸡 coral rooster, 戌狗 blue dog, 亥猪 pink pig.',
`Visual task: ${spec.prompt}.`,
'Use clean premium lighting and real product rendering. Avoid excessive text. No third-party IP, no celebrities, no watermarks, no price tags, no weapons.',
].join('\n');
}
function syncTextAssets(sessionValue) {
const now = Date.now();
const assets = [
{
templateId: 'series_positioning_brief',
kind: 'project',
title: '系列定位说明',
description: '生肖幻装系列的产品定位、用户对象和设计边界。',
outputFormat: 'paragraph',
content: `“有你家族 · 生肖幻装系列”定位为 50cm+ 具身 AI 智能陪伴机器人系列。十二生肖不是小挂件、盲盒或静态公仔,而是同一机器人家族基础结构上的十二套毛绒幻装外观。产品面向女性用户、家庭空间、礼赠场景和陪伴型智能硬件市场,核心气质为温暖科技、柔软可亲、系列收藏感和可落地的实体产品感。所有款式都必须保留情绪 visor、M 家族徽章、斜挎能量肩带、圆润低重心机身和 50cm+ 尺寸感。`,
},
{
templateId: 'design_system_rule',
kind: 'project',
title: '外观系统设计规则',
description: '固定资产、可变幻装区和十二款统一规则。',
outputFormat: 'bullets',
content: [
'固定核心:大头短身、圆润站立比例、情绪显示 visor、深色面部底层、胸前 M 徽章、斜挎肩带、中央功能扣、圆形侧面模块、短手短脚、稳定脚底。',
'可变区域:头部毛绒幻装、耳/角/鬃/冠等生肖识别件、肩带配色、胸扣造型、手臂纹样、腿脚边缘色、短绒表面纹理。',
'尺寸规则:每款高度按 50cm+ 产品处理,六视图和宣发图都要有真实体量,不允许表现成 10-20cm 小玩具。',
'材质规则:外部可采用短绒毛绒、软胶、哑光 ABS/PC、半透 visor 和织物肩带的复合表达,避免纯硬塑料的冷感。',
'系列规则:十二款可以有强生肖识别,但不能破坏同一机器人家族基型,不能变成十二个完全无关的动物玩偶。',
].join('\n'),
},
{
templateId: 'twelve_sku_design_matrix',
kind: 'marketing',
title: '十二款 SKU 设计矩阵',
description: '十二生肖款式命名、配色和核心卖点。',
outputFormat: 'table',
content: [
'| 款式 | 主色 | 识别元素 | 宣发关键词 |',
'| --- | --- | --- | --- |',
...VARIANTS.map(v => `| ${v.zodiac} | ${v.color} | ${v.traits} | 50cm+ AI 陪伴机器人,毛绒幻装,情绪 visor家庭空间陪伴 |`),
].join('\n'),
},
{
templateId: 'patent_filing_notes',
kind: 'patent',
title: '外观专利申报要点',
description: '专利六视图生成和申报时需要锁定的专业表达。',
outputFormat: 'bullets',
content: [
'建议按 12 个独立外观设计分别准备申报文件,每款包含正视图、后视图、左视图、右视图、俯视图、仰视图。',
'六视图应保持同一比例、同一站立中轴、同一投影逻辑,避免透视角度、场景光影和额外道具影响轮廓判断。',
'共同保护点包括情绪 visor 轮廓、胸前圆形 M 徽章、斜挎肩带、圆形侧面模块、短胖低重心结构和毛绒幻装与机器人基体的组合关系。',
'差异保护点包括每个生肖的头部幻装轮廓、表面纹样、配色组合、胸扣造型和局部毛绒材质分区。',
'尺寸文案统一为 50cm+ 智能陪伴机器人,避免在专利和宣发资料里出现小尺寸手办、小挂件或盲盒比例误导。',
].join('\n'),
},
{
templateId: 'launch_copy_package',
kind: 'marketing',
title: '宣发文案包',
description: '品牌主张、短句、详情页首屏和招商页文字。',
outputFormat: 'paragraph',
content: [
'主标题:有你家族 · 生肖幻装系列',
'副标题50cm+ 具身 AI 智能陪伴机器人,把十二生肖穿成可以回应你的温暖角色。',
'核心卖点:十二款生肖幻装、情绪 visor 互动、毛绒软壳触感、家庭空间级 50cm+ 尺寸、家族化 M 徽章与斜挎能量肩带、适合陪伴、礼赠、收藏和空间陈列。',
'详情页首屏:不是小玩偶,而是一位可以进入家庭空间的 AI 陪伴伙伴。每一款都保留有你家族的圆润机甲基型,并通过毛绒幻装、颜色和局部模块呈现专属生肖性格。',
'招商表述:该系列以统一基础结构降低产品化复杂度,以十二生肖外观系统拉开 SKU 阵列,兼具 IP 延展、节日礼赠、女性用户审美和智能硬件差异化表达。',
].join('\n'),
},
{
templateId: 'future_video_direction',
kind: 'video',
title: '后续视频方向预案',
description: '本轮视频暂缓,仅保留后续 30 秒集合片方向。',
outputFormat: 'script',
content: [
'片一十二生肖集合亮相。0-5s 情绪 visor 点亮5-15s 十二款按生肖顺序依次转身15-24s 50cm+ 家庭空间比例展示24-30s 全员定格,有你家族 · 生肖幻装系列。',
'片二亥猪主角带队。0-6s 亥猪 visor 呼吸光启动6-18s 镜头扫过其他十一款幻装18-26s 毛绒、肩带、徽章、侧面模块细节快切26-30s 亥猪站在前景,全系列在后景列阵。',
].join('\n'),
},
].map((asset, index) => ({
id: `${asset.templateId}_${index + 1}`,
templateId: asset.templateId,
kind: asset.kind,
title: asset.title,
description: asset.description,
outputFormat: asset.outputFormat,
content: asset.content,
prompt: 'manual professional text asset for zodiac fantasy series',
status: 'complete',
provider: 'mock',
model: 'manual',
createdAt: now + index,
}));
const previous = sessionValue.textAssets ?? [];
const ids = new Set(assets.map(asset => asset.templateId));
sessionValue.textAssets = [
...previous.filter(asset => !ids.has(asset.templateId)),
...assets,
];
}
async function loadOrCreateSession() {
const existing = await readJson(sessionPath);
if (existing) return existing;
const now = Date.now();
const uploadUrl = `/api/img/uploads/${sourceFilename}`;
return {
id: sessionId,
createdAt: now,
prompt: '有你家族 · 生肖幻装系列。12 款 50cm+ 具身 AI 智能陪伴机器人,毛绒幻装外观,按十二生肖展开;本轮只生成专业专利六视图、系列宣发图和文字资产,视频暂缓。',
refImages: [uploadUrl],
count: 1,
inputMode: 'replicate',
uploadedImages: [{
id: 'upload_zodiac_fantasy_source',
url: uploadUrl,
filename: sourceFilename,
originalFilename: path.basename(sourcePath),
mimeType: 'image/png',
uploadedAt: now,
role: 'subject',
needsCleanup: false,
}],
images: [{
id: 'source_zodiac_fantasy_board',
url: uploadUrl,
prompt: '有你家族 · 生肖幻装系列参考图',
status: 'selected',
meta: { sourcePath, role: 'series-reference-board' },
}],
characterSpec: {
name: '有你家族 · 生肖幻装系列',
oneLiner: '12 款 50cm+ 具身 AI 智能陪伴机器人,以统一家族基型承载十二生肖毛绒幻装。',
targetUser: '女性用户、家庭空间、礼赠用户、智能陪伴产品用户',
speciesShape: '圆润短胖 AI 机器人基型,外覆十二生肖毛绒幻装',
bodyRatio: '大头、小身体、短四肢、低重心站立,高度 50cm+',
faceFeatures: '黑色面部底层与弧形情绪 visor 显示屏',
colorPalette: ['奶白', '深灰', '生肖主题色', '高亮 visor 色', '织物肩带色'],
materials: ['短绒毛绒', '软壳 ABS/PC', '半透 visor', '织物肩带', '软胶扣件'],
accessories: ['斜挎能量肩带', '中央功能扣', '圆形侧面模块', '胸前 M 家族徽章'],
signatureElements: ['情绪 visor', 'M 徽章', '斜挎肩带', '圆润低重心', '十二生肖幻装'],
manufacturingNotes: ['高度 50cm+', '外层短绒需可清洁', '所有突出装饰必须软化圆角', '底部需稳定站立'],
patentFocus: ['十二款独立外观六视图', '统一机器人基型', '生肖幻装差异区', '50cm+ 产品比例'],
marketingAngle: ['温暖科技', '女性向陪伴', '家庭空间陈列', '生肖礼赠', '系列收藏'],
negativePrompt: '小挂件、小盲盒、小手办、低龄玩具化、重装战斗机甲、尖锐危险装饰、第三方 IP、水印、价格',
sourceImageId: 'source_zodiac_fantasy_board',
sourceImageUrl: uploadUrl,
cleanReferenceImageUrl: uploadUrl,
lockedAt: now,
},
packs: [],
textAssets: [],
videoTasks: [],
exports: [],
};
}
function ensurePack(sessionValue, kind, label) {
let pack = sessionValue.packs?.find(item => item.kind === kind);
if (pack) return pack;
const now = Date.now();
pack = {
id: `pack_${kind}_${now.toString(36)}_${randomHex(3)}`,
kind,
sessionId,
sourceImageId: 'source_zodiac_fantasy_board',
sourceImageUrl: `/api/img/uploads/${sourceFilename}`,
characterSpec: sessionValue.characterSpec,
assets: [],
manifestId: `manifest_${kind}_${now.toString(36)}`,
createdAt: now,
version: 'v01',
status: 'complete',
meta: { label },
};
sessionValue.packs = [...(sessionValue.packs || []), pack];
return pack;
}
function filterItems(items, getTarget) {
return items.filter(item => {
if (only.length && !only.includes(item.key) && !only.includes(item.templateId) && !only.includes(item.zodiac)) return false;
const { templateId, pack } = getTarget(item);
return fresh || !hasAsset(pack, templateId);
});
}
function hasAsset(pack, templateId) {
return Boolean(pack.assets?.some(asset => asset.templateId === templateId));
}
function upsertAsset(pack, asset) {
const index = pack.assets.findIndex(item => item.templateId === asset.templateId);
if (index >= 0) pack.assets[index] = asset;
else pack.assets.push(asset);
}
async function writeAsset(opts) {
const assetId = `${opts.templateId}_${randomHex(3)}`;
const filename = `${opts.pack.id}_${assetId}.png`;
const filePath = path.join(packDir, filename);
await writeDataUrl(opts.dataUrl, filePath);
return {
id: assetId,
templateId: opts.templateId,
kind: opts.kind,
view: opts.view,
title: opts.title,
description: opts.description,
url: `/api/img/packs/${filename}`,
prompt: opts.prompt,
status: 'draft',
version: 'v01',
aspectRatio: '16:9',
required: true,
createdAt: Date.now(),
anchorImageUrl: opts.anchorImageUrl,
derivationLevel: 2,
meta: { provider: 'gpt', model, ...opts.meta },
};
}
async function makeVariantCrops(inputPath, outputDir) {
for (const variant of VARIANTS) {
const output = path.join(outputDir, `${variant.key}.png`);
if (!fresh && await exists(output)) continue;
const geometry = `${variant.crop.w}x${variant.crop.h}+${variant.crop.x}+${variant.crop.y}`;
await execFile('magick', [inputPath, '-crop', geometry, '+repage', output]);
}
}
async function makeContactSheet(inputDir, outputPath) {
const tiles = [];
for (const variant of VARIANTS) {
const input = path.join(inputDir, `${variant.key}.png`);
const tile = path.join(path.dirname(outputPath), `tile_${variant.key}.png`);
await execFile('magick', [
input,
'-resize', '220x220',
'-background', 'white',
'-gravity', 'center',
'-extent', '240x240',
tile,
]);
tiles.push(tile);
}
const rows = [];
for (let index = 0; index < tiles.length; index += 4) {
const row = path.join(path.dirname(outputPath), `row_${index / 4}.png`);
await execFile('magick', [...tiles.slice(index, index + 4), '+append', row]);
rows.push(row);
}
await execFile('magick', [...rows, '-append', outputPath]);
}
async function generateEdit(opts) {
const rawText = await curlImageEdit(opts);
const raw = JSON.parse(rawText);
const first = raw.data?.[0];
if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`;
if (first?.url) {
const tempPath = path.join(packDir, `tmp_${Date.now()}_${randomHex(2)}.png`);
await execFile('curl', [
'-sS',
'-L',
'--retry', '2',
'--connect-timeout', '20',
'--max-time', '300',
'-o', tempPath,
first.url,
], { maxBuffer: 1024 * 1024 });
const buffer = await fs.readFile(tempPath);
await fs.unlink(tempPath);
return `data:image/png;base64,${buffer.toString('base64')}`;
}
throw new Error('GPT image edit response missing image');
}
async function curlImageEdit(opts) {
const tempDir = path.join(root, 'artifacts', 'tmp');
await fs.mkdir(tempDir, { recursive: true });
const responsePath = path.join(tempDir, `response_${Date.now()}_${randomHex(3)}.json`);
const curlConfig = [
'silent',
'show-error',
'request = "POST"',
`url = "${apiBase}/images/edits"`,
`header = "Authorization: Bearer ${apiKey}"`,
'retry = 2',
'connect-timeout = 20',
'max-time = 600',
`output = "${responsePath}"`,
`write-out = "%{http_code}"`,
`form = "model=${model}"`,
`form = "size=${opts.size || '1536x1024'}"`,
`form = "prompt=${escapeCurlConfig(opts.prompt)}"`,
`form = "image=@${opts.imagePath};type=image/png;filename=${path.basename(opts.imagePath)}"`,
].join('\n');
try {
const stdout = await runCurlConfig(curlConfig);
const rawText = await fs.readFile(responsePath, 'utf8');
const status = Number(String(stdout).trim().slice(-3));
if (!Number.isFinite(status) || status < 200 || status >= 300) {
throw new Error(`GPT image edit ${stdout}: ${rawText}`);
}
return rawText;
} finally {
await fs.rm(responsePath, { force: true });
}
}
async function runCurlConfig(configText) {
return new Promise((resolve, reject) => {
const child = spawn('curl', ['--config', '-'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
const stdout = [];
const stderr = [];
child.stdout.on('data', chunk => stdout.push(chunk));
child.stderr.on('data', chunk => stderr.push(chunk));
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8'));
} else {
reject(new Error(`curl exited ${code}: ${Buffer.concat(stderr).toString('utf8')}`));
}
});
child.stdin.end(configText);
});
}
function escapeCurlConfig(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n');
}
async function writeTextExport(sessionValue) {
await fs.writeFile(
path.join(exportDir, `${sessionId}_text_assets.json`),
`${JSON.stringify({
sessionId,
title: '有你家族 · 生肖幻装系列文字资产',
generatedAt: new Date().toISOString(),
textAssets: sessionValue.textAssets ?? [],
}, null, 2)}\n`,
);
}
async function writeSeriesManifest(sessionValue) {
await fs.writeFile(
path.join(exportDir, `${sessionId}_series_manifest.json`),
`${JSON.stringify({
sessionId,
title: '有你家族 · 生肖幻装系列',
source: `/api/img/uploads/${sourceFilename}`,
gallery: `/api/gallery/${sessionId}`,
counts: {
images: sessionValue.images?.length ?? 0,
packs: sessionValue.packs?.length ?? 0,
packAssets: (sessionValue.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0),
textAssets: sessionValue.textAssets?.length ?? 0,
},
variants: VARIANTS.map(({ crop, ...variant }) => variant),
packs: sessionValue.packs ?? [],
}, null, 2)}\n`,
);
}
async function saveSession(sessionValue) {
await fs.writeFile(sessionPath, `${JSON.stringify(sessionValue, null, 2)}\n`);
}
async function readJson(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch {
return null;
}
}
async function writeDataUrl(dataUrl, filePath) {
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
if (!match) throw new Error('invalid image data URL');
await fs.writeFile(filePath, Buffer.from(match[1], 'base64'));
}
async function assertFile(filePath) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat?.isFile()) fail(`Source image not found: ${filePath}`);
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function readEnvFile(filePath) {
try {
const raw = readFileSync(filePath, 'utf8');
return Object.fromEntries(raw
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#') && line.includes('='))
.map(line => {
const index = line.indexOf('=');
const key = line.slice(0, index).trim();
const value = line.slice(index + 1).trim().replace(/^['"]|['"]$/g, '');
return [key, value];
}));
} catch {
return {};
}
}
function parseArgs(argv) {
const parsed = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i];
if (!item.startsWith('--')) {
parsed._.push(item);
continue;
}
const key = item.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
parsed[key] = true;
} else {
parsed[key] = next;
i += 1;
}
}
return parsed;
}
function randomHex(bytes) {
return randomBytes(bytes).toString('hex');
}
function fail(message) {
console.error(message);
process.exit(1);
}

View File

@@ -0,0 +1,798 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { createWriteStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const command = args._[0] || 'run';
const sessionId = args.session || args._[1];
if (!sessionId) fail('Usage: node scripts/seedance-60s-compose.mjs run --session <sessionId> [--target-seconds 45] [--env deploy/.env.production]');
const SEGMENT_SECONDS = clampNumber(args['segment-seconds'], 15, 3, 15);
const TARGET_SECONDS = clampNumber(args['target-seconds'] || args.seconds, 45, SEGMENT_SECONDS, 180);
const SEGMENT_COUNT = Math.ceil(TARGET_SECONDS / SEGMENT_SECONDS);
const PRODUCT_CONTEXT = productContextForSession(sessionId);
const PRODUCT_SIZE_LABEL = args['product-size'] || PRODUCT_CONTEXT.sizeLabel;
const PRODUCT_SIZE_TEXT = args['product-size-text'] || PRODUCT_CONTEXT.sizeText;
const TEMPLATE_IDS = [
'video_turntable',
'video_unboxing',
'video_touch_detail',
'video_story_intro',
'video_factory_preview',
];
const runLabel = safe(args['run-label'] || `seedance${TARGET_SECONDS}`);
const freshRun = Boolean(args.fresh);
const noReferenceImages = Boolean(args['no-reference']);
const selectedTemplateIds = String(args.templates || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
const activeTemplateIds = selectedTemplateIds.length ? selectedTemplateIds : TEMPLATE_IDS;
const VIDEO_TEMPLATE_BLUEPRINTS = {
video_turntable: {
title: '360 度旋转展示',
description: `${TARGET_SECONDS} 秒,用于电商和内部评审,展示整体体积、正背侧轮廓。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒 360 度旋转展示视频:${character}. 白底或浅灰棚拍,镜头稳定,产品缓慢旋转,展示正面、侧面、背面、顶部细节,材质、表面质感、情绪屏和配件必须严格贴合角色设定。产品尺寸按 40cm+ 智能陪伴机器人摆件表现,正面宽约 28cm、侧深约 22cm镜头中要能感知 40cm 以上实体体量。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`,
},
video_unboxing: {
title: '开箱短片',
description: `${TARGET_SECONDS} 秒,用于新品宣发,展示包装到玩具出现的过程。`,
ratio: '9:16',
prompt: character => `生成 ${TARGET_SECONDS} 秒玩具开箱短片:${character}. 竖版社媒风格,从礼盒或包装打开到产品出现,温暖但克制的棚拍光线,突出礼物感、收藏感、角色识别点和配件陈列。产品为 40cm+ 智能陪伴机器人摆件,包装和手部比例必须支持 40cm 以上尺寸。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`,
},
video_touch_detail: {
title: '触感细节',
description: `${TARGET_SECONDS} 秒,展示材质、情绪屏、表面纹理和配件细节。`,
ratio: '9:16',
prompt: character => `生成 ${TARGET_SECONDS} 秒玩具细节短片:${character}. 近景镜头,展示橙色 visor 情绪屏、白色短绒或软壳触感、深灰结构件、MEEY 顶部识别条、胸前 M 徽章、斜挎肩带、灰橙扣件、侧面圆形模块和脚底细节,节奏清楚,避免加入设定外部件。必须体现 40cm+ 产品的厚实体量、柔和触感和稳定站立尺度。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`,
},
video_story_intro: {
title: '角色故事介绍',
description: `${TARGET_SECONDS} 秒,用于 IP 设定和社媒发布。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒玩具角色故事介绍视频:${character}. 轻剧情镜头,围绕“猪族世界的第一套十二生肖机甲基型”登场,展示情绪屏变化、标志性配件、色彩气质和陪伴感,适合新品发布。故事中产品始终是 40cm+ 智能陪伴机器人摆件,可以被双手抱住或稳放在家居空间,不要缩成桌面小摆件。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`,
},
video_factory_preview: {
title: '工厂预览短片',
description: `${TARGET_SECONDS} 秒,用于打样前内部沟通,展示外观、尺寸、材料、拆件和包装要点。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒工厂预览概念短片:${character}. 16:9面向内部沟通展示外观、尺寸、材料、拆件、装配和包装要点镜头清楚克制不做消费者营销话术。尺寸基准写死为成品高度 40cm+,正面宽约 28cm侧面深约 22cm必须保持 40cm 以上实体产品尺度。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`,
},
zodiac_collection_hero: {
title: '十二生肖集合主视觉短片',
description: `${TARGET_SECONDS} 秒,亥猪作为主角展示十二生肖装甲系统。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒十二生肖集合主视觉短片:${character}. 亥猪是主角,站在画面中心或首先登场,其他十一套生肖装甲作为同一基础机甲的外观扩展依次出现。每一套都必须保持同一白色圆润头盔、橙色弧形 visor、深灰面部底层、MEEY 顶部识别条、胸前 M 徽章、斜挎肩带、短胖低重心比例,只变化头顶冠盖、侧面模块、肩部/手臂/脚部装甲纹样、配色和短绒/软壳材质。不要把其它生肖变成真实动物身体;不要出现猪鼻子、尾巴、角、爪、翅膀等具象器官。潮流电子节奏,酷炫但温暖,适合系列发布。`,
},
zodiac_collection_runway: {
title: '十二生肖装甲走秀短片',
description: `${TARGET_SECONDS} 秒,展示十二生肖外观组合,亥猪压轴/领队。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒十二生肖装甲走秀短片:${character}. 以亥猪基型为领队,十二套外观像产品发布走秀一样在白色科技展台上轮换展示。颜色可以更丰富,但基础形象不能变:橙色 visor 情绪屏、胸前 M 徽章、斜挎黑色能量肩带、灰橙扣件和短胖站姿全部保留。每个生肖只通过可换装甲区域、色彩符号和表面短绒/软壳材料区分,禁止真实动物器官、禁止重装战斗化。镜头要有节奏感、旋转台、局部特写和集合收束,音乐为原创韩流感电子鼓点,不使用真实歌曲。`,
},
zodiac_fantasy_pig_showcase: {
title: '亥猪 C 位展示视频',
description: `${TARGET_SECONDS} 秒,亥猪作为 C 位主角,其它 11 款生肖幻装作为背景阵列。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 生肖幻装系列 · 亥猪 C 位展示视频”:${character}. 亥猪必须始终是画面 C 位和视觉焦点,粉色猪系毛绒幻装、粉紫情绪 visor、M 家族徽章、斜挎能量肩带、圆润机甲基型必须清晰保留。其它 11 款生肖机器人只能作为背景阵列、舞台两侧、虚化陈列或后景灯箱出现,不抢主角。镜头结构:开场亥猪 visor 点亮,随后 360 度慢旋展示正面、侧面、背面和顶部,再用展台灯光扫过背景十二生肖阵列,最后亥猪回到正中定格。硬性尺度:全系列都是 50cm+ 具身 AI 智能陪伴机器人,不是小手办。画面风格酷炫、干净、女性向高级感,原创韩流感电子节奏,不使用真实受版权保护歌曲。`,
},
zodiac_fantasy_pig_touch_detail: {
title: '亥猪 C 位触感细节',
description: `${TARGET_SECONDS}展示亥猪毛绒软壳、visor、徽章、肩带和脚底细节。`,
ratio: '9:16',
prompt: character => `生成 ${TARGET_SECONDS} 秒“亥猪 C 位触感细节视频”:${character}. 竖版近景,亥猪是唯一主角,其他生肖只作为远处柔焦背景或镜面反射,不抢画面。重点展示粉色短绒毛绒外层、软壳机甲基体、粉紫 visor 情绪屏呼吸光、胸前 M 徽章、斜挎肩带、猪系胸扣、侧面圆形模块、圆润手臂、稳定脚底。需要有手部轻触、指尖划过短绒、按压软壳边缘、visor 亮起回应的细节,但手部比例必须证明这是 50cm+ 大体量陪伴机器人。禁止把亥猪拍成小玩偶、钥匙扣或桌面小公仔。音乐是原创韩流电子节奏和干净鼓点,不使用真实歌曲。`,
},
zodiac_fantasy_pig_group_flash: {
title: '亥猪 C 位群体快闪',
description: `${TARGET_SECONDS} 秒,十二生肖群体快闪,亥猪领舞/领队/C 位收束。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“十二生肖群体快闪视频”:${character}. 亥猪必须是 C 位领队,先单独登场,再由其它 11 款生肖幻装机器人从左右和后方快闪入场形成阵列。其它角色只做背景节奏、队形变化和颜色补充,不改变亥猪主角身份。镜头节奏:快速灯光点亮、生肖角色依次闪现、亥猪向前一步、全员 visor 同步呼吸光、群体 3 排阵列收束。所有角色都是 50cm+ 具身 AI 智能陪伴机器人,体量接近家居摆件/陪伴设备,不是盲盒。风格要酷炫、潮流、女性向高级,原创韩流感电子鼓点,不使用真实受版权保护歌曲。`,
},
zodiac_fantasy_interaction_showcase: {
title: '生肖幻装系列互动展示视频',
description: `${TARGET_SECONDS} 秒,亥猪作为系列代表展示屏幕脸、表情包、视频播放和人机互动。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 生肖幻装系列互动展示视频”:${character}. 以系列中的亥猪为主要展示角色,其他生肖幻装机器人可以作为背景阵列、舞台两侧陈列或远景队列。亥猪面部必须是电子显示屏/情绪 visor屏幕上清楚变化喜、怒、哀、乐、害羞、惊喜等表情包并出现与用户语音互动、点头回应、灯效呼吸、播放短视频画面的场景。额头上的猪鼻子这条视频可以不要显示不要把猪鼻子做成突出的额头装饰保留粉色猪系幻装、M 家族徽章、斜挎能量肩带和圆润 50cm+ 具身 AI 陪伴机器人比例。镜头结构:开场系列展台点亮,亥猪屏幕脸从待机图标切换成表情包;中段展示用户说话、亥猪屏幕回应并播放小视频;后段其它生肖作为背景一起亮屏,最后回到亥猪正面定格。风格干净高级、亲和、有科技感,原创电子节奏,不使用真实受版权保护歌曲。`,
},
hai_pig_meey_interaction_showcase: {
title: '亥猪 MEEY 互动展示视频',
description: `${TARGET_SECONDS} 秒,展示屏幕脸、表情包、视频播放和陪伴交互。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 亥猪 MEEY 机甲陪伴机器人互动展示视频”:${character}. 主角是 40cm+ 亥猪 MEEY 机甲陪伴机器人白色圆润机甲头盔、橙色弧形显示屏脸、深灰面部底层、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带和灰橙功能扣必须稳定一致。面部显示器要清楚呈现喜、怒、哀、乐、撒娇、确认、睡眠等表情包切换,并展示用户靠近说话、机器人屏幕文字/表情回应、播放家庭短视频或动画片段、用灯效和轻微身体动作反馈的场景。镜头结构:开场桌面/客厅真实比例亮相;中段重点拍屏幕脸表情包和视频播放;后段成人手部或儿童在旁边互动,机器人转头/屏幕回应;最后产品完整正面收束。不要变成真实猪、毛绒动物或小手办。风格温暖科技、适合新品展示,原创电子节奏,不使用真实受版权保护歌曲。`,
},
youni_family_meey_screen_showcase: {
title: '有你家族 MEEY 屏幕脸展示视频',
description: `${TARGET_SECONDS} 秒,面部无遮挡显示屏,展示表情包、演唱会视频播放和有你家族陪伴主题。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · MEEY 机甲陪伴机器人屏幕脸展示视频”:${character}. 主题是“有你家族”,主角是亥猪 MEEY 机甲陪伴机器人但画面表达要更像品牌家族成员的智能陪伴产品发布片。最重要硬性要求机器人的面部就是一整块完整电子显示屏屏幕必须无遮挡、无护目镜遮挡、无金属横条挡住、无口罩、无外壳压住显示区域正面镜头要让观众清楚看见完整屏幕边界和屏幕内容。屏幕内容要不断变化开心、委屈、撒娇、惊喜、害羞、睡眼、爱心、确认等表情包中段必须出现播放演唱会视频的场景屏幕里有舞台灯光、歌手剪影、观众光棒或音乐可视化但不要使用真实明星肖像、真实歌曲名、真实品牌标识或可识别版权演唱会。机身保持白色圆润头盔、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带、灰橙功能扣、短胖 40cm+ 陪伴机器人比例。镜头结构:开场“有你家族”温暖家庭空间中产品亮屏;中段大特写展示无遮挡全屏表情包切换和演唱会播放;后段用户语音点播音乐会,机器人屏幕回应并播放舞台画面;最后回到正面完整屏幕露出微笑表情收束。风格温暖、科技、家庭陪伴、品牌发布感,原创电子音乐氛围,不使用真实受版权保护歌曲。`,
},
meey_face_display_dynamic_30s: {
title: 'MEEY 面部屏幕动态展示',
description: `${TARGET_SECONDS} 秒,快节奏展示面部显示屏、互动表情、演唱会播放和智能对话情绪反馈。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 亥猪 MEEY 机甲陪伴机器人面部屏幕动态展示”:${character}. 这是快节奏产品功能展示,核心只围绕面部显示屏展开。面部必须是一整块完整电子显示屏,无遮挡、无窄条 visor、无护目镜、无口罩、无金属横条遮住屏幕镜头要多次贴近完整屏幕边界。屏幕动态要丰富且切换快开心、惊喜、害羞、撒娇、认真倾听、思考中、确认收到、困困眼、爱心、音乐律动等互动表情快速轮换当用户说话时屏幕出现语音波形、听懂后的确认表情、情绪反馈动画当用户说“看演唱会”时屏幕立即播放演唱会视频效果画面里有舞台灯光、歌手剪影、观众光棒、音浪可视化和节奏跳动但不要出现真实明星肖像、真实歌曲名、真实品牌标识或可识别版权演唱会。镜头结构开场 0-5 秒快速亮屏与品牌氛围;中段 5-18 秒连续屏幕特写,表情包和语音互动快速切换;后段 18-27 秒屏幕播放演唱会并跟随节奏闪动;最后 27-30 秒回到完整正面屏幕用温暖笑脸和“陪伴感”收束。机身保持白色圆润机甲身体、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带、40cm+ 陪伴机器人比例。整体节奏比 45 秒版本更快,镜头更密集,重点是屏幕动态效果,不是全身慢展示。原创电子节奏氛围,不使用真实受版权保护歌曲。`,
},
nuonuo_pig_interaction_showcase: {
title: '糯糯猪互动展示视频',
description: `${TARGET_SECONDS} 秒,展示自主行动、语音互动和猪鼻/耳朵/尾巴/眼睛触摸反馈。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 糯糯猪智能陪伴毛绒玩具互动展示视频”:${character}. 主角是约 45cm 大尺寸浅粉色长绒毛糯糯猪,圆胖坐姿、黑亮圆眼睛、粉色立体猪鼻、下垂猪耳朵、短卷尾巴、金色挂绳项圈和爱心吊牌必须保持一致。视频必须体现它不是普通静态毛绒玩具,而是能自主行动、能和人语音互动的智能陪伴玩具:可以轻微前进/转身/抬头点头,听到用户说话后用软萌语音、眼部灯效、鼻尖呼吸光、耳朵轻动、尾巴摇动回应。必须安排人机互动镜头:用户摸猪鼻子时鼻尖发光并发出回应;摸耳朵时耳朵轻摆并听懂语音;摸尾巴时尾巴卷动或抖动反馈;看向眼睛时眼睛亮起、眨眼或显示情绪光。镜头结构:开场从沙发/儿童房自主走近;中段语音问答与触摸反馈;后段拥抱陪伴、睡前故事或亲子互动;最后糯糯猪坐回中心微笑收束。不要改成机甲机器人、不要失去毛绒小猪身份,不要变成小挂件。风格温暖治愈、真实家庭使用场景,原创轻快音乐,不使用真实受版权保护歌曲。`,
},
nuonuo_pig_motion_feedback_showcase: {
title: '糯糯猪动作反馈展示视频',
description: `${TARGET_SECONDS} 秒,重点展示主动耳朵、三套以上眼睛反馈、灵巧尾巴和四肢小动作。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 糯糯猪动作反馈展示视频”:${character}. 主角必须是约 45cm 大尺寸浅粉色长绒毛糯糯猪,圆胖可拥抱坐姿、黑亮圆眼睛、粉色立体猪鼻、下垂猪耳朵、短卷尾巴、短小四肢、金色挂绳项圈和爱心吊牌保持一致。核心主题是“身体反馈比上一条更丰富”:猪耳朵不是被动晃动,而是会主动抬起、轻摆、左右倾听、听到用户声音后有节奏地扇动;眼睛要有三套以上明确反馈效果,包括眨眼/星星眼/爱心光/困困眼/开心月牙眼/听懂后的确认光圈等,至少出现三种不同状态;尾巴要灵巧摆动,可以卷尾轻摇、左右摆、开心时快速抖动、被呼唤时像小天线一样回应;四肢也要小范围动起来,前肢可以抬手打招呼、轻轻抱住用户手指,后肢可以配合身体小步前进或坐姿调整,但动作要符合软萌毛绒玩具,不要变成硬质机器人。镜头结构:开场糯糯猪听到“有你家族”呼唤后耳朵主动竖动并小步靠近;中段连续展示眼睛三种以上反馈、尾巴灵巧摆动、前肢打招呼和语音互动;后段用户轻摸耳朵/鼻子/尾巴后分别触发不同灯效和动作反馈;最后糯糯猪坐在沙发或儿童房中央,用眼睛爱心光和尾巴轻摇温暖收束。不要失去毛绒猪身份,不要变成机甲机器人、小挂件或四足宠物。风格温暖治愈、亲子陪伴、真实家庭使用场景,原创轻快音乐,不使用真实受版权保护歌曲。`,
},
};
const env = {
...readEnvFile(path.join(root, args.env || '.env.local')),
...process.env,
};
const apiKey = env.SEEDANCE_API_KEY;
if (!apiKey) fail('SEEDANCE_API_KEY missing');
const apiBase = env.SEEDANCE_API_BASE || 'https://ark.cn-beijing.volces.com/api/v3';
const model = env.SEEDANCE_MODEL || 'doubao-seedance-2-0-260128';
const publicAppUrl = args['public-app-url'] || env.PUBLIC_APP_URL || env.NEXT_PUBLIC_APP_URL || 'https://ai-toy.kang-kang.com';
const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`);
const trackerDir = path.join(root, 'data', 'video-segments');
const trackerPath = path.join(trackerDir, `${sessionId}-${runLabel}.json`);
const videosDir = path.join(root, 'data', 'videos');
const segmentDir = path.join(videosDir, 'segments', sessionId, runLabel);
await fs.mkdir(trackerDir, { recursive: true });
await fs.mkdir(segmentDir, { recursive: true });
if (command === 'submit') {
const tracker = await ensureTracker();
if (freshRun) await updateAllProgress(tracker);
await submitMissingSegments(tracker);
await saveTracker(tracker);
printSummary(tracker);
} else if (command === 'poll') {
const tracker = await ensureTracker();
await pollAndCompose(tracker, { once: true });
await saveTracker(tracker);
printSummary(tracker);
} else if (command === 'run') {
const tracker = await ensureTracker();
if (freshRun) await updateAllProgress(tracker);
await submitMissingSegments(tracker);
await saveTracker(tracker);
await pollAndCompose(tracker, { once: Boolean(args.once) });
await saveTracker(tracker);
printSummary(tracker);
} else {
fail(`Unknown command: ${command}`);
}
function parseArgs(argv) {
const parsed = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i];
if (!item.startsWith('--')) {
parsed._.push(item);
continue;
}
const key = item.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
parsed[key] = true;
} else {
parsed[key] = next;
i += 1;
}
}
return parsed;
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), min), max);
}
function readEnvFile(filePath) {
try {
const text = readFileSync(filePath, 'utf8');
const out = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx < 0) continue;
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1).replace(/^["']|["']$/g, '');
}
return out;
} catch {
return {};
}
}
async function ensureTracker() {
const session = await readSession();
let tracker = await readTracker();
if (!tracker) {
tracker = {
sessionId,
model,
provider: 'seedance',
targetSeconds: TARGET_SECONDS,
segmentSeconds: SEGMENT_SECONDS,
createdAt: Date.now(),
updatedAt: Date.now(),
templates: {},
};
}
const anchor = findAnchor(session);
const referenceUrls = noReferenceImages ? [] : findVideoReferenceUrls(session, anchor);
const character = characterSummary(session);
for (const templateId of activeTemplateIds) {
const task = (session.videoTasks || []).find(item => item.templateId === templateId) || buildDefaultVideoTask(session, templateId, character, anchor);
if (!task) continue;
const entry = tracker.templates[templateId] || {
templateId,
title: task.title,
ratio: task.ratio || '16:9',
prompt: task.prompt,
anchorImageUrl: anchor,
referenceUrls,
finalVideoUrl: undefined,
segments: [],
};
entry.title = entry.title || task.title;
entry.ratio = entry.ratio || task.ratio || '16:9';
entry.prompt = entry.prompt || task.prompt;
entry.anchorImageUrl = entry.anchorImageUrl || anchor;
entry.referenceUrls = entry.referenceUrls?.length ? entry.referenceUrls : referenceUrls;
if (!freshRun && task.taskId && !entry.segments.some(segment => segment.part === 1)) {
entry.segments.push({
part: 1,
taskId: task.taskId,
status: task.status || 'submitted',
source: 'existing_session_task',
submittedAt: task.submittedAt || Date.now(),
});
}
for (let part = 1; part <= SEGMENT_COUNT; part += 1) {
if (!entry.segments.some(segment => segment.part === part)) {
entry.segments.push({ part, status: 'pending' });
}
}
entry.segments.sort((a, b) => a.part - b.part);
tracker.templates[templateId] = entry;
}
tracker.updatedAt = Date.now();
return tracker;
}
function buildDefaultVideoTask(session, templateId, character, anchorImageUrl) {
const blueprint = VIDEO_TEMPLATE_BLUEPRINTS[templateId];
if (!blueprint) return null;
const now = Date.now();
return {
id: `vid_${session.id}_${templateId}`,
templateId,
title: blueprint.title,
description: blueprint.description,
prompt: blueprint.prompt(character),
anchorImageUrl,
provider: 'seedance',
model,
status: 'processing',
ratio: blueprint.ratio,
duration: TARGET_SECONDS,
submittedAt: now,
updatedAt: now,
};
}
function characterSummary(session) {
const spec = session.characterSpec || {};
const parts = [
spec.name || '有你家族 · 亥猪',
spec.oneLiner,
spec.bodyRatio,
spec.faceFeatures,
spec.signatureElements?.length ? `固定识别元素:${spec.signatureElements.join('、')}` : '',
spec.materials?.length ? `材质方向:${spec.materials.join('、')}` : '',
spec.negativePrompt ? `禁忌:${spec.negativePrompt}` : '',
].filter(Boolean);
return parts.join('');
}
async function submitMissingSegments(tracker) {
for (const entry of Object.values(tracker.templates)) {
for (const segment of entry.segments) {
if (segment.taskId || segment.status === 'succeeded') continue;
const response = await submitSegment(entry, segment.part);
segment.taskId = response.taskId;
segment.status = response.status;
segment.raw = response.raw;
segment.submittedAt = Date.now();
console.log(JSON.stringify({
action: 'submitted',
templateId: entry.templateId,
part: segment.part,
taskId: segment.taskId,
status: segment.status,
}));
await updateSessionProgress(entry);
await saveTracker(tracker);
}
}
}
async function submitSegment(entry, part) {
const body = {
model,
content: [
{ type: 'text', text: segmentPrompt(entry, part) },
...entry.referenceUrls.map(url => ({
type: 'image_url',
image_url: { url: publicUrl(url) },
role: 'reference_image',
})),
],
generate_audio: true,
ratio: entry.ratio || '16:9',
duration: SEGMENT_SECONDS,
watermark: false,
};
const res = await fetch(`${apiBase}/contents/generations/tasks`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
const rawText = await res.text();
if (!res.ok) throw new Error(`Seedance submit ${res.status}: ${rawText}`);
const raw = JSON.parse(rawText);
return {
taskId: raw.task_id || raw.id,
status: normalizeStatus(raw.status),
raw,
};
}
async function pollAndCompose(tracker, opts) {
const maxWaitMs = Number(args['max-wait-minutes'] || 90) * 60 * 1000;
const pollMs = Number(args['poll-seconds'] || 45) * 1000;
const started = Date.now();
while (true) {
let changed = false;
for (const entry of Object.values(tracker.templates)) {
let entryChanged = false;
for (const segment of entry.segments) {
if (!segment.taskId || segment.status === 'succeeded' || segment.status === 'failed') continue;
const response = await getTask(segment.taskId);
segment.status = response.status;
segment.raw = response.raw;
segment.updatedAt = Date.now();
changed = true;
entryChanged = true;
if (response.videoUrl) {
segment.remoteVideoUrl = response.videoUrl;
}
if (response.status === 'succeeded') {
segment.filePath = await downloadVideo(sessionId, entry.templateId, segment.part, segment.taskId, response.videoUrl);
console.log(JSON.stringify({
action: 'downloaded',
templateId: entry.templateId,
part: segment.part,
taskId: segment.taskId,
}));
}
}
if (!entry.finalVideoUrl && entry.segments.every(segment => segment.status === 'succeeded' && segment.filePath)) {
entry.finalVideoUrl = await composeTemplate(entry);
await updateFinalTask(entry);
changed = true;
entryChanged = true;
console.log(JSON.stringify({
action: 'composed',
templateId: entry.templateId,
videoUrl: entry.finalVideoUrl,
}));
}
if (entryChanged && !entry.finalVideoUrl) {
await updateSessionProgress(entry);
}
}
tracker.updatedAt = Date.now();
if (changed) await saveTracker(tracker);
if (allDone(tracker) || opts.once) break;
if (Date.now() - started > maxWaitMs) fail(`Timed out waiting for Seedance tasks after ${Math.round(maxWaitMs / 60000)} minutes`);
await sleep(pollMs);
}
}
async function getTask(taskId) {
const res = await fetch(`${apiBase}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
headers: { authorization: `Bearer ${apiKey}` },
});
const rawText = await res.text();
if (!res.ok) throw new Error(`Seedance status ${res.status}: ${rawText}`);
const raw = JSON.parse(rawText);
return {
status: normalizeStatus(raw.status),
videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url || raw.content?.video_url || raw.content?.url,
raw,
};
}
async function downloadVideo(sessionIdValue, templateId, part, taskId, url) {
if (!url) throw new Error(`missing video URL for ${templateId} part ${part}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`download ${res.status} for ${templateId} part ${part}`);
const type = res.headers.get('content-type') || 'video/mp4';
const ext = type.includes('webm') ? 'webm' : 'mp4';
const filename = `${safe(sessionIdValue)}_${safe(templateId)}_part${part}_${safe(taskId)}.${ext}`;
const filePath = path.join(segmentDir, filename);
await streamToFile(res.body, filePath);
return filePath;
}
async function composeTemplate(entry) {
const outputName = `${safe(sessionId)}_${safe(entry.templateId)}_${runLabel}_${TARGET_SECONDS}s.mp4`;
const outputPath = path.join(videosDir, outputName);
const listPath = path.join(segmentDir, `${safe(entry.templateId)}_concat.txt`);
const list = entry.segments
.sort((a, b) => a.part - b.part)
.map(segment => `file '${String(segment.filePath).replace(/'/g, "'\\''")}'`)
.join('\n');
await fs.writeFile(listPath, `${list}\n`);
let result = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath], { encoding: 'utf8' });
if (result.status !== 0) {
result = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart', outputPath], { encoding: 'utf8' });
}
if (result.status !== 0) {
throw new Error(`ffmpeg failed for ${entry.templateId}: ${result.stderr || result.stdout}`);
}
return `/api/video-file/${outputName}`;
}
async function updateFinalTask(entry) {
const session = await readSession();
const index = (session.videoTasks || []).findIndex(task => task.templateId === entry.templateId);
const now = Date.now();
const current = index >= 0
? session.videoTasks[index]
: buildDefaultVideoTask(session, entry.templateId, characterSummary(session), entry.anchorImageUrl);
if (!current) throw new Error(`session video task missing: ${entry.templateId}`);
const nextTask = {
...current,
provider: 'seedance',
model,
status: 'succeeded',
videoUrl: entry.finalVideoUrl,
duration: TARGET_SECONDS,
updatedAt: now,
raw: {
...(typeof current.raw === 'object' && current.raw ? current.raw : {}),
seedanceSegmented: {
runLabel,
targetSeconds: TARGET_SECONDS,
segmentSeconds: SEGMENT_SECONDS,
composedAt: now,
segments: entry.segments.map(segment => ({
part: segment.part,
taskId: segment.taskId,
status: segment.status,
remoteVideoUrl: segment.remoteVideoUrl,
})),
},
},
};
session.videoTasks = session.videoTasks || [];
if (index >= 0) session.videoTasks[index] = nextTask;
else session.videoTasks.push(nextTask);
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
}
async function updateAllProgress(tracker) {
for (const entry of Object.values(tracker.templates)) {
await updateSessionProgress(entry);
}
}
async function updateSessionProgress(entry) {
const session = await readSession();
const index = (session.videoTasks || []).findIndex(task => task.templateId === entry.templateId);
const now = Date.now();
const current = index >= 0
? session.videoTasks[index]
: buildDefaultVideoTask(session, entry.templateId, characterSummary(session), entry.anchorImageUrl);
if (!current) return;
const firstTaskId = entry.segments.find(segment => segment.taskId)?.taskId;
const nextTask = {
...current,
provider: 'seedance',
model,
taskId: firstTaskId,
status: taskStatus(entry),
videoUrl: entry.finalVideoUrl,
duration: TARGET_SECONDS,
updatedAt: now,
raw: {
...(typeof current.raw === 'object' && current.raw ? current.raw : {}),
seedanceSegmented: {
runLabel,
targetSeconds: TARGET_SECONDS,
segmentSeconds: SEGMENT_SECONDS,
updatedAt: now,
segments: entry.segments.map(segment => ({
part: segment.part,
taskId: segment.taskId,
status: segment.status,
remoteVideoUrl: segment.remoteVideoUrl,
})),
},
},
};
session.videoTasks = session.videoTasks || [];
if (index >= 0) session.videoTasks[index] = nextTask;
else session.videoTasks.push(nextTask);
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
}
function taskStatus(entry) {
if (entry.finalVideoUrl) return 'succeeded';
if (entry.segments.some(segment => segment.status === 'failed')) return 'failed';
if (entry.segments.some(segment => segment.status === 'processing')) return 'processing';
if (entry.segments.some(segment => segment.taskId)) return 'submitted';
return 'processing';
}
function segmentPrompt(entry, part) {
const fullScreenFaceTemplate = entry.templateId === 'youni_family_meey_screen_showcase'
|| entry.templateId === 'meey_face_display_dynamic_30s';
const fullScreenFaceConstraints = fullScreenFaceTemplate
? [
'本条视频必须把“脸部是一整块完整显示屏”作为最高优先级,覆盖参考图中的橙色弧形 visor 造型;不要生成窄条 visor、护目镜、眼罩、头盔眉檐压住屏幕、外壳横条遮挡、口罩或任何挡住屏幕的结构。',
'每个正面或近景镜头都要能看见完整矩形或大圆角矩形屏幕边界,屏幕面积占脸部主体,大屏里显示表情包或演唱会画面;屏幕外壳只能作为薄边框。',
'可以保留白色圆润机甲身体、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带和 40cm+ 陪伴机器人比例,但不要保留旧参考图的橙色眯眼 visor 脸。',
]
: entry.templateId === 'nuonuo_pig_motion_feedback_showcase'
? [
...PRODUCT_CONTEXT.constraints,
'本条视频的动作反馈优先级最高:耳朵必须主动动,眼睛必须出现三套以上不同反馈效果,尾巴必须灵巧摆动,四肢必须做小范围互动动作。',
'眼睛反馈至少包含三种清晰状态,例如眨眼、星星眼、爱心光、开心月牙眼、困困眼、确认光圈或彩色情绪光;每一种都要在镜头中可辨认。',
'动作必须保持柔软毛绒玩具的可信度:耳朵、尾巴和四肢可以有内置机芯驱动的小动作,但不能变成硬质机器人、四足宠物或夸张卡通变形。',
]
: PRODUCT_CONTEXT.constraints;
return [
entry.prompt.trim(),
'',
`这是 Seedance 分段生成的第 ${part}/${SEGMENT_COUNT} 段,每段 ${SEGMENT_SECONDS} 秒,最终会拼成 ${TARGET_SECONDS} 秒完整视频。`,
partCue(part, entry.templateId),
`硬性尺寸约束:${PRODUCT_SIZE_TEXT}`,
`必须明显是 ${PRODUCT_SIZE_LABEL} 以上的实体产品:${PRODUCT_CONTEXT.scaleProof}`,
'参考图里的中文和数字只用于理解尺寸比例;成片画面中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
...fullScreenFaceConstraints,
].filter(Boolean).join('\n');
}
function partCue(part, templateId) {
if (templateId === 'youni_family_meey_screen_showcase') {
if (part === 1) return '第 1 段:建立“有你家族”家庭陪伴场景,机器人正面亮屏登场,脸部必须是无遮挡大显示屏,先展示微笑、爱心、欢迎等表情包。';
if (part === 2) return '第 2 段:重点展示完整屏幕脸播放演唱会视频,屏幕里有舞台灯光、歌手剪影、观众光棒或音乐可视化;屏幕边界必须完整可见。';
if (part === SEGMENT_COUNT) return '最后一段:用户语音点播,机器人用完整屏幕切换表情包并继续播放音乐会画面,最后以无遮挡大屏微笑正面收束。';
return `${part} 段:保持完整大屏脸和有你家族陪伴主题,增加表情包、音乐播放或人机互动,不要回到窄条 visor。`;
}
if (templateId === 'meey_face_display_dynamic_30s') {
if (part === 1) return '第 1 段:快节奏亮屏开场,正面完整大屏无遮挡,快速切换开心、惊喜、害羞、认真倾听等互动表情。';
if (part === 2 || part === SEGMENT_COUNT) return '最后一段:用户点播演唱会,完整面部屏幕播放舞台灯光、歌手剪影、观众光棒和音乐可视化,同时穿插智能对话情绪反馈并用笑脸收束。';
return `${part} 段:保持完整大屏脸,密集展示表情包、语音波形、确认反馈和演唱会播放效果,节奏要比 45 秒版本更快。`;
}
if (templateId === 'nuonuo_pig_motion_feedback_showcase') {
if (part === 1) return '第 1 段:建立家庭场景,糯糯猪听到“有你家族”呼唤后耳朵主动抬起和左右轻摆,身体小步靠近,展示 45cm 可拥抱体量。';
if (part === 2) return '第 2 段:集中展示眼睛三套以上反馈效果,例如眨眼、星星眼、爱心光、开心月牙眼或确认光圈,并配合语音互动。';
if (part === SEGMENT_COUNT) return '最后一段:重点展示尾巴灵巧摆动和四肢小动作,前肢打招呼或抱住用户手指,后肢小步调整坐姿,温暖收束。';
return `${part} 段:保持同一只糯糯猪和同一尺寸,继续增加耳朵、眼睛、尾巴、四肢的互动反馈细节。`;
}
if (PRODUCT_CONTEXT.kind === 'nuonuo') {
if (part === 1) return '第 1 段:建立家庭使用场景,糯糯猪从沙发、床边或儿童房角落自主行动到用户身边,展示 45cm 大尺寸毛绒体量。';
if (part === 2) return '第 2 段:重点展示语音互动和触摸反馈,猪鼻子、耳朵、眼睛、尾巴分别被触发并给出灯效、声音或动作回应。';
if (part === SEGMENT_COUNT) return '最后一段:进入亲子陪伴或睡前故事场景,糯糯猪被拥抱后仍有轻微眨眼、耳朵摆动和尾巴反馈,温暖收束。';
return `${part} 段:保持同一只糯糯猪和同一尺寸,增加真实家庭人机互动、语音问答或柔软触感细节。`;
}
if (PRODUCT_CONTEXT.kind === 'zodiac-fantasy') {
if (part === 1) return '第 1 段:建立系列展台和亥猪主角,亥猪屏幕脸点亮,先展示整体体量、粉色幻装、胸前徽章和斜挎肩带;额头猪鼻子可以不出现。';
if (part === 2) return '第 2 段:继续同一只亥猪,展示显示屏脸切换喜怒哀乐表情包、语音互动回应和播放视频画面,其它生肖只做背景。';
if (part === SEGMENT_COUNT) return '最后一段:背景十二生肖阵列亮屏,亥猪回到 C 位,屏幕脸用温暖表情收束,明确 50cm+ 具身 AI 陪伴机器人尺度。';
return `${part} 段:保持亥猪主角身份和 50cm+ 尺度,增加人机互动、局部特写或系列阵列变化,不改变基础设计。`;
}
if (PRODUCT_CONTEXT.kind === 'hai-pig-meey') {
if (part === 1) return '第 1 段:建立亥猪 MEEY 在客厅或展台中的真实比例展示屏幕脸、MEEY 顶部识别条、胸前徽章和斜挎肩带。';
if (part === 2) return '第 2 段:重点拍面部显示器,连续切换喜怒哀乐表情包,并展示屏幕播放视频与用户语音互动。';
if (part === SEGMENT_COUNT) return '最后一段:成人手部或儿童与机器人互动,机器人用屏幕表情、灯效和轻微转头回应,完整产品正面收束。';
return `${part} 段:保持同一台亥猪 MEEY 和 40cm+ 尺寸,增加陪伴交互、局部特写或多媒体播放场景。`;
}
if (SEGMENT_COUNT <= 2) {
return part === 1
? '第 1 段:建立系列世界观和主角登场,亥猪先出现,镜头给足正面、肩带、徽章、情绪屏和 40cm+ 体量。'
: '第 2 段:进入集合高潮和收束,展示装甲变化、局部细节、全体阵列或旋转台,最后回到亥猪主角。';
}
if (part === 1) return '第 1 段:建立镜头,先稳定展示亥猪整体体量、正面轮廓、橙色 visor、胸前徽章和斜挎肩带。';
if (part === 2) return '第 2 段:继续同一只产品,推进到侧面、背面、顶部 MEEY 识别条、侧面圆形模块和短绒/软壳材质细节。';
if (part === SEGMENT_COUNT) return '最后一段:回到完整产品展示收束,补充包装、材料、生产或集合场景,明确 40cm+ 成品尺度。';
return `${part} 段:保持同一角色和同一尺寸,增加使用场景、局部特写、触感互动或系列装甲转换,不改变基础设计。`;
}
function productContextForSession(value) {
if (value.includes('s_mps3u047') || value.includes('nuonuo')) {
return {
kind: 'nuonuo',
sizeLabel: '45cm',
sizeText: '主角始终是“有你家族 · 糯糯猪”约 45cm 大尺寸智能陪伴毛绒玩具,必须明显是可拥抱的家庭陪伴产品体量;可用儿童/成人手部、沙发、床、地毯、抱枕或包装盒衬托比例,不能像掌心小玩偶、钥匙扣、小挂件或桌面迷你摆件。',
scaleProof: '可用儿童/成人手部、沙发、床、地毯、抱枕或包装盒证明约 45cm 可拥抱体量;不能像掌心小玩偶、桌面迷你摆件、挂件或钥匙扣。',
constraints: [
'必须保留浅粉色长绒毛圆胖坐姿小猪形象、黑亮圆眼睛、粉色立体猪鼻、下垂猪耳朵、短卷尾巴、短小四肢、金色挂绳项圈和爱心吊牌。',
'必须体现智能互动能力:自主轻微移动、语音问答、触摸传感反馈、眼部灯效、鼻尖呼吸光、耳朵轻摆、尾巴卷动或抖动。反馈可以温柔夸张,但不能变成硬质机甲机器人。',
'外观可以暗示内置可拆卸智能机芯和安全电池仓,但主体必须是亲肤长绒毛绒玩具。禁止第三方 IP、水印、文字广告、真实品牌标识和错误尺寸标注。',
],
};
}
if (value.includes('zodiac_fantasy')) {
return {
kind: 'zodiac-fantasy',
sizeLabel: '50cm+',
sizeText: '主角始终是“有你家族 · 生肖幻装系列”中的亥猪 50cm+ 具身 AI 智能陪伴机器人,必须明显是家庭空间级真实产品体量;可用成人手部、沙发、展台、包装盒或其他生肖机器人衬托比例,不能像桌面小摆件、掌心玩偶、挂件或盲盒。',
scaleProof: '可用成人双手、包装盒、展台、沙发或其他生肖机器人证明家庭空间级体量;不能像掌心小玩偶、桌面迷你摆件、挂件或盲盒。',
constraints: [
'必须保留亥猪作为系列代表:粉色猪系毛绒幻装、电子显示屏/情绪 visor 脸、M 家族徽章、斜挎能量肩带、圆润机甲基型和 50cm+ 陪伴机器人比例。',
'额头上的猪鼻子在本条互动展示中可以不显示;不要把猪鼻子做成突出的额头装饰。面部重点是显示屏脸、表情包、喜怒哀乐和视频播放。',
'其它生肖幻装机器人只能作为背景阵列、远景陈列或辅助队列,不能抢走亥猪主角。禁止真实动物身体、四足化、攻击性重装、武器、水印和错误文字。',
],
};
}
return {
kind: 'hai-pig-meey',
sizeLabel: '40cm+',
sizeText: '主角始终是“有你家族 · 亥猪 MEEY 机甲陪伴机器人”40cm+ AI 陪伴机甲摆件,高度必须超过 40cm正面宽约 28cm侧面深约 22cm。',
scaleProof: '可用成人双手、包装盒、展台、桌面或家居物件证明比例;不能像掌心小玩偶、桌面迷你摆件、挂件或钥匙扣。',
constraints: [
'必须保留白色圆润头盔、橙色弧形显示屏脸/visor、深灰面部底层、头顶 MEEY 竖条、胸前 M 徽章、斜挎黑色能量肩带、灰橙功能扣、侧面圆形模块、短胖站立比例。',
'面部显示器必须可以切换喜怒哀乐、撒娇、确认、睡眠等表情包,并可以播放视频或动画片段;人机互动要通过屏幕表情、灯效、语音回应和轻微身体动作体现。',
'外部可呈现亲肤短绒、软壳或软硅胶复合触感,但不能改变基础机甲设计。禁止变成真实猪、毛绒动物、四足身体、武器和攻击性重装机甲。',
],
};
}
function normalizeStatus(status) {
if (status === 'succeeded' || status === 'success' || status === 'completed') return 'succeeded';
if (status === 'failed' || status === 'error') return 'failed';
if (status === 'processing' || status === 'running' || status === 'in_progress') return 'processing';
return 'submitted';
}
function publicUrl(url) {
if (/^https?:\/\//i.test(url)) return url;
return new URL(url, publicAppUrl).toString();
}
function findAnchor(session) {
const packs = session.packs || [];
const preferred = [
['marketing', 'mkt_white_front'],
['patent', 'patent_front'],
];
for (const [kind, templateId] of preferred) {
const asset = packs.find(pack => pack.kind === kind)?.assets?.find(item => item.templateId === templateId);
if (asset?.url) return asset.url;
}
const selected = (session.images || []).find(image => image.status === 'selected') || session.images?.[0];
if (selected?.url) return selected.url;
fail('No anchor image found');
}
function findVideoReferenceUrls(session, fallbackUrl) {
if (session.id?.includes('zodiac_fantasy') || sessionId.includes('zodiac_fantasy')) {
return [
findAssetUrl(session, 'zodiac_fantasy_pig_sku_card'),
findAssetUrl(session, 'patent_pig_six_view'),
fallbackUrl,
].filter(Boolean).filter((url, index, urls) => urls.indexOf(url) === index);
}
const preferredTemplateIds = [
'zodiac_fantasy_pig_sku_card',
'zodiac_fantasy_collection_kv',
'zodiac_fantasy_retail_grid',
'zodiac_fantasy_patent_overview',
'zodiac_fantasy_female_lifestyle',
'patent_pig_six_view',
'zodiac_hero_lineup',
'zodiac_armor_grid',
'zodiac_material_palette',
'zodiac_module_variants',
'prod_dimension_overall',
'mkt_white_front',
'mkt_white_back',
];
const urls = [];
for (const templateId of preferredTemplateIds) {
const url = findAssetUrl(session, templateId);
if (url) urls.push(url);
}
if (fallbackUrl) urls.push(fallbackUrl);
return [...new Set(urls)];
}
function findAssetUrl(session, templateId) {
for (const pack of session.packs || []) {
const asset = pack.assets?.find(item => item.templateId === templateId);
if (asset?.url) return asset.url;
}
return undefined;
}
async function readSession() {
return JSON.parse(await fs.readFile(sessionPath, 'utf8'));
}
async function readTracker() {
try {
return JSON.parse(await fs.readFile(trackerPath, 'utf8'));
} catch {
return null;
}
}
async function saveTracker(tracker) {
await fs.writeFile(trackerPath, `${JSON.stringify(tracker, null, 2)}\n`);
}
function allDone(tracker) {
return Object.values(tracker.templates).every(entry => entry.finalVideoUrl);
}
async function streamToFile(body, filePath) {
if (!body) throw new Error('response body missing');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await new Promise((resolve, reject) => {
const stream = createWriteStream(filePath);
Readable.fromWeb(body).pipe(stream);
stream.on('finish', resolve);
stream.on('error', reject);
});
}
function printSummary(tracker) {
const summary = Object.values(tracker.templates).map(entry => ({
templateId: entry.templateId,
finalVideoUrl: entry.finalVideoUrl,
segments: entry.segments.map(segment => ({
part: segment.part,
taskId: segment.taskId,
status: segment.status,
hasFile: Boolean(segment.filePath),
})),
}));
console.log(JSON.stringify(summary, null, 2));
}
function safe(value) {
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '_').slice(0, 120);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function fail(message) {
console.error(message);
process.exit(1);
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { recordEvent } from '@/lib/auditDb';
import { generateSeedanceVideo } from '@/lib/videoProviders';
import { activeVideoProvider, generateVideo } from '@/lib/videoProviders';
import { loadSession, saveRemoteVideo, saveSession } from '@/lib/storage';
import { VIDEO_TEMPLATES } from '@/lib/templates';
import type { VideoGenerationRequest, VideoTask } from '@/lib/types';
@@ -10,9 +10,10 @@ export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const body = (await req.json()) as VideoGenerationRequest;
const provider = activeVideoProvider();
try {
recordEvent({ action: 'video.generate_started', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } });
const response = await generateSeedanceVideo(body);
recordEvent({ action: 'video.generate_started', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'started', provider, metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } });
const response = await generateVideo(body);
let task: VideoTask | undefined;
let videoUrl = response.videoUrl;
@@ -37,7 +38,7 @@ export async function POST(req: Request) {
status: response.status,
videoUrl,
ratio: body.ratio || template?.ratio || '16:9',
duration: body.duration || template?.duration || 6,
duration: body.duration || template?.duration || 60,
submittedAt: now,
updatedAt: now,
raw: response.raw,
@@ -49,13 +50,13 @@ export async function POST(req: Request) {
await saveSession(session);
}
recordEvent({ action: 'video.generate_submitted', sessionId: body.sessionId, targetType: 'video', targetId: response.taskId ?? body.templateId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status, templateId: body.templateId } });
recordEvent({ action: 'video.generate_submitted', sessionId: body.sessionId, targetType: 'video', targetId: response.taskId ?? body.templateId ?? response.status, status: 'queued', provider: response.provider, metadata: { status: response.status, templateId: body.templateId } });
return NextResponse.json({ ...response, videoUrl, task });
} catch (error) {
const message = String(error);
recordEvent({ action: 'video.generate_failed', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'error', provider: 'seedance', message });
recordEvent({ action: 'video.generate_failed', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'error', provider, message });
return NextResponse.json({ error: message }, {
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
status: message.includes('SEEDANCE_API_KEY missing') || message.includes('OPENAI_API_KEY missing') ? 503 : 500,
});
}
}

View File

@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server';
import { recordEvent } from '@/lib/auditDb';
import { getSeedanceVideoTask } from '@/lib/videoProviders';
import { loadSession, saveRemoteVideo, saveSession } from '@/lib/storage';
import { activeVideoProvider, downloadOpenAIVideoContent, extendOpenAIVideo, getVideoTask, openAIVideoSeconds } from '@/lib/videoProviders';
import { loadSession, saveRemoteVideo, saveSession, saveVideoBuffer } from '@/lib/storage';
import type { VideoTask } from '@/lib/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -9,21 +10,43 @@ export const dynamic = 'force-dynamic';
export async function GET(req: Request, ctx: { params: Promise<{ taskId: string }> }) {
const { taskId } = await ctx.params;
const sessionId = new URL(req.url).searchParams.get('sessionId')?.trim();
const session = sessionId ? await loadSession(sessionId) : null;
const existingTask = session?.videoTasks?.find(item => item.taskId === taskId);
const provider = existingTask?.provider ?? activeVideoProvider();
try {
const response = await getSeedanceVideoTask(taskId);
let task = undefined;
let response = await getVideoTask(taskId, provider, existingTask?.duration);
let task: VideoTask | undefined = undefined;
let videoUrl = response.videoUrl;
if (sessionId) {
const session = await loadSession(sessionId);
if (session?.videoTasks?.length) {
if (sessionId && session) {
if (session.videoTasks?.length) {
const index = session.videoTasks.findIndex(item => item.taskId === taskId);
if (index >= 0) {
if (videoUrl) {
const targetDuration = session.videoTasks[index].duration;
const currentSeconds = openAIVideoSeconds(response.raw);
if (response.provider === 'openai_sora' && response.status === 'succeeded' && currentSeconds > 0 && currentSeconds < targetDuration) {
response = await extendOpenAIVideo({
taskId: response.taskId || taskId,
prompt: session.videoTasks[index].prompt,
currentSeconds,
targetSeconds: targetDuration,
});
videoUrl = undefined;
} else if (response.provider === 'openai_sora' && response.status === 'succeeded' && response.taskId) {
if (session.videoTasks[index].videoUrl) {
videoUrl = session.videoTasks[index].videoUrl;
} else {
const content = await downloadOpenAIVideoContent(response.taskId);
videoUrl = await saveVideoBuffer(session.id, response.taskId, content.buffer, content.type);
}
} else if (videoUrl) {
videoUrl = await saveRemoteVideo(session.id, taskId, videoUrl);
}
task = {
...session.videoTasks[index],
taskId: response.taskId ?? session.videoTasks[index].taskId,
status: response.status,
videoUrl: videoUrl ?? session.videoTasks[index].videoUrl,
model: response.model,
@@ -40,13 +63,13 @@ export async function GET(req: Request, ctx: { params: Promise<{ taskId: string
}
}
recordEvent({ action: 'video.status_checked', sessionId, targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } });
recordEvent({ action: 'video.status_checked', sessionId, targetType: 'video', targetId: taskId, status: 'ok', provider: response.provider, metadata: { status: response.status, taskId: response.taskId } });
return NextResponse.json({ ...response, videoUrl, task });
} catch (error) {
const message = String(error);
recordEvent({ action: 'video.status_failed', sessionId, targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message });
recordEvent({ action: 'video.status_failed', sessionId, targetType: 'video', targetId: taskId, status: 'error', provider, message });
return NextResponse.json({ error: message }, {
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
status: message.includes('SEEDANCE_API_KEY missing') || message.includes('OPENAI_API_KEY missing') ? 503 : 500,
});
}
}

View File

@@ -550,7 +550,7 @@ export default function Home() {
const d: VideoGenerationResponse = await r.json();
await reloadCurrent(sessionId);
if ((d.status === 'submitted' || d.status === 'processing') && remaining > 1) {
scheduleVideoRefresh(sessionId, taskId, remaining - 1);
scheduleVideoRefresh(sessionId, d.taskId || taskId, remaining - 1);
}
}
}, 15000);
@@ -634,7 +634,7 @@ export default function Home() {
}),
});
if (!r.ok) {
alert('Seedance 视频提交失败:' + (await r.text()));
alert('视频提交失败:' + (await r.text()));
return;
}
const d: VideoGenerationResponse = await r.json();

View File

@@ -457,6 +457,24 @@ export async function saveRemoteVideo(sessionId: string, taskId: string, url: st
return `/api/video-file/${filename}`;
}
export async function saveVideoBuffer(sessionId: string, taskId: string, buffer: Buffer, type = 'video/mp4'): Promise<string> {
await ensureDirs();
const ext = extFromMime(type);
const safeTaskId = safePart(taskId);
const filename = `${safePart(sessionId)}_${safeTaskId}.${ext === 'bin' ? 'mp4' : ext}`;
const file = path.join(VIDEO_DIR, filename);
await fs.writeFile(file, buffer);
recordEvent({
action: 'video.saved',
sessionId,
targetType: 'video',
targetId: taskId,
status: 'ok',
metadata: { filename, bytes: buffer.length, type },
});
return `/api/video-file/${filename}`;
}
export async function readVideoFile(filename: string): Promise<{ buf: Buffer; type: string } | null> {
try {
const info = await statVideoFile(filename);

View File

@@ -16,42 +16,42 @@ export const VIDEO_TEMPLATES = [
{
id: 'video_turntable',
title: '360 度旋转展示',
description: '用于电商和内部评审,展示整体体积、正背侧轮廓。',
duration: 6,
description: '45 秒,用于电商和内部评审,展示整体体积、正背侧轮廓。',
duration: 45,
ratio: '16:9',
promptTemplate: '生成 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,玩具缓慢旋转,展示正面、侧面、背面、顶部细节,材质、表面质感、五官和配件必须严格贴合角色设定。',
promptTemplate: '生成 45 秒 360 度旋转展示视频:{character}. 白底或浅灰棚拍,镜头稳定,产品缓慢旋转,展示正面、侧面、背面、顶部细节,材质、表面质感、情绪屏和配件必须严格贴合角色设定。产品尺寸按 40cm+ 智能陪伴机器人摆件表现,正面宽约 28cm、侧深约 22cm镜头中要能感知 40cm 以上实体体量。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。',
},
{
id: 'video_unboxing',
title: '开箱短片',
description: '用于新品宣发,展示包装到玩具出现的过程。',
duration: 8,
description: '45 秒,用于新品宣发,展示包装到玩具出现的过程。',
duration: 45,
ratio: '9:16',
promptTemplate: '生成玩具开箱短片:{character}. 竖版社媒风格,从礼盒或包装打开到玩具出现,温暖但克制的棚拍光线,突出礼物感、收藏感、角色识别点和配件陈列。',
promptTemplate: '生成 45 秒玩具开箱短片:{character}. 竖版社媒风格,从礼盒或包装打开到产品出现,温暖但克制的棚拍光线,突出礼物感、收藏感、角色识别点和配件陈列。产品为 40cm+ 智能陪伴机器人摆件,包装和手部比例必须支持 40cm 以上尺寸。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。',
},
{
id: 'video_touch_detail',
title: '触感细节',
description: '展示材质、五官、服装纹理和配件细节。',
duration: 6,
description: '45 秒,展示材质、情绪屏、表面纹理和配件细节。',
duration: 45,
ratio: '9:16',
promptTemplate: '生成玩具细节短片:{character}. 近景镜头,展示角色设定中的核心材质、五官或面部表达、服装纹理、手脚细节、标志性配件和包装小物,节奏清楚,避免加入设定外材质或无关部件。',
promptTemplate: '生成 45 秒玩具细节短片:{character}. 近景镜头,展示角色设定中的核心材质、橙色 visor 情绪屏、表面软壳或短绒触感、手脚细节、标志性配件和包装小物,节奏清楚,避免加入设定外材质或无关部件。必须体现 40cm+ 产品的厚实体量、柔和触感和稳定站立尺度。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。',
},
{
id: 'video_story_intro',
title: '角色故事介绍',
description: '用于 IP 设定和社媒发布。',
duration: 8,
description: '45 秒,用于 IP 设定和社媒发布。',
duration: 45,
ratio: '16:9',
promptTemplate: '生成玩具角色故事介绍视频:{character}. 轻剧情镜头,围绕角色设定的核心使用场景登场,展示情变化、标志性配件、色彩气质和陪伴感,适合新品发布。',
promptTemplate: '生成 45 秒玩具角色故事介绍视频:{character}. 轻剧情镜头,围绕角色设定的核心使用场景登场,展示情绪屏变化、标志性配件、色彩气质和陪伴感,适合新品发布。故事中产品始终是 40cm+ 智能陪伴机器人摆件,可以被双手抱住或稳放在家居空间,不要缩成桌面小摆件。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。',
},
{
id: 'video_factory_preview',
title: '工厂预览短片',
description: '用于打样前内部沟通,展示外观、尺寸、材料、拆件和包装要点。',
duration: 8,
description: '45 秒,用于打样前内部沟通,展示外观、尺寸、材料、拆件和包装要点。',
duration: 45,
ratio: '16:9',
promptTemplate: '生成工厂预览概念短片:{character}. 16:9面向内部沟通展示外观、尺寸、材料、拆件和包装要点镜头清楚克制不做消费者营销话术。',
promptTemplate: '生成 45 秒工厂预览概念短片:{character}. 16:9面向内部沟通展示外观、尺寸、材料、拆件和包装要点镜头清楚克制不做消费者营销话术。尺寸基准写死为成品高度 40cm+,正面宽约 28cm侧面深约 22cm必须保持 40cm 以上实体产品尺度。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。',
},
] as const;
@@ -218,11 +218,11 @@ export const TEXT_TEMPLATES: TextTemplate[] = [
id: 'text_production_bom',
kind: 'production',
title: 'BOM 部件清单',
description: '头、身体、四肢、耳朵、衣服、配件、包装等部件清单。',
description: '头、身体、四肢、顶部冠盖、侧面模块、肩带、扣件和包装等部件清单。',
required: true,
outputFormat: 'table',
filenamePart: 'production-bom',
promptTemplate: '根据角色设定生成毛绒玩具 BOM 部件清单:{character}. 包含部件名称、数量、材料、工艺、尺寸占位、供应/打样注意事项。',
promptTemplate: '根据角色设定生成软壳/毛绒智能玩具 BOM 部件清单:{character}. 包含部件名称、数量、材料、工艺、尺寸占位、供应/打样注意事项;不要默认加入动物耳朵、鼻子或尾巴。',
checklist: textChecklist,
},
{
@@ -322,11 +322,11 @@ export const PATENT_TEMPLATES: AssetTemplate[] = [
kind: 'patent',
view: 'back',
title: '后视图',
description: '展示背部、后脑、尾巴、标签和后侧配件。',
description: '展示背部、后脑、背部装甲/软壳结构、标签和后侧配件。',
required: true,
aspectRatio: '1:1',
filenamePart: 'back',
promptTemplate: '生成外观设计专利后视图:{character}. 背面正交视角,白底,产品居中,比例与主视图一致,无文字无水印,清楚展示背部结构、尾巴、标签位和后侧配件。',
promptTemplate: '生成外观设计专利后视图:{character}. 背面正交视角,白底,产品居中,比例与主视图一致,无文字无水印,清楚展示背部装甲/软壳结构、背部肩带走向、标签位和后侧配件;不要加入尾巴或动物器官。',
checklist: patentChecklist,
},
{
@@ -334,11 +334,11 @@ export const PATENT_TEMPLATES: AssetTemplate[] = [
kind: 'patent',
view: 'left',
title: '左视图',
description: '展示侧面厚度、鼻子凸起、耳朵和四肢外凸关系。',
description: '展示侧面厚度、头盔轮廓、侧面模块和四肢外凸关系。',
required: true,
aspectRatio: '1:1',
filenamePart: 'left',
promptTemplate: '生成外观设计专利左视图:{character}. 左侧正交视角,白底,比例统一,无文字无水印,突出厚度、耳朵角度、鼻子凸起、手脚位置。',
promptTemplate: '生成外观设计专利左视图:{character}. 左侧正交视角,白底,比例统一,无文字无水印,突出头部厚度、头盔轮廓、侧面圆形模块、肩带和手脚位置;不要加入鼻子、耳朵或尾巴。',
checklist: patentChecklist,
},
{
@@ -358,11 +358,11 @@ export const PATENT_TEMPLATES: AssetTemplate[] = [
kind: 'patent',
view: 'top',
title: '俯视图',
description: '展示头顶、耳朵、角、帽子或顶部装饰。',
description: '展示头顶冠盖、品牌识别条和顶部装甲/软壳轮廓。',
required: true,
aspectRatio: '1:1',
filenamePart: 'top',
promptTemplate: '生成外观设计专利俯视图:{character}. 从正上方观察,白底,产品居中,无文字无水印,展示头顶、耳朵、角、发饰、帽子和顶部轮廓。',
promptTemplate: '生成外观设计专利俯视图:{character}. 从正上方观察,白底,产品居中,无文字无水印,展示头顶冠盖、竖向品牌识别条、侧面模块和顶部轮廓;不要加入动物耳朵、角、鼻子或尾巴。',
checklist: patentChecklist,
},
{
@@ -398,7 +398,7 @@ export const PATENT_TEMPLATES: AssetTemplate[] = [
required: false,
aspectRatio: '1:1',
filenamePart: 'perspective-back',
promptTemplate: '生成外观设计专利后 45 度立体图:{character}. 白底,产品居中,无文字无水印,展示背面轮廓、尾巴、后侧配件和整体厚度。',
promptTemplate: '生成外观设计专利后 45 度立体图:{character}. 白底,产品居中,无文字无水印,展示背面轮廓、背部肩带走向、后侧配件和整体厚度;不要加入尾巴或动物器官。',
checklist: patentChecklist,
},
{
@@ -418,11 +418,11 @@ export const PATENT_TEMPLATES: AssetTemplate[] = [
kind: 'patent',
view: 'detail_accessory',
title: '配件局部放大',
description: '放大服装、尾巴、背包或机械件等识别部件。',
description: '放大肩带、扣件、徽章、侧面模块或机械/软壳识别部件。',
required: false,
aspectRatio: '1:1',
filenamePart: 'detail-accessory',
promptTemplate: '生成外观设计专利配件局部放大图:{character}. 白底,无文字无水印,放大最具识别度的配件、服装、尾巴、背包或机械部件。',
promptTemplate: '生成外观设计专利配件局部放大图:{character}. 白底,无文字无水印,放大最具识别度的肩带、扣件、胸前徽章、侧面模块或机械/软壳部件;不要加入尾巴或动物器官。',
checklist: patentChecklist,
},
{
@@ -461,7 +461,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'front-spec',
promptTemplate: '生成毛绒玩具生产正面打样图:{character}. 正面正交视角,白底,带清晰尺寸标注线和中文标注:总高、头宽、身体宽、手长、腿长、关键配件位置。',
promptTemplate: '生成软壳/毛绒智能玩具生产正面打样图:{character}. 正面正交视角,白底,带清晰尺寸标注线和中文标注:总高 40cm+、正面宽约 28cm、头宽、身体宽、手长、腿长、关键配件位置。',
checklist: productionChecklist,
},
{
@@ -469,11 +469,11 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
kind: 'production',
view: 'back_spec',
title: '背面打样图',
description: '标注背部结构、尾巴、标签位和线。',
description: '标注背部结构、背部肩带、标签位和拼接线。',
required: true,
aspectRatio: '1:1',
filenamePart: 'back-spec',
promptTemplate: '生成毛绒玩具生产背面打样图:{character}. 背面正交视角,白底,带中文标注:后脑、背部拼接、尾巴、标签位、线和后侧配件。',
promptTemplate: '生成软壳/毛绒智能玩具生产背面打样图:{character}. 背面正交视角,白底,带中文标注:后脑、背部装甲/软壳拼接、背部肩带、标签位、拼接线和后侧配件;不要加入尾巴。',
checklist: productionChecklist,
},
{
@@ -481,11 +481,11 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
kind: 'production',
view: 'left_spec',
title: '左侧打样图',
description: '标注厚度、鼻子凸起、耳朵角度。',
description: '标注侧面厚度、头盔轮廓、侧面模块和肩带关系。',
required: true,
aspectRatio: '1:1',
filenamePart: 'left-spec',
promptTemplate: '生成毛绒玩具生产左侧打样图:{character}. 左侧正交视角,白底,带中文尺寸标注:头部厚度、身体厚度、鼻子凸起、耳朵角度、四肢侧面位置。',
promptTemplate: '生成软壳/毛绒智能玩具生产左侧打样图:{character}. 左侧正交视角,白底,带中文尺寸标注:头部厚度、身体厚度、侧面模块位置、肩带侧面走向、四肢侧面位置、侧深约 22cm不要加入鼻子、耳朵或尾巴。',
checklist: productionChecklist,
},
{
@@ -497,7 +497,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'right-spec',
promptTemplate: '生成毛绒玩具生产右侧打样图:{character}. 右侧正交视角,白底,带中文尺寸标注,确认侧面厚度、配件位置和非对称细节。',
promptTemplate: '生成软壳/毛绒智能玩具生产右侧打样图:{character}. 右侧正交视角,白底,带中文尺寸标注,确认侧面厚度、侧面模块、配件位置和非对称细节,侧深约 22cm不要加入动物器官。',
checklist: productionChecklist,
},
{
@@ -509,7 +509,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'top-spec',
promptTemplate: '生成毛绒玩具生产俯视打样图:{character}. 从正上方观察,白底,带中文尺寸标注:头顶结构、耳朵/角/帽子位置、顶部配件、头部宽深比例。',
promptTemplate: '生成软壳/毛绒智能玩具生产俯视打样图:{character}. 从正上方观察,白底,带中文尺寸标注:头顶冠盖、竖向品牌识别条、侧面模块、顶部配件、头部宽深比例;不要加入动物耳朵、角或尾巴。',
checklist: productionChecklist,
},
{
@@ -521,7 +521,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'bottom-spec',
promptTemplate: '生成毛绒玩具生产仰视打样图:{character}. 从正下方观察,白底,带中文标注:脚底、防滑、底部标签、底盘结构、坐姿稳定接触面。',
promptTemplate: '生成软壳/毛绒智能玩具生产仰视打样图:{character}. 从正下方观察,白底,带中文标注:脚底、防滑材料、底部标签、底盘结构、站立稳定接触面。',
checklist: productionChecklist,
},
{
@@ -533,7 +533,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'dimension-overall',
promptTemplate: '生成毛绒玩具整体尺寸图:{character}. 白底,技术说明风格,标注总高、坐高、臂展、头身比、头宽、身体宽,单位 cm。',
promptTemplate: '生成软壳/毛绒智能玩具整体尺寸图:{character}. 白底,技术说明风格,标注总高 40cm+、正面宽约 28cm、侧面深约 22cm、臂展、头身比、头宽、身体宽,单位 cm。',
checklist: productionChecklist,
},
{
@@ -541,11 +541,11 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
kind: 'production',
view: 'dimension_parts',
title: '部件尺寸图',
description: '标注耳朵、角、尾巴、包、配件和四肢尺寸。',
description: '标注头顶冠盖、侧面模块、肩带、扣件、徽章和四肢尺寸。',
required: true,
aspectRatio: '4:5',
filenamePart: 'dimension-parts',
promptTemplate: '生成毛绒玩具部件尺寸图:{character}. 技术说明版式,拆出耳朵、角、尾巴、包、衣服/配件、四肢,标注长宽厚和数量,单位 cm小部件可用 mm。',
promptTemplate: '生成软壳/毛绒智能玩具部件尺寸图:{character}. 技术说明版式,拆出头顶冠盖、侧面模块、肩带、扣件、胸前徽章、手臂、腿脚和脚底,标注长宽厚和数量,单位 cm小部件可用 mm;不要加入动物耳朵、角、鼻子或尾巴。',
checklist: productionChecklist,
},
{
@@ -557,7 +557,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: false,
aspectRatio: '4:5',
filenamePart: 'scale-reference',
promptTemplate: '生成玩具比例参考图:{character}. 手持桌面常见物对比,只用于生产沟通,清楚表达 20cm/30cm/45cm 三档尺寸感,不加入营销海报元素。',
promptTemplate: '生成玩具比例参考图:{character}. 手持桌面或 22cm 水瓶对比,只用于生产沟通,清楚表达 40cm+ 实体产品尺寸感,不加入营销海报元素。',
checklist: productionChecklist,
},
{
@@ -569,7 +569,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'material-board',
promptTemplate: '生成毛绒玩具材料工艺板:{character}. 干净版式,包含面料样块、刺绣/印花说明、塑料件/织带/金属件说明、填充软硬度和坐姿稳定要求,中文标注。',
promptTemplate: '生成软壳/毛绒智能玩具材料工艺板:{character}. 干净版式,包含短绒/软硅胶/软壳样块、橙色 visor 透明件或发光屏说明、深灰结构件、织带、扣件说明、填充软硬度和站立稳定要求,中文标注。',
checklist: productionChecklist,
},
{
@@ -581,7 +581,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'color-board',
promptTemplate: '生成毛绒玩具颜色板:{character}. 包含主色、辅色、强调色、五官色、衣服/配件色,给出 HEX 色值和建议 Pantone 占位,中文标注。',
promptTemplate: '生成软壳/毛绒智能玩具颜色板:{character}. 包含白色主体、深灰结构、黑色肩带、橙色 visor/功能扣、红色小点缀、徽章色,给出 HEX 色值和建议 Pantone 占位,中文标注。',
checklist: productionChecklist,
},
{
@@ -593,7 +593,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'embroidery-detail',
promptTemplate: '生成毛绒玩具刺绣/印花细节图:{character}. 放大眼睛、嘴巴、腮红、身体图案和标识,标注刺绣线色、印花边界、针距/线宽占位和工艺注意事项。',
promptTemplate: '生成软壳/毛绒智能玩具标识/印花细节图:{character}. 放大橙色 visor 边界、胸前 M 徽章、头顶 MEEY 品牌条、肩带纹理和小标识,标注印刷/贴片/刺绣线色、边界、线宽占位和工艺注意事项。',
checklist: productionChecklist,
},
{
@@ -601,11 +601,11 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
kind: 'production',
view: 'seam_map',
title: '缝线拼接图',
description: '标注头身、耳朵、四肢和配件连接方式。',
description: '标注头身、侧面模块、四肢和配件连接方式。',
required: true,
aspectRatio: '4:5',
filenamePart: 'seam-map',
promptTemplate: '生成毛绒玩具缝线/拼接线图:{character}. 白底技术图风格,用虚线或彩色线标注头身连接、耳朵连接、四肢连接、件固定点和开口返口位置,中文说明。',
promptTemplate: '生成软壳/毛绒智能玩具拼接线图:{character}. 白底技术图风格,用虚线或彩色线标注头身连接、侧面模块连接、四肢连接、肩带/扣件固定点和开口返口位置,中文说明;不要加入动物耳朵或尾巴。',
checklist: productionChecklist,
},
{
@@ -617,7 +617,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'filling-spec',
promptTemplate: '生成毛绒玩具填充说明图:{character}. 剖面/分区技术说明风格,标注 PP 棉填充区域、软硬度、重心、坐姿稳定要求和不可过硬区域。',
promptTemplate: '生成软壳/毛绒智能玩具填充说明图:{character}. 剖面/分区技术说明风格,标注软壳包覆、短绒覆盖层、内部支撑/填充区域、软硬度、重心、站立稳定要求和不可过硬区域。',
checklist: productionChecklist,
},
{
@@ -625,11 +625,11 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
kind: 'production',
view: 'part_breakdown',
title: '拆件图',
description: '拆分头、身体、四肢、耳朵、服装和配件。',
description: '拆分头盔/软壳、身体、四肢、侧面模块、肩带和扣件。',
required: true,
aspectRatio: '4:5',
filenamePart: 'part-breakdown',
promptTemplate: '生成毛绒玩具拆件图:{character}. 技术图风格,白底,将头、身体、四肢、耳朵、服装、配件分开展示,带中文部件名称和数量标注。',
promptTemplate: '生成软壳/毛绒智能玩具拆件图:{character}. 技术图风格,白底,将头盔/软壳、身体、四肢、侧面模块、肩带、扣件、胸前徽章、脚底分开展示,带中文部件名称和数量标注;不要加入动物耳朵、鼻子或尾巴。',
checklist: productionChecklist,
},
{
@@ -641,7 +641,7 @@ export const PRODUCTION_TEMPLATES: AssetTemplate[] = [
required: false,
aspectRatio: '4:5',
filenamePart: 'accessory-sheet',
promptTemplate: '生成玩具配件单页:{character}. 将帽子、背包、吊牌、可拆物、电子件或装饰件独立展示,标注数量、材料、连接方式和尺寸建议,中文技术版式。',
promptTemplate: '生成玩具配件单页:{character}. 将头顶冠盖、侧面模块、肩带、橙色功能扣、胸前徽章、吊牌、电子件或装饰件独立展示,标注数量、材料、连接方式和尺寸建议,中文技术版式。',
checklist: productionChecklist,
},
{
@@ -680,7 +680,7 @@ export const ACCESSORY_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'inventory-sheet',
promptTemplate: '生成玩具配件总览清单图:{character}. 白底技术版式,识别并摆放所有独立配件、可拆件、服饰、背包、吊牌、包装随附物,标注名称、数量、是否可拆和用途。',
promptTemplate: '生成玩具配件总览清单图:{character}. 白底技术版式,识别并摆放所有独立配件、可拆件、头顶冠盖、侧面模块、肩带、扣件、徽章、吊牌、包装随附物,标注名称、数量、是否可拆和用途。',
checklist: productionChecklist,
},
{
@@ -863,7 +863,7 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'white-45',
promptTemplate: '生成电商白底 45 度商品图:{character}. 前侧 45 度,产品居中,柔和阴影,展示整体体积、毛绒质感和主要配件。',
promptTemplate: '生成电商白底 45 度商品图:{character}. 前侧 45 度,产品居中,柔和阴影,展示整体体积、软壳/短绒触感和主要配件。',
checklist: marketingChecklist,
},
{
@@ -875,7 +875,7 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '1:1',
filenamePart: 'white-back',
promptTemplate: '生成电商白底背面商品图:{character}. 背面,产品居中,柔和阴影,展示背部、尾巴、标签和后侧配件。',
promptTemplate: '生成电商白底背面商品图:{character}. 背面,产品居中,柔和阴影,展示背部结构、背部肩带、标签和后侧配件;不要加入尾巴。',
checklist: marketingChecklist,
},
{
@@ -883,11 +883,11 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
kind: 'marketing',
view: 'detail_face',
title: '脸部细节图',
description: '展示五官、刺绣和表情。',
description: '展示 visor 情绪屏、边框和面部结构。',
required: true,
aspectRatio: '4:5',
filenamePart: 'detail-face',
promptTemplate: '生成玩具脸部细节宣发图:{character}. 近景特写,突出眼睛、嘴巴、腮红、刺绣/印花工艺和治愈表情,可加入简短卖点文案。',
promptTemplate: '生成玩具面部情绪屏细节宣发图:{character}. 近景特写,突出橙色弧形 visor、深灰面部底层、黑色边框、发光纹理和温暖情绪反馈,可加入简短卖点文案。',
checklist: marketingChecklist,
},
{
@@ -895,11 +895,11 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
kind: 'marketing',
view: 'detail_material',
title: '面料触感图',
description: '展示绒、填充和触感卖点。',
description: '展示软壳、短绒、填充和触感卖点。',
required: true,
aspectRatio: '4:5',
filenamePart: 'detail-material',
promptTemplate: '生成玩具面料触感宣发图:{character}. 微距商业摄影,突出毛绒质感、柔软填充、可抱触感,可加入简短中文卖点。',
promptTemplate: '生成玩具面料触感宣发图:{character}. 微距商业摄影,突出短绒覆盖、软壳触感、柔软填充、可抱触感,同时保留机甲设计边界,可加入简短中文卖点。',
checklist: marketingChecklist,
},
{
@@ -911,7 +911,7 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'detail-accessory',
promptTemplate: '生成玩具配件/服装/机关细节宣发图:{character}. 近景商业摄影,突出最具识别度的配件、服装、尾巴、背包或机械件,可加入简短卖点文案。',
promptTemplate: '生成玩具配件/机关细节宣发图:{character}. 近景商业摄影,突出最具识别度的肩带、灰色扣件、橙色功能扣、胸前 M 徽章、侧面模块或机械/软壳件,可加入简短卖点文案;不要加入尾巴或动物器官。',
checklist: marketingChecklist,
},
{
@@ -959,7 +959,7 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
required: true,
aspectRatio: '4:5',
filenamePart: 'size-lifestyle',
promptTemplate: '生成玩具尺寸对比宣发图:{character}. 手持或桌面生活方式场景,体现真实大小和可抱感,可加入尺寸文案占位。',
promptTemplate: '生成玩具尺寸对比宣发图:{character}. 手持、沙发或 22cm 水瓶生活方式场景,体现 40cm+ 真实大小和可抱感,可加入尺寸文案占位。',
checklist: marketingChecklist,
},
{

View File

@@ -299,8 +299,10 @@ export type VideoGenerationRequest = {
watermark?: boolean;
};
export type VideoProvider = 'seedance' | 'openai_sora';
export type VideoGenerationResponse = {
provider: 'seedance';
provider: VideoProvider;
model: string;
taskId?: string;
status: 'submitted' | 'processing' | 'succeeded' | 'failed';
@@ -315,7 +317,7 @@ export type VideoTask = {
description: string;
prompt: string;
anchorImageUrl?: string;
provider: 'seedance';
provider: VideoProvider;
model: string;
taskId?: string;
status: VideoGenerationResponse['status'];

View File

@@ -1,10 +1,58 @@
import type { VideoGenerationRequest, VideoGenerationResponse } from './types';
import type { VideoGenerationRequest, VideoGenerationResponse, VideoProvider } from './types';
import { readImageUrl } from './storage';
export const SEEDANCE_MODEL = process.env.SEEDANCE_MODEL || 'doubao-seedance-2-0-260128';
const SEEDANCE_API_BASE = process.env.SEEDANCE_API_BASE || 'https://ark.cn-beijing.volces.com/api/v3';
export const OPENAI_VIDEO_MODEL = process.env.OPENAI_VIDEO_MODEL || 'sora-2-pro';
const OPENAI_API_BASE = process.env.OPENAI_API_BASE || process.env.GPT_API_BASE || 'https://api.openai.com/v1';
const MIN_OPENAI_TARGET_SECONDS = Math.max(Number(process.env.OPENAI_VIDEO_MIN_SECONDS || 60), 60);
type OpenAIVideoRaw = {
id?: string;
model?: string;
status?: string;
video_url?: string;
seconds?: string | number;
size?: string;
progress?: number;
error?: { code?: string; message?: string };
};
export function activeVideoProvider(): VideoProvider {
return process.env.VIDEO_PROVIDER === 'seedance' ? 'seedance' : 'openai_sora';
}
function durationOrDefault(duration?: number): number {
return Math.min(Math.max(duration ?? 6, 3), 15);
return Math.min(Math.max(duration ?? 15, 3), 15);
}
function openAITargetDuration(duration?: number): number {
return Math.max(duration ?? MIN_OPENAI_TARGET_SECONDS, MIN_OPENAI_TARGET_SECONDS);
}
function openAIInitialSeconds(targetDuration: number): '4' | '8' | '12' {
if (targetDuration >= 12) return '12';
if (targetDuration >= 8) return '8';
return '4';
}
export function openAIVideoSeconds(raw?: unknown): number {
const value = (raw as OpenAIVideoRaw | undefined)?.seconds;
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
export function openAIExtensionSeconds(currentSeconds: number, targetSeconds: number): '4' | '8' | '12' | '16' | '20' {
const remaining = targetSeconds - currentSeconds;
if (remaining >= 20) return '20';
if (remaining >= 16) return '16';
if (remaining >= 12) return '12';
if (remaining >= 8) return '8';
return '4';
}
function normalizeStatus(status?: string): VideoGenerationResponse['status'] {
@@ -14,6 +62,13 @@ function normalizeStatus(status?: string): VideoGenerationResponse['status'] {
return 'submitted';
}
function normalizeOpenAIStatus(status?: string): VideoGenerationResponse['status'] {
if (status === 'completed') return 'succeeded';
if (status === 'failed') return 'failed';
if (status === 'in_progress') return 'processing';
return 'submitted';
}
function publicUrlOrUndefined(url?: string): string | undefined {
if (!url) return undefined;
if (url.startsWith('/')) {
@@ -47,12 +102,66 @@ function buildContent(opts: VideoGenerationRequest): Array<Record<string, unknow
return content;
}
function openAISizeForRatio(ratio?: VideoGenerationRequest['ratio'], resolution?: VideoGenerationRequest['resolution']): '720x1280' | '1280x720' | '1024x1792' | '1792x1024' {
const vertical = ratio === '9:16' || ratio === '3:4';
const high = resolution === '1080p' || process.env.OPENAI_VIDEO_QUALITY === 'high';
if (vertical) return high ? '1024x1792' : '720x1280';
return high ? '1792x1024' : '1280x720';
}
function withProductVideoConstraints(prompt: string, targetDuration: number): string {
return [
prompt.trim(),
'',
'硬性一致性约束:主角必须严格服从当前锁定角色和参考图。当前项目主角是“有你家族 · 亥猪”40cm+ AI 陪伴机甲摆件,不是传统卡通猪。',
'必须保留白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶 MEEY 竖条、胸前 M 徽章、斜挎黑色能量肩带、灰橙功能扣、侧面圆形模块、短胖低重心站立比例。',
'尺寸表现按 40cm+ 实体产品处理:正面宽约 28cm侧面深约 22cm可以用成人双手、包装盒、桌面或展台帮助体现体量不能缩成掌心小玩偶、钥匙扣、迷你挂件或低于 40cm 的小公仔。',
'外部材质可以是亲肤短绒、软壳或软硅胶复合质感,但不能改变基础机甲轮廓、面罩形状、肩带路径、徽章位置和核心配件关系。',
'禁止生成猪鼻子、猪尾巴、写实猪耳、猪蹄、四足动物身体、其它生肖动物本体、武器和攻击性重装机甲。',
'如果参考图有中文或数字,只把它们当作比例依据;成片中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
`目标视频总时长不少于 ${targetDuration} 秒;如果 API 需要分段或延展,保持同一角色、同一尺寸比例和连续镜头语言。`,
].join('\n');
}
async function openAIInputReference(opts: VideoGenerationRequest): Promise<{ image_url: string } | undefined> {
const refs = [...(opts.references ?? [])];
if (opts.imageUrl) refs.unshift({ type: 'image_url', url: opts.imageUrl, role: 'reference_image' });
const image = refs.find(ref => ref.type === 'image_url');
const url = publicUrlOrUndefined(image?.url);
if (url) return { image_url: url };
if (!image?.url) return undefined;
const source = await readImageUrl(image.url);
if (source.type.includes('svg')) return undefined;
const dataUrl = `data:${source.type};base64,${source.buf.toString('base64')}`;
return dataUrl.length <= 20 * 1024 * 1024 ? { image_url: dataUrl } : undefined;
}
function openAIResponse(raw: OpenAIVideoRaw, fallbackTaskId: string | undefined, targetDuration: number): VideoGenerationResponse {
const taskId = raw.id || fallbackTaskId;
return {
provider: 'openai_sora',
model: raw.model || OPENAI_VIDEO_MODEL,
taskId,
status: normalizeOpenAIStatus(raw.status),
videoUrl: raw.video_url,
raw: {
...raw,
target_seconds: targetDuration,
current_seconds: openAIVideoSeconds(raw),
},
};
}
export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promise<VideoGenerationResponse> {
const key = process.env.SEEDANCE_API_KEY;
if (!key) throw new Error('SEEDANCE_API_KEY missing');
if (!opts.prompt?.trim()) throw new Error('prompt required');
const content = buildContent(opts);
const content = buildContent({
...opts,
prompt: withProductVideoConstraints(opts.prompt, Math.max(opts.duration ?? 60, 60)),
});
const body: Record<string, unknown> = {
model: SEEDANCE_MODEL,
content,
@@ -92,6 +201,123 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi
};
}
export async function generateOpenAIVideo(opts: VideoGenerationRequest): Promise<VideoGenerationResponse> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY missing');
if (!opts.prompt?.trim()) throw new Error('prompt required');
const targetDuration = openAITargetDuration(opts.duration);
const body: Record<string, unknown> = {
model: OPENAI_VIDEO_MODEL,
prompt: withProductVideoConstraints(opts.prompt, targetDuration),
seconds: openAIInitialSeconds(targetDuration),
size: openAISizeForRatio(opts.ratio, opts.resolution),
};
const inputReference = await openAIInputReference(opts);
if (inputReference) body.input_reference = inputReference;
let res = await fetch(`${OPENAI_API_BASE}/videos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify(body),
});
let rawText = !res.ok ? await res.text() : '';
if (!res.ok && inputReference && /must match the requested width and height/i.test(rawText)) {
delete body.input_reference;
res = await fetch(`${OPENAI_API_BASE}/videos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify(body),
});
rawText = !res.ok ? await res.text() : '';
}
if (!res.ok) throw new Error(`OpenAI video ${res.status}: ${rawText}`);
const raw = await res.json() as OpenAIVideoRaw;
if (inputReference && !body.input_reference) {
const response = openAIResponse(raw, undefined, targetDuration);
return { ...response, raw: { ...(response.raw as object), input_reference_dropped: 'size_mismatch' } };
}
return openAIResponse(raw, undefined, targetDuration);
}
export async function extendOpenAIVideo(opts: {
taskId: string;
prompt: string;
currentSeconds: number;
targetSeconds: number;
}): Promise<VideoGenerationResponse> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY missing');
if (!opts.taskId) throw new Error('taskId required');
if (!opts.prompt?.trim()) throw new Error('prompt required');
const seconds = openAIExtensionSeconds(opts.currentSeconds, opts.targetSeconds);
const res = await fetch(`${OPENAI_API_BASE}/videos/extensions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({
prompt: withProductVideoConstraints(opts.prompt, opts.targetSeconds),
seconds,
video: { id: opts.taskId },
}),
});
if (!res.ok) throw new Error(`OpenAI video extend ${res.status}: ${await res.text()}`);
const raw = await res.json() as OpenAIVideoRaw;
return openAIResponse(raw, opts.taskId, opts.targetSeconds);
}
export async function getOpenAIVideoTask(taskId: string, targetDuration = MIN_OPENAI_TARGET_SECONDS): Promise<VideoGenerationResponse> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY missing');
if (!taskId) throw new Error('taskId required');
const res = await fetch(`${OPENAI_API_BASE}/videos/${encodeURIComponent(taskId)}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!res.ok) throw new Error(`OpenAI video ${res.status}: ${await res.text()}`);
const raw = await res.json() as OpenAIVideoRaw;
return openAIResponse(raw, taskId, targetDuration);
}
export async function downloadOpenAIVideoContent(taskId: string): Promise<{ buffer: Buffer; type: string }> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY missing');
if (!taskId) throw new Error('taskId required');
const res = await fetch(`${OPENAI_API_BASE}/videos/${encodeURIComponent(taskId)}/content`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!res.ok) throw new Error(`OpenAI video content ${res.status}: ${await res.text()}`);
const type = res.headers.get('content-type')?.split(';')[0] || 'video/mp4';
return { buffer: Buffer.from(await res.arrayBuffer()), type };
}
export async function generateVideo(opts: VideoGenerationRequest): Promise<VideoGenerationResponse> {
return activeVideoProvider() === 'seedance'
? generateSeedanceVideo(opts)
: generateOpenAIVideo(opts);
}
export async function getVideoTask(taskId: string, provider: VideoProvider, targetDuration?: number): Promise<VideoGenerationResponse> {
return provider === 'seedance'
? getSeedanceVideoTask(taskId)
: getOpenAIVideoTask(taskId, targetDuration);
}
export async function getSeedanceVideoTask(taskId: string): Promise<VideoGenerationResponse> {
const key = process.env.SEEDANCE_API_KEY;
if (!key) throw new Error('SEEDANCE_API_KEY missing');