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>
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
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 };
|