Skip to content

perf(preview): trois optimisations du chemin de preview, appliquées une par une - #206

Merged
EtienneLescot merged 3 commits into
release/v1.8.0from
perf/preview-optim-incrementale
Jul 30, 2026
Merged

perf(preview): trois optimisations du chemin de preview, appliquées une par une#206
EtienneLescot merged 3 commits into
release/v1.8.0from
perf/preview-optim-incrementale

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Trois changements indépendants sur le chemin de preview, chacun isolé dans son
commit et validé séparément dans l'app avant d'appliquer le suivant.

Ce que ça contient

commit quoi validation
ef6705c3 Chemin rapide de seekseek_to ne jette plus l'état du décodeur quand la cible est la frame courante ou à moins de 0,5 s devant 101 tests du crate ; aucune régression observée en usage
45a945c8 Le <video> ne dicte plus la position de la timeline à l'arrêt corrige un tressaillement constaté : la tête de lecture sautait au début du clip voisin au scrub près d'une frontière
5850228e Livraison de frame sans copielatest_frame_since emporte le buffer au lieu de le cloner 101 tests ; invisible par construction, aucune régression observée

Pourquoi ces trois-là

seek_to faisait systématiquement av_seek_frame(BACKWARD) + avcodec_flush_buffers
puis redécodait depuis l'image clé précédente — jusqu'à 60 frames, deux fois (écran +
webcam), soit ~50 ms par pas de scrub mesurés. Le cas dominant en édition n'est pourtant
pas un saut mais « la frame suivante » ou « la même frame ». Le critère d'arrêt du
déroulement est la même expression que celui du seek complet : les deux chemins rendent
la même frame.

Le tick rAF de VirtualPreview publiait la position de la timeline en permanence, y
compris à l'arrêt. Or à l'arrêt c'est l'utilisateur qui possède la tête de lecture et le
<video> doit la suivre. Sans ce garde, un scrub était écrasé à chaque frame par la
position d'un élément encore en train de chercher, et près d'une frontière de clips le
repli seekToVirtualTimeRef(nextClip.timelineStartSec) renvoyait la tête au début du clip
voisin.

latest_frame_since clonait le buffer RGBA à chaque livraison — 1,5 ms en 1280×720,
3,4 ms en 1920×1080 — sur le thread principal de Node, celui qui doit rester libre pour
que React peigne la tête de lecture. La génération passe dans un AtomicU64 séparé, sans
quoi vider le slot la ferait repartir à 1 et rejouerait des générations déjà peintes.

Méthode

Une première tentative empilait sept changements simultanés et s'est révélée impossible à
attribuer : des régressions (décalage audio/vidéo, mauvais clip affiché) coexistaient avec
des gains réels sans qu'on puisse dire lequel venait d'où. Elle a été abandonnée au profit
de cette branche, repartie de release/v1.8.0 avec un changement à la fois et un test
utilisateur entre chaque.

Ce qui n'est PAS dedans, délibérément

  • Transport WebCodecs (encodage matériel du RT → VideoDecoder côté renderer).
    Techniquement fonctionnel et mesuré — 2,4 Ko/frame au lieu de 3,08 Mo, encodage 0,97 ms,
    un vsync d'entrée dans Chromium contre trois pour MSE + <video>. Mais il ajoute une
    latence vidéo que l'audio, qui sort du <video> du DOM, ne suit pas : décalage A/V par
    construction, absent de la référence. À reprendre avec une vraie stratégie de synchro.
  • Readback pipeliné (deux stagings + DO_NOT_WAIT). Gain mesuré important à grande
    taille de panneau (1080p 18,5 → 40,3 fps, attente GPU 11 → 0,4 ms), mais il publie la
    frame N−1, donc faux à l'arrêt. Le garde-fou correspondant n'a pas pu être validé : le
    compositing en pause n'est pas reproductible dans ce projet (3 revisites sur 6 au même
    timestamp donnent des images différentes, y compris sur du code non modifié), donc aucun
    oracle d'image ne peut trancher aujourd'hui.

Vérifié

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

Non vérifié

Le gain du chemin rapide de seek n'est pas perceptible à l'usage — il est mesuré, pas
ressenti. Il reste défendable comme retrait de gaspillage, mais si vous préférez une
branche strictement composée de changements perçus, ef6705c3 est celui à retirer.

…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.
@coderabbitai

coderabbitai Bot commented Jul 29, 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: b634b30f-e6e8-4cdb-8d11-ce6431590c17

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.

@EtienneLescot
EtienneLescot merged commit b586cd8 into release/v1.8.0 Jul 30, 2026
9 checks passed
@EtienneLescot
EtienneLescot deleted the perf/preview-optim-incrementale branch July 30, 2026 08:14
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