Miraiclip SDK
Framework packages

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/editor

Timeline geometry

FunctionReturns
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

FunctionReturns
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.

timeline.ts
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 });
}
BehaviourDetail
SnappingTo 0, the playhead and other clip edges on the track, within snapThresholdUs. Pass snapTargets to use your own
Moving across tracksupdate(deltaUs, overTrackId) moves to that track only if it is unlocked and accepts the clip's kind
TrimmingKeeps at least minDurationUs (default 200 ms). Trimming the start of a video or audio clip moves its source in-point too
Locked tracksbeginClipDrag returns null for a clip on a locked track
OverlapNot checked: call isTrackFree before commit(), as above, if your tracks don't allow it
UndoOne 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();
}
ModeBehaviour
"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.

FunctionDoes
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

editor-keys.ts
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
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 });
ActionKeysEffect
deleteDelete, BackspaceRemoves the selected clips on unlocked tracks, one undo step
splitSSplits 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

FunctionDoes
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-file.ts
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
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.

On this page