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 zodimport { 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
| Function | Package | Signature |
|---|---|---|
registerTransitionKind | core | (kind: string, paramsSchema: ZodType) => void |
registerTransitionRenderer | renderer | (kind: string, renderer: TransitionRenderer) => void |
transitionParamsSchema | core | (kind) => ZodType | undefined |
getTransitionRenderer | renderer | (kind) => TransitionRenderer | undefined |
TransitionRenderer field | Meaning |
|---|---|
rendersBothClips | true: 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 |
| Argument | Meaning |
|---|---|
role | "from" (the outgoing clip) or "to" (the incoming clip) |
progress | 0 to 1 across the window. 0.5 is the cut |
params | The 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:
| Field | Effect |
|---|---|
opacity | Multiplied 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, offsetYPx | Moves 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
| Target | Custom transitions |
|---|---|
Preview, exportProject, renderProjectStill | Yes |
| Worker export, server export | No: 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)andgetTransitionRenderer(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.