This commit is contained in:
doananh234 2026-03-28 19:09:39 +01:00 committed by GitHub
commit f2a55ca25c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
98 changed files with 3841 additions and 204 deletions

View File

@ -24,6 +24,8 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@mediapipe/face_mesh": "0.4.1657299874",
"@opencut/effects": "workspace:*",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",

View File

@ -1,50 +0,0 @@
import type { EffectDefinition } from "@/types/effects";
import blurFragmentShader from "./blur.frag.glsl";
export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
keywords: ["blur", "soft", "defocus"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 15,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, width }) => {
const intensity =
typeof effectParams.intensity === "number"
? effectParams.intensity
: Number.parseFloat(String(effectParams.intensity));
return {
u_sigma: Math.max((intensity / 5) * (width / 1920), 0.001),
u_direction: [1, 0],
};
},
},
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, height }) => {
const intensity =
typeof effectParams.intensity === "number"
? effectParams.intensity
: Number.parseFloat(String(effectParams.intensity));
return {
u_sigma: Math.max((intensity / 5) * (height / 1080), 0.001),
u_direction: [0, 1],
};
},
},
],
},
};

View File

@ -1,13 +1,2 @@
import { hasEffect, registerEffect } from "../registry";
import { blurEffectDefinition } from "./blur";
const defaultEffects = [blurEffectDefinition];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
if (hasEffect({ effectType: definition.type })) {
continue;
}
registerEffect({ definition });
}
}
/** Re-export from @opencut/effects package */
export { registerAllEffects as registerDefaultEffects } from "@opencut/effects";

View File

@ -1,9 +1,14 @@
import { generateUUID } from "@/utils/id";
import { getEffect } from "./registry";
import type { Effect, EffectParamValues } from "@/types/effects";
import { getEffect } from "@opencut/effects";
import type { Effect, EffectParamValues } from "@opencut/effects";
import type { VisualElement } from "@/types/timeline";
export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
export {
getEffect,
getAllEffects,
hasEffect,
registerEffect,
} from "@opencut/effects";
export { registerDefaultEffects } from "./definitions";
export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [

View File

@ -1,31 +1,8 @@
import type { EffectDefinition } from "@/types/effects";
const effectDefinitions = new Map<string, EffectDefinition>();
export function registerEffect({
definition,
}: {
definition: EffectDefinition;
}): void {
effectDefinitions.set(definition.type, definition);
}
export function hasEffect({ effectType }: { effectType: string }): boolean {
return effectDefinitions.has(effectType);
}
export function getEffect({
effectType,
}: {
effectType: string;
}): EffectDefinition {
const definition = effectDefinitions.get(effectType);
if (!definition) {
throw new Error(`Unknown effect type: ${effectType}`);
}
return definition;
}
export function getAllEffects(): EffectDefinition[] {
return Array.from(effectDefinitions.values());
}
/** Re-export registry functions from @opencut/effects package */
export {
registerEffect,
hasEffect,
getEffect,
getAllEffects,
clearEffects,
} from "@opencut/effects";

View File

@ -0,0 +1,244 @@
import type { EffectContext } from "@opencut/effects";
/**
* Face mesh detection provider using MediaPipe Face Mesh.
* Lazy-loads the WASM module only when first needed.
* Runs detection per frame and caches results.
*/
import type { FaceMesh as FaceMeshType, Results } from "@mediapipe/face_mesh";
let faceMeshInstance: FaceMeshType | null = null;
let isLoading = false;
/** Shared in-flight promise for pending detections — avoids race conditions */
let pendingDetection: Promise<Results> | null = null;
let pendingResolve: ((results: Results) => void) | null = null;
let pendingReject: ((error: Error) => void) | null = null;
let pendingTimeoutId: ReturnType<typeof setTimeout> | null = null;
/** Detection timeout in milliseconds */
const DETECTION_TIMEOUT_MS = 5000;
/** MediaPipe Face Mesh version — must match package.json dependency */
const MEDIAPIPE_VERSION = "0.4.1657299874";
/** Types that MediaPipe FaceMesh accepts */
type MediaPipeImageSource =
| HTMLImageElement
| HTMLCanvasElement
| HTMLVideoElement;
/** Clear the pending timeout if it exists */
function clearPendingTimeout(): void {
if (pendingTimeoutId !== null) {
clearTimeout(pendingTimeoutId);
pendingTimeoutId = null;
}
}
/** Check if source is a valid MediaPipe image source */
function isMediaPipeImageSource(
source: CanvasImageSource,
): source is MediaPipeImageSource {
return (
source instanceof HTMLImageElement ||
source instanceof HTMLCanvasElement ||
source instanceof HTMLVideoElement
);
}
/** Convert OffscreenCanvas to HTMLCanvasElement for MediaPipe compatibility */
function toHTMLCanvas(source: OffscreenCanvas): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(source, 0, 0);
}
return canvas;
}
/** Prepare source for MediaPipe — converts OffscreenCanvas if needed */
function prepareSourceForMediaPipe(
source: CanvasImageSource,
): MediaPipeImageSource | null {
if (isMediaPipeImageSource(source)) {
return source;
}
if (source instanceof OffscreenCanvas) {
return toHTMLCanvas(source);
}
// ImageBitmap, SVGImageElement, VideoFrame are not supported
return null;
}
/** Lazy-load MediaPipe Face Mesh WASM module */
async function loadFaceMesh(): Promise<FaceMeshType | null> {
if (faceMeshInstance) return faceMeshInstance;
if (isLoading) return null;
isLoading = true;
try {
const { FaceMesh } = await import("@mediapipe/face_mesh");
const fm = new FaceMesh({
locateFile: (file: string) =>
`https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@${MEDIAPIPE_VERSION}/${file}`,
});
fm.setOptions({
maxNumFaces: 1,
refineLandmarks: true,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
fm.onResults((results: Results) => {
if (pendingResolve) {
clearPendingTimeout();
pendingResolve(results);
pendingResolve = null;
pendingReject = null;
}
});
faceMeshInstance = fm;
return fm;
} catch (err) {
return null;
} finally {
isLoading = false;
}
}
/** MediaPipe face landmark indices for key regions */
const LANDMARK_INDICES = {
leftCheek: 234,
rightCheek: 454,
jawBottom: 152,
jawLeft: 132,
jawRight: 361,
leftEyeCenter: 159,
rightEyeCenter: 386,
mouthCenter: 13,
};
/** Convert MediaPipe face landmarks to EffectContext */
function landmarksToContext(
landmarks: Array<{ x: number; y: number; z: number }>,
): EffectContext {
const lc = landmarks[LANDMARK_INDICES.leftCheek];
const rc = landmarks[LANDMARK_INDICES.rightCheek];
const jaw = landmarks[LANDMARK_INDICES.jawBottom];
const jawL = landmarks[LANDMARK_INDICES.jawLeft];
const jawR = landmarks[LANDMARK_INDICES.jawRight];
// Estimate cheek radius from face width
const faceWidth = Math.abs(rc.x - lc.x);
const cheekRadius = faceWidth * 0.15;
return {
faceDetected: true,
cheekLeft: [lc.x, lc.y],
cheekRight: [rc.x, rc.y],
cheekRadius,
jawPoints: [jaw.x, jaw.y, jawL.x, jawL.y, jawR.x, jawR.y],
};
}
/** Detect face in the given image source and return EffectContext */
export async function detectFace(
source: CanvasImageSource,
): Promise<EffectContext> {
const fm = await loadFaceMesh();
if (!fm) {
return { faceDetected: false };
}
// Convert source to MediaPipe-compatible format
const mediaPipeSource = prepareSourceForMediaPipe(source);
if (!mediaPipeSource) {
// Source type not supported by MediaPipe
return { faceDetected: false };
}
// Reuse existing in-flight detection if one exists
if (pendingDetection) {
const results = await pendingDetection;
if (
!results?.multiFaceLandmarks ||
results.multiFaceLandmarks.length === 0
) {
return { faceDetected: false };
}
return landmarksToContext(results.multiFaceLandmarks[0]);
}
// Create new detection promise with timeout
pendingDetection = new Promise<Results>((resolve, reject) => {
pendingResolve = resolve;
pendingReject = reject;
// Set up timeout for detection
pendingTimeoutId = setTimeout(() => {
if (pendingReject) {
pendingReject(new Error("Face detection timeout"));
pendingResolve = null;
pendingReject = null;
pendingDetection = null;
pendingTimeoutId = null;
}
}, DETECTION_TIMEOUT_MS);
// Send image for detection, catching sync errors
try {
fm.send({ image: mediaPipeSource });
} catch (error) {
clearPendingTimeout();
reject(error instanceof Error ? error : new Error(String(error)));
}
});
let results: Results;
try {
results = await pendingDetection;
} catch (error) {
// Detection failed (timeout or error) — return no face detected
pendingDetection = null;
pendingResolve = null;
pendingReject = null;
return { faceDetected: false };
}
clearPendingTimeout();
pendingDetection = null;
pendingResolve = null;
pendingReject = null;
if (
!results?.multiFaceLandmarks ||
results.multiFaceLandmarks.length === 0
) {
return { faceDetected: false };
}
return landmarksToContext(results.multiFaceLandmarks[0]);
}
/** Check if MediaPipe is loaded (for conditional rendering) */
export function isFaceMeshReady(): boolean {
return faceMeshInstance !== null;
}
/** Clean up MediaPipe resources */
export function disposeFaceMesh(): void {
// Clear timeout and settle any pending detection before disposing
clearPendingTimeout();
if (pendingReject) {
pendingReject(new Error("Face mesh disposed"));
pendingReject = null;
pendingResolve = null;
pendingDetection = null;
}
if (faceMeshInstance) {
faceMeshInstance.close();
faceMeshInstance = null;
}
}

View File

@ -0,0 +1 @@
export { detectFace, isFaceMeshReady, disposeFaceMesh } from "./face-mesh-provider";

View File

@ -59,6 +59,7 @@ export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
effectParams: this.params.effectParams,
width: renderer.width,
height: renderer.height,
time,
}),
}));
const effectResult = webglEffectRenderer.applyEffect({

View File

@ -79,7 +79,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
const { source, width, height } = await this.cachedSource;
this.renderVisual({
await this.renderVisual({
renderer,
source,
sourceWidth: width || renderer.width,

View File

@ -57,7 +57,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
const { source, width, height } = await this.cachedSource;
this.renderVisual({
await this.renderVisual({
renderer,
source,
sourceWidth: width,

View File

@ -24,7 +24,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
});
if (frame) {
this.renderVisual({
await this.renderVisual({
renderer,
source: frame.canvas,
sourceWidth: frame.canvas.width,

View File

@ -13,6 +13,8 @@ import {
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { getEffect } from "@/lib/effects";
import { EffectCategory, type EffectContext } from "@opencut/effects";
import { detectFace } from "@/services/face-mesh";
import { webglEffectRenderer } from "../webgl-effect-renderer";
export interface VisualNodeParams {
@ -50,7 +52,7 @@ export abstract class VisualNode<
);
}
protected renderVisual({
protected async renderVisual({
renderer,
source,
sourceWidth,
@ -62,7 +64,7 @@ export abstract class VisualNode<
sourceWidth: number;
sourceHeight: number;
timelineTime: number;
}): void {
}): Promise<void> {
renderer.context.save();
const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime });
@ -127,6 +129,16 @@ export abstract class VisualNode<
let currentResult: CanvasImageSource = elementCanvas;
// Detect face once per frame if any beauty effect is active
const hasBeautyEffect = enabledEffects.some((e) => {
const def = getEffect({ effectType: e.type });
return def.category === EffectCategory.BEAUTY;
});
let faceContext: EffectContext | undefined;
if (hasBeautyEffect) {
faceContext = await detectFace(elementCanvas);
}
for (const effect of enabledEffects) {
const resolvedParams = resolveEffectParamsAtTime({
effect,
@ -134,12 +146,18 @@ export abstract class VisualNode<
localTime: animationLocalTime,
});
const definition = getEffect({ effectType: effect.type });
const context =
definition.category === EffectCategory.BEAUTY
? faceContext
: undefined;
const passes = definition.renderer.passes.map((pass) => ({
fragmentShader: pass.fragmentShader,
uniforms: pass.uniforms({
effectParams: resolvedParams,
width: scaledWidth,
height: scaledHeight,
time: timelineTime,
context,
}),
}));
currentResult = webglEffectRenderer.applyEffect({

View File

@ -1,4 +1,4 @@
import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
import { vertexShader as VERTEX_SHADER_SOURCE } from "@opencut/effects";
export interface EffectPassData {
fragmentShader: string;

View File

@ -1,69 +1,12 @@
export interface Effect {
id: string;
type: string;
params: EffectParamValues;
enabled: boolean;
}
export type EffectParamType = "number" | "boolean" | "select" | "color";
export type EffectParamValues = Record<string, number | string | boolean>;
interface BaseEffectParamDefinition {
key: string;
label: string;
}
export interface NumberEffectParamDefinition extends BaseEffectParamDefinition {
type: "number";
default: number;
min: number;
max: number;
step: number;
}
interface BooleanEffectParamDefinition extends BaseEffectParamDefinition {
type: "boolean";
default: boolean;
}
interface SelectEffectParamDefinition extends BaseEffectParamDefinition {
type: "select";
default: string;
options: Array<{ value: string; label: string }>;
}
interface ColorEffectParamDefinition extends BaseEffectParamDefinition {
type: "color";
default: string;
}
export type EffectParamDefinition =
| NumberEffectParamDefinition
| BooleanEffectParamDefinition
| SelectEffectParamDefinition
| ColorEffectParamDefinition;
export interface WebGLEffectPass {
fragmentShader: string;
uniforms(params: {
effectParams: EffectParamValues;
width: number;
height: number;
}): Record<string, number | number[]>;
}
export interface WebGLEffectRenderer {
type: "webgl";
passes: WebGLEffectPass[];
}
export type EffectRenderer = WebGLEffectRenderer;
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
params: EffectParamDefinition[];
renderer: EffectRenderer;
}
/** Re-export all effect types from @opencut/effects package */
export type {
Effect,
EffectParamType,
EffectParamValues,
EffectParamDefinition,
NumberEffectParamDefinition,
WebGLEffectPass,
WebGLEffectRenderer,
EffectRenderer,
EffectDefinition,
} from "@opencut/effects";

File diff suppressed because one or more lines are too long

238
bun.lock
View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "opencut",
@ -26,6 +27,8 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@mediapipe/face_mesh": "0.4.1633559619",
"@opencut/effects": "workspace:*",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
@ -110,6 +113,15 @@
"typescript": "^5.8.3",
},
},
"packages/effects": {
"name": "@opencut/effects",
"version": "0.0.0",
"devDependencies": {
"@types/bun": "latest",
"tsup": "^8.4.0",
"typescript": "^5.8.3",
},
},
"packages/env": {
"name": "@opencut/env",
"version": "0.0.0",
@ -167,57 +179,57 @@
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@ffmpeg/core": ["@ffmpeg/core@0.12.10", "", {}, "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA=="],
@ -315,6 +327,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@mediapipe/face_mesh": ["@mediapipe/face_mesh@0.4.1633559619", "", {}, "sha512-Vc8cdjxS5+O2gnjWH9KncYpUCVXT0h714KlWAsyqJvJbIgUJBqpppbIx8yWcAzBDxm/5cYSuBI5p5ySIPxzcEg=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.92", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.92", "@napi-rs/canvas-darwin-arm64": "0.1.92", "@napi-rs/canvas-darwin-x64": "0.1.92", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.92", "@napi-rs/canvas-linux-arm64-gnu": "0.1.92", "@napi-rs/canvas-linux-arm64-musl": "0.1.92", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-musl": "0.1.92", "@napi-rs/canvas-win32-arm64-msvc": "0.1.92", "@napi-rs/canvas-win32-x64-msvc": "0.1.92" } }, "sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.92", "", { "os": "android", "cpu": "arm64" }, "sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ=="],
@ -361,6 +375,8 @@
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@opencut/effects": ["@opencut/effects@workspace:packages/effects"],
"@opencut/env": ["@opencut/env@workspace:packages/env"],
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
@ -507,6 +523,56 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@ -543,7 +609,7 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
@ -653,6 +719,8 @@
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@ -677,7 +745,11 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
@ -711,7 +783,11 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="],
@ -805,7 +881,7 @@
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@ -843,6 +919,8 @@
"feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="],
"fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
"framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="],
@ -927,6 +1005,8 @@
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
@ -969,6 +1049,12 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
"loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="],
@ -1059,6 +1145,8 @@
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"motion": ["motion@12.23.6", "", { "dependencies": { "framer-motion": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6U55IW5i6Vut2ryKEhrZKg55490k9d6qdGXZoNSf98oQgDj5D7bqTnVJotQ6UW3AS6QfbW6KSLa7/e1gy+a07g=="],
"motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="],
@ -1067,6 +1155,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
@ -1097,6 +1187,8 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
"pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
@ -1117,12 +1209,18 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
@ -1207,10 +1305,14 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@ -1241,7 +1343,7 @@
"sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -1263,6 +1365,8 @@
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
@ -1279,16 +1383,28 @@
"terser-webpack-plugin": ["terser-webpack-plugin@5.3.16", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
"tsx": ["tsx@4.20.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="],
"turbo": ["turbo@2.7.5", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.5", "turbo-darwin-arm64": "2.7.5", "turbo-linux-64": "2.7.5", "turbo-linux-arm64": "2.7.5", "turbo-windows-64": "2.7.5", "turbo-windows-arm64": "2.7.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-7Imdmg37joOloTnj+DPrab9hIaQcDdJ5RwSzcauo/wMOSAgO+A/I/8b3hsGGs6PWQz70m/jkPgdqWsfNKtwwDQ=="],
@ -1309,6 +1425,8 @@
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
@ -1377,6 +1495,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@content-collections/core/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
@ -1529,6 +1649,10 @@
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"tsx/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
@ -1537,6 +1661,58 @@
"webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"@content-collections/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@content-collections/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@content-collections/core/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@content-collections/core/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@content-collections/core/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@content-collections/core/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@content-collections/core/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@content-collections/core/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@content-collections/core/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@content-collections/core/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@content-collections/core/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@content-collections/core/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@content-collections/core/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@content-collections/core/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@content-collections/core/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@content-collections/core/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@content-collections/core/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@content-collections/core/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@content-collections/core/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@content-collections/core/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@content-collections/core/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@content-collections/core/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@content-collections/core/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@content-collections/core/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@content-collections/core/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@content-collections/core/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],

1
packages/effects/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -0,0 +1,20 @@
{
"name": "@opencut/effects",
"version": "0.0.0",
"description": "Effect definitions, shaders, and registry for OpenCut video editor",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,30 @@
/** Effect categories for UI grouping */
export const EffectCategory = {
COLOR_TONE: "color-tone",
ARTISTIC: "artistic",
BEAUTY: "beauty",
} as const;
export type EffectCategory = (typeof EffectCategory)[keyof typeof EffectCategory];
export interface EffectCategoryMeta {
label: string;
description: string;
}
/** Display metadata for each effect category */
export const EFFECT_CATEGORY_META: Record<EffectCategory, EffectCategoryMeta> =
{
[EffectCategory.COLOR_TONE]: {
label: "Color & Tone",
description: "Brightness, contrast, saturation, and color adjustments",
},
[EffectCategory.ARTISTIC]: {
label: "Artistic",
description: "Stylize effects like blur, vignette, and film grain",
},
[EffectCategory.BEAUTY]: {
label: "Beauty",
description: "Face-aware beauty effects using AI detection",
},
};

View File

@ -0,0 +1,83 @@
import { describe, test, expect } from "bun:test";
import { blurEffectDefinition } from "./index";
import { EffectCategory } from "../../categories";
describe("Blur Effect Definition", () => {
test("has correct type and name", () => {
expect(blurEffectDefinition.type).toBe("blur");
expect(blurEffectDefinition.name).toBe("Blur");
});
test("is categorized as artistic", () => {
expect(blurEffectDefinition.category).toBe(EffectCategory.ARTISTIC);
});
test("has searchable keywords", () => {
expect(blurEffectDefinition.keywords).toContain("blur");
expect(blurEffectDefinition.keywords).toContain("soft");
});
test("has intensity param with correct defaults", () => {
const param = blurEffectDefinition.params[0];
expect(param.key).toBe("intensity");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(15);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("renderer has 2 passes (horizontal + vertical)", () => {
expect(blurEffectDefinition.renderer.type).toBe("webgl");
expect(blurEffectDefinition.renderer.passes).toHaveLength(2);
});
test("pass 1 uniforms produce horizontal direction", () => {
const pass1 = blurEffectDefinition.renderer.passes[0];
const uniforms = pass1.uniforms({
effectParams: { intensity: 50 },
width: 1920,
height: 1080,
});
expect(uniforms.u_direction).toEqual([1, 0]);
expect(uniforms.u_sigma).toBeGreaterThan(0);
});
test("pass 2 uniforms produce vertical direction", () => {
const pass2 = blurEffectDefinition.renderer.passes[1];
const uniforms = pass2.uniforms({
effectParams: { intensity: 50 },
width: 1920,
height: 1080,
});
expect(uniforms.u_direction).toEqual([0, 1]);
expect(uniforms.u_sigma).toBeGreaterThan(0);
});
test("sigma is never zero even at intensity 0", () => {
const pass1 = blurEffectDefinition.renderer.passes[0];
const uniforms = pass1.uniforms({
effectParams: { intensity: 0 },
width: 1920,
height: 1080,
});
expect(uniforms.u_sigma).toBe(0.001);
});
test("sigma scales with resolution", () => {
const pass1 = blurEffectDefinition.renderer.passes[0];
const at1920 = pass1.uniforms({
effectParams: { intensity: 50 },
width: 1920,
height: 1080,
});
const at3840 = pass1.uniforms({
effectParams: { intensity: 50 },
width: 3840,
height: 2160,
});
expect(at3840.u_sigma).toBeGreaterThan(at1920.u_sigma as number);
});
});

View File

@ -0,0 +1,59 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import blurFragmentShader from "./blur.frag.glsl";
/** Parse and validate intensity parameter, guarding against NaN and clamping to valid range */
const parseIntensity = (rawIntensity: unknown): number => {
const parsedIntensity =
typeof rawIntensity === "number"
? rawIntensity
: Number.parseFloat(String(rawIntensity));
if (!Number.isFinite(parsedIntensity)) {
return 0;
}
return Math.min(Math.max(parsedIntensity, 0), 100);
};
/** Gaussian blur — 2-pass (horizontal + vertical) for smooth, artifact-free results */
export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
category: EffectCategory.ARTISTIC,
keywords: ["blur", "soft", "defocus"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 15,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, width }) => {
const intensity = parseIntensity(effectParams.intensity);
return {
u_sigma: Math.max((intensity / 5) * (width / 1920), 0.001),
u_direction: [1, 0],
};
},
},
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, height }) => {
const intensity = parseIntensity(effectParams.intensity);
return {
u_sigma: Math.max((intensity / 5) * (height / 1080), 0.001),
u_direction: [0, 1],
};
},
},
],
},
};

View File

@ -0,0 +1,26 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform vec3 u_color;
uniform vec2 u_cheekLeft;
uniform vec2 u_cheekRight;
uniform float u_cheekRadius;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Soft circle blend at cheek positions
float distL = distance(v_texCoord, u_cheekLeft);
float distR = distance(v_texCoord, u_cheekRight);
float maskL = 1.0 - smoothstep(u_cheekRadius * 0.6, u_cheekRadius, distL);
float maskR = 1.0 - smoothstep(u_cheekRadius * 0.6, u_cheekRadius, distR);
float mask = max(maskL, maskR) * u_intensity;
// Soft light blend with blush color
color.rgb = mix(color.rgb, color.rgb * u_color + u_color * 0.2, mask);
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,32 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { hexToRgb } from "../../utils/color";
import { getNumberParam, getStringParam } from "../../utils/params";
import fragmentShader from "./blush.frag.glsl";
/** Blush — soft color blend at cheek positions from face landmarks */
export const blushEffectDefinition: EffectDefinition = {
type: "blush",
name: "Blush",
category: EffectCategory.BEAUTY,
keywords: ["blush", "cheeks", "rosy", "beauty", "makeup"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 30, min: 0, max: 100, step: 1 },
{ key: "color", label: "Color", type: "color", default: "#ff9999" },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams, context }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 100,
u_color: hexToRgb(getStringParam(effectParams, "color", "#ff9999")),
u_cheekLeft: context?.cheekLeft ?? [0.35, 0.55],
u_cheekRight: context?.cheekRight ?? [0.65, 0.55],
u_cheekRadius: context?.cheekRadius ?? 0.08,
}),
},
],
},
};

View File

@ -0,0 +1,13 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
color.rgb += u_intensity;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./brightness.frag.glsl";
export const brightnessEffectDefinition: EffectDefinition = {
type: "brightness",
name: "Brightness",
category: EffectCategory.COLOR_TONE,
keywords: ["brightness", "light", "dark", "brighten", "darken"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 0,
min: -100,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 100,
}),
},
],
},
};

View File

@ -0,0 +1,21 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform float u_angle;
varying vec2 v_texCoord;
void main() {
// Offset direction based on angle
vec2 dir = vec2(cos(u_angle), sin(u_angle)) * u_intensity;
vec2 texel = dir / u_resolution;
float r = texture2D(u_texture, v_texCoord + texel).r;
float g = texture2D(u_texture, v_texCoord).g;
float b = texture2D(u_texture, v_texCoord - texel).b;
float a = texture2D(u_texture, v_texCoord).a;
gl_FragColor = vec4(r, g, b, a);
}

View File

@ -0,0 +1,122 @@
import { describe, test, expect } from "bun:test";
import { chromaticAberrationEffectDefinition } from "./index";
import { EffectCategory } from "../../categories";
describe("Chromatic Aberration Effect Definition", () => {
test("has correct type and name", () => {
expect(chromaticAberrationEffectDefinition.type).toBe("chromatic-aberration");
expect(chromaticAberrationEffectDefinition.name).toBe("Chromatic Aberration");
});
test("is categorized as artistic", () => {
expect(chromaticAberrationEffectDefinition.category).toBe(EffectCategory.ARTISTIC);
});
test("has searchable keywords", () => {
expect(chromaticAberrationEffectDefinition.keywords).toContain("chromatic");
expect(chromaticAberrationEffectDefinition.keywords).toContain("aberration");
expect(chromaticAberrationEffectDefinition.keywords).toContain("rgb split");
expect(chromaticAberrationEffectDefinition.keywords).toContain("prism");
});
test("has 2 params: intensity and angle", () => {
expect(chromaticAberrationEffectDefinition.params.map((p) => p.key)).toEqual([
"intensity",
"angle",
]);
});
test("intensity param has correct defaults", () => {
const param = chromaticAberrationEffectDefinition.params[0];
expect(param.key).toBe("intensity");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(20);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("angle param has correct defaults", () => {
const param = chromaticAberrationEffectDefinition.params[1];
expect(param.key).toBe("angle");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(0);
expect(param.min).toBe(0);
expect(param.max).toBe(360);
expect(param.step).toBe(1);
}
});
test("renderer has single pass", () => {
expect(chromaticAberrationEffectDefinition.renderer.type).toBe("webgl");
expect(chromaticAberrationEffectDefinition.renderer.passes).toHaveLength(1);
});
test("uniforms scale intensity by factor of 10", () => {
const pass = chromaticAberrationEffectDefinition.renderer.passes[0];
const at0 = pass.uniforms({
effectParams: { intensity: 0, angle: 0 },
width: 1920,
height: 1080,
});
const at100 = pass.uniforms({
effectParams: { intensity: 100, angle: 0 },
width: 1920,
height: 1080,
});
expect(at0.u_intensity).toBe(0);
expect(at100.u_intensity).toBe(10); // 100 / 10 = 10
});
test("uniforms convert angle from degrees to radians", () => {
const pass = chromaticAberrationEffectDefinition.renderer.passes[0];
const at0 = pass.uniforms({
effectParams: { intensity: 50, angle: 0 },
width: 1920,
height: 1080,
});
const at90 = pass.uniforms({
effectParams: { intensity: 50, angle: 90 },
width: 1920,
height: 1080,
});
const at180 = pass.uniforms({
effectParams: { intensity: 50, angle: 180 },
width: 1920,
height: 1080,
});
const at360 = pass.uniforms({
effectParams: { intensity: 50, angle: 360 },
width: 1920,
height: 1080,
});
expect(at0.u_angle).toBe(0);
expect(at90.u_angle).toBeCloseTo(Math.PI / 2, 5);
expect(at180.u_angle).toBeCloseTo(Math.PI, 5);
expect(at360.u_angle).toBeCloseTo(2 * Math.PI, 5);
});
test("angle 45 degrees converts correctly", () => {
const pass = chromaticAberrationEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 50, angle: 45 },
width: 1920,
height: 1080,
});
expect(result.u_angle).toBeCloseTo(Math.PI / 4, 5);
});
test("uniforms with default values", () => {
const pass = chromaticAberrationEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 20, angle: 0 },
width: 1920,
height: 1080,
});
expect(result.u_intensity).toBe(2); // 20 / 10 = 2
expect(result.u_angle).toBe(0);
});
});

View File

@ -0,0 +1,27 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./chromatic-aberration.frag.glsl";
export const chromaticAberrationEffectDefinition: EffectDefinition = {
type: "chromatic-aberration",
name: "Chromatic Aberration",
category: EffectCategory.ARTISTIC,
keywords: ["chromatic", "aberration", "rgb split", "lens", "distortion", "prism"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 20, min: 0, max: 100, step: 1 },
{ key: "angle", label: "Angle", type: "number", default: 0, min: 0, max: 360, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 10,
u_angle: (getNumberParam(effectParams, "angle") * Math.PI) / 180,
}),
},
],
},
};

View File

@ -0,0 +1,26 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform vec3 u_shadows;
uniform vec3 u_midtones;
uniform vec3 u_highlights;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
// Weight regions by luminance
float shadowWeight = 1.0 - smoothstep(0.0, 0.5, luma);
float highlightWeight = smoothstep(0.5, 1.0, luma);
float midtoneWeight = 1.0 - shadowWeight - highlightWeight;
vec3 adjustment = u_shadows * shadowWeight
+ u_midtones * midtoneWeight
+ u_highlights * highlightWeight;
color.rgb += adjustment;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,48 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./color-balance.frag.glsl";
/** 3-way color grading: shadows, midtones, highlights */
export const colorBalanceEffectDefinition: EffectDefinition = {
type: "color-balance",
name: "Color Balance",
category: EffectCategory.COLOR_TONE,
keywords: ["color balance", "grading", "shadows", "highlights", "midtones"],
params: [
{ key: "shadowsRed", label: "Shadows Red", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "shadowsGreen", label: "Shadows Green", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "shadowsBlue", label: "Shadows Blue", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "midtonesRed", label: "Midtones Red", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "midtonesGreen", label: "Midtones Green", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "midtonesBlue", label: "Midtones Blue", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "highlightsRed", label: "Highlights Red", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "highlightsGreen", label: "Highlights Green", type: "number", default: 0, min: -100, max: 100, step: 1 },
{ key: "highlightsBlue", label: "Highlights Blue", type: "number", default: 0, min: -100, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_shadows: [
getNumberParam(effectParams, "shadowsRed") / 200,
getNumberParam(effectParams, "shadowsGreen") / 200,
getNumberParam(effectParams, "shadowsBlue") / 200,
],
u_midtones: [
getNumberParam(effectParams, "midtonesRed") / 200,
getNumberParam(effectParams, "midtonesGreen") / 200,
getNumberParam(effectParams, "midtonesBlue") / 200,
],
u_highlights: [
getNumberParam(effectParams, "highlightsRed") / 200,
getNumberParam(effectParams, "highlightsGreen") / 200,
getNumberParam(effectParams, "highlightsBlue") / 200,
],
}),
},
],
},
};

View File

@ -0,0 +1,14 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_contrast;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Scale around midpoint 0.5
color.rgb = (color.rgb - 0.5) * u_contrast + 0.5;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,34 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./contrast.frag.glsl";
export const contrastEffectDefinition: EffectDefinition = {
type: "contrast",
name: "Contrast",
category: EffectCategory.COLOR_TONE,
keywords: ["contrast", "punch", "flat"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 0,
min: -100,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
// Map -100..100 → 0..2 (0 = no contrast, 1 = normal, 2 = max)
u_contrast: 1 + getNumberParam(effectParams, "intensity") / 100,
}),
},
],
},
};

View File

@ -0,0 +1,14 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_exposure;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Photographic exposure: multiply by 2^stops
color.rgb *= pow(2.0, u_exposure);
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./exposure.frag.glsl";
export const exposureEffectDefinition: EffectDefinition = {
type: "exposure",
name: "Exposure",
category: EffectCategory.COLOR_TONE,
keywords: ["exposure", "ev", "stops", "overexpose", "underexpose"],
params: [
{
key: "stops",
label: "Stops",
type: "number",
default: 0,
min: -3,
max: 3,
step: 0.1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_exposure: getNumberParam(effectParams, "stops"),
}),
},
],
},
};

View File

@ -0,0 +1,16 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_brightness;
uniform float u_contrast;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Brightness + contrast enhancement for eye regions
color.rgb += u_brightness;
color.rgb = (color.rgb - 0.5) * u_contrast + 0.5;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,28 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./eye-enhance.frag.glsl";
/** Eye enhance — brighten and sharpen eye regions */
export const eyeEnhanceEffectDefinition: EffectDefinition = {
type: "eye-enhance",
name: "Eye Enhance",
category: EffectCategory.BEAUTY,
keywords: ["eye", "enhance", "bright", "sparkle", "beauty"],
params: [
{ key: "brightness", label: "Brightness", type: "number", default: 20, min: 0, max: 100, step: 1 },
{ key: "contrast", label: "Contrast", type: "number", default: 15, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_brightness: getNumberParam(effectParams, "brightness") / 400,
u_contrast: 1 + getNumberParam(effectParams, "contrast") / 200,
}),
},
],
},
};

View File

@ -0,0 +1,14 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Soft light blend — brightens without blowing out highlights
vec3 brightened = color.rgb + color.rgb * (1.0 - color.rgb) * u_intensity;
gl_FragColor = vec4(clamp(brightened, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,26 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./face-brighten.frag.glsl";
/** Face brighten — soft light blend, optionally masked to face region */
export const faceBrightenEffectDefinition: EffectDefinition = {
type: "face-brighten",
name: "Face Brighten",
category: EffectCategory.BEAUTY,
keywords: ["brighten", "face", "glow", "beauty", "luminous"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 30, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 200,
}),
},
],
},
};

View File

@ -0,0 +1,23 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform float u_grainSize;
uniform float u_time;
varying vec2 v_texCoord;
// Hash-based pseudo-random noise
float random(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Quantize UV by grain size for blocky noise
vec2 grainCoord = floor(v_texCoord * u_resolution / u_grainSize) * u_grainSize;
float noise = random(grainCoord + u_time) * 2.0 - 1.0;
color.rgb += noise * u_intensity;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,29 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam, getBooleanParam } from "../../utils/params";
import fragmentShader from "./film-grain.frag.glsl";
export const filmGrainEffectDefinition: EffectDefinition = {
type: "film-grain",
name: "Film Grain",
category: EffectCategory.ARTISTIC,
keywords: ["grain", "film", "noise", "analog", "texture", "vintage"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 30, min: 0, max: 100, step: 1 },
{ key: "size", label: "Grain Size", type: "number", default: 2, min: 1, max: 10, step: 1 },
{ key: "animated", label: "Animated", type: "boolean", default: true },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams, time }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 400,
u_grainSize: getNumberParam(effectParams, "size", 2),
u_time: getBooleanParam(effectParams, "animated", true) ? (time ?? 0) : 0,
}),
},
],
},
};

View File

@ -0,0 +1,13 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_gamma;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
color.rgb = pow(color.rgb, vec3(1.0 / u_gamma));
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./gamma.frag.glsl";
export const gammaEffectDefinition: EffectDefinition = {
type: "gamma",
name: "Gamma",
category: EffectCategory.COLOR_TONE,
keywords: ["gamma", "curve", "midtones"],
params: [
{
key: "value",
label: "Gamma",
type: "number",
default: 1.0,
min: 0.1,
max: 3.0,
step: 0.05,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_gamma: getNumberParam(effectParams, "value", 1.0),
}),
},
],
},
};

View File

@ -0,0 +1,29 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform float u_colorSplit;
uniform float u_time;
varying vec2 v_texCoord;
float random(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = v_texCoord;
// Block displacement — shift horizontal blocks randomly
float blockY = floor(uv.y * 20.0 + u_time * 3.0);
float blockShift = (random(vec2(blockY, u_time)) - 0.5) * u_intensity;
uv.x += blockShift;
// RGB channel split
float r = texture2D(u_texture, uv + vec2(u_colorSplit, 0.0)).r;
float g = texture2D(u_texture, uv).g;
float b = texture2D(u_texture, uv - vec2(u_colorSplit, 0.0)).b;
gl_FragColor = vec4(r, g, b, 1.0);
}

View File

@ -0,0 +1,26 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform float u_time;
varying vec2 v_texCoord;
float random(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Scanlines
float scanline = sin(v_texCoord.y * u_resolution.y * 1.5) * 0.5 + 0.5;
color.rgb *= 1.0 - u_intensity * scanline * 0.15;
// Random noise flicker
float noise = random(v_texCoord + u_time) * u_intensity * 0.1;
color.rgb += noise;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,183 @@
import { describe, test, expect } from "bun:test";
import { glitchEffectDefinition } from "./index";
import { EffectCategory } from "../../categories";
describe("Glitch Effect Definition", () => {
test("has correct type and name", () => {
expect(glitchEffectDefinition.type).toBe("glitch");
expect(glitchEffectDefinition.name).toBe("Glitch");
});
test("is categorized as artistic", () => {
expect(glitchEffectDefinition.category).toBe(EffectCategory.ARTISTIC);
});
test("has searchable keywords", () => {
expect(glitchEffectDefinition.keywords).toContain("glitch");
expect(glitchEffectDefinition.keywords).toContain("digital");
expect(glitchEffectDefinition.keywords).toContain("vhs");
expect(glitchEffectDefinition.keywords).toContain("corrupt");
});
test("has 3 params: intensity, speed, colorSplit", () => {
expect(glitchEffectDefinition.params.map((p) => p.key)).toEqual([
"intensity",
"speed",
"colorSplit",
]);
});
test("intensity param has correct defaults", () => {
const param = glitchEffectDefinition.params[0];
expect(param.key).toBe("intensity");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(50);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("speed param has correct defaults", () => {
const param = glitchEffectDefinition.params[1];
expect(param.key).toBe("speed");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(50);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("colorSplit param has correct defaults", () => {
const param = glitchEffectDefinition.params[2];
expect(param.key).toBe("colorSplit");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(30);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("renderer has 2 passes (block displacement + scanlines)", () => {
expect(glitchEffectDefinition.renderer.type).toBe("webgl");
expect(glitchEffectDefinition.renderer.passes).toHaveLength(2);
});
test("pass 1 uniforms scale intensity correctly", () => {
const pass1 = glitchEffectDefinition.renderer.passes[0];
const result = pass1.uniforms({
effectParams: { intensity: 50, speed: 50, colorSplit: 30 },
width: 1920,
height: 1080,
time: 1,
});
expect(result.u_intensity).toBe(0.05); // 50 / 1000 = 0.05
});
test("pass 1 colorSplit scales with width", () => {
const pass1 = glitchEffectDefinition.renderer.passes[0];
const at1920 = pass1.uniforms({
effectParams: { intensity: 50, speed: 50, colorSplit: 30 },
width: 1920,
height: 1080,
time: 1,
});
const at3840 = pass1.uniforms({
effectParams: { intensity: 50, speed: 50, colorSplit: 30 },
width: 3840,
height: 2160,
time: 1,
});
// colorSplit: 30 / (width * 10)
expect(at1920.u_colorSplit).toBeCloseTo(30 / 19200, 6);
expect(at3840.u_colorSplit).toBeCloseTo(30 / 38400, 6);
// Higher resolution = smaller colorSplit value
expect(at3840.u_colorSplit).toBeLessThan(at1920.u_colorSplit as number);
});
test("pass 1 time scales with speed", () => {
const pass1 = glitchEffectDefinition.renderer.passes[0];
const atSpeed0 = pass1.uniforms({
effectParams: { intensity: 50, speed: 0, colorSplit: 30 },
width: 1920,
height: 1080,
time: 2,
});
const atSpeed50 = pass1.uniforms({
effectParams: { intensity: 50, speed: 50, colorSplit: 30 },
width: 1920,
height: 1080,
time: 2,
});
const atSpeed100 = pass1.uniforms({
effectParams: { intensity: 50, speed: 100, colorSplit: 30 },
width: 1920,
height: 1080,
time: 2,
});
// time * (speed / 50)
expect(atSpeed0.u_time).toBe(0);
expect(atSpeed50.u_time).toBe(2); // 2 * (50 / 50) = 2
expect(atSpeed100.u_time).toBe(4); // 2 * (100 / 50) = 4
});
test("pass 2 uniforms scale intensity correctly", () => {
const pass2 = glitchEffectDefinition.renderer.passes[1];
const result = pass2.uniforms({
effectParams: { intensity: 50, speed: 50, colorSplit: 30 },
width: 1920,
height: 1080,
time: 1,
});
expect(result.u_intensity).toBe(0.5); // 50 / 100 = 0.5
});
test("pass 2 time scales with speed", () => {
const pass2 = glitchEffectDefinition.renderer.passes[1];
const atSpeed25 = pass2.uniforms({
effectParams: { intensity: 50, speed: 25, colorSplit: 30 },
width: 1920,
height: 1080,
time: 4,
});
expect(atSpeed25.u_time).toBe(2); // 4 * (25 / 50) = 2
});
test("both passes use time uniformly", () => {
const pass1 = glitchEffectDefinition.renderer.passes[0];
const pass2 = glitchEffectDefinition.renderer.passes[1];
const params = { intensity: 50, speed: 50, colorSplit: 30 };
const args = { effectParams: params, width: 1920, height: 1080, time: 3 };
const result1 = pass1.uniforms(args);
const result2 = pass2.uniforms(args);
// Both passes should have the same time value when speed is 50
expect(result1.u_time).toBe(3);
expect(result2.u_time).toBe(3);
});
test("handles undefined time gracefully", () => {
const pass1 = glitchEffectDefinition.renderer.passes[0];
const result = pass1.uniforms({
effectParams: { intensity: 50, speed: 50, colorSplit: 30 },
width: 1920,
height: 1080,
});
expect(result.u_time).toBe(0);
});
test("zero speed results in zero time", () => {
const pass1 = glitchEffectDefinition.renderer.passes[0];
const result = pass1.uniforms({
effectParams: { intensity: 50, speed: 0, colorSplit: 30 },
width: 1920,
height: 1080,
time: 100,
});
expect(result.u_time).toBe(0);
});
});

View File

@ -0,0 +1,38 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import pass1Shader from "./glitch-pass1.frag.glsl";
import pass2Shader from "./glitch-pass2.frag.glsl";
/** Digital glitch — 2-pass: block displacement + scanlines */
export const glitchEffectDefinition: EffectDefinition = {
type: "glitch",
name: "Glitch",
category: EffectCategory.ARTISTIC,
keywords: ["glitch", "digital", "distortion", "corrupt", "vhs", "static"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 50, min: 0, max: 100, step: 1 },
{ key: "speed", label: "Speed", type: "number", default: 50, min: 0, max: 100, step: 1 },
{ key: "colorSplit", label: "Color Split", type: "number", default: 30, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: pass1Shader,
uniforms: ({ effectParams, width, time }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 1000,
u_colorSplit: getNumberParam(effectParams, "colorSplit") / (width * 10),
u_time: (time ?? 0) * (getNumberParam(effectParams, "speed", 50) / 50),
}),
},
{
fragmentShader: pass2Shader,
uniforms: ({ effectParams, time }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 100,
u_time: (time ?? 0) * (getNumberParam(effectParams, "speed", 50) / 50),
}),
},
],
},
};

View File

@ -0,0 +1,51 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_hueShift;
varying vec2 v_texCoord;
// RGB to HSL conversion
vec3 rgb2hsl(vec3 c) {
float maxC = max(c.r, max(c.g, c.b));
float minC = min(c.r, min(c.g, c.b));
float l = (maxC + minC) * 0.5;
if (maxC == minC) return vec3(0.0, 0.0, l);
float d = maxC - minC;
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
float h;
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
h /= 6.0;
return vec3(h, s, l);
}
// HSL to RGB conversion
float hue2rgb(float p, float q, float t) {
if (t < 0.0) t += 1.0;
if (t > 1.0) t -= 1.0;
if (t < 1.0/6.0) return p + (q - p) * 6.0 * t;
if (t < 1.0/2.0) return q;
if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
if (hsl.y == 0.0) return vec3(hsl.z);
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
float p = 2.0 * hsl.z - q;
return vec3(
hue2rgb(p, q, hsl.x + 1.0/3.0),
hue2rgb(p, q, hsl.x),
hue2rgb(p, q, hsl.x - 1.0/3.0)
);
}
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
vec3 hsl = rgb2hsl(color.rgb);
hsl.x = fract(hsl.x + u_hueShift);
gl_FragColor = vec4(hsl2rgb(hsl), color.a);
}

View File

@ -0,0 +1,34 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./hue-shift.frag.glsl";
export const hueShiftEffectDefinition: EffectDefinition = {
type: "hue-shift",
name: "Hue Shift",
category: EffectCategory.COLOR_TONE,
keywords: ["hue", "shift", "rotate", "color wheel", "tint"],
params: [
{
key: "degrees",
label: "Degrees",
type: "number",
default: 0,
min: 0,
max: 360,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
// Convert degrees to 0..1 range for HSL rotation
u_hueShift: getNumberParam(effectParams, "degrees") / 360,
}),
},
],
},
};

View File

@ -0,0 +1,37 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { hexToRgb } from "../../utils/color";
import { getNumberParam, getStringParam } from "../../utils/params";
import pass1Shader from "./neon-glow-pass1.frag.glsl";
import pass2Shader from "./neon-glow-pass2.frag.glsl";
/** Neon glow — 2-pass: extract bright areas, blur + tint */
export const neonGlowEffectDefinition: EffectDefinition = {
type: "neon-glow",
name: "Neon Glow",
category: EffectCategory.ARTISTIC,
keywords: ["neon", "glow", "bloom", "light", "bright", "luminous"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 50, min: 0, max: 100, step: 1 },
{ key: "color", label: "Glow Color", type: "color", default: "#00ff88" },
{ key: "threshold", label: "Threshold", type: "number", default: 50, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: pass1Shader,
uniforms: ({ effectParams }) => ({
u_threshold: getNumberParam(effectParams, "threshold") / 100,
}),
},
{
fragmentShader: pass2Shader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 50,
u_glowColor: hexToRgb(getStringParam(effectParams, "color", "#00ff88")),
}),
},
],
},
};

View File

@ -0,0 +1,15 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_threshold;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
// Extract only bright areas above threshold
float mask = smoothstep(u_threshold, u_threshold + 0.1, luma);
gl_FragColor = vec4(color.rgb * mask, color.a);
}

View File

@ -0,0 +1,25 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform vec3 u_glowColor;
varying vec2 v_texCoord;
void main() {
// Simple box blur of the bright extraction
vec2 texel = 1.0 / u_resolution;
vec4 sum = vec4(0.0);
for (int x = -3; x <= 3; x++) {
for (int y = -3; y <= 3; y++) {
sum += texture2D(u_texture, v_texCoord + vec2(float(x), float(y)) * texel * 2.0);
}
}
sum /= 49.0;
// Tint with glow color and blend additively with original
vec4 original = texture2D(u_texture, v_texCoord);
vec3 glow = sum.rgb * u_glowColor * u_intensity;
gl_FragColor = vec4(clamp(original.rgb + glow, 0.0, 1.0), original.a);
}

View File

@ -0,0 +1,34 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import pass1Shader from "./oil-paint-pass1.frag.glsl";
import pass2Shader from "./oil-paint-pass2.frag.glsl";
/** Oil paint — 2-pass: color quantization + neighborhood averaging (Kuwahara-inspired) */
export const oilPaintEffectDefinition: EffectDefinition = {
type: "oil-paint",
name: "Oil Paint",
category: EffectCategory.ARTISTIC,
keywords: ["oil paint", "painting", "artistic", "impressionist", "canvas"],
params: [
{ key: "radius", label: "Radius", type: "number", default: 4, min: 1, max: 5, step: 1 },
{ key: "levels", label: "Color Levels", type: "number", default: 8, min: 2, max: 20, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: pass1Shader,
uniforms: ({ effectParams }) => ({
u_levels: getNumberParam(effectParams, "levels", 8),
}),
},
{
fragmentShader: pass2Shader,
uniforms: ({ effectParams }) => ({
u_radius: getNumberParam(effectParams, "radius", 4),
}),
},
],
},
};

View File

@ -0,0 +1,14 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_levels;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Quantize colors to discrete levels
color.rgb = floor(color.rgb * u_levels + 0.5) / u_levels;
gl_FragColor = color;
}

View File

@ -0,0 +1,24 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_radius;
varying vec2 v_texCoord;
void main() {
vec2 texel = 1.0 / u_resolution;
int r = int(u_radius);
// Simplified Kuwahara: find mode color in neighborhood
vec3 sum = vec3(0.0);
float count = 0.0;
for (int x = -5; x <= 5; x++) {
for (int y = -5; y <= 5; y++) {
if (x*x + y*y > r*r) continue;
sum += texture2D(u_texture, v_texCoord + vec2(float(x), float(y)) * texel).rgb;
count += 1.0;
}
}
gl_FragColor = vec4(sum / count, 1.0);
}

View File

@ -0,0 +1,25 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./pixelate.frag.glsl";
export const pixelateEffectDefinition: EffectDefinition = {
type: "pixelate",
name: "Pixelate",
category: EffectCategory.ARTISTIC,
keywords: ["pixelate", "mosaic", "block", "retro", "pixel"],
params: [
{ key: "blockSize", label: "Block Size", type: "number", default: 10, min: 2, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_blockSize: getNumberParam(effectParams, "blockSize", 10),
}),
},
],
},
};

View File

@ -0,0 +1,14 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_blockSize;
varying vec2 v_texCoord;
void main() {
// Floor UV to block grid, sample from block center
vec2 blockCoord = floor(v_texCoord * u_resolution / u_blockSize) * u_blockSize;
vec2 sampleUV = (blockCoord + u_blockSize * 0.5) / u_resolution;
gl_FragColor = texture2D(u_texture, sampleUV);
}

View File

@ -0,0 +1,34 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./saturation.frag.glsl";
export const saturationEffectDefinition: EffectDefinition = {
type: "saturation",
name: "Saturation",
category: EffectCategory.COLOR_TONE,
keywords: ["saturation", "vibrance", "color", "desaturate", "grayscale"],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 0,
min: -100,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
// Map -100..100 → 0..2 (0 = grayscale, 1 = normal, 2 = oversaturated)
u_saturation: 1 + getNumberParam(effectParams, "intensity") / 100,
}),
},
],
},
};

View File

@ -0,0 +1,15 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_saturation;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// BT.709 luminance coefficients
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
color.rgb = mix(vec3(luma), color.rgb, u_saturation);
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,25 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./sharpen.frag.glsl";
export const sharpenEffectDefinition: EffectDefinition = {
type: "sharpen",
name: "Sharpen",
category: EffectCategory.ARTISTIC,
keywords: ["sharpen", "crisp", "detail", "enhance", "unsharp mask"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 50, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 100,
}),
},
],
},
};

View File

@ -0,0 +1,20 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
varying vec2 v_texCoord;
void main() {
vec2 texel = 1.0 / u_resolution;
// Unsharp mask: center - average of neighbors
vec4 center = texture2D(u_texture, v_texCoord);
vec4 top = texture2D(u_texture, v_texCoord + vec2(0.0, texel.y));
vec4 bottom = texture2D(u_texture, v_texCoord - vec2(0.0, texel.y));
vec4 left = texture2D(u_texture, v_texCoord - vec2(texel.x, 0.0));
vec4 right = texture2D(u_texture, v_texCoord + vec2(texel.x, 0.0));
vec4 edges = center * 4.0 - top - bottom - left - right;
gl_FragColor = vec4(clamp(center.rgb + edges.rgb * u_intensity, 0.0, 1.0), center.a);
}

View File

@ -0,0 +1,84 @@
import { describe, test, expect } from "bun:test";
import { sharpenEffectDefinition } from "./index";
import { EffectCategory } from "../../categories";
describe("Sharpen Effect Definition", () => {
test("has correct type and name", () => {
expect(sharpenEffectDefinition.type).toBe("sharpen");
expect(sharpenEffectDefinition.name).toBe("Sharpen");
});
test("is categorized as artistic", () => {
expect(sharpenEffectDefinition.category).toBe(EffectCategory.ARTISTIC);
});
test("has searchable keywords", () => {
expect(sharpenEffectDefinition.keywords).toContain("sharpen");
expect(sharpenEffectDefinition.keywords).toContain("crisp");
expect(sharpenEffectDefinition.keywords).toContain("detail");
expect(sharpenEffectDefinition.keywords).toContain("unsharp mask");
});
test("has single intensity param", () => {
expect(sharpenEffectDefinition.params).toHaveLength(1);
expect(sharpenEffectDefinition.params[0].key).toBe("intensity");
});
test("intensity param has correct defaults", () => {
const param = sharpenEffectDefinition.params[0];
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(50);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("renderer has single pass", () => {
expect(sharpenEffectDefinition.renderer.type).toBe("webgl");
expect(sharpenEffectDefinition.renderer.passes).toHaveLength(1);
});
test("uniforms normalize intensity to 0..1 range", () => {
const pass = sharpenEffectDefinition.renderer.passes[0];
const at0 = pass.uniforms({
effectParams: { intensity: 0 },
width: 1920,
height: 1080,
});
const at100 = pass.uniforms({
effectParams: { intensity: 100 },
width: 1920,
height: 1080,
});
expect(at0.u_intensity).toBe(0);
expect(at100.u_intensity).toBe(1);
});
test("uniforms at 50% intensity", () => {
const pass = sharpenEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 50 },
width: 1920,
height: 1080,
});
expect(result.u_intensity).toBe(0.5);
});
test("uniforms at various intensity levels", () => {
const pass = sharpenEffectDefinition.renderer.passes[0];
const at25 = pass.uniforms({
effectParams: { intensity: 25 },
width: 1920,
height: 1080,
});
const at75 = pass.uniforms({
effectParams: { intensity: 75 },
width: 1920,
height: 1080,
});
expect(at25.u_intensity).toBe(0.25);
expect(at75.u_intensity).toBe(0.75);
});
});

View File

@ -0,0 +1,34 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import pass1Shader from "./sketch-pass1.frag.glsl";
import pass2Shader from "./sketch-pass2.frag.glsl";
/** Sketch/outline — 2-pass: Sobel edge detection + threshold */
export const sketchEffectDefinition: EffectDefinition = {
type: "sketch",
name: "Sketch",
category: EffectCategory.ARTISTIC,
keywords: ["sketch", "outline", "pencil", "edge", "drawing", "line art"],
params: [
{ key: "threshold", label: "Threshold", type: "number", default: 50, min: 0, max: 100, step: 1 },
{ key: "lineWidth", label: "Line Width", type: "number", default: 1, min: 1, max: 5, step: 0.5 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: pass1Shader,
uniforms: ({ effectParams }) => ({
u_lineWidth: getNumberParam(effectParams, "lineWidth", 1),
}),
},
{
fragmentShader: pass2Shader,
uniforms: ({ effectParams }) => ({
u_threshold: getNumberParam(effectParams, "threshold") / 100,
}),
},
],
},
};

View File

@ -0,0 +1,27 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_lineWidth;
varying vec2 v_texCoord;
void main() {
vec2 texel = u_lineWidth / u_resolution;
// Sobel edge detection on luminance
float tl = dot(texture2D(u_texture, v_texCoord + vec2(-texel.x, texel.y)).rgb, vec3(0.2126, 0.7152, 0.0722));
float t = dot(texture2D(u_texture, v_texCoord + vec2(0.0, texel.y)).rgb, vec3(0.2126, 0.7152, 0.0722));
float tr = dot(texture2D(u_texture, v_texCoord + vec2(texel.x, texel.y)).rgb, vec3(0.2126, 0.7152, 0.0722));
float l = dot(texture2D(u_texture, v_texCoord + vec2(-texel.x, 0.0)).rgb, vec3(0.2126, 0.7152, 0.0722));
float r = dot(texture2D(u_texture, v_texCoord + vec2(texel.x, 0.0)).rgb, vec3(0.2126, 0.7152, 0.0722));
float bl = dot(texture2D(u_texture, v_texCoord + vec2(-texel.x, -texel.y)).rgb, vec3(0.2126, 0.7152, 0.0722));
float b = dot(texture2D(u_texture, v_texCoord + vec2(0.0, -texel.y)).rgb, vec3(0.2126, 0.7152, 0.0722));
float br = dot(texture2D(u_texture, v_texCoord + vec2(texel.x, -texel.y)).rgb, vec3(0.2126, 0.7152, 0.0722));
float gx = -tl - 2.0*l - bl + tr + 2.0*r + br;
float gy = -tl - 2.0*t - tr + bl + 2.0*b + br;
float edge = sqrt(gx*gx + gy*gy);
gl_FragColor = vec4(vec3(edge), 1.0);
}

View File

@ -0,0 +1,15 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_threshold;
varying vec2 v_texCoord;
void main() {
vec4 edges = texture2D(u_texture, v_texCoord);
float edge = edges.r;
// Threshold and invert for sketch look (dark lines on white)
float line = step(u_threshold, edge);
gl_FragColor = vec4(vec3(1.0 - line), 1.0);
}

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import pass1Shader from "./skin-smooth-pass1.frag.glsl";
import pass2Shader from "./skin-smooth-pass2.frag.glsl";
/** Skin smooth — bilateral filter that preserves edges, optionally masked to face region */
export const skinSmoothEffectDefinition: EffectDefinition = {
type: "skin-smooth",
name: "Skin Smooth",
category: EffectCategory.BEAUTY,
keywords: ["skin", "smooth", "beauty", "soft", "face", "portrait"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 50, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: pass1Shader,
uniforms: ({ effectParams }) => ({
u_intensity: (getNumberParam(effectParams, "intensity") / 100) * 3,
}),
},
{
fragmentShader: pass2Shader,
uniforms: ({ context }) => ({
u_faceMaskEnabled: context?.faceDetected ? 1 : 0,
}),
},
],
},
};

View File

@ -0,0 +1,34 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
varying vec2 v_texCoord;
void main() {
vec2 texel = 1.0 / u_resolution;
vec4 center = texture2D(u_texture, v_texCoord);
// Bilateral filter: blur that preserves edges
vec4 sum = vec4(0.0);
float totalWeight = 0.0;
for (int x = -4; x <= 4; x++) {
for (int y = -4; y <= 4; y++) {
vec2 offset = vec2(float(x), float(y)) * texel * u_intensity;
vec4 sample_ = texture2D(u_texture, v_texCoord + offset);
// Edge-aware weight: similar colors get higher weight
float colorDist = distance(center.rgb, sample_.rgb);
float spatialWeight = exp(-float(x*x + y*y) / 32.0);
float colorWeight = exp(-colorDist * colorDist / 0.05);
float weight = spatialWeight * colorWeight;
sum += sample_ * weight;
totalWeight += weight;
}
}
gl_FragColor = vec4(sum.rgb / totalWeight, center.a);
}

View File

@ -0,0 +1,14 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_faceMaskEnabled;
varying vec2 v_texCoord;
void main() {
// Pass-through — the actual face mask blending is done by the renderer
// when face detection is available. Without a mask, the smoothing
// applies uniformly (acceptable fallback).
gl_FragColor = texture2D(u_texture, v_texCoord);
}

View File

@ -0,0 +1,35 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./slim-face.frag.glsl";
/** Slim face — pinch warp along jawline for face slimming */
export const slimFaceEffectDefinition: EffectDefinition = {
type: "slim-face",
name: "Slim Face",
category: EffectCategory.BEAUTY,
keywords: ["slim", "face", "thin", "jawline", "beauty", "reshape"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 20, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams, context }) => {
const jawCenter =
context?.jawPoints && context.jawPoints.length >= 2
? context.jawPoints.slice(0, 2)
: null;
return {
// No-op when jaw landmarks are missing to avoid warping arbitrary content
u_intensity: jawCenter ? getNumberParam(effectParams, "intensity") / 100 : 0,
u_jawCenter: jawCenter ?? [0.5, 0.6],
};
},
},
],
},
};

View File

@ -0,0 +1,25 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform vec2 u_jawCenter;
varying vec2 v_texCoord;
void main() {
vec2 uv = v_texCoord;
// Simple pinch warp toward center along jawline
vec2 toCenter = u_jawCenter - uv;
float dist = length(toCenter);
float radius = 0.3;
if (dist < radius) {
float falloff = 1.0 - smoothstep(0.0, radius, dist);
// Pull pixels inward (toward jaw center) for slimming
uv += toCenter * falloff * u_intensity * 0.05;
}
gl_FragColor = texture2D(u_texture, uv);
}

View File

@ -0,0 +1,26 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./teeth-whiten.frag.glsl";
/** Teeth whiten — desaturate + brighten, ideally masked to mouth region */
export const teethWhitenEffectDefinition: EffectDefinition = {
type: "teeth-whiten",
name: "Teeth Whiten",
category: EffectCategory.BEAUTY,
keywords: ["teeth", "whiten", "smile", "beauty", "dental"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 40, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 200,
}),
},
],
},
};

View File

@ -0,0 +1,16 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Desaturate + brighten for whitening effect
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
vec3 desaturated = mix(color.rgb, vec3(luma), u_intensity);
vec3 brightened = desaturated + u_intensity * 0.1;
gl_FragColor = vec4(clamp(brightened, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,33 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./temperature.frag.glsl";
export const temperatureEffectDefinition: EffectDefinition = {
type: "temperature",
name: "Temperature",
category: EffectCategory.COLOR_TONE,
keywords: ["temperature", "warm", "cool", "white balance", "warmth"],
params: [
{
key: "warmth",
label: "Warmth",
type: "number",
default: 0,
min: -100,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_temperature: getNumberParam(effectParams, "warmth") / 400,
}),
},
],
},
};

View File

@ -0,0 +1,15 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_temperature;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Warm = boost red, reduce blue. Cool = opposite.
color.r += u_temperature;
color.b -= u_temperature;
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
}

View File

@ -0,0 +1,29 @@
import type { EffectDefinition } from "../../types";
import { EffectCategory } from "../../categories";
import { getNumberParam } from "../../utils/params";
import fragmentShader from "./vignette.frag.glsl";
export const vignetteEffectDefinition: EffectDefinition = {
type: "vignette",
name: "Vignette",
category: EffectCategory.ARTISTIC,
keywords: ["vignette", "border", "darken", "edges", "cinematic"],
params: [
{ key: "intensity", label: "Intensity", type: "number", default: 50, min: 0, max: 100, step: 1 },
{ key: "radius", label: "Radius", type: "number", default: 80, min: 0, max: 100, step: 1 },
{ key: "softness", label: "Softness", type: "number", default: 50, min: 0, max: 100, step: 1 },
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader,
uniforms: ({ effectParams }) => ({
u_intensity: getNumberParam(effectParams, "intensity") / 100,
u_radius: getNumberParam(effectParams, "radius") / 100 * 0.707,
u_softness: getNumberParam(effectParams, "softness") / 100 * 0.5,
}),
},
],
},
};

View File

@ -0,0 +1,20 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_intensity;
uniform float u_radius;
uniform float u_softness;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// Distance from center (0..~0.707 for corners)
vec2 center = v_texCoord - 0.5;
float dist = length(center);
// Darken based on distance, radius, and softness
float vignette = smoothstep(u_radius, u_radius - u_softness, dist);
color.rgb *= mix(1.0, vignette, u_intensity);
gl_FragColor = color;
}

View File

@ -0,0 +1,131 @@
import { describe, test, expect } from "bun:test";
import { vignetteEffectDefinition } from "./index";
import { EffectCategory } from "../../categories";
describe("Vignette Effect Definition", () => {
test("has correct type and name", () => {
expect(vignetteEffectDefinition.type).toBe("vignette");
expect(vignetteEffectDefinition.name).toBe("Vignette");
});
test("is categorized as artistic", () => {
expect(vignetteEffectDefinition.category).toBe(EffectCategory.ARTISTIC);
});
test("has searchable keywords", () => {
expect(vignetteEffectDefinition.keywords).toContain("vignette");
expect(vignetteEffectDefinition.keywords).toContain("cinematic");
expect(vignetteEffectDefinition.keywords).toContain("edges");
});
test("has 3 params: intensity, radius, softness", () => {
expect(vignetteEffectDefinition.params.map((p) => p.key)).toEqual([
"intensity",
"radius",
"softness",
]);
});
test("intensity param has correct defaults", () => {
const param = vignetteEffectDefinition.params[0];
expect(param.key).toBe("intensity");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(50);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("radius param has correct defaults", () => {
const param = vignetteEffectDefinition.params[1];
expect(param.key).toBe("radius");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(80);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("softness param has correct defaults", () => {
const param = vignetteEffectDefinition.params[2];
expect(param.key).toBe("softness");
expect(param.type).toBe("number");
if (param.type === "number") {
expect(param.default).toBe(50);
expect(param.min).toBe(0);
expect(param.max).toBe(100);
expect(param.step).toBe(1);
}
});
test("renderer has single pass", () => {
expect(vignetteEffectDefinition.renderer.type).toBe("webgl");
expect(vignetteEffectDefinition.renderer.passes).toHaveLength(1);
});
test("uniforms normalize intensity to 0..1 range", () => {
const pass = vignetteEffectDefinition.renderer.passes[0];
const at0 = pass.uniforms({
effectParams: { intensity: 0, radius: 50, softness: 50 },
width: 1920,
height: 1080,
});
const at100 = pass.uniforms({
effectParams: { intensity: 100, radius: 50, softness: 50 },
width: 1920,
height: 1080,
});
expect(at0.u_intensity).toBe(0);
expect(at100.u_intensity).toBe(1);
});
test("uniforms scale radius correctly", () => {
const pass = vignetteEffectDefinition.renderer.passes[0];
const at0 = pass.uniforms({
effectParams: { intensity: 50, radius: 0, softness: 50 },
width: 1920,
height: 1080,
});
const at100 = pass.uniforms({
effectParams: { intensity: 50, radius: 100, softness: 50 },
width: 1920,
height: 1080,
});
// radius: 100 / 100 * 0.707 = 0.707
expect(at0.u_radius).toBe(0);
expect(at100.u_radius).toBeCloseTo(0.707, 3);
});
test("uniforms scale softness correctly", () => {
const pass = vignetteEffectDefinition.renderer.passes[0];
const at0 = pass.uniforms({
effectParams: { intensity: 50, radius: 50, softness: 0 },
width: 1920,
height: 1080,
});
const at100 = pass.uniforms({
effectParams: { intensity: 50, radius: 50, softness: 100 },
width: 1920,
height: 1080,
});
// softness: 100 / 100 * 0.5 = 0.5
expect(at0.u_softness).toBe(0);
expect(at100.u_softness).toBe(0.5);
});
test("uniforms with default values", () => {
const pass = vignetteEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 50, radius: 80, softness: 50 },
width: 1920,
height: 1080,
});
expect(result.u_intensity).toBe(0.5);
expect(result.u_radius).toBeCloseTo(0.5656, 3); // 80 / 100 * 0.707
expect(result.u_softness).toBe(0.25); // 50 / 100 * 0.5
});
});

5
packages/effects/src/glsl.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/** Declare .glsl files as importable string modules */
declare module "*.glsl" {
const content: string;
export default content;
}

View File

@ -0,0 +1,157 @@
// Types
export type {
Effect,
EffectParamType,
EffectParamValues,
EffectParamDefinition,
NumberEffectParamDefinition,
WebGLEffectPass,
WebGLEffectRenderer,
EffectRenderer,
EffectDefinition,
EffectContext,
} from "./types";
// Utilities
export { hexToRgb } from "./utils/color";
// Registry
export {
registerEffect,
hasEffect,
getEffect,
getAllEffects,
clearEffects,
} from "./registry";
// Categories
export {
EffectCategory,
EFFECT_CATEGORY_META,
type EffectCategoryMeta,
} from "./categories";
// Presets
export {
type EffectPreset,
type EffectPresetEntry,
type PresetCategory,
registerPreset,
getPreset,
hasPreset,
getAllPresets,
getPresetsByCategory,
clearPresets,
registerAllPresets,
getAllBundledPresets,
} from "./presets";
// Effect definitions — Artistic
export { blurEffectDefinition } from "./effects/blur";
export { vignetteEffectDefinition } from "./effects/vignette";
export { filmGrainEffectDefinition } from "./effects/film-grain";
export { sharpenEffectDefinition } from "./effects/sharpen";
export { pixelateEffectDefinition } from "./effects/pixelate";
export { chromaticAberrationEffectDefinition } from "./effects/chromatic-aberration";
export { glitchEffectDefinition } from "./effects/glitch";
export { neonGlowEffectDefinition } from "./effects/neon-glow";
export { sketchEffectDefinition } from "./effects/sketch";
export { oilPaintEffectDefinition } from "./effects/oil-paint";
// Effect definitions — Beauty
export { skinSmoothEffectDefinition } from "./effects/skin-smooth";
export { faceBrightenEffectDefinition } from "./effects/face-brighten";
export { eyeEnhanceEffectDefinition } from "./effects/eye-enhance";
export { teethWhitenEffectDefinition } from "./effects/teeth-whiten";
export { blushEffectDefinition } from "./effects/blush";
export { slimFaceEffectDefinition } from "./effects/slim-face";
// Effect definitions — Color & Tone
export { brightnessEffectDefinition } from "./effects/brightness";
export { contrastEffectDefinition } from "./effects/contrast";
export { saturationEffectDefinition } from "./effects/saturation";
export { hueShiftEffectDefinition } from "./effects/hue-shift";
export { temperatureEffectDefinition } from "./effects/temperature";
export { exposureEffectDefinition } from "./effects/exposure";
export { gammaEffectDefinition } from "./effects/gamma";
export { colorBalanceEffectDefinition } from "./effects/color-balance";
// Shared shaders
import _vertexShader from "./shaders/effect.vert.glsl";
/** Shared vertex shader source for all effects (fullscreen quad) */
export const vertexShader: string = _vertexShader;
// --- Convenience helpers ---
import { blurEffectDefinition } from "./effects/blur";
import { chromaticAberrationEffectDefinition } from "./effects/chromatic-aberration";
import { filmGrainEffectDefinition } from "./effects/film-grain";
import { glitchEffectDefinition } from "./effects/glitch";
import { neonGlowEffectDefinition } from "./effects/neon-glow";
import { oilPaintEffectDefinition } from "./effects/oil-paint";
import { pixelateEffectDefinition } from "./effects/pixelate";
import { sharpenEffectDefinition } from "./effects/sharpen";
import { sketchEffectDefinition } from "./effects/sketch";
import { vignetteEffectDefinition } from "./effects/vignette";
import { skinSmoothEffectDefinition } from "./effects/skin-smooth";
import { faceBrightenEffectDefinition } from "./effects/face-brighten";
import { eyeEnhanceEffectDefinition } from "./effects/eye-enhance";
import { teethWhitenEffectDefinition } from "./effects/teeth-whiten";
import { blushEffectDefinition } from "./effects/blush";
import { slimFaceEffectDefinition } from "./effects/slim-face";
import { brightnessEffectDefinition } from "./effects/brightness";
import { colorBalanceEffectDefinition } from "./effects/color-balance";
import { contrastEffectDefinition } from "./effects/contrast";
import { exposureEffectDefinition } from "./effects/exposure";
import { gammaEffectDefinition } from "./effects/gamma";
import { hueShiftEffectDefinition } from "./effects/hue-shift";
import { saturationEffectDefinition } from "./effects/saturation";
import { temperatureEffectDefinition } from "./effects/temperature";
import { hasEffect, registerEffect } from "./registry";
import type { EffectDefinition } from "./types";
/** All bundled effect definitions */
const allEffectDefinitions: EffectDefinition[] = [
// Artistic
blurEffectDefinition,
vignetteEffectDefinition,
filmGrainEffectDefinition,
sharpenEffectDefinition,
pixelateEffectDefinition,
chromaticAberrationEffectDefinition,
glitchEffectDefinition,
neonGlowEffectDefinition,
sketchEffectDefinition,
oilPaintEffectDefinition,
// Beauty
skinSmoothEffectDefinition,
faceBrightenEffectDefinition,
eyeEnhanceEffectDefinition,
teethWhitenEffectDefinition,
blushEffectDefinition,
slimFaceEffectDefinition,
// Color & Tone
brightnessEffectDefinition,
contrastEffectDefinition,
saturationEffectDefinition,
hueShiftEffectDefinition,
temperatureEffectDefinition,
exposureEffectDefinition,
gammaEffectDefinition,
colorBalanceEffectDefinition,
];
/** Register all bundled effects (skips already-registered) */
export function registerAllEffects(): void {
for (const definition of allEffectDefinitions) {
if (hasEffect({ effectType: definition.type })) {
continue;
}
registerEffect({ definition });
}
}
/** Get all bundled effect definitions without registering */
export function getAllEffectDefinitions(): EffectDefinition[] {
return [...allEffectDefinitions];
}

View File

@ -0,0 +1,27 @@
import type { EffectPreset } from "./types";
export const beautyPresets: EffectPreset[] = [
{
type: "soft-glow",
name: "Soft Glow",
category: "beauty",
description: "Dreamy soft-focus with gentle warmth",
effects: [
{ effectType: "brightness", params: { intensity: 5 } },
{ effectType: "contrast", params: { intensity: -5 } },
{ effectType: "saturation", params: { intensity: 10 } },
{ effectType: "blur", params: { intensity: 3 } },
],
},
{
type: "portrait",
name: "Portrait",
category: "beauty",
description: "Flattering portrait enhancement",
effects: [
{ effectType: "brightness", params: { intensity: 5 } },
{ effectType: "saturation", params: { intensity: 10 } },
{ effectType: "vignette", params: { intensity: 20, radius: 85, softness: 60 } },
],
},
];

View File

@ -0,0 +1,55 @@
import type { EffectPreset } from "./types";
export const cinematicPresets: EffectPreset[] = [
{
type: "teal-and-orange",
name: "Teal & Orange",
category: "cinematic",
description: "Classic Hollywood color grading",
effects: [
{
effectType: "color-balance",
params: {
shadowsRed: -30, shadowsGreen: 10, shadowsBlue: 30,
midtonesRed: 0, midtonesGreen: 0, midtonesBlue: 0,
highlightsRed: 30, highlightsGreen: 10, highlightsBlue: -20,
},
},
{ effectType: "contrast", params: { intensity: 15 } },
],
},
{
type: "vintage-film",
name: "Vintage Film",
category: "cinematic",
description: "Aged film stock with grain and warmth",
effects: [
{ effectType: "temperature", params: { warmth: 10 } },
{ effectType: "saturation", params: { intensity: -20 } },
{ effectType: "film-grain", params: { intensity: 20, size: 2, animated: true } },
{ effectType: "vignette", params: { intensity: 30, radius: 75, softness: 50 } },
],
},
{
type: "noir",
name: "Noir",
category: "cinematic",
description: "High contrast black and white",
effects: [
{ effectType: "saturation", params: { intensity: -100 } },
{ effectType: "contrast", params: { intensity: 30 } },
{ effectType: "vignette", params: { intensity: 50, radius: 70, softness: 40 } },
],
},
{
type: "bleach-bypass",
name: "Bleach Bypass",
category: "cinematic",
description: "Desaturated, high-contrast silver look",
effects: [
{ effectType: "saturation", params: { intensity: -40 } },
{ effectType: "contrast", params: { intensity: 25 } },
{ effectType: "brightness", params: { intensity: -5 } },
],
},
];

View File

@ -0,0 +1,30 @@
export type { EffectPreset, EffectPresetEntry, PresetCategory } from "./types";
export {
registerPreset,
getPreset,
hasPreset,
getAllPresets,
getPresetsByCategory,
clearPresets,
} from "./registry";
import { registerPreset, hasPreset } from "./registry";
import { instagramPresets } from "./instagram-filters";
import { cinematicPresets } from "./cinematic-presets";
import { beautyPresets } from "./beauty-presets";
/** All bundled presets */
const allPresets = [...instagramPresets, ...cinematicPresets, ...beautyPresets];
/** Register all bundled presets (skips already-registered) */
export function registerAllPresets(): void {
for (const preset of allPresets) {
if (hasPreset(preset.type)) continue;
registerPreset(preset);
}
}
/** Get all bundled presets without registering */
export function getAllBundledPresets() {
return [...allPresets];
}

View File

@ -0,0 +1,76 @@
import type { EffectPreset } from "./types";
export const instagramPresets: EffectPreset[] = [
{
type: "clarendon",
name: "Clarendon",
category: "instagram",
description: "Bright, vivid colors with strong contrast",
effects: [
{ effectType: "brightness", params: { intensity: 10 } },
{ effectType: "contrast", params: { intensity: 20 } },
{ effectType: "saturation", params: { intensity: 15 } },
{ effectType: "vignette", params: { intensity: 30, radius: 80, softness: 50 } },
],
},
{
type: "juno",
name: "Juno",
category: "instagram",
description: "Warm tones with boosted saturation",
effects: [
{ effectType: "brightness", params: { intensity: 5 } },
{ effectType: "saturation", params: { intensity: 25 } },
{ effectType: "temperature", params: { warmth: 15 } },
{ effectType: "vignette", params: { intensity: 20, radius: 80, softness: 50 } },
],
},
{
type: "lark",
name: "Lark",
category: "instagram",
description: "Bright, airy with desaturated colors",
effects: [
{ effectType: "brightness", params: { intensity: 15 } },
{ effectType: "contrast", params: { intensity: -10 } },
{ effectType: "saturation", params: { intensity: -15 } },
{ effectType: "temperature", params: { warmth: -10 } },
],
},
{
type: "gingham",
name: "Gingham",
category: "instagram",
description: "Soft, vintage feel with muted colors",
effects: [
{ effectType: "brightness", params: { intensity: 5 } },
{ effectType: "contrast", params: { intensity: -10 } },
{ effectType: "saturation", params: { intensity: -30 } },
{ effectType: "hue-shift", params: { degrees: 5 } },
],
},
{
type: "valencia",
name: "Valencia",
category: "instagram",
description: "Warm, faded with golden tones",
effects: [
{ effectType: "temperature", params: { warmth: 20 } },
{ effectType: "contrast", params: { intensity: 10 } },
{ effectType: "saturation", params: { intensity: -10 } },
{ effectType: "vignette", params: { intensity: 40, radius: 70, softness: 60 } },
],
},
{
type: "nashville",
name: "Nashville",
category: "instagram",
description: "Warm, dreamy with purple undertones",
effects: [
{ effectType: "brightness", params: { intensity: 10 } },
{ effectType: "temperature", params: { warmth: 30 } },
{ effectType: "contrast", params: { intensity: 20 } },
{ effectType: "saturation", params: { intensity: -20 } },
],
},
];

View File

@ -0,0 +1,29 @@
import type { EffectPreset, PresetCategory } from "./types";
const presetMap = new Map<string, EffectPreset>();
export function registerPreset(preset: EffectPreset): void {
presetMap.set(preset.type, preset);
}
export function getPreset(type: string): EffectPreset {
const preset = presetMap.get(type);
if (!preset) throw new Error(`Unknown preset: ${type}`);
return preset;
}
export function hasPreset(type: string): boolean {
return presetMap.has(type);
}
export function getAllPresets(): EffectPreset[] {
return Array.from(presetMap.values());
}
export function getPresetsByCategory(category: PresetCategory): EffectPreset[] {
return getAllPresets().filter((p) => p.category === category);
}
export function clearPresets(): void {
presetMap.clear();
}

View File

@ -0,0 +1,17 @@
/** A single effect entry within a preset */
export interface EffectPresetEntry {
/** Must match a registered effect type (e.g., "brightness") */
effectType: string;
params: Record<string, number | string | boolean>;
}
export type PresetCategory = "instagram" | "cinematic" | "beauty" | "custom";
/** Curated combination of effects with preset parameter values */
export interface EffectPreset {
type: string;
name: string;
category: PresetCategory;
description: string;
effects: EffectPresetEntry[];
}

View File

@ -0,0 +1,37 @@
import type { EffectDefinition } from "./types";
/** In-memory map of registered effect definitions keyed by type */
const effectDefinitions = new Map<string, EffectDefinition>();
export function registerEffect({
definition,
}: {
definition: EffectDefinition;
}): void {
effectDefinitions.set(definition.type, definition);
}
export function hasEffect({ effectType }: { effectType: string }): boolean {
return effectDefinitions.has(effectType);
}
export function getEffect({
effectType,
}: {
effectType: string;
}): EffectDefinition {
const definition = effectDefinitions.get(effectType);
if (!definition) {
throw new Error(`Unknown effect type: ${effectType}`);
}
return definition;
}
export function getAllEffects(): EffectDefinition[] {
return Array.from(effectDefinitions.values());
}
/** Clear all registered effects — useful for testing */
export function clearEffects(): void {
effectDefinitions.clear();
}

View File

@ -0,0 +1,43 @@
// Shared GLSL utility functions for color effects
// NOTE: WebGL 1 has no #include — copy relevant functions into each shader
// --- Luminance ---
// float luminance(vec3 color) {
// return dot(color, vec3(0.2126, 0.7152, 0.0722));
// }
// --- RGB to HSL ---
// vec3 rgb2hsl(vec3 c) {
// float maxC = max(c.r, max(c.g, c.b));
// float minC = min(c.r, min(c.g, c.b));
// float l = (maxC + minC) * 0.5;
// if (maxC == minC) return vec3(0.0, 0.0, l);
// float d = maxC - minC;
// float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
// float h;
// if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
// else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
// else h = (c.r - c.g) / d + 4.0;
// h /= 6.0;
// return vec3(h, s, l);
// }
// --- HSL to RGB ---
// float hue2rgb(float p, float q, float t) {
// if (t < 0.0) t += 1.0;
// if (t > 1.0) t -= 1.0;
// if (t < 1.0/6.0) return p + (q - p) * 6.0 * t;
// if (t < 1.0/2.0) return q;
// if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0;
// return p;
// }
// vec3 hsl2rgb(vec3 hsl) {
// if (hsl.y == 0.0) return vec3(hsl.z);
// float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
// float p = 2.0 * hsl.z - q;
// return vec3(
// hue2rgb(p, q, hsl.x + 1.0/3.0),
// hue2rgb(p, q, hsl.x),
// hue2rgb(p, q, hsl.x - 1.0/3.0)
// );
// }

View File

@ -0,0 +1,103 @@
import type { EffectCategory } from "./categories";
/** Runtime effect instance attached to a timeline element */
export interface Effect {
id: string;
type: string;
params: EffectParamValues;
enabled: boolean;
}
export type EffectParamType = "number" | "boolean" | "select" | "color";
export type EffectParamValues = Record<string, number | string | boolean>;
interface BaseEffectParamDefinition {
key: string;
label: string;
}
export interface NumberEffectParamDefinition extends BaseEffectParamDefinition {
type: "number";
default: number;
min: number;
max: number;
step: number;
}
interface BooleanEffectParamDefinition extends BaseEffectParamDefinition {
type: "boolean";
default: boolean;
}
interface SelectEffectParamDefinition extends BaseEffectParamDefinition {
type: "select";
default: string;
options: Array<{ value: string; label: string }>;
}
interface ColorEffectParamDefinition extends BaseEffectParamDefinition {
type: "color";
default: string;
}
export type EffectParamDefinition =
| NumberEffectParamDefinition
| BooleanEffectParamDefinition
| SelectEffectParamDefinition
| ColorEffectParamDefinition;
/**
* Context data for face-aware beauty effects.
* Provided by the renderer when a beauty effect is active.
* The effects package defines the shape; the web app populates it via MediaPipe.
*/
export interface EffectContext {
/** Face region mask as a flat array (0 = outside, 1 = face) */
faceMask?: number[];
/** Eye region mask */
eyeMask?: number[];
/** Mouth region mask */
mouthMask?: number[];
/** Left cheek center position [x, y] normalized 0..1 */
cheekLeft?: number[];
/** Right cheek center position [x, y] normalized 0..1 */
cheekRight?: number[];
/** Cheek radius normalized 0..1 */
cheekRadius?: number;
/** Jawline control points [x1,y1, x2,y2, ...] normalized 0..1 */
jawPoints?: number[];
/** Whether a face was detected in the current frame */
faceDetected?: boolean;
}
/** Single rendering pass — fragment shader + uniforms factory */
export interface WebGLEffectPass {
fragmentShader: string;
uniforms(params: {
effectParams: EffectParamValues;
width: number;
height: number;
/** Current playback time in seconds — for animated effects (film grain, glitch) */
time?: number;
/** Face landmark data for beauty effects — populated by renderer */
context?: EffectContext;
}): Record<string, number | number[]>;
}
export interface WebGLEffectRenderer {
type: "webgl";
passes: WebGLEffectPass[];
}
export type EffectRenderer = WebGLEffectRenderer;
/** Template for registering a new effect type */
export interface EffectDefinition {
type: string;
name: string;
category?: EffectCategory;
keywords: string[];
params: EffectParamDefinition[];
renderer: EffectRenderer;
}

View File

@ -0,0 +1,19 @@
const HEX_COLOR_REGEX = /^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/;
const DEFAULT_RGB: [number, number, number] = [0, 1, 0.533]; // #00ff88
/** Convert hex color string (#RRGGBB or #RGB) to normalized RGB array [0..1, 0..1, 0..1] */
export function hexToRgb(hex: string): [number, number, number] {
if (!hex || !HEX_COLOR_REGEX.test(hex)) {
return DEFAULT_RGB;
}
let hexString = hex.replace("#", "");
// Expand shorthand (#RGB -> #RRGGBB)
if (hexString.length === 3) {
hexString = hexString[0] + hexString[0] + hexString[1] + hexString[1] + hexString[2] + hexString[2];
}
return [
Number.parseInt(hexString.slice(0, 2), 16) / 255,
Number.parseInt(hexString.slice(2, 4), 16) / 255,
Number.parseInt(hexString.slice(4, 6), 16) / 255,
];
}

View File

@ -0,0 +1,52 @@
import type { EffectParamValues } from "../types";
/**
* Type-safe parameter getters for effect uniforms.
* These eliminate the need for `as number` / `as string` casts throughout effect definitions.
*/
/** Get a number parameter with fallback default */
export function getNumberParam(
params: EffectParamValues,
key: string,
defaultValue = 0,
): number {
const value = params[key];
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
return defaultValue;
}
/** Get a string parameter with fallback default */
export function getStringParam(
params: EffectParamValues,
key: string,
defaultValue = "",
): string {
const value = params[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return defaultValue;
}
/** Get a boolean parameter with fallback default */
export function getBooleanParam(
params: EffectParamValues,
key: string,
defaultValue = false,
): boolean {
const value = params[key];
if (typeof value === "boolean") {
return value;
}
return defaultValue;
}

View File

@ -0,0 +1,147 @@
import { describe, test, expect } from "bun:test";
import { EffectCategory } from "../src/categories";
import type { EffectDefinition } from "../src/types";
import { vignetteEffectDefinition } from "../src/effects/vignette";
import { filmGrainEffectDefinition } from "../src/effects/film-grain";
import { sharpenEffectDefinition } from "../src/effects/sharpen";
import { pixelateEffectDefinition } from "../src/effects/pixelate";
import { chromaticAberrationEffectDefinition } from "../src/effects/chromatic-aberration";
import { glitchEffectDefinition } from "../src/effects/glitch";
import { neonGlowEffectDefinition } from "../src/effects/neon-glow";
import { sketchEffectDefinition } from "../src/effects/sketch";
import { oilPaintEffectDefinition } from "../src/effects/oil-paint";
const defaultArgs = { width: 1920, height: 1080, time: 1.5 };
/** Validate common artistic effect structure */
function validateArtisticEffect(def: EffectDefinition, expectedType: string, expectedPasses: number) {
test(`${expectedType}: has correct type and category`, () => {
expect(def.type).toBe(expectedType);
expect(def.category).toBe(EffectCategory.ARTISTIC);
});
test(`${expectedType}: has ${expectedPasses} pass(es)`, () => {
expect(def.renderer.passes).toHaveLength(expectedPasses);
});
test(`${expectedType}: uniforms factory works with defaults`, () => {
const defaults: Record<string, number | string | boolean> = {};
for (const p of def.params) defaults[p.key] = p.default;
for (const pass of def.renderer.passes) {
const result = pass.uniforms({ effectParams: defaults, ...defaultArgs });
expect(typeof result).toBe("object");
}
});
}
// --- Single-pass effects ---
describe("Vignette", () => {
validateArtisticEffect(vignetteEffectDefinition, "vignette", 1);
test("has 3 params: intensity, radius, softness", () => {
expect(vignetteEffectDefinition.params.map((p) => p.key)).toEqual(["intensity", "radius", "softness"]);
});
});
describe("Film Grain", () => {
validateArtisticEffect(filmGrainEffectDefinition, "film-grain", 1);
test("has animated boolean param", () => {
const animated = filmGrainEffectDefinition.params.find((p) => p.key === "animated");
expect(animated?.type).toBe("boolean");
});
test("uses time when animated", () => {
const pass = filmGrainEffectDefinition.renderer.passes[0];
const withTime = pass.uniforms({ effectParams: { intensity: 30, size: 2, animated: true }, ...defaultArgs });
expect(withTime.u_time).toBe(1.5);
});
test("ignores time when not animated", () => {
const pass = filmGrainEffectDefinition.renderer.passes[0];
const noTime = pass.uniforms({ effectParams: { intensity: 30, size: 2, animated: false }, ...defaultArgs });
expect(noTime.u_time).toBe(0);
});
});
describe("Sharpen", () => {
validateArtisticEffect(sharpenEffectDefinition, "sharpen", 1);
});
describe("Pixelate", () => {
validateArtisticEffect(pixelateEffectDefinition, "pixelate", 1);
test("blockSize default is 10", () => {
expect(pixelateEffectDefinition.params[0].default).toBe(10);
});
test("blockSize passed directly as uniform", () => {
const pass = pixelateEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { blockSize: 20 }, ...defaultArgs });
expect(result.u_blockSize).toBe(20);
});
});
describe("Chromatic Aberration", () => {
validateArtisticEffect(chromaticAberrationEffectDefinition, "chromatic-aberration", 1);
test("angle converted to radians", () => {
const pass = chromaticAberrationEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { intensity: 50, angle: 180 }, ...defaultArgs });
expect(result.u_angle).toBeCloseTo(Math.PI, 5);
});
});
// --- Multi-pass effects ---
describe("Glitch", () => {
validateArtisticEffect(glitchEffectDefinition, "glitch", 2);
test("has 3 params: intensity, speed, colorSplit", () => {
expect(glitchEffectDefinition.params.map((p) => p.key)).toEqual(["intensity", "speed", "colorSplit"]);
});
test("both passes use time", () => {
const defaults: Record<string, number> = { intensity: 50, speed: 50, colorSplit: 30 };
for (const pass of glitchEffectDefinition.renderer.passes) {
const result = pass.uniforms({ effectParams: defaults, ...defaultArgs });
expect(result.u_time).toBeGreaterThan(0);
}
});
});
describe("Neon Glow", () => {
validateArtisticEffect(neonGlowEffectDefinition, "neon-glow", 2);
test("has color param", () => {
const colorParam = neonGlowEffectDefinition.params.find((p) => p.key === "color");
expect(colorParam?.type).toBe("color");
});
test("pass 2 converts hex color to RGB array", () => {
const pass2 = neonGlowEffectDefinition.renderer.passes[1];
const result = pass2.uniforms({ effectParams: { intensity: 50, color: "#ff0000", threshold: 50 }, ...defaultArgs });
expect(result.u_glowColor).toEqual([1, 0, 0]);
});
});
describe("Sketch", () => {
validateArtisticEffect(sketchEffectDefinition, "sketch", 2);
test("has threshold and lineWidth params", () => {
expect(sketchEffectDefinition.params.map((p) => p.key)).toEqual(["threshold", "lineWidth"]);
});
});
describe("Oil Paint", () => {
validateArtisticEffect(oilPaintEffectDefinition, "oil-paint", 2);
test("radius max is capped at 5 for performance", () => {
const radiusParam = oilPaintEffectDefinition.params.find((p) => p.key === "radius");
if (radiusParam?.type === "number") {
expect(radiusParam.max).toBeLessThanOrEqual(5);
}
});
});

View File

@ -0,0 +1,107 @@
import { describe, test, expect } from "bun:test";
import { EffectCategory } from "../src/categories";
import type { EffectContext, EffectDefinition } from "../src/types";
import { skinSmoothEffectDefinition } from "../src/effects/skin-smooth";
import { faceBrightenEffectDefinition } from "../src/effects/face-brighten";
import { eyeEnhanceEffectDefinition } from "../src/effects/eye-enhance";
import { teethWhitenEffectDefinition } from "../src/effects/teeth-whiten";
import { blushEffectDefinition } from "../src/effects/blush";
import { slimFaceEffectDefinition } from "../src/effects/slim-face";
const defaultArgs = { width: 1920, height: 1080, time: 0 };
const mockContext: EffectContext = {
faceDetected: true,
cheekLeft: [0.3, 0.5],
cheekRight: [0.7, 0.5],
cheekRadius: 0.06,
jawPoints: [0.5, 0.65],
};
function validateBeautyEffect(def: EffectDefinition, type: string) {
test(`${type}: correct type and BEAUTY category`, () => {
expect(def.type).toBe(type);
expect(def.category).toBe(EffectCategory.BEAUTY);
});
test(`${type}: uniforms work without context (graceful fallback)`, () => {
const defaults: Record<string, number | string | boolean> = {};
for (const p of def.params) defaults[p.key] = p.default;
for (const pass of def.renderer.passes) {
const result = pass.uniforms({ effectParams: defaults, ...defaultArgs });
expect(typeof result).toBe("object");
}
});
test(`${type}: uniforms work with face context`, () => {
const defaults: Record<string, number | string | boolean> = {};
for (const p of def.params) defaults[p.key] = p.default;
for (const pass of def.renderer.passes) {
const result = pass.uniforms({ effectParams: defaults, ...defaultArgs, context: mockContext });
expect(typeof result).toBe("object");
}
});
}
describe("Skin Smooth", () => {
validateBeautyEffect(skinSmoothEffectDefinition, "skin-smooth");
test("has 2 passes (bilateral filter + mask)", () => {
expect(skinSmoothEffectDefinition.renderer.passes).toHaveLength(2);
});
});
describe("Face Brighten", () => {
validateBeautyEffect(faceBrightenEffectDefinition, "face-brighten");
test("single pass", () => {
expect(faceBrightenEffectDefinition.renderer.passes).toHaveLength(1);
});
});
describe("Eye Enhance", () => {
validateBeautyEffect(eyeEnhanceEffectDefinition, "eye-enhance");
test("has brightness and contrast params", () => {
expect(eyeEnhanceEffectDefinition.params.map((p) => p.key)).toEqual(["brightness", "contrast"]);
});
});
describe("Teeth Whiten", () => {
validateBeautyEffect(teethWhitenEffectDefinition, "teeth-whiten");
});
describe("Blush", () => {
validateBeautyEffect(blushEffectDefinition, "blush");
test("has color param", () => {
const colorParam = blushEffectDefinition.params.find((p) => p.key === "color");
expect(colorParam?.type).toBe("color");
});
test("uses context cheek positions when available", () => {
const pass = blushEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 50, color: "#ff9999" },
...defaultArgs,
context: mockContext,
});
expect(result.u_cheekLeft).toEqual([0.3, 0.5]);
});
test("falls back to defaults without context", () => {
const pass = blushEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 50, color: "#ff9999" },
...defaultArgs,
});
expect(result.u_cheekLeft).toEqual([0.35, 0.55]);
});
});
describe("Slim Face", () => {
validateBeautyEffect(slimFaceEffectDefinition, "slim-face");
test("uses jawPoints from context", () => {
const pass = slimFaceEffectDefinition.renderer.passes[0];
const result = pass.uniforms({
effectParams: { intensity: 50 },
...defaultArgs,
context: mockContext,
});
expect(result.u_jawCenter).toEqual([0.5, 0.65]);
});
});

View File

@ -0,0 +1,180 @@
import { describe, test, expect } from "bun:test";
import { EffectCategory } from "../src/categories";
import type { EffectDefinition } from "../src/types";
import { brightnessEffectDefinition } from "../src/effects/brightness";
import { contrastEffectDefinition } from "../src/effects/contrast";
import { saturationEffectDefinition } from "../src/effects/saturation";
import { hueShiftEffectDefinition } from "../src/effects/hue-shift";
import { temperatureEffectDefinition } from "../src/effects/temperature";
import { exposureEffectDefinition } from "../src/effects/exposure";
import { gammaEffectDefinition } from "../src/effects/gamma";
import { colorBalanceEffectDefinition } from "../src/effects/color-balance";
const defaultUniformArgs = { width: 1920, height: 1080 };
/** Helper: validate common structure of all color/tone effects */
function validateColorEffect(def: EffectDefinition, expectedType: string) {
test(`${expectedType}: has correct type`, () => {
expect(def.type).toBe(expectedType);
});
test(`${expectedType}: is COLOR_TONE category`, () => {
expect(def.category).toBe(EffectCategory.COLOR_TONE);
});
test(`${expectedType}: has single-pass WebGL renderer`, () => {
expect(def.renderer.type).toBe("webgl");
expect(def.renderer.passes).toHaveLength(1);
});
test(`${expectedType}: has at least one param`, () => {
expect(def.params.length).toBeGreaterThanOrEqual(1);
});
test(`${expectedType}: has keywords`, () => {
expect(def.keywords.length).toBeGreaterThan(0);
});
test(`${expectedType}: uniforms factory returns object`, () => {
const defaults: Record<string, number | string | boolean> = {};
for (const p of def.params) {
defaults[p.key] = p.default;
}
const result = def.renderer.passes[0].uniforms({
effectParams: defaults,
...defaultUniformArgs,
});
expect(typeof result).toBe("object");
});
}
describe("Brightness", () => {
validateColorEffect(brightnessEffectDefinition, "brightness");
test("default intensity is 0 (no change)", () => {
expect(brightnessEffectDefinition.params[0].default).toBe(0);
});
test("uniforms map intensity to -1..1 range", () => {
const pass = brightnessEffectDefinition.renderer.passes[0];
const at100 = pass.uniforms({ effectParams: { intensity: 100 }, ...defaultUniformArgs });
const atNeg100 = pass.uniforms({ effectParams: { intensity: -100 }, ...defaultUniformArgs });
expect(at100.u_intensity).toBe(1);
expect(atNeg100.u_intensity).toBe(-1);
});
});
describe("Contrast", () => {
validateColorEffect(contrastEffectDefinition, "contrast");
test("uniforms map 0 → 1.0 (no change)", () => {
const pass = contrastEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { intensity: 0 }, ...defaultUniformArgs });
expect(result.u_contrast).toBe(1);
});
test("uniforms map 100 → 2.0 (max contrast)", () => {
const pass = contrastEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { intensity: 100 }, ...defaultUniformArgs });
expect(result.u_contrast).toBe(2);
});
});
describe("Saturation", () => {
validateColorEffect(saturationEffectDefinition, "saturation");
test("uniforms map -100 → 0.0 (grayscale)", () => {
const pass = saturationEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { intensity: -100 }, ...defaultUniformArgs });
expect(result.u_saturation).toBe(0);
});
});
describe("Hue Shift", () => {
validateColorEffect(hueShiftEffectDefinition, "hue-shift");
test("param range is 0..360 degrees", () => {
const param = hueShiftEffectDefinition.params[0];
if (param.type === "number") {
expect(param.min).toBe(0);
expect(param.max).toBe(360);
}
});
test("uniforms convert degrees to 0..1 range", () => {
const pass = hueShiftEffectDefinition.renderer.passes[0];
const at180 = pass.uniforms({ effectParams: { degrees: 180 }, ...defaultUniformArgs });
expect(at180.u_hueShift).toBe(0.5);
});
});
describe("Temperature", () => {
validateColorEffect(temperatureEffectDefinition, "temperature");
test("uniforms produce small shift values", () => {
const pass = temperatureEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { warmth: 100 }, ...defaultUniformArgs });
expect(result.u_temperature).toBe(0.25);
});
});
describe("Exposure", () => {
validateColorEffect(exposureEffectDefinition, "exposure");
test("param range is -3..3 stops", () => {
const param = exposureEffectDefinition.params[0];
if (param.type === "number") {
expect(param.min).toBe(-3);
expect(param.max).toBe(3);
expect(param.step).toBe(0.1);
}
});
test("uniforms pass stops directly", () => {
const pass = exposureEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { stops: 1.5 }, ...defaultUniformArgs });
expect(result.u_exposure).toBe(1.5);
});
});
describe("Gamma", () => {
validateColorEffect(gammaEffectDefinition, "gamma");
test("default gamma is 1.0 (linear)", () => {
expect(gammaEffectDefinition.params[0].default).toBe(1.0);
});
test("uniforms pass value directly", () => {
const pass = gammaEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: { value: 2.2 }, ...defaultUniformArgs });
expect(result.u_gamma).toBe(2.2);
});
});
describe("Color Balance", () => {
validateColorEffect(colorBalanceEffectDefinition, "color-balance");
test("has 9 params (3 channels × 3 regions)", () => {
expect(colorBalanceEffectDefinition.params).toHaveLength(9);
});
test("all params default to 0", () => {
for (const param of colorBalanceEffectDefinition.params) {
expect(param.default).toBe(0);
}
});
test("uniforms produce 3 vec3 arrays", () => {
const defaults: Record<string, number> = {};
for (const p of colorBalanceEffectDefinition.params) {
defaults[p.key] = 50;
}
const pass = colorBalanceEffectDefinition.renderer.passes[0];
const result = pass.uniforms({ effectParams: defaults, ...defaultUniformArgs });
expect(Array.isArray(result.u_shadows)).toBe(true);
expect(Array.isArray(result.u_midtones)).toBe(true);
expect(Array.isArray(result.u_highlights)).toBe(true);
expect((result.u_shadows as number[]).length).toBe(3);
});
});

View File

@ -0,0 +1,87 @@
import { describe, test, expect, beforeEach } from "bun:test";
import {
registerPreset,
getPreset,
hasPreset,
getAllPresets,
getPresetsByCategory,
clearPresets,
registerAllPresets,
getAllBundledPresets,
registerAllEffects,
clearEffects,
hasEffect,
} from "../src";
describe("Preset Registry", () => {
beforeEach(() => {
clearPresets();
});
test("registerPreset + getPreset works", () => {
registerPreset({
type: "test",
name: "Test",
category: "custom",
description: "Test preset",
effects: [{ effectType: "brightness", params: { intensity: 10 } }],
});
expect(hasPreset("test")).toBe(true);
expect(getPreset("test").name).toBe("Test");
});
test("getPreset throws for unknown", () => {
expect(() => getPreset("unknown")).toThrow("Unknown preset: unknown");
});
test("getPresetsByCategory filters correctly", () => {
registerAllPresets();
const instagram = getPresetsByCategory("instagram");
const cinematic = getPresetsByCategory("cinematic");
expect(instagram.length).toBe(6);
expect(cinematic.length).toBe(4);
});
test("clearPresets removes all", () => {
registerAllPresets();
expect(getAllPresets().length).toBeGreaterThan(0);
clearPresets();
expect(getAllPresets()).toHaveLength(0);
});
});
describe("Bundled Presets", () => {
beforeEach(() => {
clearPresets();
clearEffects();
});
test("has 12 total presets", () => {
const all = getAllBundledPresets();
expect(all).toHaveLength(12);
});
test("all presets reference valid effect types", () => {
registerAllEffects();
const presets = getAllBundledPresets();
for (const preset of presets) {
for (const entry of preset.effects) {
expect(hasEffect({ effectType: entry.effectType })).toBe(true);
}
}
});
test("each preset has 2-5 effects", () => {
for (const preset of getAllBundledPresets()) {
expect(preset.effects.length).toBeGreaterThanOrEqual(2);
expect(preset.effects.length).toBeLessThanOrEqual(5);
}
});
test("registerAllPresets skips duplicates", () => {
registerAllPresets();
const count1 = getAllPresets().length;
registerAllPresets();
expect(getAllPresets().length).toBe(count1);
});
});

View File

@ -0,0 +1,99 @@
import { describe, test, expect, beforeEach } from "bun:test";
import {
registerEffect,
getEffect,
hasEffect,
getAllEffects,
clearEffects,
} from "../src/registry";
import type { EffectDefinition } from "../src/types";
/** Minimal stub definition for testing registry logic */
function createStubDefinition(type: string): EffectDefinition {
return {
type,
name: type.charAt(0).toUpperCase() + type.slice(1),
keywords: [type],
params: [
{
key: "intensity",
label: "Intensity",
type: "number",
default: 50,
min: 0,
max: 100,
step: 1,
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: "void main() { gl_FragColor = vec4(1.0); }",
uniforms: () => ({ u_intensity: 0.5 }),
},
],
},
};
}
describe("Effect Registry", () => {
beforeEach(() => {
clearEffects();
});
test("registerEffect adds effect to registry", () => {
const def = createStubDefinition("blur");
registerEffect({ definition: def });
expect(hasEffect({ effectType: "blur" })).toBe(true);
});
test("hasEffect returns false for unregistered type", () => {
expect(hasEffect({ effectType: "nonexistent" })).toBe(false);
});
test("getEffect returns registered definition", () => {
const def = createStubDefinition("blur");
registerEffect({ definition: def });
const result = getEffect({ effectType: "blur" });
expect(result.type).toBe("blur");
expect(result.name).toBe("Blur");
});
test("getEffect throws for unknown type", () => {
expect(() => getEffect({ effectType: "unknown" })).toThrow(
"Unknown effect type: unknown",
);
});
test("getAllEffects returns all registered definitions", () => {
registerEffect({ definition: createStubDefinition("blur") });
registerEffect({ definition: createStubDefinition("brightness") });
const all = getAllEffects();
expect(all).toHaveLength(2);
expect(all.map((e) => e.type)).toContain("blur");
expect(all.map((e) => e.type)).toContain("brightness");
});
test("registerEffect overwrites duplicate type", () => {
const def1 = createStubDefinition("blur");
def1.name = "Blur V1";
registerEffect({ definition: def1 });
const def2 = createStubDefinition("blur");
def2.name = "Blur V2";
registerEffect({ definition: def2 });
expect(getAllEffects()).toHaveLength(1);
expect(getEffect({ effectType: "blur" }).name).toBe("Blur V2");
});
test("clearEffects removes all entries", () => {
registerEffect({ definition: createStubDefinition("blur") });
registerEffect({ definition: createStubDefinition("contrast") });
expect(getAllEffects()).toHaveLength(2);
clearEffects();
expect(getAllEffects()).toHaveLength(0);
});
});

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
loader: {
".glsl": "text",
},
});