Miraiclip SDK
Build an editor

Collaboration

Sync edits between users with commands or patches.

Miraiclip has no built-in sync, CRDT or operational transform. It gives you two building blocks, and your app supplies the transport, the ordering and the conflict rules.

ModelSendEach receiverFits
CommandsCommands, in one server-assigned orderDispatches them on its own ProjectSeveral people editing
Patchespatches eventsApplies them to a plain document copy with applyJsonPatchesRead-only viewers, server copies, autosave, audit logs

Commands: everyone replays the same edits

Every client starts from the same document and dispatches the same commands in the same order. Command handlers are deterministic, so every client ends up with the same document.

import { applyCommands, type Command } from "@miraiclip/core";

interface Envelope {
  seq: number; // assigned by the server: 0, 1, 2, …
  clientId: string;
  commands: Command[]; // one user action
}

declare const socket: { send(data: string): void; onmessage: ((e: { data: string }) => void) | null };
declare function showRejected(message: string): void;

const clientId = crypto.randomUUID();

// Local edits go to the server, not into the project.
function edit(...commands: Command[]) {
  socket.send(JSON.stringify({ clientId, commands }));
}

// The server stamps each action with the next seq and sends it to every client, the sender included.
let nextSeq = 0;
const early = new Map<number, Envelope>();

socket.onmessage = (e) => {
  const envelope = JSON.parse(e.data) as Envelope;
  early.set(envelope.seq, envelope);
  for (let next = early.get(nextSeq); next; next = early.get(nextSeq)) {
    early.delete(nextSeq);
    nextSeq++;
    const result = applyCommands(project, next.commands);
    if (!result.ok && next.clientId === clientId) showRejected(result.error.message);
  }
};

edit({ type: "clip/move", payload: { clipId: "intro", startUs: 2_000_000 } });
PieceWhy
Server-assigned seqAll clients apply actions in one order. Without it, two clients can apply concurrent edits in different orders and diverge
Apply on echo, not on sendThe sender's project only changes in seq order too. The cost is one round trip per edit; preview the gesture locally (a dragged element, a slider) until the echo arrives
applyCommands per actionA multi-command action (ripple delete, paste) applies all or nothing as one undo step. A failure returns { ok: false, failedIndex, error } instead of throwing
Validation on every clientA command that no longer fits the current document (its clip was deleted) is rejected. Every client has the same document, so every client rejects it. Tell its sender

To apply edits optimistically before the echo, your app has to roll back and replay its pending commands when another client's action lands first. The core has no API for that.

Joining late

Keep the latest document and its seq on the server. @miraiclip/core runs in Node, so the server can hold a Project and apply the same commands. A joining client starts from that snapshot:

import { createProject, type ProjectDocument } from "@miraiclip/core";

declare const snapshot: { doc: ProjectDocument; seq: number }; // from the server

const shared = createProject(snapshot.doc);
let nextSeq = snapshot.seq + 1; // apply actions after the snapshot

Ids must be explicit

A command that creates something takes its id from the payload. When the id is omitted, the handler generates a random one, and each client generates a different one. The documents diverge, and later commands that reference the id fail on some clients.

CommandId fieldWhen omitted
asset/add, track/add, clip/addidRequired
clip/splitnewClipIdRandom per client
clip/duplicatenewClipIdRandom per client
effect/addeffectIdRandom per client
transition/addidRandom per client
const newClipId = crypto.randomUUID(); // chosen once, by the sender
const command = { type: "clip/split", payload: { clipId: "intro", atUs: 3_000_000, newClipId } };

Helpers that build commands follow the same rule: importAudio from @miraiclip/audio-sources takes assetId, clipId and trackId options; caption import takes an idPrefix. Pass them.

Custom commands must be pure (no Date.now(), Math.random(), network or storage) and registered on every client. See Custom commands.

Conflicts

The rule is "last in seq order wins", per field:

SituationResult
Two users move the same clipThe later clip/move wins
Two users edit different properties of one clipBoth apply; clip/set-property changes only the fields it names
Two users edit the same textThe later text replaces the whole string. There is no character-level merge
One user deletes a clip, another edits itThe later edit is rejected with clip-not-found on every client

Rules your editor enforces in the UI, such as no overlaps or locked tracks, are checked against the sender's state, which may be behind. To enforce one on every client, check it inside a custom command handler, which runs on the shared, ordered state.

Undo in a shared session

History is per Project. In the command model:

  • Every applied command, including other users', lands on the local undo stack. project.undo() can revert someone else's edit.
  • undo() reverts through stored patches, not through a command. Other clients never see it, so the documents diverge.

The core has no multi-user undo. Either disable undo and redo while a session is shared, or implement undo in your app as new commands sent through the server: for example, record the clip's previous startUs and send a clip/move back to it.

Patches: a read-only mirror

A Project emits patches for every document change, including undo, redo and transaction rollbacks. Forward them in order to keep a plain copy of the document up to date:

import { applyJsonPatches, type JsonPatchOp, type ProjectDocument } from "@miraiclip/core";

declare function send(patches: JsonPatchOp[]): void;

// Editor: forward every event, in order
project.events.on("patches", ({ patches }) => send(patches));

// Viewer or server: start from a snapshot, then apply each batch
let mirror: ProjectDocument = project.toJSON();
function onRemotePatches(patches: JsonPatchOp[]) {
  mirror = applyJsonPatches(mirror, patches); // returns a new document
}

Patch paths are relative to the document root, e.g. /clips/intro/startUs. The mirror is a plain ProjectDocument, not a Project. A Project changes only through commands, so to drive a live preview, a viewer should use the command model and only receive.

More in Events & patches.

Presence

playheadUs and selection are ephemeral: no patches, not in toJSON(). Send them on a separate channel and draw other users' cursors and selections in your own UI. Calling setSelection with a remote user's ids would replace the local user's selection.

declare function sendPresence(presence: { clientId: string; selection?: string[]; playheadUs?: number }): void;
declare const clientId: string;

project.subscribe(
  (s) => s.selection,
  (selection) => sendPresence({ clientId, selection }),
);

// The playhead changes every frame during playback: throttle it.
let lastSent = 0;
project.subscribe(
  (s) => s.playheadUs,
  (playheadUs) => {
    const now = performance.now();
    if (now - lastSent < 100) return;
    lastSent = now;
    sendPresence({ clientId, playheadUs });
  },
);

On this page