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 => join(await mediaPath(email), 'originals'); export const derivedDir = async (email: string): Promise => 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(path: string, email: string, init?: RequestInit): Promise { 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 = (path: string, email: string, body: unknown): Promise => avClub(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 { 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 `.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; } } async function buildImage( sourcePath: string, staging: string, name: string, source: { bytes: number; mtimeMs: number }, email: string, ): Promise { const meta = await avClub<{ width: number | null; height: number | null }>( `/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}.`); 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(sourcePath, 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(sourcePath, join(staging, `${rung}.webp`), rung, email, width, height)); } 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 { 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. pipeline: [ { rotate: {} }, ...(rung === null ? [] : [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }]), ], output: { webp: { 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 { 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: { webp: { 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 { 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 };