feat: publish av-tools 0.2.0
This commit is contained in:
420
scripts/build-reviewed-ffmpeg-core.sh
Normal file
420
scripts/build-reviewed-ffmpeg-core.sh
Normal file
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/build-reviewed-ffmpeg-core.sh st|mt SOURCE_ARCHIVE EMSDK_ROOT [JOBS]
|
||||
|
||||
Builds one private, staged FFmpeg WebAssembly core from the exact
|
||||
0.12.10 Corresponding Source archive. The only upstream source change is
|
||||
ffmpeg.wasm commit b409e36475bc21f0451b5b1e1d126fa82871439a:
|
||||
`-sSTACK_SIZE=5MB`, which fixes upstream issues #591 and #775.
|
||||
|
||||
Output remains below .core-build/reviewed-stack5m and is never copied into the
|
||||
application automatically. Re-running resumes at verified component stamps.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 3 || $# -gt 4 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
MODE=$1
|
||||
SOURCE_ARCHIVE=$2
|
||||
EMSDK_ROOT=$3
|
||||
JOBS=${4:-2}
|
||||
|
||||
if [[ "$MODE" != "st" && "$MODE" != "mt" ]]; then
|
||||
echo "Mode must be st or mt." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$JOBS" =~ ^[1-8]$ ]]; then
|
||||
echo "JOBS must be an integer from 1 to 8." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIRECTORY=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
REPOSITORY_ROOT=$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)
|
||||
SOURCE_ARCHIVE=$(realpath -- "$SOURCE_ARCHIVE")
|
||||
EMSDK_ROOT=$(realpath -- "$EMSDK_ROOT")
|
||||
|
||||
EXPECTED_ARCHIVE_SHA256=2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a
|
||||
EXPECTED_EMCC_SHA256=b0c9551f9155fa9c028efa24591acdfd30f02f32fb247cf9266f5f0f2ede7589
|
||||
EXPECTED_CMAKE_SHA256=ade9326f83f4996b17e2cb1bbfe4848df30a237ef5769cd3c00239b9f0d3ec58
|
||||
EXPECTED_SOURCE_ROOT=ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source
|
||||
SOURCE_DATE_EPOCH=1736208000
|
||||
|
||||
if [[ ! -f "$SOURCE_ARCHIVE" || -L "$SOURCE_ARCHIVE" ]]; then
|
||||
echo "SOURCE_ARCHIVE must be a regular, non-symlink file." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$EMSDK_ROOT" || -L "$EMSDK_ROOT" ]]; then
|
||||
echo "EMSDK_ROOT must be a real directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
actual_archive_sha256=$(sha256sum -- "$SOURCE_ARCHIVE" | cut -d' ' -f1)
|
||||
if [[ "$actual_archive_sha256" != "$EXPECTED_ARCHIVE_SHA256" ]]; then
|
||||
echo "Corresponding Source archive SHA-256 mismatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$EMSDK_ROOT/emsdk_env.sh" >/dev/null
|
||||
export PATH="$REPOSITORY_ROOT/scripts/build-tools:$PATH"
|
||||
if [[ -z "${AV_REVIEWED_CMAKE_BIN:-}" ]]; then
|
||||
echo "AV_REVIEWED_CMAKE_BIN must identify the exact CMake 3.27.9 binary directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
AV_REVIEWED_CMAKE_BIN=$(realpath -- "$AV_REVIEWED_CMAKE_BIN")
|
||||
if [[ ! -d "$AV_REVIEWED_CMAKE_BIN" || -L "$AV_REVIEWED_CMAKE_BIN" ]]; then
|
||||
echo "AV_REVIEWED_CMAKE_BIN must be a real directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $(sha256sum -- "$AV_REVIEWED_CMAKE_BIN/cmake" | cut -d' ' -f1) != "$EXPECTED_CMAKE_SHA256" ]]; then
|
||||
echo "CMake binary SHA-256 mismatch; exact PyPI CMake 3.27.9 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH="$AV_REVIEWED_CMAKE_BIN:$PATH"
|
||||
EMCC=$(realpath -- "$EMSDK_ROOT/upstream/emscripten/emcc")
|
||||
actual_emcc_sha256=$(sha256sum -- "$EMCC" | cut -d' ' -f1)
|
||||
if [[ "$actual_emcc_sha256" != "$EXPECTED_EMCC_SHA256" ]]; then
|
||||
echo "Emscripten compiler SHA-256 mismatch; activate exact version 3.1.40." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$EMCC" --version | head -1 | grep -Fq \
|
||||
"3.1.40 (5c27e79dd0a9c4e27ef2326841698cdd4f6b5784)"; then
|
||||
echo "Emscripten identity mismatch; exact version 3.1.40 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $(cmake --version | head -1) != "cmake version 3.27.9" ]]; then
|
||||
echo "CMake identity mismatch; exact version 3.27.9 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_PARENT="$REPOSITORY_ROOT/.core-build/reviewed-stack5m"
|
||||
MODE_ROOT="$BUILD_PARENT/$MODE"
|
||||
EXTRACT_ROOT="$MODE_ROOT/source"
|
||||
SOURCE_ROOT="$EXTRACT_ROOT/$EXPECTED_SOURCE_ROOT/sources"
|
||||
INSTALL_DIR="$MODE_ROOT/install"
|
||||
OUTPUT_DIR="$MODE_ROOT/output"
|
||||
STAMP_DIR="$MODE_ROOT/stamps"
|
||||
|
||||
case "$MODE_ROOT" in
|
||||
"$REPOSITORY_ROOT"/.core-build/reviewed-stack5m/st | \
|
||||
"$REPOSITORY_ROOT"/.core-build/reviewed-stack5m/mt) ;;
|
||||
*)
|
||||
echo "Refusing an unexpected build directory: $MODE_ROOT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p -- "$MODE_ROOT" "$STAMP_DIR"
|
||||
if [[ ! -f "$STAMP_DIR/source-prepared" ]]; then
|
||||
if [[ -e "$EXTRACT_ROOT" ]]; then
|
||||
echo "Incomplete source staging already exists at $EXTRACT_ROOT." >&2
|
||||
echo "Remove only that private mode directory to restart it." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p -- "$EXTRACT_ROOT"
|
||||
tar -xf "$SOURCE_ARCHIVE" -C "$EXTRACT_ROOT"
|
||||
|
||||
lock="$EXTRACT_ROOT/$EXPECTED_SOURCE_ROOT/SOURCE_LOCK.json"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const expected = new Map([
|
||||
["ffmpeg.wasm", "71aa99d37c02a7b4c435275ca9ef50e612f6efa1"],
|
||||
["FFmpeg", "4729204c17f756e186d622060088371d10b34f7e"],
|
||||
["Opus", "e85ed7726db5d677c9c0677298ea0cb9c65bdd23"],
|
||||
]);
|
||||
for (const [name, revision] of expected) {
|
||||
const entry = lock.sources.find((candidate) => candidate.name === name);
|
||||
if (!entry || entry.revision !== revision) {
|
||||
throw new Error(`Unexpected ${name} source identity.`);
|
||||
}
|
||||
}
|
||||
' "$lock"
|
||||
|
||||
wasm_build_script="$SOURCE_ROOT/ffmpeg.wasm/build/ffmpeg-wasm.sh"
|
||||
if grep -Fq -- "-sSTACK_SIZE=" "$wasm_build_script"; then
|
||||
echo "Base archive unexpectedly already selects a stack size." >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i \
|
||||
'/-sUSE_SDL=2/a\ -sSTACK_SIZE=5MB # upstream b409e364: support libopus' \
|
||||
"$wasm_build_script"
|
||||
if [[ $(grep -Fc -- "-sSTACK_SIZE=5MB" "$wasm_build_script") -ne 1 ]]; then
|
||||
echo "Could not apply the reviewed one-line stack patch exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
touch "$STAMP_DIR/source-prepared"
|
||||
fi
|
||||
|
||||
# The upstream container uses unbounded `make -j`. Keep local builds bounded
|
||||
# without changing compiled source, and allow a resumed build to select a new
|
||||
# bounded job count.
|
||||
while IFS= read -r -d '' build_script; do
|
||||
sed -E -i \
|
||||
-e "s/ -j([1-8])?$/ -j$JOBS/" \
|
||||
-e "s/ -j([1-8])? / -j$JOBS /g" \
|
||||
"$build_script"
|
||||
done < <(find "$SOURCE_ROOT/ffmpeg.wasm/build" -type f -name '*.sh' -print0)
|
||||
|
||||
# The upstream container does not provide native NASM. Some development hosts
|
||||
# do; allowing CMake to discover it would produce native x86 objects that
|
||||
# cannot be linked into WebAssembly. Make the container's effective choice
|
||||
# explicit and deterministic.
|
||||
X265_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/x265.sh"
|
||||
if ! grep -Fq -- "-DENABLE_ASSEMBLY=OFF" "$X265_BUILD_SCRIPT"; then
|
||||
sed -i \
|
||||
'/-DENABLE_LIBNUMA=OFF/a\ -DENABLE_ASSEMBLY=OFF' \
|
||||
"$X265_BUILD_SCRIPT"
|
||||
fi
|
||||
if [[ $(grep -Fc -- "-DENABLE_ASSEMBLY=OFF" "$X265_BUILD_SCRIPT") -ne 1 ]]; then
|
||||
echo "Could not disable native x265 assembly exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upstream builds each dependency in an isolated container stage. A cumulative
|
||||
# local prefix would otherwise let FreeType discover the already-built zlib and
|
||||
# let HarfBuzz discover FreeType. Their resulting private dependencies are not
|
||||
# present in FFmpeg's pkg-config probes and, on a developer workstation, optional
|
||||
# libraries such as libpng may even resolve to host-native /usr objects. Make the
|
||||
# isolated-stage dependency choices explicit.
|
||||
FREETYPE_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/freetype2.sh"
|
||||
if ! grep -Fq -- "--with-zlib=no" "$FREETYPE_BUILD_SCRIPT"; then
|
||||
sed -i \
|
||||
'/--without-harfbuzz/a\ --with-zlib=no\n --with-bzip2=no\n --with-png=no\n --with-brotli=no' \
|
||||
"$FREETYPE_BUILD_SCRIPT"
|
||||
fi
|
||||
for freetype_flag in \
|
||||
"--without-harfbuzz" \
|
||||
"--with-zlib=no" \
|
||||
"--with-bzip2=no" \
|
||||
"--with-png=no" \
|
||||
"--with-brotli=no"; do
|
||||
if [[ $(grep -Fc -- "$freetype_flag" "$FREETYPE_BUILD_SCRIPT") -ne 1 ]]; then
|
||||
echo "Could not select isolated FreeType dependency flags exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
HARFBUZZ_BUILD_SCRIPT="$SOURCE_ROOT/ffmpeg.wasm/build/harfbuzz.sh"
|
||||
for harfbuzz_flag in \
|
||||
"--with-freetype=no" \
|
||||
"--with-glib=no" \
|
||||
"--with-gobject=no" \
|
||||
"--with-cairo=no" \
|
||||
"--with-icu=no" \
|
||||
"--with-graphite2=no" \
|
||||
"--with-uniscribe=no" \
|
||||
"--with-directwrite=no" \
|
||||
"--with-coretext=no"; do
|
||||
if ! grep -Fq -- "$harfbuzz_flag" "$HARFBUZZ_BUILD_SCRIPT"; then
|
||||
sed -i \
|
||||
"/--enable-static/a\\ $harfbuzz_flag" \
|
||||
"$HARFBUZZ_BUILD_SCRIPT"
|
||||
fi
|
||||
if [[ $(grep -Fc -- "$harfbuzz_flag" "$HARFBUZZ_BUILD_SCRIPT") -ne 1 ]]; then
|
||||
echo "Could not select isolated HarfBuzz dependency flags exactly once." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p -- "$INSTALL_DIR" "$OUTPUT_DIR"
|
||||
|
||||
export INSTALL_DIR
|
||||
export EM_PKG_CONFIG_PATH="$EMSDK/upstream/emscripten/system/lib/pkgconfig:$INSTALL_DIR/lib/pkgconfig"
|
||||
export EM_TOOLCHAIN_FILE="$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake"
|
||||
export PKG_CONFIG_PATH="$INSTALL_DIR/lib/pkgconfig:$EM_PKG_CONFIG_PATH"
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
export TZ=UTC
|
||||
export SOURCE_DATE_EPOCH
|
||||
export ZERO_AR_DATE=1
|
||||
|
||||
MODE_FLAGS="-O3 -msimd128"
|
||||
if [[ "$MODE" == "st" ]]; then
|
||||
export FFMPEG_ST=yes
|
||||
unset FFMPEG_MT || true
|
||||
else
|
||||
MODE_FLAGS="$MODE_FLAGS -sUSE_PTHREADS -pthread"
|
||||
# HarfBuzz's pinned upstream build script reads this value directly under
|
||||
# `set -u`, while the other scripts use its presence to disable threading.
|
||||
# An exported empty value keeps both behaviours correct for the MT build.
|
||||
export FFMPEG_ST=
|
||||
export FFMPEG_MT=yes
|
||||
fi
|
||||
export CFLAGS="-I$INSTALL_DIR/include $MODE_FLAGS"
|
||||
export CXXFLAGS="$CFLAGS"
|
||||
export LDFLAGS="-L$INSTALL_DIR/lib $CFLAGS"
|
||||
|
||||
FFMPEG_WASM="$SOURCE_ROOT/ffmpeg.wasm"
|
||||
|
||||
build_component() {
|
||||
local stamp=$1
|
||||
local directory=$2
|
||||
local script=$3
|
||||
if [[ -f "$STAMP_DIR/$stamp" ]]; then
|
||||
echo "[reviewed-core:$MODE] reuse $stamp"
|
||||
return
|
||||
fi
|
||||
echo "[reviewed-core:$MODE] build $stamp"
|
||||
(
|
||||
cd -- "$SOURCE_ROOT/$directory"
|
||||
if [[ "$stamp" == "libvpx" ]]; then
|
||||
# libvpx embeds its configure command in the linked library. A relative
|
||||
# prefix keeps that shipped identity independent of the checkout path
|
||||
# while still installing into this mode's private prefix.
|
||||
INSTALL_DIR=../../../../install \
|
||||
CFLAGS="-I../../../../install/include $MODE_FLAGS" \
|
||||
CXXFLAGS="-I../../../../install/include $MODE_FLAGS" \
|
||||
LDFLAGS="-L../../../../install/lib -I../../../../install/include $MODE_FLAGS" \
|
||||
bash "$FFMPEG_WASM/build/$script"
|
||||
else
|
||||
bash "$FFMPEG_WASM/build/$script"
|
||||
fi
|
||||
)
|
||||
touch "$STAMP_DIR/$stamp"
|
||||
}
|
||||
|
||||
if [[ ! -f "$STAMP_DIR/sdl2" ]]; then
|
||||
if [[ "$MODE" == "st" ]]; then
|
||||
embuilder build sdl2
|
||||
else
|
||||
embuilder build sdl2-mt
|
||||
fi
|
||||
touch "$STAMP_DIR/sdl2"
|
||||
fi
|
||||
|
||||
build_component x264 x264 x264.sh
|
||||
build_component x265 x265 x265.sh
|
||||
build_component libvpx libvpx libvpx.sh
|
||||
build_component lame lame lame.sh
|
||||
build_component ogg ogg ogg.sh
|
||||
build_component theora theora theora.sh
|
||||
build_component opus opus opus.sh
|
||||
build_component vorbis vorbis vorbis.sh
|
||||
build_component zlib zlib zlib.sh
|
||||
build_component libwebp libwebp libwebp.sh
|
||||
build_component freetype2 freetype2 freetype2.sh
|
||||
build_component fribidi fribidi fribidi.sh
|
||||
build_component harfbuzz harfbuzz harfbuzz.sh
|
||||
build_component libass libass libass.sh
|
||||
build_component zimg zimg zimg.sh
|
||||
|
||||
# Configuration probes from Autotools projects must never smuggle host-native
|
||||
# include/library paths into the reviewed WebAssembly link.
|
||||
if grep -R -E -n -- \
|
||||
'/usr/(lib|include)|/lib/(x86_64|i[3-6]86)-linux-gnu' \
|
||||
"$INSTALL_DIR/lib/pkgconfig" "$INSTALL_DIR/lib"/*.la; then
|
||||
echo "A staged dependency recorded a host-native build path." >&2
|
||||
exit 1
|
||||
fi
|
||||
if emnm "$INSTALL_DIR/lib/libfreetype.a" |
|
||||
grep -F -- " U png_" >/dev/null; then
|
||||
echo "FreeType unexpectedly retained an unbundled libpng dependency." >&2
|
||||
exit 1
|
||||
fi
|
||||
if emnm "$INSTALL_DIR/lib/libfreetype.a" |
|
||||
grep -E -- ' U (inflate|BZ2_|Brotli)' >/dev/null; then
|
||||
echo "FreeType unexpectedly retained a cross-stage compression dependency." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FFMPEG_SOURCE="$SOURCE_ROOT/FFmpeg"
|
||||
# FFmpeg records --extra-cflags in `ffmpeg -buildconf`. Use a path relative to
|
||||
# its source root so the shipped runtime identity is independent of the
|
||||
# maintainer's checkout location. Dependency builds above retain their
|
||||
# absolute private staging prefix; only the final FFmpeg configuration is
|
||||
# embedded in the distributed core.
|
||||
FFMPEG_INSTALL_RELATIVE=../../../../install
|
||||
export CFLAGS="-I$FFMPEG_INSTALL_RELATIVE/include $MODE_FLAGS"
|
||||
export CXXFLAGS="$CFLAGS"
|
||||
export LDFLAGS="-L$INSTALL_DIR/lib $CFLAGS"
|
||||
if [[ ! -f "$STAMP_DIR/ffmpeg" ]]; then
|
||||
echo "[reviewed-core:$MODE] build FFmpeg"
|
||||
(
|
||||
cd -- "$FFMPEG_SOURCE"
|
||||
bash "$FFMPEG_WASM/build/ffmpeg.sh" \
|
||||
--enable-gpl \
|
||||
--enable-libx264 \
|
||||
--enable-libx265 \
|
||||
--enable-libvpx \
|
||||
--enable-libmp3lame \
|
||||
--enable-libtheora \
|
||||
--enable-libvorbis \
|
||||
--enable-libopus \
|
||||
--enable-zlib \
|
||||
--enable-libwebp \
|
||||
--enable-libfreetype \
|
||||
--enable-libfribidi \
|
||||
--enable-libass \
|
||||
--enable-libzimg
|
||||
)
|
||||
touch "$STAMP_DIR/ffmpeg"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$STAMP_DIR/core" ]]; then
|
||||
echo "[reviewed-core:$MODE] link ffmpeg-core with 5 MiB stack"
|
||||
mkdir -p -- "$FFMPEG_SOURCE/src"
|
||||
cp -a -- "$FFMPEG_WASM/src/bind" "$FFMPEG_SOURCE/src/"
|
||||
cp -a -- "$FFMPEG_WASM/src/fftools" "$FFMPEG_SOURCE/src/"
|
||||
cp -- "$FFMPEG_WASM/build/ffmpeg-wasm.sh" "$FFMPEG_SOURCE/build-reviewed-core.sh"
|
||||
(
|
||||
cd -- "$FFMPEG_SOURCE"
|
||||
bash ./build-reviewed-core.sh \
|
||||
-lx264 \
|
||||
-lx265 \
|
||||
-lvpx \
|
||||
-lmp3lame \
|
||||
-logg \
|
||||
-ltheora \
|
||||
-lvorbis \
|
||||
-lvorbisenc \
|
||||
-lvorbisfile \
|
||||
-lopus \
|
||||
-lz \
|
||||
-lwebpmux \
|
||||
-lwebp \
|
||||
-lsharpyuv \
|
||||
-lfreetype \
|
||||
-lfribidi \
|
||||
-lharfbuzz \
|
||||
-lass \
|
||||
-lzimg \
|
||||
-sEXPORT_ES6 \
|
||||
-o "$OUTPUT_DIR/ffmpeg-core.js"
|
||||
)
|
||||
touch "$STAMP_DIR/core"
|
||||
fi
|
||||
|
||||
expected_files=(ffmpeg-core.js ffmpeg-core.wasm)
|
||||
if [[ "$MODE" == "mt" ]]; then
|
||||
expected_files+=(ffmpeg-core.worker.js)
|
||||
fi
|
||||
for expected_file in "${expected_files[@]}"; do
|
||||
if [[ ! -s "$OUTPUT_DIR/$expected_file" || -L "$OUTPUT_DIR/$expected_file" ]]; then
|
||||
echo "Missing staged output: $OUTPUT_DIR/$expected_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -aFq -- "$REPOSITORY_ROOT" "$OUTPUT_DIR/$expected_file" ||
|
||||
grep -aFq -- "$EMSDK_ROOT" "$OUTPUT_DIR/$expected_file"; then
|
||||
echo "Staged output leaks a maintainer-local build path: $expected_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
(
|
||||
cd -- "$OUTPUT_DIR"
|
||||
sha256sum -- "${expected_files[@]}" >SHA256SUMS
|
||||
)
|
||||
|
||||
echo "[reviewed-core:$MODE] staged outputs"
|
||||
cat "$OUTPUT_DIR/SHA256SUMS"
|
||||
99
scripts/build-tools/install
Normal file
99
scripts/build-tools/install
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# A deliberately small `install(1)` compatibility wrapper for build trees on
|
||||
# filesystems that map every created file to a shared owner and reject chmod.
|
||||
# It is used only inside the private reviewed-core build. The final asset pack
|
||||
# is hash-verified, and executable permission bits are irrelevant to browser
|
||||
# JavaScript/WebAssembly files.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
directory_mode=false
|
||||
create_leading=false
|
||||
target_directory=
|
||||
operands=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-d)
|
||||
directory_mode=true
|
||||
shift
|
||||
;;
|
||||
-D)
|
||||
create_leading=true
|
||||
shift
|
||||
;;
|
||||
-c | -p | -s | -v)
|
||||
shift
|
||||
;;
|
||||
-m | -o | -g)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "install wrapper: $1 requires a value" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-m* | -o* | -g*)
|
||||
shift
|
||||
;;
|
||||
-t)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "install wrapper: -t requires a directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
target_directory=$2
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
operands+=("$@")
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "install wrapper: unsupported option $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
operands+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if $directory_mode; then
|
||||
if [[ ${#operands[@]} -eq 0 ]]; then
|
||||
echo "install wrapper: -d requires at least one directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p -- "${operands[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -n "$target_directory" ]]; then
|
||||
if [[ ${#operands[@]} -eq 0 ]]; then
|
||||
echo "install wrapper: -t requires at least one source" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p -- "$target_directory"
|
||||
cp -f -- "${operands[@]}" "$target_directory/"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#operands[@]} -lt 2 ]]; then
|
||||
echo "install wrapper: expected source and destination" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
destination=${operands[-1]}
|
||||
unset 'operands[-1]'
|
||||
if $create_leading; then
|
||||
mkdir -p -- "$(dirname -- "$destination")"
|
||||
fi
|
||||
|
||||
if [[ ${#operands[@]} -gt 1 || -d "$destination" ]]; then
|
||||
mkdir -p -- "$destination"
|
||||
cp -f -- "${operands[@]}" "$destination/"
|
||||
else
|
||||
cp -f -- "${operands[0]}" "$destination"
|
||||
fi
|
||||
@@ -1,138 +1,203 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
|
||||
const packages = [
|
||||
{
|
||||
packageName: '@ffmpeg/core',
|
||||
mode: 'st',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
|
||||
},
|
||||
{
|
||||
packageName: '@ffmpeg/core-mt',
|
||||
mode: 'mt',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
const lockPath = path.join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const maximumArchiveBytes = 64 * 1024 * 1024;
|
||||
const maximumAssetBytes = 64 * 1024 * 1024;
|
||||
const requiredAssetPaths = new Set([
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
]);
|
||||
|
||||
async function loadPackageVersion(packageName) {
|
||||
const packagePath = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
if (parsed.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
||||
);
|
||||
}
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
async function copyAsset(packageName, mode, fileName) {
|
||||
const source = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'dist',
|
||||
'esm',
|
||||
fileName
|
||||
);
|
||||
const destination = join(outputRoot, mode, fileName);
|
||||
const sourceBytes = await readFile(source).catch((error) => {
|
||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
||||
cause: error,
|
||||
});
|
||||
function assertSafeRelativePath(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function requireRealDirectory(directory, create = false) {
|
||||
let details = await lstat(directory).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
||||
throw new Error(
|
||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
||||
);
|
||||
if (!details && create) {
|
||||
await mkdir(directory);
|
||||
details = await lstat(directory);
|
||||
}
|
||||
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
dereference: true,
|
||||
errorOnExist: false,
|
||||
force: true,
|
||||
preserveTimestamps: false,
|
||||
});
|
||||
|
||||
const destinationBytes = await readFile(destination);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relative(outputRoot, destination).replaceAll('\\', '/'),
|
||||
bytes: destinationBytes.byteLength,
|
||||
sha256: sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
await rm(outputRoot, { force: true, recursive: true });
|
||||
|
||||
const assets = [];
|
||||
for (const entry of packages) {
|
||||
await loadPackageVersion(entry.packageName);
|
||||
for (const fileName of entry.files) {
|
||||
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
|
||||
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
|
||||
const versionManifest = {
|
||||
schemaVersion: 1,
|
||||
coreVersion: expectedVersion,
|
||||
packages: {
|
||||
'@ffmpeg/core': expectedVersion,
|
||||
'@ffmpeg/core-mt': expectedVersion,
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
const lockedAssetPaths = new Set(
|
||||
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' ||
|
||||
lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 ||
|
||||
typeof lock.opusRegression?.singleThread !== 'boolean' ||
|
||||
typeof lock.opusRegression?.multiThread !== 'boolean' ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5 ||
|
||||
lock.assets.some(
|
||||
(asset) =>
|
||||
!requiredAssetPaths.has(asset.path) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 1 ||
|
||||
asset.bytes > maximumAssetBytes ||
|
||||
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||
) ||
|
||||
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||
[...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath))
|
||||
) {
|
||||
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path);
|
||||
if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) {
|
||||
throw new Error('Reviewed-core artifact path and build identity disagree.');
|
||||
}
|
||||
const artifact = await readFile(
|
||||
path.join(repositoryRoot, ...artifactRelativePath.split('/'))
|
||||
);
|
||||
if (
|
||||
artifact.byteLength < 1 ||
|
||||
artifact.byteLength > maximumArchiveBytes ||
|
||||
artifact.byteLength !== lock.artifact.bytes ||
|
||||
sha256(artifact) !== lock.artifact.sha256
|
||||
) {
|
||||
throw new Error('Reviewed FFmpeg core archive failed locked verification.');
|
||||
}
|
||||
|
||||
const lockedArchiveEntries = new Map(
|
||||
lock.assets.map((asset) => [asset.path, asset])
|
||||
);
|
||||
const archiveEntries = new Set();
|
||||
const uncompressed = unzipSync(artifact, {
|
||||
filter: (entry) => {
|
||||
const relative = assertSafeRelativePath(entry.name);
|
||||
const locked = lockedArchiveEntries.get(relative);
|
||||
if (
|
||||
!locked ||
|
||||
archiveEntries.has(relative) ||
|
||||
entry.originalSize !== locked.bytes ||
|
||||
entry.originalSize < 1 ||
|
||||
entry.originalSize > maximumAssetBytes ||
|
||||
entry.size < 1 ||
|
||||
entry.size > maximumArchiveBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
|
||||
);
|
||||
}
|
||||
archiveEntries.add(relative);
|
||||
return true;
|
||||
},
|
||||
assets,
|
||||
};
|
||||
});
|
||||
if (archiveEntries.size !== lockedArchiveEntries.size) {
|
||||
throw new Error('Reviewed FFmpeg core archive is missing an expected entry.');
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg');
|
||||
const outputRoot = path.join(vendorRoot, lock.buildId);
|
||||
await requireRealDirectory(repositoryRoot);
|
||||
await requireRealDirectory(path.join(repositoryRoot, 'public'));
|
||||
await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true);
|
||||
const existingVendorRoot = await lstat(vendorRoot).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (
|
||||
existingVendorRoot &&
|
||||
(existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory())
|
||||
) {
|
||||
throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`);
|
||||
}
|
||||
await rm(vendorRoot, { force: true, recursive: true });
|
||||
|
||||
for (const asset of lock.assets) {
|
||||
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||
const bytes = uncompressed[relativeAssetPath];
|
||||
if (
|
||||
!bytes ||
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > maximumAssetBytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed FFmpeg core entry failed locked verification: ${asset.path}`
|
||||
);
|
||||
}
|
||||
const destination = path.join(outputRoot, ...relativeAssetPath.split('/'));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 });
|
||||
}
|
||||
|
||||
const versionManifest = {
|
||||
schemaVersion: 2,
|
||||
buildId: lock.buildId,
|
||||
profile: lock.profile,
|
||||
coreVersion: lock.coreVersion,
|
||||
ffmpegVersion: lock.ffmpegVersion,
|
||||
sourceArchive: lock.sourceArchive,
|
||||
sourceLock: lock.sourceLock,
|
||||
buildDriver: lock.buildDriver,
|
||||
toolchain: lock.toolchain,
|
||||
patch: lock.patch,
|
||||
opusRegression: lock.opusRegression,
|
||||
assets: lock.assets,
|
||||
};
|
||||
await writeFile(
|
||||
join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`
|
||||
path.join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`,
|
||||
{ flag: 'wx', mode: 0o644 }
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
|
||||
`Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}`
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# FFmpeg WebAssembly Corresponding Source
|
||||
|
||||
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt`
|
||||
0.12.10 object code distributed by av-tools 0.1.0.
|
||||
This archive accompanies the reviewed single-thread and multithread FFmpeg
|
||||
WebAssembly core profile distributed by av-tools. The profile retains the
|
||||
upstream core version 0.12.10 and FFmpeg n5.1.4 identities, but it is rebuilt
|
||||
from source rather than copied from the published `@ffmpeg/core` packages.
|
||||
|
||||
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
|
||||
every external library selected by the upstream v12.15 build. The source trees
|
||||
@@ -18,10 +20,15 @@ The original build-control files are in `sources/ffmpeg.wasm/`, including:
|
||||
- `build/ffmpeg-wasm.sh`;
|
||||
- the other dependency scripts under `build/`.
|
||||
|
||||
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools
|
||||
does not modify the five published JavaScript/WebAssembly core assets. Their
|
||||
exact sizes, hashes and embedded configure arguments are recorded in the
|
||||
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
||||
The build uses Emscripten SDK 3.1.40 and FFmpeg n5.1.4. Its one source patch
|
||||
applies upstream ffmpeg.wasm commit
|
||||
`b409e36475bc21f0451b5b1e1d126fa82871439a`, which raises the Emscripten stack
|
||||
to 5 MiB and fixes the libopus encode failure reported upstream. The tracked
|
||||
`scripts/build-reviewed-ffmpeg-core.sh` driver in the matching av-tools source
|
||||
release also isolates optional host dependencies and records the exact
|
||||
toolchain identities. The generated assets' exact sizes, hashes and embedded
|
||||
configure arguments are recorded in the application's `SOURCE.md`,
|
||||
`scripts/reviewed-ffmpeg-core-lock.json` and `docs/FFMPEG_BUILD.md`.
|
||||
|
||||
The source archive preserves each component's own licence and copyright
|
||||
notices. av-tools itself is distributed separately under
|
||||
@@ -37,14 +44,11 @@ test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
|
||||
```
|
||||
|
||||
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
|
||||
but still names remote tags and branches. It is not a turnkey offline or
|
||||
byte-for-byte reproducible build recipe. To reconstruct a pinned build,
|
||||
adapt each remote `ADD` to use the corresponding bundled source tree. Replace
|
||||
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/`
|
||||
tree, which already contains its pinned GoogleTest submodule at
|
||||
`test/extra/googletest/`. Keep the included build scripts and Emscripten
|
||||
version unchanged. The upstream targets are `make prd` for single-thread and
|
||||
`make prd-mt` for multithread.
|
||||
but still names remote tags and branches. For the distributed reviewed
|
||||
profile, use this archive together with the matching av-tools source release
|
||||
and invoke its `scripts/build-reviewed-ffmpeg-core.sh` driver. The bundled
|
||||
`sources/zimg/` tree already contains its pinned GoogleTest submodule at
|
||||
`test/extra/googletest/`.
|
||||
|
||||
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
|
||||
3.1.40 container image is not copied into this archive.
|
||||
|
||||
522
scripts/opus-regression.mjs
Normal file
522
scripts/opus-regression.mjs
Normal file
@@ -0,0 +1,522 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
||||
const REPOSITORY_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..');
|
||||
const FIXTURE_RELATIVE_PATH = 'tests/fixtures/generated/tone.wav';
|
||||
const FIXTURE_PATH = path.join(REPOSITORY_ROOT, FIXTURE_RELATIVE_PATH);
|
||||
const EXPECTED_FIXTURE_BYTES = 32_102;
|
||||
const EXPECTED_FIXTURE_SHA256 =
|
||||
'0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c';
|
||||
const DEFAULT_MODE_TIMEOUT_MILLISECONDS = 60_000;
|
||||
const RESULT_PREFIX = 'OPUS_RESULT ';
|
||||
const EVENT_PREFIX = 'OPUS_EVENT ';
|
||||
const MODES = Object.freeze(['single-thread', 'multithread']);
|
||||
|
||||
const argumentsSet = new Set(process.argv.slice(2));
|
||||
const option = (name) =>
|
||||
process.argv
|
||||
.slice(2)
|
||||
.find((argument) => argument.startsWith(`--${name}=`))
|
||||
?.slice(name.length + 3);
|
||||
|
||||
function boundedInteger(value, fallback, minimum, maximum) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function serializeError(error) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(error.stack ? { stack: error.stack.split('\n').slice(0, 8) } : {}),
|
||||
};
|
||||
}
|
||||
return { name: 'Error', message: String(error) };
|
||||
}
|
||||
|
||||
function emit(prefix, value) {
|
||||
process.stdout.write(`${prefix}${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function parsePrefixedJsonLines(text, prefix) {
|
||||
return text
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.startsWith(prefix))
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line.slice(prefix.length))];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appendBounded(current, chunk, maximumLength = 32_768) {
|
||||
return `${current}${chunk}`.slice(-maximumLength);
|
||||
}
|
||||
|
||||
function terminateProcessGroup(child) {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
} else {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function runIsolatedMode(mode, origin, timeoutMilliseconds) {
|
||||
const startedAt = performance.now();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[SCRIPT_PATH, '--child', `--mode=${mode}`, `--origin=${origin}`],
|
||||
{
|
||||
cwd: REPOSITORY_ROOT,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = appendBounded(stdout, chunk);
|
||||
for (const event of parsePrefixedJsonLines(chunk, EVENT_PREFIX)) {
|
||||
if (event.stage && event.stage !== 'core-log') {
|
||||
process.stdout.write(`[opus:${mode}] ${event.stage}\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = appendBounded(stderr, chunk);
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
terminateProcessGroup(child);
|
||||
}, timeoutMilliseconds);
|
||||
|
||||
const { code, signal } = await new Promise((resolve) => {
|
||||
child.once('close', (exitCode, exitSignal) => {
|
||||
resolve({ code: exitCode, signal: exitSignal });
|
||||
});
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
const elapsedMilliseconds = Math.round(performance.now() - startedAt);
|
||||
const events = parsePrefixedJsonLines(stdout, EVENT_PREFIX);
|
||||
const reported = parsePrefixedJsonLines(stdout, RESULT_PREFIX).at(-1);
|
||||
if (reported) {
|
||||
return {
|
||||
...reported,
|
||||
elapsedMilliseconds,
|
||||
process: { exitCode: code, signal },
|
||||
};
|
||||
}
|
||||
|
||||
const coreReady = events.find((event) => event.stage === 'core-ready');
|
||||
const lastStage = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.stage && event.stage !== 'core-log');
|
||||
const relevantLogs = events
|
||||
.filter((event) => event.stage === 'core-log')
|
||||
.slice(-16)
|
||||
.map((event) => event.message);
|
||||
return {
|
||||
requestedMode: mode,
|
||||
status: 'fail',
|
||||
failure: timedOut ? 'timeout' : 'child-exited-without-result',
|
||||
message: timedOut
|
||||
? `The isolated browser did not finish within ${String(timeoutMilliseconds)} ms.`
|
||||
: 'The isolated browser exited without a structured result.',
|
||||
...(coreReady?.actualMode ? { actualMode: coreReady.actualMode } : {}),
|
||||
...(coreReady?.ffmpegVersion
|
||||
? { ffmpegVersion: coreReady.ffmpegVersion }
|
||||
: {}),
|
||||
...(coreReady?.capabilities
|
||||
? { capabilities: coreReady.capabilities }
|
||||
: {}),
|
||||
...(lastStage?.stage ? { lastStage: lastStage.stage } : {}),
|
||||
...(relevantLogs.length > 0 ? { relevantLogs } : {}),
|
||||
...(stderr.trim() ? { stderr: stderr.trim().split('\n').slice(-12) } : {}),
|
||||
elapsedMilliseconds,
|
||||
process: { exitCode: code, signal },
|
||||
};
|
||||
}
|
||||
|
||||
async function fixtureIdentity() {
|
||||
const [bytes, fixtureStat] = await Promise.all([
|
||||
readFile(FIXTURE_PATH),
|
||||
stat(FIXTURE_PATH),
|
||||
]);
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
if (
|
||||
fixtureStat.size !== EXPECTED_FIXTURE_BYTES ||
|
||||
sha256 !== EXPECTED_FIXTURE_SHA256
|
||||
) {
|
||||
throw new Error(
|
||||
`Opus fixture identity mismatch: expected ${String(EXPECTED_FIXTURE_BYTES)} bytes/${EXPECTED_FIXTURE_SHA256}, received ${String(fixtureStat.size)} bytes/${sha256}.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: FIXTURE_RELATIVE_PATH,
|
||||
bytes: fixtureStat.size,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMain() {
|
||||
const timeoutMilliseconds = boundedInteger(
|
||||
option('timeout-ms') ?? process.env.AV_OPUS_REGRESSION_TIMEOUT_MS,
|
||||
DEFAULT_MODE_TIMEOUT_MILLISECONDS,
|
||||
10_000,
|
||||
10 * 60_000
|
||||
);
|
||||
const [fixture, packageText, coreLockText] = await Promise.all([
|
||||
fixtureIdentity(),
|
||||
readFile(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
||||
readFile(
|
||||
path.join(REPOSITORY_ROOT, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
|
||||
'utf8'
|
||||
),
|
||||
]);
|
||||
const packageManifest = JSON.parse(packageText);
|
||||
const coreLock = JSON.parse(coreLockText);
|
||||
const { createServer } = await import('vite');
|
||||
const server = await createServer({
|
||||
configFile: path.join(REPOSITORY_ROOT, 'vite.config.ts'),
|
||||
logLevel: 'warn',
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
strictPort: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await server.listen();
|
||||
const address = server.httpServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Vite did not expose a local TCP address.');
|
||||
}
|
||||
const origin = `http://127.0.0.1:${String(address.port)}`;
|
||||
const modes = [];
|
||||
for (const mode of MODES) {
|
||||
modes.push(await runIsolatedMode(mode, origin, timeoutMilliseconds));
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 2,
|
||||
recordedAt: new Date().toISOString(),
|
||||
browser: 'chromium',
|
||||
wrapperVersion: packageManifest.dependencies['@ffmpeg/ffmpeg'],
|
||||
coreVersion: coreLock.coreVersion,
|
||||
coreBuildId: coreLock.buildId,
|
||||
coreProfile: coreLock.profile,
|
||||
fixture,
|
||||
perModeTimeoutMilliseconds: timeoutMilliseconds,
|
||||
modes,
|
||||
safe: modes.every((result) => result.status === 'pass'),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (argumentsSet.has('--require-pass') && !report.safe) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runBrowserMode(mode, origin) {
|
||||
const { chromium } = await import('@playwright/test');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.exposeFunction('__reportOpusEvent', (event) => {
|
||||
emit(EVENT_PREFIX, event);
|
||||
});
|
||||
await page.goto(origin, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return await page.evaluate(
|
||||
async ({ fixtureUrl, requestedMode }) => {
|
||||
const report = async (event) => {
|
||||
await globalThis.__reportOpusEvent(event);
|
||||
};
|
||||
const replacePaths = (plan, context) => {
|
||||
const replacements = new Map();
|
||||
plan.inputs.forEach((input, index) => {
|
||||
const actual = context.inputPaths[index];
|
||||
if (actual) replacements.set(input.path, actual);
|
||||
});
|
||||
plan.outputs.forEach((output, index) => {
|
||||
const actual = context.outputPaths[index];
|
||||
if (actual) replacements.set(output.path, actual);
|
||||
});
|
||||
plan.temporaryFiles.forEach((temporary, index) => {
|
||||
const actual = context.temporaryPaths[index];
|
||||
if (actual) replacements.set(temporary.path, actual);
|
||||
});
|
||||
return plan.args.map((argument) =>
|
||||
[...replacements].reduce(
|
||||
(resolved, [virtualPath, actualPath]) =>
|
||||
resolved.replaceAll(virtualPath, actualPath),
|
||||
argument
|
||||
)
|
||||
);
|
||||
};
|
||||
const errorShape = (error) =>
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(error.stack
|
||||
? { stack: error.stack.split('\n').slice(0, 8) }
|
||||
: {}),
|
||||
}
|
||||
: { name: 'Error', message: String(error) };
|
||||
let manager;
|
||||
const relevantLogs = [];
|
||||
try {
|
||||
await report({ stage: 'module-import' });
|
||||
const [{ EngineManager }, { buildConvertPlan }, { AUDIO_PRESETS }] =
|
||||
await Promise.all([
|
||||
import('/src/ffmpeg/EngineManager.ts'),
|
||||
import('/src/commands/convert.ts'),
|
||||
import('/src/presets/audio-presets.ts'),
|
||||
]);
|
||||
manager = new EngineManager();
|
||||
manager.setPreference(
|
||||
requestedMode === 'single-thread'
|
||||
? 'force-single-thread'
|
||||
: 'prefer-multithread'
|
||||
);
|
||||
manager.onLog((event) => {
|
||||
for (const line of event.message.split(/\r?\n/u)) {
|
||||
if (
|
||||
relevantLogs.length < 32 &&
|
||||
/libopus|Output #|Stream mapping|Aborted\(\)|(?:^|\s)(?:size|progress)=|error|failed/iu.test(
|
||||
line
|
||||
)
|
||||
) {
|
||||
const message = line.slice(0, 600);
|
||||
relevantLogs.push(message);
|
||||
void report({ stage: 'core-log', message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await report({ stage: 'fixture-fetch' });
|
||||
const fixtureResponse = await fetch(fixtureUrl);
|
||||
if (!fixtureResponse.ok) {
|
||||
throw new Error(
|
||||
`Fixture request failed with HTTP ${String(fixtureResponse.status)}.`
|
||||
);
|
||||
}
|
||||
const fixtureBytes = new Uint8Array(
|
||||
await fixtureResponse.arrayBuffer()
|
||||
);
|
||||
const sourceFile = new File([fixtureBytes], 'tone.wav', {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
|
||||
await report({ stage: 'core-initializing' });
|
||||
await manager.initialize();
|
||||
const ready = manager.state;
|
||||
if (ready.status !== 'ready') {
|
||||
throw new Error('The FFmpeg engine did not reach ready state.');
|
||||
}
|
||||
const capabilities = {
|
||||
opusMuxer: ready.capabilities.muxers.has('opus'),
|
||||
libopusEncoder: ready.capabilities.encoders.has('libopus'),
|
||||
};
|
||||
await report({
|
||||
stage: 'core-ready',
|
||||
requestedMode,
|
||||
actualMode: ready.mode,
|
||||
ffmpegVersion: ready.capabilities.versionText,
|
||||
capabilities,
|
||||
});
|
||||
if (ready.mode !== requestedMode) {
|
||||
throw new Error(
|
||||
`Requested ${requestedMode}, but the engine selected ${ready.mode}.`
|
||||
);
|
||||
}
|
||||
if (!capabilities.opusMuxer || !capabilities.libopusEncoder) {
|
||||
throw new Error(
|
||||
'The pinned core does not advertise the Opus muxer and libopus encoder.'
|
||||
);
|
||||
}
|
||||
|
||||
const preset = AUDIO_PRESETS.find(
|
||||
(candidate) => candidate.id === 'audio-opus'
|
||||
);
|
||||
if (!preset) {
|
||||
throw new Error('The built-in audio-opus preset is missing.');
|
||||
}
|
||||
const plan = buildConvertPlan({
|
||||
jobId: `opus-regression-${requestedMode}`,
|
||||
source: {
|
||||
id: 'tone',
|
||||
sourceIndex: 0,
|
||||
fileName: 'tone.wav',
|
||||
extension: 'wav',
|
||||
},
|
||||
preset,
|
||||
streamSelection: { audio: [0] },
|
||||
expectedDurationSeconds: 1,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
await report({
|
||||
stage: 'encode-started',
|
||||
presetId: preset.id,
|
||||
requirements: plan.requiredCapabilities,
|
||||
});
|
||||
let commandArguments = [];
|
||||
const completed = await manager.runJob({
|
||||
id: `opus-regression-${requestedMode}`,
|
||||
operation: 'Opus regression',
|
||||
coreWorkload: plan.operation,
|
||||
inputs: [sourceFile],
|
||||
outputs: plan.outputs.map((output) => ({
|
||||
name: output.fileName,
|
||||
mimeType: output.mediaType,
|
||||
})),
|
||||
expectedDurationSeconds: plan.expectedDurationSeconds,
|
||||
timeoutMilliseconds: plan.timeoutMs,
|
||||
buildArguments: (context) => {
|
||||
commandArguments = replacePaths(plan, context);
|
||||
void report({
|
||||
stage: 'encode-command',
|
||||
arguments: commandArguments,
|
||||
});
|
||||
return commandArguments;
|
||||
},
|
||||
});
|
||||
const output = completed.outputs[0];
|
||||
if (!output || output.bytes.byteLength === 0) {
|
||||
throw new Error(
|
||||
'The Opus encode did not produce non-empty output.'
|
||||
);
|
||||
}
|
||||
await report({
|
||||
stage: 'encode-finished',
|
||||
outputBytes: output.bytes.byteLength,
|
||||
});
|
||||
|
||||
const outputFile = new File([output.bytes], output.name, {
|
||||
type: output.mimeType,
|
||||
});
|
||||
await report({ stage: 'reprobe-started' });
|
||||
const probe = await manager.probe(outputFile, 15_000);
|
||||
const probeJson = probe.json;
|
||||
const streams = Array.isArray(probeJson?.streams)
|
||||
? probeJson.streams
|
||||
: [];
|
||||
const opusStream = streams.find(
|
||||
(stream) =>
|
||||
stream?.codec_type === 'audio' && stream?.codec_name === 'opus'
|
||||
);
|
||||
const durationSeconds = Number(
|
||||
probeJson?.format?.duration ?? opusStream?.duration
|
||||
);
|
||||
if (!opusStream || !(durationSeconds > 0)) {
|
||||
throw new Error(
|
||||
'The generated file did not re-probe as positive-duration Opus audio.'
|
||||
);
|
||||
}
|
||||
const digest = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256', output.bytes)
|
||||
);
|
||||
const outputSha256 = [...digest]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
await report({ stage: 'reprobe-finished', durationSeconds });
|
||||
return {
|
||||
requestedMode,
|
||||
actualMode: ready.mode,
|
||||
status: 'pass',
|
||||
ffmpegVersion: ready.capabilities.versionText,
|
||||
capabilities,
|
||||
presetId: preset.id,
|
||||
commandArguments,
|
||||
output: {
|
||||
name: output.name,
|
||||
mimeType: output.mimeType,
|
||||
bytes: output.bytes.byteLength,
|
||||
sha256: outputSha256,
|
||||
codecName: opusStream.codec_name,
|
||||
codecType: opusStream.codec_type,
|
||||
durationSeconds,
|
||||
},
|
||||
relevantLogs,
|
||||
};
|
||||
} catch (error) {
|
||||
const state = manager?.state;
|
||||
return {
|
||||
requestedMode,
|
||||
...(state?.mode ? { actualMode: state.mode } : {}),
|
||||
status: 'fail',
|
||||
failure: 'browser-operation-failed',
|
||||
error: errorShape(error),
|
||||
relevantLogs,
|
||||
};
|
||||
} finally {
|
||||
manager?.dispose();
|
||||
}
|
||||
},
|
||||
{
|
||||
fixtureUrl: `${origin}/${FIXTURE_RELATIVE_PATH}`,
|
||||
requestedMode: mode,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runChild() {
|
||||
const mode = option('mode');
|
||||
const origin = option('origin');
|
||||
if (!MODES.includes(mode) || !origin) {
|
||||
throw new Error('Child mode requires valid --mode and --origin options.');
|
||||
}
|
||||
emit(EVENT_PREFIX, { stage: 'browser-launching', requestedMode: mode });
|
||||
try {
|
||||
const result = await runBrowserMode(mode, origin);
|
||||
emit(RESULT_PREFIX, result);
|
||||
} catch (error) {
|
||||
emit(RESULT_PREFIX, {
|
||||
requestedMode: mode,
|
||||
status: 'fail',
|
||||
failure: 'browser-process-failed',
|
||||
error: serializeError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (argumentsSet.has('--child')) {
|
||||
await runChild();
|
||||
} else {
|
||||
await runMain();
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`Opus regression harness failed: ${JSON.stringify(serializeError(error))}\n`
|
||||
);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import { createReadStream } from 'node:fs';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
|
||||
const reviewedLockPath = join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
|
||||
const releaseDirectory = join(repositoryRoot, 'release');
|
||||
const force = process.argv.includes('--force');
|
||||
@@ -204,7 +209,19 @@ async function publishSourcePair({
|
||||
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
assertLock(lock);
|
||||
const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`;
|
||||
const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8'));
|
||||
if (
|
||||
reviewedLock?.schemaVersion !== 1 ||
|
||||
reviewedLock.coreVersion !== lock.coreVersion ||
|
||||
typeof reviewedLock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) ||
|
||||
!reviewedLock.buildId.startsWith(`${lock.coreVersion}-`)
|
||||
) {
|
||||
throw new Error(
|
||||
'The reviewed-core lock does not identify a build-qualified source bundle.'
|
||||
);
|
||||
}
|
||||
const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`;
|
||||
const archivePath = join(releaseDirectory, archiveName);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
await ensureReleaseDirectory();
|
||||
|
||||
@@ -52,8 +52,6 @@ const ffmpegLicenseCoverage = new Map([
|
||||
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'],
|
||||
['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'],
|
||||
]);
|
||||
|
||||
function compareText(left, right) {
|
||||
@@ -479,7 +477,8 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
}
|
||||
}
|
||||
|
||||
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
|
||||
const vendorBuildId = '0.12.10-reviewed-stack5m.1';
|
||||
const vendorManifestPath = `vendor/ffmpeg/${vendorBuildId}/version.json`;
|
||||
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
|
||||
if (!vendorManifestEntry) {
|
||||
throw new Error(
|
||||
@@ -488,10 +487,12 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
}
|
||||
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
|
||||
if (
|
||||
vendorManifest.schemaVersion !== 1 ||
|
||||
vendorManifest.schemaVersion !== 2 ||
|
||||
vendorManifest.buildId !== vendorBuildId ||
|
||||
vendorManifest.profile !== 'reviewed-stack5m' ||
|
||||
vendorManifest.coreVersion !== '0.12.10' ||
|
||||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
|
||||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
|
||||
vendorManifest.opusRegression?.singleThread !== true ||
|
||||
vendorManifest.opusRegression?.multiThread !== true ||
|
||||
!Array.isArray(vendorManifest.assets) ||
|
||||
vendorManifest.assets.length !== 5
|
||||
) {
|
||||
@@ -511,7 +512,7 @@ function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
);
|
||||
}
|
||||
const assetPath = safeArchivePath(
|
||||
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
|
||||
path.posix.join(`vendor/ffmpeg/${vendorBuildId}`, asset.path)
|
||||
);
|
||||
if (verifiedVendorPaths.has(assetPath)) {
|
||||
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
|
||||
|
||||
279
scripts/package-reviewed-ffmpeg-core.mjs
Normal file
279
scripts/package-reviewed-ffmpeg-core.mjs
Normal file
@@ -0,0 +1,279 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const lockPath = path.join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const maximumAssetBytes = 64 * 1024 * 1024;
|
||||
const fixedTimestamp = new Date(1980, 0, 1, 0, 0, 0, 0);
|
||||
const requiredAssetPaths = new Set([
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
]);
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function requireRealDirectory(directory, create = false) {
|
||||
let details = await lstat(directory).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!details && create) {
|
||||
await mkdir(directory);
|
||||
details = await lstat(directory);
|
||||
}
|
||||
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLock() {
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
const lockedAssetPaths = new Set(
|
||||
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!Number.isSafeInteger(lock.sourceArchive?.bytes) ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5 ||
|
||||
lock.assets.some(
|
||||
(asset) =>
|
||||
!requiredAssetPaths.has(asset.path) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 1 ||
|
||||
asset.bytes > maximumAssetBytes ||
|
||||
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||
) ||
|
||||
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||
[...requiredAssetPaths].some(
|
||||
(assetPath) => !lockedAssetPaths.has(assetPath)
|
||||
) ||
|
||||
typeof lock.artifact?.path !== 'string' ||
|
||||
!Number.isSafeInteger(lock.artifact?.bytes) ||
|
||||
lock.artifact.bytes < 1 ||
|
||||
lock.artifact.bytes > 64 * 1024 * 1024 ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '')
|
||||
) {
|
||||
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
async function readBuildAsset(asset) {
|
||||
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||
const [mode, fileName, ...extra] = relativeAssetPath.split('/');
|
||||
if (
|
||||
extra.length > 0 ||
|
||||
!['st', 'mt'].includes(mode) ||
|
||||
!['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'].includes(
|
||||
fileName
|
||||
)
|
||||
) {
|
||||
throw new Error(`Unexpected reviewed-core asset: ${relativeAssetPath}`);
|
||||
}
|
||||
const outputDirectory = path.join(
|
||||
repositoryRoot,
|
||||
'.core-build',
|
||||
'reviewed-stack5m',
|
||||
mode,
|
||||
'output'
|
||||
);
|
||||
const source = path.join(outputDirectory, fileName);
|
||||
const details = await lstat(source);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`Reviewed-core input must be a regular file: ${source}`);
|
||||
}
|
||||
const canonicalOutput = await realpath(outputDirectory);
|
||||
const canonicalSource = await realpath(source);
|
||||
if (!canonicalSource.startsWith(`${canonicalOutput}${path.sep}`)) {
|
||||
throw new Error(`Reviewed-core input resolves outside output: ${source}`);
|
||||
}
|
||||
const bytes = await readFile(canonicalSource);
|
||||
if (
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > maximumAssetBytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(`Reviewed-core build does not match lock: ${asset.path}`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function verifyArchiveEntries(archive, lock) {
|
||||
const lockedEntries = new Map(
|
||||
lock.assets.map((asset) => [asset.path, asset])
|
||||
);
|
||||
const seen = new Set();
|
||||
const uncompressed = unzipSync(archive, {
|
||||
filter: (entry) => {
|
||||
const relative = assertSafeRelativePath(entry.name);
|
||||
const locked = lockedEntries.get(relative);
|
||||
if (
|
||||
!locked ||
|
||||
seen.has(relative) ||
|
||||
entry.originalSize !== locked.bytes ||
|
||||
entry.originalSize < 1 ||
|
||||
entry.originalSize > maximumAssetBytes ||
|
||||
entry.size < 1 ||
|
||||
entry.size > 64 * 1024 * 1024
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed-core archive contains an unexpected entry: ${entry.name}`
|
||||
);
|
||||
}
|
||||
seen.add(relative);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (seen.size !== lockedEntries.size) {
|
||||
throw new Error('Reviewed-core archive is missing an expected entry.');
|
||||
}
|
||||
for (const asset of lock.assets) {
|
||||
const bytes = uncompressed[asset.path];
|
||||
if (
|
||||
!bytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed-core archive entry does not match lock: ${asset.path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lock = await readLock();
|
||||
const buildDriver = await readFile(
|
||||
path.join(repositoryRoot, ...lock.buildDriver.path.split('/'))
|
||||
);
|
||||
if (sha256(buildDriver) !== lock.buildDriver.sha256) {
|
||||
throw new Error(
|
||||
'Reviewed-core build driver does not match its locked identity.'
|
||||
);
|
||||
}
|
||||
const sourceLock = await readFile(
|
||||
path.join(repositoryRoot, ...lock.sourceLock.path.split('/'))
|
||||
);
|
||||
if (sha256(sourceLock) !== lock.sourceLock.sha256) {
|
||||
throw new Error('FFmpeg source lock does not match its locked identity.');
|
||||
}
|
||||
const sourceArchive = await readFile(
|
||||
path.join(repositoryRoot, ...lock.sourceArchive.path.split('/'))
|
||||
);
|
||||
if (
|
||||
sourceArchive.byteLength !== lock.sourceArchive.bytes ||
|
||||
sha256(sourceArchive) !== lock.sourceArchive.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
'Reviewed-core Corresponding Source archive does not match its locked identity.'
|
||||
);
|
||||
}
|
||||
const artifactRelativePath = assertSafeRelativePath(lock.artifact.path);
|
||||
const expectedArtifactRelativePath = `vendor/ffmpeg-core-${lock.buildId}.zip`;
|
||||
if (artifactRelativePath !== expectedArtifactRelativePath) {
|
||||
throw new Error(
|
||||
`Reviewed-core artifact path must be ${expectedArtifactRelativePath}.`
|
||||
);
|
||||
}
|
||||
const artifactPath = path.join(
|
||||
repositoryRoot,
|
||||
...artifactRelativePath.split('/')
|
||||
);
|
||||
const entries = {};
|
||||
for (const asset of [...lock.assets].sort((left, right) =>
|
||||
left.path.localeCompare(right.path, 'en')
|
||||
)) {
|
||||
entries[asset.path] = [
|
||||
await readBuildAsset(asset),
|
||||
{ level: 9, mtime: fixedTimestamp },
|
||||
];
|
||||
}
|
||||
const archive = Buffer.from(zipSync(entries, { level: 9 }));
|
||||
verifyArchiveEntries(archive, lock);
|
||||
const archiveDigest = sha256(archive);
|
||||
if (
|
||||
archive.byteLength !== lock.artifact.bytes ||
|
||||
archiveDigest !== lock.artifact.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Deterministic reviewed-core archive does not match its locked identity: received ${String(archive.byteLength)} bytes/${archiveDigest}.`
|
||||
);
|
||||
}
|
||||
|
||||
const artifactDirectory = path.dirname(artifactPath);
|
||||
await requireRealDirectory(repositoryRoot);
|
||||
await requireRealDirectory(artifactDirectory, true);
|
||||
const stagingRoot = await mkdtemp(
|
||||
path.join(artifactDirectory, '.reviewed-core-package-')
|
||||
);
|
||||
if (
|
||||
!stagingRoot.startsWith(
|
||||
`${path.join(artifactDirectory, '.reviewed-core-package-')}`
|
||||
)
|
||||
) {
|
||||
throw new Error(`Unexpected reviewed-core staging path: ${stagingRoot}`);
|
||||
}
|
||||
try {
|
||||
const stagedArtifact = path.join(stagingRoot, path.basename(artifactPath));
|
||||
await writeFile(stagedArtifact, archive, { flag: 'wx', mode: 0o644 });
|
||||
await rename(stagedArtifact, artifactPath);
|
||||
} finally {
|
||||
await rm(stagingRoot, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Packaged ${lock.assets.length} reviewed FFmpeg assets as ${artifactRelativePath} (${archive.byteLength} bytes, ${archiveDigest})`
|
||||
);
|
||||
@@ -16,6 +16,7 @@ const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1';
|
||||
|
||||
function consumeValue(argumentsList, index, option) {
|
||||
const value = argumentsList[index + 1];
|
||||
@@ -179,12 +180,21 @@ export function inspectPortalHeaders(configuration) {
|
||||
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
|
||||
!configuration.includes('vendor/ffmpeg/0.12.10')
|
||||
) {
|
||||
const immutableCoreRule = configuration
|
||||
.split(/\r?\n/u)
|
||||
.find(
|
||||
(line) =>
|
||||
/vendor\/ffmpeg\//iu.test(line) &&
|
||||
/ffmpeg-core/iu.test(line) &&
|
||||
/max-age=31536000/iu.test(line) &&
|
||||
/immutable/iu.test(line)
|
||||
);
|
||||
const supportsReviewedCore =
|
||||
immutableCoreRule !== undefined &&
|
||||
immutableCoreRule.replaceAll('\\', '').includes(reviewedCoreBuildId);
|
||||
if (!supportsReviewedCore) {
|
||||
warnings.push(
|
||||
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
|
||||
`The config does not give reviewed FFmpeg build ${reviewedCoreBuildId} an explicit immutable cache rule.`
|
||||
);
|
||||
}
|
||||
return { errors, warnings };
|
||||
@@ -229,11 +239,11 @@ async function verifyAssembly(output, appVersion, digest) {
|
||||
}
|
||||
for (const reference of [
|
||||
'./index.html',
|
||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
|
||||
]) {
|
||||
const relative = reference.replace(/^\.\//, '');
|
||||
await requireRegularFile(
|
||||
|
||||
73
scripts/reviewed-ffmpeg-core-lock.json
Normal file
73
scripts/reviewed-ffmpeg-core-lock.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"buildId": "0.12.10-reviewed-stack5m.1",
|
||||
"profile": "reviewed-stack5m",
|
||||
"coreVersion": "0.12.10",
|
||||
"ffmpegVersion": "n5.1.4",
|
||||
"sourceArchive": {
|
||||
"path": "release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz",
|
||||
"bytes": 43050052,
|
||||
"sha256": "2ab505f72be883d1257bc71cbb93880d1ade5bc029792621cb87b4ecc068399a"
|
||||
},
|
||||
"sourceLock": {
|
||||
"path": "scripts/ffmpeg-source-lock.json",
|
||||
"sha256": "f521ecab59942f899b117e4570bf3a71710fcab6281fe6d796c9bdd22a1fc986"
|
||||
},
|
||||
"buildDriver": {
|
||||
"path": "scripts/build-reviewed-ffmpeg-core.sh",
|
||||
"sha256": "d3551fe12e7883191c41b922850d9bcf7ea093e21ceaa3dec99570db7ea25e2e"
|
||||
},
|
||||
"toolchain": {
|
||||
"emscriptenVersion": "3.1.40",
|
||||
"emscriptenCommit": "5c27e79dd0a9c4e27ef2326841698cdd4f6b5784",
|
||||
"emccSha256": "b0c9551f9155fa9c028efa24591acdfd30f02f32fb247cf9266f5f0f2ede7589",
|
||||
"cmakeVersion": "3.27.9",
|
||||
"cmakeBinarySha256": "ade9326f83f4996b17e2cb1bbfe4848df30a237ef5769cd3c00239b9f0d3ec58"
|
||||
},
|
||||
"patch": {
|
||||
"upstreamCommit": "b409e36475bc21f0451b5b1e1d126fa82871439a",
|
||||
"stackSizeBytes": 5242880,
|
||||
"change": "build/ffmpeg-wasm.sh: add -sSTACK_SIZE=5MB"
|
||||
},
|
||||
"opusRegression": {
|
||||
"fixturePath": "tests/fixtures/generated/tone.wav",
|
||||
"fixtureBytes": 32102,
|
||||
"fixtureSha256": "0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c",
|
||||
"presetId": "audio-opus",
|
||||
"expectedCodec": "opus",
|
||||
"singleThread": true,
|
||||
"multiThread": true
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"path": "st/ffmpeg-core.js",
|
||||
"bytes": 111804,
|
||||
"sha256": "80c05d79d0e4e9434977b76cb851d10ddf2bfd378570178700d2c43b8afdcb24"
|
||||
},
|
||||
{
|
||||
"path": "st/ffmpeg-core.wasm",
|
||||
"bytes": 32232580,
|
||||
"sha256": "fe41ddc77220cef6c04f5d48eeffcaaaef179ad270642e098f9945013aa0c9dc"
|
||||
},
|
||||
{
|
||||
"path": "mt/ffmpeg-core.js",
|
||||
"bytes": 128953,
|
||||
"sha256": "4f2650099ab70cb2583951c0421147c62bea6c18dfbba5cfae4d7698b5d0ab62"
|
||||
},
|
||||
{
|
||||
"path": "mt/ffmpeg-core.wasm",
|
||||
"bytes": 32718455,
|
||||
"sha256": "abbead010cb0448b26f01a36a3c8c03a6b04aca07812ddd4719a7f4b6bf4b645"
|
||||
},
|
||||
{
|
||||
"path": "mt/ffmpeg-core.worker.js",
|
||||
"bytes": 2115,
|
||||
"sha256": "f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3"
|
||||
}
|
||||
],
|
||||
"artifact": {
|
||||
"path": "vendor/ffmpeg-core-0.12.10-reviewed-stack5m.1.zip",
|
||||
"bytes": 21338974,
|
||||
"sha256": "ebc068c6d096de55ae3335b04b7121130665f5b0ffa7a828067122733f55185f"
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,146 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const argumentsList = process.argv.slice(2);
|
||||
const runtimeOnly = argumentsList.includes('--runtime-only');
|
||||
if (
|
||||
argumentsList.some((argument) => argument !== '--runtime-only') ||
|
||||
(runtimeOnly && argumentsList.length !== 1)
|
||||
) {
|
||||
throw new Error(
|
||||
'Usage: node scripts/verify-ffmpeg-assets.mjs [--runtime-only]'
|
||||
);
|
||||
}
|
||||
const lock = JSON.parse(
|
||||
await readFile(
|
||||
path.join(repositoryRoot, 'scripts', 'reviewed-ffmpeg-core-lock.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!Number.isSafeInteger(lock.sourceArchive?.bytes) ||
|
||||
lock.sourceArchive.bytes < 1 ||
|
||||
lock.sourceArchive.bytes > 512 * 1024 * 1024 ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
lock.artifact?.path !== `vendor/ffmpeg-core-${lock.buildId}.zip` ||
|
||||
!Number.isSafeInteger(lock.artifact?.bytes) ||
|
||||
lock.artifact.bytes < 1 ||
|
||||
lock.artifact.bytes > 64 * 1024 * 1024 ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.artifact?.sha256 ?? '') ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5
|
||||
) {
|
||||
throw new Error('Invalid reviewed FFmpeg core lock.');
|
||||
}
|
||||
const outputRoot = path.join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
lock.buildId
|
||||
);
|
||||
const manifestPath = join(outputRoot, 'version.json');
|
||||
const manifestPath = path.join(outputRoot, 'version.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const safeAssetPath = (value) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe FFmpeg asset path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const verifyLockedFile = async (record, label) => {
|
||||
const relative = safeAssetPath(record.path);
|
||||
const absolute = path.join(repositoryRoot, ...relative.split('/'));
|
||||
const details = await lstat(absolute).catch((error) => {
|
||||
throw new Error(`${label} is missing: ${relative}`, { cause: error });
|
||||
});
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`${label} must be a regular file: ${relative}`);
|
||||
}
|
||||
const bytes = await readFile(absolute);
|
||||
if (
|
||||
(record.bytes !== undefined && bytes.byteLength !== record.bytes) ||
|
||||
sha256(bytes) !== record.sha256
|
||||
) {
|
||||
throw new Error(`${label} does not match its locked identity: ${relative}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.coreVersion !== expectedVersion ||
|
||||
manifest.schemaVersion !== 2 ||
|
||||
manifest.buildId !== lock.buildId ||
|
||||
manifest.profile !== lock.profile ||
|
||||
manifest.coreVersion !== lock.coreVersion ||
|
||||
manifest.ffmpegVersion !== lock.ffmpegVersion ||
|
||||
!isDeepStrictEqual(manifest.sourceArchive, lock.sourceArchive) ||
|
||||
!isDeepStrictEqual(manifest.sourceLock, lock.sourceLock) ||
|
||||
!isDeepStrictEqual(manifest.buildDriver, lock.buildDriver) ||
|
||||
!isDeepStrictEqual(manifest.toolchain, lock.toolchain) ||
|
||||
!isDeepStrictEqual(manifest.patch, lock.patch) ||
|
||||
!isDeepStrictEqual(manifest.opusRegression, lock.opusRegression) ||
|
||||
!Array.isArray(manifest.assets) ||
|
||||
manifest.assets.length !== 5
|
||||
manifest.assets.length !== lock.assets.length
|
||||
) {
|
||||
throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`);
|
||||
throw new Error(`Invalid reviewed FFmpeg asset manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const expectedPaths = new Set([
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
]);
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'st/ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'st/ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
]);
|
||||
if (!runtimeOnly) {
|
||||
await verifyLockedFile(lock.sourceArchive, 'FFmpeg Corresponding Source');
|
||||
}
|
||||
await verifyLockedFile(lock.sourceLock, 'FFmpeg source lock');
|
||||
await verifyLockedFile(lock.buildDriver, 'Reviewed-core build driver');
|
||||
await verifyLockedFile(lock.artifact, 'Reviewed-core deterministic artifact');
|
||||
|
||||
const lockedAssets = new Map(lock.assets.map((asset) => [asset.path, asset]));
|
||||
const seen = new Set();
|
||||
for (const asset of manifest.assets) {
|
||||
if (!expectedPaths.delete(asset.path)) {
|
||||
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`);
|
||||
}
|
||||
const bytes = await readFile(join(outputRoot, asset.path));
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
safeAssetPath(asset.path);
|
||||
const locked = lockedAssets.get(asset.path);
|
||||
if (
|
||||
asset.bytes !== bytes.byteLength ||
|
||||
asset.sha256 !== digest ||
|
||||
expectedHashes.get(asset.path) !== digest
|
||||
!locked ||
|
||||
seen.has(asset.path) ||
|
||||
asset.bytes !== locked.bytes ||
|
||||
asset.sha256 !== locked.sha256
|
||||
) {
|
||||
throw new Error(`Unexpected reviewed FFmpeg asset: ${asset.path}`);
|
||||
}
|
||||
seen.add(asset.path);
|
||||
const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/')));
|
||||
if (bytes.byteLength !== locked.bytes || sha256(bytes) !== locked.sha256) {
|
||||
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedPaths.size > 0) {
|
||||
throw new Error(
|
||||
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
|
||||
);
|
||||
if (seen.size !== lockedAssets.size) {
|
||||
throw new Error('One or more reviewed FFmpeg assets are missing.');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
|
||||
`Verified ${seen.size} FFmpeg ${lock.buildId} assets in ${path.relative(repositoryRoot, outputRoot)}${runtimeOnly ? ' (runtime inputs only)' : ' with Corresponding Source'}`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user