feat: publish av-tools 0.1.0

This commit is contained in:
2026-07-24 14:58:03 +02:00
commit fcc537b024
254 changed files with 63270 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
import { createHash } from 'node:crypto';
import { access, lstat, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
export function sha256(data) {
return createHash('sha256').update(data).digest('hex');
}
export async function sha256File(file) {
return sha256(await readFile(file));
}
async function requireRegularFile(file, label) {
const details = await lstat(file);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`${label} must be a regular file: ${file}`);
}
}
export function checksumLine(digest, archiveName) {
if (!/^[a-f0-9]{64}$/.test(digest)) {
throw new Error('SHA-256 digest must be 64 lowercase hexadecimal digits.');
}
if (
typeof archiveName !== 'string' ||
archiveName.length === 0 ||
archiveName.includes('/') ||
archiveName.includes('\\') ||
archiveName.includes('\n') ||
archiveName.includes('\r')
) {
throw new Error('Checksum archive name must be one safe basename.');
}
return `${digest} ${archiveName}\n`;
}
export function parseChecksumLine(contents) {
const match = /^([a-f0-9]{64}) ([^/\\\r\n]+)\n?$/.exec(contents);
if (!match) {
throw new Error(
'Checksum sidecar must contain `<lowercase-sha256> <archive-basename>`.'
);
}
return { digest: match[1], archiveName: match[2] };
}
export async function verifyChecksum(archivePath, sidecarPath) {
await requireRegularFile(archivePath, 'Release archive');
await requireRegularFile(sidecarPath, 'Checksum sidecar');
const expected = parseChecksumLine(await readFile(sidecarPath, 'utf8'));
if (expected.archiveName !== path.basename(archivePath)) {
throw new Error(
`Checksum names ${expected.archiveName}, not ${path.basename(archivePath)}.`
);
}
const actual = await sha256File(archivePath);
if (actual !== expected.digest) {
throw new Error(
`Checksum mismatch for ${path.basename(archivePath)}: expected ${expected.digest}, received ${actual}.`
);
}
return actual;
}
export async function writeChecksum(archivePath, options = {}) {
const sidecarPath = options.sidecarPath ?? `${archivePath}.sha256`;
await requireRegularFile(archivePath, 'Release archive');
if (options.force && (await exists(sidecarPath))) {
await requireRegularFile(sidecarPath, 'Existing checksum sidecar');
}
const digest = await sha256File(archivePath);
await writeFile(
sidecarPath,
checksumLine(digest, path.basename(archivePath)),
{
encoding: 'utf8',
flag: options.force ? 'w' : 'wx',
}
);
return { digest, sidecarPath };
}
async function exists(file) {
try {
await access(file);
return true;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function main() {
const packageJson = JSON.parse(
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
);
const defaultArchive = path.join(
repositoryRoot,
'release',
`av-tools-${packageJson.version}.zip`
);
const argumentsList = process.argv.slice(2);
const write = argumentsList.includes('--write');
const force = argumentsList.includes('--force');
const positional = argumentsList.filter(
(argument) => argument !== '--write' && argument !== '--force'
);
if (positional.length > 1) {
throw new Error(
'Usage: node scripts/checksum-release.mjs [archive.zip] [--write] [--force]'
);
}
const archivePath = path.resolve(
repositoryRoot,
positional[0] ?? defaultArchive
);
const sidecarPath = `${archivePath}.sha256`;
if (write) {
const result = await writeChecksum(archivePath, { force, sidecarPath });
console.log(
`Wrote ${path.relative(repositoryRoot, result.sidecarPath)} (${result.digest})`
);
return;
}
if (!(await exists(sidecarPath))) {
throw new Error(
`${path.relative(repositoryRoot, sidecarPath)} is missing; pass --write to create it.`
);
}
const digest = await verifyChecksum(archivePath, sidecarPath);
console.log(
`Verified ${path.relative(repositoryRoot, archivePath)} (${digest})`
);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}

View File

@@ -0,0 +1,138 @@
import { createHash } from 'node:crypto';
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersion = '0.12.10';
const outputRoot = join(
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
);
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',
],
]);
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}`
);
}
}
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,
});
});
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}`
);
}
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));
}
}
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
const versionManifest = {
schemaVersion: 1,
coreVersion: expectedVersion,
packages: {
'@ffmpeg/core': expectedVersion,
'@ffmpeg/core-mt': expectedVersion,
},
assets,
};
await writeFile(
join(outputRoot, 'version.json'),
`${JSON.stringify(versionManifest, null, 2)}\n`
);
console.log(
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
);

View File

@@ -0,0 +1,50 @@
# 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.
`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
are stored under `sources/`. In particular, the upstream branch names
`4-cores` and `master` have been resolved to the commits from which the
published core was built; neither branch changed between its recorded commit
and the upstream core release.
The original build-control files are in `sources/ffmpeg.wasm/`, including:
- `Dockerfile`;
- `Makefile`;
- `build/ffmpeg.sh`;
- `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 source archive preserves each component's own licence and copyright
notices. av-tools itself is distributed separately under
GPL-3.0-or-later. Codec patent permissions, where required, are separate from
copyright licences.
To fetch and verify an identity:
```sh
git init source-check
git -C source-check fetch --depth 1 <repository> <revision>
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.
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
3.1.40 container image is not copied into this archive.

View File

@@ -0,0 +1,116 @@
{
"schemaVersion": 1,
"coreVersion": "0.12.10",
"wrapperRelease": "v12.15",
"sourceDateEpoch": 1736208000,
"sources": [
{
"name": "ffmpeg.wasm",
"directory": "ffmpeg.wasm",
"repository": "https://github.com/ffmpegwasm/ffmpeg.wasm.git",
"revision": "71aa99d37c02a7b4c435275ca9ef50e612f6efa1"
},
{
"name": "FFmpeg",
"directory": "FFmpeg",
"repository": "https://github.com/FFmpeg/FFmpeg.git",
"revision": "4729204c17f756e186d622060088371d10b34f7e"
},
{
"name": "x264",
"directory": "x264",
"repository": "https://github.com/ffmpegwasm/x264.git",
"revision": "33cac6b77d5b9259c552156013a817ab23119612"
},
{
"name": "x265",
"directory": "x265",
"repository": "https://github.com/ffmpegwasm/x265.git",
"revision": "2bb5520e9596f361bf0ed81b3b8da0d7fd999069"
},
{
"name": "libvpx",
"directory": "libvpx",
"repository": "https://github.com/ffmpegwasm/libvpx.git",
"revision": "10b9492dcf05b652e2e4b370e205bd605d421972"
},
{
"name": "LAME",
"directory": "lame",
"repository": "https://github.com/ffmpegwasm/lame.git",
"revision": "2badea1974ae36cb8312afe99cff1e6b3b5decee"
},
{
"name": "Ogg",
"directory": "ogg",
"repository": "https://github.com/ffmpegwasm/Ogg.git",
"revision": "bada45718453ac27b56773ae663f7e65112f6a6e"
},
{
"name": "Theora",
"directory": "theora",
"repository": "https://github.com/ffmpegwasm/theora.git",
"revision": "7ffd8b2ecfc2d93ae5e16028e7528e609266bfbf"
},
{
"name": "Opus",
"directory": "opus",
"repository": "https://github.com/ffmpegwasm/opus.git",
"revision": "e85ed7726db5d677c9c0677298ea0cb9c65bdd23"
},
{
"name": "Vorbis",
"directory": "vorbis",
"repository": "https://github.com/ffmpegwasm/vorbis.git",
"revision": "7798164043197d7e33f02de4353ce2aa5b248225"
},
{
"name": "zlib",
"directory": "zlib",
"repository": "https://github.com/ffmpegwasm/zlib.git",
"revision": "cacf7f1d4e3d44d871b605da3b647f07d718623f"
},
{
"name": "libwebp",
"directory": "libwebp",
"repository": "https://github.com/ffmpegwasm/libwebp.git",
"revision": "ca332209cb5567c9b249c86788cb2dbf8847e760"
},
{
"name": "FreeType",
"directory": "freetype2",
"repository": "https://github.com/ffmpegwasm/freetype2.git",
"revision": "6a2b3e4007e794bfc6c91030d0ed987f925164a8"
},
{
"name": "FriBidi",
"directory": "fribidi",
"repository": "https://github.com/fribidi/fribidi.git",
"revision": "f9e8e71a6fbf4a4619481284c9f484d10e559995"
},
{
"name": "HarfBuzz",
"directory": "harfbuzz",
"repository": "https://github.com/harfbuzz/harfbuzz.git",
"revision": "4a1d891c6317d2c83e5f3c2607ec5f5ccedffcde"
},
{
"name": "libass",
"directory": "libass",
"repository": "https://github.com/libass/libass.git",
"revision": "d149636f502f5774ae1a8fb4c554b122674393b2"
},
{
"name": "zimg",
"directory": "zimg",
"repository": "https://github.com/sekrit-twc/zimg.git",
"revision": "e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87"
},
{
"name": "GoogleTest (zimg build submodule)",
"directory": "zimg/test/extra/googletest",
"repository": "https://github.com/google/googletest.git",
"revision": "703bd9caab50b139428cea1aaff9974ebee5742e"
}
]
}

View File

@@ -0,0 +1,315 @@
#!/bin/sh
set -eu
export LC_ALL=C
export TZ=UTC
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
project_root=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
output_dir="$project_root/tests/fixtures/generated"
force=false
if [ "${1:-}" = "--force" ]; then
force=true
shift
fi
if [ "$#" -ne 0 ]; then
echo "Usage: scripts/generate-fixtures.sh [--force]" >&2
exit 2
fi
for command_name in ffmpeg ffprobe head sha256sum mktemp; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Required development command is unavailable: $command_name" >&2
exit 1
fi
done
fixture_names="
pattern-av.mp4
compatible-a.mp4
compatible-b.mp4
different-dimensions.mp4
tone.wav
tone.mp3
tone.flac
pattern.webm
subtitle.srt
subtitle.vtt
subtitle.ass
metadata.mp4
chapters.mp4
attached-cover.mp3
truncated.mp4
SHA256SUMS
"
mkdir -p "$output_dir"
if [ -L "$output_dir" ] || [ ! -d "$output_dir" ]; then
echo "Fixture output must be a real directory: $output_dir" >&2
exit 1
fi
canonical_output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P)
if [ "$canonical_output_dir" != "$project_root/tests/fixtures/generated" ]; then
echo "Fixture output resolves outside the expected project path." >&2
exit 1
fi
for fixture_name in $fixture_names; do
fixture_path="$output_dir/$fixture_name"
if [ -e "$fixture_path" ] && [ "$force" != true ]; then
echo "Fixture already exists: $fixture_path" >&2
echo "Pass --force to replace only the known generated fixture set." >&2
exit 1
fi
done
if [ "$force" = true ]; then
for fixture_name in $fixture_names; do
rm -f -- "$output_dir/$fixture_name"
done
fi
temporary_dir=$(mktemp -d "/tmp/av-tools-fixtures.XXXXXX")
case "$temporary_dir" in
/tmp/av-tools-fixtures.*) ;;
*)
echo "Refusing unexpected temporary path: $temporary_dir" >&2
exit 1
;;
esac
trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM
run_ffmpeg() {
ffmpeg \
-hide_banner \
-loglevel error \
-y \
-fflags +bitexact \
-filter_threads 1 \
-filter_complex_threads 1 \
"$@"
}
run_ffmpeg \
-f lavfi -i "testsrc2=size=160x90:rate=12" \
-f lavfi -i "sine=frequency=440:sample_rate=16000" \
-t 2 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 12 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata title="Generated AV fixture" \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/pattern-av.mp4"
run_ffmpeg \
-f lavfi -i "color=c=red:size=128x72:rate=10" \
-f lavfi -i "sine=frequency=330:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/compatible-a.mp4"
run_ffmpeg \
-f lavfi -i "color=c=blue:size=128x72:rate=10" \
-f lavfi -i "sine=frequency=550:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/compatible-b.mp4"
run_ffmpeg \
-f lavfi -i "testsrc2=size=96x96:rate=10" \
-f lavfi -i "sine=frequency=660:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
-c:a aac -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata creation_time="2020-01-01T00:00:00Z" \
-movflags +faststart \
"$output_dir/different-dimensions.mp4"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-c:a pcm_s16le -flags:a +bitexact \
-metadata title="Generated tone" \
"$output_dir/tone.wav"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-c:a libmp3lame -b:a 32k -ar 16000 -ac 1 -flags:a +bitexact \
-id3v2_version 3 -write_xing 0 \
-metadata title="Generated tone" \
"$output_dir/tone.mp3"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-c:a flac -compression_level 5 -flags:a +bitexact \
-metadata title="Generated tone" \
"$output_dir/tone.flac"
run_ffmpeg \
-f lavfi -i "testsrc2=size=128x72:rate=10" \
-f lavfi -i "sine=frequency=440:sample_rate=16000" \
-t 1 -shortest \
-map 0:v:0 -map 1:a:0 \
-c:v libvpx -deadline good -cpu-used 8 -crf 50 -b:v 80k -threads 1 \
-c:a libvorbis -b:a 32k -ar 16000 -ac 1 \
-flags:v +bitexact -flags:a +bitexact \
-metadata title="Generated WebM fixture" \
-fflags +bitexact \
"$output_dir/pattern.webm"
printf '%s\n' \
'1' \
'00:00:00,100 --> 00:00:00,700' \
'Generated subtitle' \
>"$output_dir/subtitle.srt"
printf '%s\n' \
'WEBVTT' \
'' \
'00:00:00.100 --> 00:00:00.700' \
'Generated subtitle' \
>"$output_dir/subtitle.vtt"
printf '%s\n' \
'[Script Info]' \
'ScriptType: v4.00+' \
'PlayResX: 128' \
'PlayResY: 72' \
'' \
'[V4+ Styles]' \
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding' \
'Style: Default,sans-serif,12,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,8,8,8,1' \
'' \
'[Events]' \
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text' \
'Dialogue: 0,0:00:00.10,0:00:00.70,Default,,0,0,0,,Generated subtitle' \
>"$output_dir/subtitle.ass"
run_ffmpeg \
-i "$output_dir/compatible-a.mp4" \
-map 0 -c copy \
-metadata title="Metadata fixture" \
-metadata artist="add ideas test generator" \
-metadata comment="Local generated media" \
-metadata creation_time="2020-01-01T00:00:00Z" \
"$output_dir/metadata.mp4"
printf '%s\n' \
';FFMETADATA1' \
'title=Chapter fixture' \
'[CHAPTER]' \
'TIMEBASE=1/1000' \
'START=0' \
'END=500' \
'title=First' \
'[CHAPTER]' \
'TIMEBASE=1/1000' \
'START=500' \
'END=1000' \
'title=Second' \
>"$temporary_dir/chapters.ffmeta"
run_ffmpeg \
-i "$output_dir/compatible-b.mp4" \
-i "$temporary_dir/chapters.ffmeta" \
-map 0 -map_metadata 1 -map_chapters 1 -c copy \
-metadata creation_time="2020-01-01T00:00:00Z" \
"$output_dir/chapters.mp4"
run_ffmpeg \
-f lavfi -i "color=c=yellow:size=32x32:rate=1" \
-frames:v 1 -c:v png -threads 1 -flags:v +bitexact \
"$temporary_dir/cover.png"
run_ffmpeg \
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
-i "$temporary_dir/cover.png" \
-map 0:a:0 -map 1:v:0 \
-c:a libmp3lame -b:a 32k -ar 16000 -ac 1 \
-c:v png -threads 1 -disposition:v:0 attached_pic \
-id3v2_version 3 -write_xing 0 \
-metadata title="Attached cover fixture" \
-metadata:s:v:0 title="Generated cover" \
-metadata:s:v:0 comment="Cover (front)" \
"$output_dir/attached-cover.mp3"
head -c 128 "$output_dir/compatible-a.mp4" >"$output_dir/truncated.mp4"
for media_name in \
attached-cover.mp3 \
chapters.mp4 \
compatible-a.mp4 \
compatible-b.mp4 \
different-dimensions.mp4 \
metadata.mp4 \
pattern-av.mp4 \
pattern.webm \
tone.flac \
tone.mp3 \
tone.wav
do
ffprobe \
-v error \
-show_entries format=format_name,duration:stream=index,codec_name,codec_type \
-of json \
"$output_dir/$media_name" \
>/dev/null
done
truncated_streams=$(
ffprobe \
-v error \
-show_entries stream=index \
-of csv=p=0 \
"$output_dir/truncated.mp4"
)
if [ -n "$truncated_streams" ]; then
echo "Truncated fixture unexpectedly contains a usable stream." >&2
exit 1
fi
(
cd "$output_dir"
sha256sum \
attached-cover.mp3 \
chapters.mp4 \
compatible-a.mp4 \
compatible-b.mp4 \
different-dimensions.mp4 \
metadata.mp4 \
pattern-av.mp4 \
pattern.webm \
subtitle.ass \
subtitle.srt \
subtitle.vtt \
tone.flac \
tone.mp3 \
tone.wav \
truncated.mp4 \
>SHA256SUMS
)
for fixture_name in $fixture_names; do
chmod 0644 "$output_dir/$fixture_name"
done
echo "Generated fixtures in $output_dir"
ffmpeg -version | sed -n '1p'
(
cd "$output_dir"
sha256sum -c SHA256SUMS
)

View File

@@ -0,0 +1,46 @@
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const sourcePath = join(
repositoryRoot,
'src',
'toolbox',
'manifest.source.json'
);
const outputPath = join(repositoryRoot, 'public', 'toolbox-app.json');
const packagePath = join(repositoryRoot, 'package.json');
const checkOnly = process.argv.includes('--check');
const source = JSON.parse(await readFile(sourcePath, 'utf8'));
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
if (source.version !== packageJson.version) {
throw new Error(
`Manifest version ${source.version} differs from package version ${packageJson.version}`
);
}
if (
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
source.source?.license !== 'GPL-3.0-or-later'
) {
throw new Error(
'Manifest source must identify the canonical av-tools repository and GPL-3.0-or-later licence'
);
}
const serialized = `${JSON.stringify(source, null, 2)}\n`;
if (checkOnly) {
const current = await readFile(outputPath, 'utf8').catch(() => '');
if (current !== serialized) {
throw new Error(
`${relative(repositoryRoot, outputPath)} is stale; run npm run manifest:generate`
);
}
console.log('Toolbox manifest is synchronized with its canonical source');
} else {
await writeFile(outputPath, serialized);
console.log(`Generated ${relative(repositoryRoot, outputPath)}`);
}

View File

@@ -0,0 +1,362 @@
import { createHash } from 'node:crypto';
import {
chmod,
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { createReadStream } from 'node:fs';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
const releaseDirectory = join(repositoryRoot, 'release');
const force = process.argv.includes('--force');
for (const argument of process.argv.slice(2)) {
if (argument !== '--force') {
throw new Error(`Unknown argument: ${argument}`);
}
}
function assertLock(lock) {
if (
lock?.schemaVersion !== 1 ||
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(
lock.coreVersion
) ||
!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(lock.wrapperRelease) ||
!Number.isSafeInteger(lock.sourceDateEpoch) ||
lock.sourceDateEpoch < 315532800 ||
lock.sourceDateEpoch > 4102444799 ||
!Array.isArray(lock.sources) ||
lock.sources.length === 0
) {
throw new TypeError('Invalid FFmpeg source lock header.');
}
const directories = new Set();
for (const [index, source] of lock.sources.entries()) {
if (
typeof source?.name !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9 .()_+-]*$/u.test(source.name) ||
typeof source.directory !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/u.test(source.directory) ||
source.directory
.split('/')
.some((part) => part === '' || part === '.' || part === '..') ||
directories.has(source.directory) ||
typeof source.repository !== 'string' ||
!source.repository.startsWith('https://') ||
!/^[a-f0-9]{40}$/u.test(source.revision)
) {
throw new TypeError(`Invalid FFmpeg source lock entry ${index}.`);
}
directories.add(source.directory);
}
}
async function run(command, argumentsList, options = {}) {
await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, argumentsList, {
stdio: 'inherit',
...options,
});
child.once('error', rejectPromise);
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise();
} else {
rejectPromise(
new Error(
`${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}`
)
);
}
});
});
}
async function output(command, argumentsList, options = {}) {
return await new Promise((resolvePromise, rejectPromise) => {
let stdout = '';
let stderr = '';
const child = spawn(command, argumentsList, {
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.once('error', rejectPromise);
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise(stdout.trim());
} else {
rejectPromise(
new Error(
`${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}: ${stderr.trim()}`
)
);
}
});
});
}
async function sha256(file) {
const hash = createHash('sha256');
await new Promise((resolvePromise, rejectPromise) => {
const stream = createReadStream(file);
stream.on('data', (chunk) => hash.update(chunk));
stream.once('error', rejectPromise);
stream.once('end', resolvePromise);
});
return hash.digest('hex');
}
async function optionalDetails(path) {
try {
return await lstat(path);
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
}
async function ensureReleaseDirectory() {
let details = await optionalDetails(releaseDirectory);
if (!details) {
await mkdir(releaseDirectory, { recursive: true });
details = await lstat(releaseDirectory);
}
if (details.isSymbolicLink() || !details.isDirectory()) {
throw new Error(
`Release path must be a real directory: ${releaseDirectory}`
);
}
}
async function assertReplaceableOutput(path) {
const details = await optionalDetails(path);
if (!details) return false;
if (!force) {
throw new Error(
`${path} already exists; pass --force to replace this exact artifact.`
);
}
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`Refusing to replace non-file output ${path}.`);
}
return true;
}
async function publishSourcePair({
archiveExists,
archivePath,
checksumExists,
checksumPath,
stagedArchivePath,
stagedChecksumPath,
stagingRoot,
}) {
const previousArchive = join(stagingRoot, 'previous-archive');
const previousChecksum = join(stagingRoot, 'previous-checksum');
let archiveBackedUp = false;
let checksumBackedUp = false;
let archivePublished = false;
let checksumPublished = false;
try {
if (archiveExists) {
await rename(archivePath, previousArchive);
archiveBackedUp = true;
}
if (checksumExists) {
await rename(checksumPath, previousChecksum);
checksumBackedUp = true;
}
await rename(stagedArchivePath, archivePath);
archivePublished = true;
await rename(stagedChecksumPath, checksumPath);
checksumPublished = true;
} catch (error) {
if (checksumPublished) await rm(checksumPath, { force: true });
if (archivePublished) await rm(archivePath, { force: true });
if (checksumBackedUp) await rename(previousChecksum, checksumPath);
if (archiveBackedUp) await rename(previousArchive, archivePath);
throw error;
}
}
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
assertLock(lock);
const archiveName = `ffmpeg-core-${lock.coreVersion}-corresponding-source.tar.xz`;
const archivePath = join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
await ensureReleaseDirectory();
const archiveExists = await assertReplaceableOutput(archivePath);
const checksumExists = await assertReplaceableOutput(checksumPath);
const stagingRoot = await mkdtemp(
join(releaseDirectory, '.ffmpeg-source-package-')
);
const stagedArchivePath = join(stagingRoot, archiveName);
const stagedChecksumPath = `${stagedArchivePath}.sha256`;
let temporaryRoot;
const archiveRootName = archiveName.replace(/\.tar\.xz$/u, '');
try {
temporaryRoot = await mkdtemp(
join(tmpdir(), 'av-tools-corresponding-source-')
);
const archiveRoot = join(temporaryRoot, archiveRootName);
const sourcesRoot = join(archiveRoot, 'sources');
await mkdir(sourcesRoot, { recursive: true });
const archiveReadmePath = join(archiveRoot, 'README.md');
const archiveLockPath = join(archiveRoot, 'SOURCE_LOCK.json');
await copyFile(readmePath, archiveReadmePath);
await copyFile(lockPath, archiveLockPath);
await chmod(archiveReadmePath, 0o644);
await chmod(archiveLockPath, 0o644);
for (const [index, source] of lock.sources.entries()) {
const checkout = join(temporaryRoot, `checkout-${index}`);
const sourceTar = join(temporaryRoot, `source-${index}.tar`);
await mkdir(checkout);
await run('git', ['init', '--quiet'], { cwd: checkout });
await run('git', ['remote', 'add', 'origin', source.repository], {
cwd: checkout,
});
await run(
'git',
['fetch', '--quiet', '--depth', '1', 'origin', source.revision],
{ cwd: checkout }
);
const revision = await output('git', ['rev-parse', 'FETCH_HEAD'], {
cwd: checkout,
});
if (revision !== source.revision) {
throw new Error(
`${source.name} resolved to ${revision}, expected ${source.revision}.`
);
}
await run(
'git',
[
'archive',
'--format=tar',
`--prefix=sources/${source.directory}/`,
`--output=${sourceTar}`,
source.revision,
],
{ cwd: checkout }
);
await run('tar', [
'--same-permissions',
'-xf',
sourceTar,
'-C',
archiveRoot,
]);
process.stdout.write(`Archived ${source.name} at ${source.revision}\n`);
}
const timestamp = `@${lock.sourceDateEpoch}`;
await run('find', [
archiveRoot,
'-type',
'd',
'-exec',
'chmod',
'0755',
'{}',
'+',
]);
await run('find', [
archiveRoot,
'-type',
'f',
'-perm',
'/111',
'-exec',
'chmod',
'0755',
'{}',
'+',
]);
await run('find', [
archiveRoot,
'-type',
'f',
'!',
'-perm',
'/111',
'-exec',
'chmod',
'0644',
'{}',
'+',
]);
await run('find', [
archiveRoot,
'-exec',
'touch',
'-h',
'-d',
timestamp,
'{}',
'+',
]);
await run('tar', [
'--sort=name',
'--format=posix',
'--owner=0',
'--group=0',
'--numeric-owner',
`--mtime=${timestamp}`,
'--pax-option=delete=atime,delete=ctime',
'-C',
temporaryRoot,
'-cJf',
stagedArchivePath,
archiveRootName,
]);
const digest = await sha256(stagedArchivePath);
await writeFile(stagedChecksumPath, `${digest} ${basename(archivePath)}\n`, {
mode: 0o644,
});
await chmod(stagedArchivePath, 0o644);
await chmod(stagedChecksumPath, 0o644);
await publishSourcePair({
archiveExists,
archivePath,
checksumExists,
checksumPath,
stagedArchivePath,
stagedChecksumPath,
stagingRoot,
});
process.stdout.write(
`Corresponding Source: ${archivePath}\nSHA-256: ${digest}\n`
);
} finally {
if (temporaryRoot) {
await rm(temporaryRoot, { recursive: true, force: true });
}
await rm(stagingRoot, { recursive: true, force: true });
}

782
scripts/package-release.mjs Normal file
View File

@@ -0,0 +1,782 @@
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { zipSync } from 'fflate';
import { checksumLine, sha256 } from './checksum-release.mjs';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const maximumEntryBytes = 256 * 1024 * 1024;
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
const applicationLicenseSpdx = 'GPL-3.0-or-later';
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
const canonicalPackageRepository =
'git+https://git.add-ideas.de/zemion/av-tools.git';
const requiredNativeLegalFiles = [
'LICENSES/native/FFmpeg-LICENSE.md',
'LICENSES/native/FreeType-LICENSE.txt',
'LICENSES/native/FriBidi-COPYING.txt',
'LICENSES/native/HarfBuzz-COPYING.txt',
'LICENSES/native/LAME-COPYING.txt',
'LICENSES/native/LAME-LICENSE.txt',
'LICENSES/native/Ogg-COPYING.txt',
'LICENSES/native/Opus-COPYING.txt',
'LICENSES/native/Theora-COPYING.txt',
'LICENSES/native/Vorbis-COPYING.txt',
'LICENSES/native/libass-COPYING.txt',
'LICENSES/native/libvpx-LICENSE.txt',
'LICENSES/native/libvpx-PATENTS.txt',
'LICENSES/native/libwebp-COPYING.txt',
'LICENSES/native/libwebp-PATENTS.txt',
'LICENSES/native/x264-COPYING.txt',
'LICENSES/native/x265-COPYING.txt',
'LICENSES/native/zimg-COPYING.txt',
'LICENSES/native/zlib-README.txt',
];
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) {
return left < right ? -1 : left > right ? 1 : 0;
}
export function safeArchivePath(fileName) {
if (
typeof fileName !== 'string' ||
fileName.length === 0 ||
fileName.includes('\0') ||
fileName.includes('\\')
) {
throw new Error(`Invalid release path: ${String(fileName)}`);
}
if (fileName.startsWith('/') || /^[A-Za-z]:\//.test(fileName)) {
throw new Error(`Absolute release path is forbidden: ${fileName}`);
}
const components = fileName.split('/');
if (
components.some(
(component) => component === '' || component === '.' || component === '..'
)
) {
throw new Error(`Ambiguous or traversing release path: ${fileName}`);
}
const normalized = path.posix.normalize(fileName);
if (normalized !== fileName) {
throw new Error(`Non-canonical release path: ${fileName}`);
}
return normalized;
}
function relativeArchivePath(root, file) {
return safeArchivePath(
path.relative(root, file).split(path.sep).join(path.posix.sep)
);
}
function isInside(root, candidate) {
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
async function collectDirectoryRecursive(
canonicalRoot,
sourceRoot,
directory,
prefix,
entries
) {
const canonicalDirectory = await realpath(directory);
if (!isInside(canonicalRoot, canonicalDirectory)) {
throw new Error(`Directory resolves outside packaging root: ${directory}`);
}
const children = await readdir(directory, { withFileTypes: true });
children.sort((left, right) => compareText(left.name, right.name));
for (const child of children) {
const childPath = path.join(directory, child.name);
const details = await lstat(childPath);
if (details.isSymbolicLink()) {
throw new Error(`Refusing release symlink: ${childPath}`);
}
if (details.isDirectory()) {
await collectDirectoryRecursive(
canonicalRoot,
sourceRoot,
childPath,
prefix,
entries
);
continue;
}
if (!details.isFile()) {
throw new Error(`Refusing special release entry: ${childPath}`);
}
const canonicalFile = await realpath(childPath);
if (!isInside(canonicalRoot, canonicalFile)) {
throw new Error(`File resolves outside packaging root: ${childPath}`);
}
const relativeName = relativeArchivePath(sourceRoot, childPath);
const name = safeArchivePath(
prefix ? path.posix.join(prefix, relativeName) : relativeName
);
entries.push({ name, data: await readFile(childPath) });
}
}
export async function collectDirectoryEntries(directory, prefix = '') {
const details = await lstat(directory);
if (details.isSymbolicLink() || !details.isDirectory()) {
throw new Error(`Release input is not a real directory: ${directory}`);
}
const sourceRoot = path.resolve(directory);
const canonicalRoot = await realpath(directory);
const entries = [];
await collectDirectoryRecursive(
canonicalRoot,
sourceRoot,
sourceRoot,
prefix,
entries
);
return entries.sort((left, right) => compareText(left.name, right.name));
}
async function readRequired(file) {
try {
return await readFile(file);
} catch (error) {
throw new Error(`Required release file is missing: ${file}`, {
cause: error,
});
}
}
async function existingApplicationLicense(root) {
for (const fileName of ['LICENSE', 'LICENSE.txt', 'COPYING']) {
const candidate = path.join(root, fileName);
try {
const details = await lstat(candidate);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(
`Application licence path must be a regular file: ${candidate}`
);
}
return candidate;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
continue;
}
throw error;
}
}
return null;
}
export async function assertLicenseReady(root, allowDevelopmentSmoke = false) {
const applicationLicense = await existingApplicationLicense(root);
if (applicationLicense) {
const legalCode = await readFile(applicationLicense, 'utf8');
if (
!legalCode.includes('GNU GENERAL PUBLIC LICENSE') ||
!legalCode.includes('Version 3, 29 June 2007') ||
!legalCode.includes('END OF TERMS AND CONDITIONS')
) {
throw new Error(
`Application licence does not contain the complete GNU GPL version 3 legal code: ${applicationLicense}`
);
}
return applicationLicense;
}
if (allowDevelopmentSmoke) return null;
throw new Error(
[
'RELEASE BLOCKED: av-tools has no application licence.',
'The FFmpeg and dependency notices do not license the application itself.',
'For a local, non-publishable portal test only, pass',
'`--allow-unlicensed-development-smoke`.',
].join(' ')
);
}
function safePackageName(name) {
return name.replace(/^@/, '').replaceAll('/', '--');
}
async function findInstalledPackageDirectory(name, fromDirectory) {
let current = path.resolve(fromDirectory);
const packageSegments = name.split('/');
while (true) {
const candidate = path.join(current, 'node_modules', ...packageSegments);
try {
const details = await lstat(path.join(candidate, 'package.json'));
if (!details.isFile()) {
throw new Error(`Package manifest is not a file: ${candidate}`);
}
return await realpath(candidate);
} catch (error) {
if (!(error && typeof error === 'object' && error.code === 'ENOENT')) {
throw error;
}
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
async function collectRuntimePackages(root, packageJson) {
const queue = Object.keys(packageJson.dependencies ?? {})
.sort(compareText)
.map((name) => ({ name, fromDirectory: root }));
const visited = new Set();
const packages = [];
while (queue.length > 0) {
const dependency = queue.shift();
const directory = await findInstalledPackageDirectory(
dependency.name,
dependency.fromDirectory
);
if (!directory) {
throw new Error(
`Runtime dependency is not installed: ${dependency.name}`
);
}
const manifest = JSON.parse(
await readFile(path.join(directory, 'package.json'), 'utf8')
);
const identity = `${manifest.name}@${manifest.version}`;
if (visited.has(identity)) continue;
visited.add(identity);
packages.push({
directory,
name: manifest.name,
version: manifest.version,
});
const childNames = [
...new Set([
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.optionalDependencies ?? {}),
]),
].sort(compareText);
for (const name of childNames) {
const childDirectory = await findInstalledPackageDirectory(
name,
directory
);
if (childDirectory) {
queue.push({ name, fromDirectory: directory });
}
}
}
return packages.sort((left, right) =>
compareText(
`${left.name}@${left.version}`,
`${right.name}@${right.version}`
)
);
}
export async function collectRuntimeLicenseEntries(root, packageJson) {
const packages = await collectRuntimePackages(root, packageJson);
const entries = [];
for (const runtimePackage of packages) {
const coveredBy = ffmpegLicenseCoverage.get(runtimePackage.name);
if (coveredBy) {
await access(path.join(root, coveredBy));
continue;
}
const legalFiles = (
await readdir(runtimePackage.directory, {
withFileTypes: true,
})
)
.filter(
(entry) =>
entry.isFile() &&
/^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name)
)
.sort((left, right) => compareText(left.name, right.name));
if (legalFiles.length === 0) {
throw new Error(
`Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no packaged licence file.`
);
}
const packageDirectory = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`;
for (const legalFile of legalFiles) {
entries.push({
name: safeArchivePath(
`LICENSES/npm/${packageDirectory}/${legalFile.name}`
),
data: await readFile(
path.join(runtimePackage.directory, legalFile.name)
),
});
}
}
return entries;
}
function scanReleaseEntry(entry) {
if (entry.name.endsWith('.map')) {
throw new Error(`Source map is not approved for release: ${entry.name}`);
}
const lowerParts = entry.name.toLowerCase().split('/');
if (
lowerParts.some((part) =>
[
'.env',
'.npmrc',
'.git',
'id_rsa',
'id_ed25519',
'credentials',
].includes(part)
) ||
/\.(?:pem|key|p12|pfx)$/i.test(entry.name)
) {
throw new Error(`Credential-like release path is forbidden: ${entry.name}`);
}
// Exact FFmpeg vendor bytes are checked against pinned SHA-256 values. The
// generated Emscripten glue contains upstream build paths such as
// /home/web_user that are not paths from this repository or workstation.
if (entry.name.startsWith('vendor/ffmpeg/')) return;
if (!/\.(?:css|html|js|json|md|mjs|svg|txt)$/i.test(entry.name)) return;
const text = entry.data.toString('utf8');
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(text)) {
throw new Error(`Private-key material detected in ${entry.name}`);
}
if (/https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(text)) {
throw new Error(`Credential-bearing URL detected in ${entry.name}`);
}
if (
/(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/m.test(text)
) {
throw new Error(`Local absolute path detected in ${entry.name}`);
}
}
function assertUniqueSortedEntries(entries) {
entries.sort((left, right) => compareText(left.name, right.name));
let totalBytes = 0;
let previous;
for (const entry of entries) {
safeArchivePath(entry.name);
if (entry.name === previous) {
throw new Error(`Duplicate release path: ${entry.name}`);
}
previous = entry.name;
if (entry.data.byteLength > maximumEntryBytes) {
throw new Error(`Release entry exceeds 256 MiB: ${entry.name}`);
}
totalBytes += entry.data.byteLength;
if (totalBytes > maximumArchiveBytes) {
throw new Error('Release exceeds the 2 GiB uncompressed-size limit.');
}
scanReleaseEntry(entry);
}
}
function manifestPath(reference, field, directoryIndex = false) {
if (
typeof reference !== 'string' ||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) ||
reference.startsWith('/') ||
reference.startsWith('//') ||
reference.includes('?') ||
reference.includes('#')
) {
throw new Error(`${field} must be a plain relative packaged path.`);
}
let normalized = reference.replace(/^\.\//, '');
if (normalized === '' && directoryIndex) normalized = 'index.html';
else if (normalized.endsWith('/') && directoryIndex) {
normalized = `${normalized}index.html`;
}
return safeArchivePath(normalized);
}
function validateReleaseLayout(entries, packageJson, applicationLicense) {
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
const names = new Set(entries.map((entry) => entry.name));
for (const required of [
'index.html',
'toolbox-app.json',
'CHANGELOG.md',
'SOURCE.md',
'THIRD_PARTY_NOTICES.md',
...requiredNativeLegalFiles,
]) {
if (!names.has(required)) {
throw new Error(`Required release root file is missing: ${required}`);
}
}
for (const prefix of ['LICENSES/', 'assets/', 'vendor/']) {
if (![...names].some((name) => name.startsWith(prefix))) {
throw new Error(`Required release directory is empty: ${prefix}`);
}
}
const manifestEntry = entries.find(
(entry) => entry.name === 'toolbox-app.json'
);
const manifest = JSON.parse(manifestEntry.data.toString('utf8'));
if (
manifest.id !== 'de.add-ideas.av-tools' ||
manifest.version !== packageJson.version
) {
throw new Error(
'Packaged manifest id/version does not match av-tools package.json.'
);
}
if (
applicationLicense &&
(manifest.source?.repository !== canonicalRepository ||
manifest.source?.license !== applicationLicenseSpdx)
) {
throw new Error(
'A licensed release requires an accurate, non-placeholder manifest.source declaration.'
);
}
if (!applicationLicense && manifest.source !== undefined) {
throw new Error(
'An unlicensed development artifact must omit manifest.source.'
);
}
const references = [
[manifest.entry, 'manifest.entry', true],
[manifest.icon, 'manifest.icon', false],
...(manifest.assets ?? []).map((asset, index) => [
asset,
`manifest.assets[${index}]`,
false,
]),
];
for (const [reference, field, directoryIndex] of references) {
const requiredPath = manifestPath(reference, field, directoryIndex);
if (!names.has(requiredPath)) {
throw new Error(`${field} is missing from release: ${reference}`);
}
}
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
if (!vendorManifestEntry) {
throw new Error(
`Required FFmpeg verification file is missing: ${vendorManifestPath}`
);
}
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
if (
vendorManifest.schemaVersion !== 1 ||
vendorManifest.coreVersion !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
!Array.isArray(vendorManifest.assets) ||
vendorManifest.assets.length !== 5
) {
throw new Error(
'Packaged FFmpeg version.json has an unexpected shape or version.'
);
}
const verifiedVendorPaths = new Set();
for (const asset of vendorManifest.assets) {
if (
typeof asset.path !== 'string' ||
!Number.isSafeInteger(asset.bytes) ||
!/^[a-f0-9]{64}$/.test(asset.sha256)
) {
throw new Error(
'Packaged FFmpeg version.json contains invalid asset metadata.'
);
}
const assetPath = safeArchivePath(
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
);
if (verifiedVendorPaths.has(assetPath)) {
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
}
verifiedVendorPaths.add(assetPath);
const packagedAsset = entriesByName.get(assetPath);
if (
!packagedAsset ||
packagedAsset.data.byteLength !== asset.bytes ||
sha256(packagedAsset.data) !== asset.sha256
) {
throw new Error(
`Packaged FFmpeg asset failed byte verification: ${assetPath}`
);
}
}
const declaredVendorPaths = new Set(
(manifest.assets ?? []).map((reference) =>
manifestPath(reference, 'manifest.assets', false)
)
);
if (
verifiedVendorPaths.size !== declaredVendorPaths.size ||
[...verifiedVendorPaths].some(
(assetPath) => !declaredVendorPaths.has(assetPath)
)
) {
throw new Error(
'Manifest FFmpeg assets and the verified vendor asset set do not match.'
);
}
const indexHtml = entries
.find((entry) => entry.name === 'index.html')
.data.toString('utf8');
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
throw new Error('index.html contains a root-absolute asset reference.');
}
}
export function createDeterministicZip(inputEntries) {
const entries = inputEntries.map((entry) => ({
name: safeArchivePath(entry.name),
data: Buffer.from(entry.data),
}));
assertUniqueSortedEntries(entries);
const zippable = Object.create(null);
for (const entry of entries) {
zippable[entry.name] = [
new Uint8Array(entry.data),
{
attrs: (0o100644 << 16) >>> 0,
level: 6,
mtime: zipEpoch,
os: 3,
},
];
}
return Buffer.from(
zipSync(zippable, {
attrs: (0o100644 << 16) >>> 0,
level: 6,
mtime: zipEpoch,
os: 3,
})
);
}
export async function collectReleaseEntries(options = {}) {
const root = options.root ?? repositoryRoot;
const packageJson = JSON.parse(
await readFile(path.join(root, 'package.json'), 'utf8')
);
const applicationLicense = await assertLicenseReady(
root,
options.allowUnlicensedDevelopmentSmoke
);
if (
applicationLicense &&
(packageJson.license !== applicationLicenseSpdx ||
packageJson.repository?.url !== canonicalPackageRepository ||
packageJson.homepage !== canonicalRepository)
) {
throw new Error(
'Licensed release package metadata must identify GPL-3.0-or-later and the canonical av-tools repository.'
);
}
const entries = await collectDirectoryEntries(path.join(root, 'dist'));
for (const fileName of [
'README.md',
'CHANGELOG.md',
'SOURCE.md',
'THIRD_PARTY_NOTICES.md',
]) {
entries.push({
name: fileName,
data: await readRequired(path.join(root, fileName)),
});
}
entries.push(
...(await collectDirectoryEntries(path.join(root, 'LICENSES'), 'LICENSES'))
);
entries.push(...(await collectRuntimeLicenseEntries(root, packageJson)));
if (applicationLicense) {
const applicationLicenseBytes = await readFile(applicationLicense);
entries.push({
name: path.basename(applicationLicense),
data: applicationLicenseBytes,
});
entries.push({
name: 'LICENSES/av-tools-LICENSE.txt',
data: applicationLicenseBytes,
});
} else {
entries.push({
name: 'DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt',
data: Buffer.from(
[
'UNLICENSED DEVELOPMENT SMOKE ARTIFACT',
'',
'av-tools has no application licence. This ZIP exists only to test',
'local Toolbox Portal assembly. Do not publish or distribute it.',
'',
].join('\n')
),
});
}
assertUniqueSortedEntries(entries);
validateReleaseLayout(entries, packageJson, applicationLicense);
return { entries, packageJson, unlicensed: !applicationLicense };
}
export function parseReleaseArguments(argumentsList) {
const allowed = new Set(['--force', '--allow-unlicensed-development-smoke']);
const unknown = argumentsList.filter((argument) => !allowed.has(argument));
if (unknown.length > 0) {
throw new Error(`Unknown release argument: ${unknown.join(', ')}`);
}
return {
force: argumentsList.includes('--force'),
allowUnlicensedDevelopmentSmoke: argumentsList.includes(
'--allow-unlicensed-development-smoke'
),
};
}
async function assertWritableTarget(target, force) {
try {
const details = await lstat(target);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`Release target is not a regular file: ${target}`);
}
if (!force) {
throw new Error(
`${path.relative(repositoryRoot, target)} exists; pass --force to replace this exact version.`
);
}
return true;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function publishReleasePair({
archive,
archivePath,
checksum,
checksumPath,
archiveExists,
checksumExists,
}) {
const releaseDirectory = path.dirname(archivePath);
const staging = await mkdtemp(
path.join(releaseDirectory, '.av-tools-release-')
);
const stagedArchive = path.join(staging, path.basename(archivePath));
const stagedChecksum = path.join(staging, path.basename(checksumPath));
const previousArchive = path.join(staging, 'previous-archive');
const previousChecksum = path.join(staging, 'previous-checksum');
let archiveBackedUp = false;
let checksumBackedUp = false;
let archivePublished = false;
let checksumPublished = false;
try {
await writeFile(stagedArchive, archive, { flag: 'wx' });
await writeFile(stagedChecksum, checksum, {
encoding: 'utf8',
flag: 'wx',
});
if (archiveExists) {
await rename(archivePath, previousArchive);
archiveBackedUp = true;
}
if (checksumExists) {
await rename(checksumPath, previousChecksum);
checksumBackedUp = true;
}
await rename(stagedArchive, archivePath);
archivePublished = true;
await rename(stagedChecksum, checksumPath);
checksumPublished = true;
} catch (error) {
if (checksumPublished) await rm(checksumPath, { force: true });
if (archivePublished) await rm(archivePath, { force: true });
if (checksumBackedUp) await rename(previousChecksum, checksumPath);
if (archiveBackedUp) await rename(previousArchive, archivePath);
throw error;
} finally {
await rm(staging, { force: true, recursive: true });
}
}
async function main() {
const options = parseReleaseArguments(process.argv.slice(2));
const { entries, packageJson, unlicensed } = await collectReleaseEntries({
allowUnlicensedDevelopmentSmoke: options.allowUnlicensedDevelopmentSmoke,
});
const releaseDirectory = path.join(repositoryRoot, 'release');
await mkdir(releaseDirectory, { recursive: true });
const releaseDirectoryDetails = await lstat(releaseDirectory);
if (
releaseDirectoryDetails.isSymbolicLink() ||
!releaseDirectoryDetails.isDirectory()
) {
throw new Error('release/ must be a real directory.');
}
const archiveName = `av-tools-${packageJson.version}.zip`;
const archivePath = path.join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const archiveExists = await assertWritableTarget(archivePath, options.force);
const checksumExists = await assertWritableTarget(
checksumPath,
options.force
);
const archive = createDeterministicZip(entries);
const digest = sha256(archive);
await publishReleasePair({
archive,
archiveExists,
archivePath,
checksum: checksumLine(digest, archiveName),
checksumExists,
checksumPath,
});
if (unlicensed) {
console.warn(
'WARNING: created an unlicensed development smoke artifact. Do not publish or distribute it.'
);
}
console.log(`Created release/${archiveName}`);
console.log(`Created release/${archiveName}.sha256`);
console.log(`SHA-256 ${digest}`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}

View File

@@ -0,0 +1,408 @@
import { spawn } from 'node:child_process';
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sha256File, verifyChecksum } from './checksum-release.mjs';
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
function consumeValue(argumentsList, index, option) {
const value = argumentsList[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${option} requires a path value.`);
}
return value;
}
export function parsePortalArguments(argumentsList, root = repositoryRoot) {
const packageDefaults = {
artifact: undefined,
portalRoot: path.join(path.dirname(root), 'references', 'toolbox-portal'),
allowUnlicensedDevelopmentSmoke: false,
allowKnownHeaderGap: false,
keepTemp: false,
};
for (let index = 0; index < argumentsList.length; index += 1) {
const argument = argumentsList[index];
if (argument === '--artifact') {
packageDefaults.artifact = path.resolve(
root,
consumeValue(argumentsList, index, argument)
);
index += 1;
} else if (argument === '--portal-root') {
packageDefaults.portalRoot = path.resolve(
root,
consumeValue(argumentsList, index, argument)
);
index += 1;
} else if (argument === '--allow-unlicensed-development-smoke') {
packageDefaults.allowUnlicensedDevelopmentSmoke = true;
} else if (argument === '--allow-known-header-gap') {
packageDefaults.allowKnownHeaderGap = true;
} else if (argument === '--keep-temp') {
packageDefaults.keepTemp = true;
} else {
throw new Error(`Unknown portal-smoke argument: ${argument}`);
}
}
return packageDefaults;
}
function run(command, argumentsList, options = {}) {
console.log(`\n> ${command} ${argumentsList.join(' ')}`);
return new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd: options.cwd,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
stdio: 'inherit',
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else {
reject(
new Error(
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`
)
);
}
});
});
}
async function capture(command, argumentsList, cwd) {
return await new Promise((resolve, reject) => {
const child = spawn(command, argumentsList, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = [];
const stderr = [];
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8').trim());
} else {
reject(
new Error(
`${command} exited ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`
)
);
}
});
});
}
async function requireRegularFile(file, label) {
const details = await lstat(file);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error(`${label} is not a regular file: ${file}`);
}
}
function includesDevelopmentMarker(archive) {
return archive.includes(
Buffer.from('DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt', 'utf8')
);
}
function cspDirectives(policy) {
return new Map(
policy
.split(';')
.map((directive) => directive.trim().split(/\s+/))
.filter((parts) => parts[0])
.map(([name, ...values]) => [name, new Set(values)])
);
}
export function inspectPortalHeaders(configuration) {
const errors = [];
const warnings = [];
const requiredHeaders = [
['Cross-Origin-Opener-Policy', 'same-origin'],
['Cross-Origin-Embedder-Policy', 'require-corp'],
['Cross-Origin-Resource-Policy', 'same-origin'],
];
for (const [header, value] of requiredHeaders) {
const expression = new RegExp(
`add_header\\s+${header}\\s+"?${value}"?\\s+always`,
'i'
);
if (!expression.test(configuration)) {
errors.push(`Missing ${header}: ${value}`);
}
}
const cspMatch =
/add_header\s+Content-Security-Policy\s+"([^"]+)"\s+always/i.exec(
configuration
);
if (!cspMatch) {
errors.push('Missing Content-Security-Policy header');
} else {
const directives = cspDirectives(cspMatch[1]);
const requires = (directive, value) =>
directives.get(directive)?.has(value) === true;
if (!requires('script-src', "'wasm-unsafe-eval'")) {
errors.push("CSP script-src lacks 'wasm-unsafe-eval'");
}
if (!requires('worker-src', "'self'") || !requires('worker-src', 'blob:')) {
errors.push("CSP worker-src must contain 'self' and blob:");
}
if (!requires('media-src', "'self'") || !requires('media-src', 'blob:')) {
errors.push("CSP media-src must contain 'self' and blob:");
}
}
if (!/application\/wasm\s+wasm(?:\s|;)/i.test(configuration)) {
warnings.push(
'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')
) {
warnings.push(
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
);
}
return { errors, warnings };
}
async function verifyAssembly(output, appVersion, digest) {
const catalogue = JSON.parse(
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
);
if (
catalogue.apps?.length !== 1 ||
catalogue.apps[0]?.manifest !== './apps/av/toolbox-app.json'
) {
throw new Error('Generated catalogue does not contain the locked av app.');
}
const release = JSON.parse(
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
);
const provenance = release.apps?.[0];
if (
release.apps?.length !== 1 ||
provenance.id !== 'de.add-ideas.av-tools' ||
provenance.version !== appVersion ||
provenance.sha256 !== digest ||
provenance.target !== 'av'
) {
throw new Error('Generated release provenance does not match the AV lock.');
}
const appRoot = path.join(output, 'apps', 'av');
const manifest = JSON.parse(
await readFile(path.join(appRoot, 'toolbox-app.json'), 'utf8')
);
if (
manifest.id !== 'de.add-ideas.av-tools' ||
manifest.version !== appVersion ||
manifest.requirements?.crossOriginIsolated !== false
) {
throw new Error(
'Assembled AV manifest identity/fallback contract changed.'
);
}
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',
]) {
const relative = reference.replace(/^\.\//, '');
await requireRegularFile(
path.join(appRoot, ...relative.split('/')),
`Assembled ${reference}`
);
}
const indexHtml = await readFile(path.join(appRoot, 'index.html'), 'utf8');
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
throw new Error('Assembled app contains a root-absolute entry asset URL.');
}
}
async function main() {
const options = parsePortalArguments(process.argv.slice(2));
const packageJson = JSON.parse(
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
);
const artifact =
options.artifact ??
path.join(repositoryRoot, 'release', `av-tools-${packageJson.version}.zip`);
const sidecar = `${artifact}.sha256`;
await requireRegularFile(artifact, 'AV release artifact');
await requireRegularFile(sidecar, 'AV release checksum');
const digest = await verifyChecksum(artifact, sidecar);
const artifactBytes = await readFile(artifact);
const markedUnlicensed = includesDevelopmentMarker(artifactBytes);
if (markedUnlicensed && !options.allowUnlicensedDevelopmentSmoke) {
throw new Error(
'The ZIP is marked unlicensed development-only. Pass `--allow-unlicensed-development-smoke` for local assembly testing.'
);
}
const portalRootDetails = await lstat(options.portalRoot);
if (portalRootDetails.isSymbolicLink() || !portalRootDetails.isDirectory()) {
throw new Error(
`Portal reference must be a real Git directory: ${options.portalRoot}`
);
}
const portalRevision = await capture(
'git',
['rev-parse', 'HEAD'],
options.portalRoot
);
const temporaryRoot = await mkdtemp('/tmp/av-tools-portal-smoke-');
if (!temporaryRoot.startsWith('/tmp/av-tools-portal-smoke-')) {
throw new Error(`Unexpected temporary path: ${temporaryRoot}`);
}
const portalClone = path.join(temporaryRoot, 'toolbox-portal');
let successful = false;
try {
await run(
'git',
[
'clone',
'--no-local',
'--quiet',
'--no-checkout',
options.portalRoot,
portalClone,
],
{ cwd: temporaryRoot }
);
await run('git', ['checkout', '--quiet', '--detach', portalRevision], {
cwd: portalClone,
});
const clonedRevision = await capture(
'git',
['rev-parse', 'HEAD'],
portalClone
);
if (clonedRevision !== portalRevision) {
throw new Error('Temporary Portal clone revision changed unexpectedly.');
}
const smokeArtifacts = path.join(portalClone, 'smoke-artifacts');
await mkdir(smokeArtifacts);
const copiedArtifact = path.join(smokeArtifacts, path.basename(artifact));
await copyFile(artifact, copiedArtifact);
await copyFile(sidecar, `${copiedArtifact}.sha256`);
const templatePath = path.join(
portalClone,
'release',
'toolbox.lock.example.json'
);
const lock = JSON.parse(await readFile(templatePath, 'utf8'));
const portalPackage = JSON.parse(
await readFile(path.join(portalClone, 'package.json'), 'utf8')
);
lock.releaseVersion = `${packageJson.version}-smoke.0`;
lock.portalVersion = portalPackage.version;
lock.apps = [
{
id: 'de.add-ideas.av-tools',
version: packageJson.version,
artifact: `../smoke-artifacts/${path.basename(artifact)}`,
sha256: digest,
target: 'av',
},
];
const lockPath = path.join(
portalClone,
'release',
'av-tools.smoke.lock.json'
);
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, {
flag: 'wx',
});
await run('npm', ['ci'], { cwd: portalClone });
await run('npm', ['test'], { cwd: portalClone });
await run('npm', ['run', 'build'], { cwd: portalClone });
const output = path.join(portalClone, 'build', 'toolbox-av-smoke');
await run(
'npm',
[
'run',
'assemble',
'--',
'--lock',
'release/av-tools.smoke.lock.json',
'--portal-dist',
'dist',
'--output',
'build/toolbox-av-smoke',
'--archive',
'build/add-ideas-toolbox-av-smoke.zip',
],
{ cwd: portalClone }
);
await verifyAssembly(output, packageJson.version, digest);
const headerInspection = inspectPortalHeaders(
await readFile(path.join(portalClone, 'deploy', 'nginx.conf'), 'utf8')
);
for (const warning of headerInspection.warnings) {
console.warn(`Portal header warning: ${warning}`);
}
if (headerInspection.errors.length > 0) {
const message = `Portal header gaps: ${headerInspection.errors.join('; ')}`;
if (!options.allowKnownHeaderGap) throw new Error(message);
console.warn(
`KNOWN HEADER GAP ACCEPTED FOR DEVELOPMENT SMOKE: ${message}`
);
}
successful = true;
console.log('\nPortal assembly smoke passed');
console.log(`Portal revision: ${portalRevision}`);
console.log(`AV artifact SHA-256: ${digest}`);
console.log('Assembled target: apps/av/');
console.log(
'Validated: checksum, extraction, manifest identity/version, catalogue, ST/MT assets, relative entry URLs and ST fallback contract.'
);
} finally {
if (options.keepTemp) {
console.log(
`${successful ? 'Kept' : 'Failed; kept'} temporary Portal smoke directory: ${temporaryRoot}`
);
} else {
await rm(temporaryRoot, { force: true, recursive: true });
}
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}

View File

@@ -0,0 +1,80 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const expectedVersion = '0.12.10';
const outputRoot = join(
repositoryRoot,
'public',
'vendor',
'ffmpeg',
expectedVersion
);
const manifestPath = join(outputRoot, 'version.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
if (
manifest.schemaVersion !== 1 ||
manifest.coreVersion !== expectedVersion ||
!Array.isArray(manifest.assets) ||
manifest.assets.length !== 5
) {
throw new Error(`Invalid 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',
],
]);
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');
if (
asset.bytes !== bytes.byteLength ||
asset.sha256 !== digest ||
expectedHashes.get(asset.path) !== digest
) {
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
}
}
if (expectedPaths.size > 0) {
throw new Error(
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
);
}
console.log(
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
);