/** * Bereitet Bilder und Videos fuer das Medienverzeichnis auf, bevor sie * hochgeladen werden (docs/MEDIEN.md): * * pnpm media:optimize [ausgabe-ordner] * * - Videos (.mp4, .mov, .m4v, .webm) → ".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 "-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 { 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 { try { return (await stat(output)).mtimeMs >= (await stat(input)).mtimeMs; } catch { return false; } } async function exists(file: string): Promise { return stat(file).then( () => true, () => false, ); } function ffmpeg(args: string[]): Promise { 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 { 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 { 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 { 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 { const [inputArg, outputArg] = process.argv.slice(2); if (!inputArg) { console.error("Aufruf: pnpm media:optimize [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; });