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.
| Model | Send | Each receiver | Fits |
|---|---|---|---|
| Commands | Commands, in one server-assigned order | Dispatches them on its own Project | Several people editing |
| Patches | patches events | Applies them to a plain document copy with applyJsonPatches | Read-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 } });| Piece | Why |
|---|---|
Server-assigned seq | All clients apply actions in one order. Without it, two clients can apply concurrent edits in different orders and diverge |
| Apply on echo, not on send | The 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 action | A 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 client | A 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 snapshotIds 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.
| Command | Id field | When omitted |
|---|---|---|
asset/add, track/add, clip/add | id | Required |
clip/split | newClipId | Random per client |
clip/duplicate | newClipId | Random per client |
effect/add | effectId | Random per client |
transition/add | id | Random 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:
| Situation | Result |
|---|---|
| Two users move the same clip | The later clip/move wins |
| Two users edit different properties of one clip | Both apply; clip/set-property changes only the fields it names |
| Two users edit the same text | The later text replaces the whole string. There is no character-level merge |
| One user deletes a clip, another edits it | The 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 });
},
);