From c690979b58b2d3eb29a95431cc36c226209411cc Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 17 May 2026 16:15:48 +0800 Subject: [PATCH] feat: add product refs and video candidate slots --- docs/source-analysis.html | 20 +++- web/components/ad-recreation-board.tsx | 150 ++++++++++++++++++++----- 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 8056e40..bac6ddc 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 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和生成视频;单条生成会先把该行规划保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + web/components/ad-recreation-board.tsx信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部可上传产品白底图,建议 5 张、最多 6 张,用来锁定正面、左右 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 → 下载完成后自动触发音频处理 -> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动) - -> 信息流复刻分镜工作台:逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 对应候选视频 + -> 信息流复刻分镜工作台:产品白底图上传(建议 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
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelbuildAudioStoryboardRowsbuildStoryboardSceneFromAudioRow in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,单条生成复用 onGenerateVideoPUT /frames/{idx}/storyboard
-
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、如何抽参考帧、生成的视频应该回显到哪一行”。
+
主要源码AudioStoryboardPlanPanelbuildAudioStoryboardRowsbuildStoryboardSceneFromAudioRowStoryboardVideoSlots in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,产品白底图上传复用 uploadStoryboardAsset,单条生成复用 onGenerateVideoPUT /frames/{idx}/storyboard
+
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、如何抽参考帧、生成的视频应该回显到哪一行”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -948,6 +948,18 @@ SubjectAsset {

变更记录

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

+
+
+

2026-05-17 · 压缩分镜行并加入产品白底图与多候选视频槽

+ UI + Workflow +
+
+

问题:分镜行前半段占用横向空间偏多,右侧视频候选位太少;同时肩颈产品不是完全对称结构,只靠默认产品图容易生成左右一致、比例不准或佩戴尺寸跑偏。

+

改动:AudioStoryboardPlanPanel 顶部新增产品白底图上传条,建议上传 5 张、最多 6 张:正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例,非对称明显时补背面或底部。分镜行前几列压缩宽度、字号和缩略图高度;右侧视频区改为 6 个候选槽,便于多次生成和筛选。生成本条时会把上传的产品图写入 StoryboardScene.product_images,并把“保留左右非对称、肩颈真实比例”加入产品融合提示。

+

影响:web/components/ad-recreation-board.tsxdocs/source-analysis.html。产品白底图当前作为当前页面会话内的复刻参考,单条生成时持久化进对应关键帧分镜;后续如果要跨刷新保留全局产品图组,应新增 job 级产品参考字段。

+
+

2026-05-17 · 新增信息流复刻分镜工作台

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 0833785..5449fa1 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -11,6 +11,7 @@ import { type FrameExtractTarget, type FrameObject, type GeneratedVideo, + type ImageRef, type Job, type KeyElement, type KeyFrame, @@ -24,8 +25,10 @@ import { generatedImageUrl, hasCutout, representativeCutoutUrl, + resolveImageRefUrl, sourceAudioUrl, updateStoryboard, + uploadStoryboardAsset, videoUrl, } from "@/lib/api" import { type NodeData } from "@/components/nodes" @@ -287,14 +290,19 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] { }) } -function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null): StoryboardScene { +function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productRefs: ImageRef[] = []): StoryboardScene { + const productGuidance = productRefs.length + ? "产品白底图已上传:生成时必须同时参考正面、左侧、右侧、厚度和内侧触点/佩戴比例,保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。" + : "未上传产品白底图时使用默认 SKG 产品图;生成前建议补 5 张白底图锁定左右差异、厚度和佩戴比例。" 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} 参考帧` }, last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${row.index + 1} 尾帧` } : null, + product_images: productRefs, + product_image: productRefs[0] ?? null, subject: row.keyElements, scene: `${row.visualPlan}\n原音频依据:${row.source}`, - product: row.productIntegration, + product: `${row.productIntegration}\n${productGuidance}`, action: row.skgCopy, reference_ids: [], } @@ -911,9 +919,16 @@ function AudioStoryboardPlanPanel({ }) { const [busyRow, setBusyRow] = useState(null) const [videoBusyRow, setVideoBusyRow] = useState(null) + const [productRefs, setProductRefs] = useState([]) + const [productUploading, setProductUploading] = useState(false) + 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([]) + }, [job?.id]) + const framesForRow = (row: AudioStoryboardRow) => orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3) @@ -933,11 +948,31 @@ function AudioStoryboardPlanPanel({ } } + const uploadProductImages = async (files: FileList | null) => { + if (!job || !files?.length) return + const remaining = Math.max(0, 6 - productRefs.length) + if (remaining === 0) { + toast.info("产品白底图最多保留 6 张") + return + } + const selected = Array.from(files).slice(0, remaining) + setProductUploading(true) + try { + const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file))) + setProductRefs((prev) => [...prev, ...refs].slice(0, 6)) + toast.success(`已上传 ${refs.length} 张产品白底图`) + } catch (e) { + toast.error("产品白底图上传失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setProductUploading(false) + } + } + 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) + const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame, productRefs) setVideoBusyRow(row.index) try { const updated = await updateStoryboard(job.id, frame.index, scene) @@ -966,6 +1001,55 @@ function AudioStoryboardPlanPanel({
+
+
+
+ } title="产品白底图" /> + 建议 5 张,最多 6 张 +
+

+ 正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例;非对称明显时补背面或底部,避免生成时左右两边被做成一样。 +

+
+
+
+ {productRefs.map((ref, index) => ( + {`产品白底图 + ))} + {Array.from({ length: Math.max(0, Math.min(6, 5 - productRefs.length)) }).map((_, index) => ( +
+ {productRefs.length + index + 1} +
+ ))} +
+ + { + void uploadProductImages(event.currentTarget.files) + event.currentTarget.value = "" + }} + /> +
+
+ {rows.length ? (
{rows.map((row) => { @@ -976,7 +1060,7 @@ function AudioStoryboardPlanPanel({ return (
{row.start.toFixed(1)}-{row.end.toFixed(1)}s
@@ -986,16 +1070,16 @@ function AudioStoryboardPlanPanel({
-

{row.source}

+

{row.source}

-

{row.skgCopy}

+

{row.skgCopy}

-

{row.visualPlan}

-

+

{row.visualPlan}

+

{row.productIntegration}

@@ -1003,13 +1087,13 @@ function AudioStoryboardPlanPanel({ {refs.length ? ( -
+
{refs.map((frame) => (