feat: layer effects, bucket fill, simple brush

This commit is contained in:
2026-05-21 00:02:12 +07:00
parent bcf4650c70
commit e7dd9420e7
47 changed files with 1675 additions and 1038 deletions
@@ -0,0 +1,23 @@
import type { FaceBlurEffect } from "@pien-studio/types";
import type { EffectDefinition } from "./registry";
function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
return {
...effect,
amount: Math.max(4, Math.min(40, Math.round(effect.amount))),
regions: effect.regions.map((region) => ({
x: Number.isFinite(region.x) ? region.x : 0,
y: Number.isFinite(region.y) ? region.y : 0,
width: Math.max(1, Number.isFinite(region.width) ? region.width : 1),
height: Math.max(1, Number.isFinite(region.height) ? region.height : 1),
sourceWidth: Number.isFinite(region.sourceWidth ?? NaN) ? region.sourceWidth : undefined,
sourceHeight: Number.isFinite(region.sourceHeight ?? NaN) ? region.sourceHeight : undefined,
censorColor: region.censorColor,
})),
};
}
export const faceBlurDefinition: EffectDefinition<FaceBlurEffect> = {
kind: "face-blur",
normalize: normalizeFaceBlur,
};
@@ -0,0 +1,23 @@
import type { LayerEffect } from "@pien-studio/types";
import { faceBlurDefinition } from "./face-blur";
export type EffectDefinition<T extends LayerEffect = LayerEffect> = {
kind: T["kind"];
normalize: (effect: T) => T;
};
const definitions = [faceBlurDefinition] as EffectDefinition[];
const effectRegistry = new Map<string, EffectDefinition>(
definitions.map((def) => [def.kind, def]),
);
export function getEffectDefinition(kind: string): EffectDefinition | undefined {
return effectRegistry.get(kind);
}
export function normalizeEffect(effect: LayerEffect): LayerEffect {
const def = effectRegistry.get(effect.kind);
if (!def) return effect;
return def.normalize(effect as never);
}
+11 -1
View File
@@ -19,12 +19,14 @@ describe("editor-core", () => {
const project = createProject("new");
const updated = addLayer(project, {
id: "l1",
type: "image",
type: "raster",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
expect(updated.layers).toHaveLength(1);
@@ -41,6 +43,8 @@ describe("editor-core", () => {
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
@@ -57,6 +61,8 @@ describe("editor-core", () => {
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
@@ -74,6 +80,8 @@ describe("editor-core", () => {
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const withSecond = addLayer(withFirst, {
id: "l2",
@@ -83,6 +91,8 @@ describe("editor-core", () => {
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const reordered = reorderLayer(withSecond, "l1", 1);
+24 -200
View File
@@ -1,212 +1,36 @@
import {
PRESET_CANVAS_SIZES,
ProjectFileSchema,
type AspectRatio,
type FaceBlurSettings,
type Layer,
type Project,
type ProjectFile,
} from "@pien-studio/types";
export { createProject, setCanvasSize } from "./project";
export { createLayer, addLayer, removeLayer, moveLayer, updateLayerTransform, reorderLayer, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled } from "./layers";
export type { LayerFactoryOptions, TransformPatch } from "./layers";
export { normalizeProject } from "./normalize";
export { serializeProjectFile, parseProjectFile } from "./serialization";
export { getEffectDefinition, normalizeEffect } from "./effects/registry";
export type { EffectDefinition } from "./effects/registry";
export { faceBlurDefinition } from "./effects/face-blur";
export { getToolDefinition, getAllTools } from "./tools/registry";
export type { ToolDefinition, ToolInteractionMode, EditorToolId } from "./tools/registry";
const DEFAULT_ASPECT: AspectRatio = "4:5";
function defaultCanvas(aspect: AspectRatio) {
const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1];
return { width: preset.width, height: preset.height, unit: "px" as const };
}
export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
title,
createdAt: now,
updatedAt: now,
canvas: defaultCanvas(aspect),
aspectRatio: aspect,
layers: [],
};
}
function withUpdatedTimestamp(project: Project, layers: Layer[]): Project {
return { ...project, layers, updatedAt: new Date().toISOString() };
}
type Delta = { dx: number; dy: number };
export type TransformPatch = {
x?: number;
y?: number;
width?: number;
height?: number;
scale?: number;
rotation?: number;
opacity?: number;
sourceUri?: string;
faceBlur?: FaceBlurSettings;
};
export type LayerFactoryOptions = {
id?: string;
name?: string;
sourceUri?: string;
x?: number;
y?: number;
width?: number;
height?: number;
};
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { addLayer, moveLayer, removeLayer, reorderLayer, setLayerEffect, updateLayerTransform } from "./layers";
import { setCanvasSize } from "./project";
export type EditorOperation =
| { type: "addLayer"; layer: Layer }
| { type: "removeLayer"; layerId: string }
| { type: "moveLayer"; layerId: string; delta: Delta }
| { type: "updateLayerTransform"; layerId: string; patch: TransformPatch }
| { type: "moveLayer"; layerId: string; delta: { dx: number; dy: number } }
| { type: "updateLayerTransform"; layerId: string; patch: import("./layers").TransformPatch }
| { type: "reorderLayer"; layerId: string; toIndex: number }
| { type: "setLayerEffect"; layerId: string; effect: LayerEffect }
| { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" };
export function normalizeProject(project: Project): Project {
const normalizedLayers = project.layers.map((layer) => ({
...layer,
width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined,
height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined,
scale: Number.isFinite(layer.scale) ? layer.scale : 1,
rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0,
opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1,
faceBlur:
layer.faceBlur && Array.isArray(layer.faceBlur.regions)
? {
method: layer.faceBlur.method,
amount: Math.max(4, Math.min(40, Math.round(layer.faceBlur.amount))),
regions: layer.faceBlur.regions.map((region) => ({
x: Number.isFinite(region.x) ? region.x : 0,
y: Number.isFinite(region.y) ? region.y : 0,
width: Math.max(1, Number.isFinite(region.width) ? region.width : 1),
height: Math.max(1, Number.isFinite(region.height) ? region.height : 1),
sourceWidth: Number.isFinite(region.sourceWidth) ? region.sourceWidth : undefined,
sourceHeight: Number.isFinite(region.sourceHeight) ? region.sourceHeight : undefined,
censorColor: region.censorColor,
})),
censorColor: layer.faceBlur.censorColor,
}
: undefined,
}));
return {
...project,
canvas: {
width: Math.max(1, Math.round(project.canvas.width)),
height: Math.max(1, Math.round(project.canvas.height)),
unit: project.canvas.unit,
},
layers: normalizedLayers,
};
}
export function addLayer(project: Project, layer: Layer): Project {
return withUpdatedTimestamp(project, [...project.layers, layer]);
}
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
return {
id: options.id ?? crypto.randomUUID(),
type,
name: options.name,
sourceUri: options.sourceUri,
x: options.x ?? 110,
y: options.y ?? 90,
width: options.width,
height: options.height,
scale: 1,
rotation: 0,
opacity: 1,
};
}
export function removeLayer(project: Project, layerId: string): Project {
const layers = project.layers.filter((layer) => layer.id !== layerId);
return withUpdatedTimestamp(project, layers);
}
export function moveLayer(project: Project, layerId: string, delta: Delta): Project {
const layers = project.layers.map((layer) => {
if (layer.id !== layerId) return layer;
return { ...layer, x: layer.x + delta.dx, y: layer.y + delta.dy };
});
return withUpdatedTimestamp(project, layers);
}
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
const layers = project.layers.map((layer) => (layer.id === layerId ? { ...layer, ...patch } : layer));
return withUpdatedTimestamp(project, layers);
}
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
const fromIndex = project.layers.findIndex((l) => l.id === layerId);
if (fromIndex < 0) return project;
const layers = [...project.layers];
const [picked] = layers.splice(fromIndex, 1);
if (!picked) return project;
const bounded = Math.max(0, Math.min(toIndex, layers.length));
layers.splice(bounded, 0, picked);
return withUpdatedTimestamp(project, layers);
}
export function setCanvasSize(project: Project, width: number, height: number, unit: "px" | "in" | "cm" = "px"): Project {
return {
...project,
canvas: {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
unit,
},
updatedAt: new Date().toISOString(),
};
}
export function applyOperation(project: Project, operation: EditorOperation): Project {
switch (operation.type) {
case "addLayer":
return addLayer(project, operation.layer);
case "removeLayer":
return removeLayer(project, operation.layerId);
case "moveLayer":
return moveLayer(project, operation.layerId, operation.delta);
case "updateLayerTransform":
return updateLayerTransform(project, operation.layerId, operation.patch);
case "reorderLayer":
return reorderLayer(project, operation.layerId, operation.toIndex);
case "setCanvasSize":
return setCanvasSize(project, operation.width, operation.height, operation.unit);
default:
return project;
}
}
export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string {
const document: ProjectFile = {
format: "pien.project",
version: 1,
exportedAt: new Date().toISOString(),
app: { name: "pien.studio", platform: "web" },
project,
assets: [],
history: {
checkpointCount: options?.checkpointCount ?? 0,
},
};
return JSON.stringify(document, null, 2);
}
export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } {
try {
const data = JSON.parse(raw) as unknown;
const parsedEnvelope = ProjectFileSchema.safeParse(data);
if (parsedEnvelope.success) {
return { ok: true, project: normalizeProject(parsedEnvelope.data.project) };
}
return { ok: false, error: "Invalid project format" };
} catch {
return { ok: false, error: "Invalid JSON" };
case "addLayer": return addLayer(project, operation.layer);
case "removeLayer": return removeLayer(project, operation.layerId);
case "moveLayer": return moveLayer(project, operation.layerId, operation.delta);
case "updateLayerTransform": return updateLayerTransform(project, operation.layerId, operation.patch);
case "reorderLayer": return reorderLayer(project, operation.layerId, operation.toIndex);
case "setLayerEffect": return setLayerEffect(project, operation.layerId, operation.effect);
case "setCanvasSize": return setCanvasSize(project, operation.width, operation.height, operation.unit);
default: return project;
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
export type LayerFactoryOptions = {
id?: string;
name?: string;
sourceUri?: string;
x?: number;
y?: number;
width?: number;
height?: number;
};
export type TransformPatch = {
x?: number;
y?: number;
width?: number;
height?: number;
scale?: number;
rotation?: number;
opacity?: number;
sourceUri?: string;
effects?: LayerEffect[];
};
function withUpdatedAt(project: Project, layers: Layer[]): Project {
return { ...project, layers, updatedAt: new Date().toISOString() };
}
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
return {
id: options.id ?? crypto.randomUUID(),
type,
name: options.name,
sourceUri: options.sourceUri,
effects: [],
visible: true,
x: options.x ?? 110,
y: options.y ?? 90,
width: options.width,
height: options.height,
scale: 1,
rotation: 0,
opacity: 1,
};
}
export function addLayer(project: Project, layer: Layer): Project {
return withUpdatedAt(project, [...project.layers, layer]);
}
export function removeLayer(project: Project, layerId: string): Project {
return withUpdatedAt(project, project.layers.filter((l) => l.id !== layerId));
}
export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, x: l.x + delta.dx, y: l.y + delta.dy } : l)),
);
}
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, ...patch } : l)),
);
}
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
const fromIndex = project.layers.findIndex((l) => l.id === layerId);
if (fromIndex < 0) return project;
const layers = [...project.layers];
const [picked] = layers.splice(fromIndex, 1);
if (!picked) return project;
layers.splice(Math.max(0, Math.min(toIndex, layers.length)), 0, picked);
return withUpdatedAt(project, layers);
}
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
return withUpdatedAt(
project,
project.layers.map((l) => {
if (l.id !== layerId) return l;
const idx = l.effects.findIndex((e) => e.kind === effect.kind);
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? effect : e)) : [...l.effects, effect];
return { ...l, effects };
}),
);
}
export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, effects: l.effects.filter((e) => e.kind !== kind) } : l)),
);
}
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, visible } : l)),
);
}
export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project {
return withUpdatedAt(
project,
project.layers.map((l) =>
l.id === layerId
? { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) }
: l,
),
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Project } from "@pien-studio/types";
import { normalizeEffect } from "./effects/registry";
export function normalizeProject(project: Project): Project {
return {
...project,
canvas: {
width: Math.max(1, Math.round(project.canvas.width)),
height: Math.max(1, Math.round(project.canvas.height)),
unit: project.canvas.unit,
},
layers: project.layers.map((layer) => ({
...layer,
width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined,
height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined,
scale: Number.isFinite(layer.scale) ? layer.scale : 1,
rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0,
opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1,
effects: Array.isArray(layer.effects) ? layer.effects.map(normalizeEffect) : [],
})),
};
}
+38
View File
@@ -0,0 +1,38 @@
import { PRESET_CANVAS_SIZES, type AspectRatio, type Project } from "@pien-studio/types";
const DEFAULT_ASPECT: AspectRatio = "4:5";
function defaultCanvas(aspect: AspectRatio) {
const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1];
return { width: preset.width, height: preset.height, unit: "px" as const };
}
export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
title,
createdAt: now,
updatedAt: now,
canvas: defaultCanvas(aspect),
aspectRatio: aspect,
layers: [],
};
}
export function setCanvasSize(
project: Project,
width: number,
height: number,
unit: "px" | "in" | "cm" = "px",
): Project {
return {
...project,
canvas: {
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
unit,
},
updatedAt: new Date().toISOString(),
};
}
+26
View File
@@ -0,0 +1,26 @@
import { ProjectFileSchema, type Project, type ProjectFile } from "@pien-studio/types";
import { normalizeProject } from "./normalize";
export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string {
const document: ProjectFile = {
format: "pien.project",
version: 1,
exportedAt: new Date().toISOString(),
app: { name: "pien.studio", platform: "web" },
project,
assets: [],
history: { checkpointCount: options?.checkpointCount ?? 0 },
};
return JSON.stringify(document, null, 2);
}
export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } {
try {
const data = JSON.parse(raw) as unknown;
const result = ProjectFileSchema.safeParse(data);
if (result.success) return { ok: true, project: normalizeProject(result.data.project) };
return { ok: false, error: "Invalid project format" };
} catch {
return { ok: false, error: "Invalid JSON" };
}
}
@@ -0,0 +1,65 @@
export type ToolInteractionMode =
| "select" // can select and drag layers
| "pan" // pans the viewport, no layer interaction
| "paint" // pixel-level tool, fires onLayerClick with canvas coords
| "annotate"; // select-only, no drag (eg. face-blur region picking)
export type ToolDefinition = {
id: string;
interactionMode: ToolInteractionMode;
allowsLayerDrag: boolean;
allowsLayerResize: boolean;
allowsLayerRotate: boolean;
};
const definitions: ToolDefinition[] = [
{
id: "pointer",
interactionMode: "select",
allowsLayerDrag: true,
allowsLayerResize: true,
allowsLayerRotate: true,
},
{
id: "hand",
interactionMode: "pan",
allowsLayerDrag: false,
allowsLayerResize: false,
allowsLayerRotate: false,
},
{
id: "face",
interactionMode: "annotate",
allowsLayerDrag: false,
allowsLayerResize: false,
allowsLayerRotate: false,
},
{
id: "fill",
interactionMode: "paint",
allowsLayerDrag: false,
allowsLayerResize: false,
allowsLayerRotate: false,
},
{
id: "brush",
interactionMode: "paint",
allowsLayerDrag: false,
allowsLayerResize: false,
allowsLayerRotate: false,
},
];
const toolRegistry = new Map<string, ToolDefinition>(
definitions.map((def) => [def.id, def]),
);
export function getToolDefinition(id: string): ToolDefinition | undefined {
return toolRegistry.get(id);
}
export function getAllTools(): ToolDefinition[] {
return definitions;
}
export type EditorToolId = (typeof definitions)[number]["id"];
+23 -15
View File
@@ -72,7 +72,7 @@ function makeMalformedProject(id: string): Project {
layers: [
{
id: "layer-1",
type: "image",
type: "raster",
x: 10,
y: 10,
width: 10.4,
@@ -80,24 +80,25 @@ function makeMalformedProject(id: string): Project {
scale: 0,
rotation: 720.4,
opacity: 0.75,
effects: [],
},
],
};
}
function makeLegacyFaceBlurProject(id: string): Project {
function makeFaceBlurProject(id: string): Project {
const now = new Date().toISOString();
return {
id,
title: "legacy",
title: "faceblur",
createdAt: now,
updatedAt: now,
aspectRatio: "4:5",
canvas: { width: 1200, height: 1500, unit: "px" },
layers: [
{
id: "image-legacy",
type: "image",
id: "image-faceblur",
type: "raster",
x: 30,
y: 40,
width: 600,
@@ -105,11 +106,16 @@ function makeLegacyFaceBlurProject(id: string): Project {
scale: 1,
rotation: 0,
opacity: 1,
faceBlur: {
method: "gaussian",
amount: 14,
regions: [{ x: 120, y: 160, width: 180, height: 200 }],
},
visible: true,
effects: [
{
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 14,
regions: [{ x: 120, y: 160, width: 180, height: 200 }],
},
],
},
],
};
@@ -148,19 +154,21 @@ describe("storage read flows", () => {
expect(raw?.layers[0]?.height).toBe(11.6);
});
it("loads legacy projects without source dimensions through storage and normalize flow", async () => {
const source = makeLegacyFaceBlurProject("project-legacy-faceblur");
it("loads projects with face-blur effect through storage and normalize flow", async () => {
const source = makeFaceBlurProject("project-faceblur");
await seedProject(source);
const loaded = await getProjectById(source.id);
const region = loaded?.layers[0]?.faceBlur?.regions[0];
expect(region).toBeDefined();
const effect = loaded?.layers[0]?.effects.find((e) => e.kind === "face-blur");
expect(effect).toBeDefined();
const region = effect?.kind === "face-blur" ? effect.regions[0] : undefined;
expect(region?.x).toBe(120);
expect(region?.sourceWidth).toBeUndefined();
expect(region?.sourceHeight).toBeUndefined();
const listed = await loadProjects();
const listedRegion = listed[0]?.layers[0]?.faceBlur?.regions[0];
const listedEffect = listed.find((p) => p.id === source.id)?.layers[0]?.effects.find((e) => e.kind === "face-blur");
const listedRegion = listedEffect?.kind === "face-blur" ? listedEffect.regions[0] : undefined;
expect(listedRegion?.width).toBe(180);
expect(listedRegion?.sourceWidth).toBeUndefined();
expect(listedRegion?.sourceHeight).toBeUndefined();
+1 -1
View File
@@ -100,7 +100,7 @@ function makeLinkId(projectId: string, layerId: string): string {
}
function isBinaryLayer(layer: Layer): boolean {
return layer.type === "image" || layer.type === "sticker";
return layer.type === "raster" || layer.type === "sticker";
}
function inferMimeType(layer: Layer): string {
+12 -4
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
export const LayerTypeSchema = z.enum(["image", "text", "sticker"]);
export const LayerTypeSchema = z.enum(["raster", "text", "sticker"]);
export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
@@ -14,20 +14,25 @@ export const FaceBlurRegionSchema = z.object({
censorColor: z.string().optional(),
});
export const FaceBlurSettingsSchema = z.object({
export const FaceBlurEffectSchema = z.object({
kind: z.literal("face-blur"),
enabled: z.boolean().default(true),
method: FaceBlurMethodSchema,
amount: z.number().int().min(4).max(40),
regions: z.array(FaceBlurRegionSchema),
censorColor: z.string().optional(),
});
export const LayerEffectSchema = FaceBlurEffectSchema;
export const LayerSchema = z.object({
id: z.string(),
type: LayerTypeSchema,
name: z.string().optional(),
assetId: z.string().optional(),
sourceUri: z.string().optional(),
faceBlur: FaceBlurSettingsSchema.optional(),
effects: z.array(LayerEffectSchema).default([]),
visible: z.boolean().default(true),
x: z.number(),
y: z.number(),
width: z.number().optional(),
@@ -116,7 +121,10 @@ export const ProjectFileSchema = ProjectFileV1Schema;
export type Project = z.infer<typeof ProjectSchema>;
export type Layer = z.infer<typeof LayerSchema>;
export type LayerType = z.infer<typeof LayerTypeSchema>;
export type LayerEffect = z.infer<typeof LayerEffectSchema>;
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
export type FaceBlurSettings = z.infer<typeof FaceBlurSettingsSchema>;
export type FaceBlurEffect = z.infer<typeof FaceBlurEffectSchema>;
// Kept for compatibility with existing renderer code
export type FaceBlurSettings = Omit<FaceBlurEffect, "kind">;
export type ProjectFile = z.infer<typeof ProjectFileSchema>;