Miraiclip SDK
Extending

Custom effects

Register your own effect renderer.

A custom effect has two halves. Core validates its params; the renderer draws it with a PixiJS v8 filter.

npm install zod pixi.js
import { registerEffectKind } from "@miraiclip/core";
import { registerEffectRenderer } from "@miraiclip/renderer";
import { ColorMatrixFilter } from "pixi.js";
import { z } from "zod";

// Core: the params schema effect/add and effect/update validate against
registerEffectKind(
  "oldPhoto",
  z.object({ amount: z.number().min(0).max(1).default(1) }),
);

// Renderer: build the filter once, update it in place
registerEffectRenderer("oldPhoto", (params) => {
  const filter = new ColorMatrixFilter();
  const apply = (p: Record<string, unknown>) => {
    filter.reset();
    filter.sepia(true);
    filter.alpha = typeof p.amount === "number" ? p.amount : 1;
  };
  apply(params);
  return { kind: "oldPhoto", filter, update: apply };
});

Then use it like a built-in:

project.dispatch({ type: "effect/add", payload: { clipId: "intro", kind: "oldPhoto", params: { amount: 0.8 } } });

Register at app startup, before you create a player or start an export. Check the built-in effects first: there are 79, and unlike custom ones they render in worker and server export.

Contract

FunctionPackageSignature
registerEffectKindcore(kind: string, paramsSchema: ZodType) => void
registerEffectRendererrenderer(kind: string, factory: EffectRendererFactory) => void
effectParamsSchemacore(kind) => ZodType | undefined: is the kind registered?
getEffectRendererrenderer(kind) => EffectRendererFactory | undefined

The factory receives (params, context) and returns an ActiveEffect:

ActiveEffect fieldMeaning
kindThe effect kind
filterA PixiJS Filter. It is destroyed when the effect is removed
update(params)Called on every effect/update. Change the existing filter here instead of creating a new one
EffectContext memberMeaning
compositionSize(){ width, height } of the composition
renderScale?()Physical pixels per composition pixel (export size ÷ composition size, or the preview's density). Absent means 1

params arrive already validated, with schema defaults filled in.

A shader effect

For anything a built-in Pixi filter can't do, write a fragment shader. Keep the uniforms in a UniformGroup so update only changes values.

import { registerEffectKind } from "@miraiclip/core";
import { registerEffectRenderer } from "@miraiclip/renderer";
import { defaultFilterVert, Filter, GlProgram, UniformGroup } from "pixi.js";
import { z } from "zod";

const fragment = /* glsl */ `
in vec2 vTextureCoord;
out vec4 finalColor;

uniform sampler2D uTexture;
uniform vec3 uColor;
uniform float uAmount;

void main(void) {
  vec4 c = texture(uTexture, vTextureCoord);
  float luma = dot(c.rgb, vec3(0.299, 0.587, 0.114));
  finalColor = vec4(mix(c.rgb, uColor * luma, uAmount), c.a);
}
`;

const hexToRgb = (hex: string): [number, number, number] => {
  const n = Number.parseInt(hex.slice(1), 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
};

registerEffectKind(
  "tint",
  z.object({
    color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#ff8800"),
    amount: z.number().min(0).max(1).default(0.8),
  }),
);

registerEffectRenderer("tint", (params) => {
  const uniforms = new UniformGroup({
    uColor: { value: new Float32Array(3), type: "vec3<f32>" },
    uAmount: { value: 0, type: "f32" },
  });
  const apply = (p: Record<string, unknown>) => {
    uniforms.uniforms.uColor = new Float32Array(hexToRgb(String(p.color)));
    uniforms.uniforms.uAmount = Number(p.amount);
  };
  apply(params);
  const filter = new Filter({
    glProgram: GlProgram.from({ vertex: defaultFilterVert, fragment }),
    resources: { tintUniforms: uniforms },
  });
  return { kind: "tint", filter, update: apply };
});

The texture holds premultiplied alpha, so keep color channels at or below c.a, as the example does.

Sizes: fractions, not pixels

A param that describes a length (a blur radius, a dot size) should be a fraction of composition height. Convert it to pixels in the renderer. A pixel value would look different in a scaled preview and a full-size export.

import { registerEffectKind } from "@miraiclip/core";
import { registerEffectRenderer } from "@miraiclip/renderer";
import { BlurFilter } from "pixi.js";
import { z } from "zod";

registerEffectKind("softFocus", z.object({ radius: z.number().min(0).max(0.05).default(0.01) }));

registerEffectRenderer("softFocus", (params, context) => {
  const filter = new BlurFilter();
  const apply = (p: Record<string, unknown>) => {
    filter.strength = Number(p.radius) * context.compositionSize().height;
  };
  apply(params);
  return { kind: "softFocus", filter, update: apply };
});

Inside a shader, which runs in output pixels, also multiply by context.renderScale?.() ?? 1.

Where custom effects render

TargetCustom effects
Preview (createPlayer)Yes
exportProject (main thread)Yes
renderProjectStillYes
renderEffectThumbnailsYes: pass the kind in kinds
Worker export (exportProjectInWorker, exportViaWorker)No
Server exportNo

Renderers are functions, so they can't be sent to a worker or a server. There, a custom effect draws nothing and the clip renders without it. The document stays valid everywhere.

Notes

  • Both registries are global to the page and throw on a duplicate kind, built-ins included. With hot module reload, guard the call: if (!effectParamsSchema("tint")) registerEffectKind(...), and the same with getEffectRenderer.
  • A kind registered in core without a renderer is valid data. It applies no visual.
  • getEffectInfo and EFFECT_CATALOG describe built-ins only. Build your own UI metadata for custom kinds.
  • Effect params can't be keyframed. For time-based visuals, use a custom clip kind.

On this page