mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
👷 ci: Add CI
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
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
|
||||
|
||||
FROM deps AS builder
|
||||
WORKDIR /app/apps/api
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.3 AS runner
|
||||
WORKDIR /app/apps/api
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules /app/node_modules
|
||||
COPY --from=builder /app/apps/api/dist /app/apps/api/dist
|
||||
COPY --from=builder /app/apps/api/package.json /app/apps/api/package.json
|
||||
|
||||
EXPOSE 4000
|
||||
CMD ["bun", "run", "dist/index.js"]
|
||||
@@ -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"}`}>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
Reference in New Issue
Block a user