mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
♻️ refactor!: massive refactor
This commit is contained in:
@@ -10,8 +10,12 @@ function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
|
||||
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,
|
||||
sourceWidth: Number.isFinite(region.sourceWidth ?? NaN)
|
||||
? region.sourceWidth
|
||||
: undefined,
|
||||
sourceHeight: Number.isFinite(region.sourceHeight ?? NaN)
|
||||
? region.sourceHeight
|
||||
: undefined,
|
||||
censorColor: region.censorColor,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -12,7 +12,9 @@ const effectRegistry = new Map<string, EffectDefinition>(
|
||||
definitions.map((def) => [def.kind, def]),
|
||||
);
|
||||
|
||||
export function getEffectDefinition(kind: string): EffectDefinition | undefined {
|
||||
export function getEffectDefinition(
|
||||
kind: string,
|
||||
): EffectDefinition | undefined {
|
||||
return effectRegistry.get(kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
|
||||
export const HISTORY_LIMIT = 120;
|
||||
|
||||
export type HistoryState = {
|
||||
past: Project[];
|
||||
present: Project;
|
||||
future: Project[];
|
||||
};
|
||||
|
||||
export function deepClone<T>(value: T): T {
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function cloneProject(project: Project): Project {
|
||||
return deepClone(project);
|
||||
}
|
||||
|
||||
export function cloneLayer(layer: Layer): Layer {
|
||||
return deepClone(layer);
|
||||
}
|
||||
|
||||
export function makeHistory(project: Project): HistoryState {
|
||||
return { past: [], present: cloneProject(project), future: [] };
|
||||
}
|
||||
|
||||
export function computeHistoryFlags(history: HistoryState) {
|
||||
return {
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function capHistory(items: Project[]): Project[] {
|
||||
if (items.length <= HISTORY_LIMIT) return items;
|
||||
return items.slice(items.length - HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export function resolveSelectedLayerId(
|
||||
project: Project,
|
||||
preferred: string | null,
|
||||
): string | null {
|
||||
if (preferred && project.layers.some((layer) => layer.id === preferred))
|
||||
return preferred;
|
||||
return project.layers[0]?.id ?? null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addLayer,
|
||||
createLayer,
|
||||
createProject,
|
||||
moveLayer,
|
||||
removeLayer,
|
||||
@@ -14,20 +15,10 @@ import {
|
||||
setCanvasSize,
|
||||
updateLayerTransform,
|
||||
} from "./index";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
import type { Layer, LayerType } from "@pien-studio/types";
|
||||
|
||||
function makeLayer(id: string, type: Layer["type"] = "raster"): Layer {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
effects: [],
|
||||
visible: true,
|
||||
};
|
||||
function makeLayer(id: string, type: LayerType = "raster"): Layer {
|
||||
return createLayer(type, { id, x: 0, y: 0 });
|
||||
}
|
||||
|
||||
describe("editor-core", () => {
|
||||
@@ -48,7 +39,11 @@ describe("editor-core", () => {
|
||||
});
|
||||
|
||||
it("moves a layer by delta", () => {
|
||||
const project = addLayer(createProject("move"), { ...makeLayer("l1", "sticker"), x: 10, y: 20 });
|
||||
const project = addLayer(createProject("move"), {
|
||||
...makeLayer("l1", "sticker"),
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
||||
expect(moved.layers[0]?.x).toBe(25);
|
||||
@@ -56,9 +51,15 @@ describe("editor-core", () => {
|
||||
});
|
||||
|
||||
it("updates transform fields", () => {
|
||||
const project = addLayer(createProject("transform"), makeLayer("l1", "text"));
|
||||
const project = addLayer(
|
||||
createProject("transform"),
|
||||
makeLayer("l1", "text"),
|
||||
);
|
||||
|
||||
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
|
||||
const updated = updateLayerTransform(project, "l1", {
|
||||
scale: 1.35,
|
||||
rotation: 22,
|
||||
});
|
||||
expect(updated.layers[0]?.scale).toBe(1.35);
|
||||
expect(updated.layers[0]?.rotation).toBe(22);
|
||||
});
|
||||
@@ -96,18 +97,32 @@ describe("editor-core", () => {
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 40,
|
||||
regions: [{ x: 0, y: 3, width: 1, height: 1, sourceWidth: undefined, sourceHeight: undefined, censorColor: undefined }],
|
||||
regions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
sourceWidth: undefined,
|
||||
sourceHeight: undefined,
|
||||
censorColor: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create changes for unchanged layer visibility or effect enabled state", () => {
|
||||
const project = setLayerEffect(addLayer(createProject("visibility"), makeLayer("l1")), "l1", {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "pixelate",
|
||||
amount: 12,
|
||||
regions: [],
|
||||
});
|
||||
const project = setLayerEffect(
|
||||
addLayer(createProject("visibility"), makeLayer("l1")),
|
||||
"l1",
|
||||
{
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "pixelate",
|
||||
amount: 12,
|
||||
regions: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(setLayerVisible(project, "l1", true)).toBe(project);
|
||||
expect(setEffectEnabled(project, "l1", "face-blur", true)).toBe(project);
|
||||
@@ -122,26 +137,48 @@ describe("editor-core", () => {
|
||||
rotation: Number.NaN,
|
||||
opacity: 4,
|
||||
});
|
||||
const normalized = normalizeProject({ ...project, canvas: { width: 0.2, height: 20.6, unit: "px" } });
|
||||
const normalized = normalizeProject({
|
||||
...project,
|
||||
canvas: { width: 0.2, height: 20.6, unit: "px" },
|
||||
});
|
||||
|
||||
expect(normalized.canvas).toEqual({ width: 1, height: 21, unit: "px" });
|
||||
expect(normalized.layers[0]).toMatchObject({ width: 10, height: 1, scale: 1, rotation: 0, opacity: 1 });
|
||||
expect(normalized.layers[0]).toMatchObject({
|
||||
width: 10,
|
||||
height: 1,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes and parses project files with normalized projects", () => {
|
||||
const project = addLayer(createProject("file"), { ...makeLayer("l1"), width: 3.8 });
|
||||
const project = addLayer(createProject("file"), {
|
||||
...makeLayer("l1"),
|
||||
width: 3.8,
|
||||
});
|
||||
const raw = serializeProjectFile(project, { checkpointCount: 2 });
|
||||
const parsed = parseProjectFile(raw);
|
||||
|
||||
expect(JSON.parse(raw).history.checkpointCount).toBe(2);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.ok ? parsed.project.layers[0]?.width : undefined).toBe(4);
|
||||
expect(parseProjectFile("not json")).toEqual({ ok: false, error: "Invalid JSON" });
|
||||
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({ ok: false, error: "Invalid project format" });
|
||||
expect(parseProjectFile("not json")).toEqual({
|
||||
ok: false,
|
||||
error: "Invalid JSON",
|
||||
});
|
||||
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({
|
||||
ok: false,
|
||||
error: "Invalid project format",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets canvas size with clamped rounded dimensions", () => {
|
||||
const project = createProject("canvas");
|
||||
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({ width: 1, height: 13, unit: "cm" });
|
||||
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({
|
||||
width: 1,
|
||||
height: 13,
|
||||
unit: "cm",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export { createProject, setCanvasSize } from "./project";
|
||||
export { createLayer, addLayer, removeLayer, moveLayer, updateLayerTransform, reorderLayer, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled } from "./layers";
|
||||
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";
|
||||
@@ -7,30 +18,77 @@ 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";
|
||||
export type {
|
||||
ToolDefinition,
|
||||
ToolInteractionMode,
|
||||
EditorToolId,
|
||||
} from "./tools/registry";
|
||||
export {
|
||||
HISTORY_LIMIT,
|
||||
capHistory,
|
||||
cloneLayer,
|
||||
cloneProject,
|
||||
computeHistoryFlags,
|
||||
deepClone,
|
||||
makeHistory,
|
||||
resolveSelectedLayerId,
|
||||
} from "./history";
|
||||
export type { HistoryState } from "./history";
|
||||
|
||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
||||
import { addLayer, moveLayer, removeLayer, reorderLayer, setLayerEffect, updateLayerTransform } from "./layers";
|
||||
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: { dx: number; dy: number } }
|
||||
| { type: "updateLayerTransform"; layerId: string; patch: import("./layers").TransformPatch }
|
||||
| {
|
||||
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" };
|
||||
| {
|
||||
type: "setCanvasSize";
|
||||
width: number;
|
||||
height: number;
|
||||
unit?: "px" | "in" | "cm";
|
||||
};
|
||||
|
||||
export function applyOperation(project: Project, operation: EditorOperation): Project {
|
||||
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 "setLayerEffect": return setLayerEffect(project, operation.layerId, operation.effect);
|
||||
case "setCanvasSize": return setCanvasSize(project, operation.width, operation.height, operation.unit);
|
||||
default: return project;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
||||
import type {
|
||||
AssetRef,
|
||||
Layer,
|
||||
LayerEffect,
|
||||
LayerType,
|
||||
Project,
|
||||
RasterLayer,
|
||||
StickerLayer,
|
||||
TextLayer,
|
||||
} from "@pien-studio/types";
|
||||
import { normalizeEffect } from "./effects/registry";
|
||||
|
||||
export type LayerFactoryOptions = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
sourceUri?: string;
|
||||
asset?: AssetRef | null;
|
||||
runtimeSourceUri?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
text?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
stickerId?: string;
|
||||
};
|
||||
|
||||
export type TransformPatch = {
|
||||
@@ -19,7 +34,8 @@ export type TransformPatch = {
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
opacity?: number;
|
||||
sourceUri?: string;
|
||||
asset?: AssetRef | null;
|
||||
runtimeSourceUri?: string;
|
||||
effects?: LayerEffect[];
|
||||
};
|
||||
|
||||
@@ -27,7 +43,11 @@ function withUpdatedAt(project: Project, layers: Layer[]): Project {
|
||||
return { ...project, layers, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function updateLayer(project: Project, layerId: string, updater: (layer: Layer) => Layer): Project {
|
||||
function updateLayer(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
updater: (layer: Layer) => Layer,
|
||||
): Project {
|
||||
let changed = false;
|
||||
const layers = project.layers.map((layer) => {
|
||||
if (layer.id !== layerId) return layer;
|
||||
@@ -40,25 +60,62 @@ function updateLayer(project: Project, layerId: string, updater: (layer: Layer)
|
||||
}
|
||||
|
||||
function hasTransformPatchChange(layer: Layer, patch: TransformPatch): boolean {
|
||||
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => layer[key] !== patch[key]);
|
||||
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => {
|
||||
if (
|
||||
(key === "asset" || key === "runtimeSourceUri") &&
|
||||
layer.type !== "raster" &&
|
||||
layer.type !== "sticker"
|
||||
)
|
||||
return false;
|
||||
return (layer as Layer & TransformPatch)[key] !== patch[key];
|
||||
});
|
||||
}
|
||||
|
||||
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
|
||||
return {
|
||||
export function createLayer(
|
||||
type: LayerType,
|
||||
options: LayerFactoryOptions = {},
|
||||
): Layer {
|
||||
const base = {
|
||||
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,
|
||||
width: options.width ?? 200,
|
||||
height: options.height ?? 150,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
if (type === "text") {
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
text: options.text ?? "Text",
|
||||
fontFamily: options.fontFamily ?? "system-ui",
|
||||
fontSize: options.fontSize ?? 48,
|
||||
color: options.color ?? "#111827",
|
||||
} satisfies TextLayer;
|
||||
}
|
||||
|
||||
if (type === "sticker") {
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
asset: options.asset ?? null,
|
||||
runtimeSourceUri: options.runtimeSourceUri,
|
||||
stickerId: options.stickerId,
|
||||
} satisfies StickerLayer;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
asset: options.asset ?? null,
|
||||
runtimeSourceUri: options.runtimeSourceUri,
|
||||
} satisfies RasterLayer;
|
||||
}
|
||||
|
||||
export function addLayer(project: Project, layer: Layer): Project {
|
||||
@@ -67,19 +124,47 @@ export function addLayer(project: Project, layer: Layer): Project {
|
||||
|
||||
export function removeLayer(project: Project, layerId: string): Project {
|
||||
if (!project.layers.some((l) => l.id === layerId)) return project;
|
||||
return withUpdatedAt(project, project.layers.filter((l) => l.id !== layerId));
|
||||
return withUpdatedAt(
|
||||
project,
|
||||
project.layers.filter((l) => l.id !== layerId),
|
||||
);
|
||||
}
|
||||
|
||||
export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project {
|
||||
export function moveLayer(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
delta: { dx: number; dy: number },
|
||||
): Project {
|
||||
if (delta.dx === 0 && delta.dy === 0) return project;
|
||||
return updateLayer(project, layerId, (l) => ({ ...l, x: l.x + delta.dx, y: l.y + delta.dy }));
|
||||
return updateLayer(project, layerId, (l) => ({
|
||||
...l,
|
||||
x: l.x + delta.dx,
|
||||
y: l.y + delta.dy,
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
|
||||
return updateLayer(project, layerId, (l) => (hasTransformPatchChange(l, patch) ? { ...l, ...patch } : l));
|
||||
export function updateLayerTransform(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
patch: TransformPatch,
|
||||
): Project {
|
||||
return updateLayer(project, layerId, (l) => {
|
||||
if (!hasTransformPatchChange(l, patch)) return l;
|
||||
if (l.type === "text") {
|
||||
const { asset, runtimeSourceUri, ...textPatch } = patch;
|
||||
void asset;
|
||||
void runtimeSourceUri;
|
||||
return { ...l, ...textPatch };
|
||||
}
|
||||
return { ...l, ...patch };
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
|
||||
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];
|
||||
@@ -91,31 +176,56 @@ export function reorderLayer(project: Project, layerId: string, toIndex: number)
|
||||
return withUpdatedAt(project, layers);
|
||||
}
|
||||
|
||||
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
|
||||
export function setLayerEffect(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
effect: LayerEffect,
|
||||
): Project {
|
||||
const normalizedEffect = normalizeEffect(effect);
|
||||
return updateLayer(project, layerId, (l) => {
|
||||
const idx = l.effects.findIndex((e) => e.kind === normalizedEffect.kind);
|
||||
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? normalizedEffect : e)) : [...l.effects, normalizedEffect];
|
||||
const effects =
|
||||
idx >= 0
|
||||
? l.effects.map((e, i) => (i === idx ? normalizedEffect : e))
|
||||
: [...l.effects, normalizedEffect];
|
||||
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
|
||||
return { ...l, effects };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project {
|
||||
export function removeLayerEffect(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
kind: LayerEffect["kind"],
|
||||
): Project {
|
||||
return updateLayer(project, layerId, (l) => {
|
||||
if (!l.effects.some((e) => e.kind === kind)) return l;
|
||||
return { ...l, effects: l.effects.filter((e) => e.kind !== kind) };
|
||||
});
|
||||
}
|
||||
|
||||
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
|
||||
return updateLayer(project, layerId, (l) => (l.visible === visible ? l : { ...l, visible }));
|
||||
export function setLayerVisible(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
visible: boolean,
|
||||
): Project {
|
||||
return updateLayer(project, layerId, (l) =>
|
||||
l.visible === visible ? l : { ...l, visible },
|
||||
);
|
||||
}
|
||||
|
||||
export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project {
|
||||
export function setEffectEnabled(
|
||||
project: Project,
|
||||
layerId: string,
|
||||
kind: LayerEffect["kind"],
|
||||
enabled: boolean,
|
||||
): Project {
|
||||
return updateLayer(project, layerId, (l) => {
|
||||
const effect = l.effects.find((e) => e.kind === kind);
|
||||
if (!effect || effect.enabled === enabled) return l;
|
||||
return { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) };
|
||||
return {
|
||||
...l,
|
||||
effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
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) : [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
export { normalizeProject } from "@pien-studio/types";
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { PRESET_CANVAS_SIZES, type AspectRatio, type Project } from "@pien-studio/types";
|
||||
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];
|
||||
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 {
|
||||
export function createProject(
|
||||
title: string,
|
||||
aspect: AspectRatio = DEFAULT_ASPECT,
|
||||
): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import { ProjectFileSchema, type Project, type ProjectFile } from "@pien-studio/types";
|
||||
import {
|
||||
getLayerAssetRef,
|
||||
ProjectFileSchema,
|
||||
type Project,
|
||||
type ProjectFile,
|
||||
} from "@pien-studio/types";
|
||||
import { normalizeProject } from "./normalize";
|
||||
|
||||
export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string {
|
||||
export function serializeProjectFile(
|
||||
project: Project,
|
||||
options?: { checkpointCount?: number },
|
||||
): string {
|
||||
const normalized = normalizeProject(project);
|
||||
const document: ProjectFile = {
|
||||
format: "pien.project",
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: { name: "pien.studio", platform: "web" },
|
||||
project,
|
||||
assets: [],
|
||||
project: normalized,
|
||||
assets: normalized.layers.flatMap((layer) => {
|
||||
const asset = getLayerAssetRef(layer);
|
||||
if (!asset) return [];
|
||||
if (asset.kind === "stored") {
|
||||
return [
|
||||
{
|
||||
id: asset.id,
|
||||
kind:
|
||||
layer.type === "sticker"
|
||||
? ("sticker" as const)
|
||||
: ("image" as const),
|
||||
name: layer.name ?? layer.id,
|
||||
uri: `asset:${asset.id}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: layer.id,
|
||||
kind:
|
||||
layer.type === "sticker"
|
||||
? ("sticker" as const)
|
||||
: ("image" as const),
|
||||
name: layer.name ?? layer.id,
|
||||
uri: asset.uri,
|
||||
},
|
||||
];
|
||||
}),
|
||||
history: { checkpointCount: options?.checkpointCount ?? 0 },
|
||||
};
|
||||
return JSON.stringify(document, null, 2);
|
||||
}
|
||||
|
||||
export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } {
|
||||
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) };
|
||||
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" };
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
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 ToolInteractionMode = "select" | "pan" | "paint" | "annotate";
|
||||
|
||||
export type ToolDefinition = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user