mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
Stabilize preview seeking at clip boundaries and remove dead fallback
This commit is contained in:
@@ -115,6 +115,7 @@ import {
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
import { supportsPreviewPlaybackRate } from "./videoPlayback/playbackRate";
|
||||
import { PreviewVideoSource } from "./videoPlayback/previewVideoSource";
|
||||
import { usePreviewVideoReady } from "./videoPlayback/usePreviewVideoReady";
|
||||
import { getSceneEffectMetrics } from "./videoPlayback/sceneEffects";
|
||||
import {
|
||||
resolvePreviewMotionMode,
|
||||
@@ -188,26 +189,10 @@ type PixiRendererAttempt = {
|
||||
};
|
||||
const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000;
|
||||
|
||||
function isCanvasRenderer(application: Application): boolean {
|
||||
const rendererName = application?.renderer?.constructor?.name?.toLowerCase();
|
||||
return Boolean(
|
||||
rendererName &&
|
||||
(rendererName.includes("canvasrenderer") || rendererName.includes("canvas")),
|
||||
);
|
||||
}
|
||||
|
||||
function toRendererErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error ?? "Unknown renderer init error");
|
||||
}
|
||||
|
||||
function isRendererUnavailableError(error: unknown): boolean {
|
||||
const message = toRendererErrorMessage(error).toLowerCase();
|
||||
return (
|
||||
message.includes("canvasrenderer is not yet implemented") ||
|
||||
message.includes("no available renderer")
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string {
|
||||
const details = attempts.map((attempt) => `${attempt.backend}: ${attempt.message}`).join(" | ");
|
||||
return `No supported Pixi preview renderer was available. Attempted: ${details}`;
|
||||
@@ -407,11 +392,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
||||
const cameraContainerRef = useRef<Container | null>(null);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [pixiRendererError, setPixiRendererError] = useState<string | null>(null);
|
||||
const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>(
|
||||
null,
|
||||
);
|
||||
const videoReady = usePreviewVideoReady(videoRef, videoPath);
|
||||
|
||||
const [previewViewportWidth, setPreviewViewportWidth] = useState(640);
|
||||
const [annotationSceneTransform, setAnnotationSceneTransform] =
|
||||
useState<SceneTransformState>({
|
||||
@@ -506,7 +488,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const zoomInEasingRef = useRef(zoomInEasing);
|
||||
const zoomOutEasingRef = useRef(zoomOutEasing);
|
||||
const connectedZoomEasingRef = useRef(connectedZoomEasing);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const cursorOverlayRef = useRef<PixiCursorOverlay | null>(null);
|
||||
const cursorTelemetryRef = useRef<CursorTelemetryPoint[]>([]);
|
||||
const showCursorRef = useRef(showCursor);
|
||||
@@ -551,10 +532,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const initializePixiRenderer = useCallback(
|
||||
async (
|
||||
container: HTMLDivElement,
|
||||
): Promise<{
|
||||
app: Application;
|
||||
backend: PixiPreviewBackend;
|
||||
}> => {
|
||||
): Promise<Application> => {
|
||||
const backendOrder: PixiPreviewBackend[] = ["webgl", "webgpu"];
|
||||
const attempts: PixiRendererAttempt[] = [];
|
||||
|
||||
@@ -571,8 +549,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
|
||||
const rendererApp = new Application();
|
||||
const initStarted =
|
||||
typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
const initStarted = performance.now();
|
||||
try {
|
||||
await initializePixiApplicationWithTimeout(
|
||||
rendererApp,
|
||||
@@ -591,32 +568,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
PIXI_RENDERER_INIT_TIMEOUT_MS,
|
||||
backend,
|
||||
);
|
||||
const elapsed = Math.round(
|
||||
(typeof performance === "undefined" ? Date.now() : performance.now()) -
|
||||
initStarted,
|
||||
);
|
||||
if (isCanvasRenderer(rendererApp)) {
|
||||
throw new Error(
|
||||
`Renderer initialized with unsupported fallback backend after ${elapsed}ms: ${rendererApp.renderer.constructor?.name ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
return { app: rendererApp, backend };
|
||||
return rendererApp;
|
||||
} catch (error) {
|
||||
const elapsed = Math.round(
|
||||
(typeof performance === "undefined" ? Date.now() : performance.now()) -
|
||||
initStarted,
|
||||
);
|
||||
const elapsed = Math.round(performance.now() - initStarted);
|
||||
attempts.push({
|
||||
backend,
|
||||
message: `${toRendererErrorMessage(error)} (after ${elapsed}ms)`,
|
||||
});
|
||||
const statusMessage = isRendererUnavailableError(error)
|
||||
? "renderer backend unavailable in this runtime"
|
||||
: "renderer init failed";
|
||||
console.warn(
|
||||
`[VideoPlayback] Failed to init ${backend} renderer (${statusMessage}) after ${elapsed}ms; trying fallback.`,
|
||||
error,
|
||||
);
|
||||
|
||||
destroyPixiApplication(
|
||||
rendererApp,
|
||||
`${backend} preview renderer initialization`,
|
||||
@@ -1576,68 +1535,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
|
||||
const app = appRef.current;
|
||||
const cameraContainer = cameraContainerRef.current;
|
||||
const video = videoRef.current;
|
||||
|
||||
if (!app || !cameraContainer || !video) return;
|
||||
|
||||
const tickerWasStarted = app.ticker?.started || false;
|
||||
if (tickerWasStarted && app.ticker) {
|
||||
app.ticker.stop();
|
||||
}
|
||||
|
||||
const wasPlaying = !video.paused;
|
||||
if (wasPlaying) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
animationStateRef.current = createPlaybackAnimationState();
|
||||
cursorOverlayRef.current?.reset();
|
||||
motionBlurStateRef.current = createMotionBlurState();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const container = cameraContainerRef.current;
|
||||
const videoStage = videoContainerRef.current;
|
||||
const sprite = videoSpriteRef.current;
|
||||
const currentApp = appRef.current;
|
||||
if (!container || !videoStage || !sprite || !currentApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scale.set(1);
|
||||
container.position.set(0, 0);
|
||||
videoStage.scale.set(1);
|
||||
videoStage.position.set(0, 0);
|
||||
sprite.scale.set(1);
|
||||
sprite.position.set(0, 0);
|
||||
|
||||
layoutVideoContent();
|
||||
|
||||
applyZoomTransform({
|
||||
cameraContainer: container,
|
||||
zoomBlurFilter: zoomBlurFilterRef.current,
|
||||
motionBlurFilter: motionBlurFilterRef.current,
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
isPlaying: false,
|
||||
motionBlurAmount: 0,
|
||||
motionBlurState: motionBlurStateRef.current,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const finalApp = appRef.current;
|
||||
if (wasPlaying && video) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
if (tickerWasStarted && finalApp?.ticker) {
|
||||
finalApp.ticker.start();
|
||||
}
|
||||
});
|
||||
});
|
||||
layoutVideoContent();
|
||||
// The next ticker frame applies the current zoom; layout must never stop playback.
|
||||
shouldSnapPausedFrameRef.current = true;
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1827,12 +1730,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
error,
|
||||
);
|
||||
}
|
||||
setPixiRendererError(null);
|
||||
setPixiRendererBackend(null);
|
||||
|
||||
const result = await initializePixiRenderer(container);
|
||||
app = result.app;
|
||||
setPixiRendererBackend(result.backend);
|
||||
app = await initializePixiRenderer(container);
|
||||
|
||||
app.ticker.maxFPS = 60;
|
||||
|
||||
@@ -1905,24 +1804,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
setPixiReady(true);
|
||||
})().catch((error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to initialize preview renderer";
|
||||
if (!mounted) return;
|
||||
console.error("Failed to initialize preview renderer:", error);
|
||||
setPixiRendererError(errorMessage);
|
||||
onError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to initialize preview renderer",
|
||||
);
|
||||
onError(toRendererErrorMessage(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
setPixiReady(false);
|
||||
setPixiRendererError(null);
|
||||
setPixiRendererBackend(null);
|
||||
if (cursorOverlayRef.current) {
|
||||
cursorOverlayRef.current.destroy();
|
||||
cursorOverlayRef.current = null;
|
||||
@@ -1953,11 +1842,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
lastRenderedContentTimeRef.current = null;
|
||||
shouldSnapPausedFrameRef.current = true;
|
||||
lockedVideoDimensionsRef.current = null;
|
||||
setVideoReady(false);
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
}, [videoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2316,27 +2200,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
currentTime,
|
||||
Number.isFinite(video.duration) ? video.duration : null,
|
||||
);
|
||||
video.currentTime = targetTime;
|
||||
if (Math.abs(video.currentTime - targetTime) > 1e-8) video.currentTime = targetTime;
|
||||
video.pause();
|
||||
currentTimeRef.current = targetTime * 1000;
|
||||
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
|
||||
const waitForRenderableFrame = () => {
|
||||
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
|
||||
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
||||
if (hasDimensions && hasData) {
|
||||
videoReadyRafRef.current = null;
|
||||
setVideoReady(true);
|
||||
return;
|
||||
}
|
||||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||||
};
|
||||
|
||||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||||
};
|
||||
|
||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||
@@ -2422,15 +2288,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
}, [wallpaper]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (videoReadyRafRef.current) {
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isImageUrl =
|
||||
resolvedWallpaperKind === "image" &&
|
||||
Boolean(
|
||||
@@ -2454,10 +2311,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
// Overscan blurred wallpaper layers so the browser never samples transparent
|
||||
// pixels beyond the preview bounds, which otherwise looks like a vignette.
|
||||
const backgroundBlurOverscan = sceneEffects.backgroundOverscanPx;
|
||||
const fallbackVideoClassName = pixiRendererError
|
||||
? "absolute inset-0 h-full w-full object-cover"
|
||||
: "pointer-events-none absolute left-0 top-0 h-px w-px opacity-0";
|
||||
const hasRendererFallback = Boolean(pixiRendererError);
|
||||
const nativeAspectRatio = (() => {
|
||||
const locked = lockedVideoDimensionsRef.current;
|
||||
if (locked) {
|
||||
@@ -2531,16 +2384,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
visibility: isGap ? "hidden" : "visible",
|
||||
}}
|
||||
/>
|
||||
{hasRendererFallback && !isGap && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 p-2 text-center">
|
||||
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs text-white">
|
||||
{`Pixi renderer unavailable on this environment (${pixiRendererBackend ?? "unknown"}).`}
|
||||
<br />
|
||||
Fallback to 2D native preview so you can continue working while the GPU
|
||||
path is unavailable.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
@@ -2930,9 +2773,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
<video
|
||||
ref={attachVideo}
|
||||
src={videoPath}
|
||||
className={fallbackVideoClassName}
|
||||
className="pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"
|
||||
style={{ visibility: isGap ? "hidden" : "visible" }}
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
playsInline
|
||||
aria-hidden="true"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
|
||||
@@ -169,11 +169,49 @@ describe("clip timeline playback", () => {
|
||||
const { playback, video, onTime } = setup();
|
||||
playback.seek(4);
|
||||
expect(onTime).toHaveBeenLastCalledWith(4, 12);
|
||||
expect(video.currentTime).toBe(12);
|
||||
expect(video.currentTime).toBeCloseTo(11.999999, 8);
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
await playback.play();
|
||||
expect(onTime).toHaveBeenLastCalledWith(0, 0);
|
||||
});
|
||||
it("does not bounce back into a gap when the decoder lands just before a clip in-point", async () => {
|
||||
const { playback, video, onTime } = setup();
|
||||
playback.seek(2);
|
||||
await playback.play();
|
||||
video.currentTime = 5.999999;
|
||||
advance(16);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2, 6);
|
||||
expect(playback.isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it("does not restart decoder seeks when repeatedly selecting the start", () => {
|
||||
const { playback, video } = setup();
|
||||
let currentTime = 0;
|
||||
const setTime = vi.fn((value: number) => { currentTime = value; });
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
get: () => currentTime,
|
||||
set: setTime,
|
||||
});
|
||||
playback.seek(0);
|
||||
playback.refresh();
|
||||
expect(setTime).not.toHaveBeenCalled();
|
||||
playback.seek(0.5);
|
||||
playback.seek(0);
|
||||
playback.seek(0);
|
||||
expect(setTime.mock.calls).toEqual([[1.5], [0]]);
|
||||
});
|
||||
it("holds footage inside a trimmed final out-point and selects the next in-point at a cut", () => {
|
||||
const { playback, video } = setup([
|
||||
{ id: "a", startMs: 0, endMs: 1000, sourceStartMs: 0, speed: 1 },
|
||||
{ id: "b", startMs: 1000, endMs: 2000, sourceStartMs: 6000, speed: 1 },
|
||||
]);
|
||||
playback.seek(1);
|
||||
expect(video.currentTime).toBe(6);
|
||||
playback.seek(2);
|
||||
expect(video.currentTime).toBeLessThan(7);
|
||||
expect(video.currentTime).toBeGreaterThan(6.999);
|
||||
});
|
||||
|
||||
it("keeps real gaps black, chooses the next clip at cuts, and holds only the final endpoint", () => {
|
||||
const clips = [
|
||||
{ id: "a", startMs: 0, endMs: 1000, speed: 1 },
|
||||
|
||||
@@ -15,7 +15,7 @@ export function findPreviewClipAtTimelineTime(
|
||||
const active = findClipAtTimelineTime(timeMs, clips);
|
||||
if (active) return active;
|
||||
const last = sortClipRegions(clips)[clips.length - 1];
|
||||
return last && timeMs === last.endMs ? last : null;
|
||||
return last && Math.abs(timeMs - last.endMs) < 1e-7 ? last : null;
|
||||
}
|
||||
|
||||
/** Timeline time advances at 1x; only the source media uses the clip's speed. */
|
||||
@@ -71,7 +71,21 @@ export function createClipPlayback({
|
||||
onError(error);
|
||||
return;
|
||||
}
|
||||
if (seek || clip !== activeClip) video.currentTime = sourceMs / 1000;
|
||||
if (seek || clip !== activeClip) {
|
||||
// Clip out-points are exclusive. At the final timeline endpoint,
|
||||
// request a frame inside the clip, not EOF or the following footage.
|
||||
const atEnd = timeMs >= clip.endMs - 1e-7;
|
||||
const targetMs = atEnd
|
||||
? Math.max(getClipSourceStartMs(clip), sourceMs - 0.001)
|
||||
: sourceMs;
|
||||
const target = Math.max(0, Math.min(
|
||||
Number.isFinite(video.duration) ? Math.max(0, video.duration - 0.000001) : Infinity,
|
||||
targetMs / 1000,
|
||||
));
|
||||
// Assigning currentTime even to its current value starts another
|
||||
// asynchronous seek in Chromium (especially disruptive at zero).
|
||||
if (Math.abs(video.currentTime - target) > 1e-8) video.currentTime = target;
|
||||
}
|
||||
if (playing && (seek || clip !== activeClip)) playSource();
|
||||
} else {
|
||||
playRequest++;
|
||||
@@ -91,9 +105,12 @@ export function createClipPlayback({
|
||||
? activeClip.endMs
|
||||
: Math.min(
|
||||
activeClip.endMs,
|
||||
activeClip.startMs +
|
||||
(video.currentTime * 1000 - getClipSourceStartMs(activeClip)) /
|
||||
activeClip.speed,
|
||||
Math.max(
|
||||
activeClip.startMs,
|
||||
activeClip.startMs +
|
||||
(video.currentTime * 1000 - getClipSourceStartMs(activeClip)) /
|
||||
activeClip.speed,
|
||||
),
|
||||
);
|
||||
}
|
||||
timeMs = Math.min(duration(), timeMs);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { usePreviewVideoReady } from "./usePreviewVideoReady";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
setReady: vi.fn(),
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
}));
|
||||
vi.mock("react", () => ({
|
||||
useState: () => [false, state.setReady],
|
||||
useLayoutEffect: (effect: () => () => void) => {
|
||||
state.cleanup = effect();
|
||||
},
|
||||
}));
|
||||
class Video extends EventTarget {
|
||||
readyState = 0;
|
||||
videoWidth = 0;
|
||||
videoHeight = 0;
|
||||
}
|
||||
afterEach(() => {
|
||||
state.cleanup?.();
|
||||
state.setReady.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("becomes ready after every source change without a polling callback", () => {
|
||||
vi.stubGlobal("HTMLMediaElement", { HAVE_CURRENT_DATA: 2 });
|
||||
const video = new Video();
|
||||
const ref = { current: video as unknown as HTMLVideoElement };
|
||||
for (let index = 0; index < 10; index++) {
|
||||
state.cleanup?.();
|
||||
video.readyState = 0;
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: Mocked React effects simulate successive source subscriptions.
|
||||
usePreviewVideoReady(ref, `project-${index}.mp4`);
|
||||
expect(state.setReady).toHaveBeenLastCalledWith(false);
|
||||
video.readyState = 2;
|
||||
video.videoWidth = 1920;
|
||||
video.videoHeight = 1080;
|
||||
video.dispatchEvent(new Event("loadeddata"));
|
||||
expect(state.setReady).toHaveBeenLastCalledWith(true);
|
||||
}
|
||||
state.cleanup?.();
|
||||
state.setReady.mockClear();
|
||||
video.dispatchEvent(new Event("loadeddata"));
|
||||
expect(state.setReady).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recognizes an already loaded source when subscribing", () => {
|
||||
vi.stubGlobal("HTMLMediaElement", { HAVE_CURRENT_DATA: 2 });
|
||||
const video = new Video();
|
||||
Object.assign(video, { readyState: 4, videoWidth: 1920, videoHeight: 1080 });
|
||||
usePreviewVideoReady({ current: video as unknown as HTMLVideoElement }, "cached.mp4");
|
||||
expect(state.setReady).toHaveBeenLastCalledWith(true);
|
||||
video.readyState = 0;
|
||||
video.dispatchEvent(new Event("emptied"));
|
||||
expect(state.setReady).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("retains the preview through transient readiness drops during boundary seeks", () => {
|
||||
vi.stubGlobal("HTMLMediaElement", { HAVE_CURRENT_DATA: 2 });
|
||||
const video = new Video();
|
||||
Object.assign(video, { readyState: 4, videoWidth: 1920, videoHeight: 1080 });
|
||||
usePreviewVideoReady({ current: video as unknown as HTMLVideoElement }, "recording.mp4");
|
||||
state.setReady.mockClear();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
video.readyState = 1;
|
||||
video.dispatchEvent(new Event("seeked"));
|
||||
video.readyState = 2;
|
||||
video.dispatchEvent(new Event("loadeddata"));
|
||||
}
|
||||
expect(state.setReady).not.toHaveBeenCalledWith(false);
|
||||
video.dispatchEvent(new Event("error"));
|
||||
expect(state.setReady).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type RefObject, useLayoutEffect, useState } from "react";
|
||||
|
||||
/** Subscribe before media events can arrive for React's newly assigned source. */
|
||||
export function usePreviewVideoReady(videoRef: RefObject<HTMLVideoElement | null>, source: string) {
|
||||
const [ready, setReady] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Recheck readiness whenever React changes the media source.
|
||||
useLayoutEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
// Readiness owns the sprite/transport lifetime, not each decoder seek.
|
||||
// Retain the last frame until new data arrives; only a new source or a
|
||||
// media failure should tear the preview down.
|
||||
const update = () => {
|
||||
if (
|
||||
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
||||
video.videoWidth > 0 && video.videoHeight > 0
|
||||
) setReady(true);
|
||||
};
|
||||
const reset = () => setReady(false);
|
||||
const events = ["loadeddata", "canplay", "seeked"];
|
||||
for (const event of events) video.addEventListener(event, update);
|
||||
video.addEventListener("emptied", reset);
|
||||
video.addEventListener("error", reset);
|
||||
reset();
|
||||
update();
|
||||
return () => {
|
||||
for (const event of events) video.removeEventListener(event, update);
|
||||
video.removeEventListener("emptied", reset);
|
||||
video.removeEventListener("error", reset);
|
||||
};
|
||||
}, [videoRef, source]);
|
||||
return ready;
|
||||
}
|
||||
Reference in New Issue
Block a user