Stabilize preview seeking at clip boundaries and remove dead fallback

This commit is contained in:
webadderall
2026-09-15 09:33:09 +10:00
parent 4226186773
commit ead83023a6
5 changed files with 182 additions and 180 deletions
+17 -174
View File
@@ -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;
}