diff --git a/.memory/worklog.json b/.memory/worklog.json
index d0e39af..c161732 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,19 +1,5 @@
{
"entries": [
- {
- "files_changed": 1,
- "hash": "c3ee829",
- "message": "auto-save 2026-05-15 10:15 (~1)",
- "ts": "2026-05-15T10:15:18+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "a889e4c",
- "message": "auto-save 2026-05-15 10:20 (~1)",
- "ts": "2026-05-15T10:20:51+08:00",
- "type": "commit"
- },
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 10:20 (~1)",
@@ -3274,6 +3260,19 @@
"message": "auto-save 2026-05-17 16:32 (~3)",
"hash": "2b0afee",
"files_changed": 3
+ },
+ {
+ "ts": "2026-05-17T16:38:02+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 16:37 (~2)",
+ "hash": "9600bb4",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T08:38:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 16:37 (~2)",
+ "files_changed": 1
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 98b0b07..0a89bc4 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 度/厚度/内侧触点/背底视角提供 AI 补角度入口;每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会先把该行规划、已上传/补全的产品图和视角备注保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
+ web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图,上传后自动识别正面/左右 45 度/厚度/内侧触点/背底等视角并自动补齐缺失角度,用户只检查视角备注,鼠标悬停可放大预览;补图失败时保留单个缺失视角的重试入口。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 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 → 下载完成后自动触发音频处理
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
- -> 信息流复刻分镜工作台:产品白底图上传 / 视角备注 / AI 补角度(建议 5、最多 6)→ 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
+ -> 信息流复刻分镜工作台:产品白底图上传 → 自动识别视角 → 自动补齐缺失角度 → 人工检查备注(建议 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、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、buildStoryboardSceneFromAudioRow、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,AI 补角度复用 generateProductAngleAsset,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、每张产品图的视角备注是什么、缺哪个角度、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanel、ProductReferenceCard、MissingProductViewSlot、buildAudioStoryboardRows、buildStoryboardSceneFromAudioRow、StoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、产品图识别/补图后的备注是否准确、生成的视频应该回显到哪一行”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -839,7 +839,8 @@ 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-angle | generateProductAngleAsset | 用当前产品白底图作为参考,通过图像模型补全缺失视角,输出新的 ImageRef(kind="asset")。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例。 |
+
| 产品视角识别 | POST /jobs/{id}/assets/product-views/analyze | analyzeProductViews | 读取已上传的产品白底图,自动分类为正面、左右 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,产品融合生成视频时作为人物身份参考图提交。 |
| 产品融合引导图 | POST /jobs/{id}/product-fusion/guide | createProductFusionGuide | 旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。 |
@@ -949,6 +950,19 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-17 · 产品图上传后自动识别视角并补齐缺角度
+ UI
+ Workflow
+ API
+
+
+
问题:产品图上传后还让用户手动选择正面、45 度、侧面等视角,操作成本高,也容易把后续生视频的产品结构约束标错;缺失角度也不应该再让用户逐个判断后点击。
+
改动:新增 POST /jobs/{id}/assets/product-views/analyze 和前端 analyzeProductViews。AudioStoryboardPlanPanel 在上传产品白底图后自动识别每张图的视角、写入中文备注和置信度,再自动调用 generateProductAngleAsset 补齐缺失视角。ProductReferenceCard 移除视角下拉,改为只读“自动识别/自动补图”标签,用户只检查备注;MissingProductViewSlot 只作为自动补图失败后的重试入口。
+
影响:api/main.py、web/lib/api.ts、web/components/ad-recreation-board.tsx、docs/source-analysis.html。后续描述需求时应说“自动识别/补图后的备注是否准确”,不要再按“手选产品视角”理解这个区域。
+
+
2026-05-17 · 产品白底图加入视角备注和 AI 补角度
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index 2d2c122..06b1bc4 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -15,9 +15,11 @@ import {
type Job,
type KeyElement,
type KeyFrame,
+ type ProductViewAnalysisItem,
type StoryboardScene,
type SubjectKind,
addElement,
+ analyzeProductViews,
apiAssetUrl,
cutoutElement,
effectiveFrameUrl,
@@ -89,6 +91,7 @@ type ProductRefItem = {
view: string
note: string
source: "upload" | "ai"
+ confidence?: number
}
const PRODUCT_VIEW_SLOTS = [
@@ -312,18 +315,36 @@ function productRefKey(ref: ImageRef, index: number) {
return `${ref.kind}:${ref.frame_idx}:${ref.element_id ?? ""}:${ref.cutout_id ?? ""}:${index}`
}
+function sameImageRef(a: ImageRef, b: ImageRef) {
+ return (
+ a.kind === b.kind &&
+ a.frame_idx === b.frame_idx &&
+ (a.element_id ?? "") === (b.element_id ?? "") &&
+ (a.cutout_id ?? "") === (b.cutout_id ?? "")
+ )
+}
+
function productViewLabel(view: string) {
return PRODUCT_VIEW_SLOTS.find((slot) => slot.value === view)?.label ?? view
}
-function createProductRefItem(ref: ImageRef, index: number, source: ProductRefItem["source"] = "upload", view?: string): ProductRefItem {
+function createProductRefItem(
+ ref: ImageRef,
+ index: number,
+ source: ProductRefItem["source"] = "upload",
+ view?: string,
+ note?: string,
+ confidence?: number,
+): ProductRefItem {
const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
+ const targetSlot = PRODUCT_VIEW_SLOTS.find((item) => item.value === view) ?? slot
return {
id: productRefKey(ref, index),
ref,
- view: view ?? slot.value,
- note: slot.hint,
+ view: view ?? targetSlot.value,
+ note: note ?? targetSlot.hint,
source,
+ confidence,
}
}
@@ -967,6 +988,7 @@ function AudioStoryboardPlanPanel({
const [videoBusyRow, setVideoBusyRow] = useState(null)
const [productItems, setProductItems] = useState([])
const [productUploading, setProductUploading] = useState(false)
+ const [productAnalyzing, setProductAnalyzing] = useState(false)
const [productAngleBusy, setProductAngleBusy] = useState(null)
const productFileRef = useRef(null)
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
@@ -995,6 +1017,76 @@ function AudioStoryboardPlanPanel({
}
}
+ const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
+
+ const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = []) => refs.map((ref, index) => {
+ const analysis = analysisItems.find((item) => item.index === index)
+ const validView = analysis && PRODUCT_VIEW_SLOTS.some((slot) => slot.value === analysis.view) ? analysis.view : undefined
+ return createProductRefItem(
+ ref,
+ index,
+ itemSourceForRef(ref),
+ validView,
+ analysis?.note,
+ analysis?.confidence,
+ )
+ })
+
+ const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
+ if (!job || !seedItems.length) return seedItems
+ let working = seedItems.slice(0, 6)
+ 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)
+ try {
+ const ref = await generateProductAngleAsset(job.id, {
+ source_ref: working[0].ref,
+ target_view: slot.label,
+ note: slot.hint,
+ })
+ 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)}`)
+ }
+ }
+
+ setProductAngleBusy(null)
+ if (failures.length) {
+ toast.warning(`部分产品视角自动补图失败:${failures.map((item) => item.split(":")[0]).join("、")}`)
+ }
+ return working
+ }
+
+ 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, "正在自动识别视角...")))
+ try {
+ const analysis = await analyzeProductViews(job.id, limitedRefs)
+ const analyzed = buildAnalyzedProductItems(limitedRefs, 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)))
+ setProductItems(fallback)
+ await completeMissingProductAngles(fallback)
+ toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
+ } finally {
+ setProductAnalyzing(false)
+ setProductAngleBusy(null)
+ }
+ }
+
const uploadProductImages = async (files: FileList | null) => {
if (!job || !files?.length) return
const remaining = Math.max(0, 6 - productItems.length)
@@ -1006,10 +1098,8 @@ function AudioStoryboardPlanPanel({
setProductUploading(true)
try {
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
- setProductItems((prev) => [
- ...prev,
- ...refs.map((ref, index) => createProductRefItem(ref, prev.length + index)),
- ].slice(0, 6))
+ const nextRefs = [...productItems.map((item) => item.ref), ...refs].slice(0, 6)
+ await analyzeAndCompleteProductViews(nextRefs)
toast.success(`已上传 ${refs.length} 张产品白底图`)
} catch (e) {
toast.error("产品白底图上传失败:" + (e instanceof Error ? e.message : String(e)))
@@ -1026,12 +1116,9 @@ function AudioStoryboardPlanPanel({
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 reanalyzeProductViews = async () => {
+ if (!productItems.length) return
+ await analyzeAndCompleteProductViews(productItems.map((item) => item.ref))
}
const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
@@ -1044,7 +1131,7 @@ function AudioStoryboardPlanPanel({
target_view: slot.label,
note: slot.hint,
})
- setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value)].slice(0, 6))
+ setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)].slice(0, 6))
toast.success(`AI 已补全产品视角:${slot.label}`)
} catch (e) {
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
@@ -1092,25 +1179,31 @@ function AudioStoryboardPlanPanel({
} title="产品白底图 / 视角补全" />
建议 5 张,最多 6 张
+ {(productAnalyzing || productAngleBusy) && (
+
+
+ {productAnalyzing ? "识别视角中" : `补图中:${productViewLabel(productAngleBusy ?? "")}`}
+
+ )}
- 每张图都要标注视角和备注。推荐:正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例;缺口可用 AI 补角度,避免非对称产品被生成成左右镜像。
+ 上传后自动识别每张图的角度和视角,并自动补齐缺失角度;人只需要检查备注。重点锁住左右非对称、厚度、内侧触点和肩颈真实佩戴比例。
-
+
+ {productViewLabel(item.view)}
+
+ {item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
+
+
onPatch({ note: event.target.value })}
- placeholder="视角备注:结构差异、触点、尺寸比例"
+ 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 || "产品参考图"}
@@ -1299,11 +1390,13 @@ function MissingProductViewSlot({
slot,
canGenerate,
busy,
+ blocked,
onGenerate,
}: {
slot: typeof PRODUCT_VIEW_SLOTS[number]
canGenerate: boolean
busy: boolean
+ blocked: boolean
onGenerate: () => void
}) {
return (
@@ -1313,14 +1406,15 @@ function MissingProductViewSlot({
缺视角
{slot.hint}
+
{canGenerate ? "自动补图失败时可重试。" : "上传后会自动识别并补齐。"}
)
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 3d93382..2aae1e4 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -163,6 +163,29 @@ export async function generateProductAngleAsset(
return res.json()
}
+export interface ProductViewAnalysisItem {
+ index: number
+ view: string
+ note: string
+ confidence: number
+}
+
+export async function analyzeProductViews(
+ jobId: string,
+ refs: ImageRef[],
+): Promise<{ items: ProductViewAnalysisItem[]; missing_views: string[] }> {
+ const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-views/analyze`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refs }),
+ })
+ if (!res.ok) {
+ const txt = await res.text().catch(() => "")
+ throw new Error(`analyzeProductViews ${res.status} ${txt.slice(0, 300)}`)
+ }
+ return res.json()
+}
+
export async function listProductLibrary(): Promise {
const res = await fetch(`${API_BASE}/product-library/skg`)
if (!res.ok) {