Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/components/ai-edition/VirtualPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import "@testing-library/jest-dom";
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setCurrentNativeViewId } from "@/native";
import { VirtualPreview } from "./VirtualPreview";

// Un seul clip, un seul asset : tout seek reste dans le même clip et le même <video> (pas
// de switch d'asset), donc `seekToVirtualTime` retombe sur le chemin simple et appelle
// `onTimeChange` — l'observable qui nous dit qu'un seek a bien été appliqué au <video>.
const clips = [
{
id: "clip_a",
assetId: "asset_1",
sourceStartSec: 0,
sourceEndSec: 30,
timelineStartSec: 0,
timelineEndSec: 30,
wordRefs: [],
origin: "user" as const,
reason: "",
},
];
const videoSources = [{ id: "asset_1", src: "blob:test", label: "Screen" }];

function renderPreview(onTimeChange: (t: number) => void, requestId: number) {
return render(
<VirtualPreview
videoSources={videoSources}
clips={clips}
seekTarget={{ timeSec: 0, isSource: false, requestId }}
onTimeChange={onTimeChange}
/>,
);
}

describe("VirtualPreview throttle le seek du <video> pendant un scrub natif en pause", () => {
let nowMs = 10_000;

beforeEach(() => {
nowMs = 10_000;
vi.useFakeTimers();
// Horloge du throttle sous contrôle : sinon la fenêtre de 66 ms dépendrait du temps
// réel écoulé entre deux `rerender`, ce qui rendrait le test non déterministe.
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
});

afterEach(() => {
cleanup();
setCurrentNativeViewId(null);
vi.useRealTimers();
vi.restoreAllMocks();
});

it("coalesce des seeks rapprochés et pose quand même la position finale (bord de fuite)", () => {
setCurrentNativeViewId(1); // vue native active → le <video> est occulté
const onTimeChange = vi.fn();
// jsdom : `<video>.paused` vaut true par défaut → la condition « en pause » est remplie.
const { rerender } = renderPreview(onTimeChange, 1);
// Premier pas : rien n'a encore été appliqué (lastAppliedMs=0), donc il passe tout de suite.
const seeksAfterFirst = onTimeChange.mock.calls.length;
expect(seeksAfterFirst).toBeGreaterThanOrEqual(1);

// Trois pas rapprochés DANS la fenêtre de throttle : aucun ne doit être appliqué
// immédiatement — ils sont coalescés en attente du bord de fuite.
for (const [i, timeSec] of [2, 3, 4].entries()) {
nowMs += 10; // < 66 ms cumulés
act(() => {
rerender(
<VirtualPreview
videoSources={videoSources}
clips={clips}
seekTarget={{ timeSec, isSource: false, requestId: 2 + i }}
onTimeChange={onTimeChange}
/>,
);
});
}
expect(onTimeChange.mock.calls.length).toBe(seeksAfterFirst);

// Le bord de fuite s'exécute et applique la DERNIÈRE cible (4), pas une intermédiaire.
nowMs += 100;
act(() => {
vi.runOnlyPendingTimers();
});
expect(onTimeChange.mock.calls.length).toBe(seeksAfterFirst + 1);
expect(onTimeChange).toHaveBeenLastCalledWith(expect.closeTo(4, 1));
});

it("sans vue native, chaque seek est appliqué immédiatement (le <video> EST la preview)", () => {
setCurrentNativeViewId(null); // pas de natif → chemin immédiat, comme avant
const onTimeChange = vi.fn();
const { rerender } = renderPreview(onTimeChange, 1);
const base = onTimeChange.mock.calls.length;

for (const [i, timeSec] of [2, 3, 4].entries()) {
nowMs += 10;
act(() => {
rerender(
<VirtualPreview
videoSources={videoSources}
clips={clips}
seekTarget={{ timeSec, isSource: false, requestId: 2 + i }}
onTimeChange={onTimeChange}
/>,
);
});
}
// Trois seeks supplémentaires → trois applications immédiates, aucune coalescence.
expect(onTimeChange.mock.calls.length).toBe(base + 3);
});
});
75 changes: 72 additions & 3 deletions src/components/ai-edition/VirtualPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
type CropRegion,
DEFAULT_CROP_REGION,
Expand All @@ -21,8 +21,16 @@ import {
computeZoomPreviewTransform,
IDENTITY_ZOOM_TRANSFORM,
} from "@/lib/ai-edition/timeline/zoom-preview";
import { isNativeCompositorActive, subscribeNativeCompositor } from "@/native";
import styles from "./VirtualPreview.module.css";

// #1 — throttle du seek du <video> caché pendant un scrub en pause (voir l'effet `seekTarget`).
// ~15 Hz : le <video> reste à ≤~66 ms de la position, imperceptible pour un Play qui suit
// (togglePlay ne re-seek pas, il lit `video.currentTime`), tout en supprimant ~3/4 des
// décodages GPU dupliqués avec le natif. ponytail: seuil fixe, à ajuster si le décodage
// <video> reste visible au profilage pendant un scrub.
const SCRUB_VIDEO_SEEK_THROTTLE_MS = 66;

export interface VideoSource {
id: string;
src: string;
Expand Down Expand Up @@ -113,6 +121,20 @@ export function VirtualPreview({

const isProgrammaticSeekRef = useRef(false);
const pendingSeekRef = useRef<{ sourceTimeSec: number; play: boolean } | null>(null);
// Vue native active = le canvas natif dessine les pixels, donc le <video> est occulté.
// Lu dans l'effet `seekTarget` via une ref (pas une dépendance) pour ne pas rejouer un
// seek sur simple bascule d'activité native. `isNativeCompositorActive` est du pur JS
// (une variable de module), sans effet en test/web : la valeur y est `false`, chemin
// immédiat inchangé.
const nativeActive = useSyncExternalStore(subscribeNativeCompositor, isNativeCompositorActive);
const nativeActiveRef = useRef(nativeActive);
nativeActiveRef.current = nativeActive;
// État du throttle du seek <video> pendant un scrub en pause (voir l'effet plus bas).
const scrubSeekThrottleRef = useRef<{
lastAppliedMs: number;
timer: number;
pendingTimeSec: number | null;
}>({ lastAppliedMs: 0, timer: 0, pendingTimeSec: null });
// Which clip the rAF tick below believes is currently playing — set
// whenever a seek unambiguously resolves one (via locateVirtualPosition,
// timeline position → clip). Passed back into locateSourcePosition so
Expand Down Expand Up @@ -505,11 +527,58 @@ export function VirtualPreview({
if (!seekTarget) return;
if (seekTarget.isSource) {
seekToSourceTimeRef.current(seekTarget.timeSec);
} else {
seekToVirtualTimeRef.current?.(seekTarget.timeSec);
return;
}
const apply = (t: number) => seekToVirtualTimeRef.current?.(t);
// #1 — Pendant un scrub EN PAUSE avec le compositeur natif actif, le <video> est
// occulté par le canvas natif (qui dessine les pixels, piloté par le store via
// `useNativePlaybackSync`) et muet. Son `currentTime = …` à chaque pas déclenche un
// décodage GPU qui double celui du natif, pour des pixels jamais montrés et sans son :
// pur gaspillage, et le vrai coût GPU d'un scrub. On le throttle (bord de fuite
// garanti), sans jamais laisser le <video> sur une position périmée après relâchement.
//
// Hors de ce cas on applique tout de suite, comme avant : en LECTURE le <video> est
// l'horloge maître + la source audio ; natif ABSENT (dev web, addon en échec) le
// <video> EST la preview visible. Les seeks SOURCE (rares, discrets) ne sont pas
// throttlés non plus.
if (!nativeActiveRef.current || !videoRef.current?.paused) {
apply(seekTarget.timeSec);
return;
}
const th = scrubSeekThrottleRef.current;
const now = performance.now();
const since = now - th.lastAppliedMs;
if (since >= SCRUB_VIDEO_SEEK_THROTTLE_MS) {
th.lastAppliedMs = now;
apply(seekTarget.timeSec);
return;
}
// Trop tôt depuis le dernier décodage : on mémorise la dernière cible et on programme
// le bord de fuite (une seule fois). Sans lui, la position finale d'un scrub arrivée
// dans la fenêtre ne serait jamais appliquée, et un Play juste après lirait une
// `video.currentTime` périmée → départ audio décalé.
th.pendingTimeSec = seekTarget.timeSec;
if (th.timer === 0) {
th.timer = window.setTimeout(() => {
th.timer = 0;
th.lastAppliedMs = performance.now();
const pending = th.pendingTimeSec;
th.pendingTimeSec = null;
if (pending !== null) apply(pending);
}, SCRUB_VIDEO_SEEK_THROTTLE_MS - since);
}
}, [seekTarget]);

// Annule le bord de fuite en attente au démontage (évite un seek sur un <video> parti).
useEffect(() => {
const throttle = scrubSeekThrottleRef.current;
return () => {
if (throttle.timer !== 0) {
clearTimeout(throttle.timer);
}
};
}, []);

return (
<div className={styles.container}>
{activeSource ? (
Expand Down
Loading