Miraiclip SDK
Extending

Custom transitions

Register your own transition renderer.

A custom transition has two halves. Core validates its params; the renderer says how each clip looks at each moment of the transition.

npm install zod
import { registerTransitionKind } from "@miraiclip/core";
import { registerTransitionRenderer } from "@miraiclip/renderer";
import { z } from "zod";

registerTransitionKind("flash", z.object({}));

registerTransitionRenderer("flash", {
  rendersBothClips: false,
  frame: (role, progress) =>
    role === "from" ? { overlay: { color: 0xffffff, alpha: 1 - Math.abs(2 * progress - 1) } } : null,
});
project.dispatch({
  type: "transition/add",
  payload: { kind: "flash", fromClipId: "a", toClipId: "b", durationUs: 500_000 },
});

Register at app startup, before you create a player or start an export.

Contract

FunctionPackageSignature
registerTransitionKindcore(kind: string, paramsSchema: ZodType) => void
registerTransitionRendererrenderer(kind: string, renderer: TransitionRenderer) => void
transitionParamsSchemacore(kind) => ZodType | undefined
getTransitionRendererrenderer(kind) => TransitionRenderer | undefined
TransitionRenderer fieldMeaning
rendersBothClipstrue: both clips stay visible through the whole window (blends, wipes, slides). false: the cut stays hard and you cover it, for example with an overlay
frame(role, progress, params, context)Called every rendered frame inside the window, once per clip. Return the adjustments for that clip, or null for none
ArgumentMeaning
role"from" (the outgoing clip) or "to" (the incoming clip)
progress0 to 1 across the window. 0.5 is the cut
paramsThe transition's validated params
context.compositionSize{ width, height } in composition pixels

Keep frame pure and cheap: it runs for every frame and must give the same result in preview and export.

Adjustments

frame returns a TransitionFrameEffects object. Combine any of these:

FieldEffect
opacityMultiplied into the clip's opacity
reveal: { fraction, direction }Shows part of the clip: 0 hides it, 1 shows all of it. The visible edge moves in direction ("left", "right", "up", "down")
offsetXPx, offsetYPxMoves the clip, in composition pixels
overlay: { color, alpha }A full-frame solid color (0xRRGGBB). With several overlays active, the highest alpha wins

The built-ins use the same contract. crossDissolve is:

import type { TransitionRenderer } from "@miraiclip/renderer";

const crossDissolve: TransitionRenderer = {
  rendersBothClips: true,
  frame: (role, progress) => (role === "to" ? { opacity: progress } : null),
};

A blend with params

import { registerTransitionKind } from "@miraiclip/core";
import { registerTransitionRenderer } from "@miraiclip/renderer";
import { z } from "zod";

registerTransitionKind(
  "fadePush",
  z.object({ distance: z.number().min(0).max(1).default(0.5) }),
);

registerTransitionRenderer("fadePush", {
  rendersBothClips: true,
  frame: (role, progress, params, { compositionSize }) => {
    const distance = typeof params?.distance === "number" ? params.distance : 0.5;
    return role === "to"
      ? { opacity: progress, offsetXPx: (1 - progress) * compositionSize.width * distance }
      : null;
  },
});

project.dispatch({
  type: "transition/add",
  payload: { kind: "fadePush", fromClipId: "a", toClipId: "b", durationUs: 600_000, params: { distance: 0.25 } },
});

Express distances as fractions of compositionSize, not fixed pixels, so the transition looks the same at every output size.

Headroom

transition/add checks headroom for every kind, custom ones included: each clip needs half the transition's length of extra source media. With rendersBothClips: true the renderer uses that media; with false it doesn't, but the check still applies.

Audio

Every transition kind, custom included, ramps the audio with an equal-power curve: the outgoing clip fades out up to the cut and the incoming clip fades in after it. You don't write audio code.

Where custom transitions render

TargetCustom transitions
Preview, exportProject, renderProjectStillYes
Worker export, server exportNo: drawn as a hard cut

Renderers are functions, so they can't be sent to a worker or a server. A kind registered in core without a renderer is also drawn as a hard cut. The audio ramp still applies.

Notes

  • Both registries are global to the page and throw on a duplicate kind, built-ins included. With hot module reload, guard with transitionParamsSchema(kind) and getTransitionRenderer(kind).
  • Transitions place clips; they can't draw new pixels. For a custom look across a cut, put an HTML clip or a custom clip kind on a track above it.

On this page