feat: more tests, refactored

This commit is contained in:
2026-05-24 21:05:21 +07:00
parent e7dd9420e7
commit c1f12c7201
33 changed files with 1403 additions and 334 deletions
@@ -17,7 +17,7 @@ function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
};
}
export const faceBlurDefinition: EffectDefinition<FaceBlurEffect> = {
export const faceBlurDefinition: EffectDefinition = {
kind: "face-blur",
normalize: normalizeFaceBlur,
normalize: (effect) => normalizeFaceBlur(effect),
};
+4 -4
View File
@@ -1,9 +1,9 @@
import type { LayerEffect } from "@pien-studio/types";
import { faceBlurDefinition } from "./face-blur";
export type EffectDefinition<T extends LayerEffect = LayerEffect> = {
kind: T["kind"];
normalize: (effect: T) => T;
export type EffectDefinition = {
kind: LayerEffect["kind"];
normalize: (effect: LayerEffect) => LayerEffect;
};
const definitions = [faceBlurDefinition] as EffectDefinition[];
@@ -19,5 +19,5 @@ export function getEffectDefinition(kind: string): EffectDefinition | undefined
export function normalizeEffect(effect: LayerEffect): LayerEffect {
const def = effectRegistry.get(effect.kind);
if (!def) return effect;
return def.normalize(effect as never);
return def.normalize(effect);
}
+101 -55
View File
@@ -3,9 +3,32 @@ import {
addLayer,
createProject,
moveLayer,
removeLayer,
reorderLayer,
setEffectEnabled,
setLayerEffect,
setLayerVisible,
normalizeProject,
parseProjectFile,
serializeProjectFile,
setCanvasSize,
updateLayerTransform,
} from "./index";
import type { Layer } 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,
};
}
describe("editor-core", () => {
it("creates project with defaults", () => {
@@ -17,17 +40,7 @@ describe("editor-core", () => {
it("adds a new layer and updates timestamp", () => {
const project = createProject("new");
const updated = addLayer(project, {
id: "l1",
type: "raster",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const updated = addLayer(project, makeLayer("l1"));
expect(updated.layers).toHaveLength(1);
expect(updated.layers[0]?.id).toBe("l1");
@@ -35,17 +48,7 @@ describe("editor-core", () => {
});
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,
effects: [],
visible: true,
});
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);
@@ -53,17 +56,7 @@ describe("editor-core", () => {
});
it("updates transform fields", () => {
const project = addLayer(createProject("transform"), {
id: "l1",
type: "text",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const project = addLayer(createProject("transform"), makeLayer("l1", "text"));
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
expect(updated.layers[0]?.scale).toBe(1.35);
@@ -72,30 +65,83 @@ describe("editor-core", () => {
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,
effects: [],
visible: true,
});
const withSecond = addLayer(withFirst, {
id: "l2",
type: "sticker",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
});
const withFirst = addLayer(base, makeLayer("l1", "text"));
const withSecond = addLayer(withFirst, makeLayer("l2", "sticker"));
const reordered = reorderLayer(withSecond, "l1", 1);
expect(reordered.layers.map((layer) => layer.id)).toEqual(["l2", "l1"]);
});
it("preserves project identity for missing layers and unchanged transforms", () => {
const project = addLayer(createProject("noop"), makeLayer("l1"));
expect(removeLayer(project, "missing")).toBe(project);
expect(moveLayer(project, "l1", { dx: 0, dy: 0 })).toBe(project);
expect(updateLayerTransform(project, "l1", { x: 0, y: 0 })).toBe(project);
expect(reorderLayer(project, "l1", 0)).toBe(project);
});
it("normalizes effects before writing them to layers", () => {
const project = addLayer(createProject("effects"), makeLayer("l1"));
const updated = setLayerEffect(project, "l1", {
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 99,
regions: [{ x: Number.NaN, y: 3, width: 0, height: Number.NaN }],
});
expect(updated.layers[0]?.effects[0]).toEqual({
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 40,
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: [],
});
expect(setLayerVisible(project, "l1", true)).toBe(project);
expect(setEffectEnabled(project, "l1", "face-blur", true)).toBe(project);
});
it("normalizes project geometry and layer fields", () => {
const project = addLayer(createProject("normalize"), {
...makeLayer("l1"),
width: 10.4,
height: 0,
scale: Number.NaN,
rotation: Number.NaN,
opacity: 4,
});
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 });
});
it("serializes and parses project files with normalized projects", () => {
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" });
});
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" });
});
});
+41 -34
View File
@@ -1,4 +1,5 @@
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { normalizeEffect } from "./effects/registry";
export type LayerFactoryOptions = {
id?: string;
@@ -26,6 +27,22 @@ 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 {
let changed = false;
const layers = project.layers.map((layer) => {
if (layer.id !== layerId) return layer;
const nextLayer = updater(layer);
if (nextLayer !== layer) changed = true;
return nextLayer;
});
return changed ? withUpdatedAt(project, layers) : project;
}
function hasTransformPatchChange(layer: Layer, patch: TransformPatch): boolean {
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => layer[key] !== patch[key]);
}
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
return {
id: options.id ?? crypto.randomUUID(),
@@ -49,21 +66,17 @@ 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));
}
export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, x: l.x + delta.dx, y: l.y + delta.dy } : l)),
);
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 }));
}
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, ...patch } : l)),
);
return updateLayer(project, layerId, (l) => (hasTransformPatchChange(l, patch) ? { ...l, ...patch } : l));
}
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
@@ -72,43 +85,37 @@ export function reorderLayer(project: Project, layerId: string, toIndex: number)
const layers = [...project.layers];
const [picked] = layers.splice(fromIndex, 1);
if (!picked) return project;
layers.splice(Math.max(0, Math.min(toIndex, layers.length)), 0, picked);
const nextIndex = Math.max(0, Math.min(toIndex, layers.length));
if (fromIndex === nextIndex) return project;
layers.splice(nextIndex, 0, picked);
return withUpdatedAt(project, layers);
}
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
return withUpdatedAt(
project,
project.layers.map((l) => {
if (l.id !== layerId) return l;
const idx = l.effects.findIndex((e) => e.kind === effect.kind);
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? effect : e)) : [...l.effects, effect];
return { ...l, effects };
}),
);
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];
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
return { ...l, effects };
});
}
export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project {
return withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, effects: l.effects.filter((e) => e.kind !== kind) } : l)),
);
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 withUpdatedAt(
project,
project.layers.map((l) => (l.id === layerId ? { ...l, visible } : l)),
);
return updateLayer(project, layerId, (l) => (l.visible === visible ? l : { ...l, visible }));
}
export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project {
return withUpdatedAt(
project,
project.layers.map((l) =>
l.id === layerId
? { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) }
: l,
),
);
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)) };
});
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { Layer, Project } from "@pien-studio/types";
import {
buildAssetLinks,
buildAssetRecord,
chooseReusableAsset,
collectProjectAssetIds,
inferMimeType,
isBinaryLayer,
makeLinkId,
stripEmbeddedSourceUri,
} from "./asset-records";
function layer(partial: Partial<Layer> = {}): Layer {
return {
id: "layer-1",
type: "raster",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
visible: true,
effects: [],
...partial,
};
}
function project(layers: Layer[]): Project {
return {
id: "project-1",
title: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
canvas: { width: 100, height: 100, unit: "px" },
aspectRatio: "1:1",
layers,
};
}
describe("asset record helpers", () => {
it("identifies binary layers and infers mime types", () => {
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");
});
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();
});
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({
id: "reused",
createdAt: "then",
updatedAt: "now",
});
});
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: "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" },
]);
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");
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { Layer, Project } from "@pien-studio/types";
export type AssetRecordInput = {
existingId?: string;
reusable?: { id: string; createdAt: string };
fallbackId: string;
mimeType: string;
blob: Blob;
hash: string;
now: string;
};
export type AssetLinkRecordInput = {
id: string;
projectId: string;
layerId: string;
assetId: string;
updatedAt: string;
};
export function makeLinkId(projectId: string, layerId: string): string {
return `${projectId}:${layerId}`;
}
export function isBinaryLayer(layer: Layer): boolean {
return layer.type === "raster" || layer.type === "sticker";
}
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";
return "image/jpeg";
}
export function chooseReusableAsset<T extends { blob: Blob }>(assets: T[], blob: Blob): T | undefined {
return assets.find((asset) => asset.blob.size === blob.size);
}
export function buildAssetRecord(input: AssetRecordInput) {
return {
id: input.existingId ?? input.reusable?.id ?? input.fallbackId,
mimeType: input.mimeType,
blob: input.blob,
hash: input.hash,
createdAt: input.reusable?.createdAt ?? input.now,
updatedAt: input.now,
};
}
export function stripEmbeddedSourceUri(layer: Layer): Layer {
return {
...layer,
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
};
}
export function buildAssetLinks(project: Project, updatedAt: string): AssetLinkRecordInput[] {
return project.layers
.filter((layer) => layer.assetId && isBinaryLayer(layer))
.map((layer) => ({
id: makeLinkId(project.id, layer.id),
projectId: project.id,
layerId: layer.id,
assetId: layer.assetId as string,
updatedAt,
}));
}
export function collectProjectAssetIds(project: Project): Set<string> {
return new Set(buildAssetLinks(project, new Date(0).toISOString()).map((link) => link.assetId));
}
+112 -9
View File
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "fake-indexeddb/auto";
import { getProjectById, loadProjects } from "./index";
import { deleteProject, duplicateProject, getProjectById, loadProjects, saveProjects, upsertProject } from "./index";
import type { Project } from "@pien-studio/types";
const DB_NAME = "pien.db";
@@ -60,6 +60,25 @@ async function readRawProject(projectId: string): Promise<Project | undefined> {
return record;
}
async function readStoreCount(storeName: string): Promise<number> {
const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
openRequest.onupgradeneeded = () => ensureSchema(openRequest.result);
const db = await requestToPromise(openRequest);
const tx = db.transaction(storeName, "readonly");
const count = await requestToPromise(tx.objectStore(storeName).count());
db.close();
return count;
}
async function resetDatabase(): Promise<void> {
await new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => resolve();
});
}
function makeMalformedProject(id: string): Project {
const now = new Date().toISOString();
return {
@@ -80,6 +99,7 @@ function makeMalformedProject(id: string): Project {
scale: 0,
rotation: 720.4,
opacity: 0.75,
visible: true,
effects: [],
},
],
@@ -121,14 +141,43 @@ function makeFaceBlurProject(id: string): Project {
};
}
function makeAssetProject(id: string, sourceUri = "data:image/png;base64,aGVsbG8="): Project {
const now = new Date().toISOString();
return {
id,
title: id,
createdAt: now,
updatedAt: now,
aspectRatio: "1:1",
canvas: { width: 100, height: 100, unit: "px" },
layers: [
{
id: "image-1",
type: "raster",
sourceUri,
x: 0,
y: 0,
width: 10,
height: 10,
scale: 1,
rotation: 0,
opacity: 1,
visible: true,
effects: [],
},
],
};
}
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();
});
beforeEach(async () => {
await resetDatabase();
vi.spyOn(URL, "createObjectURL").mockImplementation((obj: Blob | MediaSource) => `blob:test-${"size" in obj ? obj.size : "media"}`);
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("loadProjects hydrates normalized values without persisting writes", async () => {
@@ -173,4 +222,58 @@ describe("storage read flows", () => {
expect(listedRegion?.sourceWidth).toBeUndefined();
expect(listedRegion?.sourceHeight).toBeUndefined();
}, 15_000);
it("persists embedded raster images as assets and hydrates object URLs", async () => {
await upsertProject(makeAssetProject("asset-project"));
const raw = await readRawProject("asset-project");
expect(raw?.layers[0]?.assetId).toBeDefined();
expect(raw?.layers[0]?.sourceUri).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(URL.createObjectURL).toHaveBeenCalledTimes(1);
});
it("duplicates projects with new ids while reusing persisted asset content", async () => {
await upsertProject(makeAssetProject("source-project"));
const copy = await duplicateProject("source-project");
expect(copy?.id).not.toBe("source-project");
expect(copy?.title).toBe("source-project Copy");
expect(copy?.layers[0]?.id).not.toBe("image-1");
expect(await readStoreCount(PROJECTS_STORE)).toBe(2);
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(2);
});
it("deletes projects and cleans orphaned assets", async () => {
await upsertProject(makeAssetProject("delete-project"));
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
await deleteProject("delete-project");
expect(await getProjectById("delete-project")).toBeNull();
expect(await readStoreCount(PROJECTS_STORE)).toBe(0);
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(0);
expect(await readStoreCount(ASSETS_STORE)).toBe(0);
});
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==");
await upsertProject(keep);
await upsertProject(drop);
await saveProjects([{ ...keep, title: "kept" }]);
const projects = await loadProjects();
expect(projects.map((project) => project.id)).toEqual(["keep-project"]);
expect(projects[0]?.title).toBe("kept");
expect(await readStoreCount(PROJECTS_STORE)).toBe(1);
expect(await readStoreCount(ASSET_LINKS_STORE)).toBe(1);
expect(await readStoreCount(ASSETS_STORE)).toBe(1);
});
});
+102 -94
View File
@@ -1,5 +1,14 @@
import { normalizeProject } from "@pien-studio/editor-core";
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
import {
buildAssetLinks,
buildAssetRecord,
chooseReusableAsset,
inferMimeType,
isBinaryLayer,
makeLinkId,
stripEmbeddedSourceUri,
} from "./asset-records";
const DB_NAME = "pien.db";
const DB_VERSION = 4;
@@ -95,20 +104,6 @@ async function hashBlob(blob: Blob): Promise<string> {
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 === "raster" || 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;
@@ -117,22 +112,21 @@ async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Prom
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 reusable = chooseReusableAsset(matching, blob);
const now = new Date().toISOString();
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
const record = buildAssetRecord({
existingId: layer.assetId,
reusable,
fallbackId: crypto.randomUUID(),
mimeType: blob.type || inferMimeType(layer),
blob,
hash,
now,
});
await requestToPromise(
assetStore.put({
id,
mimeType: blob.type || inferMimeType(layer),
blob,
hash,
createdAt: reusable?.createdAt ?? now,
updatedAt: now,
} satisfies AssetRecord),
);
await requestToPromise(assetStore.put(record satisfies AssetRecord));
return id;
return record.id;
}
return layer.assetId;
@@ -170,21 +164,13 @@ async function syncLinksForProject(db: IDBDatabase, project: Project): Promise<S
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);
const links = buildAssetLinks(project, now);
const referenced = new Set(links.map((link) => link.assetId));
for (const link of links) {
await requestToPromise(
store.put({
id,
projectId: project.id,
layerId: layer.id,
assetId: layer.assetId,
updatedAt: now,
} satisfies AssetLinkRecord),
store.put(link satisfies AssetLinkRecord),
);
existingMap.delete(id);
existingMap.delete(link.id);
}
const removedAssetIds = new Set<string>();
@@ -247,24 +233,23 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
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 reusable = chooseReusableAsset(matching, prepared.blob);
const now = new Date().toISOString();
const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID();
const record = buildAssetRecord({
existingId: layer.assetId,
reusable,
fallbackId: crypto.randomUUID(),
mimeType: prepared.mimeType,
blob: prepared.blob,
hash: prepared.hash,
now,
});
await requestToPromise(
assetStore.put({
id,
mimeType: prepared.mimeType,
blob: prepared.blob,
hash: prepared.hash,
createdAt: reusable?.createdAt ?? now,
updatedAt: now,
} satisfies AssetRecord),
);
await requestToPromise(assetStore.put(record satisfies AssetRecord));
layers[prepared.layerIndex] = {
...layer,
assetId: id,
assetId: record.id,
sourceUri: undefined,
};
}
@@ -275,9 +260,8 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
const assetId = await putAssetFromLayer(assetStore, layer);
if (!assetId) continue;
layers[index] = {
...layer,
...stripEmbeddedSourceUri(layer),
assetId,
sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri,
};
}
@@ -342,7 +326,11 @@ export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterab
export async function cleanupOrphanAssets(): Promise<number> {
const db = await openDatabase();
if (!db) return 0;
return cleanupOrphansInternal(db);
try {
return await cleanupOrphansInternal(db);
} finally {
db.close();
}
}
export function startAssetCleanupJob(intervalMs = 45_000): () => void {
@@ -357,16 +345,20 @@ 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);
try {
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);
for (const project of projects) {
await persistProject(db, project);
}
} finally {
db.close();
}
}
@@ -374,55 +366,71 @@ 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));
try {
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));
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 {
db.close();
}
}
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));
try {
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 await hydrateProject(db, normalizeProject(parsed.data));
} finally {
db.close();
}
}
export async function upsertProject(project: Project): Promise<void> {
const db = await openDatabase();
if (!db) return;
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
try {
await persistProject(db, { ...project, updatedAt: new Date().toISOString() });
} finally {
db.close();
}
}
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);
});
try {
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));
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
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> {
-2
View File
@@ -125,6 +125,4 @@ 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>;
// Kept for compatibility with existing renderer code
export type FaceBlurSettings = Omit<FaceBlurEffect, "kind">;
export type ProjectFile = z.infer<typeof ProjectFileSchema>;