// Builds the LGPL ffmpeg and ffprobe the installed app carries (beta delivery plan, task 2): testers have no ffmpeg, and // the Homebrew one on the owner's Mac is a GPL build. The pinned source release is downloaded once, its SHA-256 checked // (a download that differs is refused), configured with only what the app needs, built in // ~/.cache/yapp-build/ffmpeg-/ and copied to resources/ffmpeg/ with a LICENSE.md. The checker (check.ts) then // reads the copies and the script fails on any problem. Nothing is built again while the cached binaries were made from // this version with this configure line. // // The app never hands ffmpeg or ffprobe an http(s) input: media is downloaded first (src/main/media/download.ts) and // ingested from disk, and probe.ts guards against a path read as a URL. So the build has no TLS and no network input. // usage: pnpm ffmpeg:build import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { cpus, homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { ffmpegInfo, ffmpegProblems } from './check' const VERSION = '8.1.3' const URL = `https://ffmpeg.org/releases/ffmpeg-${VERSION}.tar.xz` const SHA256 = '7138d28c96d9d3e3af4ee3d8cad72741f8ffb40da90c1112235dea3ecd3178a3' const CONFIGURE = [ '--disable-autodetect', '--enable-videotoolbox', '--enable-zlib', '--disable-doc', '--disable-ffplay', '--disable-debug', '--enable-static', '--disable-shared', '--arch=arm64', '--target-os=darwin', '--cc=clang', '--extra-cflags=-mmacosx-version-min=14.0', '--extra-ldflags=-mmacosx-version-min=14.0', ] const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..') const target = join(repo, 'resources', 'ffmpeg') const work = join(homedir(), '.cache', 'yapp-build', `ffmpeg-${VERSION}`) const tarball = join(work, `ffmpeg-${VERSION}.tar.xz`) const source = join(work, `ffmpeg-${VERSION}`) const bin = join(work, 'bin') // what a finished build leaves: ffmpeg, ffprobe, COPYING.LGPLv2.1 and built-with.txt const builtWith = join(bin, 'built-with.txt') function run(command: string, args: string[], cwd?: string): void { const r = spawnSync(command, args, { stdio: 'inherit', cwd }) if (r.status !== 0) { console.error(`This step failed: ${command} ${args.join(' ')}`) process.exit(1) } } function sha256(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex') } function cached(): boolean { return ['ffmpeg', 'ffprobe', 'COPYING.LGPLv2.1'].every((name) => existsSync(join(bin, name))) && existsSync(builtWith) && readFileSync(builtWith, 'utf8') === `${VERSION}\n${CONFIGURE.join(' ')}\n` } mkdirSync(work, { recursive: true }) if (cached()) { console.log(`ffmpeg ${VERSION} is already built in ${bin}`) } else { if (spawnSync('clang', ['--version'], { stdio: 'ignore' }).status !== 0) { console.error('Building ffmpeg needs the Xcode command line tools (xcode-select --install).') process.exit(1) } if (!existsSync(tarball) || sha256(tarball) !== SHA256) { console.log(`Downloading ${URL}`) run('curl', ['-fL', '--retry', '3', '-o', `${tarball}.part`, URL]) const got = sha256(`${tarball}.part`) if (got !== SHA256) { rmSync(`${tarball}.part`, { force: true }) console.error(`The download from ${URL} has SHA-256 ${got}, not the pinned ${SHA256}. It was refused.`) process.exit(1) } renameSync(`${tarball}.part`, tarball) } rmSync(source, { recursive: true, force: true }) run('tar', ['-xf', tarball, '-C', work]) console.log(`Configuring ffmpeg ${VERSION}`) run('./configure', CONFIGURE, source) console.log('Building (a few minutes)') run('make', ['-j', String(cpus().length), 'ffmpeg', 'ffprobe'], source) rmSync(bin, { recursive: true, force: true }) mkdirSync(bin, { recursive: true }) for (const name of ['ffmpeg', 'ffprobe', 'COPYING.LGPLv2.1']) copyFileSync(join(source, name), join(bin, name)) writeFileSync(builtWith, `${VERSION}\n${CONFIGURE.join(' ')}\n`) } mkdirSync(target, { recursive: true }) for (const name of ['ffmpeg', 'ffprobe']) { copyFileSync(join(bin, name), join(target, `${name}.part`)) renameSync(join(target, `${name}.part`), join(target, name)) } const problems = ffmpegProblems(ffmpegInfo(target)) if (problems.length > 0) { console.error(['The ffmpeg in resources/ffmpeg is not fit to ship:', ...problems.map((p) => ` - ${p}`)].join('\n')) process.exit(1) } writeFileSync(join(target, 'LICENSE.md'), [ '# FFmpeg', '', 'Yapp Studio carries FFmpeg (the `ffmpeg` and `ffprobe` programs in this folder) to read and write video and audio.', 'FFmpeg is licensed under the GNU Lesser General Public License, version 2.1 or later. This build has neither `--enable-gpl` nor `--enable-nonfree`.', '', `- Version: ${VERSION}`, `- Source: ${URL}`, `- Source SHA-256: ${SHA256}`, `- Configure line: \`./configure ${CONFIGURE.join(' ')}\``, '', `Written offer: for at least three years after Yapp Studio last ships this version, its exact source code (the file above) and the script Yapp Studio builds it with are at https://yapp.studio/source/ffmpeg/, and Yapp Studio will send them at no charge to anyone who writes to hello@yapp.studio.`, 'FFmpeg is a trademark of Fabrice Bellard, originator of the FFmpeg project. See https://ffmpeg.org/legal.html.', '', '## GNU Lesser General Public License, version 2.1 (COPYING.LGPLv2.1)', '', '```text', readFileSync(join(bin, 'COPYING.LGPLv2.1'), 'utf8').trimEnd(), '```', '', ].join('\n')) console.log(`ffmpeg ${VERSION} is in ${target}, LGPL, and the checks pass.`)