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:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@pien-studio/contracts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DeviceSessionRequestSchema } from "./index";
|
||||
|
||||
describe("api contracts", () => {
|
||||
it("accepts valid device session payload", () => {
|
||||
const result = DeviceSessionRequestSchema.safeParse({
|
||||
deviceId: "abcd1234",
|
||||
locale: "ja",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown locale", () => {
|
||||
const result = DeviceSessionRequestSchema.safeParse({
|
||||
deviceId: "abcd1234",
|
||||
locale: "fr",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const LocaleSchema = z.enum(["en", "th", "ja"]);
|
||||
|
||||
export const DeviceSessionRequestSchema = z.object({
|
||||
deviceId: z.string().min(4),
|
||||
locale: LocaleSchema,
|
||||
});
|
||||
|
||||
export const DeviceSessionResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
scope: z.literal("local-sync"),
|
||||
});
|
||||
|
||||
export const SyncBootstrapResponseSchema = z.object({
|
||||
replication: z.object({
|
||||
pull: z.string(),
|
||||
push: z.string(),
|
||||
strategy: z.literal("operation-log"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ApiErrorSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
details: z.unknown().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DeviceSessionRequest = z.infer<typeof DeviceSessionRequestSchema>;
|
||||
export type DeviceSessionResponse = z.infer<typeof DeviceSessionResponseSchema>;
|
||||
export type SyncBootstrapResponse = z.infer<typeof SyncBootstrapResponseSchema>;
|
||||
export type ApiError = z.infer<typeof ApiErrorSchema>;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -8,22 +8,25 @@ import {
|
||||
inferMimeType,
|
||||
isBinaryLayer,
|
||||
makeLinkId,
|
||||
stripEmbeddedSourceUri,
|
||||
stripRuntimeSource,
|
||||
} from "./asset-records";
|
||||
|
||||
function layer(partial: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: "layer-1",
|
||||
type: "raster",
|
||||
asset: null,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
effects: [],
|
||||
...partial,
|
||||
};
|
||||
} as Layer;
|
||||
}
|
||||
|
||||
function project(layers: Layer[]): Project {
|
||||
@@ -43,22 +46,62 @@ describe("asset record helpers", () => {
|
||||
expect(isBinaryLayer(layer({ type: "raster" }))).toBe(true);
|
||||
expect(isBinaryLayer(layer({ type: "sticker" }))).toBe(true);
|
||||
expect(isBinaryLayer(layer({ type: "text" }))).toBe(false);
|
||||
expect(inferMimeType(layer({ sourceUri: "data:image/png;base64,a" }))).toBe("image/png");
|
||||
expect(inferMimeType(layer({ sourceUri: "data:image/webp;base64,a" }))).toBe("image/webp");
|
||||
expect(inferMimeType(layer({ sourceUri: "https://example.com/image" }))).toBe("image/jpeg");
|
||||
expect(
|
||||
inferMimeType(
|
||||
layer({ asset: { kind: "inline", uri: "data:image/png;base64,a" } }),
|
||||
),
|
||||
).toBe("image/png");
|
||||
expect(
|
||||
inferMimeType(
|
||||
layer({ asset: { kind: "inline", uri: "data:image/webp;base64,a" } }),
|
||||
),
|
||||
).toBe("image/webp");
|
||||
expect(
|
||||
inferMimeType(
|
||||
layer({ asset: { kind: "remote", uri: "https://example.com/image" } }),
|
||||
),
|
||||
).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("chooses reusable assets by blob size", () => {
|
||||
const small = new Blob(["a"]);
|
||||
const large = new Blob(["larger"]);
|
||||
expect(chooseReusableAsset([{ id: "small", blob: small }, { id: "large", blob: large }], new Blob(["b"]))?.id).toBe("small");
|
||||
expect(chooseReusableAsset([{ id: "small", blob: small }], large)).toBeUndefined();
|
||||
expect(
|
||||
chooseReusableAsset(
|
||||
[
|
||||
{ id: "small", blob: small },
|
||||
{ id: "large", blob: large },
|
||||
],
|
||||
new Blob(["b"]),
|
||||
)?.id,
|
||||
).toBe("small");
|
||||
expect(
|
||||
chooseReusableAsset([{ id: "small", blob: small }], large),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds stable asset records with precedence for existing and reusable ids", () => {
|
||||
const blob = new Blob(["image"], { type: "image/png" });
|
||||
expect(buildAssetRecord({ existingId: "existing", fallbackId: "new", mimeType: "image/png", blob, hash: "h", now: "now" }).id).toBe("existing");
|
||||
expect(buildAssetRecord({ reusable: { id: "reused", createdAt: "then" }, fallbackId: "new", mimeType: "image/png", blob, hash: "h", now: "now" })).toMatchObject({
|
||||
expect(
|
||||
buildAssetRecord({
|
||||
existingId: "existing",
|
||||
fallbackId: "new",
|
||||
mimeType: "image/png",
|
||||
blob,
|
||||
hash: "h",
|
||||
now: "now",
|
||||
}).id,
|
||||
).toBe("existing");
|
||||
expect(
|
||||
buildAssetRecord({
|
||||
reusable: { id: "reused", createdAt: "then" },
|
||||
fallbackId: "new",
|
||||
mimeType: "image/png",
|
||||
blob,
|
||||
hash: "h",
|
||||
now: "now",
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "reused",
|
||||
createdAt: "then",
|
||||
updatedAt: "now",
|
||||
@@ -67,22 +110,51 @@ describe("asset record helpers", () => {
|
||||
|
||||
it("builds asset links only for binary layers with assets", () => {
|
||||
const source = project([
|
||||
layer({ id: "raster", assetId: "asset-r" }),
|
||||
layer({ id: "text", type: "text", assetId: "asset-text" }),
|
||||
layer({ id: "sticker", type: "sticker", assetId: "asset-s" }),
|
||||
layer({ id: "raster", asset: { kind: "stored", id: "asset-r" } }),
|
||||
layer({
|
||||
id: "text",
|
||||
type: "text",
|
||||
text: "Text",
|
||||
fontFamily: "system-ui",
|
||||
fontSize: 12,
|
||||
color: "#000",
|
||||
}),
|
||||
layer({
|
||||
id: "sticker",
|
||||
type: "sticker",
|
||||
asset: { kind: "stored", id: "asset-s" },
|
||||
}),
|
||||
layer({ id: "empty" }),
|
||||
]);
|
||||
|
||||
expect(makeLinkId("project-1", "raster")).toBe("project-1:raster");
|
||||
expect(buildAssetLinks(source, "now")).toEqual([
|
||||
{ id: "project-1:raster", projectId: "project-1", layerId: "raster", assetId: "asset-r", updatedAt: "now" },
|
||||
{ id: "project-1:sticker", projectId: "project-1", layerId: "sticker", assetId: "asset-s", updatedAt: "now" },
|
||||
{
|
||||
id: "project-1:raster",
|
||||
projectId: "project-1",
|
||||
layerId: "raster",
|
||||
assetId: "asset-r",
|
||||
updatedAt: "now",
|
||||
},
|
||||
{
|
||||
id: "project-1:sticker",
|
||||
projectId: "project-1",
|
||||
layerId: "sticker",
|
||||
assetId: "asset-s",
|
||||
updatedAt: "now",
|
||||
},
|
||||
]);
|
||||
expect(collectProjectAssetIds(source)).toEqual(new Set(["asset-r", "asset-s"]));
|
||||
expect(collectProjectAssetIds(source)).toEqual(
|
||||
new Set(["asset-r", "asset-s"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips embedded image source uris and keeps remote uris", () => {
|
||||
expect(stripEmbeddedSourceUri(layer({ sourceUri: "data:image/png;base64,a" })).sourceUri).toBeUndefined();
|
||||
expect(stripEmbeddedSourceUri(layer({ sourceUri: "blob:local" })).sourceUri).toBe("blob:local");
|
||||
it("strips runtime source uris", () => {
|
||||
const stripped = stripRuntimeSource(
|
||||
layer({ runtimeSourceUri: "blob:local" }),
|
||||
);
|
||||
expect(
|
||||
stripped.type === "raster" ? stripped.runtimeSourceUri : undefined,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import {
|
||||
getAssetRefId,
|
||||
getLayerRuntimeSource,
|
||||
type Layer,
|
||||
type Project,
|
||||
} from "@pien-studio/types";
|
||||
|
||||
export type AssetRecordInput = {
|
||||
existingId?: string;
|
||||
@@ -27,12 +32,16 @@ export function isBinaryLayer(layer: Layer): boolean {
|
||||
}
|
||||
|
||||
export function inferMimeType(layer: Layer): string {
|
||||
if (layer.sourceUri?.startsWith("data:image/png")) return "image/png";
|
||||
if (layer.sourceUri?.startsWith("data:image/webp")) return "image/webp";
|
||||
const sourceUri = getLayerRuntimeSource(layer);
|
||||
if (sourceUri?.startsWith("data:image/png")) return "image/png";
|
||||
if (sourceUri?.startsWith("data:image/webp")) return "image/webp";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
export function chooseReusableAsset<T extends { blob: Blob }>(assets: T[], blob: Blob): T | undefined {
|
||||
export function chooseReusableAsset<T extends { blob: Blob }>(
|
||||
assets: T[],
|
||||
blob: Blob,
|
||||
): T | undefined {
|
||||
return assets.find((asset) => asset.blob.size === blob.size);
|
||||
}
|
||||
|
||||
@@ -47,25 +56,41 @@ export function buildAssetRecord(input: AssetRecordInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export function stripEmbeddedSourceUri(layer: Layer): Layer {
|
||||
export function stripRuntimeSource(layer: Layer): Layer {
|
||||
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
|
||||
return {
|
||||
...layer,
|
||||
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
|
||||
runtimeSourceUri: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssetLinks(project: Project, updatedAt: string): AssetLinkRecordInput[] {
|
||||
export function buildAssetLinks(
|
||||
project: Project,
|
||||
updatedAt: string,
|
||||
): AssetLinkRecordInput[] {
|
||||
return project.layers
|
||||
.filter((layer) => layer.assetId && isBinaryLayer(layer))
|
||||
.filter(
|
||||
(layer) => getAssetRefId(getLayerAsset(layer)) && isBinaryLayer(layer),
|
||||
)
|
||||
.map((layer) => ({
|
||||
id: makeLinkId(project.id, layer.id),
|
||||
projectId: project.id,
|
||||
layerId: layer.id,
|
||||
assetId: layer.assetId as string,
|
||||
assetId: getAssetRefId(getLayerAsset(layer)) as string,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectProjectAssetIds(project: Project): Set<string> {
|
||||
return new Set(buildAssetLinks(project, new Date(0).toISOString()).map((link) => link.assetId));
|
||||
return new Set(
|
||||
buildAssetLinks(project, new Date(0).toISOString()).map(
|
||||
(link) => link.assetId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getLayerAsset(layer: Layer) {
|
||||
return layer.type === "raster" || layer.type === "sticker"
|
||||
? layer.asset
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { deleteProject, duplicateProject, getProjectById, loadProjects, saveProjects, upsertProject } from "./index";
|
||||
import {
|
||||
deleteProject,
|
||||
duplicateProject,
|
||||
getProjectById,
|
||||
loadProjects,
|
||||
saveProjects,
|
||||
upsertProject,
|
||||
} from "./index";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
@@ -23,8 +30,12 @@ function ensureSchema(db: IDBDatabase) {
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
|
||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
|
||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, {
|
||||
keyPath: "id",
|
||||
});
|
||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", {
|
||||
unique: false,
|
||||
});
|
||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
||||
}
|
||||
}
|
||||
@@ -55,7 +66,9 @@ async function readRawProject(projectId: string): Promise<Project | undefined> {
|
||||
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
||||
const db = await requestToPromise(openRequest);
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const record = (await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId))) as Project | undefined;
|
||||
const record = (await requestToPromise(
|
||||
tx.objectStore(PROJECTS_STORE).get(projectId),
|
||||
)) as Project | undefined;
|
||||
db.close();
|
||||
return record;
|
||||
}
|
||||
@@ -92,6 +105,7 @@ function makeMalformedProject(id: string): Project {
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "raster",
|
||||
asset: null,
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 10.4,
|
||||
@@ -119,6 +133,7 @@ function makeFaceBlurProject(id: string): Project {
|
||||
{
|
||||
id: "image-faceblur",
|
||||
type: "raster",
|
||||
asset: null,
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 600,
|
||||
@@ -141,7 +156,10 @@ function makeFaceBlurProject(id: string): Project {
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8="): Project {
|
||||
function makeAssetProject(
|
||||
id: string,
|
||||
sourceUri = "data:image/png;base64,aGVsbG8=",
|
||||
): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
@@ -154,7 +172,7 @@ function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8
|
||||
{
|
||||
id: "image-1",
|
||||
type: "raster",
|
||||
sourceUri,
|
||||
asset: { kind: "inline", uri: sourceUri },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
@@ -172,7 +190,10 @@ function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8
|
||||
describe("storage read flows", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation((obj: Blob | MediaSource) => `blob:test-${"size" in obj ? obj.size : "media"}`);
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation(
|
||||
(obj: Blob | MediaSource) =>
|
||||
`blob:test-${"size" in obj ? obj.size : "media"}`,
|
||||
);
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
@@ -208,7 +229,9 @@ describe("storage read flows", () => {
|
||||
await seedProject(source);
|
||||
|
||||
const loaded = await getProjectById(source.id);
|
||||
const effect = loaded?.layers[0]?.effects.find((e) => e.kind === "face-blur");
|
||||
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);
|
||||
@@ -216,8 +239,11 @@ describe("storage read flows", () => {
|
||||
expect(region?.sourceHeight).toBeUndefined();
|
||||
|
||||
const listed = await loadProjects();
|
||||
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;
|
||||
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();
|
||||
@@ -227,13 +253,23 @@ describe("storage read flows", () => {
|
||||
await upsertProject(makeAssetProject("asset-project"));
|
||||
|
||||
const raw = await readRawProject("asset-project");
|
||||
expect(raw?.layers[0]?.assetId).toBeDefined();
|
||||
expect(raw?.layers[0]?.sourceUri).toBeUndefined();
|
||||
expect(
|
||||
raw?.layers[0]?.type === "raster" ? raw.layers[0].asset?.kind : undefined,
|
||||
).toBe("stored");
|
||||
expect(
|
||||
raw?.layers[0]?.type === "raster"
|
||||
? raw.layers[0].runtimeSourceUri
|
||||
: undefined,
|
||||
).toBeUndefined();
|
||||
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
|
||||
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(1);
|
||||
|
||||
const loaded = await getProjectById("asset-project");
|
||||
expect(loaded?.layers[0]?.sourceUri).toMatch(/^blob:test-/);
|
||||
expect(
|
||||
loaded?.layers[0]?.type === "raster"
|
||||
? loaded.layers[0].runtimeSourceUri
|
||||
: undefined,
|
||||
).toMatch(/^blob:test-/);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -263,7 +299,10 @@ describe("storage read flows", () => {
|
||||
|
||||
it("saveProjects prunes projects not in the replacement list", async () => {
|
||||
const keep = makeAssetProject("keep-project");
|
||||
const drop = makeAssetProject("drop-project", "data:image/png;base64,ZHJvcA==");
|
||||
const drop = makeAssetProject(
|
||||
"drop-project",
|
||||
"data:image/png;base64,ZHJvcA==",
|
||||
);
|
||||
await upsertProject(keep);
|
||||
await upsertProject(drop);
|
||||
|
||||
|
||||
+174
-63
@@ -1,5 +1,12 @@
|
||||
import { normalizeProject } from "@pien-studio/editor-core";
|
||||
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
|
||||
import {
|
||||
getAssetRefId,
|
||||
getLayerRuntimeSource,
|
||||
normalizeProject,
|
||||
ProjectSchema,
|
||||
type AssetRef,
|
||||
type Layer,
|
||||
type Project,
|
||||
} from "@pien-studio/types";
|
||||
import {
|
||||
buildAssetLinks,
|
||||
buildAssetRecord,
|
||||
@@ -7,7 +14,7 @@ import {
|
||||
inferMimeType,
|
||||
isBinaryLayer,
|
||||
makeLinkId,
|
||||
stripEmbeddedSourceUri,
|
||||
stripRuntimeSource,
|
||||
} from "./asset-records";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
@@ -65,22 +72,34 @@ function openDatabase(): Promise<IDBDatabase | null> {
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
||||
const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" });
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
||||
const assetsStore = db.createObjectStore(ASSETS_STORE, {
|
||||
keyPath: "id",
|
||||
});
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", {
|
||||
unique: false,
|
||||
});
|
||||
} else {
|
||||
const tx = request.transaction;
|
||||
if (tx) {
|
||||
const assetsStore = tx.objectStore(ASSETS_STORE);
|
||||
if (!assetsStore.indexNames.contains(ASSETS_BY_HASH_INDEX)) {
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false });
|
||||
assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) {
|
||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" });
|
||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false });
|
||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false });
|
||||
const linksStore = db.createObjectStore(ASSET_LINKS_STORE, {
|
||||
keyPath: "id",
|
||||
});
|
||||
linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", {
|
||||
unique: false,
|
||||
});
|
||||
linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,18 +123,45 @@ async function hashBlob(blob: Blob): Promise<string> {
|
||||
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
|
||||
}
|
||||
|
||||
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
|
||||
if (!isBinaryLayer(layer)) return layer.assetId;
|
||||
function getLayerAsset(layer: Layer): AssetRef | null | undefined {
|
||||
return layer.type === "raster" || layer.type === "sticker"
|
||||
? layer.asset
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (layer.sourceUri?.startsWith("data:image/")) {
|
||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
||||
function withStoredAsset(layer: Layer, assetId: string): Layer {
|
||||
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
|
||||
return {
|
||||
...layer,
|
||||
asset: { kind: "stored", id: assetId },
|
||||
runtimeSourceUri: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function withRuntimeSource(layer: Layer, runtimeSourceUri: string): Layer {
|
||||
if (layer.type !== "raster" && layer.type !== "sticker") return layer;
|
||||
return { ...layer, runtimeSourceUri };
|
||||
}
|
||||
|
||||
async function putAssetFromLayer(
|
||||
assetStore: IDBObjectStore,
|
||||
layer: Layer,
|
||||
): Promise<string | undefined> {
|
||||
const asset = getLayerAsset(layer);
|
||||
if (!isBinaryLayer(layer)) return getAssetRefId(asset);
|
||||
const sourceUri = getLayerRuntimeSource(layer);
|
||||
|
||||
if (sourceUri?.startsWith("data:image/")) {
|
||||
const blob = await dataUrlToBlob(sourceUri);
|
||||
const hash = await hashBlob(blob);
|
||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||
const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[];
|
||||
const matching = (await requestToPromise(
|
||||
hashIndex.getAll(hash),
|
||||
)) as AssetRecord[];
|
||||
const reusable = chooseReusableAsset(matching, blob);
|
||||
const now = new Date().toISOString();
|
||||
const record = buildAssetRecord({
|
||||
existingId: layer.assetId,
|
||||
existingId: getAssetRefId(asset),
|
||||
reusable,
|
||||
fallbackId: crypto.randomUUID(),
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
@@ -129,7 +175,7 @@ async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Prom
|
||||
return record.id;
|
||||
}
|
||||
|
||||
return layer.assetId;
|
||||
return getAssetRefId(asset);
|
||||
}
|
||||
|
||||
type PreparedLayerAsset = {
|
||||
@@ -139,12 +185,20 @@ type PreparedLayerAsset = {
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]> {
|
||||
async function prepareLayerAssets(
|
||||
layers: Layer[],
|
||||
): Promise<PreparedLayerAsset[]> {
|
||||
const prepared: PreparedLayerAsset[] = [];
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
if (!layer || !isBinaryLayer(layer) || !layer.sourceUri?.startsWith("data:image/")) continue;
|
||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
||||
const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined;
|
||||
if (
|
||||
!layer ||
|
||||
!isBinaryLayer(layer) ||
|
||||
!sourceUri?.startsWith("data:image/")
|
||||
)
|
||||
continue;
|
||||
const blob = await dataUrlToBlob(sourceUri);
|
||||
const hash = await hashBlob(blob);
|
||||
prepared.push({
|
||||
layerIndex: index,
|
||||
@@ -156,20 +210,23 @@ async function prepareLayerAssets(layers: Layer[]): Promise<PreparedLayerAsset[]
|
||||
return prepared;
|
||||
}
|
||||
|
||||
async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<Set<string>> {
|
||||
async function syncLinksForProject(
|
||||
db: IDBDatabase,
|
||||
project: Project,
|
||||
): Promise<Set<string>> {
|
||||
const tx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||
const store = tx.objectStore(ASSET_LINKS_STORE);
|
||||
const byProject = store.index(LINKS_BY_PROJECT_INDEX);
|
||||
const existing = (await requestToPromise(byProject.getAll(project.id))) as AssetLinkRecord[];
|
||||
const existing = (await requestToPromise(
|
||||
byProject.getAll(project.id),
|
||||
)) as AssetLinkRecord[];
|
||||
const existingMap = new Map(existing.map((link) => [link.id, link]));
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const links = buildAssetLinks(project, now);
|
||||
const referenced = new Set(links.map((link) => link.assetId));
|
||||
for (const link of links) {
|
||||
await requestToPromise(
|
||||
store.put(link satisfies AssetLinkRecord),
|
||||
);
|
||||
await requestToPromise(store.put(link satisfies AssetLinkRecord));
|
||||
existingMap.delete(link.id);
|
||||
}
|
||||
|
||||
@@ -189,8 +246,14 @@ async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<S
|
||||
return referenced;
|
||||
}
|
||||
|
||||
async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Iterable<string>): Promise<number> {
|
||||
const assetsTx = db.transaction([ASSETS_STORE, ASSET_LINKS_STORE], "readwrite");
|
||||
async function cleanupOrphansInternal(
|
||||
db: IDBDatabase,
|
||||
candidateAssetIds?: Iterable<string>,
|
||||
): Promise<number> {
|
||||
const assetsTx = db.transaction(
|
||||
[ASSETS_STORE, ASSET_LINKS_STORE],
|
||||
"readwrite",
|
||||
);
|
||||
const assetsStore = assetsTx.objectStore(ASSETS_STORE);
|
||||
const linksStore = assetsTx.objectStore(ASSET_LINKS_STORE);
|
||||
const byAsset = linksStore.index(LINKS_BY_ASSET_INDEX);
|
||||
@@ -201,11 +264,17 @@ async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Itera
|
||||
|
||||
let removed = 0;
|
||||
for (const assetId of candidates) {
|
||||
const links = (await requestToPromise(byAsset.getAll(assetId))) as AssetLinkRecord[];
|
||||
const links = (await requestToPromise(
|
||||
byAsset.getAll(assetId),
|
||||
)) as AssetLinkRecord[];
|
||||
if (links.length > 0) continue;
|
||||
await requestToPromise(assetsStore.delete(assetId));
|
||||
const url = objectUrlByAssetId.get(assetId);
|
||||
if (url?.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
if (
|
||||
url?.startsWith("blob:") &&
|
||||
typeof URL !== "undefined" &&
|
||||
typeof URL.revokeObjectURL === "function"
|
||||
) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlByAssetId.delete(assetId);
|
||||
@@ -221,7 +290,10 @@ async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Itera
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function persistProject(db: IDBDatabase, project: Project): Promise<Project> {
|
||||
async function persistProject(
|
||||
db: IDBDatabase,
|
||||
project: Project,
|
||||
): Promise<Project> {
|
||||
const normalized = normalizeProject(project);
|
||||
const preparedAssets = await prepareLayerAssets(normalized.layers);
|
||||
const assetTx = db.transaction(ASSETS_STORE, "readwrite");
|
||||
@@ -232,11 +304,13 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
for (const prepared of preparedAssets) {
|
||||
const layer = layers[prepared.layerIndex];
|
||||
if (!layer) continue;
|
||||
const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[];
|
||||
const matching = (await requestToPromise(
|
||||
hashIndex.getAll(prepared.hash),
|
||||
)) as AssetRecord[];
|
||||
const reusable = chooseReusableAsset(matching, prepared.blob);
|
||||
const now = new Date().toISOString();
|
||||
const record = buildAssetRecord({
|
||||
existingId: layer.assetId,
|
||||
existingId: getAssetRefId(getLayerAsset(layer)),
|
||||
reusable,
|
||||
fallbackId: crypto.randomUUID(),
|
||||
mimeType: prepared.mimeType,
|
||||
@@ -247,11 +321,7 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
|
||||
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
||||
|
||||
layers[prepared.layerIndex] = {
|
||||
...layer,
|
||||
assetId: record.id,
|
||||
sourceUri: undefined,
|
||||
};
|
||||
layers[prepared.layerIndex] = withStoredAsset(layer, record.id);
|
||||
}
|
||||
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
@@ -260,8 +330,7 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
const assetId = await putAssetFromLayer(assetStore, layer);
|
||||
if (!assetId) continue;
|
||||
layers[index] = {
|
||||
...stripEmbeddedSourceUri(layer),
|
||||
assetId,
|
||||
...withStoredAsset(stripRuntimeSource(layer), assetId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,21 +355,30 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
return persisted;
|
||||
}
|
||||
|
||||
async function hydrateProject(db: IDBDatabase, project: Project): Promise<Project> {
|
||||
async function hydrateProject(
|
||||
db: IDBDatabase,
|
||||
project: Project,
|
||||
): Promise<Project> {
|
||||
const tx = db.transaction(ASSETS_STORE, "readonly");
|
||||
const store = tx.objectStore(ASSETS_STORE);
|
||||
|
||||
const layers = await Promise.all(
|
||||
project.layers.map(async (layer) => {
|
||||
if (!layer.assetId) return layer;
|
||||
const record = (await requestToPromise(store.get(layer.assetId))) as AssetRecord | undefined;
|
||||
const assetId = getAssetRefId(getLayerAsset(layer));
|
||||
if (!assetId) return layer;
|
||||
const record = (await requestToPromise(store.get(assetId))) as
|
||||
| AssetRecord
|
||||
| undefined;
|
||||
if (!record?.blob) return layer;
|
||||
const existing = objectUrlByAssetId.get(layer.assetId);
|
||||
if (existing) return { ...layer, sourceUri: existing };
|
||||
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
|
||||
const existing = objectUrlByAssetId.get(assetId);
|
||||
if (existing) return withRuntimeSource(layer, existing);
|
||||
if (
|
||||
typeof URL !== "undefined" &&
|
||||
typeof URL.createObjectURL === "function"
|
||||
) {
|
||||
const objectUrl = URL.createObjectURL(record.blob);
|
||||
objectUrlByAssetId.set(layer.assetId, objectUrl);
|
||||
return { ...layer, sourceUri: objectUrl };
|
||||
objectUrlByAssetId.set(assetId, objectUrl);
|
||||
return withRuntimeSource(layer, objectUrl);
|
||||
}
|
||||
return layer;
|
||||
}),
|
||||
@@ -309,17 +387,27 @@ async function hydrateProject(db: IDBDatabase, project: Project): Promise<Projec
|
||||
return { ...project, layers };
|
||||
}
|
||||
|
||||
export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterable<string>): void {
|
||||
const keep = keepAssetIds ? new Set(Array.from(keepAssetIds).filter(Boolean)) : null;
|
||||
export function releaseProjectObjectUrls(
|
||||
project: Project,
|
||||
keepAssetIds?: Iterable<string>,
|
||||
): void {
|
||||
const keep = keepAssetIds
|
||||
? new Set(Array.from(keepAssetIds).filter(Boolean))
|
||||
: null;
|
||||
for (const layer of project.layers) {
|
||||
if (!layer.assetId) continue;
|
||||
if (keep?.has(layer.assetId)) continue;
|
||||
const url = objectUrlByAssetId.get(layer.assetId);
|
||||
const assetId = getAssetRefId(getLayerAsset(layer));
|
||||
if (!assetId) continue;
|
||||
if (keep?.has(assetId)) continue;
|
||||
const url = objectUrlByAssetId.get(assetId);
|
||||
if (!url) continue;
|
||||
if (url.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
if (
|
||||
url.startsWith("blob:") &&
|
||||
typeof URL !== "undefined" &&
|
||||
typeof URL.revokeObjectURL === "function"
|
||||
) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlByAssetId.delete(layer.assetId);
|
||||
objectUrlByAssetId.delete(assetId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,13 +456,17 @@ export async function loadProjects(): Promise<Project[]> {
|
||||
|
||||
try {
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll());
|
||||
const records = await requestToPromise(
|
||||
tx.objectStore(PROJECTS_STORE).getAll(),
|
||||
);
|
||||
const parsed = (records as unknown[])
|
||||
.map((record) => ProjectSchema.safeParse(record))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => normalizeProject(result.data));
|
||||
|
||||
const hydrated = await Promise.all(parsed.map((project) => hydrateProject(db, project)));
|
||||
const hydrated = await Promise.all(
|
||||
parsed.map((project) => hydrateProject(db, project)),
|
||||
);
|
||||
await cleanupOrphansInternal(db);
|
||||
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
} finally {
|
||||
@@ -382,13 +474,17 @@ export async function loadProjects(): Promise<Project[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectById(projectId: string): Promise<Project | null> {
|
||||
export async function getProjectById(
|
||||
projectId: string,
|
||||
): Promise<Project | null> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return null;
|
||||
|
||||
try {
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
||||
const record = await requestToPromise(
|
||||
tx.objectStore(PROJECTS_STORE).get(projectId),
|
||||
);
|
||||
const parsed = ProjectSchema.safeParse(record);
|
||||
if (!parsed.success) return null;
|
||||
return await hydrateProject(db, normalizeProject(parsed.data));
|
||||
@@ -401,7 +497,10 @@ export async function upsertProject(project: Project): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
try {
|
||||
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
|
||||
await persistProject(db, {
|
||||
...project,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -415,7 +514,9 @@ export async function deleteProject(projectId: string): Promise<void> {
|
||||
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
|
||||
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
|
||||
const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[];
|
||||
const links = (await requestToPromise(
|
||||
byProject.getAll(projectId),
|
||||
)) as AssetLinkRecord[];
|
||||
for (const link of links) {
|
||||
await requestToPromise(linksStore.delete(link.id));
|
||||
}
|
||||
@@ -426,14 +527,21 @@ export async function deleteProject(projectId: string): Promise<void> {
|
||||
});
|
||||
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
||||
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
|
||||
await requestToPromise(
|
||||
projectTx.objectStore(PROJECTS_STORE).delete(projectId),
|
||||
);
|
||||
await cleanupOrphansInternal(
|
||||
db,
|
||||
links.map((link) => link.assetId),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateProject(projectId: string): Promise<Project | null> {
|
||||
export async function duplicateProject(
|
||||
projectId: string,
|
||||
): Promise<Project | null> {
|
||||
const source = await getProjectById(projectId);
|
||||
if (!source) return null;
|
||||
const now = new Date().toISOString();
|
||||
@@ -443,7 +551,10 @@ export async function duplicateProject(projectId: string): Promise<Project | nul
|
||||
title: `${source.title} Copy`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
layers: source.layers.map((layer) => ({ ...layer, id: crypto.randomUUID() })),
|
||||
layers: source.layers.map((layer) => ({
|
||||
...layer,
|
||||
id: crypto.randomUUID(),
|
||||
})),
|
||||
};
|
||||
await upsertProject(copy);
|
||||
return getProjectById(copy.id);
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DeviceSessionSchema, ProjectFileSchema, ProjectSchema } from "./index";
|
||||
import { ProjectFileSchema, ProjectSchema } from "./index";
|
||||
|
||||
describe("types schemas", () => {
|
||||
it("accepts valid device session payload", () => {
|
||||
const result = DeviceSessionSchema.safeParse({
|
||||
deviceId: "abcd1234",
|
||||
locale: "ja",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown locale", () => {
|
||||
const result = DeviceSessionSchema.safeParse({
|
||||
deviceId: "abcd1234",
|
||||
locale: "fr",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates project shape", () => {
|
||||
const result = ProjectSchema.safeParse({
|
||||
id: "p1",
|
||||
@@ -35,7 +19,7 @@ describe("types schemas", () => {
|
||||
const now = new Date().toISOString();
|
||||
const result = ProjectFileSchema.safeParse({
|
||||
format: "pien.project",
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: now,
|
||||
app: { name: "pien.studio", platform: "web" },
|
||||
project: {
|
||||
|
||||
+106
-19
@@ -2,6 +2,12 @@ import { z } from "zod";
|
||||
|
||||
export const LayerTypeSchema = z.enum(["raster", "text", "sticker"]);
|
||||
|
||||
export const AssetRefSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("inline"), uri: z.string() }),
|
||||
z.object({ kind: z.literal("stored"), id: z.string() }),
|
||||
z.object({ kind: z.literal("remote"), uri: z.string() }),
|
||||
]);
|
||||
|
||||
export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
|
||||
|
||||
export const FaceBlurRegionSchema = z.object({
|
||||
@@ -25,31 +31,55 @@ export const FaceBlurEffectSchema = z.object({
|
||||
|
||||
export const LayerEffectSchema = FaceBlurEffectSchema;
|
||||
|
||||
export const LayerSchema = z.object({
|
||||
const BaseLayerSchema = z.object({
|
||||
id: z.string(),
|
||||
type: LayerTypeSchema,
|
||||
name: z.string().optional(),
|
||||
assetId: z.string().optional(),
|
||||
sourceUri: z.string().optional(),
|
||||
effects: z.array(LayerEffectSchema).default([]),
|
||||
visible: z.boolean().default(true),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
scale: z.number().default(1),
|
||||
rotation: z.number().default(0),
|
||||
opacity: z.number().min(0).max(1).default(1),
|
||||
});
|
||||
|
||||
export const RasterLayerSchema = BaseLayerSchema.extend({
|
||||
type: z.literal("raster"),
|
||||
asset: AssetRefSchema.nullable(),
|
||||
runtimeSourceUri: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TextLayerSchema = BaseLayerSchema.extend({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
fontFamily: z.string().default("system-ui"),
|
||||
fontSize: z.number().positive().default(48),
|
||||
color: z.string().default("#111827"),
|
||||
});
|
||||
|
||||
export const StickerLayerSchema = BaseLayerSchema.extend({
|
||||
type: z.literal("sticker"),
|
||||
asset: AssetRefSchema.nullable(),
|
||||
runtimeSourceUri: z.string().optional(),
|
||||
stickerId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const LayerSchema = z.discriminatedUnion("type", [
|
||||
RasterLayerSchema,
|
||||
TextLayerSchema,
|
||||
StickerLayerSchema,
|
||||
]);
|
||||
|
||||
export const AspectRatioSchema = z.enum([
|
||||
"1:1", // square feed
|
||||
"4:5", // portrait 4:5
|
||||
"9:16", // story / vertical
|
||||
"16:9", // widescreen
|
||||
"4:3", // classic photo
|
||||
"3:2", // landscape photo
|
||||
"free", // custom
|
||||
"1:1",
|
||||
"4:5",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"4:3",
|
||||
"3:2",
|
||||
"free",
|
||||
]);
|
||||
|
||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
@@ -88,14 +118,9 @@ export const PRESET_CANVAS_SIZES: PresetAspectRatio[] = [
|
||||
{ label: "Classic (3:2)", value: "3:2", width: 1620, height: 1080 },
|
||||
];
|
||||
|
||||
export const DeviceSessionSchema = z.object({
|
||||
deviceId: z.string().min(4),
|
||||
locale: z.enum(["en", "th", "ja"]),
|
||||
});
|
||||
|
||||
export const ProjectFileV1Schema = z.object({
|
||||
format: z.literal("pien.project"),
|
||||
version: z.literal(1),
|
||||
version: z.literal(2),
|
||||
exportedAt: z.string(),
|
||||
app: z.object({
|
||||
name: z.literal("pien.studio"),
|
||||
@@ -120,9 +145,71 @@ export const ProjectFileSchema = ProjectFileV1Schema;
|
||||
|
||||
export type Project = z.infer<typeof ProjectSchema>;
|
||||
export type Layer = z.infer<typeof LayerSchema>;
|
||||
export type RasterLayer = z.infer<typeof RasterLayerSchema>;
|
||||
export type TextLayer = z.infer<typeof TextLayerSchema>;
|
||||
export type StickerLayer = z.infer<typeof StickerLayerSchema>;
|
||||
export type LayerType = z.infer<typeof LayerTypeSchema>;
|
||||
export type AssetRef = z.infer<typeof AssetRefSchema>;
|
||||
export type LayerEffect = z.infer<typeof LayerEffectSchema>;
|
||||
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
||||
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
||||
export type FaceBlurEffect = z.infer<typeof FaceBlurEffectSchema>;
|
||||
export type ProjectFile = z.infer<typeof ProjectFileSchema>;
|
||||
|
||||
export function getAssetRefId(
|
||||
asset: AssetRef | null | undefined,
|
||||
): string | undefined {
|
||||
return asset?.kind === "stored" ? asset.id : undefined;
|
||||
}
|
||||
|
||||
export function getLayerAssetRef(layer: Layer): AssetRef | null | undefined {
|
||||
return layer.type === "raster" || layer.type === "sticker"
|
||||
? layer.asset
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getLayerRuntimeSource(layer: Layer): string | undefined {
|
||||
if (layer.type !== "raster" && layer.type !== "sticker") return undefined;
|
||||
return (
|
||||
layer.runtimeSourceUri ??
|
||||
(layer.asset?.kind === "inline" || layer.asset?.kind === "remote"
|
||||
? layer.asset.uri
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeLayerEffect(effect: LayerEffect): LayerEffect {
|
||||
if (effect.kind === "face-blur") {
|
||||
return {
|
||||
...effect,
|
||||
enabled: effect.enabled ?? true,
|
||||
amount: Math.max(4, Math.min(40, Math.round(effect.amount))),
|
||||
regions: Array.isArray(effect.regions) ? effect.regions : [],
|
||||
};
|
||||
}
|
||||
return effect;
|
||||
}
|
||||
|
||||
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: Math.max(1, Math.round(layer.width)),
|
||||
height: Math.max(1, Math.round(layer.height)),
|
||||
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(normalizeLayerEffect)
|
||||
: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user