Two things the panel could not answer. A run's state lives in a Map, so a restart erased it — and with it any record that a file FAILED. That is the gap the manifests structurally cannot cover: a derived folder records the size, mtime and variants it was built from, so provenance is already precise, but a file that did not build has no folder and leaves no trace. Once the watcher is running, a failure at three in the morning would never be discovered. So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than a table because db/schema.ts is emphatic that the connection is the only thing this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside derived/ rather than inside it, so wiping derived does not erase the history of what happened before, which is the comparison it exists for. Written in a finally, so a run that died still records that it did. And the line above the button now says what is TRUE rather than what a run once did: reconcile() walks originals, reads each manifest, and reports current, missing and changed. It uses upToDate — the same predicate the sync uses to skip a file — because two definitions of "current" is how a panel comes to say everything is in step while the next run rebuilds half of it. Missing and changed are separated because only the second means somebody edited something. The ledger is not polled. It reads the whole tree, so it loads on arrival and again when a run stops — the only two moments its answer can change. Sync also takes an optional folder now, so a subtree can be re-synced on its own rather than the whole library. The scope is recorded on each run, because "3 built" means different things for one folder and for everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
489 lines
20 KiB
TypeScript
489 lines
20 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, tallest first. `height` is what the preset scales TO.
|
||
*
|
||
* A rung is made only when the source is at least that tall — the same no-upscaling rule the image
|
||
* ladder follows, and it was got wrong here first: `web-1080` was being asked for from a 720p source,
|
||
* which ffmpeg obligingly did. The result was a 14.6 MB file that looks worse than the 7.8 MB one
|
||
* beside it, and 84 seconds of a single core to make it. Under a running sync that overran av-club's
|
||
* idle timeout and the video failed outright, which is how the bug surfaced at all.
|
||
*
|
||
* `web-480` is the floor, not a rung: it exists so a source smaller than 720 still gets something,
|
||
* rather than nothing or an upscale.
|
||
*/
|
||
const VIDEO_RUNGS = [
|
||
{ file: '1080.mp4', preset: 'web-1080', height: 1080 },
|
||
{ file: '720.mp4', preset: 'web-720', height: 720 },
|
||
] as const;
|
||
|
||
const VIDEO_FLOOR = { file: '480.mp4', preset: 'web-480', height: 480 } 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?
|
||
*
|
||
* Exported because the panel asks the same question the sync does. Two implementations of "current"
|
||
* is how a screen comes to say everything is in step while the next run rebuilds half of it.
|
||
*/
|
||
export 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[] = [];
|
||
|
||
/*
|
||
Every rung the source is actually tall enough for, and the floor when it is tall enough for none.
|
||
|
||
An unreadable height (0) is treated as "make everything": a probe that told us nothing is not
|
||
evidence the video is small, and the encoder will decline to enlarge on its own long before we
|
||
should decide on its behalf.
|
||
*/
|
||
const wanted = height ? VIDEO_RUNGS.filter((rung) => height >= rung.height) : [...VIDEO_RUNGS];
|
||
const rungs = wanted.length ? wanted : [VIDEO_FLOOR];
|
||
|
||
for (const rung of rungs) {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* A caller-supplied subtree of `originals/`, made safe to walk.
|
||
*
|
||
* Relative, always, and never able to climb out: `..` in any segment is refused rather than resolved,
|
||
* because a scope that escapes `originals/` would have the sync building derived folders for files
|
||
* nobody put there. Leading and trailing slashes are trimmed so `/GALLERY/` and `GALLERY` mean one
|
||
* thing — the caller is naming a folder, not writing a path.
|
||
*/
|
||
export function safeScope(scope: string): string {
|
||
const trimmed = scope.replace(/^\/+|\/+$/g, '');
|
||
if (!trimmed) return '';
|
||
if (trimmed.split('/').includes('..')) throw new Error('That is not a folder inside originals.');
|
||
return trimmed;
|
||
}
|
||
|
||
/** 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 };
|
||
|
||
export type Reconciliation = {
|
||
/** Convertible files under `originals/`. */
|
||
originals: number;
|
||
/** Built from the current version of their source. */
|
||
current: number;
|
||
/** Never built, or built and since lost. Names, so the panel can say WHICH. */
|
||
missing: string[];
|
||
/** Built, but from a version of the source that has changed since. */
|
||
stale: string[];
|
||
};
|
||
|
||
/**
|
||
* What `derived/` would still have to do to be in step with `originals/`.
|
||
*
|
||
* Answered from the filesystem every time rather than cached, because it is the question "is what I am
|
||
* looking at current" and a cached answer to that is worse than none.
|
||
*
|
||
* It uses `upToDate` — the SAME predicate the sync uses to decide whether to skip a file. That is the
|
||
* whole point: a panel reporting "62 current" and a run then rebuilding all 62 would be two different
|
||
* definitions of the word, and the one on screen would be the one nobody could debug.
|
||
*
|
||
* `missing` and `stale` are separated because they mean different things to a person. Missing is a file
|
||
* that has never been converted or whose output was deleted; stale is one that was replaced with a new
|
||
* version. Both are work, but only the second means somebody changed something.
|
||
*/
|
||
export async function reconcile(email: string, scope = ''): Promise<Reconciliation> {
|
||
const from = await originalsDir(email);
|
||
const to = await derivedDir(email);
|
||
const files = await listOriginals(from, safeScope(scope));
|
||
|
||
const missing: string[] = [];
|
||
const stale: string[] = [];
|
||
let current = 0;
|
||
|
||
for (const file of files) {
|
||
const info = await stat(join(from, file)).catch(() => null);
|
||
if (!info) continue;
|
||
|
||
const target = targetFor(to, file);
|
||
const manifest = await readFile(join(target, MANIFEST), 'utf8').catch(() => null);
|
||
if (!manifest) {
|
||
missing.push(file);
|
||
continue;
|
||
}
|
||
|
||
if (await upToDate(target, { bytes: info.size, mtimeMs: Math.round(info.mtimeMs) })) current++;
|
||
else stale.push(file);
|
||
}
|
||
|
||
return { originals: files.length, current, missing, stale };
|
||
}
|