-
-
Notifications
You must be signed in to change notification settings - Fork 200
Expand file tree
/
Copy pathlive.rs
More file actions
2295 lines (2157 loc) · 113 KB
/
Copy pathlive.rs
File metadata and controls
2295 lines (2157 loc) · 113 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Vue live : rend le compositing **hors-fenêtre** vers un `Vec<u8>` RGBA8
//! (taille `set_rect`) destiné à être streamé dans un `<canvas>` Electron via
//! `putImageData`. Option B (canvas) — l'ancienne option A (fenêtre D3D enfant
//! `WS_POPUP` + swapchain) supprimée : la glue TS n'a plus de surface native à
//! embarquer, elle draw chaque frame reçue comme une image bitmap.
//!
//! Pipeline interne : `Player` (decodeur lockstep screen/webcam) +
//! `Compositor::compose_frame` → RT RGBA rastérisé à la GÉOMÉTRIE DE RENDU (depuis la
//! refonte ratio : géométrie de sortie ramenée à la taille du panneau, plus le canvas
//! 16:9 figé d'avant). Le **post-traitement** :
//! - avant : blit du RT vers le backbuffer du swapchain, `Present`.
//! - maintenant : `comp.readback_direct()` copie le RT directement vers la staging
//! `D3D11_USAGE_STAGING` (déjà dimensionnée à la résolution de rendu), `Map`/
//! `D3D11_MAP_READ`, copie ligne par ligne qui respecte `RowPitch` (même idiome que
//! `dump_nv12`/`dump_raw`), et stocke le `Vec<u8>` dans `Shared::latest_frame` pour
//! le `read_frame` napi. Plus de resize intermédiaire (`blit_resized`) : le RT est
//! déjà à la taille voulue, CSS met à l'échelle vers la boîte du panneau côté JS.
//!
//! Modèle de threads : la vue n'a plus de HWND/UI côté thread appelant. Le rendu vit
//! sur un thread dédié — le thread JS/UI n'est jamais bloqué. Les objets COM et la
//! staging restent sur ce thread de rendu ; la frame est publiée via un
//! `Mutex<Option<(u64 gen, u32 w, u32 h, Vec<u8>)>>` pour la traversée de threads vers
//! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`).
use crate::compositor::{Compositor, LiveParams};
use crate::regions::{speed_at, ProgrammeClock};
use crate::scene::Scene;
use crate::config::{self, Cfg};
use crate::cursor::CursorTrack;
use crate::d3d::Gpu;
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::Decoder;
use crate::timeline_walk::{frame_step, FrameStep, NextFrameTime};
use anyhow::Result;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
/// "#rrggbb" (ou "rrggbb") → [r, g, b, 1] en 0..1. None si invalide.
fn parse_hex_color(s: &str) -> Option<[f32; 4]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 {
return None;
}
let r = u8::from_str_radix(&h[0..2], 16).ok()? as f32 / 255.0;
let g = u8::from_str_radix(&h[2..4], 16).ok()? as f32 / 255.0;
let b = u8::from_str_radix(&h[4..6], 16).ok()? as f32 / 255.0;
Some([r, g, b, 1.0])
}
fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 {
(screen_source_time_sec - webcam_offset_sec).max(0.0)
}
/// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat
/// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch`
/// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus
/// aucune E/S : c'est ce qui rend la bascule à la frontière d'un clip instantanée au lieu de
/// payer un `Decoder::open` (ouverture fichier + parsing FFmpeg) synchrone pile au moment de
/// la transition — la pause perceptible observée en usage réel.
struct PrefetchedClip {
sdec: Decoder,
wdec: Decoder,
/// `wdec` est-il la VRAIE caméra, ou le remplaçant écran (pas de caméra déclarée, ou
/// déclarée et illisible) ? Voyage avec la paire de décodeurs parce que c'est d'ELLE que
/// la réponse dépend, pas de la scène : deux clips de la même timeline peuvent avoir l'un
/// une caméra qui s'ouvre et l'autre un fichier mort, et le pool (`PooledClip`) réactive
/// des paires ouvertes plusieurs bascules plus tôt. Cf. `open_webcam_or_stand_in`.
webcam_decoder_is_real: bool,
webcam_offset_sec: f64,
idx: u32,
/// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs :
/// sans ça, la bascule à la frontière restait synchrone sur CE point précis (lecture +
/// parsing JSON du `.cursor.json`, potentiellement des milliers d'échantillons pour un
/// enregistrement long) même après que le préchargement des décodeurs a supprimé le gros
/// de la pause perceptible — un second petit accroc au même endroit, pour la même raison
/// (une E/S synchrone pile à la frontière) qu'on venait de corriger pour les décodeurs.
cursor_track: Option<CursorTrack>,
}
/// Ouvre + positionne la paire de décodeurs d'un clip (même travail que
/// `Player::set_active_clip`, mais autonome — sans instance `Player` existante, pour pouvoir
/// tourner sur un thread dédié pendant que le `Player` réel joue encore le clip actif).
/// Ouvre le décodeur webcam, ou un remplaçant quand le clip n'a pas de caméra.
///
/// La question « ce chemin désigne-t-il une vraie caméra ? » a déjà une réponse dans ce
/// crate : `webcam_is_real`. Elle couvre le chemin vide, le chemin composé d'espaces, et le
/// cas où l'appelant renvoie le chemin de l'écran lui-même — ce que fait `ExportDialog.tsx`
/// et ce que contiennent les scènes plus anciennes. Un simple `is_empty()` en raterait deux
/// sur trois, et le commentaire de `webcam_is_real` rappelle que c'est exactement cet oubli
/// qui avait mis l'enregistrement d'écran dans la vignette caméra (#265).
///
/// Sans caméra, le remplaçant est l'écran : le moteur veut une paire de décodeurs toujours
/// valide plutôt qu'un `Option` à dérouler sur tout le chemin chaud, et rien ne le dessine
/// puisque la composition ne pose une vignette que si le document déclare une caméra.
///
/// Avec une caméra déclarée dont le fichier ne s'ouvre pas, le chemin, lui, reste parfaitement
/// plausible : `webcam_is_real` répond vrai, la vignette était donc dessinée — sur le
/// remplaçant, c'est-à-dire l'enregistrement d'écran dupliqué dans son propre coin. Vu en vrai
/// avec un `.mp4` webcam de 0 octet, laissé non finalisé par le helper de capture natif.
///
/// D'où le `bool` rendu à côté du décodeur : « ce que je te rends est-il VRAIMENT la caméra ? ».
/// Seule l'ouverture peut répondre — aucune inspection du chemin ne sait qu'un fichier est mort
/// — et c'est cette réponse, et pas le chemin, qui décide de dessiner la vignette (voir
/// `should_draw_webcam`).
///
/// ponytail: on garde le remplaçant plutôt que de passer `wdec` en `Option<Decoder>`, ce qui
/// toucherait 22 sites dont le pool de décodeurs et la boucle de composition `unsafe`. À faire
/// si quelqu'un mesure que le décodeur inutile coûte (VRAM des pools D3D11VA, une ouverture
/// par clip) — l'avertissement ci-dessous dit enfin à quelle fréquence le cas visible arrive.
unsafe fn open_webcam_or_stand_in(
screen_path: &str,
webcam_path: &str,
gpu: &Gpu,
) -> Result<(Decoder, bool)> {
if !webcam_is_real(webcam_path, screen_path) {
return Ok((Decoder::open(screen_path, gpu)?, false));
}
match Decoder::open(webcam_path, gpu) {
Ok(d) => Ok((d, true)),
Err(e) => {
eprintln!(
"WARNING: caméra déclarée mais illisible ({webcam_path}) : {e}. La vignette caméra ne sera pas dessinée ; le média est à relier."
);
Ok((Decoder::open(screen_path, gpu)?, false))
}
}
}
/// Faut-il dessiner la vignette caméra ? Il faut les DEUX moitiés :
///
/// - le document déclare une caméra — `webcam_is_real`, un test de chemins ;
/// - et son décodeur s'est vraiment ouvert — `decoder_is_real`, ce que seul
/// `open_webcam_or_stand_in` sait, transporté jusqu'ici par `PrefetchedClip`/`Player`.
///
/// Le test de chemins seul ne suffit pas : il a répondu « vraie caméra » pour un `.mp4` webcam
/// de 0 octet (fichier non finalisé par le helper de capture), la vignette a été dessinée, et
/// le décodeur derrière elle était le remplaçant écran — l'utilisateur voyait son propre
/// enregistrement d'écran répliqué dans le petit rectangle caméra.
fn should_draw_webcam(webcam_path: &str, screen_path: &str, decoder_is_real: bool) -> bool {
webcam_is_real(webcam_path, screen_path) && decoder_is_real
}
unsafe fn open_and_seek_clip(
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
source_time_sec: f64,
gpu: &Gpu,
) -> Result<PrefetchedClip> {
let source_time_sec = source_time_sec.max(0.0);
let mut sdec = Decoder::open(screen_path, gpu)?;
let (mut wdec, webcam_decoder_is_real) = open_webcam_or_stand_in(screen_path, webcam_path, gpu)?;
let sf = sdec.seek_to(source_time_sec)?;
let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?;
if wf.is_null() {
wf = wdec.seek_to(0.0)?;
}
if sf.is_null() {
anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")");
}
let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32;
let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok();
Ok(PrefetchedClip { sdec, wdec, webcam_decoder_is_real, webcam_offset_sec, idx, cursor_track })
}
/// Nombre de paires de décodeurs INACTIVES gardées ouvertes en plus de la paire active.
/// Franchir un clip cross-média rouvre sinon 2 décodeurs (~39 ms) et reseek depuis une image
/// clé (~81 ms) — mesuré ~120 ms/franchissement, le plus gros à-coup du scrub. Les garder
/// ouverts à leur dernière position transforme un RETOUR sur un clip (motif A→B→A ultra
/// fréquent au scrub) en simple reseek, souvent par le chemin rapide `decode_forward`.
/// ponytail: cap fixe. Chaque paire retient son pool de surfaces D3D11VA (VRAM) ; 3 couvre
/// les timelines 2-4 clips, à baisser si la VRAM serre.
const DECODER_POOL_CAP: usize = 3;
/// Une paire de décodeurs mise de côté, prête à être réactivée sans réouverture.
struct PooledClip {
screen_path: String,
webcam_path: String,
webcam_offset_sec: f64,
clip: PrefetchedClip,
}
/// Repositionne une paire (écran + webcam) au temps source voulu. `false` = un des deux flux
/// n'a pas de frame utilisable là (position hors flux, EOF non rembobinable) ; l'appelant DOIT
/// alors repartir sur une ouverture fraîche plutôt que de composer une frame vide (« frame sans
/// texture » → preview noire définitive). Partagé par `seek_active` (paire active) et le pool.
unsafe fn seek_pair(
sdec: &mut Decoder,
wdec: &mut Decoder,
source_time_sec: f64,
webcam_offset_sec: f64,
) -> Result<bool> {
let sf = sdec.seek_to(source_time_sec)?;
if sf.is_null() {
return Ok(false);
}
let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?;
if wf.is_null() {
wf = wdec.seek_to(0.0)?;
}
if wf.is_null() {
return Ok(false);
}
Ok(true)
}
/// Bascule cross-média en réutilisant le pool de décodeurs. Réactive la paire cible si elle
/// est déjà en pool (reseek au lieu de rouvrir), sinon ouvre à neuf ; dans les DEUX cas met en
/// pool la paire qu'on QUITTE (au lieu de la fermer), dédupliquée par clé et bornée en LRU.
/// Le vidage du cache de SRV reste à la charge de l'appelant (comme avant) — over-clear est
/// sûr et bon marché, ce qui écarte tout risque « image du clip précédent ».
/// `OPENSCREEN_CLIPSWITCH_TIMING=1` journalise hit/miss + durée.
unsafe fn swap_clip_pooled(
player: &mut Player,
pool: &mut Vec<PooledClip>,
request: &ActiveClipRequest,
active_screen: &str,
active_webcam: &str,
active_webcam_offset_sec: f64,
) -> Result<()> {
let timing = std::env::var("OPENSCREEN_CLIPSWITCH_TIMING").is_ok();
let t0 = std::time::Instant::now();
let t = request.source_time_sec.max(0.0);
let matches = |p: &PooledClip| {
p.screen_path == request.screen_path
&& p.webcam_path == request.webcam_path
&& (p.webcam_offset_sec - request.webcam_offset_sec).abs() < 1e-9
};
let mut hit = false;
let incoming: PrefetchedClip = match pool.iter().position(&matches) {
Some(i) => {
let mut pooled = pool.remove(i);
// Reseek les décodeurs poolés AVANT de les installer. Échec → on les jette et on
// ouvre à neuf (chemin connu sûr), jamais une frame vide.
if seek_pair(&mut pooled.clip.sdec, &mut pooled.clip.wdec, t, request.webcam_offset_sec)? {
pooled.clip.idx = (t * pooled.clip.sdec.fps()).round().max(0.0) as u32;
hit = true;
pooled.clip
} else {
drop(pooled);
player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?
}
}
None => player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?,
};
let outgoing = player.swap_active(incoming);
// Met la paire quittée en pool : dédup par clé (jamais deux entrées d'un même média), puis
// éviction LRU (le plus ancien, en tête, part en premier).
pool.retain(|p| {
!(p.screen_path == active_screen
&& p.webcam_path == active_webcam
&& (p.webcam_offset_sec - active_webcam_offset_sec).abs() < 1e-9)
});
pool.push(PooledClip {
screen_path: active_screen.to_string(),
webcam_path: active_webcam.to_string(),
webcam_offset_sec: active_webcam_offset_sec,
clip: outgoing,
});
while pool.len() > DECODER_POOL_CAP {
pool.remove(0);
}
if timing {
eprintln!(
"[clipswitch] {} {:.1}ms (t_src={:.2}s, pool={})",
if hit { "POOL_HIT reseek" } else { "OPEN fresh " },
t0.elapsed().as_secs_f64() * 1000.0,
t,
pool.len(),
);
}
Ok(())
}
/// Lit deux sources en lockstep et compose la frame courante dans le RT du compositeur.
/// Partagé avec la GUI standalone (`app.rs`).
pub struct Player {
sdec: Decoder,
wdec: Decoder,
gpu: Gpu,
/// Même question que `PrefetchedClip::webcam_decoder_is_real`, pour la paire ACTIVE :
/// `wdec` est-il la caméra ou le remplaçant écran ? Mis à jour à chaque bascule de clip
/// (`swap_active`), lu par la boucle de rendu pour décider de dessiner la vignette.
webcam_decoder_is_real: bool,
webcam_offset_sec: f64,
has_current_frame: bool,
use_current_on_next_step: bool,
idx: u32,
/// Horloge programme du clip ACTIF, posée par `render_thread` à chaque changement de
/// clip ou de scène (`set_programme_clock`). `None` = pas de scène : fixture.
programme: Option<ProgrammeClock>,
}
impl Player {
pub unsafe fn open(screen: &str, webcam: &str, gpu: &Gpu) -> Result<Player> {
let (wdec, webcam_decoder_is_real) = open_webcam_or_stand_in(screen, webcam, gpu)?;
Ok(Player {
sdec: Decoder::open(screen, gpu)?,
wdec,
gpu: Gpu {
device: gpu.device.clone(),
context: gpu.context.clone(),
feature_level: gpu.feature_level,
backend: gpu.backend,
},
webcam_decoder_is_real,
webcam_offset_sec: 0.0,
has_current_frame: false,
use_current_on_next_step: false,
idx: 0,
programme: None,
})
}
/// Remplace atomiquement la paire de décodeurs du clip actif. Les nouvelles sources sont
/// ouvertes et positionnées au playhead source courant avant de libérer l'ancienne paire.
/// Synchrone (bloque le thread appelant le temps de l'ouverture) — `render_thread` préfère
/// `apply_prefetched` quand un préchargement en tâche de fond est déjà prêt ; ceci reste le
/// repli correct dans tous les autres cas (changement de clip explicite depuis l'app,
/// préchargement pas encore prêt, etc).
pub unsafe fn set_active_clip(
&mut self,
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
source_time_sec: f64,
) -> Result<()> {
let prefetched =
open_and_seek_clip(screen_path, webcam_path, webcam_offset_sec, source_time_sec, &self.gpu)?;
self.apply_prefetched(prefetched);
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);
// Les DEUX flux doivent avoir une frame : `compose_frame` les échantillonne tous les
// deux sans condition, un seul manquant suffit à le faire échouer (d'où le `false` que
// `seek_pair` peut rendre → l'appelant retombe sur l'ouverture complète).
if !seek_pair(&mut self.sdec, &mut self.wdec, source_time_sec, self.webcam_offset_sec)? {
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
/// en tâche de fond est déjà prêt au moment de franchir la frontière du clip.
unsafe fn apply_prefetched(&mut self, prefetched: PrefetchedClip) {
// La paire sortante est droppée ici (comportement inchangé pour la lecture libre) ; le
// pool du scrub, lui, récupère la sortante en appelant `swap_active` directement.
let _ = self.swap_active(prefetched);
}
/// Échange la paire active contre `incoming` (déjà ouverte + positionnée) et REND la paire
/// sortante — pour la mettre en pool au lieu de la fermer. Aucune E/S : juste des champs.
unsafe fn swap_active(&mut self, incoming: PrefetchedClip) -> PrefetchedClip {
let outgoing = PrefetchedClip {
sdec: std::mem::replace(&mut self.sdec, incoming.sdec),
wdec: std::mem::replace(&mut self.wdec, incoming.wdec),
// Suit son décodeur dans les deux sens : la paire sortante emporte sa réponse vers
// le pool (elle sera réactivée sans réouverture, donc sans personne pour la
// recalculer), l'entrante impose la sienne au player.
webcam_decoder_is_real: self.webcam_decoder_is_real,
webcam_offset_sec: self.webcam_offset_sec,
idx: self.idx,
// Le curseur est re-dérivé du chemin à la réactivation ; inutile de le trimballer.
cursor_track: None,
};
self.webcam_decoder_is_real = incoming.webcam_decoder_is_real;
self.webcam_offset_sec = incoming.webcam_offset_sec;
self.idx = incoming.idx;
self.has_current_frame = true;
self.use_current_on_next_step = true;
outgoing
}
/// Ouvre une nouvelle paire de décodeurs positionnée à `source_time_sec`, SANS l'installer
/// (l'appelant l'échange via `swap_active`). Réutilise le device D3D11 du player.
unsafe fn open_clip(
&self,
screen: &str,
webcam: &str,
webcam_offset_sec: f64,
source_time_sec: f64,
) -> Result<PrefetchedClip> {
open_and_seek_clip(screen, webcam, webcam_offset_sec, source_time_sec, &self.gpu)
}
/// Le décodeur webcam ACTIF est-il la vraie caméra ? `false` quand c'est le remplaçant
/// écran — aucune caméra déclarée, ou une caméra déclarée dont le fichier ne s'ouvre pas.
/// La boucle de rendu en a besoin parce que la seule autre source d'information dont elle
/// dispose, le chemin webcam du clip, ment dans le second cas (cf. `should_draw_webcam`).
pub fn webcam_decoder_is_real(&self) -> bool {
self.webcam_decoder_is_real
}
/// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le
/// franchissement de la fin de fenêtre du clip actif pendant la lecture libre, et pour
/// calculer la cible de `step` en lecture libre. `pub` (pas `pub(crate)`) : le harnais
/// `poc-d3d` (crate externe) en a besoin pour piloter sa propre boucle de lecture libre.
pub unsafe fn screen_time_sec(&self) -> f64 {
self.sdec.cur_time_sec()
}
/// Recalcule l'horloge programme pour le clip `clip_index` de `scene` (la scène COMPLÈTE,
/// pas la fenêtre d'un clip : il faut la durée de sortie des clips précédents).
///
/// Cadence : `scene.output.fps`, sinon celle de la source, arrondie — le repli de l'export.
/// ponytail: l'export prend la source du PREMIER clip et, depuis la modale, sa propre
/// cadence ; un écart ne décale le temps programme que d'au plus une frame par span de
/// vitesse (arrondi `ceil` des spans). À aligner si un effet devient sensible à la frame.
pub unsafe fn set_programme_clock(&mut self, scene: Option<&Scene>, clip_index: usize) {
self.programme = scene.and_then(|s| {
let fps = s.output.fps.unwrap_or_else(|| self.sdec.fps().round().max(1.0));
ProgrammeClock::for_clip(s, clip_index, fps)
});
}
/// Temps programme de la frame écran courante : fonction de son seul pts, donc identique
/// qu'on y arrive en lecture (`step`) ou par un seek (`present_frame`).
pub unsafe fn programme_time(&self) -> Option<f32> {
let t = self.sdec.cur_time_sec();
self.programme.as_ref().map(|c| c.at(t) as f32)
}
/// Compose la PROCHAINE frame due (→ `comp.rt`), au plus une, si `target_source_time`
/// (temps écran) est atteint. Sémantique de "hold" : `false` sans rien composer quand la
/// frame suivante n'est pas encore due — l'appelant garde alors l'image déjà affichée,
/// au lieu d'avancer aveuglément. Boucle sur EOF réel. `false` aussi si fixture vide.
///
/// BUG corrigé : cette fonction consommait auparavant EXACTEMENT une frame réelle par
/// appel (un `next()` inconditionnel), et `render_thread` l'appelait une fois par tranche
/// de 1/60s de temps réel écoulé — une hypothèse de vidéo à ~60 fps constant. Or
/// ScreenCaptureKit (et les captures équivalentes) ne livre une frame que quand l'écran
/// change : un enregistrement de 26s avec de longs plans fixes peut ne contenir que
/// quelques centaines de frames RÉELLES. Consommer 1 frame/tick épuisait alors le flux
/// bien avant que le temps réel écoulé n'atteigne la durée de l'enregistrement — le
/// décodeur retombait sur l'EOF, rebouclait sur `seek_to(0.0)`, et la preview semblait
/// « accélérer puis sauter au début » en boucle. Le curseur/zoom, eux, suivent le pts réel
/// (`sync_time`) et se retrouvaient donc en avance sur ce que l'œil voyait défiler.
///
/// Le correctif : ne décoder/adopter (`commit_peek`) la frame suivante QUE si son pts a
/// réellement été atteint par `target_source_time` (le temps réel écoulé, mis à l'échelle
/// par la vitesse active — cf. `render_thread`) ; sinon on continue de tenir la frame
/// courante, aussi longtemps qu'il le faut. Même principe que `advance_decoder_to`
/// (`timeline_walk.rs`), déjà correct côté export.
///
/// La webcam suit le MÊME principe indépendamment (son propre temps source =
/// `screen_time - webcam_offset_sec`, pas un pas 1:1 avec l'écran) : deux pipelines de
/// capture indépendants n'ont pas la même cadence ni les mêmes trous. Arrivée à son
/// propre EOF — un clip webcam plus court que l'écran, cas normal quand la caméra
/// s'arrête avant la capture — elle TIENT sa dernière image et laisse l'écran
/// continuer seul, plutôt que de reboucler au début.
pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg, target_source_time: f64) -> Result<bool> {
let use_current = self.use_current_on_next_step;
self.use_current_on_next_step = false;
let sf = if use_current {
self.sdec.cur_frame()
} else {
// Match direct sur `NextFrameTime` plutôt que via `frame_step` : la lecture
// live a une politique d'EOF PROPRE (reboucler au début), là où l'export tient
// la dernière frame. Le reste — « due » vs « pas encore due » — est la même
// règle, `>`/`<=` compris.
match self.sdec.peek_next_time_sec()? {
NextFrameTime::At(t) if t <= target_source_time => self.sdec.commit_peek()?,
NextFrameTime::At(_) => return Ok(false), // pas encore due : on tient la courante.
// pts inexploitable : impossible de dire si elle est due. `step()` n'adopte
// qu'une frame écran par appel, donc l'adopter revient exactement à
// l'ancien « une frame par tick » — le repli correct pour un flux cassé.
NextFrameTime::Unknown => self.sdec.commit_peek()?,
NextFrameTime::Eof => {
// EOF réel (plus aucune frame à décoder) : reboucle sur le début.
self.idx = 0;
self.sdec.seek_to(0.0)?
}
}
};
if sf.is_null() {
self.has_current_frame = false;
return Ok(false);
}
let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0);
let wf = if use_current {
self.wdec.cur_frame()
} else {
let cur = self.wdec.cur_frame();
if cur.is_null() {
// Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé.
self.wdec.seek_to(target_webcam_t)?
} else {
// Rattrape la webcam vers `target_webcam_t` par pts réel, jamais au-delà —
// même sémantique de hold que l'écran ci-dessus (et que `advance_decoder_to`) :
// adopter une frame webcam dont le pts dépasse `target_webcam_t` l'afficherait
// en avance sur son heure. Le garde-fou ne joue que contre un cas pathologique.
//
// BUG corrigé : à son EOF la webcam était reseekée à 0 alors que
// `target_webcam_t` continue de croître avec le temps écran. Au tick
// suivant, le rattrapage repartait donc de 0 et réavalait le fichier
// entier vers une cible toujours aussi lointaine — 1000 frames par tick
// (le plafond du garde-fou), en boucle, pour l'éternité. Un décodage
// permanent à fond, pour afficher une webcam qui n'a plus rien à montrer.
// Une fois l'EOF traité comme un hold, la décision webcam est EXACTEMENT
// celle de l'écran à l'export : `frame_step` couvre les quatre cas sans
// rien de spécifique, et ses tests couvrent donc aussi ce chemin.
let mut wf = cur;
let mut guard = 0u32;
loop {
match frame_step(self.wdec.peek_next_time_sec()?, 0.0, target_webcam_t) {
FrameStep::Commit => wf = self.wdec.commit_peek()?,
FrameStep::CommitAndStop => {
wf = self.wdec.commit_peek()?;
break;
}
FrameStep::Hold => break,
}
guard += 1;
if guard > 1000 {
break;
}
}
wf
}
};
if wf.is_null() {
self.has_current_frame = false;
return Ok(false);
}
self.has_current_frame = true;
self.sync_time(comp);
comp.compose_frame(sf, wf, self.idx as f32, cfg)?;
self.idx = self.idx.wrapping_add(1);
Ok(true)
}
/// Positionne `comp` sur le temps source RÉEL (pts) de la frame écran courante, pour que le
/// curseur ET les zoom/full-camera regions du clip actif restent exacts quelle
/// que soit la cadence réelle de l'enregistrement — BUG corrigé : tout dérivait auparavant
/// de `frame / 60.0` (un compteur de frames supposant 60fps pile), qui dérive
/// silencieusement de plus en plus au fil de la lecture dès que le fichier n'est pas
/// exactement à 60fps (30/59.94/etc. sont courants), au lieu de suivre le pts réel du
/// décodeur — exactement la cause du "zoom désynchronisé de la timeline" observé.
unsafe fn sync_time(&self, comp: &Compositor) {
let t = self.sdec.cur_time_sec() as f32;
comp.set_cursor_time(Some(t));
comp.set_timeline_time(Some(t));
comp.set_programme_time(self.programme_time());
}
/// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param.
pub unsafe fn recompose(&self, comp: &Compositor, cfg: &Cfg) -> Result<bool> {
if !self.has_current_frame {
return Ok(false);
}
let sf = self.sdec.cur_frame();
let wf = self.wdec.cur_frame();
if sf.is_null() || wf.is_null() {
return Ok(false);
}
self.sync_time(comp);
let f = self.idx.saturating_sub(1);
comp.compose_frame(sf, wf, f as f32, cfg)?;
Ok(true)
}
/// Seek à `target_sec` (secondes source du clip actif) : keyframe-seek + décodage-avant
/// (`Decoder::seek_to`, même mécanisme robuste que l'export) — remplace l'ancien modèle
/// "compte de frames" qui rewindait tout au frame 0 pour le moindre seek arrière et n'avait
/// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de
/// `set_time`, incorrect au-delà de 6s sur un enregistrement réel).
pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result<bool> {
let sf = self.sdec.seek_to(target_sec)?;
let wf = self
.wdec
.seek_to(webcam_seek_time(target_sec, self.webcam_offset_sec))?;
if sf.is_null() || wf.is_null() {
self.has_current_frame = false;
return Ok(false);
}
self.has_current_frame = true;
self.use_current_on_next_step = false;
self.sync_time(comp);
// "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé
// du temps réel pour rester cohérent si jamais consulté.
self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32;
comp.compose_frame(sf, wf, self.idx as f32, cfg)?;
Ok(true)
}
}
/// Retranche de l'accumulateur le temps source RÉELLEMENT consommé par la frame qui vient
/// d'être adoptée. C'est ce qui fait jouer une source à sa propre cadence : une frame de
/// 1/24 s consomme 1/24 s d'accumulateur, donc 24 frames par seconde réelle — là où
/// l'ancien pas fixe de 1/60 s en décodait 60, soit 2,5× trop vite sur du 24 fps.
///
/// `after < before` : `step()` a rebouclé sur l'EOF (le temps recule) — le delta n'a plus
/// de sens, on repart d'un accumulateur propre.
///
/// Fonction à part pour être testable : c'est l'arithmétique dont dépend la vitesse de
/// lecture, et elle vivait au milieu de la boucle de rendu.
pub(crate) fn consume_acc(acc: f64, before: f64, after: f64) -> f64 {
if after >= before {
(acc - (after - before)).max(0.0)
} else {
0.0
}
}
/// Paramètres inspector pilotés depuis l'UI (setParam). Le thread de rendu les applique :
/// booléens/taps → reconstruits dans le `Cfg` ; valeurs continues → `set_live_params`.
#[derive(Clone, Copy, PartialEq)]
struct InspectorParams {
bg_blur: bool,
bg_color: [f32; 4],
shadow_scale: f32,
radius_scale: f32,
mblur_taps: u32,
padding: f32,
webcam_size_scale: f32,
webcam_mirror: bool,
webcam_shape: u32,
cursor_show: bool,
cursor_size_scale: f32,
cursor_bounce_scale: f32,
/// 0..1 : force du lissage ressort-amortisseur de la position (0 = brut). Reconstruit la
/// piste (voir `raw_cursor.smoothed()` dans `render_thread`) plutôt qu'un simple scalaire de
/// dessin — d'où le suivi séparé de sa dernière valeur appliquée.
cursor_smoothing: f32,
/// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran).
cursor_motion_blur: f32,
/// Flèche modélisée en 3D (mode 15).
cursor_model3d: bool,
cursor_auto_hide: bool,
}
impl Default for InspectorParams {
fn default() -> Self {
Self {
bg_blur: false,
bg_color: [0.10, 0.11, 0.14, 1.0],
shadow_scale: 1.0,
radius_scale: 1.0,
mblur_taps: 8,
padding: 0.0,
webcam_size_scale: 1.0,
webcam_mirror: false,
webcam_shape: 3,
cursor_show: true,
cursor_size_scale: 1.0,
cursor_bounce_scale: 1.0,
cursor_smoothing: 0.0,
cursor_motion_blur: 0.0,
cursor_model3d: false,
cursor_auto_hide: false,
}
}
}
#[derive(Clone)]
struct ActiveClipRequest {
screen_path: String,
webcam_path: String,
webcam_offset_sec: f64,
/// Identité dans le flux `Scene.clips` trié (les chemins ne suffisent pas pour un asset partagé).
clip_index: usize,
/// Playhead exprimé sur l'horloge source écran du nouveau clip.
source_time_sec: f64,
}
fn same_source_path(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
fn scene_clip_matches(
clip: &crate::scene::SceneClip,
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
) -> bool {
same_source_path(&clip.screen_path, screen_path)
&& same_source_path(&clip.webcam_path, webcam_path)
&& (clip.webcam_offset_sec - webcam_offset_sec).abs() <= 1e-6
}
fn find_scene_clip_index(
scene: &Scene,
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
) -> Option<usize> {
scene.clips.iter()
.position(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec))
.or_else(|| scene.clips.iter().position(|clip| {
same_source_path(&clip.screen_path, screen_path)
&& same_source_path(&clip.webcam_path, webcam_path)
}))
}
/// Paths and the asset-level webcam offset are identical for multiple cuts of one recording,
/// so path lookup alone always returns clip 0. Prefer the explicit timeline identity.
fn resolve_scene_clip_index(
scene: &Scene,
requested_clip_index: usize,
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
) -> Option<usize> {
if scene.clips.get(requested_clip_index)
.is_some_and(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec))
{
Some(requested_clip_index)
} else {
find_scene_clip_index(scene, screen_path, webcam_path, webcam_offset_sec)
}
}
fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene {
match scene.clips.get(clip_index) {
Some(clip) => scene.for_clip_window(
clip_index,
clip.source_start_sec,
clip.source_end_sec,
),
None => scene.clone(),
}
}
/// Dernière frame readback vers CPU, prête pour le napi `read_frame`.
///
/// `(gen, w, h, vec)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed (R, G, B, A
/// en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone
/// (≥ 1, `0` réservé à « le consommateur n'a encore rien vu ») incrémentée à CHAQUE
/// publication, càd uniquement quand une nouvelle frame a réellement été composée (le
/// thread de rendu ne republie pas une frame identique — cf. `stepped || first`). Elle
/// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC
/// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les
/// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide).
type LatestFrame = (u64, u32, u32, Vec<u8>);
/// État partagé thread appelant → thread de rendu (commandes sans blocage).
struct Shared {
/// Résolution cible du preview (largeur, hauteur) en pixels devices — ce que la
/// zone canvas Electron affiche. Plus de HWND/HWND-parent : la preview est une
/// image bitmap posée sur un `<canvas>`, la position CSS est gérée entièrement
/// côté web. Lecture/écriture exclusive via `Mutex`.
preview_size: Mutex<(u32, u32)>,
inspector: Mutex<InspectorParams>,
/// Temps source (secondes) demandé par l'app pour le clip actif (presentTime/seek), prioritaire
/// sur la lecture libre. En SECONDES (pas un index de frame) : `Player::present_frame` fait un
/// vrai seek keyframe (`Decoder::seek_to`, comme l'export) au lieu de compter des frames —
/// BUG corrigé : l'ancien `set_time` convertissait en index de frame à 60fps fixe PUIS le
/// wrappait modulo `FIXTURE_FRAMES` (360 = 6s) — un reliquat du bench fixture qui faisait
/// boucler silencieusement tout seek au-delà de 6s sur un enregistrement réel, exactement
/// la cause du "zoom timeline désynchronisé" observé.
requested_frame: Mutex<Option<f64>>,
/// Changement de sources consommé par le thread de rendu, seul propriétaire des décodeurs.
active_clip_request: Mutex<Option<ActiveClipRequest>>,
/// scène de l'app (contrat) ; appliquée au compositeur quand `scene_dirty`.
scene: Mutex<Option<Scene>>,
scene_dirty: AtomicBool,
playing: AtomicBool,
stop: AtomicBool,
/// Dernière frame RGBA8 readback (taille + pixels R,G,B,A tightly-packed). Écrit
/// par le thread de rendu après chaque `compose_frame` réussi, lu par le napi
/// `read_frame` depuis le thread Node principal. `Mutex<Option<LatestFrame>>` —
/// Option pour distinguer "pas de frame encore composée" (avant le 1er compose,
/// `read_frame` retourne `Ok(None)`) d'un buffer vide (qui n'arrive jamais).
latest_frame: Mutex<Option<LatestFrame>>,
/// Génération de la dernière frame publiée. Tenue À PART de `latest_frame` : la
/// livraison sans copie vide le slot en le lisant, et dériver la génération d'un slot
/// vide la ferait repartir à 1 — donc rejouer des générations déjà peintes. Monotone,
/// jamais remise à zéro.
frame_gen: AtomicU64,
/// Erreur fatale du thread de rendu (device D3D11 introuvable, décodeur qui refuse
/// le fichier…). Le thread meurt sur la première erreur ; sans ce champ, elle
/// finissait dans un `eprintln!` que personne ne lit et l'utilisateur n'avait
/// qu'un canvas noir — exactement le « on dirait que l'app rame » de la PR #162.
/// `read_frame` la relaie en `Err` au prochain tour de la boucle de pull (~33 ms),
/// donc elle remonte jusqu'à l'UI par le chemin d'erreur qui existe déjà.
fatal: Mutex<Option<String>>,
}
/// Handle d'une vue live. `Drop` arrête le rendu.
///
/// Plus de fenêtre/OS : le handle ne porte plus de `HWND`. Toute la machinerie Win32
/// (CreateWindowEx / SetWindowPos / DestroyWindow / register_overlay_class) a été
/// retirée — la preview est désormais purement hors-fenêtre, transportable via
/// mémoire.
pub struct LiveView {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
// `LiveView` ne référence plus aucune ressource Win32 non-`Send`. `Shared` non plus
// (`Mutex`, `AtomicBool`, `Option<Vec<u8>>`). Le `JoinHandle` est `Send`/`!Sync`
// mais on n'en extrait rien côté napi. Tout ce qui vit dans le thread de rendu
// (compositor, décodeurs, staging, GPU) y reste confiné.
unsafe impl Send for LiveView {}
impl LiveView {
/// Crée une vue offscreen : pas de HWND/UI côté thread appelant. Démarre juste
/// le thread de rendu qui va composer chaque frame et publier le readback dans
/// `Shared::latest_frame` pour le napi `read_frame`.
///
/// `w`/`h` sont la **résolution cible du preview** (taille du `<canvas>` Electron
/// affichant la preview, en pixels device) — anciennement c'était le rect de la
/// fenêtre overlay ; maintenant c'est juste la taille du bitmap RGBA produit.
/// Ajustable à chaud via `set_rect(w, h)`.
pub fn create(
w: u32,
h: u32,
screen: &str,
webcam: &str,
cursor_json: &str,
) -> Result<LiveView> {
let shared = Arc::new(Shared {
preview_size: Mutex::new((w.max(1), h.max(1))),
inspector: Mutex::new(InspectorParams::default()),
requested_frame: Mutex::new(None),
active_clip_request: Mutex::new(None),
scene: Mutex::new(None),
scene_dirty: AtomicBool::new(false),
playing: AtomicBool::new(true),
stop: AtomicBool::new(false),
latest_frame: Mutex::new(None),
frame_gen: AtomicU64::new(0),
fatal: Mutex::new(None),
});
let sh = shared.clone();
let (s, wc, cj) = (screen.to_string(), webcam.to_string(), cursor_json.to_string());
let thread = std::thread::spawn(move || {
if let Err(e) = unsafe { render_thread(sh.clone(), &s, &wc, &cj) } {
eprintln!("[live] render thread error: {e:#}");
if let Ok(mut fatal) = sh.fatal.lock() {
*fatal = Some(format!("{e:#}"));
}
}
});
Ok(LiveView { shared, thread: Some(thread) })
}
/// Met à jour la résolution cible du preview. Force le redimensionnement des
/// ressources GPU de readback (`Compositor::ensure_resize_target` /
/// `live_readback_staging`) au prochain tour du thread de rendu.
///
/// Signature : `(w, h)` — l'ancienne `(x, y, w, h)` de la fenêtre overlay n'a
/// plus de sens (la position est gérée par CSS côté Electron). `set_rect` côté
/// napi doit s'aligner sur ce 2-param (la largeur/hauteur seule).
pub fn set_rect(&self, w: u32, h: u32) {
if let Ok(mut s) = self.shared.preview_size.lock() {
*s = (w.max(1), h.max(1));
}
}
/// Message de l'erreur qui a tué le thread de rendu, `None` tant qu'il tourne.
/// Définitif : le thread ne redémarre pas.
pub fn fatal_error(&self) -> Option<String> {
self.shared.fatal.lock().ok().and_then(|guard| guard.clone())
}
/// Récupère la dernière frame readback (gen + taille + RGBA8 tightly-packed).
/// `None` si rien n'a encore été composé (jamais écrit). **Coût : O(w·h)**
/// (copie du `Vec<u8>` — nécessaire pour traverser la frontière thread + le
/// FFI vers le Buffer napi). Le `Vec<u8>` retourné a `len() == w*h*4`.
/// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand
/// le consommateur possède déjà la génération courante.
pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec<u8>)> {
self.shared
.latest_frame
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned())
}
/// Récupère la dernière frame UNIQUEMENT si sa génération est postérieure à
/// `since_gen`. `None` couvre les DEUX cas où le consommateur n'a rien à peindre :
/// - rien n'a encore été composé (aucune frame publiée), ou
/// - il possède déjà la génération courante (`gen <= since_gen`).
/// Dans ce second cas — l'essentiel du temps d'édition, preview en pause sur une
/// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du
/// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au
/// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`.
pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec<u8>)> {
let mut guard = self.shared.latest_frame.lock().ok()?;
match guard.as_ref() {
// Le buffer est EMPORTÉ, pas copié. Le thread de rendu le remplace à chaque
// frame composée et le consommateur garde ses pixels peints sur le canvas :
// personne ne relit jamais la même génération. Le `clone()` d'avant était un
// memcpy `O(w·h)` — 1,5 ms à 1280×720, 3,4 ms à 1920×1080 — payé sur le THREAD
// PRINCIPAL de Node, celui-là même qui doit rester libre pour que React peigne
// la tête de lecture.
//
// Contrepartie assumée : une relecture forcée (`since_gen = 0`) après la
// première ne retrouve rien tant qu'une nouvelle frame n'est pas composée. Sans
// conséquence ici — le seul consommateur ne l'utilise qu'au montage, et un
// redimensionnement provoque de toute façon une recomposition.
Some((gen, ..)) if *gen > since_gen => guard.take(),
_ => None,
}
}
/// Switch inspector (booléen).
pub fn set_param_bool(&self, key: &str, value: bool) {
if let Ok(mut p) = self.shared.inspector.lock() {
match key {
"backgroundBlur" => p.bg_blur = value,
"webcamMirror" => p.webcam_mirror = value,
"cursorShow" => p.cursor_show = value,
"cursorAutoHide" => p.cursor_auto_hide = value,
"cursorModel3d" => p.cursor_model3d = value,
_ => {}
}
}
}
/// Slider inspector (numérique). Conventions : `shadow`/`roundness`/`webcamSize`/
/// `cursorSize`/`cursorClickBounce` = échelle (1 = défaut) ; `padding` = 0..1 ;
/// `motionBlur` = 0..1 mappé sur 1..16 taps.
pub fn set_param_num(&self, key: &str, value: f64) {
if let Ok(mut p) = self.shared.inspector.lock() {
let v = value as f32;
match key {
"shadow" => p.shadow_scale = v.max(0.0),
"roundness" => p.radius_scale = v.max(0.0),
"motionBlur" => p.mblur_taps = (1.0 + value.clamp(0.0, 1.0) * 15.0).round() as u32,
"padding" => p.padding = v.clamp(0.0, 1.0),
"webcamSize" => p.webcam_size_scale = v.max(0.05),
"cursorSize" => p.cursor_size_scale = v.max(0.0),
"cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0),
"cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0),
"cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0),
_ => {}
}
}
}
/// Sélection de chaîne : couleur de fond "#rrggbb" ou forme webcam.
pub fn set_param_str(&self, key: &str, value: &str) {
if let Ok(mut p) = self.shared.inspector.lock() {
match key {
"backgroundColor" => {
if let Some(c) = parse_hex_color(value) {
p.bg_color = c;
}
}
"webcamShape" => {
p.webcam_shape = crate::compositor::webcam_shape_code(value);
}
_ => {}
}
}
}
pub fn set_playing(&self, playing: bool) {
self.shared.playing.store(playing, Ordering::Relaxed);
}
/// Ce que la vue est en train de faire : lecture libre (`true`) ou pause (`false`).
/// Lu par l'export, qui met les previews en pause le temps d'encoder et doit pouvoir
/// leur rendre CET état plutôt que d'en supposer un (voir `PausedPreviews`).
pub fn playing(&self) -> bool {
self.shared.playing.load(Ordering::Relaxed)
}
/// Installe la scène de l'app (JSON `SceneDescription`). Parsé ici (hors thread de rendu) ;
/// appliqué au compositeur au prochain tour via le flag `scene_dirty`. JSON invalide → ignoré.
pub fn set_scene(&self, json: &str) {
match Scene::from_json(json) {
Ok(scene) => {
if let Ok(mut p) = self.shared.inspector.lock() {