Skip to content

perf(preview): supprimer le travail redondant au changement de segment, avec la sonde qui l'a trouvé - #207

Merged
EtienneLescot merged 11 commits into
release/v1.8.0from
perf/preview-exploration
Jul 30, 2026
Merged

perf(preview): supprimer le travail redondant au changement de segment, avec la sonde qui l'a trouvé#207
EtienneLescot merged 11 commits into
release/v1.8.0from
perf/preview-exploration

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Suite de #206, sur la même méthode : un changement à la fois, mesuré avant/après.

Contrairement à #206, cette branche embarque son instrument. C'est lui qui a désigné
les cibles, et c'est lui qui a réfuté deux hypothèses avant qu'on ne code dessus.

Résultats mesurés

Même parcours à chaque fois (~20 s de scrub par clip, deux franchissements voulus) :

avant après
tâches longues (>50 ms) 36, total 2369 ms, max 470 ms 3, total 209 ms, max 95 ms
scrub, gros buckets 18–28 % de frames > 25 ms 8–13 %
scrub+preview 30–56 % 18–30 %

Onze fois moins de temps bloquant sur le thread principal.

Ce que contient la branche

  • f245ad7c — sonde de fluidité. Diagnostic activable à la demande
    (window.__uiProbe.start()), inerte sinon. Segmente les intervalles rAF par état
    (repos / preview / scrub / scrub+preview) ET par nombre de franchissements de clip,
    compte les frames > 25 et > 40 ms plutôt qu'une moyenne, nomme les tâches longues, et
    refuse de mesurer une fenêtre cachée. Chaque contrainte vient d'une mesure précédente
    qui s'était révélée fausse pour cette raison exacte.
  • 67211a7b — ne plus rouvrir les décodeurs quand seul le segment change.
    resolveVisibleClips découpe les clips aux trims ; deux segments d'un même clip pointent
    sur le même fichier. Chaque frontière de segment déclenchait pourtant deux
    Decoder::open + avformat_find_stream_info + init D3D11VA. Mesuré : 31 bascules pour
    2 changements de clip voulus.
  • 0b4cdd73 — vider le cache de SRV quand un jeu de décodeurs est fermé.
    clear_srv_cache existait, documentée pour ce cas précis, sans aucun appelant. Le cache
    est keyé sur l'ADRESSE de la texture : garder les entrées d'un décodeur fermé fait fuir
    de la VRAM et, en cas de réutilisation d'adresse, fait rendre l'image du clip précédent.
  • faaf0f1e — repli sûr, et ne plus relire le curseur pour rien. Voir « régression »
    ci-dessous. Au passage : la télémétrie curseur était relue du disque à chaque demande de
    clip, chemin identique compris — 66 relectures du même fichier sur un scrub.
  • a06599cb annule un throttle de setScrubbingTimeSec : appliqué puis reverté, sans
    effet mesurable. L'écart qui l'avait motivé venait d'une variable cachée (le
    franchissement de clip), pas du code.

Une régression introduite puis corrigée, à connaître

seek_active marquait la frame comme utilisable sans vérifier les DEUX seeks. Ce chemin
hérite de décodeurs déjà ouverts, donc d'un état ; quand le seek webcam ne rendait rien,
compose_frame recevait un AVFrame vide et le thread de rendu s'arrêtait
DÉFINITIVEMENT — preview noire.

Le mode de défaillance mérite d'être retenu : sans preview, tous les indicateurs de
fluidité s'améliorent
. Le rapport de sonde correspondant était le meilleur de la
session. Une régression qui supprime le travail est indiscernable d'une optimisation
réussie si l'on ne regarde que les métriques. C'est l'utilisateur qui l'a vu.

Le repli est désormais explicite : seek_active rend un booléen, faux → set_active_clip.
Vérifié par un harnais headless qui exerce six setActiveClip à fichiers identiques sur
des positions variées, bords inclus.

Ce qui n'est PAS résolu

La dégradation au repos persiste : repos@0 = 0,0 % de frames > 25 ms, repos@47 =
13,2 %. Le vidage du cache de SRV ne l'explique donc pas, contrairement à ce que suggère
son message de commit — je le note ici plutôt que de réécrire l'historique.

La forme du défaut a changé, ce qui est une piste : repos@47 a un max de 25,8 ms et
0 % de frames au-delà de 40 ms. Plus aucun pic, mais une cadence stable à 25 ms au lieu
de 16,7. Ce n'est plus de la saccade, c'est un régime plus lent après de nombreux
franchissements — un phénomène différent, à investiguer séparément.

0b4cdd73 reste défendable sur ses autres mérites (fuite de VRAM, et surtout collision
d'adresse pouvant faire rendre le mauvais clip), mais pas sur celui-là.

Vérifié

cargo test -p openscreen-compositor : 101 tests. tsc --noEmit propre. Tests vitest des
zones touchées. Harnais headless pour le chemin seek_active. Chaque commit testé dans
l'app avant le suivant.

…ur pour rien

`Decoder::seek_to` faisait systématiquement `av_seek_frame(BACKWARD)` +
`avcodec_flush_buffers`, puis redécodait depuis l'image clé précédente — jusqu'à
`gop_size` frames (60 sur nos captures), et deux fois puisque l'écran et la webcam
ont chacun leur décodeur. Mesuré à ~50 ms par pas de scrub, sur le chemin exact de
l'édition en pause.

Or le cas dominant n'est pas un saut : c'est « la frame suivante » (scrub,
pas-à-pas) ou « la même frame » (un paramètre a changé, la scène est recomposée au
même instant). Deux chemins rapides couvrent ça :

  1. la frame courante est déjà celle demandée (à moins d'une demi-durée de frame)
     → on la rend, zéro décodage ;
  2. la cible est devant et à moins de 0,5 s → on déroule depuis la position
     courante, sans jeter l'état.

Recul ou saut lointain : seek complet, inchangé. Le seuil de 0,5 s est le demi-GOP
de nos captures, au-delà duquel repartir d'une clé redevient moins cher.

Le critère d'arrêt de `decode_forward_to` est la MÊME expression que celui du seek
complet, donc les deux chemins rendent la même frame : optimisation, pas changement
de comportement. `cur_pts` est nécessaire parce qu'un `AVFrame` fraîchement alloué a
un `best_effort_timestamp` indéterminé — sans lui, impossible de savoir si l'état
courant est exploitable ; il est remis à `None` à chaque flush.

Première optimisation appliquée seule sur une branche partie de release/v1.8.0,
après qu'une pile de sept changements simultanés se soit révélée impossible à
attribuer. Vérifié : 101 tests du crate. NON vérifié : le ressenti au scrub, et le
comportement au franchissement de clip.
…'arrêt

Le tick rAF de `VirtualPreview` publiait `updateVirtualTime(...)` dérivé de
`v.currentTime` en permanence, y compris quand la lecture est arrêtée. Pendant la
lecture c'est la bonne source — le média avance, la tête le suit. À l'arrêt c'est
l'inverse : l'utilisateur possède la tête de lecture et le `<video>` doit la SUIVRE.

Deux symptômes en découlaient :

  - un scrub était écrasé à chaque frame par la position d'un élément encore en
    train de chercher ;
  - près d'une frontière de clips, `locateSourcePosition` ne résolvait pas, et le
    repli `seekToVirtualTimeRef(nextClip.timelineStartSec)` renvoyait la tête au
    DÉBUT du clip voisin — le tressaillement au passage d'un clip à l'autre, visible
    dans les deux sens.

`clockRef` et `setSourceTimeSec` continuent d'être publiés : la webcam et le calque
curseur ont besoin du temps source même à l'arrêt. Seule la position de la TIMELINE
cesse d'être dictée par le média.

Deuxième optimisation appliquée seule. Correctif déjà constaté efficace sur le saut
du curseur lors d'un essai précédent, mais il y était noyé parmi sept changements
simultanés, donc non attribuable ; isolé ici pour être jugé pour lui-même.

À surveiller : ce tick réécrivait aussi la position 60 fois par seconde, ce qui
faisait re-déclencher la bascule de clip par accident jusqu'à ce qu'elle prenne.
Si le passage d'un clip à l'autre se dégrade, c'est que cette réparation par
tremblement masquait un défaut du verrou de bascule dans `NativeCompositorOverlay`
— à traiter alors comme un troisième correctif distinct.

Vérifié : tsc, 66/66 tests src/components/ai-edition.
`latest_frame_since` clonait le buffer RGBA à chaque livraison — un memcpy `O(w·h)`
mesuré à 1,5 ms en 1280×720 et 3,4 ms en 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.

Le buffer est désormais EMPORTÉ (`take`). 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, donc la copie ne servait à rien.

La génération passe dans un `AtomicU64` séparé : elle était dérivée du slot
(`slot.map(|g| g+1).unwrap_or(1)`), or vider le slot la ferait repartir à 1 — le
consommateur recevrait des générations déjà peintes et boucherait. Séquence
identique à l'ancienne tant que le slot n'est pas vidé.

Contrepartie assumée : une relecture forcée (`sinceGen = 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.

Troisième optimisation appliquée seule. Invisible par construction : elle libère du
temps de thread principal, elle ne change aucun pixel. Le critère de validation est
donc « rien ne se dégrade », pas « on voit mieux ».

Vérifié : 101 tests du crate.
Diagnostic activable à la demande (`window.__uiProbe.start()` depuis la console du
renderer), inerte tant qu'on ne l'appelle pas.

Elle existe parce que les mesures précédentes ne pouvaient pas trancher :

  - 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é, qu'on attribue
    quand même au scrub. Ici chaque intervalle rAF est rangé dans l'état où il a été
    mesuré : repos / preview / scrub / scrub+preview ;
  - une UI rugueuse n'a pas une moyenne haute, elle a des retards épisodiques. À
    60 Hz, un p50 à 16,7 ms coexiste très bien avec 5 % de frames à 50 ms, et ce sont
    ces 5 % qu'on ressent. D'où le comptage des frames au-delà de 25 et 40 ms plutôt
    qu'une moyenne ;
  - une fenêtre cachée est throttlée par Chromium, donc la sonde refuse de démarrer
    dans ce cas plutôt que de produire des chiffres inadmissibles.

Un `PerformanceObserver` sur `longtask` nomme en plus les blocages > 50 ms, pour
distinguer « une grosse opération synchrone » d'« une accumulation de travaux
moyens » — les deux n'appellent pas le même correctif.

Premier verdict obtenu avec elle : repos 0 % de frames > 25 ms, preview seule 0,6 %,
scrub seul 8,8 %, scrub+preview 23,8 % (p90 à 50 ms), et quasiment aucune tâche
longue. La preview seule ne coûte rien ; c'est la manipulation, et surtout la
manipulation PENDANT que la preview se met à jour, qui casse le budget de frame — par
accumulation de travaux moyens, pas par un blocage unique.
…is pendant un scrub

`seekToClientX` appelait `setScrubbingTimeSec(...)` à CHAQUE `pointermove`. Une souris
émet 125 à 1000 événements par seconde ; à chacun, cet état local re-rendait
`V4Timeline` en entier — clips, waveforms, régions, lane pills — pour déplacer une tête
de lecture que la ligne juste au-dessus venait DÉJÀ d'écrire directement dans le DOM.

Ce rendu React ne sert qu'aux consommateurs de l'override (timecode, overlay), qui n'ont
aucune raison d'être rafraîchis plus vite qu'une frame. Il rejoint donc le rAF qui
throttlait déjà l'écriture au store. L'écriture DOM directe reste, elle, à chaque
événement : la tête continue de coller au curseur.

Mesuré avant ce changement avec `uiFrameProbe`, segmenté par état :

  repos           >25ms = 0,0 %
  preview seule   >25ms = 0,6 %
  scrub seul      >25ms = 8,8 %   p99 = 50 ms
  scrub+preview   >25ms = 23,8 %  p90 = 50 ms, >40ms = 16,9 %

La preview seule ne coûte rien : c'est la manipulation qui casse le budget de frame.
Ce commit vise les 8,8 % du scrub seul. Le reste — l'écart entre 8,8 % et 23,8 % —
vient de la peinture de la preview sur le thread principal (`ImageData` +
`createImageBitmap` + `drawImage` de ~3 Mo) et sera traité séparément.

Vérifié : tsc, 14/14 tests v4. La sonde donne le critère chiffré pour juger l'effet —
relancer `__uiProbe.start()` et comparer la ligne `scrub`.
…ent souris pendant un scrub"

This reverts commit 4fe4a85.
…issement de clip

Une comparaison a déjà été faussée par cette variable cachée : un run où le scrub
restait dans un seul clip a donné 8,8 % de frames au-delà de 25 ms, un autre où il
franchissait une frontière en a donné 21 % — et l'écart a été attribué à un changement
de code qui n'y était pour rien. Le correctif suspecté a été appliqué puis reverté sur
la foi de cette fausse lecture.

Chaque état porte désormais le nombre de bascules déjà vues (`scrub@0`, `scrub@1`…), de
sorte que « avant » et « après » ne peuvent plus être moyennés ensemble. La séparation
est faite par la sonde, pas par la discipline de celui qui teste.

Le rapport affiche aussi le nombre d'éléments `<video>` vivants. Le `<video>` de la
preview est keyé sur l'id du clip, donc React le remonte à chaque bascule ; si ce
compte grimpe, des éléments média orphelins survivent avec leur décodeur, ce qui
expliquerait une dégradation qui PERSISTE après un franchissement au lieu d'être un
coût transitoire. Hypothèse à confirmer ou infirmer par ce chiffre, pas à corriger
d'avance.

Vérifié : tsc, 66/66 tests src/components/ai-edition.
…ange

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. Or chaque changement de segment déclenchait un
`set_active_clip` complet, c'est-à-dire deux `Decoder::open` + `avformat_find_stream_info`
+ init D3D11VA, pour des fichiers déjà ouverts.

Mesuré avec `uiFrameProbe` sur un scrub traversant deux clips, l'utilisateur ayant fait
2 changements de clip voulus :

  31 bascules au total, 21 à moins de 500 ms de la précédente, écart médian 261 ms,
  13 aller-retours A→B→A
  séquence : seg1→seg2  seg2→seg3  seg3→seg2  seg2→seg1  seg1→seg2  seg2→seg3 …

Ce sont des `seg`, pas des clips. La comparaison porte donc désormais sur les chemins
réels (écran, webcam, décalage caméra) : identiques → `Player::seek_active`, qui
repositionne les décodeurs déjà ouverts ; différents → `set_active_clip` inchangé.

Ce que ça n'est pas : un anti-rebond. On ne retarde ni n'ignore aucune demande de
l'app — on cesse seulement de refaire un travail d'ouverture quand rien n'a changé
côté média. La position demandée est honorée à chaque fois.

Trouvé après avoir écarté deux hypothèses par la mesure : les `<video>` orphelins (le
compte reste à 2 quel que soit le nombre de bascules) et le re-rendu React par
`pointermove` (throttle appliqué puis reverté, l'écart venait d'une variable cachée —
le franchissement de clip — et non du code).

Vérifié : 101 tests du crate. NON vérifié : l'effet ressenti, à mesurer avec la sonde
(`scrub@N` et le compte de bascules) avant de conclure.
`Compositor::clear_srv_cache` existait, documentée « à appeler après la fermeture d'un
jeu de décodeurs pour ne pas retenir indéfiniment des textures de pool », et n'avait
AUCUN appelant.

Le cache est keyé sur `(adresse de la texture, tranche d'array)`. Chaque bascule de clip
ferme un jeu de décodeurs et laissait ses entrées derrière, avec deux conséquences dont
la seconde n'est pas une fuite mais une faute :

  1. le cache grandit sans borne et retient les textures via les `ID3D11ShaderResourceView`
     clonés qu'il conserve ;
  2. un décodeur neuf peut allouer une texture à une adresse déjà vue. La clé entre alors
     en collision et `nv12_srvs` rend le SRV PÉRIMÉ — c'est-à-dire l'image du clip
     précédent, sur un chemin où rien ne signale l'erreur.

Le point 2 est un candidat sérieux pour le « mauvais clip affiché » observé en usage, que
j'avais d'abord cherché du côté du verrou de bascule React.

Mesuré avec `uiFrameProbe` : le REPOS lui-même se dégrade avec le nombre de bascules —
0,7 % de frames au-delà de 25 ms à `repos@0`, 7,3 % à `repos@33`, sans aucune interaction.
Une dégradation qui persiste à l'arrêt désigne une accumulation, pas un coût de transition.

Vidage aux deux endroits où les décodeurs sont réellement remplacés : le traitement
d'`active_clip_request` (uniquement quand le média change — un simple changement de segment
ne ferme plus rien depuis le commit précédent) et `advance_to_next_scene_clip` pendant la
lecture libre.

Vérifié : 101 tests du crate. NON vérifié : que `repos@N` cesse de dériver — c'est la
mesure qui tranche, à refaire avec la sonde.
…relire le curseur pour rien

Deux défauts introduits par le commit précédent, dont un fatal.

FATAL — `seek_active` marquait la frame comme utilisable sans vérifier les DEUX seeks.
Ce chemin hérite de décodeurs déjà ouverts, donc d'un état : fin de piste, position hors
fenêtre, EOF déjà envoyé. `open_and_seek_clip` ne peut pas rencontrer ça (ses décodeurs
sont neufs). Quand le seek webcam ne rendait rien, `compose_frame` recevait 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. Constaté en
usage, et invisible dans les métriques : sans preview, tous les indicateurs de fluidité
s'améliorent, ce qui rendait la régression facile à prendre pour un gain.

`seek_active` rend désormais `bool`. Faux → l'appelant retombe sur `set_active_clip`,
chemin connu comme sûr. L'optimisation ne s'applique que là où elle fonctionne
démontrablement, et son échec n'est jamais fatal. Le vidage du cache de SRV est
conditionné à `repositioned` et non plus à `same_media` : un repli a bien fermé des
décodeurs, même à médias identiques.

REDONDANCE — la télémétrie curseur était relue depuis le disque (ouverture + 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. Elle n'est rechargée que si le chemin change ; l'application
au compositeur, elle, reste inconditionnelle (une reconstruction du compositeur lui fait
perdre son curseur). Le log passe dans la branche de relecture : il annonçait « loaded=ok »
alors que plus rien n'était lu.

Vérifié : 101 tests du crate, plus un harnais headless qui exerce six `setActiveClip` à
fichiers identiques sur des positions variées (dont les bords 0 s et 24,5 s) et confirme
que des frames continuent d'arriver — c'est précisément ce que je n'avais pas vérifié
avant de livrer la régression.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 63b15696-a325-4609-b80a-f42bb04e8dbe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from perf/preview-optim-incrementale to release/v1.8.0 July 30, 2026 08:14
@EtienneLescot
EtienneLescot merged commit bee2f6b into release/v1.8.0 Jul 30, 2026
9 checks passed
@EtienneLescot
EtienneLescot deleted the perf/preview-exploration branch July 30, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant