@classytic/vixel-ui
v0.15.0
Published
Headless, configurable React editor primitives for vixel. A timeline UI over the VixelSpec contract: agent-emittable, human-editable, render via @classytic/vixel.
Downloads
842
Readme
@classytic/vixel-ui
Headless, configurable React editor primitives for vixel.
A timeline + canvas editor over the VixelSpec contract — the same spec an agent
emits, a human edits, and the vixel renderers export. Built for React 19.
One contract, two authors. The agent emits a
VixelSpec(@classytic/vixel-schema);vixel-uilets a human tweak the same spec; the server renders it. No second composition model, no drift — and the server export (@classytic/vixel-render-pixi) runs this package's own Pixi renderer, so preview == export by construction.
Why
- 🎛️ Headless: no default styles, no shadow DOM. Render-prop + children-as-function,
data-*styling hooks, CSS-variable theming. You own every pixel. - ⚡ React 19 native:
useSyncExternalStore+ selector subscriptions — a 60 Hz playhead re-renders nothing but the playhead. - 🖱️ Real editor interactions: multi-select (ctrl/shift/marquee), group drag, clipboard, a rebindable keyboard registry, frame-accurate transport.
- 🖼️ Pro timeline visuals: cached audio waveforms + video filmstrips, headless or batteries-included.
- 🧩 Capability-rich, client-configurable: every tool lives here; a mount enables a subset via
features. Unused features tree-shake out. - 📜 Contract-first: edits a
VixelSpecfrom@classytic/vixel-schema— the browser never pulls ffmpeg.
Install
npm install @classytic/vixel-ui @classytic/vixel-schema react react-domThe ffmpeg engine (@classytic/vixel) is server-side only — install it where
you render, not where you edit. In-browser MP4/GIF/PNG export ships here (WebCodecs,
@classytic/vixel-ui/export).
Quick start
import { VixelEditor } from '@classytic/vixel-ui';
import { PixiPreview, TransformOverlay } from '@classytic/vixel-ui/preview';
import { Timeline, TimeRuler, Playhead, TimelineTrack, TimelineClip } from '@classytic/vixel-ui/timeline';
import { PlayButton, TimecodeDisplay } from '@classytic/vixel-ui/transport';
import type { VixelSpec } from '@classytic/vixel-schema';
export function Editor({ spec, onChange }: { spec: VixelSpec; onChange: (s: VixelSpec) => void }) {
return (
<VixelEditor spec={spec} onChange={onChange}>
{/* Canvas — the SAME renderer the server export drives */}
<div className="relative">
<PixiPreview className="w-full" />
<TransformOverlay /> {/* on-canvas move / resize / rotate */}
</div>
<div className="flex items-center gap-2">
<PlayButton className="px-3 py-1 rounded bg-black text-white" />
<TimecodeDisplay /> {/* frame-accurate M:SS.FF */}
</div>
<Timeline className="relative h-40 bg-neutral-900">
{(tracks) => (
<>
<TimeRuler className="h-6" />
{tracks.map((t) => (
<TimelineTrack key={t.index} track={t} className="h-12">
{(item) => (
<TimelineClip
item={item}
className="rounded bg-indigo-600 data-[selected=true]:ring-2 data-[primary=true]:ring-indigo-300"
/>
)}
</TimelineTrack>
))}
<Playhead className="w-px bg-red-500" />
</>
)}
</Timeline>
</VixelEditor>
);
}Prefer batteries-included? StandardEditor (from @classytic/vixel-ui, styled via
CSS variables) mounts all of the above — including waveforms/filmstrips — in one
component.
Applying derived styling (controlled spec — don't remount)
The spec prop is reconciling-controlled: the editor re-seeds only on a spec
its store has never seen, and ignores the echo of its own onChange. Two correct
ways to apply host-side transforms (theme/brand/background swaps), and one trap:
// ✅ In place, via the store — undo-labeled, media cache preserved.
const actions = useEditorActions();
actions.setSpec(restyleBackdrops(actions.getSpec(), brandFill));
// ✅ Controlled replacement — pass the transformed spec as the `spec` prop.
<VixelEditor spec={styledSpec} onChange={setSpec}>…</VixelEditor>
// ❌ NEVER key-remount to force a "refresh":
<VixelEditor key={styleVersion} spec={styledSpec}>…</VixelEditor>
// A remount destroys the editor instance AND its media cache — every image and
// video re-downloads and re-decodes. The two paths above re-render only the
// clips that actually changed.Selection & editing
Selection is multi and id-keyed (it survives inserts/moves/undo by construction): plain click selects, ctrl/cmd-click toggles, shift-click selects a same-lane range, dragging empty timeline draws a marquee (ctrl = additive, Escape restores). Dragging a selected clip moves the whole selection.
Everything routes through one store (useEditorActions()):
const actions = useEditorActions();
actions.dispatch({ type: 'splitClip', clipId, atSec }); // typed, id-addressed command
actions.dispatchAll(commands, 'Apply agent edit'); // N commands → ONE undo entry
actions.deleteSelected(); // 'Delete 3 items' — one undo entry
actions.copySelected(); actions.pasteAt(); // relative layout preserveddispatch/dispatchAll run the same pure reducer (applyCommand from
@classytic/vixel-schema) an AI agent or server uses — one edit path for humans,
agents, and telemetry. onSelect fires with the primary; onSelectionChange
with the full set.
Template groups (compound clips)
Clips sharing a group tag (what applyTemplate stamps on every scene clip)
collapse to one draggable/resizable block on the timeline — the CapCut
template unit, not N stacked clips. The row model emits it as a
kind: 'group' item, so track render-props branch:
{(item) => item.kind === 'group'
? <TimelineGroupClip item={item} /> // click = select members, dbl-click = edit
: <TimelineClip item={item} />}Click selects all members (Delete/copy act on the whole scene); a body drag
commits ONE moveGroup; the right-edge handle commits ONE resizeGroup
(proportional scene re-time — schema semantics). Double-click / Enter opens
focused template-edit mode (actions.openGroup): the scene explodes into
one row per element (templates build on one layered lane — inline members
would overlap), top row = front-most. Each exploded row carries a host-ready
TrackView.memberLabel (text content / source basename / shape kind) for
gutters, and its item keeps its REAL spec position, so edits stay correct.
Everything else is flagged locked (data-locked — dim it via CSS; all
interactions early-return), and drags are time-only on the item's own lane
while the mode is active. actions.closeGroup() exits; the mode auto-exits
when the group is deleted or dissolved.
Keyboard shortcuts
One registry, scoped to the editor root (host inputs are never hijacked), every
binding rebindable via the keymap prop, all off via features.shortcuts.
| | | | |
| --- | --- | --- | --- |
| Space play/pause | S split at playhead | Del delete selection | ⌘D duplicate |
| ⌘A select all | Esc clear selection | ⌘C/X/V copy/cut/paste | ⌘Z/⇧⌘Z undo/redo |
| ←/→ step 1 frame | ⇧←/→ step 1 s | ⌥←/→ nudge selection | Home/End start/end |
| +/- zoom | | | |
Waveforms & filmstrips
Audio items render waveforms and video clips render filmstrip thumbnails
in StandardEditor (toggle: features.mediaThumbnails). Building your own UI?
The primitives are exported and cached (one decode per source, shared across
clips, abort-safe, SSR-safe):
import { useWaveform, Waveform, useFilmstrip, Filmstrip } from '@classytic/vixel-ui';
const { peaks } = useWaveform(url);
<Waveform peaks={peaks} trimStartSec={item.in} style={{ color: '#818cf8' }} />;
const { tiles } = useFilmstrip({ url, trimStartSec, durationSec, height: 48 });
<Filmstrip tiles={tiles} />;Preview & export
@classytic/vixel-ui/preview—PixiPreview(retained Pixi scene, gl transitions, shader effects, masks, keyframes) +TransformOverlay(canvas gizmo publishing exact boxes).@classytic/vixel-ui/export— in-browser MP4 (WebCodecs + AAC), GIF, and image export for shorts-length work.@classytic/vixel-ui/renderer— the rawrenderSceneused by@classytic/vixel-render-pixifor WYSIWYG server export, and by hosts doing custom draw loops.
Features
| Flag | Gates | Default |
| --- | --- | --- |
| transitions | clip-to-clip transition UI | on |
| kenBurns | zoom/pan animation UI | on |
| captions | caption editing | on |
| overlays | extra visual lanes | on |
| multiTrackAudio | multiple audio lanes | on |
| effects | WebGL effect surfaces | on |
| shortcuts | the keyboard registry | on |
| mediaThumbnails | waveforms + filmstrips in StandardEditor | on |
Subpath exports
| Subpath | Contents |
| --- | --- |
| @classytic/vixel-ui | VixelEditor, StandardEditor, store hooks, media primitives, keymap, types |
| /editor | editor store / provider internals |
| /timeline | Timeline, TimeRuler, Playhead, TimelineTrack, TimelineClip, TimelineGroupClip, TimelineMarquee, drag/marquee hooks |
| /transport | PlayButton, TimeDisplay, TimecodeDisplay, PreviewSurface, ExportButton |
| /preview | PixiPreview, TransformOverlay, preview audio |
| /renderer | headless renderScene (server export / custom hosts) |
| /export | WebCodecs MP4 / GIF / image export, audio-mix plan executor |
| /shared | time / spec utilities, cn, cva variants |
| /headless | the PORTABLE core — store, commands, keymap logic, clipboard/split planning, audio-mix plan (no DOM, CI-enforced; see PORTING.md) |
| /driver | RenderDriver / FrameServer / AudioMixPlan porting contracts + createPixiRenderDriver() |
Theming — bring your own design system
The primitives are headless (positioning + interaction inline, zero visual
opinion); StandardEditor + the cva variants are one reference skin. Hosts
restyle at four standardized levels (shadcn/Radix conventions):
1. className + classNames slots. Every component takes className
(root). Composites also take a classNames record of named parts, merged via
cn (tailwind-merge) AFTER defaults — your classes win conflicts:
<StandardEditor
spec={spec}
classNames={{
clip: 'bg-emerald-700 rounded-sm', // beats the default bg-chart-1
clipSelected: 'ring-amber-400', // appended while selected
track: 'h-16', playhead: 'bg-red-500', ruler: 'text-neutral-400',
}}
/>
<TimelineClip item={item} classNames={{ root: '…', selected: '…', dragging: '…', trimStart: '…', trimEnd: '…' }} />Slots per component: Timeline { root, marquee } · TimelineClip { root,
selected, dragging, trimStart, trimEnd } · TimelineGroupClip { root,
selected, dragging, trimHandle, label, duration } · TimeRuler { root,
tick } · MarkerRail { root, marker } · KeyframeRail { root, marker } ·
TimelineTransitions { container, seam, handle, core } · PixiPreview
{ root, loading, spinner } · StandardEditor { root, toolbar, playButton,
timeDisplay, exportButton, preview, timeline, ruler, track, clip, clipSelected,
clipLabel, playhead, groupClip }.
2. State via data-attributes. Interactive state is exposed on the DOM, so
Tailwind (data-[selected=true]:…) and plain CSS both target it:
| Element | Attributes |
| --- | --- |
| [data-vixel-clip] | data-kind, data-selected, data-primary, data-dragging, data-locked |
| [data-vixel-group-clip] | data-group-id, data-selected, data-dragging, data-locked |
| [data-vixel-playhead] | data-dragging |
| [data-vixel-marquee] | data-active |
| [data-vixel-transition] | data-selected, data-empty, data-dragging |
| [data-vixel-keyframe] | data-active, data-dragging |
| [data-vixel-play], [data-vixel-preview] | data-state="playing|paused" |
| [data-vixel-pixi-preview] | data-loading, data-playing |
| [data-vixel-track] | data-track-type, data-track-index, data-track-lane |
3. --vixel-* design tokens. Everything the package styles outside
Tailwind (transform gizmo, marquee, preview loader) reads CSS custom
properties with safe inline fallbacks — override at :root (or any wrapper)
to retheme; the full token table with defaults lives at the top of
styles.css. --vixel-primary falls back to the shadcn
--color-primary, so a shadcn theme drives both the Tailwind skin and the
inline affordances automatically. Importing styles.css stays optional —
components are fully functional unstyled (enforced by the no-styles smoke
test).
4. Render-prop slot overrides. Where a part is genuinely replaceable, the
tree already has an escape hatch: children-as-function on TimelineClip /
TimelineGroupClip / PlayButton / TimeDisplay / TimeRuler (ticks),
renderMarker on MarkerRail / KeyframeRail, children(state) on
TimelineTransitions.
Porting (React Native and beyond)
The editor core is enforced-portable (@classytic/vixel-ui/headless — a CI
scan rejects any DOM leak) and the platform seams are named contracts
(@classytic/vixel-ui/driver: RenderDriver, FrameServer, AudioMixPlan).
A native app is a binding project — new UI + new drivers over the same
store, commands, and plans — never a rewrite, and never a WebView. The full
map: PORTING.md.
Packages
| Package | Role |
| --- | --- |
| @classytic/vixel-schema | the contract — VixelSpec, validator, pure edit core |
| @classytic/vixel-ui | this — headless editor + Pixi preview + browser export |
| @classytic/vixel | server render engine (ffmpeg) |
| @classytic/vixel-render-pixi | WYSIWYG server export (drives this package's renderer) |
| @classytic/vixel-agent | agent tool surface (AI-SDK / MCP) over the same commands |
Releasing
npm test proves logic; it does not prove that a ten-minute long-GOP source
survives hard scrubbing and resumes playing. That is measured by the real-browser
benchmarks, which need fixtures too large to commit, so publishing runs them as a
gate rather than skipping them:
ffmpeg -version # required to build the fixtures
node scripts/gen-hostile-fixtures.mjs # once per checkout (~250 MB, gitignored)
npm run test:perf # the browser benchmark gate alone
npm run release # typecheck + tests + test:perf + buildprepublishOnly runs release, so a publish cannot skip the benchmark. If the
fixtures or the browser driver are missing, test:perf fails with the command
to run; it never degrades into a quiet skip. Run it on an otherwise unloaded
machine: the assertions are wall-clock.
License
MIT © Classytic
Trademark
MIT-licensed code. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see TRADEMARK.md.
