Canvas-rendered timeline · plain JSON projects · real mp4 export · opt-in three.js lighting
Upload your own video, edit, animate keyframes, switch lighting picker themes, all in the browser. Export needs a backend — see Configuring the hosted demo for the env vars.
Most "video editor in the browser" projects are either a finished SaaS (you can't embed them) or a single demo file you'd have to fork to ship. AiCut is a publishable component library. One framework-agnostic engine, thin React and Vue shells, JSON projects so your host app owns the data.
| 🧠 | One engine, multiple frontends@iplex/aicut-core does all the work; the React / Vue wrappers are <100 LOC shells. Same shape as ag-Grid. |
| 🚀 | Canvas timeline, zero DOM clip nodes Hundreds of clips render in <2 ms; smooth pan, zoom, drag-snap, edge auto-scroll. |
| 📦 | Plain JSON projects Millisecond timing, no framework or runtime coupling. Save to your DB, diff in git, ship to a backend. |
| 🎨 | First-class theming + i18n CSS variables for chrome + letterbox; English default with bundled zh pack, host-overridable per-key. |
| 🛰️ | BYO export backend with progress Reference Fastify (TypeScript) and net/http (Go) services with real-time SSE progress over ffmpeg. |
| 🧩 | Custom slots everywhere Optional headerLeft/headerRight above the preview (project name, Share / Export, profile) and toolbarLeft/toolbarRight bookends on the toolbar. All collapse to zero when empty — default editor is unchanged for callers that don't use them. |
| 💡 | Opt-in 3D lighting picker A separate @iplex/aicut-core/lighting entry powers an interactive sphere-and-image lighting director with perspective / front views, drag-snap directions, and a host-supplied AI smart panel. Three.js is bundled only on this sub-entry — the video-editor bundle stays small. |
pnpm add @iplex/aicut-react @iplex/aicut-coreimport { useRef } from "react";
import {
VideoEditor,
type VideoEditorApi,
type Project,
} from "@iplex/aicut-react";
import "@iplex/aicut-core/styles.css";
const project: Project = {
version: 1,
sources: [
{ id: "src-1", url: "/media/clip-a.mp4", kind: "video", name: "A" },
],
tracks: [
{ id: "tr-1", kind: "video", clips: [
{ id: "cl-1", sourceId: "src-1", in: 0, out: 8000, start: 0 },
]},
],
};
export function MyApp() {
const apiRef = useRef<VideoEditorApi | null>(null);
return (
<VideoEditor
apiRef={apiRef}
defaultProject={project}
onChange={(p) => console.log("autosave", p)}
onExport={(p) =>
fetch("/export", { method: "POST", body: JSON.stringify({ project: p }) })
}
style={{ height: 600 }}
/>
);
}The apiRef exposes imperative methods (split, seek, setProject, requestExport, …) for keyboard shortcuts or external controls.
pnpm add @iplex/aicut-vue @iplex/aicut-core<script setup lang="ts">
import { ref } from "vue";
import { VideoEditor, type EditorApi, type Project } from "@iplex/aicut-vue";
import "@iplex/aicut-core/styles.css";
const editor = ref<{ api(): EditorApi | null } | null>(null);
const project: Project = { /* same shape */ };
</script>
<template>
<VideoEditor
ref="editor"
:default-project="project"
@change="(p) => console.log('autosave', p)"
@export="onExport"
/>
</template>import { Editor } from "@iplex/aicut-core";
import "@iplex/aicut-core/styles.css";
const editor = Editor.create({
container: document.getElementById("app")!,
project: { /* … */ },
});
editor.on("change", ({ project }) => console.log("autosave", project));Two CSS-variable swaps and you have a totally different look. Defaults to a pro-NLE charcoal; pass theme={...} to switch.
<VideoEditor
theme={{
controlsBg: "#f6f6f8",
controlsText: "rgba(0, 0, 0, 0.78)",
controlsBorder: "rgba(0, 0, 0, 0.08)",
controlsHover: "rgba(0, 0, 0, 0.06)",
controlsActive: "rgba(0, 0, 0, 0.08)",
previewBg: "#e4e4e7", // letterbox colour
}}
/>Every variable is also writeable as plain CSS — .aicut-root { --aicut-controls-bg: ...; } works just as well if you'd rather keep theming out of JS.
English by default. The bundled localeZh covers the editor end-to-end (toolbar tooltips, canvas track headers, exit-fullscreen overlay). Hosts can override any subset of keys, and runtime switching is supported.
import { VideoEditor, localeZh } from "@iplex/aicut-react";
// Whole-locale swap
<VideoEditor locale={localeZh} />
// Partial override
<VideoEditor locale={{ undo: "Annuler", redo: "Refaire" }} />Switching at runtime is a regular prop change — the toolbar re-titles and the timeline canvas re-paints in place.
Six host-fillable slots on the editor — empty by default, no chrome cost. The same pattern is the standalone <Timeline>'s toolbarLeft/toolbarRight props.
| Slot | Where | Typical use |
|---|---|---|
headerLeft |
Top header, left | Project name, file menu, breadcrumbs |
headerRight |
Top header, right | Share / Export / profile / settings |
toolbarLeft |
Toolbar, left bookend | Project name, status pill, branding |
toolbarRight |
Toolbar, right bookend | Custom action icons |
panelLeft |
Main row, left of preview (centered layout) | Source library, media bin, generators |
panelRight |
Main row, right of preview (centered layout) | Inspector, properties, AI tools |
The two panel* slots only render when previewLayout="centered" (the default — see Preview layout below). The aspect chip is now built into the toolbar, so toolbarLeft is free for whatever else.
When both headerLeft and headerRight are empty the header bar collapses entirely — the default editor layout is byte-for-byte identical to before the slots existed.
<VideoEditor
// Header row above the preview — auto-hidden when both are null.
headerLeft={<span style={{ fontWeight: 600 }}>Untitled project</span>}
headerRight={
<>
<button onClick={share}>Share</button>
<button onClick={() => apiRef.current?.requestExport()}>Export</button>
</>
}
// Toolbar bookends — independent slots.
toolbarLeft={<ProjectNameInput value={name} onChange={setName} />}
toolbarRight={<ExportStatusPill status={exportStatus} />}
// CapCut-desktop-style side rails — only render in centered layout.
panelLeft={<MediaBin sources={sources} onPickClip={addClip} />}
panelRight={<InspectorPanel clip={selectedClip} />}
/>The editor ships two top-level layouts, picked via previewLayout:
| Layout | Behaviour | When to use |
|---|---|---|
"centered" (default) |
Preview lives in a width-capped card in the middle third of the row, with panelLeft / panelRight slots flanking it. |
CapCut-desktop posture — host owns a media library / inspector. |
"fullWidth" |
Preview spans the entire row, no side columns. | Embeds where the host doesn't need side panels. |
Playback controls (time · play · duration · fullscreen) sit as a footer on the preview card in both layouts, not on top of the canvas — keyframe handles and the canvas guide stay unobstructed.
<VideoEditor
previewLayout="centered" // or "fullWidth"
panelLeft={<MediaBin />}
panelRight={<Inspector />}
/>The layout switch is reactive — flip the prop and the chrome rearranges without remounting the editor.
Every spatial value in a project — keyframe panX/panY, the canvas guide rect, the export resolution — lives in one coordinate system, the project canvas:
project.output = { width: 1920, height: 1080, fps: 30 };The preview is just a scaled view of this canvas. The export writes exactly these dimensions. Pan/scale are in canvas pixels, not preview pixels, so a project authored on a 720×720 tab and rendered on a 4K screen lands every transform identically.
editor.setAspect("9:16")updatesProject.outputto the 1080p tier for that ratio (1080×1920). Hosts can override witheditor.setOutput({ width, height, fps }).- The backend export reads
Project.outputfirst; explicitoutputin the request body overrides. - Legacy projects without
output:normalizeProjectderives one fromaspector the first clip's intrinsic source dims (CapCut "Original" behaviour), so old JSON round-trips without manual migration.
CapCut-style per-property keyframes for panX, panY, and scale — pin a value at any moment, the engine animates between them. Authored in the editor, previewed live by any playback engine, compiled to ffmpeg expressions on export so the rendered mp4 matches the preview frame-for-frame.
panX / panY are stored in canvas pixels of Project.output (see Project canvas). One pan unit = one pixel in the export, regardless of preview size — so the same project rendered on a small embed and a full-screen tab lands every keyframe in the same place.
clip.keyframes = [
{ id: "k1", prop: "scale", time: 0, value: 1 },
{ id: "k2", prop: "scale", time: 2000, value: 2.5, easing: "easeInOut" },
{ id: "k3", prop: "scale", time: 4000, value: 1 },
];| Capability | Notes |
|---|---|
| Per-property model | panX / panY / scale animate independently. Pre-easing tuple-keyframes auto-migrate via normalizeProject. |
| Easing curves | linear / easeIn / easeOut / easeInOut (cubic). Stored on the leaving kf — matches AE / Premiere / CapCut convention. Omitted = linear (back-compat). |
| Editor UI | Toolbar diamond toggle, draggable preview overlay (translate body / scale corners / pinch wheel), floating numeric panel with easing dropdown, timeline diamond markers with drag-to-retime (drags the whole moment — every prop pinned at the same time travels together) + snap. |
| Backend export | compileKeyframeExpression emits a gte(t,A)*lt(t,B)*… sum compiled into scale=...:eval=frame + overlay=…:eval=frame filters. Both @iplex/backend-ts and @iplex/backend-go support it identically. |
| PiP semantics | Output frame is fixed; pan/scale moves the content inside it (overflow: hidden in the HTML engine, ctx.clip() in canvas, ffmpeg overlay onto a fixed-size color background on the backend). |
| Lossless splits | splitClipAt mid-segment inserts interpolated boundary keyframes so cutting and not moving the halves plays back identically to the un-cut clip. |
| Drag-burst undo | Editor.beginInteraction() / endInteraction() coalesce a 30+ tick drag into ONE history entry. Wheel-pinch debounces 200ms. |
| Reactive props | <VideoEditor keyframes={{ enabled }} /> toggles the editing UI without losing data. Off ⇒ chrome stays identical to today, kfs round-trip unchanged. |
The full design — clip-local time, history snapshot strategy, ffmpeg expression format — lives in packages/core/src/keyframes/ and backends/ts/src/keyframe-expression.ts.
Bound on the editor root and (where applicable) the standalone Timeline. All shortcuts no-op while focus is in an <input> / <textarea>.
| Action | Key | Notes |
|---|---|---|
| Play / Pause | Space |
|
| Undo / Redo | ⌘Z / ⇧⌘Z |
macOS; Ctrl+Z on Windows/Linux |
| Split at playhead | K |
|
| Trim left / right edge | Q / W |
|
| Step one frame | ← / → |
Uses Project.fps (default 30) |
| Step ten frames | ⇧← / ⇧→ |
|
| Jump to clip start / end | I / O |
Requires clipEdgeNav.enabled |
| Delete selected clip | ⌫ / Delete |
The editor never calls a backend on its own. onExport hands the host a JSON Project; from there your app POSTs it wherever. We ship two reference backends that produce a real mp4 via ffmpeg:
| Backend | Stack | Port |
|---|---|---|
backends/ts |
TypeScript + Fastify | 8787 |
backends/go |
Go + net/http | 8788 |
Both implement the same wire contract:
POST /export Content-Type: application/json
body: { project: Project, output?: { width, height, fps } }
→ Content-Type: text/event-stream
data: {"phase":"encode","overall":0.42,"clipIndex":0,"totalClips":3}
data: {"phase":"concat","overall":0.99,"totalClips":3}
data: {"phase":"done","fileUrl":"/files/<uuid>.mp4","id":"<uuid>"}
POST /upload (multipart, field "file") → { url, id, name }
GET /files/<uuid>.<ext> → video/* (with HTTP Range support)
Output dimensions resolve in priority order: explicit output in the request body → Project.output → ffprobe of the bottom track's first clip → 1920×1080. With Project.output set (default behaviour from the editor), the request body's output is purely an override.
The TS backend runs the entire timeline through a single filter_complex pass — bottom track painted onto a black color= canvas first, each higher track overlaid with enable='between(t,start,end)' so PiP windows respect their lifetimes. Keyframe panX/panY/scale compile to per-frame ffmpeg expressions; the compositor renders at 2× internal resolution and downsamples with lanczos so sub-pixel motion stays smooth. Audio: only the top track contributes (matching the editor's PiP policy), mixed via amix.
out_time_us from ffmpeg's -progress stream feeds the overall fraction. Aborting the client connection (or AbortController on the fetch) kills the in-flight ffmpeg.
The Go backend still uses the per-clip + concat strategy and ignores cross-track compositing — slated for parity in a follow-up release.
The demo's React-side parser + UI lives in examples/react-demo/src/App.tsx.
Each backend resolves an ffmpeg binary in this order:
AICUT_FFMPEGenv var (/abs/path/to/ffmpeg)./ffmpeg-bin/ffmpegnext to the backend- System
ffmpegon$PATH
The live demo on GitHub Pages is a fully working editor — but the browser alone can't ffmpeg-export your project, and it doesn't know where to put video files you upload. Two optional env vars wire those up:
| Env var | What it does |
|---|---|
VITE_UPLOAD_ENDPOINT |
POST endpoint that accepts a multipart file field and replies with JSON { "url": "https://..." }. Without it, uploaded videos use browser-local blob: URLs — playable in the tab but not openable by the export backend. |
VITE_BACKEND_TS_URL |
Public URL of the TypeScript exporter (backends/ts). Without it, clicking export shows a toast hint. |
VITE_BACKEND_GO_URL |
Public URL of the Go exporter (backends/go). Same fallback as above. |
For the GitHub Pages deploy, set them as repository secrets (Settings → Secrets and variables → Actions). The workflow at .github/workflows/pages.yml reads them at build time and bakes the values into the static bundle. Empty / missing → the demo gracefully degrades to "local-only" mode with toast hints when you hit the missing piece.
For local dev, copy examples/react-demo/.env.example to .env.local and fill in. Without an .env.local the demo defaults to http://127.0.0.1:8787 (TS) and http://127.0.0.1:8788 (Go) — start either backend with pnpm --filter @iplex/backend-ts dev (or --filter @iplex/backend-go) and exports just work.
- Repo → Settings → Pages → Source: GitHub Actions.
- (Optional) Repo → Settings → Secrets and variables → Actions → New repository secret for each of
VITE_UPLOAD_ENDPOINT,VITE_BACKEND_TS_URL,VITE_BACKEND_GO_URL. - Push to
main. Thepages.ymlworkflow builds the demo withVITE_BASE_PATH=/<repo-name>/and publishes to thegithub-pagesenvironment. First deploy takes a minute or two to provision; subsequent ones land in ~30s.
An independent 3D component for AI-relighting workflows. The picker shows the host-picked frame on a flat plane inside a wireframe sphere; the user drags a light dot around the surface to set direction. Brightness drives the cone-beam length; color tints the beam.
Three.js powers the scene and ships only on the @iplex/aicut-core/lighting sub-entry — consumers of the video editor pay nothing for it.
The library renders just the picker (scene + controls). Smart-mode prompt, preset thumbnails, Generate button, close behaviour — all host code, laid out alongside <LightingEditor> in your own flex/grid:
import { useRef, useState } from "react";
import { LightingEditor, type LightingEditorApi } from "@iplex/aicut-react/lighting";
import "@iplex/aicut-core/styles.css";
function Relight() {
const apiRef = useRef<LightingEditorApi | null>(null);
const [smartOpen, setSmartOpen] = useState(true);
const onGenerate = (): void => {
const cfg = apiRef.current?.getConfig();
if (cfg) fetch("/relight", { method: "POST", body: JSON.stringify(cfg) });
};
return (
<div style={{ display: "flex", gap: 16 }}>
<LightingEditor
apiRef={apiRef}
subjectImageUrl="/frames/subject.jpg"
onChange={(cfg) => console.log(cfg)}
// Buttons rendered inside the controls column's footer slot
// — the only place the library leaves room for host actions.
controlsFooter={
<button onClick={() => apiRef.current?.reset()}>Reset</button>
}
/>
{smartOpen && (
<aside>
<button onClick={() => setSmartOpen(false)}>×</button>
<textarea placeholder="Describe the lighting…" />
<button onClick={onGenerate}>Generate</button>
</aside>
)}
</div>
);
}The full LightingConfig (brightness, color, key-direction unit vector, key preset, rim toggle) is plain JSON — same philosophy as the video editor's project.
The <Timeline> component works without the rest of the editor — useful for a frame-picker, a thumbnail strip, or a read-only preview.
import { Timeline } from "@iplex/aicut-react";
<Timeline
defaultProject={{ /* single clip */ }}
showHeader={false}
readOnly
toolbar
toolbarLeft={<span>Picked at {pickedMs / 1000}s</span>}
onSeek={(ms) => setPickedMs(ms)}
/>packages/
core/ @iplex/aicut-core framework-agnostic engine
├─ Editor + Project + EventBus
├─ Pluggable PlaybackEngine
│ (default: HtmlVideoEngine; host
│ can inject WebCodecs / WebGL /
│ IPC-bridged native players)
├─ Canvas Timeline (ruler, tracks, clips,
│ thumbnails, playhead, snap, scrollbars)
└─ Theme + i18n (en / zh)
react/ @iplex/aicut-react thin React shell, portal-based slots
vue/ @iplex/aicut-vue thin Vue 3 shell, slot watchers
examples/
react-demo/ Vite playground covering every public surface
e2e/ Playwright (system Chrome, --no-proxy-server)
backends/
ts/ Fastify SSE export service
go/ net/http SSE export service
docs/
screenshots/ README assets, regenerated by the screenshots spec
Library packages (packages/*) publish to npm. Everything else exists to exercise and validate them.
pnpm install # workspace install
pnpm build # build core / react / vue
pnpm demo:react # http://127.0.0.1:5173
# Demo media: drop two H.264 MP4/MOV files at
# examples/react-demo/public/{a,b}.mov
# Served same-origin so both <video> and WebCodecs/fetch work without
# CORS gymnastics. Gitignored — replace with your own clips.
# Backends
cd backends/ts && pnpm dev # http://127.0.0.1:8787
cd backends/go && go run . # http://127.0.0.1:8788
# Tests
pnpm typecheck # whole workspace, strict TS
pnpm test # Vitest unit tests (packages/core)
pnpm test:e2e # Playwright against the live demo
pnpm screenshots # regenerate docs/screenshots/*.png
# (drop any short clip at examples/
# react-demo/public/sample.mp4 first)# Bump versions in packages/*/package.json then:
NPM_TOKEN=npm_xxx ./scripts/publish.sh
# Or with 2FA:
NPM_TOKEN=npm_xxx ./scripts/publish.sh --otp 123456The script is idempotent — already-published versions are skipped, so a re-run after a network blip only ships what's missing. Tags v<core-version> on full success.
- Multi-track timeline with drag / trim / split / snap
- In-canvas scrollbars + edge auto-scroll while dragging
- Top-toolbar slots for host-supplied controls
- SSE-progress export backends (TS + Go)
- Bundled
en/zhlocale packs + runtime switch - 3D lighting picker (
@iplex/aicut-core/lightingsub-entry) - Pluggable
PlaybackEngineinterface (HTML5 default, host can inject) - WebCodecs preview engine for frame-accurate seek (
@iplex/aicut-core/webcodecs, PoC: single-track MP4) - Density knobs —
timelineHeight(reactive),trackHeight,rulerHeightfor compact viewports - Per-clip keyframe animation (X / Y / Scale) + easing curves (linear / easeIn / easeOut / easeInOut)
- Backend ffmpeg compilation of keyframes — animated
scale+overlayfilter graph with per-framet-expressions, both TS + Go - Multi-track timeline export (PiP-aware compositor in TS backend — single
filter_complexpass with per-track overlay +enablewindows) - Source-of-truth project canvas (
Project.output) — pan/scale in canvas pixels, preview/export pixel-for-pixel - 3-column preview layout +
panelLeft/panelRighthost slots (CapCut-desktop posture) - Frame-aware timeline ruler — seconds first, frame sub-ticks at high zoom (
rulerMinTickPxknob) - Backend upload endpoint + HTTP Range support (QuickTime
.movwith trailing moov streams correctly) - Go backend parity with TS multi-track compositor
- Speed adjustment (timeline already reserves the slot)
- Audio track rendering + waveform thumbnails
- WebCodecs engine: multi-track compositing + transitions
- Lighting → relighting backend reference
- Hosted demo site
npm — @iplex/aicut-core · @iplex/aicut-react · @iplex/aicut-vue · Issues
Made with ❤️ for browser-based video editing · MIT License





