diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a485575..e301ff5 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -15,9 +15,42 @@ env: NEXT_TELEMETRY_DISABLED: "1" jobs: - test: - name: Run tests + validate: + name: ${{ matrix.check.name }} runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + check: + - name: Lint + command: bun run lint + - name: Typecheck + command: bun run typecheck + - name: Tests + command: bun run test + - name: Format + command: bun run format + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.1.38 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run ${{ matrix.check.name }} + run: ${{ matrix.check.command }} + + build: + name: Build + runs-on: ubuntu-latest + needs: validate permissions: contents: read steps: @@ -32,13 +65,13 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run tests - run: bun run test + - name: Build + run: bun run build docker: name: Build and publish Docker images - ${{ matrix.app }} runs-on: ubuntu-latest - needs: test + needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/main' strategy: matrix: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3abc9e0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +node_modules +.next +.turbo +coverage +playwright-report +test-results +dist +build +out +docs +bun.lock +next-env.d.ts diff --git a/apps/api/package.json b/apps/api/package.json index f8c66ba..9c0b82c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@pien-studio/types": "workspace:*", + "@pien-studio/contracts": "workspace:*", "elysia": "^1.1.25", "zod": "^4.4.3" }, diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts index b7303bf..56df6d9 100644 --- a/apps/api/src/index.test.ts +++ b/apps/api/src/index.test.ts @@ -21,7 +21,7 @@ describe("api endpoints", () => { it("returns token for valid device payload", async () => { const app = createApp(); const response = await app.handle( - new Request("http://localhost/auth/device", { + new Request("http://localhost/v1/auth/device", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceId: "dev1234", locale: "en" }), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bbe2fd4..11a789c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,12 @@ import { Elysia } from "elysia"; -import { DeviceSessionSchema } from "@pien-studio/types"; +import { DeviceSessionRequestSchema } from "@pien-studio/contracts"; + +function errorResponse(code: string, message: string, status = 400) { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { "content-type": "application/json" }, + }); +} export function createApp() { return new Elysia() @@ -8,25 +15,41 @@ export function createApp() { status: "ok", })) .get("/health", () => ({ ok: true, service: "pien-api" })) - .post("/auth/device", ({ body }) => { - const parsed = DeviceSessionSchema.safeParse(body); + .get("/v1/health", () => ({ ok: true, service: "pien-api" })) + .post("/v1/auth/device", ({ body }) => { + const parsed = DeviceSessionRequestSchema.safeParse(body); if (!parsed.success) { - return new Response(JSON.stringify({ error: "invalid_device_payload" }), { - status: 400, - }); + return errorResponse( + "invalid_device_payload", + "Device payload is invalid", + ); } return { token: `dev_${parsed.data.deviceId}`, scope: "local-sync", }; }) - .get("/sync/bootstrap", () => ({ + .get("/v1/sync/bootstrap", () => ({ replication: { - pull: "/sync/pull", - push: "/sync/push", - strategy: "couch-compatible", + pull: "/v1/sync/pull", + push: "/v1/sync/push", + strategy: "operation-log", }, - })); + })) + .post("/v1/sync/pull", () => + errorResponse( + "sync_not_implemented", + "Sync pull is not implemented yet", + 501, + ), + ) + .post("/v1/sync/push", () => + errorResponse( + "sync_not_implemented", + "Sync push is not implemented yet", + 501, + ), + ); } if (import.meta.main) { diff --git a/apps/web/app/api/commit-hash/route.ts b/apps/web/app/api/commit-hash/route.ts index de8b63e..ae704fc 100644 --- a/apps/web/app/api/commit-hash/route.ts +++ b/apps/web/app/api/commit-hash/route.ts @@ -5,11 +5,15 @@ export const dynamic = "force-dynamic"; export function GET() { if (process.env.NODE_ENV !== "development") { - return NextResponse.json({ hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown" }); + return NextResponse.json({ + hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown", + }); } try { - const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() }).toString().trim(); + const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() }) + .toString() + .trim(); return NextResponse.json({ hash }); } catch { return NextResponse.json({ hash: "unknown" }); diff --git a/apps/web/app/editor/[projectId]/page.tsx b/apps/web/app/editor/[projectId]/page.tsx index 7ccb9c5..5cbba38 100644 --- a/apps/web/app/editor/[projectId]/page.tsx +++ b/apps/web/app/editor/[projectId]/page.tsx @@ -2,7 +2,16 @@ import React from "react"; import { useParams } from "next/navigation"; -import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react"; +import { getLayerRuntimeSource } from "@pien-studio/types"; +import { + MousePointer2, + Hand, + ScanFace, + PaintBucket, + Brush, + Type, + ImagePlus, +} from "lucide-react"; import { useEditorStore } from "../../../store/editor-store"; import { useUiStore, isDarkTheme } from "../../../store/ui-store"; import { CanvasSizeModal } from "../../../components/canvas-size-modal"; @@ -27,7 +36,10 @@ import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job"; import { useTranslations } from "../../../hooks/use-translations"; import type { AspectRatio } from "@pien-studio/types"; -const TOOL_ICONS: Record> = { +const TOOL_ICONS: Record< + string, + React.ComponentType<{ className?: string }> +> = { pointer: MousePointer2, hand: Hand, face: ScanFace, @@ -89,7 +101,8 @@ export default function EditorPage() { const { theme, hydrate } = useUiStore((s) => s); const { t } = useTranslations(); const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t); - const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu(); + const { contextMenu, openContextMenu, closeContextMenu } = + useEditorContextMenu(); const [canvasModalOpen, setCanvasModalOpen] = React.useState(false); const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false); const [fillColor, setFillColor] = React.useState("#ff0000"); @@ -98,16 +111,21 @@ export default function EditorPage() { const [brushSize, setBrushSize] = React.useState(20); const [brushOpacity, setBrushOpacity] = React.useState(1); const [brushHardness, setBrushHardness] = React.useState(0.8); - const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle"); + const previousFaceStatusRef = React.useRef< + "idle" | "detecting" | "unsupported" + >("idle"); const imageInputRef = React.useRef(null); const selectedImageLayer = React.useMemo(() => { - if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) { + const sourceUri = selectedLayer + ? getLayerRuntimeSource(selectedLayer) + : undefined; + if (!selectedLayer || selectedLayer.type !== "raster" || !sourceUri) { return null; } return { id: selectedLayer.id, - sourceUri: selectedLayer.sourceUri, + sourceUri, width: selectedLayer.width, height: selectedLayer.height, }; @@ -118,11 +136,12 @@ export default function EditorPage() { return state.selectedLayerId === layerId; }, []); - const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({ - tool, - selectedImageLayer, - activeLayerStillSelected: isLayerStillSelected, - }); + const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = + useFaceDetection({ + tool, + selectedImageLayer, + activeLayerStillSelected: isLayerStillSelected, + }); const { blurMethod, @@ -204,7 +223,10 @@ export default function EditorPage() { }); React.useEffect(() => { - if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") { + if ( + faceStatus === "unsupported" && + previousFaceStatusRef.current !== "unsupported" + ) { setFaceMlErrorModalOpen(true); } previousFaceStatusRef.current = faceStatus; @@ -217,41 +239,53 @@ export default function EditorPage() { event.target.value = ""; } - function handleCanvasApply(width: number, height: number, aspect: AspectRatio) { + function handleCanvasApply( + width: number, + height: number, + aspect: AspectRatio, + ) { void aspect; setCanvasSize(width, height); } - const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => { - const layer = project.layers.find((l) => l.id === layerId); - if (!layer || layer.type !== "raster" || !layer.sourceUri) return; - const layerWidth = layer.width ?? Math.round(200 * layer.scale); - const layerHeight = layer.height ?? Math.round(150 * layer.scale); - // x/y are in layer CSS-pixel space; scale to image pixel space - const img = new Image(); - const uri = layer.sourceUri; - const color = fillColor; - const tolerance = fillTolerance; - img.onload = () => { - const scaleX = img.naturalWidth / layerWidth; - const scaleY = img.naturalHeight / layerHeight; - const pixelX = x * scaleX; - const pixelY = y * scaleY; - floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => { - if (nextUri !== uri) updateImageLayerSource(layerId, nextUri); - }).catch(() => {}); - }; - img.src = uri; - }, [project.layers, fillColor, fillTolerance, updateImageLayerSource]); + const handleFillLayer = React.useCallback( + async (layerId: string, x: number, y: number) => { + const layer = project.layers.find((l) => l.id === layerId); + const uri = layer ? getLayerRuntimeSource(layer) : undefined; + if (!layer || layer.type !== "raster" || !uri) return; + const layerWidth = layer.width; + const layerHeight = layer.height; + const img = new Image(); + const color = fillColor; + const tolerance = fillTolerance; + img.onload = () => { + const scaleX = img.naturalWidth / layerWidth; + const scaleY = img.naturalHeight / layerHeight; + const pixelX = x * scaleX; + const pixelY = y * scaleY; + floodFillDataUrl(uri, pixelX, pixelY, color, tolerance) + .then((nextUri) => { + if (nextUri !== uri) updateImageLayerSource(layerId, nextUri); + }) + .catch(() => {}); + }; + img.src = uri; + }, + [project.layers, fillColor, fillTolerance, updateImageLayerSource], + ); - const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => { - const layer = project.layers.find((l) => l.id === layerId); - if (!layer || layer.type !== "raster" || !layer.sourceUri) return; - try { - const nextUri = await commitStroke(layer.sourceUri, stroke); - updateImageLayerSource(layerId, nextUri); - } catch {} - }, [project.layers, updateImageLayerSource]); + const handleBrushCommit = React.useCallback( + async (layerId: string, stroke: BrushStroke) => { + const layer = project.layers.find((l) => l.id === layerId); + const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined; + if (!layer || layer.type !== "raster" || !sourceUri) return; + try { + const nextUri = await commitStroke(sourceUri, stroke); + updateImageLayerSource(layerId, nextUri); + } catch {} + }, + [project.layers, updateImageLayerSource], + ); const handleCreateFillLayer = React.useCallback(() => { const canvas = document.createElement("canvas"); @@ -275,23 +309,42 @@ export default function EditorPage() { imageInputRef.current?.click(); }, []); - const toolControllers = React.useMemo(() => [ - { kind: "mode", id: "pointer", label: t("editor.toolPointer") }, - { kind: "mode", id: "hand", label: t("editor.toolPan") }, - { kind: "mode", id: "face", label: t("editor.toolFace") }, - { kind: "mode", id: "fill", label: t("editor.toolFill") }, - { kind: "mode", id: "brush", label: t("editor.toolBrush") }, - { kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") }, - { kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick }, - ], [addLayerByType, handleImportImageClick, t]); + const toolControllers = React.useMemo( + () => [ + { kind: "mode", id: "pointer", label: t("editor.toolPointer") }, + { kind: "mode", id: "hand", label: t("editor.toolPan") }, + { kind: "mode", id: "face", label: t("editor.toolFace") }, + { kind: "mode", id: "fill", label: t("editor.toolFill") }, + { kind: "mode", id: "brush", label: t("editor.toolBrush") }, + { + kind: "action", + id: "add-text", + label: t("editor.toolText"), + run: () => addLayerByType("text"), + }, + { + kind: "action", + id: "import-image", + label: t("editor.toolImage"), + run: handleImportImageClick, + }, + ], + [addLayerByType, handleImportImageClick, t], + ); const canvasBindings = { - onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y), - onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y), - onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height), - onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height), - onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation), - onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation), + onMoveLayer: (_id: string, x: number, y: number) => + setSelectedLayerPositionDraft(x, y), + onMoveLayerEnd: (_id: string, x: number, y: number) => + setSelectedLayerPosition(x, y), + onResizeLayer: (_id: string, width: number, height: number) => + setSelectedLayerSizeDraft(width, height), + onResizeLayerEnd: (_id: string, width: number, height: number) => + setSelectedLayerSize(width, height), + onRotateLayer: (_id: string, rotation: number) => + setSelectedLayerRotationDraft(rotation), + onRotateLayerEnd: (_id: string, rotation: number) => + setSelectedLayerRotation(rotation), onInteractionStart: startTransaction, onInteractionEnd: commitTransaction, }; @@ -361,7 +414,12 @@ export default function EditorPage() { onCut={cutSelectedLayer} onPaste={pasteLayer} onFillLayer={handleFillLayer} - brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }} + brushOptions={{ + color: brushColor, + size: brushSize, + opacity: brushOpacity, + hardness: brushHardness, + }} onBrushCommit={handleBrushCommit} /> @@ -397,7 +455,7 @@ export default function EditorPage() { selectedFaceIndices={selectedFaceIndices} onSelectLayer={selectLayer} onSetLayerVisible={setLayerVisible} - onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)} + onSetEffectEnabled={setEffectEnabled} onMoveLayerOrder={moveSelectedLayerOrder} onRemoveSelectedLayer={removeSelectedLayer} onUndo={undo} @@ -437,7 +495,13 @@ export default function EditorPage() { onInteractionStart={canvasBindings.onInteractionStart} onInteractionEnd={canvasBindings.onInteractionEnd} /> - + {faceMlErrorModalOpen ? ( -
setFaceMlErrorModalOpen(false)}> +
setFaceMlErrorModalOpen(false)} + >
event.stopPropagation()} >
-

{t("editor.faceTool")}

+

+ {t("editor.faceTool")} +

-

{t("editor.faceMlFailed")}

+

+ {t("editor.faceMlFailed")} +

@@ -152,20 +198,38 @@ export default function HomePage() { ) : null} {projectPendingDelete ? ( -
setProjectPendingDelete(null)}> +
setProjectPendingDelete(null)} + >
event.stopPropagation()} >

Delete project?

-

- This will permanently delete {projectPendingDelete.title} from local storage. +

+ This will permanently delete{" "} + + {projectPendingDelete.title} + {" "} + from local storage.

@@ -188,74 +252,162 @@ export default function HomePage() { isDark ? "bg-[#1b1d21] text-[#e8eaed]" : "bg-[#f5f6f8] text-[#1f2430]" }`} > -
-
-
-

pien.studio

-

{t("home.projectHub")}

-

{t("home.createOpenManage")}

-
- -
-
- -
- - - - - -
+
+
+

+ pien.studio +

+

{t("home.projectHub")}

+

+ {t("home.createOpenManage")} +

+
+ +
+ -
-

{t("home.myProjects")}

- {projects.length === 0 ?

{t("home.noProjectsYet")}

: null} -
- {projects.map((project) => ( -
-

{project.title}

-

{new Date(project.updatedAt).toLocaleString()}

-
- + + + + +
+ +
+

+ {t("home.myProjects")} +

+ {projects.length === 0 ? ( +

+ {t("home.noProjectsYet")} +

+ ) : null} +
+ {projects.map((project) => ( +
+

- {t("home.open")} - - - -

- - ))} -
- + {new Date(project.updatedAt).toLocaleString()} +

+
+ + + +
+ + ))} +
+ ); diff --git a/apps/web/components/canvas-renderer.test.tsx b/apps/web/components/canvas-renderer.test.tsx index 6e8a95d..9899913 100644 --- a/apps/web/components/canvas-renderer.test.tsx +++ b/apps/web/components/canvas-renderer.test.tsx @@ -5,8 +5,18 @@ import { CanvasRenderer } from "./canvas-renderer"; import type { Layer } from "@pien-studio/types"; vi.mock("next/image", () => ({ - default: ({ alt, src, unoptimized, ...props }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => - React.createElement("img", { alt, src, "data-unoptimized": unoptimized ? "true" : undefined, ...props }), + default: ({ + alt, + src, + unoptimized, + ...props + }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => + React.createElement("img", { + alt, + src, + "data-unoptimized": unoptimized ? "true" : undefined, + ...props, + }), })); vi.mock("../hooks/use-translations", () => ({ @@ -21,14 +31,17 @@ class ResizeObserverMock { describe("CanvasRenderer", () => { beforeEach(() => { vi.stubGlobal("ResizeObserver", ResizeObserverMock); - Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { configurable: true, value: vi.fn() }); + Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { + configurable: true, + value: vi.fn(), + }); }); it("keeps the rotation handle interactive", () => { const layer: Layer = { id: "layer-1", type: "raster", - sourceUri: "data:image/png;base64,test", + asset: { kind: "inline", uri: "data:image/png;base64,test" }, x: 0, y: 0, width: 100, @@ -58,8 +71,23 @@ describe("CanvasRenderer", () => { const rotateHandle = screen.getByTitle("editor.rotate"); expect(rotateHandle).toHaveClass("pointer-events-auto"); - fireEvent(rotateHandle, new MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 50, clientY: 0 })); - fireEvent(rotateHandle, new MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 50 })); + fireEvent( + rotateHandle, + new MouseEvent("pointerdown", { + bubbles: true, + button: 0, + clientX: 50, + clientY: 0, + }), + ); + fireEvent( + rotateHandle, + new MouseEvent("pointermove", { + bubbles: true, + clientX: 100, + clientY: 50, + }), + ); expect(onRotateLayer).toHaveBeenCalledWith(layer.id, 90); }); diff --git a/apps/web/components/canvas-renderer.tsx b/apps/web/components/canvas-renderer.tsx index 4adc04e..ac5fe40 100644 --- a/apps/web/components/canvas-renderer.tsx +++ b/apps/web/components/canvas-renderer.tsx @@ -13,8 +13,17 @@ import { getToolUiDefinition } from "../lib/tools/registry"; import { getToolDefinition } from "@pien-studio/editor-core"; import { useCanvasInteractions } from "../hooks/use-canvas-interactions"; import { useTranslations } from "../hooks/use-translations"; -import { createStroke, paintSegment, type BrushStroke, type BrushOptions } from "../lib/brush-painter"; -import type { Layer, LayerEffect } from "@pien-studio/types"; +import { + createStroke, + paintSegment, + type BrushStroke, + type BrushOptions, +} from "../lib/brush-painter"; +import { + getLayerRuntimeSource, + type Layer, + type LayerEffect, +} from "@pien-studio/types"; interface CanvasRendererProps { layers: Layer[]; @@ -50,7 +59,15 @@ interface CanvasRendererProps { } | null; } -function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) { +function BrushOverlayCanvas({ + stroke, + width, + height, +}: { + stroke: HTMLCanvasElement; + width: number; + height: number; +}) { const canvasRef = React.useRef(null); React.useEffect(() => { @@ -87,6 +104,7 @@ function EffectImageLayer({ const imageRef = React.useRef(null); const activeEffects = effectsOverride ?? layer.effects; + const sourceUri = getLayerRuntimeSource(layer); const draw = React.useCallback(() => { const canvas = canvasRef.current; @@ -95,11 +113,17 @@ function EffectImageLayer({ const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); - renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height); + renderLayerWithEffects( + ctx, + image, + activeEffects, + canvas.width, + canvas.height, + ); }, [activeEffects]); React.useEffect(() => { - if (!layer.sourceUri) return; + if (!sourceUri) return; let canceled = false; const image = new Image(); image.crossOrigin = "anonymous"; @@ -108,11 +132,11 @@ function EffectImageLayer({ imageRef.current = image; draw(); }; - image.src = layer.sourceUri; + image.src = sourceUri; return () => { canceled = true; }; - }, [draw, layer.sourceUri]); + }, [draw, sourceUri]); React.useEffect(() => { draw(); @@ -156,32 +180,51 @@ export function CanvasRenderer({ const brushStrokeRef = React.useRef(null); const brushLastPosRef = React.useRef<{ x: number; y: number } | null>(null); - const [brushOverlay, setBrushOverlay] = React.useState<{ layerId: string; canvas: HTMLCanvasElement } | null>(null); + const [brushOverlay, setBrushOverlay] = React.useState<{ + layerId: string; + canvas: HTMLCanvasElement; + } | null>(null); - const handleBrushStrokeStart = React.useCallback((layerId: string, x: number, y: number, layerWidth: number, layerHeight: number): void => { - if (!brushOptions) return; - const stroke = createStroke(layerWidth, layerHeight); - brushStrokeRef.current = stroke; - brushLastPosRef.current = { x, y }; - paintSegment(stroke, x, y, x, y, brushOptions); - setBrushOverlay({ layerId, canvas: stroke.canvas }); - }, [brushOptions]); + const handleBrushStrokeStart = React.useCallback( + ( + layerId: string, + x: number, + y: number, + layerWidth: number, + layerHeight: number, + ): void => { + if (!brushOptions) return; + const stroke = createStroke(layerWidth, layerHeight); + brushStrokeRef.current = stroke; + brushLastPosRef.current = { x, y }; + paintSegment(stroke, x, y, x, y, brushOptions); + setBrushOverlay({ layerId, canvas: stroke.canvas }); + }, + [brushOptions], + ); - const handleBrushStrokeMove = React.useCallback((_layerId: string, x: number, y: number) => { - if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions) return; - const { x: lx, y: ly } = brushLastPosRef.current; - paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions); - brushLastPosRef.current = { x, y }; - setBrushOverlay((prev) => prev ? { ...prev } : prev); - }, [brushOptions]); + const handleBrushStrokeMove = React.useCallback( + (_layerId: string, x: number, y: number) => { + if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions) + return; + const { x: lx, y: ly } = brushLastPosRef.current; + paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions); + brushLastPosRef.current = { x, y }; + setBrushOverlay((prev) => (prev ? { ...prev } : prev)); + }, + [brushOptions], + ); - const handleBrushStrokeEnd = React.useCallback((layerId: string) => { - const stroke = brushStrokeRef.current; - brushStrokeRef.current = null; - brushLastPosRef.current = null; - setBrushOverlay(null); - if (stroke && onBrushCommit) onBrushCommit(layerId, stroke); - }, [onBrushCommit]); + const handleBrushStrokeEnd = React.useCallback( + (layerId: string) => { + const stroke = brushStrokeRef.current; + brushStrokeRef.current = null; + brushLastPosRef.current = null; + setBrushOverlay(null); + if (stroke && onBrushCommit) onBrushCommit(layerId, stroke); + }, + [onBrushCommit], + ); const { containerRef, @@ -226,13 +269,7 @@ export function CanvasRenderer({ faceDetections, viewport, ); - }, [ - faceDetections, - faceOverlayLayerId, - layers, - tool, - viewport, - ]); + }, [faceDetections, faceOverlayLayerId, layers, tool, viewport]); return (
{ - if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null); + if ( + getToolDefinition(tool)?.interactionMode === "select" && + e.button === 0 + ) + onSelectLayer(null); onContainerPointerDown(e); }} onMouseDown={(e) => e.preventDefault()} @@ -282,12 +325,9 @@ export function CanvasRenderer({ {layers.map((layer, idx) => { const isSelected = layer.id === selectedLayerId; const isImage = layer.type === "raster"; - const layerWidth = - layer.width ?? - (isImage ? Math.round(200 * layer.scale) : undefined); - const layerHeight = - layer.height ?? - (isImage ? Math.round(150 * layer.scale) : undefined); + const sourceUri = getLayerRuntimeSource(layer); + const layerWidth = layer.width; + const layerHeight = layer.height; const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale; const handleSizePx = `${handleSize}px`; const largeHandleSize = @@ -319,8 +359,9 @@ export function CanvasRenderer({ onPointerDown={(e) => onLayerPointerDown(e, layer)} onClick={() => onSelectLayer(layer.id)} > - {isImage && layer.sourceUri ? ( - layer.effects.length > 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? ( + {isImage && sourceUri ? ( + layer.effects.length > 0 || + (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? ( ) : ( )} {brushOverlay && brushOverlay.layerId === layer.id ? ( - + ) : null} {tool === "face" && faceOverlayLayerId === layer.id ? faceDetections.map((face, index) => ( diff --git a/apps/web/components/canvas-size-modal.tsx b/apps/web/components/canvas-size-modal.tsx index f85fb33..fc15598 100644 --- a/apps/web/components/canvas-size-modal.tsx +++ b/apps/web/components/canvas-size-modal.tsx @@ -15,12 +15,48 @@ interface CanvasSizeModalProps { } const PRESETS = [ - { labelKey: "editor.presetSquare", sublabel: "1080 × 1080", aspect: "1:1" as AspectRatio, width: 1080, height: 1080 }, - { labelKey: "editor.presetPortrait45", sublabel: "1080 × 1350", aspect: "4:5" as AspectRatio, width: 1080, height: 1350 }, - { labelKey: "editor.presetStory916", sublabel: "1080 × 1920", aspect: "9:16" as AspectRatio, width: 1080, height: 1920 }, - { labelKey: "editor.presetWidescreen", sublabel: "1920 × 1080", aspect: "16:9" as AspectRatio, width: 1920, height: 1080 }, - { labelKey: "editor.presetPhoto43", sublabel: "1440 × 1080", aspect: "4:3" as AspectRatio, width: 1440, height: 1080 }, - { labelKey: "editor.presetClassic32", sublabel: "1620 × 1080", aspect: "3:2" as AspectRatio, width: 1620, height: 1080 }, + { + labelKey: "editor.presetSquare", + sublabel: "1080 × 1080", + aspect: "1:1" as AspectRatio, + width: 1080, + height: 1080, + }, + { + labelKey: "editor.presetPortrait45", + sublabel: "1080 × 1350", + aspect: "4:5" as AspectRatio, + width: 1080, + height: 1350, + }, + { + labelKey: "editor.presetStory916", + sublabel: "1080 × 1920", + aspect: "9:16" as AspectRatio, + width: 1080, + height: 1920, + }, + { + labelKey: "editor.presetWidescreen", + sublabel: "1920 × 1080", + aspect: "16:9" as AspectRatio, + width: 1920, + height: 1080, + }, + { + labelKey: "editor.presetPhoto43", + sublabel: "1440 × 1080", + aspect: "4:3" as AspectRatio, + width: 1440, + height: 1080, + }, + { + labelKey: "editor.presetClassic32", + sublabel: "1620 × 1080", + aspect: "3:2" as AspectRatio, + width: 1620, + height: 1080, + }, ]; export function CanvasSizeModal({ @@ -37,8 +73,11 @@ export function CanvasSizeModal({ PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom", ); const [customWidth, setCustomWidth] = React.useState(currentWidth.toString()); - const [customHeight, setCustomHeight] = React.useState(currentHeight.toString()); - const [selectedPreset, setSelectedPreset] = React.useState(currentAspect); + const [customHeight, setCustomHeight] = React.useState( + currentHeight.toString(), + ); + const [selectedPreset, setSelectedPreset] = + React.useState(currentAspect); if (!isOpen) return null; @@ -56,7 +95,8 @@ export function CanvasSizeModal({ onClose(); } - const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40"; + const overlay = + "fixed inset-0 z-50 flex items-center justify-center bg-black/40"; const panel = `w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${ isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white" }`; @@ -65,7 +105,9 @@ export function CanvasSizeModal({
e.stopPropagation()}>
-

+

{t("editor.canvasSizeTitle")}

@@ -118,33 +162,55 @@ export function CanvasSizeModal({ ) : (
- + setCustomWidth(e.target.value)} min={1} className={`flex-1 rounded border px-2 py-1.5 text-sm ${ - isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]" + isDark + ? "border-white/20 bg-[#25272b] text-[#e8eaed]" + : "border-black/20 bg-[#f6f8fb] text-[#1f2430]" }`} /> - px + + px +
- + setCustomHeight(e.target.value)} min={1} className={`flex-1 rounded border px-2 py-1.5 text-sm ${ - isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]" + isDark + ? "border-white/20 bg-[#25272b] text-[#e8eaed]" + : "border-black/20 bg-[#f6f8fb] text-[#1f2430]" }`} /> - px + + px +
- + {parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
@@ -155,7 +221,9 @@ export function CanvasSizeModal({ - -
diff --git a/apps/web/components/editor/editor-canvas-stage.test.tsx b/apps/web/components/editor/editor-canvas-stage.test.tsx index 7e488ef..0a01710 100644 --- a/apps/web/components/editor/editor-canvas-stage.test.tsx +++ b/apps/web/components/editor/editor-canvas-stage.test.tsx @@ -4,13 +4,20 @@ import { describe, expect, it, vi } from "vitest"; import { EditorCanvasStage } from "./editor-canvas-stage"; vi.mock("../canvas-renderer", () => ({ - CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => ( + CanvasRenderer: ({ + onContextMenu, + }: { + onContextMenu?: (x: number, y: number) => void; + }) => React.createElement( "button", - { type: "button", "data-testid": "canvas-renderer", onClick: () => onContextMenu?.(10, 12) }, + { + type: "button", + "data-testid": "canvas-renderer", + onClick: () => onContextMenu?.(10, 12), + }, "canvas", - ) - ), + ), })); describe("EditorCanvasStage", () => { diff --git a/apps/web/components/editor/editor-canvas-stage.tsx b/apps/web/components/editor/editor-canvas-stage.tsx index b12eb74..299a1ed 100644 --- a/apps/web/components/editor/editor-canvas-stage.tsx +++ b/apps/web/components/editor/editor-canvas-stage.tsx @@ -69,7 +69,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) { } = props; return ( -
+
+
-

+

pien.studio

-

{projectTitle}

+

+ {projectTitle} +

@@ -116,7 +145,9 @@ export function EditorHeader({ disabled={!canUndo} title={labels.undo} className={`rounded border px-2.5 py-1.5 text-xs font-medium ${ - isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]" + isDark + ? "border-white/15 bg-[#25272b] text-[#d7dae0]" + : "border-black/15 bg-[#f2f4f8] text-[#1f2430]" } ${!canUndo ? "opacity-50" : "hover:opacity-90"}`} > @@ -127,12 +158,16 @@ export function EditorHeader({ disabled={!canRedo} title={labels.redo} className={`rounded border px-2.5 py-1.5 text-xs font-medium ${ - isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]" + isDark + ? "border-white/15 bg-[#25272b] text-[#d7dae0]" + : "border-black/15 bg-[#f2f4f8] text-[#1f2430]" } ${!canRedo ? "opacity-50" : "hover:opacity-90"}`} > - + {isDirty ? labels.unsavedChanges : labels.saved}
@@ -140,10 +175,23 @@ export function EditorHeader({ ); } -function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) { +function MenuShell({ + isDark, + label, + menuClass, + children, +}: { + isDark: boolean; + label: string; + menuClass: string; + children: React.ReactNode; +}) { return (
-
event.stopPropagation()}> @@ -176,13 +224,43 @@ function FileMenu({ }) { return (
- - + +
- - + +
- +
); } @@ -210,29 +288,93 @@ function EditMenu({ }) { return (
- - + +
- - - + + +
); } -function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) { +function ViewMenu({ + isDark, + labels, + onSetHandTool, + onSetPointerTool, +}: { + isDark: boolean; + labels: EditorHeaderProps["labels"]; + onSetHandTool: () => void; + onSetPointerTool: () => void; +}) { return (
- - + +
); } -function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) { +function SettingsMenu({ + isDark, + preferences, +}: { + isDark: boolean; + preferences: string; +}) { return (
-

{preferences}

+

+ {preferences} +

); diff --git a/apps/web/components/editor/editor-mobile-section.test.tsx b/apps/web/components/editor/editor-mobile-section.test.tsx index 080b302..4828a02 100644 --- a/apps/web/components/editor/editor-mobile-section.test.tsx +++ b/apps/web/components/editor/editor-mobile-section.test.tsx @@ -4,7 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { EditorMobileSection } from "./editor-mobile-section"; vi.mock("../canvas-renderer", () => ({ - CanvasRenderer: () => React.createElement("div", { "data-testid": "canvas-renderer" }), + CanvasRenderer: () => + React.createElement("div", { "data-testid": "canvas-renderer" }), })); describe("EditorMobileSection", () => { @@ -17,7 +18,17 @@ describe("EditorMobileSection", () => { layers: [], selectedLayerId: null, tool: "face", - faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }], + faceDetections: [ + { + x: 1, + y: 1, + width: 10, + height: 10, + label: "f", + sourceWidth: 100, + sourceHeight: 100, + }, + ], faceOverlayLayerId: null, faceStatus: "idle", faceBlurPreview: null, diff --git a/apps/web/components/editor/editor-mobile-section.tsx b/apps/web/components/editor/editor-mobile-section.tsx index 1530daa..343a59c 100644 --- a/apps/web/components/editor/editor-mobile-section.tsx +++ b/apps/web/components/editor/editor-mobile-section.tsx @@ -66,15 +66,21 @@ export function EditorMobileSection(props: EditorMobileSectionProps) { return (
-
+
-

+

{canvasWidth} x {canvasHeight}px

{tool === "face" && ( -

+

{faceStatus === "unsupported" ? labels.faceMlFailedShort : faceStatus === "detecting" @@ -116,27 +124,35 @@ export function EditorMobileSection(props: EditorMobileSectionProps) { )}

-
+
- {[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => ( - - ))} + {[labels.mood, labels.quick, labels.face, labels.decor].map( + (toolLabel) => ( + + ), + )}
diff --git a/apps/web/components/editor/editor-sidebar.tsx b/apps/web/components/editor/editor-sidebar.tsx index 0926993..7183303 100644 --- a/apps/web/components/editor/editor-sidebar.tsx +++ b/apps/web/components/editor/editor-sidebar.tsx @@ -1,9 +1,17 @@ import React from "react"; -import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types"; +import type { + FaceBlurMethod, + Layer, + LayerEffect, + Project, +} from "@pien-studio/types"; import { FacePanel } from "./face-panel"; import { HistoryPanel } from "./history-panel"; import { LayersPanel } from "./layers-panel"; -import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection"; +import type { + FaceDetectionOverlay, + FacePreview, +} from "../../hooks/use-face-detection"; type EditorSidebarProps = { isDark: boolean; @@ -37,7 +45,11 @@ type EditorSidebarProps = { selectedFaceIndices: number[]; onSelectLayer: (layerId: string | null) => void; onSetLayerVisible: (layerId: string, visible: boolean) => void; - onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union + onSetEffectEnabled: ( + layerId: string, + kind: LayerEffect["kind"], + enabled: boolean, + ) => void; onMoveLayerOrder: (direction: "up" | "down") => void; onRemoveSelectedLayer: () => void; onUndo: () => void; @@ -99,7 +111,9 @@ export function EditorSidebar({ onClearBlur, }: EditorSidebarProps) { return ( -