Skip to content
Merged
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
166 changes: 165 additions & 1 deletion src/components/ai-edition/TransportBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
import { act, cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/contexts/I18nContext";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
Expand Down Expand Up @@ -92,3 +92,167 @@ describe("TransportBar reads the playhead from the store", () => {
expect(screen.getByText("0:04.5")).toBeInTheDocument();
});
});

describe("le drag de la barre de progression est coalescé en rAF", () => {
beforeEach(() => {
useProjectStore.setState({ currentTimeSec: 0 });
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});

/** rAF piloté à la main : les callbacks ne partent que sur `flush()`, ce qui permet de
* compter ce qui se passe DANS une frame — impossible avec le vrai rAF en test. */
function stubRaf() {
const pending: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
pending.push(cb);
return pending.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {
// rien à annuler : `flush()` ne rejoue que ce qui reste en attente
});
return () => {
const due = pending.splice(0, pending.length);
for (const cb of due) cb(0);
};
}

// C'est LE défaut que ce chemin avait : `onChange` d'un `<input type="range">` se
// déclenche à la cadence du pointeur (jusqu'à 1000 Hz) et appelait `onSeek` à chaque
// fois. Or `onSeek` repose un `seekTarget` dans l'état de la racine de l'éditeur, donc
// re-rend tout et fait poser `<video>.currentTime`. La timeline fait les mêmes appels
// mais une fois par frame ; ce test verrouille cette parité.
it("n'émet qu'un seul seek par frame, quel que soit le nombre d'événements", () => {
const flush = stubRaf();
const onSeek = vi.fn();
render(
<I18nProvider>
<TransportBar
playing={false}
overrideTimeSec={null}
clips={clips}
onTogglePlay={noop}
onPrevClip={noop}
onNextClip={noop}
onSeek={onSeek}
/>
</I18nProvider>,
);
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;

act(() => {
fireEvent.pointerDown(input);
});
// Dix mouvements dans la même frame : un seul seek doit en sortir, et c'est le
// DERNIER qui compte — sans quoi la tête accuserait un retard permanent.
act(() => {
for (const value of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
fireEvent.change(input, { target: { value: String(value) } });
}
});
expect(onSeek).not.toHaveBeenCalled();

act(() => flush());
expect(onSeek).toHaveBeenCalledTimes(1);
expect(onSeek).toHaveBeenLastCalledWith(10);
});

// Le mouvement compris entre le dernier rAF et le relâchement serait perdu sans commit
// final : la tête s'arrêterait un cran avant le doigt.
it("pose la dernière position au relâchement", () => {
stubRaf();
const onSeek = vi.fn();
render(
<I18nProvider>
<TransportBar
playing={false}
overrideTimeSec={null}
clips={clips}
onTogglePlay={noop}
onPrevClip={noop}
onNextClip={noop}
onSeek={onSeek}
/>
</I18nProvider>,
);
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;

act(() => {
fireEvent.pointerDown(input);
fireEvent.change(input, { target: { value: "7" } });
fireEvent.pointerUp(input);
});
expect(onSeek).toHaveBeenCalledWith(7);
});

// Hors drag il n'y a rien à coalescer : une flèche du clavier est un saut unique et
// doit prendre effet immédiatement, sans attendre une frame.
it("applique immédiatement un changement hors drag", () => {
stubRaf();
const onSeek = vi.fn();
render(
<I18nProvider>
<TransportBar
playing={false}
overrideTimeSec={null}
clips={clips}
onTogglePlay={noop}
onPrevClip={noop}
onNextClip={noop}
onSeek={onSeek}
/>
</I18nProvider>,
);
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;

act(() => {
fireEvent.change(input, { target: { value: "3" } });
});
expect(onSeek).toHaveBeenCalledTimes(1);
expect(onSeek).toHaveBeenCalledWith(3);
});

// Le remplissage et le curseur étaient positionnés uniquement par React depuis le store,
// donc en retard d'un commit à chaque mouvement — alors que la tête de lecture de la
// timeline, elle, est écrite directement dans le DOM et colle au pointeur. Ce test
// verrouille la parité : le visuel bouge AVANT le rAF, donc sans attendre React.
it("déplace le curseur dans le DOM avant tout rendu React", () => {
stubRaf();
const onSeek = vi.fn();
const { container } = render(
<I18nProvider>
<TransportBar
playing={false}
overrideTimeSec={null}
clips={clips}
onTogglePlay={noop}
onPrevClip={noop}
onNextClip={noop}
onSeek={onSeek}
/>
</I18nProvider>,
);
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;
// Les deux éléments décorés : le remplissage (largeur) et le curseur (position).
const styled = Array.from(container.querySelectorAll("div")).filter(
(el) => el.style.width !== "" || el.style.left !== "",
);
expect(styled.length).toBeGreaterThanOrEqual(2);

act(() => {
fireEvent.pointerDown(input);
// La moitié de la durée (clip de 30 s) → 50 %.
fireEvent.change(input, { target: { value: "15" } });
});

// Aucun rAF n'a été vidé, donc aucune écriture au store et aucun rendu React : ce qui
// a bougé ne peut venir que de l'écriture DOM directe.
expect(onSeek).not.toHaveBeenCalled();
const moved = styled.filter((el) => el.style.width === "50%" || el.style.left === "50%");
expect(moved.length).toBeGreaterThanOrEqual(2);
});
});
119 changes: 116 additions & 3 deletions src/components/ai-edition/TransportBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Pause, Play, SkipBack, SkipForward } from "lucide-react";
import { memo } from "react";
import { memo, useCallback, useEffect, useRef } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe";
import type { AxcutClip } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import styles from "./NewEditorShell.module.css";
Expand Down Expand Up @@ -51,6 +52,100 @@ export const TransportBar = memo(function TransportBar({
const inputValue = Math.min(Math.max(currentTimeSec, 0), inputMax);
const progress = (inputValue / inputMax) * 100;

// ── Drag de la barre : même cadence que le drag de la timeline ──────────────────
//
// L'`onChange` d'un `<input type="range">` se déclenche à la cadence du POINTEUR — 125 à
// 1000 Hz selon la souris — et appelait `onSeek` à chacun. Or `onSeek` est `handleSeek`,
// qui écrit au store ET repose un `seekTarget` neuf dans l'état de `NewEditorShell`, la
// racine : chaque appel re-rend tout l'éditeur et fait poser `<video>.currentTime`, un
// vrai seek média.
//
// La timeline fait exactement les mêmes appels, mais les coalesce en rAF — donc ~60 fois
// par seconde au lieu de jusqu'à 1000. C'est cette seule différence de cadence qui
// sépare les deux chemins, et elle explique l'écart de fluidité remonté en usage.
//
// On ne fait donc rien de plus que rétablir la parité : même travail, cadence d'écran.
// Délibérément PAS « ne poser le seekTarget qu'au relâchement » — ce serait un
// comportement différent de la timeline, où le `<video>` suit pendant le drag, et
// divergence entre deux chemins censés faire la même chose est précisément ce qui a
// produit les bugs de cette zone.
const rafRef = useRef(0);
const pendingRef = useRef<number | null>(null);
const draggingRef = useRef(false);
// Remplissage et curseur de la barre, écrits DIRECTEMENT pendant un drag.
//
// Même patron que `playheadElRef` dans V4Timeline, et pour la même raison : la position
// que l'utilisateur voit ne doit pas attendre un rendu React. Ces deux éléments étaient
// positionnés uniquement depuis `progress`, dérivé du store — donc en retard d'un commit
// à chaque mouvement, alors que la tête de lecture de la timeline, elle, colle au
// pointeur. C'est la dernière différence de parité entre les deux chemins que j'aie pu
// identifier dans le code.
//
// React continue de les positionner hors drag (et au rendu suivant pendant le drag, avec
// une valeur au pire vieille d'une frame puisque le rAF écrit au store à 60 Hz) : les
// deux écritures convergent au lieu de se contredire.
const progressElRef = useRef<HTMLDivElement | null>(null);
const thumbElRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
return () => {
if (rafRef.current !== 0) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);

const handleInputChange = useCallback(
(value: number) => {
// Hors drag (flèches du clavier, clic simple sur la piste) : rien à coalescer,
// c'est un saut unique et il doit prendre effet tout de suite.
if (!draggingRef.current) {
onSeek(value);
return;
}
pendingRef.current = value;
// Visuel d'abord, sans passer par React : latence nulle, comme la tête de lecture
// de la timeline. `inputMax` est déjà borné à 1 minimum, pas de division par zéro.
const pct = Math.min(100, Math.max(0, (value / inputMax) * 100));
if (progressElRef.current) {
progressElRef.current.style.width = `${pct}%`;
}
if (thumbElRef.current) {
thumbElRef.current.style.left = `${pct}%`;
}
if (rafRef.current !== 0) {
return;
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const pending = pendingRef.current;
if (pending !== null) {
onSeek(pending);
}
});
},
[onSeek, inputMax],
);

const endDrag = useCallback(() => {
if (!draggingRef.current) {
return;
}
draggingRef.current = false;
setUiProbeScrubbing(false, "bar");
if (rafRef.current !== 0) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
// Pose la dernière position : sans ce commit, le mouvement compris entre le dernier
// rAF et le relâchement serait perdu et la tête s'arrêterait un cran avant le doigt.
const pending = pendingRef.current;
if (pending !== null) {
pendingRef.current = null;
onSeek(pending);
}
}, [onSeek]);

return (
<div className={styles.transport} role="toolbar" aria-label={te("transport.playbackControls")}>
<button
Expand Down Expand Up @@ -88,19 +183,37 @@ export const TransportBar = memo(function TransportBar({
</span>
<div className={styles.scrubBar}>
<div className={styles.scrubTrack}>
<div className={styles.scrubProgress} style={{ width: `${progress}%` }} />
<div
ref={progressElRef}
className={styles.scrubProgress}
style={{ width: `${progress}%` }}
/>
</div>
<input
type="range"
min={0}
max={inputMax}
step={0.01}
value={inputValue}
onChange={(e) => onSeek(Number(e.target.value))}
onChange={(e) => handleInputChange(Number(e.target.value))}
onPointerDown={() => {
draggingRef.current = true;
// Sonde de fluidité (diagnostic) : marque la fenêtre de drag comme
// venant de la BARRE, pour ne pas la confondre avec un drag de
// timeline — les deux empruntent des chemins de code différents.
setUiProbeScrubbing(true, "bar");
}}
onPointerUp={endDrag}
// `pointercancel` et `blur` ferment aussi le drag : un pointeur capturé
// puis interrompu (geste système, perte de focus) ne produit pas de
// `pointerup`, et la dernière position resterait alors non confirmée.
onPointerCancel={endDrag}
onBlur={endDrag}
className={styles.scrubInput}
aria-label={te("transport.seekVideo")}
/>
<div
ref={thumbElRef}
className={styles.scrubThumb}
style={{
left: `${progress}%`,
Expand Down
Loading
Loading