Merge 46d741a51a into 7e2a913e13
This commit is contained in:
commit
f2a55ca25c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"][] = [
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { detectFace, isFaceMeshReady, disposeFaceMesh } from "./face-mesh-provider";
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
238
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
dist
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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")),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/** Declare .glsl files as importable string modules */
|
||||
declare module "*.glsl" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
// );
|
||||
// }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue