fix: smooth waveform playback cursor
This commit is contained in:
@@ -588,7 +588,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>信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放会同步高亮并滚动当前句,点击音频波形或字幕行会跳转原视频时间。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</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>
|
||||
@@ -941,6 +941,18 @@ SubjectAsset {
|
||||
<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>波形上的播放线只跟随 <code>video.timeupdate</code> 刷新,浏览器触发频率较低,播放时会一顿一顿。</p>
|
||||
<p><strong>改动:</strong><code>web/components/ad-recreation-board.tsx</code> 在原视频播放期间启用 <code>requestAnimationFrame</code> 同步 <code>currentTime</code>,暂停、结束和卸载时停止帧循环;波形 SVG 点位用 <code>useMemo</code> 缓存,避免播放时重复计算整条包络。</p>
|
||||
<p><strong>影响:</strong><code>web/components/ad-recreation-board.tsx</code>、<code>docs/source-analysis.html</code>。后续调整波形交互时,播放线应继续走帧同步,不要只依赖 <code>timeupdate</code>。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 增加原视频与连续波形联动审片</h3>
|
||||
|
||||
@@ -595,6 +595,7 @@ function AudioIntakePanel({ job }: { job: Job | null }) {
|
||||
const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||
const syncFrameRef = useRef<number | null>(null)
|
||||
const script = job?.audio_script
|
||||
const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : ""
|
||||
const profiles = [
|
||||
@@ -646,6 +647,34 @@ function AudioIntakePanel({ job }: { job: Job | null }) {
|
||||
if (activeSegment) rowRefs.current[activeSegment.index]?.scrollIntoView({ block: "nearest" })
|
||||
}, [activeSegment?.index])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopFrameSync = () => {
|
||||
if (syncFrameRef.current !== null) {
|
||||
cancelAnimationFrame(syncFrameRef.current)
|
||||
syncFrameRef.current = null
|
||||
}
|
||||
if (videoRef.current) setCurrentTime(videoRef.current.currentTime)
|
||||
}
|
||||
|
||||
const startFrameSync = () => {
|
||||
if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
|
||||
const tick = () => {
|
||||
const video = videoRef.current
|
||||
if (!video || video.paused || video.ended) {
|
||||
stopFrameSync()
|
||||
return
|
||||
}
|
||||
setCurrentTime(video.currentTime)
|
||||
syncFrameRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
syncFrameRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const seekTo = (time: number) => {
|
||||
const next = clampNumber(time, 0, timelineDuration)
|
||||
if (videoRef.current) videoRef.current.currentTime = next
|
||||
@@ -706,6 +735,10 @@ function AudioIntakePanel({ job }: { job: Job | null }) {
|
||||
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)
|
||||
@@ -778,16 +811,18 @@ function AudioWaveform({
|
||||
}) {
|
||||
const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100)
|
||||
const hasFeatures = features.length > 0
|
||||
const topPoints = features.map((feature, index) => {
|
||||
const x = features.length <= 1 ? 0 : (index / (features.length - 1)) * 100
|
||||
return `${x.toFixed(2)},${(50 - feature.loudness * 32).toFixed(2)}`
|
||||
}).join(" ")
|
||||
const bottomPoints = [...features].reverse().map((feature, index) => {
|
||||
const originalIndex = features.length - 1 - index
|
||||
const x = features.length <= 1 ? 0 : (originalIndex / (features.length - 1)) * 100
|
||||
return `${x.toFixed(2)},${(50 + feature.loudness * 32).toFixed(2)}`
|
||||
}).join(" ")
|
||||
const envelopePoints = `${topPoints} ${bottomPoints}`
|
||||
const { topPoints, bottomPoints, envelopePoints } = useMemo(() => {
|
||||
const top = features.map((feature, index) => {
|
||||
const x = features.length <= 1 ? 0 : (index / (features.length - 1)) * 100
|
||||
return `${x.toFixed(2)},${(50 - feature.loudness * 32).toFixed(2)}`
|
||||
}).join(" ")
|
||||
const bottom = [...features].reverse().map((feature, index) => {
|
||||
const originalIndex = features.length - 1 - index
|
||||
const x = features.length <= 1 ? 0 : (originalIndex / (features.length - 1)) * 100
|
||||
return `${x.toFixed(2)},${(50 + feature.loudness * 32).toFixed(2)}`
|
||||
}).join(" ")
|
||||
return { topPoints: top, bottomPoints: bottom, envelopePoints: `${top} ${bottom}` }
|
||||
}, [features])
|
||||
return (
|
||||
<div
|
||||
className="relative h-24 cursor-pointer overflow-hidden rounded-md border border-white/10 bg-black/35 px-2"
|
||||
@@ -835,7 +870,7 @@ function AudioWaveform({
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.85)]"
|
||||
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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user