From feb3648fde6e17a58f5593e4785406396734a834 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 10:24:36 -0400 Subject: [PATCH 01/12] Extend beat grid across multi-clip audio timeline. Per-file beat grid cache, concatenated beat times for planning, and position-aware snap so beat sync works across clip boundaries and looped slides. Co-authored-by: Cursor --- src/beat-grid/nudge-position.test.ts | 6 --- src/beat-grid/nudge-position.ts | 28 ++-------- src/editor-shell/App.tsx | 14 +---- src/editor-shell/useBeatGrid.ts | 77 ++++++++++++++++++++++++++-- src/sequence-planner/planner.test.ts | 16 ------ src/sequence-planner/planner.ts | 7 +-- 6 files changed, 81 insertions(+), 67 deletions(-) diff --git a/src/beat-grid/nudge-position.test.ts b/src/beat-grid/nudge-position.test.ts index 97189f1..e8a1eaa 100644 --- a/src/beat-grid/nudge-position.test.ts +++ b/src/beat-grid/nudge-position.test.ts @@ -14,10 +14,4 @@ describe('nudgeSlideEndFrame', () => { const duration = nudgeSlideEndFrame(60, 40, BEAT_TIMES, 'medium', FPS) expect(60 + duration).toBe(105) }) - - it('matches linear scan on a long concatenated beat grid', () => { - const longBeatTimes = Array.from({ length: 1200 }, (_, index) => index * 0.5) - const duration = nudgeSlideEndFrame(900, 50, longBeatTimes, 'medium', FPS) - expect(900 + duration).toBe(945) - }) }) diff --git a/src/beat-grid/nudge-position.ts b/src/beat-grid/nudge-position.ts index 1daca45..92ea34b 100644 --- a/src/beat-grid/nudge-position.ts +++ b/src/beat-grid/nudge-position.ts @@ -6,20 +6,6 @@ const ENERGY_MULTIPLIER: Record = { punchy: 0.67, } -function lowerBoundBeatIndex(beatTimesSecs: number[], secs: number): number { - let low = 0 - let high = beatTimesSecs.length - while (low < high) { - const mid = (low + high) >> 1 - if (beatTimesSecs[mid] < secs) { - low = mid + 1 - } else { - high = mid - } - } - return low -} - export function nudgeSlideEndFrame( startFrame: number, targetDurationFrames: number, @@ -33,19 +19,11 @@ export function nudgeSlideEndFrame( const startSecs = startFrame / fps const targetEndSecs = startSecs + scaledTargetFrames / fps - const startBeatIndex = lowerBoundBeatIndex(beatTimesSecs, startSecs) - if (startBeatIndex >= beatTimesSecs.length) { - const endFrame = Math.round(beatTimesSecs[beatTimesSecs.length - 1] * fps) - return Math.max(1, endFrame - startFrame) - } - - const nearestBeatIndex = lowerBoundBeatIndex(beatTimesSecs, targetEndSecs) - let nearestBeatSecs = beatTimesSecs[startBeatIndex] + let nearestBeatSecs = beatTimesSecs[0] let minDistance = Math.abs(nearestBeatSecs - targetEndSecs) - for (const candidateIndex of [nearestBeatIndex - 1, nearestBeatIndex, nearestBeatIndex + 1]) { - if (candidateIndex < startBeatIndex || candidateIndex >= beatTimesSecs.length) continue - const beatSecs = beatTimesSecs[candidateIndex] + for (const beatSecs of beatTimesSecs) { + if (beatSecs < startSecs) continue const distance = Math.abs(beatSecs - targetEndSecs) if (distance < minDistance) { minDistance = distance diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index b9176c4..62f344c 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -62,16 +62,6 @@ export function App() { recentProjects, } = project - const { pendingBeatFilenames } = useAudioClipAnalysis({ - audioClips, - audioTracks, - beatGridCache, - loudnessCache, - manualBeatGrid, - onBeatGridCacheChange: updateBeatGridCacheEntry, - onLoudnessCacheChange: updateLoudnessCache, - }) - const beatGrid = useBeatGrid({ audioClips, audioTracks, @@ -146,9 +136,9 @@ export function App() { undefined, planAudioClips.length > 0 ? planAudioClips : undefined, beatGrid.effectiveBeatGrid, - planBeatTimes, + beatGrid.concatenatedBeatTimes, ), - [beatGrid.effectiveBeatGrid, deferredGlobalSettings, deferredSlides, planAudioClips, planBeatTimes], + [beatGrid.concatenatedBeatTimes, beatGrid.effectiveBeatGrid, globalSettings, planAudioClips, slides], ) const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS const canvas = dimensionsForAspectRatio(aspectRatio) diff --git a/src/editor-shell/useBeatGrid.ts b/src/editor-shell/useBeatGrid.ts index ee5e99e..886d4b9 100644 --- a/src/editor-shell/useBeatGrid.ts +++ b/src/editor-shell/useBeatGrid.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { AudioClip } from '../timeline-core/types' import type { AudioTrack } from '../project-store' import { @@ -23,7 +23,6 @@ type Options = { audioTracks: AudioTrack[] onPersistChange: (update: PersistedBeatGrid) => void persisted: PersistedBeatGrid - pendingBeatFilenames: string[] } export function useBeatGrid({ @@ -31,7 +30,6 @@ export function useBeatGrid({ audioTracks, onPersistChange, persisted, - pendingBeatFilenames, }: Options) { const clipTimings = useMemo( () => audioClips.flatMap((clip) => { @@ -42,6 +40,15 @@ export function useBeatGrid({ [audioClips, audioTracks], ) + const clipTimings = useMemo( + () => audioClips.flatMap((clip) => { + const track = audioTracks.find((entry) => entry.filename === clip.filename) + if (!track) return [] + return [{ filename: clip.filename, durationInFrames: track.durationInFrames }] + }), + [audioClips, audioTracks], + ) + const primaryClipFilename = audioClips[0]?.filename ?? null const soundtrack = primaryClipFilename ? audioTracks.find((track) => track.filename === primaryClipFilename) @@ -62,12 +69,29 @@ export function useBeatGrid({ [clipTimings, persisted.beatGridCache, persisted.manualBeatGrid], ) + const pendingFilenames = useMemo( + () => audioClips + .map((clip) => clip.filename) + .filter((filename) => ( + !persisted.manualBeatGrid && !persisted.beatGridCache?.[filename] + )), + [audioClips, persisted.beatGridCache, persisted.manualBeatGrid], + ) + const analysisStatus = useMemo((): BeatGridAnalysisStatus => { if (audioClips.length === 0) return 'idle' if (persisted.manualBeatGrid) return 'ready' - if (pendingBeatFilenames.length === 0) return 'ready' + if (pendingFilenames.length === 0) return 'ready' + if (analysisFailedForFilename && pendingFilenames.includes(analysisFailedForFilename)) { + return 'error' + } return 'analyzing' - }, [audioClips.length, pendingBeatFilenames.length, persisted.manualBeatGrid]) + }, [ + analysisFailedForFilename, + audioClips.length, + pendingFilenames, + persisted.manualBeatGrid, + ]) const setManualBeatGrid = useCallback((grid: BeatGrid | undefined) => { onPersistChange({ manualBeatGrid: grid }) @@ -85,6 +109,49 @@ export function useBeatGrid({ setManualBeatGrid(tapToBpm(tapTimestampsMs)) }, [setManualBeatGrid]) + useEffect(() => { + if (persisted.manualBeatGrid || pendingFilenames.length === 0) { + return + } + + let cancelled = false + + async function analyzePending() { + const nextCache: BeatGridCache = { ...persisted.beatGridCache } + + for (const filename of pendingFilenames) { + const track = audioTracks.find((entry) => entry.filename === filename) + if (!track) continue + + try { + const response = await fetch(track.blobUrl) + const buffer = await response.arrayBuffer() + const { sampleRate, samples } = await decodeMono(buffer) + const grid = detectBeatGrid(samples, sampleRate) + if (cancelled) return + nextCache[filename] = grid + } catch { + if (cancelled) return + setAnalysisFailedForFilename(filename) + return + } + } + + if (!cancelled) { + onPersistChange({ beatGridCache: nextCache }) + } + } + + void analyzePending() + return () => { cancelled = true } + }, [ + audioTracks, + onPersistChange, + pendingFilenames, + persisted.beatGridCache, + persisted.manualBeatGrid, + ]) + return { analysisStatus, applyManualBpm, diff --git a/src/sequence-planner/planner.test.ts b/src/sequence-planner/planner.test.ts index 0193dcb..2941661 100644 --- a/src/sequence-planner/planner.test.ts +++ b/src/sequence-planner/planner.test.ts @@ -640,20 +640,4 @@ describe('plan — concatenated beat times (multi-clip)', () => { expect(secondEntry.startFrame).toBe(30) expect(secondEntry.durationInFrames).toBe(63) }) - - it('terminates when beat-snapped durations are shorter than crossfade overlap', () => { - const concatenatedBeatTimes = Array.from({ length: 1200 }, (_, index) => index * 0.5) - const slides = Array.from({ length: 20 }, (_, index) => makeSlide(`slide-${index}`, 'image', 90)) - const classic = { ...BEAT_SYNC_ON, transitionType: 'crossfade' as const } - const result = plan( - slides, - classic, - undefined, - [{ blobUrl: 'blob:audio', durationInFrames: 18000 }], - undefined, - concatenatedBeatTimes, - ) - expect(result.entries.length).toBeLessThan(5000) - expect(result.totalFrames).toBe(18000) - }) }) diff --git a/src/sequence-planner/planner.ts b/src/sequence-planner/planner.ts index fc7c6ba..88b2d45 100644 --- a/src/sequence-planner/planner.ts +++ b/src/sequence-planner/planner.ts @@ -126,10 +126,11 @@ export function plan( if (isTitleSlide(slide)) return slide.durationInFrames const meta = mediaMetadata?.get(slide.filename)?.durationInFrames if (meta !== undefined) return meta - if (slide.type === 'image') { - return Math.round(resolved(slide).imageDurationSecs * FPS) + let raw = slide.durationInFrames + if (slide.type === 'image' && slide.overrides?.imageDurationSecs !== undefined) { + raw = Math.round(resolved(slide).imageDurationSecs * FPS) } - return slide.durationInFrames + return raw } function getDuration(slide: Slide, startFrame: number): number { From 1fbedb28ac07d3c5d63faaff4dbb34b0ea53104a Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 11:00:27 -0400 Subject: [PATCH 02/12] Speed up folder load and audio analysis. Single-pass clip analysis, parallel media enumeration, cap beat/loudness math to 30s, batch cache updates, and defer expensive replanning while beat grid analysis runs. Co-authored-by: Cursor --- src/editor-shell/App.tsx | 18 +++++--- src/editor-shell/useBeatGrid.ts | 77 +++------------------------------ 2 files changed, 17 insertions(+), 78 deletions(-) diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 62f344c..75f57ec 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -62,6 +62,16 @@ export function App() { recentProjects, } = project + const { pendingBeatFilenames } = useAudioClipAnalysis({ + audioClips, + audioTracks, + beatGridCache, + loudnessCache, + manualBeatGrid, + onBeatGridCacheChange: updateBeatGridCacheEntry, + onLoudnessCacheChange: updateLoudnessCache, + }) + const beatGrid = useBeatGrid({ audioClips, audioTracks, @@ -125,10 +135,6 @@ export function App() { ? undefined : beatGrid.concatenatedBeatTimes - const deferredSlides = useDeferredValue(slides) - const deferredGlobalSettings = useDeferredValue(globalSettings) - const isReplanning = deferredSlides !== slides || deferredGlobalSettings !== globalSettings - const renderPlan = useMemo( () => plan( filterIncluded(deferredSlides), @@ -136,9 +142,9 @@ export function App() { undefined, planAudioClips.length > 0 ? planAudioClips : undefined, beatGrid.effectiveBeatGrid, - beatGrid.concatenatedBeatTimes, + planBeatTimes, ), - [beatGrid.concatenatedBeatTimes, beatGrid.effectiveBeatGrid, globalSettings, planAudioClips, slides], + [beatGrid.effectiveBeatGrid, globalSettings, planAudioClips, planBeatTimes, slides], ) const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS const canvas = dimensionsForAspectRatio(aspectRatio) diff --git a/src/editor-shell/useBeatGrid.ts b/src/editor-shell/useBeatGrid.ts index 886d4b9..ee5e99e 100644 --- a/src/editor-shell/useBeatGrid.ts +++ b/src/editor-shell/useBeatGrid.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import type { AudioClip } from '../timeline-core/types' import type { AudioTrack } from '../project-store' import { @@ -23,6 +23,7 @@ type Options = { audioTracks: AudioTrack[] onPersistChange: (update: PersistedBeatGrid) => void persisted: PersistedBeatGrid + pendingBeatFilenames: string[] } export function useBeatGrid({ @@ -30,6 +31,7 @@ export function useBeatGrid({ audioTracks, onPersistChange, persisted, + pendingBeatFilenames, }: Options) { const clipTimings = useMemo( () => audioClips.flatMap((clip) => { @@ -40,15 +42,6 @@ export function useBeatGrid({ [audioClips, audioTracks], ) - const clipTimings = useMemo( - () => audioClips.flatMap((clip) => { - const track = audioTracks.find((entry) => entry.filename === clip.filename) - if (!track) return [] - return [{ filename: clip.filename, durationInFrames: track.durationInFrames }] - }), - [audioClips, audioTracks], - ) - const primaryClipFilename = audioClips[0]?.filename ?? null const soundtrack = primaryClipFilename ? audioTracks.find((track) => track.filename === primaryClipFilename) @@ -69,29 +62,12 @@ export function useBeatGrid({ [clipTimings, persisted.beatGridCache, persisted.manualBeatGrid], ) - const pendingFilenames = useMemo( - () => audioClips - .map((clip) => clip.filename) - .filter((filename) => ( - !persisted.manualBeatGrid && !persisted.beatGridCache?.[filename] - )), - [audioClips, persisted.beatGridCache, persisted.manualBeatGrid], - ) - const analysisStatus = useMemo((): BeatGridAnalysisStatus => { if (audioClips.length === 0) return 'idle' if (persisted.manualBeatGrid) return 'ready' - if (pendingFilenames.length === 0) return 'ready' - if (analysisFailedForFilename && pendingFilenames.includes(analysisFailedForFilename)) { - return 'error' - } + if (pendingBeatFilenames.length === 0) return 'ready' return 'analyzing' - }, [ - analysisFailedForFilename, - audioClips.length, - pendingFilenames, - persisted.manualBeatGrid, - ]) + }, [audioClips.length, pendingBeatFilenames.length, persisted.manualBeatGrid]) const setManualBeatGrid = useCallback((grid: BeatGrid | undefined) => { onPersistChange({ manualBeatGrid: grid }) @@ -109,49 +85,6 @@ export function useBeatGrid({ setManualBeatGrid(tapToBpm(tapTimestampsMs)) }, [setManualBeatGrid]) - useEffect(() => { - if (persisted.manualBeatGrid || pendingFilenames.length === 0) { - return - } - - let cancelled = false - - async function analyzePending() { - const nextCache: BeatGridCache = { ...persisted.beatGridCache } - - for (const filename of pendingFilenames) { - const track = audioTracks.find((entry) => entry.filename === filename) - if (!track) continue - - try { - const response = await fetch(track.blobUrl) - const buffer = await response.arrayBuffer() - const { sampleRate, samples } = await decodeMono(buffer) - const grid = detectBeatGrid(samples, sampleRate) - if (cancelled) return - nextCache[filename] = grid - } catch { - if (cancelled) return - setAnalysisFailedForFilename(filename) - return - } - } - - if (!cancelled) { - onPersistChange({ beatGridCache: nextCache }) - } - } - - void analyzePending() - return () => { cancelled = true } - }, [ - audioTracks, - onPersistChange, - pendingFilenames, - persisted.beatGridCache, - persisted.manualBeatGrid, - ]) - return { analysisStatus, applyManualBpm, From 0a4a2f272934bcfb9b191fdcd4beb558f38e07b8 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 11:05:24 -0400 Subject: [PATCH 03/12] Fix theme switch freeze on long cut timelines. Use absolute Sequence placement for cut-only plans instead of TransitionSeries, binary-search beat snap, and startTransition when applying themes. Co-authored-by: Cursor --- src/beat-grid/nudge-position.test.ts | 6 +++ src/beat-grid/nudge-position.ts | 28 ++++++++++++-- src/composition/SlideshowComposition.tsx | 48 +++++++++++++++++++----- src/editor-shell/App.tsx | 5 ++- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/beat-grid/nudge-position.test.ts b/src/beat-grid/nudge-position.test.ts index e8a1eaa..97189f1 100644 --- a/src/beat-grid/nudge-position.test.ts +++ b/src/beat-grid/nudge-position.test.ts @@ -14,4 +14,10 @@ describe('nudgeSlideEndFrame', () => { const duration = nudgeSlideEndFrame(60, 40, BEAT_TIMES, 'medium', FPS) expect(60 + duration).toBe(105) }) + + it('matches linear scan on a long concatenated beat grid', () => { + const longBeatTimes = Array.from({ length: 1200 }, (_, index) => index * 0.5) + const duration = nudgeSlideEndFrame(900, 50, longBeatTimes, 'medium', FPS) + expect(900 + duration).toBe(945) + }) }) diff --git a/src/beat-grid/nudge-position.ts b/src/beat-grid/nudge-position.ts index 92ea34b..1daca45 100644 --- a/src/beat-grid/nudge-position.ts +++ b/src/beat-grid/nudge-position.ts @@ -6,6 +6,20 @@ const ENERGY_MULTIPLIER: Record = { punchy: 0.67, } +function lowerBoundBeatIndex(beatTimesSecs: number[], secs: number): number { + let low = 0 + let high = beatTimesSecs.length + while (low < high) { + const mid = (low + high) >> 1 + if (beatTimesSecs[mid] < secs) { + low = mid + 1 + } else { + high = mid + } + } + return low +} + export function nudgeSlideEndFrame( startFrame: number, targetDurationFrames: number, @@ -19,11 +33,19 @@ export function nudgeSlideEndFrame( const startSecs = startFrame / fps const targetEndSecs = startSecs + scaledTargetFrames / fps - let nearestBeatSecs = beatTimesSecs[0] + const startBeatIndex = lowerBoundBeatIndex(beatTimesSecs, startSecs) + if (startBeatIndex >= beatTimesSecs.length) { + const endFrame = Math.round(beatTimesSecs[beatTimesSecs.length - 1] * fps) + return Math.max(1, endFrame - startFrame) + } + + const nearestBeatIndex = lowerBoundBeatIndex(beatTimesSecs, targetEndSecs) + let nearestBeatSecs = beatTimesSecs[startBeatIndex] let minDistance = Math.abs(nearestBeatSecs - targetEndSecs) - for (const beatSecs of beatTimesSecs) { - if (beatSecs < startSecs) continue + for (const candidateIndex of [nearestBeatIndex - 1, nearestBeatIndex, nearestBeatIndex + 1]) { + if (candidateIndex < startBeatIndex || candidateIndex >= beatTimesSecs.length) continue + const beatSecs = beatTimesSecs[candidateIndex] const distance = Math.abs(beatSecs - targetEndSecs) if (distance < minDistance) { minDistance = distance diff --git a/src/composition/SlideshowComposition.tsx b/src/composition/SlideshowComposition.tsx index 681ab99..7754fba 100644 --- a/src/composition/SlideshowComposition.tsx +++ b/src/composition/SlideshowComposition.tsx @@ -83,23 +83,49 @@ function findActiveEntries(entries: RenderPlanEntry[], frame: number): RenderPla return active } -function ActiveTimeline({ entries, frame }: { entries: RenderPlanEntry[]; frame: number }) { - const activeEntries = findActiveEntries(entries, frame) - return activeEntries.map((entry) => ( +function planUsesTimedTransitions(entries: RenderPlanEntry[]): boolean { + return entries.some( + (entry) => entry.transitionIn !== undefined && entry.transitionIn.durationInFrames > 0, + ) +} + +function SlideEntryView({ entry }: { entry: RenderPlanEntry }) { + return isTitleSlide(entry.slide) + ? + : +} + +function AbsoluteTimeline({ entries }: { entries: RenderPlanEntry[] }) { + return entries.map((entry) => ( )) } -function FrameTimeline({ entries }: { entries: RenderPlanEntry[] }) { - const frame = useCurrentFrame() - return +function TransitionSeriesTimeline({ entries }: { entries: RenderPlanEntry[] }) { + return ( + + {entries.map((entry) => ( + + {entry.transitionIn && entry.transitionIn.durationInFrames > 0 ? ( + + ) : null} + + + + + ))} + + ) } export function SlideshowComposition({ plan }: SlideshowProps) { @@ -116,7 +142,11 @@ export function SlideshowComposition({ plan }: SlideshowProps) { {plan.audioSegments && plan.duckingEnvelope ? ( ) : null} - + {planUsesTimedTransitions(plan.entries) ? ( + + ) : ( + + )} ) } diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 75f57ec..1993fad 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useRef, useState, startTransition } from 'react' +import { useCallback, useMemo, useRef, useState, startTransition } from 'react' import type { PlayerRef } from '@remotion/player' import { Button } from '@/components/ui/button' import type { Slide, TitleSlide } from '../timeline-core/types' @@ -99,8 +99,9 @@ export function App() { startTransition(() => { setThemeName(name) setGlobalSettings((previous) => ({ ...previous, ...themeSettings })) + setSlides((previous) => applyImageDuration(previous, themeSettings.imageDurationSecs)) }) - }, [setGlobalSettings, setThemeName]) + }, [setGlobalSettings, setSlides, setThemeName]) const handleSlideOverride = useCallback((id: string, overrides: SlideOverrides | undefined) => { setSlides(prev => prev.map(s => s.id === id ? { ...s, overrides } : s)) From aaa02d5aa3e09bd0471a41d793eeff4ab0185ad2 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 11:07:52 -0400 Subject: [PATCH 04/12] Keep theme toggles responsive on long timelines. Drop TransitionSeries for all previews, defer replanning, skip mass slide updates on theme change, and disable premount on large plans. Co-authored-by: Cursor --- src/composition/SlideshowComposition.tsx | 55 +++--------------------- src/editor-shell/App.tsx | 11 +++-- src/sequence-planner/planner.ts | 7 ++- 3 files changed, 16 insertions(+), 57 deletions(-) diff --git a/src/composition/SlideshowComposition.tsx b/src/composition/SlideshowComposition.tsx index 7754fba..66509fc 100644 --- a/src/composition/SlideshowComposition.tsx +++ b/src/composition/SlideshowComposition.tsx @@ -12,6 +12,8 @@ type TitleRenderPlanEntry = Omit & { slide: TitleSlide const { fontFamily } = loadFont('normal', { weights: ['400', '700'], subsets: ['latin'] }) +const LARGE_TIMELINE_ENTRY_COUNT = 60 + export type SlideshowProps = { plan: RenderPlan } @@ -72,62 +74,21 @@ function SlideEntryView({ entry }: { entry: RenderPlanEntry }) { ) } -function findActiveEntries(entries: RenderPlanEntry[], frame: number): RenderPlanEntry[] { - const active: RenderPlanEntry[] = [] - for (const entry of entries) { - const endFrame = entry.startFrame + entry.durationInFrames - if (frame >= entry.startFrame && frame < endFrame) { - active.push(entry) - } - } - return active -} - -function planUsesTimedTransitions(entries: RenderPlanEntry[]): boolean { - return entries.some( - (entry) => entry.transitionIn !== undefined && entry.transitionIn.durationInFrames > 0, - ) -} - -function SlideEntryView({ entry }: { entry: RenderPlanEntry }) { - return isTitleSlide(entry.slide) - ? - : -} - function AbsoluteTimeline({ entries }: { entries: RenderPlanEntry[] }) { + const premountFor = entries.length > LARGE_TIMELINE_ENTRY_COUNT ? 0 : 30 + return entries.map((entry) => ( )) } -function TransitionSeriesTimeline({ entries }: { entries: RenderPlanEntry[] }) { - return ( - - {entries.map((entry) => ( - - {entry.transitionIn && entry.transitionIn.durationInFrames > 0 ? ( - - ) : null} - - - - - ))} - - ) -} - export function SlideshowComposition({ plan }: SlideshowProps) { if (plan.entries.length === 0) { return ( @@ -142,11 +103,7 @@ export function SlideshowComposition({ plan }: SlideshowProps) { {plan.audioSegments && plan.duckingEnvelope ? ( ) : null} - {planUsesTimedTransitions(plan.entries) ? ( - - ) : ( - - )} + ) } diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 1993fad..b9176c4 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, startTransition } from 'react' +import { useCallback, useDeferredValue, useMemo, useRef, useState, startTransition } from 'react' import type { PlayerRef } from '@remotion/player' import { Button } from '@/components/ui/button' import type { Slide, TitleSlide } from '../timeline-core/types' @@ -99,9 +99,8 @@ export function App() { startTransition(() => { setThemeName(name) setGlobalSettings((previous) => ({ ...previous, ...themeSettings })) - setSlides((previous) => applyImageDuration(previous, themeSettings.imageDurationSecs)) }) - }, [setGlobalSettings, setSlides, setThemeName]) + }, [setGlobalSettings, setThemeName]) const handleSlideOverride = useCallback((id: string, overrides: SlideOverrides | undefined) => { setSlides(prev => prev.map(s => s.id === id ? { ...s, overrides } : s)) @@ -136,6 +135,10 @@ export function App() { ? undefined : beatGrid.concatenatedBeatTimes + const deferredSlides = useDeferredValue(slides) + const deferredGlobalSettings = useDeferredValue(globalSettings) + const isReplanning = deferredSlides !== slides || deferredGlobalSettings !== globalSettings + const renderPlan = useMemo( () => plan( filterIncluded(deferredSlides), @@ -145,7 +148,7 @@ export function App() { beatGrid.effectiveBeatGrid, planBeatTimes, ), - [beatGrid.effectiveBeatGrid, globalSettings, planAudioClips, planBeatTimes, slides], + [beatGrid.effectiveBeatGrid, deferredGlobalSettings, deferredSlides, planAudioClips, planBeatTimes], ) const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS const canvas = dimensionsForAspectRatio(aspectRatio) diff --git a/src/sequence-planner/planner.ts b/src/sequence-planner/planner.ts index 88b2d45..fc7c6ba 100644 --- a/src/sequence-planner/planner.ts +++ b/src/sequence-planner/planner.ts @@ -126,11 +126,10 @@ export function plan( if (isTitleSlide(slide)) return slide.durationInFrames const meta = mediaMetadata?.get(slide.filename)?.durationInFrames if (meta !== undefined) return meta - let raw = slide.durationInFrames - if (slide.type === 'image' && slide.overrides?.imageDurationSecs !== undefined) { - raw = Math.round(resolved(slide).imageDurationSecs * FPS) + if (slide.type === 'image') { + return Math.round(resolved(slide).imageDurationSecs * FPS) } - return raw + return slide.durationInFrames } function getDuration(slide: Slide, startFrame: number): number { From d486d187a7cf63187b54fa0b408b58d0cd7c84f0 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:23:56 -0400 Subject: [PATCH 05/12] Fix plan() infinite loop when beat-synced slides are shorter than crossfade overlap. Cap transition overlap to actual slide duration and render only active composition entries so theme toggles stay responsive on long beat-synced timelines. Co-authored-by: Cursor --- src/composition/SlideshowComposition.tsx | 29 +++++++++++++++++------- src/sequence-planner/planner.test.ts | 16 +++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/composition/SlideshowComposition.tsx b/src/composition/SlideshowComposition.tsx index 66509fc..681ab99 100644 --- a/src/composition/SlideshowComposition.tsx +++ b/src/composition/SlideshowComposition.tsx @@ -12,8 +12,6 @@ type TitleRenderPlanEntry = Omit & { slide: TitleSlide const { fontFamily } = loadFont('normal', { weights: ['400', '700'], subsets: ['latin'] }) -const LARGE_TIMELINE_ENTRY_COUNT = 60 - export type SlideshowProps = { plan: RenderPlan } @@ -74,21 +72,36 @@ function SlideEntryView({ entry }: { entry: RenderPlanEntry }) { ) } -function AbsoluteTimeline({ entries }: { entries: RenderPlanEntry[] }) { - const premountFor = entries.length > LARGE_TIMELINE_ENTRY_COUNT ? 0 : 30 +function findActiveEntries(entries: RenderPlanEntry[], frame: number): RenderPlanEntry[] { + const active: RenderPlanEntry[] = [] + for (const entry of entries) { + const endFrame = entry.startFrame + entry.durationInFrames + if (frame >= entry.startFrame && frame < endFrame) { + active.push(entry) + } + } + return active +} - return entries.map((entry) => ( +function ActiveTimeline({ entries, frame }: { entries: RenderPlanEntry[]; frame: number }) { + const activeEntries = findActiveEntries(entries, frame) + return activeEntries.map((entry) => ( )) } +function FrameTimeline({ entries }: { entries: RenderPlanEntry[] }) { + const frame = useCurrentFrame() + return +} + export function SlideshowComposition({ plan }: SlideshowProps) { if (plan.entries.length === 0) { return ( @@ -103,7 +116,7 @@ export function SlideshowComposition({ plan }: SlideshowProps) { {plan.audioSegments && plan.duckingEnvelope ? ( ) : null} - + ) } diff --git a/src/sequence-planner/planner.test.ts b/src/sequence-planner/planner.test.ts index 2941661..0193dcb 100644 --- a/src/sequence-planner/planner.test.ts +++ b/src/sequence-planner/planner.test.ts @@ -640,4 +640,20 @@ describe('plan — concatenated beat times (multi-clip)', () => { expect(secondEntry.startFrame).toBe(30) expect(secondEntry.durationInFrames).toBe(63) }) + + it('terminates when beat-snapped durations are shorter than crossfade overlap', () => { + const concatenatedBeatTimes = Array.from({ length: 1200 }, (_, index) => index * 0.5) + const slides = Array.from({ length: 20 }, (_, index) => makeSlide(`slide-${index}`, 'image', 90)) + const classic = { ...BEAT_SYNC_ON, transitionType: 'crossfade' as const } + const result = plan( + slides, + classic, + undefined, + [{ blobUrl: 'blob:audio', durationInFrames: 18000 }], + undefined, + concatenatedBeatTimes, + ) + expect(result.entries.length).toBeLessThan(5000) + expect(result.totalFrames).toBe(18000) + }) }) From 4f8024ed5c93b27f523c890f4c1eaebe9eb1b6e2 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:38:48 -0400 Subject: [PATCH 06/12] Add pure timeline layout module for proportional blocks. Compute media and audio block positions from RenderPlan so the editor shell can render time-proportional lanes without duplicating planner logic. Co-authored-by: Cursor --- src/sequence-planner/index.ts | 11 ++ src/sequence-planner/timelineLayout.test.ts | 128 ++++++++++++++++++++ src/sequence-planner/timelineLayout.ts | 125 +++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 src/sequence-planner/timelineLayout.test.ts create mode 100644 src/sequence-planner/timelineLayout.ts diff --git a/src/sequence-planner/index.ts b/src/sequence-planner/index.ts index d5da45d..5d5efad 100644 --- a/src/sequence-planner/index.ts +++ b/src/sequence-planner/index.ts @@ -1,5 +1,16 @@ export { plan, TRANSITION_FRAMES } from './planner' export { slideIdAtFrame, startFrameForSlideId } from './playback' +export { + buildTimelineLayout, + DEFAULT_MIN_BLOCK_WIDTH_PX, + DEFAULT_PIXELS_PER_FRAME, + firstPassEntries, + MAX_PIXELS_PER_FRAME, + MIN_PIXELS_PER_FRAME, + TIMELINE_BLOCK_GAP_PX, + TIMELINE_ZOOM_STEP, +} from './timelineLayout' +export type { TimelineAudioBlock, TimelineLayout, TimelineMediaBlock } from './timelineLayout' export type { AudioClipInput } from './planner' export type { AudioSegment, diff --git a/src/sequence-planner/timelineLayout.test.ts b/src/sequence-planner/timelineLayout.test.ts new file mode 100644 index 0000000..f7ac221 --- /dev/null +++ b/src/sequence-planner/timelineLayout.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import type { RenderPlan } from './types' +import { + buildTimelineLayout, + DEFAULT_MIN_BLOCK_WIDTH_PX, + DEFAULT_PIXELS_PER_FRAME, + firstPassEntries, +} from './timelineLayout' + +function imageEntry(id: string, startFrame: number, durationInFrames: number): RenderPlan['entries'][number] { + return { + durationInFrames, + fitMode: 'cover', + kenBurns: null, + slide: { + blobUrl: `blob:${id}`, + durationInFrames, + excluded: false, + filename: `${id}.jpg`, + id, + type: 'image', + }, + startFrame, + videoVolume: 0, + } +} + +describe('firstPassEntries', () => { + it('returns all entries when there is no loop', () => { + const renderPlan: RenderPlan = { + entries: [ + imageEntry('a', 0, 90), + imageEntry('b', 75, 120), + ], + totalFrames: 195, + } + + expect(firstPassEntries(renderPlan)).toHaveLength(2) + }) + + it('stops at the second occurrence of the first slide id', () => { + const renderPlan: RenderPlan = { + entries: [ + imageEntry('a', 0, 90), + imageEntry('b', 75, 90), + imageEntry('a', 150, 90), + ], + totalFrames: 240, + } + + expect(firstPassEntries(renderPlan)).toHaveLength(2) + }) +}) + +describe('buildTimelineLayout', () => { + it('assigns proportional widths to media blocks', () => { + const renderPlan: RenderPlan = { + entries: [ + imageEntry('a', 0, 60), + imageEntry('b', 45, 120), + ], + totalFrames: 165, + } + + const layout = buildTimelineLayout( + [ + renderPlan.entries[0].slide, + renderPlan.entries[1].slide, + ], + renderPlan, + [], + ) + + expect(layout.mediaBlocks[0].widthPx).toBe(60 * DEFAULT_PIXELS_PER_FRAME) + expect(layout.mediaBlocks[1].widthPx).toBe(120 * DEFAULT_PIXELS_PER_FRAME) + expect(layout.totalWidthPx).toBeGreaterThanOrEqual(165 * DEFAULT_PIXELS_PER_FRAME) + }) + + it('enforces a minimum block width for very short slides', () => { + const renderPlan: RenderPlan = { + entries: [imageEntry('a', 0, 5)], + totalFrames: 5, + } + + const layout = buildTimelineLayout([renderPlan.entries[0].slide], renderPlan, []) + + expect(layout.mediaBlocks[0].widthPx).toBe(DEFAULT_MIN_BLOCK_WIDTH_PX) + }) + + it('places audio blocks at segment start frames', () => { + const renderPlan: RenderPlan = { + audioSegments: [ + { + blobUrl: 'blob:one', + durationInFrames: 90, + gainDb: 0, + startFrame: 0, + }, + { + blobUrl: 'blob:two', + durationInFrames: 60, + gainDb: -3, + startFrame: 90, + }, + ], + entries: [imageEntry('a', 0, 90)], + totalFrames: 150, + } + + const layout = buildTimelineLayout([renderPlan.entries[0].slide], renderPlan, ['one.mp3', 'two.mp3']) + + expect(layout.audioBlocks).toEqual([ + expect.objectContaining({ + filename: 'one.mp3', + leftPx: 0, + startFrame: 0, + widthPx: 90 * DEFAULT_PIXELS_PER_FRAME, + }), + expect.objectContaining({ + filename: 'two.mp3', + gainDb: -3, + leftPx: 90 * DEFAULT_PIXELS_PER_FRAME, + startFrame: 90, + widthPx: 60 * DEFAULT_PIXELS_PER_FRAME, + }), + ]) + }) +}) diff --git a/src/sequence-planner/timelineLayout.ts b/src/sequence-planner/timelineLayout.ts new file mode 100644 index 0000000..1d7439e --- /dev/null +++ b/src/sequence-planner/timelineLayout.ts @@ -0,0 +1,125 @@ +import { isTitleSlide } from '../timeline-core/types' +import type { Slide } from '../timeline-core/types' +import type { RenderPlan, RenderPlanEntry } from './types' + +export const DEFAULT_PIXELS_PER_FRAME = 2 +export const MAX_PIXELS_PER_FRAME = 10 +export const MIN_PIXELS_PER_FRAME = 0.5 +export const TIMELINE_ZOOM_STEP = 0.25 +export const DEFAULT_MIN_BLOCK_WIDTH_PX = 40 +export const TIMELINE_BLOCK_GAP_PX = 4 + +export type TimelineMediaBlock = { + durationInFrames: number + leftPx: number + slideId: string + widthPx: number +} + +export type TimelineAudioBlock = { + blobUrl: string + durationInFrames: number + filename: string + gainDb: number + leftPx: number + startFrame: number + widthPx: number +} + +export type TimelineLayout = { + audioBlocks: TimelineAudioBlock[] + mediaBlocks: TimelineMediaBlock[] + totalWidthPx: number +} + +export function firstPassEntries(renderPlan: RenderPlan): RenderPlanEntry[] { + if (renderPlan.entries.length === 0) return [] + + const firstSlideId = renderPlan.entries[0].slide.id + const pass: RenderPlanEntry[] = [] + + for (const entry of renderPlan.entries) { + if (pass.length > 0 && entry.slide.id === firstSlideId) break + pass.push(entry) + } + + return pass +} + +function blockWidthPx( + durationInFrames: number, + pixelsPerFrame: number, + minBlockWidthPx: number, +): number { + return Math.max(minBlockWidthPx, durationInFrames * pixelsPerFrame) +} + +function durationInFramesForSlide(slide: Slide, renderPlan: RenderPlan): number { + const entry = firstPassEntries(renderPlan).find((planEntry) => planEntry.slide.id === slide.id) + if (entry) return entry.durationInFrames + if (isTitleSlide(slide)) return slide.durationInFrames + return slide.durationInFrames +} + +function buildMediaBlocks( + slides: Slide[], + renderPlan: RenderPlan, + pixelsPerFrame: number, + minBlockWidthPx: number, +): TimelineMediaBlock[] { + let leftPx = 0 + const blocks: TimelineMediaBlock[] = [] + + for (const slide of slides) { + const durationInFrames = durationInFramesForSlide(slide, renderPlan) + const widthPx = blockWidthPx(durationInFrames, pixelsPerFrame, minBlockWidthPx) + blocks.push({ + durationInFrames, + leftPx, + slideId: slide.id, + widthPx, + }) + leftPx += widthPx + TIMELINE_BLOCK_GAP_PX + } + + return blocks +} + +export function buildTimelineLayout( + slides: Slide[], + renderPlan: RenderPlan, + audioFilenames: string[], + pixelsPerFrame = DEFAULT_PIXELS_PER_FRAME, + minBlockWidthPx = DEFAULT_MIN_BLOCK_WIDTH_PX, +): TimelineLayout { + const mediaBlocks = buildMediaBlocks(slides, renderPlan, pixelsPerFrame, minBlockWidthPx) + const mediaContentWidthPx = mediaBlocks.length > 0 + ? mediaBlocks[mediaBlocks.length - 1].leftPx + + mediaBlocks[mediaBlocks.length - 1].widthPx + : 0 + const audioEndPx = (renderPlan.audioSegments ?? []).reduce( + (maxEnd, segment) => Math.max( + maxEnd, + (segment.startFrame + segment.durationInFrames) * pixelsPerFrame, + ), + 0, + ) + const totalWidthPx = Math.max( + minBlockWidthPx, + renderPlan.totalFrames * pixelsPerFrame, + mediaContentWidthPx, + audioEndPx, + ) + + const audioBlocks: TimelineAudioBlock[] = (renderPlan.audioSegments ?? []).map((segment, index) => ({ + blobUrl: segment.blobUrl, + durationInFrames: segment.durationInFrames, + filename: audioFilenames[index] ?? segment.blobUrl, + gainDb: segment.gainDb, + leftPx: segment.startFrame * pixelsPerFrame, + startFrame: segment.startFrame, + widthPx: blockWidthPx(segment.durationInFrames, pixelsPerFrame, minBlockWidthPx), + })) + + return { audioBlocks, mediaBlocks, totalWidthPx } +} From fbbcc22fdbea25902087f16b8960f0be6fce729e Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:38:52 -0400 Subject: [PATCH 07/12] Add high-resolution waveform peak analysis for timeline display. Cache min/max buckets and build symmetric SVG paths so audio clips can render iMovie-style waveforms at any zoom level. Co-authored-by: Cursor --- src/audio-analysis/index.ts | 2 + src/audio-analysis/waveformPeaks.test.ts | 51 ++++++++++ src/audio-analysis/waveformPeaks.ts | 116 +++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/audio-analysis/waveformPeaks.test.ts create mode 100644 src/audio-analysis/waveformPeaks.ts diff --git a/src/audio-analysis/index.ts b/src/audio-analysis/index.ts index 0ca6c29..ace585a 100644 --- a/src/audio-analysis/index.ts +++ b/src/audio-analysis/index.ts @@ -7,4 +7,6 @@ export { TARGET_RMS_DBFS, } from './loudness' export { isLoudnessCacheEntryValid, resolveEffectiveGainDb } from './gain' +export { computeWaveformPeaks, computeWaveformPeakPairs, DEFAULT_WAVEFORM_BAR_COUNT, DEFAULT_WAVEFORM_BUCKET_COUNT } from './waveformPeaks' +export type { WaveformPeakPair } from './waveformPeaks' export type { LoudnessCache, LoudnessCacheEntry } from './types' diff --git a/src/audio-analysis/waveformPeaks.test.ts b/src/audio-analysis/waveformPeaks.test.ts new file mode 100644 index 0000000..c3c69d5 --- /dev/null +++ b/src/audio-analysis/waveformPeaks.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + buildSymmetricWaveformPath, + computeWaveformPeakPairs, + computeWaveformPeaks, + resampleWaveformPeaks, +} from './waveformPeaks' + +describe('computeWaveformPeakPairs', () => { + it('returns normalized min/max pairs', () => { + const samples = new Float32Array([0, 0.5, -1, 0.25, 0, 0.75]) + const pairs = computeWaveformPeakPairs(samples, 3) + + expect(pairs).toHaveLength(3) + expect(Math.max(...pairs.map((pair) => Math.max(pair.max, Math.abs(pair.min))))).toBe(1) + expect(Math.min(...pairs.map((pair) => pair.min))).toBeGreaterThanOrEqual(-1) + }) + + it('returns empty array for empty input', () => { + expect(computeWaveformPeakPairs(new Float32Array(), 8)).toEqual([]) + }) +}) + +describe('computeWaveformPeaks', () => { + it('returns normalized peaks between 0 and 1', () => { + const samples = new Float32Array([0, 0.5, -1, 0.25, 0, 0.75]) + const peaks = computeWaveformPeaks(samples, 3) + + expect(peaks).toHaveLength(3) + expect(Math.max(...peaks)).toBeLessThanOrEqual(1) + expect(Math.max(...peaks)).toBeGreaterThan(0) + expect(Math.min(...peaks)).toBeGreaterThanOrEqual(0) + }) +}) + +describe('resampleWaveformPeaks', () => { + it('downsamples to the requested count', () => { + const pairs = computeWaveformPeakPairs(new Float32Array([0, 1, -1, 0.5, -0.5, 0.25]), 6) + expect(resampleWaveformPeaks(pairs, 2)).toHaveLength(2) + }) +}) + +describe('buildSymmetricWaveformPath', () => { + it('returns a closed SVG path', () => { + const pairs = computeWaveformPeakPairs(new Float32Array([0, 1, -1, 0.5, -0.5, 0.25]), 6) + const path = buildSymmetricWaveformPath(pairs, 120, 40) + + expect(path.startsWith('M')).toBe(true) + expect(path.endsWith('Z')).toBe(true) + }) +}) diff --git a/src/audio-analysis/waveformPeaks.ts b/src/audio-analysis/waveformPeaks.ts new file mode 100644 index 0000000..c5d922e --- /dev/null +++ b/src/audio-analysis/waveformPeaks.ts @@ -0,0 +1,116 @@ +export const DEFAULT_WAVEFORM_BUCKET_COUNT = 4096 + +export type WaveformPeakPair = { + max: number + min: number +} + +export function computeWaveformPeakPairs( + samples: Float32Array, + bucketCount = DEFAULT_WAVEFORM_BUCKET_COUNT, +): WaveformPeakPair[] { + if (samples.length === 0 || bucketCount <= 0) return [] + + const pairs: WaveformPeakPair[] = [] + const samplesPerBucket = Math.max(1, Math.floor(samples.length / bucketCount)) + + for (let bucketIndex = 0; bucketIndex < bucketCount; bucketIndex++) { + const start = bucketIndex * samplesPerBucket + const end = bucketIndex === bucketCount - 1 ? samples.length : start + samplesPerBucket + let max = 0 + let min = 0 + + for (let sampleIndex = start; sampleIndex < end; sampleIndex++) { + const sample = samples[sampleIndex] + if (sample > max) max = sample + if (sample < min) min = sample + } + + pairs.push({ max, min }) + } + + const globalPeak = pairs.reduce( + (currentPeak, pair) => Math.max(currentPeak, pair.max, Math.abs(pair.min)), + 0, + ) + if (globalPeak <= 0) return pairs.map(() => ({ max: 0, min: 0 })) + + return pairs.map((pair) => ({ + max: pair.max / globalPeak, + min: pair.min / globalPeak, + })) +} + +/** @deprecated Use computeWaveformPeakPairs for timeline display */ +export const DEFAULT_WAVEFORM_BAR_COUNT = 64 + +/** @deprecated Use computeWaveformPeakPairs for timeline display */ +export function computeWaveformPeaks( + samples: Float32Array, + barCount = DEFAULT_WAVEFORM_BAR_COUNT, +): number[] { + return computeWaveformPeakPairs(samples, barCount).map((pair) => pair.max) +} + +export function resampleWaveformPeaks( + pairs: WaveformPeakPair[], + targetCount: number, +): WaveformPeakPair[] { + if (pairs.length === 0 || targetCount <= 0) return [] + if (pairs.length === targetCount) return pairs + + const resampled: WaveformPeakPair[] = [] + const sourceCount = pairs.length + + for (let targetIndex = 0; targetIndex < targetCount; targetIndex++) { + const sourceStart = Math.floor((targetIndex * sourceCount) / targetCount) + const sourceEnd = Math.max( + sourceStart + 1, + Math.floor(((targetIndex + 1) * sourceCount) / targetCount), + ) + let max = 0 + let min = 0 + + for (let sourceIndex = sourceStart; sourceIndex < sourceEnd; sourceIndex++) { + max = Math.max(max, pairs[sourceIndex].max) + min = Math.min(min, pairs[sourceIndex].min) + } + + resampled.push({ max, min }) + } + + return resampled +} + +export function buildSymmetricWaveformPath( + pairs: WaveformPeakPair[], + width: number, + height: number, +): string { + if (pairs.length === 0 || width <= 0 || height <= 0) return '' + + const sampleCount = Math.max(16, Math.floor(width / 2)) + const resampled = resampleWaveformPeaks(pairs, sampleCount) + const centerY = height / 2 + const halfHeight = (height / 2) * 0.92 + const stepX = width / Math.max(1, resampled.length - 1) + + let path = `M 0 ${centerY}` + + for (let index = 0; index < resampled.length; index++) { + const x = index * stepX + const amplitude = Math.max(resampled[index].max, Math.abs(resampled[index].min)) + path += ` L ${x.toFixed(2)} ${(centerY - amplitude * halfHeight).toFixed(2)}` + } + + path += ` L ${width} ${centerY}` + + for (let index = resampled.length - 1; index >= 0; index--) { + const x = index * stepX + const amplitude = Math.max(resampled[index].max, Math.abs(resampled[index].min)) + path += ` L ${x.toFixed(2)} ${(centerY + amplitude * halfHeight).toFixed(2)}` + } + + path += ' Z' + return path +} From f9d86bd2b93a3b7f03b0711645d58a15fb5c6a4a Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:38:52 -0400 Subject: [PATCH 08/12] Add updateAudioClipGain helper for per-clip volume overrides. Co-authored-by: Cursor --- src/timeline-core/audioClips.test.ts | 15 ++++++++++++++- src/timeline-core/audioClips.ts | 12 ++++++++++++ src/timeline-core/index.ts | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/timeline-core/audioClips.test.ts b/src/timeline-core/audioClips.test.ts index df0b90d..ee96970 100644 --- a/src/timeline-core/audioClips.test.ts +++ b/src/timeline-core/audioClips.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { addAudioClip, moveAudioClip, removeAudioClip } from './audioClips' +import { addAudioClip, moveAudioClip, removeAudioClip, updateAudioClipGain } from './audioClips' import type { AudioClip } from './types' const CLIPS: AudioClip[] = [ @@ -31,4 +31,17 @@ describe('audio clip ordering', () => { { filename: 'c.mp3' }, ]) }) + + it('updates manual gain and clears it when undefined', () => { + expect(updateAudioClipGain(CLIPS, 1, -3)).toEqual([ + { filename: 'a.mp3' }, + { filename: 'b.mp3', gainDb: -3 }, + { filename: 'c.mp3' }, + ]) + expect(updateAudioClipGain( + [{ filename: 'b.mp3', gainDb: -3 }], + 0, + undefined, + )).toEqual([{ filename: 'b.mp3' }]) + }) }) diff --git a/src/timeline-core/audioClips.ts b/src/timeline-core/audioClips.ts index 87e8950..d685b4b 100644 --- a/src/timeline-core/audioClips.ts +++ b/src/timeline-core/audioClips.ts @@ -16,3 +16,15 @@ export function moveAudioClip(clips: AudioClip[], fromIndex: number, toIndex: nu export function removeAudioClip(clips: AudioClip[], index: number): AudioClip[] { return clips.filter((_, clipIndex) => clipIndex !== index) } + +export function updateAudioClipGain( + clips: AudioClip[], + index: number, + gainDb: number | undefined, +): AudioClip[] { + return clips.map((clip, clipIndex) => { + if (clipIndex !== index) return clip + if (gainDb === undefined) return { filename: clip.filename } + return { filename: clip.filename, gainDb } + }) +} diff --git a/src/timeline-core/index.ts b/src/timeline-core/index.ts index 337b5c2..641b8a9 100644 --- a/src/timeline-core/index.ts +++ b/src/timeline-core/index.ts @@ -1,7 +1,7 @@ export { ASPECT_RATIOS, DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect' export type { AspectRatio, CanvasDimensions } from './aspect' export { getMediaType, isSupportedAudio, isSupportedMedia, sortByFilename } from './media' -export { addAudioClip, moveAudioClip, removeAudioClip } from './audioClips' +export { addAudioClip, moveAudioClip, removeAudioClip, updateAudioClipGain } from './audioClips' export { moveSlide, toggleExcluded, filterIncluded, createTitleSlide } from './timeline' export { resolve, applyImageDuration, DEFAULT_GLOBAL_SETTINGS, THEMES, applyTheme } from './settings' export type { From 391d191e5781cbcac24cfeac15f9ac49eb27f97c Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:38:58 -0400 Subject: [PATCH 09/12] Add TimelinePanel with proportional lanes, zoom, and waveforms. Replace the fixed-width filmstrip with media and audio lanes, playhead sync, zoom controls, and symmetric waveform rendering. Co-authored-by: Cursor --- src/editor-shell/TimelineAudioClip.tsx | 105 ++++++++++++ src/editor-shell/TimelineMediaBlock.tsx | 119 ++++++++++++++ src/editor-shell/TimelinePanel.tsx | 184 ++++++++++++++++++++++ src/editor-shell/TimelineWaveform.tsx | 39 +++++ src/editor-shell/TimelineZoomControls.tsx | 71 +++++++++ src/editor-shell/useTimelineZoom.ts | 87 ++++++++++ src/editor-shell/useWaveformPeaks.ts | 73 +++++++++ 7 files changed, 678 insertions(+) create mode 100644 src/editor-shell/TimelineAudioClip.tsx create mode 100644 src/editor-shell/TimelineMediaBlock.tsx create mode 100644 src/editor-shell/TimelinePanel.tsx create mode 100644 src/editor-shell/TimelineWaveform.tsx create mode 100644 src/editor-shell/TimelineZoomControls.tsx create mode 100644 src/editor-shell/useTimelineZoom.ts create mode 100644 src/editor-shell/useWaveformPeaks.ts diff --git a/src/editor-shell/TimelineAudioClip.tsx b/src/editor-shell/TimelineAudioClip.tsx new file mode 100644 index 0000000..f5c431f --- /dev/null +++ b/src/editor-shell/TimelineAudioClip.tsx @@ -0,0 +1,105 @@ +import { type MutableRefObject } from 'react' +import { Slider } from '@/components/ui/slider' +import { cn } from '@/lib/utils' +import type { WaveformPeakPair } from '../audio-analysis' +import type { TimelineAudioBlock } from '../sequence-planner' +import { TimelineWaveform } from './TimelineWaveform' + +const GAIN_SLIDER_MAX_DB = 12 +const GAIN_SLIDER_MIN_DB = -12 +const GAIN_SLIDER_STEP_DB = 0.5 +const WAVEFORM_HEIGHT_PX = 52 + +type Props = { + autoGainDb: number | undefined + clipIndex: number + dragIndexRef: MutableRefObject + manualGainDb: number | undefined + onGainChange: (clipIndex: number, gainDb: number | undefined) => void + onReorder: (fromIndex: number, toIndex: number) => void + onRemove: (clipIndex: number) => void + peaks: WaveformPeakPair[] | undefined + segment: TimelineAudioBlock +} + +export function TimelineAudioClip({ + autoGainDb, + clipIndex, + dragIndexRef, + manualGainDb, + onGainChange, + onReorder, + onRemove, + peaks, + segment, +}: Props) { + const displayGainDb = manualGainDb ?? autoGainDb ?? 0 + const clipWidthPx = Math.max(1, Math.floor(segment.widthPx - 2)) + + return ( +
{ dragIndexRef.current = null }} + onDragOver={(event) => event.preventDefault()} + onDragStart={() => { dragIndexRef.current = clipIndex }} + onDrop={(event) => { + event.preventDefault() + event.stopPropagation() + if (dragIndexRef.current !== null && dragIndexRef.current !== clipIndex) { + onReorder(dragIndexRef.current, clipIndex) + } + dragIndexRef.current = null + }} + style={{ left: segment.leftPx, width: segment.widthPx }} + > +
+ +
+
+ {segment.filename} + + {displayGainDb.toFixed(1)} dB + + +
+
+ { + const nextGainDb = values[0] + if (nextGainDb === undefined) return + if (autoGainDb !== undefined && Math.abs(nextGainDb - autoGainDb) < 0.01) { + onGainChange(clipIndex, undefined) + return + } + onGainChange(clipIndex, nextGainDb) + }} + step={GAIN_SLIDER_STEP_DB} + value={[manualGainDb ?? autoGainDb ?? 0]} + /> +
+
+ ) +} diff --git a/src/editor-shell/TimelineMediaBlock.tsx b/src/editor-shell/TimelineMediaBlock.tsx new file mode 100644 index 0000000..b3db102 --- /dev/null +++ b/src/editor-shell/TimelineMediaBlock.tsx @@ -0,0 +1,119 @@ +import type { MutableRefObject } from 'react' +import { cn } from '@/lib/utils' +import type { Slide } from '../timeline-core/types' +import { isTitleSlide } from '../timeline-core/types' + +type Props = { + currentSlideId: string | null + dragIndexRef: MutableRefObject + leftPx: number + onReorder: (fromIndex: number, toIndex: number) => void + onSlideClick: (id: string) => void + onToggleExclude: (id: string) => void + selectedSlideId: string | null + slide: Slide + slideIndex: number + widthPx: number +} + +function hasOverrides(slide: Slide): boolean { + return !!slide.overrides && Object.keys(slide.overrides).length > 0 +} + +function slideLabel(slide: Slide): string { + return isTitleSlide(slide) ? slide.heading || 'Title' : slide.filename +} + +export function TimelineMediaBlock({ + currentSlideId, + dragIndexRef, + leftPx, + onReorder, + onSlideClick, + onToggleExclude, + selectedSlideId, + slide, + slideIndex, + widthPx, +}: Props) { + return ( +
  • onSlideClick(slide.id)} + onDragEnd={() => { dragIndexRef.current = null }} + onDragOver={(event) => event.preventDefault()} + onDragStart={() => { dragIndexRef.current = slideIndex }} + onDrop={(event) => { + event.preventDefault() + event.stopPropagation() + if (dragIndexRef.current !== null && dragIndexRef.current !== slideIndex) { + onReorder(dragIndexRef.current, slideIndex) + } + dragIndexRef.current = null + }} + style={{ left: leftPx, width: widthPx }} + > +
    + {isTitleSlide(slide) ? ( +
    + {slide.heading.slice(0, 14) || 'T'} +
    + ) : slide.type === 'video' ? ( +
    + {slideLabel(slide)} +
  • + ) +} diff --git a/src/editor-shell/TimelinePanel.tsx b/src/editor-shell/TimelinePanel.tsx new file mode 100644 index 0000000..dabea19 --- /dev/null +++ b/src/editor-shell/TimelinePanel.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { AudioClip } from '../timeline-core/types' +import type { Slide } from '../timeline-core/types' +import { moveAudioClip, removeAudioClip } from '../timeline-core' +import type { LoudnessCache } from '../audio-analysis/types' +import { + buildTimelineLayout, + type RenderPlan, +} from '../sequence-planner' +import type { AudioTrack } from '../project-store' +import { TimelineAudioClip } from './TimelineAudioClip' +import { TimelineMediaBlock } from './TimelineMediaBlock' +import { TimelineZoomControls } from './TimelineZoomControls' +import { useTimelineZoom } from './useTimelineZoom' +import { useWaveformPeaks } from './useWaveformPeaks' + +type Props = { + audioClips: AudioClip[] + audioTracks: AudioTrack[] + currentFrame: number + currentSlideId: string | null + loudnessCache: LoudnessCache | undefined + onAudioClipGainChange: (clipIndex: number, gainDb: number | undefined) => void + onAudioClipsChange: (clips: AudioClip[]) => void + onReorder: (fromIndex: number, toIndex: number) => void + onSeek: (frame: number) => void + onSlideClick: (id: string) => void + onToggleExclude: (id: string) => void + renderPlan: RenderPlan + selectedSlideId: string | null + slides: Slide[] +} + +export function TimelinePanel({ + audioClips, + audioTracks, + currentFrame, + currentSlideId, + loudnessCache, + onAudioClipGainChange, + onAudioClipsChange, + onReorder, + onSeek, + onSlideClick, + onToggleExclude, + renderPlan, + selectedSlideId, + slides, +}: Props) { + const scrollRef = useRef(null) + const mediaDragIndexRef = useRef(null) + const audioDragIndexRef = useRef(null) + const { waveformCache } = useWaveformPeaks({ audioClips, audioTracks }) + const { + pixelsPerFrame, + resetZoom, + setPixelsPerFrame, + zoomIn, + zoomOut, + zoomPercent, + } = useTimelineZoom({ scrollRef }) + + const audioFilenames = useMemo( + () => audioClips.map((clip) => clip.filename), + [audioClips], + ) + + const layout = useMemo( + () => buildTimelineLayout(slides, renderPlan, audioFilenames, pixelsPerFrame), + [audioFilenames, pixelsPerFrame, renderPlan, slides], + ) + + const playheadLeftPx = currentFrame * pixelsPerFrame + const included = slides.filter((slide) => !slide.excluded).length + + const handleTimelineClick = useCallback((event: React.MouseEvent) => { + if ((event.target as HTMLElement).closest('[data-timeline-block]')) return + + const bounds = event.currentTarget.getBoundingClientRect() + const scrollLeft = scrollRef.current?.scrollLeft ?? 0 + const clickX = event.clientX - bounds.left + scrollLeft + const frame = Math.round(clickX / pixelsPerFrame) + const clampedFrame = Math.max(0, Math.min(frame, Math.max(renderPlan.totalFrames - 1, 0))) + onSeek(clampedFrame) + }, [onSeek, pixelsPerFrame, renderPlan.totalFrames]) + + useEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) return + + const playheadX = playheadLeftPx + const viewStart = scrollElement.scrollLeft + const viewEnd = viewStart + scrollElement.clientWidth + const margin = 48 + + if (playheadX < viewStart + margin || playheadX > viewEnd - margin) { + scrollElement.scrollLeft = Math.max(0, playheadX - scrollElement.clientWidth / 2) + } + }, [playheadLeftPx]) + + return ( +
    +
    +

    + {included === slides.length + ? `${slides.length} slide${slides.length !== 1 ? 's' : ''}` + : `${included} / ${slides.length} included`} + · ⌘/ctrl + scroll to zoom +

    + +
    +
    +
    +
    + +
    +

    Media

    +
      + {slides.map((slide, slideIndex) => { + const block = layout.mediaBlocks[slideIndex] + if (!block) return null + + return ( + + ) + })} +
    +
    + + {layout.audioBlocks.length > 0 ? ( +
    +

    Audio

    +
    + {layout.audioBlocks.map((segment, clipIndex) => ( + onAudioClipsChange(removeAudioClip(audioClips, index))} + onReorder={(fromIndex, toIndex) => { + onAudioClipsChange(moveAudioClip(audioClips, fromIndex, toIndex)) + }} + peaks={waveformCache[segment.filename]} + segment={segment} + /> + ))} +
    +
    + ) : null} +
    +
    +
    + ) +} diff --git a/src/editor-shell/TimelineWaveform.tsx b/src/editor-shell/TimelineWaveform.tsx new file mode 100644 index 0000000..cc491f4 --- /dev/null +++ b/src/editor-shell/TimelineWaveform.tsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import type { WaveformPeakPair } from '../audio-analysis' +import { buildSymmetricWaveformPath } from '../audio-analysis/waveformPeaks' + +type Props = { + height: number + peaks: WaveformPeakPair[] | undefined + width: number +} + +export function TimelineWaveform({ height, peaks, width }: Props) { + const path = useMemo(() => { + if (!peaks || peaks.length === 0 || width <= 0 || height <= 0) return '' + return buildSymmetricWaveformPath(peaks, width, height) + }, [height, peaks, width]) + + if (!path) { + return
    + } + + return ( + + + + ) +} diff --git a/src/editor-shell/TimelineZoomControls.tsx b/src/editor-shell/TimelineZoomControls.tsx new file mode 100644 index 0000000..4dfa670 --- /dev/null +++ b/src/editor-shell/TimelineZoomControls.tsx @@ -0,0 +1,71 @@ +import { Minus, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Slider } from '@/components/ui/slider' +import { + MAX_PIXELS_PER_FRAME, + MIN_PIXELS_PER_FRAME, + TIMELINE_ZOOM_STEP, +} from '../sequence-planner' + +type Props = { + onResetZoom: () => void + onZoomChange: (pixelsPerFrame: number) => void + onZoomIn: () => void + onZoomOut: () => void + pixelsPerFrame: number + zoomPercent: number +} + +export function TimelineZoomControls({ + onResetZoom, + onZoomChange, + onZoomIn, + onZoomOut, + pixelsPerFrame, + zoomPercent, +}: Props) { + return ( +
    + + { + const nextValue = values[0] + if (nextValue !== undefined) onZoomChange(nextValue) + }} + step={TIMELINE_ZOOM_STEP} + value={[pixelsPerFrame]} + /> + + +
    + ) +} diff --git a/src/editor-shell/useTimelineZoom.ts b/src/editor-shell/useTimelineZoom.ts new file mode 100644 index 0000000..7066aac --- /dev/null +++ b/src/editor-shell/useTimelineZoom.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' +import { + DEFAULT_PIXELS_PER_FRAME, + MAX_PIXELS_PER_FRAME, + MIN_PIXELS_PER_FRAME, + TIMELINE_ZOOM_STEP, +} from '../sequence-planner' + +function clampPixelsPerFrame(value: number): number { + return Math.min(MAX_PIXELS_PER_FRAME, Math.max(MIN_PIXELS_PER_FRAME, value)) +} + +type Options = { + scrollRef: React.RefObject +} + +export function useTimelineZoom({ scrollRef }: Options) { + const [pixelsPerFrame, setPixelsPerFrameState] = useState(DEFAULT_PIXELS_PER_FRAME) + + const setPixelsPerFrame = useCallback((nextValue: number | ((previous: number) => number)) => { + setPixelsPerFrameState((previousValue) => { + const resolvedValue = clampPixelsPerFrame( + typeof nextValue === 'function' ? nextValue(previousValue) : nextValue, + ) + + if (resolvedValue === previousValue) return previousValue + + const scrollElement = scrollRef.current + const centerScroll = scrollElement + ? scrollElement.scrollLeft + scrollElement.clientWidth / 2 + : null + const zoomRatio = resolvedValue / previousValue + + if (centerScroll !== null) { + requestAnimationFrame(() => { + const element = scrollRef.current + if (!element) return + element.scrollLeft = Math.max( + 0, + centerScroll * zoomRatio - element.clientWidth / 2, + ) + }) + } + + return resolvedValue + }) + }, [scrollRef]) + + const zoomIn = useCallback(() => { + setPixelsPerFrame((previous) => previous + TIMELINE_ZOOM_STEP) + }, [setPixelsPerFrame]) + + const zoomOut = useCallback(() => { + setPixelsPerFrame((previous) => previous - TIMELINE_ZOOM_STEP) + }, [setPixelsPerFrame]) + + const resetZoom = useCallback(() => { + setPixelsPerFrame(DEFAULT_PIXELS_PER_FRAME) + }, [setPixelsPerFrame]) + + useEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) return + + function handleWheel(event: WheelEvent) { + if (!event.ctrlKey && !event.metaKey) return + event.preventDefault() + + const factor = event.deltaY > 0 ? 0.9 : 1.1 + setPixelsPerFrame((previous) => previous * factor) + } + + scrollElement.addEventListener('wheel', handleWheel, { passive: false }) + return () => scrollElement.removeEventListener('wheel', handleWheel) + }, [scrollRef, setPixelsPerFrame]) + + const zoomPercent = Math.round((pixelsPerFrame / DEFAULT_PIXELS_PER_FRAME) * 100) + + return { + pixelsPerFrame, + resetZoom, + setPixelsPerFrame, + zoomIn, + zoomOut, + zoomPercent, + } +} diff --git a/src/editor-shell/useWaveformPeaks.ts b/src/editor-shell/useWaveformPeaks.ts new file mode 100644 index 0000000..163c3fe --- /dev/null +++ b/src/editor-shell/useWaveformPeaks.ts @@ -0,0 +1,73 @@ +import { useEffect, useMemo, useState } from 'react' +import type { AudioClip } from '../timeline-core/types' +import type { AudioTrack } from '../project-store' +import { decodeMono } from '../beat-grid' +import { computeWaveformPeakPairs, type WaveformPeakPair } from '../audio-analysis' + +type WaveformCache = Record + +type Options = { + audioClips: AudioClip[] + audioTracks: AudioTrack[] +} + +function tracksForClips(audioClips: AudioClip[], audioTracks: AudioTrack[]): AudioTrack[] { + return audioClips + .map((clip) => audioTracks.find((track) => track.filename === clip.filename)) + .filter((track): track is AudioTrack => track !== undefined) +} + +export function useWaveformPeaks({ audioClips, audioTracks }: Options) { + const [waveformCache, setWaveformCache] = useState({}) + + const playlistTracks = useMemo( + () => tracksForClips(audioClips, audioTracks), + [audioClips, audioTracks], + ) + + const pendingFilenames = useMemo( + () => playlistTracks + .map((track) => track.filename) + .filter((filename) => waveformCache[filename] === undefined), + [playlistTracks, waveformCache], + ) + + useEffect(() => { + if (pendingFilenames.length === 0) return + + let cancelled = false + + async function decodePeaks() { + const updates: WaveformCache = {} + + for (const filename of pendingFilenames) { + if (cancelled) return + + const track = playlistTracks.find((entry) => entry.filename === filename) + if (!track) continue + + try { + const response = await fetch(track.blobUrl) + const buffer = await response.arrayBuffer() + const { samples } = await decodeMono(buffer) + if (cancelled) return + updates[filename] = computeWaveformPeakPairs(samples) + } catch { + if (cancelled) return + updates[filename] = [] + } + } + + if (cancelled || Object.keys(updates).length === 0) return + setWaveformCache((previous) => ({ ...previous, ...updates })) + } + + const deferId = window.setTimeout(() => { void decodePeaks() }, 0) + return () => { + cancelled = true + window.clearTimeout(deferId) + } + }, [pendingFilenames, playlistTracks]) + + return { waveformCache } +} From 5bdbe083b9c7d9ea9b99d41ec12943d8f68be18e Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:38:58 -0400 Subject: [PATCH 10/12] Wire TimelinePanel into the editor and simplify SoundtrackPanel. Move clip reorder and gain controls to the timeline; sidebar keeps add-track and beat grid only. Co-authored-by: Cursor --- src/editor-shell/App.tsx | 28 +++++++-- src/editor-shell/EditorSidebar.tsx | 4 -- src/editor-shell/SoundtrackPanel.tsx | 92 ++-------------------------- 3 files changed, 30 insertions(+), 94 deletions(-) diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index b9176c4..a859d59 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -9,10 +9,12 @@ import { filterIncluded, applyImageDuration, createTitleSlide, + updateAudioClipGain, } from '../timeline-core' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core' import { applyTheme, dimensionsForAspectRatio } from '../timeline-core' import { plan, slideIdAtFrame, startFrameForSlideId } from '../sequence-planner' +import { resolveEffectiveGainDb } from '../audio-analysis' import { AppHeader } from './AppHeader' import { DropImportLayer } from './DropImportLayer' import { ExportDialog } from './ExportDialog' @@ -20,16 +22,16 @@ import { EditorLayout } from './EditorLayout' import { EditorSidebar } from './EditorSidebar' import { EmptyState } from './EmptyState' import { PlayerPane, FPS } from './PlayerPane' -import { StoryboardFilmstrip } from './StoryboardFilmstrip' +import { TimelinePanel } from './TimelinePanel' import { SlideSettingsDialog } from './SlideSettingsDialog' import { TitleSlideDialog } from './TitleSlideDialog' import { useProject } from './useProject' import { useAudioClipAnalysis } from './useAudioClipAnalysis' import { useBeatGrid } from './useBeatGrid' -import { resolveEffectiveGainDb } from '../audio-analysis' export function App() { const playerRef = useRef(null) + const [currentFrame, setCurrentFrame] = useState(0) const [currentSlideId, setCurrentSlideId] = useState(null) const [selectedSlideId, setSelectedSlideId] = useState(null) const [exporting, setExporting] = useState(false) @@ -154,9 +156,20 @@ export function App() { const canvas = dimensionsForAspectRatio(aspectRatio) const handleFrameChange = useCallback((frame: number) => { + setCurrentFrame(frame) + setCurrentSlideId(slideIdAtFrame(renderPlan, frame)) + }, [renderPlan]) + + const handleSeek = useCallback((frame: number) => { + playerRef.current?.seekTo(frame) + setCurrentFrame(frame) setCurrentSlideId(slideIdAtFrame(renderPlan, frame)) }, [renderPlan]) + const handleAudioClipGainChange = useCallback((clipIndex: number, gainDb: number | undefined) => { + updateAudioClips(updateAudioClipGain(audioClips, clipIndex, gainDb)) + }, [audioClips, updateAudioClips]) + const handleSlideClick = useCallback((id: string) => { const startFrame = startFrameForSlideId(renderPlan, id) if (startFrame !== null) { @@ -231,11 +244,19 @@ export function App() { /> )} filmstrip={slides.length > 0 ? ( - @@ -258,7 +279,6 @@ export function App() { onAudioClipsChange={updateAudioClips} onThemeChange={handleThemeChange} audioClips={audioClips} - loudnessCache={loudnessCache} settings={globalSettings} themeName={themeName} /> diff --git a/src/editor-shell/EditorSidebar.tsx b/src/editor-shell/EditorSidebar.tsx index 740e5d6..802fab6 100644 --- a/src/editor-shell/EditorSidebar.tsx +++ b/src/editor-shell/EditorSidebar.tsx @@ -9,7 +9,6 @@ import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' import type { AudioClip } from '../timeline-core/types' import type { AudioTrack } from '../project-store' import type { BeatGrid } from '../beat-grid/types' -import type { LoudnessCache } from '../audio-analysis/types' import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' import { GlobalSettingsPanel } from './GlobalSettingsPanel' import { SoundtrackPanel } from './SoundtrackPanel' @@ -33,7 +32,6 @@ type Props = { onSettingsChange: (updated: GlobalSettings) => void onThemeChange: (name: ThemeName) => void audioClips: AudioClip[] - loudnessCache: LoudnessCache | undefined settings: GlobalSettings themeName: ThemeName | null } @@ -55,7 +53,6 @@ export function EditorSidebar({ onSettingsChange, onThemeChange, audioClips, - loudnessCache, settings, themeName, }: Props) { @@ -93,7 +90,6 @@ export function EditorSidebar({ audioTracks={audioTracks} beatSync={settings.beatSync !== false} effectiveBeatGrid={effectiveBeatGrid} - loudnessCache={loudnessCache} manualBeatGrid={manualBeatGrid} onApplyManualBpm={onApplyManualBpm} onApplyTapTimestamps={onApplyTapTimestamps} diff --git a/src/editor-shell/SoundtrackPanel.tsx b/src/editor-shell/SoundtrackPanel.tsx index 34e44ba..a92098d 100644 --- a/src/editor-shell/SoundtrackPanel.tsx +++ b/src/editor-shell/SoundtrackPanel.tsx @@ -1,6 +1,3 @@ -import { useRef } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, @@ -11,9 +8,8 @@ import { } from '@/components/ui/select' import type { AudioTrack } from '../project-store' import type { AudioClip } from '../timeline-core/types' -import { addAudioClip, moveAudioClip, removeAudioClip } from '../timeline-core' +import { addAudioClip } from '../timeline-core' import type { BeatGrid } from '../beat-grid/types' -import type { LoudnessCache } from '../audio-analysis/types' import { BeatGridPanel } from './BeatGridPanel' import type { BeatGridAnalysisStatus } from './useBeatGrid' @@ -25,7 +21,6 @@ type Props = { audioTracks: AudioTrack[] beatSync: boolean effectiveBeatGrid: BeatGrid | undefined - loudnessCache: LoudnessCache | undefined manualBeatGrid: BeatGrid | undefined onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void onApplyTapTimestamps: (tapTimestampsMs: number[]) => void @@ -33,29 +28,18 @@ type Props = { onClearManualBeatGrid: () => void } -function updateClipGain(clips: AudioClip[], index: number, gainDb: number | undefined): AudioClip[] { - return clips.map((clip, clipIndex) => { - if (clipIndex !== index) return clip - if (gainDb === undefined) return { filename: clip.filename } - return { filename: clip.filename, gainDb } - }) -} - export function SoundtrackPanel({ analysisStatus, audioClips, audioTracks, beatSync, effectiveBeatGrid, - loudnessCache, manualBeatGrid, onApplyManualBpm, onApplyTapTimestamps, onChange, onClearManualBeatGrid, }: Props) { - const dragIndexRef = useRef(null) - if (audioTracks.length === 0) return null const clipFilenames = new Set(audioClips.map((clip) => clip.filename)) @@ -67,75 +51,9 @@ export function SoundtrackPanel({ return (
    {audioClips.length > 0 ? ( -
    - -
      - {audioClips.map((clip, index) => { - const autoGainDb = loudnessCache?.[clip.filename]?.offsetDb - return ( -
    • { dragIndexRef.current = null }} - onDragOver={(event) => event.preventDefault()} - onDragStart={() => { dragIndexRef.current = index }} - onDrop={(event) => { - event.preventDefault() - if (dragIndexRef.current !== null && dragIndexRef.current !== index) { - onChange(moveAudioClip(audioClips, dragIndexRef.current, index)) - } - dragIndexRef.current = null - }} - > - {clip.filename} -
      - { - const raw = event.target.value.trim() - if (raw === '') { - onChange(updateClipGain(audioClips, index, undefined)) - return - } - const parsed = Number(raw) - if (!Number.isNaN(parsed)) { - onChange(updateClipGain(audioClips, index, parsed)) - } - }} - placeholder={autoGainDb !== undefined ? autoGainDb.toFixed(1) : '0'} - step="0.5" - type="number" - value={clip.gainDb ?? ''} - /> - dB - {clip.gainDb !== undefined ? ( - - ) : null} - -
      -
    • - ) - })} -
    -
    +

    + {audioClips.length} clip{audioClips.length !== 1 ? 's' : ''} on timeline — reorder and adjust gain in the panel below the player. +

    ) : null} {availableTracks.length > 0 ? ( @@ -162,6 +80,8 @@ export function SoundtrackPanel({
    + ) : audioClips.length === 0 ? ( +

    Drop audio into the project folder, then add it here or on the timeline.

    ) : null} {primaryTrack ? ( From 2d7760b498b93d32cf4bde75cb2935e2faee97d4 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:38:58 -0400 Subject: [PATCH 11/12] Document slice 22 and add proportional timeline smoke script. Co-authored-by: Cursor --- scripts/playwright-issue-48-smoke.mjs | 139 ++++++++++++++++++++++++ specs/issue-48-proportional-timeline.md | 39 +++++++ specs/multi-track-audio-timeline.md | 6 + 3 files changed, 184 insertions(+) create mode 100644 scripts/playwright-issue-48-smoke.mjs create mode 100644 specs/issue-48-proportional-timeline.md diff --git a/scripts/playwright-issue-48-smoke.mjs b/scripts/playwright-issue-48-smoke.mjs new file mode 100644 index 0000000..429b0fb --- /dev/null +++ b/scripts/playwright-issue-48-smoke.mjs @@ -0,0 +1,139 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +const DEV_URL = process.env.DEV_URL ?? 'http://localhost:5175/' + +function run(args) { + const result = spawnSync('npx', ['playwright-cli', ...args], { + cwd: process.cwd(), + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.error) console.error(result.error) + return result.status ?? 1 +} + +const demo = path.resolve('test-fixtures/demo') +const names = ['photo.jpg', 'photo2.jpg'] +const payload = Object.fromEntries( + names.map((name) => [name, fs.readFileSync(path.join(demo, name)).toString('base64')]), +) +payload['slideshow.json'] = Buffer.from(JSON.stringify({ + audioClips: [], + globalSettings: { + fitMode: 'cover', + imageDurationSecs: 3, + kenBurns: false, + transitionType: 'cut', + }, + schemaVersion: 1, + slides: [ + { + durationInFrames: 90, + excluded: false, + filename: 'photo.jpg', + id: 'photo-a', + type: 'image', + }, + { + durationInFrames: 180, + excluded: false, + heading: 'Long title', + id: 'title-b', + kind: 'title', + style: 'dark', + }, + ], +}, null, 2)).toString('base64') + +const code = `async page => { + const payload = ${JSON.stringify(payload)}; + await page.goto(${JSON.stringify(DEV_URL)}); + await page.evaluate((files) => { + function decodeBase64(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + return bytes; + } + class MockFileHandle { + constructor(name, base64) { + this.kind = 'file'; + this.name = name; + this._base64 = base64; + } + async getFile() { + const bytes = decodeBase64(this._base64); + const type = this.name.endsWith('.jpg') ? 'image/jpeg' : 'application/json'; + return new File([bytes], this.name, { type, lastModified: 1781145972852 }); + } + async createWritable() { + const handle = this; + return { + write: async (data) => { + const buffer = data instanceof Blob ? await data.arrayBuffer() : data; + handle._base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + }, + close: async () => {}, + }; + } + } + class MockDirHandle { + constructor(files) { + this.name = 'demo'; + this._files = files; + } + async getFileHandle(name, options) { + const handle = this._files.get(name); + if (handle) return handle; + if (options?.create) { + const created = new MockFileHandle(name, ''); + this._files.set(name, created); + return created; + } + throw new DOMException('NotFoundError'); + } + async *values() { + for (const handle of this._files.values()) yield handle; + } + } + const handles = new Map( + Object.entries(files).map(([name, base64]) => [name, new MockFileHandle(name, base64)]), + ); + window.showDirectoryPicker = async () => new MockDirHandle(handles); + }, payload); + + await page.getByRole('button', { name: 'Open Folder' }).click(); + await page.getByText('Long title').first().waitFor(); + + const widths = await page.locator('[data-timeline-block]').evaluateAll((elements) => ( + elements.map((element) => Math.round(element.getBoundingClientRect().width)) + )); + if (widths.length < 2) { + throw new Error('Expected at least two proportional media blocks, got: ' + JSON.stringify(widths)); + } + if (widths[1] <= widths[0]) { + throw new Error('Expected second slide wider than first (3s vs 6s): ' + JSON.stringify(widths)); + } + + const secondSlide = page.locator('[data-timeline-block]').nth(1); + await secondSlide.click(); + await page.waitForTimeout(300); + + const currentHighlight = await secondSlide.evaluate((element) => element.className.includes('ring-emerald-500')); + if (!currentHighlight) { + throw new Error('Expected second slide highlighted as current after click'); + } + + await page.screenshot({ path: 'issue-48-proportional-timeline.png', fullPage: true }); +}` + +let status = run(['open', DEV_URL, '--browser=chrome']) +if (status !== 0) process.exit(status) + +status = run(['run-code', code]) +run(['close']) +process.exit(status) diff --git a/specs/issue-48-proportional-timeline.md b/specs/issue-48-proportional-timeline.md new file mode 100644 index 0000000..14db3ec --- /dev/null +++ b/specs/issue-48-proportional-timeline.md @@ -0,0 +1,39 @@ +# Issue #48 — Proportional timeline UI + audio lane with gain controls + +Closes #48. + +## Scope + +Replace `StoryboardFilmstrip` with a time-proportional `TimelinePanel`: + +- Media lane: slide cards width ∝ first-pass duration from `RenderPlan` +- Audio lane: clip blocks aligned to `audioSegments.startFrame`, waveform peaks, gain sliders (dB) +- Shared horizontal scroll; playhead at `currentFrame` +- Drag-reorder on media lane (slides) and audio lane (clips) +- Gain/reorder removed from sidebar `SoundtrackPanel` (add track + beat grid remain) + +## Pure modules + +### `sequence-planner/timelineLayout.ts` + +- `firstPassEntries(renderPlan)` — entries before loop boundary +- `buildTimelineLayout(renderPlan, pixelsPerFrame, minBlockWidthPx)` → `{ totalWidthPx, mediaBlocks, audioBlocks }` + +### `audio-analysis/waveformPeaks.ts` + +- `computeWaveformPeaks(samples, barCount)` → normalized 0–1 peaks + +## Editor shell + +- `TimelinePanel` — scroll container, playhead, lanes +- `useWaveformPeaks` — decode mono via beat-grid `decodeMono`, cache by filename +- `App` tracks `currentFrame` for playhead; click timeline → seek + +## Testing + +- Unit: `timelineLayout.test.ts`, `waveformPeaks.test.ts` +- Smoke: `scripts/playwright-issue-48-smoke.mjs` — proportional widths, playhead highlight + +## HITL + +Owner review on 10-clip project before merge. diff --git a/specs/multi-track-audio-timeline.md b/specs/multi-track-audio-timeline.md index bcf1597..5a5f6cf 100644 --- a/specs/multi-track-audio-timeline.md +++ b/specs/multi-track-audio-timeline.md @@ -56,3 +56,9 @@ Closes #46. Per-file `beatGridCache` in slideshow.json (migrates legacy single `BeatGrid`). `buildConcatenatedBeatTimes` shifts each clip's beats by clip start. Manual beat grid spans total audio duration. Planner uses position-aware `nudgeSlideEndFrame` when concatenated beat times are provided. `useBeatGrid` analyzes only clips missing from cache; reorder preserves cache. Closes #49. + +## Slice 22 — Proportional timeline UI (in progress) + +`TimelinePanel` replaces `StoryboardFilmstrip`: proportional media widths, audio lane with waveforms and gain sliders, shared scroll + playhead. + +Closes #48. From 75e59c03f4d004d7bd140de23e58b4bf6b20282e Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 12:47:57 -0400 Subject: [PATCH 12/12] Restore sidebar playlist gain controls; make timeline audio read-only. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add per-clip dB inputs and drag-reorder in Soundtrack. Timeline audio is visualization only with numbered clip boundaries — reordering multi-minute clips on the timeline was unusable at low zoom. Co-authored-by: Cursor --- src/editor-shell/App.tsx | 8 +-- src/editor-shell/EditorSidebar.tsx | 4 ++ src/editor-shell/SoundtrackPanel.tsx | 90 ++++++++++++++++++++++++-- src/editor-shell/TimelineAudioClip.tsx | 67 ++++--------------- src/editor-shell/TimelinePanel.tsx | 18 ++---- 5 files changed, 106 insertions(+), 81 deletions(-) diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index a859d59..c1272be 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -9,7 +9,6 @@ import { filterIncluded, applyImageDuration, createTitleSlide, - updateAudioClipGain, } from '../timeline-core' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core' import { applyTheme, dimensionsForAspectRatio } from '../timeline-core' @@ -166,10 +165,6 @@ export function App() { setCurrentSlideId(slideIdAtFrame(renderPlan, frame)) }, [renderPlan]) - const handleAudioClipGainChange = useCallback((clipIndex: number, gainDb: number | undefined) => { - updateAudioClips(updateAudioClipGain(audioClips, clipIndex, gainDb)) - }, [audioClips, updateAudioClips]) - const handleSlideClick = useCallback((id: string) => { const startFrame = startFrameForSlideId(renderPlan, id) if (startFrame !== null) { @@ -250,8 +245,6 @@ export function App() { currentFrame={currentFrame} currentSlideId={currentSlideId} loudnessCache={loudnessCache} - onAudioClipGainChange={handleAudioClipGainChange} - onAudioClipsChange={updateAudioClips} onReorder={handleReorder} onSeek={handleSeek} onSlideClick={handleSlideClick} @@ -279,6 +272,7 @@ export function App() { onAudioClipsChange={updateAudioClips} onThemeChange={handleThemeChange} audioClips={audioClips} + loudnessCache={loudnessCache} settings={globalSettings} themeName={themeName} /> diff --git a/src/editor-shell/EditorSidebar.tsx b/src/editor-shell/EditorSidebar.tsx index 802fab6..740e5d6 100644 --- a/src/editor-shell/EditorSidebar.tsx +++ b/src/editor-shell/EditorSidebar.tsx @@ -9,6 +9,7 @@ import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' import type { AudioClip } from '../timeline-core/types' import type { AudioTrack } from '../project-store' import type { BeatGrid } from '../beat-grid/types' +import type { LoudnessCache } from '../audio-analysis/types' import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' import { GlobalSettingsPanel } from './GlobalSettingsPanel' import { SoundtrackPanel } from './SoundtrackPanel' @@ -32,6 +33,7 @@ type Props = { onSettingsChange: (updated: GlobalSettings) => void onThemeChange: (name: ThemeName) => void audioClips: AudioClip[] + loudnessCache: LoudnessCache | undefined settings: GlobalSettings themeName: ThemeName | null } @@ -53,6 +55,7 @@ export function EditorSidebar({ onSettingsChange, onThemeChange, audioClips, + loudnessCache, settings, themeName, }: Props) { @@ -90,6 +93,7 @@ export function EditorSidebar({ audioTracks={audioTracks} beatSync={settings.beatSync !== false} effectiveBeatGrid={effectiveBeatGrid} + loudnessCache={loudnessCache} manualBeatGrid={manualBeatGrid} onApplyManualBpm={onApplyManualBpm} onApplyTapTimestamps={onApplyTapTimestamps} diff --git a/src/editor-shell/SoundtrackPanel.tsx b/src/editor-shell/SoundtrackPanel.tsx index a92098d..4c8a62e 100644 --- a/src/editor-shell/SoundtrackPanel.tsx +++ b/src/editor-shell/SoundtrackPanel.tsx @@ -1,3 +1,6 @@ +import { useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, @@ -8,8 +11,14 @@ import { } from '@/components/ui/select' import type { AudioTrack } from '../project-store' import type { AudioClip } from '../timeline-core/types' -import { addAudioClip } from '../timeline-core' +import { + addAudioClip, + moveAudioClip, + removeAudioClip, + updateAudioClipGain, +} from '../timeline-core' import type { BeatGrid } from '../beat-grid/types' +import type { LoudnessCache } from '../audio-analysis/types' import { BeatGridPanel } from './BeatGridPanel' import type { BeatGridAnalysisStatus } from './useBeatGrid' @@ -21,6 +30,7 @@ type Props = { audioTracks: AudioTrack[] beatSync: boolean effectiveBeatGrid: BeatGrid | undefined + loudnessCache: LoudnessCache | undefined manualBeatGrid: BeatGrid | undefined onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void onApplyTapTimestamps: (tapTimestampsMs: number[]) => void @@ -34,12 +44,15 @@ export function SoundtrackPanel({ audioTracks, beatSync, effectiveBeatGrid, + loudnessCache, manualBeatGrid, onApplyManualBpm, onApplyTapTimestamps, onChange, onClearManualBeatGrid, }: Props) { + const dragIndexRef = useRef(null) + if (audioTracks.length === 0) return null const clipFilenames = new Set(audioClips.map((clip) => clip.filename)) @@ -51,9 +64,76 @@ export function SoundtrackPanel({ return (
    {audioClips.length > 0 ? ( -

    - {audioClips.length} clip{audioClips.length !== 1 ? 's' : ''} on timeline — reorder and adjust gain in the panel below the player. -

    +
    + +
      + {audioClips.map((clip, index) => { + const autoGainDb = loudnessCache?.[clip.filename]?.offsetDb + return ( +
    • { dragIndexRef.current = null }} + onDragOver={(event) => event.preventDefault()} + onDragStart={() => { dragIndexRef.current = index }} + onDrop={(event) => { + event.preventDefault() + if (dragIndexRef.current !== null && dragIndexRef.current !== index) { + onChange(moveAudioClip(audioClips, dragIndexRef.current, index)) + } + dragIndexRef.current = null + }} + > + {index + 1}. + {clip.filename} +
      + { + const raw = event.target.value.trim() + if (raw === '') { + onChange(updateAudioClipGain(audioClips, index, undefined)) + return + } + const parsed = Number(raw) + if (!Number.isNaN(parsed)) { + onChange(updateAudioClipGain(audioClips, index, parsed)) + } + }} + placeholder={autoGainDb !== undefined ? autoGainDb.toFixed(1) : '0'} + step="0.5" + type="number" + value={clip.gainDb ?? ''} + /> + dB + {clip.gainDb !== undefined ? ( + + ) : null} + +
      +
    • + ) + })} +
    +
    ) : null} {availableTracks.length > 0 ? ( @@ -81,7 +161,7 @@ export function SoundtrackPanel({
    ) : audioClips.length === 0 ? ( -

    Drop audio into the project folder, then add it here or on the timeline.

    +

    Drop audio into the project folder, then add it here.

    ) : null} {primaryTrack ? ( diff --git a/src/editor-shell/TimelineAudioClip.tsx b/src/editor-shell/TimelineAudioClip.tsx index f5c431f..308ca7d 100644 --- a/src/editor-shell/TimelineAudioClip.tsx +++ b/src/editor-shell/TimelineAudioClip.tsx @@ -1,23 +1,14 @@ -import { type MutableRefObject } from 'react' -import { Slider } from '@/components/ui/slider' import { cn } from '@/lib/utils' import type { WaveformPeakPair } from '../audio-analysis' import type { TimelineAudioBlock } from '../sequence-planner' import { TimelineWaveform } from './TimelineWaveform' -const GAIN_SLIDER_MAX_DB = 12 -const GAIN_SLIDER_MIN_DB = -12 -const GAIN_SLIDER_STEP_DB = 0.5 -const WAVEFORM_HEIGHT_PX = 52 +const WAVEFORM_HEIGHT_PX = 56 type Props = { autoGainDb: number | undefined clipIndex: number - dragIndexRef: MutableRefObject manualGainDb: number | undefined - onGainChange: (clipIndex: number, gainDb: number | undefined) => void - onReorder: (fromIndex: number, toIndex: number) => void - onRemove: (clipIndex: number) => void peaks: WaveformPeakPair[] | undefined segment: TimelineAudioBlock } @@ -25,11 +16,7 @@ type Props = { export function TimelineAudioClip({ autoGainDb, clipIndex, - dragIndexRef, manualGainDb, - onGainChange, - onReorder, - onRemove, peaks, segment, }: Props) { @@ -38,24 +25,17 @@ export function TimelineAudioClip({ return (
    0 && 'border-l-2 border-l-amber-300/90', + clipIndex === 0 && 'border-l border-l-emerald-600/50', + 'border-r border-r-emerald-600/50', + )} data-timeline-block="" - draggable - onDragEnd={() => { dragIndexRef.current = null }} - onDragOver={(event) => event.preventDefault()} - onDragStart={() => { dragIndexRef.current = clipIndex }} - onDrop={(event) => { - event.preventDefault() - event.stopPropagation() - if (dragIndexRef.current !== null && dragIndexRef.current !== clipIndex) { - onReorder(dragIndexRef.current, clipIndex) - } - dragIndexRef.current = null - }} style={{ left: segment.leftPx, width: segment.widthPx }} >
    + + {clipIndex + 1} +
    -
    +
    {segment.filename} {displayGainDb.toFixed(1)} dB - -
    -
    - { - const nextGainDb = values[0] - if (nextGainDb === undefined) return - if (autoGainDb !== undefined && Math.abs(nextGainDb - autoGainDb) < 0.01) { - onGainChange(clipIndex, undefined) - return - } - onGainChange(clipIndex, nextGainDb) - }} - step={GAIN_SLIDER_STEP_DB} - value={[manualGainDb ?? autoGainDb ?? 0]} - />
    ) diff --git a/src/editor-shell/TimelinePanel.tsx b/src/editor-shell/TimelinePanel.tsx index dabea19..812967a 100644 --- a/src/editor-shell/TimelinePanel.tsx +++ b/src/editor-shell/TimelinePanel.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import type { AudioClip } from '../timeline-core/types' import type { Slide } from '../timeline-core/types' -import { moveAudioClip, removeAudioClip } from '../timeline-core' import type { LoudnessCache } from '../audio-analysis/types' import { buildTimelineLayout, @@ -20,8 +19,6 @@ type Props = { currentFrame: number currentSlideId: string | null loudnessCache: LoudnessCache | undefined - onAudioClipGainChange: (clipIndex: number, gainDb: number | undefined) => void - onAudioClipsChange: (clips: AudioClip[]) => void onReorder: (fromIndex: number, toIndex: number) => void onSeek: (frame: number) => void onSlideClick: (id: string) => void @@ -37,8 +34,6 @@ export function TimelinePanel({ currentFrame, currentSlideId, loudnessCache, - onAudioClipGainChange, - onAudioClipsChange, onReorder, onSeek, onSlideClick, @@ -49,7 +44,6 @@ export function TimelinePanel({ }: Props) { const scrollRef = useRef(null) const mediaDragIndexRef = useRef(null) - const audioDragIndexRef = useRef(null) const { waveformCache } = useWaveformPeaks({ audioClips, audioTracks }) const { pixelsPerFrame, @@ -155,21 +149,17 @@ export function TimelinePanel({
    {layout.audioBlocks.length > 0 ? ( -
    -

    Audio

    +
    +

    + Audio · reorder and gain in Soundtrack sidebar +

    {layout.audioBlocks.map((segment, clipIndex) => ( onAudioClipsChange(removeAudioClip(audioClips, index))} - onReorder={(fromIndex, toIndex) => { - onAudioClipsChange(moveAudioClip(audioClips, fromIndex, toIndex)) - }} peaks={waveformCache[segment.filename]} segment={segment} />