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.jsimport { 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
| Function | Package | Signature |
|---|---|---|
registerEffectKind | core | (kind: string, paramsSchema: ZodType) => void |
registerEffectRenderer | renderer | (kind: string, factory: EffectRendererFactory) => void |
effectParamsSchema | core | (kind) => ZodType | undefined: is the kind registered? |
getEffectRenderer | renderer | (kind) => EffectRendererFactory | undefined |
The factory receives (params, context) and returns an ActiveEffect:
ActiveEffect field | Meaning |
|---|---|
kind | The effect kind |
filter | A 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 member | Meaning |
|---|---|
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
| Target | Custom effects |
|---|---|
Preview (createPlayer) | Yes |
exportProject (main thread) | Yes |
renderProjectStill | Yes |
renderEffectThumbnails | Yes: pass the kind in kinds |
Worker export (exportProjectInWorker, exportViaWorker) | No |
| Server export | No |
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 withgetEffectRenderer. - A kind registered in core without a renderer is valid data. It applies no visual.
getEffectInfoandEFFECT_CATALOGdescribe 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.