Export
The record button. Wrap any machine and it can be saved as an animated WebP, an animated GIF or a still — encoded in the page, with nothing uploaded.
Open in workbenchvariant
view
Drawn by the machine this file solves for, so you can see the maths move.
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/robot-export.jsonNotes
- Every number is typed, not picked. The chips are shortcuts to values the field beside them would take anyway: 7.5 seconds at 24 fps scaled 1.75× is as available as the two-second default, and arrow keys step a field.
- Seconds, rate and frames are three views of one recording. Asking for 90 frames at 30 fps is asking for three seconds, and the field you did not touch follows.
- The button carries `data-robocn-hide`, and the snapshotter drops those from the clone — otherwise every recording would have a record button in the corner of it.
- Settings are per menu, not global: the scale, rate and ground of one panel do not follow you to the next. `onSettingsChange` is there for a page that wants them to.
- Everything runs in the page — `robot-capture` does the work and the file is an anchor click. Notes: `docs/export.md`.
Usage
import { RobotExport, ExportMenu } from "@/components/ui/robot-export"
// Wrap a machine: the button appears over its corner on hover.
<RobotExport name="robot-arm">
<RobotArm behavior="sweep" />
</RobotExport>
// Or put the menu in a toolbar that already knows its target.
<ExportMenu target={stageRef} name="robot-arm" label="Export" />
// Open on your own numbers, and widen what the fields will accept.
<RobotExport
name="robot-arm"
defaults={{ format: "gif", seconds: 7.5, fps: 24, scale: 1.5, ground: "custom", groundColor: "#101014" }}
limits={{ fps: [1, 120], frames: 2000 }}
presets={{ seconds: [0, 3, 7.5], fps: [24, 60], scale: [] }}
>
<RobotArm behavior="sweep" />
</RobotExport>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | "robocn" | File stem. The extension comes from the format. |
| target | RefObject<Element> | (() => Element | null) | — | Record something other than the wrapper — the machine itself, or a whole stage. |
| formats | ("webp" | "gif" | "png")[] | ["webp", "gif", "png"] | Which formats the menu offers, in order. WebP is dropped on a browser whose canvas cannot write it. |
| defaults | Partial<ExportSettings> | — | Opening settings: `format`, `seconds` (0 is a still), `fps`, `scale`, `quality`, `loop`, `ground` and `groundColor`. |
| limits | Partial<ExportLimits> | 0–120 s, 1–60 fps, 0.1–8×, 900 frames | What the fields will accept. The ceiling stops a typo asking for a hundred thousand frames; raise it and the field takes the higher number. |
| presets | { seconds?: number[]; fps?: number[]; scale?: number[] } | — | The chips beside each field — shortcuts, not the range. An empty array leaves a field bare. |
| onSettingsChange | (settings: ExportSettings) => void | — | On `ExportMenu`: every setting as it changes, for a page that wants to remember them. |
| destinations | ExportDestination[] | — | Where the file may go. One is used silently; more than one becomes a row in the menu. Defaults to the browser's download. |
| alwaysVisible | boolean | false | Keep the button on screen instead of revealing it on hover or focus. |
| corner | "start" | "end" | "end" | Which top corner the button sits in. |
| open / onOpenChange | boolean / (open: boolean) => void | — | On `ExportMenu`: drive the menu from outside, which is how the workbench binds it to a key. |
| destinations | ExportDestination[] | [{ id: "download", label: "Download" }] | Where the finished file may go. One is used silently; more than one becomes a row in the menu. A destination with a `write(blob, filename)` callback replaces the browser's download — that is how the workbench writes a recording into a folder it is holding. |
Source
src/components/ui/robot-export.tsx
"use client"
/**
* The record button: any machine, any pose, out of the page as a file.
*
* Two shapes of the same control. `RobotExport` wraps a machine and floats the
* button over its corner, so a component gets an export path by being wrapped.
* `ExportMenu` is that menu on its own, for a toolbar that already knows what
* it is pointing at — hand it a ref.
*
* Every number is typed, not picked: the chips are shortcuts to values the
* field beside them will take anyway, so a 7.5-second, 24 fps, 1.75× recording
* is as available as a two-second one. Seconds, rate and frame count are three
* views of the same recording and stay consistent with each other.
*
* Everything happens in the page: `@/lib/robocn/capture` snapshots the DOM,
* `gif.ts` and `webp.ts` write the container, and the file is an anchor click.
* Nothing is uploaded. Notes: `docs/export.md`.
*/
import * as React from "react"
import { Check, Download, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
backgroundBehind,
exportNode,
frameCount,
supportsWebp,
type CaptureTarget,
type ExportFormat,
} from "@/lib/robocn/capture"
import { cn } from "@/lib/utils"
/** What the menu is pointed at: a ref, a getter, or nothing — meaning the wrapper. */
export type ExportTarget =
| React.RefObject<HTMLElement | null>
| React.RefObject<SVGElement | null>
| React.RefObject<CaptureTarget | null>
| (() => CaptureTarget | null)
/**
* Somewhere a finished recording can go.
*
* The browser's downloads folder is the default and the only one most pages
* offer. A page that is holding a directory handle can offer a second — the
* repository the picture was destined for — without this component knowing
* anything about how that folder was granted.
*/
export interface ExportDestination {
id: string
label: string
/** Omitted for the browser's own download. Returns where the file landed. */
write?: (blob: Blob, filename: string) => Promise<string> | string
}
const DOWNLOAD: ExportDestination = { id: "download", label: "Download" }
/** Where the recording's ground comes from. */
export type ExportGround = "page" | "none" | "custom"
export interface ExportSettings {
format: ExportFormat
/** Seconds of motion. Zero is a still, and so is any length in PNG. */
seconds: number
/** Frames a second to aim for. What lands in the file is what was measured. */
fps: number
/** Device pixels per CSS pixel. Fractional is fine. */
scale: number
/** WebP quality, 0–1. */
quality: number
/** Repeat count for the animated formats; 0 is forever. */
loop: number
/** The page's own background, no background at all, or a colour you name. */
ground: ExportGround
/** Used when `ground` is `custom`. Any CSS colour. */
groundColor: string
}
/**
* What the fields will accept.
*
* Wide on purpose — the ceiling is there to stop a typo asking for a hundred
* thousand frames, not to have an opinion about what a recording should be.
*/
export interface ExportLimits {
seconds: [number, number]
fps: [number, number]
scale: [number, number]
/** The most frames one recording may hold, whatever seconds × rate asks for. */
frames: number
}
export const exportLimits: ExportLimits = {
seconds: [0, 120],
fps: [1, 60],
scale: [0.1, 8],
frames: 900,
}
/** The chips above each field: shortcuts, not the range. */
export interface ExportPresets {
seconds?: number[]
fps?: number[]
scale?: number[]
}
export const exportPresets: Required<ExportPresets> = {
seconds: [0, 1, 2, 4, 8],
fps: [10, 15, 24, 30],
scale: [1, 2, 3],
}
const defaultSettings: ExportSettings = {
format: "webp",
seconds: 2,
fps: 15,
scale: 2,
quality: 0.92,
loop: 0,
ground: "page",
groundColor: "#ffffff",
}
const formatLabel: Record<ExportFormat, string> = {
webp: "WebP",
gif: "GIF",
png: "PNG",
}
let webpSupport: boolean | null = null
/** Whether this browser's canvas can write WebP at all, memoized per page. */
const canWriteWebp = () => {
if (typeof document === "undefined") return true
if (webpSupport === null) webpSupport = supportsWebp()
return webpSupport
}
const kilobytes = (bytes: number) =>
bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
const clamp = (value: number, [low, high]: [number, number]) =>
Math.min(high, Math.max(low, value))
/** `2` not `2.00`, `2.5` not `2.500`: the number as someone would say it. */
const trim = (value: number, places = 2) =>
String(Number(value.toFixed(places)))
const chipClass = (selected: boolean) =>
cn(
"h-6 rounded-sm px-1.5 font-mono text-[11px] transition-colors hover:bg-muted",
"disabled:pointer-events-none",
selected ? "bg-foreground text-background hover:bg-foreground" : "text-muted-foreground",
)
const fieldClass =
"h-6 w-16 rounded-sm border border-border bg-background px-1.5 text-right font-mono " +
"text-[11px] tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring " +
"disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [appearance:textfield]"
interface RowProps<T> {
label: string
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
disabled?: boolean
}
/** One line of the menu: a label and a row of pressed-state buttons. */
function Row<T extends string | number | boolean>({
label,
value,
options,
onChange,
disabled,
}: RowProps<T>) {
return (
<div className={cn("flex items-center justify-between gap-3", disabled && "opacity-40")}>
<span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
{label}
</span>
<div className="flex items-center gap-0.5" role="group" aria-label={label}>
{options.map((option) => (
<button
key={String(option.value)}
type="button"
disabled={disabled}
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className={chipClass(option.value === value)}
>
{option.label}
</button>
))}
</div>
</div>
)
}
interface NumberFieldProps {
label: string
value: number
onChange: (value: number) => void
range: [number, number]
step?: number
/** Shortcuts. The field still takes anything inside `range`. */
presets?: number[]
presetLabel?: (value: number) => string
suffix?: string
disabled?: boolean
/** Shown under the row: what this number means for the file. */
hint?: string
}
/**
* A typed number with chips beside it.
*
* A text input rather than `type="number"`: a number input sanitizes its own
* value, so a half-typed `0.` or `-` reads back as the empty string and a
* decimal cannot be entered at all. The draft is the text as typed; every
* value parsed out of it is clamped into the field's range, and letting go of
* the field snaps it to what was actually taken.
*/
function NumberField({
label,
value,
onChange,
range,
step = 1,
presets,
presetLabel,
suffix,
disabled,
hint,
}: NumberFieldProps) {
const [draft, setDraft] = React.useState<string | null>(null)
const commit = (text: string) => {
setDraft(text)
if (text.trim() === "") return
const parsed = Number(text)
if (Number.isFinite(parsed)) onChange(clamp(parsed, range))
}
return (
<div className={cn("flex flex-col gap-1", disabled && "opacity-40")}>
{/* Label and field on one line, chips under them: five shortcuts and a
typed number do not fit across a popover, and the field is the part
that must never be clipped. */}
<div className="flex items-center justify-between gap-2">
<label className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
{label}
</label>
<span className="flex items-center gap-1">
<input
type="text"
inputMode="decimal"
aria-label={label}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={range[0]}
aria-valuemax={range[1]}
value={draft ?? trim(value, 3)}
disabled={disabled}
onChange={(event) => commit(event.target.value)}
onBlur={() => setDraft(null)}
onKeyDown={(event) => {
// Arrows step the value the way a number input would.
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
event.preventDefault()
setDraft(null)
onChange(clamp(value + (event.key === "ArrowUp" ? step : -step), range))
}}
className={fieldClass}
/>
<span className="w-7 font-mono text-[10px] text-muted-foreground">{suffix ?? ""}</span>
</span>
</div>
{presets?.length ? (
<div
className="flex flex-wrap items-center justify-end gap-0.5 pr-7"
role="group"
aria-label={`${label} presets`}
>
{presets.map((preset) => (
<button
key={preset}
type="button"
disabled={disabled}
aria-pressed={preset === value}
onClick={() => {
setDraft(null)
onChange(clamp(preset, range))
}}
className={chipClass(preset === value)}
>
{presetLabel ? presetLabel(preset) : trim(preset)}
</button>
))}
</div>
) : null}
{hint ? (
<p className="pr-7 text-right font-mono text-[10px] text-muted-foreground">{hint}</p>
) : null}
</div>
)
}
export interface ExportMenuProps {
/** What to record. A wrapper passes its own element; a toolbar passes a ref. */
target: ExportTarget
/** File stem, and what the name field opens on. The extension is the format's. */
name?: string
/** Which formats to offer, in order. */
formats?: ExportFormat[]
defaults?: Partial<ExportSettings>
/** Raise or lower what the fields accept. */
limits?: Partial<ExportLimits>
/** Replace the chips beside a field. An empty array leaves it bare. */
presets?: ExportPresets
/** Text beside the icon. Omitted, the button is the icon alone. */
label?: string
className?: string
align?: "start" | "center" | "end"
side?: "top" | "right" | "bottom" | "left"
/** Drive the menu from outside — a toolbar with a keyboard shortcut. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Told about every setting as it changes, for a page that wants to keep them. */
onSettingsChange?: (settings: ExportSettings) => void
/**
* Where the file may go. One destination is used silently; more than one
* becomes a row in the menu. Defaults to the browser's download.
*/
destinations?: ExportDestination[]
}
/** The menu on its own: format, length, rate, frames, scale, ground, and Record. */
function ExportMenu({
target,
name = "robocn",
formats = ["webp", "gif", "png"],
defaults,
limits,
presets,
label,
className,
align = "end",
side = "bottom",
open,
onOpenChange,
onSettingsChange,
destinations,
}: ExportMenuProps) {
const range = { ...exportLimits, ...limits }
const chips = { ...exportPresets, ...presets }
const [settings, setSettings] = React.useState<ExportSettings>({
...defaultSettings,
...defaults,
})
const [stem, setStem] = React.useState(name)
const [progress, setProgress] = React.useState<number | null>(null)
const [done, setDone] = React.useState<string | null>(null)
const [error, setError] = React.useState<string | null>(null)
const [selfOpen, setSelfOpen] = React.useState(false)
const [box, setBox] = React.useState<{ width: number; height: number } | null>(null)
const update = (patch: Partial<ExportSettings>) =>
setSettings((current) => {
const next = { ...current, ...patch }
onSettingsChange?.(next)
return next
})
const places = destinations?.length ? destinations : [DOWNLOAD]
const [placeId, setPlaceId] = React.useState(places[0].id)
const place = places.find((entry) => entry.id === placeId) ?? places[0]
// Asked once per page: the answer cannot change, and the check costs a canvas
// encode. On the server it is assumed — nothing of the menu is rendered there.
const [webp] = React.useState(canWriteWebp)
const offered = formats.filter((format) => format !== "webp" || webp)
const format = offered.includes(settings.format) ? settings.format : offered[0] ?? "gif"
const still = format === "png" || settings.seconds === 0
const node = () => (typeof target === "function" ? target() : target.current)
const isOpen = open ?? selfOpen
const setOpen = (value: boolean) => {
setSelfOpen(value)
onOpenChange?.(value)
}
/**
* Measured as the menu mounts, so the readout says what the file will
* actually be rather than what a scale factor implies. A callback ref, not an
* effect: the menu is only in the tree while it is open, and this has to work
* whether it was opened by the button or by a keystroke somewhere else.
*/
const measure = (mounted: HTMLDivElement | null) => {
if (!mounted) return
const element = node()
const rect = element?.getBoundingClientRect()
// Same numbers, same object: a callback ref is a fresh function every
// render, so re-attaching must not be able to schedule another render.
setBox((previous) => {
if (!rect) return previous === null ? previous : null
if (previous && previous.width === rect.width && previous.height === rect.height) {
return previous
}
return { width: rect.width, height: rect.height }
})
}
const wanted = still ? 1 : frameCount(settings.seconds, settings.fps)
const frames = Math.min(wanted, range.frames)
const capped = wanted > frames
// What the recording will actually run for: the length asked for, unless the
// frame ceiling cut it short.
const length = capped ? frames / settings.fps : settings.seconds
const pixels = box
? `${Math.round(box.width * settings.scale)} × ${Math.round(box.height * settings.scale)} px`
: null
const ground = () => {
const element = node()
if (settings.ground === "none") return null
if (settings.ground === "custom") return settings.groundColor
return element ? backgroundBehind(element as Element) : null
}
const record = async () => {
const element = node()
if (!element) {
setError("Nothing to record.")
return
}
setError(null)
setDone(null)
setProgress(0)
try {
let landed: string | null = null
const result = await exportNode(element as CaptureTarget, {
format,
name: stem,
// Frames are the truth: a capped recording is shorter than the seconds
// asked for, and the length handed over says so.
duration: still ? 0 : frames / settings.fps,
fps: settings.fps,
scale: settings.scale,
quality: settings.quality,
loop: settings.loop,
background: ground(),
onProgress: (captured, total) => setProgress(captured / total),
save: place.write
? async (blob, filename) => {
landed = (await place.write?.(blob, filename)) ?? filename
}
: undefined,
})
setDone(
`${landed ?? result.filename} · ${result.frames} frame${result.frames === 1 ? "" : "s"} · ` +
`${result.width} × ${result.height} · ${kilobytes(result.blob.size)}`,
)
} catch (cause) {
setError(cause instanceof Error ? cause.message : "The export failed.")
} finally {
setProgress(null)
}
}
const busy = progress !== null
return (
<Popover open={isOpen} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
size={label ? "sm" : "icon-sm"}
className={className}
aria-label="Export this as a picture"
title="Export as WebP, GIF or PNG"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{label ? <span>{busy ? `${Math.round((progress ?? 0) * 100)}%` : label}</span> : null}
</Button>
}
/>
<PopoverContent align={align} side={side} className="w-72 p-3">
<div ref={measure} className="flex max-h-[70vh] flex-col gap-2.5 overflow-y-auto">
<Row
label="Format"
value={format}
options={offered.map((entry) => ({ value: entry, label: formatLabel[entry] }))}
onChange={(value) => update({ format: value })}
/>
<NumberField
label="Seconds"
value={settings.seconds}
onChange={(seconds) => update({ seconds })}
range={range.seconds}
step={0.5}
presets={chips.seconds}
presetLabel={(value) => (value === 0 ? "Still" : `${trim(value)}s`)}
suffix="s"
disabled={format === "png"}
/>
<NumberField
label="Rate"
value={settings.fps}
onChange={(fps) => update({ fps })}
range={range.fps}
presets={chips.fps}
suffix="fps"
disabled={still}
/>
<NumberField
label="Frames"
value={frames}
// Frames are seconds and rate seen from the other side: asking for
// 90 of them at 30 fps is asking for three seconds.
onChange={(count) => update({ seconds: clamp(count / settings.fps, range.seconds) })}
range={[1, range.frames]}
disabled={still}
hint={capped ? `Capped at ${range.frames}` : undefined}
/>
<NumberField
label="Scale"
value={settings.scale}
onChange={(scale) => update({ scale })}
range={range.scale}
step={0.25}
presets={chips.scale}
presetLabel={(value) => `${trim(value)}×`}
suffix="×"
hint={pixels ?? undefined}
/>
{format === "webp" ? (
<NumberField
label="Quality"
value={Math.round(settings.quality * 100)}
onChange={(quality) => update({ quality: clamp(quality, [1, 100]) / 100 })}
range={[1, 100]}
suffix="%"
/>
) : null}
{!still ? (
<NumberField
label="Loop"
value={settings.loop}
onChange={(loop) => update({ loop })}
range={[0, 65535]}
hint={settings.loop === 0 ? "0 repeats forever" : undefined}
/>
) : null}
<Row
label="Ground"
value={settings.ground}
options={[
{ value: "page" as ExportGround, label: "Page" },
{ value: "none" as ExportGround, label: "None" },
{ value: "custom" as ExportGround, label: "Colour" },
]}
onChange={(value) => update({ ground: value })}
/>
{settings.ground === "custom" ? (
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Colour
</span>
<span className="flex items-center gap-1">
<input
type="color"
aria-label="Ground colour"
value={/^#[0-9a-f]{6}$/i.test(settings.groundColor) ? settings.groundColor : "#ffffff"}
onChange={(event) => update({ groundColor: event.target.value })}
className="size-6 cursor-pointer rounded-sm border border-border bg-background"
/>
<input
type="text"
aria-label="Ground colour value"
value={settings.groundColor}
onChange={(event) => update({ groundColor: event.target.value })}
className={cn(fieldClass, "w-24 text-left")}
/>
</span>
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<label className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Name
</label>
<span className="flex items-center gap-1">
<input
type="text"
aria-label="File name"
value={stem}
onChange={(event) => setStem(event.target.value)}
className={cn(fieldClass, "w-32 text-left")}
/>
<span className="w-10 font-mono text-[10px] text-muted-foreground">
.{format}
</span>
</span>
</div>
{places.length > 1 ? (
<Row
label="To"
value={place.id}
options={places.map((entry) => ({ value: entry.id, label: entry.label }))}
onChange={setPlaceId}
/>
) : null}
<Button size="sm" disabled={busy} onClick={() => void record()} className="mt-0.5 w-full">
{busy ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Recording {Math.round((progress ?? 0) * 100)}%
</>
) : (
<>
<Download className="size-3.5" />
{still
? `Save ${formatLabel[format]}`
: `Record ${trim(length)}s ${formatLabel[format]}`}
</>
)}
</Button>
<p className="font-mono text-[10px] text-muted-foreground">
{still
? `One frame${pixels ? ` · ${pixels}` : ""}`
: `${frames} frames at ${trim(settings.fps)} fps${pixels ? ` · ${pixels}` : ""}`}
</p>
{/* Real time is the only way to record a `requestAnimationFrame` loop,
and a person watching a frozen button deserves to know why. */}
{busy && !still ? (
<p className="font-mono text-[10px] text-muted-foreground">
Recording in real time — keep this tab in front.
</p>
) : null}
{done ? (
<p className="flex items-start gap-1 font-mono text-[10px] text-muted-foreground">
<Check className="mt-px size-3 shrink-0" />
<span className="break-all">{done}</span>
</p>
) : null}
{error ? <p className="font-mono text-[10px] text-destructive">{error}</p> : null}
</div>
</PopoverContent>
</Popover>
)
}
export interface RobotExportProps extends React.ComponentProps<"div"> {
/** File stem for whatever is saved. */
name?: string
/** Record something other than this wrapper — a ref to the machine itself. */
target?: ExportTarget
formats?: ExportFormat[]
defaults?: Partial<ExportSettings>
limits?: Partial<ExportLimits>
presets?: ExportPresets
/** Keep the button on screen rather than revealing it on hover or focus. */
alwaysVisible?: boolean
/** Which top corner the button sits in. */
corner?: "start" | "end"
/** Where the file may go. Defaults to the browser's download. */
destinations?: ExportDestination[]
}
/**
* Wrap a machine to give it an export path.
*
* The button is marked `data-robocn-hide`, which the snapshotter drops from the
* clone — otherwise every recording would have a record button in the corner
* of it.
*/
function RobotExport({
name = "robocn",
target,
formats,
defaults,
limits,
presets,
alwaysVisible = false,
corner = "end",
destinations,
className,
children,
...props
}: RobotExportProps) {
const own = React.useRef<HTMLDivElement>(null)
return (
<div
ref={own}
className={cn("group/export relative", className)}
{...props}
>
{children}
<div
data-robocn-hide=""
className={cn(
"absolute top-2 z-10 transition-opacity",
corner === "end" ? "right-2" : "left-2",
alwaysVisible
? "opacity-100"
: "opacity-0 group-hover/export:opacity-100 focus-within:opacity-100",
)}
>
<ExportMenu
target={target ?? (() => own.current)}
name={name}
formats={formats}
defaults={defaults}
limits={limits}
presets={presets}
destinations={destinations}
/>
</div>
</div>
)
}
export { ExportMenu, NumberField, RobotExport }