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
125 changes: 108 additions & 17 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/ai-edition/NativeCompositorOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;

Expand Down
5 changes: 5 additions & 0 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading