mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: initial app
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addLayer,
|
||||
createProject,
|
||||
moveLayer,
|
||||
reorderLayer,
|
||||
updateLayerTransform,
|
||||
} from "./index";
|
||||
|
||||
describe("editor-core", () => {
|
||||
it("creates project with defaults", () => {
|
||||
const project = createProject("new");
|
||||
expect(project.title).toBe("new");
|
||||
expect(project.aspectRatio).toBe("4:5");
|
||||
expect(project.layers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adds a new layer and updates timestamp", () => {
|
||||
const project = createProject("new");
|
||||
const updated = addLayer(project, {
|
||||
id: "l1",
|
||||
type: "image",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
expect(updated.layers).toHaveLength(1);
|
||||
expect(updated.layers[0]?.id).toBe("l1");
|
||||
expect(updated.updatedAt >= project.updatedAt).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a layer by delta", () => {
|
||||
const project = addLayer(createProject("move"), {
|
||||
id: "l1",
|
||||
type: "sticker",
|
||||
x: 10,
|
||||
y: 20,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
||||
expect(moved.layers[0]?.x).toBe(25);
|
||||
expect(moved.layers[0]?.y).toBe(15);
|
||||
});
|
||||
|
||||
it("updates transform fields", () => {
|
||||
const project = addLayer(createProject("transform"), {
|
||||
id: "l1",
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("reorders layer to the front", () => {
|
||||
const base = createProject("reorder");
|
||||
const withFirst = addLayer(base, {
|
||||
id: "l1",
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
const withSecond = addLayer(withFirst, {
|
||||
id: "l2",
|
||||
type: "sticker",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const reordered = reorderLayer(withSecond, "l1", 1);
|
||||
expect(reordered.layers.map((layer) => layer.id)).toEqual(["l2", "l1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
PRESET_CANVAS_SIZES,
|
||||
ProjectFileSchema,
|
||||
type AspectRatio,
|
||||
type FaceBlurSettings,
|
||||
type Layer,
|
||||
type Project,
|
||||
type ProjectFile,
|
||||
} 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: [],
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type EditorOperation =
|
||||
| { type: "addLayer"; layer: Layer }
|
||||
| { type: "removeLayer"; layerId: string }
|
||||
| { type: "moveLayer"; layerId: string; delta: Delta }
|
||||
| { type: "updateLayerTransform"; layerId: string; patch: TransformPatch }
|
||||
| { type: "reorderLayer"; layerId: string; toIndex: number }
|
||||
| { 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" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user