mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
Keep late-loading preview audio under timeline control
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user