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/.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/.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/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/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..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. @@ -29,11 +33,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/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 new file mode 100644 index 0000000..21a6cf4 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,147 @@ +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-')) + // 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() + 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/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 }) +}) 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..d60779f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,9 @@ "name": "filesmith", "version": "0.2.0", "license": "MIT", - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, "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", @@ -27,7 +24,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", @@ -1469,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", @@ -6183,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", @@ -6394,6 +6457,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 +6467,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 +6693,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 74b35ab..12dd12c 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,17 @@ "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: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": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, "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", @@ -38,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/scripts/fetch-binaries.mjs b/scripts/fetch-binaries.mjs index cefa7ac..f075a93 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) { @@ -64,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) @@ -72,10 +80,19 @@ function bundleImageMagick() { if (keep && statSync(src).isFile()) { copyFileSync(src, join(BIN, f)) files++ - 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. */ @@ -101,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() ) @@ -114,14 +134,23 @@ 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 }) @@ -271,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 = [ @@ -299,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')) @@ -381,6 +415,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/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/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/generate/archRegistry.ts b/src/main/generate/archRegistry.ts index 3b19140..46827b8 100644 Binary files a/src/main/generate/archRegistry.ts and b/src/main/generate/archRegistry.ts differ 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..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 } @@ -72,7 +84,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/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 297c62b..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,8 +144,16 @@ 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, 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 +183,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 @@ -237,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), @@ -259,6 +270,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 })) @@ -267,7 +279,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 +340,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/index.ts b/src/main/index.ts index f445b47..a87eaa6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,8 @@ 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' import { stopComfyServer } from './generate' @@ -147,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 @@ -194,6 +193,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. @@ -205,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() @@ -219,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 6d48ae7..c181fd1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,24 +7,22 @@ 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' 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 { @@ -35,9 +33,15 @@ 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' +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. @@ -46,9 +50,49 @@ function isSupported(f: FileInfo): boolean { return f.kind !== 'other' } -/** 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)) +// 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 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) + } + /** 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. @@ -101,13 +145,16 @@ export function registerIpc(win: BrowserWindow): 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. 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() @@ -119,15 +166,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: [ { @@ -185,8 +232,6 @@ export function registerIpc(win: BrowserWindow): 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, @@ -194,13 +239,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, @@ -211,12 +259,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) => win.webContents.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) } } }) @@ -225,7 +273,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 { @@ -251,23 +299,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) => win.webContents.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 } : {}) @@ -281,8 +337,9 @@ 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.' } - writeComfyStore({ folder, models: readComfyStore()?.models ?? [] }) + if (!folder || !existsSync(folder)) + return { ok: false, error: 'That folder no longer exists.' } + mergeComfyStore({ folder }) clearComfyPythonCache() return { ok: true } } catch (e) { @@ -292,19 +349,21 @@ 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) } } }) - ipcMain.handle('comfy:list', () => usableComfyModels()) // --- 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(), @@ -315,29 +374,44 @@ export function registerIpc(win: BrowserWindow): JobQueue { registryWarnings: loadRegistry().warnings } }) - ipcMain.handle('generate:download', async (_e, id: string, model: string) => { - try { - 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) } - } + // 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) => + sendTo(e.sender, 'generate:download-progress', { id, ...prog }) + ) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + companionDownloads.delete(model) + } + })() + 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) => 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) => 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) } @@ -347,8 +421,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'] }] @@ -368,20 +442,6 @@ export function registerIpc(win: BrowserWindow): JobQueue { await shell.openPath(dir) return true }) - ipcMain.handle('registry:info', () => { - 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/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/net/download.ts b/src/main/net/download.ts index 452f481..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,27 +219,39 @@ 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}`) + 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})` ) 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..c5fccbb 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 @@ -82,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/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/src/main/pid/install.ts b/src/main/pid/install.ts index fc98ed7..6523508 100644 --- a/src/main/pid/install.ts +++ b/src/main/pid/install.ts @@ -11,7 +11,9 @@ 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' import { findComfyPidWeights } from '../comfy/discover' import { PID_BACKBONES, pidEnvMarker, pidRepoDir, pidRoot, spandrelMarker } from './paths' @@ -37,8 +39,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 +88,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 +133,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 }) @@ -227,7 +237,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 +322,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 @@ -355,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) @@ -373,7 +390,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') @@ -398,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/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/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..9d76e5e 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 { 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,14 +84,16 @@ 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 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)) { @@ -110,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. */ @@ -139,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 } @@ -158,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/registry/userLayer.ts b/src/main/registry/userLayer.ts index 270bd41..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.' } @@ -188,7 +195,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 +218,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, '') @@ -218,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 @@ -228,9 +246,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/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/thumbnail.ts b/src/main/thumbnail.ts index 37608f7..0afa89b 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). @@ -25,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 { @@ -48,7 +54,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. */ @@ -90,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/main/toolResolver.ts b/src/main/toolResolver.ts index d39fffc..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). @@ -42,6 +41,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 @@ -160,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 { @@ -170,9 +195,12 @@ 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() - if (uv) return { cmd: uv, prefix: ['tool', 'run', '--python', '3.11', '--from', REMBG_SPEC, 'rembg'] } + // (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 } @@ -186,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 @@ -232,7 +233,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 1ca8b75..d3f83a5 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 { 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 @@ -30,8 +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 : `${input}[0]` - 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 --------------------------------------------------------------------- @@ -67,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') @@ -95,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> = { @@ -113,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 { @@ -125,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 b33c526..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, @@ -18,6 +21,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] @@ -32,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/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 256330d..92166f0 100644 --- a/src/main/tools/registry.ts +++ b/src/main/tools/registry.ts @@ -11,14 +11,8 @@ import { writeFileSync } 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 { FileInfo, ToolId } from '@shared/types' +import type { AudioCodec, ImageFormat, PdfLevel, UpscaleModel, VideoCodec } from '@shared/compress' import { resolveGhostscript, resolveRealesrgan, @@ -43,11 +37,12 @@ import { buildFfmpegArgs, buildMagickArgs, canCompress, - convertTargets, + ffmpegExtraFor, isSameFormat, magickExtraFor, + magickFrame, + magickQualityArgs, normalizeExt, - qualityNum, toolForKind } from './convert' import { buildResizeArgs, buildResizeSpec, isValidResizeSpec } from './resize' @@ -75,17 +70,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 +97,31 @@ 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) - if (code !== 0 || !wrote) { - throw new Error(describeToolError(stderr, label, code)) + 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 || 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( + 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) return output } catch (e) { // Remove the placeholder / partial so a failed or canceled job never leaves @@ -117,6 +136,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 +191,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 +206,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 +229,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 +264,29 @@ 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, ffmpegExtraFor(file.kind, targetExt)), 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 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)) + ? file.path + : magickFrame(file.path) return runToOutput( resolveTool(kindTool), - args, + (out) => buildMagickArgs(src, out, extra), output, ctx, kindTool, @@ -291,7 +313,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 +331,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', @@ -394,20 +416,39 @@ 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 + // 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 +456,7 @@ const pdfTool: ToolModule = { ctx.onProgress(undefined, 'Extracting text…') return runToOutput( mutool, - buildPdfTextArgs(file.path, output), + (out) => buildPdfTextArgs(file.path, out), output, ctx, 'mutool', @@ -429,13 +470,14 @@ 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' return runToOutput( resolveTool('magick'), - buildResizeArgs(file.path, output, spec, animated), + (out) => buildResizeArgs(file.path, out, spec, animated), output, ctx, 'magick', @@ -461,7 +503,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 +515,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,12 +537,14 @@ 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', true, - ffmpegProgress(duration, (pct, eta) => ctx.onProgress(pct, `Compressing video (${codec})…`, eta)) + ffmpegProgress(duration, (pct, eta) => + ctx.onProgress(pct, `Compressing video (${codec})…`, eta) + ) ) } @@ -514,12 +558,14 @@ 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', 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) + ) ) } @@ -566,7 +612,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 +645,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 }) @@ -649,11 +695,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-')) @@ -680,16 +722,21 @@ 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)) } 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') { @@ -713,7 +760,8 @@ async function upscaleWithPid( // 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 }) @@ -723,7 +771,11 @@ async function upscaleWithPid( 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 */ + } } } @@ -749,26 +801,34 @@ 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)) } 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) + // 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, 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 + copyFileSync(out, output) + return output } catch (e) { try { if (output && existsSync(output)) rmSync(output, { force: true }) @@ -777,7 +837,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 */ + } } } @@ -800,8 +864,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) @@ -815,11 +878,9 @@ const upscaleTool: 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], - { 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)) } @@ -902,7 +963,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)) @@ -984,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/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/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..aff67fc 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 @@ -77,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 e778459..41b08c9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,11 +5,10 @@ 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' import type { GenerateOptions } from '@shared/generate' import type { GenModelScan } from '@shared/genArch' @@ -60,9 +59,14 @@ 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), + 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) @@ -73,21 +77,16 @@ 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), + upscaleOpenModelsFolder: (): Promise => ipcRenderer.invoke('upscale:open-models-folder'), // 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,17 +102,9 @@ 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'), + 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). @@ -127,12 +118,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 }> => @@ -140,8 +125,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) @@ -164,11 +147,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 +182,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..052e501 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { + useCallback, useEffect, useReducer, useRef, @@ -8,7 +9,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, @@ -17,7 +24,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, @@ -34,7 +41,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' @@ -78,7 +91,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('.') @@ -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 @@ -155,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. @@ -177,7 +192,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 () => { @@ -189,7 +207,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 +223,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) @@ -253,8 +276,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 }))) } } @@ -274,9 +299,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. @@ -343,18 +366,82 @@ 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 { + if (column === 'output') { + void trashOutputs(ids) + return + } for (const id of ids) { - if (column === 'output') { - const it = cur.items.find((x) => x.id === id) - if (it?.outputPath) void window.filesmith.trashFile(it.outputPath) + 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 (it && (it.status === 'queued' || it.status === 'running')) { + void window.filesmith.cancelJob(id) } + 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', + hideCancel: true, + 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) + } + // 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. @@ -370,6 +457,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, @@ -380,6 +472,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', @@ -398,13 +499,26 @@ 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', 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', + danger: true, + onConfirm: () => dismiss(targets, 'output') + }) + : dismiss(targets, 'output') } ] }) @@ -417,19 +531,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 @@ -491,7 +610,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 +635,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 @@ -544,6 +671,7 @@ export default function App(): JSX.Element { title: 'Generation failed', body: r.error ?? 'Unknown error', confirmLabel: 'OK', + hideCancel: true, onConfirm: () => {} }) } finally { @@ -565,6 +693,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 @@ -586,7 +715,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 }) @@ -616,7 +745,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) { @@ -652,9 +782,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,142 +876,141 @@ 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() + if (op.tool !== 'generate') 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)} /> - setConfirm(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/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 && ( + + )} - + +
+
+ + ) + } +} + +/** 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/Icon.tsx b/src/renderer/src/components/Icon.tsx index c331eb8..42c35a1 100644 --- a/src/renderer/src/components/Icon.tsx +++ b/src/renderer/src/components/Icon.tsx @@ -13,8 +13,6 @@ export type IconName = | 'min' | 'max' | 'close' - | 'logo' - | 'x' | 'eye' | 'folder' | 'trash' @@ -30,7 +28,6 @@ export type IconName = | 'volume-mute' | 'fullscreen' | 'expand' - | 'music' | 'image' | 'video' | 'audio' @@ -45,7 +42,9 @@ const PATHS: Record = { ), video: , audio: , - doc: , + doc: ( + + ), text: , 'chevron-down': , edit: , @@ -70,14 +69,6 @@ const PATHS: Record = { min: , max: , close: , - logo: ( - <> - - - - - ), - x: , eye: ( <> @@ -85,7 +76,9 @@ const PATHS: Record = { ), folder: , - trash: , + trash: ( + + ), dots: ( <> @@ -124,13 +117,6 @@ const PATHS: Record = { - ), - music: ( - <> - - - - ) } 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 && (
@@ -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 @@ -454,7 +461,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 ( <> @@ -482,6 +501,23 @@ function UpscaleOptions({
+ {/* The drop-in models folder that upscale:models reads was reachable + only by knowing the %APPDATA% path by heart. */} + {!inAi && ( + + )} + {/* 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). */} @@ -499,6 +535,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 && (
@@ -599,6 +640,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" @@ -609,6 +651,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" @@ -772,7 +815,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 +944,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) => ( @@ -1043,6 +1095,87 @@ 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. */ +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. @@ -1059,7 +1192,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) @@ -1091,15 +1229,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 ( <> @@ -1109,7 +1255,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 ? (
    @@ -1195,7 +1343,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) @@ -1205,23 +1357,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)} />
    )} @@ -1239,7 +1379,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 +
@@ -1358,8 +1506,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/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..cc5362c 100644 --- a/src/renderer/src/components/Queue.tsx +++ b/src/renderer/src/components/Queue.tsx @@ -1,15 +1,24 @@ 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('.') 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 { @@ -108,7 +123,8 @@ function InputCard({ compatible, onClick, onOpen, - onMenu + onMenu, + onCancel }: { item: QueueItem tool: ToolId @@ -118,6 +134,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. @@ -148,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} @@ -174,7 +199,21 @@ function InputCard({
- + {/* An in-flight row is stoppable right where its status shows. */} + {item.status === 'running' || item.status === 'queued' ? ( + + ) : ( + + )}
) @@ -205,7 +244,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]' }`} >
@@ -261,7 +302,8 @@ export function Queues({ outThumbs, onItemClick, onOpen, - onMenu + onMenu, + onCancel }: { items: QueueItem[] tool: ToolId @@ -272,6 +314,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) @@ -308,6 +351,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)} /> ))} 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/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/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/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 b97df50..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: { @@ -159,8 +157,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 +216,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,14 +254,18 @@ 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) + // 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) // 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 ?? {})) { if (!q || !validCat(cat)) continue queues[cat as CategoryId] = { - items: (q.items ?? []).map(normalizeItem), + items: (q.items ?? []).filter(isValidItem).map(normalizeItem), selected: [], anchor: null } @@ -260,7 +276,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 +286,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 +322,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 } } @@ -313,8 +337,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()) } } } /** @@ -322,11 +351,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)) { @@ -367,20 +392,41 @@ 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. 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) @@ -407,7 +453,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 @@ -418,10 +469,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': { @@ -429,7 +482,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 @@ -500,7 +553,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 @@ -533,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 { @@ -551,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 f72be9a..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' @@ -76,7 +77,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, '') } /** @@ -94,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/compress.ts b/src/shared/compress.ts index 1dacb01..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) } } @@ -71,12 +73,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/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/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 +} diff --git a/src/shared/genArch.ts b/src/shared/genArch.ts index 93d4128..a72e8c8 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.' } } @@ -83,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/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/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[] +} diff --git a/src/shared/registry.ts b/src/shared/registry.ts index d2c676d..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 { @@ -337,8 +374,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) } @@ -386,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/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/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/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..98eba56 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']) @@ -99,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')) @@ -151,7 +185,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/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/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)) + }) +}) 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..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,10 +59,16 @@ 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', 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) @@ -70,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'), @@ -78,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']) }) @@ -90,7 +97,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' }) @@ -98,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) }) @@ -106,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 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') + }) +}) 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..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, @@ -121,9 +126,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,43 +219,110 @@ 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', + // 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', + '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') + // 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', 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', '-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', '-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', () => { @@ -259,8 +331,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 +370,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,13 +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) @@ -341,13 +427,37 @@ 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' - ]) - // 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.flac', { + codec: 'keep', + bitrate: 192, + sourceExt: '.wav' + }) + ).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', '-map', '0:a', '-c:a', 'libopus', '-b:a', '96k', 'out.opus']) }) it('rejects documents, text, and unknown kinds', () => { expect(canCompress('document', '.docx')).toBe(false) @@ -355,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) + }) +}) 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) }) 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']