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>
663 lines
37 KiB
HTML
663 lines
37 KiB
HTML
<!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 │
|
||
│ gate:characterSpec 必须存在 │
|
||
└─────────────────────────────────────┘
|
||
┌────────── 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 × N(4/8/12),ref 图作为文本提示拼接</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>差异 1:RULES.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>差异 5:preFilledSlot 命中后 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=&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 + manifest,pdf 未生成</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>multipart,role 必传</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>三道 gate;background=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=&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>
|