@classytic/vixel-schema
v0.13.0
Published
The VixelSpec composition contract: zero-dependency, agent-emittable TypeScript types shared by vixel (engine), vixel-ui (editor), and agents. No ffmpeg, no React.
Downloads
867
Readme
@classytic/vixel-schema
The VixelSpec composition contract — zero dependencies.
The single source of truth for the JSON that an agent emits, an editor edits, and every renderer renders. No ffmpeg, no React — types, pure edit primitives, and an opt-in validator.
@classytic/vixel-schema ← the contract (this package)
├── @classytic/vixel engine → renders a spec to MP4 (server, ffmpeg)
├── @classytic/vixel-ui editor → previews + edits a spec (browser, Pixi)
├── @classytic/vixel-render-pixi export → WYSIWYG server render (headless Pixi)
├── @classytic/vixel-agent agent → AI-SDK / MCP tools over the spec
└── @classytic/vixel-studio catalog → templates / decks as pure spec dataBecause everyone depends on this, the contract never drifts — and a frontend that mounts the editor never pulls ffmpeg into its dependency tree.
The spec in 30 seconds
Visual lanes hold any media (video / image / text / shape / effect), absolutely
timed (at + duration), stacked in array order. A sequential lane is the
"main track" (clips butt end-to-end). Audio lives on audio lanes. Transitions are
first-class on the lane, between adjacent clips.
import { defineComposition } from '@classytic/vixel-schema';
export const spec = defineComposition({
version: 1,
output: { width: 1080, height: 1920, fps: 30 },
tracks: [
{
type: 'visual',
sequential: true, // main track — `at` is re-flowed for you on edits
clips: [
{ media: { kind: 'video', source: 'a.mp4' }, at: 0, duration: 3 },
{ media: { kind: 'video', source: 'b.mp4', trimStart: 2 }, at: 3, duration: 4 },
],
transitions: [{ between: [0, 1], transition: { id: 'fade', duration: 0.5 } }],
},
{
type: 'visual', // overlay lane — composites on top
clips: [
{
media: { kind: 'text', text: 'Hello' },
at: 0.5,
duration: 2,
place: { region: 'lower-third' }, // semantic placement → resolved to transform.frame
},
],
},
{ type: 'audio', items: [{ source: 'music.mp3', at: 0, gain: -12 }] }, // gain in dB
],
});Units worth knowing: transform.frame is a normalized rect (0..1 of the canvas),
audio gain is dB (0 = unity), a clip's volume is linear 0..1.
Validate at every trust boundary
Core stays zero-dep; the validator is an opt-in subpath (zod loads only if you import it). Point it at anything untrusted — an agent's emission, an API ingest, a pasted project:
import { safeParseSpec, validateSpec } from '@classytic/vixel-schema/validate';
const r = safeParseSpec(json); // { success, data? , error? } — typed VixelSpec on success
const { valid, errors } = validateSpec(json); // errors as "path: message" lines —
// feed them straight back to the agent for a retry loop.Two layers: structural (shape/enums/ranges) and semantic (every effect/transition
id must resolve in the live registry, params range-checked against descriptors —
so a registered BYO pack validates for free). Unknown keys are preserved, not
stripped: a spec authored against a newer schema round-trips losslessly.
Normalize once, render anywhere
import { normalizeSpec } from '@classytic/vixel-schema';
const ready = normalizeSpec(spec);Resolves agent-friendly shorthands into the explicit shape renderers read:
semantic place → transform.frame, shape style presets inlined, stable ids
minted on every track/clip/item/marker, transition between canonicalized to
ids. Pure and idempotent.
The edit core — one reducer for UI, agents, and servers
Every edit is a typed, id-addressed command applied by a pure reducer. The browser editor, a Node agent, and a server pipeline all run this exact code:
import { applyCommand, type EditorCommand } from '@classytic/vixel-schema';
let next = applyCommand(spec, { type: 'splitClip', clipId: 'clip-3', atSec: 1.5 });
next = applyCommand(next, {
type: 'setTransition',
afterClipId: 'clip-3',
ref: { id: 'zoom-punch', duration: 0.4 },
});Commands targeting a missing id are a no-op (one stale ref can't crash a
batch). New clips get fresh ids immediately, so the next command can address
them. The with*() primitives behind the reducer (withClipSplit,
withClipPatch, …) are exported too.
Catalog, packs, and BYO effects
import { describeCatalog, registerPack, registerSpecPacks } from '@classytic/vixel-schema';
describeCatalog(); // filters/effects/transitions/templates/themes — built for LLM context
registerPack(myPack); // a JSON manifest of effect/transition descriptors + GLSL/LUT URLs
registerSpecPacks(spec); // packs can travel INSIDE the spec — an agent can emit a novel shaderRenderers implement one generic executor per effect kind (filter / lut / overlay / shader); everything else is data — the gl-transitions / CapCut-pack model.
Bundling policy: data ships, assets are BYO
The convention that keeps vixel "batteries-included but lightweight":
- Small pure-data descriptors ship as built-ins — effect / transition / text-preset metadata and inline GLSL, plus template builder functions. They are a few KB, cost nothing at runtime until a spec uses them, and being browsable is their value (an editor panel or agent enumerates the whole vocabulary). Add individual effects/transitions/presets straight to the built-in catalogs.
- Larger or curated collections ship as opt-in packs — e.g. the essentials
templates behind
registerEssentialsTemplates(). Both@classytic/vixel-schemaand@classytic/vixel-uisetsideEffects: false, so a pack a host never registers is tree-shaken out of its bundle entirely. Prefer this when there will be many entries or a host will want to curate which appear in a picker. - Binary assets are never bundled — particle textures, overlay footage
(light leaks / dust), fonts, and audio SFX are brought by the host via a pack
baseUrl(or an absolute asset URL on the descriptor). This keeps install size small and, just as importantly, keeps third-party asset licenses the host's explicit choice rather than something vixel embeds. A "confetti" preset ships as emitter data + a texture URL, not an embedded PNG.
When adding to vixel: reach for a built-in for one small descriptor, an opt-in
pack for a collection, and always a URL (not an embed) for anything binary. See
the repo NOTICE for third-party attributions this policy also governs.
Templates & themes
import { registerTheme, buildScene, applyCommand } from '@classytic/vixel-schema';
// A brand is just a registered Theme; templates read the resolved tokens.
const next = applyCommand(spec, {
type: 'applyTemplate',
template: 'studio/social-ad',
theme: 'brand:acme',
content: { headline: 'Launch day' },
});
// → a new layered scene with fillable slots (slot ids are addressable clip ids)Timeline intelligence
- Markers — timeline-absolute intent anchors (chapters, beats, notes) with VTT / FFMETADATA chapter export.
- Link groups — A/V pairs share a
linkId; the link-aware helpers answer "what moves with this?" for every editor. - Ripple —
rippleDeleteRanges(spec, trackId, ranges): link-aware, multi-range, transcript-driven cuts. - Transcript —
timelineTranscript(...)projects ASR words onto the output timeline (the "edit video like text" primitive). - Determinism — seeded PRNG + counter-based ids (
./random): the same spec renders identically everywhere. Preview == export is designed in, not hoped for.
Subpath exports
| Subpath | Contents |
| --- | --- |
| @classytic/vixel-schema | the whole zero-dep contract + edit core + catalogs |
| @classytic/vixel-schema/validate | parseSpec / safeParseSpec / validateSpec (pulls zod) |
| @classytic/vixel-schema/isf | ISF shader → descriptor adapter |
License
MIT © Classytic
Trademark
MIT-licensed code. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see TRADEMARK.md.
