Miraiclip SDK
Build an editor

Properties panel

Forms for the selected clip, generated from the command schemas.

A properties panel reads the selected clip and dispatches clip/set-property when a field changes. The field types, ranges and enums come from the command catalog, so the form doesn't hard-code validation.

The selected clip

import type { Clip, ProjectState } from "@miraiclip/core";

const selectClip = (s: ProjectState) =>
  s.selection.length === 1 ? s.doc.clips[s.selection[0]!] : undefined;

project.subscribe(selectClip, (clip) => {
  if (clip) renderPanel(clip);
  else clearPanel();
});

declare function renderPanel(clip: Clip): void;
declare function clearPanel(): void;

The selector returns the clip object itself. It's a new object only when that clip changed, so other edits don't re-render the panel. It returns undefined for a removed clip, because removing a clip leaves its id in selection.

Fields per clip kind

clip/set-property has one schema for every kind. A field that doesn't apply to the clip's kind is rejected (CommandRejectedError), so pick fields by kind:

KindFields
Alltransform (x, y, scale, rotation, opacity); nested fields merge
video, audiovolume, fadeInUs, fadeOutUs
imagetransform only
texttext, fontFamily, fontSizePx, color, fontWeight, fontStyle, lineHeight, letterSpacing, textAlign
captionstyle (nested fields merge), words
htmltemplate, params, unsetParams, widthPx, heightPx, animated
Rejection codeCause
no-audiovolume or fades on a clip without sound
not-textA text field on a non-text clip
not-captionstyle or words on a non-caption clip
not-htmlAn html field on a non-html clip

Custom clip kinds

clip/set-property has no props field. Unknown fields are stripped by validation, so { props: … } changes nothing. Edit a custom kind's props with a custom command.

Fields from the schema

project.commandCatalog() returns a JSON Schema per command. Read the clip/set-property properties to build inputs. Optional fields that can be cleared are anyOf: [<type>, { type: "null" }].

import { tryDispatch, type Clip } from "@miraiclip/core";

interface JsonSchema {
  type?: string;
  enum?: string[];
  anyOf?: JsonSchema[];
  minimum?: number;
  maximum?: number;
  exclusiveMinimum?: number;
  multipleOf?: number;
  properties?: Record<string, JsonSchema>;
}

const setProperty = project.commandCatalog()["clip/set-property"] as JsonSchema;

const FIELDS: Record<string, string[]> = {
  video: ["volume", "fadeInUs", "fadeOutUs"],
  audio: ["volume", "fadeInUs", "fadeOutUs"],
  text: ["text", "fontFamily", "fontSizePx", "color", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "textAlign"],
};

function field(clip: Clip, key: string, onError: (message: string) => void): HTMLElement {
  const schema = setProperty.properties![key]!;
  const nullable = schema.anyOf?.some((s) => s.type === "null") ?? false;
  const base = schema.anyOf?.find((s) => s.type !== "null") ?? schema;
  const current = (clip as unknown as Record<string, unknown>)[key];

  let input: HTMLInputElement | HTMLSelectElement;
  if (base.enum) {
    input = document.createElement("select");
    const options = nullable ? ["", ...base.enum] : base.enum; // "" = default
    input.append(...options.map((value) => new Option(value || "Default", value)));
  } else {
    input = document.createElement("input");
    if (base.type === "number" || base.type === "integer") {
      input.type = "number";
      if (base.minimum !== undefined) input.min = String(base.minimum);
      if (base.maximum !== undefined) input.max = String(base.maximum);
      input.step = String(base.multipleOf ?? (base.type === "integer" ? 1 : "any"));
    }
  }
  input.value = current === undefined ? "" : String(current);

  input.addEventListener("change", () => {
    const raw = input.value;
    const isNumber = base.type === "number" || base.type === "integer";
    const value = raw === "" && nullable ? null : isNumber ? Number(raw) : raw;
    const result = tryDispatch(project, { type: "clip/set-property", payload: { clipId: clip.id, [key]: value } });
    if (!result.ok) onError(result.error.message);
  });
  return input;
}

function fieldsFor(clip: Clip, onError: (message: string) => void): HTMLElement[] {
  return (FIELDS[clip.kind] ?? []).map((key) => field(clip, key, onError));
}
SchemaInput
enum<select>
type: "number" / "integer"Number input or slider; minimum, maximum, exclusiveMinimum, multipleOf give the limits
type: "string"Text input. Colors are plain strings (#rrggbb); use a color input for color
anyOf with nullOptional: offer a "Default" or reset control that sends null
type: "object"Nested group (transform, caption style); send only the changed key

tryDispatch returns { ok: false, error } instead of throwing. error.kind is invalid-payload (with error.issues) or rejected (with error.code). Show error.message next to the field.

The same schemas are exported as Zod objects in builtinPayloadSchemas["clip/set-property"] if you'd rather validate in your form library.

Clearing optional fields

null removes the key, which brings back the default.

import { TYPOGRAPHY_DEFAULTS } from "@miraiclip/core";

project.dispatch({ type: "clip/set-property", payload: { clipId: "title", fontWeight: null, letterSpacing: null } });

// Show the default as a placeholder when the key is absent.
const weightPlaceholder = String(TYPOGRAPHY_DEFAULTS.fontWeight); // "400"
Clearable with nullKinds
fadeInUs, fadeOutUsvideo, audio
fontWeight, fontStyle, lineHeight, letterSpacing, textAligntext
widthPx, heightPxhtml
Optional style fields (backgroundColor, strokeColor, shadowColor, …)caption

Sliders and undo

Every dispatch is one undo step. A slider fires input many times per drag, and transaction(fn) is synchronous, so it can't span pointer events. Two ways to get one step per drag:

Commit on release. Dispatch on change only. The preview updates when the drag ends.

const slider = document.querySelector<HTMLInputElement>("#opacity")!;

slider.addEventListener("change", () => {
  project.dispatch({ type: "clip/set-property", payload: { clipId: "intro", transform: { opacity: slider.valueAsNumber } } });
});

Live, one step. Dispatch on every input, and undo the previous step of the same drag first. The preview follows the slider, and the drag leaves one undo step.

function bindLiveSlider(slider: HTMLInputElement, clipId: string) {
  let stepOpen = false; // this drag already added an undo step
  let mine = false;

  // Another edit landed mid-drag: don't undo it.
  project.events.on("history", ({ kind }) => {
    if (kind === "commit" && !mine) stepOpen = false;
  });

  slider.addEventListener("input", () => {
    mine = true;
    try {
      if (stepOpen) project.undo();
      project.dispatch({ type: "clip/set-property", payload: { clipId, transform: { opacity: slider.valueAsNumber } } });
      stepOpen = true;
    } finally {
      mine = false;
    }
  });
  slider.addEventListener("change", () => {
    stepOpen = false;
  });
}

The live variant emits patches and history events for every intermediate value, including the undos. If you sync edits to other users, use commit on release; see Collaboration.

A dispatch that changes nothing (the same value again) adds no undo step and emits no events.

Animated properties

A property with keyframes ignores its static value. Editing transform.opacity on a clip with opacity keyframes changes nothing on screen. Show the evaluated value at the playhead and write a keyframe instead:

import { evaluateClipAt } from "@miraiclip/core";

const { doc, playheadUs } = project.getState();
const clip = doc.clips["intro"]!;
const localUs = playheadUs - clip.startUs;

if (clip.animations?.opacity?.length && localUs >= 0 && localUs <= clip.durationUs) {
  const shown = evaluateClipAt(clip, localUs).opacity;
  project.dispatch({ type: "keyframe/set", payload: { clipId: clip.id, property: "opacity", timeUs: localUs, value: shown } });
}

More in Keyframes.

Effects

getEffectInfo(kind) gives labels, ranges, steps and defaults for each built-in effect param. Build the effect section from it and dispatch effect/update, which merges params.

import { getEffectInfo, type EffectInstance } from "@miraiclip/core";

function effectInputs(clipId: string, effect: EffectInstance): HTMLInputElement[] {
  const info = getEffectInfo(effect.kind);
  if (!info) return []; // a custom kind: use its own schema
  return info.params.map((param) => {
    const input = document.createElement("input");
    input.title = param.label;
    if (param.type === "number") {
      Object.assign(input, { type: "range", min: String(param.min), max: String(param.max), step: String(param.step) });
    } else {
      input.type = "color";
    }
    input.value = String(effect.params[param.key] ?? param.default);
    input.addEventListener("change", () => {
      const value = param.type === "number" ? input.valueAsNumber : input.value;
      project.dispatch({ type: "effect/update", payload: { clipId, effectId: effect.id, params: { [param.key]: value } } });
    });
    return input;
  });
}

Effect kinds, categories and the picker: Effects. Typography fields: Typography. Caption styles: Captions.

On this page