Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@mediapipe/face_mesh": "0.4.1657299874",
"@opencut/effects": "workspace:*",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-accordion": "^1.2.12",
Expand Down
50 changes: 0 additions & 50 deletions apps/web/src/lib/effects/definitions/blur.ts

This file was deleted.

15 changes: 2 additions & 13 deletions apps/web/src/lib/effects/definitions/index.ts
Original file line number Diff line number Diff line change
@@ -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";
11 changes: 8 additions & 3 deletions apps/web/src/lib/effects/index.ts
Original file line number Diff line number Diff line change
@@ -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"][] = [
Expand Down
39 changes: 8 additions & 31 deletions apps/web/src/lib/effects/registry.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,8 @@
import type { EffectDefinition } from "@/types/effects";

const effectDefinitions = new Map<string, EffectDefinition>();

export function registerEffect({
definition,
}: {
definition: EffectDefinition;
}): void {
effectDefinitions.set(definition.type, definition);
}

export function hasEffect({ effectType }: { effectType: string }): boolean {
return effectDefinitions.has(effectType);
}

export function getEffect({
effectType,
}: {
effectType: string;
}): EffectDefinition {
const definition = effectDefinitions.get(effectType);
if (!definition) {
throw new Error(`Unknown effect type: ${effectType}`);
}
return definition;
}

export function getAllEffects(): EffectDefinition[] {
return Array.from(effectDefinitions.values());
}
/** Re-export registry functions from @opencut/effects package */
export {
registerEffect,
hasEffect,
getEffect,
getAllEffects,
clearEffects,
} from "@opencut/effects";
148 changes: 148 additions & 0 deletions apps/web/src/services/face-mesh/face-mesh-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { EffectContext } from "@opencut/effects";

/**
* Face mesh detection provider using MediaPipe Face Mesh.
* Lazy-loads the WASM module only when first needed.
* Runs detection per frame and caches results.
*/

import type { FaceMesh as FaceMeshType, Results } from "@mediapipe/face_mesh";

let faceMeshInstance: FaceMeshType | null = null;
let isLoading = false;
/** Shared in-flight promise for pending detections — avoids race conditions */
let pendingDetection: Promise<Results> | null = null;
let pendingResolve: ((results: Results) => void) | null = null;
let pendingReject: ((error: Error) => void) | null = null;

/** Lazy-load MediaPipe Face Mesh WASM module */
async function loadFaceMesh(): Promise<FaceMeshType | null> {
if (faceMeshInstance) return faceMeshInstance;
if (isLoading) return null;

isLoading = true;
try {
const { FaceMesh } = await import("@mediapipe/face_mesh");
const fm = new FaceMesh({
locateFile: (file: string) =>
`https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fm.setOptions({
maxNumFaces: 1,
refineLandmarks: true,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
fm.onResults((results: Results) => {
if (pendingResolve) {
pendingResolve(results);
pendingResolve = null;
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
faceMeshInstance = fm;
return fm;
} catch (err) {
console.warn("[face-mesh] Failed to load MediaPipe:", err);
return null;
Comment thread
doananh234 marked this conversation as resolved.
} finally {
isLoading = false;
}
}

/** MediaPipe face landmark indices for key regions */
const LANDMARK_INDICES = {
leftCheek: 234,
rightCheek: 454,
jawBottom: 152,
jawLeft: 132,
jawRight: 361,
leftEyeCenter: 159,
rightEyeCenter: 386,
mouthCenter: 13,
};

/** Convert MediaPipe face landmarks to EffectContext */
function landmarksToContext(
landmarks: Array<{ x: number; y: number; z: number }>,
): EffectContext {
const lc = landmarks[LANDMARK_INDICES.leftCheek];
const rc = landmarks[LANDMARK_INDICES.rightCheek];
const jaw = landmarks[LANDMARK_INDICES.jawBottom];
const jawL = landmarks[LANDMARK_INDICES.jawLeft];
const jawR = landmarks[LANDMARK_INDICES.jawRight];

// Estimate cheek radius from face width
const faceWidth = Math.abs(rc.x - lc.x);
const cheekRadius = faceWidth * 0.15;

return {
faceDetected: true,
cheekLeft: [lc.x, lc.y],
cheekRight: [rc.x, rc.y],
cheekRadius,
jawPoints: [jaw.x, jaw.y, jawL.x, jawL.y, jawR.x, jawR.y],
};
}

/** Detect face in the given image source and return EffectContext */
export async function detectFace(
source: CanvasImageSource,
): Promise<EffectContext> {
const fm = await loadFaceMesh();
if (!fm) {
return { faceDetected: false };
}

// Reuse existing in-flight detection if one exists
if (pendingDetection) {
const results = await pendingDetection;
if (
!results?.multiFaceLandmarks ||
results.multiFaceLandmarks.length === 0
) {
return { faceDetected: false };
}
return landmarksToContext(results.multiFaceLandmarks[0]);
}

// Create new detection promise
pendingDetection = new Promise<Results>((resolve, reject) => {
pendingResolve = resolve;
pendingReject = reject;
fm.send({ image: source as HTMLCanvasElement });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

const results = await pendingDetection;
pendingDetection = null;
pendingResolve = null;
pendingReject = null;

if (
!results?.multiFaceLandmarks ||
results.multiFaceLandmarks.length === 0
) {
return { faceDetected: false };
}

return landmarksToContext(results.multiFaceLandmarks[0]);
}

/** Check if MediaPipe is loaded (for conditional rendering) */
export function isFaceMeshReady(): boolean {
return faceMeshInstance !== null;
}

/** Clean up MediaPipe resources */
export function disposeFaceMesh(): void {
// Settle any pending detection before disposing
if (pendingReject) {
pendingReject(new Error("Face mesh disposed"));
pendingReject = null;
pendingResolve = null;
pendingDetection = null;
}
if (faceMeshInstance) {
faceMeshInstance.close();
faceMeshInstance = null;
}
}
1 change: 1 addition & 0 deletions apps/web/src/services/face-mesh/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { detectFace, isFaceMeshReady, disposeFaceMesh } from "./face-mesh-provider";
1 change: 1 addition & 0 deletions apps/web/src/services/renderer/nodes/effect-layer-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
effectParams: this.params.effectParams,
width: renderer.width,
height: renderer.height,
time,
}),
}));
const effectResult = webglEffectRenderer.applyEffect({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/services/renderer/nodes/image-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {

const { source, width, height } = await this.cachedSource;

this.renderVisual({
await this.renderVisual({
renderer,
source,
sourceWidth: width || renderer.width,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/services/renderer/nodes/sticker-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {

const { source, width, height } = await this.cachedSource;

this.renderVisual({
await this.renderVisual({
renderer,
source,
sourceWidth: width,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/services/renderer/nodes/video-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
});

if (frame) {
this.renderVisual({
await this.renderVisual({
renderer,
source: frame.canvas,
sourceWidth: frame.canvas.width,
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/services/renderer/nodes/visual-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,7 +52,7 @@ export abstract class VisualNode<
);
}

protected renderVisual({
protected async renderVisual({
renderer,
source,
sourceWidth,
Expand All @@ -62,7 +64,7 @@ export abstract class VisualNode<
sourceWidth: number;
sourceHeight: number;
timelineTime: number;
}): void {
}): Promise<void> {
renderer.context.save();

const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime });
Expand Down Expand Up @@ -127,19 +129,35 @@ 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,
animations: this.params.animations,
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({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/services/renderer/webgl-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading