derived/ is built from originals/, and you can watch it happen

The mapping: derived/ mirrors originals/ directory for directory, and where
originals holds a FILE, derived holds a DIRECTORY of the same name — extension
included — with the renditions inside it. Keeping the extension is what makes it
total: a.jpg and a.png are different originals and must not share a folder. The
inverse is pure string work, so the filesystem is the record and there is no
second copy of the truth.

Images get a WebP ladder at 320/640/960/1280/1920/2560 plus full.webp, and
nothing is ever upscaled — a rung wider than the original would be a soft file
that srcset then confidently prefers over the sharp one. full.webp always
exists because for a HEIC it is the only browser-readable copy of the picture
there is.

Video gets av-club's web-1080 and web-720 presets by name, plus a poster. Images
are named by width because srcset descriptors are widths; video by height
because that is av-club's vocabulary and nobody misreads 1080.mp4.

The ladder is fixed here and the SITE chooses from it. This never reads
website/src/media/slots.ts — a plugin that knows a website's layout is a plugin
for one website, and that boundary is the difference between a template and
something fofos-shaped.

Each derived folder carries a manifest naming the size and mtime it was built
from, so the sync is idempotent and resumable: a run that died halfway is
finished by running it again. That is what makes an in-memory job acceptable
rather than merely convenient. It builds into <name>.partial and renames, so an
interruption can never leave a folder that looks finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 11:13:56 +01:00
co-authored by Claude Opus 5
parent 1b171c3335
commit e87e19ee46
5 changed files with 708 additions and 2 deletions
+23
View File
@@ -5,6 +5,7 @@ import { deleteConnection, readConnection, readCredentials, recordProbe, saveCon
import { readSettings, saveRootPath } from './settings';
import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website';
import { readMediaStatus, createMediaDir } from './media';
import { readSync, startSync } from './sync';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
/**
@@ -228,6 +229,28 @@ router.post('/_config/media/create', async (ctx) => {
return ctx.json({ ...result, status: await readMediaStatus(user.email) }, result.ok ? 200 : 400);
});
/**
* Building `derived/` from `originals/`.
*
* Two routes and no more: start one, and ask how it is going. The work is asynchronous because a
* folder of photographs takes minutes, and the panel polls the second route rather than holding a
* request open — a connection that has to survive an eight-minute encode is a connection that will
* not.
*
* `POST` answers 200 whether it started a run or found one already going, and says which in `started`.
* "Already running" is not a failure to report to somebody who pressed Sync; the honest answer is the
* run that is in progress, which is what comes back.
*/
router.post('/_config/media/sync', async (ctx) => {
const user = ctx.get('user');
return ctx.json(startSync(user.email));
});
router.get('/_config/media/sync', async (ctx) => {
const user = ctx.get('user');
return ctx.json(readSync(user.email));
});
/**
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
*