Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/compositor/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ fn main() {
if let Some(v) = ff.as_ref() {
let lib_dir = Path::new(v).join("lib");
println!("cargo:rustc-link-search=native={}", lib_dir.display());
for lib in ["avformat", "avcodec", "avutil", "swscale", "swresample"] {
for lib in ["avformat", "avcodec", "avutil", "swscale", "swresample", "avfilter"] {
Comment thread
EtienneLescot marked this conversation as resolved.
println!("cargo:rustc-link-lib=dylib={}", lib);
}
}
Expand Down
664 changes: 656 additions & 8 deletions crates/compositor/src/audio.rs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions crates/compositor/wrapper_linux.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@
#include <libavutil/pixdesc.h>
#include <libswresample/swresample.h>
#include <libswscale/swscale.h>
#include <libavfilter/avfilter.h>
#include <libavfilter/buffersrc.h>
#include <libavfilter/buffersink.h>
5 changes: 4 additions & 1 deletion crates/compositor/wrapper_macos.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@
/* Software decode path : swscale était déjà LIÉ (build.rs) sans être bindé.
Conservé identique côté macOS pour que la symétrie avec cpu_frames_windows.rs
soit claire ; le code effectif vit dans mac_frames.rs. */
#include <libswscale/swscale.h>
#include <libswscale/swscale.h>
#include <libavfilter/avfilter.h>
#include <libavfilter/buffersrc.h>
#include <libavfilter/buffersink.h>
3 changes: 3 additions & 0 deletions crates/compositor/wrapper_windows.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@
elle couvre les formats exotiques (10 bits, 4:2:2) qu'un interleave écrit à la
main casserait silencieusement. */
#include <libswscale/swscale.h>
#include <libavfilter/avfilter.h>
#include <libavfilter/buffersrc.h>
#include <libavfilter/buffersink.h>
15 changes: 13 additions & 2 deletions nix/compositor-view.nix
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,18 @@ rustPlatform.buildRustPackage {
# Copy each library under its soname and read its symbol table. awk rather
# than sed with a backreference: the third field is the name, and anything
# after an @ is the version tag.
for lib in ${ffmpegLgpl.lib}/lib/lib{avformat,avcodec,avutil,swscale,swresample}.so.*; do
#
# The list must hold every library crates/compositor/build.rs emits a
# cargo:rustc-link-lib for -- all six of them, avfilter included since the
# speed-region stretch runs through atempo. Miss one and the build either
# dies on `cannot find -lavfilter` (no unversioned symlink is staged for it
# below) or links the store's UN-renamed copy, and a cdylib tolerating
# undefined symbols means the failure surfaces only at require() time as
# "undefined symbol: osff_avfilter_graph_alloc" -- the addon then loads as a
# no-op and preview plus every export are dead. avfilter's exports are all
# av-prefixed (avfilter_*, av_buffersrc_*, av_buffersink_*), so the awk
# filter here and the leak check in installPhase already cover them.
for lib in ${ffmpegLgpl.lib}/lib/lib{avformat,avcodec,avutil,swscale,swresample,avfilter}.so.*; do
case "$lib" in *.so.*.*) continue ;; esac
test -f "$lib" || continue
cp "$(readlink -f "$lib")" "$stage/lib/$(basename "$lib")"
Expand Down Expand Up @@ -178,7 +189,7 @@ rustPlatform.buildRustPackage {
# Each copy still carries the RUNPATH it inherited from the original ffmpeg
# output, which is where the UN-renamed libraries live -- so libavcodec's own
# osff_swr_init would resolve against a libswresample that defines swr_init.
# It only works today because all five happen to be direct DT_NEEDED of the
# It only works today because all six happen to be direct DT_NEEDED of the
# addon, so $ORIGIN is searched first; the day --as-needed drops one the
# loader falls through to the store copy and dlopen fails on an undefined
# osff_ symbol. Put $ORIGIN in front so the renamed set can only resolve
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ buildNpmPackage {
);
};

npmDepsHash = "sha256-mnI1d8HyZdaCiYolAd8VvBSlSdEsZT89Rf0ADVm9Bf8=";
npmDepsHash = "sha256-LkKX1edTPHZq5nQRrbLAn11oVw36kb0smNQMmVRMEPA=";

env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";

Expand Down
17 changes: 10 additions & 7 deletions scripts/before-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ const MAC_REQUIRED = [
breaks: "the preview and every export render nothing",
fix: FIX_MAC,
},
{
match: (name) => /^libav(codec|format|util)\.\d+\.dylib$/.test(name),
what: "the LGPL ffmpeg dylibs the compositor links",
// One requirement per library, not `atLeast: N` over a combined regex — the
// same trap LINUX_REQUIRED documents above. Several versioned copies of one
// library would satisfy a combined count while another was missing entirely,
// and the addon would still fail to load.
...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map((library) => ({
match: (name) => new RegExp(`^lib${library}\\.\\d+\\.dylib$`).test(name),
what: `the LGPL lib${library} dylib the compositor links`,
breaks: "the compositor addon cannot be loaded at all (dyld error at require())",
fix: FIX_MAC,
atLeast: 3,
},
})),
{
match: (name) => name === "whisper-stt-server",
what: "the whisper.cpp STT helper",
Expand Down Expand Up @@ -162,7 +165,7 @@ const LINUX_REQUIRED = [
// pendant qu'une autre manquait. Le paquet passait alors la garde et le
// compositeur ne chargeait pas : exactement le mode de panne que cette garde
// existe pour attraper.
...["avcodec", "avformat", "avutil", "swresample", "swscale"].map((library) => ({
...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map((library) => ({
match: (name) => new RegExp(`^lib${library}\\.so\\.\\d+$`).test(name),
what: `the symbol-renamed lib${library} shared object the compositor links`,
breaks: "the compositor addon cannot be loaded at all (ld.so error at require())",
Expand Down Expand Up @@ -276,7 +279,7 @@ const WIN_REQUIRED = [
// (avcodec-60/61/62.dll left by an earlier fetch) would satisfy a combined count
// while another library was missing entirely, and the addon would still fail to
// load.
...["avcodec", "avformat", "avutil"].map((library) => ({
...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map((library) => ({
match: (name) => new RegExp(`^${library}-\\d+\\.dll$`).test(name),
what: `the ${library} DLL the compositor links`,
breaks: "the addon cannot be loaded at all under MSIX, which ignores PATH",
Expand Down
3 changes: 2 additions & 1 deletion scripts/build-linux-compositor-addon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// (ensureFfmpegSharedDllsOnPath), but glibc reads LD_LIBRARY_PATH once at
// process start, so the equivalent trick cannot work after Electron is
// already running. Instead the addon is linked with `-rpath,$ORIGIN` and
// the five ffmpeg sonames are copied next to it, which makes the .node
// the six ffmpeg sonames are copied next to it, which makes the .node
// self-contained wherever it is installed — no env var, no PATH surgery.

import { spawnSync } from "node:child_process";
Expand All @@ -36,6 +36,7 @@ const FFMPEG_SONAMES = [
"libavutil.so.60",
"libswscale.so.9",
"libswresample.so.6",
"libavfilter.so.11",
];

const run = (command, args, options = {}) =>
Expand Down
30 changes: 25 additions & 5 deletions scripts/fetch-ffmpeg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Comment thread
EtienneLescot marked this conversation as resolved.
"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)),
);
Comment thread
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.
Expand Down
111 changes: 111 additions & 0 deletions scripts/ffmpeg-linked-libraries.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// `crates/compositor/build.rs` decides which ffmpeg shared libraries the native addon
// imports. Six places have to agree with that list, and none of them is derived from it:
//
// - scripts/fetch-ffmpeg.mjs vendors the Windows DLLs and decides when to skip
// - scripts/before-pack.cjs fails the pack if one is missing (three OS tables)
// - scripts/build-linux-compositor-addon.mjs copies + symbol-renames the sonames
// - nix/compositor-view.nix builds symbols.map from a brace glob
//
// Drift is not a cosmetic problem. The addon is a cdylib, so a missing library does not
// fail the link: it fails at `require()` with "undefined symbol: osff_avfilter_graph_alloc",
// `compositorViewService` logs "native addon not present; running as no-op", and the app
// ships with a blank preview and every export dead — the exact symptom 1.9.0 shipped with.
// Every guard listed above passes in that state, because each one only knows the list it
// was written with.
//
// This test derives the truth from build.rs and checks the other five against it, so
// linking a seventh library fails here instead of in a user's installer. It reads source
// text: before-pack.cjs and fetch-ffmpeg.mjs both do work at import time, and the property
// under test is a property of the literal lists anyway.
//
// The nix derivation is the reason this file exists rather than an assertion inside
// before-pack.test.mjs: `.github/workflows/nix-build.yml` does not run on pull requests
// and there is no nix on the Windows dev box, so nix/compositor-view.nix reaches main with
// no pre-merge signal at all. A text assertion is not `nix build`, but it does catch the
// one mistake that has actually happened.

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const read = (relative) => fs.readFileSync(path.join(repoRoot, relative), "utf8");

const buildRs = read("crates/compositor/build.rs");

/** The `for lib in [...] { println!("cargo:rustc-link-lib=…") }` list, in build.rs order. */
const linked = (() => {
const block = buildRs.match(/for lib in \[([\s\S]*?)\]\s*\{[\s\S]*?rustc-link-lib/);
if (!block) return [];
return [...block[1].matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
})();

describe("the ffmpeg libraries the compositor links", () => {
// Without this the regex could silently match nothing and every assertion below
// would iterate an empty list and pass vacuously.
it("is read out of build.rs", () => {
expect(linked.length).toBeGreaterThanOrEqual(6);
expect(linked).toContain("avcodec");
expect(linked).toContain("avfilter");
});

it("is vendored in full by fetch-ffmpeg.mjs", () => {
// The probe that decides whether a warm tree still needs a re-vendor. When it
// listed fewer libraries than build.rs links, a tree holding the previous set
// satisfied it and the new DLL was never fetched.
const source = read("scripts/fetch-ffmpeg.mjs");
const table = source.match(/REQUIRED_SHARED_DLLS = \[([\s\S]*?)\]/);
expect(table, "fetch-ffmpeg.mjs no longer declares REQUIRED_SHARED_DLLS").not.toBeNull();
const required = [...table[1].matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
expect([...required].sort()).toEqual([...linked].sort());
});

it("is staged and symbol-renamed for the Linux addon", () => {
const source = read("scripts/build-linux-compositor-addon.mjs");
const table = source.match(/FFMPEG_SONAMES = \[([\s\S]*?)\]/);
expect(
table,
"build-linux-compositor-addon.mjs no longer declares FFMPEG_SONAMES",
).not.toBeNull();
const sonames = [...table[1].matchAll(/"lib([a-z]+)\.so\.\d+"/g)].map((match) => match[1]);
expect([...sonames].sort()).toEqual([...linked].sort());
});

it("is staged and symbol-renamed by the nix derivation", () => {
// `for lib in ${ffmpegLgpl.lib}/lib/lib{avformat,…}.so.*` — the brace glob feeds
// both the copy into $stage/lib and the symbols.map the addon is linked against.
// A name missing here links against nixpkgs' un-renamed copy, and the
// installPhase leak check only flags symbols WITHOUT the osff_ prefix, so it
// passes either way.
const source = read("nix/compositor-view.nix");
const glob = source.match(/\/lib\/lib\{([a-z,]+)\}\.so\.\*/);
expect(glob, "nix/compositor-view.nix no longer globs the ffmpeg sonames").not.toBeNull();
expect(glob[1].split(",").sort()).toEqual([...linked].sort());
});

describe("is required by before-pack.cjs", () => {
const source = read("scripts/before-pack.cjs");
/** The `[...]` array literal a `...[…].map(` spread iterates, per OS table. */
const listsIn = (table) => {
const block = source.slice(source.indexOf(`const ${table} = [`));
const end = block.indexOf("\n];");
return [...block.slice(0, end).matchAll(/\.\.\.\[([^\]]*)\]\.map\(/g)].flatMap((match) =>
[...match[1].matchAll(/"([a-z]+)"/g)].map((name) => name[1]),
);
};

// One requirement per library rather than `atLeast: N` over a combined regex:
// several versioned copies of one library would satisfy a count while another
// was missing entirely, which is how a broken pack passed the guard before.
for (const table of ["MAC_REQUIRED", "LINUX_REQUIRED", "WIN_REQUIRED"]) {
it(table, () => {
const required = listsIn(table);
expect(required.length, `${table} declares no per-library spread`).toBeGreaterThan(0);
for (const library of linked) {
expect(required, `${table} does not require lib${library}`).toContain(library);
}
});
}
});
});
28 changes: 23 additions & 5 deletions technical-documentation/architecture/export-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,36 @@ 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,
negotiates a format the drain does not read, or still comes up short of
the target after the corrected pass (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 submitted to the encoder, on the same thread —
so the stretch time is added to the export wall, not hidden behind it.
`progress()` counts composed frames as they are handed to the encoder,
and nothing calls it during the audio phase, so a long clip parks the
export at whatever percentage its last frame reported. (The encoder's
own flush comes later still, after the timeline walk.) That reporting
gap is why the `atempo` path matters so much here: WSOLA is
O(grain × radius) per rendered sample, which on a long clip meant
minutes of an apparently frozen export. Reporting progress across the
audio phase would fix the symptom rather than the cost, and is tracked
separately.

- **Output** honours the timeline's selected aspect ratio
(`resolveAspectRatioValue` over `getEditorSettings(document).aspectRatio` —
Expand Down
Loading
Loading