mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(erase-object): optional high-quality diffusion inpainting bundle (#566)
Adds an opt-in High Quality mode to the Object Eraser, backed by a new inpaint-hq feature bundle (Stable Diffusion 1.5 inpainting via diffusers). The default fast LaMa path is unchanged. Both arch archives are published to deepsafe/feature-bundles and the manifest carries their real sha256/sizes. Verified end to end: a fresh container pulls the bundle from HuggingFace, checksum-verifies it, extracts torch/diffusers plus the fp16 model, and the HQ sidecar erases a large object with a plausible fill. Refs #141
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { inpaint } from "@snapotter/ai";
|
||||
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
||||
import { FEATURE_BUNDLES, getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
@@ -8,7 +8,7 @@ import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { isFeatureInstalled, isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||
@@ -24,8 +24,12 @@ const settingsSchema = z.object({
|
||||
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"])
|
||||
.default("auto"),
|
||||
quality: z.number().int().min(1).max(100).default(95),
|
||||
// "fast" = LaMa (always available); "hq" = diffusion, gated behind inpaint-hq.
|
||||
qualityMode: z.enum(["fast", "hq"]).default("fast"),
|
||||
});
|
||||
|
||||
const HQ_BUNDLE_ID = "inpaint-hq";
|
||||
|
||||
/**
|
||||
* Object eraser / inpainting route.
|
||||
* Accepts an image and a mask image, erases masked areas using LaMa.
|
||||
@@ -60,6 +64,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
let saveModeRaw: string | null = null;
|
||||
let format = "png";
|
||||
let quality = 95;
|
||||
let qualityMode = "fast";
|
||||
let imageKey: string | null = null;
|
||||
let maskKey: string | null = null;
|
||||
|
||||
@@ -88,6 +93,8 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
format = (part.value as string) || "png";
|
||||
} else if (part.fieldname === "quality") {
|
||||
quality = Number(part.value) || 95;
|
||||
} else if (part.fieldname === "qualityMode") {
|
||||
qualityMode = (part.value as string) || "fast";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -123,8 +130,8 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
|
||||
}
|
||||
|
||||
// Validate format and quality via Zod
|
||||
const settingsResult = settingsSchema.safeParse({ format, quality });
|
||||
// Validate format, quality, and quality mode via Zod
|
||||
const settingsResult = settingsSchema.safeParse({ format, quality, qualityMode });
|
||||
if (!settingsResult.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
@@ -135,6 +142,21 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
}
|
||||
format = settingsResult.data.format;
|
||||
quality = settingsResult.data.quality;
|
||||
qualityMode = settingsResult.data.qualityMode;
|
||||
|
||||
// High-Quality mode needs the optional diffusion bundle on top of the base
|
||||
// (LaMa) bundle already checked above. Fail loud with the standard install
|
||||
// contract; never silently downgrade HQ to the fast path.
|
||||
if (qualityMode === "hq" && !isFeatureInstalled(HQ_BUNDLE_ID)) {
|
||||
const hqBundle = FEATURE_BUNDLES[HQ_BUNDLE_ID];
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: HQ_BUNDLE_ID,
|
||||
featureName: hqBundle?.name ?? "High-Quality Inpainting",
|
||||
estimatedSize: hqBundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "auto") {
|
||||
const detected = await resolveOutputFormat(imageBuffer, filename);
|
||||
@@ -176,7 +198,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
pool: "ai",
|
||||
inputRefs: [imageKey, maskKey],
|
||||
filename,
|
||||
settings: { format, quality },
|
||||
settings: { format, quality, qualityMode },
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
@@ -198,8 +220,12 @@ registerAiJobHandler("erase-object", async (input, data, ctx) => {
|
||||
const format = settings.format;
|
||||
const quality = settings.quality;
|
||||
|
||||
const resultBuffer = await inpaint(input, maskBuffer, ctx.scratchDir, (percent, stage) =>
|
||||
ctx.report(percent, stage),
|
||||
const resultBuffer = await inpaint(
|
||||
input,
|
||||
maskBuffer,
|
||||
ctx.scratchDir,
|
||||
(percent, stage) => ctx.report(percent, stage),
|
||||
settings.qualityMode,
|
||||
);
|
||||
|
||||
// Convert to requested output format
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Download, Lasso, Paintbrush, Redo, Trash2 } from "lucide-react";
|
||||
import { Download, Lasso, Loader2, Paintbrush, Redo, Sparkles, Trash2, Zap } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { format } from "@/lib/format";
|
||||
import { format, formatFileSize } from "@/lib/format";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { EraserCanvasRef } from "./eraser-canvas";
|
||||
|
||||
type QualityMode = "fast" | "hq";
|
||||
const HQ_BUNDLE_ID = "inpaint-hq";
|
||||
|
||||
const OUTPUT_FORMATS = [
|
||||
"png",
|
||||
"jpg",
|
||||
@@ -160,6 +165,22 @@ export function EraseObjectSettings({
|
||||
|
||||
const [outputFormat, setOutputFormat] = useState("png");
|
||||
const [quality, setQuality] = useState(95);
|
||||
const [qualityMode, setQualityMode] = useState<QualityMode>("fast");
|
||||
|
||||
// High-Quality (diffusion) mode is backed by the optional inpaint-hq bundle.
|
||||
// Mirrors the OCR quality control: pick the mode, and if the pack is missing
|
||||
// show the standard install prompt instead of silently running the fast path.
|
||||
const { hasPermission } = useAuth();
|
||||
const hqBundle = useFeaturesStore((s) => s.bundles.find((b) => b.id === HQ_BUNDLE_ID));
|
||||
const hqInstalled = hqBundle?.status === "installed";
|
||||
const installBundle = useFeaturesStore((s) => s.installBundle);
|
||||
const hqInstalling = useFeaturesStore((s) => s.installing[HQ_BUNDLE_ID]);
|
||||
const hqQueued = useFeaturesStore((s) => s.queued.includes(HQ_BUNDLE_ID));
|
||||
const hqInstallError = useFeaturesStore((s) => s.errors[HQ_BUNDLE_ID]);
|
||||
const needsHqPack = qualityMode === "hq" && !hqInstalled;
|
||||
const isAdmin = hasPermission("features:manage");
|
||||
const hqSizeBytes = hqBundle?.missingDownloadBytes ?? hqBundle?.downloadBytes;
|
||||
const hqSize = hqSizeBytes ? formatFileSize(hqSizeBytes) : (hqBundle?.estimatedSize ?? "5-7 GB");
|
||||
|
||||
const processOneFile = (
|
||||
entryIndex: number,
|
||||
@@ -199,6 +220,7 @@ export function EraseObjectSettings({
|
||||
formData.append("clientJobId", clientJobId);
|
||||
formData.append("format", outputFormat);
|
||||
formData.append("quality", String(quality));
|
||||
formData.append("qualityMode", qualityMode);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 600_000;
|
||||
@@ -323,6 +345,7 @@ export function EraseObjectSettings({
|
||||
formData.append("clientJobId", clientJobId);
|
||||
formData.append("format", outputFormat);
|
||||
formData.append("quality", String(quality));
|
||||
formData.append("qualityMode", qualityMode);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 600_000;
|
||||
@@ -482,6 +505,81 @@ export function EraseObjectSettings({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quality: Fast (LaMa, always available) vs High quality (diffusion, inpaint-hq) */}
|
||||
<div>
|
||||
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="eraser-quality-fast"
|
||||
aria-pressed={qualityMode === "fast"}
|
||||
disabled={processing}
|
||||
onClick={() => setQualityMode("fast")}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
qualityMode === "fast"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t.toolSettings["erase-object"].qualityFast}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="eraser-quality-hq"
|
||||
aria-pressed={qualityMode === "hq"}
|
||||
disabled={processing}
|
||||
onClick={() => setQualityMode("hq")}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
qualityMode === "hq"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t.toolSettings["erase-object"].qualityHq}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{qualityMode === "hq" && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{t.toolSettings["erase-object"].qualityHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{needsHqPack && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-muted/40 p-3 text-start">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(t.features.requiresDownload, { size: hqSize })}
|
||||
</p>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="eraser-install-hq"
|
||||
onClick={() => installBundle(HQ_BUNDLE_ID)}
|
||||
disabled={!!hqInstalling || hqQueued}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{hqInstalling || hqQueued ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{hqInstalling || hqQueued
|
||||
? t.settings.aiFeatures.installing
|
||||
: format(t.features.enableButton, {
|
||||
name: hqBundle?.name ?? "High-Quality Inpainting",
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t.features.notEnabledDescription}
|
||||
</p>
|
||||
)}
|
||||
{hqInstallError && <p className="mt-1 text-xs text-destructive">{hqInstallError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brush size (brush mode only) */}
|
||||
{mode === "brush" && (
|
||||
<div>
|
||||
@@ -606,7 +704,7 @@ export function EraseObjectSettings({
|
||||
type="button"
|
||||
data-testid="erase-object-submit"
|
||||
onClick={maskedFileCount > 1 ? handleProcessAll : handleProcess}
|
||||
disabled={!hasFile || (!hasStrokes && maskedFileCount === 0) || processing}
|
||||
disabled={!hasFile || (!hasStrokes && maskedFileCount === 0) || processing || needsHqPack}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{maskedFileCount > 1
|
||||
|
||||
Reference in New Issue
Block a user