From 6fc767523c785703ad87765930b5fb9cd3d991f4 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 22:21:45 +0800 Subject: [PATCH] fix: add mediapipe to upscale-enhance bundle for face enhancement The enhance-faces tool requires MediaPipe for face detection, but the upscale-enhance feature bundle did not include mediapipe in its pip packages. Users who installed only the upscale-enhance bundle got "Face detection requires MediaPipe" errors. Added mediapipe to both amd64 and arm64 package lists, matching the pattern used by the face-detection and photo-restoration bundles. Also added feature-manifest.test.ts with 25 tests validating bundle dependency completeness to prevent similar missing-dependency bugs. Closes #129 --- docker/feature-manifest.json | 5 +- tests/unit/features/feature-manifest.test.ts | 212 +++++++++++++++++++ 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 tests/unit/features/feature-manifest.test.ts diff --git a/docker/feature-manifest.json b/docker/feature-manifest.json index 9525012d..91fae4ab 100644 --- a/docker/feature-manifest.json +++ b/docker/feature-manifest.json @@ -139,9 +139,10 @@ "amd64": [ "torch==2.7.0+cu126 torchvision==0.22.0+cu126 --index-url https://download.pytorch.org/whl/cu126", "lpips", - "realesrgan==0.3.0" + "realesrgan==0.3.0", + "mediapipe==0.10.21" ], - "arm64": ["lpips", "realesrgan==0.3.0"] + "arm64": ["lpips", "realesrgan==0.3.0", "mediapipe==0.10.18"] }, "pipFlags": { "codeformer-pip==0.0.4": "--no-deps" diff --git a/tests/unit/features/feature-manifest.test.ts b/tests/unit/features/feature-manifest.test.ts new file mode 100644 index 00000000..824161f8 --- /dev/null +++ b/tests/unit/features/feature-manifest.test.ts @@ -0,0 +1,212 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const manifestPath = join(import.meta.dirname, "../../../docker/feature-manifest.json"); +const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); +const bundles = manifest.bundles; + +function getAllPackages(bundleId: string): string[] { + const bundle = bundles[bundleId]; + const common = bundle.packages.common ?? []; + const amd64 = bundle.packages.amd64 ?? []; + const arm64 = bundle.packages.arm64 ?? []; + return [...common, ...amd64, ...arm64].map((p: string) => p.split(/[=<>!\s[]/)[0].toLowerCase()); +} + +function getBasePackages(): string[] { + return (manifest.basePackages ?? []).map((p: string) => p.split(/[=<>!\s[]/)[0].toLowerCase()); +} + +function bundleIncludesPackage(bundleId: string, pkg: string): boolean { + const all = [...getAllPackages(bundleId), ...getBasePackages()]; + return all.some((p) => p === pkg.toLowerCase()); +} + +function archPackagesInclude(bundleId: string, arch: "amd64" | "arm64", pkg: string): boolean { + const archPkgs = (bundles[bundleId].packages[arch] ?? []).map((p: string) => + p.split(/[=<>!\s[]/)[0].toLowerCase(), + ); + return archPkgs.some((p: string) => p === pkg.toLowerCase()); +} + +describe("Feature manifest structure", () => { + it("manifest has valid version fields", () => { + expect(manifest.manifestVersion).toBe(1); + expect(manifest.pythonVersion).toBeDefined(); + expect(manifest.basePackages).toBeInstanceOf(Array); + }); + + it("all 6 bundles are defined", () => { + expect(Object.keys(bundles)).toHaveLength(6); + expect(bundles["background-removal"]).toBeDefined(); + expect(bundles["face-detection"]).toBeDefined(); + expect(bundles["object-eraser-colorize"]).toBeDefined(); + expect(bundles["upscale-enhance"]).toBeDefined(); + expect(bundles["photo-restoration"]).toBeDefined(); + expect(bundles["ocr"]).toBeDefined(); + }); + + it("every bundle has required fields", () => { + for (const [id, bundle] of Object.entries>(bundles)) { + expect(bundle.name, `${id} missing name`).toBeDefined(); + expect(bundle.description, `${id} missing description`).toBeDefined(); + expect(bundle.packages, `${id} missing packages`).toBeDefined(); + expect(bundle.enablesTools, `${id} missing enablesTools`).toBeDefined(); + + const pkgs = bundle.packages as Record; + expect(pkgs.common, `${id} missing packages.common`).toBeInstanceOf(Array); + expect(pkgs.amd64, `${id} missing packages.amd64`).toBeInstanceOf(Array); + expect(pkgs.arm64, `${id} missing packages.arm64`).toBeInstanceOf(Array); + } + }); + + it("every bundle has at least one enabled tool", () => { + for (const [id, bundle] of Object.entries>(bundles)) { + const tools = bundle.enablesTools as string[]; + expect(tools.length, `${id} has no enabled tools`).toBeGreaterThan(0); + } + }); +}); + +describe("Feature manifest: mediapipe dependency (regression for #129)", () => { + it("upscale-enhance bundle includes mediapipe for amd64", () => { + expect(archPackagesInclude("upscale-enhance", "amd64", "mediapipe")).toBe(true); + }); + + it("upscale-enhance bundle includes mediapipe for arm64", () => { + expect(archPackagesInclude("upscale-enhance", "arm64", "mediapipe")).toBe(true); + }); +}); + +describe("Feature manifest: bundle dependency completeness", () => { + const TOOL_REQUIRED_PACKAGES: Record = { + "enhance-faces": { + bundle: "upscale-enhance", + requires: ["mediapipe", "codeformer-pip", "realesrgan"], + }, + "blur-faces": { + bundle: "face-detection", + requires: ["mediapipe"], + }, + "red-eye-removal": { + bundle: "face-detection", + requires: ["mediapipe"], + }, + "remove-background": { + bundle: "background-removal", + requires: ["rembg"], + }, + "erase-object": { + bundle: "object-eraser-colorize", + requires: ["huggingface-hub"], + }, + upscale: { + bundle: "upscale-enhance", + requires: ["realesrgan"], + }, + "noise-removal": { + bundle: "upscale-enhance", + requires: ["einops"], + }, + "restore-photo": { + bundle: "photo-restoration", + requires: ["mediapipe", "codeformer-pip", "realesrgan"], + }, + ocr: { + bundle: "ocr", + requires: ["paddleocr"], + }, + colorize: { + bundle: "object-eraser-colorize", + requires: ["huggingface-hub"], + }, + }; + + for (const [toolId, { bundle, requires }] of Object.entries(TOOL_REQUIRED_PACKAGES)) { + for (const pkg of requires) { + it(`${bundle} bundle includes ${pkg} (needed by ${toolId})`, () => { + expect( + bundleIncludesPackage(bundle, pkg), + `Bundle "${bundle}" is missing "${pkg}" which is required by tool "${toolId}". ` + + `This will cause an ImportError at runtime when "${toolId}" is used.`, + ).toBe(true); + }); + } + } +}); + +describe("Feature manifest: mediapipe version consistency", () => { + const MEDIAPIPE_BUNDLES = [ + "face-detection", + "background-removal", + "upscale-enhance", + "photo-restoration", + ]; + + it("all bundles use the same mediapipe version for amd64", () => { + const versions: string[] = []; + for (const bundleId of MEDIAPIPE_BUNDLES) { + const amd64Pkgs = bundles[bundleId].packages.amd64 as string[]; + const mp = amd64Pkgs.find((p: string) => p.startsWith("mediapipe")); + if (mp) versions.push(mp); + } + expect(versions.length).toBeGreaterThan(0); + expect( + new Set(versions).size, + `Inconsistent mediapipe versions on amd64: ${versions.join(", ")}`, + ).toBe(1); + }); + + it("all bundles use the same mediapipe version for arm64", () => { + const versions: string[] = []; + for (const bundleId of MEDIAPIPE_BUNDLES) { + const arm64Pkgs = bundles[bundleId].packages.arm64 as string[]; + const mp = arm64Pkgs.find((p: string) => p.startsWith("mediapipe")); + if (mp) versions.push(mp); + } + expect(versions.length).toBeGreaterThan(0); + expect( + new Set(versions).size, + `Inconsistent mediapipe versions on arm64: ${versions.join(", ")}`, + ).toBe(1); + }); +}); + +describe("Feature manifest: architecture parity", () => { + it("every bundle with amd64 packages has arm64 packages", () => { + for (const [id, bundle] of Object.entries>(bundles)) { + const pkgs = bundle.packages as Record; + if (pkgs.amd64.length > 0) { + expect(pkgs.arm64.length, `${id} has amd64 packages but no arm64 packages`).toBeGreaterThan( + 0, + ); + } + } + }); + + it("mediapipe is present on both architectures when present on either", () => { + for (const [id, bundle] of Object.entries>(bundles)) { + const pkgs = bundle.packages as Record; + const hasAmd64 = pkgs.amd64.some((p: string) => p.startsWith("mediapipe")); + const hasArm64 = pkgs.arm64.some((p: string) => p.startsWith("mediapipe")); + if (hasAmd64 || hasArm64) { + expect(hasAmd64, `${id}: mediapipe in arm64 but missing from amd64`).toBe(true); + expect(hasArm64, `${id}: mediapipe in amd64 but missing from arm64`).toBe(true); + } + } + }); +}); + +describe("Feature manifest: enablesTools consistency with shared features", () => { + it("manifest enablesTools matches features.ts for each bundle", async () => { + const { FEATURE_BUNDLES } = await import("@snapotter/shared"); + for (const [id, bundle] of Object.entries>(bundles)) { + const manifestTools = (bundle.enablesTools as string[]).sort(); + const sharedTools = FEATURE_BUNDLES[id].enablesTools.sort(); + expect(manifestTools, `${id}: manifest enablesTools diverges from features.ts`).toEqual( + sharedTools, + ); + } + }); +});