feat: support downloadFn-based model manifests and improve install error messages

Add support for models defined via downloadFn/args (rembg_session,
hf_snapshot) in bundle verification, recovery, and uninstall paths.
Previously only path-based models were tracked, so bundles using
rembg or HF snapshot downloads appeared broken after install.

Also improve pip install error messages with user-friendly hints for
common failures (basicsr build issues, OOM, disk full) and add better
error context for rembg session download failures.
This commit is contained in:
SnapOtter
2026-06-04 22:27:44 +08:00
parent 1616ad1a5a
commit e03c6089af
3 changed files with 139 additions and 46 deletions
+57 -27
View File
@@ -219,6 +219,8 @@ export function setInstallProgress(
interface ManifestModel {
id: string;
path?: string;
downloadFn?: string;
args?: string[];
minSize?: number;
}
@@ -309,25 +311,42 @@ export function recoverInterruptedInstalls(): void {
if (!manifestBundle) continue;
for (const model of manifestBundle.models) {
if (!model.path) continue;
const modelPath = join(MODELS_DIR, model.path);
if (!existsSync(modelPath)) {
console.warn(`[feature-status] Bundle "${bundleId}" missing model file: ${model.path}`);
// Don't remove from installed.json — getFeatureStates will surface the error
break;
}
if (model.minSize != null && model.minSize > 0) {
try {
const st = statSync(modelPath);
if (st.size < model.minSize) {
if (model.path) {
const modelPath = join(MODELS_DIR, model.path);
if (!existsSync(modelPath)) {
console.warn(`[feature-status] Bundle "${bundleId}" missing model file: ${model.path}`);
break;
}
if (model.minSize != null && model.minSize > 0) {
try {
const st = statSync(modelPath);
if (st.size < model.minSize) {
console.warn(
`[feature-status] Bundle "${bundleId}" model "${model.path}" is undersized (${st.size} < ${model.minSize})`,
);
break;
}
} catch {
console.warn(
`[feature-status] Bundle "${bundleId}" model "${model.path}" is undersized (${st.size} < ${model.minSize})`,
`[feature-status] Bundle "${bundleId}" cannot stat model: ${model.path}`,
);
break;
}
} catch {
// stat failed, treat as missing
console.warn(`[feature-status] Bundle "${bundleId}" cannot stat model: ${model.path}`);
}
} else if (model.downloadFn === "rembg_session" && model.args?.[0]) {
const filePath = join(MODELS_DIR, "rembg", `${model.args[0]}.onnx`);
if (!existsSync(filePath)) {
console.warn(
`[feature-status] Bundle "${bundleId}" missing rembg model: ${model.args[0]}`,
);
break;
}
} else if (model.downloadFn === "hf_snapshot" && model.args?.[1]) {
const dirPath = join(MODELS_DIR, model.args[1]);
if (!existsSync(dirPath)) {
console.warn(
`[feature-status] Bundle "${bundleId}" missing model directory: ${model.args[1]}`,
);
break;
}
}
@@ -348,19 +367,30 @@ function verifyBundleModels(bundleId: string): string | null {
if (!manifestBundle) return null;
for (const model of manifestBundle.models) {
if (!model.path) continue;
const modelPath = join(MODELS_DIR, model.path);
if (!existsSync(modelPath)) {
return `Missing model file: ${model.path}`;
}
if (model.minSize != null && model.minSize > 0) {
try {
const st = statSync(modelPath);
if (st.size < model.minSize) {
return `Model "${model.path}" is undersized (${st.size} < ${model.minSize})`;
if (model.path) {
const modelPath = join(MODELS_DIR, model.path);
if (!existsSync(modelPath)) {
return `Missing model file: ${model.path}`;
}
if (model.minSize != null && model.minSize > 0) {
try {
const st = statSync(modelPath);
if (st.size < model.minSize) {
return `Model "${model.path}" is undersized (${st.size} < ${model.minSize})`;
}
} catch {
return `Cannot read model file: ${model.path}`;
}
} catch {
return `Cannot read model file: ${model.path}`;
}
} else if (model.downloadFn === "rembg_session" && model.args?.[0]) {
const filePath = join(MODELS_DIR, "rembg", `${model.args[0]}.onnx`);
if (!existsSync(filePath)) {
return `Missing rembg model: ${model.args[0]}`;
}
} else if (model.downloadFn === "hf_snapshot" && model.args?.[1]) {
const dirPath = join(MODELS_DIR, model.args[1]);
if (!existsSync(dirPath)) {
return `Missing model directory: ${model.args[1]}`;
}
}
}
+36 -13
View File
@@ -9,7 +9,15 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import { type Dirent, existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
import {
type Dirent,
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { shutdownDispatcher } from "@snapotter/ai";
import { ANALYTICS_EVENTS, FEATURE_BUNDLES } from "@snapotter/shared";
@@ -42,6 +50,8 @@ interface BundleIdParams {
interface ManifestModel {
id: string;
path?: string;
downloadFn?: string;
args?: string[];
}
interface ManifestBundle {
@@ -277,30 +287,43 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
return reply.status(409).send({ error: `Bundle "${bundleId}" is not installed` });
}
// Read manifest to find model files to delete, but skip models
// that are still needed by another installed bundle.
const manifest = readManifest();
if (manifest) {
const manifestBundle = manifest.bundles[bundleId];
if (manifestBundle) {
// Collect model paths that OTHER installed bundles still need
const sharedPaths = new Set<string>();
const protectedFiles = new Set<string>();
const protectedDirs = new Set<string>();
for (const [otherId, otherBundle] of Object.entries(manifest.bundles)) {
if (otherId === bundleId) continue;
if (!isFeatureInstalled(otherId)) continue;
if (otherId === bundleId || !isFeatureInstalled(otherId)) continue;
for (const m of otherBundle.models ?? []) {
if (m.path) sharedPaths.add(m.path);
if (m.path) protectedFiles.add(m.path);
if (m.downloadFn === "rembg_session" && m.args?.[0]) {
protectedFiles.add(`rembg/${m.args[0]}.onnx`);
}
if (m.downloadFn === "hf_snapshot" && m.args?.[1]) {
protectedDirs.add(m.args[1]);
}
}
}
const modelsDir = getModelsDir();
for (const model of manifestBundle.models) {
if (!model.path) continue;
if (sharedPaths.has(model.path)) continue; // still needed
const modelPath = join(modelsDir, model.path);
try {
if (existsSync(modelPath)) {
unlinkSync(modelPath);
if (model.path && !protectedFiles.has(model.path)) {
const modelPath = join(modelsDir, model.path);
if (existsSync(modelPath)) unlinkSync(modelPath);
} else if (model.downloadFn === "rembg_session" && model.args?.[0]) {
const relPath = `rembg/${model.args[0]}.onnx`;
if (!protectedFiles.has(relPath)) {
const filePath = join(modelsDir, relPath);
if (existsSync(filePath)) unlinkSync(filePath);
}
} else if (!model.path && model.downloadFn === "hf_snapshot" && model.args?.[1]) {
const subdir = model.args[1];
if (!protectedDirs.has(subdir)) {
const dirPath = join(modelsDir, subdir);
if (existsSync(dirPath)) rmSync(dirPath, { recursive: true, force: true });
}
}
} catch {
// Best-effort deletion