diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index afdc56e7..13d9514b 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -451,9 +451,11 @@ const VideoPlayback = forwardRef( const clipRegionsRef = useRef(clipRegions); const clipPlaybackRef = useRef | null>(null); const onPlaybackErrorRef = useRef(onError); - onPlaybackErrorRef.current = onError; const timelineTimeRef = useRef(timelineTime); - timelineTimeRef.current = timelineTime; + useEffect(() => { + onPlaybackErrorRef.current = onError; + timelineTimeRef.current = timelineTime; + }, [onError, timelineTime]); const currentTimeRef = useRef(0); useEffect(() => { clipRegionsRef.current = clipRegions; diff --git a/src/components/video-editor/audio/useAudioPreviewSync.test.ts b/src/components/video-editor/audio/useAudioPreviewSync.test.ts new file mode 100644 index 00000000..9b03c595 --- /dev/null +++ b/src/components/video-editor/audio/useAudioPreviewSync.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAudioPreviewSync } from "./useAudioPreviewSync"; + +const harness = vi.hoisted(() => ({ + effects: [] as (() => void | (() => void))[], + refs: [] as { current: unknown }[], + index: 0, + loaded: vi.fn(), +})); +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useEffect: (effect: () => void) => harness.effects.push(effect), + useRef: (value: unknown) => { + const index = harness.index++; + harness.refs[index] ??= { current: value }; + return harness.refs[index]; + }, + useState: () => [0, harness.loaded], +})); +vi.mock("@/lib/exporter/localMediaSource", () => ({ + resolveMediaElementSource: async () => ({ src: "file:///audio.wav", revoke: vi.fn() }), +})); +vi.mock("../videoPlayback/playbackRate", () => ({ + supportsPreviewPlaybackRate: (rate: number) => rate <= 16, +})); + +afterEach(() => { + vi.unstubAllGlobals(); + harness.effects = []; + harness.refs = []; + harness.index = 0; + harness.loaded.mockClear(); +}); + +describe("source preview playback ownership", () => { + it.each([ + { + name: "playing clip", + muted: false, + playing: true, + rate: 1, + time: 1, + delay: 0, + plays: true, + }, + { + name: "gap or muted clip", + muted: true, + playing: true, + rate: 1, + time: 1, + delay: 0, + plays: false, + }, + { + name: "paused clip", + muted: false, + playing: false, + rate: 1, + time: 1, + delay: 0, + plays: false, + }, + { + name: "unsupported rate", + muted: false, + playing: true, + rate: 20, + time: 1, + delay: 0, + plays: false, + }, + { + name: "before audio start", + muted: false, + playing: true, + rate: 1, + time: 0, + delay: 1000, + plays: false, + }, + { + name: "media end", + muted: false, + playing: true, + rate: 1, + time: 10, + delay: 0, + plays: false, + }, + ])("checks $name after a late source load", async ({ + muted, + playing, + rate, + time, + delay, + plays, + }) => { + const audio = { + src: "", + dataset: {}, + duration: 10, + currentTime: 0, + playbackRate: 1, + paused: true, + volume: 1, + load: vi.fn(), + pause: vi.fn(), + play: vi.fn().mockResolvedValue(undefined), + }; + vi.stubGlobal("Audio", function () { + return audio; + }); + vi.stubGlobal( + "AudioContext", + class { + state = "running"; + destination = {}; + createGain() { + return { gain: { value: 1 }, connect: vi.fn() }; + } + }, + ); + // Execute mocked effects explicitly so the asynchronous load can finish between syncs. + useAudioPreviewSync({ + audioRegions: [], + previewVolume: 1, + isPlaying: playing, + currentTime: time, + timelineTime: time, + duration: 10, + sourcePlaybackRate: rate, + previewSourceAudioFallbackPaths: ["/audio.system.wav"], + sourceAudioFallbackStartDelayMsByPath: { "/audio.system.wav": delay }, + sourceAudioResourceVersion: 0, + isCurrentClipMuted: muted, + getSourceTrackPreviewGain: () => 1, + onSourceFallbackLoadError: vi.fn(), + }); + for (const effect of harness.effects) effect(); + await Promise.resolve(); + expect(harness.loaded).toHaveBeenCalledOnce(); + expect(audio.play).not.toHaveBeenCalled(); + harness.effects.at(-1)?.(); + await Promise.resolve(); + expect(audio.play).toHaveBeenCalledTimes(plays ? 1 : 0); + }); +}); diff --git a/src/components/video-editor/audio/useAudioPreviewSync.ts b/src/components/video-editor/audio/useAudioPreviewSync.ts index 78c1b02c..af64230b 100644 --- a/src/components/video-editor/audio/useAudioPreviewSync.ts +++ b/src/components/video-editor/audio/useAudioPreviewSync.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildResolvedAudioPlan } from "@/lib/exporter/audioRoutingEngine"; import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; import { @@ -80,6 +80,7 @@ export function useAudioPreviewSync({ const sourceAudioMasterGainRef = useRef(null); const sourceAudioResumePromiseRef = useRef | null>(null); const lastSourceAudioSyncTimeRef = useRef(null); + const [sourceLoadVersion, setSourceLoadVersion] = useState(0); const ensureSourceAudioContext = useCallback(() => { if (!sourceAudioContextRef.current) { @@ -111,10 +112,6 @@ export function useAudioPreviewSync({ const playSourceAudioPreview = useCallback(() => { void ensureSourceAudioRunning(); - for (const audio of sourceAudioElementsRef.current.values()) { - if (!audio.src) continue; - audio.play().catch(() => undefined); - } }, [ensureSourceAudioRunning]); useEffect(() => { @@ -242,9 +239,7 @@ export function useAudioPreviewSync({ sourceAudioResourceVersion, ); latestAudio.load(); - if (isPlaying) { - playSourceAudioPreview(); - } + setSourceLoadVersion((version) => version + 1); } catch (error) { const latestAudio = existing.get(audioPath); if ( @@ -290,13 +285,11 @@ export function useAudioPreviewSync({ } }, [ getSourceTrackPreviewGain, - isPlaying, isCurrentClipMuted, onSourceFallbackLoadError, resolvedSourceTracks, sourceAudioResourceVersion, previewVolume, - playSourceAudioPreview, ]); useEffect(() => { @@ -382,6 +375,9 @@ export function useAudioPreviewSync({ lastSourceAudioSyncTimeRef.current = null; return; } + let cancelled = false; + // A newly resolved source must pass the same playback checks as timeline updates. + void sourceLoadVersion; const previousTimelineTime = lastSourceAudioSyncTimeRef.current; const timelineJumped = @@ -396,6 +392,7 @@ export function useAudioPreviewSync({ } for (const audio of sourceAudioElementsRef.current.values()) { + if (!audio.src) continue; if (!supportsPreviewPlaybackRate(sourcePlaybackRate)) { audio.pause(); continue; @@ -455,7 +452,7 @@ export function useAudioPreviewSync({ const atEnd = audioDuration !== null && targetTime >= audioDuration; if (isPlaying && !isCurrentClipMuted && !beforeAudioStart && !atEnd) { void ensureSourceAudioRunning().then(() => { - audio.play().catch(() => undefined); + if (!cancelled) audio.play().catch(() => undefined); }); } else if (!audio.paused) { audio.pause(); @@ -463,7 +460,11 @@ export function useAudioPreviewSync({ } lastSourceAudioSyncTimeRef.current = currentTime; + return () => { + cancelled = true; + }; }, [ + sourceLoadVersion, currentTime, duration, sourcePlaybackRate, @@ -476,18 +477,5 @@ export function useAudioPreviewSync({ ensureSourceAudioRunning, ]); - useEffect(() => { - if (!isPlaying || resolvedSourceTracks.length === 0) { - return; - } - void ensureSourceAudioRunning().then(() => { - for (const audio of sourceAudioElementsRef.current.values()) { - if (audio.paused) { - audio.play().catch(() => undefined); - } - } - }); - }, [isPlaying, resolvedSourceTracks.length, ensureSourceAudioRunning]); - return { playSourceAudioPreview }; } diff --git a/src/components/video-editor/layout/EditorTimelinePanel.tsx b/src/components/video-editor/layout/EditorTimelinePanel.tsx index 6f960f9f..2d1ba28d 100644 --- a/src/components/video-editor/layout/EditorTimelinePanel.tsx +++ b/src/components/video-editor/layout/EditorTimelinePanel.tsx @@ -106,7 +106,7 @@ export function EditorTimelinePanel(props: Props) { (cue) => cue.sourceCueId === timeline.selectedCaptionId && currentTime * 1000 >= cue.startMs && - currentTime * 1000 <= cue.endMs, + currentTime * 1000 < cue.endMs, )?.id ?? null } onSelectCaption={(id) => { diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts index 9f06dd69..0ae7493b 100644 --- a/src/components/video-editor/project/useProjectLibraryController.ts +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -5,13 +5,9 @@ import { toFileUrl } from "../projectPersistence"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useProjectState } from "../state/useProjectState"; import type { useTimelineState } from "../state/useTimelineState"; -import { - findClipAtTimelineTime, - getClipSourceEndMs, - getClipSourceStartMs, - type SpeedRegion, -} from "../types"; +import { getClipSourceEndMs, getClipSourceStartMs, type SpeedRegion } from "../types"; import type { VideoPlaybackRef } from "../VideoPlayback"; +import { findPreviewClipAtTimelineTime } from "../videoPlayback/clipPlayback"; type Input = { project: ReturnType; @@ -146,7 +142,7 @@ export function useProjectLibraryController({ try { const sourceTimestampUs = previewVideo.currentTime * 1_000_000; - if (findClipAtTimelineTime(frameTimestampUs / 1000, clipRegions)) { + if (findPreviewClipAtTimelineTime(frameTimestampUs / 1000, clipRegions)) { videoFrame = new VideoFrame(previewVideo, { timestamp: sourceTimestampUs }); } frameRenderer = new FrameRenderer({ diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.test.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.test.ts index d408c89b..84dc678d 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.test.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.test.ts @@ -20,6 +20,7 @@ function setup(selectedClipId: string | null = "clip") { const addEventListener = vi.fn(); vi.stubGlobal("window", { addEventListener, removeEventListener: vi.fn() }); const deleteSelectedClip = vi.fn(); + // React useEffect is mocked to test listener registration without mounting a component. useTimelineKeyboardShortcuts({ isTimelineFocusedRef: { current: false }, selectedClipId,