diff --git a/manifest.ts b/manifest.ts index bd0cb88..c33bcf6 100644 --- a/manifest.ts +++ b/manifest.ts @@ -18,7 +18,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.28.01', + version: '2026.08.29.01', platform: '>=1.0.0', label: 'AV Club', summary: 'Convert and treat images, video and audio — sharp, ffmpeg and ffprobe behind one API', @@ -56,6 +56,23 @@ export const manifest: PluginManifest = { No `binary:` that means anything on its own — it is a shared library plugin, not a command. The binary named is the one that USES it, which is what makes the check meaningful. */ + /* + The formats the platform cannot read, and which this plugin can. + + HEIC is why av-club is worth installing for somebody who never converts anything: sharp's libheif + has no HEVC decoder, so without this a folder of iPhone photographs is a wall of file icons and + opening one offers a download. With it, they thumbnail and they open. + + Only HEIC and HEIF are claimed. Everything else the file browser shows — jpeg, png, webp, avif, svg, + and every video ffmpeg reads — core already handles, and claiming those would be taking work away + from a path that is faster and needs no plugin. + */ + fileTypes: { + extensions: ['heic', 'heif'], + thumbnail: '/v1/thumb', + render: '/v1/render', + }, + osDependencies: [ { binary: 'ffmpeg', diff --git a/sidecar/index.ts b/sidecar/index.ts index edc1e17..475760b 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -158,6 +158,68 @@ async function route(request: Request): Promise { Read-only, so there is no output path to check — ffprobe is told a file and prints what it sees. */ + /* + ── The file browser's two questions, answered for formats the platform cannot read ── + + The platform's thumbnailer uses sharp, whose bundled libheif has no HEVC decoder, so it does not + claim `.heic` at all. This plugin does — see `fileTypes` in the manifest — and these are the routes + the platform calls when it meets one. + + `size` and `width` are CLAMPED here rather than trusted. The caller is the platform rather than a + browser, but a route that resizes to whatever number arrives is one bad query string from an + allocation the size of the machine, and "the only caller is trusted" is a property that stops being + true the first time somebody adds a second caller. + + Both stream bytes rather than returning an asset id: the platform caches the result under its own + key, and an asset here would be a second copy of something already cached elsewhere, with no owner + and nothing to expire it. + */ + if ((path === "/v1/thumb" || path === "/v1/render") && request.method === "GET") { + const target = await safeInputPath(url.searchParams.get("path"), "path"); + const thumb = path === "/v1/thumb"; + + const asked = Number(url.searchParams.get(thumb ? "size" : "width")); + const box = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 32), thumb ? 1024 : 4096) : thumb ? 256 : 2048; + + const result = await withSlot(() => + runSharp({ + inputPath: target, + pipeline: [ + // EXIF orientation applied and the tag dropped, or every photo taken sideways arrives sideways. + { rotate: {} }, + { resize: { width: box, height: box, fit: "inside", withoutEnlargement: true } }, + ], + // JPEG for a thumbnail (small, universal); WebP for the viewer, which wants the quality back. + output: thumb ? { format: "jpeg", options: { quality: 80, mozjpeg: true } } : { format: "webp", options: { quality: 90 } }, + }), + ); + + if (!("id" in result)) throw new Error("expected an asset"); + + /* + READ THE BYTES, THEN DELETE THE ASSET. + + `runSharp` without an `outputPath` stores its result, which is right for the API and wrong here: + this is a derived view of a file that already exists, requested on every folder listing, and + keeping one would fill the asset store with thumbnails nothing owns and nothing expires. + + Read fully rather than streamed from disk, because a streamed `Bun.file` would still be reading + when the delete lands. These are a few hundred kilobytes at most — the size is bounded by the box + clamped above. + */ + const bytes = await Bun.file(contentPath(result.id)).arrayBuffer(); + await deleteAsset(result.id).catch(() => {}); + + return new Response(bytes, { + headers: { + "Content-Type": thumb ? "image/jpeg" : "image/webp", + // The platform caches under a key containing the source's mtime and size, so this answer cannot + // go stale for a given URL. Its own cache is what matters; this is just the producer. + "Cache-Control": "private, max-age=300", + }, + }); + } + if (path === "/v1/probe" && request.method === "GET") { const target = await safeInputPath(url.searchParams.get("path"), "path"); return Response.json(await withSlot(() => ffprobe(target)));