fix(editor): align preview timing with presented video frames

This commit is contained in:
wiiiii123
2026-04-25 23:26:12 +07:00
parent fa4e283e8a
commit d9d38df24d
6 changed files with 326 additions and 40 deletions
+29 -13
View File
@@ -149,6 +149,7 @@ import {
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_OVERLAY,
DEFAULT_WEBCAM_TIME_OFFSET_MS,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
@@ -1569,6 +1570,7 @@ export default function VideoEditor() {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath: normalizedEditor.webcam.sourcePath,
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
});
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath);
@@ -1695,7 +1697,7 @@ export default function VideoEditor() {
}, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]);
const syncRecordingSessionWebcam = useCallback(
async (webcamPath: string | null) => {
async (webcamPath: string | null, timeOffsetMs?: number) => {
if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) {
return;
}
@@ -1703,9 +1705,15 @@ export default function VideoEditor() {
await window.electronAPI.setCurrentRecordingSession({
videoPath: currentSourcePath,
webcamPath,
timeOffsetMs:
webcamPath && Number.isFinite(timeOffsetMs)
? (timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS)
: webcamPath
? webcam.timeOffsetMs
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
});
},
[currentSourcePath],
[currentSourcePath, webcam.timeOffsetMs],
);
const syncActiveVideoSource = useCallback(
@@ -1714,13 +1722,14 @@ export default function VideoEditor() {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath,
timeOffsetMs: webcam.timeOffsetMs,
});
return;
}
await window.electronAPI.setCurrentVideoPath(sourcePath);
},
[],
[webcam.timeOffsetMs],
);
const handleUploadWebcam = useCallback(async () => {
@@ -1733,9 +1742,10 @@ export default function VideoEditor() {
...prev,
enabled: true,
sourcePath: result.path ?? null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
await syncRecordingSessionWebcam(result.path);
await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS);
toast.success(t("settings.effects.webcamFootageAdded"));
}, [syncRecordingSessionWebcam, t]);
@@ -1744,6 +1754,7 @@ export default function VideoEditor() {
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
await syncRecordingSessionWebcam(null);
@@ -1838,6 +1849,7 @@ export default function VideoEditor() {
...prev,
enabled: !!smokeWebcamSourcePath,
sourcePath: smokeWebcamSourcePath,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
shadow:
smokeExportConfig.webcamShadow === undefined
? prev.shadow
@@ -1896,6 +1908,8 @@ export default function VideoEditor() {
...prev,
enabled: Boolean(sessionResult.session?.webcamPath),
sourcePath: sessionResult.session?.webcamPath ?? null,
timeOffsetMs:
sessionResult.session?.timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
return;
}
@@ -1913,6 +1927,7 @@ export default function VideoEditor() {
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
} else {
setError("No video to load. Please record or select a video.");
@@ -3594,14 +3609,14 @@ export default function VideoEditor() {
}
}
for (const audioPath of previewSourceAudioFallbackPaths) {
let audio = existing.get(audioPath);
if (!audio) {
audio = new Audio();
audio.preload = "auto";
existing.set(audioPath, audio);
}
audio.dataset.sourceAudioPath = audioPath;
for (const audioPath of previewSourceAudioFallbackPaths) {
let audio = existing.get(audioPath);
if (!audio) {
audio = new Audio();
audio.preload = "auto";
existing.set(audioPath, audio);
}
audio.dataset.sourceAudioPath = audioPath;
if (sourceAudioElementResourcesRef.current.get(audioPath) !== audioPath) {
audio.pause();
@@ -5476,7 +5491,8 @@ export default function VideoEditor() {
audioRegions.length > 0
? Math.max(
...audioRegions.map(
(region) => region.trackIndex ?? 0,
(region) =>
region.trackIndex ?? 0,
),
) + 1
: 0;
+19 -13
View File
@@ -101,6 +101,7 @@ import {
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_SWAY,
DEFAULT_PADDING,
DEFAULT_WEBCAM_CORNER_RADIUS,
DEFAULT_WEBCAM_REACT_TO_ZOOM,
DEFAULT_WEBCAM_SHADOW,
@@ -110,7 +111,6 @@ import {
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
DEFAULT_PADDING,
getDefaultCaptionFontFamily,
} from "./types";
import {
@@ -124,6 +124,7 @@ import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focu
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { getWebcamPreviewTargetTimeSeconds } from "./videoPlayback/webcamSync";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -1232,10 +1233,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return;
}
const targetTime = clampMediaTimeToDuration(
const targetTime = getWebcamPreviewTargetTimeSeconds({
currentTime,
Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
);
webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
timeOffsetMs: webcam.timeOffsetMs,
});
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs,
@@ -1470,7 +1472,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
layoutVideoContent();
video.pause();
const { handlePlay, handlePause, handleSeeked, handleSeeking } =
const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } =
createVideoEventHandlers({
video,
isSeekingRef,
@@ -1496,10 +1498,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
video.removeEventListener("ended", handlePause);
video.removeEventListener("seeked", handleSeeked);
video.removeEventListener("seeking", handleSeeking);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
dispose();
if (videoSprite) {
videoContainer.removeChild(videoSprite);
@@ -1749,9 +1748,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
// through an intermediate RenderTexture at renderer resolution, which
// downsamples the native video and degrades preview quality.
// Hysteresis prevents flickering when motionIntensity oscillates near threshold.
const filtersActive = Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
const cameraIsMoving = filtersActive ? motionIntensity > 0.002 : motionIntensity > 0.008;
const needsFilters = zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
const filtersActive =
Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
const cameraIsMoving = filtersActive
? motionIntensity > 0.002
: motionIntensity > 0.008;
const needsFilters =
zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
if (needsFilters && !filtersActive && motionBlurFilterRef.current) {
videoContainer.filters = [motionBlurFilterRef.current];
} else if (!needsFilters && filtersActive) {
@@ -2451,12 +2454,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
})()}
</div>
)}
{/* Keep the source video off-screen instead of display:none so the
browser continues producing presented frames for Pixi and preview sync. */}
<video
ref={videoRef}
src={videoPath}
className="hidden"
className="pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"
preload="metadata"
playsInline
aria-hidden="true"
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={(e) => {
onDurationChange(e.currentTarget.duration);
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/extensions", () => ({
extensionHost: {
emitEvent: vi.fn(),
},
}));
import { extensionHost } from "@/lib/extensions";
import { createVideoEventHandlers } from "./videoEventHandlers";
type PresentedFrameCallback = (now: DOMHighResTimeStamp, metadata: { mediaTime?: number }) => void;
type MockVideo = HTMLVideoElement & {
requestVideoFrameCallback?: (callback: PresentedFrameCallback) => number;
cancelVideoFrameCallback?: (handle: number) => void;
};
function createMutableRef<T>(value: T) {
return { current: value };
}
function createMockVideo(overrides: Partial<MockVideo> = {}): MockVideo {
const video = {
currentTime: 0.5,
duration: 10,
paused: false,
ended: false,
playbackRate: 1,
pause: vi.fn(),
} as unknown as MockVideo;
return Object.assign(video, overrides);
}
describe("createVideoEventHandlers", () => {
const emitEventMock = vi.mocked(extensionHost.emitEvent);
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
let cancelAnimationFrameMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
requestAnimationFrameMock = vi.fn(() => 11);
cancelAnimationFrameMock = vi.fn();
vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock);
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameMock);
emitEventMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("prefers requestVideoFrameCallback mediaTime when available", () => {
let presentedFrameCallback: PresentedFrameCallback | null = null;
const video = createMockVideo({
requestVideoFrameCallback: vi.fn((callback) => {
presentedFrameCallback = callback;
return 7;
}),
cancelVideoFrameCallback: vi.fn(),
});
const onPlayStateChange = vi.fn();
const onTimeUpdate = vi.fn();
const currentTimeRef = createMutableRef(0);
const timeUpdateAnimationRef = createMutableRef<number | null>(null);
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(false),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef,
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
trimRegionsRef: createMutableRef([]),
speedRegionsRef: createMutableRef([]),
});
handlers.handlePlay();
expect(onPlayStateChange).toHaveBeenCalledWith(true);
expect(video.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
expect(requestAnimationFrameMock).not.toHaveBeenCalled();
presentedFrameCallback?.(0, { mediaTime: 1.25 });
expect(onTimeUpdate).toHaveBeenCalledWith(1.25);
expect(currentTimeRef.current).toBe(1250);
expect(emitEventMock).toHaveBeenLastCalledWith({
type: "playback:timeupdate",
timeMs: 1250,
});
});
it("falls back to requestAnimationFrame when requestVideoFrameCallback is unavailable", () => {
let animationFrameCallback: FrameRequestCallback | null = null;
requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => {
animationFrameCallback = callback;
return 19;
});
const video = createMockVideo({ currentTime: 0.75 });
const onTimeUpdate = vi.fn();
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(false),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef: createMutableRef(0),
timeUpdateAnimationRef: createMutableRef<number | null>(null),
onPlayStateChange: vi.fn(),
onTimeUpdate,
trimRegionsRef: createMutableRef([]),
speedRegionsRef: createMutableRef([]),
});
handlers.handlePlay();
expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1);
video.paused = true;
animationFrameCallback?.(0);
expect(onTimeUpdate).toHaveBeenCalledWith(0.75);
});
it("cancels a pending requestVideoFrameCallback on pause and dispose", () => {
const cancelVideoFrameCallback = vi.fn();
const video = createMockVideo({
requestVideoFrameCallback: vi.fn(() => 23),
cancelVideoFrameCallback,
});
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(false),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef: createMutableRef(0),
timeUpdateAnimationRef: createMutableRef<number | null>(null),
onPlayStateChange: vi.fn(),
onTimeUpdate: vi.fn(),
trimRegionsRef: createMutableRef([]),
speedRegionsRef: createMutableRef([]),
});
handlers.handlePlay();
handlers.handlePause();
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
cancelVideoFrameCallback.mockClear();
handlers.handlePlay();
handlers.dispose();
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
});
});
@@ -2,6 +2,17 @@ import type React from "react";
import { extensionHost } from "@/lib/extensions";
import type { SpeedRegion, TrimRegion } from "../types";
interface PresentedFrameMetadata {
mediaTime?: number;
}
type PresentedFrameVideoElement = HTMLVideoElement & {
requestVideoFrameCallback?: (
callback: (now: DOMHighResTimeStamp, metadata: PresentedFrameMetadata) => void,
) => number;
cancelVideoFrameCallback?: (handle: number) => void;
};
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
@@ -28,6 +39,8 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
trimRegionsRef,
speedRegionsRef,
} = params;
const presentedFrameVideo = video as PresentedFrameVideoElement;
let videoFrameRequestId: number | null = null;
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
@@ -66,10 +79,54 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
}
};
function updateTime() {
const cancelScheduledUpdate = () => {
if (timeUpdateAnimationRef.current !== null) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
if (
videoFrameRequestId !== null &&
typeof presentedFrameVideo.cancelVideoFrameCallback === "function"
) {
presentedFrameVideo.cancelVideoFrameCallback(videoFrameRequestId);
videoFrameRequestId = null;
}
};
const scheduleNextUpdate = () => {
if (video.paused || video.ended) {
return;
}
// Align editor state with the frame Chromium actually presented instead of
// polling `currentTime` on a generic animation frame.
if (typeof presentedFrameVideo.requestVideoFrameCallback === "function") {
videoFrameRequestId = presentedFrameVideo.requestVideoFrameCallback(
(_now, metadata) => {
videoFrameRequestId = null;
updateTime(metadata);
},
);
return;
}
timeUpdateAnimationRef.current = requestAnimationFrame(() => {
timeUpdateAnimationRef.current = null;
updateTime();
});
};
function getPresentedTime(metadata?: PresentedFrameMetadata): number {
const mediaTime = metadata?.mediaTime;
return Number.isFinite(mediaTime) ? (mediaTime ?? 0) : video.currentTime;
}
function updateTime(metadata?: PresentedFrameMetadata) {
if (!video) return;
const currentTimeMs = video.currentTime * 1000;
const presentedTime = getPresentedTime(metadata);
const currentTimeMs = presentedTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we're in a trim region during playback, skip to the end of it
@@ -79,12 +136,10 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
// Apply playback speed from active speed region
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
emitTime(video.currentTime);
emitTime(presentedTime);
}
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
scheduleNextUpdate();
}
const handlePlay = () => {
@@ -95,19 +150,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
isPlayingRef.current = true;
onPlayStateChange(true);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
cancelScheduledUpdate();
scheduleNextUpdate();
};
const handlePause = () => {
isPlayingRef.current = false;
onPlayStateChange(false);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
cancelScheduledUpdate();
emitTime(video.currentTime);
};
@@ -131,6 +181,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
};
return {
dispose: cancelScheduledUpdate,
handlePlay,
handlePause,
handleSeeked,
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { getWebcamPreviewTargetTimeSeconds } from "./webcamSync";
describe("getWebcamPreviewTargetTimeSeconds", () => {
it("applies positive webcam offsets", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 10,
webcamDuration: 20,
timeOffsetMs: 250,
}),
).toBe(10.25);
});
it("clamps negative webcam offsets to zero", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 0.1,
webcamDuration: 20,
timeOffsetMs: -250,
}),
).toBe(0);
});
it("falls back to the unshifted time when the offset is invalid", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 3.5,
webcamDuration: 20,
timeOffsetMs: Number.NaN,
}),
).toBe(3.5);
});
it("clamps to the webcam duration", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 8.9,
webcamDuration: 9,
timeOffsetMs: 500,
}),
).toBe(9);
});
});
@@ -0,0 +1,15 @@
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
export function getWebcamPreviewTargetTimeSeconds({
currentTime,
webcamDuration,
timeOffsetMs,
}: {
currentTime: number;
webcamDuration?: number | null;
timeOffsetMs?: number | null;
}): number {
const safeOffsetMs = Number.isFinite(timeOffsetMs) ? (timeOffsetMs ?? 0) : 0;
const shiftedTime = currentTime + safeOffsetMs / 1000;
return clampMediaTimeToDuration(shiftedTime, webcamDuration);
}