Commands
Every edit is a validated, serializable, undoable command.
Commands are the only way to change the document. A command is plain JSON: a type and a payload.
project.dispatch({ type: "clip/move", payload: { clipId: "intro", startUs: 1_000_000 } });| Property | What it gives you |
|---|---|
| Validated | Payloads are checked against a Zod schema before anything runs |
| Atomic | A failed command throws and leaves state untouched |
| Deterministic | Same state + same command = same result. Required for collaboration and replay |
| Undoable | Each command records its inverse; see History |
| Serializable | Log, sync, store or generate them with an LLM |
Built-in commands
| Group | Commands |
|---|---|
| Project | project/set-settings |
| Assets | asset/add, asset/remove, asset/set-property → Assets |
| Tracks | track/add, track/remove, track/reorder, track/rename, track/set-property → Tracks |
| Clips | clip/add, clip/remove, clip/move, clip/trim, clip/split, clip/duplicate, clip/set-property → Clips |
| Keyframes | keyframe/set, keyframe/remove, keyframe/clear → Keyframes |
| Effects | effect/add, effect/update, effect/remove, effect/reorder → Effects |
| Transitions | transition/add, transition/update, transition/remove → Transitions |
project.commandCatalog() returns a JSON Schema for every registered command. It's the reference for every payload field, and you can hand it to an LLM as its tools: Tools from the command catalog.
Errors
dispatch throws one of three errors. State is never changed when it throws.
| Error | When | Useful fields |
|---|---|---|
UnknownCommandError | No command with that type is registered | commandType |
CommandValidationError | The payload doesn't match the schema | commandType, issues (Zod issues) |
CommandRejectedError | The payload is valid but can't apply to the current state | commandType, code |
import { CommandRejectedError, CommandValidationError } from "@miraiclip/core";
try {
project.dispatch({ type: "asset/remove", payload: { id: "intro-mp4" } });
} catch (error) {
if (error instanceof CommandRejectedError && error.code === "asset-in-use") {
// remove the clips that use it first
} else if (error instanceof CommandValidationError) {
console.error(error.issues);
} else {
throw error;
}
}Common rejection codes:
| Code | Cause |
|---|---|
track-not-found, clip-not-found | The id doesn't exist |
duplicate-id | An entity with that id already exists |
kind-mismatch | The track doesn't accept this clip kind |
asset-kind-mismatch | E.g. a video clip pointing at an audio asset |
asset-in-use | Removing an asset that clips still use |
Without exceptions
tryDispatch returns a result instead of throwing. Its failures are plain data, which suits AI agents and form validation.
import { tryDispatch } from "@miraiclip/core";
const result = tryDispatch(project, { type: "clip/move", payload: { clipId: "nope", startUs: 0 } });
if (!result.ok) {
result.error.kind; // "unknown-command" | "invalid-payload" | "rejected"
result.error.message; // 'Command "clip/move" rejected (clip-not-found): no clip "nope"'
}Several commands as one step
Wrap related commands in a transaction. They undo and redo together, and if one throws, the earlier ones are rolled back. More in History.
project.transaction(() => {
project.dispatch({ type: "clip/split", payload: { clipId: "intro", atUs: 2_000_000, newClipId: "intro-b" } });
project.dispatch({ type: "clip/remove", payload: { clipId: "intro-b" } });
}, "Trim end");Custom commands
Register your own command types. They get the same validation, history, patches and catalog entry as built-ins.
import { CommandRejectedError } from "@miraiclip/core";
import { z } from "zod";
project.registerCommand({
type: "clip/nudge",
schema: z.object({ clipId: z.string(), byUs: z.number().int() }),
handler: (doc, { clipId, byUs }) => {
const clip = doc.clips[clipId];
if (!clip) throw new CommandRejectedError("clip/nudge", "clip-not-found", `No clip ${clipId}`);
clip.startUs = Math.max(0, clip.startUs + byUs);
},
});
project.dispatch({ type: "clip/nudge", payload: { clipId: "intro", byUs: 500_000 } });| Rule | Why |
|---|---|
The handler mutates doc, a draft | Changes are recorded as patches and inverses (Immer) |
Throw CommandRejectedError to refuse | The draft is discarded and state stays untouched |
Keep handlers pure: no Date.now(), Math.random() or I/O | Replays and collaborators must get the same result |
| Register on every project instance | Registration is per project, not global |
Custom commands use zod v4 schemas (the version @miraiclip/core uses).