what a prepared file is: kind, size, length, poster, blur, colour and frames
npx shadcn add @ag/media-assetAlready installed? Preview an update
npx shadcn add @ag/media-asset --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-10
- New:
kind: "audio"(itswidthandheightits picture's, 0 when it has none),average(the mean colour,#rrggbb), andframes: { every, count, src }, stills along a clip, frameiati × everyseconds atsrcwith{i}replaced.isMediaAssetchecks them. The asset is written to be the contract an app preparing with AVFoundation emits too.
shelf media
used by prepare-media, upload, browser
independent of storage
{
"valid": true,
"asset": {
"src": "/glass-river.jpg",
"kind": "image",
"width": 1200,
"height": 900,
"mime": "image/jpeg",
"bytes": 196429
}
}the demo above, verbatim
import { Sample } from "@/app/samples"
import "@/registry/base-nova/ui/edge-fade"
import { isMediaAsset, type MediaAsset } from "./media-asset"
// #region asset
const asset: MediaAsset = {
src: "/glass-river.jpg",
kind: "image",
width: 1200,
height: 900,
mime: "image/jpeg",
bytes: 196429,
}
// #endregion
export default function Demo() {
return (
<Sample
name="metadata"
with="asset"
title="the contract"
description="independent of storage"
>
<pre className="ag-edge-fade-xy text-xs">
{JSON.stringify({ valid: isMediaAsset(asset), asset }, null, 2)}
</pre>
</Sample>
)
}the source, every file an install writes
// A MEDIA ASSET'S FACTS, portable: what a file is, how big, how long, its picture and
// its tone, and the stills along it, stored beside the file and read by whatever draws
// it. Never inferred in a renderer.
//
// THE CONTRACT BETWEEN PREPARATION AND DISPLAY, whoever prepares. `prepare-media` makes
// it in Node (ffmpeg, sharp); a Mac app makes the same JSON with AVFoundation
// (`AVAssetExportSession` for a span, `AVAssetImageGenerator` for frames at
// `i × every` seconds, `CIAreaAverage` for the tone), and a gallery reads either
// without knowing which. Every unit is the one both sides already speak: pixels,
// bytes, seconds.
export type MediaAsset = {
src: string
kind: "image" | "animation" | "video" | "audio"
/** pixels; for audio, its picture's (0 when it has none) */
width: number
height: number
mime: string
bytes: number
blurDataURL?: string
poster?: string
/** seconds */
duration?: number
/** the picture's mean colour, `#rrggbb`: what stands in before it loads */
average?: string
/** stills along it: frame `i` (from 0, `count` of them) is the picture at
* `i × every` seconds, at `src` with `{i}` replaced */
frames?: { every: number; count: number; src: string }
}
const finite = (n: unknown, min: number) =>
typeof n === "number" && Number.isFinite(n) && n >= min
/** Validate responses at the upload boundary; no framework or storage dependency. */
export function isMediaAsset(value: unknown): value is MediaAsset {
if (!value || typeof value !== "object") return false
const a = value as Record<string, unknown>
const f = a.frames as Record<string, unknown> | undefined
const sized =
a.kind === "audio"
? [a.width, a.height].every((n) => finite(n, 0))
: [a.width, a.height].every((n) => finite(n, 0) && (n as number) > 0)
return (
typeof a.src === "string" &&
a.src.length > 0 &&
["image", "animation", "video", "audio"].includes(String(a.kind)) &&
typeof a.mime === "string" &&
sized &&
finite(a.bytes, 0) &&
(a.bytes as number) > 0 &&
(a.poster === undefined || typeof a.poster === "string") &&
(a.blurDataURL === undefined || typeof a.blurDataURL === "string") &&
(a.duration === undefined || finite(a.duration, 0)) &&
(a.average === undefined ||
(typeof a.average === "string" && /^#[0-9a-f]{6}$/i.test(a.average))) &&
(f === undefined ||
(finite(f.every, 0) &&
(f.every as number) > 0 &&
finite(f.count, 1) &&
typeof f.src === "string" &&
f.src.includes("{i}")))
)
}