)
@@ -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 tab counts as 2 spaces (one level) too.
- expect(renderMarkdown('- a\n\t- nested')).toContain('')
+ expect(renderMarkdown('- a\n\t- nested')).toContain(
+ ''
+ )
})
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']