← ui

media-asset

lib

what a prepared file is: kind, size, length, poster, blur, colour and frames

npx shadcn add @ag/media-asset
Already installed? Preview an update
npx shadcn add @ag/media-asset --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: kind: "audio" (its width and height its picture's, 0 when it has none), average (the mean colour, #rrggbb), and frames: { every, count, src }, stills along a clip, frame i at i × every seconds at src with {i} replaced. isMediaAsset checks them. The asset is written to be the contract an app preparing with AVFoundation emits too.
Updated 2026-10-10 · release notes

shelf media

used by prepare-media, upload, browser

the contract

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

media-asset.demo.tsx
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

media-asset.ts
// 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}")))
  )
}