fix(passport-photo): require the face-detection bundle, not just background-removal (#329)

* fix(passport-photo): require the face-detection bundle, not just background-removal

Passport Photo runs face-landmark detection (face_landmarks.py, gated to the
face-detection bundle) before background removal (background-removal bundle),
but it was only declared under and guarded against background-removal. A user
who installed only Background Removal passed every JS-side check, then hit a
late "feature_not_installed" from the Python dispatcher gate when the analyze
step ran face landmarks, and the UI never told them Face Detection was needed.

- shared: add TOOL_EXTRA_BUNDLES + getRequiredBundlesForTool so a tool can
  declare more than one required bundle (passport-photo needs background-removal
  and face-detection). enablesTools is untouched, so the one-tool-per-bundle
  invariant still holds.
- api: isToolInstalled() now checks every required bundle; add
  getFirstMissingBundleForTool() so the analyze and base routes, pipeline (both
  guards) and batch report the bundle the user actually still needs.
- web: the proactive install prompt (tool-page) and features-store treat a tool
  as installed only when all required bundles are present, and point the prompt
  at the first missing one (sequential install, no new UI).

Refs #327

* test(passport-photo): deterministic integration coverage for the two-bundle guard

Boots the real API with an isolated DATA_DIR and controls installed.json to
prove the HTTP route behavior end-to-end:
- nothing installed -> 501 naming background-removal
- only background-removal installed -> 501 naming face-detection (issue #327)
- both installed -> guard passes (not 501)
- base route reports face-detection too

Refs #327
This commit is contained in:
SnapOtter
2026-06-22 23:31:18 +08:00
committed by GitHub
parent 8952e9ba47
commit 32c1192d63
11 changed files with 346 additions and 43 deletions
+12 -4
View File
@@ -1,7 +1,7 @@
import {
getRequiredBundlesForTool,
PYTHON_SIDECAR_TOOLS,
SECTIONS,
TOOL_BUNDLE_MAP,
TOOLS,
toolSection,
} from "@snapotter/shared";
@@ -226,9 +226,17 @@ export function ToolPage() {
const fetchFeatures = useFeaturesStore((s) => s.fetch);
const featureBundle = useMemo(() => {
if (!toolId) return null;
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return null;
return featureBundles.find((b) => b.id === bundleId) ?? null;
const required = getRequiredBundlesForTool(toolId);
if (required.length === 0) return null;
// A tool can need more than one bundle (e.g. passport-photo needs
// background-removal AND face-detection). Surface the first one that is
// still missing so the install prompt asks for what's actually needed;
// once everything is installed, fall back to the primary bundle.
for (const bundleId of required) {
const bundle = featureBundles.find((b) => b.id === bundleId);
if (bundle && bundle.status !== "installed") return bundle;
}
return featureBundles.find((b) => b.id === required[0]) ?? null;
}, [toolId, featureBundles]);
const toolInstalled = featureBundle ? featureBundle.status === "installed" : !isAiTool;
const showSizeComparison = toolId === "compress" || toolId === "optimize-for-web";
+28 -8
View File
@@ -1,8 +1,20 @@
import type { FeatureBundleState } from "@snapotter/shared";
import { TOOL_BUNDLE_MAP } from "@snapotter/shared";
import { TOOL_BUNDLE_MAP, TOOL_EXTRA_BUNDLES } from "@snapotter/shared";
import { create } from "zustand";
import { apiGet, apiPost } from "@/lib/api";
/**
* Every bundle a tool needs: its primary bundle plus any extras. Computed from
* the exported maps (not the shared helper) so unit tests that mock
* TOOL_BUNDLE_MAP still drive this logic. A tool can need more than one bundle
* (e.g. passport-photo needs background-removal AND face-detection).
*/
function requiredBundlesForTool(toolId: string): string[] {
const primary = TOOL_BUNDLE_MAP[toolId];
if (!primary) return [];
return [...new Set([primary, ...(TOOL_EXTRA_BUNDLES[toolId] ?? [])])];
}
interface BundleProgress {
percent: number;
stage: string;
@@ -197,16 +209,24 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
refresh: refreshBundles,
isToolInstalled: (toolId: string) => {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return true;
const bundle = get().bundles.find((b) => b.id === bundleId);
return bundle?.status === "installed";
const required = requiredBundlesForTool(toolId);
if (required.length === 0) return true;
return required.every(
(bundleId) => get().bundles.find((b) => b.id === bundleId)?.status === "installed",
);
},
getBundleForTool: (toolId: string) => {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return null;
return get().bundles.find((b) => b.id === bundleId) ?? null;
const required = requiredBundlesForTool(toolId);
if (required.length === 0) return null;
const bundles = get().bundles;
// Point the user at the first bundle they still need to install; fall
// back to the primary bundle once everything required is installed.
for (const bundleId of required) {
const bundle = bundles.find((b) => b.id === bundleId);
if (bundle && bundle.status !== "installed") return bundle;
}
return bundles.find((b) => b.id === required[0]) ?? null;
},
installBundle: async (bundleId: string) => {