-
Notifications
You must be signed in to change notification settings - Fork 137
perf(audio): stretch speed regions through libavfilter atempo instead of WSOLA — fixes exports frozen at ~80% #371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
456ef53
0ae7884
e75d070
a05e87a
24c94da
2d4dfb3
1ec9ef3
8dc0483
01e991c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -407,13 +407,33 @@ async function fetchSharedDlls(tag, binDir) { | |
| return; | ||
| } | ||
|
|
||
| // probe for any previously vendored DLL by name; re-download is driven by | ||
| // --force same as the static exe, checked once we know what we'd extract. | ||
| const alreadyVendored = | ||
| process.platform === "win32" && | ||
| // Completeness probe, not a mere existence probe. The compositor addon now | ||
| // links six shared ffmpeg DLLs — see crates/compositor/build.rs | ||
| // (avcodec, avformat, avutil, swresample, swscale, avfilter). A warm dev/CI | ||
| // tree that already holds the five pre-avfilter DLLs would satisfy an "any | ||
| // av*.dll is present" check and let `avfilter-11.dll` go un-vendored, breaking | ||
| // require() at runtime (OpenScreen#371 review, EtienneLescot). Require all | ||
| // six explicitly so a missing one forces a re-vendor. | ||
| const REQUIRED_SHARED_DLLS = [ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new A regex typo — |
||
| "avcodec", | ||
| "avformat", | ||
| "avutil", | ||
| "swresample", | ||
| "swscale", | ||
| "avfilter", | ||
| ]; | ||
| fs.mkdirSync(binDir, { recursive: true }); | ||
| const vendoredFiles = new Set( | ||
| fs | ||
| .readdirSync(binDir, { withFileTypes: true }) | ||
| .some((e) => e.isFile() && isSharedLib(e.name) && /^(lib)?av/i.test(e.name)); | ||
| .filter((e) => e.isFile()) | ||
| .map((e) => e.name), | ||
| ); | ||
| const alreadyVendored = | ||
| process.platform === "win32" && | ||
| REQUIRED_SHARED_DLLS.every((lib) => | ||
| [...vendoredFiles].some((f) => new RegExp(`^${lib}-\\d+\\.dll$`).test(f)), | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| // The build-time SDK comes out of this same archive, so a tree that has the | ||
| // DLLs but not the SDK must still re-download — otherwise we skip here and | ||
| // the compositor build fails afterwards on the missing FFMPEG_DIR. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,18 +61,30 @@ and **one** encoder + muxer pair: | |
| per-segment rounded frame counts into a single output frame counter; | ||
| audio follows the same integer accumulation (`AudioConcatPlan`). | ||
|
|
||
| - **Audio and video junctions are seamless.** Audio is decoded per | ||
| segment up front (`audio.rs::decode_clip_audio`), WSOLA stretches each | ||
| speed sub-segment to its output sample count, and | ||
| - **Audio and video junctions are seamless.** Audio is decoded per clip | ||
| (`audio.rs::decode_clip_audio`), a libavfilter `atempo` chain stretches | ||
| each speed sub-segment to its output sample count, and | ||
| `assemble_concatenated_pcm` concatenates the per-segment PCM at the | ||
| integer sample offsets the video loop just produced — never | ||
| `round(cumulativeSec * sampleRate)`, because that compounds per-segment | ||
| rounding error into audible A/V drift across a long multi-segment | ||
| timeline. A short equal-power fade (`cos` on the tail, `sin` on the | ||
| head, `cos² + sin² = 1`) covers each internal boundary to suppress the | ||
| click where two recordings meet butt-joined, without shifting timing. | ||
| The WSOLA stretch is kicked off before the video loop so it overlaps | ||
| the encode and does not add to the wall. | ||
| The in-tree WSOLA stretcher is still there, but only as the fallback | ||
| `stretch_pcm_to_length` takes when the filter chain cannot be built or | ||
| yields too little audio (see [Audio](native-compositor.md#audio)). | ||
|
|
||
| - **The stretch is not overlapped with the encode.** Decode and stretch | ||
| run inside `walk_composited_timeline`'s `on_clip_end` callback | ||
| (`pipeline.rs`), which fires once per clip *after* that clip's frames | ||
| have been composed and encoded, on the same thread — so the stretch | ||
| time is added to the export wall, not hidden behind it. `progress()` is | ||
| driven only by encoded video frames, so nothing moves while it runs and | ||
| a long clip parks the export at whatever percentage the last frame | ||
|
Comment on lines
+78
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Correct the frame-progress timing description. In Describe progress as driven by composed timeline frames. State that encoding can lag by the readback ring and that the final drain occurs after the timeline walk. Suggested wording change- `progress()` is driven only by encoded video frames, so nothing moves while
+ `progress()` is driven by composed video frames, so nothing moves while
it runs and a long clip parks the export at whatever percentage the last frame
reported.🤖 Prompt for AI Agents |
||
| reported. That is why the `atempo` path matters: it is O(n) where WSOLA | ||
| is O(grain × radius) per rendered sample, which on a long clip meant | ||
| minutes of an apparently frozen export. | ||
|
|
||
| - **Output** honours the timeline's selected aspect ratio | ||
| (`resolveAspectRatioValue` over `getEditorSettings(document).aspectRatio` — | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,7 +31,7 @@ and without that flag the runtime corruption is silent. | |
| | [`crates/compositor/src/scene.rs`](../../crates/compositor/src/scene.rs) | the `Scene` struct parsed from the app's `SceneDescription` JSON | | ||
| | [`crates/compositor/src/regions.rs`](../../crates/compositor/src/regions.rs) | zoom / speed / Full Camera regions — envelope shapes and per-frame state sampling | | ||
| | [`crates/compositor/src/pipeline.rs`](../../crates/compositor/src/pipeline_windows.rs) | demux + `D3D11VA` decode + composite + AMF encode + mux (`run_c0` for decode/encode only, `run_composited` for the full path) | | ||
| | [`crates/compositor/src/audio.rs`](../../crates/compositor/src/audio.rs) | per-clip audio decode, swresample → f32 planar 48 kHz stereo, WSOLA speed stretch, multi-track mix, AAC encoder | | ||
| | [`crates/compositor/src/audio.rs`](../../crates/compositor/src/audio.rs) | per-clip audio decode, swresample → f32 planar 48 kHz stereo, libavfilter `atempo` speed stretch (WSOLA fallback), multi-track mix, AAC encoder | | ||
| | [`crates/compositor/src/cursor.rs`](../../crates/compositor/src/cursor.rs) | `.cursor.json` parser + interpolated cursor track (position, click bounces, adaptive follow samples) | | ||
| | [`crates/compositor/src/text.rs`](../../crates/compositor/src/text_windows.rs) | DirectWrite + Direct2D text rasterisation for annotation labels, cached per (content, style, box) | | ||
| | [`crates/compositor/src/text_anim.rs`](../../crates/compositor/src/text_anim.rs) | text-annotation appearance animations (port of the TS animation curves, in fractions of the output short side) | | ||
|
|
@@ -199,9 +199,28 @@ each track is recut to the same `[source_start_sec, source_end_sec)` — | |
| pads in front for late-starting tracks, trims the pre-roll for early-decoded | ||
| ones — so a summing mixer is enough and a real mix matrix is not needed. | ||
|
|
||
| Speed regions apply after decode: WSOLA stretches each speed sub-segment to | ||
| its output frame count, sharing search positions across channels from a | ||
| mono down-mix (so the stereo image does not wander between channels). | ||
| Speed regions apply after decode: `stretch_pcm_to_length` stretches each | ||
| speed sub-segment to its output frame count through a libavfilter | ||
| `abuffer → atempo… → abuffersink` graph built in-process | ||
| (`avfilter_atempo_stretch`). The graph is pinned to the fltp / 48 kHz / | ||
| stereo format `decode_clip_audio` already produces, and `atempo` preserves | ||
| format, channels and rate, so no conversion is involved; the result is | ||
| recut to the exact target length by truncation or zero-padding. `atempo` | ||
|
Comment on lines
+202
to
+208
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository convention files ---'
find /tmp/coderabbit-repo-knowledge/getopenscreen-openscreen-086fd783 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- convention headers ---'
for f in /tmp/coderabbit-repo-knowledge/getopenscreen-openscreen-086fd783/*/*.md; do
[ -f "$f" ] && { echo "### $f"; head -5 "$f"; }
done
printf '%s\n' '--- target documentation ---'
cat -n technical-documentation/architecture/native-compositor.md | sed -n '185,220p'
printf '%s\n' '--- symbol locations ---'
rg -n --glob '!build' --glob '!dist' 'avfilter_atempo_stretch|decode_clip_audio|AV_SAMPLE_FMT_FLTP|AV_SAMPLE_FMT_FLT|abuffersink' .Repository: getopenscreen/openscreen Length of output: 10715 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- documentation learning ---'
cat /tmp/coderabbit-repo-knowledge/getopenscreen-openscreen-086fd783/learnings/technical-documentation.md
printf '%s\n' '--- audio constants and PlanarPcm contract ---'
cat -n crates/compositor/src/audio.rs | sed -n '90,145p'
printf '%s\n' '--- atempo implementation and drain path ---'
cat -n crates/compositor/src/audio.rs | sed -n '850,1070p'Repository: getopenscreen/openscreen Length of output: 14128 Describe the actual sample-format contract.
🧰 Tools🪛 LanguageTool[grammar] ~203-~203: Ensure spelling is correct (QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1) 🤖 Prompt for AI Agents |
||
| only accepts a factor in `[0.5, 100.0]`, so `atempo_factors` chains | ||
| several instances whose product is the requested speed (0.2 → | ||
| `[0.5, 0.5, 0.8]`). | ||
|
|
||
| The in-tree WSOLA stretcher remains as the fallback, taken whenever | ||
| `avfilter_atempo_stretch` returns `None` — the graph could not be built or | ||
| configured, a buffersrc/buffersink call failed, or the chain drained fewer | ||
| than 90% of the target samples, which is what happens on a span too short | ||
| for `atempo`'s analysis window (a few tens of milliseconds between two | ||
| speed regions). WSOLA shares its search positions across channels from a | ||
| mono down-mix, so the stereo image does not wander between them. The move | ||
| to `atempo` is a cost change, not a quality one: WSOLA is | ||
| O(grain × radius) per rendered sample, minutes of a full core on a long | ||
| clip, against `atempo`'s O(n) with ffmpeg's SIMD routines. | ||
|
|
||
| Across segments, `build_audio_concat_plan` sizes each segment's PCM by | ||
| **integer accumulation of the per-segment rounded sample count**, never | ||
| `round(cumulativeSec * sampleRate)` — that single change is what keeps A/V | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bloquant —
avfilterdevient une dépendance de chargement, mais ni le vendoring Windows ni les gardes de packaging ne le connaissent.Cette ligne met
avfilter-11.dll/libavfilter.so.11dans la table d'import decompositor_view.node. Trois endroits, tous hors diff, n'ont pas suivi — je les signale ici faute de pouvoir commenter des fichiers non modifiés.scripts/fetch-ffmpeg.mjs:412— le court-circuit « déjà vendored » est une sonde d'existence (« unav*.dllquelconque est là »), pas une vérification d'ensemble :build:winappellenpm run fetch:ffmpegsans--force. Sur toute machine de dev ou workspace CI tiède oùelectron/native/bin/win32-x64/contient déjà les cinq DLL d'avant cette PR et oùcrates/thirdparty/ffmpeg-n8.1.2-win64-lgpl-sharedexiste,fetchSharedDllssort tôt etavfilter-11.dlln'est jamais copié.require()échoue alors avec « The specified module could not be found »,tryLoadAddonl'avale, et l'app part avec une preview blanche et un compositeur inerte — le symptôme du build Store 1.9.0 quebuild-windows-compositor-addon.mjsdocumente. C'est la première fois que l'ensemble requis grandit depuis l'écriture de ce garde, donc le cas n'a jamais été exercé.scripts/before-pack.cjs— les trois listes par librairie ignorentavfilter::134(Linux),:237(Windows),:79(macOS,/^libav(codec|format|util)\.\d+\.dylib$/avecatLeast: 3). Le commentaire de la liste Linux explique qu'elle est écrite une-entrée-par-famille précisément pour qu'une librairie manquante ne se cache pas derrière un total — une régression déjà livrée une fois. Un build propre embarque bien la librairie (leFFMPEG_SONAMESde cette PR côté Linux,otool -Lcôté macOS) : c'est le garde qui a régressé. Un payload issu d'un build natif périmé ou partiel passe doncbeforePacket installe un addon qui meurt dansld.so/dyldàrequire()— pas une dégradation vers WSOLA, mais preview et export morts.À corriger dans la foulée : le miroir doc
technical-documentation/engineering/build-and-packaging.md:207a besoin de la même entrée, et l'en-tête descripts/build-linux-compositor-addon.mjs:19dit encore « the five ffmpeg sonames » pour une liste qui en compte six.Generated by Claude Code