fix: reorganize source video frame workflow

This commit is contained in:
2026-05-17 22:46:05 +08:00
parent 18d2c5e426
commit 71c9a45a1f
2 changed files with 139 additions and 93 deletions

View File

@@ -589,7 +589,7 @@
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。</td></tr>
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态jobs、activeJobId、生成任务状态主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。</td></tr>
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器,中间为“关键帧 / 相似主角”,右侧为较窄的逐句时间轴侧栏,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。原视频标题栏右侧提供“当前点抽帧”,按当前播放秒数手动补参考帧;关键帧区一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化,缩略图按竖版完整比例显示不裁切,鼠标停留会通过固定浮层放大展示完整帧。人工勾选后调用 <code>generateSubjectAssets</code><code>source_actor + similar</code> 模式生成 6 张白底相似主角视图;这是新演员重构,不做像素提取或精确复刻源人物身份。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,把横向空间留给新口播、画面规划和视频候选;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;候选视频槽在宽屏下一排显示 6 个竖版预览,避免前面空旷、后面拥挤。单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方第一行左侧为原视频播放器、右侧为逐句时间轴,第二行铺开“关键帧 / 相似主角”。底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。原视频标题栏右侧提供“当前点抽帧”,按当前播放秒数手动补参考帧;关键帧区一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化,缩略图按竖版完整比例显示不裁切并横向多列铺开,鼠标停留会通过固定浮层放大展示完整帧。“生成 6 视图”放在相似主角白底视图区,不和抽参考按钮平齐;人工勾选后调用 <code>generateSubjectAssets</code><code>source_actor + similar</code> 模式生成 6 张白底相似主角视图;这是新演员重构,不做像素提取或精确复刻源人物身份。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,把横向空间留给新口播、画面规划和视频候选;生成本条视频时使用当前编辑后的新口播文案。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和 6 个候选视频槽;候选视频槽在宽屏下一排显示 6 个竖版预览,避免前面空旷、后面拥挤。单条生成会从全局选中关键帧或 12 张关键帧中取最贴近本句时间点的参考帧。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图,不会把全部产品图提交给生视频模型,然后把产品坐标系、视角标注、方向、结构点和风险写入 Seedance 提示。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。</td></tr>
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout覆盖全站默认网页标题和描述为空避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
<tr><td><code>web/components/login/oasis-canvas.tsx</code></td><td>登录页全屏动态视觉层:用 iframe 直接承载下载包 <code>web/public/oasis-source/index.html</code> 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 <code>postMessage</code> 转发给 iframe避免登录面板或输入框遮挡时草地失去鼠标响应。</td></tr>
@@ -626,7 +626,7 @@
web/app/page.tsx
-> 信息流广告复刻工作表web/components/ad-recreation-board.tsx
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频播放器右上角可当前点抽帧 / 动作峰值 12 张参考帧完整竖图选择 / 相似主角 6 白底视图 / 较窄逐句时间轴侧栏并排,底部连续响度波形联动
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频播放器右上角可当前点抽帧,逐句时间轴在原视频右侧,参考帧池在下方多列铺开,相似主角 6 白底视图生成按钮放在视图区,底部连续响度波形显示当前/总时长/指针停点
-> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 6 个候选视频槽
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
-> 旧节点/深度素材面板web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx底层保留当前不作为主入口
@@ -877,7 +877,7 @@ ProductRefStateItem {
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&amp;target=&amp;mode=&amp;quality=</code></td><td><code>analyzeJob</code></td><td>后续阶段保留的抽帧能力。默认 <code>frames=12</code><code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口;原版视频旁的“抽参考 12 帧”会显式用 <code>target=motion</code><code>quality=accurate</code><code>mode=replace</code> 重新生成全局动作/节奏参考帧池。</td></tr>
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>若尚未拆轨,先从 <code>source.mp4</code> 提取 <code>audio.wav</code> 并回填 <code>source_audio_url</code>;随后用 ASR 提取原始文案,翻译成中文,写入 <code>audio_script.source_text</code><code>source_zh</code> 和逐句 <code>transcript</code>。远端 <code>ASR_MODEL</code> 失败后先走本机 <code>LOCAL_ASR_BIN</code>/<code>LOCAL_ASR_MODEL</code>(默认 <code>mlx_whisper</code>),再尝试 <code>ASR_FALLBACK_MODEL</code>。后端会拒绝重复文本、逐秒假字幕或覆盖率过低的结果,不再把不可听的多模态输出写进时间轴。再用 <code>ASR_FALLBACK_MODEL</code> 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 <code>speaker_profile</code><code>rhythm_profile</code><code>background_audio_profile</code>。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。</td></tr>
<tr><td>分镜脚本改写</td><td><code>POST /jobs/{id}/script/rewrite</code></td><td><code>rewriteStoryboardScript</code></td><td>根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。<code>mode=segment</code> 只改一段;<code>mode=all</code> 一次改完整片,要求整片前后连贯。接口只返回 <code>items[index,text]</code>,前端暂存在当前页面状态里,生成本条视频时写入 <code>StoryboardScene.action</code></td></tr>
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav当前主界面不再渲染底部吸附音频条右侧复刻工作表会读取该文件生成参考图式横向响度波形并和原视频、逐句时间轴联动。</td></tr>
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav当前主界面不再渲染底部吸附音频条右侧复刻工作表会读取该文件生成参考图式横向响度波形并和原视频、逐句时间轴联动;波形标题栏显示当前播放秒数、总时长和鼠标指针停点秒数</td></tr>
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。</td></tr>
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧index 递增但 frames 按 timestamp 排序。当前主界面会把原版视频播放器的播放秒数传给 <code>AudioIntakePanel</code> 标题栏右侧的“当前点抽帧”。</td></tr>
<tr><td>删除关键帧</td><td><code>DELETE /jobs/{id}/frames/{idx}</code></td><td><code>deleteFrame</code></td><td>删除单张关键帧并清掉对应选择态;当前主界面每张缩略图右下角提供删除入口,方便手动抽错后直接修正。</td></tr>
@@ -1003,6 +1003,18 @@ ProductRefStateItem {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-17 · 时间轴贴近原视频并压小参考帧</h3>
<span class="tag rose">UI</span>
<span class="tag cyan">Workflow</span>
</header>
<div class="body">
<p><strong>问题:</strong>参考帧区占在原视频和逐句时间轴中间,导致视频和字幕联动不直观;抽帧缩略图仍偏大,一屏可比较的帧数少;“生成 6 视图”和抽参考按钮平齐,语义上像同一级操作。</p>
<p><strong>改动:</strong><code>AudioIntakePanel</code> 改成上层“原版视频 + 逐句时间轴”左右排列,下层 <code>SourceReferenceBuildPanel</code> 横向铺开参考帧;时间轴侧栏改成时间 + 原文/中文双行,减少宽度占用;参考帧缩略图用 6/8/12 列响应式排列;“生成 6 视图”移动到相似主角白底视图区。<code>AudioWaveform</code> 新增鼠标悬停时间回传,标题栏显示当前播放、总时长和指针停点秒数。</p>
<p><strong>影响:</strong><code>web/components/ad-recreation-board.tsx</code><code>docs/source-analysis.html</code>。后续查看原片时先看视频和右侧逐句时间轴;参考帧池是下一行横向候选区。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-17 · 当前点抽帧移回视频区并补悬停预览</h3>

View File

@@ -1007,6 +1007,7 @@ function AudioIntakePanel({
const [audioFeatures, setAudioFeatures] = useState<AudioFeature[]>([])
const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
const [manualBusy, setManualBusy] = useState(false)
const [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
const videoRef = useRef<HTMLVideoElement | null>(null)
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
const syncFrameRef = useRef<number | null>(null)
@@ -1029,6 +1030,11 @@ function AudioIntakePanel({
)
}, [job, mediaDuration])
const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2))
const waveTimeHint = waveHoverTime !== null
? `指针停点 ${waveHoverTime.toFixed(1)}s`
: activeSegment
? `当前句 ${activeSegment.start.toFixed(1)}-${activeSegment.end.toFixed(1)}s`
: "指针 -"
useEffect(() => {
if (!job?.id || !audioSrcUrl) {
@@ -1130,59 +1136,107 @@ function AudioIntakePanel({
<div className="grid gap-2 border-t border-white/8 pt-2">
<div className="rounded-md border border-white/10 bg-black/32 p-2">
<div className="mb-1 flex items-center justify-between text-[10px] text-white/40">
<div className="mb-1 flex items-center justify-between gap-3 text-[10px] text-white/40">
<span> / </span>
<span className="font-mono">{currentTime.toFixed(1)}s</span>
<div className="flex items-center gap-2 font-mono">
<span> {currentTime.toFixed(1)}s</span>
<span> {formatSeconds(timelineDuration)}</span>
<span>{waveTimeHint}</span>
</div>
</div>
<AudioWaveform
features={audioFeatures}
status={audioFeatureStatus}
currentTime={currentTime}
hoverTime={waveHoverTime}
duration={timelineDuration}
segments={job.transcript}
onSeek={seekTo}
onHoverTimeChange={setWaveHoverTime}
/>
</div>
<div className="grid gap-2 xl:grid-cols-[360px_minmax(500px,1fr)_260px] 2xl:grid-cols-[440px_minmax(600px,1fr)_280px]">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s / {formatSeconds(timelineDuration)}</span>
<button
type="button"
onClick={() => void addFrameAtCurrentTime()}
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
title={`按当前播放位置手动抽帧:${currentTime.toFixed(1)}s`}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-emerald-300/20 bg-emerald-300/[0.08] px-2 text-[10.5px] font-semibold text-emerald-100 transition hover:border-emerald-200/45 hover:bg-emerald-300/[0.14] disabled:cursor-not-allowed disabled:opacity-35"
>
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
<div className="grid gap-2">
<div className="grid gap-2 xl:grid-cols-[minmax(520px,1fr)_360px] 2xl:grid-cols-[minmax(640px,1fr)_420px]">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s / {formatSeconds(timelineDuration)}</span>
<button
type="button"
onClick={() => void addFrameAtCurrentTime()}
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
title={`按当前播放位置手动抽帧:${currentTime.toFixed(1)}s`}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-emerald-300/20 bg-emerald-300/[0.08] px-2 text-[10.5px] font-semibold text-emerald-100 transition hover:border-emerald-200/45 hover:bg-emerald-300/[0.14] disabled:cursor-not-allowed disabled:opacity-35"
>
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
</div>
</div>
<div className="overflow-hidden rounded-md border border-white/10 bg-black/45">
{job.video_url ? (
<video
ref={videoRef}
controls
playsInline
className="h-[320px] w-full bg-black object-contain 2xl:h-[380px]"
src={videoSrcUrl}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
onPlay={startFrameSync}
onPlaying={startFrameSync}
onPause={stopFrameSync}
onEnded={stopFrameSync}
onLoadedMetadata={(event) => {
setMediaDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)
setCurrentTime(event.currentTarget.currentTime)
}}
/>
) : (
<div className="flex h-[320px] items-center justify-center text-[12px] text-white/38 2xl:h-[380px]"></div>
)}
</div>
</div>
<div className="overflow-hidden rounded-md border border-white/10 bg-black/45">
{job.video_url ? (
<video
ref={videoRef}
controls
playsInline
className="h-[320px] w-full bg-black object-contain 2xl:h-[380px]"
src={videoSrcUrl}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
onPlay={startFrameSync}
onPlaying={startFrameSync}
onPause={stopFrameSync}
onEnded={stopFrameSync}
onLoadedMetadata={(event) => {
setMediaDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)
setCurrentTime(event.currentTarget.currentTime)
}}
/>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} </span>
</div>
{job.transcript.length ? (
<div className="overflow-hidden rounded-md border border-white/10">
<div className="grid grid-cols-[76px_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
<div></div>
<div> / </div>
</div>
<div className="max-h-[320px] overflow-y-auto 2xl:max-h-[380px]">
{job.transcript.map((segment) => {
const active = activeSegment?.index === segment.index
return (
<div
key={segment.index}
ref={(node) => { rowRefs.current[segment.index] = node }}
onClick={() => seekTo(segment.start)}
className={`grid cursor-pointer grid-cols-[76px_minmax(0,1fr)] gap-2 border-b px-3 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
active
? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
: "border-white/8 text-white/64 hover:bg-white/[0.045]"
}`}
>
<div className={`font-mono text-[10.5px] ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
<div className="min-w-0">
<div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
<div className={`mt-0.5 truncate text-[11px] ${active ? "text-emerald-50/80" : "text-white/42"}`} title={segment.zh}>{segment.zh || <span className="text-white/30"></span>}</div>
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="flex h-[320px] items-center justify-center text-[12px] text-white/38 2xl:h-[380px]"></div>
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
)}
</div>
</div>
@@ -1194,45 +1248,6 @@ function AudioIntakePanel({
onJobUpdate={onJobUpdate}
onDeleteFrame={onDeleteFrame}
/>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} </span>
</div>
{job.transcript.length ? (
<div className="overflow-hidden rounded-md border border-white/10">
<div className="grid grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
<div></div>
<div></div>
<div></div>
</div>
<div className="max-h-[320px] overflow-y-auto 2xl:max-h-[380px]">
{job.transcript.map((segment) => {
const active = activeSegment?.index === segment.index
return (
<div
key={segment.index}
ref={(node) => { rowRefs.current[segment.index] = node }}
onClick={() => seekTo(segment.start)}
className={`grid cursor-pointer grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] gap-3 border-b px-3 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
active
? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
: "border-white/8 text-white/64 hover:bg-white/[0.045]"
}`}
>
<div className={`font-mono text-[11px] ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
<div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
<div className="truncate" title={segment.zh}>{segment.zh || <span className="text-white/30"></span>}</div>
</div>
)
})}
</div>
</div>
) : (
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
)}
</div>
</div>
</div>
</section>
@@ -1391,8 +1406,8 @@ function SourceReferenceBuildPanel({
{frames.length ? `${frames.length}` : "待抽帧"} · {selectedReferenceFrames.length}
</span>
</div>
<div className="h-[320px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[380px]">
<div className="grid grid-cols-2 gap-1.5">
<div className="h-[300px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[340px]">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void extractKeyframes()}
@@ -1403,18 +1418,10 @@ function SourceReferenceBuildPanel({
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
12
</button>
<button
type="button"
onClick={() => void generateSimilarActor()}
disabled={!selectedReferenceFrames.length || subjectBusy}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
6
</button>
<span className="text-[10.5px] text-white/34"></span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 2xl:grid-cols-4">
<div className="mt-2 grid grid-cols-6 gap-1.5 md:grid-cols-8 xl:grid-cols-12">
{frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
@@ -1467,7 +1474,18 @@ function SourceReferenceBuildPanel({
<div className="mt-2 border-t border-white/8 pt-2">
<div className="mb-1.5 flex items-center justify-between text-[10px] text-white/36">
<span></span>
<span>{actorAssets.length}/6</span>
<div className="flex items-center gap-2">
<span>{actorAssets.length}/6</span>
<button
type="button"
onClick={() => void generateSimilarActor()}
disabled={!selectedReferenceFrames.length || subjectBusy}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
6
</button>
</div>
</div>
{actorAssets.length ? (
<div className="grid grid-cols-6 gap-1.5">
@@ -2191,18 +2209,23 @@ function AudioWaveform({
features,
status,
currentTime,
hoverTime,
duration,
segments,
onSeek,
onHoverTimeChange,
}: {
features: AudioFeature[]
status: AudioFeatureStatus
currentTime: number
hoverTime: number | null
duration: number
segments: Array<{ start: number; end: number }>
onSeek: (time: number) => void
onHoverTimeChange?: (time: number | null) => void
}) {
const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100)
const hoverPct = hoverTime === null ? null : clampNumber((hoverTime / Math.max(duration, 1)) * 100, 0, 100)
const hasFeatures = features.length > 0
const { topPoints, bottomPoints, envelopePoints } = useMemo(() => {
const top = features.map((feature, index) => {
@@ -2224,6 +2247,11 @@ function AudioWaveform({
const rect = event.currentTarget.getBoundingClientRect()
onSeek(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration)
}}
onMouseMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect()
onHoverTimeChange?.(clampNumber(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration, 0, duration))
}}
onMouseLeave={() => onHoverTimeChange?.(null)}
>
<div className="absolute inset-y-2 left-2 right-2">
{!hasFeatures && (
@@ -2262,6 +2290,12 @@ function AudioWaveform({
style={{ left: `${clampNumber((segment.start / Math.max(duration, 1)) * 100, 0, 100)}%` }}
/>
))}
{hoverPct !== null && (
<div
className="pointer-events-none absolute inset-y-0 w-px bg-cyan-100/70"
style={{ left: `${hoverPct}%` }}
/>
)}
<div
className="pointer-events-none absolute inset-y-0 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.85)] will-change-[left]"
style={{ left: `${pointerPct}%` }}