Files
Artista-Filmcamp/scripts/optimize-media.ts
T
Nick SzaunigandClaude Opus 5.5 d45700a9ef perf: Medien aufbereiten (pnpm media:optimize) und länger cachen
Ursachen der langen Ladezeiten: Reel-Video 1,6 GB in 4K mit Index am Dateiende, Headerbild 15 MB, Vorschaubild 1,3 MB im Original. Neues Skript erzeugt Web-Fassungen (<name>.web.mp4, 1080p, faststart; verkleinerte Bilder), die Seite bevorzugt sie automatisch. Mediendateien mit ?v= werden ein Jahr (immutable) gecacht, optimierte Bilder 30 Tage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 07:17:31 +02:00

163 lines
6.5 KiB
TypeScript

/**
* Bereitet Bilder und Videos fuer das Medienverzeichnis auf, bevor sie
* hochgeladen werden (docs/MEDIEN.md):
*
* pnpm media:optimize <eingabe-ordner> [ausgabe-ordner]
*
* - Videos (.mp4, .mov, .m4v, .webm) → "<name>.web.mp4": laengste Seite
* hoechstens 1920 px, H.264, Index am Dateianfang (faststart), damit der
* Browser sofort abspielen kann statt erst die ganze Datei zu laden.
* Headervideos ohne Ton. Fehlt ein Vorschaubild, wird eins erzeugt.
* Die Seite nimmt automatisch die .web.mp4, wenn sie daneben liegt.
* - Bilder groesser als noetig werden verkleinert (Logo 1600 px, sonst
* 2560 px laengste Seite), kleinere unveraendert kopiert.
*
* Ausgabe standardmaessig in "<eingabe-ordner>-web" mit gleicher
* Ordnerstruktur; diesen Inhalt ins Medienverzeichnis kopieren. Bereits
* aufbereitete Dateien (neuer als das Original) werden uebersprungen.
*/
import { spawn } from "node:child_process";
import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
import path from "node:path";
import ffmpegPath from "ffmpeg-static";
import sharp from "sharp";
const VIDEO_EXTS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const MAX_VIDEO_EDGE = 1920;
const MAX_IMAGE_EDGE = 2560;
const MAX_LOGO_EDGE = 1600;
const POSTER_SECOND = 1;
async function* walk(dir: string): AsyncGenerator<string> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (entry.isFile()) yield full;
}
}
async function isFresh(output: string, input: string): Promise<boolean> {
try {
return (await stat(output)).mtimeMs >= (await stat(input)).mtimeMs;
} catch {
return false;
}
}
async function exists(file: string): Promise<boolean> {
return stat(file).then(
() => true,
() => false,
);
}
function ffmpeg(args: string[]): Promise<void> {
const binary = ffmpegPath as unknown as string | null;
if (!binary) throw new Error("ffmpeg-static liefert fuer dieses System kein ffmpeg.");
return new Promise((resolve, reject) => {
const child = spawn(binary, ["-hide_banner", "-loglevel", "error", "-stats", "-y", ...args], { stdio: ["ignore", "inherit", "inherit"] });
child.on("error", reject);
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg endete mit Code ${code}`))));
});
}
/** Laengste Seite auf MAX_VIDEO_EDGE begrenzen, nie vergroessern, gerade Masse fuer H.264. */
const SCALE_FILTER = `scale='if(gte(iw,ih),min(${MAX_VIDEO_EDGE},iw),-2)':'if(gte(iw,ih),-2,min(${MAX_VIDEO_EDGE},ih))'`;
async function hasPoster(input: string, base: string): Promise<boolean> {
const dir = path.dirname(input);
const name = path.basename(base);
for (const ext of IMAGE_EXTS) {
if (await exists(path.join(dir, `${name}${ext}`))) return true;
}
return false;
}
async function optimizeVideo(input: string, outDir: string, base: string): Promise<string[]> {
const output = path.join(outDir, `${base}.web.mp4`);
const written: string[] = [];
if (!(await isFresh(output, input))) {
const silent = base.toLowerCase() === "header";
await ffmpeg([
"-i", input,
"-vf", SCALE_FILTER,
"-c:v", "libx264", "-preset", "slow", "-crf", "23", "-profile:v", "high", "-pix_fmt", "yuv420p",
"-maxrate", "6M", "-bufsize", "12M",
...(silent ? ["-an"] : ["-c:a", "aac", "-b:a", "128k"]),
"-movflags", "+faststart",
output,
]);
written.push(output);
}
const poster = path.join(outDir, `${base}.jpg`);
if (!(await hasPoster(input, base)) && !(await isFresh(poster, input))) {
await ffmpeg(["-ss", String(POSTER_SECOND), "-i", input, "-frames:v", "1", "-vf", SCALE_FILTER, "-q:v", "3", poster]);
written.push(poster);
}
return written;
}
async function optimizeImage(input: string, output: string): Promise<string[]> {
if (await isFresh(output, input)) return [];
const isLogo = path.basename(input).toLowerCase().startsWith("logo");
const limit = isLogo ? MAX_LOGO_EDGE : MAX_IMAGE_EDGE;
const meta = await sharp(input).metadata();
if (Math.max(meta.width ?? 0, meta.height ?? 0) <= limit) {
await copyFile(input, output);
return [output];
}
const resized = sharp(input).rotate().resize(limit, limit, { fit: "inside", withoutEnlargement: true });
const ext = path.extname(input).toLowerCase();
if (ext === ".png") await resized.png({ compressionLevel: 9 }).toFile(output);
else if (ext === ".webp") await resized.webp({ quality: 82 }).toFile(output);
else await resized.jpeg({ quality: 82, mozjpeg: true }).toFile(output);
return [output];
}
function megabytes(bytes: number): string {
return `${(bytes / 1_048_576).toFixed(1)} MB`;
}
async function main(): Promise<void> {
const [inputArg, outputArg] = process.argv.slice(2);
if (!inputArg) {
console.error("Aufruf: pnpm media:optimize <eingabe-ordner> [ausgabe-ordner]");
process.exitCode = 2;
return;
}
const inputDir = path.resolve(inputArg);
const outputDir = path.resolve(outputArg ?? `${inputDir}-web`);
if (outputDir === inputDir) throw new Error("Ausgabe-Ordner muss sich vom Eingabe-Ordner unterscheiden.");
let before = 0;
let after = 0;
for await (const input of walk(inputDir)) {
const rel = path.relative(inputDir, input);
const ext = path.extname(rel).toLowerCase();
if (!VIDEO_EXTS.has(ext) && !IMAGE_EXTS.has(ext)) continue;
if (rel.toLowerCase().endsWith(".web.mp4")) continue;
const outDir = path.join(outputDir, path.dirname(rel));
await mkdir(outDir, { recursive: true });
console.log(`→ ${rel}`);
const written = VIDEO_EXTS.has(ext)
? await optimizeVideo(input, outDir, path.basename(rel, path.extname(rel)))
: await optimizeImage(input, path.join(outDir, path.basename(rel)));
if (written.length === 0) {
console.log(" bereits aktuell");
continue;
}
const inSize = (await stat(input)).size;
const outSize = (await Promise.all(written.map((f) => stat(f)))).reduce((sum, s) => sum + s.size, 0);
before += inSize;
after += outSize;
console.log(` ${megabytes(inSize)} → ${megabytes(outSize)}: ${written.map((f) => path.relative(outputDir, f)).join(", ")}`);
}
console.log(`Fertig: ${megabytes(before)} → ${megabytes(after)}. Inhalt von ${outputDir} ins Medienverzeichnis kopieren.`);
}
main().catch((error: unknown) => {
console.error(`Abbruch: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});