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,6 @@
|
||||
{
|
||||
"name": "@pien-studio/config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pien-studio/editor-core",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pien-studio/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@pien-studio/storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pien-studio/editor-core": "workspace:*",
|
||||
"@pien-studio/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fake-indexeddb": "^6.2.2",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { getProjectById, loadProjects } from "./index";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
const DB_VERSION = 4;
|
||||
const PROJECTS_STORE = "projects";
|
||||
const ASSETS_STORE = "assets";
|
||||
const ASSETS_BY_HASH_INDEX = "byHash";
|
||||
const ASSET_LINKS_STORE = "assetLinks";
|
||||
const LINKS_BY_PROJECT_INDEX = "byProjectId";
|
||||
const LINKS_BY_ASSET_INDEX = "byAssetId";
|
||||
|
||||
function ensureSchema(db: IDBDatabase) {
|
||||
if (!db.objectStoreNames.contains(PROJECTS_STORE)) {
|
||||
db.createObjectStore(PROJECTS_STORE, { keyPath: "id" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
||||
const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function seedProject(project: Project): Promise<void> {
|
||||
const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
|
||||
const db = await requestToPromise(openRequest);
|
||||
const tx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(tx.objectStore(PROJECTS_STORE).put(project));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readRawProject(projectId: string): Promise<Project | undefined> {
|
||||
const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
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;
|
||||
db.close();
|
||||
return record;
|
||||
}
|
||||
|
||||
function makeMalformedProject(id: string): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
title: "seed",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1200, height: 1200, unit: "px" },
|
||||
layers: [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 10.4,
|
||||
height: 11.6,
|
||||
scale: 0,
|
||||
rotation: 720.4,
|
||||
opacity: 0.75,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeLegacyFaceBlurProject(id: string): Project {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
title: "legacy",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1200, height: 1500, unit: "px" },
|
||||
layers: [
|
||||
{
|
||||
id: "image-legacy",
|
||||
type: "image",
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 600,
|
||||
height: 800,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
faceBlur: {
|
||||
method: "gaussian",
|
||||
amount: 14,
|
||||
regions: [{ x: 120, y: 160, width: 180, height: 200 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage read flows", () => {
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("loadProjects hydrates normalized values without persisting writes", async () => {
|
||||
const source = makeMalformedProject("project-load");
|
||||
await seedProject(source);
|
||||
|
||||
const byId = await getProjectById(source.id);
|
||||
expect(byId?.canvas.height).toBe(1200);
|
||||
expect(byId?.layers[0]?.height).toBe(12);
|
||||
|
||||
const rawAfterById = await readRawProject(source.id);
|
||||
expect(rawAfterById?.layers[0]?.height).toBe(11.6);
|
||||
|
||||
const loaded = await loadProjects();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]?.canvas.width).toBe(1200);
|
||||
expect(loaded[0]?.layers[0]?.width).toBe(10);
|
||||
expect(loaded[0]?.layers[0]?.height).toBe(12);
|
||||
|
||||
const raw = await readRawProject(source.id);
|
||||
expect(raw?.canvas.width).toBe(1200);
|
||||
expect(raw?.layers[0]?.width).toBe(10.4);
|
||||
expect(raw?.layers[0]?.height).toBe(11.6);
|
||||
});
|
||||
|
||||
it("loads legacy projects without source dimensions through storage and normalize flow", async () => {
|
||||
const source = makeLegacyFaceBlurProject("project-legacy-faceblur");
|
||||
await seedProject(source);
|
||||
|
||||
const loaded = await getProjectById(source.id);
|
||||
const region = loaded?.layers[0]?.faceBlur?.regions[0];
|
||||
expect(region).toBeDefined();
|
||||
expect(region?.x).toBe(120);
|
||||
expect(region?.sourceWidth).toBeUndefined();
|
||||
expect(region?.sourceHeight).toBeUndefined();
|
||||
|
||||
const listed = await loadProjects();
|
||||
const listedRegion = listed[0]?.layers[0]?.faceBlur?.regions[0];
|
||||
expect(listedRegion?.width).toBe(180);
|
||||
expect(listedRegion?.sourceWidth).toBeUndefined();
|
||||
expect(listedRegion?.sourceHeight).toBeUndefined();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { normalizeProject } from "@pien-studio/editor-core";
|
||||
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
|
||||
|
||||
const DB_NAME = "pien.db";
|
||||
const DB_VERSION = 4;
|
||||
const PROJECTS_STORE = "projects";
|
||||
const ASSETS_STORE = "assets";
|
||||
const ASSETS_BY_HASH_INDEX = "byHash";
|
||||
const ASSET_LINKS_STORE = "assetLinks";
|
||||
const LINKS_BY_PROJECT_INDEX = "byProjectId";
|
||||
const LINKS_BY_ASSET_INDEX = "byAssetId";
|
||||
|
||||
type AssetRecord = {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
hash: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AssetLinkRecord = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
layerId: string;
|
||||
assetId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const objectUrlByAssetId = new Map<string, string>();
|
||||
|
||||
function getIndexedDb(): IDBFactory | null {
|
||||
if (typeof indexedDB === "undefined") return null;
|
||||
return indexedDB;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase | null> {
|
||||
const idb = getIndexedDb();
|
||||
if (!idb) return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = idb.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
|
||||
if (!db.objectStoreNames.contains(PROJECTS_STORE)) {
|
||||
db.createObjectStore(PROJECTS_STORE, { keyPath: "id" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(ASSETS_STORE)) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
||||
const response = await fetch(dataUrl);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function hashBlob(blob: Blob): Promise<string> {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
if (typeof crypto !== "undefined" && crypto.subtle) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-");
|
||||
}
|
||||
|
||||
function makeLinkId(projectId: string, layerId: string): string {
|
||||
return `${projectId}:${layerId}`;
|
||||
}
|
||||
|
||||
function isBinaryLayer(layer: Layer): boolean {
|
||||
return layer.type === "image" || layer.type === "sticker";
|
||||
}
|
||||
|
||||
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";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
|
||||
if (!isBinaryLayer(layer)) return layer.assetId;
|
||||
|
||||
if (layer.sourceUri?.startsWith("data:image/")) {
|
||||
const blob = await dataUrlToBlob(layer.sourceUri);
|
||||
const hash = await hashBlob(blob);
|
||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||
const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[];
|
||||
const reusable = matching.find((asset) => asset.blob.size === blob.size);
|
||||
const now = new Date().toISOString();
|
||||
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
|
||||
|
||||
await requestToPromise(
|
||||
assetStore.put({
|
||||
id,
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
blob,
|
||||
hash,
|
||||
createdAt: reusable?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies AssetRecord),
|
||||
);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
return layer.assetId;
|
||||
}
|
||||
|
||||
type PreparedLayerAsset = {
|
||||
layerIndex: number;
|
||||
blob: Blob;
|
||||
hash: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
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 hash = await hashBlob(blob);
|
||||
prepared.push({
|
||||
layerIndex: index,
|
||||
blob,
|
||||
hash,
|
||||
mimeType: blob.type || inferMimeType(layer),
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
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 existingMap = new Map(existing.map((link) => [link.id, link]));
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const layer of project.layers) {
|
||||
if (!layer.assetId || !isBinaryLayer(layer)) continue;
|
||||
const id = makeLinkId(project.id, layer.id);
|
||||
referenced.add(layer.assetId);
|
||||
await requestToPromise(
|
||||
store.put({
|
||||
id,
|
||||
projectId: project.id,
|
||||
layerId: layer.id,
|
||||
assetId: layer.assetId,
|
||||
updatedAt: now,
|
||||
} satisfies AssetLinkRecord),
|
||||
);
|
||||
existingMap.delete(id);
|
||||
}
|
||||
|
||||
const removedAssetIds = new Set<string>();
|
||||
for (const stale of existingMap.values()) {
|
||||
removedAssetIds.add(stale.assetId);
|
||||
await requestToPromise(store.delete(stale.id));
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
|
||||
for (const id of removedAssetIds) referenced.add(id);
|
||||
return referenced;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const candidates = candidateAssetIds
|
||||
? Array.from(new Set(candidateAssetIds)).filter(Boolean)
|
||||
: ((await requestToPromise(assetsStore.getAllKeys())) as string[]);
|
||||
|
||||
let removed = 0;
|
||||
for (const assetId of candidates) {
|
||||
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") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlByAssetId.delete(assetId);
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
assetsTx.oncomplete = () => resolve();
|
||||
assetsTx.onerror = () => reject(assetsTx.error);
|
||||
assetsTx.onabort = () => reject(assetsTx.error);
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
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");
|
||||
const assetStore = assetTx.objectStore(ASSETS_STORE);
|
||||
const layers = [...normalized.layers];
|
||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
||||
|
||||
for (const prepared of preparedAssets) {
|
||||
const layer = layers[prepared.layerIndex];
|
||||
if (!layer) continue;
|
||||
const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[];
|
||||
const reusable = matching.find((asset) => asset.blob.size === prepared.blob.size);
|
||||
const now = new Date().toISOString();
|
||||
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
|
||||
|
||||
await requestToPromise(
|
||||
assetStore.put({
|
||||
id,
|
||||
mimeType: prepared.mimeType,
|
||||
blob: prepared.blob,
|
||||
hash: prepared.hash,
|
||||
createdAt: reusable?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
} satisfies AssetRecord),
|
||||
);
|
||||
|
||||
layers[prepared.layerIndex] = {
|
||||
...layer,
|
||||
assetId: id,
|
||||
sourceUri: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
if (!layer || !isBinaryLayer(layer)) continue;
|
||||
const assetId = await putAssetFromLayer(assetStore, layer);
|
||||
if (!assetId) continue;
|
||||
layers[index] = {
|
||||
...layer,
|
||||
assetId,
|
||||
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
|
||||
};
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
assetTx.oncomplete = () => resolve();
|
||||
assetTx.onerror = () => reject(assetTx.error);
|
||||
assetTx.onabort = () => reject(assetTx.error);
|
||||
});
|
||||
|
||||
const persisted = { ...normalized, layers };
|
||||
const candidateAssets = await syncLinksForProject(db, persisted);
|
||||
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).put(persisted));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
projectTx.oncomplete = () => resolve();
|
||||
projectTx.onerror = () => reject(projectTx.error);
|
||||
projectTx.onabort = () => reject(projectTx.error);
|
||||
});
|
||||
|
||||
await cleanupOrphansInternal(db, candidateAssets);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
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;
|
||||
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 objectUrl = URL.createObjectURL(record.blob);
|
||||
objectUrlByAssetId.set(layer.assetId, objectUrl);
|
||||
return { ...layer, sourceUri: objectUrl };
|
||||
}
|
||||
return layer;
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...project, layers };
|
||||
}
|
||||
|
||||
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);
|
||||
if (!url) continue;
|
||||
if (url.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
objectUrlByAssetId.delete(layer.assetId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOrphanAssets(): Promise<number> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return 0;
|
||||
return cleanupOrphansInternal(db);
|
||||
}
|
||||
|
||||
export function startAssetCleanupJob(intervalMs = 45_000): () => void {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
const id = window.setInterval(() => {
|
||||
void cleanupOrphanAssets();
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}
|
||||
|
||||
export async function saveProjects(projects: Project[]): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
|
||||
const existing = await loadProjects();
|
||||
const keep = new Set(projects.map((project) => project.id));
|
||||
for (const project of existing) {
|
||||
if (!keep.has(project.id)) {
|
||||
await deleteProject(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
await persistProject(db, project);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProjects(): Promise<Project[]> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return [];
|
||||
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
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)));
|
||||
void cleanupOrphansInternal(db);
|
||||
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export async function getProjectById(projectId: string): Promise<Project | null> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return null;
|
||||
|
||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
||||
const parsed = ProjectSchema.safeParse(record);
|
||||
if (!parsed.success) return null;
|
||||
return hydrateProject(db, normalizeProject(parsed.data));
|
||||
}
|
||||
|
||||
export async function upsertProject(project: Project): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
|
||||
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[];
|
||||
for (const link of links) {
|
||||
await requestToPromise(linksStore.delete(link.id));
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
linksTx.oncomplete = () => resolve();
|
||||
linksTx.onerror = () => reject(linksTx.error);
|
||||
linksTx.onabort = () => reject(linksTx.error);
|
||||
});
|
||||
|
||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
||||
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
|
||||
}
|
||||
|
||||
export async function duplicateProject(projectId: string): Promise<Project | null> {
|
||||
const source = await getProjectById(projectId);
|
||||
if (!source) return null;
|
||||
const now = new Date().toISOString();
|
||||
const copy: Project = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
title: `${source.title} Copy`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
layers: source.layers.map((layer) => ({ ...layer, id: crypto.randomUUID() })),
|
||||
};
|
||||
await upsertProject(copy);
|
||||
return getProjectById(copy.id);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pien-studio/types",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DeviceSessionSchema, 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",
|
||||
title: "test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1080, height: 1350, unit: "px" },
|
||||
layers: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates versioned project file envelope", () => {
|
||||
const now = new Date().toISOString();
|
||||
const result = ProjectFileSchema.safeParse({
|
||||
format: "pien.project",
|
||||
version: 1,
|
||||
exportedAt: now,
|
||||
app: { name: "pien.studio", platform: "web" },
|
||||
project: {
|
||||
id: "p1",
|
||||
title: "test",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
aspectRatio: "4:5",
|
||||
canvas: { width: 1080, height: 1350, unit: "px" },
|
||||
layers: [],
|
||||
},
|
||||
assets: [],
|
||||
history: { checkpointCount: 0 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const LayerTypeSchema = z.enum(["image", "text", "sticker"]);
|
||||
|
||||
export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
|
||||
|
||||
export const FaceBlurRegionSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
sourceWidth: z.number().optional(),
|
||||
sourceHeight: z.number().optional(),
|
||||
censorColor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FaceBlurSettingsSchema = z.object({
|
||||
method: FaceBlurMethodSchema,
|
||||
amount: z.number().int().min(4).max(40),
|
||||
regions: z.array(FaceBlurRegionSchema),
|
||||
censorColor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const LayerSchema = z.object({
|
||||
id: z.string(),
|
||||
type: LayerTypeSchema,
|
||||
name: z.string().optional(),
|
||||
assetId: z.string().optional(),
|
||||
sourceUri: z.string().optional(),
|
||||
faceBlur: FaceBlurSettingsSchema.optional(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
scale: z.number().default(1),
|
||||
rotation: z.number().default(0),
|
||||
opacity: z.number().min(0).max(1).default(1),
|
||||
});
|
||||
|
||||
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
|
||||
]);
|
||||
|
||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
|
||||
export const CanvasSizeSchema = z.object({
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
unit: z.enum(["px", "in", "cm"]).default("px"),
|
||||
});
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
canvas: CanvasSizeSchema,
|
||||
aspectRatio: AspectRatioSchema,
|
||||
layers: z.array(LayerSchema),
|
||||
});
|
||||
|
||||
export const PresetAspectRatioSchema = z.object({
|
||||
label: z.string(),
|
||||
value: AspectRatioSchema,
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
});
|
||||
|
||||
export type PresetAspectRatio = z.infer<typeof PresetAspectRatioSchema>;
|
||||
|
||||
export const PRESET_CANVAS_SIZES: PresetAspectRatio[] = [
|
||||
{ label: "Square (1:1)", value: "1:1", width: 1080, height: 1080 },
|
||||
{ label: "Portrait (4:5)", value: "4:5", width: 1080, height: 1350 },
|
||||
{ label: "Story (9:16)", value: "9:16", width: 1080, height: 1920 },
|
||||
{ label: "Widescreen (16:9)", value: "16:9", width: 1920, height: 1080 },
|
||||
{ label: "Photo (4:3)", value: "4:3", width: 1440, height: 1080 },
|
||||
{ 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),
|
||||
exportedAt: z.string(),
|
||||
app: z.object({
|
||||
name: z.literal("pien.studio"),
|
||||
platform: z.enum(["web", "mobile", "desktop"]).default("web"),
|
||||
}),
|
||||
project: ProjectSchema,
|
||||
assets: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["image", "sticker", "font", "frame"]),
|
||||
name: z.string(),
|
||||
uri: z.string(),
|
||||
checksum: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
history: z.object({
|
||||
checkpointCount: z.number().int().nonnegative().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectFileSchema = ProjectFileV1Schema;
|
||||
|
||||
export type Project = z.infer<typeof ProjectSchema>;
|
||||
export type Layer = z.infer<typeof LayerSchema>;
|
||||
export type LayerType = z.infer<typeof LayerTypeSchema>;
|
||||
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
||||
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
||||
export type FaceBlurSettings = z.infer<typeof FaceBlurSettingsSchema>;
|
||||
export type ProjectFile = z.infer<typeof ProjectFileSchema>;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@pien-studio/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./tokens";
|
||||
@@ -0,0 +1,13 @@
|
||||
export const themeTokens = {
|
||||
radius: {
|
||||
sm: "10px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
},
|
||||
spacing: {
|
||||
xs: "4px",
|
||||
sm: "8px",
|
||||
md: "12px",
|
||||
lg: "20px",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user