diff --git a/apps/web/package.json b/apps/web/package.json index a00c9671..1afae45c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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.1633559619", + "@opencut/effects": "workspace:*", "@opencut/env": "workspace:*", "@opencut/ui": "workspace:*", "@radix-ui/react-accordion": "^1.2.12", diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts deleted file mode 100644 index 3819f82c..00000000 --- a/apps/web/src/lib/effects/definitions/blur.ts +++ /dev/null @@ -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], - }; - }, - }, - ], - }, -}; diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts index 1742b1d7..a8980a42 100644 --- a/apps/web/src/lib/effects/definitions/index.ts +++ b/apps/web/src/lib/effects/definitions/index.ts @@ -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"; diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts index 14d06d84..41a30de9 100644 --- a/apps/web/src/lib/effects/index.ts +++ b/apps/web/src/lib/effects/index.ts @@ -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"][] = [ diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts index 568b8924..5109e4ca 100644 --- a/apps/web/src/lib/effects/registry.ts +++ b/apps/web/src/lib/effects/registry.ts @@ -1,31 +1,8 @@ -import type { EffectDefinition } from "@/types/effects"; - -const effectDefinitions = new Map(); - -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"; diff --git a/apps/web/src/services/face-mesh/face-mesh-provider.ts b/apps/web/src/services/face-mesh/face-mesh-provider.ts new file mode 100644 index 00000000..bd90d0f2 --- /dev/null +++ b/apps/web/src/services/face-mesh/face-mesh-provider.ts @@ -0,0 +1,122 @@ +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; +/** Resolve function for the current pending detection — avoids race conditions */ +let pendingResolve: ((results: Results) => void) | null = null; + +/** Lazy-load MediaPipe Face Mesh WASM module */ +async function loadFaceMesh(): Promise { + 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/${file}`, + }); + fm.setOptions({ + maxNumFaces: 1, + refineLandmarks: true, + minDetectionConfidence: 0.5, + minTrackingConfidence: 0.5, + }); + fm.onResults((results: Results) => { + if (pendingResolve) { + pendingResolve(results); + pendingResolve = null; + } + }); + faceMeshInstance = fm; + return fm; + } catch (err) { + console.warn("[face-mesh] Failed to load MediaPipe:", 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 { + const fm = await loadFaceMesh(); + if (!fm) { + return { faceDetected: false }; + } + + // Promise-based approach avoids race conditions with concurrent calls + const results = await new Promise((resolve) => { + pendingResolve = resolve; + fm.send({ image: source as HTMLCanvasElement }); + }); + + 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 { + if (faceMeshInstance) { + faceMeshInstance.close(); + faceMeshInstance = null; + } + pendingResolve = null; +} diff --git a/apps/web/src/services/face-mesh/index.ts b/apps/web/src/services/face-mesh/index.ts new file mode 100644 index 00000000..cbf431eb --- /dev/null +++ b/apps/web/src/services/face-mesh/index.ts @@ -0,0 +1 @@ +export { detectFace, isFaceMeshReady, disposeFaceMesh } from "./face-mesh-provider"; diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts index 3d84be2b..641f18da 100644 --- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -59,6 +59,7 @@ export class EffectLayerNode extends BaseNode { effectParams: this.params.effectParams, width: renderer.width, height: renderer.height, + time, }), })); const effectResult = webglEffectRenderer.applyEffect({ diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 6720a5e5..06731ec8 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -79,7 +79,7 @@ export class ImageNode extends VisualNode { const { source, width, height } = await this.cachedSource; - this.renderVisual({ + await this.renderVisual({ renderer, source, sourceWidth: width || renderer.width, diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index da271685..098c6a94 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -57,7 +57,7 @@ export class StickerNode extends VisualNode { const { source, width, height } = await this.cachedSource; - this.renderVisual({ + await this.renderVisual({ renderer, source, sourceWidth: width, diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 503c9c03..aa73370b 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -24,7 +24,7 @@ export class VideoNode extends VisualNode { }); if (frame) { - this.renderVisual({ + await this.renderVisual({ renderer, source: frame.canvas, sourceWidth: frame.canvas.width, diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 6c50aa12..4ddc5be9 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -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 { 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({ diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts index 834c652e..7b397e31 100644 --- a/apps/web/src/services/renderer/webgl-utils.ts +++ b/apps/web/src/services/renderer/webgl-utils.ts @@ -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; diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts index 6c96dfb2..feabd745 100644 --- a/apps/web/src/types/effects.ts +++ b/apps/web/src/types/effects.ts @@ -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; - -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; -} - -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"; diff --git a/bun.lock b/bun.lock index 12453d3c..fc43f150 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/effects/.gitignore b/packages/effects/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/packages/effects/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/effects/package.json b/packages/effects/package.json new file mode 100644 index 00000000..90a5f5ce --- /dev/null +++ b/packages/effects/package.json @@ -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" + } +} diff --git a/packages/effects/src/categories.ts b/packages/effects/src/categories.ts new file mode 100644 index 00000000..bca2e973 --- /dev/null +++ b/packages/effects/src/categories.ts @@ -0,0 +1,28 @@ +/** Effect categories for UI grouping */ +export enum EffectCategory { + COLOR_TONE = "color-tone", + ARTISTIC = "artistic", + BEAUTY = "beauty", +} + +export interface EffectCategoryMeta { + label: string; + description: string; +} + +/** Display metadata for each effect category */ +export const EFFECT_CATEGORY_META: Record = + { + [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", + }, + }; diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/packages/effects/src/effects/blur/blur.frag.glsl similarity index 100% rename from apps/web/src/lib/effects/definitions/blur.frag.glsl rename to packages/effects/src/effects/blur/blur.frag.glsl diff --git a/packages/effects/src/effects/blur/blur.test.ts b/packages/effects/src/effects/blur/blur.test.ts new file mode 100644 index 00000000..bd43aea1 --- /dev/null +++ b/packages/effects/src/effects/blur/blur.test.ts @@ -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); + }); +}); diff --git a/packages/effects/src/effects/blur/index.ts b/packages/effects/src/effects/blur/index.ts new file mode 100644 index 00000000..9bea42e2 --- /dev/null +++ b/packages/effects/src/effects/blur/index.ts @@ -0,0 +1,53 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +import blurFragmentShader from "./blur.frag.glsl"; + +/** 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 = + 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], + }; + }, + }, + ], + }, +}; diff --git a/packages/effects/src/effects/blush/blush.frag.glsl b/packages/effects/src/effects/blush/blush.frag.glsl new file mode 100644 index 00000000..b8af2cbf --- /dev/null +++ b/packages/effects/src/effects/blush/blush.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/blush/index.ts b/packages/effects/src/effects/blush/index.ts new file mode 100644 index 00000000..fd1bfecd --- /dev/null +++ b/packages/effects/src/effects/blush/index.ts @@ -0,0 +1,31 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +import { hexToRgb } from "../../utils/color"; +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: (effectParams.intensity as number) / 100, + u_color: hexToRgb(effectParams.color as string), + u_cheekLeft: context?.cheekLeft ?? [0.35, 0.55], + u_cheekRight: context?.cheekRight ?? [0.65, 0.55], + u_cheekRadius: context?.cheekRadius ?? 0.08, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/brightness/brightness.frag.glsl b/packages/effects/src/effects/brightness/brightness.frag.glsl new file mode 100644 index 00000000..6ada8e4f --- /dev/null +++ b/packages/effects/src/effects/brightness/brightness.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/brightness/index.ts b/packages/effects/src/effects/brightness/index.ts new file mode 100644 index 00000000..4a31c68b --- /dev/null +++ b/packages/effects/src/effects/brightness/index.ts @@ -0,0 +1,32 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 100, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/chromatic-aberration/chromatic-aberration.frag.glsl b/packages/effects/src/effects/chromatic-aberration/chromatic-aberration.frag.glsl new file mode 100644 index 00000000..e9305155 --- /dev/null +++ b/packages/effects/src/effects/chromatic-aberration/chromatic-aberration.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/chromatic-aberration/index.ts b/packages/effects/src/effects/chromatic-aberration/index.ts new file mode 100644 index 00000000..17a5e49e --- /dev/null +++ b/packages/effects/src/effects/chromatic-aberration/index.ts @@ -0,0 +1,26 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 10, + u_angle: ((effectParams.angle as number) * Math.PI) / 180, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/color-balance/color-balance.frag.glsl b/packages/effects/src/effects/color-balance/color-balance.frag.glsl new file mode 100644 index 00000000..5c0699c9 --- /dev/null +++ b/packages/effects/src/effects/color-balance/color-balance.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/color-balance/index.ts b/packages/effects/src/effects/color-balance/index.ts new file mode 100644 index 00000000..644965f3 --- /dev/null +++ b/packages/effects/src/effects/color-balance/index.ts @@ -0,0 +1,47 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: "shadowsR", label: "Shadows Red", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "shadowsG", label: "Shadows Green", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "shadowsB", label: "Shadows Blue", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "midsR", label: "Midtones Red", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "midsG", label: "Midtones Green", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "midsB", label: "Midtones Blue", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "highlightsR", label: "Highlights Red", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "highlightsG", label: "Highlights Green", type: "number", default: 0, min: -100, max: 100, step: 1 }, + { key: "highlightsB", label: "Highlights Blue", type: "number", default: 0, min: -100, max: 100, step: 1 }, + ], + renderer: { + type: "webgl", + passes: [ + { + fragmentShader, + uniforms: ({ effectParams }) => ({ + u_shadows: [ + (effectParams.shadowsR as number) / 200, + (effectParams.shadowsG as number) / 200, + (effectParams.shadowsB as number) / 200, + ], + u_midtones: [ + (effectParams.midsR as number) / 200, + (effectParams.midsG as number) / 200, + (effectParams.midsB as number) / 200, + ], + u_highlights: [ + (effectParams.highlightsR as number) / 200, + (effectParams.highlightsG as number) / 200, + (effectParams.highlightsB as number) / 200, + ], + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/contrast/contrast.frag.glsl b/packages/effects/src/effects/contrast/contrast.frag.glsl new file mode 100644 index 00000000..ae18b805 --- /dev/null +++ b/packages/effects/src/effects/contrast/contrast.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/contrast/index.ts b/packages/effects/src/effects/contrast/index.ts new file mode 100644 index 00000000..d0deca2a --- /dev/null +++ b/packages/effects/src/effects/contrast/index.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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 + (effectParams.intensity as number) / 100, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/exposure/exposure.frag.glsl b/packages/effects/src/effects/exposure/exposure.frag.glsl new file mode 100644 index 00000000..bf9c0e84 --- /dev/null +++ b/packages/effects/src/effects/exposure/exposure.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/exposure/index.ts b/packages/effects/src/effects/exposure/index.ts new file mode 100644 index 00000000..c5dfc27b --- /dev/null +++ b/packages/effects/src/effects/exposure/index.ts @@ -0,0 +1,32 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: effectParams.stops as number, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/eye-enhance/eye-enhance.frag.glsl b/packages/effects/src/effects/eye-enhance/eye-enhance.frag.glsl new file mode 100644 index 00000000..848e1757 --- /dev/null +++ b/packages/effects/src/effects/eye-enhance/eye-enhance.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/eye-enhance/index.ts b/packages/effects/src/effects/eye-enhance/index.ts new file mode 100644 index 00000000..cd4b89c0 --- /dev/null +++ b/packages/effects/src/effects/eye-enhance/index.ts @@ -0,0 +1,27 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.brightness as number) / 400, + u_contrast: 1 + (effectParams.contrast as number) / 200, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/face-brighten/face-brighten.frag.glsl b/packages/effects/src/effects/face-brighten/face-brighten.frag.glsl new file mode 100644 index 00000000..6c79d91e --- /dev/null +++ b/packages/effects/src/effects/face-brighten/face-brighten.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/face-brighten/index.ts b/packages/effects/src/effects/face-brighten/index.ts new file mode 100644 index 00000000..b773508a --- /dev/null +++ b/packages/effects/src/effects/face-brighten/index.ts @@ -0,0 +1,25 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 200, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/film-grain/film-grain.frag.glsl b/packages/effects/src/effects/film-grain/film-grain.frag.glsl new file mode 100644 index 00000000..0801052e --- /dev/null +++ b/packages/effects/src/effects/film-grain/film-grain.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/film-grain/index.ts b/packages/effects/src/effects/film-grain/index.ts new file mode 100644 index 00000000..f1675fbe --- /dev/null +++ b/packages/effects/src/effects/film-grain/index.ts @@ -0,0 +1,28 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 400, + u_grainSize: effectParams.size as number, + u_time: effectParams.animated ? (time ?? 0) : 0, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/gamma/gamma.frag.glsl b/packages/effects/src/effects/gamma/gamma.frag.glsl new file mode 100644 index 00000000..cacfb692 --- /dev/null +++ b/packages/effects/src/effects/gamma/gamma.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/gamma/index.ts b/packages/effects/src/effects/gamma/index.ts new file mode 100644 index 00000000..80c6db85 --- /dev/null +++ b/packages/effects/src/effects/gamma/index.ts @@ -0,0 +1,32 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: effectParams.value as number, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/glitch/glitch-pass1.frag.glsl b/packages/effects/src/effects/glitch/glitch-pass1.frag.glsl new file mode 100644 index 00000000..6e14757d --- /dev/null +++ b/packages/effects/src/effects/glitch/glitch-pass1.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/glitch/glitch-pass2.frag.glsl b/packages/effects/src/effects/glitch/glitch-pass2.frag.glsl new file mode 100644 index 00000000..1f28db9d --- /dev/null +++ b/packages/effects/src/effects/glitch/glitch-pass2.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/glitch/index.ts b/packages/effects/src/effects/glitch/index.ts new file mode 100644 index 00000000..bdea6486 --- /dev/null +++ b/packages/effects/src/effects/glitch/index.ts @@ -0,0 +1,37 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 1000, + u_colorSplit: (effectParams.colorSplit as number) / (width * 10), + u_time: (time ?? 0) * ((effectParams.speed as number) / 50), + }), + }, + { + fragmentShader: pass2Shader, + uniforms: ({ effectParams, time }) => ({ + u_intensity: (effectParams.intensity as number) / 100, + u_time: (time ?? 0) * ((effectParams.speed as number) / 50), + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/hue-shift/hue-shift.frag.glsl b/packages/effects/src/effects/hue-shift/hue-shift.frag.glsl new file mode 100644 index 00000000..1efa9a3a --- /dev/null +++ b/packages/effects/src/effects/hue-shift/hue-shift.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/hue-shift/index.ts b/packages/effects/src/effects/hue-shift/index.ts new file mode 100644 index 00000000..e2f95603 --- /dev/null +++ b/packages/effects/src/effects/hue-shift/index.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.degrees as number) / 360, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/neon-glow/index.ts b/packages/effects/src/effects/neon-glow/index.ts new file mode 100644 index 00000000..e9113ad5 --- /dev/null +++ b/packages/effects/src/effects/neon-glow/index.ts @@ -0,0 +1,36 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +import { hexToRgb } from "../../utils/color"; +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: (effectParams.threshold as number) / 100, + }), + }, + { + fragmentShader: pass2Shader, + uniforms: ({ effectParams }) => ({ + u_intensity: (effectParams.intensity as number) / 50, + u_glowColor: hexToRgb(effectParams.color as string), + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/neon-glow/neon-glow-pass1.frag.glsl b/packages/effects/src/effects/neon-glow/neon-glow-pass1.frag.glsl new file mode 100644 index 00000000..7baff6b9 --- /dev/null +++ b/packages/effects/src/effects/neon-glow/neon-glow-pass1.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/neon-glow/neon-glow-pass2.frag.glsl b/packages/effects/src/effects/neon-glow/neon-glow-pass2.frag.glsl new file mode 100644 index 00000000..680f5655 --- /dev/null +++ b/packages/effects/src/effects/neon-glow/neon-glow-pass2.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/oil-paint/index.ts b/packages/effects/src/effects/oil-paint/index.ts new file mode 100644 index 00000000..c8f06914 --- /dev/null +++ b/packages/effects/src/effects/oil-paint/index.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: effectParams.levels as number, + }), + }, + { + fragmentShader: pass2Shader, + uniforms: ({ effectParams }) => ({ + u_radius: effectParams.radius as number, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/oil-paint/oil-paint-pass1.frag.glsl b/packages/effects/src/effects/oil-paint/oil-paint-pass1.frag.glsl new file mode 100644 index 00000000..32073d81 --- /dev/null +++ b/packages/effects/src/effects/oil-paint/oil-paint-pass1.frag.glsl @@ -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; +} diff --git a/packages/effects/src/effects/oil-paint/oil-paint-pass2.frag.glsl b/packages/effects/src/effects/oil-paint/oil-paint-pass2.frag.glsl new file mode 100644 index 00000000..97d612a9 --- /dev/null +++ b/packages/effects/src/effects/oil-paint/oil-paint-pass2.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/pixelate/index.ts b/packages/effects/src/effects/pixelate/index.ts new file mode 100644 index 00000000..557dbf8d --- /dev/null +++ b/packages/effects/src/effects/pixelate/index.ts @@ -0,0 +1,24 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: effectParams.blockSize as number, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/pixelate/pixelate.frag.glsl b/packages/effects/src/effects/pixelate/pixelate.frag.glsl new file mode 100644 index 00000000..7965a831 --- /dev/null +++ b/packages/effects/src/effects/pixelate/pixelate.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/saturation/index.ts b/packages/effects/src/effects/saturation/index.ts new file mode 100644 index 00000000..6dd9e656 --- /dev/null +++ b/packages/effects/src/effects/saturation/index.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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 + (effectParams.intensity as number) / 100, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/saturation/saturation.frag.glsl b/packages/effects/src/effects/saturation/saturation.frag.glsl new file mode 100644 index 00000000..9ffe33c7 --- /dev/null +++ b/packages/effects/src/effects/saturation/saturation.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/sharpen/index.ts b/packages/effects/src/effects/sharpen/index.ts new file mode 100644 index 00000000..394c5cc7 --- /dev/null +++ b/packages/effects/src/effects/sharpen/index.ts @@ -0,0 +1,24 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 100, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/sharpen/sharpen.frag.glsl b/packages/effects/src/effects/sharpen/sharpen.frag.glsl new file mode 100644 index 00000000..f2b9adb8 --- /dev/null +++ b/packages/effects/src/effects/sharpen/sharpen.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/sketch/index.ts b/packages/effects/src/effects/sketch/index.ts new file mode 100644 index 00000000..575df940 --- /dev/null +++ b/packages/effects/src/effects/sketch/index.ts @@ -0,0 +1,33 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: effectParams.lineWidth as number, + }), + }, + { + fragmentShader: pass2Shader, + uniforms: ({ effectParams }) => ({ + u_threshold: (effectParams.threshold as number) / 100, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/sketch/sketch-pass1.frag.glsl b/packages/effects/src/effects/sketch/sketch-pass1.frag.glsl new file mode 100644 index 00000000..bfda3e50 --- /dev/null +++ b/packages/effects/src/effects/sketch/sketch-pass1.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/sketch/sketch-pass2.frag.glsl b/packages/effects/src/effects/sketch/sketch-pass2.frag.glsl new file mode 100644 index 00000000..727b49f9 --- /dev/null +++ b/packages/effects/src/effects/sketch/sketch-pass2.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/skin-smooth/index.ts b/packages/effects/src/effects/skin-smooth/index.ts new file mode 100644 index 00000000..9ad2410b --- /dev/null +++ b/packages/effects/src/effects/skin-smooth/index.ts @@ -0,0 +1,32 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: ((effectParams.intensity as number) / 100) * 3, + }), + }, + { + fragmentShader: pass2Shader, + uniforms: ({ context }) => ({ + u_faceMaskEnabled: context?.faceDetected ? 1 : 0, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/skin-smooth/skin-smooth-pass1.frag.glsl b/packages/effects/src/effects/skin-smooth/skin-smooth-pass1.frag.glsl new file mode 100644 index 00000000..7de16fbe --- /dev/null +++ b/packages/effects/src/effects/skin-smooth/skin-smooth-pass1.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/skin-smooth/skin-smooth-pass2.frag.glsl b/packages/effects/src/effects/skin-smooth/skin-smooth-pass2.frag.glsl new file mode 100644 index 00000000..0f8ec5ff --- /dev/null +++ b/packages/effects/src/effects/skin-smooth/skin-smooth-pass2.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/slim-face/index.ts b/packages/effects/src/effects/slim-face/index.ts new file mode 100644 index 00000000..71f25431 --- /dev/null +++ b/packages/effects/src/effects/slim-face/index.ts @@ -0,0 +1,27 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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 }) => ({ + u_intensity: (effectParams.intensity as number) / 100, + // Default jaw center if no face detection + u_jawCenter: context?.jawPoints?.slice(0, 2) ?? [0.5, 0.6], + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/slim-face/slim-face.frag.glsl b/packages/effects/src/effects/slim-face/slim-face.frag.glsl new file mode 100644 index 00000000..f4725f1b --- /dev/null +++ b/packages/effects/src/effects/slim-face/slim-face.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/teeth-whiten/index.ts b/packages/effects/src/effects/teeth-whiten/index.ts new file mode 100644 index 00000000..c4a0d6bf --- /dev/null +++ b/packages/effects/src/effects/teeth-whiten/index.ts @@ -0,0 +1,25 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 200, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/teeth-whiten/teeth-whiten.frag.glsl b/packages/effects/src/effects/teeth-whiten/teeth-whiten.frag.glsl new file mode 100644 index 00000000..0e7c8be5 --- /dev/null +++ b/packages/effects/src/effects/teeth-whiten/teeth-whiten.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/temperature/index.ts b/packages/effects/src/effects/temperature/index.ts new file mode 100644 index 00000000..e1aefa04 --- /dev/null +++ b/packages/effects/src/effects/temperature/index.ts @@ -0,0 +1,32 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.warmth as number) / 400, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/temperature/temperature.frag.glsl b/packages/effects/src/effects/temperature/temperature.frag.glsl new file mode 100644 index 00000000..b56cfad6 --- /dev/null +++ b/packages/effects/src/effects/temperature/temperature.frag.glsl @@ -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); +} diff --git a/packages/effects/src/effects/vignette/index.ts b/packages/effects/src/effects/vignette/index.ts new file mode 100644 index 00000000..e16438e6 --- /dev/null +++ b/packages/effects/src/effects/vignette/index.ts @@ -0,0 +1,28 @@ +import type { EffectDefinition } from "../../types"; +import { EffectCategory } from "../../categories"; +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: (effectParams.intensity as number) / 100, + u_radius: (effectParams.radius as number) / 100 * 0.707, + u_softness: (effectParams.softness as number) / 100 * 0.5, + }), + }, + ], + }, +}; diff --git a/packages/effects/src/effects/vignette/vignette.frag.glsl b/packages/effects/src/effects/vignette/vignette.frag.glsl new file mode 100644 index 00000000..4fa01bdc --- /dev/null +++ b/packages/effects/src/effects/vignette/vignette.frag.glsl @@ -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; +} diff --git a/packages/effects/src/glsl.d.ts b/packages/effects/src/glsl.d.ts new file mode 100644 index 00000000..0297ae2e --- /dev/null +++ b/packages/effects/src/glsl.d.ts @@ -0,0 +1,5 @@ +/** Declare .glsl files as importable string modules */ +declare module "*.glsl" { + const content: string; + export default content; +} diff --git a/packages/effects/src/index.ts b/packages/effects/src/index.ts new file mode 100644 index 00000000..95c7c455 --- /dev/null +++ b/packages/effects/src/index.ts @@ -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]; +} diff --git a/packages/effects/src/presets/beauty-presets.ts b/packages/effects/src/presets/beauty-presets.ts new file mode 100644 index 00000000..3b59f1dd --- /dev/null +++ b/packages/effects/src/presets/beauty-presets.ts @@ -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 } }, + ], + }, +]; diff --git a/packages/effects/src/presets/cinematic-presets.ts b/packages/effects/src/presets/cinematic-presets.ts new file mode 100644 index 00000000..9a345f0e --- /dev/null +++ b/packages/effects/src/presets/cinematic-presets.ts @@ -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: { + shadowsR: -30, shadowsG: 10, shadowsB: 30, + midsR: 0, midsG: 0, midsB: 0, + highlightsR: 30, highlightsG: 10, highlightsB: -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 } }, + ], + }, +]; diff --git a/packages/effects/src/presets/index.ts b/packages/effects/src/presets/index.ts new file mode 100644 index 00000000..056e47e6 --- /dev/null +++ b/packages/effects/src/presets/index.ts @@ -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]; +} diff --git a/packages/effects/src/presets/instagram-filters.ts b/packages/effects/src/presets/instagram-filters.ts new file mode 100644 index 00000000..bdba4491 --- /dev/null +++ b/packages/effects/src/presets/instagram-filters.ts @@ -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 } }, + ], + }, +]; diff --git a/packages/effects/src/presets/registry.ts b/packages/effects/src/presets/registry.ts new file mode 100644 index 00000000..8f0167ac --- /dev/null +++ b/packages/effects/src/presets/registry.ts @@ -0,0 +1,29 @@ +import type { EffectPreset, PresetCategory } from "./types"; + +const presetMap = new Map(); + +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(); +} diff --git a/packages/effects/src/presets/types.ts b/packages/effects/src/presets/types.ts new file mode 100644 index 00000000..8c86f402 --- /dev/null +++ b/packages/effects/src/presets/types.ts @@ -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; +} + +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[]; +} diff --git a/packages/effects/src/registry.ts b/packages/effects/src/registry.ts new file mode 100644 index 00000000..1130b1aa --- /dev/null +++ b/packages/effects/src/registry.ts @@ -0,0 +1,37 @@ +import type { EffectDefinition } from "./types"; + +/** In-memory map of registered effect definitions keyed by type */ +const effectDefinitions = new Map(); + +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(); +} diff --git a/packages/effects/src/shaders/common.glsl b/packages/effects/src/shaders/common.glsl new file mode 100644 index 00000000..5c754dd2 --- /dev/null +++ b/packages/effects/src/shaders/common.glsl @@ -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) +// ); +// } diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/packages/effects/src/shaders/effect.vert.glsl similarity index 100% rename from apps/web/src/lib/effects/effect.vert.glsl rename to packages/effects/src/shaders/effect.vert.glsl diff --git a/packages/effects/src/types.ts b/packages/effects/src/types.ts new file mode 100644 index 00000000..8599bce3 --- /dev/null +++ b/packages/effects/src/types.ts @@ -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; + +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; +} + +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; +} diff --git a/packages/effects/src/utils/color.ts b/packages/effects/src/utils/color.ts new file mode 100644 index 00000000..cebce6d6 --- /dev/null +++ b/packages/effects/src/utils/color.ts @@ -0,0 +1,9 @@ +/** Convert hex color string (#RRGGBB) to normalized RGB array [0..1, 0..1, 0..1] */ +export function hexToRgb(hex: string): number[] { + const h = hex.replace("#", ""); + return [ + Number.parseInt(h.substring(0, 2), 16) / 255, + Number.parseInt(h.substring(2, 4), 16) / 255, + Number.parseInt(h.substring(4, 6), 16) / 255, + ]; +} diff --git a/packages/effects/tests/artistic-effects.test.ts b/packages/effects/tests/artistic-effects.test.ts new file mode 100644 index 00000000..61c0da91 --- /dev/null +++ b/packages/effects/tests/artistic-effects.test.ts @@ -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 = {}; + 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 = { 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(10); + } + }); +}); diff --git a/packages/effects/tests/beauty-effects.test.ts b/packages/effects/tests/beauty-effects.test.ts new file mode 100644 index 00000000..06eaf73f --- /dev/null +++ b/packages/effects/tests/beauty-effects.test.ts @@ -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 = {}; + 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 = {}; + 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]); + }); +}); diff --git a/packages/effects/tests/color-tone-effects.test.ts b/packages/effects/tests/color-tone-effects.test.ts new file mode 100644 index 00000000..31ed9064 --- /dev/null +++ b/packages/effects/tests/color-tone-effects.test.ts @@ -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 = {}; + 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 = {}; + 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); + }); +}); diff --git a/packages/effects/tests/presets.test.ts b/packages/effects/tests/presets.test.ts new file mode 100644 index 00000000..b87ae86a --- /dev/null +++ b/packages/effects/tests/presets.test.ts @@ -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); + }); +}); diff --git a/packages/effects/tests/registry.test.ts b/packages/effects/tests/registry.test.ts new file mode 100644 index 00000000..369ae176 --- /dev/null +++ b/packages/effects/tests/registry.test.ts @@ -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); + }); +}); diff --git a/packages/effects/tsconfig.json b/packages/effects/tsconfig.json new file mode 100644 index 00000000..9e2050d0 --- /dev/null +++ b/packages/effects/tsconfig.json @@ -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"] +} diff --git a/packages/effects/tsup.config.ts b/packages/effects/tsup.config.ts new file mode 100644 index 00000000..e05a0f8d --- /dev/null +++ b/packages/effects/tsup.config.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", + }, +});