av-club claims HEIC for the file browser
`fileTypes` in the manifest, and two GET routes behind it: /v1/thumb and /v1/render. the platform calls them when it meets a `.heic` and nothing in the platform knows which plugin answered. THIS IS THE REASON TO INSTALL av-club 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 browser shows — jpeg, png, webp, avif, svg, and every video ffmpeg reads — core already handles, and claiming those would take work off a path that is faster and needs no plugin. 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 query string from an allocation the size of the machine, and "the only caller is trusted" stops being true the first time somebody adds a second caller. THE DERIVED ASSET IS DELETED AFTER THE BYTES ARE READ. runSharp without an outputPath stores its result — right for the API, wrong here: this is a view of a file that already exists, asked for on every folder listing, and keeping them would fill the asset store with thumbnails nothing owns and nothing expires. read fully rather than streamed, because a streamed Bun.file would still be reading when the delete lands. jpeg for the thumbnail (small, universal), webp for the render (the viewer wants the quality back). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+18
-1
@@ -18,7 +18,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
|||||||
*/
|
*/
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
version: '2026.08.28.01',
|
version: '2026.08.29.01',
|
||||||
platform: '>=1.0.0',
|
platform: '>=1.0.0',
|
||||||
label: 'AV Club',
|
label: 'AV Club',
|
||||||
summary: 'Convert and treat images, video and audio — sharp, ffmpeg and ffprobe behind one API',
|
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
|
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.
|
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: [
|
osDependencies: [
|
||||||
{
|
{
|
||||||
binary: 'ffmpeg',
|
binary: 'ffmpeg',
|
||||||
|
|||||||
@@ -158,6 +158,68 @@ async function route(request: Request): Promise<Response> {
|
|||||||
|
|
||||||
Read-only, so there is no output path to check — ffprobe is told a file and prints what it sees.
|
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") {
|
if (path === "/v1/probe" && request.method === "GET") {
|
||||||
const target = await safeInputPath(url.searchParams.get("path"), "path");
|
const target = await safeInputPath(url.searchParams.get("path"), "path");
|
||||||
return Response.json(await withSlot(() => ffprobe(target)));
|
return Response.json(await withSlot(() => ffprobe(target)));
|
||||||
|
|||||||
Reference in New Issue
Block a user