Keep late-loading preview audio under timeline control

This commit is contained in:
webadderall
2026-09-14 14:43:06 +10:00
parent d6e6a3e9c7
commit 4226186773
6 changed files with 170 additions and 34 deletions
@@ -451,9 +451,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const clipRegionsRef = useRef(clipRegions);
const clipPlaybackRef = useRef<ReturnType<typeof createClipPlayback> | 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;
@@ -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);
});
});
@@ -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<GainNode | null>(null);
const sourceAudioResumePromiseRef = useRef<Promise<void> | null>(null);
const lastSourceAudioSyncTimeRef = useRef<number | null>(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 };
}
@@ -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) => {
@@ -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<typeof useProjectState>;
@@ -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({
@@ -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,