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]>
This commit is contained in:
1 parent
1ca09cbaef
commit
d45700a9ef
7 files changed
+321
-1
No files matched your search
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
Reference in new issue
Block a user