Skip to content

Commit 8766c17

Browse files
committed
perf(preview): pool de décodeurs — plus de réouverture au franchissement de clip au scrub
Mesuré (headless, offscreen) : franchir un clip cross-média coûtait ~120 ms de blocage synchrone sur le thread de rendu — 2× `Decoder::open` (avformat + init D3D11VA + avcodec_open2, ~39 ms) puis 2× seek keyframe + décode-avant (~81 ms). C'est le plus gros à-coup du scrub (les pics p99 100-167 ms de la sonde), ~13× le readback. Et jusqu'ici on JETAIT les décodeurs sortants (`apply_prefetched`), donc revenir sur un clip (motif A→B→A ultra-fréquent) rouvrait tout à neuf + reseek keyframe complet. On garde désormais un petit pool LRU (cap 3) de paires INACTIVES, ouvertes à leur dernière position. Au franchissement cross-média (chemin scrub `active_clip_request`, hors lecture libre déjà préchargée) : si la cible est en pool → reseek au lieu de rouvrir ; sinon ouverture neuve. Dans les deux cas la paire QUITTÉE part en pool (dédup par clé + éviction LRU) au lieu d'être fermée. Effet mesuré, borné honnêtement : - scrub qui SAUTE loin : POOL_HIT ~50-120 ms (open éliminé, seek keyframe restant) vs ~120 ms. - scrub PRÈS d'une frontière (le cas ressenti : on traîne la tête à travers la coupe) : le décodeur poolé est à ~1 frame → chemin rapide `decode_forward` → POOL_HIT ~5-9 ms, soit ~20× moins que les ~120 ms d'avant. Sûreté (la classe « frame sans texture → preview noire définitive » a déjà mordu ici) : - la paire poolée est reseekée AVANT installation ; échec du seek → on la jette et on ouvre à neuf (chemin connu sûr), jamais une frame vide (`seek_pair` rend `false`). - le cache de SRV (keyé sur l'adresse de texture) reste vidé à chaque franchissement comme avant — over-clear est sûr et bon marché, ce qui écarte tout « image du clip précédent ». - lecture libre inchangée : `apply_prefetched`/`advance_to_next_scene_clip` droppent toujours (déjà préchargé, pas de stall) ; seul le scrub alimente/consulte le pool. Vérifié headless (harness bench-clipswitch, OPENSCREEN_CLIPSWITCH_TIMING=1) sur 3 phases — sauts, retours locaux, et cyclage de 5 assets distincts (force l'éviction LRU) : le pool reste borné à 3 et AUCUN franchissement, éviction comprise, ne produit de frame noire. cargo check OK.
1 parent 0104115 commit 8766c17

1 file changed

Lines changed: 158 additions & 19 deletions

File tree

crates/compositor/src/live.rs

Lines changed: 158 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,115 @@ unsafe fn open_and_seek_clip(
100100
Ok(PrefetchedClip { sdec, wdec, webcam_offset_sec, idx, cursor_track })
101101
}
102102

103+
/// Nombre de paires de décodeurs INACTIVES gardées ouvertes en plus de la paire active.
104+
/// Franchir un clip cross-média rouvre sinon 2 décodeurs (~39 ms) et reseek depuis une image
105+
/// clé (~81 ms) — mesuré ~120 ms/franchissement, le plus gros à-coup du scrub. Les garder
106+
/// ouverts à leur dernière position transforme un RETOUR sur un clip (motif A→B→A ultra
107+
/// fréquent au scrub) en simple reseek, souvent par le chemin rapide `decode_forward`.
108+
/// ponytail: cap fixe. Chaque paire retient son pool de surfaces D3D11VA (VRAM) ; 3 couvre
109+
/// les timelines 2-4 clips, à baisser si la VRAM serre.
110+
const DECODER_POOL_CAP: usize = 3;
111+
112+
/// Une paire de décodeurs mise de côté, prête à être réactivée sans réouverture.
113+
struct PooledClip {
114+
screen_path: String,
115+
webcam_path: String,
116+
webcam_offset_sec: f64,
117+
clip: PrefetchedClip,
118+
}
119+
120+
/// Repositionne une paire (écran + webcam) au temps source voulu. `false` = un des deux flux
121+
/// n'a pas de frame utilisable là (position hors flux, EOF non rembobinable) ; l'appelant DOIT
122+
/// alors repartir sur une ouverture fraîche plutôt que de composer une frame vide (« frame sans
123+
/// texture » → preview noire définitive). Partagé par `seek_active` (paire active) et le pool.
124+
unsafe fn seek_pair(
125+
sdec: &mut Decoder,
126+
wdec: &mut Decoder,
127+
source_time_sec: f64,
128+
webcam_offset_sec: f64,
129+
) -> Result<bool> {
130+
let sf = sdec.seek_to(source_time_sec)?;
131+
if sf.is_null() {
132+
return Ok(false);
133+
}
134+
let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?;
135+
if wf.is_null() {
136+
wf = wdec.seek_to(0.0)?;
137+
}
138+
if wf.is_null() {
139+
return Ok(false);
140+
}
141+
Ok(true)
142+
}
143+
144+
/// Bascule cross-média en réutilisant le pool de décodeurs. Réactive la paire cible si elle
145+
/// est déjà en pool (reseek au lieu de rouvrir), sinon ouvre à neuf ; dans les DEUX cas met en
146+
/// pool la paire qu'on QUITTE (au lieu de la fermer), dédupliquée par clé et bornée en LRU.
147+
/// Le vidage du cache de SRV reste à la charge de l'appelant (comme avant) — over-clear est
148+
/// sûr et bon marché, ce qui écarte tout risque « image du clip précédent ».
149+
/// `OPENSCREEN_CLIPSWITCH_TIMING=1` journalise hit/miss + durée.
150+
unsafe fn swap_clip_pooled(
151+
player: &mut Player,
152+
pool: &mut Vec<PooledClip>,
153+
request: &ActiveClipRequest,
154+
active_screen: &str,
155+
active_webcam: &str,
156+
active_webcam_offset_sec: f64,
157+
) -> Result<()> {
158+
let timing = std::env::var("OPENSCREEN_CLIPSWITCH_TIMING").is_ok();
159+
let t0 = std::time::Instant::now();
160+
let t = request.source_time_sec.max(0.0);
161+
let matches = |p: &PooledClip| {
162+
p.screen_path == request.screen_path
163+
&& p.webcam_path == request.webcam_path
164+
&& (p.webcam_offset_sec - request.webcam_offset_sec).abs() < 1e-9
165+
};
166+
let mut hit = false;
167+
let incoming: PrefetchedClip = match pool.iter().position(&matches) {
168+
Some(i) => {
169+
let mut pooled = pool.remove(i);
170+
// Reseek les décodeurs poolés AVANT de les installer. Échec → on les jette et on
171+
// ouvre à neuf (chemin connu sûr), jamais une frame vide.
172+
if seek_pair(&mut pooled.clip.sdec, &mut pooled.clip.wdec, t, request.webcam_offset_sec)? {
173+
pooled.clip.idx = (t * pooled.clip.sdec.fps()).round().max(0.0) as u32;
174+
hit = true;
175+
pooled.clip
176+
} else {
177+
drop(pooled);
178+
player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?
179+
}
180+
}
181+
None => player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?,
182+
};
183+
let outgoing = player.swap_active(incoming);
184+
// Met la paire quittée en pool : dédup par clé (jamais deux entrées d'un même média), puis
185+
// éviction LRU (le plus ancien, en tête, part en premier).
186+
pool.retain(|p| {
187+
!(p.screen_path == active_screen
188+
&& p.webcam_path == active_webcam
189+
&& (p.webcam_offset_sec - active_webcam_offset_sec).abs() < 1e-9)
190+
});
191+
pool.push(PooledClip {
192+
screen_path: active_screen.to_string(),
193+
webcam_path: active_webcam.to_string(),
194+
webcam_offset_sec: active_webcam_offset_sec,
195+
clip: outgoing,
196+
});
197+
while pool.len() > DECODER_POOL_CAP {
198+
pool.remove(0);
199+
}
200+
if timing {
201+
eprintln!(
202+
"[clipswitch] {} {:.1}ms (t_src={:.2}s, pool={})",
203+
if hit { "POOL_HIT reseek" } else { "OPEN fresh " },
204+
t0.elapsed().as_secs_f64() * 1000.0,
205+
t,
206+
pool.len(),
207+
);
208+
}
209+
Ok(())
210+
}
211+
103212
/// Lit deux sources en lockstep et compose la frame courante dans le RT du compositeur.
104213
/// Partagé avec la GUI standalone (`app.rs`).
105214
pub struct Player {
@@ -176,17 +285,10 @@ impl Player {
176285
/// jusqu'à recréation de la vue.
177286
pub unsafe fn seek_active(&mut self, source_time_sec: f64) -> Result<bool> {
178287
let source_time_sec = source_time_sec.max(0.0);
179-
let sf = self.sdec.seek_to(source_time_sec)?;
180-
if sf.is_null() {
181-
return Ok(false);
182-
}
183-
let mut wf = self.wdec.seek_to(webcam_seek_time(source_time_sec, self.webcam_offset_sec))?;
184-
if wf.is_null() {
185-
wf = self.wdec.seek_to(0.0)?;
186-
}
187288
// Les DEUX flux doivent avoir une frame : `compose_frame` les échantillonne tous les
188-
// deux sans condition, un seul manquant suffit à le faire échouer.
189-
if wf.is_null() {
289+
// deux sans condition, un seul manquant suffit à le faire échouer (d'où le `false` que
290+
// `seek_pair` peut rendre → l'appelant retombe sur l'ouverture complète).
291+
if !seek_pair(&mut self.sdec, &mut self.wdec, source_time_sec, self.webcam_offset_sec)? {
190292
return Ok(false);
191293
}
192294
self.idx = (source_time_sec * self.sdec.fps()).round().max(0.0) as u32;
@@ -200,12 +302,39 @@ impl Player {
200302
/// propre `open_and_seek_clip`) et directement par `render_thread` quand un préchargement
201303
/// en tâche de fond est déjà prêt au moment de franchir la frontière du clip.
202304
unsafe fn apply_prefetched(&mut self, prefetched: PrefetchedClip) {
203-
self.sdec = prefetched.sdec;
204-
self.wdec = prefetched.wdec;
205-
self.webcam_offset_sec = prefetched.webcam_offset_sec;
305+
// La paire sortante est droppée ici (comportement inchangé pour la lecture libre) ; le
306+
// pool du scrub, lui, récupère la sortante en appelant `swap_active` directement.
307+
let _ = self.swap_active(prefetched);
308+
}
309+
310+
/// Échange la paire active contre `incoming` (déjà ouverte + positionnée) et REND la paire
311+
/// sortante — pour la mettre en pool au lieu de la fermer. Aucune E/S : juste des champs.
312+
unsafe fn swap_active(&mut self, incoming: PrefetchedClip) -> PrefetchedClip {
313+
let outgoing = PrefetchedClip {
314+
sdec: std::mem::replace(&mut self.sdec, incoming.sdec),
315+
wdec: std::mem::replace(&mut self.wdec, incoming.wdec),
316+
webcam_offset_sec: self.webcam_offset_sec,
317+
idx: self.idx,
318+
// Le curseur est re-dérivé du chemin à la réactivation ; inutile de le trimballer.
319+
cursor_track: None,
320+
};
321+
self.webcam_offset_sec = incoming.webcam_offset_sec;
322+
self.idx = incoming.idx;
206323
self.has_current_frame = true;
207324
self.use_current_on_next_step = true;
208-
self.idx = prefetched.idx;
325+
outgoing
326+
}
327+
328+
/// Ouvre une nouvelle paire de décodeurs positionnée à `source_time_sec`, SANS l'installer
329+
/// (l'appelant l'échange via `swap_active`). Réutilise le device D3D11 du player.
330+
unsafe fn open_clip(
331+
&self,
332+
screen: &str,
333+
webcam: &str,
334+
webcam_offset_sec: f64,
335+
source_time_sec: f64,
336+
) -> Result<PrefetchedClip> {
337+
open_and_seek_clip(screen, webcam, webcam_offset_sec, source_time_sec, &self.gpu)
209338
}
210339

211340
/// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le
@@ -1059,6 +1188,10 @@ unsafe fn render_thread(
10591188
// devient obsolète (nouvelle scène, changement de clip explicite) pour ne jamais risquer
10601189
// d'appliquer les décodeurs d'un préchargement qui ne correspond plus à la situation.
10611190
let mut prefetch: Option<PendingPrefetch> = None;
1191+
// Pool de décodeurs INACTIFS gardés ouverts entre franchissements de clip (scrub). Vidé
1192+
// quand le thread de rendu meurt (vue détruite / document rechargé). Voir `swap_clip_pooled`
1193+
// et `DECODER_POOL_CAP` — c'est le remède au ~120 ms/franchissement mesuré.
1194+
let mut decoder_pool: Vec<PooledClip> = Vec::new();
10621195

10631196
// config de base = C8 (tous effets) ; le fond flouté est piloté par le param live.
10641197
let mut cfg = config::all().pop().expect("au moins une config");
@@ -1104,11 +1237,17 @@ unsafe fn render_thread(
11041237
let switch_result = if repositioned {
11051238
Ok(())
11061239
} else {
1107-
player.set_active_clip(
1108-
&request.screen_path,
1109-
&request.webcam_path,
1110-
request.webcam_offset_sec,
1111-
request.source_time_sec,
1240+
// Cross-média : passe par le pool de décodeurs — réutilise une paire déjà
1241+
// ouverte si possible (reseek au lieu de rouvrir) et met en pool celle qu'on
1242+
// quitte, au lieu du couple ouvrir-puis-fermer. Voir `swap_clip_pooled` et la
1243+
// mesure de ~120 ms/franchissement qui l'a motivé.
1244+
swap_clip_pooled(
1245+
&mut player,
1246+
&mut decoder_pool,
1247+
&request,
1248+
&active_screen_path,
1249+
&active_webcam_path,
1250+
active_webcam_offset_sec,
11121251
)
11131252
};
11141253
match switch_result {

0 commit comments

Comments
 (0)