diff --git a/.memory/worklog.json b/.memory/worklog.json
index 8284456..0e2e1ad 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,19 +1,5 @@
{
"entries": [
- {
- "files_changed": 1,
- "hash": "4df2601",
- "message": "auto-save 2026-05-15 13:05 (~1)",
- "ts": "2026-05-15T13:05:21+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "1e84642",
- "message": "auto-save 2026-05-15 13:11 (~1)",
- "ts": "2026-05-15T13:11:13+08:00",
- "type": "commit"
- },
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 13:11 (~1)",
@@ -3261,6 +3247,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 21:14 (~3)",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T21:36:46+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 21:36 (~4)",
+ "hash": "97a1f66",
+ "files_changed": 4
+ },
+ {
+ "ts": "2026-05-17T13:38:30Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:auto-save 2026-05-17 21:36 (~4)",
+ "files_changed": 3
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 0477207..e844d39 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -682,7 +682,8 @@ api/main.py
frames: KeyFrame[],
transcript: TranscriptSegment[],
audio_script: AudioScript,
- storyboard_images?: StoryboardImage[]
+ storyboard_images?: StoryboardImage[],
+ product_refs?: ProductRefStateItem[]
}
@@ -795,7 +796,7 @@ SubjectAsset {
ProductViewAnalysisItem
-
产品素材池识别结果。它不判断不同产品身份,只服务同一款挂脖肩颈按摩仪的生视频选图和方向约束;左/右按佩戴者身体左右,不按图片左右。
+
产品素材池识别结果。它不判断不同产品身份,只服务同一款挂脖肩颈按摩仪的生视频选图和方向约束;左/右按佩戴者身体左右,不按图片左右。前端会把上传图、识别标注、AI 补图、备注和删除结果写入 Job.product_refs,后端保存到 state.json,避免刷新、热更新或服务重启后丢失产品素材池。
ProductViewAnalysisItem {
index,
view: front | left_45 | right_45 | side_thickness | inner_contacts | back_bottom,
@@ -811,6 +812,21 @@ SubjectAsset {
note,
risk,
confidence
+}
+
+ProductRefStateItem {
+ id,
+ ref: ImageRef,
+ view,
+ background,
+ useTags[],
+ orientation,
+ landmarks[],
+ note,
+ risk,
+ source: upload | ai,
+ assetMeta,
+ confidence
}
@@ -873,7 +889,8 @@ SubjectAsset {
| 主体资产包 | POST /elements/{element_id}/subject-assets | generateSubjectAssets | 根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。新增 subject_style=source_actor 与 reconstruction_mode=similar 用于信息流相似主角:最多读取 12 张已选关键帧,生成 6 张白底新演员视图,保留角色气质、动作词汇、机位和服装类别,但不复刻源人物身份或像素。旧透明骨架人流程仍默认走 subject_style=transparent_human。 |
| 首尾帧资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。 |
| 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
-
| 产品图入库到 job | POST /jobs/{id}/assets、POST /jobs/{id}/assets/product-library | uploadStoryboardAsset、copyProductLibraryAsset | 上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。 |
+
| 产品图入库到 job | POST /jobs/{id}/assets、POST /jobs/{id}/assets/product-library | uploadStoryboardAsset、copyProductLibraryAsset | 上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 |
+
| 产品素材池保存 | PUT /jobs/{id}/product-refs | saveProductRefs | 把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 Job.product_refs / state.json。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。 |
| 产品视角识别 | POST /jobs/{id}/assets/product-views/analyze | analyzeProductViews | 读取同一产品素材池,按批次把多张图一次性提交给视觉模型,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 view、background、use_tags、orientation、landmarks、中文备注、生成风险和置信度;orientation 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。前端不再要求用户手动选择视角,也不做不同产品身份判断。 |
| 产品缺角度补图 | POST /jobs/{id}/assets/product-angle | generateProductAngleAsset | 用当前产品白底图作为参考,通过图像模型自动补全缺失视角,输出新的 ImageRef(kind="asset")。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例;前端只在自动补图失败时暴露重试入口。 |
| 角色库 | GET /character-library/skg | listCharacterLibrary | 读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。 |
@@ -985,6 +1002,19 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-17 · 产品素材池持久化到 job
+ API
+ UI
+ Workflow
+
+
+
问题:产品图文件虽然已写入 jobs/<jobId>/assets,但产品素材池列表、识别结果、备注和 AI 补图只存在前端 React state;代码热更新、刷新或切换任务后会清空,用户需要重新上传和重新计算。
+
改动:Job 新增 product_refs;后端新增 PUT /jobs/{id}/product-refs,前端新增 saveProductRefs。AudioStoryboardPlanPanel 在上传、识别、补图、备注编辑和删除时同步保存产品素材池,组件重新挂载时从 job.product_refs 恢复。
+
影响:api/main.py、web/lib/api.ts、web/components/ad-recreation-board.tsx、docs/source-analysis.html。后续更新 UI 或重启本地服务不应再清掉用户已上传的产品图和标注。
+
+
2026-05-17 · 参考帧改为原视频旁全局关键帧与相似主角
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index ac750df..9ee8ab8 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -1504,7 +1504,7 @@ function AudioStoryboardPlanPanel({
...working,
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1),
]
- setProductItems(working)
+ setAndPersistProductItems(working)
} catch (e) {
failures.push(`${slot.label}:${e instanceof Error ? e.message : String(e)}`)
}
@@ -1520,16 +1520,17 @@ function AudioStoryboardPlanPanel({
const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
if (!job || !refs.length) return
setProductAnalyzing(true)
- setProductItems(refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角...")))
+ const pending = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角..."))
+ setAndPersistProductItems(pending)
try {
const analysis = await analyzeProductViews(job.id, refs)
const analyzed = buildAnalyzedProductItems(refs, analysis.items)
- setProductItems(analyzed)
+ setAndPersistProductItems(analyzed)
const completed = await completeMissingProductAngles(analyzed)
toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
} catch (e) {
const fallback = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
- setProductItems(fallback)
+ setAndPersistProductItems(fallback)
await completeMissingProductAngles(fallback)
toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -1543,7 +1544,7 @@ function AudioStoryboardPlanPanel({
const baseItems = productItems
const startIndex = baseItems.length
setProductAnalyzing(true)
- setProductItems([
+ setAndPersistProductItems([
...baseItems,
...refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload", undefined, "正在自动识别视角...")),
])
@@ -1551,13 +1552,13 @@ function AudioStoryboardPlanPanel({
const analysis = await analyzeProductViews(job.id, refs)
const newItems = buildAnalyzedProductItems(refs, analysis.items, startIndex)
const combined = [...baseItems, ...newItems]
- setProductItems(combined)
+ setAndPersistProductItems(combined)
const completed = await completeMissingProductAngles(combined)
toast.success(completed.length > combined.length ? "新产品图已识别,并已补齐缺失角度" : "新产品图已自动识别")
} catch (e) {
const fallback = refs.map((ref, index) => createProductRefItem(ref, startIndex + index, "upload"))
const combined = [...baseItems, ...fallback]
- setProductItems(combined)
+ setAndPersistProductItems(combined)
await completeMissingProductAngles(combined)
toast.warning("新产品图识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -1582,11 +1583,19 @@ function AudioStoryboardPlanPanel({
}
const patchProductItem = (id: string, patch: Partial) => {
- setProductItems((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item))
+ setProductItems((prev) => {
+ const next = prev.map((item) => item.id === id ? { ...item, ...patch } : item)
+ void persistProductItems(next)
+ return next
+ })
}
const removeProductItem = (id: string) => {
- setProductItems((prev) => prev.filter((item) => item.id !== id))
+ setProductItems((prev) => {
+ const next = prev.filter((item) => item.id !== id)
+ void persistProductItems(next)
+ return next
+ })
}
const reanalyzeProductViews = async () => {
@@ -1651,7 +1660,11 @@ function AudioStoryboardPlanPanel({
target_view: slot.label,
note: slot.hint,
})
- setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1)])
+ setProductItems((prev) => {
+ const next = [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1)]
+ void persistProductItems(next)
+ return next
+ })
toast.success(`AI 已补全产品视角:${slot.label}`)
} catch (e) {
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))