From e8d56c72e75dfcb5a775495d505e18e1b7258d41 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 09:34:54 -0400 Subject: [PATCH] Wire beat grid detection into editor shell. Decode soundtrack on selection, pass effective beat grid to plan(), persist cache/manual overrides in slideshow.json, and expose beat sync controls plus BPM status in the sidebar. Co-authored-by: Cursor --- src/editor-shell/App.tsx | 34 +++- src/editor-shell/BeatGridPanel.tsx | 195 ++++++++++++++++++++++ src/editor-shell/EditorSidebar.tsx | 65 +++++--- src/editor-shell/GlobalSettingsPanel.tsx | 39 ++++- src/editor-shell/SoundtrackPanel.tsx | 78 ++++++--- src/editor-shell/slidePersistence.test.ts | 23 +++ src/editor-shell/slidePersistence.ts | 5 + src/editor-shell/useBeatGrid.ts | 111 ++++++++++++ src/editor-shell/useProject.ts | 33 +++- 9 files changed, 531 insertions(+), 52 deletions(-) create mode 100644 src/editor-shell/BeatGridPanel.tsx create mode 100644 src/editor-shell/useBeatGrid.ts diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 84c3430..9bb8463 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -24,6 +24,7 @@ import { StoryboardFilmstrip } from './StoryboardFilmstrip' import { SlideSettingsDialog } from './SlideSettingsDialog' import { TitleSlideDialog } from './TitleSlideDialog' import { useProject } from './useProject' +import { useBeatGrid } from './useBeatGrid' export function App() { const playerRef = useRef(null) @@ -37,13 +38,16 @@ export function App() { aspectRatio, setAspectRatio, audioTracks, + beatGridCache, globalSettings, setGlobalSettings, + manualBeatGrid, slides, setSlides, soundtrackFilename, themeName, setThemeName, + updateBeatGridPersist, loading, error, corruptError, @@ -52,6 +56,13 @@ export function App() { recentProjects, } = project + const beatGrid = useBeatGrid({ + audioTracks, + onPersistChange: updateBeatGridPersist, + persisted: { beatGridCache, manualBeatGrid }, + soundtrackFilename, + }) + const handleReorder = useCallback((fromIndex: number, toIndex: number) => { setSlides(prev => moveSlide(prev, fromIndex, toIndex)) }, [setSlides]) @@ -106,8 +117,9 @@ export function App() { durationInFrames: selectedSoundtrack.durationInFrames, } : undefined, + beatGrid.effectiveBeatGrid, ), - [globalSettings, selectedSoundtrack, slides], + [beatGrid.effectiveBeatGrid, globalSettings, selectedSoundtrack, slides], ) const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS const canvas = dimensionsForAspectRatio(aspectRatio) @@ -195,18 +207,24 @@ export function App() { ) : null} sidebar={ } /> diff --git a/src/editor-shell/BeatGridPanel.tsx b/src/editor-shell/BeatGridPanel.tsx new file mode 100644 index 0000000..e82309f --- /dev/null +++ b/src/editor-shell/BeatGridPanel.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { BeatGrid } from '../beat-grid/types' +import type { BeatGridAnalysisStatus } from './useBeatGrid' + +const MIN_TAP_COUNT = 8 + +type ManualBpmFieldsProps = { + defaultBpm?: number + defaultOffsetSecs?: number + manualBeatGrid: BeatGrid | undefined + onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void + onClearManualBeatGrid: () => void +} + +function ManualBpmFields({ + defaultBpm, + defaultOffsetSecs = 0, + manualBeatGrid, + onApplyManualBpm, + onClearManualBeatGrid, +}: ManualBpmFieldsProps) { + const [bpmInput, setBpmInput] = useState(defaultBpm ? String(Math.round(defaultBpm)) : '') + const [offsetInput, setOffsetInput] = useState(String(Number(defaultOffsetSecs.toFixed(2)))) + + function handleApplyManualBpm() { + const bpm = parseFloat(bpmInput) + const offset = parseFloat(offsetInput) + if (Number.isNaN(bpm) || bpm <= 0) return + if (Number.isNaN(offset) || offset < 0) return + onApplyManualBpm(bpm, offset) + } + + return ( +
+ +
+ setBpmInput(event.target.value)} + placeholder="120" + type="number" + value={bpmInput} + /> + setOffsetInput(event.target.value)} + placeholder="0" + step={0.01} + type="number" + value={offsetInput} + /> + s offset +
+
+ + {manualBeatGrid ? ( + + ) : null} +
+
+ ) +} + +type Props = { + analysisStatus: BeatGridAnalysisStatus + beatSync: boolean + effectiveBeatGrid: BeatGrid | undefined + manualBeatGrid: BeatGrid | undefined + onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void + onApplyTapTimestamps: (tapTimestampsMs: number[]) => void + onClearManualBeatGrid: () => void + soundtrackBlobUrl: string +} + +function formatStatus( + analysisStatus: BeatGridAnalysisStatus, + beatSync: boolean, + effectiveBeatGrid: BeatGrid | undefined, + manualBeatGrid: BeatGrid | undefined, +): string { + if (analysisStatus === 'analyzing') return 'Analyzing soundtrack…' + if (analysisStatus === 'error') return 'Could not detect tempo' + if (!effectiveBeatGrid) return 'No tempo detected yet' + const bpm = `${Math.round(effectiveBeatGrid.bpm)} BPM` + const source = manualBeatGrid ? 'manual' : 'detected' + const sync = beatSync ? 'syncing to beat' : 'beat sync off' + return `${bpm} · ${source} · ${sync}` +} + +export function BeatGridPanel({ + analysisStatus, + beatSync, + effectiveBeatGrid, + manualBeatGrid, + onApplyManualBpm, + onApplyTapTimestamps, + onClearManualBeatGrid, + soundtrackBlobUrl, +}: Props) { + const audioRef = useRef(null) + const [tapCount, setTapCount] = useState(0) + const [tapping, setTapping] = useState(false) + const tapTimestampsRef = useRef([]) + + useEffect(() => () => { + audioRef.current?.pause() + }, []) + + const stopTapping = useCallback(() => { + setTapping(false) + audioRef.current?.pause() + tapTimestampsRef.current = [] + setTapCount(0) + }, []) + + const handleStartTapping = useCallback(async () => { + const audio = audioRef.current + if (!audio) return + tapTimestampsRef.current = [] + setTapCount(0) + setTapping(true) + audio.currentTime = 0 + try { + await audio.play() + } catch { + setTapping(false) + } + }, []) + + const handleTap = useCallback(() => { + const audio = audioRef.current + if (!audio || !tapping) return + const timestamps = [...tapTimestampsRef.current, audio.currentTime * 1000] + tapTimestampsRef.current = timestamps + setTapCount(timestamps.length) + if (timestamps.length >= MIN_TAP_COUNT) { + stopTapping() + onApplyTapTimestamps(timestamps) + } + }, [onApplyTapTimestamps, stopTapping, tapping]) + + const manualDefaultsKey = manualBeatGrid + ? `manual-${manualBeatGrid.bpm}-${manualBeatGrid.firstBeatOffsetSecs}` + : `detected-${effectiveBeatGrid?.bpm ?? 'none'}-${effectiveBeatGrid?.firstBeatOffsetSecs ?? 0}` + + return ( +
+

+ {formatStatus(analysisStatus, beatSync, effectiveBeatGrid, manualBeatGrid)} +

+ +
+ ) +} diff --git a/src/editor-shell/EditorSidebar.tsx b/src/editor-shell/EditorSidebar.tsx index 069296a..2a06a58 100644 --- a/src/editor-shell/EditorSidebar.tsx +++ b/src/editor-shell/EditorSidebar.tsx @@ -7,39 +7,53 @@ import { import { Button } from '@/components/ui/button' import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' import type { AudioTrack } from '../project-store' +import type { BeatGrid } from '../beat-grid/types' import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' import { GlobalSettingsPanel } from './GlobalSettingsPanel' import { SoundtrackPanel } from './SoundtrackPanel' import { JamendoPanel } from './JamendoPanel' +import type { BeatGridAnalysisStatus } from './useBeatGrid' type Props = { + analysisStatus: BeatGridAnalysisStatus aspectRatio: AspectRatio + audioTracks: AudioTrack[] + effectiveBeatGrid: BeatGrid | undefined + jamendoClientId: string | undefined + manualBeatGrid: BeatGrid | undefined + onAddTitleSlide: () => void + onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void + onApplyTapTimestamps: (tapTimestampsMs: number[]) => void onAspectRatioChange: (ratio: AspectRatio) => void - settings: GlobalSettings - themeName: ThemeName | null + onClearManualBeatGrid: () => void + onJamendoAdd: (track: JamendoTrack, attribution: JamendoAttribution) => Promise onSettingsChange: (updated: GlobalSettings) => void + onSoundtrackChange: (filename: string | null) => void onThemeChange: (name: ThemeName) => void - audioTracks: AudioTrack[] + settings: GlobalSettings soundtrackFilename: string | null - onSoundtrackChange: (filename: string | null) => void - jamendoClientId: string | undefined - onJamendoAdd: (track: JamendoTrack, attribution: JamendoAttribution) => Promise - onAddTitleSlide: () => void + themeName: ThemeName | null } export function EditorSidebar({ + analysisStatus, aspectRatio, + audioTracks, + effectiveBeatGrid, + jamendoClientId, + manualBeatGrid, + onAddTitleSlide, + onApplyManualBpm, + onApplyTapTimestamps, onAspectRatioChange, - settings, - themeName, + onClearManualBeatGrid, + onJamendoAdd, onSettingsChange, + onSoundtrackChange, onThemeChange, - audioTracks, + settings, soundtrackFilename, - onSoundtrackChange, - jamendoClientId, - onJamendoAdd, - onAddTitleSlide, + themeName, }: Props) { return (