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
+17 -4
View File
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path";
import type { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import type { FeatureBundleState, FeatureStatus } from "@snapotter/shared";
import { FEATURE_BUNDLES, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import { FEATURE_BUNDLES, getRequiredBundlesForTool } from "@snapotter/shared";
import * as tar from "tar";
// ── Paths ───────────────────────────────────────────────────────────────
@@ -146,9 +146,22 @@ export function isFeatureInstalled(bundleId: string): boolean {
}
export function isToolInstalled(toolId: string): boolean {
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return true;
return isFeatureInstalled(bundleId);
const required = getRequiredBundlesForTool(toolId);
if (required.length === 0) return true;
return required.every((bundleId) => isFeatureInstalled(bundleId));
}
/**
* The first required bundle for a tool that is not yet installed, or null when
* the tool needs no bundle or all of them are installed. A tool can require
* more than one bundle (see TOOL_EXTRA_BUNDLES), so this is what tells the user
* exactly which feature to install next.
*/
export function getFirstMissingBundleForTool(toolId: string): string | null {
for (const bundleId of getRequiredBundlesForTool(toolId)) {
if (!isFeatureInstalled(bundleId)) return bundleId;
}
return null;
}
// ── Install status mutations ────────────────────────────────────────────
+7 -6
View File
@@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getBundleForTool, TOOL_BUNDLE_MAP, TOOLS, toolSection } from "@snapotter/shared";
import { FEATURE_BUNDLES, TOOLS, toolSection } from "@snapotter/shared";
import archiver from "archiver";
import type { FlowJob } from "bullmq";
import { eq } from "drizzle-orm";
@@ -26,7 +26,7 @@ import { type Pool, queueName, type ToolJobData } from "../jobs/types.js";
import { autoOrient } from "../lib/auto-orient.js";
import { getSecurityHeaders } from "../lib/csp.js";
import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
import { getFirstMissingBundleForTool } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
@@ -79,13 +79,14 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
return reply.status(404).send({ error: `Tool "${toolId}" not found` });
}
// Guard: check if the tool's AI feature bundle is installed
if (!isToolInstalled(toolId)) {
const bundle = getBundleForTool(toolId);
// Guard: check that every AI feature bundle the tool needs is installed
const missingBundleId = getFirstMissingBundleForTool(toolId);
if (missingBundleId) {
const bundle = FEATURE_BUNDLES[missingBundleId];
return reply.status(501).send({
error: "Feature not installed",
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[toolId],
feature: missingBundleId,
featureName: bundle?.name ?? toolId,
estimatedSize: bundle?.estimatedSize ?? "unknown",
});
+10 -8
View File
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import { ANALYTICS_EVENTS, FEATURE_BUNDLES, TOOLS } from "@snapotter/shared";
import archiver from "archiver";
import type { FlowJob } from "bullmq";
import { eq } from "drizzle-orm";
@@ -26,7 +26,7 @@ import { trackEvent } from "../lib/analytics.js";
import { autoOrient } from "../lib/auto-orient.js";
import { getSecurityHeaders } from "../lib/csp.js";
import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
import { getFirstMissingBundleForTool } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
@@ -361,12 +361,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
}
// Guard: check if the tool's AI feature bundle is installed
if (!isToolInstalled(resolvedToolId)) {
const bundle = getBundleForTool(resolvedToolId);
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
if (missingBundleId) {
const bundle = FEATURE_BUNDLES[missingBundleId];
return reply.status(501).send({
error: `Step ${i + 1} (${step.toolId}): Feature "${bundle?.name}" is not installed`,
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[resolvedToolId],
feature: missingBundleId,
featureName: bundle?.name ?? resolvedToolId,
});
}
@@ -787,12 +788,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (!isToolInstalled(resolvedToolId)) {
const bundle = getBundleForTool(resolvedToolId);
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
if (missingBundleId) {
const bundle = FEATURE_BUNDLES[missingBundleId];
return reply.status(501).send({
error: `Step ${i + 1} (${step.toolId}): Feature "${bundle?.name}" is not installed`,
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[resolvedToolId],
feature: missingBundleId,
featureName: bundle?.name ?? resolvedToolId,
});
}
+12 -13
View File
@@ -3,18 +3,13 @@ import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { detectFaceLandmarks, removeBackground } from "@snapotter/ai";
import {
getBundleForTool,
PASSPORT_SPECS,
PRINT_LAYOUTS,
TOOL_BUNDLE_MAP,
} from "@snapotter/shared";
import { FEATURE_BUNDLES, PASSPORT_SPECS, PRINT_LAYOUTS } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { getFirstMissingBundleForTool } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
@@ -134,12 +129,15 @@ export function registerPassportPhoto(app: FastifyInstance) {
"/api/v1/tools/image/passport-photo/analyze",
async (request: FastifyRequest, reply: FastifyReply) => {
const toolId = "passport-photo";
if (!isToolInstalled(toolId)) {
const bundle = getBundleForTool(toolId);
// Passport Photo needs two bundles (face-detection + background-removal);
// report whichever one the user still has to install.
const missingBundleId = getFirstMissingBundleForTool(toolId);
if (missingBundleId) {
const bundle = FEATURE_BUNDLES[missingBundleId];
return reply.status(501).send({
error: "Feature not installed",
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[toolId],
feature: missingBundleId,
featureName: bundle?.name ?? toolId,
estimatedSize: bundle?.estimatedSize ?? "unknown",
});
@@ -313,12 +311,13 @@ export function registerPassportPhoto(app: FastifyInstance) {
"/api/v1/tools/image/passport-photo",
async (_request: FastifyRequest, reply: FastifyReply) => {
const toolId = "passport-photo";
if (!isToolInstalled(toolId)) {
const bundle = getBundleForTool(toolId);
const missingBundleId = getFirstMissingBundleForTool(toolId);
if (missingBundleId) {
const bundle = FEATURE_BUNDLES[missingBundleId];
return reply.status(501).send({
error: "Feature not installed",
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[toolId],
feature: missingBundleId,
featureName: bundle?.name ?? toolId,
estimatedSize: bundle?.estimatedSize ?? "unknown",
});