Skip to content

Commit 4518c4f

Browse files
committed
perf(preview): throttler le seek du <video> caché pendant un scrub natif en pause
Le `<video>` de VirtualPreview ne dessine plus rien : le canvas natif D3D est la seule source de pixels et l'occulte. Le `<video>` reste monté uniquement pour l'horloge de lecture, le son et la metadata. Or pendant un scrub EN PAUSE, l'effet `seekTarget` posait `video.currentTime = …` à chaque pas (~60 Hz, cadence du rAF de scrub), donc un décodage GPU par pas — pour des pixels jamais montrés et sans son. Ce décodage double exactement celui que fait déjà le compositeur natif (piloté séparément par `useNativePlaybackSync` → `setNativeTime`, depuis le store). C'est du pur gaspillage, et le vrai coût GPU d'un scrub. On throttle donc ce seek à ~15 Hz (bord de fuite garanti), ce qui supprime ~3/4 des décodages dupliqués. Le `<video>` reste à ≤~66 ms de la position — imperceptible pour un Play qui suit, car `togglePlay` ne re-seek pas : il lit `video.currentTime` tel quel. Le bord de fuite garantit qu'aucun scrub ne laisse le `<video>` sur une position périmée après relâchement (sinon départ audio décalé au Play). Le `<video>` webcam suit indirectement : son rAF chasse l'horloge publiée par l'écran, qui n'avance plus qu'à ~15 Hz → il se re-seek d'autant moins. Strictement borné au cas où c'est redondant : - LECTURE → le `<video>` est l'horloge maître + la source audio : seek immédiat, inchangé. - natif ABSENT (dev web, addon en échec) → le `<video>` EST la preview visible : immédiat. - seeks SOURCE (rares, discrets) : non throttlés. Rien de visible ne change : le natif affiche déjà le scrub à pleine cadence. Le gain est indirect — moins de contention GPU pendant le scrub — à confirmer à la sonde. Vérifié : tsc, biome, 72/72 src/components/ai-edition dont 2 nouveaux tests (coalescence + bord de fuite quand natif actif & en pause ; immédiat sinon).
1 parent 0104115 commit 4518c4f

2 files changed

Lines changed: 183 additions & 3 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import "@testing-library/jest-dom";
2+
import { act, cleanup, render } from "@testing-library/react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { setCurrentNativeViewId } from "@/native";
5+
import { VirtualPreview } from "./VirtualPreview";
6+
7+
// Un seul clip, un seul asset : tout seek reste dans le même clip et le même <video> (pas
8+
// de switch d'asset), donc `seekToVirtualTime` retombe sur le chemin simple et appelle
9+
// `onTimeChange` — l'observable qui nous dit qu'un seek a bien été appliqué au <video>.
10+
const clips = [
11+
{
12+
id: "clip_a",
13+
assetId: "asset_1",
14+
sourceStartSec: 0,
15+
sourceEndSec: 30,
16+
timelineStartSec: 0,
17+
timelineEndSec: 30,
18+
wordRefs: [],
19+
origin: "user" as const,
20+
reason: "",
21+
},
22+
];
23+
const videoSources = [{ id: "asset_1", src: "blob:test", label: "Screen" }];
24+
25+
function renderPreview(onTimeChange: (t: number) => void, requestId: number) {
26+
return render(
27+
<VirtualPreview
28+
videoSources={videoSources}
29+
clips={clips}
30+
seekTarget={{ timeSec: 0, isSource: false, requestId }}
31+
onTimeChange={onTimeChange}
32+
/>,
33+
);
34+
}
35+
36+
describe("VirtualPreview throttle le seek du <video> pendant un scrub natif en pause", () => {
37+
let nowMs = 10_000;
38+
39+
beforeEach(() => {
40+
nowMs = 10_000;
41+
vi.useFakeTimers();
42+
// Horloge du throttle sous contrôle : sinon la fenêtre de 66 ms dépendrait du temps
43+
// réel écoulé entre deux `rerender`, ce qui rendrait le test non déterministe.
44+
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
45+
});
46+
47+
afterEach(() => {
48+
cleanup();
49+
setCurrentNativeViewId(null);
50+
vi.useRealTimers();
51+
vi.restoreAllMocks();
52+
});
53+
54+
it("coalesce des seeks rapprochés et pose quand même la position finale (bord de fuite)", () => {
55+
setCurrentNativeViewId(1); // vue native active → le <video> est occulté
56+
const onTimeChange = vi.fn();
57+
// jsdom : `<video>.paused` vaut true par défaut → la condition « en pause » est remplie.
58+
const { rerender } = renderPreview(onTimeChange, 1);
59+
// Premier pas : rien n'a encore été appliqué (lastAppliedMs=0), donc il passe tout de suite.
60+
const seeksAfterFirst = onTimeChange.mock.calls.length;
61+
expect(seeksAfterFirst).toBeGreaterThanOrEqual(1);
62+
63+
// Trois pas rapprochés DANS la fenêtre de throttle : aucun ne doit être appliqué
64+
// immédiatement — ils sont coalescés en attente du bord de fuite.
65+
for (const [i, timeSec] of [2, 3, 4].entries()) {
66+
nowMs += 10; // < 66 ms cumulés
67+
act(() => {
68+
rerender(
69+
<VirtualPreview
70+
videoSources={videoSources}
71+
clips={clips}
72+
seekTarget={{ timeSec, isSource: false, requestId: 2 + i }}
73+
onTimeChange={onTimeChange}
74+
/>,
75+
);
76+
});
77+
}
78+
expect(onTimeChange.mock.calls.length).toBe(seeksAfterFirst);
79+
80+
// Le bord de fuite s'exécute et applique la DERNIÈRE cible (4), pas une intermédiaire.
81+
nowMs += 100;
82+
act(() => {
83+
vi.runOnlyPendingTimers();
84+
});
85+
expect(onTimeChange.mock.calls.length).toBe(seeksAfterFirst + 1);
86+
expect(onTimeChange).toHaveBeenLastCalledWith(expect.closeTo(4, 1));
87+
});
88+
89+
it("sans vue native, chaque seek est appliqué immédiatement (le <video> EST la preview)", () => {
90+
setCurrentNativeViewId(null); // pas de natif → chemin immédiat, comme avant
91+
const onTimeChange = vi.fn();
92+
const { rerender } = renderPreview(onTimeChange, 1);
93+
const base = onTimeChange.mock.calls.length;
94+
95+
for (const [i, timeSec] of [2, 3, 4].entries()) {
96+
nowMs += 10;
97+
act(() => {
98+
rerender(
99+
<VirtualPreview
100+
videoSources={videoSources}
101+
clips={clips}
102+
seekTarget={{ timeSec, isSource: false, requestId: 2 + i }}
103+
onTimeChange={onTimeChange}
104+
/>,
105+
);
106+
});
107+
}
108+
// Trois seeks supplémentaires → trois applications immédiates, aucune coalescence.
109+
expect(onTimeChange.mock.calls.length).toBe(base + 3);
110+
});
111+
});

src/components/ai-edition/VirtualPreview.tsx

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
22
import {
33
type CropRegion,
44
DEFAULT_CROP_REGION,
@@ -21,8 +21,16 @@ import {
2121
computeZoomPreviewTransform,
2222
IDENTITY_ZOOM_TRANSFORM,
2323
} from "@/lib/ai-edition/timeline/zoom-preview";
24+
import { isNativeCompositorActive, subscribeNativeCompositor } from "@/native";
2425
import styles from "./VirtualPreview.module.css";
2526

27+
// #1 — throttle du seek du <video> caché pendant un scrub en pause (voir l'effet `seekTarget`).
28+
// ~15 Hz : le <video> reste à ≤~66 ms de la position, imperceptible pour un Play qui suit
29+
// (togglePlay ne re-seek pas, il lit `video.currentTime`), tout en supprimant ~3/4 des
30+
// décodages GPU dupliqués avec le natif. ponytail: seuil fixe, à ajuster si le décodage
31+
// <video> reste visible au profilage pendant un scrub.
32+
const SCRUB_VIDEO_SEEK_THROTTLE_MS = 66;
33+
2634
export interface VideoSource {
2735
id: string;
2836
src: string;
@@ -113,6 +121,20 @@ export function VirtualPreview({
113121

114122
const isProgrammaticSeekRef = useRef(false);
115123
const pendingSeekRef = useRef<{ sourceTimeSec: number; play: boolean } | null>(null);
124+
// Vue native active = le canvas natif dessine les pixels, donc le <video> est occulté.
125+
// Lu dans l'effet `seekTarget` via une ref (pas une dépendance) pour ne pas rejouer un
126+
// seek sur simple bascule d'activité native. `isNativeCompositorActive` est du pur JS
127+
// (une variable de module), sans effet en test/web : la valeur y est `false`, chemin
128+
// immédiat inchangé.
129+
const nativeActive = useSyncExternalStore(subscribeNativeCompositor, isNativeCompositorActive);
130+
const nativeActiveRef = useRef(nativeActive);
131+
nativeActiveRef.current = nativeActive;
132+
// État du throttle du seek <video> pendant un scrub en pause (voir l'effet plus bas).
133+
const scrubSeekThrottleRef = useRef<{
134+
lastAppliedMs: number;
135+
timer: number;
136+
pendingTimeSec: number | null;
137+
}>({ lastAppliedMs: 0, timer: 0, pendingTimeSec: null });
116138
// Which clip the rAF tick below believes is currently playing — set
117139
// whenever a seek unambiguously resolves one (via locateVirtualPosition,
118140
// timeline position → clip). Passed back into locateSourcePosition so
@@ -505,11 +527,58 @@ export function VirtualPreview({
505527
if (!seekTarget) return;
506528
if (seekTarget.isSource) {
507529
seekToSourceTimeRef.current(seekTarget.timeSec);
508-
} else {
509-
seekToVirtualTimeRef.current?.(seekTarget.timeSec);
530+
return;
531+
}
532+
const apply = (t: number) => seekToVirtualTimeRef.current?.(t);
533+
// #1 — Pendant un scrub EN PAUSE avec le compositeur natif actif, le <video> est
534+
// occulté par le canvas natif (qui dessine les pixels, piloté par le store via
535+
// `useNativePlaybackSync`) et muet. Son `currentTime = …` à chaque pas déclenche un
536+
// décodage GPU qui double celui du natif, pour des pixels jamais montrés et sans son :
537+
// pur gaspillage, et le vrai coût GPU d'un scrub. On le throttle (bord de fuite
538+
// garanti), sans jamais laisser le <video> sur une position périmée après relâchement.
539+
//
540+
// Hors de ce cas on applique tout de suite, comme avant : en LECTURE le <video> est
541+
// l'horloge maître + la source audio ; natif ABSENT (dev web, addon en échec) le
542+
// <video> EST la preview visible. Les seeks SOURCE (rares, discrets) ne sont pas
543+
// throttlés non plus.
544+
if (!nativeActiveRef.current || !videoRef.current?.paused) {
545+
apply(seekTarget.timeSec);
546+
return;
547+
}
548+
const th = scrubSeekThrottleRef.current;
549+
const now = performance.now();
550+
const since = now - th.lastAppliedMs;
551+
if (since >= SCRUB_VIDEO_SEEK_THROTTLE_MS) {
552+
th.lastAppliedMs = now;
553+
apply(seekTarget.timeSec);
554+
return;
555+
}
556+
// Trop tôt depuis le dernier décodage : on mémorise la dernière cible et on programme
557+
// le bord de fuite (une seule fois). Sans lui, la position finale d'un scrub arrivée
558+
// dans la fenêtre ne serait jamais appliquée, et un Play juste après lirait une
559+
// `video.currentTime` périmée → départ audio décalé.
560+
th.pendingTimeSec = seekTarget.timeSec;
561+
if (th.timer === 0) {
562+
th.timer = window.setTimeout(() => {
563+
th.timer = 0;
564+
th.lastAppliedMs = performance.now();
565+
const pending = th.pendingTimeSec;
566+
th.pendingTimeSec = null;
567+
if (pending !== null) apply(pending);
568+
}, SCRUB_VIDEO_SEEK_THROTTLE_MS - since);
510569
}
511570
}, [seekTarget]);
512571

572+
// Annule le bord de fuite en attente au démontage (évite un seek sur un <video> parti).
573+
useEffect(() => {
574+
const throttle = scrubSeekThrottleRef.current;
575+
return () => {
576+
if (throttle.timer !== 0) {
577+
clearTimeout(throttle.timer);
578+
}
579+
};
580+
}, []);
581+
513582
return (
514583
<div className={styles.container}>
515584
{activeSource ? (

0 commit comments

Comments
 (0)