From c7b574b60271e02c59356e8581a1fc0e59dbb82a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:29:25 +0200 Subject: [PATCH 01/15] fix(packaging): ship ImageMagick's coder modules, stop the silent full-ffmpeg fallback The bundled magick.exe is the dynamic *modules* build: every format coder is a DLL under modules/coders, and the flat copy missed them all - on a machine without a system ImageMagick every image operation died with "no decode delegate", while dev machines silently borrowed the Program Files coders. bundleImageMagick() now ships the modules tree and the app points spawned children at it via MAGICK_CODER_MODULE_PATH at startup. bundleFfmpeg()'s fallback to the ~227 MB local "full" build is now opt-in (--allow-local-ffmpeg); it had silently won once and produced a 553 MB installer. assertBundled() gains a size ceiling for ffmpeg/ffprobe and a modules/coders check, so existence alone can no longer pass a broken bundle. Refs #12 Co-Authored-By: Claude Opus 5 --- scripts/fetch-binaries.mjs | 76 ++++++++++++++++++++++++++++++++------ src/main/index.ts | 3 ++ src/main/toolResolver.ts | 24 ++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/scripts/fetch-binaries.mjs b/scripts/fetch-binaries.mjs index cefa7ac..853abfd 100644 --- a/scripts/fetch-binaries.mjs +++ b/scripts/fetch-binaries.mjs @@ -7,8 +7,13 @@ * * Strategy per tool: * - ImageMagick (magick.exe + its DLL/XML/ICC runtime): copied from the local - * winget/Program Files install. It's the dynamic build, so magick.exe needs - * its sibling DLLs — a flat copy into resources/bin keeps them together. + * winget/Program Files install. It's the dynamic *modules* build, so + * magick.exe needs its sibling DLLs — a flat copy into resources/bin keeps + * them together — AND the coder DLLs under modules/coders (one per format: + * PNG, JPEG, WebP, …). Without the modules tree the shipped magick cannot + * decode anything ("no decode delegate"); a dev machine hides that because + * magick falls back to its compiled-in Program Files path. The spawn env + * points at the bundled tree (see toolResolver.magickEnv). * - CaesiumCLT (caesiumclt.exe): a single self-contained exe, copied from the * local install. * - ffmpeg (ffmpeg.exe): downloaded from gyan.dev's "essentials" build. The @@ -55,7 +60,11 @@ function which(name) { } } -/** ImageMagick: copy magick.exe plus its runtime siblings (dynamic build). */ +/** ImageMagick: copy magick.exe plus its runtime siblings (dynamic build), AND + * the modules/ tree (coders + filters). The winget build loads one DLL per + * format from modules/coders at runtime; shipping magick.exe without them + * produces an installer where every image operation fails with "no decode + * delegate" on any machine that has no system ImageMagick to fall back to. */ function bundleImageMagick() { const magick = which('magick') if (!magick) { @@ -75,7 +84,17 @@ function bundleImageMagick() { bytes += statSync(src).size } } - log(` ✓ ImageMagick: ${files} files (${(bytes / MB).toFixed(1)} MB)`) + const modules = join(dir, 'modules') + if (existsSync(modules)) { + rmSync(join(BIN, 'modules'), { recursive: true, force: true }) + cpSync(modules, join(BIN, 'modules'), { recursive: true }) + const coders = join(BIN, 'modules', 'coders') + const n = existsSync(coders) ? readdirSync(coders).filter((f) => f.endsWith('.dll')).length : 0 + log(` ✓ ImageMagick: ${files} files + ${n} coder modules`) + } else { + // A static build compiles the coders in; only the modules build needs them. + log(` ✓ ImageMagick: ${files} files (static build, no modules dir)`) + } } /** CaesiumCLT: a single self-contained exe. */ @@ -114,14 +133,21 @@ async function bundleFfmpeg() { log(` ✓ ffmpeg: ffmpeg.exe + ffprobe.exe (${mb(join(BIN, 'ffmpeg.exe'))} MB)`) } catch (e) { log(` ! ffmpeg download failed (${e.message})`) - const local = which('ffmpeg') - const localProbe = which('ffprobe') - if (local) { - copyFileSync(local, join(BIN, 'ffmpeg.exe')) - if (localProbe) copyFileSync(localProbe, join(BIN, 'ffprobe.exe')) - log(` ✓ ffmpeg: copied local build (${mb(join(BIN, 'ffmpeg.exe'))} MB — larger than essentials)`) + // The local winget build is the ~227 MB "full" static one. Falling back to + // it silently once produced a 553 MB installer, so the fallback is opt-in. + if (process.argv.includes('--allow-local-ffmpeg')) { + const local = which('ffmpeg') + const localProbe = which('ffprobe') + if (local) { + copyFileSync(local, join(BIN, 'ffmpeg.exe')) + if (localProbe) copyFileSync(localProbe, join(BIN, 'ffprobe.exe')) + log(` ✓ ffmpeg: copied local build (${mb(join(BIN, 'ffmpeg.exe'))} MB — larger than essentials)`) + } else { + log(' ffmpeg not bundled; video/audio convert will fall back to PATH') + } } else { - log(' ffmpeg not bundled; video/audio convert will fall back to PATH') + log(' NOT falling back to the local "full" build (~227 MB per exe).') + log(' Re-run with network access, or pass --allow-local-ffmpeg to accept the size.') } } finally { rmSync(tmp, { recursive: true, force: true }) @@ -381,6 +407,34 @@ function assertBundled() { how: 'winget install ImageMagick.ImageMagick Gyan.FFmpeg ArtifexSoftware.mutool SaeraSoft.CaesiumCLT' }) + // The essentials ffmpeg is ~90 MB; the local "full" static build is ~227 MB + // per exe. Existence alone once let a stale full build ride into a 553 MB + // installer, so size is part of the contract. + const FFMPEG_CEILING = 120 * MB + for (const f of ['ffmpeg.exe', 'ffprobe.exe']) { + const p = join(BIN, f) + if (existsSync(p) && statSync(p).size > FFMPEG_CEILING) + missing.push({ + id: f, + what: `installer size (${mb(p)} MB — the "full" build leaked in)`, + how: 'delete resources/bin/ffmpeg.exe + ffprobe.exe and re-run to fetch essentials, or pass --allow-local-ffmpeg deliberately' + }) + } + + // A small magick.exe is the modules build: it decodes nothing without its + // coder DLLs. A static build (~30+ MB) compiles them in and needs no tree. + const magick = join(BIN, 'magick.exe') + if (existsSync(magick) && statSync(magick).size < 5 * MB) { + const coders = join(BIN, 'modules', 'coders') + const ok = existsSync(coders) && readdirSync(coders).some((f) => f.endsWith('.dll')) + if (!ok) + missing.push({ + id: 'magick modules', + what: 'every image operation ("no decode delegate" on a clean install)', + how: 'delete resources/bin/magick.exe and re-run so the modules/ tree is copied too' + }) + } + for (const t of REQUIRED_TREES) { const base = t.id.split('-')[0] if (SKIPPED.has(t.id) || SKIPPED.has(base)) continue diff --git a/src/main/index.ts b/src/main/index.ts index f445b47..108b574 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { createReadStream, readdirSync, rmSync, statSync } from 'fs' import { tmpdir } from 'os' import { Readable } from 'stream' import { registerIpc } from './ipc' +import { configureBundledMagickEnv } from './toolResolver' import { pidSidecar } from './pid/sidecar' import { spandrelSidecar } from './comfy/sidecar' import { stopComfyServer } from './generate' @@ -194,6 +195,8 @@ if (!app.requestSingleInstanceLock()) { app.whenReady().then(() => { sweepStaleTempDirs() + // The bundled magick needs MAGICK_CODER_MODULE_PATH before the first spawn. + configureBundledMagickEnv() // Create the writable registry layers so a user can drop a model file in // without having to guess (or create) the path first. diff --git a/src/main/toolResolver.ts b/src/main/toolResolver.ts index d39fffc..3a9aba6 100644 --- a/src/main/toolResolver.ts +++ b/src/main/toolResolver.ts @@ -42,6 +42,30 @@ export function resolveTool(name: string): string { return existsSync(bundled) ? bundled : name } +/** + * Point the bundled ImageMagick at its own coder modules. The shipped magick is + * the dynamic *modules* build: every format decoder/encoder is a DLL under + * bin/modules/coders, located at runtime via MAGICK_CODER_MODULE_PATH. Without + * it, magick falls back to its compiled-in Program Files path — which exists on + * a dev machine and hides the problem, and does not exist on a clean install, + * where every image operation dies with "no decode delegate". + * + * Set on process.env once at startup so every spawned child inherits it. Only + * set when the bundled magick + modules are actually present: pointing some + * other ImageMagick install at our version-specific coders would break it, and + * when the bundled magick is present resolveTool() always picks it. + */ +export function configureBundledMagickEnv(): void { + const bin = bundledDir() + const coders = join(bin, 'modules', 'coders') + if (!existsSync(join(bin, 'magick' + EXE)) || !existsSync(coders)) return + process.env.MAGICK_CODER_MODULE_PATH = coders + const filters = join(bin, 'modules', 'filters') + if (existsSync(filters)) process.env.MAGICK_CODER_FILTER_PATH = filters + // The xml config (delegates, policy, …) sits flat in bin next to magick.exe. + process.env.MAGICK_CONFIGURE_PATH = bin +} + /** * Resolve LibreOffice's `soffice`. LibreOffice is a full install tree (not a * single exe), so it lives in resources/libreoffice/ rather than the flat bin From 0041a7fa2fe8fdbcd8429c79772f3ba22445097b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:29:28 +0200 Subject: [PATCH 02/15] fix(engine): stop magick/mutool/gs/soffice mangling paths that contain % One architectural gap, four failure modes: every tool module handed raw user paths to engines with printf/URL semantics. - magick, mutool draw and Ghostscript printf-expand the OUTPUT path they are handed ("100%off (resized).png" writes over the unrelated "1000ff (resized).png" and exits 0, destroying user data). runToOutput now takes an argsFor(out) builder: when the reserved name contains %, the tool writes to a %-free temp file and the bytes are copied onto the reserved name - the shape the Caesium and LibreOffice branches already used. Exit 0 with nothing written is now its own error instead of silent success. - Appending a scene spec ([0]) switches magick to printf interpretation on READ ("100%off.png[0]" fails with "unable to open image '1000ff.png'"). New magickFrame() escapes % as %% (measured round-trip) at all eight sites. - mutool draw's intentional page-%d.png pattern now renders into a temp dir when the OUTPUT FOLDER name carries a % from the source; pages-to-images also cleans up its partial folder on failure, like the other folder ops. - LibreOffice percent-DECODES its input path ("100%off.txt" fails with "source file could not be loaded"); documents now convert from a neutral temp copy, which also drops the dependency on soffice naming the output after the input basename. Regression tests pin the escape helper and, when the bundled binary is present, the measured magick behaviours the fix rests on. Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/thumbnail.ts | 3 +- src/main/tools/compress.ts | 3 +- src/main/tools/convert.ts | 12 ++++ src/main/tools/registry.ts | 132 ++++++++++++++++++++++++------------- test/percent-paths.test.ts | 93 ++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 48 deletions(-) create mode 100644 test/percent-paths.test.ts diff --git a/src/main/thumbnail.ts b/src/main/thumbnail.ts index 37608f7..16b3fe6 100644 --- a/src/main/thumbnail.ts +++ b/src/main/thumbnail.ts @@ -6,6 +6,7 @@ import { nativeImage } from 'electron' import type { FileKind } from '@shared/types' import { resolveTool } from './toolResolver' import { run } from './run' +import { magickFrame } from './tools/convert' /** * Best-effort thumbnail for a file, returned as a PNG data URL (or null). @@ -48,7 +49,7 @@ const scale = (size: number): string => `scale=${size}:${size}:force_original_as /** ImageMagick can decode formats the shell can't; [0] takes the first frame/page. */ function magickThumbnail(path: string, size: number): Promise { - return toolPng('magick', [`${path}[0]`, '-thumbnail', `${size}x${size}`]) + return toolPng('magick', [magickFrame(path), '-thumbnail', `${size}x${size}`]) } /** A representative video frame: seek ~1s to skip black lead-in, else frame 0. */ diff --git a/src/main/tools/compress.ts b/src/main/tools/compress.ts index 1ca8b75..1ddb18e 100644 --- a/src/main/tools/compress.ts +++ b/src/main/tools/compress.ts @@ -1,5 +1,6 @@ import { extname } from 'path' import { isLosslessAudio, normalizeExt } from '@shared/convert' +import { magickFrame } from './convert' import { SCALE_MAX, SCALE_MIN, type AudioCodec, type VideoCodec } from '@shared/compress' // Image targets that hold multiple frames — keep all frames (an animated GIF @@ -30,7 +31,7 @@ export function buildCompressArgs(input: string, outDir: string, quality: number * single-frame target reads only `input[0]` (so a multi-frame source doesn't * split); a multi-frame target keeps every frame (animated GIF -> animated WebP). */ export function buildMagickCompressArgs(input: string, output: string, quality: number): string[] { - const src = MULTIFRAME_TARGETS.includes(normalizeExt(extname(output))) ? input : `${input}[0]` + const src = MULTIFRAME_TARGETS.includes(normalizeExt(extname(output))) ? input : magickFrame(input) return [src, '-quality', String(quality), output] } diff --git a/src/main/tools/convert.ts b/src/main/tools/convert.ts index b33c526..5c2e24d 100644 --- a/src/main/tools/convert.ts +++ b/src/main/tools/convert.ts @@ -18,6 +18,18 @@ export function buildMagickArgs(input: string, output: string, extra: string[] = return [input, ...extra, output] } +/** + * A source path with a scene spec (`[0]` = first frame) appended, safe for + * paths containing `%`. Appending a scene spec switches ImageMagick to + * InterpretImageFilename on READ, where `%d`/`%x`/… in the path (or its folder) + * are consumed as format codes: `magick "100%off.png[0]" out.jpg` fails with + * "unable to open image '1000ff.png'". `%%` round-trips to a literal `%` + * (measured against the bundled binary), so escape before appending. + */ +export function magickFrame(path: string, frame = 0): string { + return `${path.replace(/%/g, '%%')}[${frame}]` +} + /** ffmpeg args: `ffmpeg -y -i [extra...] `. */ export function buildFfmpegArgs(input: string, output: string, extra: string[] = []): string[] { return ['-y', '-i', input, ...extra, output] diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 256330d..9d4af7f 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -46,6 +46,7 @@ import { convertTargets, isSameFormat, magickExtraFor, + magickFrame, normalizeExt, qualityNum, toolForKind @@ -75,17 +76,26 @@ import { } from './pdf' /** - * Run a CLI tool that writes directly to an already-reserved `output` path, and - * clean up on any failure or cancel. `output` was reserved as an empty - * placeholder (see reserveOutPath), so success requires the tool to have - * actually written bytes — a 0-byte result means the tool failed or (for - * ImageMagick) split a multi-frame source into `output-0.ext`, `output-1.ext` - * and left the placeholder empty. `requireNonEmpty` is false only for text - * extraction, where an empty result is legitimate (a PDF with no text layer). + * Run a CLI tool that writes to an already-reserved `output` path, and clean up + * on any failure or cancel. `output` was reserved as an empty placeholder (see + * reserveOutPath), so success requires the tool to have actually written bytes + * — a 0-byte result means the tool failed or (for ImageMagick) split a + * multi-frame source into `output-0.ext`, `output-1.ext` and left the + * placeholder empty. `requireNonEmpty` is false only for text extraction, where + * an empty result is legitimate (a PDF with no text layer). + * + * `argsFor` builds the tool's argv for the path the tool should write, which is + * NOT always the reserved path: magick, mutool draw and Ghostscript + * printf-expand `%` sequences in the output path they are handed + * (InterpretImageFilename / fz_format_output_path / -sOutputFile), so + * `100%off (resized).png` silently writes over the unrelated `1000ff + * (resized).png` and exits 0. When the reserved name contains a `%`, the tool + * writes to a %-free temp file instead and the bytes are copied onto the + * reserved name — the same shape the Caesium and LibreOffice branches use. */ async function runToOutput( tool: string, - args: string[], + argsFor: (out: string) => string[], output: string, ctx: ToolContext, label: string, @@ -93,16 +103,26 @@ async function runToOutput( onStderr?: (chunk: string) => void, estimateSec?: number ): Promise { + const tmp = output.includes('%') ? mkdtempSync(join(tmpdir(), 'filesmith-out-')) : null + const toolOut = tmp ? join(tmp, 'out' + extname(output)) : output // If the tool reports no real progress (no onStderr parser) but the caller // gave an expected duration, drive an estimated bar so the % always moves. const est = !onStderr && estimateSec ? estimateProgress(estimateSec, (p) => ctx.onProgress(p)) : null try { - const { code, stderr } = await run(tool, args, { signal: ctx.signal, onStderr }) - const wrote = existsSync(output) && (!requireNonEmpty || statSync(output).size > 0) + const { code, stderr } = await run(tool, argsFor(toolOut), { signal: ctx.signal, onStderr }) + const wrote = existsSync(toolOut) && (!requireNonEmpty || statSync(toolOut).size > 0) if (code !== 0 || !wrote) { - throw new Error(describeToolError(stderr, label, code)) + // Exit 0 with nothing at the expected path means the tool wrote somewhere + // else (a printf expansion we missed) or produced nothing — either way, + // never report success for it. + throw new Error( + code === 0 && !wrote + ? `${label} reported success but wrote no output` + : describeToolError(stderr, label, code) + ) } + if (tmp) copyFileSync(toolOut, output) return output } catch (e) { // Remove the placeholder / partial so a failed or canceled job never leaves @@ -117,6 +137,7 @@ async function runToOutput( throw e instanceof ToolMissingError ? new Error(toolMissingMessage(e.tool), { cause: e }) : e } finally { est?.stop() + if (tmp) rmSync(tmp, { recursive: true, force: true }) } } @@ -171,7 +192,7 @@ const convertTool: ToolModule = { const output = reserveOutPath(file.path, '.txt', 'converted') return runToOutput( resolveTool('mutool'), - buildPdfTextArgs(file.path, output), + (out) => buildPdfTextArgs(file.path, out), output, ctx, 'mutool', @@ -186,11 +207,18 @@ const convertTool: ToolModule = { if (kindTool === 'soffice') { const tmp = mkdtempSync(join(tmpdir(), 'filesmith-doc-')) try { + // LibreOffice percent-DECODES the input path it is handed (the profile + // is a file URL, the input is not), so `100%off.txt` fails its load + // with "source file could not be loaded". Convert a copy under a + // neutral name and read back the neutral result — which also drops the + // dependency on LibreOffice naming the output after the input. + const safeIn = join(tmp, 'in' + extname(file.path)) + copyFileSync(file.path, safeIn) let result try { result = await run( resolveSoffice(), - buildSofficeArgs(file.path, tmp, join(tmp, 'profile'), targetExt), + buildSofficeArgs(safeIn, tmp, join(tmp, 'profile'), targetExt), { signal: ctx.signal } ) } catch (e) { @@ -202,7 +230,7 @@ const convertTool: ToolModule = { ) } const { code, stderr } = result - const produced = sofficeOutputPath(file.path, tmp, targetExt) + const produced = sofficeOutputPath(safeIn, tmp, targetExt) if (code !== 0 || !existsSync(produced)) { const last = stderr.trim().split('\n').pop()?.trim() throw new Error( @@ -237,34 +265,30 @@ const convertTool: ToolModule = { } const output = reserveOutPath(file.path, targetExt, 'converted') - let args: string[] if (kindTool === 'ffmpeg') { // Real progress for media transcodes (they can run for a long time). const duration = await probeDuration(file.path) - args = buildFfmpegArgs(file.path, output) if (duration) ctx.onProgress(0, 'Converting…') return runToOutput( resolveTool(kindTool), - args, + (out) => buildFfmpegArgs(file.path, out), output, ctx, kindTool, true, ffmpegProgress(duration, (pct, eta) => ctx.onProgress(pct, 'Converting…', eta)) ) - } else { - const q = qualityNum(options.quality) - const extra = [...magickExtraFor(targetExt), ...(q != null ? ['-quality', String(q)] : [])] - // Single-frame target: read only the first frame so a multi-frame source - // doesn't split into out-0/out-1/… and leave the exact output path empty. - const src = MULTIFRAME_TARGETS.includes(normalizeExt(targetExt)) - ? file.path - : `${file.path}[0]` - args = buildMagickArgs(src, output, extra) } + const q = qualityNum(options.quality) + const extra = [...magickExtraFor(targetExt), ...(q != null ? ['-quality', String(q)] : [])] + // Single-frame target: read only the first frame so a multi-frame source + // doesn't split into out-0/out-1/… and leave the exact output path empty. + const src = MULTIFRAME_TARGETS.includes(normalizeExt(targetExt)) + ? file.path + : magickFrame(file.path) return runToOutput( resolveTool(kindTool), - args, + (out) => buildMagickArgs(src, out, extra), output, ctx, kindTool, @@ -291,7 +315,7 @@ const pdfTool: ToolModule = { ctx.onProgress(undefined, `Merging ${inputs.length} PDFs…`) return runToOutput( mutool, - buildPdfMergeArgs(inputs, output), + (out) => buildPdfMergeArgs(inputs, out), output, ctx, 'mutool', @@ -309,7 +333,7 @@ const pdfTool: ToolModule = { ctx.onProgress(undefined, `Extracting pages ${pages}…`) return runToOutput( mutool, - buildPdfPagesArgs(file.path, output, pages), + (out) => buildPdfPagesArgs(file.path, out, pages), output, ctx, 'mutool', @@ -396,18 +420,34 @@ const pdfTool: ToolModule = { const dpi = Math.max(36, Math.min(600, Number(options.dpi ?? 150))) const dir = uniqueOutDir(dirname(file.path), basename(file.path, extname(file.path)) + ' (pages)') mkdirSync(dir, { recursive: true }) + // mutool draw's -o is a printf pattern (the `page-%d.png` is the point), + // so a `%` in the folder name — inherited from the source name — would be + // expanded too. Render into a neutral temp dir then, and move the pages. + const renderDir = dir.includes('%') ? mkdtempSync(join(tmpdir(), 'filesmith-pages-')) : dir ctx.onProgress(undefined, `Rendering pages @ ${dpi} DPI…`) const est = estimateProgress(estimateSecForBytes(file.size, 0.15), (p) => ctx.onProgress(p)) - let code: number, stderr: string try { - ;({ code, stderr } = await run(mutool, buildPdfImagesArgs(file.path, dir, dpi), { + const { code, stderr } = await run(mutool, buildPdfImagesArgs(file.path, renderDir, dpi), { signal: ctx.signal - })) + }) + if (code !== 0) + throw new Error(stderr.trim().split('\n').pop()?.trim() || `mutool exited ${code}`) + if (renderDir !== dir) + for (const f of readdirSync(renderDir)) copyFileSync(join(renderDir, f), join(dir, f)) + return dir + } catch (e) { + // Never leave a partial folder behind (a corrupt page 138 of 400 would + // otherwise strand 137 PNGs, and the next run makes "name (pages) (2)"). + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + /* best effort */ + } + throw e } finally { est.stop() + if (renderDir !== dir) rmSync(renderDir, { recursive: true, force: true }) } - if (code !== 0) throw new Error(stderr.trim().split('\n').pop()?.trim() || `mutool exited ${code}`) - return dir } // extract-text @@ -415,7 +455,7 @@ const pdfTool: ToolModule = { ctx.onProgress(undefined, 'Extracting text…') return runToOutput( mutool, - buildPdfTextArgs(file.path, output), + (out) => buildPdfTextArgs(file.path, out), output, ctx, 'mutool', @@ -435,7 +475,7 @@ const resizeTool: ToolModule = { const animated = normalizeExt(file.ext) === '.gif' return runToOutput( resolveTool('magick'), - buildResizeArgs(file.path, output, spec, animated), + (out) => buildResizeArgs(file.path, out, spec, animated), output, ctx, 'magick', @@ -461,7 +501,7 @@ const compressTool: ToolModule = { ctx.onProgress(undefined, 'Compressing PDF (lossless)…') return runToOutput( resolveTool('mutool'), - buildPdfCompressArgs(file.path, output), + (out) => buildPdfCompressArgs(file.path, out), output, ctx, 'mutool', @@ -473,7 +513,7 @@ const compressTool: ToolModule = { ctx.onProgress(undefined, `Compressing PDF (${level}${gray ? ', gray' : ''})…`) return runToOutput( resolveGhostscript(), - buildGsCompressArgs(file.path, output, level, gray), + (out) => buildGsCompressArgs(file.path, out, level, gray), output, ctx, 'ghostscript', @@ -495,7 +535,7 @@ const compressTool: ToolModule = { ctx.onProgress(duration ? 0 : undefined, `Compressing video (${codec})…`) return runToOutput( resolveTool('ffmpeg'), - buildVideoCompressArgs(file.path, output, { codec, quality, scale }), + (out) => buildVideoCompressArgs(file.path, out, { codec, quality, scale }), output, ctx, 'ffmpeg', @@ -514,7 +554,7 @@ const compressTool: ToolModule = { ctx.onProgress(duration ? 0 : undefined, `Compressing audio (${bitrate}k)…`) return runToOutput( resolveTool('ffmpeg'), - buildAudioCompressArgs(file.path, output, { codec, bitrate, sourceExt: file.ext }), + (out) => buildAudioCompressArgs(file.path, out, { codec, bitrate, sourceExt: file.ext }), output, ctx, 'ffmpeg', @@ -566,7 +606,7 @@ const compressTool: ToolModule = { const output = reserveOutPath(file.path, outExt, 'compressed') return runToOutput( resolveTool('magick'), - buildMagickCompressArgs(file.path, output, quality), + (out) => buildMagickCompressArgs(file.path, out, quality), output, ctx, 'magick', @@ -599,7 +639,7 @@ async function restoreAlpha( tmp: string ): Promise { const magick = resolveTool('magick') - const frame = `${src}[0]` // multi-frame sources (.ico, .gif) match the upscaled frame + const frame = magickFrame(src) // multi-frame sources (.ico, .gif) match the upscaled frame const { code, stdout } = await run(magick, ['identify', '-format', '%A', frame], { signal: ctx.signal }) @@ -680,7 +720,7 @@ async function upscaleWithPid( let src = file.path if (needsPreConvert(file.ext)) { src = join(tmp, 'src.png') - const { code, stderr } = await run(resolveTool('magick'), [`${file.path}[0]`, src], { + const { code, stderr } = await run(resolveTool('magick'), [magickFrame(file.path), src], { signal: ctx.signal }) if (code !== 0 || !existsSync(src)) throw new Error(describeToolError(stderr, 'magick', code)) @@ -749,7 +789,7 @@ async function upscaleWithComfy( let src = file.path if (needsPreConvert(file.ext)) { src = join(tmp, 'src.png') - const { code, stderr } = await run(resolveTool('magick'), [`${file.path}[0]`, src], { + const { code, stderr } = await run(resolveTool('magick'), [magickFrame(file.path), src], { signal: ctx.signal }) if (code !== 0 || !existsSync(src)) throw new Error(describeToolError(stderr, 'magick', code)) @@ -817,7 +857,7 @@ const upscaleTool: ToolModule = { src = join(tmp, 'src.png') const { code, stderr } = await run( resolveTool('magick'), - [`${file.path}[0]`, src], + [magickFrame(file.path), src], { signal: ctx.signal } ) if (code !== 0 || !existsSync(src)) @@ -902,7 +942,7 @@ const removebgTool: ToolModule = { let src = file.path if (needsPreConvert(file.ext)) { src = join(tmp, 'src.png') - const { code, stderr } = await run(resolveTool('magick'), [`${file.path}[0]`, src], { + const { code, stderr } = await run(resolveTool('magick'), [magickFrame(file.path), src], { signal: ctx.signal }) if (code !== 0 || !existsSync(src)) diff --git a/test/percent-paths.test.ts b/test/percent-paths.test.ts new file mode 100644 index 0000000..8a0bf31 --- /dev/null +++ b/test/percent-paths.test.ts @@ -0,0 +1,93 @@ +import { execFileSync } from 'child_process' +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { afterAll, describe, expect, it } from 'vitest' +import { magickFrame } from '../src/main/tools/convert' +import { buildMagickCompressArgs } from '../src/main/tools/compress' + +// Paths containing `%` — `100%off.png`, `50%discount.pdf` — are real user +// input, and the engines printf-expand them: magick's InterpretImageFilename +// kicks in on READ once a scene spec (`[0]`) is appended, and on WRITE always; +// mutool draw and Ghostscript format their output path the same way. The read +// side is fixed by escaping `%` as `%%` (magickFrame); the write side by +// redirecting the tool to a %-free temp file (runToOutput) and copying onto +// the reserved name. These tests pin the escape helper and — when the bundled +// binary is present — the measured magick behaviour the fix rests on. + +describe('magickFrame', () => { + it('appends the scene spec', () => { + expect(magickFrame('C:\\pics\\photo.png')).toBe('C:\\pics\\photo.png[0]') + }) + + it('escapes % so InterpretImageFilename round-trips it', () => { + expect(magickFrame('C:\\pics\\100%off.png')).toBe('C:\\pics\\100%%off.png[0]') + expect(magickFrame('C:\\50% done\\win%x2.png')).toBe('C:\\50%% done\\win%%x2.png[0]') + }) + + it('takes an explicit frame index', () => { + expect(magickFrame('a.gif', 3)).toBe('a.gif[3]') + }) +}) + +describe('buildMagickCompressArgs with % in the source', () => { + it('escapes the single-frame read', () => { + const args = buildMagickCompressArgs('C:\\x\\100%off.png', 'C:\\x\\out.jpg', 80) + expect(args[0]).toBe('C:\\x\\100%%off.png[0]') + }) + + it('leaves a multi-frame read unescaped (no scene spec, no interpretation)', () => { + const args = buildMagickCompressArgs('C:\\x\\100%off.gif', 'C:\\x\\out.gif', 80) + expect(args[0]).toBe('C:\\x\\100%off.gif') + }) +}) + +// Integration against the actual bundled binary: the exact failure that was +// measured (`unable to open image '1000ff.png'`) and the escape that fixes it. +const BIN = resolve(__dirname, '..', 'resources', 'bin') +const MAGICK = join(BIN, 'magick.exe') +const coders = join(BIN, 'modules', 'coders') +const magickEnv = { + ...process.env, + ...(existsSync(coders) ? { MAGICK_CODER_MODULE_PATH: coders, MAGICK_CONFIGURE_PATH: BIN } : {}) +} + +describe.skipIf(!existsSync(MAGICK))('bundled magick and % paths (integration)', () => { + const dir = mkdtempSync(join(tmpdir(), 'filesmith-pct-')) + afterAll(() => rmSync(dir, { recursive: true, force: true })) + + // A tiny valid PNG (1x1, white) so the test needs no fixture checkout. + const PNG_1PX = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ) + + const magick = (args: string[]): void => { + execFileSync(MAGICK, args, { env: magickEnv, stdio: 'pipe' }) + } + + it('fails on an unescaped scene-spec read of a % path (the bug)', () => { + const src = join(dir, '100%off.png') + writeFileSync(src, PNG_1PX) + expect(() => magick([`${src}[0]`, join(dir, 'bug.jpg')])).toThrow() + }) + + it('reads the same path fine once % is escaped (the fix)', () => { + const src = join(dir, '100%off2.png') + writeFileSync(src, PNG_1PX) + const out = join(dir, 'fixed.jpg') + magick([magickFrame(src), out]) + expect(existsSync(out)).toBe(true) + }) + + it('printf-expands a % OUTPUT path (why runToOutput must redirect it)', () => { + const src = join(dir, 'plain.png') + writeFileSync(src, PNG_1PX) + const intended = join(dir, '100%off (resized).png') + magick([src, intended]) // exits 0… + // …but the intended path was never written: the %o was expanded away. + expect(existsSync(intended)).toBe(false) + // Clean whatever it did write so the temp dir teardown stays honest. + for (const f of readdirSync(dir)) if (f.includes('(resized)')) rmSync(join(dir, f)) + }) +}) From 48d7f4a8362ac3fd42833c3529910abd0dc2b277 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:32:40 +0200 Subject: [PATCH 03/15] chore(ci): stand up the gates, fix the lint error that rode through three PRs - .github/workflows/ci.yml: typecheck + lint + prettier --check + tests on windows-latest. There was no CI at all, which is how an eslint error (and a real bug behind it) lived on main through PRs #9-#11. - archRegistry.ts: the flagged escape was a genuine Windows path bug - abs.split(/[\/]/) never splits a backslash path, so detect-by-content specs compared the FULL path against ncnnParamBasename and could never match. Now node's basename(). - package.json: drop --passWithNoTests, so a broken include glob fails instead of green-lighting an empty run. - fetch-binaries: extract with System32's bsdtar by full path (a bare `tar` can resolve to Git Bash's GNU tar, which parses C:\ as a remote host). Refs #12 Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ package.json | 2 +- scripts/fetch-binaries.mjs | 22 +++++++++++++++------- src/main/generate/archRegistry.ts | Bin 8222 -> 8487 bytes 4 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..08df131 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + # windows-latest, not ubuntu: the app is Windows-first and several suites + # exercise Windows path semantics (drive letters, backslashes, reserved + # names) that a Linux runner would never catch. + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npx prettier --check . + - run: npm test diff --git a/package.json b/package.json index 74b35ab..345111f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "test:watch": "vitest", "lint": "eslint .", "format": "prettier --write .", diff --git a/scripts/fetch-binaries.mjs b/scripts/fetch-binaries.mjs index 853abfd..f075a93 100644 --- a/scripts/fetch-binaries.mjs +++ b/scripts/fetch-binaries.mjs @@ -73,7 +73,6 @@ function bundleImageMagick() { } const dir = dirname(magick) let files = 0 - let bytes = 0 for (const f of readdirSync(dir)) { const ext = f.toLowerCase().slice(f.lastIndexOf('.') + 1) const keep = f.toLowerCase() === 'magick.exe' || ['dll', 'xml', 'icc'].includes(ext) @@ -81,7 +80,6 @@ function bundleImageMagick() { if (keep && statSync(src).isFile()) { copyFileSync(src, join(BIN, f)) files++ - bytes += statSync(src).size } } const modules = join(dir, 'modules') @@ -120,8 +118,11 @@ async function bundleFfmpeg() { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) writeFileSync(zip, Buffer.from(await res.arrayBuffer())) - // Windows 10+ ships bsdtar as `tar`, which extracts .zip. - execFileSync('tar', ['-xf', zip, '-C', tmp]) + // Windows 10+ ships bsdtar (which extracts .zip) in System32. Use it by + // full path: a bare `tar` can resolve to Git Bash's GNU tar on PATH, which + // parses `C:\…` as a remote host ("Cannot connect to C: resolve failed"). + const systemTar = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') + execFileSync(existsSync(systemTar) ? systemTar : 'tar', ['-xf', zip, '-C', tmp]) const top = readdirSync(tmp).find( (f) => f.startsWith('ffmpeg-') && statSync(join(tmp, f)).isDirectory() ) @@ -141,7 +142,9 @@ async function bundleFfmpeg() { if (local) { copyFileSync(local, join(BIN, 'ffmpeg.exe')) if (localProbe) copyFileSync(localProbe, join(BIN, 'ffprobe.exe')) - log(` ✓ ffmpeg: copied local build (${mb(join(BIN, 'ffmpeg.exe'))} MB — larger than essentials)`) + log( + ` ✓ ffmpeg: copied local build (${mb(join(BIN, 'ffmpeg.exe'))} MB — larger than essentials)` + ) } else { log(' ffmpeg not bundled; video/audio convert will fall back to PATH') } @@ -297,7 +300,8 @@ async function bundleRealesrgan() { const src = join(root, 'models') for (const m of REALESRGAN_MODELS) for (const ext of ['.bin', '.param']) - if (existsSync(join(src, m + ext))) cpSync(join(src, m + ext), join(dest, 'models', m + ext)) + if (existsSync(join(src, m + ext))) + cpSync(join(src, m + ext), join(dest, 'models', m + ext)) } // (a) a local copy (RCMM installs the same binary on demand) or $REALESRGAN_DIR const local = [ @@ -325,7 +329,11 @@ async function bundleRealesrgan() { writeFileSync(zip, Buffer.from(await res.arrayBuffer())) execFileSync( 'powershell', - ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${zip}' -DestinationPath '${join(tmp, 'x')}' -Force`], + [ + '-NoProfile', + '-Command', + `Expand-Archive -LiteralPath '${zip}' -DestinationPath '${join(tmp, 'x')}' -Force` + ], { stdio: 'ignore' } ) copySubset(join(tmp, 'x')) diff --git a/src/main/generate/archRegistry.ts b/src/main/generate/archRegistry.ts index 3b191401ee9bed03f072adbd22ae1f4cc0458884..f9d9775abdcf4f4f95a382b22f9799364dd625b4 100644 GIT binary patch delta 459 zcma)&F-rqM5QS0HV6?H72-BHBlIss4F^!#wAPOpE?rxGj*_#c!8;=N@zaXTywU%rA zKjP02oOq`;7H%~>-n?(;J@ualkNwRE9WiFqe(Ak$9n=BP#wvs(;zJqQB4j2ZeJqv` zoVO{Dm!eDWXfX5SdLi&Mi^$fCi|voKc@O~0!oGu_WDz_dDCDGLjnz510- delta 157 zcmZ4PG|ypz!$z+s+K!(m_M;>8eh2qSrRHw?4)M9%pg}l<-q|~C# zSv-GPG?EgFQ}YsYQ>_#dlZy3<3vx0`H1wlm^kelk^$PL}G&B|L?LktTbA Date: Thu, 20 Aug 2026 14:32:41 +0200 Subject: [PATCH 04/15] chore(format): enforce prettier (endOfLine auto) and reformat the tree .prettierrc.yaml gains endOfLine: auto to match what git checks out on Windows (core.autocrlf), and .prettierignore now covers resources/ and the point-in-time design mockups. One-time reformat of the 60+ files that had drifted; CI now runs prettier --check so it cannot drift again. Refs #12 Co-Authored-By: Claude Opus 5 --- .prettierignore | 4 +- .prettierrc.yaml | 3 + README.md | 24 +- docs/adding-models.md | 18 +- docs/comfyui-upscaler-import.md | 4 +- docs/compression-upgrade-plan.md | 28 +- docs/dependency-model-audit.md | 249 ++++++++++---- docs/upscaler-upgrade-plan.md | 10 +- scripts/registry-channel.mjs | 5 +- scripts/registry-hashes.mjs | 4 +- src/main/comfy/discover.ts | 3 +- src/main/comfy/roots.ts | 6 +- src/main/comfy/sidecar.ts | 4 +- src/main/generate/archScan.ts | 13 +- src/main/generate/comfy.ts | 36 +- src/main/generate/companions.ts | 3 +- src/main/generate/models.ts | 21 +- src/main/generate/preflight.ts | 7 +- src/main/generate/workflow.ts | 10 +- src/main/ipc.ts | 13 +- src/main/net/download.ts | 7 +- src/main/pid/install.ts | 8 +- src/main/registry/channel.ts | 7 +- src/main/registry/load.ts | 4 +- src/main/registry/userLayer.ts | 18 +- src/main/toolResolver.ts | 5 +- src/main/tools/compress.ts | 4 +- src/main/tools/pdf.ts | 7 +- src/main/tools/registry.ts | 48 ++- src/main/tools/upscale.ts | 17 +- src/main/uv.ts | 3 +- src/preload/index.ts | 37 +- src/renderer/src/App.tsx | 325 ++++++++++-------- src/renderer/src/components/CategoryRail.tsx | 8 +- src/renderer/src/components/Icon.tsx | 8 +- src/renderer/src/components/MediaBar.tsx | 20 +- src/renderer/src/components/OptionsPanel.tsx | 61 +++- src/renderer/src/components/PreviewWindow.tsx | 19 +- src/renderer/src/components/Queue.tsx | 4 +- src/renderer/src/lib/markdown.ts | 28 +- src/renderer/src/state.ts | 44 ++- src/shared/comfy.ts | 5 +- src/shared/compress.ts | 7 +- src/shared/genArch.ts | 23 +- src/shared/generate.ts | 6 +- src/shared/registry.ts | 3 +- src/shared/types.ts | 9 +- test/arch-scan.test.ts | 110 +++++- test/catalogs.test.ts | 7 +- test/channel.test.ts | 5 +- test/comfy.test.ts | 33 +- test/documents.test.ts | 34 +- test/gen-registry.test.ts | 14 +- test/gguf.test.ts | 24 +- test/markdown.test.ts | 4 +- test/pid.test.ts | 8 +- test/queues.test.ts | 12 +- test/registry.test.ts | 8 +- test/removebg.test.ts | 1 - test/resolvers.test.ts | 4 +- test/session-persist.test.ts | 20 +- test/tools.test.ts | 109 ++++-- test/upscale.test.ts | 1 - test/user-registry.test.ts | 25 +- 64 files changed, 1091 insertions(+), 528 deletions(-) diff --git a/.prettierignore b/.prettierignore index 9eb9200..af585fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ node_modules out dist -resources/bin +resources package-lock.json +# Design mockups are point-in-time artifacts, not living code. +docs/mockups diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 15c8fab..43a2aff 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -2,3 +2,6 @@ semi: false singleQuote: true printWidth: 100 trailingComma: none +# Match what git checks out on Windows (core.autocrlf) so `format` never +# rewrites the whole tree over line endings. +endOfLine: auto diff --git a/README.md b/README.md index 691e007..d569683 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@
Filesmith - # Filesmith +# Filesmith - A local file toolkit for Windows. Drop files, pick a tool, get results. +A local file toolkit for Windows. Drop files, pick a tool, get results. - [![Latest release](https://img.shields.io/github/v/release/Maxaubert/Filesmith?style=flat-square&color=5b5bd6&cacheSeconds=1800)](https://github.com/Maxaubert/Filesmith/releases/latest) - [![Downloads](https://img.shields.io/github/downloads/Maxaubert/Filesmith/total?style=flat-square&color=5b5bd6&cacheSeconds=1800)](https://github.com/Maxaubert/Filesmith/releases) - [![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D4?style=flat-square)](https://github.com/Maxaubert/Filesmith/releases/latest) - [![Built with](https://img.shields.io/badge/Electron%20·%20React%20·%20TypeScript-2b2e3a?style=flat-square)](#build-from-source) - [![License: MIT](https://img.shields.io/badge/License-MIT-22b364?style=flat-square)](LICENSE) +[![Latest release](https://img.shields.io/github/v/release/Maxaubert/Filesmith?style=flat-square&color=5b5bd6&cacheSeconds=1800)](https://github.com/Maxaubert/Filesmith/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/Maxaubert/Filesmith/total?style=flat-square&color=5b5bd6&cacheSeconds=1800)](https://github.com/Maxaubert/Filesmith/releases) +[![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-0078D4?style=flat-square)](https://github.com/Maxaubert/Filesmith/releases/latest) +[![Built with](https://img.shields.io/badge/Electron%20·%20React%20·%20TypeScript-2b2e3a?style=flat-square)](#build-from-source) +[![License: MIT](https://img.shields.io/badge/License-MIT-22b364?style=flat-square)](LICENSE)
--- @@ -48,11 +48,11 @@ Plus batch queues, thumbnails for every kind (images, video frames, audio cover The everyday tools (convert, compress, resize, and all PDF / video / audio / document operations) run **fully offline** from bundled binaries, with no AI and no downloads. The AI features are **opt-in**, and nothing AI runs unless you choose it: -| Feature | What it needs | -|---|---| -| **Remove Background** | The free [`uv`](https://docs.astral.sh/uv/) tool; a small AI model is downloaded on first use (once, then offline). The panel tells you before you commit files. | -| **Upscale** | An upscaling model, downloaded on first use. NVIDIA (PiD) mode needs an NVIDIA GPU. | -| **Generate** | An existing [ComfyUI](https://github.com/comfyanonymous/ComfyUI) install, which Filesmith drives headlessly. Supports SDXL, Flux 1, Flux 2 (klein), Z-Image, and Krea 2 models; missing text-encoders / VAEs can be downloaded from the panel. If something is missing, the panel says exactly what to do. | +| Feature | What it needs | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Remove Background** | The free [`uv`](https://docs.astral.sh/uv/) tool; a small AI model is downloaded on first use (once, then offline). The panel tells you before you commit files. | +| **Upscale** | An upscaling model, downloaded on first use. NVIDIA (PiD) mode needs an NVIDIA GPU. | +| **Generate** | An existing [ComfyUI](https://github.com/comfyanonymous/ComfyUI) install, which Filesmith drives headlessly. Supports SDXL, Flux 1, Flux 2 (klein), Z-Image, and Krea 2 models; missing text-encoders / VAEs can be downloaded from the panel. If something is missing, the panel says exactly what to do. | Don't want AI? Just use the core tools, and the AI features stay out of your way. diff --git a/docs/adding-models.md b/docs/adding-models.md index 8cf8f13..7f951cc 100644 --- a/docs/adding-models.md +++ b/docs/adding-models.md @@ -1,6 +1,6 @@ # Adding a model to Filesmith -Filesmith does not bake models in. What a model *is* — how to recognize it, what files it needs, +Filesmith does not bake models in. What a model _is_ — how to recognize it, what files it needs, which ComfyUI graph runs it — is **data on disk**, so you can add a model family that did not exist when your copy of Filesmith was built, without waiting for a release. @@ -8,11 +8,11 @@ when your copy of Filesmith was built, without waiting for a release. Three layers, merged by `id`, later layers winning **field by field**: -| Layer | Path | Who writes it | -|---|---|---| -| 1. Built-in | `/resources/registry/*.json` | ships in the installer, read-only | -| 2. Channel | `%APPDATA%/Filesmith/registry/channel/` | refreshed over the network (signed) | -| 3. **Yours** | `%APPDATA%/Filesmith/registry/user/` | you | +| Layer | Path | Who writes it | +| ------------ | --------------------------------------- | ----------------------------------- | +| 1. Built-in | `/resources/registry/*.json` | ships in the installer, read-only | +| 2. Channel | `%APPDATA%/Filesmith/registry/channel/` | refreshed over the network (signed) | +| 3. **Yours** | `%APPDATA%/Filesmith/registry/user/` | you | Two rules that will not change: @@ -129,7 +129,7 @@ Available placeholders: `${unet}` `${clip}` `${clip2}` `${vae}` `${model}` · `${prompt}` `${negative}` `${seed}` `${steps}` `${cfg}` `${guidance}` `${sampler}` `${scheduler}` `${width}` `${height}` `${batch}` `${prefix}` -A value that is *exactly* one placeholder is replaced with the raw value, so `"seed": "${seed}"` +A value that is _exactly_ one placeholder is replaced with the raw value, so `"seed": "${seed}"` yields a number, not a string. Use `workflow` for a bare diffusion model (UNETLoader + separate encoders) and `checkpointWorkflow` for an all-in-one single-file checkpoint. An entry can have both. @@ -165,7 +165,7 @@ always tell whether Filesmith saw the file at all. Every download in `resources/registry/gen-archs.json` carries a real `sha256` and a URL pinned to an immutable commit revision, with the `resolve/main` branch URL kept after it as a fallback mirror. -The hashes are not invented: Hugging Face stores large files in git-LFS, and an LFS object id *is* +The hashes are not invented: Hugging Face stores large files in git-LFS, and an LFS object id _is_ the sha256 of the content, exposed per file by the repo tree API. ``` @@ -183,7 +183,7 @@ trust-on-first-use, which is where everything was before. ## Publishing a channel update -Every companion URL points into someone else's repo. When one is reorganized, every *installed* +Every companion URL points into someone else's repo. When one is reorganized, every _installed_ copy of Filesmith gets a 404 and stays broken until a new release ships. The channel fixes that without a release: publish a signed pack, and every install picks it up within a day. diff --git a/docs/comfyui-upscaler-import.md b/docs/comfyui-upscaler-import.md index 1fc2401..6d73ea9 100644 --- a/docs/comfyui-upscaler-import.md +++ b/docs/comfyui-upscaler-import.md @@ -11,7 +11,7 @@ uses) and run through our own tiled upscaler. ## Why this over bundling - **Licensing**: most good community upscalers are non-commercial (CC-BY-NC-SA), - so we can't ship them. Running the user's *own* files is distribution-free. + so we can't ship them. Running the user's _own_ files is distribution-free. - **VRAM**: tiled ESRGAN inference is bounded, so it avoids the large-output blow-ups the diffusion path (PiD) hits. - **Coverage**: spandrel auto-detects architecture, so one code path handles the @@ -22,7 +22,7 @@ uses) and run through our own tiled upscaler. 1. **Import policy** — load anything spandrel accepts; badge known-good models **Verified**, the rest **Experimental**; diffusion/unloadable files are shown greyed as **Unsupported** with the reason. -2. **Placement** — imported models sit *alongside* the existing options +2. **Placement** — imported models sit _alongside_ the existing options (Photo / Anime bundled Real-ESRGAN, PiD). Nothing is removed. PiD keeps its own diffusion path (and its pending input-resize fix). 3. **Storage** — **reference in place**: read models straight from the ComfyUI diff --git a/docs/compression-upgrade-plan.md b/docs/compression-upgrade-plan.md index 335e55d..aa8dccb 100644 --- a/docs/compression-upgrade-plan.md +++ b/docs/compression-upgrade-plan.md @@ -4,11 +4,13 @@ A running design doc for expanding the **Compress** tab, decided category by category with the user. Status per section: DECIDED / discussing / TODO. **STATUS: IMPLEMENTED** (images, video, audio, PDF). Engine + per-kind options UI -+ live video-resolution preview + Ghostscript/ffprobe bundling all landed and -verified end-to-end against the real binaries. Possible follow-up: allow WAV/FLAC -into audio Compress (wav→opus is a big win), currently excluded by canCompress. + +- live video-resolution preview + Ghostscript/ffprobe bundling all landed and + verified end-to-end against the real binaries. Possible follow-up: allow WAV/FLAC + into audio Compress (wav→opus is a big win), currently excluded by canCompress. Current baseline (what the Compress tab does today): + - Images: CaesiumCLT (jpg/png/webp/gif/tiff) + ImageMagick fallback, re-encode at a quality value, **same format in/out**. - Video: ffmpeg CRF, x264 (VP9 for webm), fixed 128k AAC audio, one quality slider. @@ -31,16 +33,18 @@ Add a **Format** choice to the image Compress options. Three options: narrower compatibility. The "squeeze it as hard as possible" option. Key decisions: + - **Both new formats run in LOSSY mode**, driven by the existing quality slider. Rationale: a Compress button should reliably make files smaller; lossy is where the big wins are for photos (the common case). Lossless WebP/AVIF only - helps graphics and can make photos *larger*, so it's not offered here. + helps graphics and can make photos _larger_, so it's not offered here. - **No "→ JPEG" option in Compress.** Converting to JPEG is a footgun on transparency / sharp edges; that belongs in the Convert tool. - Perfect-quality (lossless) format conversion, if ever wanted, also belongs in Convert, not Compress. Implementation notes: + - ImageMagick (already bundled) can already encode WebP and AVIF at `-quality` (verified in the conversion matrix), so an **MVP needs no new binary** — route the WebP/AVIF options through magick with the mapped quality. @@ -51,6 +55,7 @@ Implementation notes: - Transparency is preserved in lossy WebP/AVIF (unlike JPEG). Second-wave / not now (nice-to-have specialists): + - mozjpeg (better JPEG encoder, 10-30% smaller), oxipng / pngquant (PNG lossless / palette), gifsicle (GIF). Bigger, format-specific wins; revisit later. - "Strip metadata" toggle (EXIF/ICC/XMP) — cheap, always helps a little. @@ -62,6 +67,7 @@ Second-wave / not now (nice-to-have specialists): Three independent controls in the video Compress options: ### a. Codec (the biggest lever) — same "compatible / smaller / smallest" framing as images + 1. **H.264 (compatible)** — universal, plays everywhere, least efficient. Today's default. ffmpeg `libx264`. 2. **H.265 / HEVC (smaller)** — ~30-50% smaller than H.264 at equal quality, @@ -70,16 +76,19 @@ Three independent controls in the video Compress options: 3. **AV1 (smallest)** — another ~20-35% smaller than H.265, slowest encode, narrower support. ffmpeg `libsvtav1` (fast AV1 encoder). The bundled gyan.dev essentials ffmpeg includes SVT-AV1 (verified). + - **Output is always .mp4**, driven by the chosen codec (H.264/H.265/AV1 all mux there). The codec picker replaces the old auto-VP9-for-WebM behavior: a `.webm` source is re-encoded to `.mp4` with the selected codec. (No VP9 option is offered; VP9/WebM output would be a Convert-tool concern if ever wanted.) ### b. Quality — the existing slider + - Maps to CRF per codec (the CRF scale differs by codec: ~18-32 for x264/x265, higher numbers for AV1, so use a per-codec mapping, not one shared range). ### c. Resolution — presets + live output list + - Presets: **Original / 1440p / 1080p / 720p / 480p / 360p / 240p**. - Each preset means "fit within that box, **preserve aspect ratio, never upscale**" — so it works for any shape (landscape, portrait, ultrawide) and any @@ -99,6 +108,7 @@ Three independent controls in the video Compress options: - Optional later: a percentage / custom-dimensions mode for power users. ### Audio track (inside video) + - MVP: keep the current fixed AAC 128k. Optionally expose audio bitrate / "remove audio" later, reusing the audio-category decisions below. @@ -109,20 +119,24 @@ lossy formats — mp3/m4a/aac/ogg/opus/wma; lossless flac/wav are excluded by `canCompress`, as today.) All via bundled ffmpeg, no new tools. ### a. Codec / format picker — parenthetical labels for the tradeoff + 1. **MP3 (compatible)** — universal, least efficient. `libmp3lame`. 2. **AAC (balanced)** — better quality per byte, widely supported (`.m4a`). ffmpeg `aac`. 3. **Opus (smallest)** — most efficient (~40-60% smaller at equal quality), especially at low bitrates; fine on modern devices. `libopus`. + - "Keep format" behavior can remain the default (re-encode in the source codec), with MP3/AAC/Opus as explicit targets. ### b. Bitrate picker (replaces the quality slider for audio) + - User picks the **bitrate directly**, shown in kbps (not an abstract quality slider). Preset values: **320 / 256 / 192 / 128 / 96 / 64 kbps** (default ~192). - ffmpeg `-b:a k`. Lower bitrate = smaller file; Opus stays clean lower than MP3/AAC. ### Explicitly NOT doing (this round) + - **No "already-compressed" warning.** (User decision.) - Sample-rate reduction / stereo→mono downmix / Voice-Podcast-Music presets: deferred, revisit later if wanted. @@ -130,12 +144,13 @@ lossy formats — mp3/m4a/aac/ogg/opus/wma; lossless flac/wav are excluded by ## 4. PDF — DECIDED Bundle **Ghostscript** (`gs`) — the only mature offline single-binary tool that -downsamples the *images inside* a PDF (mutool/qpdf only touch structure, which is +downsamples the _images inside_ a PDF (mutool/qpdf only touch structure, which is why today's compress does almost nothing on scanned / image-heavy PDFs). Worth the ~60 MB. License: AGPL, compatible since Filesmith is open-source and already ships GPL ffmpeg (no network service, so AGPL's extra clause doesn't bite). ### a. Compression level picker — default Balanced + - **Lossless** — today's `mutool clean -gggg -z`. No image changes, safe, small savings. Best for text-only PDFs / when you don't want to risk altering render. - **High quality** (~300 dpi) — Ghostscript `-dPDFSETTINGS=/printer`. Downsamples @@ -149,9 +164,10 @@ ships GPL ffmpeg (no network service, so AGPL's extra clause doesn't bite). mutool is the conservative "shrink without altering" pass. ### b. Grayscale toggle — optional, OFF by default + - Converts color → grayscale for docs that don't need color (scanned documents to email). ~30-50% extra. Ghostscript `-sColorConversionStrategy=Gray - -sProcessColorModel=DeviceGray`. Off by default so it never surprises anyone. +-sProcessColorModel=DeviceGray`. Off by default so it never surprises anyone. Base gs invocation: `gs -sDEVICE=pdfwrite -dPDFSETTINGS=/ -dNOPAUSE -dBATCH -dQUIET -o out.pdf in.pdf` diff --git a/docs/dependency-model-audit.md b/docs/dependency-model-audit.md index 9405f6a..bb5aeba 100644 --- a/docs/dependency-model-audit.md +++ b/docs/dependency-model-audit.md @@ -28,10 +28,10 @@ change plus a signed release. **Two things will bite a stranger before they ever get to models.** A build made on any machine but yours silently ships without LibreOffice, Ghostscript or Real-ESRGAN, because the packaging gate checks only five flat exes (`scripts/fetch-binaries.mjs:342`) while three tree-shaped tools -ride `extraResources` unchecked (`electron-builder.yml:22-34`), and electron-builder only *warns* +ride `extraResources` unchecked (`electron-builder.yml:22-34`), and electron-builder only _warns_ on a missing source. And a user whose ComfyUI is not in the ~35 guessed paths has no way to tell the app where it is: the only folder picker in the entire renderer is behind an NVIDIA gate -(`OptionsPanel.tsx:405`) *and* behind a 3 GB engine download (`ComfyImport.tsx:81`). +(`OptionsPanel.tsx:405`) _and_ behind a 3 GB engine download (`ComfyImport.tsx:81`). **One hard bug**: a space in `%TEMP%` breaks every document conversion, reproduced by direct execution of the bundled `soffice.com` (`src/main/tools/soffice.ts:28`). @@ -50,6 +50,7 @@ those two files. An honest baseline. These are not consolations, they are the parts you should not touch. ### Process execution and file safety + - **No shell, anywhere.** Zero `shell: true`, zero `exec`/`execSync`/`spawnSync` across `src/`. Every spawn goes through `run.ts:24` with an argv array and `windowsHide`. A filename containing `& rd /s /q C:\`, quotes or `$(...)` is inert. This is the single biggest correctness win for @@ -63,6 +64,7 @@ An honest baseline. These are not consolations, they are the parts you should no (`generate/index.ts:26-34`). Sidecar paths travel as JSON over stdin (`pid/sidecar.ts:198`). ### Packaging and paths + - One consistent dev-vs-packaged rule across all six resource trees (`toolResolver.ts:10-14, 33-36, 61-64, 89-93`; `pid/paths.ts:48-59`), verified to hold in both modes with no drift. @@ -75,6 +77,7 @@ An honest baseline. These are not consolations, they are the parts you should no (`tools/soffice.ts:22-44`). ### Downloads + - Genuinely atomic and never mistakable for complete: stream to `.part`, verify byte count against Content-Length, delete the part on any failure, rename last (`net/download.ts:47-80`, `pid/install.ts:87-116`). @@ -91,9 +94,10 @@ An honest baseline. These are not consolations, they are the parts you should no resumes cleanly instead of reading as a torch-less success. ### Model identification (the part that already generalizes) + - **Architecture is inferred from the file, not the name.** `archScan.ts:21` reads the 8-byte length plus JSON header, capped at 16 MB against a hostile length field; `classifyArch` - (`:76-105`) decides from tensor-key signatures. A new *finetune* of a known family works with + (`:76-105`) decides from tensor-key signatures. A new _finetune_ of a known family works with zero code change. Declared `modelspec.architecture` is preferred where present (`:72`). - Non-image DiTs (video/3D/audio) are positively excluded before the image checks (`archScan.ts:113-125`), with regression tests for HunyuanVideo, FramePack, Hunyuan3D, Wan and @@ -110,6 +114,7 @@ An honest baseline. These are not consolations, they are the parts you should no (`resolve(dir)` into a `visited` set). Three sibling walkers are missing it (see §3). ### Live reconciliation + - `resolveAgainstComfy` (`preflight.ts:31-39, 77-131`) fetches `/object_info` before queueing, validates every node class and every CLIPLoader `type` enum value, and resolves each of our filesystem names to the exact string ComfyUI reports, separator- and case-insensitively. A wrong @@ -117,12 +122,14 @@ An honest baseline. These are not consolations, they are the parts you should no raw HTTP 400. This is rare and excellent. ### Not downloading things it does not need + - `findComfyPidWeights` (`discover.ts:214-237`) reuses a ComfyUI user's existing PiD weights with 2 GB / 200 MB size floors, saving ~3 GB. `pythonEnv.ts:96-106` runs the spandrel sidecar in the user's **own** ComfyUI Python when it has torch and spandrel, so many ComfyUI users need no multi-GB install at all. Models are referenced in place, never copied (`comfy/store.ts:6-8`). ### Honest, up-front disclosure + - Cost and license are stated **before** the user commits: `PidUpscale.tsx:36-40` leads with "non-commercial use only" and "~6 GB"; `OptionsPanel.tsx:673-684` warns about the rembg first-run download before files are queued. @@ -134,6 +141,7 @@ An honest baseline. These are not consolations, they are the parts you should no (`registry.ts:838-844`), which pattern-matches the driver error and rewrites it. ### Electron security posture + - Defaults left secure: no `nodeIntegration: true`, no `contextIsolation: false`, no `webSecurity: false`, zero `NODE_TLS_REJECT_UNAUTHORIZED` / `rejectUnauthorized` / `certificate-error` handlers. A real CSP ships (`renderer/index.html:8`, @@ -144,6 +152,7 @@ An honest baseline. These are not consolations, they are the parts you should no renderer-supplied model path against the on-disk store (`registry.ts:739`). ### State portability + - Everything mutable lives under `app.getPath('userData')`: `session.json`, `comfy-upscalers.json`, the PiD env and weights, the generated extra-model-paths YAML. Nothing is written next to the exe. Per-user NSIS install needs no admin rights. Single-instance lock @@ -157,6 +166,7 @@ An honest baseline. These are not consolations, they are the parts you should no Ranked by (fresh-user impact) x (how many people it hits). Model-extensibility findings are §4. ### H1. A space in `%TEMP%` crashes every document conversion + **`src/main/tools/soffice.ts:28`** ・ severity **high** ・ breaks for: **any user whose account name contains a space** ("John Smith"), or who redirected TEMP to a spaced path. @@ -170,15 +180,15 @@ A raw string splice with no URL encoding. `registry.ts:184-191` builds the profi Reproduced by direct execution against the repo's own `resources/libreoffice/program/soffice.com` with the exact argv `buildSofficeArgs` emits, varying only the profile dir: -| profile URL | result | -|---|---| -| `file:///C:/.../p1/profile` | status 0, `in.pdf` written | -| `file:///C:/.../p2/pro file` | **status 3765269347 (0xE06D7363, C++ exception), no PDF, empty stdout AND stderr** | -| `file:///C:/.../p3/pro%20file` | status 0, `in.pdf` written | +| profile URL | result | +| ------------------------------ | ---------------------------------------------------------------------------------- | +| `file:///C:/.../p1/profile` | status 0, `in.pdf` written | +| `file:///C:/.../p2/pro file` | **status 3765269347 (0xE06D7363, C++ exception), no PDF, empty stdout AND stderr** | +| `file:///C:/.../p3/pro%20file` | status 0, `in.pdf` written | Because stderr is empty, `registry.ts:202-207` falls through to the generic "LibreOffice couldn't convert to PDF" with no diagnostic. A `#` in the profile path makes `soffice.bin` **hang -indefinitely** instead, and there is no timeout anywhere in `run.ts`. Non-ASCII is *not* affected: +indefinitely** instead, and there is no timeout anywhere in `run.ts`. Non-ASCII is _not_ affected: accented and Cyrillic profile dirs both converted fine, so this is specifically an unencoded-space/`#` bug, not an encoding bug. @@ -189,6 +199,7 @@ One line, one test. --- ### H2. A build made anywhere but your machine silently ships without LibreOffice, Ghostscript or Real-ESRGAN + **`scripts/fetch-binaries.mjs:342`** and **`electron-builder.yml:26`** ・ severity **high** ・ breaks for: **every end user of any installer built on a machine lacking those trees**, and any contributor or CI cutting a release from a fresh clone (`resources/` is gitignored). @@ -225,6 +236,7 @@ past a missing source. --- ### H3. There is no way to tell Filesmith where ComfyUI is, unless you have an NVIDIA GPU and download 3 GB first + **`src/renderer/src/components/OptionsPanel.tsx:405, 974`** and **`ComfyImport.tsx:81`** ・ severity **high** ・ breaks for: **any user whose ComfyUI is outside the ~35 guessed paths** (F:/G:/a NAS, `C:\AI\comfy`, a OneDrive-redirected Documents, a dev checkout), and **every non-NVIDIA ComfyUI @@ -261,12 +273,13 @@ drive the entire generation feature. **Fix**: promote "ComfyUI location" to a first-class setting, reachable from a Settings surface **and** inline in the Generate panel whenever `generate:status.available === false`. Un-gate the Browse button from both `hasNvidia` and `engineReady` (ComfyUI is not NVIDIA-only, and picking the -folder is what *makes* discovery work). One pick then fixes generate, upscale and companion +folder is what _makes_ discovery work). One pick then fixes generate, upscale and companion downloads together, since they all read the same store first. --- ### H4. ComfyUI Desktop installs can never be auto-launched, and are reported "not found" forever + **`src/main/comfy/pythonEnv.ts:86`** ・ severity **high** ・ breaks for: **anyone who installed ComfyUI via the official Desktop installer**, precisely the non-technical target user, whenever the Desktop app is not already running. @@ -297,11 +310,13 @@ answers `/system_stats`. --- ### M1. ComfyUI port is fixed at 8188/8199, and the launched child's piped stdio is never read + **`src/main/generate/comfy.ts:79, 92`** ・ severity **medium, but the stdio half is a live hang** ```ts -for (const p of [8188, FS_PORT]) // FS_PORT = 8199, comfy.ts:13 -proc = spawn(launch.python, args, { cwd, windowsHide: true, env }) // comfy.ts:92 +for (const p of [8188, FS_PORT]) + // FS_PORT = 8199, comfy.ts:13 + proc = spawn(launch.python, args, { cwd, windowsHide: true, env }) // comfy.ts:92 ``` `spawn` with no `stdio` option defaults to **pipes**. The only listener attached is @@ -321,10 +336,13 @@ port instead of a fixed 8199. --- ### M2. A ComfyUI that is running right now is reported as "not found" + **`src/main/generate/comfy.ts:73`** ・ severity **medium** ```ts -export function comfyGenerationAvailable(): boolean { return findComfyLaunch() != null } +export function comfyGenerationAvailable(): boolean { + return findComfyLaunch() != null +} ``` Pure filesystem, and this is what `generate:status` returns (`ipc.ts:258`). Meanwhile @@ -339,6 +357,7 @@ filesystem scan only when nothing is listening. --- ### M3. A missing bundled binary surfaces as `spawn gswin64c ENOENT` on the queue card + **`src/main/toolResolver.ts:81, 99`** → **`run.ts:40`** → **`registry.ts:106-114`** → **`jobQueue.ts:100`** ・ severity **medium**, conditional on H2 @@ -359,6 +378,7 @@ run a one-shot startup probe that disables affected operations in the UI rather --- ### M4. `resolveUv()` cannot see the uv the app itself downloaded + **`src/main/toolResolver.ts:159`** ・ severity **medium** ・ breaks for: a fresh user with no uv, and provably **any user who already completed the PiD install**. @@ -381,6 +401,7 @@ hardcoding the hash. --- ### M5. Three of the four recursive model walks have no symlink-cycle guard, and the scan blocks the main process + **`src/main/generate/models.ts:35`**, **`archRegistry.ts:152-177`**, **`comfy/discover.ts:188-209`** ・ severity **medium** @@ -397,6 +418,7 @@ main thread, and cache keyed on directory mtimes. --- ### M6. No install lock: two concurrent installs destroy each other + **`src/main/ipc.ts:182, 221`** ・ severity **medium** Neither `pid:install` nor `comfy:install` has in-flight dedupe or a module-level lock, and both @@ -412,6 +434,7 @@ points (they share every phase anyway); `mkdtempSync` per run instead of fixed t --- ### M7. No disk-space preflight, no resumable downloads, no repair path + **`src/main/pid/install.ts:295`** ・ severity **medium** `installPid` goes straight from `mkdirSync(pidRoot())` into the phases with no space check @@ -434,6 +457,7 @@ visible "Repair / remove AI install" button that does `rmSync(pidRoot(), {recurs --- ### M8. `pid/install.ts` forks a weaker downloader than the one in the same repo + **`src/main/pid/install.ts:83`** ・ severity **medium** `install.ts:70-120` is a private copy of `net/download.ts` missing all three of that file's guards: @@ -447,6 +471,7 @@ and is populated and is never read by anything. --- ### M9. Downloads use Node's undici `fetch`, not Electron's `net.fetch` + **`src/main/net/download.ts:33`**, **`pid/install.ts:83`** ・ severity **medium** Both are the Node global: no system-proxy support, and TLS validated against Node's bundled CA list @@ -467,6 +492,7 @@ traffic, ask IT to allow huggingface.co and github.com". --- ### M10. No integrity verification on anything downloaded, including code that gets executed + **`src/main/net/download.ts:15`**, **`pid/install.ts:70`** ・ severity **medium/high** `grep -riE "sha256|checksum|createHash|integrity|signature" src/` returns exactly one hit, an @@ -489,6 +515,7 @@ makes a data-driven model list safe**, so it is a prerequisite for §5, not an o --- ### M11. The GPU gate is "nvidia-smi answered", with no compute-capability or driver floor + **`src/main/pid/gpu.ts:41`** ・ severity **medium** `detectNvidia` queries only `name,memory.total`; `hasNvidia()` is `detectNvidia() != null`; that is @@ -511,6 +538,7 @@ chosen per machine. --- ### M12. Zero test coverage for dependency and model resolution + **`test/`** ・ severity **medium** Grepping all 15 test files for `resolveUv`, `resolveTool`, `resolveSoffice`, `resolveGhostscript`, @@ -528,6 +556,7 @@ remembered store; `resolveArch()` reports missing companions against a fake mode --- ### Low-severity, worth a cleanup PR + - **`toolResolver.ts:181`** `toolAvailable()` probes with `-version`, which two of the four bundled tools reject. Verified against the repo's own binaries: `magick -version` → 0, `ffmpeg -version` → 0, **`mutool -version` → exit 1** (prints usage), **`caesiumclt -version` → @@ -535,7 +564,7 @@ remembered store; `resolveArch()` reports missing companions against a fake mode code today (zero renderer callers), but it is the foundation of the M3 preflight. - **`toolResolver.ts:43-48, 70`** and **`fetch-binaries.mjs:157-159, 196, 216-219`** hardcode `C:\Program Files`. Build from `process.env.ProgramFiles` / `ProgramFiles(x86)` / `ProgramW6432`, - keeping the literals as fallback. (Note: Windows localizes only the *display* name; the on-disk + keeping the literals as fallback. (Note: Windows localizes only the _display_ name; the on-disk path is always `\Program Files`. The real case is a non-C: system drive.) `pid/install.ts:58` already does this correctly with `process.env.SystemRoot`. - **`generate/comfy.ts:54`** writes `base_path: ${d}` as a bare YAML scalar. A space followed by @@ -548,7 +577,7 @@ remembered store; `resolveArch()` reports missing companions against a fake mode in one line. Copy that. - **`fetch-binaries.mjs:94`** uses the **rolling** `ffmpeg-release-essentials.zip`, whose contents change with every release, and no download in the file computes a hash. Ghostscript (`:227`) and - Real-ESRGAN (`:290`) *are* pinned, so ffmpeg is the only floating URL. `bundleImageMagick`, + Real-ESRGAN (`:290`) _are_ pinned, so ffmpeg is the only floating URL. `bundleImageMagick`, `bundleCaesium`, `bundleMutool` and `bundleLibreOffice` all source from a **local install found via `which()`**, so the shipped bytes depend on your winget state and a bare CI runner cannot reproduce the build. @@ -573,6 +602,7 @@ This is your central worry, and it is correctly placed. Here is the **actual pat follow today** to add a new model, per type. I walked each one in the code. ### 4a. A new ComfyUI checkpoint of a known family (a new Flux 1 finetune, a new SDXL merge) + 1. Drop the file in `ComfyUI/models/checkpoints/` or `diffusion_models/`. 2. Reopen the Generate panel. 3. It works. @@ -582,6 +612,7 @@ follow today** to add a new model, per type. I walked each one in the code. companions, and the rename-safe byte-size check picks the right Flux 2 encoder. Zero code change. ### 4b. A new checkpoint **family** (SD3.5, Qwen-Image, Chroma, HiDream, whatever ships next month) + 1. Drop the file in `diffusion_models/`. 2. `classifyArch` returns `'unknown'` (or `'sd3'`). 3. `models.ts:111-114`: `if (!SUPPORTED.includes(arch)) { unrecognized += 1; continue }`. @@ -591,6 +622,7 @@ companions, and the rename-safe byte-size check picks the right Flux 2 encoder. 5. The user's only recourse is to wait for you to ship a new Filesmith build. Adding that family requires editing **six places**, none of which is data: + - `src/shared/genArch.ts:14` the `GenArch` union - `src/shared/genArch.ts:32` `ARCH_INFO` (sampler, scheduler, steps, cfg, guidance) - `src/shared/genArch.ts:152/188/222/255` a hand-written API-format graph, plus the @@ -613,6 +645,7 @@ it with only a retry button, and the model stays non-runnable forever. There is user-supplied URL, and no "I already have this file, point at it" picker anywhere in the flow. ### 4c. A new upscaler architecture (DAT, SPAN, ATD, RealPLKSR, SeedVR, the 2026 arch) + **Via ComfyUI import**: 1. Drop the file in `models/upscale_models/`. 2. Click Rescan. 3. spandrel probes it and it works, badged "experimental". **This path is genuinely arch-agnostic and needs no code change.** Excellent. @@ -637,6 +670,7 @@ The ncnn binary would happily run any `.param`/`.bin` pair dropped in there, on including AMD. Nothing looks. ### 4d. A new rembg model (a new BiRefNet generation, BEN2, RMBG-3) + 1. There is no UI. Grepping the entire renderer for `bgModel` returns **zero hits**. 2. `shared/removebg.ts:22` is a four-value compile-time union; `tools/removebg.ts:18-21` clamps anything else back to the default. @@ -652,6 +686,7 @@ not an oversight. It must stay a vetted list. What genuinely rots is that the ve compiled code with no data-driven override. ### 4e. A new PiD backbone + 1. There is no UI and no path. `paths.ts:84-91` has exactly one entry, commented "Only flux is wired for now". 2. The identity is hardcoded across four files, **including the IPC boundary**: `ipc.ts:180` @@ -671,22 +706,22 @@ compiled code with no data-driven override. ### Where the path rots: the summary table -| Bound thing | File:line | Rots when | -|---|---|---| -| `GenArch` union | `shared/genArch.ts:14` | any new checkpoint family ships | -| `SUPPORTED` allowlist | `generate/models.ts:16` | same; file becomes invisible | -| Workflow graphs + builder switch | `shared/genArch.ts:152-337` | ComfyUI renames a node | -| `CLIP_REQ` / `EXTRA_NODES` | `generate/preflight.ts:42-56` | ComfyUI renames a loader `type` enum | -| 8 companion URLs on `resolve/main` | `generate/archRegistry.ts:36-136` | HF repo reorg or rename | -| Companion filename regexes | `generate/archRegistry.ts:33-133` | a user's file is named differently | -| ncnn model names (build + runtime) | `fetch-binaries.mjs:255`, `upscale.ts:13` | any new ncnn upscaler | -| `VERIFIED_TOKENS` badge list | `shared/comfy.ts:32` | any upscaler released after this build | -| rembg sessions + version pin | `shared/removebg.ts:22`, `toolResolver.ts:120` | any new matting model | -| PiD backbone table + IPC literals | `pid/paths.ts:84`, `ipc.ts:180-212` | any new NVIDIA checkpoint | -| PiD repo `heads/main.zip` + empty marker | `pid/install.ts:30, 135, 170` | upstream touches `_src` | -| spandrel frozen by version-less marker | `pid/install.ts:305-319` | any new upscaler arch | -| Dimension clamp 2048, SDXL bucket list | `shared/generate.ts:89-103` | a model with a higher native res | -| torch pin + cu128 index | `pid/install.ts:236-244` | a non-12.8 CUDA generation | +| Bound thing | File:line | Rots when | +| ---------------------------------------- | ---------------------------------------------- | -------------------------------------- | +| `GenArch` union | `shared/genArch.ts:14` | any new checkpoint family ships | +| `SUPPORTED` allowlist | `generate/models.ts:16` | same; file becomes invisible | +| Workflow graphs + builder switch | `shared/genArch.ts:152-337` | ComfyUI renames a node | +| `CLIP_REQ` / `EXTRA_NODES` | `generate/preflight.ts:42-56` | ComfyUI renames a loader `type` enum | +| 8 companion URLs on `resolve/main` | `generate/archRegistry.ts:36-136` | HF repo reorg or rename | +| Companion filename regexes | `generate/archRegistry.ts:33-133` | a user's file is named differently | +| ncnn model names (build + runtime) | `fetch-binaries.mjs:255`, `upscale.ts:13` | any new ncnn upscaler | +| `VERIFIED_TOKENS` badge list | `shared/comfy.ts:32` | any upscaler released after this build | +| rembg sessions + version pin | `shared/removebg.ts:22`, `toolResolver.ts:120` | any new matting model | +| PiD backbone table + IPC literals | `pid/paths.ts:84`, `ipc.ts:180-212` | any new NVIDIA checkpoint | +| PiD repo `heads/main.zip` + empty marker | `pid/install.ts:30, 135, 170` | upstream touches `_src` | +| spandrel frozen by version-less marker | `pid/install.ts:305-319` | any new upscaler arch | +| Dimension clamp 2048, SDXL bucket list | `shared/generate.ts:89-103` | a model with a higher native res | +| torch pin + cu128 index | `pid/install.ts:236-244` | a non-12.8 CUDA generation | Thirteen of those fourteen rows are fixed by one mechanism. Build it once. @@ -709,6 +744,7 @@ Three layers, merged by `id`, later layers winning field-by-field: ``` Rules, non-negotiable: + - **An app update replaces layer 1 only.** It can never read, write or delete layer 3. This is the whole point. - **A channel refresh replaces layer 2 wholesale**, only after signature verification. If the @@ -719,7 +755,7 @@ Rules, non-negotiable: user fixes a dead HF URL in 30 seconds without understanding the rest. - Each file is `{ "schemaVersion": 1, "entries": [...] }`. On load, entries whose `schemaVersion` exceeds the app's are **skipped with a visible note**, never crash the load. - A malformed file disables *that file* and surfaces a warning, never bricks the registry. + A malformed file disables _that file_ and surfaces a warning, never bricks the registry. - `readComfyStore` / `writeComfyStore` (`comfy/store.ts`) is already exactly this pattern at 45 lines. Generalize it; do not invent something new. @@ -783,6 +819,7 @@ impossible.** One entry point, reachable from the Generate panel, the Upscale panel and Settings: **Add a model** → + - **Point at a file or folder.** App probes it, shows what it inferred (architecture, scale, detected companions, missing companions), lets the user correct any field, then registers a user entry. This is the 90% case and needs no network. @@ -872,19 +909,40 @@ This is where a data-driven registry earns its keep or becomes a liability. Non- "capabilities": { "task": "text-to-image", - "minDim": 256, "maxDim": 2048, "dimStep": 64, - "sizeBuckets": [[1024,1024],[1152,896],[896,1152],[1216,832],[832,1216]] + "minDim": 256, + "maxDim": 2048, + "dimStep": 64, + "sizeBuckets": [ + [1024, 1024], + [1152, 896], + [896, 1152], + [1216, 832], + [832, 1216] + ] }, "sampler": { - "name": "res_multistep", "scheduler": "simple", - "steps": 8, "cfg": 1, "guidance": 0, "hasGuidance": false + "name": "res_multistep", + "scheduler": "simple", + "steps": 8, + "cfg": 1, + "guidance": 0, + "hasGuidance": false }, "requires": { - "nodes": ["UNETLoader","CLIPLoader","VAELoader","CLIPTextEncode", - "ModelSamplingAuraFlow","ConditioningZeroOut","EmptySD3LatentImage", - "KSampler","VAEDecode","SaveImage"], + "nodes": [ + "UNETLoader", + "CLIPLoader", + "VAELoader", + "CLIPTextEncode", + "ModelSamplingAuraFlow", + "ConditioningZeroOut", + "EmptySD3LatentImage", + "KSampler", + "VAEDecode", + "SaveImage" + ], "clipLoader": { "node": "CLIPLoader", "type": "lumina2" }, "minComfyNote": "Z-Image needs ComfyUI v0.6.0+ (late November 2025)." }, @@ -915,27 +973,48 @@ This is where a data-driven registry earns its keep or becomes a liability. Non- "workflow": { "format": "comfy-api-v1", "template": { - "1": { "class_type": "UNETLoader", - "inputs": { "unet_name": "${unet}", "weight_dtype": "default" } }, - "2": { "class_type": "CLIPLoader", - "inputs": { "clip_name": "${clip}", "type": "lumina2", "device": "default" } }, + "1": { + "class_type": "UNETLoader", + "inputs": { "unet_name": "${unet}", "weight_dtype": "default" } + }, + "2": { + "class_type": "CLIPLoader", + "inputs": { "clip_name": "${clip}", "type": "lumina2", "device": "default" } + }, "3": { "class_type": "VAELoader", "inputs": { "vae_name": "${vae}" } }, - "4": { "class_type": "ModelSamplingAuraFlow", - "inputs": { "model": ["1", 0], "shift": 3 } }, - "5": { "class_type": "CLIPTextEncode", - "inputs": { "clip": ["2", 0], "text": "${prompt}" } }, + "4": { + "class_type": "ModelSamplingAuraFlow", + "inputs": { "model": ["1", 0], "shift": 3 } + }, + "5": { + "class_type": "CLIPTextEncode", + "inputs": { "clip": ["2", 0], "text": "${prompt}" } + }, "6": { "class_type": "ConditioningZeroOut", "inputs": { "conditioning": ["5", 0] } }, - "7": { "class_type": "EmptySD3LatentImage", - "inputs": { "width": "${width}", "height": "${height}", "batch_size": 1 } }, - "8": { "class_type": "KSampler", - "inputs": { "model": ["4", 0], "positive": ["5", 0], "negative": ["6", 0], - "latent_image": ["7", 0], "seed": "${seed}", - "steps": "${steps}", "cfg": "${cfg}", - "sampler_name": "${sampler}", "scheduler": "${scheduler}", - "denoise": 1 } }, + "7": { + "class_type": "EmptySD3LatentImage", + "inputs": { "width": "${width}", "height": "${height}", "batch_size": 1 } + }, + "8": { + "class_type": "KSampler", + "inputs": { + "model": ["4", 0], + "positive": ["5", 0], + "negative": ["6", 0], + "latent_image": ["7", 0], + "seed": "${seed}", + "steps": "${steps}", + "cfg": "${cfg}", + "sampler_name": "${sampler}", + "scheduler": "${scheduler}", + "denoise": 1 + } + }, "9": { "class_type": "VAEDecode", "inputs": { "samples": ["8", 0], "vae": ["3", 0] } }, - "10": { "class_type": "SaveImage", - "inputs": { "images": ["9", 0], "filename_prefix": "${prefix}" } } + "10": { + "class_type": "SaveImage", + "inputs": { "images": ["9", 0], "filename_prefix": "${prefix}" } + } } } } @@ -954,11 +1033,14 @@ extensibility at all today: "label": "Real-ESRGAN anime video v3", "provenance": { "source": "user", "addedAt": "2026-07-31T09:12:00Z" }, "detect": { "ncnnParamBasename": "realesrgan-animevideov3" }, - "capabilities": { "task": "upscale", "scales": [2, 3, 4], - "inputFormats": [".png", ".jpg", ".webp"] }, + "capabilities": { + "task": "upscale", + "scales": [2, 3, 4], + "inputFormats": [".png", ".jpg", ".webp"] + }, "files": [ { "path": "realesrgan-animevideov3.param", "sha256": "..." }, - { "path": "realesrgan-animevideov3.bin", "sha256": "..." } + { "path": "realesrgan-animevideov3.bin", "sha256": "..." } ] } ``` @@ -997,7 +1079,9 @@ export interface DetectSpec { export interface Capabilities { task: 'text-to-image' | 'upscale' | 'remove-background' /** Generation */ - minDim?: number; maxDim?: number; dimStep?: number + minDim?: number + maxDim?: number + dimStep?: number sizeBuckets?: [number, number][] /** Upscale */ scales?: number[] @@ -1005,8 +1089,12 @@ export interface Capabilities { } export interface SamplerSpec { - name: string; scheduler: string - steps: number; cfg: number; guidance: number; hasGuidance: boolean + name: string + scheduler: string + steps: number + cfg: number + guidance: number + hasGuidance: boolean } export interface Requirements { @@ -1029,8 +1117,7 @@ export interface DownloadSpec { } export type CompanionSubdir = - | 'text_encoders' | 'clip' | 'vae' | 'checkpoints' | 'diffusion_models' - | 'unet' | 'upscale_models' + 'text_encoders' | 'clip' | 'vae' | 'checkpoints' | 'diffusion_models' | 'unet' | 'upscale_models' export interface CompanionSpec { role: 'clip' | 'clip2' | 'vae' @@ -1042,7 +1129,10 @@ export interface CompanionSpec { } /** A companion declared once and referenced by id (e.g. the shared Flux AE). */ -export interface CompanionRef { role: CompanionSpec['role']; ref: string } +export interface CompanionRef { + role: CompanionSpec['role'] + ref: string +} export interface WorkflowSpec { format: 'comfy-api-v1' @@ -1071,19 +1161,26 @@ export interface RegistryEntry { schemaVersion?: number } -export interface RegistryFile { schemaVersion: number; entries: RegistryEntry[] } +export interface RegistryFile { + schemaVersion: number + entries: RegistryEntry[] +} /** Merge builtin < channel < user, per id, per field. Pure, unit-testable. */ -export function mergeRegistry(layers: RegistryFile[]): RegistryEntry[] { /* ... */ } +export function mergeRegistry(layers: RegistryFile[]): RegistryEntry[] { + /* ... */ +} /** Score a probed file against an entry's DetectSpec. 0 = no match. Pure. */ -export function scoreDetect(d: DetectSpec, probe: ProbedFile): number { /* ... */ } +export function scoreDetect(d: DetectSpec, probe: ProbedFile): number { + /* ... */ +} ``` Note that `ARCH_INFO` (`shared/genArch.ts:32`) is already a per-arch data table that the UI reads from (`OptionsPanel.tsx:999` hides the negative prompt when `info.cfg === 1`), and `classifyModel` (`shared/comfy.ts:57`) **already accepts a `tokens` parameter**. The indirection you -need mostly exists. This is a change of *source*, not of *shape*. +need mostly exists. This is a change of _source_, not of _shape_. --- @@ -1094,6 +1191,7 @@ are days of work and remove most of the "only works on the developer's machine" are the registry. Phases 7 to 8 are hardening. **Phase 1 · Make a build on any machine either correct or loud** (highest impact per line) + - `scripts/fetch-binaries.mjs:342` extend the required-check to the three trees; add `--skip=` that also drops the `extraResources` entry. - `src/main/tools/soffice.ts:28` use `pathToFileURL().href`; add the spaced-path test in @@ -1105,6 +1203,7 @@ are the registry. Phases 7 to 8 are hardening. `tools/registry.ts`, `toolResolver.ts`, `test/documents.test.ts`. **Phase 2 · Let the user say where ComfyUI is, from anywhere** + - Un-gate the picker from `hasNvidia` (`OptionsPanel.tsx:405`) and from `engineReady` (`ComfyImport.tsx:81`); add "Locate my ComfyUI" inline in `GenerateOptions` where the dead-end text is today (`OptionsPanel.tsx:974`). @@ -1114,6 +1213,7 @@ are the registry. Phases 7 to 8 are hardening. - Touches: `OptionsPanel.tsx`, `ComfyImport.tsx`, `comfy/discover.ts`, `comfy/pythonEnv.ts`. **Phase 3 · Stop lying about availability, and stop deadlocking** + - `generate/comfy.ts:92` attach `stdout.resume()` + stderr-tail; bail on `exit`; include the tail in the timeout error. (This is a live hang fix, not cosmetics.) - `generate/comfy.ts:73` make `comfyGenerationAvailable` probe a live server; add a "ComfyUI server @@ -1123,6 +1223,7 @@ are the registry. Phases 7 to 8 are hardening. - Touches: `generate/comfy.ts`, `generate/models.ts`, `ipc.ts`, `OptionsPanel.tsx`. **Phase 4 · Ship the registry loader with today's data, no behaviour change** + - New `src/shared/registry.ts` (types + `mergeRegistry` + `scoreDetect`, pure) and `src/main/registry/load.ts` (three-layer load, schema validation, path constraints, provenance). - Move `ARCH_INFO`, the four workflow graphs, `CLIP_REQ`, `EXTRA_NODES` and `requiredCompanions` @@ -1133,6 +1234,7 @@ are the registry. Phases 7 to 8 are hardening. `generate/archRegistry.ts`, `test/gen-registry.test.ts`. **Phase 5 · Integrity, then the user layer** + - Add `sha256` to `DownloadSpec` and verify while streaming in `net/download.ts`; delete `pid/install.ts:70-120`'s private fork and call `downloadFile` with `minBytes = approxBytes * 0.9`. Switch both to `net.fetch` and map `err.cause.code` to real @@ -1142,6 +1244,7 @@ are the registry. Phases 7 to 8 are hardening. - Touches: `net/download.ts`, `pid/install.ts`, `generate/companions.ts`, `ipc.ts`, new UI. **Phase 6 · Retire the remaining hardcoded catalogs** + - Real-ESRGAN: enumerate `resources/realesrgan/models/*.param` plus a user overlay under `userData/models/realesrgan`; derive the picker from disk (`upscale.ts:13`, `shared/compress.ts:70-73`, `fetch-binaries.mjs:255`). This un-freezes the only AI upscaler an @@ -1160,6 +1263,7 @@ are the registry. Phases 7 to 8 are hardening. `pid/paths.ts`, `pid/install.ts`, `ipc.ts`, `generate/archRegistry.ts`, `shared/comfy.ts`. **Phase 7 · Channel updates and the "try anyway" escape hatch** + - Ed25519-signed channel refresh into layer 2, once a day, background, silent-fail-to-cache (`crypto.verify`, no dependency). This is the lever that fixes a dead HF URL for everyone in a day. - "Try anyway" on unknown generation models with a generic graph, surfacing ComfyUI's own error. @@ -1169,6 +1273,7 @@ are the registry. Phases 7 to 8 are hardening. (`shared/generate.ts:89-103`). **Phase 8 · Robustness and the remaining hardening** + - Install lock + `mkdtempSync` temp dirs (`ipc.ts:182/221`, `install.ts:137/144/205`); disk-space preflight via `fs.statfs`; `Range`-resumable downloads; a visible "Repair / remove AI install". - Symlink-cycle guards on `models.ts:35`, `archRegistry.ts:152`, `discover.ts:188`; move the scan @@ -1207,8 +1312,8 @@ Things I looked at and am deliberately **not** recommending you fix. an error-message fix, not a security fix. - **The rembg model allowlist as a concept.** `shared/removebg.ts:1-12` documents a real per-model licence audit (bria-rmbg CC BY-NC, u2net_human_seg's Supervisely provenance, isnet-anime's scraped - provenance, sam being the wrong tool). Keep it vetted. Phase 6 moves *where the list lives*, not - *whether it is a list*. Do not open it up. + provenance, sam being the wrong tool). Keep it vetted. Phase 6 moves _where the list lives_, not + _whether it is a list_. Do not open it up. - **Localized Windows folder names.** Windows localizes only the Explorer display name; `%USERPROFILE%\Desktop` and `\Documents` are the real on-disk paths, which is exactly what `join(homedir(), 'Desktop')` produces. The genuine cases are OneDrive redirection and non-C/D/E diff --git a/docs/upscaler-upgrade-plan.md b/docs/upscaler-upgrade-plan.md index 94c27e8..8ca8cea 100644 --- a/docs/upscaler-upgrade-plan.md +++ b/docs/upscaler-upgrade-plan.md @@ -29,11 +29,11 @@ three tiers: ## Tiers and models -| Tier | Engine | Runs on | Models | Ships? | -|---|---|---|---|---| -| Standard | ONNX + DirectML (`onnxruntime-node`) | NVIDIA/AMD/Intel + CPU | Real-ESRGAN (default), NMKD-Superscale, one transformer (SwinIR/DAT) | yes | -| Advanced (NVIDIA) | torch + diffusers via uv/Python | NVIDIA CUDA only | PiD (backbone VAE: flux/sd3) | no (opt-in) | -| Custom | ONNX + DirectML | any | user `.onnx` from OpenModelDB | n/a | +| Tier | Engine | Runs on | Models | Ships? | +| ----------------- | ------------------------------------ | ---------------------- | -------------------------------------------------------------------- | ----------- | +| Standard | ONNX + DirectML (`onnxruntime-node`) | NVIDIA/AMD/Intel + CPU | Real-ESRGAN (default), NMKD-Superscale, one transformer (SwinIR/DAT) | yes | +| Advanced (NVIDIA) | torch + diffusers via uv/Python | NVIDIA CUDA only | PiD (backbone VAE: flux/sd3) | no (opt-in) | +| Custom | ONNX + DirectML | any | user `.onnx` from OpenModelDB | n/a | Cross-cutting: **tiling with OOM back-off** (halve tile on allocator error, retry) so one build survives an 8GB laptop GPU, CPU fallback, and the RTX 5090. diff --git a/scripts/registry-channel.mjs b/scripts/registry-channel.mjs index 99a557f..7487bc7 100644 --- a/scripts/registry-channel.mjs +++ b/scripts/registry-channel.mjs @@ -84,7 +84,10 @@ if (cmd === 'keygen') { } const pack = JSON.parse(readFileSync(file, 'utf-8')) const key = createPublicKey({ - key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(pub, 'base64')]), + key: Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + Buffer.from(pub, 'base64') + ]), format: 'der', type: 'spki' }) diff --git a/scripts/registry-hashes.mjs b/scripts/registry-hashes.mjs index 9a31147..f0e1674 100644 --- a/scripts/registry-hashes.mjs +++ b/scripts/registry-hashes.mjs @@ -108,7 +108,9 @@ for (const c of companions) { continue } if (CHECK) { - log(` ! ${label}: STALE (pack has ${d.sha256?.slice(0, 12) ?? 'no hash'}, upstream ${sha256.slice(0, 12)})`) + log( + ` ! ${label}: STALE (pack has ${d.sha256?.slice(0, 12) ?? 'no hash'}, upstream ${sha256.slice(0, 12)})` + ) failed += 1 continue } diff --git a/src/main/comfy/discover.ts b/src/main/comfy/discover.ts index d90dc69..c0cbe95 100644 --- a/src/main/comfy/discover.ts +++ b/src/main/comfy/discover.ts @@ -283,8 +283,7 @@ export function probeModels(paths: string[]): Promise { // Any path with no reply (spandrel crashed/hung on it) is unsupported. resolvePromise( paths.map( - (p) => - byPath.get(resolve(p)) ?? { path: p, ok: false, reason: 'could not be read' } + (p) => byPath.get(resolve(p)) ?? { path: p, ok: false, reason: 'could not be read' } ) ) } diff --git a/src/main/comfy/roots.ts b/src/main/comfy/roots.ts index 133eb56..2f9effc 100644 --- a/src/main/comfy/roots.ts +++ b/src/main/comfy/roots.ts @@ -63,8 +63,7 @@ function userRoots(): string[] { process.env.OneDriveCommercial, join(home, 'OneDrive') ].filter((p): p is string => Boolean(p)) - for (const od of new Set(oneDrives)) - out.push(od, join(od, 'Desktop'), join(od, 'Documents')) + for (const od of new Set(oneDrives)) out.push(od, join(od, 'Desktop'), join(od, 'Documents')) return [...new Set(out)] } @@ -102,8 +101,7 @@ export function comfyCandidateDirs(opts?: { trusted?: boolean }): string[] { const remembered = readComfyStore()?.folder if (remembered) out.push(remembered) const roots = opts?.trusted ? trustedRoots() : comfySearchRoots() - for (const root of roots) - for (const name of COMFY_DIR_NAMES) out.push(join(root, name)) + for (const root of roots) for (const name of COMFY_DIR_NAMES) out.push(join(root, name)) // ComfyUI Desktop (the official installer) keeps user data under %APPDATA% and // the app itself under %LOCALAPPDATA%\Programs. if (process.env.APPDATA) out.push(join(process.env.APPDATA, 'ComfyUI')) diff --git a/src/main/comfy/sidecar.ts b/src/main/comfy/sidecar.ts index 2e10705..d3f3744 100644 --- a/src/main/comfy/sidecar.ts +++ b/src/main/comfy/sidecar.ts @@ -89,7 +89,9 @@ class SpandrelSidecar { }) proc.on('close', (code) => { const tail = stderrTail.trim().split('\n').filter(Boolean).pop() - const err = new Error(tail ? `Upscaler failed: ${tail}` : `spandrel process exited (${code})`) + const err = new Error( + tail ? `Upscaler failed: ${tail}` : `spandrel process exited (${code})` + ) this.reset(err) reject(err) }) diff --git a/src/main/generate/archScan.ts b/src/main/generate/archScan.ts index 9c8f12f..f3847dc 100644 --- a/src/main/generate/archScan.ts +++ b/src/main/generate/archScan.ts @@ -27,7 +27,8 @@ export function readSafetensorsHeader(path: string): SafetensorsHeader | null { const lenBuf = Buffer.allocUnsafe(8) if (readSync(fd, lenBuf, 0, 8, 0) < 8) return null const headerLen = Number(lenBuf.readBigUInt64LE(0)) - if (!Number.isSafeInteger(headerLen) || headerLen <= 0 || headerLen > MAX_HEADER_BYTES) return null + if (!Number.isSafeInteger(headerLen) || headerLen <= 0 || headerLen > MAX_HEADER_BYTES) + return null if (8 + headerLen > size) return null const json = Buffer.allocUnsafe(headerLen) let read = 0 @@ -116,11 +117,13 @@ export function isExcludedNonImage(h: SafetensorsHeader): boolean { // HunyuanVideo (token refiner), FramePack (clean-image embedder), Wan (VACE). if (has('individual_token_refiner') || has('clean_x_embedder') || has('vace_blocks')) return true // Wan-style video DiT: 3D patch-embedding + temporal embedding, no Flux blocks. - if (has('patch_embedding') && (has('time_embedding') || has('temporal')) && !has('double_blocks')) return true + if (has('patch_embedding') && (has('time_embedding') || has('temporal')) && !has('double_blocks')) + return true // Audio / vocoder stacks (LTX and similar bundle these). if (has('vocoder') || has('audio_vae')) return true // Flux-style blocks but no text+image projections = a 3D/other shape DiT. - if ((has('double_blocks') || has('single_blocks')) && !(has('img_in') && has('txt_in'))) return true + if ((has('double_blocks') || has('single_blocks')) && !(has('img_in') && has('txt_in'))) + return true return false } @@ -128,7 +131,9 @@ export function isExcludedNonImage(h: SafetensorsHeader): boolean { * checkpoint), meaning UNETLoader reads only the diffusion weights and the * baked encoders/VAE are ignored — we still supply encoders/VAE separately. */ export function isAllInOne(h: SafetensorsHeader): boolean { - return h.keys.some((k) => k.startsWith('text_encoders.')) && h.keys.some((k) => k.startsWith('vae.')) + return ( + h.keys.some((k) => k.startsWith('text_encoders.')) && h.keys.some((k) => k.startsWith('vae.')) + ) } /** classifyModelFile + the excluded/unrecognized distinction in one header read. diff --git a/src/main/generate/comfy.ts b/src/main/generate/comfy.ts index a5739e5..8835071 100644 --- a/src/main/generate/comfy.ts +++ b/src/main/generate/comfy.ts @@ -76,7 +76,16 @@ export function findComfyLaunch(): { python: string; cwd: string } | null { * they live in a different folder (e.g. a shared models dir) than the code root. * Returns the config path, or null if no model dirs were found. */ -const MODEL_SUBS = ['checkpoints', 'vae', 'loras', 'diffusion_models', 'text_encoders', 'clip', 'unet', 'upscale_models'] +const MODEL_SUBS = [ + 'checkpoints', + 'vae', + 'loras', + 'diffusion_models', + 'text_encoders', + 'clip', + 'unet', + 'upscale_models' +] function writeExtraModelPaths(): string | null { const dirs = new Set() @@ -133,7 +142,14 @@ export async function ensureComfyServer(onStatus?: (s: string) => void): Promise if (!proc) { onStatus?.('Starting ComfyUI (first run of the session)…') const port = await freePort() - const args = ['main.py', '--port', String(port), '--listen', '127.0.0.1', '--disable-auto-launch'] + const args = [ + 'main.py', + '--port', + String(port), + '--listen', + '127.0.0.1', + '--disable-auto-launch' + ] const extraPaths = writeExtraModelPaths() if (extraPaths) args.push('--extra-model-paths-config', extraPaths) procTail = '' @@ -236,7 +252,11 @@ export function openProgressSocket( data?: { prompt_id?: string; value?: number; max?: number } } if (msg.type === 'progress' && msg.data?.prompt_id) - onProgress(String(msg.data.prompt_id), Number(msg.data.value) || 0, Number(msg.data.max) || 0) + onProgress( + String(msg.data.prompt_id), + Number(msg.data.value) || 0, + Number(msg.data.max) || 0 + ) } catch { /* non-JSON */ } @@ -298,7 +318,10 @@ export async function waitForImages( try { const r = await fetch(`${baseUrl}/history/${promptId}`, { signal: AbortSignal.timeout(5000) }) if (r.ok) { - const hist = (await r.json()) as Record }> + const hist = (await r.json()) as Record< + string, + { status?: { status_str?: string }; outputs?: Record } + > const entry = hist[promptId] if (entry) { if (entry.status?.status_str === 'error') @@ -337,7 +360,10 @@ export async function listCheckpoints(baseUrl: string): Promise { signal: AbortSignal.timeout(5000) }) if (!r.ok) return [] - const j = (await r.json()) as Record + const j = (await r.json()) as Record< + string, + { input?: { required?: { ckpt_name?: unknown[][] } } } + > const names = j.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] return Array.isArray(names) ? (names as string[]) : [] } catch { diff --git a/src/main/generate/companions.ts b/src/main/generate/companions.ts index d19233a..fdd0baa 100644 --- a/src/main/generate/companions.ts +++ b/src/main/generate/companions.ts @@ -72,7 +72,8 @@ export async function downloadCompanions( // overwriting a good file. const sha256 = expectedHash(urls[0], f.sha256) const result = await downloadFile(urls, dest, { - onPct: (pct) => onProgress({ index: i + 1, total, label: f.label, filename: f.filename, pct }), + onPct: (pct) => + onProgress({ index: i + 1, total, label: f.label, filename: f.filename, pct }), minBytes: minBytes || undefined, sha256 }) diff --git a/src/main/generate/models.ts b/src/main/generate/models.ts index 297c62b..3580ffe 100644 --- a/src/main/generate/models.ts +++ b/src/main/generate/models.ts @@ -145,7 +145,13 @@ export function scanGenerationModels(): GenModelScan { // --- Single-file checkpoints (CheckpointLoaderSimple) -------------------- for (const { rel, abs, base } of walkModels(['checkpoints'], /\.(safetensors|ckpt|sft)$/i)) { - const common = { name: rel, label: label(rel), group: '', source: 'checkpoint' as const, baseDir: base } + const common = { + name: rel, + label: label(rel), + group: '', + source: 'checkpoint' as const, + baseDir: base + } // .ckpt is a pickle we can't header-inspect; trust CheckpointLoaderSimple with // the SDXL graph (the overwhelmingly common case for a .ckpt checkpoint). const header = /\.(safetensors|sft)$/i.test(rel) ? readSafetensorsHeader(abs) : null @@ -175,7 +181,10 @@ export function scanGenerationModels(): GenModelScan { } // --- Bare diffusion models (UNETLoader + separate encoders/VAE) ---------- - for (const { rel, abs, base } of walkModels(['diffusion_models', 'unet'], /\.(safetensors|sft|gguf)$/i)) { + for (const { rel, abs, base } of walkModels( + ['diffusion_models', 'unet'], + /\.(safetensors|sft|gguf)$/i + )) { // GGUF is the quantized container (a 24 GB Flux at ~7 GB, which is how it // fits a smaller card). Its tensor names are the same ones the safetensors // build uses, so reading its header lets the ORDINARY classifier identify @@ -267,7 +276,10 @@ export function scanGenerationModels(): GenModelScan { } models.sort( - (a, b) => Number(b.runnable) - Number(a.runnable) || a.group.localeCompare(b.group) || a.label.localeCompare(b.label) + (a, b) => + Number(b.runnable) - Number(a.runnable) || + a.group.localeCompare(b.group) || + a.label.localeCompare(b.label) ) return { models, excluded, unrecognized, gguf } } @@ -325,7 +337,8 @@ export function primaryModelsDir(preferBase?: string): string | null { if (preferBase && existsSync(preferBase)) return preferBase const bases = comfyModelsBases() for (const base of bases) - if (existsSync(join(base, 'checkpoints')) || existsSync(join(base, 'diffusion_models'))) return base + if (existsSync(join(base, 'checkpoints')) || existsSync(join(base, 'diffusion_models'))) + return base for (const base of bases) if (existsSync(base)) return base return null } diff --git a/src/main/generate/preflight.ts b/src/main/generate/preflight.ts index 5b37c53..b3c4cdb 100644 --- a/src/main/generate/preflight.ts +++ b/src/main/generate/preflight.ts @@ -36,7 +36,9 @@ function norm(s: string): string { /** Find the exact string ComfyUI reports for a file we resolved from disk. */ function resolveName(accepted: string[], wanted: string): string | null { const w = norm(wanted) - return accepted.find((a) => norm(a) === w) ?? accepted.find((a) => norm(a).endsWith('/' + w)) ?? null + return ( + accepted.find((a) => norm(a) === w) ?? accepted.find((a) => norm(a).endsWith('/' + w)) ?? null + ) } // Which CLIP loader + "type" enum value, and which non-loader nodes, each arch @@ -71,7 +73,8 @@ function missingNodeError(arch: GenArch, node: string): Error { ) const note = registryEntry(arch)?.requires?.minComfyNote return new Error( - note ?? `Your ComfyUI is missing the "${node}" node — update ComfyUI to the latest version and try again.` + note ?? + `Your ComfyUI is missing the "${node}" node — update ComfyUI to the latest version and try again.` ) } diff --git a/src/main/generate/workflow.ts b/src/main/generate/workflow.ts index 83bf604..82d5065 100644 --- a/src/main/generate/workflow.ts +++ b/src/main/generate/workflow.ts @@ -71,7 +71,12 @@ function templateVars( * to the user than our refusal to attempt anything. */ function genericWorkflow(gm: GenModel): WorkflowSpec { - const sampler = (model: [string, number], pos: string, neg: string, latent: string): WorkflowNode => ({ + const sampler = ( + model: [string, number], + pos: string, + neg: string, + latent: string + ): WorkflowNode => ({ class_type: 'KSampler', inputs: { seed: '${seed}', @@ -164,7 +169,8 @@ export function buildWorkflow( gm.source === 'checkpoint' ? entry?.checkpointWorkflow : gm.source === 'gguf' - ? (entry?.ggufWorkflow ?? (entry?.workflow ? deriveGgufWorkflow(entry.workflow) : undefined)) + ? (entry?.ggufWorkflow ?? + (entry?.workflow ? deriveGgufWorkflow(entry.workflow) : undefined)) : entry?.workflow // "Try anyway": no known workflow, so send the generic one and let ComfyUI // give its own verdict. The user learns something either way — which beats a diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6d48ae7..a007bb9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -107,7 +107,11 @@ export function registerIpc(win: BrowserWindow): JobQueue { // so the picker reflects what is installed instead of a build-time literal. ipcMain.handle('upscale:models', () => { ensureUserNcnnDir() - return listNcnnModels().map((m) => ({ value: `esrgan:${m.name}`, label: m.label, user: m.user })) + return listNcnnModels().map((m) => ({ + value: `esrgan:${m.name}`, + label: m.label, + user: m.user + })) }) ipcMain.handle('upscale:open-models-folder', async () => { ensureUserNcnnDir() @@ -281,7 +285,8 @@ export function registerIpc(win: BrowserWindow): JobQueue { // now feeds generate, upscale and companion discovery alike. ipcMain.handle('comfy:set-folder', (_e, folder: string) => { try { - if (!folder || !existsSync(folder)) return { ok: false, error: 'That folder no longer exists.' } + if (!folder || !existsSync(folder)) + return { ok: false, error: 'That folder no longer exists.' } writeComfyStore({ folder, models: readComfyStore()?.models ?? [] }) clearComfyPythonCache() return { ok: true } @@ -317,7 +322,9 @@ export function registerIpc(win: BrowserWindow): JobQueue { }) ipcMain.handle('generate:download', async (_e, id: string, model: string) => { try { - await downloadCompanions(model, (p) => win.webContents.send('generate:download-progress', { id, ...p })) + await downloadCompanions(model, (p) => + win.webContents.send('generate:download-progress', { id, ...p }) + ) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } diff --git a/src/main/net/download.ts b/src/main/net/download.ts index 452f481..c364d65 100644 --- a/src/main/net/download.ts +++ b/src/main/net/download.ts @@ -216,11 +216,12 @@ async function downloadOne( rmSync(part, { force: true }) throw new Error(msg) } - if (total && got < total) - fail(`Download incomplete: got ${got} of ${total} bytes from ${url}`) + if (total && got < total) fail(`Download incomplete: got ${got} of ${total} bytes from ${url}`) // Final size sanity check even when Content-Length was absent. if (opts.minBytes && got < opts.minBytes) - fail(`Download looks wrong: got only ${got} bytes, expected at least ${opts.minBytes}. ${url}`) + fail( + `Download looks wrong: got only ${got} bytes, expected at least ${opts.minBytes}. ${url}` + ) if (opts.sha256 && digest && digest.toLowerCase() !== opts.sha256.toLowerCase()) fail( `This download does not match its expected checksum and was discarded. Expected ${opts.sha256.slice(0, 16)}…, got ${digest.slice(0, 16)}… (${url})` diff --git a/src/main/pid/install.ts b/src/main/pid/install.ts index fc98ed7..d220790 100644 --- a/src/main/pid/install.ts +++ b/src/main/pid/install.ts @@ -227,7 +227,8 @@ async function ensureEnv(onProgress: InstallProgress): Promise { const depRes = await run(uv, ['pip', 'install', '--python', python, '-e', '.'], { cwd: pidRepoDir() }) - if (depRes.code !== 0) throw new Error(`PiD dependency install failed: ${depRes.stderr.slice(-400)}`) + if (depRes.code !== 0) + throw new Error(`PiD dependency install failed: ${depRes.stderr.slice(-400)}`) writeFileSync(marker, '') } @@ -311,10 +312,7 @@ function withInstallLock(fn: () => Promise): Promise { */ const ENV_APPROX_BYTES = 3_000_000_000 -export function checkDiskSpace( - needBytes: number, - dir?: string -): { ok: boolean; reason?: string } { +export function checkDiskSpace(needBytes: number, dir?: string): { ok: boolean; reason?: string } { if (!(needBytes > 0)) return { ok: true } try { // statfs is Node 18.15+; absent or failing, we simply don't block — refusing diff --git a/src/main/registry/channel.ts b/src/main/registry/channel.ts index 18e809b..4186af9 100644 --- a/src/main/registry/channel.ts +++ b/src/main/registry/channel.ts @@ -101,7 +101,12 @@ export function verifyPack(pack: SignedPack, publicKeyB64: string): boolean { format: 'der', type: 'spki' }) - return verify(null, Buffer.from(pack.payload, 'utf-8'), key, Buffer.from(pack.signature, 'base64')) + return verify( + null, + Buffer.from(pack.payload, 'utf-8'), + key, + Buffer.from(pack.signature, 'base64') + ) } catch { return false } diff --git a/src/main/registry/load.ts b/src/main/registry/load.ts index 52ffcf6..1e72a12 100644 --- a/src/main/registry/load.ts +++ b/src/main/registry/load.ts @@ -91,7 +91,9 @@ function readLayer(layer: Layer, warnings: string[]): RegistryFile[] { try { parsed = JSON.parse(readFileSync(path, 'utf-8')) as RegistryFile } catch (e) { - warnings.push(`${layer}/${f}: not valid JSON (${e instanceof Error ? e.message : e}) — skipped`) + warnings.push( + `${layer}/${f}: not valid JSON (${e instanceof Error ? e.message : e}) — skipped` + ) continue } if (!parsed || !Array.isArray(parsed.entries)) { diff --git a/src/main/registry/userLayer.ts b/src/main/registry/userLayer.ts index 270bd41..cf64d42 100644 --- a/src/main/registry/userLayer.ts +++ b/src/main/registry/userLayer.ts @@ -188,7 +188,9 @@ export function entryFromApiWorkflow( hasGuidance: guidance != null }, requires: { - nodes: [...nodes].filter((n) => !/^(UNETLoader|VAELoader|CLIPLoader|DualCLIPLoader)$/.test(n)), + nodes: [...nodes].filter( + (n) => !/^(UNETLoader|VAELoader|CLIPLoader|DualCLIPLoader)$/.test(n) + ), ...(clipLoader ? { clipLoader } : {}) }, [sawGguf ? 'ggufWorkflow' : sawCheckpoint && !sawUnet ? 'checkpointWorkflow' : 'workflow']: { @@ -209,7 +211,10 @@ export function importRegistryJson(path: string, text: string): ImportResult { try { parsed = JSON.parse(text) } catch (e) { - return { ok: false, error: `That file is not valid JSON (${e instanceof Error ? e.message : e}).` } + return { + ok: false, + error: `That file is not valid JSON (${e instanceof Error ? e.message : e}).` + } } const stem = basename(path).replace(/\.json$/i, '') @@ -228,9 +233,14 @@ export function importRegistryJson(path: string, text: string): ImportResult { const id = stem.toLowerCase().replace(/[^a-z0-9._-]+/g, '-') || 'my-model' const { entry, notes } = entryFromApiWorkflow(parsed, id, stem) const wf = entry.workflow ?? entry.checkpointWorkflow - const unknown = wf ? workflowPlaceholders(wf).filter((p) => !KNOWN_PLACEHOLDERS.includes(p)) : [] + const unknown = wf + ? workflowPlaceholders(wf).filter((p) => !KNOWN_PLACEHOLDERS.includes(p)) + : [] if (unknown.length) - return { ok: false, error: `That workflow uses placeholders Filesmith can't fill: ${unknown.join(', ')}` } + return { + ok: false, + error: `That workflow uses placeholders Filesmith can't fill: ${unknown.join(', ')}` + } const res = saveUserPack([entry], id) return res.ok ? { ...res, notes } : res } diff --git a/src/main/toolResolver.ts b/src/main/toolResolver.ts index 3a9aba6..30c7ac6 100644 --- a/src/main/toolResolver.ts +++ b/src/main/toolResolver.ts @@ -196,7 +196,8 @@ export function resolveRembg(): RembgCommand | null { // (b) uv itself, which fetches Python + rembg on demand const uv = resolveUv() - if (uv) return { cmd: uv, prefix: ['tool', 'run', '--python', '3.11', '--from', REMBG_SPEC, 'rembg'] } + if (uv) + return { cmd: uv, prefix: ['tool', 'run', '--python', '3.11', '--from', REMBG_SPEC, 'rembg'] } return null } @@ -256,7 +257,7 @@ const MISSING_TOOL_HELP: Record = { 'realesrgan-ncnn-vulkan': 'AI upscaling needs Real-ESRGAN, which is missing from this installation. Reinstall Filesmith.', soffice: - "Document conversion needs LibreOffice, which is missing from this installation. Reinstall Filesmith, or install LibreOffice (winget install TheDocumentFoundation.LibreOffice) and restart." + 'Document conversion needs LibreOffice, which is missing from this installation. Reinstall Filesmith, or install LibreOffice (winget install TheDocumentFoundation.LibreOffice) and restart.' } /** A user-facing message for a `ToolMissingError`'s command. */ diff --git a/src/main/tools/compress.ts b/src/main/tools/compress.ts index 1ddb18e..9f316e7 100644 --- a/src/main/tools/compress.ts +++ b/src/main/tools/compress.ts @@ -31,7 +31,9 @@ export function buildCompressArgs(input: string, outDir: string, quality: number * single-frame target reads only `input[0]` (so a multi-frame source doesn't * split); a multi-frame target keeps every frame (animated GIF -> animated WebP). */ export function buildMagickCompressArgs(input: string, output: string, quality: number): string[] { - const src = MULTIFRAME_TARGETS.includes(normalizeExt(extname(output))) ? input : magickFrame(input) + const src = MULTIFRAME_TARGETS.includes(normalizeExt(extname(output))) + ? input + : magickFrame(input) return [src, '-quality', String(quality), output] } diff --git a/src/main/tools/pdf.ts b/src/main/tools/pdf.ts index ba92b4b..009a7fc 100644 --- a/src/main/tools/pdf.ts +++ b/src/main/tools/pdf.ts @@ -8,12 +8,7 @@ import type { PdfLevel } from '@shared/compress' // not here — a second mutool-only "compress" op silently produced far worse // results for the same filename, so it was removed. export type PdfOp = - | 'extract-text' - | 'pages-to-images' - | 'merge' - | 'split-range' - | 'split-pages' - | 'extract-images' + 'extract-text' | 'pages-to-images' | 'merge' | 'split-range' | 'split-pages' | 'extract-images' /** `mutool draw -F txt -o ` — extract the text layer. */ export function buildPdfTextArgs(input: string, output: string): string[] { diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 9d4af7f..d199bd9 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -12,13 +12,7 @@ import { } from 'fs' import { tmpdir } from 'os' import type { FileInfo, ToolId, ToolTarget } from '@shared/types' -import type { - AudioCodec, - ImageFormat, - PdfLevel, - UpscaleModel, - VideoCodec -} from '@shared/compress' +import type { AudioCodec, ImageFormat, PdfLevel, UpscaleModel, VideoCodec } from '@shared/compress' import { resolveGhostscript, resolveRealesrgan, @@ -418,7 +412,10 @@ const pdfTool: ToolModule = { if (op === 'pages-to-images') { const dpi = Math.max(36, Math.min(600, Number(options.dpi ?? 150))) - const dir = uniqueOutDir(dirname(file.path), basename(file.path, extname(file.path)) + ' (pages)') + const dir = uniqueOutDir( + dirname(file.path), + basename(file.path, extname(file.path)) + ' (pages)' + ) mkdirSync(dir, { recursive: true }) // mutool draw's -o is a printf pattern (the `page-%d.png` is the point), // so a `%` in the folder name — inherited from the source name — would be @@ -469,7 +466,8 @@ const pdfTool: ToolModule = { const resizeTool: ToolModule = { async run(file, options, ctx) { const spec = buildResizeSpec(options) - if (!isValidResizeSpec(spec)) throw new Error('Enter a width, height, or percentage to resize by') + if (!isValidResizeSpec(spec)) + throw new Error('Enter a width, height, or percentage to resize by') const output = reserveOutPath(file.path, file.ext, 'resized') ctx.onProgress(undefined, `Resizing ${spec}…`) const animated = normalizeExt(file.ext) === '.gif' @@ -540,7 +538,9 @@ const compressTool: ToolModule = { ctx, 'ffmpeg', true, - ffmpegProgress(duration, (pct, eta) => ctx.onProgress(pct, `Compressing video (${codec})…`, eta)) + ffmpegProgress(duration, (pct, eta) => + ctx.onProgress(pct, `Compressing video (${codec})…`, eta) + ) ) } @@ -559,7 +559,9 @@ const compressTool: ToolModule = { ctx, 'ffmpeg', true, - ffmpegProgress(duration, (pct, eta) => ctx.onProgress(pct, `Compressing audio (${bitrate}k)…`, eta)) + ffmpegProgress(duration, (pct, eta) => + ctx.onProgress(pct, `Compressing audio (${bitrate}k)…`, eta) + ) ) } @@ -689,11 +691,7 @@ async function restoreAlpha( * warm. A missing install throws a clear error here as a backstop; the UI's own * pid:status check (not this error) is what drives the one-click download prompt. */ -async function upscaleWithPid( - file: FileInfo, - factor: number, - ctx: ToolContext -): Promise { +async function upscaleWithPid(file: FileInfo, factor: number, ctx: ToolContext): Promise { if (!pidInstalled('flux')) throw new Error('PiD is not installed. Pick PiD in the options panel and click Download first.') const tmp = mkdtempSync(join(tmpdir(), 'filesmith-pid-')) @@ -795,7 +793,9 @@ async function upscaleWithComfy( if (code !== 0 || !existsSync(src)) throw new Error(describeToolError(stderr, 'magick', code)) } output = reserveOutPath(file.path, '.png', 'upscaled') - const label = background ? `Upscaling with ${model.name} (background)…` : `Upscaling with ${model.name}…` + const label = background + ? `Upscaling with ${model.name} (background)…` + : `Upscaling with ${model.name}…` ctx.onProgress(0, label) // Background: smaller tiles + a VRAM cap + inter-tile pacing so the GPU stays // free for other apps, at the cost of speed. @@ -806,7 +806,8 @@ async function upscaleWithComfy( onProgress: (pct) => ctx.onProgress(pct, label), signal: ctx.signal }) - if (!existsSync(out) || statSync(out).size === 0) throw new Error('The upscaler produced no output') + if (!existsSync(out) || statSync(out).size === 0) + throw new Error('The upscaler produced no output') await restoreAlpha(file.path, out, ctx, tmp) return out } catch (e) { @@ -840,8 +841,7 @@ const upscaleTool: ToolModule = { // can't be paced, so background mode doesn't apply to it.) if (model === 'pid') return upscaleWithPid(file, factor, ctx) // The ComfyUI category with no specific model chosen yet. - if (model === 'comfy') - throw new Error('Pick one of your ComfyUI models from the list first.') + if (model === 'comfy') throw new Error('Pick one of your ComfyUI models from the list first.') // A user-imported ComfyUI model, keyed by its absolute path. if (model.startsWith('comfy:')) return upscaleWithComfy(file, model.slice(6), factor, background, ctx) @@ -855,11 +855,9 @@ const upscaleTool: ToolModule = { let src = file.path if (needsPreConvert(file.ext)) { src = join(tmp, 'src.png') - const { code, stderr } = await run( - resolveTool('magick'), - [magickFrame(file.path), src], - { signal: ctx.signal } - ) + const { code, stderr } = await run(resolveTool('magick'), [magickFrame(file.path), src], { + signal: ctx.signal + }) if (code !== 0 || !existsSync(src)) throw new Error(describeToolError(stderr, 'magick', code)) } diff --git a/src/main/tools/upscale.ts b/src/main/tools/upscale.ts index 6aa29a0..8891e9c 100644 --- a/src/main/tools/upscale.ts +++ b/src/main/tools/upscale.ts @@ -39,18 +39,7 @@ export interface UpscaleOpts { */ export function buildUpscaleArgs(input: string, output: string, o: UpscaleOpts): string[] { const factor = [2, 3, 4].includes(o.factor) ? o.factor : 4 - const args = [ - '-i', - input, - '-o', - output, - '-n', - o.model, - '-s', - String(factor), - '-f', - 'png' - ] + const args = ['-i', input, '-o', output, '-n', o.model, '-s', String(factor), '-f', 'png'] if (o.tile && o.tile > 0) args.push('-t', String(o.tile)) return args } @@ -60,9 +49,7 @@ export function buildUpscaleArgs(input: string, output: string, o: UpscaleOpts): * those into 0-99 for the UI; a 4x upscale of a big photo takes seconds to * minutes, so a moving bar matters. */ -export function upscaleProgress( - onPercent: (pct: number) => void -): (chunk: string) => void { +export function upscaleProgress(onPercent: (pct: number) => void): (chunk: string) => void { return (chunk: string) => { let m: RegExpExecArray | null = null const re = /(\d+(?:\.\d+)?)%/g diff --git a/src/main/uv.ts b/src/main/uv.ts index 2fd8292..04b0c7c 100644 --- a/src/main/uv.ts +++ b/src/main/uv.ts @@ -45,8 +45,7 @@ export function uvCandidates(): string[] { /* no Electron */ } out.push(...wingetCandidates()) - if (process.env.USERPROFILE) - out.push(join(process.env.USERPROFILE, '.local', 'bin', 'uv' + EXE)) + if (process.env.USERPROFILE) out.push(join(process.env.USERPROFILE, '.local', 'bin', 'uv' + EXE)) if (process.env.LOCALAPPDATA) out.push(join(process.env.LOCALAPPDATA, 'Programs', 'uv', 'uv' + EXE)) return out diff --git a/src/preload/index.ts b/src/preload/index.ts index e778459..fb55923 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -61,8 +61,7 @@ const api = { return () => ipcRenderer.removeListener('preview:update', listener) }, /** Push a fresh file list to an open preview window (keeps its position). */ - updatePreviewList: (files: PreviewItem[]): void => - ipcRenderer.send('preview:update-list', files), + updatePreviewList: (files: PreviewItem[]): void => ipcRenderer.send('preview:update-list', files), /** The preview window listens for live list changes. */ onPreviewList: (cb: (files: PreviewItem[]) => void): (() => void) => { const listener = (_: unknown, files: PreviewItem[]): void => cb(files) @@ -79,15 +78,16 @@ const api = { upscaleModels: (): Promise<{ value: string; label: string; user: boolean }[]> => ipcRenderer.invoke('upscale:models'), /** Open the folder where the user can drop their own Real-ESRGAN models. */ - upscaleOpenModelsFolder: (): Promise => - ipcRenderer.invoke('upscale:open-models-folder'), + upscaleOpenModelsFolder: (): Promise => ipcRenderer.invoke('upscale:open-models-folder'), targets: (tool: ToolId, file: FileInfo): Promise => ipcRenderer.invoke('tool:targets', tool, file), // PiD Advanced (NVIDIA) upscaler tier /** NVIDIA GPU presence + whether the PiD engine is installed. */ - pidStatus: (): Promise<{ nvidia: { name: string; vramMb: number | null } | null; installed: boolean }> => - ipcRenderer.invoke('pid:status'), + pidStatus: (): Promise<{ + nvidia: { name: string; vramMb: number | null } | null + installed: boolean + }> => ipcRenderer.invoke('pid:status'), /** Delete the AI install (repair path for a corrupt/poisoned one). */ pidRemove: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('pid:remove'), /** True while an install is running anywhere in the app. */ @@ -112,8 +112,7 @@ const api = { models: ComfyModel[] }> => ipcRenderer.invoke('comfy:status'), /** Build the shared torch env + spandrel (no PiD weights). */ - comfyInstall: (): Promise<{ ok: boolean; error?: string }> => - ipcRenderer.invoke('comfy:install'), + comfyInstall: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('comfy:install'), /** Open a folder picker; resolves to the chosen path or null. */ comfyPickFolder: (): Promise => ipcRenderer.invoke('comfy:pick-folder'), // The user's own model registry (add a model with no app release). @@ -164,11 +163,25 @@ const api = { ipcRenderer.invoke('generate:download', id, model), /** Progress of an in-flight companion download. */ onGenerateDownloadProgress: ( - cb: (p: { id: string; index: number; total: number; label: string; filename: string; pct: number | null }) => void + cb: (p: { + id: string + index: number + total: number + label: string + filename: string + pct: number | null + }) => void ): (() => void) => { const listener = ( _: unknown, - p: { id: string; index: number; total: number; label: string; filename: string; pct: number | null } + p: { + id: string + index: number + total: number + label: string + filename: string + pct: number | null + } ): void => cb(p) ipcRenderer.on('generate:download-progress', listener) return () => ipcRenderer.removeListener('generate:download-progress', listener) @@ -185,9 +198,7 @@ const api = { return () => ipcRenderer.removeListener('generate:progress', listener) }, /** An image in the batch finished (index + saved path). */ - onGenerateImage: ( - cb: (p: { id: string; index: number; path: string }) => void - ): (() => void) => { + onGenerateImage: (cb: (p: { id: string; index: number; path: string }) => void): (() => void) => { const listener = (_: unknown, p: { id: string; index: number; path: string }): void => cb(p) ipcRenderer.on('generate:image', listener) return () => ipcRenderer.removeListener('generate:image', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a11875a..1af2e7e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,7 +8,13 @@ import { type MouseEvent } from 'react' import type { FileInfo, FileKind, PreviewItem } from '@shared/types' -import { canCompress, familyFormats, isSameFormat, normalizeExt, toolForKind } from '@shared/convert' +import { + canCompress, + familyFormats, + isSameFormat, + normalizeExt, + toolForKind +} from '@shared/convert' import { estimatedPngBytes, formatBytes, @@ -34,7 +40,13 @@ import { type QueueItem, type SelectMode } from './state' -import { acceptsKind, categoryOf, findOperation, operationsFor, type CategoryId } from '@shared/catalog' +import { + acceptsKind, + categoryOf, + findOperation, + operationsFor, + type CategoryId +} from '@shared/catalog' import { TopBar } from './components/TopBar' import { CategoryRail } from './components/CategoryRail' import { OperationTitle } from './components/OperationTitle' @@ -104,15 +116,13 @@ function toPreviewFiles( outThumbs: Record ): PreviewItem[] { if (side === 'input') { - return items - .filter(inInput) - .map((it) => ({ - path: it.file.path, - name: it.file.name, - kind: it.file.kind, - size: it.file.size, - thumb: it.thumb - })) + return items.filter(inInput).map((it) => ({ + path: it.file.path, + name: it.file.name, + kind: it.file.kind, + size: it.file.size, + thumb: it.thumb + })) } return items.filter(inOutput).map((it) => { const out = it.outputPath as string @@ -189,7 +199,10 @@ export default function App(): JSX.Element { // a saved session with the empty initial state during the async load. useEffect(() => { if (!hydrated.current) return - const t = setTimeout(() => window.filesmith.sessionSave(sessionSnapshot(state, genResults)), 400) + const t = setTimeout( + () => window.filesmith.sessionSave(sessionSnapshot(state, genResults)), + 400 + ) return () => clearTimeout(t) }, [state, genResults]) @@ -202,7 +215,9 @@ export default function App(): JSX.Element { useEffect(() => { const flush = (): void => { if (hydrated.current) - window.filesmith.sessionSave(sessionSnapshot(latest.current.state, latest.current.genResults)) + window.filesmith.sessionSave( + sessionSnapshot(latest.current.state, latest.current.genResults) + ) } window.addEventListener('beforeunload', flush) return () => window.removeEventListener('beforeunload', flush) @@ -274,9 +289,7 @@ export default function App(): JSX.Element { // A source is runnable when idle (incl. already-done, so it can run again); a // result is always runnable — running it promotes its output back to input. const canRun = (i: QueueItem): boolean => - i.isResult - ? !!i.outputPath - : ['ready', 'failed', 'canceled', 'done'].includes(i.status) + i.isResult ? !!i.outputPath : ['ready', 'failed', 'canceled', 'done'].includes(i.status) // The files a run would actually process: selected, runnable, tool-compatible, // and (for convert) not already the target format. @@ -398,7 +411,11 @@ export default function App(): JSX.Element { y, items: [ { label: 'Preview', icon: 'eye', onClick: () => openPreview('output', item) }, - { label: 'Reveal in Explorer', icon: 'folder', onClick: () => window.filesmith.reveal(out) }, + { + label: 'Reveal in Explorer', + icon: 'folder', + onClick: () => window.filesmith.reveal(out) + }, { sep: true }, { label: n > 1 ? `Delete ${n} files` : 'Delete file', @@ -491,7 +508,11 @@ export default function App(): JSX.Element { icon: 'upload', onClick: () => window.filesmith.openFile(path) }, - { label: 'Reveal in Explorer', icon: 'folder', onClick: () => window.filesmith.reveal(path) }, + { + label: 'Reveal in Explorer', + icon: 'folder', + onClick: () => window.filesmith.reveal(path) + }, { sep: true }, { label: 'Delete file', @@ -512,7 +533,11 @@ export default function App(): JSX.Element { const id = `gen-${(genIdRef.current += 1)}` genActiveId.current = id const count = Math.max(1, Math.min(8, Number(opts.count ?? 1))) - setGenRun({ running: true, slots: Array.from({ length: count }, () => ({ pct: 0 })), message: 'Starting…' }) + setGenRun({ + running: true, + slots: Array.from({ length: count }, () => ({ pct: 0 })), + message: 'Starting…' + }) const finished: (string | undefined)[] = [] const unsubP = window.filesmith.onGenerateProgress((p) => { if (p.id !== id) return @@ -652,9 +677,15 @@ export default function App(): JSX.Element { // Upscale reuses the same probe cache to preview the (much larger) result size. const probePaths = tool === 'compress' - ? runList.map(effectiveFile).filter((f) => f.kind === 'video').map((f) => f.path) + ? runList + .map(effectiveFile) + .filter((f) => f.kind === 'video') + .map((f) => f.path) : tool === 'upscale' || tool === 'resize' - ? runList.map(effectiveFile).filter((f) => f.kind === 'image').map((f) => f.path) + ? runList + .map(effectiveFile) + .filter((f) => f.kind === 'image') + .map((f) => f.path) : [] const compressVideoPaths = tool === 'compress' ? probePaths : [] useEffect(() => { @@ -740,138 +771,136 @@ export default function App(): JSX.Element { /> <> -
{ - e.preventDefault() - setDragging(true) - }} - onDragLeave={(e) => { - if (e.currentTarget === e.target) setDragging(false) - }} - onDrop={onDrop} - > - {/* The rail names the file type; the sidebar switcher names (and +
{ + e.preventDefault() + setDragging(true) + }} + onDragLeave={(e) => { + if (e.currentTarget === e.target) setDragging(false) + }} + onDrop={onDrop} + > + {/* The rail names the file type; the sidebar switcher names (and colours) the operation. This heading is just a heading. */} - - {op.tool === 'generate' ? ( - <> - dispatch({ type: 'setOption', key: 'prompt', value: v })} - /> - {genRun.running && ( -
- -
- )} - {/* Startup indicator only ("Starting ComfyUI…" / "Connecting…"). + + {op.tool === 'generate' ? ( + <> + dispatch({ type: 'setOption', key: 'prompt', value: v })} + /> + {genRun.running && ( +
+ +
+ )} + {/* Startup indicator only ("Starting ComfyUI…" / "Connecting…"). Once images are sampling, the per-tile bars carry it, so the redundant "Generating X of Y" line is dropped. */} - {genRun.running && - genRun.message && - !genRun.message.startsWith('Generating') && ( -
-
{genRun.message}
-
-
-
-
- )} - {(genRun.running || genResults.length > 0) && ( -
- {/* This run's tiles first (each fills as it finishes)… */} - {genRun.running && - genRun.slots.map((slot, i) => - slot.path ? ( - - ) : ( -
-
-
-
- {slot.pct > 0 ? `${slot.pct}%` : 'Generating…'} -
-
-
-
+ {genRun.running && genRun.message && !genRun.message.startsWith('Generating') && ( +
+
{genRun.message}
+
+
+
+
+ )} + {(genRun.running || genResults.length > 0) && ( +
+ {/* This run's tiles first (each fills as it finishes)… */} + {genRun.running && + genRun.slots.map((slot, i) => + slot.path ? ( + + ) : ( +
+
+
+
+ {slot.pct > 0 ? `${slot.pct}%` : 'Generating…'} +
+
+
- ) - )} - {/* …then everything generated earlier stays visible. */} - {genResults.map((path) => ( - - ))} -
- )} - - ) : ( - <> - void browse()} - /> - - - )} -
- - dispatch({ type: 'setOperation', operation: id })} - options={curOptions} - activeKind={activeKind} - runKind={runKind} - fallbackKind={category.kinds[0]} - videoOutputs={videoOutputs} - upscaleOutputs={upscaleOutputs} - resizeOutputs={resizeOutputs} - sourceExt={sourceExt} - srcExts={srcExts} - runCount={runCount} - onSet={(k, v) => dispatch({ type: 'setOption', key: k, value: v })} - onRun={() => void run()} - /> + + ) + )} + {/* …then everything generated earlier stays visible. */} + {genResults.map((path) => ( + + ))} + + )} + + ) : ( + <> + void browse()} + /> + + + )} +
+ + dispatch({ type: 'setOperation', operation: id })} + options={curOptions} + activeKind={activeKind} + runKind={runKind} + fallbackKind={category.kinds[0]} + videoOutputs={videoOutputs} + upscaleOutputs={upscaleOutputs} + resizeOutputs={resizeOutputs} + sourceExt={sourceExt} + srcExts={srcExts} + runCount={runCount} + onSet={(k, v) => dispatch({ type: 'setOption', key: k, value: v })} + onRun={() => void run()} + /> setMenu(null)} /> diff --git a/src/renderer/src/components/CategoryRail.tsx b/src/renderer/src/components/CategoryRail.tsx index 3875b96..b126d40 100644 --- a/src/renderer/src/components/CategoryRail.tsx +++ b/src/renderer/src/components/CategoryRail.tsx @@ -126,7 +126,9 @@ export function CategoryRail({ // The slot the grabbed row currently hovers over. const target = - drag == null ? null : clamp(drag.index + Math.round(drag.offset / drag.pitch), 0, rows.length - 1) + drag == null + ? null + : clamp(drag.index + Math.round(drag.offset / drag.pitch), 0, rows.length - 1) /** Vertical shift for the row at `i` while a drag is in progress. */ function shiftFor(i: number): { ty: number; lifted: boolean; animate: boolean } { @@ -231,7 +233,9 @@ export function CategoryRail({ {c.label} {n > 0 && ( - + {n} )} diff --git a/src/renderer/src/components/Icon.tsx b/src/renderer/src/components/Icon.tsx index c331eb8..7fd69b4 100644 --- a/src/renderer/src/components/Icon.tsx +++ b/src/renderer/src/components/Icon.tsx @@ -45,7 +45,9 @@ const PATHS: Record = { ), video: , audio: , - doc: , + doc: ( + + ), text: , 'chevron-down': , edit: , @@ -85,7 +87,9 @@ const PATHS: Record = { ), folder: , - trash: , + trash: ( + + ), dots: ( <> diff --git a/src/renderer/src/components/MediaBar.tsx b/src/renderer/src/components/MediaBar.tsx index e85409f..e15f7eb 100644 --- a/src/renderer/src/components/MediaBar.tsx +++ b/src/renderer/src/components/MediaBar.tsx @@ -29,7 +29,12 @@ function setPlaybackRate(m: HTMLMediaElement, r: number): void { } const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] -const vertical: CSSProperties = { writingMode: 'vertical-lr', direction: 'rtl', width: '20px', height: '90px' } +const vertical: CSSProperties = { + writingMode: 'vertical-lr', + direction: 'rtl', + width: '20px', + height: '90px' +} /** * Custom media controls: a muted track, an indigo (accent) elapsed fill, a @@ -120,7 +125,10 @@ export function MediaBar({ {fmt(cur)} / {fmt(dur)}
-
+
-
+
{SPEEDS.map((s) => (
-
+
- Scale + + Scale + {Number(options.scale ?? 100) === 100 ? 'Original' : `${Number(options.scale)}%`} @@ -289,9 +291,7 @@ function CompressOptions({ Original
- {Number(options.scale ?? 100) < 100 && - videoOutputs && - videoOutputs.length > 0 && ( + {Number(options.scale ?? 100) < 100 && videoOutputs && videoOutputs.length > 0 && (
@@ -454,7 +454,19 @@ function UpscaleOptions({ else if (isComfyPath && !comfyChoices.some((c) => c.value === rawModel)) set('upscaleModel', subDefault) else if (isPid && !showPid) set('upscaleModel', subDefault) - }, [pid, comfy.status, hasNvidia, inAi, isComfyPath, isPid, showPid, rawModel, comfyChoices, subDefault, set]) + }, [ + pid, + comfy.status, + hasNvidia, + inAi, + isComfyPath, + isPid, + showPid, + rawModel, + comfyChoices, + subDefault, + set + ]) return ( <> @@ -772,7 +784,9 @@ function PdfOptions({ Resolution - {Number(options.dpi ?? 150)} DPI + + {Number(options.dpi ?? 150)} DPI +
void }): JSX.Element { +function CompanionDownload({ + model, + onDone +}: { + model: GenModel + onDone: () => void +}): JSX.Element { const [busy, setBusy] = useState(false) const [msg, setMsg] = useState(null) const [pct, setPct] = useState(null) @@ -893,7 +913,8 @@ function CompanionDownload({ model, onDone }: { model: GenModel; onDone: () => v return (

- This model needs {missing.length} file{missing.length === 1 ? '' : 's'} you don't have yet: + This model needs {missing.length} file{missing.length === 1 ? '' : 's'} you don't have + yet:

    {missing.map((m) => ( @@ -1109,7 +1130,9 @@ function GenerateOptions({ {models.length ? ( set('model', v)} /> ) : ( -

    No image models found in your ComfyUI models folder.

    +

    + No image models found in your ComfyUI models folder. +

    )} {selected && !selected.runnable && selected.missing?.length ? (
    @@ -1239,7 +1262,9 @@ function GenerateOptions({ Steps - {Number(options.steps ?? info.steps)} + + {Number(options.steps ?? info.steps)} +
    - {Number(options.cfg ?? 7)} + + {Number(options.cfg ?? 7)} +
- {Number(options.guidance ?? info.guidance)} + + {Number(options.guidance ?? info.guidance)} +
- Seed + + Seed +
@@ -1402,7 +1433,9 @@ export function OptionsPanel({ )} {operation.tool === 'removebg' && } - {operation.tool === 'pdf' && } + {operation.tool === 'pdf' && ( + + )} {operation.tool === 'generate' && }
diff --git a/src/renderer/src/components/PreviewWindow.tsx b/src/renderer/src/components/PreviewWindow.tsx index 6e1a348..4eee7df 100644 --- a/src/renderer/src/components/PreviewWindow.tsx +++ b/src/renderer/src/components/PreviewWindow.tsx @@ -70,7 +70,9 @@ function FallbackCard({ file }: { file: PreviewItem }): JSX.Element { export function PreviewWindow(): JSX.Element | null { const [state, setState] = useState<{ p: PreviewPayload; ver: number } | null>(null) useEffect(() => { - void window.filesmith.getPreviewData().then((p) => setState((s) => ({ p, ver: (s?.ver ?? 0) + 1 }))) + void window.filesmith + .getPreviewData() + .then((p) => setState((s) => ({ p, ver: (s?.ver ?? 0) + 1 }))) // Explicit re-open jumps to a new item (bump ver -> remount). A live list // update just swaps the files and keeps the current position (same ver). const unsubUpdate = window.filesmith.onPreviewUpdate((p) => @@ -88,7 +90,13 @@ export function PreviewWindow(): JSX.Element | null { return } -function PreviewView({ files, start }: { files: PreviewItem[]; start: number }): JSX.Element | null { +function PreviewView({ + files, + start +}: { + files: PreviewItem[] + start: number +}): JSX.Element | null { const [i, setI] = useState(start) const mediaRef = useRef(null) const [mediaEl, setMediaEl] = useState(null) @@ -127,8 +135,7 @@ function PreviewView({ files, start }: { files: PreviewItem[]; start: number }): if (e.key === 'Escape') { if (document.fullscreenElement) return // let the browser exit fullscreen close() - } - else if (e.key === 'ArrowLeft' && many) step(-1) + } else if (e.key === 'ArrowLeft' && many) step(-1) else if (e.key === 'ArrowRight' && many) step(1) else if (e.key === ' ' && mediaRef.current) { e.preventDefault() @@ -184,7 +191,9 @@ function PreviewView({ files, start }: { files: PreviewItem[]; start: number }): setFailed(pdfPath) return } - obj = URL.createObjectURL(new Blob([bytes as Uint8Array], { type: 'application/pdf' })) + obj = URL.createObjectURL( + new Blob([bytes as Uint8Array], { type: 'application/pdf' }) + ) setPdf({ path: pdfPath, src: obj }) }) return () => { diff --git a/src/renderer/src/components/Queue.tsx b/src/renderer/src/components/Queue.tsx index 21f3655..c2382c5 100644 --- a/src/renderer/src/components/Queue.tsx +++ b/src/renderer/src/components/Queue.tsx @@ -205,7 +205,9 @@ function OutputCard({ onMenu(e.clientX, e.clientY) }} className={`group flex cursor-pointer items-center gap-3 rounded-2xl border bg-white p-2.5 shadow-[0_1px_3px_rgba(0,0,0,.05),0_8px_22px_rgba(20,20,40,.05)] transition ${ - selected ? 'border-accent ring-2 ring-accent/60' : 'border-black/[.07] hover:border-black/[.14]' + selected + ? 'border-accent ring-2 ring-accent/60' + : 'border-black/[.07] hover:border-black/[.14]' }`} >
diff --git a/src/renderer/src/lib/markdown.ts b/src/renderer/src/lib/markdown.ts index e1ff3d9..299ac49 100644 --- a/src/renderer/src/lib/markdown.ts +++ b/src/renderer/src/lib/markdown.ts @@ -7,7 +7,11 @@ // the DOM. function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') } /** A URL is "relative" if it has no scheme, protocol, anchor, or root slash. */ @@ -70,9 +74,11 @@ function isSafeUrl(url: string): boolean { /** Resolve `rel` against `baseDir` (forward-slashed), collapsing . and .. */ function joinPath(baseDir: string, rel: string): string { - const parts = (baseDir.replace(/\\/g, '/').replace(/\/+$/, '') + '/' + rel.replace(/\\/g, '/')).split( - '/' - ) + const parts = ( + baseDir.replace(/\\/g, '/').replace(/\/+$/, '') + + '/' + + rel.replace(/\\/g, '/') + ).split('/') const stack: string[] = [] for (const p of parts) { if (p === '' || p === '.') continue @@ -109,7 +115,10 @@ function inline(s: string, baseDir?: string): string { function sanitizeHtml(s: string): string { return s .replace(/<(script|style)[\s\S]*?<\/\1>/gi, '') - .replace(/<\/?(script|style|iframe|object|embed|link|meta|base|form|input|button)\b[^>]*>/gi, '') + .replace( + /<\/?(script|style|iframe|object|embed|link|meta|base|form|input|button)\b[^>]*>/gi, + '' + ) .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') .replace(/(href|src)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1="#"') } @@ -117,8 +126,10 @@ function sanitizeHtml(s: string): string { /** Rewrite relative src="…" in raw HTML to fsmedia:// under baseDir. */ function resolveHtmlSrcs(html: string, baseDir?: string): string { if (!baseDir) return html - return html.replace(/(\ssrc\s*=\s*)("|')([^"']+)\2/gi, (m, pre: string, q: string, url: string) => - isRel(url) ? `${pre}${q}${resolveSrc(url, baseDir)}${q}` : m + return html.replace( + /(\ssrc\s*=\s*)("|')([^"']+)\2/gi, + (m, pre: string, q: string, url: string) => + isRel(url) ? `${pre}${q}${resolveSrc(url, baseDir)}${q}` : m ) } @@ -251,7 +262,8 @@ export function renderMarkdown(src: string, baseDir?: string): string { flushPara() flushList() const buf: string[] = [] - while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, '')) + while (i < lines.length && /^\s*>/.test(lines[i])) + buf.push(lines[i++].replace(/^\s*>\s?/, '')) i-- out.push(`
${fmt(buf.join(' '))}
`) continue diff --git a/src/renderer/src/state.ts b/src/renderer/src/state.ts index b97df50..8bb13b1 100644 --- a/src/renderer/src/state.ts +++ b/src/renderer/src/state.ts @@ -242,8 +242,11 @@ export function parseSession(raw: unknown): { state: AppState; genResults: strin return false } } - const category = validCat(p.category) && findOperation(p.category, p.operation) ? p.category : FIRST_CATEGORY - const operation = findOperation(category, p.operation) ? p.operation : defaultOperation(category) + const category = + validCat(p.category) && findOperation(p.category, p.operation) ? p.category : FIRST_CATEGORY + const operation = findOperation(category, p.operation) + ? p.operation + : defaultOperation(category) // Drop queues for categories no longer in the catalog (a removed/renamed tool). const queues: AppState['queues'] = {} for (const [cat, q] of Object.entries(p.queues ?? {})) { @@ -260,7 +263,8 @@ export function parseSession(raw: unknown): { state: AppState; genResults: strin const idx = k.indexOf(':') const c = idx >= 0 ? k.slice(0, idx) : '' const o = idx >= 0 ? k.slice(idx + 1) : '' - if (validCat(c) && findOperation(c as CategoryId, o)) options[k as WorkspaceKey] = v as JobOptions + if (validCat(c) && findOperation(c as CategoryId, o)) + options[k as WorkspaceKey] = v as JobOptions } const key = workspaceKey(category, operation) if (!options[key]) options[key] = defaultOptionsFor(category, operation) @@ -269,7 +273,12 @@ export function parseSession(raw: unknown): { state: AppState; genResults: strin for (const [c, o] of Object.entries(p.lastOperation ?? {})) if (validCat(c) && o && findOperation(c as CategoryId, o)) lastOperation[c as CategoryId] = o const state: AppState = { category, operation, lastOperation, queues, options } - return { state, genResults: Array.isArray(p.genResults) ? p.genResults.filter((x) => typeof x === 'string') : [] } + return { + state, + genResults: Array.isArray(p.genResults) + ? p.genResults.filter((x) => typeof x === 'string') + : [] + } } catch { return null } @@ -300,7 +309,9 @@ export function pruneMissing( for (const [cat, q] of Object.entries(state.queues)) { if (!q) continue const items = q.items.filter((it) => - it.isResult ? !!it.outputPath && exists.has(it.outputPath) : !!it.file?.path && exists.has(it.file.path) + it.isResult + ? !!it.outputPath && exists.has(it.outputPath) + : !!it.file?.path && exists.has(it.file.path) ) queues[cat as CategoryId] = { items, selected: [], anchor: null } } @@ -322,11 +333,7 @@ function mapQueue(state: AppState, fn: (q: QueueState) => QueueState): AppState * arrive asynchronously and may land after the user has switched tabs, so we * can't assume the item lives in the current tool's queue. */ -function mapItemById( - state: AppState, - id: string, - fn: (i: QueueItem) => QueueItem -): AppState { +function mapItemById(state: AppState, id: string, fn: (i: QueueItem) => QueueItem): AppState { const queues = { ...state.queues } for (const [k, q] of Object.entries(queues) as [CategoryId, QueueState][]) { if (q.items.some((i) => i.id === id)) { @@ -374,13 +381,18 @@ export function reducer(state: AppState, action: Action): AppState { // Images→Video→Images lands back on your chosen operation, not the default. const remembered = state.lastOperation[action.category] const opId = - remembered && findOperation(action.category, remembered) ? remembered : defaultOperation(action.category) + remembered && findOperation(action.category, remembered) + ? remembered + : defaultOperation(action.category) const key = workspaceKey(action.category, opId) return { ...state, category: action.category, operation: opId, - queues: { ...state.queues, [action.category]: state.queues[action.category] ?? emptyQueue() }, + queues: { + ...state.queues, + [action.category]: state.queues[action.category] ?? emptyQueue() + }, options: { ...state.options, [key]: state.options[key] ?? defaultOptionsFor(action.category, opId) @@ -500,7 +512,13 @@ export function reducer(state: AppState, action: Action): AppState { items: [ ...q.items.map((i) => i.id === e.id - ? { ...i, status: 'done' as ItemStatus, percent: 100, message: undefined, error: undefined } + ? { + ...i, + status: 'done' as ItemStatus, + percent: 100, + message: undefined, + error: undefined + } : i ), result diff --git a/src/shared/comfy.ts b/src/shared/comfy.ts index f72be9a..85ab289 100644 --- a/src/shared/comfy.ts +++ b/src/shared/comfy.ts @@ -76,7 +76,10 @@ export const VERIFIED_TOKENS: string[] = [ /** Normalize a filename to alphanumerics for tolerant matching. */ export function normalizeModelName(name: string): string { - return name.toLowerCase().replace(/\.[a-z0-9]+$/, '').replace(/[^a-z0-9]/g, '') + return name + .toLowerCase() + .replace(/\.[a-z0-9]+$/, '') + .replace(/[^a-z0-9]/g, '') } /** diff --git a/src/shared/compress.ts b/src/shared/compress.ts index 1dacb01..6b996cc 100644 --- a/src/shared/compress.ts +++ b/src/shared/compress.ts @@ -71,12 +71,7 @@ export const AUDIO_BITRATES = [320, 256, 192, 128, 96, 64] as const // discovered on disk is 'esrgan:'. Which models exist is read from the // models folder at runtime, not frozen at build time. export type UpscaleModel = - | 'photo' - | 'anime' - | 'pid' - | 'comfy' - | `comfy:${string}` - | `esrgan:${string}` + 'photo' | 'anime' | 'pid' | 'comfy' | `comfy:${string}` | `esrgan:${string}` export const UPSCALE_MODELS: Choice[] = [ { value: 'photo', label: 'Photo' }, { value: 'anime', label: 'Anime' } diff --git a/src/shared/genArch.ts b/src/shared/genArch.ts index 93d4128..33bb178 100644 --- a/src/shared/genArch.ts +++ b/src/shared/genArch.ts @@ -37,8 +37,24 @@ export interface ArchInfo { * fallback and is asserted against the shipped pack by a test, so the two * cannot drift. */ export const ARCH_INFO: Record = { - sdxl: { group: 'Checkpoints', sampler: 'dpmpp_2m', scheduler: 'karras', steps: 28, cfg: 7, guidance: 0, hasGuidance: false }, - flux1: { group: 'Flux', sampler: 'euler', scheduler: 'simple', steps: 20, cfg: 1, guidance: 3.5, hasGuidance: true }, + sdxl: { + group: 'Checkpoints', + sampler: 'dpmpp_2m', + scheduler: 'karras', + steps: 28, + cfg: 7, + guidance: 0, + hasGuidance: false + }, + flux1: { + group: 'Flux', + sampler: 'euler', + scheduler: 'simple', + steps: 20, + cfg: 1, + guidance: 3.5, + hasGuidance: true + }, flux2: { group: 'Flux 2', sampler: 'euler', @@ -67,7 +83,8 @@ export const ARCH_INFO: Record = { cfg: 1, guidance: 0, hasGuidance: false, - minComfyNote: 'Krea 2 needs an up-to-date ComfyUI (nightly, mid-2026) for the krea2 encoder type.' + minComfyNote: + 'Krea 2 needs an up-to-date ComfyUI (nightly, mid-2026) for the krea2 encoder type.' } } diff --git a/src/shared/generate.ts b/src/shared/generate.ts index 0095b24..8f05f89 100644 --- a/src/shared/generate.ts +++ b/src/shared/generate.ts @@ -57,7 +57,8 @@ export const GEN_STYLES: GenStyle[] = [ { id: 'photo', label: 'Photographic', - positive: 'professional photograph, DSLR, 50mm, shallow depth of field, cinematic lighting, film grain', + positive: + 'professional photograph, DSLR, 50mm, shallow depth of field, cinematic lighting, film grain', negative: 'illustration, cartoon, 3d render' }, { @@ -75,7 +76,8 @@ export const GEN_STYLES: GenStyle[] = [ { id: '3d', label: '3D Render', - positive: '3d render, octane render, physically based rendering, subsurface scattering, high detail', + positive: + '3d render, octane render, physically based rendering, subsurface scattering, high detail', negative: '' }, { diff --git a/src/shared/registry.ts b/src/shared/registry.ts index d2c676d..50cc05f 100644 --- a/src/shared/registry.ts +++ b/src/shared/registry.ts @@ -337,8 +337,7 @@ export function instantiateWorkflow( export function workflowPlaceholders(spec: WorkflowSpec): string[] { const found = new Set() const walk = (v: unknown): void => { - if (typeof v === 'string') - for (const m of v.matchAll(/\$\{([a-zA-Z0-9_]+)\}/g)) found.add(m[1]) + if (typeof v === 'string') for (const m of v.matchAll(/\$\{([a-zA-Z0-9_]+)\}/g)) found.add(m[1]) else if (Array.isArray(v)) v.forEach(walk) else if (v && typeof v === 'object') Object.values(v as object).forEach(walk) } diff --git a/src/shared/types.ts b/src/shared/types.ts index c0058e5..959a0e1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,13 +1,6 @@ // Shared types crossing the main <-> renderer boundary. No Node or DOM imports. -export type ToolId = - | 'convert' - | 'compress' - | 'resize' - | 'upscale' - | 'removebg' - | 'pdf' - | 'generate' +export type ToolId = 'convert' | 'compress' | 'resize' | 'upscale' | 'removebg' | 'pdf' | 'generate' export type FileKind = 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'text' | 'other' diff --git a/test/arch-scan.test.ts b/test/arch-scan.test.ts index a8cedfd..43cd72d 100644 --- a/test/arch-scan.test.ts +++ b/test/arch-scan.test.ts @@ -30,7 +30,9 @@ function writeSafetensors(name: string, keys: string[], meta?: Record { it('reads keys and metadata, excluding __metadata__', () => { - const p = writeSafetensors('m.safetensors', ['a.weight', 'b.bias'], { 'modelspec.architecture': 'X' }) + const p = writeSafetensors('m.safetensors', ['a.weight', 'b.bias'], { + 'modelspec.architecture': 'X' + }) const h = readSafetensorsHeader(p) expect(h?.keys.sort()).toEqual(['a.weight', 'b.bias']) expect(h?.metadata['modelspec.architecture']).toBe('X') @@ -53,21 +55,46 @@ describe('readSafetensorsHeader', () => { describe('classifyArch', () => { const cases: [string, string[], string][] = [ - ['flux1', ['double_blocks.0.img_attn.qkv.weight', 'single_blocks.0.linear1.weight', 'img_in.weight', 'txt_in.weight'], 'flux1'], + [ + 'flux1', + [ + 'double_blocks.0.img_attn.qkv.weight', + 'single_blocks.0.linear1.weight', + 'img_in.weight', + 'txt_in.weight' + ], + 'flux1' + ], [ 'flux2', - ['double_blocks.0.x.weight', 'single_blocks.0.x.weight', 'double_stream_modulation_img.0.weight', 'img_in.weight', 'txt_in.weight'], + [ + 'double_blocks.0.x.weight', + 'single_blocks.0.x.weight', + 'double_stream_modulation_img.0.weight', + 'img_in.weight', + 'txt_in.weight' + ], 'flux2' ], ['sd3', ['joint_blocks.0.x_block.attn.qkv.weight'], 'sd3'], - ['z-image', ['cap_embedder.0.weight', 'noise_refiner.0.weight', 'context_refiner.0.weight'], 'z-image'], + [ + 'z-image', + ['cap_embedder.0.weight', 'noise_refiner.0.weight', 'context_refiner.0.weight'], + 'z-image' + ], ['sdxl', ['input_blocks.0.0.weight', 'middle_block.1.weight'], 'sdxl'], ['krea2', ['blocks.0.weight', 'tmlp.0.weight', 'txtfusion.0.weight', 'tproj.weight'], 'krea2'], ['genuinely unknown', ['foo.0.weight', 'bar.baz.weight'], 'unknown'], // Regression: non-image DiTs that reuse Flux's block names must NOT be Flux. [ 'HunyuanVideo (flux-like blocks + token refiner)', - ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.weight', 'txt_in.individual_token_refiner.blocks.0.w', 'guidance_in.w'], + [ + 'double_blocks.0.w', + 'single_blocks.0.w', + 'img_in.weight', + 'txt_in.individual_token_refiner.blocks.0.w', + 'guidance_in.w' + ], 'unknown' ], [ @@ -75,7 +102,11 @@ describe('classifyArch', () => { ['double_blocks.0.w', 'single_blocks.0.w', 'conditioner.w', 'patch_embed.w', 'time_in.w'], 'unknown' ], - ['FramePack (clean_x_embedder)', ['clean_x_embedder.w', 'double_blocks.0.w', 'img_in.w', 'txt_in.w'], 'unknown'] + [ + 'FramePack (clean_x_embedder)', + ['clean_x_embedder.w', 'double_blocks.0.w', 'img_in.w', 'txt_in.w'], + 'unknown' + ] ] for (const [label, keys, expected] of cases) { it(`classifies ${label} as ${expected}`, () => { @@ -84,18 +115,28 @@ describe('classifyArch', () => { } it('prefers flux2 over flux1 when modulation keys are present', () => { - const keys = ['double_blocks.0.w', 'single_blocks.0.w', 'single_stream_modulation.0.w', 'img_in.w', 'txt_in.w'] + const keys = [ + 'double_blocks.0.w', + 'single_blocks.0.w', + 'single_stream_modulation.0.w', + 'img_in.w', + 'txt_in.w' + ] expect(classifyArch({ keys, metadata: {} })).toBe('flux2') }) it('rejects a model whose metadata names a video family, even with image-like keys', () => { const keys = ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.w', 'txt_in.w'] - expect(classifyArch({ keys, metadata: { 'modelspec.architecture': 'hunyuan-video' } })).toBe('unknown') + expect(classifyArch({ keys, metadata: { 'modelspec.architecture': 'hunyuan-video' } })).toBe( + 'unknown' + ) }) it('treats Lumina 2 (declares lumina) as unknown so it is not mis-wired as Z-Image', () => { const keys = ['cap_embedder.0.w', 'noise_refiner.0.w', 'context_refiner.0.w'] - expect(classifyArch({ keys, metadata: { 'modelspec.architecture': 'Lumina-Image-2.0' } })).toBe('unknown') + expect(classifyArch({ keys, metadata: { 'modelspec.architecture': 'Lumina-Image-2.0' } })).toBe( + 'unknown' + ) // Without the metadata it would (correctly, for real Z-Image) be z-image. expect(classifyArch({ keys, metadata: {} })).toBe('z-image') }) @@ -103,34 +144,58 @@ describe('classifyArch', () => { describe('isExcludedNonImage', () => { const yes: [string, string[], Record?][] = [ - ['HunyuanVideo token refiner', ['double_blocks.0.w', 'img_in.w', 'txt_in.individual_token_refiner.w']], + [ + 'HunyuanVideo token refiner', + ['double_blocks.0.w', 'img_in.w', 'txt_in.individual_token_refiner.w'] + ], ['Wan video patch/time embed', ['patch_embedding.w', 'time_embedding.w', 'blocks.0.w']], ['LTX vocoder/audio', ['vocoder.w', 'audio_vae.w', 'model.w']], ['Hunyuan3D shape DiT', ['double_blocks.0.w', 'single_blocks.0.w', 'conditioner.w']], ['metadata video', ['x.w'], { 'modelspec.architecture': 'wan-2.2' }] ] for (const [label, keys, metadata] of yes) - it(`excludes ${label}`, () => expect(isExcludedNonImage({ keys, metadata: metadata ?? {} })).toBe(true)) + it(`excludes ${label}`, () => + expect(isExcludedNonImage({ keys, metadata: metadata ?? {} })).toBe(true)) it('does NOT exclude a real Flux or Z-Image', () => { - expect(isExcludedNonImage({ keys: ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.w', 'txt_in.w'], metadata: {} })).toBe(false) - expect(isExcludedNonImage({ keys: ['cap_embedder.0.w', 'noise_refiner.0.w', 'context_refiner.0.w'], metadata: {} })).toBe(false) + expect( + isExcludedNonImage({ + keys: ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.w', 'txt_in.w'], + metadata: {} + }) + ).toBe(false) + expect( + isExcludedNonImage({ + keys: ['cap_embedder.0.w', 'noise_refiner.0.w', 'context_refiner.0.w'], + metadata: {} + }) + ).toBe(false) }) }) describe('isAllInOne', () => { it('detects a baked text-encoder + VAE checkpoint', () => { - const h = { keys: ['model.diffusion_model.x', 'text_encoders.t5.w', 'vae.decoder.w'], metadata: {} } + const h = { + keys: ['model.diffusion_model.x', 'text_encoders.t5.w', 'vae.decoder.w'], + metadata: {} + } expect(isAllInOne(h)).toBe(true) }) it('is false for a bare UNET', () => { - expect(isAllInOne({ keys: ['double_blocks.0.w', 'single_blocks.0.w'], metadata: {} })).toBe(false) + expect(isAllInOne({ keys: ['double_blocks.0.w', 'single_blocks.0.w'], metadata: {} })).toBe( + false + ) }) }) describe('classifyModelFile', () => { it('classifies from a real file end-to-end', () => { - const p = writeSafetensors('flux.safetensors', ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.w', 'txt_in.w']) + const p = writeSafetensors('flux.safetensors', [ + 'double_blocks.0.w', + 'single_blocks.0.w', + 'img_in.w', + 'txt_in.w' + ]) expect(classifyModelFile(p)).toBe('flux1') }) it('returns unknown for an unreadable path', () => { @@ -140,10 +205,19 @@ describe('classifyModelFile', () => { describe('inspectModelFile', () => { it('reports arch + excluded together', () => { - const flux = writeSafetensors('f2.safetensors', ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.w', 'txt_in.w']) + const flux = writeSafetensors('f2.safetensors', [ + 'double_blocks.0.w', + 'single_blocks.0.w', + 'img_in.w', + 'txt_in.w' + ]) expect(inspectModelFile(flux)).toMatchObject({ arch: 'flux1', excluded: false }) - const video = writeSafetensors('v.safetensors', ['patch_embedding.w', 'time_embedding.w', 'blocks.0.w']) + const video = writeSafetensors('v.safetensors', [ + 'patch_embedding.w', + 'time_embedding.w', + 'blocks.0.w' + ]) expect(inspectModelFile(video)).toMatchObject({ arch: 'unknown', excluded: true }) const mystery = writeSafetensors('m.safetensors', ['some_new_dit.0.w']) diff --git a/test/catalogs.test.ts b/test/catalogs.test.ts index 592d5c1..a021039 100644 --- a/test/catalogs.test.ts +++ b/test/catalogs.test.ts @@ -63,7 +63,12 @@ describe('upscaler badge follows the probed architecture, not the calendar', () }) it('still verifies a known name whose architecture we do not list', () => { - const m = classifyModel({ path: '/m/4x-UltraSharpV2.pth', ok: true, arch: 'SomethingNew', scale: 4 }) + const m = classifyModel({ + path: '/m/4x-UltraSharpV2.pth', + ok: true, + arch: 'SomethingNew', + scale: 4 + }) expect(m.badge).toBe('verified') expect(VERIFIED_TOKENS).toContain('ultrasharp') }) diff --git a/test/channel.test.ts b/test/channel.test.ts index 5dd9c2e..6aff333 100644 --- a/test/channel.test.ts +++ b/test/channel.test.ts @@ -31,7 +31,10 @@ describe('channel signature verification', () => { // The whole point: content that changed after signing must not reach the // registry, because a channel pack can add downloadable model URLs. const { pack, publicKeyB64 } = makeSigned(payload) - const tampered = { ...pack, payload: payload.replace('"entries":[]', '"entries":[{"id":"evil"}]') } + const tampered = { + ...pack, + payload: payload.replace('"entries":[]', '"entries":[{"id":"evil"}]') + } expect(verifyPack(tampered, publicKeyB64)).toBe(false) }) diff --git a/test/comfy.test.ts b/test/comfy.test.ts index 1b389bb..ac2e3fb 100644 --- a/test/comfy.test.ts +++ b/test/comfy.test.ts @@ -82,8 +82,18 @@ describe('normalizeModelName', () => { describe('classifyModel', () => { it('marks a known token as verified with its arch and scale', () => { - const m = classifyModel({ path: 'C:/m/4x-UltraSharpV2.safetensors', ok: true, arch: 'ESRGAN', scale: 4 }) - expect(m).toMatchObject({ name: '4x-UltraSharpV2', badge: 'verified', arch: 'ESRGAN', scale: 4 }) + const m = classifyModel({ + path: 'C:/m/4x-UltraSharpV2.safetensors', + ok: true, + arch: 'ESRGAN', + scale: 4 + }) + expect(m).toMatchObject({ + name: '4x-UltraSharpV2', + badge: 'verified', + arch: 'ESRGAN', + scale: 4 + }) }) it('marks a model experimental only when BOTH its name and its arch are unknown', () => { @@ -93,13 +103,22 @@ describe('classifyModel', () => { expect( classifyModel({ path: '/m/SomeRandomUpscaler.pth', ok: true, arch: 'DAT', scale: 2 }).badge ).toBe('verified') - const m = classifyModel({ path: '/m/SomeRandomUpscaler.pth', ok: true, arch: 'BrandNew', scale: 2 }) + const m = classifyModel({ + path: '/m/SomeRandomUpscaler.pth', + ok: true, + arch: 'BrandNew', + scale: 2 + }) expect(m.badge).toBe('experimental') expect(m.scale).toBe(2) // and it stays fully usable either way }) it('marks an unloadable file unsupported with the reason', () => { - const m = classifyModel({ path: '/m/supir_v0.safetensors', ok: false, reason: 'not an image upscaler' }) + const m = classifyModel({ + path: '/m/supir_v0.safetensors', + ok: false, + reason: 'not an image upscaler' + }) expect(m).toMatchObject({ badge: 'unsupported', reason: 'not an image upscaler', scale: 0 }) }) }) @@ -109,7 +128,11 @@ describe('classifySpandrelLine', () => { expect(classifySpandrelLine('{"ready": true}')).toEqual({ kind: 'ready' }) }) it('parses a progress line into a 0-99 percent', () => { - expect(classifySpandrelLine('{"id": 2, "progress": 0.5}')).toEqual({ kind: 'progress', id: 2, pct: 50 }) + expect(classifySpandrelLine('{"id": 2, "progress": 0.5}')).toEqual({ + kind: 'progress', + id: 2, + pct: 50 + }) }) it('parses a success reply', () => { expect(classifySpandrelLine('{"id": 1, "ok": true, "output": "o.png", "ms": 42}')).toEqual({ diff --git a/test/documents.test.ts b/test/documents.test.ts index 294c640..3b910c3 100644 --- a/test/documents.test.ts +++ b/test/documents.test.ts @@ -3,16 +3,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import { convertGroup, convertTargets, familyFormats } from '../src/shared/convert' -import { - buildSofficeArgs, - sofficeFilter, - sofficeOutputPath -} from '../src/main/tools/soffice' -import { - buildPdfCompressArgs, - buildPdfImagesArgs, - buildPdfTextArgs -} from '../src/main/tools/pdf' +import { buildSofficeArgs, sofficeFilter, sofficeOutputPath } from '../src/main/tools/soffice' +import { buildPdfCompressArgs, buildPdfImagesArgs, buildPdfTextArgs } from '../src/main/tools/pdf' import { uniqueOutDir } from '../src/main/output' describe('document convert targets', () => { @@ -61,7 +53,10 @@ describe('document convert targets', () => { it('every family format has a dotted extension', () => { for (const src of ['.docx', '.xlsx', '.pptx', '.pdf', '.md']) - for (const f of familyFormats(src === '.pdf' ? 'pdf' : src === '.md' ? 'text' : 'document', src)) + for (const f of familyFormats( + src === '.pdf' ? 'pdf' : src === '.md' ? 'text' : 'document', + src + )) expect(f.ext.startsWith('.')).toBe(true) }) }) @@ -104,14 +99,25 @@ describe('soffice (LibreOffice) args', () => { }) it('predicts LibreOffice output filename in the outdir', () => { - expect(sofficeOutputPath('C:/in/report.docx', 'C:/tmp', '.pdf')).toBe(join('C:/tmp', 'report.pdf')) - expect(sofficeOutputPath('C:/in/report.docx', 'C:/tmp', 'txt')).toBe(join('C:/tmp', 'report.txt')) + expect(sofficeOutputPath('C:/in/report.docx', 'C:/tmp', '.pdf')).toBe( + join('C:/tmp', 'report.pdf') + ) + expect(sofficeOutputPath('C:/in/report.docx', 'C:/tmp', 'txt')).toBe( + join('C:/tmp', 'report.txt') + ) }) }) describe('mutool (PDF) args', () => { it('extract text: draw -F txt', () => { - expect(buildPdfTextArgs('in.pdf', 'out.txt')).toEqual(['draw', '-F', 'txt', '-o', 'out.txt', 'in.pdf']) + expect(buildPdfTextArgs('in.pdf', 'out.txt')).toEqual([ + 'draw', + '-F', + 'txt', + '-o', + 'out.txt', + 'in.pdf' + ]) }) it('pages to images: draw -F png at a DPI into a folder', () => { expect(buildPdfImagesArgs('in.pdf', 'C:/out', 150)).toEqual([ diff --git a/test/gen-registry.test.ts b/test/gen-registry.test.ts index 9642cdb..8159f07 100644 --- a/test/gen-registry.test.ts +++ b/test/gen-registry.test.ts @@ -17,7 +17,9 @@ describe('requiredCompanions', () => { }) it('krea2 needs the Qwen3-VL encoder and the Qwen-Image VAE', () => { - const files = requiredCompanions('krea2', 'krea2_turbo.safetensors').map((x) => x.download.filename) + const files = requiredCompanions('krea2', 'krea2_turbo.safetensors').map( + (x) => x.download.filename + ) expect(files).toContain('qwen3vl_4b_fp8_scaled.safetensors') expect(files).toContain('qwen_image_vae.safetensors') }) @@ -31,14 +33,16 @@ describe('requiredCompanions', () => { expect(small.map((x) => x.download.filename)).toContain('qwen_3_4b.safetensors') // Falls back to the filename token when size is unknown. - expect(requiredCompanions('flux2', 'flux-2-klein-9b-fp8.safetensors').map((x) => x.download.filename)).toContain( - 'qwen_3_8b_fp8mixed.safetensors' - ) + expect( + requiredCompanions('flux2', 'flux-2-klein-9b-fp8.safetensors').map((x) => x.download.filename) + ).toContain('qwen_3_8b_fp8mixed.safetensors') }) it('every flux2 variant also needs the flux2 VAE', () => { for (const size of [3_800_000_000, 8_800_000_000]) { - const files = requiredCompanions('flux2', 'm.safetensors', size).map((x) => x.download.filename) + const files = requiredCompanions('flux2', 'm.safetensors', size).map( + (x) => x.download.filename + ) expect(files).toContain('flux2-vae.safetensors') } }) diff --git a/test/gguf.test.ts b/test/gguf.test.ts index 3f72d93..ddb5e9b 100644 --- a/test/gguf.test.ts +++ b/test/gguf.test.ts @@ -81,11 +81,21 @@ describe('reading a GGUF header', () => { it('skips non-string metadata values of every scalar type', () => { // The KV block must be walked exactly, or the tensor names read as garbage. const extra = [ - str('a.u32'), u32(4), u32(7), - str('b.f32'), u32(6), Buffer.from(Float32Array.of(1.5).buffer), - str('c.bool'), u32(7), Buffer.from([1]), - str('d.u64'), u32(10), u64(9), - str('e.i16'), u32(3), Buffer.from([0xff, 0xff]) + str('a.u32'), + u32(4), + u32(7), + str('b.f32'), + u32(6), + Buffer.from(Float32Array.of(1.5).buffer), + str('c.bool'), + u32(7), + Buffer.from([1]), + str('d.u64'), + u32(10), + u64(9), + str('e.i16'), + u32(3), + Buffer.from([0xff, 0xff]) ] const p = writeGguf('mixed.gguf', ['cap_embedder.w', 'noise_refiner.w'], {}, extra) expect(readGgufHeader(p)?.keys).toEqual(['cap_embedder.w', 'noise_refiner.w']) @@ -151,7 +161,9 @@ describe('the GGUF workflow', () => { const wf = registryEntry(id)?.workflow expect(wf, `${id} has no workflow`).toBeDefined() const derived = deriveGgufWorkflow(wf!) - expect(Object.values(derived.template).some((n) => n.class_type === GGUF_UNET_NODE)).toBe(true) + expect(Object.values(derived.template).some((n) => n.class_type === GGUF_UNET_NODE)).toBe( + true + ) } }) }) diff --git a/test/markdown.test.ts b/test/markdown.test.ts index 5e714a3..6109901 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -42,7 +42,9 @@ describe('renderMarkdown', () => { '
  • a
    • nested
  • b
' ) // A tab counts as 2 spaces (one level) too. - expect(renderMarkdown('- a\n\t- nested')).toContain('
  • a
    • nested
') + expect(renderMarkdown('- a\n\t- nested')).toContain( + '
  • a
    • nested
' + ) }) it('honors an ordered-list start number via
    ', () => { diff --git a/test/pid.test.ts b/test/pid.test.ts index 6a3d956..35b9edd 100644 --- a/test/pid.test.ts +++ b/test/pid.test.ts @@ -35,7 +35,9 @@ describe('classifySidecarLine', () => { }) it('parses a successful reply with id, output and ms', () => { - expect(classifySidecarLine('{"id": 7, "ok": true, "output": "C:/out.png", "ms": 1234}')).toEqual({ + expect( + classifySidecarLine('{"id": 7, "ok": true, "output": "C:/out.png", "ms": 1234}') + ).toEqual({ kind: 'ok', id: 7, output: 'C:/out.png', @@ -115,7 +117,9 @@ describe('cudaTierSupport (the gate before a ~3 GB cu128 download)', () => { }) it('accepts an RTX-class card', () => { - expect(cudaTierSupport(parseNvidiaSmi('NVIDIA GeForce RTX 4070, 12282, 8.9, 581.15')).ok).toBe(true) + expect(cudaTierSupport(parseNvidiaSmi('NVIDIA GeForce RTX 4070, 12282, 8.9, 581.15')).ok).toBe( + true + ) }) it('refuses a driver below the floor', () => { diff --git a/test/queues.test.ts b/test/queues.test.ts index ae19757..0e60c98 100644 --- a/test/queues.test.ts +++ b/test/queues.test.ts @@ -60,8 +60,14 @@ describe('per-category queues (shared across operations)', () => { it('appends a fresh result on each finished run; the source persists', () => { let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) const id = s.queues[IMAGES]!.items[0].id - s = reducer(s, { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a (1).webp' } }) - s = reducer(s, { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a (2).webp' } }) + s = reducer(s, { + type: 'jobEvent', + event: { id, status: 'done', outputPath: 'C:/x/a (1).webp' } + }) + s = reducer(s, { + type: 'jobEvent', + event: { id, status: 'done', outputPath: 'C:/x/a (2).webp' } + }) const items = s.queues[IMAGES]!.items expect(items.filter((i) => !i.isResult)).toHaveLength(1) const results = items.filter((i) => i.isResult) @@ -90,7 +96,7 @@ describe('navigation', () => { expect(initialState.operation).toBe('convert') }) - it('switching file type lands on that type\'s default operation, ready for files', () => { + it("switching file type lands on that type's default operation, ready for files", () => { // Operation ids are per-category, so carrying one across would be meaningless. let s = open(initialState, 'upscale') s = reducer(s, { type: 'setCategory', category: 'pdf' }) diff --git a/test/registry.test.ts b/test/registry.test.ts index 3370202..ea9bd2b 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -157,7 +157,9 @@ describe('validateEntry (the path-traversal and https gate)', () => { it('rejects a filename with a separator or ..', () => { for (const bad of ['../../evil.exe', 'sub/dir.safetensors', '..', 'a\\b']) expect( - validateEntry(withCompanion({ download: { filename: bad, approxSize: '1 MB', urls: ['https://h/x'] } })) + validateEntry( + withCompanion({ download: { filename: bad, approxSize: '1 MB', urls: ['https://h/x'] } }) + ) ).not.toEqual([]) }) @@ -239,7 +241,9 @@ describe('scoreDetect (content beats the filename, always)', () => { const d = { tensorKeys: { all: ['double_blocks'], none: ['vace_blocks'] } } expect(scoreDetect(d, { basename: 'm', tensorKeys: ['x.double_blocks.0'] })).toBeGreaterThan(0) expect(scoreDetect(d, { basename: 'm', tensorKeys: ['x.single_blocks.0'] })).toBe(0) - expect(scoreDetect(d, { basename: 'm', tensorKeys: ['x.double_blocks.0', 'vace_blocks.1'] })).toBe(0) + expect( + scoreDetect(d, { basename: 'm', tensorKeys: ['x.double_blocks.0', 'vace_blocks.1'] }) + ).toBe(0) }) it('scores tensor evidence far above a filename hint', () => { diff --git a/test/removebg.test.ts b/test/removebg.test.ts index 3d008d5..b018810 100644 --- a/test/removebg.test.ts +++ b/test/removebg.test.ts @@ -45,7 +45,6 @@ describe('buildRembgArgs', () => { expect(args.slice(args.indexOf('-ae'), args.indexOf('-ae') + 2)).toEqual(['-ae', '10']) }) - it('passes a background colour as four bare ints', () => { const args = buildRembgArgs('in.png', 'out.png', { ...base, bgFill: 'white' }) const i = args.indexOf('-bgc') diff --git a/test/resolvers.test.ts b/test/resolvers.test.ts index 7282b2d..29c55c3 100644 --- a/test/resolvers.test.ts +++ b/test/resolvers.test.ts @@ -103,9 +103,7 @@ describe('ComfyUI discovery: reading is wide, EXECUTING is narrow', () => { try { const dirs = comfyCandidateDirs({ trusted: true }) expect(dirs).toContain(join(root, 'appdata', 'ComfyUI')) - expect(dirs).toContain( - join(root, 'localappdata', 'Programs', '@comfyorgcomfyui-electron') - ) + expect(dirs).toContain(join(root, 'localappdata', 'Programs', '@comfyorgcomfyui-electron')) } finally { restore() } diff --git a/test/session-persist.test.ts b/test/session-persist.test.ts index 7d23934..7c0e9f7 100644 --- a/test/session-persist.test.ts +++ b/test/session-persist.test.ts @@ -39,13 +39,24 @@ const result = (id: string, src: string, out: string): QueueItem => ({ function stateWith(items: QueueItem[]): AppState { return { ...initialState, - queues: { images: { items, selected: [items[0]?.id].filter(Boolean) as string[], anchor: items[0]?.id ?? null } } + queues: { + images: { + items, + selected: [items[0]?.id].filter(Boolean) as string[], + anchor: items[0]?.id ?? null + } + } } } describe('session persistence round-trip', () => { it('snapshots and restores a session, stripping thumbs and settling in-flight items', () => { - const running: QueueItem = { ...source('a', 'C:/in/a.png'), status: 'running', percent: 42, thumb: 'data:big' } + const running: QueueItem = { + ...source('a', 'C:/in/a.png'), + status: 'running', + percent: 42, + thumb: 'data:big' + } const state = stateWith([running, result('b', 'C:/in/a.png', 'C:/out/a (converted).png')]) const snap = sessionSnapshot(state, ['C:/gen/one.png']) const parsed = parseSession(snap) @@ -70,7 +81,10 @@ describe('session persistence round-trip', () => { }) it('collects every on-disk path referenced by the session', () => { - const state = stateWith([source('a', 'C:/in/a.png'), result('b', 'C:/in/a.png', 'C:/out/b.png')]) + const state = stateWith([ + source('a', 'C:/in/a.png'), + result('b', 'C:/in/a.png', 'C:/out/b.png') + ]) const paths = sessionPaths(state, ['C:/gen/g.png']) expect(new Set(paths)).toEqual(new Set(['C:/in/a.png', 'C:/out/b.png', 'C:/gen/g.png'])) }) diff --git a/test/tools.test.ts b/test/tools.test.ts index 486b7ad..2e333ec 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -121,9 +121,9 @@ describe('resize', () => { expect(buildResizeSpec({ mode: 'dimensions', width: 800, height: 600 })).toBe('800x600') }) it('appends ! when stretching to both numbers', () => { - expect( - buildResizeSpec({ mode: 'dimensions', width: 800, height: 600, fit: 'stretch' }) - ).toBe('800x600!') + expect(buildResizeSpec({ mode: 'dimensions', width: 800, height: 600, fit: 'stretch' })).toBe( + '800x600!' + ) }) it('does not stretch against a blank field (nothing to distort to)', () => { expect(buildResizeSpec({ mode: 'dimensions', width: 800, height: '', fit: 'stretch' })).toBe( @@ -214,40 +214,70 @@ describe('compress', () => { expect( buildVideoCompressArgs('in.mkv', 'out.mp4', { codec: 'h264', quality: 100, scale: 100 }) ).toEqual([ - '-y', '-i', 'in.mkv', - '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', - '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', + '-y', + '-i', + 'in.mkv', + '-c:v', + 'libx264', + '-preset', + 'medium', + '-crf', + '18', + '-pix_fmt', + 'yuv420p', + '-c:a', + 'aac', + '-b:a', + '128k', + '-movflags', + '+faststart', 'out.mp4' ]) // H.265 adds the hvc1 tag; AV1 uses libsvtav1 + numeric preset expect( buildVideoCompressArgs('in.mp4', 'out.mp4', { codec: 'h265', quality: 100, scale: 100 }) ).toContain('hvc1') - const av1 = buildVideoCompressArgs('in.mp4', 'out.mp4', { codec: 'av1', quality: 100, scale: 100 }) + const av1 = buildVideoCompressArgs('in.mp4', 'out.mp4', { + codec: 'av1', + quality: 100, + scale: 100 + }) expect(av1).toContain('libsvtav1') expect(av1.join(' ')).toContain('-preset 6') }) it('adds an aspect-safe percentage scale filter below 100%', () => { const a = buildVideoCompressArgs('in.mp4', 'out.mp4', { - codec: 'h264', quality: 80, scale: 50 + codec: 'h264', + quality: 80, + scale: 50 }) expect(a).toContain('-vf') expect(a[a.indexOf('-vf') + 1]).toBe('scale=w=iw*0.5:h=ih*0.5:force_divisible_by=2') // 100% (original) adds no filter at all const b = buildVideoCompressArgs('in.mp4', 'out.mp4', { - codec: 'h264', quality: 80, scale: 100 + codec: 'h264', + quality: 80, + scale: 100 }) expect(b).not.toContain('-vf') }) it('builds audio args for a target codec + bitrate, keep uses source codec', () => { - expect(buildAudioCompressArgs('in.wav', 'out.opus', { codec: 'opus', bitrate: 96, sourceExt: '.wav' })).toEqual([ - '-y', '-i', 'in.wav', '-c:a', 'libopus', '-b:a', '96k', 'out.opus' - ]) - expect(buildAudioCompressArgs('in.ogg', 'out.ogg', { codec: 'keep', bitrate: 128, sourceExt: '.ogg' })).toEqual([ - '-y', '-i', 'in.ogg', '-c:a', 'libvorbis', '-b:a', '128k', 'out.ogg' - ]) + expect( + buildAudioCompressArgs('in.wav', 'out.opus', { + codec: 'opus', + bitrate: 96, + sourceExt: '.wav' + }) + ).toEqual(['-y', '-i', 'in.wav', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) + expect( + buildAudioCompressArgs('in.ogg', 'out.ogg', { + codec: 'keep', + bitrate: 128, + sourceExt: '.ogg' + }) + ).toEqual(['-y', '-i', 'in.ogg', '-c:a', 'libvorbis', '-b:a', '128k', 'out.ogg']) expect(audioOutputExt('mp3', '.m4a')).toBe('.mp3') expect(audioOutputExt('aac', '.wav')).toBe('.m4a') expect(audioOutputExt('keep', '.ogg')).toBe('.ogg') @@ -259,8 +289,12 @@ describe('compress', () => { expect(bal).toContain('-dPDFSETTINGS=/ebook') expect(bal).toContain('-sOutputFile=out.pdf') expect(bal).not.toContain('-sColorConversionStrategy=Gray') - expect(buildGsCompressArgs('in.pdf', 'out.pdf', 'smallest', false)).toContain('-dPDFSETTINGS=/screen') - expect(buildGsCompressArgs('in.pdf', 'out.pdf', 'high', true)).toContain('-sColorConversionStrategy=Gray') + expect(buildGsCompressArgs('in.pdf', 'out.pdf', 'smallest', false)).toContain( + '-dPDFSETTINGS=/screen' + ) + expect(buildGsCompressArgs('in.pdf', 'out.pdf', 'high', true)).toContain( + '-sColorConversionStrategy=Gray' + ) }) }) @@ -294,7 +328,12 @@ describe('pdf ops', () => { 'out.pdf', '1-3,5' ]) - expect(buildPdfPagesArgs('in.pdf', 'p-04.pdf', '4')).toEqual(['clean', 'in.pdf', 'p-04.pdf', '4']) + expect(buildPdfPagesArgs('in.pdf', 'p-04.pdf', '4')).toEqual([ + 'clean', + 'in.pdf', + 'p-04.pdf', + '4' + ]) }) it('builds info + extract args', () => { expect(buildPdfInfoArgs('in.pdf')).toEqual(['info', 'in.pdf']) @@ -322,7 +361,19 @@ describe('canCompress', () => { expect(canCompress('pdf', '.pdf')).toBe(true) }) it('accepts raster images the compressors handle, not vector/exotic exts', () => { - for (const e of ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.tif', '.tiff', '.avif', '.jxl', '.bmp', '.heic']) + for (const e of [ + '.png', + '.jpg', + '.jpeg', + '.webp', + '.gif', + '.tif', + '.tiff', + '.avif', + '.jxl', + '.bmp', + '.heic' + ]) expect(canCompress('image', e)).toBe(true) // vector / layered / exotic: would silently rasterize -> excluded expect(canCompress('image', '.svg')).toBe(false) @@ -341,13 +392,21 @@ describe('canCompress', () => { // A bitrate is meaningless for wav/flac, so keep-format means lossless FLAC. expect(audioOutputExt('keep', '.wav')).toBe('.flac') expect(audioOutputExt('keep', '.flac')).toBe('.flac') - expect(buildAudioCompressArgs('in.wav', 'out.flac', { codec: 'keep', bitrate: 192, sourceExt: '.wav' })).toEqual([ - '-y', '-i', 'in.wav', '-c:a', 'flac', '-compression_level', '8', 'out.flac' - ]) + expect( + buildAudioCompressArgs('in.wav', 'out.flac', { + codec: 'keep', + bitrate: 192, + sourceExt: '.wav' + }) + ).toEqual(['-y', '-i', 'in.wav', '-c:a', 'flac', '-compression_level', '8', 'out.flac']) // an explicit lossy codec still applies the bitrate - expect(buildAudioCompressArgs('in.wav', 'out.opus', { codec: 'opus', bitrate: 96, sourceExt: '.wav' })).toEqual([ - '-y', '-i', 'in.wav', '-c:a', 'libopus', '-b:a', '96k', 'out.opus' - ]) + expect( + buildAudioCompressArgs('in.wav', 'out.opus', { + codec: 'opus', + bitrate: 96, + sourceExt: '.wav' + }) + ).toEqual(['-y', '-i', 'in.wav', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) }) it('rejects documents, text, and unknown kinds', () => { expect(canCompress('document', '.docx')).toBe(false) diff --git a/test/upscale.test.ts b/test/upscale.test.ts index 748724b..39eb41e 100644 --- a/test/upscale.test.ts +++ b/test/upscale.test.ts @@ -82,7 +82,6 @@ describe('upscaledSize', () => { expect(upscaledSize(640, 420, 4)).toEqual({ w: 2560, h: 1680 }) expect(upscaledSize(500, 500, 3)).toEqual({ w: 1500, h: 1500 }) }) - }) describe('output size estimate', () => { diff --git a/test/user-registry.test.ts b/test/user-registry.test.ts index 53011cf..1764931 100644 --- a/test/user-registry.test.ts +++ b/test/user-registry.test.ts @@ -5,8 +5,14 @@ import type { WorkflowNode } from '../src/shared/registry' // A realistic ComfyUI "Export (API)" payload for a Flux-style diffusion model. const exported: Record = { - '1': { class_type: 'UNETLoader', inputs: { unet_name: 'their-model.safetensors', weight_dtype: 'default' } }, - '2': { class_type: 'DualCLIPLoader', inputs: { clip_name1: 't5.safetensors', clip_name2: 'clip_l.safetensors', type: 'flux' } }, + '1': { + class_type: 'UNETLoader', + inputs: { unet_name: 'their-model.safetensors', weight_dtype: 'default' } + }, + '2': { + class_type: 'DualCLIPLoader', + inputs: { clip_name1: 't5.safetensors', clip_name2: 'clip_l.safetensors', type: 'flux' } + }, '3': { class_type: 'VAELoader', inputs: { vae_name: 'ae.safetensors' } }, '4': { class_type: 'CLIPTextEncode', inputs: { text: 'a cat they typed once', clip: ['2', 0] } }, '5': { class_type: 'FluxGuidance', inputs: { conditioning: ['4', 0], guidance: 3.5 } }, @@ -43,7 +49,20 @@ describe('importing a ComfyUI "Export (API)" workflow', () => { // Without this the exported prompt, seed and size would be frozen into every // image the user ever generates with this model. const ph = workflowPlaceholders(entry.workflow!) - for (const p of ['unet', 'clip', 'clip2', 'vae', 'prompt', 'negative', 'seed', 'steps', 'width', 'height', 'batch', 'prefix']) + for (const p of [ + 'unet', + 'clip', + 'clip2', + 'vae', + 'prompt', + 'negative', + 'seed', + 'steps', + 'width', + 'height', + 'batch', + 'prefix' + ]) expect(ph, `missing \${${p}}`).toContain(p) }) From e3f2621b5ba55c74a44a213754b8a2d417a0ae63 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:34:30 +0200 Subject: [PATCH 05/15] test(output): test the collision-safe writer production uses, not lookalikes test/output.test.ts exercised uniqueFileInDir/uniqueOutPath - functions with zero production callers - while reserveFileInDir/reserveOutPath (the atomic 'wx' variants behind all 16 output sites) had no test at all; swapping 'wx' for 'w' passed the whole suite. The suite now pins the reserve functions: placeholder exists on return, consecutive reservations never collide, the escalation chain, source never returned, non-EEXIST errnos propagate. The two dead helpers are deleted. Also adds a catalog<->engine cross-check: TOOLS is a Partial record (no compiler exhaustiveness), so a renamed ToolId used to fail only at runtime with "Unknown tool". A minimal electron stub in test/mocks lets unit tests load main-process modules for this. Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/output.ts | 30 ++-------------- test/catalog-tools.test.ts | 23 ++++++++++++ test/mocks/electron.ts | 22 ++++++++++++ test/output.test.ts | 71 +++++++++++++++++++++++++++----------- vitest.config.ts | 7 +++- 5 files changed, 105 insertions(+), 48 deletions(-) create mode 100644 test/catalog-tools.test.ts create mode 100644 test/mocks/electron.ts diff --git a/src/main/output.ts b/src/main/output.ts index 35983d5..dd50130 100644 --- a/src/main/output.ts +++ b/src/main/output.ts @@ -6,33 +6,9 @@ import { basename, dirname, extname, join } from 'path' // the user's source or an existing unrelated file. This is a hard rule. /** - * A collision-free file path in `dir`: `name.ext` -> `name (tag).ext` -> - * `name (tag 2).ext` -> ... The tag names the operation ("converted", - * "compressed", "resized", "upscaled"). - */ -export function uniqueFileInDir(dir: string, name: string, ext: string, tag: string): string { - const e = ext.startsWith('.') ? ext : '.' + ext - let cand = join(dir, name + e) - if (!existsSync(cand)) return cand - cand = join(dir, `${name} (${tag})${e}`) - let n = 2 - while (existsSync(cand)) { - cand = join(dir, `${name} (${tag} ${n})${e}`) - n++ - } - return cand -} - -/** Output next to the source with a new extension, collision-safe. */ -export function uniqueOutPath(sourcePath: string, ext: string, tag: string): string { - const dir = dirname(sourcePath) - const name = basename(sourcePath, extname(sourcePath)) - return uniqueFileInDir(dir, name, ext, tag) -} - -/** - * Like uniqueFileInDir, but ATOMICALLY claims the chosen name by creating an - * empty placeholder (openSync 'wx' — exclusive create). Two jobs running + * A collision-free file path (`name.ext` -> `name (tag).ext` -> + * `name (tag 2).ext` -> ...) that ATOMICALLY claims the chosen name by creating + * an empty placeholder (openSync 'wx' — exclusive create). Two jobs running * concurrently can otherwise pick the same free name before either has written * it (a batch of differently-named sources converting to one target format all * land on `name.ext`); the exclusive create makes the second job skip to the diff --git a/test/catalog-tools.test.ts b/test/catalog-tools.test.ts new file mode 100644 index 0000000..fd68125 --- /dev/null +++ b/test/catalog-tools.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { OPERATIONS } from '../src/shared/catalog' +import { getTool } from '../src/main/tools/registry' + +// TOOLS in tools/registry.ts is a Partial>, which +// removes the compiler's exhaustiveness check: a renamed or added ToolId +// compiles fine and fails at runtime with "Unknown tool: X". This walks the +// UI's own catalog and asserts every operation resolves to an engine module. +// +// 'generate' is the one deliberate exemption: generation runs on its own +// generate:* IPC path (see App.tsx's early return), not through the job queue. +const QUEUE_EXEMPT = new Set(['generate']) + +describe('catalog <-> engine registry', () => { + it('every catalog operation maps to an implemented tool module', () => { + for (const ops of Object.values(OPERATIONS)) { + for (const op of ops) { + if (QUEUE_EXEMPT.has(op.tool)) continue + expect(getTool(op.tool), `tool '${op.tool}' (operation '${op.id}')`).toBeDefined() + } + } + }) +}) diff --git a/test/mocks/electron.ts b/test/mocks/electron.ts new file mode 100644 index 0000000..fd24424 --- /dev/null +++ b/test/mocks/electron.ts @@ -0,0 +1,22 @@ +import { join } from 'path' +import { tmpdir } from 'os' + +// Minimal electron stand-in so unit tests can load main-process modules +// (tools/registry.ts and friends) that import { app } at module scope. Only +// the surface those modules touch at LOAD time is provided; anything that +// would actually need a running Electron throws, which is the correct signal +// that a unit test is reaching too far. + +export const app = { + isPackaged: false, + getAppPath: (): string => process.cwd(), + getPath: (name: string): string => join(tmpdir(), 'filesmith-test-userdata', name) +} + +export const nativeImage = { + createThumbnailFromPath: async (): Promise => { + throw new Error('nativeImage is not available in unit tests') + } +} + +export default { app, nativeImage } diff --git a/test/output.test.ts b/test/output.test.ts index ad26841..a3307d3 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { uniqueFileInDir, uniqueOutPath, uniqueOutDir } from '../src/main/output' +import { reserveFileInDir, reserveOutPath, uniqueOutDir } from '../src/main/output' + +// Collision-safe output naming is a hard rule (never overwrite the user's +// source or an existing file). These tests target the functions PRODUCTION +// actually calls — reserveFileInDir / reserveOutPath, the atomic 'wx' variants +// every tool module uses — not lookalike helpers. let dir: string beforeEach(() => { @@ -12,45 +17,71 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) -describe('uniqueFileInDir', () => { - it('uses the plain name when nothing exists', () => { - expect(uniqueFileInDir(dir, 'photo', '.png', 'converted')).toBe(join(dir, 'photo.png')) +describe('reserveFileInDir', () => { + it('uses the plain name when nothing exists, and leaves a placeholder', () => { + const p = reserveFileInDir(dir, 'photo', '.png', 'converted') + expect(p).toBe(join(dir, 'photo.png')) + // The reservation is the placeholder: it must exist the moment we return, + // so a concurrent job cannot pick the same name. + expect(existsSync(p)).toBe(true) + expect(statSync(p).size).toBe(0) }) - it('adds the tag when the plain name is taken', () => { - writeFileSync(join(dir, 'photo.png'), '') - expect(uniqueFileInDir(dir, 'photo', '.png', 'converted')).toBe( + it('escalates name.ext -> name (tag).ext -> name (tag 2).ext', () => { + writeFileSync(join(dir, 'photo.png'), 'x') + expect(reserveFileInDir(dir, 'photo', '.png', 'converted')).toBe( join(dir, 'photo (converted).png') ) - }) - - it('numbers further collisions', () => { - writeFileSync(join(dir, 'photo.png'), '') - writeFileSync(join(dir, 'photo (converted).png'), '') - writeFileSync(join(dir, 'photo (converted 2).png'), '') - expect(uniqueFileInDir(dir, 'photo', '.png', 'converted')).toBe( + // The reservation above now exists, so the next call must skip past it. + expect(reserveFileInDir(dir, 'photo', '.png', 'converted')).toBe( + join(dir, 'photo (converted 2).png') + ) + expect(reserveFileInDir(dir, 'photo', '.png', 'converted')).toBe( join(dir, 'photo (converted 3).png') ) }) + it('two consecutive reservations never collide', () => { + const a = reserveFileInDir(dir, 'clip', '.mp4', 'compressed') + const b = reserveFileInDir(dir, 'clip', '.mp4', 'compressed') + expect(a).not.toBe(b) + expect(existsSync(a)).toBe(true) + expect(existsSync(b)).toBe(true) + }) + it('normalizes an extension without a leading dot', () => { - expect(uniqueFileInDir(dir, 'a', 'webp', 'converted')).toBe(join(dir, 'a.webp')) + expect(reserveFileInDir(dir, 'a', 'webp', 'converted')).toBe(join(dir, 'a.webp')) + }) + + it('propagates a non-EEXIST errno instead of looping', () => { + // A destination that cannot be created (missing directory) must surface the + // real errno so the friendly-error mapping can name it, not spin forever. + expect(() => reserveFileInDir(join(dir, 'no-such-dir'), 'a', '.png', 'tag')).toThrow() }) }) -describe('uniqueOutPath', () => { +describe('reserveOutPath', () => { it('never returns the source path (mkv -> mp4 when mp4 exists)', () => { writeFileSync(join(dir, 'clip.mkv'), '') writeFileSync(join(dir, 'clip.mp4'), '') // unrelated existing file must be protected - const out = uniqueOutPath(join(dir, 'clip.mkv'), '.mp4', 'converted') + const out = reserveOutPath(join(dir, 'clip.mkv'), '.mp4', 'converted') expect(out).toBe(join(dir, 'clip (converted).mp4')) }) + + it('never returns the source path even for a same-extension operation', () => { + const src = join(dir, 'photo.png') + writeFileSync(src, 'source bytes') + const out = reserveOutPath(src, '.png', 'resized') + expect(out).not.toBe(src) + expect(out).toBe(join(dir, 'photo (resized).png')) + // And the source is untouched. + expect(statSync(src).size).toBeGreaterThan(0) + }) }) describe('uniqueOutDir', () => { it('suffixes a taken directory name', () => { - mkdtempSync(join(dir, 'x')) // ensure dir exists - writeFileSync(join(dir, 'pages'), '') + mkdirSync(join(dir, 'pages')) expect(uniqueOutDir(dir, 'pages')).toBe(join(dir, 'pages (2)')) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 93b9ea3..09308c7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,12 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ resolve: { - alias: { '@shared': resolve(__dirname, 'src/shared') } + alias: { + '@shared': resolve(__dirname, 'src/shared'), + // Main-process modules import { app } from 'electron' at module scope; + // this minimal stub lets unit tests load them without a running Electron. + electron: resolve(__dirname, 'test/mocks/electron.ts') + } }, test: { include: ['test/**/*.test.ts'] From 470ea60fb1ff68148dfa32b0b698d4bd866bc94e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:37:36 +0200 Subject: [PATCH 06/15] fix(jobs): wire cancel end to end, kill process trees, guard dead webContents Cancel existed in the engine (jobQueue.cancel), IPC (job:cancel) and the preload bridge, and had zero renderer callers - no job could be stopped, and removing a running row orphaned its ffmpeg. Now: - In-flight rows get a Stop control in place of the status cell, and a Cancel entry in the context menu (queued rows included - the queue handles both). - dismiss() cancels a queued/running item before dropping the row, so a removed card can no longer leave a headless encode writing untracked files. - run() kills the whole PROCESS TREE on abort (taskkill /T /F on win32). spawn's `signal` option only terminated the direct child, so `uv tool run`'s python and soffice's workers survived cancel/quit, kept GPU memory, and wrote outputs the app had already released. - The PiD/spandrel sidecars write to a temp target copied onto the reserved name on success: a cancelled python run that finishes late can no longer clobber a released (or re-reserved) output path. - Every engine->renderer push in registerIpc goes through one isDestroyed guard (the 200ms estimate ticker could fire into a destroyed webContents during quit). Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/ipc.ts | 23 +++++++++------- src/main/run.ts | 39 ++++++++++++++++++++++++--- src/main/tools/registry.ts | 31 ++++++++++++++++----- src/renderer/src/App.tsx | 27 ++++++++++++++++++- src/renderer/src/components/Queue.tsx | 25 ++++++++++++++--- 5 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a007bb9..f055cde 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -48,7 +48,14 @@ function isSupported(f: FileInfo): boolean { /** Wire the renderer <-> engine channels for one window. */ export function registerIpc(win: BrowserWindow): JobQueue { - const queue = new JobQueue((e: JobEvent) => win.webContents.send('job:event', e)) + // Progress/terminal events can fire while the window is tearing down (the + // estimate ticker runs on a 200ms interval; macOS keeps the app alive with + // no window at all). Sending to a destroyed webContents throws, so every + // engine->renderer push goes through this guard. + const send = (channel: string, payload: unknown): void => { + if (!win.isDestroyed() && !win.webContents.isDestroyed()) win.webContents.send(channel, payload) + } + const queue = new JobQueue((e: JobEvent) => send('job:event', e)) // Custom (frameless) window controls — act on the sender's window so both the // main window and the preview window control themselves. @@ -217,7 +224,7 @@ export function registerIpc(win: BrowserWindow): JobQueue { ipcMain.handle('pid:installing', () => installInProgress()) ipcMain.handle('pid:install', async (_e, backbone = 'flux') => { try { - await installPid(backbone, (step, pct) => win.webContents.send('pid:progress', { step, pct })) + await installPid(backbone, (step, pct) => send('pid:progress', { step, pct })) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } @@ -260,7 +267,7 @@ export function registerIpc(win: BrowserWindow): JobQueue { }) ipcMain.handle('comfy:install', async () => { try { - await installComfyEngine((step, pct) => win.webContents.send('comfy:progress', { step, pct })) + await installComfyEngine((step, pct) => send('comfy:progress', { step, pct })) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } @@ -322,9 +329,7 @@ export function registerIpc(win: BrowserWindow): JobQueue { }) ipcMain.handle('generate:download', async (_e, id: string, model: string) => { try { - await downloadCompanions(model, (p) => - win.webContents.send('generate:download-progress', { id, ...p }) - ) + await downloadCompanions(model, (p) => send('generate:download-progress', { id, ...p })) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } @@ -337,9 +342,9 @@ export function registerIpc(win: BrowserWindow): JobQueue { try { await generateImages( opts, - (index, path) => win.webContents.send('generate:image', { id, index, path }), - (index, pct) => win.webContents.send('generate:progress', { id, index, pct }), - (message) => win.webContents.send('generate:progress', { id, index: -1, message }), + (index, path) => send('generate:image', { id, index, path }), + (index, pct) => send('generate:progress', { id, index, pct }), + (message) => send('generate:progress', { id, index: -1, message }), ctrl.signal ) return { ok: true } diff --git a/src/main/run.ts b/src/main/run.ts index 2ba0819..d5434dc 100644 --- a/src/main/run.ts +++ b/src/main/run.ts @@ -38,10 +38,29 @@ export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise return new Promise((resolve, reject) => { const child = spawn(cmd, args, { windowsHide: true, - cwd: opts.cwd, - signal: opts.signal + cwd: opts.cwd }) + // Abort kills the whole PROCESS TREE, not just the direct child. spawn's + // own `signal` option maps to TerminateProcess on win32, which leaves + // grandchildren running: `uv tool run`'s python and soffice's workers + // survived cancel/quit, kept GPU memory, and later wrote an output path + // the app had already released. + let aborted = false + const onAbort = (): void => { + aborted = true + if (process.platform === 'win32' && child.pid) { + const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + windowsHide: true + }) + killer.on('error', () => child.kill()) + } else { + child.kill('SIGKILL') + } + } + if (opts.signal?.aborted) queueMicrotask(onAbort) + else opts.signal?.addEventListener('abort', onAbort, { once: true }) + let stdout = '' let stderr = '' child.stdout?.on('data', (d: Buffer) => { @@ -53,11 +72,23 @@ export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise opts.onStderr?.(s) }) child.on('error', (e) => { + opts.signal?.removeEventListener('abort', onAbort) // ENOENT/EACCES here mean the image itself is missing or unrunnable, not - // that the conversion failed. Aborts (AbortError) fall through unchanged. + // that the conversion failed. const code = (e as NodeJS.ErrnoException).code reject(code === 'ENOENT' || code === 'EACCES' ? new ToolMissingError(cmd, e) : e) }) - child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr })) + child.on('close', (code) => { + opts.signal?.removeEventListener('abort', onAbort) + if (aborted) { + // Match the AbortError shape spawn's `signal` option used to produce, + // so callers checking ctx.signal.aborted keep working unchanged. + const e = new Error('The operation was aborted') + e.name = 'AbortError' + reject(e) + return + } + resolve({ code: code ?? -1, stdout, stderr }) + }) }) } diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index d199bd9..9cb203d 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -725,9 +725,14 @@ async function upscaleWithPid(file: FileInfo, factor: number, ctx: ToolContext): } output = reserveOutPath(file.path, '.png', 'upscaled') ctx.onProgress(undefined, 'Starting PiD…') + // The sidecar writes to a temp target, copied onto the reserved name only + // on success: a cancelled python run keeps going and writes its target + // LATE, which used to land on a path the app had already released (and + // could clobber a later job's reserved output). + const sidecarOut = join(tmp, 'result.png') const { output: out } = await pidSidecar.upscale( src, - output, + sidecarOut, factor, (phase, detail) => { if (phase === 'starting' || phase === 'loading') { @@ -751,7 +756,8 @@ async function upscaleWithPid(file: FileInfo, factor: number, ctx: ToolContext): // Diffusion output is RGB; carry the source's transparency across like the // Real-ESRGAN path does, so a transparent PNG doesn't come back opaque. await restoreAlpha(file.path, out, ctx, tmp) - return out + copyFileSync(out, output) + return output } catch (e) { try { if (output && existsSync(output)) rmSync(output, { force: true }) @@ -761,7 +767,11 @@ async function upscaleWithPid(file: FileInfo, factor: number, ctx: ToolContext): throw e } finally { stopEst() - rmSync(tmp, { recursive: true, force: true }) + try { + rmSync(tmp, { recursive: true, force: true }) + } catch { + /* a just-killed sidecar may still hold a temp file; the startup sweeper gets it */ + } } } @@ -797,9 +807,13 @@ async function upscaleWithComfy( ? `Upscaling with ${model.name} (background)…` : `Upscaling with ${model.name}…` ctx.onProgress(0, label) + // Temp target, copied onto the reserved name on success — same reasoning + // as the PiD path: a cancelled sidecar run finishes late and must not + // write onto a released (or re-reserved) name. + const sidecarOut = join(tmp, 'result.png') // Background: smaller tiles + a VRAM cap + inter-tile pacing so the GPU stays // free for other apps, at the cost of speed. - const { output: out } = await spandrelSidecar.upscale(model.path, src, output, factor, { + const { output: out } = await spandrelSidecar.upscale(model.path, src, sidecarOut, factor, { tile: background ? 256 : 512, memFraction: background ? 0.3 : 0, paceMs: background ? 150 : 0, @@ -809,7 +823,8 @@ async function upscaleWithComfy( if (!existsSync(out) || statSync(out).size === 0) throw new Error('The upscaler produced no output') await restoreAlpha(file.path, out, ctx, tmp) - return out + copyFileSync(out, output) + return output } catch (e) { try { if (output && existsSync(output)) rmSync(output, { force: true }) @@ -818,7 +833,11 @@ async function upscaleWithComfy( } throw e } finally { - rmSync(tmp, { recursive: true, force: true }) + try { + rmSync(tmp, { recursive: true, force: true }) + } catch { + /* a just-killed sidecar may still hold a temp file; the startup sweeper gets it */ + } } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1af2e7e..d6a5b40 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -360,14 +360,24 @@ export default function App(): JSX.Element { // produced file before dropping the card. function dismiss(ids: string[], column: 'input' | 'output'): void { for (const id of ids) { + const it = cur.items.find((x) => x.id === id) + // Removing an in-flight row must not orphan its process: cancel the job + // first, or ffmpeg keeps encoding headless and writes an untracked file. + if (column === 'input' && it && (it.status === 'queued' || it.status === 'running')) { + void window.filesmith.cancelJob(id) + } if (column === 'output') { - const it = cur.items.find((x) => x.id === id) if (it?.outputPath) void window.filesmith.trashFile(it.outputPath) } dispatch({ type: 'dismiss', id, column }) } } + /** Stop a queued or running job; the engine emits the terminal 'canceled'. */ + function cancelJob(id: string): void { + void window.filesmith.cancelJob(id) + } + // Build the right-click / ⋯ menu for a queue item. Destructive actions apply // to the whole selection when the clicked item is part of a multi-selection; // otherwise just to that one item. @@ -383,6 +393,11 @@ export default function App(): JSX.Element { const n = targets.length if (side === 'input') { + // Cancel applies to whichever of the targeted rows are actually in flight. + const cancellable = targets.filter((id) => { + const it = cur.items.find((x) => x.id === id) + return it != null && (it.status === 'queued' || it.status === 'running') + }) setMenu({ x, y, @@ -393,6 +408,15 @@ export default function App(): JSX.Element { icon: 'folder', onClick: () => window.filesmith.reveal(item.file.path) }, + ...(cancellable.length + ? [ + { + label: cancellable.length > 1 ? `Cancel ${cancellable.length} jobs` : 'Cancel', + icon: 'close' as const, + onClick: () => cancellable.forEach(cancelJob) + } + ] + : []), { sep: true }, { label: n > 1 ? `Remove ${n} from list` : 'Remove from list', @@ -879,6 +903,7 @@ export default function App(): JSX.Element { onItemClick={onItemClick} onOpen={openPreview} onMenu={openMenu} + onCancel={cancelJob} /> )} diff --git a/src/renderer/src/components/Queue.tsx b/src/renderer/src/components/Queue.tsx index c2382c5..1b504c5 100644 --- a/src/renderer/src/components/Queue.tsx +++ b/src/renderer/src/components/Queue.tsx @@ -108,7 +108,8 @@ function InputCard({ compatible, onClick, onOpen, - onMenu + onMenu, + onCancel }: { item: QueueItem tool: ToolId @@ -118,6 +119,7 @@ function InputCard({ onClick: (e: MouseEvent) => void onOpen: () => void onMenu: (x: number, y: number) => void + onCancel: () => void }): JSX.Element { // Determinate only once the job reports a real percentage — a long encode // legitimately sits at 0-1% for a while and must NOT fall back to the fake fill. @@ -174,7 +176,21 @@ function InputCard({
- + {/* An in-flight row is stoppable right where its status shows. */} + {item.status === 'running' || item.status === 'queued' ? ( + + ) : ( + + )}
) @@ -263,7 +279,8 @@ export function Queues({ outThumbs, onItemClick, onOpen, - onMenu + onMenu, + onCancel }: { items: QueueItem[] tool: ToolId @@ -274,6 +291,7 @@ export function Queues({ onItemClick: (id: string, e: MouseEvent) => void onOpen: (side: 'input' | 'output', item: QueueItem) => void onMenu: (side: 'input' | 'output', item: QueueItem, x: number, y: number) => void + onCancel: (id: string) => void }): JSX.Element { const inputs = items.filter(inInput) const done = items.filter(inOutput) @@ -310,6 +328,7 @@ export function Queues({ onClick={(e) => onItemClick(i.id, e)} onOpen={() => onOpen('input', i)} onMenu={(x, y) => onMenu('input', i, x, y)} + onCancel={() => onCancel(i.id)} /> ))} From c5ff0316ba18d28d9a84da05f2ad2c22be36cb39 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:43:33 +0200 Subject: [PATCH 07/15] fix(engine): correct the ffmpeg/magick argument gaps measured in review All verified against the bundled binaries: - Video compress: even dimensions via trunc(iw*s/2)*2 in the scale expression. force_divisible_by is only honoured inside the force_original_aspect_ratio branch, so 3 of 15 scale positions aborted with "width not divisible by 2" - and describeToolError then blamed the source file. scaleResolution mirrors the same formula so the preview is honest. - Audio: explicit stream maps. Without them ffmpeg re-encoded embedded cover art as a video stream - a JPEG cover became a PNG making a "compressed" MP3 3x its source, and an h264 cover failed every M4A encode. Cover art now stream-copies where the container holds it (.mp3/.m4a/.flac) and is dropped where it can't be. `.ac3` gets its encoder; keep-format on a codec with no encoder entry moves to .m4a so codec and container always agree (.amr kept its extension around AAC and 0-byted). - Video compress/convert keep every audio track (-map 0:a?), not just the "best" one; MKV convert keeps subtitles by stream copy. - Video->GIF gets fps cap + width bound + palettegen/paletteuse (bare defaults: ~75 MB/min of 720p at WORSE fidelity). AVI leaves mpeg4's worst quantizer (q=31 -> q:v 5); WebM runs VP9 at cpu-used 4 with row-mt. - Convert quality preset applies only to lossy targets; for PNG, -quality is zlib-level+filter and the shipped default (82) made the LARGEST file - PNG now gets png:compression-level=9, gif/bmp/ico/tiff get nothing. - Compress drops HEIC/HEIF (no bundled encoder: magick warned, exited 0, and wrote junk wearing a .heic name) and BMP (uncompressed, a no-op). magick's "no encode delegate" warning is now treated as failure in runToOutput. - Compressing TIFF passes -compress Zip (re-encoding dropped the source's Zip entirely: measured +59%). - Resize rejects zero/negative dimensions (0x wrote a 1x1 image that passed the size check; -5x silently copied). SVG (review finding 12) needed no change here: the coder modules bundled in c7b574b include the SVG coder, and the bundled magick now rasterises SVGs. Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/tools/compress.ts | 38 ++++++++--- src/main/tools/convert.ts | 87 ++++++++++++++++++++++++ src/main/tools/registry.ts | 21 +++--- src/main/tools/resize.ts | 10 ++- src/shared/compress.ts | 4 +- src/shared/convert.ts | 17 ++--- test/tools.test.ts | 133 +++++++++++++++++++++++++++++++------ 7 files changed, 257 insertions(+), 53 deletions(-) diff --git a/src/main/tools/compress.ts b/src/main/tools/compress.ts index 9f316e7..d3f83a5 100644 --- a/src/main/tools/compress.ts +++ b/src/main/tools/compress.ts @@ -1,6 +1,6 @@ import { extname } from 'path' import { isLosslessAudio, normalizeExt } from '@shared/convert' -import { magickFrame } from './convert' +import { audioMapArgs, magickFrame } from './convert' import { SCALE_MAX, SCALE_MIN, type AudioCodec, type VideoCodec } from '@shared/compress' // Image targets that hold multiple frames — keep all frames (an animated GIF @@ -31,10 +31,12 @@ export function buildCompressArgs(input: string, outDir: string, quality: number * single-frame target reads only `input[0]` (so a multi-frame source doesn't * split); a multi-frame target keeps every frame (animated GIF -> animated WebP). */ export function buildMagickCompressArgs(input: string, output: string, quality: number): string[] { - const src = MULTIFRAME_TARGETS.includes(normalizeExt(extname(output))) - ? input - : magickFrame(input) - return [src, '-quality', String(quality), output] + const e = normalizeExt(extname(output)) + const src = MULTIFRAME_TARGETS.includes(e) ? input : magickFrame(input) + // TIFF: without an explicit -compress, re-encoding DROPPED the source's Zip + // compression entirely (measured +59% on a Zip TIFF at every quality). + const extra = e === '.tiff' ? ['-compress', 'Zip'] : [] + return [src, '-quality', String(quality), ...extra, output] } // --- Video --------------------------------------------------------------------- @@ -70,10 +72,16 @@ export interface VideoOpts { * probe is needed. Audio -> AAC 128k. */ export function buildVideoCompressArgs(input: string, output: string, o: VideoOpts): string[] { - const args = ['-y', '-i', input] + // Keep EVERY audio track (the default selection takes only the "best" one); + // subtitles are left behind - image subs (PGS) cannot become mov_text and + // mapping them would fail the whole compress. + const args = ['-y', '-i', input, '-map', '0:v:0', '-map', '0:a?'] if (o.scale < 100) { const s = Math.max(SCALE_MIN, Math.min(SCALE_MAX, o.scale)) / 100 - args.push('-vf', `scale=w=iw*${s}:h=ih*${s}:force_divisible_by=2`) + // Even dimensions via trunc(x/2)*2 IN the expression: ffmpeg honours + // `force_divisible_by` only inside the force_original_aspect_ratio branch, + // so 3 of the 15 scale positions aborted with "width not divisible by 2". + args.push('-vf', `scale=w=trunc(iw*${s}/2)*2:h=trunc(ih*${s}/2)*2`) } args.push('-c:v', VIDEO_ENCODER[o.codec]) args.push('-preset', o.codec === 'av1' ? '6' : 'medium') @@ -98,7 +106,8 @@ const EXT_ENCODER: Record = { '.aac': 'aac', '.ogg': 'libvorbis', '.opus': 'libopus', - '.wma': 'wmav2' + '.wma': 'wmav2', + '.ac3': 'ac3' } // The output extension for a chosen codec (keep -> source ext). const CODEC_EXT: Record, string> = { @@ -116,7 +125,13 @@ const CODEC_EXT: Record, string> = { */ export function audioOutputExt(codec: AudioCodec, sourceExt: string): string { if (codec !== 'keep') return CODEC_EXT[codec] - return isLosslessAudio(sourceExt) ? '.flac' : normalizeExt(sourceExt) + if (isLosslessAudio(sourceExt)) return '.flac' + const e = normalizeExt(sourceExt) + // "Keep" needs an encoder for the source codec. A source with no entry falls + // back to AAC - and AAC inside the source's container fails outright (.amr + // kept its extension while carrying AAC: "Could not write header"), so the + // container must follow the fallback codec. + return EXT_ENCODER[e] ? e : '.m4a' } export interface AudioOpts { @@ -128,10 +143,11 @@ export interface AudioOpts { /** ffmpeg audio compress to a target codec + bitrate (kbps), or lossless FLAC * when keeping the format of a lossless source. */ export function buildAudioCompressArgs(input: string, output: string, o: AudioOpts): string[] { + const maps = audioMapArgs(extname(output)) if (o.codec === 'keep' && isLosslessAudio(o.sourceExt)) { - return ['-y', '-i', input, '-c:a', 'flac', '-compression_level', '8', output] + return ['-y', '-i', input, ...maps, '-c:a', 'flac', '-compression_level', '8', output] } const enc = o.codec === 'keep' ? (EXT_ENCODER[normalizeExt(o.sourceExt)] ?? 'aac') : AUDIO_ENCODER[o.codec] - return ['-y', '-i', input, '-c:a', enc, '-b:a', `${o.bitrate}k`, output] + return ['-y', '-i', input, ...maps, '-c:a', enc, '-b:a', `${o.bitrate}k`, output] } diff --git a/src/main/tools/convert.ts b/src/main/tools/convert.ts index 5c2e24d..99e0465 100644 --- a/src/main/tools/convert.ts +++ b/src/main/tools/convert.ts @@ -1,6 +1,9 @@ // Engine-side convert helpers. The catalog + rules live in @shared/convert so the // UI uses the exact same data; this module adds the argument builders and quality // mapping the runner needs. +import type { FileKind } from '@shared/types' +import { normalizeExt } from '@shared/convert' + export { canCompress, categoryFormats, @@ -44,3 +47,87 @@ export function qualityNum(q: unknown): number | null { if (q === 'best') return 95 return null } + +// Image targets where -quality is a real lossy dial. +const LOSSY_TARGETS = ['.jpg', '.webp', '.avif', '.jxl'] + +/** + * The magick quality flags for a convert target. `-quality` is only a lossy + * dial for jpg/webp/avif/jxl; for PNG it encodes zlib-level + filter-type, and + * the shipped preset (82) produced the LARGEST output of the three (measured: + * q82 1.75 MB vs 1.01 MB plain). PNG gets max deflate instead; gif/bmp/ico/ + * tiff have no meaningful dial and get nothing. + */ +export function magickQualityArgs(targetExt: string, quality: unknown): string[] { + const e = normalizeExt(targetExt) + if (LOSSY_TARGETS.includes(e)) { + const q = qualityNum(quality) + return q != null ? ['-quality', String(q)] : [] + } + if (e === '.png') return ['-define', 'png:compression-level=9'] + return [] +} + +// Audio containers that can carry an attached-picture stream via stream copy. +const COVER_ART_EXTS = ['.mp3', '.m4a', '.flac'] + +/** + * Stream mapping for an audio encode: always take the audio, carry embedded + * cover art across UN-re-encoded when the target container holds one, and drop + * it otherwise. Without an explicit map, ffmpeg treats the cover as a video + * stream to re-encode: a JPEG cover became a PNG that made a "compressed" MP3 + * three times its source's size, and an h264-coded cover made every M4A encode + * fail outright ("Could not find tag for codec h264"). + */ +export function audioMapArgs(outputExt: string): string[] { + const base = ['-map', '0:a'] + return COVER_ART_EXTS.includes(normalizeExt(outputExt)) + ? [...base, '-map', '0:v?', '-c:v', 'copy'] + : base +} + +// Single-pass palette GIF: cap the frame rate, bound the width, generate and +// apply a per-clip palette. Bare defaults produced ~75 MB/minute of 720p GIF +// at measurably WORSE fidelity (SSIM 0.9501 vs 0.9786 with the palette pass). +const GIF_FILTER = + "[0:v]fps=12,scale='min(480,iw)':-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" + +/** + * Per-target ffmpeg args for a media convert. The bare `-y -i in out` default + * was measured to be wrong for most targets: it drops every audio track but + * the first and all subtitles (no -map), encodes AVI at mpeg4's worst + * quantizer (q=31), runs VP9 at cpu-used 0 (0.66x realtime), and writes + * palette-less GIFs. Subtitles are kept only where they can be stream-copied + * (MKV); converting image subs (PGS) to mov_text fails the whole job, so + * MP4/MOV take video + all audio and leave subs behind. + */ +export function ffmpegExtraFor(kind: FileKind, targetExt: string): string[] { + const e = normalizeExt(targetExt) + if (kind === 'audio') return audioMapArgs(e) + if (kind !== 'video') return [] + if (e === '.gif') return ['-an', '-filter_complex', GIF_FILTER] + if (e === '.webm') + return [ + '-map', + '0:v:0', + '-map', + '0:a?', + '-c:v', + 'libvpx-vp9', + '-crf', + '32', + '-b:v', + '0', + '-row-mt', + '1', + '-cpu-used', + '4', + '-c:a', + 'libopus' + ] + if (e === '.avi') + return ['-map', '0:v:0', '-map', '0:a?', '-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'libmp3lame'] + if (e === '.mkv') return ['-map', '0:v:0', '-map', '0:a?', '-map', '0:s?', '-c:s', 'copy'] + // mp4 / mov: default encoders (libx264 + aac) are right; keep all audio. + return ['-map', '0:v:0', '-map', '0:a?'] +} diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index 9cb203d..eaa9152 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -38,11 +38,12 @@ import { buildMagickArgs, canCompress, convertTargets, + ffmpegExtraFor, isSameFormat, magickExtraFor, magickFrame, + magickQualityArgs, normalizeExt, - qualityNum, toolForKind } from './convert' import { buildResizeArgs, buildResizeSpec, isValidResizeSpec } from './resize' @@ -105,15 +106,20 @@ async function runToOutput( !onStderr && estimateSec ? estimateProgress(estimateSec, (p) => ctx.onProgress(p)) : null try { const { code, stderr } = await run(tool, argsFor(toolOut), { signal: ctx.signal, onStderr }) + // magick prints "no encode delegate for this image format" as a WARNING and + // exits 0 - having written the wrong format into the requested extension. + const delegateFail = /no (en|de)code delegate/i.test(stderr) const wrote = existsSync(toolOut) && (!requireNonEmpty || statSync(toolOut).size > 0) - if (code !== 0 || !wrote) { + if (code !== 0 || !wrote || delegateFail) { // Exit 0 with nothing at the expected path means the tool wrote somewhere // else (a printf expansion we missed) or produced nothing — either way, // never report success for it. throw new Error( - code === 0 && !wrote - ? `${label} reported success but wrote no output` - : describeToolError(stderr, label, code) + delegateFail + ? `${label} cannot encode this format on this machine (missing encoder)` + : code === 0 && !wrote + ? `${label} reported success but wrote no output` + : describeToolError(stderr, label, code) ) } if (tmp) copyFileSync(toolOut, output) @@ -265,7 +271,7 @@ const convertTool: ToolModule = { if (duration) ctx.onProgress(0, 'Converting…') return runToOutput( resolveTool(kindTool), - (out) => buildFfmpegArgs(file.path, out), + (out) => buildFfmpegArgs(file.path, out, ffmpegExtraFor(file.kind, targetExt)), output, ctx, kindTool, @@ -273,8 +279,7 @@ const convertTool: ToolModule = { ffmpegProgress(duration, (pct, eta) => ctx.onProgress(pct, 'Converting…', eta)) ) } - const q = qualityNum(options.quality) - const extra = [...magickExtraFor(targetExt), ...(q != null ? ['-quality', String(q)] : [])] + const extra = [...magickExtraFor(targetExt), ...magickQualityArgs(targetExt, options.quality)] // Single-frame target: read only the first frame so a multi-frame source // doesn't split into out-0/out-1/… and leave the exact output path empty. const src = MULTIFRAME_TARGETS.includes(normalizeExt(targetExt)) diff --git a/src/main/tools/resize.ts b/src/main/tools/resize.ts index 87bd2cc..712a0aa 100644 --- a/src/main/tools/resize.ts +++ b/src/main/tools/resize.ts @@ -32,11 +32,19 @@ export function buildResizeSpec(options: JobOptions): string { * mode with a blank/zero/non-finite value (`"0%"`, `"NaN%"`). */ export function isValidResizeSpec(spec: string): boolean { - if (/^x!?$/.test(spec)) return false if (spec.endsWith('%')) { const n = Number(spec.slice(0, -1)) return Number.isFinite(n) && n > 0 } + // Dimensions: every present side must be a positive integer. `0x` made + // magick exit 0 with a 1x1 image (passing the size>0 check), and `-5x` + // silently copied the file unchanged. + const m = /^(\d*)x(\d*)!?$/.exec(spec) + if (!m) return false + const [, w, h] = m + if (!w && !h) return false + if (w && Number(w) < 1) return false + if (h && Number(h) < 1) return false return true } diff --git a/src/shared/compress.ts b/src/shared/compress.ts index 6b996cc..a72edd0 100644 --- a/src/shared/compress.ts +++ b/src/shared/compress.ts @@ -42,7 +42,9 @@ export const SCALE_STEP = 5 export function scaleResolution(w: number, h: number, pct: number): { w: number; h: number } { const s = Math.max(SCALE_MIN, Math.min(SCALE_MAX, pct)) / 100 if (s >= 1 || w <= 0 || h <= 0) return { w, h } - const even = (n: number): number => Math.max(2, Math.round((n * s) / 2) * 2) + // trunc, not round: mirrors the exact ffmpeg expression the encoder runs + // (`trunc(iw*s/2)*2`), so the preview promises the pixels ffmpeg produces. + const even = (n: number): number => Math.max(2, Math.trunc((n * s) / 2) * 2) return { w: even(w), h: even(h) } } diff --git a/src/shared/convert.ts b/src/shared/convert.ts index 3827293..bd27119 100644 --- a/src/shared/convert.ts +++ b/src/shared/convert.ts @@ -137,18 +137,11 @@ export const LOSSLESS_AUDIO_EXTS = ['.flac', '.wav', '.aiff', '.aif'] // exotic exts (svg, xcf, tga, ppm, mpo) are excluded — "compress to same ext" // there would silently rasterize into a broken file that still passes a size>0 // check, so they must not be offered. -export const COMPRESSIBLE_IMAGE_EXTS = [ - '.jpg', - '.png', - '.webp', - '.gif', - '.tiff', - '.avif', - '.jxl', - '.heic', - '.heif', - '.bmp' -] +// HEIC/HEIF are absent for the same reason as the vector formats: the bundled +// magick has no HEIC ENCODER, prints "no encode delegate" as a warning, exits +// 0, and leaves a junk file wearing a .heic name. BMP is absent because BMP is +// uncompressed - a "compress" that changes nothing at every quality is a lie. +export const COMPRESSIBLE_IMAGE_EXTS = ['.jpg', '.png', '.webp', '.gif', '.tiff', '.avif', '.jxl'] /** Whether the Compress tool supports a given file. Images: only formats the * compressors handle (Caesium set + raster magick); video, audio and PDF always diff --git a/test/tools.test.ts b/test/tools.test.ts index 2e333ec..d97f60f 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { buildFfmpegArgs, buildMagickArgs } from '../src/main/tools/convert' +import { + buildFfmpegArgs, + buildMagickArgs, + ffmpegExtraFor, + magickQualityArgs +} from '../src/main/tools/convert' import { categoryFormats, convertTargets, @@ -217,6 +222,12 @@ describe('compress', () => { '-y', '-i', 'in.mkv', + // every audio track, not just the "best" one; subs stay behind (PGS + // cannot become mov_text and would fail the job) + '-map', + '0:v:0', + '-map', + '0:a?', '-c:v', 'libx264', '-preset', @@ -253,7 +264,10 @@ describe('compress', () => { scale: 50 }) expect(a).toContain('-vf') - expect(a[a.indexOf('-vf') + 1]).toBe('scale=w=iw*0.5:h=ih*0.5:force_divisible_by=2') + // trunc(x/2)*2 in the expression: `force_divisible_by` is honoured only + // inside the force_original_aspect_ratio branch, so odd results (854 x + // 0.25 = 213) aborted the encode with "width not divisible by 2". + expect(a[a.indexOf('-vf') + 1]).toBe('scale=w=trunc(iw*0.5/2)*2:h=trunc(ih*0.5/2)*2') // 100% (original) adds no filter at all const b = buildVideoCompressArgs('in.mp4', 'out.mp4', { codec: 'h264', @@ -270,17 +284,45 @@ describe('compress', () => { bitrate: 96, sourceExt: '.wav' }) - ).toEqual(['-y', '-i', 'in.wav', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) + ).toEqual(['-y', '-i', 'in.wav', '-map', '0:a', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) expect( buildAudioCompressArgs('in.ogg', 'out.ogg', { codec: 'keep', bitrate: 128, sourceExt: '.ogg' }) - ).toEqual(['-y', '-i', 'in.ogg', '-c:a', 'libvorbis', '-b:a', '128k', 'out.ogg']) + ).toEqual(['-y', '-i', 'in.ogg', '-map', '0:a', '-c:a', 'libvorbis', '-b:a', '128k', 'out.ogg']) expect(audioOutputExt('mp3', '.m4a')).toBe('.mp3') expect(audioOutputExt('aac', '.wav')).toBe('.m4a') expect(audioOutputExt('keep', '.ogg')).toBe('.ogg') + // keep on a codec with no encoder entry must move to a container that + // matches the AAC fallback (.amr kept its ext and failed to mux) + expect(audioOutputExt('keep', '.amr')).toBe('.m4a') + expect(audioOutputExt('keep', '.ac3')).toBe('.ac3') + // cover art rides across un-re-encoded where the container supports it; + // without the map ffmpeg re-encoded a JPEG cover into a PNG 3x the source + expect( + buildAudioCompressArgs('in.mp3', 'out.mp3', { + codec: 'keep', + bitrate: 128, + sourceExt: '.mp3' + }) + ).toEqual([ + '-y', + '-i', + 'in.mp3', + '-map', + '0:a', + '-map', + '0:v?', + '-c:v', + 'copy', + '-c:a', + 'libmp3lame', + '-b:a', + '128k', + 'out.mp3' + ]) }) it('builds Ghostscript PDF compress args per level + grayscale', () => { @@ -361,25 +403,18 @@ describe('canCompress', () => { expect(canCompress('pdf', '.pdf')).toBe(true) }) it('accepts raster images the compressors handle, not vector/exotic exts', () => { - for (const e of [ - '.png', - '.jpg', - '.jpeg', - '.webp', - '.gif', - '.tif', - '.tiff', - '.avif', - '.jxl', - '.bmp', - '.heic' - ]) + for (const e of ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.tif', '.tiff', '.avif', '.jxl']) expect(canCompress('image', e)).toBe(true) // vector / layered / exotic: would silently rasterize -> excluded expect(canCompress('image', '.svg')).toBe(false) expect(canCompress('image', '.xcf')).toBe(false) expect(canCompress('image', '.tga')).toBe(false) expect(canCompress('image', '.ppm')).toBe(false) + // HEIC/HEIF: the bundled magick has no HEIC encoder (exit 0 + junk file); + // BMP: uncompressed, a "compress" would be a no-op at every quality + expect(canCompress('image', '.heic')).toBe(false) + expect(canCompress('image', '.heif')).toBe(false) + expect(canCompress('image', '.bmp')).toBe(false) }) it('accepts all audio, including lossless (flac/wav -> opus is a big win)', () => { expect(canCompress('audio', '.mp3')).toBe(true) @@ -398,15 +433,31 @@ describe('canCompress', () => { bitrate: 192, sourceExt: '.wav' }) - ).toEqual(['-y', '-i', 'in.wav', '-c:a', 'flac', '-compression_level', '8', 'out.flac']) - // an explicit lossy codec still applies the bitrate + ).toEqual([ + '-y', + '-i', + 'in.wav', + '-map', + '0:a', + '-map', + '0:v?', + '-c:v', + 'copy', + '-c:a', + 'flac', + '-compression_level', + '8', + 'out.flac' + ]) + // an explicit lossy codec still applies the bitrate (opus: cover dropped, + // the ogg container takes no attached-picture stream copy) expect( buildAudioCompressArgs('in.wav', 'out.opus', { codec: 'opus', bitrate: 96, sourceExt: '.wav' }) - ).toEqual(['-y', '-i', 'in.wav', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) + ).toEqual(['-y', '-i', 'in.wav', '-map', '0:a', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) }) it('rejects documents, text, and unknown kinds', () => { expect(canCompress('document', '.docx')).toBe(false) @@ -414,3 +465,45 @@ describe('canCompress', () => { expect(canCompress('other', '.bin')).toBe(false) }) }) + +describe('per-target convert args', () => { + it('applies -quality only to lossy image targets', () => { + expect(magickQualityArgs('.jpg', 'balanced')).toEqual(['-quality', '82']) + expect(magickQualityArgs('.webp', 'smaller')).toEqual(['-quality', '60']) + // PNG: -quality is zlib-level+filter, not a lossy dial - the old default + // (82) produced the LARGEST file. Max deflate instead. + expect(magickQualityArgs('.png', 'balanced')).toEqual(['-define', 'png:compression-level=9']) + // no meaningful dial for these + expect(magickQualityArgs('.gif', 'best')).toEqual([]) + expect(magickQualityArgs('.ico', 'best')).toEqual([]) + }) + + it('gives video targets real encoder settings instead of bare defaults', () => { + // GIF: palette pass + fps cap + width bound (bare defaults: ~75 MB/min) + const gif = ffmpegExtraFor('video', '.gif') + expect(gif).toContain('-filter_complex') + expect(gif.join(' ')).toContain('palettegen') + expect(gif).toContain('-an') + // WebM: vp9 with row-mt and a realtime-capable cpu-used (0 ran 0.66x) + const webm = ffmpegExtraFor('video', '.webm') + expect(webm).toContain('libvpx-vp9') + expect(webm.join(' ')).toContain('-row-mt 1') + // AVI: not mpeg4's worst quantizer + expect(ffmpegExtraFor('video', '.avi').join(' ')).toContain('-q:v 5') + // MKV keeps subtitles by stream copy; MP4 keeps all audio, no subs + expect(ffmpegExtraFor('video', '.mkv').join(' ')).toContain('-map 0:s? -c:s copy') + expect(ffmpegExtraFor('video', '.mp4')).toEqual(['-map', '0:v:0', '-map', '0:a?']) + // audio convert carries cover art where the container supports it + expect(ffmpegExtraFor('audio', '.mp3')).toEqual(['-map', '0:a', '-map', '0:v?', '-c:v', 'copy']) + expect(ffmpegExtraFor('audio', '.ogg')).toEqual(['-map', '0:a']) + }) + + it('rejects zero and negative resize dimensions', () => { + // 0x made magick exit 0 with a 1x1 image; -5x silently copied unchanged + expect(isValidResizeSpec('0x')).toBe(false) + expect(isValidResizeSpec('x0')).toBe(false) + expect(isValidResizeSpec('-5x')).toBe(false) + expect(isValidResizeSpec('800x')).toBe(true) + expect(isValidResizeSpec('800x600!')).toBe(true) + }) +}) From 01291d70b5a63126fa18c89e229cafb8faa6cbf4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:51:23 +0200 Subject: [PATCH 08/15] fix(registry): validate what is USED, verify what is FETCHED Registry contract: - Partial overrides work now. The loader validated each layer's FRAGMENT, so the documented {"id":"x","companions":[...]} override was dropped for "missing kind/label" (console.warn only) and the "fix a dead URL in thirty seconds" path silently did nothing. mergeRegistryChecked validates each overlay's MERGED result; an override that would make its entry invalid is rejected with a warning and the previous state kept. registry:import and saveUserPack validate against the merged base the same way. - engineSpec is constrained to a bare PEP 508 requirement (no URLs, flags, or whitespace) - it is spliced into `uv pip install`, so an imported "model pack" JSON was code execution on the next Install click. ensureSpandrel additionally refuses a user-layer engineSpec outright. - Hand-edited user registry files (registry:open-folder invites them) now take effect on the next panel refresh via an mtime fingerprint, not the next app restart. Downloads and integrity: - Every pid/install.ts download (uv zip, vendored source, 2.6 GB checkpoint, VAE) now goes through the trust-on-first-use ledger like the companion path; they were the only fetches that bypassed it entirely. PID_REPO_REF is pinned to a commit sha instead of the moving `main`. - A dropped socket keeps the .part (that is what resume EXISTS for; it was deleted on exactly that failure), with a sidecar meta so only bytes from the same URL are ever resumed - splicing two mirrors' files together was possible before. Verification failures still discard everything. - recordHash refuses an empty digest (a resumed transfer hashes only its tail; recording '' anchored future verifications to nothing). - generate:download joins an in-flight download for the same model instead of racing its .part: the old renderer-local guard died on unmount, and the second starter deleted the first's completed file mid-rename, then re-fetched ~5 GB from the unverified mirror. - Companion completeness compares against the registry's declared byte size, not size>0, and a disk-space precheck runs against the volume the companions actually land on. GGUF: the header reader's refill computed its file offset from a stale `consumed`, so any header past 1 MB misread everything after the first refill and the model went unrecognized. Regression test proves the old code fails and the fix parses. Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/generate/archRegistry.ts | Bin 8487 -> 8555 bytes src/main/generate/companions.ts | 16 +++- src/main/generate/ggufScan.ts | 10 ++- src/main/generate/models.ts | 1 + src/main/ipc.ts | 41 +++++++-- src/main/net/download.ts | 34 ++++++-- src/main/net/integrity.ts | 3 + src/main/pid/install.ts | 27 ++++-- src/main/registry/load.ts | 58 ++++++++++--- src/main/registry/userLayer.ts | 21 ++++- src/shared/genArch.ts | 2 + src/shared/registry.ts | 55 ++++++++++++ test/gguf.test.ts | 24 ++++++ test/registry-hardening.test.ts | 136 ++++++++++++++++++++++++++++++ 14 files changed, 387 insertions(+), 41 deletions(-) create mode 100644 test/registry-hardening.test.ts diff --git a/src/main/generate/archRegistry.ts b/src/main/generate/archRegistry.ts index f9d9775abdcf4f4f95a382b22f9799364dd625b4..d12426da0e432aff5dc20c40bdba193447f85adb 100644 GIT binary patch delta 176 zcmZ4P^xA2ID_cF60uZES=A`B&=B8RH6qgib=B0xLON(;Q`Nc4S=vc6l#DapN{EFbr zDpZ}t8Hq-wX7(_}V5LcwC8@o>YNe)F0D3P delta 86 zcmaFuwA^WfE1SMTT4qjaUSe*ll|pezQD$DcwL)o84hp9j#*2=%p4`YLENHDzoRMf` SYGx0U*u0mmpJ{P6rvv~=C>{?0 diff --git a/src/main/generate/companions.ts b/src/main/generate/companions.ts index fdd0baa..9d36d55 100644 --- a/src/main/generate/companions.ts +++ b/src/main/generate/companions.ts @@ -2,6 +2,7 @@ import { existsSync, statSync } from 'fs' import { join } from 'path' import { downloadFile } from '../net/download' import { expectedHash, recordHash } from '../net/integrity' +import { checkDiskSpace } from '../pid/install' import { findGenerationModel, primaryModelsDir } from './models' /** Parse an approxSize like "8 GB" / "335 MB" into bytes (0 if unparseable). */ @@ -48,13 +49,24 @@ export async function downloadCompanions( const root = primaryModelsDir(model.baseDir) if (!root) throw new Error('Could not find your ComfyUI models folder to download into.') + // Refuse before a multi-GB download that cannot fit — measured on the volume + // the companions actually land on (the ComfyUI tree), not pidRoot's. ENOSPC + // otherwise surfaces gigabytes in, as a raw stream error. + const needed = missing.reduce((a, f) => a + (f.bytes ?? approxBytes(f.approxSize)), 0) + const space = checkDiskSpace(needed, root) + if (!space.ok) throw new Error(space.reason) + const total = missing.length for (let i = 0; i < missing.length; i += 1) { const f = missing[i] const dest = join(root, f.subdir, f.filename) - // Skip a companion already fetched (non-empty file present). + // Skip a companion already fetched. "Complete" means matching the declared + // byte size when the registry knows it, not merely non-empty: a file some + // other tool is mid-write (or a stale copy of a corrected upstream file) + // must not read as done forever. try { - if (existsSync(dest) && statSync(dest).size > 0) { + const st = existsSync(dest) ? statSync(dest) : null + if (st && st.size > 0 && (f.bytes == null || st.size === f.bytes)) { onProgress({ index: i + 1, total, label: f.label, filename: f.filename, pct: 100 }) continue } diff --git a/src/main/generate/ggufScan.ts b/src/main/generate/ggufScan.ts index b4e2731..63902da 100644 --- a/src/main/generate/ggufScan.ts +++ b/src/main/generate/ggufScan.ts @@ -40,17 +40,21 @@ class Cursor { /** Ensure `n` bytes are available from the current position. */ private need(n: number): void { if (this.pos + n <= this.buf.length) return - if (this.consumed + n > MAX_HEADER_BYTES) throw new Error('gguf header too large') + // Retire the consumed prefix FIRST: `at` must be computed from the updated + // `consumed`, or every refill after the first re-reads from the wrong file + // offset (the old code parsed a 50 KB header fine and lost the identical + // header the moment it crossed 1 MB). const keep = this.buf.subarray(this.pos) + this.consumed += this.pos + this.pos = 0 + if (this.consumed + keep.length + n > MAX_HEADER_BYTES) throw new Error('gguf header too large') const want = Math.max(CHUNK, n) const next = Buffer.alloc(want) const at = this.consumed + keep.length if (at >= this.size) throw new Error('gguf truncated') const got = readSync(this.fd, next, 0, Math.min(want, this.size - at), at) if (got <= 0) throw new Error('gguf truncated') - this.consumed += this.pos this.buf = Buffer.concat([keep, next.subarray(0, got)]) - this.pos = 0 if (this.buf.length < n) throw new Error('gguf truncated') } diff --git a/src/main/generate/models.ts b/src/main/generate/models.ts index 3580ffe..5915d46 100644 --- a/src/main/generate/models.ts +++ b/src/main/generate/models.ts @@ -268,6 +268,7 @@ export function scanGenerationModels(): GenModelScan { url: m.download.url, urls: m.download.urls, sha256: m.download.sha256, + bytes: m.download.bytes, approxSize: m.download.approxSize, subdir: m.download.subdir })) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f055cde..bd050f7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -35,7 +35,12 @@ import { registryDimCaps, downloadCompanions } from './generate' -import { loadRegistry, layerDir, ensureUserLayers } from './registry/load' +import { + loadRegistry, + layerDir, + ensureUserLayers, + invalidateRegistryIfChanged +} from './registry/load' import { importRegistryJson } from './registry/userLayer' import type { GenerateOptions } from '@shared/generate' @@ -317,6 +322,9 @@ export function registerIpc(win: BrowserWindow): JobQueue { // --- Text-to-image generation (via headless ComfyUI) ----------------------- ipcMain.handle('generate:status', async () => { + // Pick up hand-edited user registry files (registry:open-folder invites + // exactly that) without an app restart. + invalidateRegistryIfChanged() const scan = scanGenerationModels() return { available: await comfyGenerationAvailable(), @@ -327,13 +335,29 @@ export function registerIpc(win: BrowserWindow): JobQueue { registryWarnings: loadRegistry().warnings } }) - ipcMain.handle('generate:download', async (_e, id: string, model: string) => { - try { - await downloadCompanions(model, (p) => send('generate:download-progress', { id, ...p })) - return { ok: true } - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } - } + // One in-flight companion download per model. The renderer's only guard was + // component-local state on a conditionally-mounted card, so two clicks (or a + // navigate-away-and-back) raced the same .part file: the second starter + // deleted the first's completed file mid-rename, then re-fetched ~5 GB from + // the UNVERIFIED mirror. A second caller now joins the running download. + const companionDownloads = new Map>() + ipcMain.handle('generate:download', (_e, id: string, model: string) => { + const running = companionDownloads.get(model) + if (running) return running + const p = (async (): Promise<{ ok: boolean; error?: string }> => { + try { + await downloadCompanions(model, (prog) => + send('generate:download-progress', { id, ...prog }) + ) + return { ok: true } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } finally { + companionDownloads.delete(model) + } + })() + companionDownloads.set(model, p) + return p }) const genControllers = new Map() ipcMain.handle('generate:run', async (_e, id: string, opts: GenerateOptions) => { @@ -381,6 +405,7 @@ export function registerIpc(win: BrowserWindow): JobQueue { return true }) ipcMain.handle('registry:info', () => { + invalidateRegistryIfChanged() const { entries, warnings } = loadRegistry() return { folder: layerDir('user'), diff --git a/src/main/net/download.ts b/src/main/net/download.ts index c364d65..c76dc7e 100644 --- a/src/main/net/download.ts +++ b/src/main/net/download.ts @@ -1,5 +1,13 @@ import { createHash } from 'crypto' -import { createWriteStream, mkdirSync, renameSync, rmSync, statSync } from 'fs' +import { + createWriteStream, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'fs' import { dirname } from 'path' import { Readable, Transform } from 'stream' import { pipeline } from 'stream/promises' @@ -137,18 +145,22 @@ async function downloadOne( } arm() const part = `${dest}.part` + const partMeta = `${part}.meta` // Resume where we left off. Neither downloader sent a Range header or reused // an existing part file — both opened with rmSync — so a 2.6 GB checkpoint // that dropped at 95% restarted from zero, every time. Only attempted when no // hash is expected for the whole file, since a partial body can't be verified - // against one without re-reading what is already on disk. + // against one without re-reading what is already on disk. And only for a part + // whose sidecar meta says it came from THIS url: resuming another mirror's + // bytes would splice two possibly-different files together. let resumeAt = 0 if (!opts.sha256) { try { const st = statSync(part) - if (st.isFile() && st.size > 0) resumeAt = st.size + const m = JSON.parse(readFileSync(partMeta, 'utf-8')) as { url?: string } + if (st.isFile() && st.size > 0 && m.url === url) resumeAt = st.size } catch { - /* no part file — a fresh start */ + /* no part/meta — a fresh start */ } } try { @@ -182,6 +194,7 @@ async function downloadOne( ) if (!resuming) { rmSync(part, { force: true }) + rmSync(partMeta, { force: true }) resumeAt = 0 } @@ -206,14 +219,24 @@ async function downloadOne( { signal: ac.signal } ) } catch (e) { - rmSync(part, { force: true }) + // KEEP the .part: a dropped socket is exactly the failure resume exists + // for (the old code deleted it here, so resume only ever survived a hard + // process kill). The meta records which URL the bytes belong to. + try { + writeFileSync(partMeta, JSON.stringify({ url })) + } catch { + /* best effort */ + } throw describeNetworkError(e, url) } // A resumed transfer only hashed the tail, so the digest is meaningless — // report it as empty rather than as a wrong-but-confident value. const digest = resuming ? '' : hash.digest('hex') const fail = (msg: string): never => { + // A VERIFICATION failure (wrong size, wrong hash) discards the bytes — + // unlike a transport failure, these bytes can never become a good file. rmSync(part, { force: true }) + rmSync(partMeta, { force: true }) throw new Error(msg) } if (total && got < total) fail(`Download incomplete: got ${got} of ${total} bytes from ${url}`) @@ -228,6 +251,7 @@ async function downloadOne( ) rmSync(dest, { force: true }) + rmSync(partMeta, { force: true }) renameSync(part, dest) opts.onPct?.(100) return { bytes: got, sha256: digest, url } diff --git a/src/main/net/integrity.ts b/src/main/net/integrity.ts index 73d4151..41abdcd 100644 --- a/src/main/net/integrity.ts +++ b/src/main/net/integrity.ts @@ -71,6 +71,9 @@ export function expectedHash(url: string, declared?: string): string | undefined /** Record a completed download. A declared hash was already verified upstream. */ export function recordHash(url: string, sha256: string, bytes: number): void { + // A resumed transfer hashes only its tail and reports '' — recording that + // would anchor future verifications to an empty string. + if (!sha256) return const l = read() if (l[url]?.sha256 === sha256) return let host = url diff --git a/src/main/pid/install.ts b/src/main/pid/install.ts index d220790..cc95397 100644 --- a/src/main/pid/install.ts +++ b/src/main/pid/install.ts @@ -12,6 +12,7 @@ import { import { basename, dirname, join } from 'path' import { run } from '../run' import { downloadFile } from '../net/download' +import { expectedHash, recordHash } from '../net/integrity' import { resolveUv } from '../toolResolver' import { findComfyPidWeights } from '../comfy/discover' import { PID_BACKBONES, pidEnvMarker, pidRepoDir, pidRoot, spandrelMarker } from './paths' @@ -37,8 +38,11 @@ const VAE_APPROX_BYTES = 335_000_000 // code, neither could tell which, and neither could ever receive a fix, because // the marker's mere existence counted as "up to date". The ref is now written // into the marker and a mismatch forces a re-vendor. -const PID_REPO_REF = 'main' -const PID_REPO_ZIP = `https://github.com/nv-tlabs/PiD/archive/refs/heads/${PID_REPO_REF}.zip` +// Pinned to a COMMIT, not the moving `main`: two installs a month apart must +// run the same vendored code, and the marker comparison below can only mean +// something when the ref itself is immutable. +const PID_REPO_REF = '2c8814c2b91cc41a2be7809962c891e0d0ccff5f' +const PID_REPO_ZIP = `https://github.com/nv-tlabs/PiD/archive/${PID_REPO_REF}.zip` const HF_BASE = 'https://huggingface.co/nvidia/PiD/resolve/main' // Written at the very end of ensureRepo (after the pin is relaxed), so an @@ -83,7 +87,12 @@ async function download( onPct?: (pct: number) => void, minBytes?: number ): Promise { - await downloadFile(url, dest, { onPct, minBytes }) + // Trust-on-first-use like every other download in the app: the uv zip, the + // vendored source and the multi-GB weights used to be the ONLY fetches that + // bypassed the integrity ledger entirely - accepted on a size floor, never + // recorded, so a re-download could silently differ from what was installed. + const result = await downloadFile(url, dest, { onPct, minBytes, sha256: expectedHash(url) }) + recordHash(result.url, result.sha256, result.bytes) } /** Copy a file into place atomically (temp + rename), so an interrupted copy @@ -123,8 +132,8 @@ async function ensureRepo(onProgress: InstallProgress): Promise { const ex = await run(winTar(), ['-xf', tmpZip, '-C', extractTo]) if (ex.code !== 0) throw new Error(`PiD source extract failed: ${ex.stderr.slice(-400)}`) - const inner = join(extractTo, 'PiD-main') - if (!existsSync(inner)) throw new Error('PiD source extract produced no PiD-main folder') + const inner = join(extractTo, `PiD-${PID_REPO_REF}`) + if (!existsSync(inner)) throw new Error('PiD source extract produced no PiD-* folder') rmSync(pidRepoDir(), { recursive: true, force: true }) renameSync(inner, pidRepoDir()) rmSync(extractTo, { recursive: true, force: true }) @@ -371,7 +380,13 @@ async function ensureSpandrel(onProgress: InstallProgress): Promise { // so whatever spandrel resolved on setup day was frozen forever — and a model // with a newer architecture then reported "could not be read" with no way in // the UI to update the loader. - const spec = registryEntry('spandrel')?.engineSpec ?? 'spandrel>=0.4.1' + // Never honour a USER-layer engineSpec: with field-by-field merge an + // imported pack can override this string, and it is spliced into + // `uv pip install`. validateEntry constrains its shape; provenance + // constrains who may set it. + const entry = registryEntry('spandrel') + const spec = + (entry && entry.provenance.source !== 'user' && entry.engineSpec) || 'spandrel>=0.4.1' if (markerSays(spandrelMarker(), spec)) return const uv = await ensureUv(onProgress) const python = join(pidRepoDir(), '.venv', 'Scripts', 'python.exe') diff --git a/src/main/registry/load.ts b/src/main/registry/load.ts index 1e72a12..de30be0 100644 --- a/src/main/registry/load.ts +++ b/src/main/registry/load.ts @@ -1,8 +1,8 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs' import { join } from 'path' import { app } from 'electron' import type { RegistryEntry, RegistryFile } from '@shared/registry' -import { REGISTRY_SCHEMA_VERSION, mergeRegistry, validateEntry } from '@shared/registry' +import { REGISTRY_SCHEMA_VERSION, mergeRegistryChecked } from '@shared/registry' /** * The three-layer model registry. @@ -75,7 +75,7 @@ export function ensureUserLayers(): void { } } -function readLayer(layer: Layer, warnings: string[]): RegistryFile[] { +function readLayer(layer: Layer, warnings: string[]): { file: string; entries: RegistryEntry[] }[] { const dir = layerDir(layer) if (!dir) return [] let files: string[] @@ -84,7 +84,7 @@ function readLayer(layer: Layer, warnings: string[]): RegistryFile[] { } catch { return [] // a missing layer is normal, not an error } - const out: RegistryFile[] = [] + const out: { file: string; entries: RegistryEntry[] }[] = [] for (const f of files.sort()) { const path = join(dir, f) let parsed: RegistryFile @@ -112,20 +112,49 @@ function readLayer(layer: Layer, warnings: string[]): RegistryFile[] { warnings.push(`${layer}/${f}: entry "${e?.id}" needs a newer Filesmith — skipped`) continue } - const errs = validateEntry(e) - if (errs.length) { - warnings.push(`${layer}/${f}: ${errs.join('; ')} — entry skipped`) - continue - } - // Provenance is assigned by the LAYER, never trusted from the file: an - // entry cannot claim to be built-in by writing so. + // Validation happens on the MERGED entry (mergeRegistryChecked), not the + // fragment: a companions-only override has no kind/label of its own. + // Provenance is still assigned by the LAYER, never trusted from the file. entries.push({ ...e, provenance: { ...(e.provenance ?? {}), source: layer } }) } - out.push({ schemaVersion: parsed.schemaVersion ?? 1, entries }) + out.push({ file: `${layer}/${f}`, entries }) } return out } +/** mtimes of the WRITABLE layers' files, so a hand-edited user file (the + * registry:open-folder flow actively invites it) is picked up without an app + * restart instead of being ignored until the next launch. */ +function layerFingerprint(): string { + const parts: string[] = [] + for (const l of ['channel', 'user'] as const) { + const dir = layerDir(l) + if (!dir) continue + try { + for (const f of readdirSync(dir).sort()) { + if (!f.toLowerCase().endsWith('.json')) continue + try { + parts.push(`${l}/${f}:${statSync(join(dir, f)).mtimeMs}`) + } catch { + /* raced a delete */ + } + } + } catch { + /* missing layer */ + } + } + return parts.join('|') +} + +let fingerprint = '' + +/** Drop the cache when a writable layer changed on disk. Called from the IPC + * status endpoints so an edit takes effect on the next panel refresh. */ +export function invalidateRegistryIfChanged(): void { + if (!cache) return + if (layerFingerprint() !== fingerprint) cache = null +} + let cache: RegistryLoadResult | null = null /** Load + merge all three layers. Cached; call `reloadRegistry` after a change. */ @@ -141,8 +170,11 @@ export function loadRegistry(): RegistryLoadResult { warnings.push( 'No model registry found. Filesmith ships one in resources/registry — this installation looks incomplete.' ) + const merged = mergeRegistryChecked(layers) + warnings.push(...merged.warnings) for (const w of warnings) console.warn('[registry]', w) - cache = { entries: mergeRegistry(layers), warnings } + fingerprint = layerFingerprint() + cache = { entries: merged.entries, warnings } return cache } diff --git a/src/main/registry/userLayer.ts b/src/main/registry/userLayer.ts index cf64d42..39c050f 100644 --- a/src/main/registry/userLayer.ts +++ b/src/main/registry/userLayer.ts @@ -7,7 +7,7 @@ import { validateEntry, workflowPlaceholders } from '@shared/registry' -import { ensureUserLayers, layerDir, reloadRegistry } from './load' +import { ensureUserLayers, layerDir, registryEntry, reloadRegistry } from './load' // Writing to the user layer: the "add a model without waiting for a release" // path. Two shapes are accepted, because those are the two things a user @@ -48,7 +48,14 @@ function userFile(name: string): string | null { /** Write a validated pack into the user layer. */ export function saveUserPack(entries: RegistryEntry[], name: string): ImportResult { - const errs = entries.flatMap(validateEntry) + // Validate each entry as it will actually be USED: merged field-by-field + // onto whatever entry it overrides. A companions-only fragment has no + // kind/label of its own, and validating it standalone rejected the exact + // partial override the docs promise. + const errs = entries.flatMap((e) => { + const base = e?.id ? registryEntry(e.id) : undefined + return validateEntry(base ? { ...base, ...e } : e) + }) if (errs.length) return { ok: false, error: errs.join('; ') } const path = userFile(safeName(name)) if (!path) return { ok: false, error: 'Could not locate your Filesmith data folder.' } @@ -223,9 +230,15 @@ export function importRegistryJson(path: string, text: string): ImportResult { if (asPack && Array.isArray(asPack.entries)) return saveUserPack(asPack.entries as RegistryEntry[], stem) - // (b) a single entry + // (b) a single entry — or a partial override of an entry that already + // exists (it has an id but no kind of its own). const asEntry = parsed as RegistryEntry - if (asEntry && typeof asEntry === 'object' && typeof asEntry.id === 'string' && asEntry.kind) + if ( + asEntry && + typeof asEntry === 'object' && + typeof asEntry.id === 'string' && + (asEntry.kind || registryEntry(asEntry.id)) + ) return saveUserPack([asEntry], asEntry.id) // (c) a raw ComfyUI API-format workflow diff --git a/src/shared/genArch.ts b/src/shared/genArch.ts index 33bb178..a72e8c8 100644 --- a/src/shared/genArch.ts +++ b/src/shared/genArch.ts @@ -100,6 +100,8 @@ export interface MissingFile { urls?: string[] /** Declared checksum, verified while streaming. */ sha256?: string + /** Exact size in bytes when the registry knows it. */ + bytes?: number approxSize: string subdir: 'text_encoders' | 'vae' } diff --git a/src/shared/registry.ts b/src/shared/registry.ts index 50cc05f..1a36999 100644 --- a/src/shared/registry.ts +++ b/src/shared/registry.ts @@ -198,6 +198,43 @@ export function mergeRegistry(layers: RegistryFile[]): RegistryEntry[] { return order.map((id) => byId.get(id)!) } +/** + * Merge with per-overlay validation: each fragment is applied onto the current + * merged entry and the RESULT is validated. A fragment that would make its + * entry invalid is rejected (with a warning) and the previous state kept. + * Validating the merged result rather than each fragment in isolation is what + * makes the documented partial override ({"id":"flux2","companions":[...]}) + * actually work: a fragment has no kind/label of its own, and validating it + * standalone rejected the exact thirty-second fix the user layer exists for. + */ +export function mergeRegistryChecked(layers: { file: string; entries: RegistryEntry[] }[]): { + entries: RegistryEntry[] + warnings: string[] +} { + const byId = new Map() + const order: string[] = [] + const warnings: string[] = [] + for (const layer of layers) { + for (const e of layer.entries ?? []) { + if (!e?.id) continue + const prev = byId.get(e.id) + const merged: RegistryEntry = prev + ? { ...prev, ...e, provenance: e.provenance ?? prev.provenance } + : e + const errs = validateEntry(merged) + if (errs.length) { + warnings.push( + `${layer.file}: ${errs.join('; ')} — ${prev ? 'override ignored' : 'entry skipped'}` + ) + continue + } + if (!prev) order.push(e.id) + byId.set(e.id, merged) + } + } + return { entries: order.map((id) => byId.get(id)!), warnings } +} + // --- detection -------------------------------------------------------------- function safeRegExp(source: string): RegExp | null { @@ -385,6 +422,24 @@ export function validateEntry(e: RegistryEntry): string[] { if (!e.kind) errs.push(`${e.id}: missing kind`) if (!e.label) errs.push(`${e.id}: missing label`) + // engineSpec is spliced into `uv pip install `. Unvalidated, an + // imported "model pack" carrying a URL (or an --index-url flag) was remote + // code execution on the next Install click. Only a bare PEP 508 requirement + // is allowed: name, optional extras, optional version constraints. + if (e.engineSpec != null) { + const spec = e.engineSpec + const bare = + typeof spec === 'string' && + !/\s/.test(spec) && + !spec.startsWith('-') && + !spec.includes('://') && + /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?([<>=!~][^@/\\]*)?$/.test(spec) + if (!bare) + errs.push( + `${e.id}: engineSpec must be a bare package requirement (like "spandrel>=0.4.1"), got "${spec}"` + ) + } + const checkCompanion = (c: CompanionSpec): void => { if (!COMPANION_SUBDIRS.includes(c.subdir)) errs.push(`${e.id}: companion subdir "${c.subdir}" is not allowed`) diff --git a/test/gguf.test.ts b/test/gguf.test.ts index ddb5e9b..98eba56 100644 --- a/test/gguf.test.ts +++ b/test/gguf.test.ts @@ -109,6 +109,30 @@ describe('reading a GGUF header', () => { expect(readGgufHeader(p)?.keys).toEqual(['txtfusion.w']) }) + it('parses a header larger than one refill chunk (the >1 MB regression)', () => { + // The refill computed its file offset from a stale `consumed`, so a 50 KB + // header parsed while the identical header past 1 MB (CHUNK) lost its + // place and misread everything after the first refill. A tokenizer-sized + // array value (3 MB of u8s, skipped in chunks) forces several refills; + // the tensor names AFTER it must still read back exactly. + const blob = Buffer.alloc(3 * 1024 * 1024) + const bigArray = [ + str('tokenizer.blob'), + u32(9), + Buffer.concat([u32(0), u64(blob.length), blob]) + ] + const p = writeGguf( + 'big-header.gguf', + ['double_blocks.0.w', 'single_blocks.0.w', 'img_in.w', 'txt_in.w'], + { 'general.architecture': 'flux' }, + bigArray + ) + const h = readGgufHeader(p) + expect(h?.keys).toContain('double_blocks.0.w') + expect(h?.keys).toContain('txt_in.w') + expect(h?.metadata['general.architecture']).toBe('flux') + }) + it('returns null for anything that is not a readable GGUF', () => { const bad = join(dir, 'not.gguf') writeFileSync(bad, Buffer.from('this is not a gguf file at all')) diff --git a/test/registry-hardening.test.ts b/test/registry-hardening.test.ts new file mode 100644 index 0000000..c9ce603 --- /dev/null +++ b/test/registry-hardening.test.ts @@ -0,0 +1,136 @@ +import { mkdirSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' +import { mergeRegistryChecked, validateEntry, type RegistryEntry } from '../src/shared/registry' +import { layerDir, reloadRegistry } from '../src/main/registry/load' + +// The two registry findings from the review: +// - finding 6: engineSpec reached `uv pip install` unvalidated, so an +// imported JSON was code execution; +// - finding 7: the loader validated each layer's FRAGMENT, so the documented +// partial override ({"id": "x", "companions": [...]}) was silently dropped +// (missing kind/label) and the "fix a dead URL in thirty seconds" path did +// nothing at all. + +const entry = (over: Partial): RegistryEntry => + ({ + id: 'fam', + kind: 'generate', + label: 'Family', + provenance: { source: 'builtin' }, + ...over + }) as RegistryEntry + +describe('validateEntry: engineSpec', () => { + it('accepts a bare PEP 508 requirement', () => { + expect(validateEntry(entry({ engineSpec: 'spandrel>=0.4.1' }))).toEqual([]) + expect(validateEntry(entry({ engineSpec: 'spandrel' }))).toEqual([]) + expect(validateEntry(entry({ engineSpec: 'rembg[cli,cpu]>=2.0.75,<3' }))).toEqual([]) + }) + + it('rejects URLs, flags, and anything with whitespace', () => { + // Each of these would be command-line / package-source injection once + // spliced into `uv pip install `. + for (const bad of [ + 'https://attacker.example/evil.whl', + '--index-url=https://attacker.example/simple', + '-e .', + 'spandrel @ https://attacker.example/evil.whl', + 'spandrel --index-url x' + ]) { + expect(validateEntry(entry({ engineSpec: bad }))).not.toEqual([]) + } + }) +}) + +describe('mergeRegistryChecked', () => { + it('accepts a companions-only override of an existing entry', () => { + const base = entry({ + companions: [ + { + role: 'vae', + label: 'VAE', + subdir: 'vae', + identify: {}, + download: { filename: 'vae.safetensors', approxSize: '335 MB', urls: ['https://a/x'] } + } + ] + }) + const override = { + id: 'fam', + provenance: { source: 'user' }, + companions: [ + { + role: 'vae', + label: 'VAE', + subdir: 'vae', + identify: {}, + download: { filename: 'vae.safetensors', approxSize: '335 MB', urls: ['https://b/y'] } + } + ] + } as RegistryEntry + const { entries, warnings } = mergeRegistryChecked([ + { file: 'builtin/f.json', entries: [base] }, + { file: 'user/o.json', entries: [override] } + ]) + expect(warnings).toEqual([]) + expect(entries).toHaveLength(1) + // The override's URL won; the base's kind/label were inherited. + expect(entries[0].companions?.[0].download.urls).toEqual(['https://b/y']) + expect(entries[0].label).toBe('Family') + }) + + it('keeps the previous state when an override would make the entry invalid', () => { + const base = entry({}) + const evil = { id: 'fam', engineSpec: 'https://attacker/evil.whl' } as RegistryEntry + const { entries, warnings } = mergeRegistryChecked([ + { file: 'builtin/f.json', entries: [base] }, + { file: 'user/evil.json', entries: [evil] } + ]) + expect(warnings).toHaveLength(1) + expect(entries).toHaveLength(1) + expect(entries[0].engineSpec).toBeUndefined() + expect(entries[0].provenance.source).toBe('builtin') + }) + + it('skips a NEW entry that is invalid on its own', () => { + const bad = { id: 'lonely', companions: [] } as unknown as RegistryEntry + const { entries, warnings } = mergeRegistryChecked([{ file: 'user/l.json', entries: [bad] }]) + expect(entries).toEqual([]) + expect(warnings).toHaveLength(1) + }) +}) + +describe('partial override end to end through loadRegistry', () => { + // The electron test stub points userData at a temp dir, so the real loader + // path (readLayer -> mergeRegistryChecked) can be exercised for real. + const userDir = layerDir('user')! + beforeEach(() => { + rmSync(userDir, { recursive: true, force: true }) + mkdirSync(userDir, { recursive: true }) + }) + afterAll(() => { + rmSync(userDir, { recursive: true, force: true }) + reloadRegistry() + }) + + it('a user fragment overrides one field of a built-in entry', () => { + const before = reloadRegistry() + const target = before.entries.find((e) => e.kind === 'generate' && e.companions?.length) + if (!target) return // shipped pack carries no companion entry — nothing to override + writeFileSync( + join(userDir, 'fix.json'), + JSON.stringify({ + schemaVersion: 1, + entries: [{ id: target.id, label: 'Renamed by user' }] + }) + ) + const after = reloadRegistry() + const merged = after.entries.find((e) => e.id === target.id)! + expect(merged.label).toBe('Renamed by user') + // Everything not overridden is inherited, not lost. + expect(merged.kind).toBe(target.kind) + expect(merged.companions?.length).toBe(target.companions?.length) + expect(merged.provenance.source).toBe('user') + }) +}) From b44bd6842de362378bb739c7e98538e7f0a371bb Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:56:36 +0200 Subject: [PATCH 09/15] fix(renderer): state correctness - hydrate, async races, deletes, errors - Session hydrate MERGES instead of replacing: the window is interactive while the restore round-trips, so files dropped in that gap survived it; StrictMode's discarded first pass can no longer arm the save early. - addItems/addSources carry the category they were initiated in: both are dispatched after an await (files:classify), and switching category during the round trip filed images into whatever queue was open when the reply landed - and persisted it. - Delete honours trashFile's result: a locked/network file that could not be recycled keeps its row (with a dialog) instead of silently staying on disk while the card disappears. Multi-file deletes get the confirmation an expensive upscale already had. Trashing evicts the output-thumbnail and probe caches, so a delete-and-rerun can't show the previous run's image. - An ErrorBoundary with Reload / Reset session: a render throw used to blank the frameless window with no menu bar - and a throw caused by a bad persisted item reproduced every launch, so parseSession now also validates item shape. Unhandled rejections at least log. - Failed rows wrap to three lines and are selectable: engine errors are full remedial sentences, truncated to one 11.5px line inside a user-select:none UI. Queue subtitles name the actual operation (upscale/removebg/PDF verbs all read "Compress " before). - Generate: "Custom..." size is reachable (explicit sizeMode - the derived value snapped straight back to the preset, so the width/height inputs never mounted in any build), and the fields clamp on blur instead of turning "832" into 256 at the first keystroke. Sampler defaults no longer wipe the persisted Steps/CFG on every mount. Drops in Generate mode are refused instead of landing in an invisible queue. - The options panel pins its action footer: the Run button was an ordinary last child of one scroll container and left the viewport at the app's own default window size. Resize dimension fields refuse 0/negative. - A renamed operation id no longer discards the persisted category; output thumbnails request the output's kind (an MKV->MP3 wants cover art, not a frame grab). Refs #12 Co-Authored-By: Claude Opus 5 --- src/renderer/src/App.tsx | 91 +++++++-- src/renderer/src/components/ErrorBoundary.tsx | 68 +++++++ src/renderer/src/components/OptionsPanel.tsx | 182 ++++++++++++------ src/renderer/src/components/Queue.tsx | 27 ++- src/renderer/src/main.tsx | 11 +- src/renderer/src/state.ts | 71 +++++-- test/queues.test.ts | 20 +- 7 files changed, 369 insertions(+), 101 deletions(-) create mode 100644 src/renderer/src/components/ErrorBoundary.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d6a5b40..3078685 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -187,7 +187,10 @@ export default function App(): JSX.Element { dispatch({ type: 'hydrate', state: pruned.state }) setGenResults(pruned.genResults) } finally { - hydrated.current = true + // Only the LIVE pass may enable saving: StrictMode's discarded first + // run otherwise armed the save before the restore had landed, letting + // an early change overwrite the persisted session. + if (alive) hydrated.current = true } })() return () => { @@ -268,8 +271,10 @@ export default function App(): JSX.Element { const out = item.outputPath if (!out || item.status !== 'done' || outRequested.current.has(out)) continue outRequested.current.add(out) + // The OUTPUT's kind, not the source's: an MKV -> MP3 output wants + // cover-art extraction, not a video frame grab. void window.filesmith - .thumbnail(out, 128, item.file.kind) + .thumbnail(out, 128, fileKind(extOfPath(out))) .then((t) => setOutThumbs((m) => ({ ...m, [out]: t }))) } } @@ -359,20 +364,68 @@ export default function App(): JSX.Element { // Dismiss a set of items from a column. For Output we also recycle-bin the // produced file before dropping the card. function dismiss(ids: string[], column: 'input' | 'output'): void { + if (column === 'output') { + void trashOutputs(ids) + return + } for (const id of ids) { const it = cur.items.find((x) => x.id === id) // Removing an in-flight row must not orphan its process: cancel the job // first, or ffmpeg keeps encoding headless and writes an untracked file. - if (column === 'input' && it && (it.status === 'queued' || it.status === 'running')) { + if (it && (it.status === 'queued' || it.status === 'running')) { void window.filesmith.cancelJob(id) } - if (column === 'output') { - if (it?.outputPath) void window.filesmith.trashFile(it.outputPath) - } + if (it) evictProbe(it.file.path) dispatch({ type: 'dismiss', id, column }) } } + /** Trash output files, honouring the result: a locked/network file that + * could NOT be recycled keeps its row instead of silently staying on disk + * while the card disappears. */ + async function trashOutputs(ids: string[]): Promise { + const failed: string[] = [] + for (const id of ids) { + const it = cur.items.find((x) => x.id === id) + const out = it?.outputPath + if (!out) continue + const ok = await window.filesmith.trashFile(out) + if (!ok) { + failed.push(baseName(out)) + continue + } + // Evict the caches keyed by this path: output names are reusable after a + // delete, and a re-run must not show the previous run's thumbnail. + outRequested.current.delete(out) + setOutThumbs((m) => { + const rest = { ...m } + delete rest[out] + return rest + }) + evictProbe(out) + dispatch({ type: 'dismiss', id, column: 'output' }) + } + if (failed.length) + setConfirm({ + title: + failed.length === 1 ? 'Could not delete file' : `Could not delete ${failed.length} files`, + body: `${failed.join(', ')} could not be moved to the Recycle Bin — the file may be open in another app.`, + confirmLabel: 'OK', + onConfirm: () => {} + }) + } + + /** Drop a path from the probe caches so a changed file gets re-probed. */ + function evictProbe(path: string): void { + vDimsRequested.current.delete(path) + setVDims((m) => { + if (!(path in m)) return m + const rest = { ...m } + delete rest[path] + return rest + }) + } + /** Stop a queued or running job; the engine emits the terminal 'canceled'. */ function cancelJob(id: string): void { void window.filesmith.cancelJob(id) @@ -445,7 +498,15 @@ export default function App(): JSX.Element { label: n > 1 ? `Delete ${n} files` : 'Delete file', icon: 'trash', danger: true, - onClick: () => dismiss(targets, 'output') + onClick: () => + n > 1 + ? setConfirm({ + title: `Delete ${n} files?`, + body: 'They will be moved to the Recycle Bin.', + confirmLabel: 'Delete', + onConfirm: () => dismiss(targets, 'output') + }) + : dismiss(targets, 'output') } ] }) @@ -458,19 +519,24 @@ export default function App(): JSX.Element { } async function browse(): Promise { + const category = state.category const files = ofCategory(await window.filesmith.pickFiles()) - if (files.length) dispatch({ type: 'addItems', files }) + if (files.length) dispatch({ type: 'addItems', files, category }) } async function onDrop(e: DragEvent): Promise { e.preventDefault() setDragging(false) + // Generate has no queue on screen: a file accepted here would land in an + // invisible list with no feedback at all. + if (op.tool === 'generate') return const paths = Array.from(e.dataTransfer.files) .map((f) => window.filesmith.pathForFile(f)) .filter(Boolean) if (!paths.length) return + const category = state.category const files = ofCategory(await window.filesmith.classify(paths)) - if (files.length) dispatch({ type: 'addItems', files }) + if (files.length) dispatch({ type: 'addItems', files, category }) } // Build a fresh Input-column source item for a path (a promoted output, or a @@ -635,7 +701,7 @@ export default function App(): JSX.Element { if (anchor.isResult) { const src = await makeSource(anchor) if (!src) return - dispatch({ type: 'addSources', items: [src] }) + dispatch({ type: 'addSources', items: [src], category: state.category }) anchorId = src.id } dispatch({ type: 'markQueued', ids: [anchorId], options: opts }) @@ -665,7 +731,8 @@ export default function App(): JSX.Element { newSources.push(src) targets.push({ id: src.id, path: src.file.path }) } - if (newSources.length) dispatch({ type: 'addSources', items: newSources }) + if (newSources.length) + dispatch({ type: 'addSources', items: newSources, category: state.category }) if (!targets.length) return dispatch({ type: 'markQueued', ids: targets.map((t) => t.id), options: opts }) for (const t of targets) { @@ -799,7 +866,7 @@ export default function App(): JSX.Element { className="flex min-w-0 flex-1 flex-col gap-4 px-7 pb-5 pt-1" onDragOver={(e) => { e.preventDefault() - setDragging(true) + if (op.tool !== 'generate') setDragging(true) }} onDragLeave={(e) => { if (e.currentTarget === e.target) setDragging(false) diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ce7e07a --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { Component, type ErrorInfo, type JSX, type ReactNode } from 'react' + +// The window is frameless: a render throw used to blank it entirely, with no +// menu bar and no way to recover — and a throw caused by a bad persisted item +// reproduced on every launch. This boundary keeps the failure visible and +// offers the two exits that actually help: reload, or reset the saved session +// and reload. + +interface State { + error: Error | null +} + +export class ErrorBoundary extends Component<{ children: ReactNode }, State> { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[renderer] render error:', error, info.componentStack) + } + + private reset = (): void => { + // Clear the persisted session first: if a restored item caused the throw, + // a plain reload would just crash again. + try { + window.filesmith.sessionSave(null) + } catch { + /* reload regardless */ + } + window.location.reload() + } + + render(): ReactNode { + if (!this.state.error) return this.props.children + return ( +
+
+

Something went wrong

+

+ {this.state.error.message} +

+
+ + +
+
+
+ ) + } +} + +/** One JSX Element wrapper so main.tsx stays a single expression. */ +export function Boundary({ children }: { children: ReactNode }): JSX.Element { + return {children} +} diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index 3518f97..4ea6781 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -611,6 +611,7 @@ function ResizeOptions({ set('width', e.target.value === '' ? '' : Number(e.target.value))} className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent" @@ -621,6 +622,7 @@ function ResizeOptions({ set('height', e.target.value === '' ? '' : Number(e.target.value))} className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent" @@ -1064,6 +1066,47 @@ function AddModel({ ) } +/** A dimension field that clamps on BLUR, not on every keystroke: clamping + * while typing turned "832" into 256 the moment its first digit landed, which + * made the field impossible to use at all. Steps by the model's own dimStep. */ +function DimInput({ + value, + caps, + onCommit +}: { + value: number + caps?: { minDim?: number; maxDim?: number; dimStep?: number } + onCommit: (v: number) => void +}): JSX.Element { + const [text, setText] = useState(String(value)) + // Re-sync when the committed value changes from OUTSIDE (preset picked): + // the render-time adjustment pattern, not an effect (no extra paint). + const [lastValue, setLastValue] = useState(value) + if (value !== lastValue) { + setLastValue(value) + setText(String(value)) + } + const commit = (): void => { + const n = Number(text) + onCommit(clampDim(Number.isFinite(n) && n > 0 ? n : value, caps)) + } + return ( + setText(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit() + }} + className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent" + /> + ) +} + /** * Text-to-image generation options. The prompt lives in the center PromptBox; * this panel carries the model + sampling settings and the availability notice. @@ -1080,7 +1123,12 @@ function GenerateOptions({ const w = Number(options.width ?? 1024) const h = Number(options.height ?? 1024) const seed = Number(options.seed ?? -1) - const sizeValue = GEN_SIZES.some((s) => s.width === w && s.height === h) ? `${w}x${h}` : 'custom' + // An explicit mode: derived-from-dimensions alone made "Custom…" unreachable + // (the defaults equal a preset, so the select snapped straight back and the + // width/height inputs never mounted in any build). + const isPreset = GEN_SIZES.some((s) => s.width === w && s.height === h) + const sizeValue = + String(options.sizeMode ?? '') === 'custom' || !isPreset ? 'custom' : `${w}x${h}` const models = useMemo(() => status?.models ?? [], [status]) const selected = models.find((m) => m.name === model) @@ -1112,15 +1160,23 @@ function GenerateOptions({ // When the architecture changes, reset the sampler knobs to that arch's sane // defaults (cfg MUST be 1 for Flux/Z-Image/Krea; steps differ per family). + // Only AFTER the scan resolves, and only on a real change: the ref used to + // start null with arch defaulting to 'sdxl' mid-scan, so every mount fired + // the reset twice and the persisted Steps/CFG were never once honoured. const prevArch = useRef(null) useEffect(() => { + if (!status) return + if (prevArch.current === null) { + prevArch.current = arch + return + } if (prevArch.current === arch) return prevArch.current = arch set('tryAnyway', 0) set('steps', info.steps) set('cfg', info.cfg) if (info.hasGuidance) set('guidance', info.guidance) - }, [arch, info, set]) + }, [arch, info, set, status]) return ( <> @@ -1218,7 +1274,11 @@ function GenerateOptions({ { value: 'custom', label: 'Custom…' } ]} onChange={(v) => { - if (v === 'custom') return + if (v === 'custom') { + set('sizeMode', 'custom') + return + } + set('sizeMode', 'preset') const [ww, hh] = v.split('x').map(Number) set('width', ww) set('height', hh) @@ -1228,23 +1288,11 @@ function GenerateOptions({
- set('width', clampDim(Number(e.target.value), dimCaps))} - className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent" - /> + set('width', v)} />
- set('height', clampDim(Number(e.target.value), dimCaps))} - className="w-full rounded-xl border border-black/[.10] bg-white px-3 py-2 text-sm outline-none focus:border-accent" - /> + set('height', v)} />
)} @@ -1389,8 +1437,12 @@ export function OptionsPanel({ // Every option control (slider, toggle, run button, selected chip) reads the // accent variables, so overriding them here themes the whole panel black in // one place, without touching the rail or the coloured switcher above. + // Three regions: a fixed header (the switcher), a scrolling middle (the + // option groups), and a PINNED footer holding the primary action - which + // used to be an ordinary last child of one scroll container and left the + // viewport entirely at the app's own default window size. ) } diff --git a/src/renderer/src/components/Queue.tsx b/src/renderer/src/components/Queue.tsx index 1b504c5..69150ae 100644 --- a/src/renderer/src/components/Queue.tsx +++ b/src/renderer/src/components/Queue.tsx @@ -10,6 +10,15 @@ const extOf = (p: string): string => { return i > 0 ? b.slice(i + 1).toUpperCase() : '' } +const PDF_OP_LABEL: Record = { + 'extract-text': 'Extract text', + 'pages-to-images': 'Pages to PNG', + merge: 'Merge PDFs', + 'split-range': 'Split pages', + 'split-pages': 'Burst pages', + 'extract-images': 'Extract images' +} + function inputSub(item: QueueItem, tool: ToolId, panelOptions: JobOptions): string { const src = item.file.ext.replace('.', '').toUpperCase() // Once a row has run, describe what IT did; only a not-yet-run row previews @@ -27,7 +36,13 @@ function inputSub(item: QueueItem, tool: ToolId, panelOptions: JobOptions): stri const h = options.height === '' || options.height == null ? 'auto' : options.height return `Resize ${w}×${h}${options.fit === 'stretch' ? ' stretched' : ''}` } - return `Compress ${src}` + // Name the operation the row is actually part of - the old fall-through + // stamped "Compress" on upscale, removebg and every PDF verb. + if (tool === 'upscale') return `Upscale ${options.upscaleFactor ?? 4}×` + if (tool === 'removebg') return 'Remove background' + if (tool === 'pdf') return PDF_OP_LABEL[String(options.op)] ?? 'PDF' + if (tool === 'compress') return `Compress ${src}` + return src } function StatusCell({ item }: { item: QueueItem }): JSX.Element | null { @@ -150,7 +165,15 @@ function InputCard({
{item.file.name}
-
+
{item.status === 'failed' && item.error ? ( // Say WHY it failed. "Failed" alone leaves the user guessing. {item.error} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index d42a0f7..a5b87db 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,12 +2,21 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' import { PreviewWindow } from './components/PreviewWindow' +import { ErrorBoundary } from './components/ErrorBoundary' import './index.css' // The same bundle serves both windows; the preview window is entered via the // '#preview' hash (see previewWindow.ts in main). const isPreview = window.location.hash === '#preview' +// Rejected IPC promises otherwise vanish (every call site voids them); at +// least leave a trace for a bug report instead of pure silence. +window.addEventListener('unhandledrejection', (e) => { + console.error('[renderer] unhandled rejection:', e.reason) +}) + createRoot(document.getElementById('root') as HTMLElement).render( - {isPreview ? : } + + {isPreview ? : } + ) diff --git a/src/renderer/src/state.ts b/src/renderer/src/state.ts index 8bb13b1..7230b4f 100644 --- a/src/renderer/src/state.ts +++ b/src/renderer/src/state.ts @@ -159,8 +159,8 @@ export type Action = | { type: 'setCategory'; category: CategoryId } | { type: 'setOperation'; operation: string } | { type: 'setOption'; key: string; value: string | number | boolean } - | { type: 'addItems'; files: FileInfo[] } - | { type: 'addSources'; items: QueueItem[] } + | { type: 'addItems'; files: FileInfo[]; category: CategoryId } + | { type: 'addSources'; items: QueueItem[]; category: CategoryId } | { type: 'setThumb'; id: string; thumb: string | null } | { type: 'dismiss'; id: string; column: 'input' | 'output' } | { type: 'markQueued'; ids: string[]; options?: JobOptions } @@ -218,6 +218,20 @@ export function sessionSnapshot(state: AppState, genResults: string[]): unknown } } +/** Structural check on a restored item: a malformed one (hand-edited file, + * partial write) must be dropped, not rendered. */ +function isValidItem(i: unknown): i is QueueItem { + if (!i || typeof i !== 'object') return false + const q = i as QueueItem + return ( + typeof q.id === 'string' && + !!q.file && + typeof q.file.path === 'string' && + typeof q.file.name === 'string' && + (!q.isResult || typeof q.outputPath === 'string') + ) +} + interface PersistedSession { category: CategoryId operation: string @@ -242,8 +256,9 @@ export function parseSession(raw: unknown): { state: AppState; genResults: strin return false } } - const category = - validCat(p.category) && findOperation(p.category, p.operation) ? p.category : FIRST_CATEGORY + // Two independent checks: a renamed OPERATION must not also discard the + // persisted category. + const category = validCat(p.category) ? p.category : FIRST_CATEGORY const operation = findOperation(category, p.operation) ? p.operation : defaultOperation(category) @@ -252,7 +267,7 @@ export function parseSession(raw: unknown): { state: AppState; genResults: strin for (const [cat, q] of Object.entries(p.queues ?? {})) { if (!q || !validCat(cat)) continue queues[cat as CategoryId] = { - items: (q.items ?? []).map(normalizeItem), + items: (q.items ?? []).filter(isValidItem).map(normalizeItem), selected: [], anchor: null } @@ -324,8 +339,13 @@ export function pruneMissing( /** Replace the current workspace's queue via `fn`. A no-op on the operation * grid, where no workspace is open. */ function mapQueue(state: AppState, fn: (q: QueueState) => QueueState): AppState { - const key = state.category - return { ...state, queues: { ...state.queues, [key]: fn(state.queues[key] ?? emptyQueue()) } } + return mapQueueIn(state, state.category, fn) +} + +/** Replace a SPECIFIC category's queue - for actions dispatched after an + * await, which must land where they were initiated, not where the user is. */ +function mapQueueIn(state: AppState, cat: CategoryId, fn: (q: QueueState) => QueueState): AppState { + return { ...state, queues: { ...state.queues, [cat]: fn(state.queues[cat] ?? emptyQueue()) } } } /** @@ -374,8 +394,24 @@ function selectInQueue(q: QueueState, id: string, mode: SelectMode): QueueState export function reducer(state: AppState, action: Action): AppState { switch (action.type) { - case 'hydrate': - return action.state + case 'hydrate': { + // Merge, never replace: the window is interactive while the session + // restore round-trips, so files dropped in that gap must survive it. + const merged = { ...action.state, queues: { ...action.state.queues } } + for (const [cat, q] of Object.entries(state.queues) as [CategoryId, QueueState][]) { + if (!q?.items.length) continue + const restored = merged.queues[cat] + if (!restored) { + merged.queues[cat] = q + continue + } + const key = (i: QueueItem): string => (i.isResult ? (i.outputPath ?? i.id) : i.file.path) + const have = new Set(restored.items.map(key)) + const extra = q.items.filter((i) => !have.has(key(i))) + if (extra.length) merged.queues[cat] = { ...restored, items: [...restored.items, ...extra] } + } + return merged + } case 'setCategory': { // Return to the mode last used in this category (if still valid), so // Images→Video→Images lands back on your chosen operation, not the default. @@ -419,7 +455,12 @@ export function reducer(state: AppState, action: Action): AppState { } } case 'addItems': { - const q = state.queues[state.category] ?? emptyQueue() + // The category rides ON the action: these are dispatched after an await + // (files:classify), and the user can switch category during the round + // trip - reducing against state.category filed images into whatever + // queue was open when the reply landed. + const cat = action.category + const q = state.queues[cat] ?? emptyQueue() // Ignore input-dismissed items so re-dropping a removed file re-adds it. const seen = new Set(q.items.filter(inInput).map((i) => i.file.path)) const add = action.files @@ -430,10 +471,12 @@ export function reducer(state: AppState, action: Action): AppState { // convert group (the first added file's) so a batch is never cross-category. const firstGroup = groupOf(add[0].file) const ids = add.filter((i) => groupOf(i.file) === firstGroup).map((i) => i.id) - return mapQueue(state, (cur) => ({ + return mapQueueIn(state, cat, (cur) => ({ items: [...cur.items, ...add], - selected: ids, - anchor: ids[ids.length - 1] + // Only steer the selection when the user is still LOOKING at this + // category; a background add must not clobber another queue's state. + selected: cat === state.category ? ids : cur.selected, + anchor: cat === state.category ? ids[ids.length - 1] : cur.anchor })) } case 'addSources': { @@ -441,7 +484,7 @@ export function reducer(state: AppState, action: Action): AppState { // origin is visible) and select them. The caller (run) already reuses an // existing input for a path that's already present, so no dedup here. if (!action.items.length) return state - return mapQueue(state, (cur) => ({ + return mapQueueIn(state, action.category, (cur) => ({ items: [...cur.items, ...action.items], selected: action.items.map((i) => i.id), anchor: action.items[action.items.length - 1].id diff --git a/test/queues.test.ts b/test/queues.test.ts index 0e60c98..c05af50 100644 --- a/test/queues.test.ts +++ b/test/queues.test.ts @@ -24,7 +24,7 @@ describe('per-category queues (shared across operations)', () => { it('keeps files when switching operation within a category', () => { // The whole point: an image added while converting is still there after // switching to compress, because the queue belongs to the file type. - let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) expect(s.queues[IMAGES]!.items).toHaveLength(1) s = open(s, 'compress') expect(s.queues[IMAGES]!.items).toHaveLength(1) // retained @@ -33,18 +33,19 @@ describe('per-category queues (shared across operations)', () => { }) it('keeps each file type queue separate', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) s = reducer(s, { type: 'setCategory', category: 'pdf' }) s = reducer(s, { type: 'addItems', - files: [{ path: 'C:/x/d.pdf', name: 'd.pdf', ext: '.pdf', kind: 'pdf', size: 5 }] + files: [{ path: 'C:/x/d.pdf', name: 'd.pdf', ext: '.pdf', kind: 'pdf', size: 5 }], + category: 'pdf' }) expect(s.queues[PDF]!.items).toHaveLength(1) expect(s.queues[IMAGES]!.items).toHaveLength(1) // images untouched }) it('routes a job event to whichever category queue holds the item', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) const id = s.queues[IMAGES]!.items[0].id s = open(s, 'resize') s = reducer(s, { type: 'jobEvent', event: { id, status: 'done', outputPath: 'C:/x/a.webp' } }) @@ -58,7 +59,7 @@ describe('per-category queues (shared across operations)', () => { }) it('appends a fresh result on each finished run; the source persists', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) const id = s.queues[IMAGES]!.items[0].id s = reducer(s, { type: 'jobEvent', @@ -76,7 +77,7 @@ describe('per-category queues (shared across operations)', () => { }) it('addSources appends pre-built input rows and selects them', () => { - let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) const clone = { id: 'clone-1', file: img('a.png'), @@ -84,7 +85,7 @@ describe('per-category queues (shared across operations)', () => { status: 'ready' as const, percent: 0 } - s = reducer(s, { type: 'addSources', items: [clone] }) + s = reducer(s, { type: 'addSources', items: [clone], category: 'images' }) expect(s.queues[IMAGES]!.items.filter((i) => !i.isResult)).toHaveLength(2) expect(s.queues[IMAGES]!.selected).toEqual(['clone-1']) }) @@ -104,7 +105,8 @@ describe('navigation', () => { expect(s.operation).toBe('extract-text') s = reducer(s, { type: 'addItems', - files: [{ path: 'C:/x/a.pdf', name: 'a.pdf', ext: '.pdf', kind: 'pdf', size: 10 }] + files: [{ path: 'C:/x/a.pdf', name: 'a.pdf', ext: '.pdf', kind: 'pdf', size: 10 }], + category: 'pdf' }) expect(s.queues[PDF]!.items).toHaveLength(1) }) @@ -112,7 +114,7 @@ describe('navigation', () => { it('keeps options per operation while the queue is shared', () => { // Set a Convert-only option, switch away and back: the option is remembered, // and separate from Compress's options. - let s = reducer(start, { type: 'addItems', files: [img('a.png')] }) + let s = reducer(start, { type: 'addItems', files: [img('a.png')], category: 'images' }) s = reducer(s, { type: 'setOption', key: 'format', value: '.avif' }) s = open(s, 'compress') expect(s.options[workspaceKey('images', 'compress')].quality).toBe(80) // its own default From ce8a66b581ecf754f20e275c69f5c698d4b44ddc Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:59:44 +0200 Subject: [PATCH 10/15] refactor(ipc): process-global registration, shared payload types, enforced GPU gate - registerIpc ran inside createWindow, closing over that window: a second createWindow (macOS dock activate, any future second window) stacked duplicate ipcMain.on listeners and then threw at the first duplicate handle(), stranding a constructed-but-never-loaded window. Now registerGlobalIpc() runs once at whenReady; dialogs parent to the sender's window, progress streams target the sender, and job events broadcast to every live window. - genControllers moved to module scope with cancelActiveGenerations(): before-quit cancelled the job queue and both sidecars but never an in-flight text-to-image run. - New src/shared/ipc.ts declares PidStatus/ComfyStatus once. pid:status was typed three times (handler, preload, hook) and the narrowest copy won - which is how cudaReason got computed, serialized, and thrown away. - pid:status now gates `nvidia` on the CUDA verdict exactly like comfy:status (the ungated value OR'd back in and re-offered the ~3 GB install to a GTX 1080); the UI shows cudaReason instead of silently hiding the tier; and installPid/installComfyEngine refuse an unsupported GPU up front. - comfy:set-folder / comfy:scan merge the store instead of rewriting it - the whole-object write silently deleted a hand-configured serverUrl. Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/comfy/store.ts | 14 ++ src/main/index.ts | 11 +- src/main/ipc.ts | 128 ++++++++++++------ src/main/pid/install.ts | 11 ++ src/preload/index.ts | 18 +-- src/renderer/src/components/OptionsPanel.tsx | 7 + src/renderer/src/components/useComfyModels.ts | 14 +- src/renderer/src/components/usePidStatus.ts | 6 +- src/shared/ipc.ts | 37 +++++ 9 files changed, 172 insertions(+), 74 deletions(-) create mode 100644 src/shared/ipc.ts diff --git a/src/main/comfy/store.ts b/src/main/comfy/store.ts index 4da3e2c..67155da 100644 --- a/src/main/comfy/store.ts +++ b/src/main/comfy/store.ts @@ -37,6 +37,20 @@ export function writeComfyStore(store: ComfyStore): void { writeFileSync(storePath(), JSON.stringify(store, null, 2)) } +/** Update only the given fields, preserving the rest. The whole-object writes + * in comfy:set-folder / comfy:scan silently deleted a hand-configured + * serverUrl every time the folder was re-picked. */ +export function mergeComfyStore(patch: Partial): void { + const cur = readComfyStore() + const merged: ComfyStore = { + folder: patch.folder ?? cur?.folder ?? '', + models: patch.models ?? cur?.models ?? [] + } + const serverUrl = patch.serverUrl ?? cur?.serverUrl + if (serverUrl) merged.serverUrl = serverUrl + writeComfyStore(merged) +} + /** The usable (loadable) models from the store, filtered to ones still on disk. */ export function usableComfyModels(): ComfyModel[] { const store = readComfyStore() diff --git a/src/main/index.ts b/src/main/index.ts index 108b574..a87eaa6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,7 @@ import { join, extname } from 'path' import { createReadStream, readdirSync, rmSync, statSync } from 'fs' import { tmpdir } from 'os' import { Readable } from 'stream' -import { registerIpc } from './ipc' +import { registerGlobalIpc, cancelActiveGenerations } from './ipc' import { configureBundledMagickEnv } from './toolResolver' import { pidSidecar } from './pid/sidecar' import { spandrelSidecar } from './comfy/sidecar' @@ -148,8 +148,6 @@ function createWindow(): void { mainWindow.on('ready-to-show', () => mainWindow.show()) - jobQueue = registerIpc(mainWindow) - // Open external links in the OS browser, never in-app. mainWindow.webContents.setWindowOpenHandler((details) => { // Scheme allowlist: shell.openExternal will happily launch a file:// or a @@ -208,6 +206,12 @@ if (!app.requestSingleInstanceLock()) { // Serve local files for the preview: fsmedia://local/. protocol.handle(MEDIA_SCHEME, (request) => serveMedia(request)) + // ONCE per process, before any window: handlers are process-global, and + // registering from inside createWindow made a second createWindow (macOS + // dock activate) stack duplicate listeners and throw on the first + // duplicate handle(). + jobQueue = registerGlobalIpc() + createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() @@ -222,6 +226,7 @@ if (!app.requestSingleInstanceLock()) { // headless) and free the warm AI sidecars (PiD holds ~10 GB of VRAM). app.on('before-quit', () => { jobQueue?.cancelAll() + cancelActiveGenerations() pidSidecar.stop() spandrelSidecar.stop() stopComfyServer() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bd050f7..a0ede4e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -24,7 +24,7 @@ import { basename } from 'path' import { pidInstalled, comfyEngineReady, pidEnvMarker, PID_BACKBONES } from './pid/paths' import { installPid, installComfyEngine, removePidInstall, installInProgress } from './pid/install' import { scanComfy, guessComfyFolder, findComfyPidWeights } from './comfy/discover' -import { readComfyStore, writeComfyStore, usableComfyModels } from './comfy/store' +import { mergeComfyStore, readComfyStore, usableComfyModels } from './comfy/store' import { comfyPythonReady, clearComfyPythonCache } from './comfy/pythonEnv' import { loadSession, saveSession, filesExist } from './session' import { @@ -43,6 +43,7 @@ import { } from './registry/load' import { importRegistryJson } from './registry/userLayer' import type { GenerateOptions } from '@shared/generate' +import type { ComfyStatus, PidStatus } from '@shared/ipc' // Only files Filesmith can actually act on. Everything else (exe, zip, docs, …) // is hidden from the picker and dropped from drag-and-drop. @@ -51,16 +52,49 @@ function isSupported(f: FileInfo): boolean { return f.kind !== 'other' } -/** Wire the renderer <-> engine channels for one window. */ -export function registerIpc(win: BrowserWindow): JobQueue { - // Progress/terminal events can fire while the window is tearing down (the +// In-flight generations, module-scope so app quit can abort them: before-quit +// cancelled the job queue and both sidecars but never these. +const genControllers = new Map() + +/** Abort every in-flight text-to-image run (app quit). */ +export function cancelActiveGenerations(): void { + for (const ctrl of genControllers.values()) ctrl.abort() + genControllers.clear() +} + +/** + * Wire the renderer <-> engine channels, ONCE per process at app ready. + * + * This used to run inside createWindow closing over that window: a second + * createWindow (macOS dock activate, any future second window) stacked + * duplicate ipcMain.on listeners and then THREW at the first duplicate + * ipcMain.handle, stranding a constructed-but-never-loaded window. Handlers + * now resolve their target from the event's sender; broadcast events go to + * every live window. + */ +export function registerGlobalIpc(): JobQueue { + // Progress/terminal events can fire while a window is tearing down (the // estimate ticker runs on a 200ms interval; macOS keeps the app alive with // no window at all). Sending to a destroyed webContents throws, so every - // engine->renderer push goes through this guard. - const send = (channel: string, payload: unknown): void => { - if (!win.isDestroyed() && !win.webContents.isDestroyed()) win.webContents.send(channel, payload) + // engine->renderer push goes through these guards. + const broadcast = (channel: string, payload: unknown): void => { + for (const w of BrowserWindow.getAllWindows()) + if (!w.isDestroyed() && !w.webContents.isDestroyed()) w.webContents.send(channel, payload) + } + const sendTo = (wc: Electron.WebContents, channel: string, payload: unknown): void => { + if (!wc.isDestroyed()) wc.send(channel, payload) } - const queue = new JobQueue((e: JobEvent) => send('job:event', e)) + /** The sender's window, for dialogs that should be parented to it. */ + const winOf = (e: Electron.IpcMainInvokeEvent): BrowserWindow | null => + BrowserWindow.fromWebContents(e.sender) + const openDialog = ( + e: Electron.IpcMainInvokeEvent, + opts: Electron.OpenDialogOptions + ): Promise => { + const w = winOf(e) + return w ? dialog.showOpenDialog(w, opts) : dialog.showOpenDialog(opts) + } + const queue = new JobQueue((e: JobEvent) => broadcast('job:event', e)) // Custom (frameless) window controls — act on the sender's window so both the // main window and the preview window control themselves. @@ -135,15 +169,15 @@ export function registerIpc(win: BrowserWindow): JobQueue { ) // A single image, for the Remove Background backdrop. Separate from // files:pick because it feeds an option, not the queue. - ipcMain.handle('image:pick', async () => { - const r = await dialog.showOpenDialog(win, { + ipcMain.handle('image:pick', async (e) => { + const r = await openDialog(e, { properties: ['openFile'], filters: [{ name: 'Images', extensions: bare(IMAGE_EXTS) }] }) return r.canceled || !r.filePaths.length ? null : r.filePaths[0] }) - ipcMain.handle('files:pick', async () => { - const r = await dialog.showOpenDialog(win, { + ipcMain.handle('files:pick', async (e) => { + const r = await openDialog(e, { properties: ['openFile', 'multiSelections'], filters: [ { @@ -210,13 +244,16 @@ export function registerIpc(win: BrowserWindow): JobQueue { // The backbone id comes from the caller (defaulting to the only wired one) // rather than being the literal 'flux' at the IPC boundary — that literal // appeared in four files and made a second backbone an app-wide edit. - ipcMain.handle('pid:status', async (_e, backbone = 'flux') => { + ipcMain.handle('pid:status', async (_e, backbone = 'flux'): Promise => { const gpu = await detectNvidia() // Report the compute-capability / driver verdict too, so a Pascal card is // turned away BEFORE a ~3 GB cu128 torch download rather than after it. const support = cudaTierSupport(gpu) return { - nvidia: gpu, + // GATED, matching comfy:status: an unsupported GPU reads as "no GPU" + // for the picker (the ungated value used to OR back in and re-offer the + // ~3 GB install to a card that can't run it), with the reason alongside. + nvidia: support.ok ? gpu : null, installed: pidInstalled(backbone), backbone, cudaOk: support.ok, @@ -227,12 +264,12 @@ export function registerIpc(win: BrowserWindow): JobQueue { // existsSync, so a corrupt weight was otherwise unrecoverable from the UI. ipcMain.handle('pid:remove', () => removePidInstall()) ipcMain.handle('pid:installing', () => installInProgress()) - ipcMain.handle('pid:install', async (_e, backbone = 'flux') => { + ipcMain.handle('pid:install', async (e, backbone = 'flux') => { try { - await installPid(backbone, (step, pct) => send('pid:progress', { step, pct })) + await installPid(backbone, (step, pct) => sendTo(e.sender, 'pid:progress', { step, pct })) return { ok: true } - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } } }) @@ -241,7 +278,7 @@ export function registerIpc(win: BrowserWindow): JobQueue { // and usable model count. install: builds the shared torch env + spandrel (no // PiD weights). pick+scan: choose a ComfyUI folder, classify its upscalers, // remember them. list: the usable models for the picker. - ipcMain.handle('comfy:status', async () => { + ipcMain.handle('comfy:status', async (): Promise => { // Never reject: a rejected status leaves the renderer's comfy.status null, // which would hide the whole "ComfyUI models" option. try { @@ -267,23 +304,31 @@ export function registerIpc(win: BrowserWindow): JobQueue { } } catch (e) { console.error('[comfy:status] failed:', e) - return { nvidia: null, engineReady: false, envExists: false, folder: null, models: [] } + return { + nvidia: null, + cudaReason: null, + engineReady: false, + envExists: false, + pidReusable: false, + folder: null, + models: [] + } } }) - ipcMain.handle('comfy:install', async () => { + ipcMain.handle('comfy:install', async (e) => { try { - await installComfyEngine((step, pct) => send('comfy:progress', { step, pct })) + await installComfyEngine((step, pct) => sendTo(e.sender, 'comfy:progress', { step, pct })) return { ok: true } - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } } }) - ipcMain.handle('comfy:pick-folder', async () => { + ipcMain.handle('comfy:pick-folder', async (e) => { // Open at the remembered folder (if it still exists), else the best guess at // a ComfyUI install, so a moved folder doesn't drop them in a blank Explorer. const stored = readComfyStore()?.folder const defaultPath = stored && existsSync(stored) ? stored : guessComfyFolder() - const r = await dialog.showOpenDialog(win, { + const r = await openDialog(e, { title: 'Select your ComfyUI folder', properties: ['openDirectory'], ...(defaultPath ? { defaultPath } : {}) @@ -299,7 +344,7 @@ export function registerIpc(win: BrowserWindow): JobQueue { try { if (!folder || !existsSync(folder)) return { ok: false, error: 'That folder no longer exists.' } - writeComfyStore({ folder, models: readComfyStore()?.models ?? [] }) + mergeComfyStore({ folder }) clearComfyPythonCache() return { ok: true } } catch (e) { @@ -309,10 +354,10 @@ export function registerIpc(win: BrowserWindow): JobQueue { ipcMain.handle('comfy:scan', async (_e, folder: string) => { try { // A newly-picked folder may bring its own ComfyUI Python into scope. - writeComfyStore({ folder, models: readComfyStore()?.models ?? [] }) + mergeComfyStore({ folder }) clearComfyPythonCache() const models = await scanComfy(folder) - writeComfyStore({ folder, models }) + mergeComfyStore({ folder, models }) return { ok: true, models } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } @@ -341,17 +386,17 @@ export function registerIpc(win: BrowserWindow): JobQueue { // deleted the first's completed file mid-rename, then re-fetched ~5 GB from // the UNVERIFIED mirror. A second caller now joins the running download. const companionDownloads = new Map>() - ipcMain.handle('generate:download', (_e, id: string, model: string) => { + ipcMain.handle('generate:download', (e, id: string, model: string) => { const running = companionDownloads.get(model) if (running) return running const p = (async (): Promise<{ ok: boolean; error?: string }> => { try { await downloadCompanions(model, (prog) => - send('generate:download-progress', { id, ...prog }) + sendTo(e.sender, 'generate:download-progress', { id, ...prog }) ) return { ok: true } - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } } finally { companionDownloads.delete(model) } @@ -359,21 +404,20 @@ export function registerIpc(win: BrowserWindow): JobQueue { companionDownloads.set(model, p) return p }) - const genControllers = new Map() - ipcMain.handle('generate:run', async (_e, id: string, opts: GenerateOptions) => { + ipcMain.handle('generate:run', async (e, id: string, opts: GenerateOptions) => { const ctrl = new AbortController() genControllers.set(id, ctrl) try { await generateImages( opts, - (index, path) => send('generate:image', { id, index, path }), - (index, pct) => send('generate:progress', { id, index, pct }), - (message) => send('generate:progress', { id, index: -1, message }), + (index, path) => sendTo(e.sender, 'generate:image', { id, index, path }), + (index, pct) => sendTo(e.sender, 'generate:progress', { id, index, pct }), + (message) => sendTo(e.sender, 'generate:progress', { id, index: -1, message }), ctrl.signal ) return { ok: true } - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } } finally { genControllers.delete(id) } @@ -383,8 +427,8 @@ export function registerIpc(win: BrowserWindow): JobQueue { // --- The user's own model registry ----------------------------------------- // "Add a model without waiting for a release": import a registry entry or a // ComfyUI "Export (API)" workflow, or just open the folder and edit by hand. - ipcMain.handle('registry:import', async () => { - const r = await dialog.showOpenDialog(win, { + ipcMain.handle('registry:import', async (e) => { + const r = await openDialog(e, { title: 'Add a model (registry entry or ComfyUI API workflow)', properties: ['openFile'], filters: [{ name: 'JSON', extensions: ['json'] }] diff --git a/src/main/pid/install.ts b/src/main/pid/install.ts index cc95397..6523508 100644 --- a/src/main/pid/install.ts +++ b/src/main/pid/install.ts @@ -11,6 +11,7 @@ import { } from 'fs' import { basename, dirname, join } from 'path' import { run } from '../run' +import { cudaTierSupport, detectNvidia } from './gpu' import { downloadFile } from '../net/download' import { expectedHash, recordHash } from '../net/integrity' import { resolveUv } from '../toolResolver' @@ -362,7 +363,16 @@ export async function installPid(backbone: string, onProgress: InstallProgress): return withInstallLock(() => installPidInner(backbone, onProgress)) } +/** Refuse the multi-GB download when this GPU can't run the result. The + * verdict (cudaTierSupport) was computed for the status endpoint and then + * never enforced: a GTX 1080 was still offered the ~3 GB cu128 torch. */ +async function assertCudaCapable(): Promise { + const support = cudaTierSupport(await detectNvidia()) + if (!support.ok) throw new Error(support.reason ?? 'This GPU cannot run the CUDA engine.') +} + async function installPidInner(backbone: string, onProgress: InstallProgress): Promise { + await assertCudaCapable() mkdirSync(pidRoot(), { recursive: true }) const bbForSpace = PID_BACKBONES[backbone] const space = checkDiskSpace((bbForSpace?.approxBytes ?? 0) + ENV_APPROX_BYTES) @@ -411,6 +421,7 @@ export async function installComfyEngine(onProgress: InstallProgress): Promise { + await assertCudaCapable() mkdirSync(pidRoot(), { recursive: true }) const space = checkDiskSpace(ENV_APPROX_BYTES) if (!space.ok) throw new Error(space.reason) diff --git a/src/preload/index.ts b/src/preload/index.ts index fb55923..13dfe89 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import type { ToolTarget } from '@shared/types' import type { ComfyModel } from '@shared/comfy' +import type { ComfyStatus, PidStatus } from '@shared/ipc' import type { GenerateOptions } from '@shared/generate' import type { GenModelScan } from '@shared/genArch' @@ -83,11 +84,9 @@ const api = { ipcRenderer.invoke('tool:targets', tool, file), // PiD Advanced (NVIDIA) upscaler tier - /** NVIDIA GPU presence + whether the PiD engine is installed. */ - pidStatus: (): Promise<{ - nvidia: { name: string; vramMb: number | null } | null - installed: boolean - }> => ipcRenderer.invoke('pid:status'), + /** NVIDIA/PiD availability. Typed by the SHARED PidStatus: this wrapper used + * to re-declare a narrower shape, which silently discarded cudaReason. */ + pidStatus: (): Promise => ipcRenderer.invoke('pid:status'), /** Delete the AI install (repair path for a corrupt/poisoned one). */ pidRemove: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('pid:remove'), /** True while an install is running anywhere in the app. */ @@ -103,14 +102,7 @@ const api = { // ComfyUI-imported upscale models /** GPU + spandrel-engine readiness + remembered folder + usable models. */ - comfyStatus: (): Promise<{ - nvidia: { name: string; vramMb: number | null } | null - engineReady: boolean - envExists: boolean - pidReusable: boolean - folder: string | null - models: ComfyModel[] - }> => ipcRenderer.invoke('comfy:status'), + comfyStatus: (): Promise => ipcRenderer.invoke('comfy:status'), /** Build the shared torch env + spandrel (no PiD weights). */ comfyInstall: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('comfy:install'), /** Open a folder picker; resolves to the chosen path or null. */ diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index 4ea6781..4b0f659 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -494,6 +494,13 @@ function UpscaleOptions({
+ {/* Why the AI tier is missing, instead of silently hiding it: the + verdict was computed in main and thrown away before any UI. */} + {!hasNvidia && (pid?.cudaReason || comfy.status?.cudaReason) && ( +

+ {pid?.cudaReason ?? comfy.status?.cudaReason} +

+ )} {category === 'comfy' && ( <> {/* Second picker: which AI upscaler (imported ESRGAN models + PiD). */} diff --git a/src/renderer/src/components/useComfyModels.ts b/src/renderer/src/components/useComfyModels.ts index e5b1136..0c13927 100644 --- a/src/renderer/src/components/useComfyModels.ts +++ b/src/renderer/src/components/useComfyModels.ts @@ -1,17 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { ComfyModel } from '@shared/comfy' +import type { ComfyStatus } from '@shared/ipc' -export interface ComfyStatus { - nvidia: { name: string; vramMb: number | null } | null - engineReady: boolean - /** The shared torch env already exists (setup is then just the spandrel loader). */ - envExists: boolean - /** The user's ComfyUI has PiD weights we can reuse — PiD is only offered when - * this is true or PiD is already installed. */ - pidReusable: boolean - folder: string | null - models: ComfyModel[] -} +export type { ComfyStatus } /** ComfyUI import status (GPU, engine readiness, remembered folder + models), * fetched on mount. `refresh` re-reads it (after install / scan). */ diff --git a/src/renderer/src/components/usePidStatus.ts b/src/renderer/src/components/usePidStatus.ts index d8845e5..f259022 100644 --- a/src/renderer/src/components/usePidStatus.ts +++ b/src/renderer/src/components/usePidStatus.ts @@ -1,12 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import type { PidStatus } from '@shared/ipc' // PiD status hook, kept apart from the PidInstallCard component so the card file // exports only components (React Fast Refresh requires that). -export interface PidStatus { - nvidia: { name: string; vramMb: number | null } | null - installed: boolean -} +export type { PidStatus } /** GPU presence + install state, fetched once on mount. `refresh` re-checks. */ export function usePidStatus(): { status: PidStatus | null; refresh: () => void } { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..57cd8af --- /dev/null +++ b/src/shared/ipc.ts @@ -0,0 +1,37 @@ +import type { ComfyModel } from './comfy' + +// IPC payloads that cross main -> preload -> renderer, declared ONCE. +// pid:status used to be typed three times (the handler, the preload wrapper, +// and the renderer hook), and the narrowest copy won — which is exactly how +// cudaReason got computed, serialized, and thrown away before any UI saw it. + +export interface GpuInfo { + name: string + vramMb: number | null +} + +/** pid:status — NVIDIA/PiD availability for the Advanced upscale tier. */ +export interface PidStatus { + /** The detected GPU, or null when none exists OR it cannot run the CUDA + * tier (see cudaReason) — the gating the UI keys off. */ + nvidia: GpuInfo | null + installed: boolean + backbone: string + cudaOk: boolean + /** Why the CUDA tier is unavailable (driver too old, pre-Turing card, …). */ + cudaReason: string | null +} + +/** comfy:status — ComfyUI-imported upscaler availability. */ +export interface ComfyStatus { + nvidia: GpuInfo | null + cudaReason: string | null + engineReady: boolean + /** The shared torch env already exists (setup is then just the spandrel loader). */ + envExists: boolean + /** The user's ComfyUI has PiD weights we can reuse — PiD is only offered when + * this is true or PiD is already installed. */ + pidReusable: boolean + folder: string | null + models: ComfyModel[] +} From 60b381e2932e6c0de4144353b6c3a8951c488c49 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:05:21 +0200 Subject: [PATCH 11/15] chore(surface): wire the two repair paths, delete the dead ones Wired (they were the only exits from states the code anticipates): - pid:remove gets UI - a two-step "Remove PiD install" under the model picker. pidInstalled() is a bare existsSync, so a corrupt multi-GB weight was otherwise unrecoverable without knowing the %APPDATA% path. - upscale:open-models-folder gets "Add your own model..." - the drop-in folder upscale:models reads was otherwise unreachable. - findUvAsync's PATH probe (written for scoop/choco/cargo installs, never called): removebg's availability check now runs it, and resolveRembg uses the cached verdict, so a PATH-only uv finally counts. Deleted, with their vertical slices (zero callers each): checkTool + toolAvailable + VERSION_FLAG, toolsFor/targetsFor + their channels + ToolTarget plumbing, comfyList, registryInfo + integrityLedger, registryHealthy, TOOL_IDS (typed ToolId[] but omitted 'generate' - a trap), processable, categoryForKind, the duplicate 'x'/logo/music icons, and the renderer's second formatBytes (clamped at GB with different rounding on the same screen). baseName is now ONE shared helper instead of five hand-rolled copies. The unused .backdrop-fade keyframe is gone; .modal-pop is wired into ConfirmDialog instead of deleted (the entrance was clearly intended). ConfirmDialog gains the alert variant (no Cancel button on one-choice dialogs) and focuses Cancel on destructive confirms, so a stray Enter cannot approve a 40 GB upscale. Build hygiene: react/react-dom move to devDependencies (the renderer bundles its own React; ~7 MB of node_modules rode into app.asar unused), and electron-builder's files list becomes an allowlist (the denylist named eslint.config.js - the repo has .mjs - and shipped vitest.config.ts). docs/upscaler-upgrade-plan.md is marked superseded. Deferred, deliberately: the pid/comfy sidecar JsonSidecar extraction (~90% duplicated protocol code) - process-management code with EPIPE subtleties I can't runtime-verify here, better done with the hardware paths exercisable. Refs #12 Co-Authored-By: Claude Opus 5 --- docs/upscaler-upgrade-plan.md | 4 ++ electron-builder.yml | 18 ++---- package-lock.json | 9 +-- package.json | 7 +-- src/main/ipc.ts | 25 +-------- src/main/net/integrity.ts | 5 -- src/main/registry/load.ts | 7 +-- src/main/toolResolver.ts | 44 ++++----------- src/main/tools/registry.ts | 20 +------ src/main/uv.ts | 6 ++ src/preload/index.ts | 16 +----- src/renderer/src/App.tsx | 7 ++- src/renderer/src/components/ConfirmDialog.tsx | 27 ++++++--- src/renderer/src/components/Icon.tsx | 18 ------ src/renderer/src/components/OptionsPanel.tsx | 55 +++++++++++++++++++ src/renderer/src/components/Queue.tsx | 2 +- src/renderer/src/index.css | 14 +---- src/renderer/src/state.ts | 20 ++----- src/shared/catalog.ts | 5 -- src/shared/comfy.ts | 3 +- src/shared/fileKind.ts | 6 ++ 21 files changed, 131 insertions(+), 187 deletions(-) diff --git a/docs/upscaler-upgrade-plan.md b/docs/upscaler-upgrade-plan.md index 8ca8cea..b19d6fb 100644 --- a/docs/upscaler-upgrade-plan.md +++ b/docs/upscaler-upgrade-plan.md @@ -1,3 +1,7 @@ +> **STATUS: SUPERSEDED (2026-08).** The ONNX/DirectML "Standard" tier described +> below was never built. What shipped instead is the spandrel/ComfyUI import +> path — see `docs/comfyui-upscaler-import.md`. Kept for the decision record. + # Image Upscale v2: PiD flagship + cross-GPU ONNX engine Status: APPROVED 2026-07-21. Order: PiD first, then ONNX Standard tier, then custom import. diff --git a/electron-builder.yml b/electron-builder.yml index edaafd5..67037be 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,19 +3,13 @@ productName: Filesmith directories: buildResources: build output: dist +# An ALLOWLIST, not a denylist: the old exclude list named eslint.config.js +# (the repo has .mjs) and missed vitest.config.ts, so stray dev files rode +# into app.asar — and every new top-level file would too. Only the built app +# belongs in the archive; resources/ ships via extraResources below. files: - - '!**/.vscode/*' - - '!src/*' - - '!electron.vite.config.{js,ts,mjs,cjs}' - - '!{.eslintcache,.prettierrc.yaml,.prettierignore,eslint.config.js,tsconfig*.json}' - - '!{.git,.claude,docs,test}/*' - # resources/ ships via extraResources below; without these excludes electron- - # builder's default **/* also packs the whole 1.4 GB tree INTO app.asar, - # doubling the installer. scripts/ and build/ are dev-only. - - '!resources/**' - - '!scripts/**' - - '!build/**' - - '!dist/**' + - 'out/**/*' + - 'package.json' # Bundled core CLI tools (ffmpeg, ImageMagick, mutool, CaesiumCLT) ship in # resources/bin; LibreOffice (document conversion) ships in resources/libreoffice. # Both are populated by scripts/fetch-binaries.mjs before packaging. diff --git a/package-lock.json b/package-lock.json index 1cbd86d..7ddde17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,6 @@ "name": "filesmith", "version": "0.2.0", "license": "MIT", - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.2", @@ -28,6 +24,8 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "prettier": "^3.9.5", + "react": "^19.2.7", + "react-dom": "^19.2.7", "tailwindcss": "^4.3.2", "typescript": "^5.9.3", "typescript-eslint": "^8.64.0", @@ -6394,6 +6392,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6403,6 +6402,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -6628,6 +6628,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/semver": { diff --git a/package.json b/package.json index 345111f..2c37762 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,11 @@ "binaries": "node scripts/fetch-binaries.mjs", "package": "node scripts/fetch-binaries.mjs && electron-vite build && electron-builder --win" }, - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, + "dependencies": {}, "devDependencies": { "@eslint/js": "^10.0.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", "@tailwindcss/vite": "^4.3.2", "@types/node": "^26.1.1", "@types/react": "^19.2.17", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a0ede4e..c181fd1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,18 +7,16 @@ import type { JobEvent, JobRequest, PreviewItem, - PreviewPayload, - ToolId + PreviewPayload } from '@shared/types' import { AUDIO_EXTS, DOC_EXTS, IMAGE_EXTS, TEXT_EXTS, VIDEO_EXTS } from '@shared/fileKind' import { JobQueue } from './jobQueue' import { fileInfoFromPath } from './fileInfo' -import { toolAvailable, removebgStatus } from './toolResolver' +import { removebgStatus } from './toolResolver' import { ensureUserNcnnDir, listNcnnModels, userNcnnDir } from './tools/ncnnModels' import { probeDimensions, probeImageDimensions } from './probe' import { makeThumbnail } from './thumbnail' import { openPreviewWindow, getPreviewPayload, updatePreviewFiles } from './previewWindow' -import { targetsFor, toolsFor } from './tools/registry' import { cudaTierSupport, detectNvidia } from './pid/gpu' import { basename } from 'path' import { pidInstalled, comfyEngineReady, pidEnvMarker, PID_BACKBONES } from './pid/paths' @@ -147,7 +145,6 @@ export function registerGlobalIpc(): JobQueue { queue.cancel(id) return true }) - ipcMain.handle('tool:check', (_e, name: string) => toolAvailable(name)) ipcMain.handle('removebg:status', () => removebgStatus()) // The AI upscalers actually present on disk (bundled + the user's overlay), // so the picker reflects what is installed instead of a build-time literal. @@ -235,8 +232,6 @@ export function registerGlobalIpc(): JobQueue { // Images go through ImageMagick instead: ffprobe rejects very large ones and // can't read svg/jxl/heic at all. ipcMain.handle('image:dimensions', (_e, p: string) => probeImageDimensions(p)) - ipcMain.handle('tools:for', (_e, file: FileInfo) => toolsFor(file)) - ipcMain.handle('tool:targets', (_e, id: ToolId, file: FileInfo) => targetsFor(id, file)) // --- PiD Advanced (NVIDIA) upscaler tier ----------------------------------- // Status drives the gated model option; install runs the one-click download, @@ -363,7 +358,6 @@ export function registerGlobalIpc(): JobQueue { return { ok: false, error: e instanceof Error ? e.message : String(e) } } }) - ipcMain.handle('comfy:list', () => usableComfyModels()) // --- Text-to-image generation (via headless ComfyUI) ----------------------- ipcMain.handle('generate:status', async () => { @@ -448,21 +442,6 @@ export function registerGlobalIpc(): JobQueue { await shell.openPath(dir) return true }) - ipcMain.handle('registry:info', () => { - invalidateRegistryIfChanged() - const { entries, warnings } = loadRegistry() - return { - folder: layerDir('user'), - warnings, - entries: entries.map((e) => ({ - id: e.id, - kind: e.kind, - label: e.label, - source: e.provenance.source, - host: e.provenance.host ?? null - })) - } - }) // --- Session persistence (queues, produced files, options) ----------------- ipcMain.handle('session:load', () => loadSession()) diff --git a/src/main/net/integrity.ts b/src/main/net/integrity.ts index 41abdcd..c5fccbb 100644 --- a/src/main/net/integrity.ts +++ b/src/main/net/integrity.ts @@ -85,8 +85,3 @@ export function recordHash(url: string, sha256: string, bytes: number): void { l[url] = { sha256, bytes, host, firstSeen: new Date().toISOString() } write(l) } - -/** Everything we've recorded, for a provenance view. */ -export function integrityLedger(): Ledger { - return read() -} diff --git a/src/main/registry/load.ts b/src/main/registry/load.ts index de30be0..9d76e5e 100644 --- a/src/main/registry/load.ts +++ b/src/main/registry/load.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs' +import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs' import { join } from 'path' import { app } from 'electron' import type { RegistryEntry, RegistryFile } from '@shared/registry' @@ -192,8 +192,3 @@ export function registryEntries(kind: RegistryEntry['kind']): RegistryEntry[] { export function registryEntry(id: string): RegistryEntry | undefined { return loadRegistry().entries.find((e) => e.id === id) } - -/** True when the built-in pack was found — used by a startup sanity check. */ -export function registryHealthy(): boolean { - return existsSync(builtinDir()) && loadRegistry().entries.length > 0 -} diff --git a/src/main/toolResolver.ts b/src/main/toolResolver.ts index 30c7ac6..a9811ba 100644 --- a/src/main/toolResolver.ts +++ b/src/main/toolResolver.ts @@ -1,8 +1,7 @@ import { existsSync, readdirSync } from 'fs' import { join } from 'path' import { app } from 'electron' -import { run } from './run' -import { findUv } from './uv' +import { findUv, findUvAsync, uvOnPathCached } from './uv' // Core CLI tools bundled in resources/bin (packed by electron-builder into // process.resourcesPath/bin in production; the repo's resources/bin in dev). @@ -184,9 +183,11 @@ export interface RembgStatus { /** Proactive Remove-Background availability, so the panel can DISCLOSE the AI * model + one-time download (and the uv requirement) before the user commits - * files — rather than failing mid-run. */ -export function removebgStatus(): RembgStatus { - return { ready: existsSync(rembgInstalledPath()), uvAvailable: resolveUv() != null } + * files — rather than failing mid-run. Async so the PATH probe counts: a uv + * installed via scoop/choco/cargo lives only on PATH, and the fixed-path + * lookup reported uvAvailable:false for those users. */ +export async function removebgStatus(): Promise { + return { ready: existsSync(rembgInstalledPath()), uvAvailable: (await findUvAsync()) != null } } export function resolveRembg(): RembgCommand | null { @@ -194,8 +195,10 @@ export function resolveRembg(): RembgCommand | null { const installed = rembgInstalledPath() if (existsSync(installed)) return { cmd: installed, prefix: [] } - // (b) uv itself, which fetches Python + rembg on demand - const uv = resolveUv() + // (b) uv itself, which fetches Python + rembg on demand. A PATH-only uv + // (scoop/choco/cargo) is used by bare name when the async status probe has + // already confirmed one answers — resolveRembg stays synchronous. + const uv = resolveUv() ?? (uvOnPathCached() ? 'uv' : null) if (uv) return { cmd: uv, prefix: ['tool', 'run', '--python', '3.11', '--from', REMBG_SPEC, 'rembg'] } return null @@ -211,33 +214,6 @@ export function resolveUv(): string | null { return findUv() } -/** - * The flag each bundled tool actually accepts for a version probe. Measured - * against the shipped binaries, not assumed: `-version` works for magick / - * ffmpeg / ffprobe but **mutool exits 1** (it prints usage) and **caesiumclt - * exits 2** ("unexpected argument '-v'"), so a single `-version` probe reports - * two of the four bundled tools as unavailable. - */ -export const VERSION_FLAG: Record = { - magick: '-version', - ffmpeg: '-version', - ffprobe: '-version', - mutool: '-v', - caesiumclt: '--version' -} - -/** True if the tool is bundled or answers a version probe on PATH. */ -export async function toolAvailable(name: string): Promise { - const bundled = join(bundledDir(), name + EXE) - if (existsSync(bundled)) return true - try { - const { code } = await run(name, [VERSION_FLAG[name] ?? '-version']) - return code === 0 - } catch { - return false - } -} - /** * What to tell the user when a binary could not be started at all. Keyed on the * command's base name, because the resolvers hand back either a bundled path or diff --git a/src/main/tools/registry.ts b/src/main/tools/registry.ts index eaa9152..92166f0 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -11,7 +11,7 @@ import { writeFileSync } from 'fs' import { tmpdir } from 'os' -import type { FileInfo, ToolId, ToolTarget } from '@shared/types' +import type { FileInfo, ToolId } from '@shared/types' import type { AudioCodec, ImageFormat, PdfLevel, UpscaleModel, VideoCodec } from '@shared/compress' import { resolveGhostscript, @@ -37,7 +37,6 @@ import { buildFfmpegArgs, buildMagickArgs, canCompress, - convertTargets, ffmpegExtraFor, isSameFormat, magickExtraFor, @@ -1046,20 +1045,3 @@ const TOOLS: Partial> = { export function getTool(id: ToolId): ToolModule | undefined { return TOOLS[id] } - -/** Which tools apply to a file (drives the UI's tool highlighting). */ -export function toolsFor(file: FileInfo): ToolId[] { - const compress = canCompress(file.kind, file.ext) ? (['compress'] as ToolId[]) : [] - if (file.kind === 'image') return ['convert', ...compress, 'resize'] - if (file.kind === 'video' || file.kind === 'audio') return ['convert', ...compress] - if (file.kind === 'pdf') return ['convert', ...compress, 'pdf'] - if (file.kind === 'document' || file.kind === 'text') return ['convert'] - return [] -} - -/** Target options a tool offers for a file (the options panel). */ -export function targetsFor(id: ToolId, file: FileInfo): ToolTarget[] { - if (id === 'convert') - return convertTargets(file.kind, file.ext).map(({ label, ext }) => ({ label, ext })) - return [] -} diff --git a/src/main/uv.ts b/src/main/uv.ts index 04b0c7c..aff67fc 100644 --- a/src/main/uv.ts +++ b/src/main/uv.ts @@ -76,3 +76,9 @@ export async function findUvAsync(): Promise { if (local) return local return (await uvOnPath()) ? 'uv' : null } + +/** The PATH probe's cached verdict, for synchronous callers (resolveRembg). + * False until uvOnPath()/findUvAsync() has run once this session. */ +export function uvOnPathCached(): boolean { + return pathProbe?.found ?? false +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 13dfe89..4e60816 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,9 +5,7 @@ import type { JobEvent, JobRequest, PreviewItem, - PreviewPayload, - ToolId, - ToolTarget + PreviewPayload } from '@shared/types' import type { ComfyModel } from '@shared/comfy' import type { ComfyStatus, PidStatus } from '@shared/ipc' @@ -73,15 +71,11 @@ const api = { trashFile: (path: string): Promise => ipcRenderer.invoke('file:trash', path), // tools - checkTool: (name: string): Promise => ipcRenderer.invoke('tool:check', name), - toolsFor: (file: FileInfo): Promise => ipcRenderer.invoke('tools:for', file), /** AI upscale models present on disk (bundled + the user's own overlay). */ upscaleModels: (): Promise<{ value: string; label: string; user: boolean }[]> => ipcRenderer.invoke('upscale:models'), /** Open the folder where the user can drop their own Real-ESRGAN models. */ upscaleOpenModelsFolder: (): Promise => ipcRenderer.invoke('upscale:open-models-folder'), - targets: (tool: ToolId, file: FileInfo): Promise => - ipcRenderer.invoke('tool:targets', tool, file), // PiD Advanced (NVIDIA) upscaler tier /** NVIDIA/PiD availability. Typed by the SHARED PidStatus: this wrapper used @@ -118,12 +112,6 @@ const api = { }> => ipcRenderer.invoke('registry:import'), /** Open the user registry folder in the OS file manager. */ registryOpenFolder: (): Promise => ipcRenderer.invoke('registry:open-folder'), - /** Every known model entry with its layer + source host (provenance). */ - registryInfo: (): Promise<{ - folder: string | null - warnings: string[] - entries: { id: string; kind: string; label: string; source: string; host: string | null }[] - }> => ipcRenderer.invoke('registry:info'), /** Remember where ComfyUI is, without needing the upscale engine installed. */ comfySetFolder: (folder: string): Promise<{ ok: boolean; error?: string }> => @@ -131,8 +119,6 @@ const api = { /** Scan a folder for upscale models, classify + remember them. */ comfyScan: (folder: string): Promise<{ ok: boolean; models?: ComfyModel[]; error?: string }> => ipcRenderer.invoke('comfy:scan', folder), - /** The remembered, still-on-disk usable models. */ - comfyList: (): Promise => ipcRenderer.invoke('comfy:list'), /** Engine-install progress updates. */ onComfyProgress: (cb: (p: { step: string; pct: number | null }) => void): (() => void) => { const listener = (_: unknown, p: { step: string; pct: number | null }): void => cb(p) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3078685..d579437 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -23,7 +23,7 @@ import { upscaledSize } from '@shared/compress' import { resizedSize } from '@shared/resize' -import { fileKind } from '@shared/fileKind' +import { baseName, fileKind } from '@shared/fileKind' import { reducer, initialState, @@ -90,7 +90,6 @@ import { OptionsPanel, type VideoOutputRow } from './components/OptionsPanel' import { ContextMenu, type MenuState } from './components/ContextMenu' import { ConfirmDialog, type ConfirmState } from './components/ConfirmDialog' -const baseName = (p: string): string => p.split(/[\\/]/).pop() ?? p const extOfPath = (p: string): string => { const b = baseName(p) const i = b.lastIndexOf('.') @@ -411,6 +410,7 @@ export default function App(): JSX.Element { failed.length === 1 ? 'Could not delete file' : `Could not delete ${failed.length} files`, body: `${failed.join(', ')} could not be moved to the Recycle Bin — the file may be open in another app.`, confirmLabel: 'OK', + hideCancel: true, onConfirm: () => {} }) } @@ -504,6 +504,7 @@ export default function App(): JSX.Element { title: `Delete ${n} files?`, body: 'They will be moved to the Recycle Bin.', confirmLabel: 'Delete', + danger: true, onConfirm: () => dismiss(targets, 'output') }) : dismiss(targets, 'output') @@ -659,6 +660,7 @@ export default function App(): JSX.Element { title: 'Generation failed', body: r.error ?? 'Unknown error', confirmLabel: 'OK', + hideCancel: true, onConfirm: () => {} }) } finally { @@ -680,6 +682,7 @@ export default function App(): JSX.Element { title: 'That is a very large image', body: warning, confirmLabel: 'Upscale anyway', + danger: true, onConfirm: () => { // One confirmation covers this run only. confirmedHuge.current = true diff --git a/src/renderer/src/components/ConfirmDialog.tsx b/src/renderer/src/components/ConfirmDialog.tsx index f381b83..18da47f 100644 --- a/src/renderer/src/components/ConfirmDialog.tsx +++ b/src/renderer/src/components/ConfirmDialog.tsx @@ -5,6 +5,12 @@ export interface ConfirmState { body: string confirmLabel: string onConfirm: () => void + /** An alert has one button; rendering Cancel + OK (both closing) implied a + * choice that did not exist. */ + hideCancel?: boolean + /** Destructive/expensive confirms focus Cancel, so a stray Enter cannot + * approve a 40 GB upscale or a multi-file delete. */ + danger?: boolean } /** @@ -45,20 +51,23 @@ export function ConfirmDialog({ // Clicking the backdrop (the dialog element itself, outside the card). if (e.target === ref.current) onClose() }} - className="m-auto max-w-[420px] rounded-[18px] border border-black/[.08] bg-white p-0 text-ink shadow-[0_24px_60px_rgba(0,0,0,.22)] backdrop:bg-black/25" + className="modal-pop m-auto max-w-[420px] rounded-[18px] border border-black/[.08] bg-white p-0 text-ink shadow-[0_24px_60px_rgba(0,0,0,.22)] backdrop:bg-black/25" >

{state.title}

-

{state.body}

+

{state.body}

+ {!state.hideCancel && ( + + )} - + )} {/* Why the AI tier is missing, instead of silently hiding it: the verdict was computed in main and thrown away before any UI. */} {!hasNvidia && (pid?.cudaReason || comfy.status?.cudaReason) && ( @@ -518,6 +528,11 @@ function UpscaleOptions({
)} + {/* The way OUT of a poisoned install: pidInstalled() is a bare + existsSync, so a corrupt multi-GB weight was otherwise + unrecoverable from the UI. Two-step to avoid an accidental + 6 GB re-download. */} + {isPid && pid?.installed && } {comfy.status && (
@@ -1073,6 +1088,46 @@ function AddModel({ ) } +/** Remove the PiD install (repair path). Two clicks: the first arms it. */ +function PidRemoveButton({ onRemoved }: { onRemoved: () => void }): JSX.Element { + const [armed, setArmed] = useState(false) + const [error, setError] = useState(null) + const remove = (): void => { + if (!armed) { + setArmed(true) + return + } + setArmed(false) + void window.filesmith.pidInstalling().then((busy) => { + if (busy) { + setError('An install is running. Wait for it to finish first.') + return + } + void window.filesmith.pidRemove().then((r) => { + if (!r.ok) setError(r.error ?? 'Could not remove the install.') + else { + setError(null) + onRemoved() + } + }) + }) + } + return ( +
+ + {error &&

{error}

} +
+ ) +} + /** A dimension field that clamps on BLUR, not on every keystroke: clamping * while typing turned "832" into 256 the moment its first digit landed, which * made the field impossible to use at all. Steps by the model's own dimStep. */ diff --git a/src/renderer/src/components/Queue.tsx b/src/renderer/src/components/Queue.tsx index 69150ae..cc5362c 100644 --- a/src/renderer/src/components/Queue.tsx +++ b/src/renderer/src/components/Queue.tsx @@ -1,9 +1,9 @@ import type { JSX, MouseEvent } from 'react' import type { JobOptions, ToolId } from '@shared/types' import { formatBytes, formatEta, groupOf, inInput, inOutput, type QueueItem } from '../state' +import { baseName } from '@shared/fileKind' import { Icon } from './Icon' -const baseName = (p: string): string => p.split(/[\\/]/).pop() ?? p const extOf = (p: string): string => { const b = baseName(p) const i = b.lastIndexOf('.') diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 1103063..35e9f91 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -125,20 +125,8 @@ img { .modal-pop { animation: modalpop 0.18s cubic-bezier(0.23, 1, 0.32, 1); } -@keyframes backdropfade { - from { - opacity: 0; - } - to { - opacity: 1; - } -} -.backdrop-fade { - animation: backdropfade 0.16s ease-out; -} @media (prefers-reduced-motion: reduce) { - .modal-pop, - .backdrop-fade { + .modal-pop { animation: none; } } diff --git a/src/renderer/src/state.ts b/src/renderer/src/state.ts index 7230b4f..0a04937 100644 --- a/src/renderer/src/state.ts +++ b/src/renderer/src/state.ts @@ -89,8 +89,6 @@ export function optionsKey(state: AppState): WorkspaceKey { return workspaceKey(state.category, state.operation) } -export const TOOL_IDS: ToolId[] = ['convert', 'compress', 'resize', 'upscale', 'removebg', 'pdf'] - export const DEFAULT_OPTIONS: Record = { convert: { format: '.webp', quality: 'balanced' }, compress: { @@ -594,13 +592,6 @@ export function reducer(state: AppState, action: Action): AppState { } } -/** Items eligible to (re)run: everything except in-flight or already done. */ -export function processable(items: QueueItem[]): QueueItem[] { - return items.filter( - (i) => i.status === 'ready' || i.status === 'failed' || i.status === 'canceled' - ) -} - /** "45s left" / "12m left" / "1h 22m left" — what actually reassures a user * during a long encode that sits below 1% for minutes. */ export function formatEta(sec: number): string { @@ -612,10 +603,7 @@ export function formatEta(sec: number): string { return `${h}h ${m % 60}m left` } -export function formatBytes(n: number): string { - if (n <= 0) return '0 B' - const u = ['B', 'KB', 'MB', 'GB'] - const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024))) - const v = n / Math.pow(1024, i) - return `${i === 0 ? Math.round(v) : v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${u[i]}` -} +// One byte formatter for the whole app: this file used to carry its own copy +// (clamped at GB, different rounding) that rendered different strings on the +// same screen as the shared one. +export { formatBytes } from '@shared/compress' diff --git a/src/shared/catalog.ts b/src/shared/catalog.ts index f3ac1da..8edd1aa 100644 --- a/src/shared/catalog.ts +++ b/src/shared/catalog.ts @@ -190,8 +190,3 @@ export function defaultOperation(c: CategoryId): string { export function acceptsKind(c: CategoryId, kind: FileKind): boolean { return categoryOf(c).kinds.includes(kind) } - -/** The category a dropped file belongs to, or null if nothing handles it. */ -export function categoryForKind(kind: FileKind): CategoryId | null { - return CATEGORIES.find((c) => c.kinds.includes(kind))?.id ?? null -} diff --git a/src/shared/comfy.ts b/src/shared/comfy.ts index 85ab289..1f22af4 100644 --- a/src/shared/comfy.ts +++ b/src/shared/comfy.ts @@ -1,5 +1,6 @@ // Types + pure classification for ComfyUI-imported upscale models, shared by the // engine (scan) and the renderer (picker). No Node/Electron imports here. +import { baseName } from './fileKind' export type ComfyBadge = 'verified' | 'experimental' | 'unsupported' @@ -97,7 +98,7 @@ export function classifyModel( tokens: string[] = VERIFIED_TOKENS, archs: string[] = VERIFIED_ARCHS ): ComfyModel { - const base = probe.path.split(/[\\/]/).pop() ?? probe.path + const base = baseName(probe.path) const name = base.replace(/\.[^.]+$/, '') if (!probe.ok) { return { path: probe.path, name, scale: 0, badge: 'unsupported', reason: probe.reason } diff --git a/src/shared/fileKind.ts b/src/shared/fileKind.ts index 22abe6c..fc6f8a0 100644 --- a/src/shared/fileKind.ts +++ b/src/shared/fileKind.ts @@ -113,3 +113,9 @@ export function fileKind(ext: string): FileKind { if (DOC_EXTS.includes(e)) return 'document' return 'other' } + +/** The last path segment, for display. Hand-rolled five times before (one of + * which - a forward-slash-only regex fed Windows paths - was a real bug). */ +export function baseName(p: string): string { + return p.split(/[\\/]/).pop() ?? p +} From c485c46a0a88474266526e9b7f67be04e6151ca4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:07:46 +0200 Subject: [PATCH 12/15] perf: per-tool concurrency, hoisted scans, bounded thumbnails, preview churn - JobQueue caps GPU/sidecar tools at one concurrent run inside the global limit: the python sidecars are strictly serial, so three of four "running" upscales were estimate-ticker fiction, and concurrent realesrgan processes each auto-sized tiles against FULL VRAM. Capped tools queue without blocking other tools behind them. - Model scanning walks the encoder/VAE pools ONCE per scan instead of twice per model (30 models = 60 synchronous recursive readdirs on the main thread, on every generate-panel mount). - Thumbnails: the limiter now wraps the whole pipeline (osThumbnail was uncapped - hundreds of dropped files fired hundreds of concurrent shell requests), and a 20s watchdog aborts a tool hung on a dead network share so it can't hold a limiter slot forever. - The preview window announces its close: the renderer kept structured- cloning the preview list (base64 thumbnails included) on every progress tick, forever, into a window that no longer existed. - Stable menu/dialog close handlers (ContextMenu re-registered five window listeners on all ~20 progress renders a second) and a memoized comfyChoices (the validity effect depended on a freshly-mapped array and called set() from inside itself, escaping a loop only by convergence). Refs #12 Co-Authored-By: Claude Opus 5 --- src/main/generate/archRegistry.ts | Bin 8555 -> 9072 bytes src/main/generate/models.ts | 6 +++-- src/main/jobQueue.ts | 22 +++++++++++++--- src/main/previewWindow.ts | 5 ++++ src/main/thumbnail.ts | 26 ++++++++++++++----- src/preload/index.ts | 6 +++++ src/renderer/src/App.tsx | 15 +++++++++-- src/renderer/src/components/OptionsPanel.tsx | 21 ++++++++++----- 8 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/main/generate/archRegistry.ts b/src/main/generate/archRegistry.ts index d12426da0e432aff5dc20c40bdba193447f85adb..46827b8a0e1d724725391f08de30a7986146c02b 100644 GIT binary patch delta 498 zcmZwDy-EW?5C`x^5UvvhAs;Yckq<(Q2o?ttG>MfOK|8Tn_vXCCz1?F!E`|`(`2u+e zTft|tvhfj|yI>Tto86iH{fGTH`8<7nsxGb*1kO|>BIXccA<+T@gI3ENj}1?#D+VZ!8tEk@ydJRF@t z^RNdCJ5@X}T4`_T5sPTtLaa;fpa-drP}&Y?1&%gNDV>U9u)M<@3C@^8T2XuDT9iT= z-e6HU>_U4lz{gA*2SPbCG2>V+&y>*Wa!YGzVHNzun%RYwsIh&E~Ch delta 67 zcmez1_S$Jfo%G}u+4+-gzC?x0S6_+TKC8h$!6ml|) VOM*+2G@{jk0_w4wyXBs;002fT7svnr diff --git a/src/main/generate/models.ts b/src/main/generate/models.ts index 5915d46..d75f0ca 100644 --- a/src/main/generate/models.ts +++ b/src/main/generate/models.ts @@ -16,7 +16,7 @@ import { readSafetensorsHeader } from './archScan' import { readGgufHeader } from './ggufScan' -import { resolveArch } from './archRegistry' +import { listCompanionPools, resolveArch } from './archRegistry' // The generation model list the UI shows: single-file checkpoints AND recognized // image-generation diffusion models (Flux 1/2, Z-Image, Krea 2). Both are header- @@ -144,6 +144,8 @@ export function scanGenerationModels(): GenModelScan { let gguf = 0 // --- Single-file checkpoints (CheckpointLoaderSimple) -------------------- + // One pool walk for the whole scan, not two per model. + const pools = listCompanionPools() for (const { rel, abs, base } of walkModels(['checkpoints'], /\.(safetensors|ckpt|sft)$/i)) { const common = { name: rel, @@ -246,7 +248,7 @@ export function scanGenerationModels(): GenModelScan { continue } const ga = arch as GenArch - const { missing, wiring } = resolveArch(ga, rel, fileSize(abs)) + const { missing, wiring } = resolveArch(ga, rel, fileSize(abs), pools) const common = { name: rel, label: label(rel), diff --git a/src/main/jobQueue.ts b/src/main/jobQueue.ts index 2df21b7..fe00245 100644 --- a/src/main/jobQueue.ts +++ b/src/main/jobQueue.ts @@ -1,6 +1,6 @@ import { cpus } from 'os' import { statSync } from 'fs' -import type { JobEvent, JobRequest } from '@shared/types' +import type { JobEvent, JobRequest, ToolId } from '@shared/types' import { fileInfoFromPath } from './fileInfo' import { getTool } from './tools/registry' import { ToolMissingError } from './run' @@ -8,6 +8,12 @@ import { toolMissingMessage } from './toolResolver' type Emit = (event: JobEvent) => void +// Per-tool concurrency caps inside the global limit. The AI tools are +// effectively serial: the python sidecars process one request at a time (so +// three of four "running" upscales were fiction driven by estimate tickers), +// and concurrent realesrgan processes each auto-size tiles against FULL VRAM. +const TOOL_LIMIT: Partial> = { upscale: 1, removebg: 1 } + /** * Batch queue: runs jobs with bounded concurrency, streams progress via `emit`, * and supports per-job cancellation. Each job spawns one tool run. @@ -16,6 +22,7 @@ export class JobQueue { private controllers = new Map() private queue: JobRequest[] = [] private active = 0 + private activeByTool = new Map() private readonly concurrency: number constructor( @@ -54,8 +61,15 @@ export class JobQueue { private pump(): void { while (this.active < this.concurrency && this.queue.length > 0) { - const req = this.queue.shift() - if (req) void this.execute(req) + // First request whose tool still has capacity; capped tools wait + // without blocking the rest of the queue behind them. + const idx = this.queue.findIndex((r) => { + const limit = TOOL_LIMIT[r.tool] + return limit == null || (this.activeByTool.get(r.tool) ?? 0) < limit + }) + if (idx === -1) return + const req = this.queue.splice(idx, 1)[0] + void this.execute(req) } } @@ -68,6 +82,7 @@ export class JobQueue { const ctrl = new AbortController() this.controllers.set(req.id, ctrl) this.active++ + this.activeByTool.set(req.tool, (this.activeByTool.get(req.tool) ?? 0) + 1) // No percent here: "started" is not progress. Only a tool reporting a real // percentage should switch the UI to a determinate bar. this.emit({ id: req.id, status: 'running' }) @@ -110,6 +125,7 @@ export class JobQueue { } finally { this.controllers.delete(req.id) this.active-- + this.activeByTool.set(req.tool, Math.max(0, (this.activeByTool.get(req.tool) ?? 1) - 1)) this.pump() } } diff --git a/src/main/previewWindow.ts b/src/main/previewWindow.ts index fd3d18b..5de90cc 100644 --- a/src/main/previewWindow.ts +++ b/src/main/previewWindow.ts @@ -45,6 +45,11 @@ export function openPreviewWindow(next: PreviewPayload): void { win.on('ready-to-show', () => win?.show()) win.on('closed', () => { win = null + // Tell the app windows: the renderer otherwise kept re-serialising its + // preview list (with base64 thumbnails) on every progress tick, forever, + // into a window that no longer existed. + for (const w of BrowserWindow.getAllWindows()) + if (!w.isDestroyed() && !w.webContents.isDestroyed()) w.webContents.send('preview:closed') }) // A markdown/HTML link click must open in the OS browser, never navigate the diff --git a/src/main/thumbnail.ts b/src/main/thumbnail.ts index 16b3fe6..0afa89b 100644 --- a/src/main/thumbnail.ts +++ b/src/main/thumbnail.ts @@ -26,12 +26,17 @@ export async function makeThumbnail( size: number, kind: FileKind ): Promise { - const os = await osThumbnail(path, size) - if (os) return os - if (kind === 'image') return magickThumbnail(path, size) - if (kind === 'video') return videoFrame(path, size) - if (kind === 'audio') return audioCover(path, size) - return null + // The WHOLE pipeline runs under the limiter: osThumbnail was previously + // uncapped, so dropping hundreds of files fired hundreds of concurrent + // shell-thumbnail requests in one pass. + return withLimit(async () => { + const os = await osThumbnail(path, size) + if (os) return os + if (kind === 'image') return magickThumbnail(path, size) + if (kind === 'video') return videoFrame(path, size) + if (kind === 'audio') return audioCover(path, size) + return null + }) } async function osThumbnail(path: string, size: number): Promise { @@ -91,13 +96,20 @@ async function toolPng(tool: string, args: string[]): Promise { // `filesmith-` prefix so the stale-temp sweeper (index.ts) collects it if a // hard crash skips the finally cleanup. const out = join(tmpdir(), `filesmith-thumb-${randomUUID()}.png`) + // A watchdog, because this work has no user-visible failure mode: an ffmpeg + // hung on a disconnected network share would otherwise hold a limiter slot + // forever and park every later thumbnail behind it. + const ac = new AbortController() + const watchdog = setTimeout(() => ac.abort(), 20_000) + watchdog.unref?.() try { - const { code } = await withLimit(() => run(resolveTool(tool), [...args, out])) + const { code } = await run(resolveTool(tool), [...args, out], { signal: ac.signal }) if (code !== 0 || !existsSync(out)) return null return `data:image/png;base64,${readFileSync(out).toString('base64')}` } catch { return null } finally { + clearTimeout(watchdog) rmSync(out, { force: true }) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 4e60816..41b08c9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -59,6 +59,12 @@ const api = { ipcRenderer.on('preview:update', listener) return () => ipcRenderer.removeListener('preview:update', listener) }, + /** The preview window was closed — stop pushing list updates to it. */ + onPreviewClosed: (cb: () => void): (() => void) => { + const listener = (): void => cb() + ipcRenderer.on('preview:closed', listener) + return () => ipcRenderer.removeListener('preview:closed', listener) + }, /** Push a fresh file list to an open preview window (keeps its position). */ updatePreviewList: (files: PreviewItem[]): void => ipcRenderer.send('preview:update-list', files), /** The preview window listens for live list changes. */ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d579437..052e501 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { + useCallback, useEffect, useReducer, useRef, @@ -164,6 +165,11 @@ export default function App(): JSX.Element { const vDimsRequested = useRef>(new Set()) // Oversize-upscale confirmation, and the flag that lets the confirmed run through. const [confirm, setConfirm] = useState(null) + // Stable close handlers: ContextMenu re-registers five window listeners + // whenever its onClose identity changes, which an inline arrow made happen + // on all ~20 progress renders a second. + const closeMenu = useCallback(() => setMenu(null), []) + const closeConfirm = useCallback(() => setConfirm(null), []) const confirmedHuge = useRef(false) // Session persistence: restore the last session (queues + produced files) on // launch, pruning anything whose file was deleted since; then save on change. @@ -360,6 +366,11 @@ export default function App(): JSX.Element { window.filesmith.updatePreviewList(toPreviewFiles(items, previewCtx.side, outThumbs)) }, [state.queues, outThumbs, previewCtx]) + // Stop the sync the moment the preview window closes — it used to keep + // structured-cloning the list (base64 thumbs included) on every progress + // tick, forever, into a window that no longer existed. + useEffect(() => window.filesmith.onPreviewClosed(() => setPreviewCtx(null)), []) + // Dismiss a set of items from a column. For Output we also recycle-bin the // produced file before dropping the card. function dismiss(ids: string[], column: 'input' | 'output'): void { @@ -998,8 +1009,8 @@ export default function App(): JSX.Element { />
- setMenu(null)} /> - setConfirm(null)} /> + +
) } diff --git a/src/renderer/src/components/OptionsPanel.tsx b/src/renderer/src/components/OptionsPanel.tsx index 0f557cd..ba708c6 100644 --- a/src/renderer/src/components/OptionsPanel.tsx +++ b/src/renderer/src/components/OptionsPanel.tsx @@ -399,13 +399,20 @@ function UpscaleOptions({ useEffect(() => { void window.filesmith.upscaleModels().then(setNcnn) }, []) - const comfyModels = comfy.status?.models ?? [] - const comfyChoices = comfyModels.map((m) => ({ - value: `comfy:${m.path}`, - // Verified models read clean; only flag the unvetted ones, so the marker - // isn't mistaken for a "selected" checkmark. - label: `${m.name} · ${m.scale}×${m.badge === 'experimental' ? ' · experimental' : ''}` - })) + const comfyModels = comfy.status?.models + // Memoized: the validity effect below depends on this list and calls set() + // from inside itself — with a freshly-mapped array every render it escaped + // looping only by convergence. + const comfyChoices = useMemo( + () => + (comfyModels ?? []).map((m) => ({ + value: `comfy:${m.path}`, + // Verified models read clean; only flag the unvetted ones, so the + // marker isn't mistaken for a "selected" checkmark. + label: `${m.name} · ${m.scale}×${m.badge === 'experimental' ? ' · experimental' : ''}` + })), + [comfyModels] + ) // Fall back to the legacy Photo/Anime aliases until the disk scan returns, so // the picker is never empty for a frame. const ncnnChoices: Choice[] = ncnn?.length From 84848e19d8dec2b76f16070e770e2d81a5dda0ff Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:09:50 +0200 Subject: [PATCH 13/15] test(e2e): Playwright smoke suite against the real built app `npm run test:e2e` launches out/main via _electron and exercises the chain the 316 unit tests structurally cannot: preload bridge -> IPC -> job queue -> bundled binary -> collision-safe output. Four specs: - boots to the Images workspace with the primary action on screen - convert PNG -> JPG lands the collision-safe output through the real engine - a % in the filename converts fine AND an unrelated file with the lookalike printf-expanded name is untouched (the review's data-loss finding, verified end to end) - cancel stops a running AV1 encode, reports 'canceled', leaves no partial Specs self-skip when the fetched binaries are absent (a bare CI checkout), so CI stays green without them. All four pass locally against this branch. Refs #12 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 3 +- e2e/smoke.spec.ts | 143 ++++++++++++++++++++++++++++++++++++ package-lock.json | 65 ++++++++++++++++ package.json | 8 +- playwright.config.ts | 13 ++++ test-results/.last-run.json | 4 + 6 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 test-results/.last-run.json diff --git a/CLAUDE.md b/CLAUDE.md index f614101..ed56ce0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by e - `npm test` — Vitest unit tests (arg-builders, format catalogs, output collision-safety). - `npm run lint` / `npm run format` — eslint (flat config) / prettier. - `npm run package` — electron-vite build + electron-builder NSIS installer to `dist/`. -- UI end-to-end: Playwright (scaffolded once the real UI exists — per the global testing rule). +- `npm run test:e2e` — Playwright end-to-end (launches the built app via `_electron`; run + `npm run build` first). Covers the preload/IPC/engine chain unit tests can't reach. ## Conventions diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..d85d7b9 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,143 @@ +import { _electron, type ElectronApplication, type Page } from 'playwright' +import { test, expect } from '@playwright/test' +import { execFileSync } from 'child_process' +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import type { JobEvent } from '../src/shared/types' + +// Smoke the real app end to end: launch the built Electron bundle, run a job +// through the actual preload bridge -> IPC -> engine -> bundled binary, and +// assert the collision-safe output landed. This is the coverage the unit +// suite structurally cannot have (a renamed IPC channel, a broken preload +// bridge, or a mis-bundled binary all pass unit tests and fail here). + +const ROOT = resolve(__dirname, '..') +const MAIN = join(ROOT, 'out', 'main', 'index.js') +const MAGICK = join(ROOT, 'resources', 'bin', 'magick.exe') +const FFMPEG = join(ROOT, 'resources', 'bin', 'ffmpeg.exe') + +// A tiny valid PNG (1x1 white). +const PNG_1PX = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +) + +// The bundled binaries are fetched, not committed; without them (a bare CI +// checkout) the engine specs cannot mean anything. +const binariesPresent = existsSync(MAGICK) && existsSync(FFMPEG) + +let app: ElectronApplication +let page: Page +let dir: string + +test.beforeAll(async () => { + test.skip(!existsSync(MAIN), 'run `npm run build` first') + dir = mkdtempSync(join(tmpdir(), 'filesmith-e2e-')) + app = await _electron.launch({ args: [MAIN] }) + page = await app.firstWindow() +}) + +test.afterAll(async () => { + await app?.close() + rmSync(dir, { recursive: true, force: true }) +}) + +/** Run one job through the real bridge and wait for its terminal event. */ +function runJob(tool: string, input: string, options: Record): Promise { + return page.evaluate( + ({ tool, input, options }) => + new Promise((resolvePromise) => { + const id = `e2e-${Math.random().toString(36).slice(2)}` + const un = window.filesmith.onJobEvent((e) => { + if (e.id !== id) return + if (e.status === 'done' || e.status === 'failed' || e.status === 'canceled') { + un() + resolvePromise(e) + } + }) + void window.filesmith.runJob({ + id, + tool: tool as never, + input, + options: options as never + }) + }), + { tool, input, options } + ) +} + +test('the app boots to the Images workspace', async () => { + await expect(page.locator('text=Images').first()).toBeVisible() + // The primary action is on screen without scrolling (the pinned footer). + await expect(page.locator('button', { hasText: 'Convert' }).last()).toBeVisible() +}) + +test('convert PNG -> JPG lands a collision-safe output', async () => { + test.skip(!binariesPresent, 'bundled binaries not fetched') + const src = join(dir, 'photo.png') + writeFileSync(src, PNG_1PX) + const e = await runJob('convert', src, { format: '.jpg', quality: 'balanced' }) + expect(e.status).toBe('done') + expect(e.outputPath).toBe(join(dir, 'photo.jpg')) + expect(existsSync(e.outputPath!)).toBe(true) +}) + +test('a % in the filename neither fails the job nor touches a neighbour', async () => { + test.skip(!binariesPresent, 'bundled binaries not fetched') + const src = join(dir, '100%off.png') + const bystander = join(dir, '1000ff (converted).jpg') + writeFileSync(src, PNG_1PX) + writeFileSync(bystander, 'bystander bytes') + const e = await runJob('convert', src, { format: '.jpg', quality: 'balanced' }) + expect(e.status).toBe('done') + expect(existsSync(e.outputPath!)).toBe(true) + // The unrelated file with the lookalike printf-expanded name is untouched. + const fs = await import('fs') + expect(fs.readFileSync(bystander, 'utf-8')).toBe('bystander bytes') +}) + +test('cancel stops a running encode and reports canceled', async () => { + test.skip(!binariesPresent, 'bundled binaries not fetched') + // A clip long enough that an AV1 encode cannot finish before the cancel. + const clip = join(dir, 'clip.mp4') + execFileSync(FFMPEG, [ + '-y', + '-f', + 'lavfi', + '-i', + 'testsrc=duration=20:size=1280x720:rate=30', + '-pix_fmt', + 'yuv420p', + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + clip + ]) + const e = await page.evaluate( + (input) => + new Promise((resolvePromise) => { + const id = 'e2e-cancel' + const un = window.filesmith.onJobEvent((ev) => { + if (ev.id !== id) return + if (ev.status === 'running') void window.filesmith.cancelJob(id) + if (ev.status === 'done' || ev.status === 'failed' || ev.status === 'canceled') { + un() + resolvePromise(ev) + } + }) + void window.filesmith.runJob({ + id, + tool: 'compress', + input, + options: { videoCodec: 'av1', quality: 80, scale: 100 } + }) + }), + clip + ) + expect(e.status).toBe('canceled') + // No partial output was left behind next to the source. + const leftovers = readdirSync(dir).filter((f) => f.includes('(compressed)')) + expect(leftovers).toEqual([]) +}) diff --git a/package-lock.json b/package-lock.json index 7ddde17..d60779f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.3.2", "@types/node": "^26.1.1", "@types/react": "^19.2.17", @@ -23,6 +24,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", + "playwright": "^1.62.1", "prettier": "^3.9.5", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -1467,6 +1469,22 @@ "node": ">=14.18.0" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -6181,6 +6199,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index 2c37762..12dd12c 100644 --- a/package.json +++ b/package.json @@ -13,17 +13,16 @@ "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", "test": "vitest run", + "test:e2e": "playwright test", "test:watch": "vitest", "lint": "eslint .", "format": "prettier --write .", "binaries": "node scripts/fetch-binaries.mjs", "package": "node scripts/fetch-binaries.mjs && electron-vite build && electron-builder --win" }, - "dependencies": {}, "devDependencies": { "@eslint/js": "^10.0.1", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.3.2", "@types/node": "^26.1.1", "@types/react": "^19.2.17", @@ -37,7 +36,10 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", + "playwright": "^1.62.1", "prettier": "^3.9.5", + "react": "^19.2.7", + "react-dom": "^19.2.7", "tailwindcss": "^4.3.2", "typescript": "^5.9.3", "typescript-eslint": "^8.64.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e11eb2c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test' + +// End-to-end tests drive the REAL built app through Electron (_electron.launch) +// — the preload contract, IPC wiring and engine that 300+ unit tests cannot +// touch. Run `npm run build` first (the specs launch out/main/index.js). +export default defineConfig({ + testDir: 'e2e', + timeout: 120_000, + // One app instance at a time: the app holds a single-instance lock, and the + // suites share the bundled binaries. + workers: 1, + reporter: [['list']] +}) diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..f740f7c --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} From fa9429eea0b3a612833a0b5e6dddc6112af3856d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:10:00 +0200 Subject: [PATCH 14/15] chore: ignore Playwright artifacts Refs #12 Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++++ test-results/.last-run.json | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 test-results/.last-run.json diff --git a/.gitignore b/.gitignore index 2473176..a53cc37 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ build/logo-mockups/ # Channel signing key — never commit. See scripts/registry-channel.mjs channel-private.pem channel.json + +# Playwright artifacts +test-results +playwright-report diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index f740f7c..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} From 41bcb7905b1127872f88273dcd76e09aaf1ef014 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:45:22 +0200 Subject: [PATCH 15/15] test(e2e): full workflow matrix against the real app, launched correctly e2e/workflows.spec.ts drives every operation of every category through the real preload -> IPC -> queue -> engine chain, asserting on the bytes that land on disk: image convert (incl. multi-size ICO, animated GIF frame preservation, same-format refusal), Caesium + magick compress, resize (percent, exact stretch, coalesced GIF), Real-ESRGAN upscale (size AND restored alpha), rembg background removal, video convert (MKV + palette GIF), the once-aborting 25% scale position, audio cover-art stream copy, WAV -> FLAC, document conversion via LibreOffice, all seven PDF verbs (text with unicode intact, pages, split, burst, extract, merge, mutool + Ghostscript compress), collision safety on repeat runs, a unicode+spaces+% filename, and one real 512px ComfyUI generation (the spec deletes its own output). Machine-dependent specs (rembg, generate) skip cleanly when the environment lacks them. Launch fix that matters beyond this suite: launching Electron with `args: [out/main/index.js]` makes app.getAppPath() resolve to out/main, which hides every resources/ tree - the app then reported Real-ESRGAN, Ghostscript and the built-in registry as missing while resolveTool silently fell back to PATH binaries, so the earlier smoke suite was partly testing the machine's tools. Both suites now launch by package root, matching dev and packaged behaviour; all 29 tests pass against the bundled binaries. Refs #12 Co-Authored-By: Claude Opus 5 --- e2e/helpers.ts | 48 +++++ e2e/smoke.spec.ts | 6 +- e2e/workflows.spec.ts | 484 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 e2e/helpers.ts create mode 100644 e2e/workflows.spec.ts diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..f4cc691 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,48 @@ +import type { Page } from 'playwright' +import { join, resolve } from 'path' +import type { JobEvent } from '../src/shared/types' + +export const ROOT = resolve(__dirname, '..') +export const MAIN = join(ROOT, 'out', 'main', 'index.js') +export const BIN = join(ROOT, 'resources', 'bin') +export const MAGICK = join(BIN, 'magick.exe') +export const FFMPEG = join(BIN, 'ffmpeg.exe') +export const FFPROBE = join(BIN, 'ffprobe.exe') +export const MUTOOL = join(BIN, 'mutool.exe') + +/** Env so the bundled modules-build magick finds its coder DLLs when the specs + * call it directly (the app sets the same env for its own spawns). */ +export const magickEnv = { + ...process.env, + MAGICK_CODER_MODULE_PATH: join(BIN, 'modules', 'coders'), + MAGICK_CONFIGURE_PATH: BIN +} + +/** Run one job through the real preload bridge; resolve on its terminal event. */ +export function runJob( + page: Page, + tool: string, + input: string, + options: Record +): Promise { + return page.evaluate( + ({ tool, input, options }) => + new Promise((resolvePromise) => { + const id = `e2e-${Math.random().toString(36).slice(2)}` + const un = window.filesmith.onJobEvent((e) => { + if (e.id !== id) return + if (e.status === 'done' || e.status === 'failed' || e.status === 'canceled') { + un() + resolvePromise(e) + } + }) + void window.filesmith.runJob({ + id, + tool: tool as never, + input, + options: options as never + }) + }), + { tool, input, options } + ) +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index d85d7b9..21a6cf4 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -34,7 +34,11 @@ let dir: string test.beforeAll(async () => { test.skip(!existsSync(MAIN), 'run `npm run build` first') dir = mkdtempSync(join(tmpdir(), 'filesmith-e2e-')) - app = await _electron.launch({ args: [MAIN] }) + // Launch by PACKAGE ROOT, not the main script: `electron out/main/index.js` + // makes app.getAppPath() resolve to out/main, which silently hides every + // resources/ tree and lets resolveTool fall back to PATH binaries — the + // suite would then test the machine's tools, not the bundled ones. + app = await _electron.launch({ args: [ROOT] }) page = await app.firstWindow() }) diff --git a/e2e/workflows.spec.ts b/e2e/workflows.spec.ts new file mode 100644 index 0000000..8958dcf --- /dev/null +++ b/e2e/workflows.spec.ts @@ -0,0 +1,484 @@ +import { _electron, type ElectronApplication, type Page } from 'playwright' +import { test, expect } from '@playwright/test' +import { execFileSync } from 'child_process' +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { basename, join } from 'path' +import { FFMPEG, FFPROBE, MAGICK, MAIN, MUTOOL, ROOT, magickEnv, runJob } from './helpers' + +// The full workflow matrix, run against the REAL built app: every operation of +// every category, through the actual preload bridge -> IPC -> queue -> engine +// -> bundled binary, asserting on the bytes that land on disk. Fixtures are +// synthesized into one temp dir (outputs land beside their sources, so the +// teardown sweep removes everything the suite produced). + +const binariesPresent = [MAGICK, FFMPEG, FFPROBE, MUTOOL].every(existsSync) + +let app: ElectronApplication +let page: Page +let dir: string + +// --- fixture builders (bundled binaries only — nothing from PATH) ----------- + +const magick = (args: string[]): string => + execFileSync(MAGICK, args, { env: magickEnv, encoding: 'utf-8' }) +const ffmpeg = (args: string[]): void => { + execFileSync(FFMPEG, ['-y', '-loglevel', 'error', ...args], { stdio: 'pipe' }) +} +const ffprobeJson = (args: string[]): unknown => + JSON.parse( + execFileSync(FFPROBE, ['-v', 'quiet', '-print_format', 'json', ...args], { + encoding: 'utf-8' + }) + ) +const identify = (fmt: string, file: string): string => + magick(['identify', '-format', fmt, file]).trim() +const pageCount = (pdf: string): number => { + const out = execFileSync(MUTOOL, ['info', pdf], { encoding: 'utf-8' }) + return Number(/Pages:\s*(\d+)/i.exec(out)?.[1] ?? 0) +} + +test.beforeAll(async () => { + test.skip(!existsSync(MAIN), 'run `npm run build` first') + test.skip(!binariesPresent, 'bundled binaries not fetched') + dir = mkdtempSync(join(tmpdir(), 'filesmith-wf-')) + + // Images: an opaque photo, a transparent logo, an animated GIF. + magick(['-size', '400x300', 'gradient:blue-red', join(dir, 'photo.png')]) + magick([ + '-size', + '200x200', + 'xc:none', + '-fill', + 'white', + '-draw', + 'circle 100,100 100,40', + join(dir, 'logo.png') + ]) + magick([ + '-delay', + '20', + '-size', + '100x100', + 'xc:red', + 'xc:green', + 'xc:blue', + join(dir, 'anim.gif') + ]) + + // Video: an odd-scaling source (854x480 hits the divisibility fix) + a clip. + ffmpeg([ + '-f', + 'lavfi', + '-i', + 'testsrc=duration=3:size=854x480:rate=30', + '-pix_fmt', + 'yuv420p', + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + join(dir, 'clip854.mp4') + ]) + + // Audio: a plain tone, a lossless WAV, and an MP3 carrying PNG cover art + // (the cover-art regression: it used to be re-encoded into a giant PNG). + ffmpeg([ + '-f', + 'lavfi', + '-i', + 'sine=frequency=440:duration=3', + '-c:a', + 'libmp3lame', + '-b:a', + '128k', + join(dir, 'plain.mp3') + ]) + ffmpeg(['-f', 'lavfi', '-i', 'sine=frequency=330:duration=3', join(dir, 'tone.wav')]) + ffmpeg([ + '-i', + join(dir, 'plain.mp3'), + '-i', + join(dir, 'photo.png'), + '-map', + '0:a', + '-map', + '1', + '-c:a', + 'copy', + '-c:v', + 'png', + '-disposition:v', + 'attached_pic', + join(dir, 'covered.mp3') + ]) + + // PDFs: a 3-page image PDF, and (via the app itself later) a text PDF. + magick([ + join(dir, 'photo.png'), + join(dir, 'logo.png'), + join(dir, 'photo.png'), + join(dir, 'pages3.pdf') + ]) + + // Document: real prose so extract-text has something to find. + writeFileSync( + join(dir, 'notes.txt'), + 'Filesmith end-to-end fixture.\nSecond line with unicode: æøå — and a % sign.\n' + ) + + // Launch by PACKAGE ROOT, not the main script: `electron out/main/index.js` + // makes app.getAppPath() resolve to out/main, which silently hides every + // resources/ tree and lets resolveTool fall back to PATH binaries — the + // suite would then test the machine's tools, not the bundled ones. + app = await _electron.launch({ args: [ROOT] }) + page = await app.firstWindow() +}) + +test.afterAll(async () => { + await app?.close() + // Everything the suite made — fixtures AND outputs — lives under this dir. + if (dir) rmSync(dir, { recursive: true, force: true }) +}) + +// --- UI navigation ----------------------------------------------------------- + +test('every category opens its workspace with the right operations', async () => { + const expected: Record = { + Images: ['Convert', 'Compress', 'Resize', 'Remove Background', 'Upscale', 'Generate'], + Video: ['Convert', 'Compress'], + Audio: ['Convert', 'Compress'], + PDF: ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst', 'Extract images', 'Compress'], + Documents: ['Convert'] + } + for (const [category, ops] of Object.entries(expected)) { + await page.locator(`button:has-text("${category}")`).first().click() + // The operation switcher lists every operation of this category. + await page.locator('aside button').first().click() // open the switcher + for (const op of ops) { + await expect(page.locator(`text=${op}`).first()).toBeVisible() + } + await page.keyboard.press('Escape') + } +}) + +// --- Images ------------------------------------------------------------------ + +test('convert: png -> jpg, webp, bmp (flattened), multi-size ico', async () => { + for (const format of ['.jpg', '.webp', '.bmp', '.ico']) { + const e = await runJob(page, 'convert', join(dir, 'photo.png'), { + format, + quality: 'balanced' + }) + expect(e.status, format).toBe('done') + expect(e.outputPath!.endsWith(format)).toBe(true) + expect(statSync(e.outputPath!).size).toBeGreaterThan(0) + } + // The ICO really is multi-size (icon:auto-resize). + const ico = join(dir, 'photo.ico') + expect(identify('%wx%h;', ico).split(';').filter(Boolean).length).toBeGreaterThan(1) +}) + +test('convert: animated gif -> webp keeps every frame', async () => { + const e = await runJob(page, 'convert', join(dir, 'anim.gif'), { + format: '.webp', + quality: 'balanced' + }) + expect(e.status).toBe('done') + expect(identify('%n;', e.outputPath!).split(';').filter(Boolean)[0]).toBe('3') +}) + +test('convert: same format is refused, not silently copied', async () => { + const e = await runJob(page, 'convert', join(dir, 'photo.png'), { format: '.png' }) + expect(e.status).toBe('failed') + expect(e.error).toMatch(/already that format/i) +}) + +test('compress image: caesium keep-format and magick -> webp both shrink-or-report', async () => { + const keep = await runJob(page, 'compress', join(dir, 'photo.png'), { + quality: 60, + imageFormat: 'keep' + }) + expect(keep.status).toBe('done') + expect(basename(keep.outputPath!)).toBe('photo (compressed).png') + + const webp = await runJob(page, 'compress', join(dir, 'photo.png'), { + quality: 60, + imageFormat: 'webp' + }) + expect(webp.status).toBe('done') + expect(webp.outputPath!.endsWith('.webp')).toBe(true) +}) + +test('resize: percent, and exact stretch dimensions', async () => { + const pct = await runJob(page, 'resize', join(dir, 'photo.png'), { + mode: 'percent', + percent: 50 + }) + expect(pct.status).toBe('done') + expect(identify('%wx%h', pct.outputPath!)).toBe('200x150') + + const dim = await runJob(page, 'resize', join(dir, 'photo.png'), { + mode: 'dimensions', + width: 200, + height: 100, + fit: 'stretch' + }) + expect(dim.status).toBe('done') + expect(identify('%wx%h', dim.outputPath!)).toBe('200x100') +}) + +test('resize: animated gif is coalesced and stays animated', async () => { + const e = await runJob(page, 'resize', join(dir, 'anim.gif'), { mode: 'percent', percent: 50 }) + expect(e.status).toBe('done') + expect(identify('%n;', e.outputPath!).split(';').filter(Boolean)[0]).toBe('3') + expect(identify('%wx%h;', e.outputPath!).startsWith('50x50')).toBe(true) +}) + +test('collision safety: running the same convert twice yields two distinct outputs', async () => { + const a = await runJob(page, 'convert', join(dir, 'logo.png'), { format: '.webp' }) + const b = await runJob(page, 'convert', join(dir, 'logo.png'), { format: '.webp' }) + expect(a.status).toBe('done') + expect(b.status).toBe('done') + expect(a.outputPath).not.toBe(b.outputPath) + expect(existsSync(a.outputPath!)).toBe(true) + expect(existsSync(b.outputPath!)).toBe(true) +}) + +test('upscale: Real-ESRGAN 2x keeps size AND the alpha channel', async () => { + test.setTimeout(180_000) + const e = await runJob(page, 'upscale', join(dir, 'logo.png'), { + upscaleModel: 'photo', + upscaleFactor: 2 + }) + expect(e.status, e.error).toBe('done') + expect(identify('%wx%h', e.outputPath!)).toBe('400x400') + // restoreAlpha: the RGB-only network output gets the source matte back. + expect(identify('%A', e.outputPath!).toLowerCase()).not.toContain('undefined') +}) + +test('remove background: rembg cuts the subject out (skips if uv is unavailable)', async () => { + test.setTimeout(600_000) // first run downloads the engine + model + const status = await page.evaluate(() => window.filesmith.removebgStatus()) + test.skip(!status.ready && !status.uvAvailable, 'uv not installed on this machine') + const e = await runJob(page, 'removebg', join(dir, 'photo.png'), {}) + expect(e.status).toBe('done') + expect(e.outputPath!.endsWith('.png')).toBe(true) + expect(identify('%A', e.outputPath!).toLowerCase()).not.toContain('undefined') +}) + +// --- Video ------------------------------------------------------------------- + +test('video convert: mp4 -> mkv and a palette GIF', async () => { + const mkv = await runJob(page, 'convert', join(dir, 'clip854.mp4'), { format: '.mkv' }) + expect(mkv.status).toBe('done') + expect(statSync(mkv.outputPath!).size).toBeGreaterThan(0) + + const gif = await runJob(page, 'convert', join(dir, 'clip854.mp4'), { format: '.gif' }) + expect(gif.status).toBe('done') + const info = ffprobeJson(['-show_streams', gif.outputPath!]) as { + streams: { codec_name: string }[] + } + expect(info.streams[0].codec_name).toBe('gif') +}) + +test('video compress: the 25% scale position that used to abort now encodes evenly', async () => { + // 854x480 * 0.25 = 213.5x120 — the exact "width not divisible by 2" case. + const e = await runJob(page, 'compress', join(dir, 'clip854.mp4'), { + videoCodec: 'h264', + quality: 70, + scale: 25 + }) + expect(e.status).toBe('done') + const info = ffprobeJson(['-show_streams', e.outputPath!]) as { + streams: { width?: number; height?: number }[] + } + const v = info.streams.find((s) => s.width) + expect((v!.width! % 2) + (v!.height! % 2)).toBe(0) + expect(v!.width).toBe(212) +}) + +// --- Audio ------------------------------------------------------------------- + +test('audio compress keep-format: cover art is stream-copied, not blown up', async () => { + const src = join(dir, 'covered.mp3') + const e = await runJob(page, 'compress', src, { audioCodec: 'keep', audioBitrate: 96 }) + expect(e.status).toBe('done') + const info = ffprobeJson(['-show_streams', e.outputPath!]) as { + streams: { codec_type: string; codec_name: string }[] + } + // The attached picture is still there, still PNG (copied, not re-encoded). + const pic = info.streams.find((s) => s.codec_type === 'video') + expect(pic?.codec_name).toBe('png') + // And the "compressed" file did not balloon past its source. + expect(statSync(e.outputPath!).size).toBeLessThan(statSync(src).size * 1.5) +}) + +test('audio compress: lossless wav keeps every sample as FLAC', async () => { + const e = await runJob(page, 'compress', join(dir, 'tone.wav'), { + audioCodec: 'keep', + audioBitrate: 192 + }) + expect(e.status).toBe('done') + expect(e.outputPath!.endsWith('.flac')).toBe(true) + expect(statSync(e.outputPath!).size).toBeLessThan(statSync(join(dir, 'tone.wav')).size) +}) + +test('audio convert: mp3 -> m4a and -> opus', async () => { + for (const format of ['.m4a', '.opus']) { + const e = await runJob(page, 'convert', join(dir, 'plain.mp3'), { format }) + expect(e.status, format).toBe('done') + expect(statSync(e.outputPath!).size).toBeGreaterThan(0) + } +}) + +// --- Documents (LibreOffice) ------------------------------------------------- + +test('document convert: txt -> pdf and txt -> docx', async () => { + test.setTimeout(180_000) // first soffice launch builds a profile + const pdf = await runJob(page, 'convert', join(dir, 'notes.txt'), { format: '.pdf' }) + expect(pdf.status).toBe('done') + expect(basename(pdf.outputPath!)).toBe('notes.pdf') + expect(pageCount(pdf.outputPath!)).toBeGreaterThan(0) + + const docx = await runJob(page, 'convert', join(dir, 'notes.txt'), { format: '.docx' }) + expect(docx.status).toBe('done') + expect(statSync(docx.outputPath!).size).toBeGreaterThan(0) +}) + +// --- PDF --------------------------------------------------------------------- + +test('pdf extract-text: recovers the document text (unicode intact)', async () => { + const e = await runJob(page, 'pdf', join(dir, 'notes.pdf'), { op: 'extract-text' }) + expect(e.status).toBe('done') + const fs = await import('fs') + const text = fs.readFileSync(e.outputPath!, 'utf-8') + expect(text).toContain('Filesmith end-to-end fixture') + expect(text).toContain('æøå') +}) + +test('pdf pages-to-images: one PNG per page in a fresh folder', async () => { + const e = await runJob(page, 'pdf', join(dir, 'pages3.pdf'), { + op: 'pages-to-images', + dpi: 72 + }) + expect(e.status).toBe('done') + const files = readdirSync(e.outputPath!) + expect(files.filter((f) => f.endsWith('.png'))).toHaveLength(3) +}) + +test('pdf split-range keeps the listed pages', async () => { + const e = await runJob(page, 'pdf', join(dir, 'pages3.pdf'), { op: 'split-range', range: '1,3' }) + expect(e.status).toBe('done') + expect(pageCount(e.outputPath!)).toBe(2) +}) + +test('pdf burst: every page becomes its own file', async () => { + const e = await runJob(page, 'pdf', join(dir, 'pages3.pdf'), { op: 'split-pages' }) + expect(e.status).toBe('done') + const parts = readdirSync(e.outputPath!).filter((f) => f.endsWith('.pdf')) + expect(parts).toHaveLength(3) + expect(pageCount(join(e.outputPath!, parts[0]))).toBe(1) +}) + +test('pdf extract-images pulls the embedded rasters', async () => { + const e = await runJob(page, 'pdf', join(dir, 'pages3.pdf'), { op: 'extract-images' }) + expect(e.status).toBe('done') + expect(readdirSync(e.outputPath!).length).toBeGreaterThan(0) +}) + +test('pdf merge concatenates in order', async () => { + const e = await runJob(page, 'pdf', join(dir, 'notes.pdf'), { + op: 'merge', + mergeInputs: [join(dir, 'notes.pdf'), join(dir, 'pages3.pdf')] + }) + expect(e.status).toBe('done') + expect(pageCount(e.outputPath!)).toBe(pageCount(join(dir, 'notes.pdf')) + 3) +}) + +test('pdf compress: lossless (mutool) and balanced (Ghostscript)', async () => { + const lossless = await runJob(page, 'compress', join(dir, 'pages3.pdf'), { + pdfLevel: 'lossless' + }) + expect(lossless.status).toBe('done') + expect(pageCount(lossless.outputPath!)).toBe(3) + + const balanced = await runJob(page, 'compress', join(dir, 'pages3.pdf'), { + pdfLevel: 'balanced', + pdfGray: false + }) + expect(balanced.status, balanced.error).toBe('done') + expect(pageCount(balanced.outputPath!)).toBe(3) +}) + +// --- Edge cases -------------------------------------------------------------- + +test('a unicode + spaces + % filename survives every stage', async () => { + const wild = join(dir, 'æøå 50% off 图片.png') + const fs = await import('fs') + fs.copyFileSync(join(dir, 'photo.png'), wild) + const conv = await runJob(page, 'convert', wild, { format: '.jpg', quality: 'best' }) + expect(conv.status).toBe('done') + expect(existsSync(conv.outputPath!)).toBe(true) + const rez = await runJob(page, 'resize', wild, { mode: 'percent', percent: 25 }) + expect(rez.status).toBe('done') + expect(identify('%wx%h', rez.outputPath!)).toBe('100x75') +}) + +// --- Generate (needs the user's ComfyUI; skips cleanly without it) ----------- + +test('generate: one 512px image through headless ComfyUI, then delete it', async () => { + test.setTimeout(600_000) + const status = await page.evaluate(() => window.filesmith.generateStatus()) + const model = (status.models ?? []).find((m: { runnable?: boolean }) => m.runnable) as + { name: string; arch: string } | undefined + test.skip(!status.available || !model, 'no runnable ComfyUI generation model on this machine') + + const info = (status.archInfo?.[model!.arch] ?? {}) as { + steps?: number + cfg?: number + guidance?: number + } + const produced = await page.evaluate( + ({ modelName, steps, cfg, guidance }) => + new Promise<{ ok: boolean; error?: string; paths: string[] }>((resolvePromise) => { + const id = 'e2e-gen' + const paths: string[] = [] + const unI = window.filesmith.onGenerateImage((p) => { + if (p.id === id) paths.push(p.path) + }) + void window.filesmith + .generateRun(id, { + prompt: 'a small red cube on a white table, studio photo', + model: modelName, + negative: '', + style: 'none', + width: 512, + height: 512, + count: 1, + steps, + cfg, + guidance, + seed: 12345 + } as never) + .then((r) => { + unI() + resolvePromise({ ...r, paths }) + }) + }), + { + modelName: model!.name, + steps: info.steps ?? 20, + cfg: info.cfg ?? 7, + guidance: info.guidance ?? 3.5 + } + ) + expect(produced.ok, produced.error).toBe(true) + expect(produced.paths.length).toBe(1) + const fs = await import('fs') + expect(fs.existsSync(produced.paths[0])).toBe(true) + expect(fs.statSync(produced.paths[0]).size).toBeGreaterThan(1000) + // Clean up: the generated image is the one output that lands OUTSIDE the + // suite's temp dir (the app writes to the user's output folder). + for (const p of produced.paths) fs.rmSync(p, { force: true }) +})