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.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 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图、识别/标注视角、填写视角备注、鼠标悬停放大预览,并对缺失的正面/左右 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
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,AI 补角度复用 generateProductAngleAsset,单条生成复用 onGenerateVideoPUT /frames/{idx}/storyboard
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、每张产品图的视角备注是什么、缺哪个角度、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset,单条生成复用 onGenerateVideoPUT /frames/{idx}/storyboard
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、产品图识别/补图后的备注是否准确、生成的视频应该回显到哪一行”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -839,7 +839,8 @@ SubjectAsset { 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。 产品图库GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 产品图入库到 jobPOST /jobs/{id}/assets/product-librarycopyProductLibraryAsset把一个内置产品图库条目复制为当前 job 的普通 asset,返回 ImageRef(kind="asset"),用于画面工作台产品融合和分镜产品参考组。 - 产品缺角度补图POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前产品白底图作为参考,通过图像模型补全缺失视角,输出新的 ImageRef(kind="asset")。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例。 + 产品视角识别POST /jobs/{id}/assets/product-views/analyzeanalyzeProductViews读取已上传的产品白底图,自动分类为正面、左右 45 度、侧面厚度、内侧触点或背面/底部,并返回中文视角备注和置信度;前端不再要求用户手动选择视角。 + 产品缺角度补图POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前产品白底图作为参考,通过图像模型自动补全缺失视角,输出新的 ImageRef(kind="asset")。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例;前端只在自动补图失败时暴露重试入口。 角色库GET /character-library/skglistCharacterLibrary读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。 角色图入库到 jobPOST /jobs/{id}/assets/character-librarycopyCharacterLibraryAssets把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。 产品融合引导图POST /jobs/{id}/product-fusion/guidecreateProductFusionGuide旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。 @@ -949,6 +950,19 @@ SubjectAsset {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-17 · 产品图上传后自动识别视角并补齐缺角度

+ UI + Workflow + API +
+
+

问题:产品图上传后还让用户手动选择正面、45 度、侧面等视角,操作成本高,也容易把后续生视频的产品结构约束标错;缺失角度也不应该再让用户逐个判断后点击。

+

改动:新增 POST /jobs/{id}/assets/product-views/analyze 和前端 analyzeProductViewsAudioStoryboardPlanPanel 在上传产品白底图后自动识别每张图的视角、写入中文备注和置信度,再自动调用 generateProductAngleAsset 补齐缺失视角。ProductReferenceCard 移除视角下拉,改为只读“自动识别/自动补图”标签,用户只检查备注;MissingProductViewSlot 只作为自动补图失败后的重试入口。

+

影响:api/main.pyweb/lib/api.tsweb/components/ad-recreation-board.tsxdocs/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) {