diff --git a/.memory/worklog.json b/.memory/worklog.json
index 4b59256..d40f5c2 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,19 +1,5 @@
{
"entries": [
- {
- "files_changed": 1,
- "hash": "b610cd8",
- "message": "auto-save 2026-05-15 10:01 (~1)",
- "ts": "2026-05-15T10:02:07+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "5211fb5",
- "message": "auto-save 2026-05-15 10:09 (~1)",
- "ts": "2026-05-15T10:09:43+08:00",
- "type": "commit"
- },
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 10:09 (~1)",
@@ -3274,6 +3260,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add product refs and video candidate slots",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T16:27:18+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 16:27 (~4)",
+ "hash": "3d851d8",
+ "files_changed": 4
+ },
+ {
+ "ts": "2026-05-17T08:28:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-17 16:27 (~4)",
+ "files_changed": 2
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index bac6ddc..98b0b07 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -589,7 +589,7 @@
web/next.config.mjsNext.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 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部可上传产品白底图,建议 5 张、最多 6 张,用来锁定正面、左右 45 度、厚度、内侧触点/佩戴比例,避免非对称产品被生成成左右镜像;每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会先把该行规划和已上传产品图保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。
+ web/components/ad-recreation-board.tsx信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图、识别/标注视角、填写视角备注、鼠标悬停放大预览,并对缺失的正面/左右 45 度/厚度/内侧触点/背底视角提供 AI 补角度入口;每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 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 个候选视频槽
+ -> 信息流复刻分镜工作台:产品白底图上传 / 视角备注 / AI 补角度(建议 5、最多 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、buildAudioStoryboardRows、buildStoryboardSceneFromAudioRow、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
-
适合怎么描述 “按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、如何抽参考帧、生成的视频应该回显到哪一行”。
+
主要源码 AudioStoryboardPlanPanel、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、buildStoryboardSceneFromAudioRow、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,AI 补角度复用 generateProductAngleAsset,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
+
适合怎么描述 “按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、每张产品图的视角备注是什么、缺哪个角度、生成的视频应该回显到哪一行”。
你看到的区域 旧深度素材面板(当前不作为主路径)
@@ -839,6 +839,7 @@ SubjectAsset {
首尾帧资产 POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。
产品图库 GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。
产品图入库到 job POST /jobs/{id}/assets/product-librarycopyProductLibraryAsset把一个内置产品图库条目复制为当前 job 的普通 asset,返回 ImageRef(kind="asset"),用于画面工作台产品融合和分镜产品参考组。
+
产品缺角度补图 POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前产品白底图作为参考,通过图像模型补全缺失视角,输出新的 ImageRef(kind="asset")。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例。
角色库 GET /character-library/skglistCharacterLibrary读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。
角色图入库到 job POST /jobs/{id}/assets/character-librarycopyCharacterLibraryAssets把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。
产品融合引导图 POST /jobs/{id}/product-fusion/guidecreateProductFusionGuide旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。
@@ -948,6 +949,19 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-17 · 产品白底图加入视角备注和 AI 补角度
+ UI
+ Workflow
+ API
+
+
+
问题: 只上传产品白底图还不够,生成视频前需要明确每张图是什么视角、有哪些结构重点;如果某些视角没拍到,也需要通过 AI 先补出可用参考图。
+
改动: AudioStoryboardPlanPanel 的产品参考区升级为产品视角工作台:每张图都有视角下拉和备注输入,鼠标悬停显示放大预览;缺失视角显示独立占位,并提供“AI 补角度”。新增 POST /jobs/{id}/assets/product-angle 和前端 generateProductAngleAsset,基于已有产品图生成缺失的白底产品视角。单条视频生成会把每张产品图的视角备注写入 StoryboardScene.product,用于约束左右非对称、厚度、内侧触点和肩颈佩戴比例。
+
影响: api/main.py、web/lib/api.ts、web/components/ad-recreation-board.tsx、docs/source-analysis.html。AI 补角度生成的新图保存在 jobs/<jobId>/assets,作为普通 ImageRef(kind="asset") 参与后续分镜视频生成。
+
+
2026-05-17 · 压缩分镜行并加入产品白底图与多候选视频槽
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index 32e8ee3..2d2c122 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -965,14 +965,15 @@ function AudioStoryboardPlanPanel({
}) {
const [busyRow, setBusyRow] = useState(null)
const [videoBusyRow, setVideoBusyRow] = useState(null)
- const [productRefs, setProductRefs] = useState([])
+ const [productItems, setProductItems] = useState([])
const [productUploading, setProductUploading] = useState(false)
+ const [productAngleBusy, setProductAngleBusy] = useState(null)
const productFileRef = useRef(null)
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
useEffect(() => {
- setProductRefs([])
+ setProductItems([])
}, [job?.id])
const framesForRow = (row: AudioStoryboardRow) =>
@@ -996,7 +997,7 @@ function AudioStoryboardPlanPanel({
const uploadProductImages = async (files: FileList | null) => {
if (!job || !files?.length) return
- const remaining = Math.max(0, 6 - productRefs.length)
+ const remaining = Math.max(0, 6 - productItems.length)
if (remaining === 0) {
toast.info("产品白底图最多保留 6 张")
return
@@ -1005,7 +1006,10 @@ function AudioStoryboardPlanPanel({
setProductUploading(true)
try {
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
- setProductRefs((prev) => [...prev, ...refs].slice(0, 6))
+ setProductItems((prev) => [
+ ...prev,
+ ...refs.map((ref, index) => createProductRefItem(ref, prev.length + index)),
+ ].slice(0, 6))
toast.success(`已上传 ${refs.length} 张产品白底图`)
} catch (e) {
toast.error("产品白底图上传失败:" + (e instanceof Error ? e.message : String(e)))
@@ -1014,11 +1018,46 @@ function AudioStoryboardPlanPanel({
}
}
+ const patchProductItem = (id: string, patch: Partial) => {
+ setProductItems((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item))
+ }
+
+ const removeProductItem = (id: string) => {
+ setProductItems((prev) => prev.filter((item) => item.id !== id))
+ }
+
+ const autoMarkProductViews = () => {
+ setProductItems((prev) => prev.map((item, index) => {
+ const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
+ return { ...item, view: slot.value, note: item.note || slot.hint }
+ }))
+ toast.success("已按产品参考顺序标注视角,可继续手动修正备注")
+ }
+
+ const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
+ if (!job || !productItems.length) return
+ const source = productItems[0]
+ setProductAngleBusy(slot.value)
+ try {
+ const ref = await generateProductAngleAsset(job.id, {
+ source_ref: source.ref,
+ target_view: slot.label,
+ note: slot.hint,
+ })
+ setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value)].slice(0, 6))
+ toast.success(`AI 已补全产品视角:${slot.label}`)
+ } catch (e) {
+ toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
+ } finally {
+ setProductAngleBusy(null)
+ }
+ }
+
const generateRowVideo = async (row: AudioStoryboardRow, refs: KeyFrame[]) => {
if (!job || !refs.length || !onGenerateVideo) return
const frame = refs[0]
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
- const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame, productRefs)
+ const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame, productItems)
setVideoBusyRow(row.index)
try {
const updated = await updateStoryboard(job.id, frame.index, scene)
@@ -1047,52 +1086,69 @@ function AudioStoryboardPlanPanel({
-
-
-
-
} title="产品白底图" />
-
建议 5 张,最多 6 张
+
+
+
+
+ } title="产品白底图 / 视角补全" />
+ 建议 5 张,最多 6 张
+
+
+ 每张图都要标注视角和备注。推荐:正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例;缺口可用 AI 补角度,避免非对称产品被生成成左右镜像。
+
+
+
+
+
+ 识别视角
+
+ productFileRef.current?.click()}
+ disabled={!job || productUploading || productItems.length >= 6}
+ className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {productUploading ? : }
+ 上传白底图
+
+ {
+ void uploadProductImages(event.currentTarget.files)
+ event.currentTarget.value = ""
+ }}
+ />
-
- 正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例;非对称明显时补背面或底部,避免生成时左右两边被做成一样。
-
-
-
- {productRefs.map((ref, index) => (
-
- ))}
- {Array.from({ length: Math.max(0, Math.min(6, 5 - productRefs.length)) }).map((_, index) => (
-
- {productRefs.length + index + 1}
-
- ))}
-
-
productFileRef.current?.click()}
- disabled={!job || productUploading || productRefs.length >= 6}
- className="inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
- >
- {productUploading ? : }
- 上传白底图
-
-
{
- void uploadProductImages(event.currentTarget.files)
- event.currentTarget.value = ""
- }}
- />
+
+
+ {productItems.map((item) => (
+
patchProductItem(item.id, patch)}
+ onRemove={() => removeProductItem(item.id)}
+ />
+ ))}
+ {PRODUCT_VIEW_SLOTS.filter((slot) => !productItems.some((item) => item.view === slot.value)).map((slot) => (
+ generateMissingProductAngle(slot)}
+ />
+ ))}
@@ -1187,6 +1243,89 @@ function AudioStoryboardPlanPanel({
)
}
+function ProductReferenceCard({
+ job,
+ item,
+ onPatch,
+ onRemove,
+}: {
+ job: Job
+ item: ProductRefItem
+ onPatch: (patch: Partial
) => void
+ onRemove: () => void
+}) {
+ const src = resolveImageRefUrl(job.id, item.ref)
+ return (
+
+
+
+
+
+
{productViewLabel(item.view)} · {item.note}
+
+
{item.source === "ai" ? "AI" : "图"}
+
+
+
onPatch({ view: event.target.value })}
+ className="h-7 w-full rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white outline-none"
+ >
+ {PRODUCT_VIEW_SLOTS.map((slot) => (
+ {slot.label}
+ ))}
+
+
onPatch({ note: event.target.value })}
+ placeholder="视角备注:结构差异、触点、尺寸比例"
+ className="mt-1 h-8 w-full rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
+ />
+
{item.ref.label || "产品参考图"}
+
+
+
+
+
+ )
+}
+
+function MissingProductViewSlot({
+ slot,
+ canGenerate,
+ busy,
+ onGenerate,
+}: {
+ slot: typeof PRODUCT_VIEW_SLOTS[number]
+ canGenerate: boolean
+ busy: boolean
+ onGenerate: () => void
+}) {
+ return (
+
+
+
{slot.hint}
+
+ {busy ? : }
+ AI 补角度
+
+
+ )
+}
+
function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
return (