mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: more tests, refactored
This commit is contained in:
@@ -7,6 +7,7 @@ node_modules
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
coverage/
|
||||||
/playwright-report
|
/playwright-report
|
||||||
/test-results
|
/test-results
|
||||||
|
|
||||||
|
|||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
bun run check
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"rootDir": "../..",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ToolRail } from "../../../components/editor/tool-rail";
|
|||||||
import { exportProjectAsPng } from "../../../lib/export-png";
|
import { exportProjectAsPng } from "../../../lib/export-png";
|
||||||
import { floodFillDataUrl } from "../../../lib/flood-fill";
|
import { floodFillDataUrl } from "../../../lib/flood-fill";
|
||||||
import { commitStroke, type BrushStroke } from "../../../lib/brush-painter";
|
import { commitStroke, type BrushStroke } from "../../../lib/brush-painter";
|
||||||
import { createEditorToolControllers } from "../../../lib/editor-tool-controller";
|
import type { EditorToolController } from "../../../lib/editor-tool-controller";
|
||||||
import { useFaceDetection } from "../../../hooks/use-face-detection";
|
import { useFaceDetection } from "../../../hooks/use-face-detection";
|
||||||
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
|
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
|
||||||
import { useEditorAutosave } from "../../../hooks/use-editor-autosave";
|
import { useEditorAutosave } from "../../../hooks/use-editor-autosave";
|
||||||
@@ -111,7 +111,7 @@ export default function EditorPage() {
|
|||||||
width: selectedLayer.width,
|
width: selectedLayer.width,
|
||||||
height: selectedLayer.height,
|
height: selectedLayer.height,
|
||||||
};
|
};
|
||||||
}, [selectedLayer?.id, selectedLayer?.type, selectedLayer?.sourceUri, selectedLayer?.width, selectedLayer?.height]);
|
}, [selectedLayer]);
|
||||||
|
|
||||||
const isLayerStillSelected = React.useCallback((layerId: string) => {
|
const isLayerStillSelected = React.useCallback((layerId: string) => {
|
||||||
const state = useEditorStore.getState();
|
const state = useEditorStore.getState();
|
||||||
@@ -120,7 +120,6 @@ export default function EditorPage() {
|
|||||||
|
|
||||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
||||||
tool,
|
tool,
|
||||||
selectedLayerId,
|
|
||||||
selectedImageLayer,
|
selectedImageLayer,
|
||||||
activeLayerStillSelected: isLayerStillSelected,
|
activeLayerStillSelected: isLayerStillSelected,
|
||||||
});
|
});
|
||||||
@@ -173,7 +172,7 @@ export default function EditorPage() {
|
|||||||
|
|
||||||
const handleExportPng = React.useCallback(async () => {
|
const handleExportPng = React.useCallback(async () => {
|
||||||
await exportProjectAsPng(project, { isDark });
|
await exportProjectAsPng(project, { isDark });
|
||||||
}, [isDark, project, exportProjectAsPng]);
|
}, [isDark, project]);
|
||||||
|
|
||||||
const handleExportProjectFile = React.useCallback(() => {
|
const handleExportProjectFile = React.useCallback(() => {
|
||||||
const json = exportProjectToJson();
|
const json = exportProjectToJson();
|
||||||
@@ -219,6 +218,7 @@ export default function EditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
||||||
|
void aspect;
|
||||||
setCanvasSize(width, height);
|
setCanvasSize(width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,23 +271,19 @@ export default function EditorPage() {
|
|||||||
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer");
|
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer");
|
||||||
}, [cw, ch, addCanvasSizedLayer]);
|
}, [cw, ch, addCanvasSizedLayer]);
|
||||||
|
|
||||||
const toolControllers = React.useMemo(
|
const handleImportImageClick = React.useCallback(() => {
|
||||||
() =>
|
imageInputRef.current?.click();
|
||||||
createEditorToolControllers({
|
}, []);
|
||||||
onAddTextLayer: () => addLayerByType("text"),
|
|
||||||
onImportImage: () => imageInputRef.current?.click(),
|
const toolControllers = React.useMemo<EditorToolController[]>(() => [
|
||||||
labels: {
|
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
|
||||||
pointer: t("editor.toolPointer"),
|
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
|
||||||
pan: t("editor.toolPan"),
|
{ kind: "mode", id: "face", label: t("editor.toolFace") },
|
||||||
face: t("editor.toolFace"),
|
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
|
||||||
fill: t("editor.toolFill"),
|
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
|
||||||
brush: t("editor.toolBrush"),
|
{ kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") },
|
||||||
text: t("editor.toolText"),
|
{ kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick },
|
||||||
image: t("editor.toolImage"),
|
], [addLayerByType, handleImportImageClick, t]);
|
||||||
},
|
|
||||||
}),
|
|
||||||
[addLayerByType, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const canvasBindings = {
|
const canvasBindings = {
|
||||||
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
|
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
hydrate();
|
hydrate();
|
||||||
void refreshProjects();
|
loadProjects().then((loadedProjects) => {
|
||||||
}, [hydrate, refreshProjects]);
|
if (!canceled) setProjects(loadedProjects);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [hydrate]);
|
||||||
|
|
||||||
function openProject(project: Project) {
|
function openProject(project: Project) {
|
||||||
setProject(project);
|
setProject(project);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { CanvasRenderer } from "./canvas-renderer";
|
|||||||
import type { Layer } from "@pien-studio/types";
|
import type { Layer } from "@pien-studio/types";
|
||||||
|
|
||||||
vi.mock("next/image", () => ({
|
vi.mock("next/image", () => ({
|
||||||
default: ({ alt, src, unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => React.createElement("img", { alt, src, ...props }),
|
default: ({ alt, src, unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) =>
|
||||||
|
React.createElement("img", { alt, src, "data-unoptimized": unoptimized ? "true" : undefined, ...props }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/use-translations", () => ({
|
vi.mock("../hooks/use-translations", () => ({
|
||||||
|
|||||||
@@ -231,9 +231,7 @@ export function CanvasRenderer({
|
|||||||
faceOverlayLayerId,
|
faceOverlayLayerId,
|
||||||
layers,
|
layers,
|
||||||
tool,
|
tool,
|
||||||
viewport.scale,
|
viewport,
|
||||||
viewport.x,
|
|
||||||
viewport.y,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ export function EditorSidebar({
|
|||||||
className="w-full accent-[var(--color-accent-strong)]"
|
className="w-full accent-[var(--color-accent-strong)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{onCreateFillLayer ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreateFillLayer}
|
||||||
|
className={`mt-3 w-full rounded border px-3 py-2 text-xs font-semibold ${isDark ? "border-white/10 bg-[#353942] text-[#f0f3f8] hover:bg-[#3d424d]" : "border-black/10 bg-[#f4f6f9] text-[#1f2430] hover:bg-white"}`}
|
||||||
|
>
|
||||||
|
Create fill layer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTypescript from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...nextVitals,
|
||||||
|
...nextTypescript,
|
||||||
|
{
|
||||||
|
ignores: [".next/**", "coverage/**", "next-env.d.ts"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -7,6 +7,11 @@ type FaceBlurPreview = {
|
|||||||
effects: LayerEffect[];
|
effects: LayerEffect[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FaceSelectionState = {
|
||||||
|
key: string;
|
||||||
|
indices: number[];
|
||||||
|
};
|
||||||
|
|
||||||
type UseFaceBlurWorkflowOptions = {
|
type UseFaceBlurWorkflowOptions = {
|
||||||
selectedLayer: Layer | null;
|
selectedLayer: Layer | null;
|
||||||
faceDetectionsLayerId: string | null;
|
faceDetectionsLayerId: string | null;
|
||||||
@@ -20,8 +25,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
||||||
const [blurAmount, setBlurAmount] = React.useState(14);
|
const [blurAmount, setBlurAmount] = React.useState(14);
|
||||||
const [censorColor, setCensorColor] = React.useState("#111111");
|
const [censorColor, setCensorColor] = React.useState("#111111");
|
||||||
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
|
const [faceSelection, setFaceSelection] = React.useState<FaceSelectionState | null>(null);
|
||||||
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
|
|
||||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
|
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
|
||||||
|
|
||||||
const faceBlurEffect = React.useMemo(
|
const faceBlurEffect = React.useMemo(
|
||||||
@@ -29,6 +33,15 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
[selectedLayer?.effects],
|
[selectedLayer?.effects],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectionKey = `${selectedLayer?.id ?? "none"}:${faceDetectionsLayerId ?? "none"}:${faceDetections.length}:${faceBlurEffect ? "effect" : "empty"}`;
|
||||||
|
|
||||||
|
const defaultSelectedFaceIndices = React.useMemo(() => {
|
||||||
|
if (!hasDetectableSelection || faceBlurEffect) return [];
|
||||||
|
return faceDetections.map((_, index) => index);
|
||||||
|
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
|
||||||
|
|
||||||
|
const selectedFaceIndices = faceSelection?.key === selectionKey ? faceSelection.indices : defaultSelectedFaceIndices;
|
||||||
|
|
||||||
const buildBlurRegions = React.useCallback(
|
const buildBlurRegions = React.useCallback(
|
||||||
(indices: number[]) => {
|
(indices: number[]) => {
|
||||||
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
||||||
@@ -56,14 +69,18 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toggleFaceIndex = React.useCallback((index: number) => {
|
const toggleFaceIndex = React.useCallback((index: number) => {
|
||||||
setSelectedFaceIndices((prev) => (prev.includes(index) ? prev.filter((item) => item !== index) : [...prev, index]));
|
setFaceSelection((prev) => {
|
||||||
}, []);
|
const current = prev?.key === selectionKey ? prev.indices : defaultSelectedFaceIndices;
|
||||||
|
const indices = current.includes(index) ? current.filter((item) => item !== index) : [...current, index];
|
||||||
|
return { key: selectionKey, indices };
|
||||||
|
});
|
||||||
|
}, [defaultSelectedFaceIndices, selectionKey]);
|
||||||
|
|
||||||
const clearBlur = React.useCallback(() => {
|
const clearBlur = React.useCallback(() => {
|
||||||
if (!selectedLayer) return;
|
if (!selectedLayer) return;
|
||||||
removeLayerEffect(selectedLayer.id, "face-blur");
|
removeLayerEffect(selectedLayer.id, "face-blur");
|
||||||
setFaceBlurPreview(null);
|
setFaceSelection({ key: selectionKey, indices: [] });
|
||||||
}, [selectedLayer, removeLayerEffect]);
|
}, [removeLayerEffect, selectedLayer, selectionKey]);
|
||||||
|
|
||||||
const blurFaces = React.useCallback(
|
const blurFaces = React.useCallback(
|
||||||
(indices: number[]) => {
|
(indices: number[]) => {
|
||||||
@@ -78,41 +95,21 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
regions,
|
regions,
|
||||||
censorColor,
|
censorColor,
|
||||||
});
|
});
|
||||||
setSelectedFaceIndices([]);
|
setFaceSelection({ key: selectionKey, indices: [] });
|
||||||
setFaceBlurPreview(null);
|
|
||||||
},
|
},
|
||||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect],
|
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, selectionKey, setLayerEffect],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
|
||||||
if (!hasDetectableSelection) {
|
|
||||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
|
||||||
setFaceBlurPreview(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (faceBlurEffect) {
|
|
||||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedFaceIndices((prev) => {
|
|
||||||
const next = faceDetections.map((_, i) => i);
|
|
||||||
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [faceDetections, hasDetectableSelection, faceBlurEffect]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
|
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
|
||||||
setFaceBlurPreview(null);
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFaceIndices.length === 0) {
|
if (selectedFaceIndices.length === 0) {
|
||||||
setFaceBlurPreview(null);
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFaceBlurPreview({
|
return {
|
||||||
layerId: selectedLayer.id,
|
layerId: selectedLayer.id,
|
||||||
effects: [{
|
effects: [{
|
||||||
kind: "face-blur",
|
kind: "face-blur",
|
||||||
@@ -122,7 +119,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
|||||||
regions: buildBlurRegions(selectedFaceIndices),
|
regions: buildBlurRegions(selectedFaceIndices),
|
||||||
censorColor,
|
censorColor,
|
||||||
}],
|
}],
|
||||||
});
|
};
|
||||||
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ type SelectedImageLayer = {
|
|||||||
|
|
||||||
type UseFaceDetectionOptions = {
|
type UseFaceDetectionOptions = {
|
||||||
tool: EditorToolId;
|
tool: EditorToolId;
|
||||||
selectedLayerId: string | null;
|
|
||||||
selectedImageLayer: SelectedImageLayer | null;
|
selectedImageLayer: SelectedImageLayer | null;
|
||||||
activeLayerStillSelected: (layerId: string) => boolean;
|
activeLayerStillSelected: (layerId: string) => boolean;
|
||||||
};
|
};
|
||||||
@@ -40,7 +39,6 @@ type UseFaceDetectionOptions = {
|
|||||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||||
const {
|
const {
|
||||||
tool,
|
tool,
|
||||||
selectedLayerId,
|
|
||||||
selectedImageLayer,
|
selectedImageLayer,
|
||||||
activeLayerStillSelected,
|
activeLayerStillSelected,
|
||||||
} = options;
|
} = options;
|
||||||
@@ -63,6 +61,7 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
(status: "idle" | "detecting" | "unsupported" = "idle") => {
|
(status: "idle" | "detecting" | "unsupported" = "idle") => {
|
||||||
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
||||||
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
||||||
|
setFacePreviews((prev) => (prev.length === 0 ? prev : []));
|
||||||
setFaceStatus((prev) => (prev === status ? prev : status));
|
setFaceStatus((prev) => (prev === status ? prev : status));
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -115,18 +114,6 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
tool,
|
tool,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (tool !== "face") {
|
|
||||||
setFaceDetections([]);
|
|
||||||
setFaceDetectionsLayerId(null);
|
|
||||||
setFacePreviews([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFaceDetections([]);
|
|
||||||
setFaceDetectionsLayerId(null);
|
|
||||||
setFacePreviews([]);
|
|
||||||
}, [tool, selectedLayerId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildBrushDabs, clampBrushOptions, hexToRgb } from "./brush-painter";
|
||||||
|
|
||||||
|
describe("brush painter pure helpers", () => {
|
||||||
|
it("parses valid hex colors and falls back to black", () => {
|
||||||
|
expect(hexToRgb("#ff8040")).toEqual({ r: 255, g: 128, b: 64 });
|
||||||
|
expect(hexToRgb("00aaee")).toEqual({ r: 0, g: 170, b: 238 });
|
||||||
|
expect(hexToRgb("nope")).toEqual({ r: 0, g: 0, b: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps invalid brush options", () => {
|
||||||
|
expect(clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 })).toEqual({
|
||||||
|
color: "#fff",
|
||||||
|
size: 1,
|
||||||
|
opacity: 1,
|
||||||
|
hardness: 0,
|
||||||
|
});
|
||||||
|
expect(clampBrushOptions({ color: "#000", size: Number.NaN, opacity: Number.NaN, hardness: Number.NaN })).toEqual({
|
||||||
|
color: "#000",
|
||||||
|
size: 1,
|
||||||
|
opacity: 1,
|
||||||
|
hardness: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates brush dabs including endpoints", () => {
|
||||||
|
expect(buildBrushDabs(0, 0, 4, 0, 4)).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 2, y: 0 },
|
||||||
|
{ x: 3, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
]);
|
||||||
|
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([{ x: 2, y: 3 }, { x: 2, y: 3 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,8 +12,14 @@ export type BrushStroke = {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
export type BrushDab = {
|
||||||
const clean = hex.replace("#", "");
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const clean = hex.replace("#", "").trim();
|
||||||
|
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return { r: 0, g: 0, b: 0 };
|
||||||
return {
|
return {
|
||||||
r: parseInt(clean.slice(0, 2), 16),
|
r: parseInt(clean.slice(0, 2), 16),
|
||||||
g: parseInt(clean.slice(2, 4), 16),
|
g: parseInt(clean.slice(2, 4), 16),
|
||||||
@@ -21,20 +27,45 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clampBrushOptions(options: BrushOptions): BrushOptions {
|
||||||
|
return {
|
||||||
|
color: options.color,
|
||||||
|
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
|
||||||
|
opacity: Math.max(0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1)),
|
||||||
|
hardness: Math.max(0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrushDabs(x0: number, y0: number, x1: number, y1: number, size: number): BrushDab[] {
|
||||||
|
const dx = x1 - x0;
|
||||||
|
const dy = y1 - y0;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const step = Math.max(1, size * 0.25);
|
||||||
|
const steps = Math.max(1, Math.ceil(dist / step));
|
||||||
|
const dabs: BrushDab[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
dabs.push({ x: x0 + dx * t, y: y0 + dy * t });
|
||||||
|
}
|
||||||
|
|
||||||
|
return dabs;
|
||||||
|
}
|
||||||
|
|
||||||
function drawDab(
|
function drawDab(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
options: BrushOptions,
|
options: BrushOptions,
|
||||||
) {
|
) {
|
||||||
const r = options.size / 2;
|
const normalized = clampBrushOptions(options);
|
||||||
const { r: cr, g: cg, b: cb } = hexToRgb(options.color);
|
const r = normalized.size / 2;
|
||||||
|
const { r: cr, g: cg, b: cb } = hexToRgb(normalized.color);
|
||||||
|
|
||||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||||
const innerStop = Math.max(0, Math.min(1, options.hardness));
|
|
||||||
|
|
||||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
||||||
gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
gradient.addColorStop(normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
||||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -62,15 +93,9 @@ export function paintSegment(
|
|||||||
y1: number,
|
y1: number,
|
||||||
options: BrushOptions,
|
options: BrushOptions,
|
||||||
) {
|
) {
|
||||||
const dx = x1 - x0;
|
const normalized = clampBrushOptions(options);
|
||||||
const dy = y1 - y0;
|
for (const dab of buildBrushDabs(x0, y0, x1, y1, normalized.size)) {
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
drawDab(stroke.ctx, dab.x, dab.y, normalized);
|
||||||
const step = Math.max(1, options.size * 0.25);
|
|
||||||
const steps = Math.max(1, Math.ceil(dist / step));
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const t = steps === 0 ? 0 : i / steps;
|
|
||||||
drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,29 +14,3 @@ export type ToolActionController = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type EditorToolController = ToolModeController | ToolActionController;
|
export type EditorToolController = ToolModeController | ToolActionController;
|
||||||
|
|
||||||
type CreateEditorToolControllersOptions = {
|
|
||||||
onAddTextLayer: () => void;
|
|
||||||
onImportImage: () => void;
|
|
||||||
labels: {
|
|
||||||
pointer: string;
|
|
||||||
pan: string;
|
|
||||||
face: string;
|
|
||||||
fill: string;
|
|
||||||
brush: string;
|
|
||||||
text: string;
|
|
||||||
image: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] {
|
|
||||||
return [
|
|
||||||
{ kind: "mode", id: "pointer", label: options.labels.pointer },
|
|
||||||
{ kind: "mode", id: "hand", label: options.labels.pan },
|
|
||||||
{ kind: "mode", id: "face", label: options.labels.face },
|
|
||||||
{ kind: "mode", id: "fill", label: options.labels.fill },
|
|
||||||
{ kind: "mode", id: "brush", label: options.labels.brush },
|
|
||||||
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
|
|
||||||
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
|
|||||||
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
|
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const faceBlurRenderer: EffectRenderer<FaceBlurEffect> = {
|
export const faceBlurRenderer: EffectRenderer = {
|
||||||
kind: "face-blur",
|
kind: "face-blur",
|
||||||
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
|
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
|
||||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ export function renderLayerWithEffects(
|
|||||||
// The first effect owns the full layer render (draws base image + applies itself)
|
// The first effect owns the full layer render (draws base image + applies itself)
|
||||||
const first = activeEffects[0];
|
const first = activeEffects[0];
|
||||||
const firstRenderer = effectRendererRegistry.get(first.kind);
|
const firstRenderer = effectRendererRegistry.get(first.kind);
|
||||||
firstRenderer?.renderLayer(context, first as never);
|
firstRenderer?.renderLayer(context, first);
|
||||||
|
|
||||||
// Subsequent effects render on top (overlay only, no re-draw of base)
|
// Subsequent effects render on top (overlay only, no re-draw of base)
|
||||||
for (let i = 1; i < activeEffects.length; i++) {
|
for (let i = 1; i < activeEffects.length; i++) {
|
||||||
const effect = activeEffects[i];
|
const effect = activeEffects[i];
|
||||||
const renderer = effectRendererRegistry.get(effect.kind);
|
const renderer = effectRendererRegistry.get(effect.kind);
|
||||||
renderer?.render(context, effect as never);
|
renderer?.render(context, effect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export type EffectRenderContext = {
|
|||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EffectRenderer<T extends LayerEffect = LayerEffect> = {
|
export type EffectRenderer = {
|
||||||
kind: T["kind"];
|
kind: LayerEffect["kind"];
|
||||||
/** Renders the effect onto the canvas. Called after the base image is drawn. */
|
/** Renders the effect onto the canvas. Called after the base image is drawn. */
|
||||||
render: (context: EffectRenderContext, effect: T) => void;
|
render: (context: EffectRenderContext, effect: LayerEffect) => void;
|
||||||
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
|
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
|
||||||
renderLayer: (context: EffectRenderContext, effect: T) => void;
|
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ describe("hasProjectChanged", () => {
|
|||||||
expect(hasProjectChanged(a, b)).toBe(true);
|
expect(hasProjectChanged(a, b)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects visibility changes", () => {
|
||||||
|
const a = makeProject();
|
||||||
|
const b = makeProject();
|
||||||
|
b.layers[0].visible = false;
|
||||||
|
expect(hasProjectChanged(a, b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats missing optional fields and undefined as equal", () => {
|
it("treats missing optional fields and undefined as equal", () => {
|
||||||
const a = makeProject();
|
const a = makeProject();
|
||||||
const b = makeProject();
|
const b = makeProject();
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
|||||||
a.height !== b.height ||
|
a.height !== b.height ||
|
||||||
a.scale !== b.scale ||
|
a.scale !== b.scale ||
|
||||||
a.rotation !== b.rotation ||
|
a.rotation !== b.rotation ||
|
||||||
a.opacity !== b.opacity
|
a.opacity !== b.opacity ||
|
||||||
|
a.visible !== b.visible
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "next dev --webpack -p 3000",
|
"dev": "next dev --webpack -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { useEditorStore } from "./editor-store";
|
import { useEditorStore } from "./editor-store";
|
||||||
|
import { createProject } from "@pien-studio/editor-core";
|
||||||
|
|
||||||
describe("editor store", () => {
|
describe("editor store", () => {
|
||||||
it("saves and loads project from indexeddb", async () => {
|
it("saves and loads project from indexeddb", async () => {
|
||||||
@@ -74,4 +75,79 @@ describe("editor store", () => {
|
|||||||
expect(after[0].id).toBe(second.id);
|
expect(after[0].id).toBe(second.id);
|
||||||
expect(after[1].id).toBe(first.id);
|
expect(after[1].id).toBe(first.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies effects, toggles visibility, and tracks undo", () => {
|
||||||
|
useEditorStore.getState().resetProject();
|
||||||
|
useEditorStore.getState().addLayerByType("raster");
|
||||||
|
const layerId = useEditorStore.getState().project.layers[0]?.id;
|
||||||
|
expect(layerId).toBeDefined();
|
||||||
|
|
||||||
|
useEditorStore.getState().setLayerEffect(layerId!, {
|
||||||
|
kind: "face-blur",
|
||||||
|
enabled: true,
|
||||||
|
method: "gaussian",
|
||||||
|
amount: 99,
|
||||||
|
regions: [{ x: Number.NaN, y: 1, width: 0, height: 2 }],
|
||||||
|
});
|
||||||
|
useEditorStore.getState().setEffectEnabled(layerId!, "face-blur", false);
|
||||||
|
useEditorStore.getState().setLayerVisible(layerId!, false);
|
||||||
|
|
||||||
|
const layer = useEditorStore.getState().project.layers[0];
|
||||||
|
expect(layer?.visible).toBe(false);
|
||||||
|
expect(layer?.effects[0]).toMatchObject({ amount: 40, enabled: false, regions: [{ x: 0, y: 1, width: 1, height: 2 }] });
|
||||||
|
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||||
|
|
||||||
|
useEditorStore.getState().undo();
|
||||||
|
expect(useEditorStore.getState().project.layers[0]?.visible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits and cancels draft transactions", () => {
|
||||||
|
useEditorStore.getState().resetProject();
|
||||||
|
useEditorStore.getState().addLayerByType("text");
|
||||||
|
const initial = useEditorStore.getState().project.layers[0];
|
||||||
|
expect(initial).toBeDefined();
|
||||||
|
|
||||||
|
useEditorStore.getState().startTransaction();
|
||||||
|
useEditorStore.getState().setSelectedLayerPositionDraft(300, 301);
|
||||||
|
useEditorStore.getState().cancelTransaction();
|
||||||
|
expect(useEditorStore.getState().project.layers[0]?.x).toBe(initial?.x);
|
||||||
|
|
||||||
|
useEditorStore.getState().startTransaction();
|
||||||
|
useEditorStore.getState().setSelectedLayerPositionDraft(310, 311);
|
||||||
|
useEditorStore.getState().commitTransaction();
|
||||||
|
expect(useEditorStore.getState().project.layers[0]?.x).toBe(310);
|
||||||
|
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets canvas size and supports redo and timeline jumps", () => {
|
||||||
|
useEditorStore.getState().resetProject();
|
||||||
|
useEditorStore.getState().setCanvasSize(222.4, 333.6);
|
||||||
|
useEditorStore.getState().addLayerByType("text");
|
||||||
|
expect(useEditorStore.getState().project.canvas).toMatchObject({ width: 222, height: 334 });
|
||||||
|
|
||||||
|
useEditorStore.getState().undo();
|
||||||
|
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||||
|
expect(useEditorStore.getState().canRedo).toBe(true);
|
||||||
|
|
||||||
|
useEditorStore.getState().redo();
|
||||||
|
expect(useEditorStore.getState().project.layers).toHaveLength(1);
|
||||||
|
|
||||||
|
useEditorStore.getState().setSelectedLayerRotation(45);
|
||||||
|
useEditorStore.getState().jumpToPast(1);
|
||||||
|
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||||
|
useEditorStore.getState().jumpToFuture(2);
|
||||||
|
expect(useEditorStore.getState().project.layers[0]?.rotation).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid project json and replaces projects", () => {
|
||||||
|
useEditorStore.getState().resetProject();
|
||||||
|
const originalId = useEditorStore.getState().project.id;
|
||||||
|
expect(useEditorStore.getState().importProjectFromJson("nope")).toEqual({ ok: false, error: "Invalid JSON" });
|
||||||
|
expect(useEditorStore.getState().project.id).toBe(originalId);
|
||||||
|
|
||||||
|
const project = createProject("replacement");
|
||||||
|
useEditorStore.getState().setProject(project);
|
||||||
|
expect(useEditorStore.getState().project.title).toBe("replacement");
|
||||||
|
expect(useEditorStore.getState().isDirty).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-1
@@ -9,12 +9,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
|
"check": "bun run typecheck && bun run test",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"test:unit": "turbo run test",
|
"test:unit": "turbo run test",
|
||||||
|
"test:coverage": "turbo run test -- --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
@@ -22,6 +25,10 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"@vitest/coverage-v8": "4.1.5",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.2.6",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"turbo": "^2.9.9",
|
"turbo": "^2.9.9",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const faceBlurDefinition: EffectDefinition<FaceBlurEffect> = {
|
export const faceBlurDefinition: EffectDefinition = {
|
||||||
kind: "face-blur",
|
kind: "face-blur",
|
||||||
normalize: normalizeFaceBlur,
|
normalize: (effect) => normalizeFaceBlur(effect),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { LayerEffect } from "@pien-studio/types";
|
import type { LayerEffect } from "@pien-studio/types";
|
||||||
import { faceBlurDefinition } from "./face-blur";
|
import { faceBlurDefinition } from "./face-blur";
|
||||||
|
|
||||||
export type EffectDefinition<T extends LayerEffect = LayerEffect> = {
|
export type EffectDefinition = {
|
||||||
kind: T["kind"];
|
kind: LayerEffect["kind"];
|
||||||
normalize: (effect: T) => T;
|
normalize: (effect: LayerEffect) => LayerEffect;
|
||||||
};
|
};
|
||||||
|
|
||||||
const definitions = [faceBlurDefinition] as EffectDefinition[];
|
const definitions = [faceBlurDefinition] as EffectDefinition[];
|
||||||
@@ -19,5 +19,5 @@ export function getEffectDefinition(kind: string): EffectDefinition | undefined
|
|||||||
export function normalizeEffect(effect: LayerEffect): LayerEffect {
|
export function normalizeEffect(effect: LayerEffect): LayerEffect {
|
||||||
const def = effectRegistry.get(effect.kind);
|
const def = effectRegistry.get(effect.kind);
|
||||||
if (!def) return effect;
|
if (!def) return effect;
|
||||||
return def.normalize(effect as never);
|
return def.normalize(effect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,32 @@ import {
|
|||||||
addLayer,
|
addLayer,
|
||||||
createProject,
|
createProject,
|
||||||
moveLayer,
|
moveLayer,
|
||||||
|
removeLayer,
|
||||||
reorderLayer,
|
reorderLayer,
|
||||||
|
setEffectEnabled,
|
||||||
|
setLayerEffect,
|
||||||
|
setLayerVisible,
|
||||||
|
normalizeProject,
|
||||||
|
parseProjectFile,
|
||||||
|
serializeProjectFile,
|
||||||
|
setCanvasSize,
|
||||||
updateLayerTransform,
|
updateLayerTransform,
|
||||||
} from "./index";
|
} 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", () => {
|
describe("editor-core", () => {
|
||||||
it("creates project with defaults", () => {
|
it("creates project with defaults", () => {
|
||||||
@@ -17,17 +40,7 @@ describe("editor-core", () => {
|
|||||||
|
|
||||||
it("adds a new layer and updates timestamp", () => {
|
it("adds a new layer and updates timestamp", () => {
|
||||||
const project = createProject("new");
|
const project = createProject("new");
|
||||||
const updated = addLayer(project, {
|
const updated = addLayer(project, makeLayer("l1"));
|
||||||
id: "l1",
|
|
||||||
type: "raster",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
rotation: 0,
|
|
||||||
opacity: 1,
|
|
||||||
effects: [],
|
|
||||||
visible: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updated.layers).toHaveLength(1);
|
expect(updated.layers).toHaveLength(1);
|
||||||
expect(updated.layers[0]?.id).toBe("l1");
|
expect(updated.layers[0]?.id).toBe("l1");
|
||||||
@@ -35,17 +48,7 @@ describe("editor-core", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("moves a layer by delta", () => {
|
it("moves a layer by delta", () => {
|
||||||
const project = addLayer(createProject("move"), {
|
const project = addLayer(createProject("move"), { ...makeLayer("l1", "sticker"), x: 10, y: 20 });
|
||||||
id: "l1",
|
|
||||||
type: "sticker",
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
scale: 1,
|
|
||||||
rotation: 0,
|
|
||||||
opacity: 1,
|
|
||||||
effects: [],
|
|
||||||
visible: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
|
||||||
expect(moved.layers[0]?.x).toBe(25);
|
expect(moved.layers[0]?.x).toBe(25);
|
||||||
@@ -53,17 +56,7 @@ describe("editor-core", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates transform fields", () => {
|
it("updates transform fields", () => {
|
||||||
const project = addLayer(createProject("transform"), {
|
const project = addLayer(createProject("transform"), makeLayer("l1", "text"));
|
||||||
id: "l1",
|
|
||||||
type: "text",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
rotation: 0,
|
|
||||||
opacity: 1,
|
|
||||||
effects: [],
|
|
||||||
visible: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
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]?.scale).toBe(1.35);
|
||||||
@@ -72,30 +65,83 @@ describe("editor-core", () => {
|
|||||||
|
|
||||||
it("reorders layer to the front", () => {
|
it("reorders layer to the front", () => {
|
||||||
const base = createProject("reorder");
|
const base = createProject("reorder");
|
||||||
const withFirst = addLayer(base, {
|
const withFirst = addLayer(base, makeLayer("l1", "text"));
|
||||||
id: "l1",
|
const withSecond = addLayer(withFirst, makeLayer("l2", "sticker"));
|
||||||
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 reordered = reorderLayer(withSecond, "l1", 1);
|
const reordered = reorderLayer(withSecond, "l1", 1);
|
||||||
expect(reordered.layers.map((layer) => layer.id)).toEqual(["l2", "l1"]);
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
|
||||||
|
import { normalizeEffect } from "./effects/registry";
|
||||||
|
|
||||||
export type LayerFactoryOptions = {
|
export type LayerFactoryOptions = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -26,6 +27,22 @@ function withUpdatedAt(project: Project, layers: Layer[]): Project {
|
|||||||
return { ...project, layers, updatedAt: new Date().toISOString() };
|
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 {
|
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
|
||||||
return {
|
return {
|
||||||
id: options.id ?? crypto.randomUUID(),
|
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 {
|
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 {
|
||||||
return withUpdatedAt(
|
if (delta.dx === 0 && delta.dy === 0) return project;
|
||||||
project,
|
return updateLayer(project, layerId, (l) => ({ ...l, x: l.x + delta.dx, y: l.y + delta.dy }));
|
||||||
project.layers.map((l) => (l.id === layerId ? { ...l, x: l.x + delta.dx, y: l.y + delta.dy } : l)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
|
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
|
||||||
return withUpdatedAt(
|
return updateLayer(project, layerId, (l) => (hasTransformPatchChange(l, patch) ? { ...l, ...patch } : l));
|
||||||
project,
|
|
||||||
project.layers.map((l) => (l.id === layerId ? { ...l, ...patch } : l)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
|
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 layers = [...project.layers];
|
||||||
const [picked] = layers.splice(fromIndex, 1);
|
const [picked] = layers.splice(fromIndex, 1);
|
||||||
if (!picked) return project;
|
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);
|
return withUpdatedAt(project, layers);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
|
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
|
||||||
return withUpdatedAt(
|
const normalizedEffect = normalizeEffect(effect);
|
||||||
project,
|
return updateLayer(project, layerId, (l) => {
|
||||||
project.layers.map((l) => {
|
const idx = l.effects.findIndex((e) => e.kind === normalizedEffect.kind);
|
||||||
if (l.id !== layerId) return l;
|
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? normalizedEffect : e)) : [...l.effects, normalizedEffect];
|
||||||
const idx = l.effects.findIndex((e) => e.kind === effect.kind);
|
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
|
||||||
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? effect : e)) : [...l.effects, effect];
|
return { ...l, effects };
|
||||||
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 withUpdatedAt(
|
return updateLayer(project, layerId, (l) => {
|
||||||
project,
|
if (!l.effects.some((e) => e.kind === kind)) return l;
|
||||||
project.layers.map((l) => (l.id === layerId ? { ...l, effects: l.effects.filter((e) => e.kind !== kind) } : l)),
|
return { ...l, effects: l.effects.filter((e) => e.kind !== kind) };
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
|
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
|
||||||
return withUpdatedAt(
|
return updateLayer(project, layerId, (l) => (l.visible === visible ? l : { ...l, visible }));
|
||||||
project,
|
|
||||||
project.layers.map((l) => (l.id === layerId ? { ...l, visible } : l)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 withUpdatedAt(
|
return updateLayer(project, layerId, (l) => {
|
||||||
project,
|
const effect = l.effects.find((e) => e.kind === kind);
|
||||||
project.layers.map((l) =>
|
if (!effect || effect.enabled === enabled) return l;
|
||||||
l.id === layerId
|
return { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) };
|
||||||
? { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) }
|
});
|
||||||
: l,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 "fake-indexeddb/auto";
|
||||||
import { getProjectById, loadProjects } from "./index";
|
import { deleteProject, duplicateProject, getProjectById, loadProjects, saveProjects, upsertProject } from "./index";
|
||||||
import type { Project } from "@pien-studio/types";
|
import type { Project } from "@pien-studio/types";
|
||||||
|
|
||||||
const DB_NAME = "pien.db";
|
const DB_NAME = "pien.db";
|
||||||
@@ -60,6 +60,25 @@ async function readRawProject(projectId: string): Promise<Project | undefined> {
|
|||||||
return record;
|
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 {
|
function makeMalformedProject(id: string): Project {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
@@ -80,6 +99,7 @@ function makeMalformedProject(id: string): Project {
|
|||||||
scale: 0,
|
scale: 0,
|
||||||
rotation: 720.4,
|
rotation: 720.4,
|
||||||
opacity: 0.75,
|
opacity: 0.75,
|
||||||
|
visible: true,
|
||||||
effects: [],
|
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", () => {
|
describe("storage read flows", () => {
|
||||||
beforeAll(async () => {
|
beforeEach(async () => {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await resetDatabase();
|
||||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
vi.spyOn(URL, "createObjectURL").mockImplementation((obj: Blob | MediaSource) => `blob:test-${"size" in obj ? obj.size : "media"}`);
|
||||||
req.onsuccess = () => resolve();
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
||||||
req.onerror = () => reject(req.error);
|
});
|
||||||
req.onblocked = () => resolve();
|
|
||||||
});
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loadProjects hydrates normalized values without persisting writes", async () => {
|
it("loadProjects hydrates normalized values without persisting writes", async () => {
|
||||||
@@ -173,4 +222,58 @@ describe("storage read flows", () => {
|
|||||||
expect(listedRegion?.sourceWidth).toBeUndefined();
|
expect(listedRegion?.sourceWidth).toBeUndefined();
|
||||||
expect(listedRegion?.sourceHeight).toBeUndefined();
|
expect(listedRegion?.sourceHeight).toBeUndefined();
|
||||||
}, 15_000);
|
}, 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
@@ -1,5 +1,14 @@
|
|||||||
import { normalizeProject } from "@pien-studio/editor-core";
|
import { normalizeProject } from "@pien-studio/editor-core";
|
||||||
import { ProjectSchema, type Layer, type Project } from "@pien-studio/types";
|
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_NAME = "pien.db";
|
||||||
const DB_VERSION = 4;
|
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("-");
|
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> {
|
async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise<string | undefined> {
|
||||||
if (!isBinaryLayer(layer)) return layer.assetId;
|
if (!isBinaryLayer(layer)) return layer.assetId;
|
||||||
|
|
||||||
@@ -117,22 +112,21 @@ async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Prom
|
|||||||
const hash = await hashBlob(blob);
|
const hash = await hashBlob(blob);
|
||||||
const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX);
|
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 = matching.find((asset) => asset.blob.size === blob.size);
|
const reusable = chooseReusableAsset(matching, blob);
|
||||||
const now = new Date().toISOString();
|
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(
|
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
||||||
assetStore.put({
|
|
||||||
id,
|
|
||||||
mimeType: blob.type || inferMimeType(layer),
|
|
||||||
blob,
|
|
||||||
hash,
|
|
||||||
createdAt: reusable?.createdAt ?? now,
|
|
||||||
updatedAt: now,
|
|
||||||
} satisfies AssetRecord),
|
|
||||||
);
|
|
||||||
|
|
||||||
return id;
|
return record.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return layer.assetId;
|
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 existingMap = new Map(existing.map((link) => [link.id, link]));
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const referenced = new Set<string>();
|
const links = buildAssetLinks(project, now);
|
||||||
for (const layer of project.layers) {
|
const referenced = new Set(links.map((link) => link.assetId));
|
||||||
if (!layer.assetId || !isBinaryLayer(layer)) continue;
|
for (const link of links) {
|
||||||
const id = makeLinkId(project.id, layer.id);
|
|
||||||
referenced.add(layer.assetId);
|
|
||||||
await requestToPromise(
|
await requestToPromise(
|
||||||
store.put({
|
store.put(link satisfies AssetLinkRecord),
|
||||||
id,
|
|
||||||
projectId: project.id,
|
|
||||||
layerId: layer.id,
|
|
||||||
assetId: layer.assetId,
|
|
||||||
updatedAt: now,
|
|
||||||
} satisfies AssetLinkRecord),
|
|
||||||
);
|
);
|
||||||
existingMap.delete(id);
|
existingMap.delete(link.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removedAssetIds = new Set<string>();
|
const removedAssetIds = new Set<string>();
|
||||||
@@ -247,24 +233,23 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
const layer = layers[prepared.layerIndex];
|
const layer = layers[prepared.layerIndex];
|
||||||
if (!layer) continue;
|
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 = matching.find((asset) => asset.blob.size === prepared.blob.size);
|
const reusable = chooseReusableAsset(matching, prepared.blob);
|
||||||
const now = new Date().toISOString();
|
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(
|
await requestToPromise(assetStore.put(record satisfies AssetRecord));
|
||||||
assetStore.put({
|
|
||||||
id,
|
|
||||||
mimeType: prepared.mimeType,
|
|
||||||
blob: prepared.blob,
|
|
||||||
hash: prepared.hash,
|
|
||||||
createdAt: reusable?.createdAt ?? now,
|
|
||||||
updatedAt: now,
|
|
||||||
} satisfies AssetRecord),
|
|
||||||
);
|
|
||||||
|
|
||||||
layers[prepared.layerIndex] = {
|
layers[prepared.layerIndex] = {
|
||||||
...layer,
|
...layer,
|
||||||
assetId: id,
|
assetId: record.id,
|
||||||
sourceUri: undefined,
|
sourceUri: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -275,9 +260,8 @@ async function persistProject(db: IDBDatabase, project: Project): Promise<Projec
|
|||||||
const assetId = await putAssetFromLayer(assetStore, layer);
|
const assetId = await putAssetFromLayer(assetStore, layer);
|
||||||
if (!assetId) continue;
|
if (!assetId) continue;
|
||||||
layers[index] = {
|
layers[index] = {
|
||||||
...layer,
|
...stripEmbeddedSourceUri(layer),
|
||||||
assetId,
|
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> {
|
export async function cleanupOrphanAssets(): Promise<number> {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return 0;
|
if (!db) return 0;
|
||||||
return cleanupOrphansInternal(db);
|
try {
|
||||||
|
return await cleanupOrphansInternal(db);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startAssetCleanupJob(intervalMs = 45_000): () => void {
|
export function startAssetCleanupJob(intervalMs = 45_000): () => void {
|
||||||
@@ -357,16 +345,20 @@ export async function saveProjects(projects: Project[]): Promise<void> {
|
|||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
const existing = await loadProjects();
|
try {
|
||||||
const keep = new Set(projects.map((project) => project.id));
|
const existing = await loadProjects();
|
||||||
for (const project of existing) {
|
const keep = new Set(projects.map((project) => project.id));
|
||||||
if (!keep.has(project.id)) {
|
for (const project of existing) {
|
||||||
await deleteProject(project.id);
|
if (!keep.has(project.id)) {
|
||||||
|
await deleteProject(project.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
await persistProject(db, project);
|
await persistProject(db, project);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,55 +366,71 @@ export async function loadProjects(): Promise<Project[]> {
|
|||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
|
|
||||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
try {
|
||||||
const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll());
|
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||||
const parsed = (records as unknown[])
|
const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll());
|
||||||
.map((record) => ProjectSchema.safeParse(record))
|
const parsed = (records as unknown[])
|
||||||
.filter((result) => result.success)
|
.map((record) => ProjectSchema.safeParse(record))
|
||||||
.map((result) => normalizeProject(result.data));
|
.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)));
|
||||||
void cleanupOrphansInternal(db);
|
await cleanupOrphansInternal(db);
|
||||||
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectById(projectId: string): Promise<Project | null> {
|
export async function getProjectById(projectId: string): Promise<Project | null> {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return null;
|
if (!db) return null;
|
||||||
|
|
||||||
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
try {
|
||||||
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
const tx = db.transaction(PROJECTS_STORE, "readonly");
|
||||||
const parsed = ProjectSchema.safeParse(record);
|
const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId));
|
||||||
if (!parsed.success) return null;
|
const parsed = ProjectSchema.safeParse(record);
|
||||||
return hydrateProject(db, normalizeProject(parsed.data));
|
if (!parsed.success) return null;
|
||||||
|
return await hydrateProject(db, normalizeProject(parsed.data));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertProject(project: Project): Promise<void> {
|
export async function upsertProject(project: Project): Promise<void> {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return;
|
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> {
|
export async function deleteProject(projectId: string): Promise<void> {
|
||||||
const db = await openDatabase();
|
const db = await openDatabase();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
try {
|
||||||
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
|
const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite");
|
||||||
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
|
const linksStore = linksTx.objectStore(ASSET_LINKS_STORE);
|
||||||
const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[];
|
const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX);
|
||||||
for (const link of links) {
|
const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[];
|
||||||
await requestToPromise(linksStore.delete(link.id));
|
for (const link of links) {
|
||||||
}
|
await requestToPromise(linksStore.delete(link.id));
|
||||||
await new Promise<void>((resolve, reject) => {
|
}
|
||||||
linksTx.oncomplete = () => resolve();
|
await new Promise<void>((resolve, reject) => {
|
||||||
linksTx.onerror = () => reject(linksTx.error);
|
linksTx.oncomplete = () => resolve();
|
||||||
linksTx.onabort = () => reject(linksTx.error);
|
linksTx.onerror = () => reject(linksTx.error);
|
||||||
});
|
linksTx.onabort = () => reject(linksTx.error);
|
||||||
|
});
|
||||||
|
|
||||||
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
const projectTx = db.transaction(PROJECTS_STORE, "readwrite");
|
||||||
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId));
|
||||||
await cleanupOrphansInternal(db, links.map((link) => link.assetId));
|
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> {
|
||||||
|
|||||||
@@ -125,6 +125,4 @@ export type LayerEffect = z.infer<typeof LayerEffectSchema>;
|
|||||||
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
|
||||||
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
|
||||||
export type FaceBlurEffect = z.infer<typeof FaceBlurEffectSchema>;
|
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>;
|
export type ProjectFile = z.infer<typeof ProjectFileSchema>;
|
||||||
|
|||||||
+4
-5
@@ -7,12 +7,11 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@pien-studio/types": ["packages/types/src/index.ts"],
|
"@pien-studio/types": ["./packages/types/src/index.ts"],
|
||||||
"@pien-studio/editor-core": ["packages/editor-core/src/index.ts"],
|
"@pien-studio/editor-core": ["./packages/editor-core/src/index.ts"],
|
||||||
"@pien-studio/storage": ["packages/storage/src/index.ts"],
|
"@pien-studio/storage": ["./packages/storage/src/index.ts"],
|
||||||
"@pien-studio/ui/*": ["packages/ui/src/*"]
|
"@pien-studio/ui/*": ["./packages/ui/src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user