← ui

prepare-media

lib

images, video and sound made ready in Node: posters, previews, frames, FFmpeg

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

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

Latest changes · 2026-10-15
  • averageOf(bytes): a picture's mean colour from bytes fetched at request time, the same measure a prepared picture's average is (a contact's poster, a quote's ground).
Updated 2026-10-15 · release notes

also called transcode

shelf media

uses media-asset

Run preparation in Node at upload time or in a CLI. It returns files and metadata; your application chooses storage and publishes only after the files are written. Video requires FFmpeg and ffprobe on PATH and an explicit video option. Run heavy video work in a worker, not an edge request.

For a deliberate boomerang, add --boomerang to a GIF or video input. Preparation renders a silent forward/reverse MP4, ready for Video with loop enabled. Clips must be 0.1–10 seconds. Without this option, GIF bytes and animation stay intact.

Prepared river landscape

the demo above, verbatim

prepare-media.demo.tsx
import { readFile } from "node:fs/promises"
import { Sample } from "@/app/samples"
import { Image } from "@/registry/base-nova/ui/image"
import { mediaAsset, prepareMedia } from "./prepare-media"

// #region prepare
async function example() {
  const prepared = await prepareMedia(await readFile("public/glass-river.jpg"))
  // A real uploader writes prepared.files to its own storage before publishing this metadata.
  // This example uses data URLs so its prepared output requires no upload service.
  return mediaAsset(prepared, (name) => {
    const file = prepared.files.find((f) => f.name === name)
    if (!file) throw new Error("Missing prepared file")
    return `data:${file.mime};base64,${file.data.toString("base64")}`
  })
}
// #endregion
export default async function Demo() {
  const asset = await example()
  return (
    <div className="space-y-8">
      <p className="text-sm text-muted-foreground">
        Run preparation in Node at upload time or in a CLI. It returns files and
        metadata; your application chooses storage and publishes only after the
        files are written. Video requires FFmpeg and ffprobe on PATH and an
        explicit video option. Run heavy video work in a worker, not an edge
        request.
      </p>
      <p className="text-sm text-muted-foreground">
        For a deliberate boomerang, add --boomerang to a GIF or video input.
        Preparation renders a silent forward/reverse MP4, ready for Video with
        loop enabled. Clips must be 0.1–10 seconds. Without this option, GIF
        bytes and animation stay intact.
      </p>
      <Sample
        name="prepared"
        with="prepare"
        title="the output of real preparation"
      >
        <Image
          src={asset.src}
          width={asset.width}
          height={asset.height}
          blurDataURL={asset.blurDataURL}
          alt="Prepared river landscape"
          className="w-full rounded-xl"
          unoptimized
        />
      </Sample>
    </div>
  )
}

the source, every file an install writes

prepare-media.ts
// PREPARE A FILE ONCE, for the web: its facts (`MediaAsset`) and the files a page shows
// (the source made web-ready, a poster, stills along a clip), content-addressed, with
// no storage, credentials or database: the host writes the files where it keeps them
// and stores the asset beside them.
//
// What comes in: bytes (an upload), a path (a file on disk) or a URL (a file on
// another host, read by ranges: ffmpeg seeks, so a span of a long video costs that
// span). Images go through sharp, untouched but measured; video and sound through
// ffmpeg, opt-in. What a gallery needs beyond a player is asked for, not assumed: a
// `span` of the source, `frames` along it (a still every so many seconds, what a cell
// scrubs and a hover lands on), sound alone (`audioOnly`, a speech kept as audio), and
// always the picture's `average` colour.
//
// The asset is the contract (lib/media-asset): a Mac app making the same JSON with
// AVFoundation is read by the same gallery.

import { execFile } from "node:child_process"
import { createHash } from "node:crypto"
import {
  mkdir,
  mkdtemp,
  readdir,
  readFile,
  rm,
  writeFile,
} from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { promisify } from "node:util"
import sharp from "sharp"
import type { MediaAsset } from "@/registry/base-nova/lib/media-asset"

const exec = promisify(execFile)

/** What is prepared: its bytes, a file on disk, or a file on another host. */
export type MediaInput =
  | Buffer
  | { path: string }
  | { url: string; headers?: Record<string, string> }

export type PreparedMedia = {
  asset: Omit<MediaAsset, "src" | "poster" | "frames"> & {
    /** the frames' names, `{i}` the index: `mediaAsset` resolves them */
    frames?: { every: number; count: number; name: string }
  }
  files: {
    role: "source" | "poster" | "frame"
    name: string
    mime: string
    data: Buffer
  }[]
}

export type PrepareOptions = {
  /** The most bytes an input held in memory may have (an upload). */
  maxBytes?: number
  maxPixels?: number
  /** Enable video preparation. Requires ffmpeg/ffprobe on PATH. */
  video?: boolean
  /** Keep sound in the prepared video. Preview components still play muted. */
  audio?: boolean
  /** Prepare the sound alone, as AAC in MP4 (`kind: "audio"`), its picture (when the
   *  source has one) as the poster. Implies `video`. */
  audioOnly?: boolean
  posterFormat?: "webp" | "jpeg"
  /** A chosen video poster time, clamped to the clip duration. */
  posterTime?: number
  /** Prepare a silent forward/reverse video from a GIF or video up to 10 seconds.
   * Explicitly enables FFmpeg processing; ordinary images remain untouched. Left
   * unset, a video is prepared as it plays. */
  playback?: "boomerang"
  /** The part of a video or sound kept, in seconds of the source. */
  span?: { from: number; to: number }
  /** A still every `every` seconds of the prepared clip, `width` px wide (480). */
  frames?: { every: number; width?: number }
  /** The longest side of a prepared video, px (1920). */
  maxSide?: number
  signal?: AbortSignal
}

/** The longest a prepared video or sound may run, seconds. */
const MAX_SECONDS = 600

async function preview(
  data: Buffer,
  maxPixels: number,
  format: "webp" | "jpeg" = "webp",
) {
  const image = sharp(data, { limitInputPixels: maxPixels }).rotate()
  const poster = await image
    .clone()
    .resize({
      width: 1600,
      height: 1600,
      fit: "inside",
      withoutEnlargement: true,
    })
    .toFormat(format, { quality: 82 })
    .toBuffer()
  // PNG keeps transparency. Blur belongs to presentation, not a lossy preprocessing pass.
  const tiny = await image
    .clone()
    .resize({ width: 16, height: 16, fit: "inside", withoutEnlargement: true })
    .png()
    .toBuffer()
  return {
    poster,
    mime: `image/${format}`,
    ext: format === "jpeg" ? "jpg" : "webp",
    blurDataURL: `data:image/png;base64,${tiny.toString("base64")}`,
    average: await averageOf(data, maxPixels),
  }
}

/** A picture's mean colour as `#rrggbb`, flattened over black: what a cell shows
 *  before the picture, and the colour anything drawn from it is tinted by (a
 *  quote's ground, a contact's poster). A true mean of every pixel (a 1×1 resize
 *  only estimates one), so the Swift preparer's CIAreaAverage gives the same
 *  colour. The one measure of it: a picture that is not prepared (an avatar
 *  fetched at request time) is measured here too. */
export async function averageOf(
  data: Buffer | Uint8Array,
  maxPixels = 40_000_000,
): Promise<string> {
  const { channels } = await sharp(data, { limitInputPixels: maxPixels })
    .flatten()
    .stats()
  return `#${channels
    .slice(0, 3)
    .map((c) => Math.round(c.mean).toString(16).padStart(2, "0"))
    .join("")}`
}

/** An input as bytes, for the image path. */
async function bytesOf(
  input: MediaInput,
  maxBytes: number,
  signal?: AbortSignal,
) {
  if (Buffer.isBuffer(input)) return input
  if ("path" in input) return readFile(input.path, { signal })
  const res = await fetch(input.url, { headers: input.headers, signal })
  if (!res.ok) throw new Error(`${input.url} answered ${res.status}`)
  const length = Number(res.headers.get("content-length") ?? 0)
  if (length > maxBytes)
    throw new Error(`Media must contain 1–${maxBytes} bytes`)
  return Buffer.from(await res.arrayBuffer())
}

const isVideoURL = (input: MediaInput) =>
  !Buffer.isBuffer(input) &&
  /\.(mp4|m4v|mov|webm|mkv|ogv|ogg|m4a|mp3|wav)(\?|$)/i.test(
    "path" in input ? input.path : input.url,
  )

/** Prepare once. Upload returned files using the application's own store.
 * Originals are preserved; no credentials, publication, or database writes. */
export async function prepareMedia(
  input: MediaInput,
  options: PrepareOptions = {},
): Promise<PreparedMedia> {
  const {
    maxBytes = 32 * 1024 * 1024,
    maxPixels = 40_000_000,
    signal,
  } = options
  signal?.throwIfAborted()
  const boomerang = options.playback === "boomerang"
  const timed = isVideoURL(input) || options.audioOnly
  // Images (and a GIF to boomerang) are read whole; video and sound stream to ffmpeg.
  const data = timed ? null : await bytesOf(input, maxBytes, signal)
  if (data && (!data.length || data.length > maxBytes))
    throw new Error(`Media must contain 1–${maxBytes} bytes`)
  const meta = data
    ? await sharp(data, { limitInputPixels: maxPixels })
        .metadata()
        .catch(() => null)
    : null
  // Sharp identifies AVIF through its HEIF container and AV1 compression.
  const format =
    meta?.format === "heif" && meta.compression === "av1"
      ? "avif"
      : meta?.format
  if (meta && boomerang && (meta.pages ?? 1) <= 1)
    throw new Error("Boomerang requires an animated image or video")
  if (
    data &&
    meta &&
    !boomerang &&
    ["jpeg", "png", "webp", "gif", "avif"].includes(format ?? "")
  ) {
    const height = meta.pageHeight ?? meta.height
    if (!meta.width || !height || meta.width * height > maxPixels)
      throw new Error("Invalid image dimensions")
    const rotated = (meta.orientation ?? 0) >= 5
    const width = rotated ? height : meta.width
    const displayHeight = rotated ? meta.width : height
    const animated = (meta.pages ?? 1) > 1
    const ext = format === "jpeg" ? "jpg" : format
    const mime = `image/${format}`
    const p = await preview(data, maxPixels, options.posterFormat)
    const id = createHash("sha256")
      .update(data)
      .update(p.poster)
      .update(p.blurDataURL)
      .digest("hex")
      .slice(0, 24)
    signal?.throwIfAborted()
    return {
      asset: {
        kind: animated ? "animation" : "image",
        width,
        height: displayHeight,
        mime,
        bytes: data.length,
        blurDataURL: p.blurDataURL,
        average: p.average,
      },
      files: [
        {
          role: "source",
          name: `${id}-${width}x${displayHeight}.${ext}`,
          mime,
          data,
        },
        {
          role: "poster",
          name: `${id}.poster.${p.ext}`,
          mime: p.mime,
          data: p.poster,
        },
      ],
    }
  }
  if (!options.video && !options.audioOnly && !boomerang)
    throw new Error(
      "Unsupported image; video preparation must be explicitly enabled",
    )
  const dir = await mkdtemp(join(tmpdir(), "prepare-media-"))
  try {
    // What ffmpeg reads: the bytes written down, the path, or the URL. Its headers are
    // options OF that input, so they stand before the input they are for (`-i`).
    let target: string
    let reading: string[] = []
    if (data) {
      target = join(dir, "source")
      await writeFile(target, data)
    } else if (Buffer.isBuffer(input)) throw new Error("No media to read")
    else if ("path" in input) target = input.path
    else {
      target = input.url
      if (input.headers)
        reading = [
          "-headers",
          Object.entries(input.headers)
            .map(([k, v]) => `${k}: ${v}\r\n`)
            .join(""),
        ]
    }
    /** The input, as ffmpeg takes it: its options, then `-i` and it. */
    const from = [...reading, "-i", target]
    const run = (bin: string, args: string[]) =>
      exec(bin, args, { signal, timeout: 300_000, maxBuffer: 1024 * 1024 })
    const { stdout } = await run("ffprobe", [
      "-v",
      "error",
      "-show_streams",
      "-show_format",
      "-of",
      "json",
      ...reading,
      target,
    ])
    const probe = JSON.parse(stdout)
    const stream = probe.streams?.find(
      (s: { codec_type: string }) => s.codec_type === "video",
    )
    const sound = probe.streams?.some(
      (s: { codec_type: string }) => s.codec_type === "audio",
    )
    const total = Number(probe.format?.duration)
    const span = options.span
    const length = span ? span.to - span.from : total
    if (
      (!stream && !options.audioOnly) ||
      (options.audioOnly && !sound) ||
      !Number.isFinite(length) ||
      length <= 0 ||
      length > MAX_SECONDS ||
      (span && (span.from < 0 || span.to > total + 0.5)) ||
      (stream && stream.width * stream.height > maxPixels)
    )
      throw new Error(
        `Expected ${options.audioOnly ? "sound" : "a video"} up to ${MAX_SECONDS / 60} minutes within the pixel limit${span ? ", the span inside it" : ""}`,
      )
    if (boomerang && (length < 0.1 || length > 10))
      throw new Error("Boomerang clips must be between 0.1 and 10 seconds")
    if (boomerang && options.audio === true)
      throw new Error(
        "Boomerang clips are silent; omit audio or set it to false",
      )
    // The span is a seek before the input: only its bytes are read.
    const seek = span ? ["-ss", String(span.from), "-t", String(length)] : []
    const side = options.maxSide ?? 1920
    const fit = `scale='if(gt(iw,ih),min(${side},iw),-2)':'if(gt(iw,ih),-2,min(${side},ih))'`
    const posterPath = join(dir, "poster.png")

    if (options.audioOnly) {
      const output = join(dir, "sound.m4a")
      await run("ffmpeg", [
        "-v",
        "error",
        ...seek,
        ...from,
        "-vn",
        "-c:a",
        "aac",
        "-b:a",
        "128k",
        "-movflags",
        "+faststart",
        output,
      ])
      // Its picture, when it has one: the source a second in.
      const p = stream
        ? await (async () => {
            await run("ffmpeg", [
              "-v",
              "error",
              "-ss",
              String((span?.from ?? 0) + Math.min(1, length / 2)),
              ...from,
              "-frames:v",
              "1",
              "-vf",
              fit,
              posterPath,
            ])
            // ffmpeg can end well and write nothing when a remote source drops.
            const picture = await readFile(posterPath).catch(() => {
              throw new Error(
                `The source gave no picture at ${(span?.from ?? 0) + Math.min(1, length / 2)} s`,
              )
            })
            return preview(picture, maxPixels, options.posterFormat)
          })()
        : null
      const dims = p ? await sharp(p.poster).metadata() : null
      const bytes = await readFile(output)
      const rev = createHash("sha256").update(bytes).digest("hex").slice(0, 24)
      return {
        asset: {
          kind: "audio",
          width: dims?.width ?? 0,
          height: dims?.height ?? 0,
          mime: "audio/mp4",
          bytes: bytes.length,
          duration: length,
          ...(p && { blurDataURL: p.blurDataURL, average: p.average }),
        },
        files: [
          {
            role: "source",
            name: `${rev}.m4a`,
            mime: "audio/mp4",
            data: bytes,
          },
          ...(p
            ? [
                {
                  role: "poster" as const,
                  name: `${rev}.poster.${p.ext}`,
                  mime: p.mime,
                  data: p.poster,
                },
              ]
            : []),
        ],
      }
    }

    const output = join(dir, "video.mp4")
    // Reverse is prepared once, never emulated with browser seeks. Trim duplicate
    // turnaround frames so the loop does not pause at either endpoint.
    const filter = `scale=w='min(1280,iw)':h='min(1280,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=30,split[f][r];[r]reverse,trim=start_frame=1:end=${length - 1 / 30},setpts=PTS-STARTPTS[b];[f][b]concat=n=2:v=1:a=0[v]`
    // Forward renditions preserve audio for native players; boomerangs are silent.
    await run("ffmpeg", [
      "-v",
      "error",
      ...seek,
      ...from,
      ...(boomerang
        ? ["-filter_complex", filter, "-map", "[v]"]
        : ["-map", "0:v:0", "-map", "0:a?", "-vf", fit]),
      "-c:v",
      "libx264",
      "-pix_fmt",
      "yuv420p",
      "-crf",
      "23",
      ...(boomerang || options.audio === false ? ["-an"] : ["-c:a", "aac"]),
      "-movflags",
      "+faststart",
      output,
    ])
    await run("ffmpeg", [
      "-v",
      "error",
      "-ss",
      String(Math.max(0, Math.min(options.posterTime ?? 0, length / 2))),
      "-i",
      output,
      "-frames:v",
      "1",
      posterPath,
    ])
    const posterBytes = await readFile(posterPath)
    const dimensions = await sharp(posterBytes).metadata()
    if (!dimensions.width || !dimensions.height)
      throw new Error("Missing video dimensions")
    const p = await preview(posterBytes, maxPixels, options.posterFormat)
    const video = await readFile(output)
    const outputProbe = await run("ffprobe", [
      "-v",
      "error",
      "-show_entries",
      "format=duration",
      "-of",
      "json",
      output,
    ])
    const outputDuration = Number(
      JSON.parse(outputProbe.stdout).format.duration,
    )
    const rev = createHash("sha256")
      .update(video)
      .update(p.poster)
      .update(p.blurDataURL)
      .digest("hex")
      .slice(0, 24)
    // Frames along the clip: frame i is the picture at i × every seconds.
    const frames: PreparedMedia["files"] = []
    if (options.frames) {
      const framesDir = join(dir, "frames")
      await mkdir(framesDir)
      await run("ffmpeg", [
        "-v",
        "error",
        "-i",
        output,
        "-vf",
        `fps=1/${options.frames.every},scale=${options.frames.width ?? 480}:-2`,
        "-q:v",
        "7",
        "-start_number",
        "0",
        join(framesDir, "%d.jpg"),
      ])
      const names = (await readdir(framesDir))
        .map((n) => Number.parseInt(n, 10))
        .sort((a, b) => a - b)
      for (const i of names)
        frames.push({
          role: "frame",
          name: `${rev}.frame-${i}.jpg`,
          mime: "image/jpeg",
          data: await readFile(join(framesDir, `${i}.jpg`)),
        })
    }
    return {
      asset: {
        kind: "video",
        width: dimensions.width,
        height: dimensions.height,
        mime: "video/mp4",
        bytes: video.length,
        duration: outputDuration,
        blurDataURL: p.blurDataURL,
        average: p.average,
        ...(options.frames &&
          frames.length > 0 && {
            frames: {
              every: options.frames.every,
              count: frames.length,
              name: `${rev}.frame-{i}.jpg`,
            },
          }),
      },
      files: [
        { role: "source", name: `${rev}.mp4`, mime: "video/mp4", data: video },
        {
          role: "poster",
          name: `${rev}.poster.${p.ext}`,
          mime: p.mime,
          data: p.poster,
        },
        ...frames,
      ],
    }
  } finally {
    await rm(dir, { recursive: true, force: true })
  }
}

/** Resolve storage URLs after writing the prepared files. `url` maps a file's name to
 *  its address and leaves `{i}` in a frames name as it is. */
export function mediaAsset(
  prepared: PreparedMedia,
  url: (name: string) => string,
): MediaAsset {
  const source = prepared.files.find((f) => f.role === "source")
  if (!source) throw new Error("Prepared media has no source")
  const poster = prepared.files.find((f) => f.role === "poster")
  const { frames, ...asset } = prepared.asset
  return {
    ...asset,
    src: url(source.name),
    ...(poster ? { poster: url(poster.name) } : {}),
    ...(frames
      ? {
          frames: {
            every: frames.every,
            count: frames.count,
            src: url(frames.name),
          },
        }
      : {}),
  }
}