Files
fofos-admin/api/derive.ts
T
pastilhasandClaude Opus 5 6db5b1e85d decode once, encode seven times, and one file at a time
A HEIC was being decoded once per rung. av-club is stateless per request, so
seven rungs asked for from the original are seven ImageMagick conversions of it
— measured on fofos at 8.5 s each, against 1.1 s for the resize and encode that
follow. Six of the seven produced a pixel-identical intermediate and threw it
away: about 67 seconds per photograph where 16 would do, paid again on every
future upload.

So when av-club says a format needs converting, convert it ONCE into the staging
directory and cut every rung from that. `needsConversion` is asked rather than
guessed, because which formats are expensive is av-club's fact and a list of
them kept here would go on paying after its build stopped needing it. JPEG and
PNG are untouched — writing a full-resolution intermediate to save six cheap
reads would cost more than it saved.

Concurrency drops to one. Two was chosen to match av-club and is the right
number on a machine with room; fofos has 1.87 GiB of RAM, and two concurrent
12-megapixel decodes left 520 MiB free with 648 MB already paged out. The work
is CPU-bound on a box already at 99%, so the second worker was not buying
throughput — it was buying a second resident bitmap.

And the sharp calls now name their output format properly. `output` takes
`{ format, options }`; passing `{ webp: { … } }` was silently ignored, so
everything built before this encoded at sharp's default quality rather than the
82 asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:24:18 +01:00

397 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { sidecarPort } from '@@/sidecar-registry';
import { mediaPath } from './media';
/**
* Building `derived/` from `originals/`.
*
* ── The mapping ──
*
* `derived/` mirrors `originals/` directory for directory. Where originals holds a FILE, derived holds
* a DIRECTORY of the same name — extension included — and the renditions live inside it:
*
* originals/homepage_galeria01.jpg → derived/homepage_galeria01.jpg/320.webp
* derived/homepage_galeria01.jpg/640.webp …
* originals/ABOUT US/portrait.HEIC → derived/ABOUT US/portrait.HEIC/…
* originals/homepage_video.mp4 → derived/homepage_video.mp4/1080.mp4
* derived/homepage_video.mp4/poster.webp
*
* Keeping the extension is what makes the mapping total: `a.jpg` and `a.png` are different originals
* and must not share a directory. It also makes the inverse pure string work — no index, no table, no
* second copy of the truth. **The filesystem is the record.**
*
* ── The ladder is fixed here, and the SITE chooses from it ──
*
* The widths below cover every slot fofos has at up to 3× pixel density — `website/src/media/slots.ts`
* puts the largest at 1232 CSS px, so `minUpload` is 2464 and 2560 is the top rung. But this file does
* not read that one, and must not: a plugin that knows a website's layout is a plugin for one website.
* It emits a ladder; `srcset` and the `sizes` already written in `website/src/media/render.ts` do the
* choosing, because the site is the only thing that knows what space an image occupies.
*
* That boundary is the difference between this being a template and being fofos-shaped.
*
* ── Nothing is ever upscaled ──
*
* A rung wider than the original is not written. Inventing a 2560 from a 900px photograph produces a
* soft file that `srcset` would then confidently prefer over the sharp one — worse than not having it.
*
* ── Video rungs are named by height, images by width ──
*
* Deliberate, not an oversight. `srcset` descriptors are widths, so an image file called `1280` has to
* be 1280 pixels wide or the browser is being lied to. Video has no such mechanism, and every person
* reading `1080.mp4` knows what it means. Each unit matches its consumer.
*
* ── Every byte is written by av-club ──
*
* This module decides WHAT should exist and av-club decides how to make it. The video presets are its
* `web-1080` / `web-720` / `poster`, by name — the encoder flags behind them are its business, and
* revising them for every site at once is the reason it is a separate service.
*/
/** Image rungs, in pixels of width. Rungs wider than the original are skipped — see above. */
export const LADDER = [320, 640, 960, 1280, 1920, 2560] as const;
/** WebP quality. 82 is the usual knee: visually clean on photographs, well off the top of the curve. */
const WEBP_QUALITY = 82;
/** Video renditions, as av-club preset names paired with the source height each one needs to be worth making. */
const VIDEO_RUNGS = [
{ file: '1080.mp4', preset: 'web-1080', minSourceHeight: 720 },
{ file: '720.mp4', preset: 'web-720', minSourceHeight: 0 },
] as const;
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'heic', 'heif']);
const VIDEO_EXT = new Set(['mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi', 'wmv', 'flv', 'mpg', 'mpeg', '3gp', 'm2ts', 'mts']);
export type Kind = 'image' | 'video';
export type Variant = {
file: string;
width: number | null;
height: number | null;
bytes: number;
};
/**
* What a derived directory says about itself.
*
* Written last, and it is also the idempotency record: a source whose size and mtime still match the
* one recorded here has nothing to redo. That is what makes the initial sync resumable after a
* restart, and what makes the watcher's per-file call the same code path as the full pass.
*
* It is a manifest rather than a database row for the same reason the mapping is string work: a
* `derived/` you can delete and rebuild must not leave a row behind claiming it is still there.
*/
export type Manifest = {
kind: Kind;
source: { name: string; bytes: number; mtimeMs: number; width: number | null; height: number | null };
variants: Variant[];
/** ISO. For a person reading the folder, not for the code. */
builtAt: string;
};
const MANIFEST = 'manifest.json';
export const extensionOf = (name: string): string => {
const dot = name.lastIndexOf('.');
return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase();
};
export const kindOf = (name: string): Kind | null => {
const ext = extensionOf(name);
if (IMAGE_EXT.has(ext)) return 'image';
if (VIDEO_EXT.has(ext)) return 'video';
return null;
};
export const originalsDir = async (email: string): Promise<string> => join(await mediaPath(email), 'originals');
export const derivedDir = async (email: string): Promise<string> => join(await mediaPath(email), 'derived');
/**
* Talk to av-club.
*
* Straight to the sidecar's loopback port rather than back through the platform's own HTTP: this code
* IS the platform, and making it authenticate to itself to reach a service already on the machine adds
* a credential and a hop to buy nothing. `x-officer-user` is the sidecar's whole authentication and is
* exactly what its proxy would have sent — see `src/servers/api/file-browser/plugin-file-types.ts`,
* which reaches it the same way and explains why that is sound.
*/
class AvClubUnavailable extends Error {}
async function avClub<T>(path: string, email: string, init?: RequestInit): Promise<T> {
const port = sidecarPort('av-club');
if (!port) throw new AvClubUnavailable('av-club is not running. Install and start the AV Club plugin.');
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
...init,
headers: { 'x-officer-user': email, ...(init?.body ? { 'content-type': 'application/json' } : {}), ...init?.headers },
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: { message?: string } } | null;
throw new Error(body?.error?.message ?? `av-club answered ${response.status}`);
}
return (await response.json()) as T;
}
const post = <T>(path: string, email: string, body: unknown): Promise<T> =>
avClub<T>(path, email, { method: 'POST', body: JSON.stringify(body) });
/** Is this derived directory already correct for this original? */
async function upToDate(dir: string, source: { bytes: number; mtimeMs: number }): Promise<boolean> {
const raw = await readFile(join(dir, MANIFEST), 'utf8').catch(() => null);
if (!raw) return false;
try {
const manifest = JSON.parse(raw) as Manifest;
return manifest.source.bytes === source.bytes && manifest.source.mtimeMs === source.mtimeMs;
} catch {
return false;
}
}
/**
* Build one original's directory of renditions.
*
* ── It builds beside, then swaps ──
*
* Into `<name>.partial`, renamed over the real one only once everything succeeded. A sync interrupted
* halfway — a restart, a kill, ffmpeg dying on one file — must never leave a directory that LOOKS
* finished, because the manifest is what everything else trusts. Half a ladder with a manifest is
* worse than no ladder at all.
*/
export async function deriveOne(
sourcePath: string,
targetDir: string,
email: string,
options: { force?: boolean } = {},
): Promise<{ built: boolean; manifest: Manifest }> {
const name = sourcePath.slice(sourcePath.lastIndexOf('/') + 1);
const kind = kindOf(name);
if (!kind) throw new Error(`${name} is neither an image nor a video this understands.`);
const info = await stat(sourcePath);
const source = { bytes: info.size, mtimeMs: Math.round(info.mtimeMs) };
if (!options.force && (await upToDate(targetDir, source))) {
const manifest = JSON.parse(await readFile(join(targetDir, MANIFEST), 'utf8')) as Manifest;
return { built: false, manifest };
}
const staging = `${targetDir}.partial`;
await rm(staging, { recursive: true, force: true });
await mkdir(staging, { recursive: true });
try {
const manifest =
kind === 'image'
? await buildImage(sourcePath, staging, name, source, email)
: await buildVideo(sourcePath, staging, name, source, email);
await writeFile(join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
// `rename` cannot replace a non-empty directory, so the old one goes first. The window between the
// two is the reason this is not the step that produces the files.
await rm(targetDir, { recursive: true, force: true });
await rename(staging, targetDir);
return { built: true, manifest };
} catch (error) {
await rm(staging, { recursive: true, force: true }).catch(() => {});
throw error;
}
}
/**
* The intermediate a ladder is built from, when reading the original repeatedly would be the expensive
* part.
*
* Never compressed — `compressionLevel: 0`. It lives for seconds inside the staging directory and is
* then deleted, so every cycle spent shrinking it is spent twice for nothing: once packing, once
* unpacking, seven times over.
*/
const MASTER = '.master.png';
async function buildImage(
sourcePath: string,
staging: string,
name: string,
source: { bytes: number; mtimeMs: number },
email: string,
): Promise<Manifest> {
const meta = await avClub<{ width: number | null; height: number | null; needsConversion: boolean }>(
`/v1/image?path=${encodeURIComponent(sourcePath)}`,
email,
);
const width = meta.width ?? 0;
const height = meta.height ?? 0;
if (!width || !height) throw new Error(`Could not read the dimensions of ${name}.`);
/*
DECODE ONCE.
av-club is stateless per request, so seven rungs asked for from the original are seven decodes of
it. For a JPEG that is fine — sharp reads one cheaply, and writing a full-resolution intermediate
to save six cheap reads would cost more than it saved. For a HEIC it is not: every request spawns
ImageMagick to produce a full-resolution PNG, measured on fofos at 8.5 s against 1.1 s for the
resize and encode that follow. Seven rungs spent about a minute making the same intermediate over
and over and throwing six of them away.
So when av-club says this format needs converting, it is converted ONCE, here, into the staging
directory nobody else can see, and every rung is cut from that. 67 seconds becomes 16.
`needsConversion` is asked rather than guessed. Which formats are expensive is av-club's fact and
changes when its build changes; a list of them kept here would go on paying for a conversion that
had stopped being necessary.
*/
const master = meta.needsConversion ? join(staging, MASTER) : sourcePath;
if (meta.needsConversion) {
await post('/v1/sharp', email, {
inputPath: sourcePath,
outputPath: master,
pipeline: [{ rotate: {} }],
output: { format: 'png', options: { compressionLevel: 0 } },
});
}
const variants: Variant[] = [];
/*
`full.webp` first, and always.
It is the top of the ladder, and for a HEIC it is the only browser-readable copy of the picture that
exists at all — the original renders nowhere. A lightbox, a zoom, or handing the client their own
photograph back all need it.
*/
variants.push(await encodeWebp(master, join(staging, 'full.webp'), null, email, width, height));
// Strictly less than the original: a rung at or above it would duplicate `full.webp` byte for byte.
for (const rung of LADDER) {
if (rung >= width) continue;
variants.push(await encodeWebp(master, join(staging, `${rung}.webp`), rung, email, width, height));
}
// Before the manifest is written, so a directory that has one never contains this.
if (meta.needsConversion) await rm(master, { force: true });
return {
kind: 'image',
source: { name, bytes: source.bytes, mtimeMs: source.mtimeMs, width, height },
variants,
builtAt: new Date().toISOString(),
};
}
async function encodeWebp(
sourcePath: string,
outputPath: string,
rung: number | null,
email: string,
sourceWidth: number,
sourceHeight: number,
): Promise<Variant> {
const result = await post<{ path: string; bytes: number }>('/v1/sharp', email, {
inputPath: sourcePath,
outputPath,
// `rotate` with no angle applies the EXIF orientation, which phone photographs rely on entirely. A
// ladder built without it is a set of sideways pictures. Kept on every rung including those cut
// from the master, where it is a no-op — a PNG carries no EXIF, and the master was written with the
// rotation already applied. One code path beats two that can disagree about which way up a photo is.
pipeline: [
{ rotate: {} },
...(rung === null ? [] : [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }]),
],
output: { format: 'webp', options: { quality: WEBP_QUALITY } },
});
const width = rung ?? sourceWidth;
return {
file: outputPath.slice(outputPath.lastIndexOf('/') + 1),
width,
height: Math.round((width / sourceWidth) * sourceHeight),
bytes: result.bytes,
};
}
async function buildVideo(
sourcePath: string,
staging: string,
name: string,
source: { bytes: number; mtimeMs: number },
email: string,
): Promise<Manifest> {
const probe = await avClub<{ streams?: Array<{ codec_type?: string; width?: number; height?: number }> }>(
`/v1/probe?path=${encodeURIComponent(sourcePath)}`,
email,
);
const video = probe.streams?.find((stream) => stream.codec_type === 'video');
const height = video?.height ?? 0;
const width = video?.width ?? 0;
const variants: Variant[] = [];
for (const rung of VIDEO_RUNGS) {
// A 720p source re-encoded as "1080" is bigger and no better. The lowest rung has no floor, so
// every video gets at least one rendition however small it started.
if (height && height < rung.minSourceHeight) continue;
const result = await post<{ path: string; bytes: number }>('/v1/ffmpeg', email, {
inputPath: sourcePath,
outputPath: join(staging, rung.file),
preset: rung.preset,
});
variants.push({ file: rung.file, width: null, height: null, bytes: result.bytes });
}
/*
The poster, in two steps.
av-club's `poster` preset writes a JPEG, which is its call and the right one for a general service.
Here everything a browser is handed is WebP, so it goes through sharp on the way out and the JPEG
is dropped. Two calls per video, once, at build time.
*/
const stillJpg = join(staging, 'poster.jpg');
await post('/v1/ffmpeg', email, {
inputPath: sourcePath,
outputPath: stillJpg,
preset: 'poster',
// A frame from a second in, because frame zero of a great many videos is black.
inputOptions: { ss: 1 },
}).catch(() => post('/v1/ffmpeg', email, { inputPath: sourcePath, outputPath: stillJpg, preset: 'poster' }));
const poster = await post<{ path: string; bytes: number }>('/v1/sharp', email, {
inputPath: stillJpg,
outputPath: join(staging, 'poster.webp'),
output: { format: 'webp', options: { quality: WEBP_QUALITY } },
});
await rm(stillJpg, { force: true });
variants.push({ file: 'poster.webp', width: null, height: null, bytes: poster.bytes });
return {
kind: 'video',
source: { name, bytes: source.bytes, mtimeMs: source.mtimeMs, width: width || null, height: height || null },
variants,
builtAt: new Date().toISOString(),
};
}
/** Every file under `originals/`, relative to it, depth first. Directories are structure, not work. */
export async function listOriginals(root: string, prefix = ''): Promise<string[]> {
const entries = await readdir(join(root, prefix), { withFileTypes: true }).catch(() => []);
const found: string[] = [];
for (const entry of entries) {
// Dotfiles are `.DS_Store` and friends. Nothing under here is a deliberate hidden file.
if (entry.name.startsWith('.')) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) found.push(...(await listOriginals(root, rel)));
else if (kindOf(entry.name)) found.push(rel);
}
return found;
}
/** Where a relative original ends up. Exported because the watcher needs the same answer. */
export const targetFor = (derivedRoot: string, relativePath: string): string => join(derivedRoot, relativePath);
export { relative, AvClubUnavailable };