Headless editor logic
Timeline maths, clip drag and trim, preview transforms, shortcuts and placement, for any framework.
@miraiclip/editor is the logic behind an editor UI, with no framework. The React package and its components build on it, and you can use it directly from vanilla JS, Vue or Svelte. Everything except the media-import helpers runs without a DOM, so it works in Node and tests too.
npm install @miraiclip/core @miraiclip/editorTimeline geometry
| Function | Returns |
|---|---|
usToPx(us, pxPerSecond), pxToUs(px, pxPerSecond) | Time ↔ pixels at a zoom. Round (or snapToFrame) before dispatching |
visibleRange({ scrollLeftPx, viewportWidthPx, pxPerSecond }) | { fromUs, toUs } to render: the visible part plus one viewport each side, in half-viewport steps |
zoomAtPoint({ pxPerSecond, scrollLeftPx, anchorPx, factor, min/maxPxPerSecond }) | The new zoom and the scroll that keeps the time under the pointer fixed |
wheelZoomFactor(deltaY) | A zoom factor for a wheel event |
playheadOffscreenSide(view), followPlayheadScroll(view), revealPlayheadScroll(view) | Keep the playhead findable while playing |
formatClock(us) | mm:ss |
Selectors
| Function | Returns |
|---|---|
clipsByTrack(doc.clips) | Map<trackId, Clip[]>, each list in time order |
clipsInRange(clips, fromUs, toUs) | Clips of one sorted list overlapping a range, including long clips that start before it |
isTrackFree(doc, trackId, startUs, endUs, ignoreClipId?) | Whether a range on a track is empty |
compositionDurationUs(doc.clips), transitionsByTrack(doc.transitions) | Composition length; transitions per track |
Results are cached per document object. The core replaces doc.clips only on an edit, so during playback these are lookups, not scans.
Dragging and trimming clips
beginClipDrag(project, clipId, mode, options) handles a move ("move") or a trim ("trim-start", "trim-end"). You feed it pointer movement; it returns where the clip would land, and dispatches one clip/move or clip/trim when you commit.
import type { Clip, Project, ProjectDocument } from "@miraiclip/core";
import { beginClipDrag, clipsByTrack, isTrackFree, pxToUs, usToPx } from "@miraiclip/editor";
export { usToPx };
/** The clips on one track, in time order. */
export function clipsOnTrack(doc: ProjectDocument, trackId: string): Clip[] {
return clipsByTrack(doc.clips).get(trackId) ?? [];
}
/**
* Drag a clip along its track. The element follows the pointer, snapping to
* clip edges, 0 and the playhead; one `clip/move` is dispatched on release,
* and only when the new range is free. Clips on locked tracks don't move.
*/
export function dragClip(
project: Project,
clipId: string,
el: HTMLElement,
down: { pointerId: number; clientX: number },
pxPerSecond: number,
) {
const drag = beginClipDrag(project, clipId, "move", { snapThresholdUs: pxToUs(8, pxPerSecond) });
if (!drag) return;
const fromUs = drag.preview.startUs;
const onMove = (e: PointerEvent) => {
const { startUs } = drag.update(pxToUs(e.clientX - down.clientX, pxPerSecond));
el.style.translate = `${usToPx(startUs - fromUs, pxPerSecond)}px 0`;
};
const onUp = () => {
el.removeEventListener("pointermove", onMove);
el.style.translate = "";
const { trackId, startUs, durationUs } = drag.preview;
if (isTrackFree(project.getState().doc, trackId, startUs, startUs + durationUs, clipId)) drag.commit();
};
el.setPointerCapture(down.pointerId);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp, { once: true });
el.addEventListener("pointercancel", onUp, { once: true });
}| Behaviour | Detail |
|---|---|
| Snapping | To 0, the playhead and other clip edges on the track, within snapThresholdUs. Pass snapTargets to use your own |
| Moving across tracks | update(deltaUs, overTrackId) moves to that track only if it is unlocked and accepts the clip's kind |
| Trimming | Keeps at least minDurationUs (default 200 ms). Trimming the start of a video or audio clip moves its source in-point too |
| Locked tracks | beginClipDrag returns null for a clip on a locked track |
| Overlap | Not checked: call isTrackFree before commit(), as above, if your tracks don't allow it |
| Undo | One entry per drag. commit() returns false and dispatches nothing when the clip didn't change |
Track cuts
Added in 0.2.0. trackCuts(clips, transitions?) lists the cuts on one track: places where one visual clip ends exactly where the next starts, which is where a transition can go. Pass one track's start-sorted clips (a list from clipsByTrack) and its transitions (from transitionsByTrack).
import { clipsByTrack, trackCuts, transitionsByTrack } from "@miraiclip/editor";
const { doc } = project.getState();
const cuts = trackCuts(clipsByTrack(doc.clips).get("main") ?? [], transitionsByTrack(doc.transitions).get("main"));
for (const { from, to, atUs, transition } of cuts) {
console.log(`${from.id} → ${to.id} at ${atUs} µs`, transition ? transition.kind : "no transition");
}Core's findCuts(doc) covers the whole project and adds each cut's longest allowed transition; trackCuts is the cheap per-track version a timeline draws from. <Timeline.Cuts> uses it.
Preview transforms
Added in 0.2.0. Move, scale and rotate a clip on the preview by dragging. The geometry works in composition pixels, from the bounds the preview reports (getClipBounds or hitTest on the preview controller).
import { beginTransformDrag } from "@miraiclip/editor";
// `bounds`: controller.getClipBounds(clipId). `start`: the pointer, in composition px.
const drag = beginTransformDrag(project, bounds, "move", start, { snapThresholdPx: 6 / scale });
if (drag) {
// On each pointer move: applies the edit live and returns the snap lines to draw.
const guides = drag.update(pointer, { altKey: event.altKey, shiftKey: event.shiftKey });
// On release: the whole drag is one undo step.
drag.end();
}| Mode | Behaviour |
|---|---|
"move" | Snaps the content's edges and center to the frame's edges and center within snapThresholdPx. altKey places freely |
"scale" | Uniform scale around the clip's anchor, between minScale and maxScale (default 0.05 to 20) |
"rotate" | Snaps gently to right angles; shiftKey snaps to 15° steps |
beginTransformDrag returns null for a clip that can't be transformed (missing, audio, or on a locked track). dragThresholdPx makes small movements a click: drag.moved stays false and nothing is written.
| Function | Does |
|---|---|
canTransformClip(project, clipId) | Whether the clip exists, is visual and its track isn't locked |
writeClipTransform(project, clipId, patch) | Writes x, y, scale, rotation or opacity. A property with keyframes gets a keyframe at the playhead; the others change the static transform |
nudgeClip(project, bounds, dxPx, dyPx) | Moves a clip by whole composition pixels, one undo step (arrow-key nudges) |
rotateHandlePosition(bounds, distancePx) | Where a rotate handle sits above the top edge, and the edge midpoint for a connecting line |
cornerCursor(index, rotationDeg) | The CSS resize cursor for a corner handle, adjusted for rotation |
snapOffset(candidates, targets, thresholdPx) | The offset that snaps the closest candidate to the closest target, or null |
<TransformBox.*> is the React component built on these.
Commands and gestures
createCommands(project) returns typed builders for every built-in command, so UI code never writes raw command objects:
import { createCommands } from "@miraiclip/editor";
const commands = createCommands(project);
commands.splitClip("intro", 2_000_000);
commands.setClipProperty("title", { text: "Hello", color: "#ffffff" });beginGesture(project, label) turns a continuous edit (a canvas drag, a slider) into one history entry. Each update(apply) replaces the previous one, and end() finishes. If anything else touches history mid-gesture, the next update starts a new entry instead of undoing someone else's.
Shortcuts
import { matchEditorShortcut, runEditorAction } from "@miraiclip/editor";
import { project } from "./project";
// Delete / Backspace, S (split at the playhead), ⌘/Ctrl+Z, ⇧⌘/Ctrl+Shift+Z or Ctrl+Y.
// Delete and split act on every selected clip on an unlocked track, as one undo step.
window.addEventListener("keydown", (e) => {
const el = e.target as HTMLElement;
if (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return;
const action = matchEditorShortcut(e);
if (action && runEditorAction(project, action)) e.preventDefault();
});Also uses project.ts
import { createProject } from "@miraiclip/core";
// One project instance for the whole app. Pass it to components as a prop.
export const project = createProject({ width: 1280, height: 720, fps: 30 });| Action | Keys | Effect |
|---|---|---|
delete | Delete, Backspace | Removes the selected clips on unlocked tracks, one undo step |
split | S | Splits the selected clips under the playhead, one undo step |
undo | ⌘/Ctrl+Z | |
redo | ⌘/Ctrl+Shift+Z, Ctrl+Y |
matchEditorShortcut(event) only maps keys, so you can rebind them or add your own before calling runEditorAction.
Placing assets
| Function | Does |
|---|---|
placeAssetOnTrack(project, assetId, trackId, startUs) | Adds the asset as a clip there, if the track is unlocked and accepts its kind |
placeAssetAtPlayhead(project, assetId) | Uses the first free accepting track at the playhead, or adds a track; one undo step |
Both select the new clip and return its id, or null when nothing was placed.
Importing media
Added in 0.1.1. probeMediaFile(file) reads a picked or dropped file in the browser: its kind, duration, size and a thumbnail, with an object URL as src. isSourceReachable(src) tells whether a saved project's media can still be fetched.
import { placeAssetAtPlayhead, probeMediaFile } from "@miraiclip/editor";
import { project } from "./project";
/** Probe a picked or dropped file, register it, and place it at the playhead. */
export async function importFile(file: File): Promise<string | null> {
const media = await probeMediaFile(file); // null for anything but video, audio, images
if (!media) return null;
const assetId = crypto.randomUUID();
project.transaction(() => {
project.dispatch({
type: "asset/add",
payload: {
id: assetId,
kind: media.kind,
src: media.src, // an object URL: upload the file and use your own URL to keep it across reloads
durationUs: media.durationUs,
...(media.width ? { width: media.width, height: media.height } : {}),
name: file.name,
},
});
placeAssetAtPlayhead(project, assetId);
}, "import");
return assetId;
}Also uses project.ts
import { createProject } from "@miraiclip/core";
// One project instance for the whole app. Pass it to components as a prop.
export const project = createProject({ width: 1280, height: 720, fps: 30 });For codec checks before import, use the renderer's demuxer instead (see Asset import).
Framing
ASPECT_PRESETS lists common output sizes. fillScale(asset, settings) is the scale that makes a clip cover the frame, and refillForAspect(doc, nextSize) lists the clips to rescale so filled clips stay filled after an aspect change.