mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix(editor): align preview timing with presented video frames
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user