@vibedout/media-core
v0.3.0
Published
File handling for Cloudflare Workers, as pure functions and small interfaces: a magic-byte sniff table for images (HEIC and AVIF included), video, audio, PDF, office documents, zip and text, a lossless metadata and GPS remover for JPEG, PNG, WebP and GIF,
Maintainers
Readme
@vibedout/media-core
File handling for Cloudflare Workers, as pure functions and small interfaces. It is what sits between "a person uploaded something" and "a bucket holds it and a browser may be shown it":
- Sniffing. A closed table of file types recognised from their first bytes: PNG, JPEG, WebP, GIF, AVIF, HEIC and SVG images; MP4, QuickTime and WebM video; MP3, M4A, WAV, Ogg and FLAC audio; PDF, Word, Excel, PowerPoint, OpenDocument, legacy Office, RTF and plain-text documents; zip. The browser's
Content-Typeand the file's extension are never trusted. - Cleaning images.
cleanImageremoves the camera, the time, the GPS location, thumbnails, comments and anything stuck on after the picture, from JPEG, PNG, WebP and GIF, without decoding or re-encoding the pixels.cleanAndOrientadds the one step that does touch the pixels: when the picture says it is stored sideways, it turns it upright through an image transformer you hand it. - Serving safely. Only a raster image, audio, video or a PDF is shown inline; everything else is a download with
nosniffandContent-Security-Policy: sandbox, so an uploaded HTML or SVG file can never run script as part of the application. Content-Disposition is written in both filename forms. - Names, keys, hashes, limits. Filename cleaning, a versioned object-key scheme with a closed ladder of image sizes, SHA-256 of a body while it streams, size caps and a daily upload allowance.
- Re-encoding what cannot be cleaned in place.
reencodeImageturns a HEIC (into a JPEG) or an AVIF (into a WebP) once, through an image encoder you hand it, and cleans the result. - Placeholders.
placeholderFromImageasks an image resizer for a tiny PNG of a picture, decodes it (decodePng) and makes a ThumbHash and the dominant colour;thumbhashDataUrldraws the hash in a browser. - Asking a vision model.
moderateImage(is it fit to show) anddescribeImage(a line of alt text and a few tags), through anything shaped like the Workers AI binding. Neither throws. - Two seams. A
StorageDriver(with an R2 implementation and an in-memory one for tests) and aScanner(with the defaultnonescanner).
Web platform APIs and WebCrypto only; no required runtime dependency. The small packages it builds on (@vibedout/web-crypto, @vibedout/locale-core, @vibedout/provider-kit for the bounded body read, and Evan Wallace's thumbhash, MIT) are bundled into the build, so the published package imports none of them at run time.
Module map
| Import | What |
|---|---|
| @vibedout/media-core | Everything below except the R2 driver and the test doubles |
| @vibedout/media-core/sniff | sniffFile, sniffImage, detectFileType, FILE_TYPES, typeById, typeForMime, typeForExtension, extensionsOf, isAllowed, DEFAULT_ALLOWED_KINDS, SNIFF_BYTES, SNIFF_EXTENDED_BYTES, MAX_DIMENSION_PX |
| @vibedout/media-core/clean | cleanImage, readExifSummary, readTiffSummary |
| @vibedout/media-core/orient | cleanAndOrient, orientImage, orientationTransform, ORIENT_QUALITY, and the types ImagesLike, OrientReport |
| @vibedout/media-core/reencode | reencodeImage, REENCODE_TARGET, and the type ImageEncoderLike |
| @vibedout/media-core/placeholder | placeholderFromImage, placeholderOf, placeholderSize, decodePng, fitWithin, dominantColor, thumbhashDataUrl, PLACEHOLDER_MAX_SIDE, and the types ImageResizerLike, RgbaImage, Placeholder |
| @vibedout/media-core/vision | moderateImage, describeImage, VISION_MODEL, MODERATION_MODEL, and the types AiLike, ModerationOutcome, Description |
| @vibedout/media-core/headers | servingHeaders, contentDisposition, asciiFilename, isInlineSafe |
| @vibedout/media-core/filename | cleanFilename, filenameParts |
| @vibedout/media-core/keys | buildAssetKey, parseAssetKey, assetKeyPrefix, versionKeyPrefix, newKeyToken, isKeyToken, userScope, isScope, SITE_SCOPE, ASSET_VARIANTS, variantSpec, isVariantName, VARIANT_NAMES, EAGER_VARIANTS |
| @vibedout/media-core/hash | hashingStream, sha256OfStream, sha256OfBytes, hasDigestStream, HashFallbackTooLargeError |
| @vibedout/media-core/body | readStreamWithLimit, PayloadTooLargeError |
| @vibedout/media-core/limits | DEFAULT_MAX_BYTES, maxBytesFor, MULTIPART_THRESHOLD_BYTES, DEFAULT_MULTIPART_CAP_BYTES, DEFAULT_DAILY_QUOTA, quotaFor, quotaCharges, quotaReasonOf |
| @vibedout/media-core/scanner | Scanner, ScanVerdict, noneScanner, isServableAfterScan |
| @vibedout/media-core/storage | StorageDriver and its option and result types, StorageIntegrityError, deleteByPrefix |
| @vibedout/media-core/r2 | r2Driver (needs @cloudflare/workers-types in the project that compiles it) |
| @vibedout/media-core/testing | memoryDriver, fakeScanner, runStorageConformance |
Nothing under src/ is importable: only the paths in this table exist.
Accepting an upload
Read the first bytes, decide what the file is, clean it, and only then store it:
import { cleanFilename, cleanImage, SNIFF_EXTENDED_BYTES, sniffFile } from "@vibedout/media-core";
export function acceptUpload(bytes: Uint8Array, claimedName: string) {
// `hint` is the claimed name; it only chooses between Office formats or kinds of plain text, never overrides the bytes
const sniffed = sniffFile(bytes.subarray(0, SNIFF_EXTENDED_BYTES), { hint: claimedName });
if (!sniffed.ok) return { rejected: sniffed.error }; // unrecognised_format, not_allowed, zero_dimension, dimension_too_large, corrupt_header
let stored = bytes;
if (sniffed.type.kind === "image") {
const cleaned = cleanImage(bytes);
if (!cleaned.ok) return { rejected: cleaned.reason }; // needs_reencode (HEIC, AVIF), corrupt, unsupported (SVG)
stored = cleaned.bytes;
}
return { bytes: stored, mime: sniffed.type.mime, filename: cleanFilename(claimedName, { ext: sniffed.type.ext }) };
}sniffFile judges a file against a policy. The default accepts images, video, audio and documents and refuses SVG, HTML, zip archives and a bare OLE container (installers and mail messages share that format). Turn more on with allow:
import { sniffFile } from "@vibedout/media-core/sniff";
declare const head: Uint8Array;
const result = sniffFile(head, { allow: { kinds: ["image", "document", "archive"], svg: false }, maxDimension: 12000 });
if (result.ok) {
result.type.id; // "png", "docx", "zip", ...
result.width; // pixel size of a raster image; absent for other kinds
}A HEIC or AVIF picture's size is in a box that can start after the first 4 KiB; give SNIFF_EXTENDED_BYTES (64 KiB) when you want it. Without it the type is still found and width and height are left out.
sniffImage is the narrow question an image service asks (a type from a fixed list, a believable size) with the older, flat answer:
import { sniffImage } from "@vibedout/media-core/sniff";
declare const bytes: Uint8Array;
const meta = sniffImage(bytes, { types: ["image/png", "image/jpeg", "image/webp", "image/gif"] as const });
if (meta.ok) console.log(meta.meta.contentType, meta.meta.ext, meta.meta.width, meta.meta.height);What decides a type
The first bytes (magic numbers, the ftyp brands of ISO media files, the first entry of a zip, UTF-8 validity for text). An Office document is a zip: its kind is read from the part names inside it (word/, xl/, ppt/), and when those are past the bytes you gave, the claimed extension may choose between the three Office formats and nothing else. Plain text is refined by the extension into CSV, TSV, Markdown or JSON; a name never makes a file HTML, SVG or a binary type.
Removing location and metadata
import { cleanImage, readExifSummary } from "@vibedout/media-core/clean";
declare const photo: Uint8Array;
const result = cleanImage(photo);
if (result.ok) {
result.bytes; // the same picture, byte for byte, without the rest
result.removed; // ["exif", "gps", "xmp", "iptc", "mpf", "comment", "trailer", "text", "time", "metadata"]: only what was found
result.hadGps; // a GPS block in EXIF, or GPS fields in XMP
result.orientation; // 2 to 8 when the output still carries a tag saying the picture is not upright, see below
result.sourceOrientation; // 2 to 8 when the input declared one, kept or not
result.animated; // true for an APNG or an animated WebP
result.complete; // false when the file was cut off before its end marker
readExifSummary(result.bytes); // { found: false, hasGps: false } or an orientation-only EXIF block
}It is a whitelist: each format is rebuilt from the parts that draw the picture and describe its colours, so a part nobody anticipated is dropped, not kept. In a JPEG that is the JFIF header, the ICC colour profile, Adobe's colour-transform marker, the tables, the frame and every scan; everything after the first end-of-image marker is dropped too (a second picture, the video of a motion photo, a hidden payload). In a PNG the critical chunks plus the colour, transparency and animation chunks; a WebP keeps its image, alpha, animation and colour-profile chunks; a GIF keeps its frames, timing and loop count.
The kept parts are checked as well, because a segment that is kept whole could carry something after its real fields. The JFIF and Adobe segments of a JPEG are written again from their known fields (a thumbnail or a payload after them is gone); a JPEG or WebP colour profile is kept only when its header is well formed (its declared size is its length and the acsp signature is there), which a payload hidden behind a profile's name is not; the frames of an animated WebP are opened and only their picture and alpha are kept; a GIF's loop extension is written again from its loop count. What a valid colour profile says in its own tags (its description, its copyright) is the profile's content and stays, and a PNG's iCCP profile is compressed and is kept as it is.
Two things to know:
- Orientation is kept, as a tag, for a JPEG. Phones store a photo's rotation in EXIF. Dropping it would show many photos sideways, so a JPEG that was not upright keeps one 36-byte EXIF segment that holds only the orientation (no camera, time or location). The pixels are never rotated by
cleanImage. A PNG or WebP cannot keep it: its orientation lives in the EXIF chunk that is dropped, andsourceOrientationis how a caller learns that it existed.cleanImage(bytes, { keepOrientation: false })leaves the tag out of a JPEG too, for a caller that is about to turn the pixels (next section). - HEIC and AVIF are not cleaned in place. Their metadata lives in boxes that other boxes point into, so
cleanImageanswers{ ok: false, reason: "needs_reencode" }. Re-encode them (for example through the Images binding) before keeping them.
Every parser is bounds-checked: a damaged or hostile structure (a segment longer than the file, bytes between segments, no picture at all) gives { ok: false, reason: "corrupt" }, never an exception or a loop. A file that only lacks its end marker (a truncated photo) is cleaned as far as it goes and reported with complete: false; decide whether to keep it.
Turning a picture upright
The tag is lost or ignored in many places (a PNG or WebP cleaned of its metadata, an email client, a thumbnailer, a script reading pixels), so a stored master is better with its pixels turned and no tag at all. cleanAndOrient is the one call that makes it: it cleans, and if the picture declared an orientation other than 1 it turns the pixels through an image transformer, then cleans the result again.
import { cleanAndOrient } from "@vibedout/media-core/orient";
declare const env: { IMAGES?: ImagesBinding };
declare const upload: Uint8Array;
const prepared = await cleanAndOrient(upload, { images: env.IMAGES });
if (prepared.ok) {
prepared.bytes; // cleaned; upright when prepared.orientation.outcome is "applied"
prepared.width; // the pixel size after the turn (a quarter turn swaps it); set only when the pixels were turned
prepared.orientation; // { source: 6 | null, outcome: "upright" | "applied" | "no_binding" | "animated" | "unsupported_type" | "failed", detail? }
}The transformer is anything that fits ImagesLike; Cloudflare's Images binding does as it is. Without one the cleaned bytes come back unchanged and the report says no_binding (a JPEG still carries its tag, a PNG or WebP shows turned). Nothing throws: a transformer that fails, or answers a picture of the wrong size, leaves the cleaned bytes and outcome: "failed" with the reason. A JPEG or WebP is re-encoded once at ORIENT_QUALITY (92); a PNG is lossless; a picture that is already upright is never re-encoded. Animations are not turned.
Two facts about Cloudflare's Images binding decide how this is written. It applies a JPEG's own orientation tag by itself (and ignores the tag in a PNG or WebP), so the tag is removed before the turn is asked for, otherwise a JPEG would be turned twice; and flip happens before rotate, which is clockwise. orientationTransform(n) is that table for the seven orientations.
Both facts, and the table, were observed on the real service on 2026-10-08 through a scratch Worker run with wrangler dev --remote (which uses the real Images service, not the local emulation): a JPEG tagged 6 sent with an explicit 90 degree turn came out turned twice, the same file with no transform came out upright, and a PNG or WebP tagged 6 came out unturned; with the tag removed first, all seven orientations (2 to 8) in JPEG, PNG and WebP came out upright (21 of 21 fixtures, each checked by the colour of its four quadrants against the upright picture; a 640 by 480 photo with a GPS block came out upright with no tag and no location, within 0.3 of 255 per channel of Pillow's own reading of the tag). The local wrangler dev emulation is not the real service: it turns by 90, 180 and 270 degrees correctly but ignores flip, so orientations 2, 4, 5 and 7 come out mirrored there. Prove a mirrored orientation with --remote.
HEIC and AVIF
A HEIC or AVIF picture keeps its metadata in boxes that other boxes point into, so cleanImage does not rewrite it in place and answers needs_reencode. reencodeImage re-encodes it once through anything shaped like Cloudflare's Images binding (input(stream).output({ format, quality })) and cleans what comes back:
import { cleanImage, reencodeImage } from "@vibedout/media-core";
const cleaned = cleanImage(bytes);
if (!cleaned.ok && cleaned.reason === "needs_reencode") {
const result = await reencodeImage(env.IMAGES, bytes, { maxBytes: 20 * 1024 * 1024 });
if (result.outcome === "applied") store(result.bytes, result.type); // "jpeg" for a HEIC, "webp" for an AVIF
}A HEIC becomes a JPEG (a phone photo; every viewer and mail client reads JPEG) and an AVIF a WebP (often a picture with transparency, which WebP keeps), both at quality 92. The answer must be the format asked for, no larger than the picture's own size either way round (a decoder applies a HEIC's rotation, and may trim a few pixels), and at least half of it; anything else is failed with a detail. A HEIC's rotation is part of the picture, so any orientation tag on the output is removed, never applied again. Cloudflare's local Images emulation (sharp) reads AVIF but not HEIC: a HEIC is converted only by the real service.
Placeholders
A page shows a blurred preview and a background colour while a picture loads. Both come from a copy at most 100 pixels across, which a Worker cannot decode from a JPEG or a WebP itself, so the copy is asked of an image resizer as a PNG and decoded here:
import { placeholderFromImage } from "@vibedout/media-core/placeholder";
const result = await placeholderFromImage(env.IMAGES, object.body, { width: 4032, height: 3024 }); // one transformation
if (result.ok) save(result.placeholder.thumbhash, result.placeholder.color); // base64 ThumbHash, "#rrggbb"decodePng reads every PNG colour type and bit depth with its transparency (not interlaced files, nor ones larger than 2048 by 2048). The colour is the most common one among the opaque pixels (4,096 boxes, the mean of the fullest), which is more vivid than the mean of the whole picture. In the browser, thumbhashDataUrl(hash) gives a PNG data URL to use as a background until the picture arrives.
Moderation and description
import { describeImage, moderateImage, type AiLike } from "@vibedout/media-core/vision";
const ai = env.AI as unknown as AiLike;
const verdict = await moderateImage(ai, smallJpeg); // { status: "clean" } | { status: "flagged", label } | { status: "review_failed", reason }
const description = await describeImage(ai, smallJpeg, { language: "es-MX" }); // { ok: true, alt, tags } | { ok: false, reason }Both ask Mistral Small 3.1 on Workers AI (VISION_MODEL), with the picture as a base64 data URL. Moderation fails open: no binding, a failed call or an answer that is neither SAFE nor FLAG: <reason> is review_failed, never clean, and the caller decides (keep the picture, mark it, log it). A description is cleaned before it is returned: one line of alt text cut at a word (maxAlt, 300), tags trimmed, lower case in the language asked, without commas or repeats, at most maxTags (5) of at most maxTagLength (40) characters. A vision model judged by a prompt is not a purpose-built safety classifier: check it on your own pictures before relying on it.
Serving
import { servingHeaders } from "@vibedout/media-core/headers";
declare const object: { body: ReadableStream; size: number; mime: string; name: string };
export function download(): Response {
// `mime` is the type recorded when the file was stored (the sniffed one, never the uploader's claim)
const headers = servingHeaders({ mime: object.mime, filename: object.name, size: object.size });
return new Response(object.body, { headers });
}servingHeaders returns a plain object:
Content-Typefrom the recognised type (text types get; charset=utf-8; a mime this package does not know is served asapplication/octet-stream).Content-Disposition:inlinefor a raster image, audio, video or PDF;attachmentfor everything else.disposition: "inline"is never honoured for a type that is not inline-safe. The filename is written in both forms,filename="plain ascii"; filename*=UTF-8''percent-encoded, so "Fotografía de la boda.jpg" survives.X-Content-Type-Options: nosniff, always.Content-Security-Policy: sandboxfor every type that is not inline-safe and for the PDF.Content-LengthandETagwhen given.
It sets no Cross-Origin-Resource-Policy: a public file is embedded from other origins (an email). The PDF's sandbox flag is the sandbox field of its row in FILE_TYPES; a browser's built-in PDF viewer may refuse to draw a sandboxed PDF, in which case set disposition: "attachment" for it.
File names
import { cleanFilename } from "@vibedout/media-core/filename";
cleanFilename("../../etc/passwd"); // "passwd"
cleanFilename("CON.txt"); // "_CON.txt"
cleanFilename("photo.exe", { ext: "png" }); // "photo.png": the extension is replaced when it does not fit the type the bytes are
cleanFilename("evilgnp.exe"); // "evilgnp.exe": direction overrides and zero-width characters are droppedNo path, no control character, no Windows device name, NFC-normalised, at most 200 UTF-8 bytes (maxBytes) without cutting a character, and never empty.
Keys
import { buildAssetKey, parseAssetKey, newKeyToken, userScope, assetKeyPrefix } from "@vibedout/media-core/keys";
const token = newKeyToken(); // 22 base64url characters, 128 random bits
const key = buildAssetKey({ scope: userScope("user_01"), token, version: 2, name: "master", ext: "jpg" });
// assets/u-user_01/<token>/v2/master.jpg
parseAssetKey(key); // { scope, token, version: 2, name: "master", ext: "jpg" }, or null for anything it did not write
assetKeyPrefix("site", token); // "assets/site/<token>/": every version and size of one asset, to list or deletescope is site or u-<id>; name is master or the name of a size made from it. ASSET_VARIANTS is the closed ladder: w320, w640, w960, w1280, w1920 (width only, never enlarged) and the shapes sq400 and og1200, all WebP; EAGER_VARIANTS (w320) is the one to make at upload.
Hashing while streaming
import { hashingStream } from "@vibedout/media-core/hash";
import { r2Driver } from "@vibedout/media-core/r2";
export async function store(bucket: R2Bucket, key: string, request: Request): Promise<string> {
const length = Number(request.headers.get("content-length"));
const hashed = hashingStream(request.body as ReadableStream<Uint8Array>);
await r2Driver(bucket).put(key, hashed.stream, { size: length });
const { sha256, bytes } = await hashed.result; // 64 hex characters, and the number of bytes that went through
return `${sha256} (${bytes} bytes)`;
}On Workers the hash is computed by crypto.DigestStream as the chunks go by, with no copy kept. Where that does not exist (Node, a browser) the chunks are buffered up to maxBufferBytes (100 MiB by default) and hashed at the end; past that, HashFallbackTooLargeError. result rejects if the source fails or the stream is cancelled. readStreamWithLimit(stream, limit) reads a body into memory under a hard cap (it is @vibedout/provider-kit's readCapped, giving its refusal this package's own PayloadTooLargeError) and throws that the moment the cap is passed.
Storage
import type { StorageDriver } from "@vibedout/media-core/storage";
export async function replace(driver: StorageDriver, key: string, bytes: Uint8Array, sha256: string) {
const stored = await driver.put(key, bytes, { contentType: "image/jpeg", sha256, onlyIf: { etagDoesNotMatch: "*" } });
if (!stored) throw new Error("something is already stored under this key");
const range = await driver.get(key, { range: { offset: 0, length: 16 } });
return range?.body;
}StorageDriver has put (a stream body needs size; sha256 makes the store check the bytes; onlyIf makes the write conditional and answers null when it does not hold), get (with range and ifNoneMatch, which answers notModified with no body), head, delete, list and an optional multipart (create, resume; a session has uploadPart, complete, abort). Every part but the last must be the same size and at least 5 MiB.
r2Driver(bucket) implements it over a Workers R2 binding. A stream body is checked to be exactly size bytes long: a longer or shorter stream fails and stores nothing. memoryDriver() from /testing is the same behaviour in memory, and runStorageConformance is the suite both pass:
import { describe, expect, it } from "vitest";
import { memoryDriver, runStorageConformance } from "@vibedout/media-core/testing";
runStorageConformance(() => memoryDriver(), { describe, it, expect: expect as never });deleteByPrefix(driver, prefix) removes everything under a prefix (one version of an asset with its sizes, versionKeyPrefix(...), or the whole asset, assetKeyPrefix(...)) a page of a thousand at a time and answers how many objects there were; an empty prefix is refused.
Limits and quota
import { maxBytesFor, quotaCharges, quotaFor, quotaReasonOf } from "@vibedout/media-core/limits";
maxBytesFor("image"); // 20 MiB; video 100, audio 50, document 50, archive and other 25 (mebibytes)
const limits = quotaFor("menu", { menu: { count: 400, bytes: 1024 * 1024 * 1024 } }); // an app's row, else 100 uploads and 200 MiB a day
const charges = quotaCharges(limits, 3_000_000); // [{ counter: "count", amount: 1, cap }, { counter: "bytes", amount: 3000000, cap }]
quotaReasonOf("bytes"); // "daily_bytes_exceeded"quotaCharges has the shape a capped period counter takes for its reservation, so the counting stays in whatever store the application has. MULTIPART_THRESHOLD_BYTES (95 MiB) is where a single request body stops being streamed through a Worker and a multipart upload takes over, up to DEFAULT_MULTIPART_CAP_BYTES (1 GiB).
Scanning
import { isServableAfterScan, noneScanner, type Scanner } from "@vibedout/media-core/scanner";
const scanner: Scanner = noneScanner; // scans nothing and says "skipped"
const verdict = await scanner.scan({ bytes: new Uint8Array(0), mime: "application/pdf", size: 0 });
isServableAfterScan(verdict); // true; with { requireClean: true } a skipped file is held backA verdict is clean, infected, skipped (no scanner) or error (the scanner failed: never treated as clean). A real scanner is another implementation of Scanner; fakeScanner from /testing scripts one.
Third-party code
The build bundles thumbhash 0.1.1 (the ThumbHash encoder and decoder of ./placeholder), under the MIT licence:
Copyright (c) 2023 Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
