diff --git a/.memory/worklog.json b/.memory/worklog.json
index c161732..c32f11e 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,18 +1,5 @@
{
"entries": [
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 10:20 (~1)",
- "ts": "2026-05-15T02:23:07Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "cca4f3e",
- "message": "auto-save 2026-05-15 10:26 (~1)",
- "ts": "2026-05-15T10:26:25+08:00",
- "type": "commit"
- },
{
"files_changed": 1,
"hash": "d3ee267",
@@ -3273,6 +3260,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 16:37 (~2)",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T16:43:24+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 16:43 (~4)",
+ "hash": "9a4d983",
+ "files_changed": 4
+ },
+ {
+ "ts": "2026-05-17T08:48:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 16:43 (~4)",
+ "files_changed": 1
}
]
}
diff --git a/api/main.py b/api/main.py
index 2c3203a..ed5a485 100644
--- a/api/main.py
+++ b/api/main.py
@@ -4331,7 +4331,7 @@ def analyze_product_views(job_id: str, req: AnalyzeProductViewsReq) -> dict:
if job_id not in JOBS:
raise HTTPException(404, "job not found")
items = []
- for index, ref in enumerate(req.refs[:6]):
+ for index, ref in enumerate(req.refs):
ref_path = storyboard_ref_path(job_id, ref)
if not ref_path or not ref_path.exists():
result = fallback_product_view(index)
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 0a89bc4..c41d9b1 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -589,7 +589,7 @@
web/next.config.mjs | Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 |
web/app/globals.css | 全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。 |
web/app/page.tsx | 产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始”编排状态只负责在下载完成后自动触发 triggerTranscribe,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。 |
- web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图,上传后自动识别正面/左右 45 度/厚度/内侧触点/背底等视角并自动补齐缺失角度,用户只检查视角备注,鼠标悬停可放大预览;补图失败时保留单个缺失视角的重试入口。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会先把该行规划、已上传/补全的产品图和视角备注保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
+ web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是不限量产品素材池,可持续上传产品白底图;上传后自动识别正面/左右 45 度/厚度/内侧触点/背底等视角并自动补齐缺失角度,用户只检查视角备注,鼠标悬停可放大预览;补图失败时保留单个缺失视角的重试入口。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会从产品素材池自动挑选最多 6 张相关产品图和备注保存为对应关键帧分镜,不会把全部产品图提交给生视频模型,然后复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
web/app/login/page.tsx | 生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 |
web/app/login/layout.tsx | 登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 |
web/components/login/oasis-canvas.tsx | 登录页全屏动态视觉层:用 iframe 直接承载下载包 web/public/oasis-source/index.html 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 postMessage 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。 |
@@ -627,7 +627,7 @@ web/app/page.tsx
-> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
- -> 信息流复刻分镜工作台:产品白底图上传 → 自动识别视角 → 自动补齐缺失角度 → 人工检查备注(建议 5、最多 6)→ 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
+ -> 信息流复刻分镜工作台:产品白底图素材池不限量上传 → 自动识别视角 → 自动补齐缺失角度 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
-> API 契约:web/lib/api.ts
@@ -654,8 +654,8 @@ api/main.py
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanel、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、buildStoryboardSceneFromAudioRow、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、产品图识别/补图后的备注是否准确、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanel、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、selectProductItemsForRow、buildStoryboardSceneFromAudioRow、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、产品素材池识别/补图后的备注是否准确、单条生成该选哪几张产品图、生成的视频应该回显到哪一行”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -839,7 +839,7 @@ SubjectAsset {
| 首尾帧资产 | 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/product-library | copyProductLibraryAsset | 把一个内置产品图库条目复制为当前 job 的普通 asset,返回 ImageRef(kind="asset"),用于画面工作台产品融合和分镜产品参考组。 |
-
| 产品视角识别 | POST /jobs/{id}/assets/product-views/analyze | analyzeProductViews | 读取已上传的产品白底图,自动分类为正面、左右 45 度、侧面厚度、内侧触点或背面/底部,并返回中文视角备注和置信度;前端不再要求用户手动选择视角。 |
+
| 产品视角识别 | POST /jobs/{id}/assets/product-views/analyze | analyzeProductViews | 读取已上传的产品白底图素材池,不限制只看前 6 张;自动分类为正面、左右 45 度、侧面厚度、内侧触点或背面/底部,并返回中文视角备注和置信度;前端不再要求用户手动选择视角。 |
| 产品缺角度补图 | POST /jobs/{id}/assets/product-angle | generateProductAngleAsset | 用当前产品白底图作为参考,通过图像模型自动补全缺失视角,输出新的 ImageRef(kind="asset")。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例;前端只在自动补图失败时暴露重试入口。 |
| 角色库 | GET /character-library/skg | listCharacterLibrary | 读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。 |
| 角色图入库到 job | POST /jobs/{id}/assets/character-library | copyCharacterLibraryAssets | 把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。 |
@@ -950,6 +950,19 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-17 · 产品素材池取消数量上限,单条生成自动选图
+ UI
+ Workflow
+ API
+
+
+
问题:产品白底图不能固定最多 6 张。后续分镜很多,产品参考图应作为可持续扩充的素材池;但单条视频生成时也不能把素材池里的全部图片都提交给 AI。
+
改动:AudioStoryboardPlanPanel 移除上传数量上限,产品参考区改为不限量素材池,并用滚动网格承载更多图片。analyzeProductViews 和后端 POST /jobs/{id}/assets/product-views/analyze 不再只处理前 6 张;自动补缺失视角也不再因为已有 6 张素材而停止。新增 selectProductItemsForRow,单条分镜生成时按分镜角色、视角优先级和行号轮转,从素材池中自动挑选最多 6 张相关产品图写入 StoryboardScene.product_images。
+
影响:api/main.py、web/components/ad-recreation-board.tsx、docs/source-analysis.html。后续需求应区分“产品素材池可无限上传”和“单条生成实际提交的产品参考图集合”。
+
+
2026-05-17 · 产品图上传后自动识别视角并补齐缺角度
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index 06b1bc4..0ed5d44 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -103,6 +103,8 @@ const PRODUCT_VIEW_SLOTS = [
{ value: "back_bottom", label: "背面/底部", hint: "底面、背部闭合结构、补缺" },
] as const
+const MAX_PRODUCT_REFS_PER_VIDEO = 6
+
const controlClass =
"h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40"
@@ -355,12 +357,44 @@ function productReferenceNotes(items: ProductRefItem[]) {
.join(";")
}
+function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem[]) {
+ if (!items.length) return []
+ const priorityByRole: Record = {
+ "开场钩子": ["front", "left_45", "right_45", "side_thickness"],
+ "痛点推进": ["front", "side_thickness", "left_45", "right_45"],
+ "利益证明": ["front", "inner_contacts", "side_thickness", "left_45", "right_45", "back_bottom"],
+ "方案过渡": ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
+ "转化收口": ["front", "left_45", "right_45", "back_bottom", "inner_contacts"],
+ "节奏承接": ["front", "left_45", "right_45", "side_thickness"],
+ }
+ const priority = priorityByRole[row.role] ?? priorityByRole["节奏承接"]
+ const picked: ProductRefItem[] = []
+ const pickedIds = new Set()
+ const add = (item?: ProductRefItem) => {
+ if (!item || pickedIds.has(item.id) || picked.length >= MAX_PRODUCT_REFS_PER_VIDEO) return
+ picked.push(item)
+ pickedIds.add(item.id)
+ }
+
+ for (const view of priority) {
+ const matches = items.filter((item) => item.view === view)
+ add(matches[row.index % Math.max(matches.length, 1)])
+ }
+
+ for (let i = 0; picked.length < Math.min(MAX_PRODUCT_REFS_PER_VIDEO, items.length) && i < items.length; i += 1) {
+ add(items[(row.index + i) % items.length])
+ }
+
+ return picked
+}
+
function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene {
- const productRefs = productItems.map((item) => item.ref)
- const notes = productReferenceNotes(productItems)
+ const selectedProductItems = selectProductItemsForRow(row, productItems)
+ const productRefs = selectedProductItems.map((item) => item.ref)
+ const notes = productReferenceNotes(selectedProductItems)
const productGuidance = productItems.length
- ? `产品白底图已上传:生成时必须同时参考各视角备注。视角备注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
- : "未上传产品白底图时使用默认 SKG 产品图;生成前建议补 5 张白底图锁定左右差异、厚度和佩戴比例。"
+ ? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。视角备注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
+ : "未上传产品白底图时使用默认 SKG 产品图;生成前建议先建立产品素材池,锁定左右差异、厚度和佩戴比例。"
return {
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
@@ -1034,11 +1068,10 @@ function AudioStoryboardPlanPanel({
const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
if (!job || !seedItems.length) return seedItems
- let working = seedItems.slice(0, 6)
+ let working = seedItems
const failures: string[] = []
const missing = PRODUCT_VIEW_SLOTS
.filter((slot) => !working.some((item) => item.view === slot.value))
- .slice(0, Math.max(0, 6 - working.length))
for (const slot of missing) {
setProductAngleBusy(slot.value)
@@ -1051,7 +1084,7 @@ function AudioStoryboardPlanPanel({
working = [
...working,
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1),
- ].slice(0, 6)
+ ]
setProductItems(working)
} catch (e) {
failures.push(`${slot.label}:${e instanceof Error ? e.message : String(e)}`)
@@ -1067,17 +1100,16 @@ function AudioStoryboardPlanPanel({
const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
if (!job || !refs.length) return
- const limitedRefs = refs.slice(0, 6)
setProductAnalyzing(true)
- setProductItems(limitedRefs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角...")))
+ setProductItems(refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角...")))
try {
- const analysis = await analyzeProductViews(job.id, limitedRefs)
- const analyzed = buildAnalyzedProductItems(limitedRefs, analysis.items)
+ const analysis = await analyzeProductViews(job.id, refs)
+ const analyzed = buildAnalyzedProductItems(refs, analysis.items)
setProductItems(analyzed)
const completed = await completeMissingProductAngles(analyzed)
toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
} catch (e) {
- const fallback = limitedRefs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
+ const fallback = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
setProductItems(fallback)
await completeMissingProductAngles(fallback)
toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
@@ -1089,16 +1121,11 @@ function AudioStoryboardPlanPanel({
const uploadProductImages = async (files: FileList | null) => {
if (!job || !files?.length) return
- const remaining = Math.max(0, 6 - productItems.length)
- if (remaining === 0) {
- toast.info("产品白底图最多保留 6 张")
- return
- }
- const selected = Array.from(files).slice(0, remaining)
+ const selected = Array.from(files)
setProductUploading(true)
try {
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
- const nextRefs = [...productItems.map((item) => item.ref), ...refs].slice(0, 6)
+ const nextRefs = [...productItems.map((item) => item.ref), ...refs]
await analyzeAndCompleteProductViews(nextRefs)
toast.success(`已上传 ${refs.length} 张产品白底图`)
} catch (e) {
@@ -1131,7 +1158,7 @@ function AudioStoryboardPlanPanel({
target_view: slot.label,
note: slot.hint,
})
- setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)].slice(0, 6))
+ setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)])
toast.success(`AI 已补全产品视角:${slot.label}`)
} catch (e) {
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
@@ -1178,7 +1205,7 @@ function AudioStoryboardPlanPanel({
} title="产品白底图 / 视角补全" />
- 建议 5 张,最多 6 张
+ {productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}
{(productAnalyzing || productAngleBusy) && (
@@ -1187,7 +1214,7 @@ function AudioStoryboardPlanPanel({
)}
- 上传后自动识别每张图的角度和视角,并自动补齐缺失角度;人只需要检查备注。重点锁住左右非对称、厚度、内侧触点和肩颈真实佩戴比例。
+ 上传后自动识别每张图的角度和视角,并自动补齐缺失角度;产品素材池不限制数量。每条视频生成时只自动挑选最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图,避免把所有素材都塞给模型。
@@ -1203,7 +1230,7 @@ function AudioStoryboardPlanPanel({
-
+
{productItems.map((item) => (
generateMissingProductAngle(slot)}