👷 ci: Add CI

This commit is contained in:
2026-05-10 13:37:51 +07:00
parent f84c9a4bb2
commit 4d2a1bb7ec
15 changed files with 361 additions and 114 deletions
+29
View File
@@ -0,0 +1,29 @@
FROM oven/bun:1.3 AS deps
WORKDIR /app
COPY package.json bun.lock turbo.json tsconfig.base.json bunfig.toml ./
COPY apps ./apps
COPY packages ./packages
RUN bun install --frozen-lockfile
RUN bun install --cwd /app/apps/web --frozen-lockfile
FROM deps AS builder
WORKDIR /app/apps/web
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build
FROM oven/bun:1.3 AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PATH=/app/apps/web/node_modules/.bin:/app/node_modules/.bin:$PATH
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=deps /app/apps/web/node_modules /app/apps/web/node_modules
COPY --from=builder /app/apps/web/.next /app/apps/web/.next
COPY --from=builder /app/apps/web/package.json /app/apps/web/package.json
COPY --from=builder /app/apps/web/next.config.mjs /app/apps/web/next.config.mjs
WORKDIR /app/apps/web
EXPOSE 3000
CMD ["bun", "run", "--cwd", "/app/apps/web", "start"]
@@ -5,9 +5,11 @@ import { EditorCanvasStage } from "./editor-canvas-stage";
vi.mock("../canvas-renderer", () => ({
CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => (
<button type="button" data-testid="canvas-renderer" onClick={() => onContextMenu?.(10, 12)}>
canvas
</button>
React.createElement(
"button",
{ type: "button", "data-testid": "canvas-renderer", onClick: () => onContextMenu?.(10, 12) },
"canvas",
)
),
}));
@@ -17,33 +19,33 @@ describe("EditorCanvasStage", () => {
const onCloseContextMenu = vi.fn();
render(
<EditorCanvasStage
isDark={false}
layers={[]}
canvasWidth={100}
canvasHeight={100}
selectedLayerId={null}
tool="pointer"
faceDetections={[]}
faceOverlayLayerId={null}
faceBlurPreview={null}
contextMenu={{ x: 10, y: 20 }}
labels={{ copy: "Copy", cut: "Cut", paste: "Paste" }}
onSelectLayer={() => undefined}
onMoveLayer={() => undefined}
onMoveLayerEnd={() => undefined}
onResizeLayer={() => undefined}
onResizeLayerEnd={() => undefined}
onRotateLayer={() => undefined}
onRotateLayerEnd={() => undefined}
onInteractionStart={() => undefined}
onInteractionEnd={() => undefined}
onContextMenu={() => undefined}
onCloseContextMenu={onCloseContextMenu}
onCopy={onCopy}
onCut={() => undefined}
onPaste={() => undefined}
/>,
React.createElement(EditorCanvasStage, {
isDark: false,
layers: [],
canvasWidth: 100,
canvasHeight: 100,
selectedLayerId: null,
tool: "pointer",
faceDetections: [],
faceOverlayLayerId: null,
faceBlurPreview: null,
contextMenu: { x: 10, y: 20 },
labels: { copy: "Copy", cut: "Cut", paste: "Paste" },
onSelectLayer: () => undefined,
onMoveLayer: () => undefined,
onMoveLayerEnd: () => undefined,
onResizeLayer: () => undefined,
onResizeLayerEnd: () => undefined,
onRotateLayer: () => undefined,
onRotateLayerEnd: () => undefined,
onInteractionStart: () => undefined,
onInteractionEnd: () => undefined,
onContextMenu: () => undefined,
onCloseContextMenu,
onCopy,
onCut: () => undefined,
onPaste: () => undefined,
}),
);
fireEvent.click(screen.getByText("Copy"));
@@ -99,7 +99,7 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
faceOverlayLayerId={faceOverlayLayerId}
faceBlurPreview={faceBlurPreview}
/>
{contextMenu ? (
{contextMenu && (
<CanvasContextMenu
isDark={isDark}
x={contextMenu.x}
@@ -118,7 +118,7 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
onCloseContextMenu();
}}
/>
) : null}
)}
</div>
</div>
</div>
@@ -4,24 +4,24 @@ import { describe, expect, it, vi } from "vitest";
import { EditorMobileSection } from "./editor-mobile-section";
vi.mock("../canvas-renderer", () => ({
CanvasRenderer: () => <div data-testid="canvas-renderer" />,
CanvasRenderer: () => React.createElement("div", { "data-testid": "canvas-renderer" }),
}));
describe("EditorMobileSection", () => {
it("shows face detection helper message in face mode", () => {
render(
<EditorMobileSection
isDark={false}
canvasWidth={100}
canvasHeight={120}
layers={[]}
selectedLayerId={null}
tool="face"
faceDetections={[{ x: 1, y: 1, width: 10, height: 10, label: "f" }]}
faceOverlayLayerId={null}
faceStatus="idle"
faceBlurPreview={null}
labels={{
React.createElement(EditorMobileSection, {
isDark: false,
canvasWidth: 100,
canvasHeight: 120,
layers: [],
selectedLayerId: null,
tool: "face",
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f" }],
faceOverlayLayerId: null,
faceStatus: "idle",
faceBlurPreview: null,
labels: {
resize: "Resize",
faceMlFailedShort: "Face failed",
detectingFacesShort: "Detecting",
@@ -31,19 +31,19 @@ describe("EditorMobileSection", () => {
quick: "Quick",
face: "Face",
decor: "Decor",
}}
onOpenCanvasSize={() => undefined}
onImportImage={() => undefined}
onSelectLayer={() => undefined}
onMoveLayer={() => undefined}
onMoveLayerEnd={() => undefined}
onResizeLayer={() => undefined}
onResizeLayerEnd={() => undefined}
onRotateLayer={() => undefined}
onRotateLayerEnd={() => undefined}
onInteractionStart={() => undefined}
onInteractionEnd={() => undefined}
/>,
},
onOpenCanvasSize: () => undefined,
onImportImage: () => undefined,
onSelectLayer: () => undefined,
onMoveLayer: () => undefined,
onMoveLayerEnd: () => undefined,
onResizeLayer: () => undefined,
onResizeLayerEnd: () => undefined,
onRotateLayer: () => undefined,
onRotateLayerEnd: () => undefined,
onInteractionStart: () => undefined,
onInteractionEnd: () => undefined,
}),
);
expect(screen.getByText("Faces 1")).toBeInTheDocument();
@@ -110,7 +110,7 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
faceBlurPreview={faceBlurPreview}
/>
</div>
{tool === "face" ? (
{tool === "face" && (
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
{faceStatus === "unsupported"
? labels.faceMlFailedShort
@@ -118,7 +118,7 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
? labels.detectingFacesShort
: labels.faceDetectionTip(faceDetections.length)}
</p>
) : null}
)}
</div>
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
+5 -3
View File
@@ -39,9 +39,11 @@ export function renderFaceBlurRegions(
const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight);
for (const region of blur.regions) {
const hasSourceDims = typeof region.sourceWidth === "number" && typeof region.sourceHeight === "number" && region.sourceWidth > 0 && region.sourceHeight > 0;
const scaleX = hasSourceDims ? targetWidth / region.sourceWidth : 1;
const scaleY = hasSourceDims ? targetHeight / region.sourceHeight : 1;
const sourceWidth = region.sourceWidth ?? 0;
const sourceHeight = region.sourceHeight ?? 0;
const hasSourceDims = sourceWidth > 0 && sourceHeight > 0;
const scaleX = hasSourceDims ? targetWidth / sourceWidth : 1;
const scaleY = hasSourceDims ? targetHeight / sourceHeight : 1;
const x = Math.max(0, Math.floor(region.x * scaleX));
const y = Math.max(0, Math.floor(region.y * scaleY));
+14 -6
View File
@@ -19,7 +19,10 @@ export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[
faceapi.nets.ageGenderNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
]);
const TinyFaceDetectorOptions = (faceapi as unknown as { TinyFaceDetectorOptions: new (o: object) => object }).TinyFaceDetectorOptions;
type TinyFaceOptions = { inputSize: number; scoreThreshold: number };
const TinyFaceDetectorOptions = (
faceapi as unknown as { TinyFaceDetectorOptions: new (options: TinyFaceOptions) => TinyFaceOptions }
).TinyFaceDetectorOptions;
const passes = [
{ inputSize: 320, scoreThreshold: 0.5 },
@@ -41,9 +44,14 @@ export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[
(faceapi as unknown as {
detectAllFaces: (
img: HTMLImageElement,
options: { inputSize: number; scoreThreshold: number }
) => Promise<FaceApiDet[]>
}).detectAllFaces(image, new TinyFaceDetectorOptions({ inputSize, scoreThreshold }))
options: TinyFaceOptions
) => {
withFaceLandmarks: (useTinyLandmarkNet: boolean) => {
withAgeAndGender: () => Promise<FaceApiDet[]>;
};
};
})
.detectAllFaces(image, new TinyFaceDetectorOptions({ inputSize, scoreThreshold }))
.withFaceLandmarks(true)
.withAgeAndGender()
)
@@ -56,7 +64,7 @@ export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[
const ix = Math.max(a.x, b.x);
const iy = Math.max(a.y, b.y);
const ix2 = Math.min(a.x + a.width, b.x + b.width);
const iy2 = Math.min(a.y + b.height, b.y + b.height);
const iy2 = Math.min(a.y + a.height, b.y + b.height);
const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
const union = a.width * a.height + b.width * b.height - inter;
return union > 0 ? inter / union : 0;
@@ -112,4 +120,4 @@ export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[
genderScore,
};
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8 -1
View File
@@ -10,12 +10,13 @@
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dependencies": {
"@mediapipe/face_detection": "~0.4.0",
"@pien-studio/editor-core": "workspace:*",
"@pien-studio/storage": "workspace:*",
"@pien-studio/types": "workspace:*",
"@radix-ui/react-select": "^2.2.6",
"@tailwindcss/postcss": "^4.1.13",
"@tensorflow-models/face-detection": "^1.0.3",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-backend-cpu": "^4.22.0",
@@ -30,6 +31,12 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwindcss": "^4.2.4",
"autoprefixer": "^10.4.21",
"zustand": "^5.0.13"
},
"devDependencies": {
"fake-indexeddb": "^6.2.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2"
}
}
@@ -1,6 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
oxc: false,
esbuild: {
jsx: "automatic",
},
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],