Files
ai-toy-patent-workflow/docs/orchestration.html
kang 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

663 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>