@miraiclip/editor
Headless editor logic for Miraiclip: timeline scale, clip drag and trim with snapping, selectors, typed commands, gestures, shortcuts, media import. No UI framework.
npm install @miraiclip/editor| Version | 0.2.0 |
| Runs in | Anywhere (a few media helpers need the browser) |
| Exports | 62 |
| Guides | Headless editor logic, Timeline |
Functions
beginClipDrag
Start dragging a clip (move, or trim either edge) with snapping. Pure: the
caller turns pointer events into update(deltaUs, overTrackId) and calls
commit() on release, so a drag is one undo step. Returns null when the
clip doesn't exist or sits on a locked track.
function beginClipDrag(project: Project, clipId: string, mode: ClipDragMode, options: ClipDragOptions): ClipDrag | nullGuides: Timeline, Timeline, Headless editor logic
beginGesture
A live, continuous edit (canvas drag, rotate) that lands in history as ONE entry. Each update undoes the gesture's previous entry and commits the new absolute state, so the canvas shows every intermediate frame while undo steps back over the whole gesture at once.
Safety: it only ever undoes an entry it committed itself. If anything else commits, undoes or redoes mid-gesture (an AI edit, a keyboard undo), the next update starts a fresh entry instead of undoing someone else's.
function beginGesture(project: Project, label: string): GestureGuides: Headless editor logic
beginTransformDrag
Start moving, scaling or rotating a clip from bounds (where it is drawn
now, keyframes evaluated). start is the pointer in composition px.
Returns null when the clip can't be transformed (missing, audio, locked).
Moves snap the content's box (edges and center) to the frame's edges and center; scale is uniform around the origin; rotation snaps gently to right angles, or to 15° steps with Shift.
function beginTransformDrag(project: Project, bounds: TransformBounds, mode: TransformDragMode, start: {
x: number;
y: number;
}, options?: TransformDragOptions): TransformDrag | nullGuides: Selection & canvas editing, Transform box, Headless editor logic
canTransformClip
True when the clip can be moved on the preview: it exists, is visual, and its track isn't locked.
function canTransformClip(project: Project, clipId: string): booleanGuides: Headless editor logic
clipsByTrack
Every track's clips, sorted by start time. Cached per doc.clips object.
function clipsByTrack(clips: ProjectDocument["clips"]): Map<string, Clip[]>Guides: Headless editor logic
clipsInRange
Clips overlapping [fromUs, toUs), from a start-sorted list (one track from
clipsByTrack). A clip starting before the range can still reach into it,
so the search looks back past the longest clip on the list. Use it to mount
only the clips a timeline lane shows.
function clipsInRange(clips: readonly Clip[], fromUs: Us, toUs: Us): Clip[]Guides: Timeline, Headless editor logic
clipSnapTargets
Default snap targets for a clip: 0, the playhead, and the other clips' edges on its track.
function clipSnapTargets(project: Project, clip: Clip): Us[]compositionDurationUs
End of the last clip: the composition's length. Cached per doc.clips object.
function compositionDurationUs(clips: ProjectDocument["clips"]): UsGuides: Headless editor logic
cornerCursor
CSS resize cursor for corner index (0 top-left, clockwise), adjusted for rotation.
function cornerCursor(index: number, rotationDeg: number): stringGuides: Headless editor logic
createCommands
Typed command builders. UI code dispatches through these helpers instead of
hand-writing raw command objects, so the command vocabulary lives in one
place. Property edits are clip/set-property and track/set-property;
there is no generic clip/update.
function createCommands(project: Project): {
setProjectSettings(patch: {
width?: number;
height?: number;
fps?: number;
name?: string;
}): void;
addAsset(payload: {
id: string;
kind: "video" | "audio" | "image" | "font";
src: string;
durationUs?: Us;
width?: number;
height?: number;
fps?: number;
family?: string;
weight?: FontWeight;
style?: FontStyle;
weightRange?: [FontWeight, FontWeight];
name?: string;
source?: AssetSource;
license?: AssetLicense;
attribution?: string;
}): void;
setAssetProperty(id: string, patch: {
name?: string | null;
source?: AssetSource | null;
license?: AssetLicense | null;
attribution?: string | null;
}): void;
addTrack(payload: {
id: string;
kind: "video" | "audio";
name?: string;
index?: number;
}): void;
removeTrack(trackId: string): void;
renameTrack(trackId: string, name: string): void;
reorderTrack(trackId: string, index: number): void;
setTrackProperty(trackId: string, patch: {
muted?: boolean;
solo?: boolean;
locked?: boolean;
hidden?: boolean;
}): void;
addVideoClip(payload: {
id: string;
trackId: string;
assetId: string;
startUs: Us;
durationUs: Us;
trimStartUs?: Us;
volume?: number;
fadeInUs?: Us;
fadeOutUs?: Us;
transform?: Partial<Transform>;
}): void;
addAudioClip(payload: {
id: string;
trackId: string;
assetId: string;
startUs: Us;
durationUs: Us;
trimStartUs?: Us;
volume?: number;
fadeInUs?: Us;
fadeOutUs?: Us;
}): void;
addImageClip(payload: {
id: string;
trackId: string;
assetId: string;
startUs: Us;
durationUs: Us;
transform?: Partial<Transform>;
}): void;
addTextClip(payload: {
id: string;
trackId: string;
startUs: Us;
durationUs: Us;
text: string;
fontFamily?: string;
fontSizePx?: number;
color?: string;
fontWeight?: FontWeight;
textAlign?: TextAlign;
transform?: Partial<Transform>;
}): void;
addCaptionClip(payload: {
id: string;
trackId: string;
startUs: Us;
durationUs: Us;
words: CaptionWord[];
style?: Partial<CaptionStyle>;
transform?: Partial<Transform>;
}): void;
addHtmlClip(payload: {
id: string;
trackId: string;
startUs: Us;
durationUs: Us;
template: string;
params: Record<string, HtmlParamValue>;
widthPx?: number;
heightPx?: number;
animated?: boolean;
transform?: Partial<Transform>;
}): void;
moveClip(clipId: string, patch: {
startUs?: Us;
trackId?: string;
}): void;
trimClip(clipId: string, patch: {
startUs?: Us;
durationUs?: Us;
trimStartUs?: Us;
}): void;
splitClip(clipId: string, atUs: Us, newClipId?: string): void;
duplicateClip(clipId: string, patch?: {
newClipId?: string;
startUs?: Us;
trackId?: string;
}): void;
removeClip(clipId: string): void;
setClipProperty(clipId: string, patch: {
transform?: Partial<Transform>;
volume?: number;
fadeInUs?: Us | null;
fadeOutUs?: Us | null;
text?: string;
fontFamily?: string;
fontSizePx?: number;
color?: string;
fontWeight?: FontWeight | null;
fontStyle?: FontStyle | null;
lineHeight?: number | null;
letterSpacing?: number | null;
textAlign?: TextAlign | null;
style?: Record<string, unknown>;
params?: Record<string, HtmlParamValue>;
template?: string;
unsetParams?: string[];
widthPx?: number | null;
heightPx?: number | null;
animated?: boolean;
words?: CaptionWord[];
}): void;
addEffect(clipId: string, kind: string, params?: Record<string, unknown>, enabled?: boolean): void;
updateEffect(clipId: string, effectId: string, patch: {
params?: Record<string, unknown>;
enabled?: boolean;
}): void;
removeEffect(clipId: string, effectId: string): void;
addTransition(payload: {
id?: string;
kind: string;
fromClipId: string;
toClipId: string;
durationUs: Us;
params?: Record<string, unknown>;
}): void;
updateTransition(transitionId: string, patch: {
durationUs?: Us;
params?: Record<string, unknown>;
}): void;
removeTransition(transitionId: string): void;
setKeyframe(payload: {
clipId: string;
property: AnimatableProperty;
timeUs: Us;
value: number;
easing?: EasingPreset | Easing;
anchor?: "start" | "end";
}): void;
clearKeyframes(clipId: string, property?: AnimatableProperty): void;
select(ids: string[]): void;
setPlayhead(us: Us): void;
transaction(fn: () => void, label?: string): void;
}Guides: Headless editor logic
fillScale
The renderer draws video/image clips so that transform.scale = 1 FITS the
composition (contain). FILL (cover) is therefore max ratio / min ratio.
Needs the asset's pixel size; returns undefined when it isn't known.
function fillScale(asset: Asset | undefined, settings: Pick<ProjectSettings, "width" | "height">): number | undefinedGuides: Headless editor logic
followPlayheadScroll
Page-follow during playback: when the playhead nears the right edge (or is behind the left one), the scroll that puts it 10% in from the left. Returns null when no scroll is needed.
function followPlayheadScroll(input: PlayheadViewInput & {
edgePx?: number;
}): number | nullGuides: Headless editor logic
formatClock
mm:ss (the compact clock used in transport bars and ruler labels).
function formatClock(us: Us): stringGuides: Timeline, Headless editor logic
isFramable
Clips whose picture can be framed (fit/fill/scale).
function isFramable(clip: Clip | undefined): clip is Clip & {
assetId: string;
}isSourceReachable
Whether a media src can still be fetched: catches session-only blob:
URLs in a reopened project and URLs that 404. data: URLs are always
reachable.
function isSourceReachable(src: string): Promise<boolean>Guides: Headless editor logic, Compatibility, Roadmap
isTrackFree
True when [startUs, endUs) overlaps no clip on the track, except ignoreClipId
(the clip being moved).
function isTrackFree(doc: ProjectDocument, trackId: string, startUs: Us, endUs: Us, ignoreClipId?: string): booleanGuides: Headless editor logic
lowerBound
First index in start-sorted clips whose start is at or after us.
function lowerBound(clips: readonly Clip[], us: Us): numbermatchEditorShortcut
The default key map:
- Delete / Backspace → delete the selected clips
- S → split the selected clips at the playhead
- Ctrl/⌘ + Z → undo; Ctrl/⌘ + Shift + Z or Ctrl/⌘ + Y → redo
Returns null for any other key. Callers skip events from text fields.
function matchEditorShortcut(event: ShortcutKey): EditorAction | nullGuides: Headless editor logic
newId
A unique id with a readable prefix, e.g. clip-V1StGXR8_Z.
function newId(prefix?: string): stringnudgeClip
Move a clip by whole composition pixels (arrow-key nudges), as one undo step. Returns false when it can't move.
function nudgeClip(project: Project, bounds: TransformBounds, dxPx: number, dyPx: number): booleanGuides: Headless editor logic
placeAssetAtPlayhead
Add an asset at the playhead on the first accepting, unlocked track that is free for the clip's span; when none is, add a new track after the last accepting one. One undo step either way. Returns the clip id or null.
function placeAssetAtPlayhead(project: Project, assetId: string, options?: PlaceAssetOptions): string | nullGuides: Asset import, Blocks, Headless editor logic
placeAssetOnTrack
Add an asset as a clip on a track (clip/add). Returns the clip id, or null
when the asset or track is missing, the track is locked, the track can't
hold that kind, or the asset isn't placeable (fonts).
function placeAssetOnTrack(project: Project, assetId: string, trackId: string, startUs: Us, options?: PlaceAssetOptions): string | nullGuides: Headless editor logic
playheadOffscreenSide
Which side of the viewport the playhead is off, or null when it is visible.
function playheadOffscreenSide(input: PlayheadViewInput): "left" | "right" | nullGuides: Headless editor logic
presetFor
function presetFor(settings: Pick<ProjectSettings, "width" | "height">): AspectPreset | undefinedprobeMediaFile
Read a media file's metadata (and a thumbnail for video/image) in the
browser, without decoding it into the engine. Returns everything needed for
an asset/add command, with src as an object URL (revoke it when the
asset is gone, or upload the file and use your own URL). Best-effort: on a
probe failure it still returns a usable asset with a fallback duration.
Returns null for files that aren't video, audio or images.
function probeMediaFile(file: File): Promise<ProbedMedia | null>Guides: Asset import, Blocks, Headless editor logic, Quickstart
pxToUs
Pixels → microseconds at a zoom. Not rounded: round (or snapToFrame) before dispatching.
function pxToUs(px: number, pxPerSecond: number): UsGuides: Timeline, Headless editor logic
refillForAspect
For an aspect-ratio change: clips currently framed to FILL stay filled (their fill scale changes with the aspect). Returns [clipId, newScale] pairs.
function refillForAspect(doc: ProjectDocument, next: Pick<ProjectSettings, "width" | "height">): [string, number][]Guides: Headless editor logic
revealPlayheadScroll
The scroll that brings the playhead into view, 30% in from the left.
function revealPlayheadScroll(input: Omit<PlayheadViewInput, "scrollLeftPx">): numberGuides: Headless editor logic
rotateHandlePosition
Where a rotate handle sits: distancePx beyond the middle of the top
edge, along the content's "up". Returns the handle and the edge midpoint
(for a connecting line), in composition px.
function rotateHandlePosition(bounds: TransformBounds, distancePx: number): {
handle: CompositionPoint;
anchor: CompositionPoint;
}Guides: Headless editor logic
runEditorAction
Run an editing action against the project. Delete and split act on every
selected clip on an unlocked track, as one undo step. Returns whether
anything happened (so a key handler knows to preventDefault).
function runEditorAction(project: Project, action: EditorAction): booleanGuides: Timeline, Headless editor logic
snapOffset
Snap the closest candidate to the closest target within thresholdPx.
Returns the offset to add and the target hit, or null.
function snapOffset(candidates: readonly number[], targets: readonly number[], thresholdPx: number): {
delta: number;
target: number;
} | nullGuides: Headless editor logic
snapToTargets
The value in targets closest to valueUs within thresholdUs, else valueUs itself.
function snapToTargets(valueUs: Us, targets: readonly Us[], thresholdUs: Us): UstrackCuts
The cuts on one track, in time order, from its start-sorted clips (one
list from clipsByTrack) and its transitions. Only visual clips form
cuts, as in core's findCuts, which covers the whole project with the
transition-length limits.
function trackCuts(clips: readonly Clip[], transitions?: readonly Transition[]): TrackCut[]Guides: Timeline, Headless editor logic
transitionsByTrack
Every track's transitions. Cached per doc.transitions object.
function transitionsByTrack(transitions: ProjectDocument["transitions"]): Map<string, Transition[]>Guides: Headless editor logic
usToPx
Microseconds → pixels at a zoom.
function usToPx(us: Us, pxPerSecond: number): numberGuides: Headless editor logic
visibleRange
The time range a timeline should render (virtualisation): what is visible plus one viewport of overscan on each side, snapped to half-viewport steps so ordinary scrolling changes the range only every half screen. Clips outside it need not be in the DOM at all.
function visibleRange({ scrollLeftPx, viewportWidthPx, pxPerSecond }: VisibleRangeInput): {
fromUs: Us;
toUs: Us;
}Guides: Timeline, Headless editor logic
wheelZoomFactor
Zoom factor for a wheel event's deltaY (smooth on trackpads, steady on mice).
function wheelZoomFactor(deltaY: number): numberGuides: Headless editor logic
writeClipTransform
Write transform values. A property with keyframes gets a keyframe at the playhead; the others change the clip's static transform.
function writeClipTransform(project: Project, clipId: string, patch: Partial<Transform>): voidGuides: Transform box, Headless editor logic
zoomAtPoint
Zoom around a point: the new zoom and the scroll that keeps the anchored time under the pointer.
function zoomAtPoint(input: ZoomAtPointInput): {
pxPerSecond: number;
scrollLeftPx: number;
}Guides: Timeline, Headless editor logic
Constants
ASPECT_PRESETS
Output-format presets. The short side stays 1080 so quality is constant.
const ASPECT_PRESETS: readonly [{
readonly id: "9:16";
readonly label: "9:16";
readonly hint: "Reels, Shorts, TikTok";
readonly width: 1080;
readonly height: 1920;
}, {
readonly id: "16:9";
readonly label: "16:9";
readonly hint: "YouTube, landscape";
readonly width: 1920;
readonly height: 1080;
}, {
readonly id: "1:1";
readonly label: "1:1";
readonly hint: "Square post";
readonly width: 1080;
readonly height: 1080;
}, {
readonly id: "4:5";
readonly label: "4:5";
readonly hint: "Instagram feed";
readonly width: 1080;
readonly height: 135 …Guides: Headless editor logic
Interfaces
ClipDrag
| Member | Type | Description |
|---|---|---|
preview | ClipDragPreview | |
update | (deltaUs: Us, overTrackId?: string) => ClipDragPreview | Pointer moved: deltaUs is the horizontal movement since the drag began; overTrackId the track under the pointer (move only). A track that is locked or can't hold this clip kind is ignored. |
commit | () => boolean | Dispatch the result as one clip/move or clip/trim. Returns false when nothing changed. |
ClipDragOptions
| Member | Type | Description |
|---|---|---|
snapThresholdUs | Us | Snap distance in µs. Callers usually derive it from a pixel distance at the current zoom: pxToUs(8, pxPerSecond). 0 turns snapping off. |
minDurationUs? | Us | Shortest a trim can make the clip (default 200 ms). |
snapTargets? | readonly Us[] | Snap targets in µs. Default: 0, the playhead, and every other clip edge on the clip's track. |
ClipDragPreview
The dragged clip as it would land right now. Nothing is dispatched until commit.
| Member | Type | Description |
|---|---|---|
clipId | string | |
mode | ClipDragMode | |
originTrackId | string | |
trackId | string | The track it would land on (differs from originTrackId only when moving across tracks). |
startUs | Us | |
durationUs | Us |
Guides: Timeline
CompositionPoint
A point in composition pixels.
| Member | Type | Description |
|---|---|---|
xPx | number | |
yPx | number |
Gesture
| Member | Type | Description |
|---|---|---|
update | (apply: () => void) => void | Apply the gesture's CURRENT absolute state (e.g. "x = start + delta"). Replaces the previous update in history. |
end | () => void | Finish: the last update stays as one history entry. |
PlaceAssetOptions
| Member | Type | Description |
|---|---|---|
clipId? | string | Id for the new clip (default: a generated clip-… id). |
imageDurationUs? | Us | Length of a still image clip (default 5 s). |
select? | boolean | Select the new clip (default true). |
PlayheadViewInput
| Member | Type | Description |
|---|---|---|
playheadUs | Us | |
pxPerSecond | number | |
scrollLeftPx | number | |
viewportWidthPx | number |
ProbedMedia
| Member | Type | Description |
|---|---|---|
kind | Extract<AssetKind, "video" | "audio" | "image"> | |
src | string | |
durationUs | number | |
width? | number | |
height? | number | |
thumb? | string | data: URL thumbnail (video first frame / the image itself); undefined for audio. |
ShortcutKey
The parts of a keyboard event the shortcut map reads (a DOM KeyboardEvent fits).
| Member | Type | Description |
|---|---|---|
key | string | |
ctrlKey? | boolean | |
metaKey? | boolean | |
shiftKey? | boolean | |
altKey? | boolean |
TrackCut
A cut on one track: from ends exactly where to starts.
| Member | Type | Description |
|---|---|---|
from | Clip | |
to | Clip | |
atUs | Us | Timeline position of the cut. |
transition? | Transition | The transition on this cut, if any. |
TransformBounds
Where a clip is drawn. Structurally the renderer's ClipBounds
(getClipBounds / hitTest on the preview), so those can be passed as is.
| Member | Type | Description |
|---|---|---|
clipId | string | |
originXPx | number | The placement point (transform x/y in pixels). Scale and rotation pivot here. |
originYPx | number | |
centerXPx | number | Center of the drawn content. |
centerYPx | number | |
scale | number | |
rotationDeg | number | |
corners | readonly [CompositionPoint, CompositionPoint, CompositionPoint, CompositionPoint] | Corners clockwise from top-left, rotation applied. |
TransformDrag
| Member | Type | Description |
|---|---|---|
clipId | string | |
mode | TransformDragMode | |
moved | boolean | True once the pointer passed the drag threshold. |
update | (point: { x: number; y: number; }, modifiers?: TransformModifiers) => TransformGuides | Pointer moved to point (composition px). Applies the edit live and returns the guides to show. |
end | () => void | Finish: the drag is one undo step. |
TransformDragOptions
| Member | Type | Description |
|---|---|---|
snapThresholdPx? | number | Snap distance for moves, in composition px (0, the default, turns snapping off). |
dragThresholdPx? | number | Movement below this (composition px) is a click, not a drag (default 0). |
minScale? | number | Scale limits (default 0.05 … 20). |
maxScale? | number |
TransformGuides
Frame lines a move snapped to, in composition px, for drawing guides.
| Member | Type | Description |
|---|---|---|
x | number[] | |
y | number[] |
TransformModifiers
| Member | Type | Description |
|---|---|---|
altKey? | boolean | Move: place freely, without snapping. |
shiftKey? | boolean | Rotate: 15° steps. |
VisibleRangeInput
| Member | Type | Description |
|---|---|---|
scrollLeftPx | number | Horizontal scroll of the lanes, in px. |
viewportWidthPx | number | Width of the lanes' viewport, in px (without a sticky header column). |
pxPerSecond | number |
ZoomAtPointInput
| Member | Type | Description |
|---|---|---|
pxPerSecond | number | |
scrollLeftPx | number | |
anchorPx | number | Pointer x within the lanes' viewport, in px. The time under it stays put. |
factor | number | Multiplier on the current zoom (> 1 zooms in). |
minPxPerSecond | number | |
maxPxPerSecond | number |
Types
AspectPreset
type AspectPreset = (typeof ASPECT_PRESETS)[number];ClipDragMode
What a drag changes: the whole clip, its in-point, or its out-point.
type ClipDragMode = "move" | "trim-start" | "trim-end";Commands
type Commands = ReturnType<typeof createCommands>;EditorAction
Editing actions with a default key.
type EditorAction = "delete" | "split" | "undo" | "redo";TransformDragMode
type TransformDragMode = "move" | "scale" | "rotate";