diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 7f18ee8c77..0ba8e9e884 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -153,6 +153,48 @@ impl Player { Ok(()) } + /// Repositionne les décodeurs DÉJÀ ouverts, sans en rouvrir aucun. + /// + /// Pendant de `set_active_clip` pour le cas — dominant — où les FICHIERS n'ont pas + /// changé. L'app raisonne en *segments* (`resolveVisibleClips` découpe les clips aux + /// trims), et deux segments consécutifs d'un même clip pointent sur le même fichier + /// source : seule la fenêtre temporelle diffère. Y répondre par un `set_active_clip` + /// complet, c'est refaire un `Decoder::open` + `avformat_find_stream_info` + une init + /// D3D11VA, deux fois, pour rien. + /// + /// Mesuré : un scrub traversant deux clips a produit 31 bascules — 21 à moins de 500 ms + /// l'une de l'autre — pour deux changements de média réels. + /// Rend `false` quand le repositionnement n'aboutit pas — l'appelant DOIT alors + /// retomber sur `set_active_clip`. + /// + /// Cette sortie existe parce que ce chemin hérite de décodeurs déjà ouverts, donc d'un + /// état : fin de piste atteinte, position hors de la fenêtre, EOF déjà envoyé. + /// `open_and_seek_clip` ne peut pas rencontrer ça (ses décodeurs sont neufs). Une + /// première version marquait la frame comme utilisable sans vérifier les DEUX seeks ; + /// `compose_frame` recevait alors un `AVFrame` vide, `nv12_srvs` échouait avec « frame + /// sans texture D3D11 », et le thread de rendu s'arrêtait définitivement — preview noire + /// jusqu'à recréation de la vue. + pub unsafe fn seek_active(&mut self, source_time_sec: f64) -> Result { + let source_time_sec = source_time_sec.max(0.0); + let sf = self.sdec.seek_to(source_time_sec)?; + if sf.is_null() { + return Ok(false); + } + let mut wf = self.wdec.seek_to(webcam_seek_time(source_time_sec, self.webcam_offset_sec))?; + if wf.is_null() { + wf = self.wdec.seek_to(0.0)?; + } + // Les DEUX flux doivent avoir une frame : `compose_frame` les échantillonne tous les + // deux sans condition, un seul manquant suffit à le faire échouer. + if wf.is_null() { + return Ok(false); + } + self.idx = (source_time_sec * self.sdec.fps()).round().max(0.0) as u32; + self.has_current_frame = true; + self.use_current_on_next_step = true; + Ok(true) + } + /// Bascule instantanément sur une paire de décodeurs déjà ouverte + positionnée — aucune /// E/S ici, juste l'échange des champs. Utilisé par `set_active_clip` (juste après son /// propre `open_and_seek_clip`) et directement par `render_thread` quand un préchargement @@ -910,6 +952,12 @@ unsafe fn advance_to_next_scene_clip( match applied { Ok(()) => { + // Jeu de décodeurs remplacé ici aussi (franchissement pendant la lecture libre) : + // même raison qu'au traitement d'`active_clip_request` — le cache de SRV est keyé + // sur l'adresse de la texture, et garder des entrées d'un décodeur fermé fait + // fuir de la VRAM puis, en cas de réutilisation d'adresse, rendre l'image du clip + // précédent. + comp.clear_srv_cache(); *active_screen_path = next_clip.screen_path.clone(); *active_webcam_path = next_clip.webcam_path.clone(); *active_webcam_offset_sec = next_clip.webcam_offset_sec; @@ -978,6 +1026,9 @@ unsafe fn render_thread( // Gardée à part (raw_cursor) pour pouvoir régénérer une variante lissée sans relire le // fichier à chaque changement du slider "smoothing" (voir la boucle plus bas). let mut raw_cursor = CursorTrack::load(cursor_json, 0.0, 24.0 * 3600.0).ok(); + /// Chemin de la télémétrie curseur actuellement chargée dans `raw_cursor` — évite de + /// relire le même fichier à chaque changement de segment (voir plus bas). + let mut loaded_cursor_path = cursor_json.to_string(); if let Some(track) = &raw_cursor { comp.set_cursor(track.smoothed(0.0)); } @@ -1031,13 +1082,42 @@ unsafe fn render_thread( // autrement) — sans ça, `advance_to_next_scene_clip` pourrait plus tard appliquer // des décodeurs qui ne correspondent plus au contexte réel. prefetch = None; - match player.set_active_clip( - &request.screen_path, - &request.webcam_path, - request.webcam_offset_sec, - request.source_time_sec, - ) { + // Mêmes fichiers que ceux déjà ouverts → seule la fenêtre temporelle change + // (segments d'un même clip séparés par un trim). On repositionne au lieu de + // rouvrir : voir `Player::seek_active` pour ce que ça évite. + let same_media = request.screen_path == active_screen_path + && request.webcam_path == active_webcam_path + && (request.webcam_offset_sec - active_webcam_offset_sec).abs() < 1e-9; + // Le repositionnement n'est tenté que sur médias identiques, et son échec n'est + // JAMAIS fatal : on retombe sur l'ouverture complète, chemin connu comme sûr. + // L'optimisation ne s'applique donc que là où elle fonctionne démontrablement. + let repositioned = same_media && matches!(player.seek_active(request.source_time_sec), Ok(true)); + let switch_result = if repositioned { + Ok(()) + } else { + player.set_active_clip( + &request.screen_path, + &request.webcam_path, + request.webcam_offset_sec, + request.source_time_sec, + ) + }; + match switch_result { Ok(()) => { + // Condition sur `repositioned`, pas sur `same_media` : un repositionnement + // qui a échoué est retombé sur l'ouverture complète, donc des décodeurs + // ONT été fermés et le cache doit être vidé malgré des médias identiques. + if !repositioned { + // Les anciens décodeurs viennent d'être fermés : leurs textures ne + // doivent plus figurer dans le cache de SRV, qui est keyé sur + // l'ADRESSE de la texture. Sans ce vidage, deux défauts se cumulent — + // le cache grandit sans borne et retient les textures via les SRV + // clonés ; et un décodeur neuf peut allouer à une adresse déjà vue, + // donner une collision de clé, et faire rendre l'image du clip + // PRÉCÉDENT. `clear_srv_cache` existait pour ça et n'avait aucun + // appelant. + comp.clear_srv_cache(); + } active_screen_path = request.screen_path; active_webcam_path = request.webcam_path; active_webcam_offset_sec = request.webcam_offset_sec; @@ -1061,25 +1141,36 @@ unsafe fn render_thread( comp.set_scene(Some(scene_for_clip(&base_scene, active_clip_index))); scene_applied = true; } + // Relire la télémétrie curseur seulement si le FICHIER change. Elle était + // rechargée — ouverture disque + parse JSON — à chaque demande de clip, y + // compris quand seul le segment changeait, où le chemin est par + // construction identique. Mesuré : 66 bascules sur un scrub de deux clips, + // donc 66 relectures du même fichier. let cursor_path = format!("{}.cursor.json", active_screen_path); - raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); - match &raw_cursor { - Some(track) => { - eprintln!( + if cursor_path != loaded_cursor_path { + loaded_cursor_path = cursor_path.clone(); + raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); + // Journalisé ICI seulement : sinon la ligne annonce « loaded=ok » à + // chaque changement de segment alors que rien n'a été relu, et le log + // laisse croire à un travail qui n'a plus lieu. + match &raw_cursor { + Some(track) => eprintln!( "[live] cursor: path={} loaded=ok samples={}", cursor_path, track.sample_count(), - ); - comp.set_cursor(track.smoothed(0.0)); - } - None => { - eprintln!( + ), + None => eprintln!( "[live] cursor: path={} loaded=FAIL — clear_cursor()", cursor_path, - ); - comp.clear_cursor(); + ), } } + // Appliqué à chaque fois, y compris sans relecture : le compositeur peut + // avoir été reconstruit (changement de taille) et perdu son curseur. + match &raw_cursor { + Some(track) => comp.set_cursor(track.smoothed(0.0)), + None => comp.clear_cursor(), + } last_smoothing = -1.0; clip_changed = true; } diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx index 445abf0f23..c232e61652 100644 --- a/src/components/ai-edition/NativeCompositorOverlay.tsx +++ b/src/components/ai-edition/NativeCompositorOverlay.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { noteUiProbeClipSwitch } from "@/lib/ai-edition/perf/uiFrameProbe"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { resolveNativePosition } from "@/lib/ai-edition/timeline/timelineMap"; import { @@ -163,6 +164,9 @@ export function NativeCompositorOverlay() { const cam = asset.cameraTrack; const webcamPath = cam && cam.visible && cam.sourcePath ? cam.sourcePath : ""; const targetClipId = activeClipId; + // Sonde de fluidité (diagnostic) : sépare les mesures d'avant et d'après un + // franchissement de clip, qui se sont déjà révélées non comparables. + noteUiProbeClipSwitch(previousActiveClipIdRef.current, activeClipId); pendingTargetClipIdRef.current = targetClipId; previousActiveClipIdRef.current = targetClipId; diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index b56c41fd2b..8e53bf75f5 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -31,6 +31,7 @@ import { useScopedT } from "@/contexts/I18nContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; import { createId } from "@/lib/ai-edition/document/ids"; import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat"; +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 { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; @@ -442,8 +443,12 @@ export function V4Timeline({ if (target.closest("[data-clip-id]") || target.closest(`.${styles.lanePill}`)) return; tl.clearSelection(); seekToClientX(e.clientX, true); + // Sonde de fluidité (diagnostic) : marque la fenêtre de drag pour que les + // intervalles rAF mesurés pendant le scrub soient comptés à part. + setUiProbeScrubbing(true); const move = (ev: PointerEvent) => seekToClientX(ev.clientX); const up = () => { + setUiProbeScrubbing(false); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); if (rafSeekRef.current !== 0) { diff --git a/src/lib/ai-edition/perf/uiFrameProbe.ts b/src/lib/ai-edition/perf/uiFrameProbe.ts new file mode 100644 index 0000000000..abfc69c94d --- /dev/null +++ b/src/lib/ai-edition/perf/uiFrameProbe.ts @@ -0,0 +1,291 @@ +/** + * Sonde de fluidité de l'UI — DIAGNOSTIC, à retirer une fois la question tranchée. + * + * La question : quand la preview tourne, l'UI est-elle réellement en retard, et de combien ? + * L'observation qui la motive est empirique — quand la fenêtre de preview disparaît, le + * curseur redevient fluide — mais aucune mesure ne l'a jamais confirmée. + * + * Ce qu'elle corrige par rapport aux mesures précédentes : + * + * - **Elle segmente par état** au lieu de moyenner. Une moyenne sur une fenêtre dont on + * ignore le taux d'activité ne dit rien : 45 s dont 15 s de scrub donnent un chiffre + * dilué par 30 s d'inactivité, et on l'attribue quand même au scrub. Ici chaque + * intervalle est rangé dans l'état où il a été mesuré. + * - **Elle compte les frames LONGUES** plutôt que de rapporter une moyenne. Une UI rugueuse + * n'a pas une moyenne haute, elle a des retards épisodiques : à 60 Hz, un p50 à 16,7 ms + * peut coexister avec 5 % de frames à 50 ms, et ce sont ces 5 % qu'on ressent. + * - **Elle refuse de mesurer une fenêtre cachée**, qui est throttlée par Chromium et + * rendrait tout inadmissible. + * + * Activation : `window.__uiProbe.start()` depuis la console du renderer. Rien ne tourne tant + * qu'on ne le demande pas — aucun coût en usage normal. + */ + +type BaseState = "repos" | "preview" | "scrub" | "scrub+preview"; +/** L'état porte le suffixe `@N` = nombre de bascules de clip déjà vues. Voir + * `noteUiProbeClipSwitch` : sans cette séparation, deux mesures ne sont pas comparables. */ +type ProbeState = string; + +interface Bucket { + intervals: number[]; +} + +const buckets = new Map(); +let running = false; +let rafHandle = 0; +let lastTs = 0; +/** Frames de preview peintes depuis le dernier tick — dit si la preview travaille. */ +let previewFramesSinceTick = 0; +let scrubbing = false; +/** Bascules de clip vues depuis le démarrage de la sonde. Sépare les états. */ +let clipSwitches = 0; + +/** Appelé par le hook de preview à chaque frame effectivement livrée. */ +export function noteUiProbePreviewFrame(): void { + if (running) { + previewFramesSinceTick++; + } +} + +/** Appelé par la timeline à l'entrée et à la sortie d'un drag de tête de lecture. */ +export function setUiProbeScrubbing(active: boolean): void { + scrubbing = active; +} + +/** + * Appelé quand la preview bascule sur un autre clip. + * + * Existe parce qu'une comparaison a déjà été faussée par cette variable : un run où le + * scrub restait dans un seul clip donnait 8,8 % de frames en retard, un autre où il + * franchissait une frontière en donnait 21 %, et on a attribué l'écart à un changement de + * code qui n'y était pour rien. Tant que « avant » et « après » ne sont pas séparés, deux + * mesures ne sont pas comparables — donc la sonde le fait elle-même plutôt que de compter + * sur la discipline de celui qui teste. + */ +export function noteUiProbeClipSwitch(fromClipId: string | null, toClipId: string): void { + if (!running) { + return; + } + clipSwitches++; + clipSwitchLog.push({ t: performance.now(), from: fromClipId ?? "-", to: toClipId }); +} + +/** Horodatage et destination de chaque bascule — pour distinguer une oscillation rapide + * (aller-retour au voisinage d'une frontière) d'un étalement normal. Les deux n'appellent + * pas le même remède : un anti-rebond pour la première, une correction de la logique de + * résolution de clip pour la seconde. */ +const clipSwitchLog: { t: number; from: string; to: string }[] = []; + +function bucketFor(state: ProbeState): Bucket { + let b = buckets.get(state); + if (!b) { + b = { intervals: [] }; + buckets.set(state, b); + } + return b; +} + +function currentState(): ProbeState { + const previewActive = previewFramesSinceTick > 0; + const base: BaseState = + scrubbing && previewActive + ? "scrub+preview" + : scrubbing + ? "scrub" + : previewActive + ? "preview" + : "repos"; + return `${base}@${clipSwitches}`; +} + +function tick(ts: number) { + rafHandle = requestAnimationFrame(tick); + if (lastTs !== 0) { + bucketFor(currentState()).intervals.push(ts - lastTs); + } + lastTs = ts; + previewFramesSinceTick = 0; +} + +function summarize() { + const rows: string[] = []; + // Trié par état puis par nombre de bascules, pour que « avant / après le 1er + // changement de clip » se lisent l'un sous l'autre. + const states = [...buckets.keys()].sort((a, b) => { + const order = ["repos", "preview", "scrub", "scrub+preview"]; + const [ba, na] = a.split("@"); + const [bb, nb] = b.split("@"); + return order.indexOf(ba) - order.indexOf(bb) || Number(na) - Number(nb); + }); + for (const state of states) { + const b = buckets.get(state); + if (!b || b.intervals.length < 10) { + continue; + } + const s = [...b.intervals].sort((a, x) => a - x); + const q = (p: number) => s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; + // Une frame « en retard » = au-delà d'un vsync et demi à 60 Hz. C'est le seuil à + // partir duquel un mouvement suivi à l'œil commence à accrocher. + const late = s.filter((x) => x > 25).length; + const veryLate = s.filter((x) => x > 40).length; + rows.push( + `${state.padEnd(18)} n=${String(s.length).padStart(5)} ` + + `p50=${q(50).toFixed(1)} p90=${q(90).toFixed(1)} p99=${q(99).toFixed(1)} ` + + `max=${s[s.length - 1].toFixed(1)} ` + + `>25ms=${((late / s.length) * 100).toFixed(1)}% >40ms=${((veryLate / s.length) * 100).toFixed(1)}%`, + ); + } + // Nombre d'éléments `