From 29b0a0d7632bfb18feff4065eb6cc333e7b4c8de Mon Sep 17 00:00:00 2001 From: Jaymie Date: Thu, 8 Oct 2026 14:39:29 -0400 Subject: [PATCH 1/7] Release TrainKit 1.3.1 with preview and setup fixes --- CHANGELOG.md | 22 ++ README.md | 15 +- backend/core/jobs.py | 7 + backend/models/requests.py | 2 + backend/service/image_captioning.py | 7 +- backend/service/image_tagging.py | 22 +- backend/tests/test_cloud_captioning.py | 2 +- backend/tests/test_output_preview.py | 141 ++++++++ package-lock.json | 4 +- package.json | 2 +- .../trainkit/panels/caption-panel.tsx | 2 +- .../trainkit/panels/rename-panel.tsx | 2 +- src/components/trainkit/panels/tag-panel.tsx | 8 +- .../trainkit/panels/upscale-panel.tsx | 2 +- .../trainkit/shared/ImagePreview.test.ts | 16 + .../trainkit/shared/ImagePreview.tsx | 330 ++++++------------ src/image-output.test.ts | 66 ++++ src/image-output.ts | 52 +++ src/main.test.ts | 111 +++++- src/main.ts | 54 ++- src/preload.ts | 1 + src/setup-manager.test.ts | 77 ++++ src/setup-manager.ts | 9 +- src/splash.html | 28 +- src/types/contracts.ts | 7 +- src/types/electron.d.ts | 3 +- 26 files changed, 722 insertions(+), 270 deletions(-) create mode 100644 backend/tests/test_output_preview.py create mode 100644 src/components/trainkit/shared/ImagePreview.test.ts create mode 100644 src/image-output.test.ts create mode 100644 src/image-output.ts create mode 100644 src/setup-manager.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bdedd2e..9920758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable TrainKit changes are documented here. TrainKit follows semantic versioning. +## [1.3.1] - 2026-10-08 + +TrainKit 1.3.1 improves image previews, shows saved captions and tags, and makes dependency setup clearer. + +### Added + +- Captions and tags below the image preview, including existing sidecars and outputs renamed on collision. Saved results refresh during processing. + +### Changed + +- Image previews follow the image's proportions and stay within a bounded height across all four operations. +- Large images use bounded system thumbnails when available, reducing the data sent to the renderer. +- Setup keeps a visible explanation while large dependencies download and install; package details remain in the log. +- Existing dependency environments are reused when the lockfile matches, including across app version changes. + +### Fixed + +- Tagging closes images after inference and stops before saving when cancellation is requested. +- Fully skipped tagging batches no longer load a model. +- Preview folder listings exclude directories with image extensions, and failed image decoding shows a readable error. +- Setup notices a missing Python interpreter, and its visible log stays bounded during long installs. + ## [1.3.0] - 2026-10-07 TrainKit 1.3.0 adds Claude and OpenAI captioning through your own API keys and improves batch safety and desktop reliability. diff --git a/README.md b/README.md index 088673c..8ec4889 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,14 @@ TrainKit prepares individual images or image folders with local captioning, upscaling, tagging, and renaming. Claude and OpenAI captioning use your own API keys. You can cancel batches and save progress in a manifest to resume later. -## What's new in 1.3.0 +## What's new in 1.3.1 -- Claude and OpenAI captioning through your own API keys, alongside local models. -- An API tab to save session keys, remember keys with OS encryption, choose vision models, and test key connections. -- Cloud requests can be cancelled. Failed requests stop the batch without automatic paid retries. -- Recovered progress after reconnecting, safer model switching and shutdown, and fixes for batch file handling. -- Updated Electron and packaging dependencies with a clean dependency audit. +- Image previews fit the image without stretching to the form's height. +- Saved captions and tags appear below the preview and refresh as images finish processing. +- Setup explains that large packages can take several minutes. Existing dependencies are reused when they have not changed. +- Tagging releases images after inference and avoids loading a model when every output is skipped. -See the [complete 1.3.0 changelog](CHANGELOG.md#130---2026-10-07) for details. +See the [complete 1.3.1 changelog](CHANGELOG.md#131---2026-10-08) for details. ## Features @@ -104,7 +103,7 @@ See [model support](docs/model-support.md) for model layouts and NCNN assumption ## Project status -Version 1.3.0 adds cloud captioning and improves batch and desktop reliability. New feature proposals and model-compatibility reports are welcome through GitHub issues. +Version 1.3.1 improves previews, setup, and tagging. New feature proposals and model-compatibility reports are welcome through GitHub issues. ## Contributing and security diff --git a/backend/core/jobs.py b/backend/core/jobs.py index 46ec874..2cd4559 100644 --- a/backend/core/jobs.py +++ b/backend/core/jobs.py @@ -34,6 +34,8 @@ class JobRecord: error: str | None created_at: datetime updated_at: datetime + preview_source: str | None = None + preview_output: str | None = None def to_dict(self) -> dict[str, Any]: value = asdict(self) @@ -66,6 +68,9 @@ async def progress( total: int, message: str, manifest_path: str | Path | None = None, + *, + preview_source: str | None = None, + preview_output: str | None = None, ): self.raise_if_cancelled() await self.manager.update( @@ -74,6 +79,8 @@ async def progress( total=total, message=message, manifest_path=str(manifest_path) if manifest_path else None, + preview_source=preview_source, + preview_output=preview_output, ) diff --git a/backend/models/requests.py b/backend/models/requests.py index a7400b6..4a8de6e 100644 --- a/backend/models/requests.py +++ b/backend/models/requests.py @@ -106,6 +106,8 @@ class JobResponse(BaseModel): error: str | None = None created_at: datetime updated_at: datetime + preview_source: str | None = None + preview_output: str | None = None class StatusResponse(BaseModel): diff --git a/backend/service/image_captioning.py b/backend/service/image_captioning.py index 779f9d9..f182a05 100644 --- a/backend/service/image_captioning.py +++ b/backend/service/image_captioning.py @@ -146,7 +146,12 @@ async def process_caption_batch( if output_manifest is not None: manifest.save(output_manifest) await context.progress( - completed, total, f"Captioned {Path(item.source).name}", output_manifest + completed, + total, + f"Captioned {Path(item.source).name}", + output_manifest, + preview_source=item.source if item.status == "completed" else None, + preview_output=item.destination if item.status == "completed" else None, ) if failures: detail = f": {first_error}" if first_error else "" diff --git a/backend/service/image_tagging.py b/backend/service/image_tagging.py index 9502dfd..459729a 100644 --- a/backend/service/image_tagging.py +++ b/backend/service/image_tagging.py @@ -9,7 +9,7 @@ import torch from transformers import AutoImageProcessor, AutoModelForImageClassification -from core.exceptions import InvalidPathError, ModelLoadError, ProcessingError +from core.exceptions import InvalidPathError, JobCancelledError, ModelLoadError, ProcessingError from core.jobs import JobContext from models import TagRequest from service.manifest import ( @@ -184,8 +184,9 @@ async def process(self, request: TagRequest, context: JobContext) -> Path | None completed = sum(item.status in {"completed", "skipped"} for item in manifest.items) message = "Tag manifest ready" if output_manifest is not None else "Tag plan ready" await context.progress(completed, total, message, output_manifest) - if request.dry_run: + if request.dry_run or completed == total: return output_manifest + context.raise_if_cancelled() await self.load() failures = 0 @@ -196,7 +197,13 @@ async def process(self, request: TagRequest, context: JobContext) -> Path | None context.raise_if_cancelled() try: image = await asyncio.to_thread(load_rgb_image, Path(item.source)) - tags = await asyncio.to_thread(self._tag, image, request.threshold, request.top_k) + try: + tags = await asyncio.to_thread( + self._tag, image, request.threshold, request.top_k + ) + finally: + image.close() + context.raise_if_cancelled() destination = Path(item.destination) tag_output = str(item.metadata.get("tag_output", request.output)) if tag_output in {"json", "both"}: @@ -218,6 +225,8 @@ async def process(self, request: TagRequest, context: JobContext) -> Path | None ) item.status = "completed" item.metadata["tag_count"] = len(tags) + except JobCancelledError: + raise except Exception as exc: item.status = "failed" item.error = str(exc) @@ -227,7 +236,12 @@ async def process(self, request: TagRequest, context: JobContext) -> Path | None if output_manifest is not None: manifest.save(output_manifest) await context.progress( - completed, total, f"Tagged {Path(item.source).name}", output_manifest + completed, + total, + f"Tagged {Path(item.source).name}", + output_manifest, + preview_source=item.source if item.status == "completed" else None, + preview_output=item.destination if item.status == "completed" else None, ) if failures: detail = f": {first_error}" if first_error else "" diff --git a/backend/tests/test_cloud_captioning.py b/backend/tests/test_cloud_captioning.py index c1e27a4..502e334 100644 --- a/backend/tests/test_cloud_captioning.py +++ b/backend/tests/test_cloud_captioning.py @@ -33,7 +33,7 @@ def raise_if_cancelled(self): if self.cancelled.is_set(): raise JobCancelledError() - async def progress(self, current, total, message, manifest_path=None): + async def progress(self, current, total, message, manifest_path=None, **_preview): self.raise_if_cancelled() self.updates.append((current, total, message, manifest_path)) diff --git a/backend/tests/test_output_preview.py b/backend/tests/test_output_preview.py new file mode 100644 index 0000000..a164cb6 --- /dev/null +++ b/backend/tests/test_output_preview.py @@ -0,0 +1,141 @@ +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest +from PIL import Image + +from core.jobs import JobManager +from models import CaptionRequest, JobResponse, TagRequest +from service.image_captioning import process_caption_batch +from service.image_tagging import ImageTaggingService + + +class Connections: + def __init__(self): + self.events = [] + + async def send_job(self, job): + self.events.append(job.copy()) + + +def image_paths(tmp_path: Path): + source = tmp_path / "input" + output = tmp_path / "output" + source.mkdir() + output.mkdir() + Image.new("RGB", (4, 4), "orange").save(source / "image.png") + return source, output + + +@pytest.mark.asyncio +async def test_caption_preview_points_to_actual_renamed_output(tmp_path): + source, output = image_paths(tmp_path) + (output / "image.txt").write_text("Original caption", encoding="utf-8") + request = CaptionRequest( + provider="openai", + cloud_model="gpt-4.1-mini", + load_path=str(source), + save_path=str(output), + prompt="Describe it", + collision_policy="rename", + ) + connections = Connections() + manager = JobManager(connections) # type: ignore[arg-type] + created = await manager.start( + "caption", + lambda context: process_caption_batch( + request, context, AsyncMock(return_value="New caption") + ), + ) + await manager.tasks[created["job_id"]] + final = manager.get(created["job_id"]) + assert final["status"] == "completed" + assert final["preview_source"] == str(source / "image.png") + assert final["preview_output"] == str(output / "image_1.txt") + assert Path(final["preview_output"]).read_text(encoding="utf-8") == "New caption" + assert (output / "image.txt").read_text(encoding="utf-8") == "Original caption" + assert JobResponse(**final).model_dump()["preview_output"] == final["preview_output"] + assert any(event["preview_output"] == final["preview_output"] for event in connections.events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output_mode,suffix", [("txt", ".tags.txt"), ("json", ".tags.json"), ("both", ".tags.json")] +) +async def test_tag_preview_publishes_saved_output_and_closes_images( + tmp_path, monkeypatch, output_mode, suffix +): + source, output = image_paths(tmp_path) + service = ImageTaggingService(tmp_path / "model") + monkeypatch.setattr(service, "load", AsyncMock()) + monkeypatch.setattr(service, "_tag", lambda *_args: [{"tag": "orange", "score": 0.9}]) + image = Mock() + monkeypatch.setattr("service.image_tagging.load_rgb_image", lambda _path: image) + request = TagRequest( + tag_model_path=str(tmp_path / "model"), + load_path=str(source), + save_path=str(output), + output=output_mode, + ) + manager = JobManager(Connections()) # type: ignore[arg-type] + created = await manager.start("tag", lambda context: service.process(request, context)) + await manager.tasks[created["job_id"]] + final = manager.get(created["job_id"]) + assert final["status"] == "completed" + assert final["preview_output"] == str(output / f"image{suffix}") + assert Path(final["preview_output"]).is_file() + image.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_fully_skipped_tag_batch_does_not_load_model(tmp_path, monkeypatch): + source, output = image_paths(tmp_path) + (output / "image.tags.txt").write_text("existing tags", encoding="utf-8") + service = ImageTaggingService(tmp_path / "model") + load = AsyncMock() + monkeypatch.setattr(service, "load", load) + request = TagRequest( + tag_model_path=str(tmp_path / "model"), + load_path=str(source), + save_path=str(output), + output="txt", + collision_policy="skip", + ) + manager = JobManager(Connections()) # type: ignore[arg-type] + created = await manager.start("tag", lambda context: service.process(request, context)) + await manager.tasks[created["job_id"]] + assert manager.get(created["job_id"])["status"] == "completed" + load.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancel,fail", [(True, False), (False, True)]) +async def test_tag_inference_cancellation_and_failure_close_image_without_writing( + tmp_path, monkeypatch, cancel, fail +): + source, output = image_paths(tmp_path) + service = ImageTaggingService(tmp_path / "model") + monkeypatch.setattr(service, "load", AsyncMock()) + image = Mock() + monkeypatch.setattr("service.image_tagging.load_rgb_image", lambda _path: image) + request = TagRequest( + tag_model_path=str(tmp_path / "model"), + load_path=str(source), + save_path=str(output), + output="txt", + ) + manager = JobManager(Connections()) # type: ignore[arg-type] + + def tag(*_args): + if cancel: + next(iter(manager.cancel_events.values())).set() + if fail: + raise RuntimeError("inference failed") + return [{"tag": "orange", "score": 0.9}] + + monkeypatch.setattr(service, "_tag", tag) + created = await manager.start("tag", lambda context: service.process(request, context)) + await manager.tasks[created["job_id"]] + assert manager.get(created["job_id"])["status"] == ("cancelled" if cancel else "failed") + assert not (output / "image.tags.txt").exists() + image.close.assert_called_once() diff --git a/package-lock.json b/package-lock.json index 013c2f3..dcf1f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trainkit", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trainkit", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "clsx": "^2.1.1", diff --git a/package.json b/package.json index 392e1aa..7abb32e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "trainkit", "productName": "TrainKit", - "version": "1.3.0", + "version": "1.3.1", "description": "A dataset preparation toolkit for AI image training", "main": ".vite/build/main.cjs", "scripts": { diff --git a/src/components/trainkit/panels/caption-panel.tsx b/src/components/trainkit/panels/caption-panel.tsx index 650494a..2b9145e 100644 --- a/src/components/trainkit/panels/caption-panel.tsx +++ b/src/components/trainkit/panels/caption-panel.tsx @@ -191,7 +191,7 @@ export function CaptionPanel({ isBackendOnline, providerSettings, onOpenApi }: { - + ); diff --git a/src/components/trainkit/panels/rename-panel.tsx b/src/components/trainkit/panels/rename-panel.tsx index 6c3fe13..c5e2ea5 100644 --- a/src/components/trainkit/panels/rename-panel.tsx +++ b/src/components/trainkit/panels/rename-panel.tsx @@ -65,7 +65,7 @@ export function RenamePanel({ isBackendOnline }: { isBackendOnline: boolean }) {
- + ); diff --git a/src/components/trainkit/panels/tag-panel.tsx b/src/components/trainkit/panels/tag-panel.tsx index 3f00b30..ce8c4a1 100644 --- a/src/components/trainkit/panels/tag-panel.tsx +++ b/src/components/trainkit/panels/tag-panel.tsx @@ -72,16 +72,16 @@ export function TagPanel({ isBackendOnline }: { isBackendOnline: boolean }) {
chooseDirectory(setLoadPath)} onBrowseFile={chooseImage} disabled={isActive} /> chooseDirectory(setSavePath)} disabled={isActive} />
- - - + + +
{error &&

{error}

}
- + ); diff --git a/src/components/trainkit/panels/upscale-panel.tsx b/src/components/trainkit/panels/upscale-panel.tsx index a17a741..375730f 100644 --- a/src/components/trainkit/panels/upscale-panel.tsx +++ b/src/components/trainkit/panels/upscale-panel.tsx @@ -169,7 +169,7 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
- + ); diff --git a/src/components/trainkit/shared/ImagePreview.test.ts b/src/components/trainkit/shared/ImagePreview.test.ts new file mode 100644 index 0000000..1e6000a --- /dev/null +++ b/src/components/trainkit/shared/ImagePreview.test.ts @@ -0,0 +1,16 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ImagePreview } from "./ImagePreview"; + +describe("preview initial render", () => { + it("renders safely before a file or folder has been selected", () => { + expect(renderToStaticMarkup(createElement(ImagePreview, { directoryPath: "" }))).toContain("Select an image or folder to preview"); + }); + + it("renders safely before the selected folder's async list has arrived", () => { + expect(() => renderToStaticMarkup(createElement(ImagePreview, { + directoryPath: "/images", outputKind: "caption", outputDirectory: "/captions", + }))).not.toThrow(); + }); +}); diff --git a/src/components/trainkit/shared/ImagePreview.tsx b/src/components/trainkit/shared/ImagePreview.tsx index f364dbf..4f2af83 100644 --- a/src/components/trainkit/shared/ImagePreview.tsx +++ b/src/components/trainkit/shared/ImagePreview.tsx @@ -1,258 +1,132 @@ -import { - useState, - useEffect, - useCallback, - memo, - type KeyboardEvent as ReactKeyboardEvent, -} from "react"; +import { useEffect, useState, memo, type KeyboardEvent } from "react"; +import { ChevronLeft, ChevronRight, Image as ImageIcon, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; -import { - ChevronLeft, - ChevronRight, - Image as ImageIcon, - Loader2, -} from "lucide-react"; +import type { ImageOutputKind } from "@/types/contracts"; interface ImagePreviewProps { directoryPath: string; className?: string; - currentIndex?: number; - onIndexChange?: (index: number) => void; + outputKind?: ImageOutputKind; + outputDirectory?: string; + refreshKey?: string; } export const ImagePreview = memo(function ImagePreview({ directoryPath, className, - currentIndex: externalIndex, - onIndexChange, + outputKind, + outputDirectory = "", + refreshKey, }: ImagePreviewProps) { const [images, setImages] = useState([]); - const [internalIndex, setInternalIndex] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); - const [isLoadingImage, setIsLoadingImage] = useState(false); - const [error, setError] = useState(null); - const [imageDataUrl, setImageDataUrl] = useState(null); - - // Use external index otherwise use internal state - const isControlled = externalIndex !== undefined && onIndexChange !== undefined; - const currentIndex = isControlled ? externalIndex : internalIndex; - const setCurrentIndex = useCallback((indexOrFn: number | ((prev: number) => number)) => { - if (isControlled) { - const newIndex = typeof indexOrFn === "function" ? indexOrFn(externalIndex) : indexOrFn; - onIndexChange(newIndex); - } else { - setInternalIndex(indexOrFn); - } - }, [isControlled, externalIndex, onIndexChange]); + const [error, setError] = useState(""); + const [image, setImage] = useState<{ path: string; dataUrl: string | null } | null>(null); + const [output, setOutput] = useState<{ key: string; text: string | null; error: string } | null>(null); + const currentImage = images[currentIndex]; + const fileName = currentImage?.split(/[\\/]/).pop() ?? ""; + const imageDataUrl = image && image.path === currentImage ? image.dataUrl : null; + const isLoadingImage = Boolean(currentImage && image?.path !== currentImage); + const outputKey = JSON.stringify([currentImage, outputDirectory, outputKind]); + const currentOutput = output?.key === outputKey ? output : null; - // Load images list when directory changes useEffect(() => { - if (!directoryPath) { - setImages([]); - if (!isControlled) setInternalIndex(0); - setImageDataUrl(null); - setIsLoading(false); - setError(null); - return; - } - let cancelled = false; - const loadImages = async () => { - setIsLoading(true); - setError(null); - try { - const imageList = await window.electronAPI.listImages(directoryPath); - if (cancelled) return; - setImages(imageList); - // Only reset index if not controlled and directory changed - if (!isControlled) setInternalIndex(0); - } catch { - if (cancelled) return; - setError("Failed to load images"); - setImages([]); - } finally { - if (!cancelled) setIsLoading(false); - } - }; - - void loadImages(); - return () => { - cancelled = true; - }; - }, [directoryPath, isControlled]); + setImages([]); + setCurrentIndex(0); + setError(""); + setIsLoading(Boolean(directoryPath)); + if (!directoryPath) return; + void window.electronAPI.listImages(directoryPath).then((list) => { + if (!cancelled) setImages(list); + }).catch(() => { + if (!cancelled) setError("Failed to load images."); + }).finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { cancelled = true; }; + }, [directoryPath]); - // Load current image as data URL useEffect(() => { - const img = images[currentIndex]; - if (!img) { - setImageDataUrl(null); - setIsLoadingImage(false); - return; - } - let cancelled = false; - const loadImage = async () => { - setIsLoadingImage(true); - try { - const dataUrl = await window.electronAPI.readImageAsDataUrl(img); - if (!cancelled) setImageDataUrl(dataUrl); - } catch { - if (!cancelled) setImageDataUrl(null); - } finally { - if (!cancelled) setIsLoadingImage(false); - } - }; - - void loadImage(); - return () => { - cancelled = true; - }; - }, [images, currentIndex]); + setImage(null); + if (!currentImage) return; + void window.electronAPI.readImageAsDataUrl(currentImage).then((dataUrl) => { + if (!cancelled) setImage({ path: currentImage, dataUrl }); + }).catch(() => { + if (!cancelled) setImage({ path: currentImage, dataUrl: null }); + }); + return () => { cancelled = true; }; + }, [currentImage]); - const handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - setCurrentIndex((previous) => - previous > 0 ? previous - 1 : images.length - 1, - ); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - setCurrentIndex((previous) => - previous < images.length - 1 ? previous + 1 : 0, - ); - } - }, - [images.length, setCurrentIndex], - ); - - const goToPrevious = useCallback(() => { - setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); - }, [images.length, setCurrentIndex]); - - const goToNext = useCallback(() => { - setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); - }, [images.length, setCurrentIndex]); - - const currentImage = images[currentIndex]; - const fileName = currentImage ? currentImage.split(/[\\/]/).pop() : ""; - - if (!directoryPath) { - return ( -
-
- - Select an image or folder to preview -
-
- ); - } - - if (isLoading) { - return ( -
-
Loading images...
-
- ); - } - - if (error) { - return ( -
-
{error}
-
- ); - } - - if (images.length === 0) { - return ( -
-
- - No supported images found -
+ useEffect(() => { + let cancelled = false; + if (!currentImage || !outputKind) return; + void window.electronAPI.readImageOutput(currentImage, outputDirectory, outputKind).then((text) => { + if (!cancelled) setOutput({ key: outputKey, text, error: "" }); + }).catch((caught) => { + if (!cancelled) setOutput({ key: outputKey, text: null, error: caught instanceof Error ? caught.message : "Saved output could not be read." }); + }); + return () => { cancelled = true; }; + }, [currentImage, outputDirectory, outputKind, outputKey, refreshKey]); + + const previous = () => setCurrentIndex(index => (index + images.length - 1) % images.length); + const next = () => setCurrentIndex(index => (index + 1) % images.length); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + if (event.target instanceof HTMLElement && event.target.closest("[data-saved-output]")) return; + event.preventDefault(); + if (event.key === "ArrowLeft") previous(); + else next(); + }; + + const notice = !directoryPath ? "Select an image or folder to preview" + : isLoading ? "Loading images..." + : error || (images.length === 0 ? "No supported images found" : ""); + if (notice) { + return
+
+
- ); +
; } return ( -
- {/* Image container */} -
- {isLoadingImage ? ( - - ) : imageDataUrl ? ( - {fileName} - ) : ( -
- Failed to load image -
- )} - - {/* Navigation arrows */} - {images.length > 1 && ( - <> - - - - )} + className={cn("min-w-0 self-start space-y-3 rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary", className)}> +
+ {isLoadingImage ?
+
: imageDataUrl ? {fileName} setImage({ path: currentImage, dataUrl: null })} + className="block h-auto w-auto max-h-[min(60vh,640px,calc(100vh-360px))] max-w-full" /> + :

Failed to load image.

} + {images.length > 1 && <> + + + }
- - {/* Footer with file info */} -
- - {fileName} - - - {currentIndex + 1} / {images.length} - +
+ {fileName} + {currentIndex + 1} / {images.length}
+ {outputKind &&
+

{outputKind === "caption" ? "Caption" : "Tags"}

+

+ {!currentOutput ? "Reading saved output..." : currentOutput.error || + (currentOutput.text === null ? `No saved ${outputKind === "caption" ? "caption" : "tags"} for this image.` : currentOutput.text || "The saved output is empty.")} +

+
}
); }); diff --git a/src/image-output.test.ts b/src/image-output.test.ts new file mode 100644 index 0000000..e4508b5 --- /dev/null +++ b/src/image-output.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readImageOutput } from "./image-output"; + +let directory: string; +let image: string; +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "trainkit-output-")); + image = path.join(directory, "image.png"); +}); +afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + +describe("saved image output", () => { + it("reads existing UTF-8 captions beside an image and prefers the selected output folder", async () => { + fs.writeFileSync(path.join(directory, "image.txt"), "\uFEFFAn existing caption.\n"); + expect(await readImageOutput(image, "", "caption", () => true)).toBe("An existing caption."); + const output = path.join(directory, "output"); + fs.mkdirSync(output); + fs.writeFileSync(path.join(output, "image.txt"), "A new caption."); + expect(await readImageOutput(image, output, "caption", () => true)).toBe("A new caption."); + }); + + it("uses the actual renamed destination without confusing tags and captions", async () => { + fs.writeFileSync(path.join(directory, "image.txt"), "Old caption"); + const renamed = path.join(directory, "image_1.txt"); + fs.writeFileSync(renamed, "New caption"); + expect(await readImageOutput(image, directory, "caption", () => true, renamed)).toBe("New caption"); + expect(await readImageOutput(image, directory, "tag", () => true)).toBeNull(); + }); + + it("reads text tags and JSON-only scored tags without displaying JSON syntax", async () => { + const json = path.join(directory, "image.tags.json"); + fs.writeFileSync(json, JSON.stringify({ source: image, tags: [{ tag: "blue sky", score: 0.9 }, { tag: "trees", score: 0.7 }] })); + expect(await readImageOutput(image, "", "tag", () => true)).toBe("blue sky, trees"); + fs.writeFileSync(path.join(directory, "image.tags.txt"), "updated tags"); + expect(await readImageOutput(image, "", "tag", () => true)).toBe("updated tags"); + }); + + it("distinguishes missing output from a valid empty tag result", async () => { + expect(await readImageOutput(image, "", "tag", () => true)).toBeNull(); + fs.writeFileSync(path.join(directory, "image.tags.json"), '{"tags":[]}'); + expect(await readImageOutput(image, "", "tag", () => true)).toBe(""); + }); + + it("reports malformed or oversized output and skips directories named like sidecars", async () => { + const json = path.join(directory, "image.tags.json"); + fs.writeFileSync(json, '{"tags":[{"score":0.9}]}'); + await expect(readImageOutput(image, "", "tag", () => true)).rejects.toThrow("Saved tags could not be read"); + fs.writeFileSync(json, "null"); + await expect(readImageOutput(image, "", "tag", () => true)).rejects.toThrow("Saved tags could not be read"); + const caption = path.join(directory, "image.txt"); + fs.writeFileSync(caption, "x".repeat(256 * 1024 + 1)); + await expect(readImageOutput(image, "", "caption", () => true)).rejects.toThrow("too large"); + fs.unlinkSync(caption); + fs.mkdirSync(caption); + expect(await readImageOutput(image, "", "caption", () => true)).toBeNull(); + }); + + it("never reads an unapproved candidate", async () => { + fs.writeFileSync(path.join(directory, "image.txt"), "Private text"); + expect(await readImageOutput(image, directory, "caption", () => false)).toBeNull(); + expect(await readImageOutput(image, directory, "caption", () => true)).toBe("Private text"); + }); +}); diff --git a/src/image-output.ts b/src/image-output.ts new file mode 100644 index 0000000..4f6859d --- /dev/null +++ b/src/image-output.ts @@ -0,0 +1,52 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ImageOutputKind } from "./types/contracts"; + +const OUTPUT_PREVIEW_LIMIT = 256 * 1024; + +export function sidecarPaths(imagePath: string, directory: string, kind: ImageOutputKind): string[] { + const stem = path.parse(imagePath).name; + return kind === "caption" + ? [path.join(directory, `${stem}.txt`)] + : [path.join(directory, `${stem}.tags.txt`), path.join(directory, `${stem}.tags.json`)]; +} + +export async function readImageOutput( + imagePath: string, + outputDirectory: string, + kind: ImageOutputKind, + canRead: (filePath: string) => boolean, + generatedPath?: string, +): Promise { + const candidates = [ + ...(generatedPath ? [generatedPath] : []), + ...(outputDirectory ? sidecarPaths(imagePath, outputDirectory, kind) : []), + ...sidecarPaths(imagePath, path.dirname(imagePath), kind), + ]; + for (const candidate of new Set(candidates)) { + if (!canRead(candidate)) continue; + try { + const stats = await fs.stat(candidate); + if (!stats.isFile()) continue; + if (stats.size > OUTPUT_PREVIEW_LIMIT) throw new Error("Saved output is too large to preview."); + const data = await fs.readFile(candidate); + if (data.length > OUTPUT_PREVIEW_LIMIT) throw new Error("Saved output is too large to preview."); + const text = data.toString("utf8").replace(/^\uFEFF/, "").trim(); + if (kind === "caption" || path.extname(candidate) !== ".json") return text; + let tags: unknown; + try { + tags = (JSON.parse(text) as { tags?: unknown }).tags; + } catch { + throw new Error("Saved tags could not be read."); + } + if (!Array.isArray(tags) || !tags.every(tag => tag && typeof tag.tag === "string")) { + throw new Error("Saved tags could not be read."); + } + return tags.map(tag => tag.tag).join(", "); + } catch (error) { + if (["ENOENT", "ENOTDIR"].includes((error as { code?: string }).code ?? "")) continue; + throw error; + } + } + return null; +} diff --git a/src/main.test.ts b/src/main.test.ts index e6ab21a..f0c13fe 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,11 +1,17 @@ import type { BrowserWindow } from "electron"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { JobEvent } from "./types/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = await vi.hoisted(async () => { const { EventEmitter } = await import("node:events"); return { windows: [] as BrowserWindow[], - handlers: new Map unknown>(), + handlers: new Map unknown>(), + dialog: { showOpenDialog: vi.fn() }, + thumbnail: vi.fn(), app: Object.assign(new EventEmitter(), { requestSingleInstanceLock: () => true, whenReady: async (): Promise => undefined, @@ -49,11 +55,110 @@ vi.mock("electron", async () => { } return { app: mocks.app, BrowserWindow: Window, - ipcMain: { handle: (channel: string, handler: (event: { sender: object }) => unknown) => mocks.handlers.set(channel, handler) }, + ipcMain: { handle: (channel: string, handler: (event: { sender: object }, ...args: unknown[]) => unknown) => mocks.handlers.set(channel, handler) }, session: { defaultSession: { setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), setDevicePermissionHandler: vi.fn() } }, - dialog: {}, shell: {}, safeStorage: {}, + dialog: mocks.dialog, nativeImage: { createThumbnailFromPath: mocks.thumbnail }, shell: {}, safeStorage: {}, }; }); + +describe("preview IPC", () => { + let directory: string; + let sender: object; + const invoke = (channel: string, ...args: unknown[]) => mocks.handlers.get(channel)!({ sender }, ...args); + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.app.removeAllListeners(); + mocks.handlers.clear(); + mocks.windows.length = 0; + mocks.setup.isSetupRequired.mockReturnValue(false); + mocks.backend.start.mockResolvedValue(undefined); + mocks.backend.waitForReady.mockResolvedValue(true); + vi.stubGlobal("__dirname", "/test/build"); + vi.stubGlobal("MAIN_WINDOW_VITE_DEV_SERVER_URL", undefined); + vi.stubGlobal("MAIN_WINDOW_VITE_NAME", "main_window"); + directory = fs.mkdtempSync(path.join(os.tmpdir(), "trainkit-preview-")); + await import("./main"); + await vi.waitFor(() => expect(mocks.windows).toHaveLength(2)); + sender = mocks.windows[1].webContents; + }); + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("allows only exact adjacent sidecars for an individually selected image", async () => { + const image = path.join(directory, "image.png"); + fs.writeFileSync(image, "image"); + fs.writeFileSync(path.join(directory, "image.txt"), "Existing caption"); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [image] }); + await invoke("dialog:openFile"); + expect(await invoke("fs:readImageOutput", image, "", "caption")).toBe("Existing caption"); + await expect(invoke("fs:readImageOutput", path.join(directory, "other.png"), "", "caption")).rejects.toThrow("Choose an image"); + await expect(mocks.handlers.get("fs:readImageOutput")!({ sender: {} }, image, "", "caption")).rejects.toThrow("untrusted renderer"); + }); + + it("rejects unapproved output folders and invalid output kinds", async () => { + const image = path.join(directory, "image.png"); + fs.writeFileSync(image, "image"); + const output = path.join(directory, "output"); + fs.mkdirSync(output); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [image] }); + await invoke("dialog:openFile"); + await expect(invoke("fs:readImageOutput", image, output, "tag")).rejects.toThrow("Choose an output folder"); + await expect(invoke("fs:readImageOutput", image, "", "other")).rejects.toThrow("Invalid preview request"); + }); + + it("lists files in natural order without including directories named like images", async () => { + fs.writeFileSync(path.join(directory, "image10.png"), "image"); + fs.writeFileSync(path.join(directory, "image2.png"), "image"); + fs.mkdirSync(path.join(directory, "fake.png")); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [directory] }); + await invoke("dialog:openDirectory"); + expect(await invoke("fs:listImages", directory)).toEqual([path.join(directory, "image2.png"), path.join(directory, "image10.png")]); + }); + + it("uses generated renamed output only for its matching source and selected output folder", async () => { + const source = path.join(directory, "image.png"); + const output = path.join(directory, "output"); + const otherOutput = path.join(directory, "other-output"); + fs.writeFileSync(source, "image"); + fs.mkdirSync(output); fs.mkdirSync(otherOutput); + fs.writeFileSync(path.join(output, "image.txt"), "Old caption"); + fs.writeFileSync(path.join(output, "image_1.txt"), "New caption"); + fs.writeFileSync(path.join(otherOutput, "image.txt"), "Other folder"); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [directory] }); + await invoke("dialog:openDirectory"); + const { isJobEvent } = await import("./types/contracts"); + const event: JobEvent = { + type: "job", job_id: "preview", operation: "caption", status: "running", + current: 1, total: 1, percent: 100, message: "Captioned image.png", manifest_path: null, error: null, + created_at: "2026-10-08T00:00:00Z", updated_at: "2026-10-08T00:00:01Z", + preview_source: source, preview_output: path.join(output, "image_1.txt"), + }; + expect(isJobEvent(event)).toBe(true); + mocks.backend.onEvent.mock.calls[0][0](event); + expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("New caption"); + expect(await invoke("fs:readImageOutput", source, otherOutput, "caption")).toBe("Other folder"); + mocks.backend.onEvent.mock.calls[0][0]({ ...event, status: "queued", preview_source: null, preview_output: null }); + expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("Old caption"); + }); + + it("bounds tall system thumbnails and falls back to the original if no thumbnail provider is available", async () => { + if (process.platform !== "win32" && process.platform !== "darwin") return; + const image = path.join(directory, "large.png"); + fs.writeFileSync(image, Buffer.alloc(1024 * 1024 + 1)); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [image] }); + await invoke("dialog:openFile"); + const resize = vi.fn(() => ({ toDataURL: () => "data:image/png;base64,thumbnail" })); + mocks.thumbnail.mockResolvedValue({ getSize: () => ({ width: 840, height: 1680 }), isEmpty: () => false, resize }); + expect(await invoke("fs:readImageAsDataUrl", image)).toBe("data:image/png;base64,thumbnail"); + expect(resize).toHaveBeenCalledWith({ width: 420, height: 840 }); + mocks.thumbnail.mockRejectedValue(new Error("No thumbnail provider")); + const fallback = await invoke("fs:readImageAsDataUrl", image) as string; + expect(fallback).toBe(`data:image/png;base64,${fs.readFileSync(image).toString("base64")}`); + }); +}); vi.mock("./backend-manager", () => ({ getBackendManager: () => mocks.backend, BackendManager: { sendLogsToUI: () => mocks.logCleanup }, diff --git a/src/main.ts b/src/main.ts index 4df84a1..9de2b6a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { BrowserWindow, dialog, ipcMain, + nativeImage, session, shell, type IpcMainInvokeEvent, @@ -15,6 +16,8 @@ import { getLogger, closeLogger } from "./logger"; import { ProviderSettingsStore, testProviderKey } from "./provider-settings"; import { isCloudProvider, type CloudProvider, type ProviderUpdate } from "./types/providers"; import { canonicalPath } from "./path-grants"; +import { readImageOutput, sidecarPaths } from "./image-output"; +import { isJobEvent, type ImageOutputKind } from "./types/contracts"; const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.quit(); @@ -31,6 +34,7 @@ const EXTERNAL_HOSTS = new Set([ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp"]); const MODEL_EXTENSIONS = new Set([".safetensors", ".param", ".bin"]); const IMAGE_PREVIEW_LIMIT = 32 * 1024 * 1024; +const generatedOutputs: Record> = { caption: new Map(), tag: new Map() }; const MIME_TYPES: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", @@ -246,6 +250,13 @@ function sendSetupProgress(progress: SetupProgress) { } backendManager.onEvent((event) => { + if (isJobEvent(event) && (event.operation === "caption" || event.operation === "tag")) { + const outputs = generatedOutputs[event.operation]; + if (event.status === "queued") outputs.clear(); + if (event.preview_source && event.preview_output) { + outputs.set(path.resolve(event.preview_source), event.preview_output); + } + } mainWindow?.webContents.send("backend:event", event); }); backendManager.onStatus((status) => { @@ -419,9 +430,9 @@ ipcMain.handle("fs:listImages", async (event, sourcePath: string) => { : []; } if (!stats.isDirectory()) return []; - return (await fs.promises.readdir(sourcePath)) - .filter((file) => IMAGE_EXTENSIONS.has(path.extname(file).toLowerCase())) - .map((file) => path.join(sourcePath, file)) + return (await fs.promises.readdir(sourcePath, { withFileTypes: true })) + .filter((file) => (file.isFile() || file.isSymbolicLink()) && IMAGE_EXTENSIONS.has(path.extname(file.name).toLowerCase())) + .map((file) => path.join(sourcePath, file.name)) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })); } catch { return []; @@ -434,7 +445,24 @@ ipcMain.handle("fs:readImageAsDataUrl", async (event, imagePath: string) => { if (!IMAGE_EXTENSIONS.has(extension)) return null; try { const stats = await fs.promises.stat(imagePath); - if (!stats.isFile() || stats.size > IMAGE_PREVIEW_LIMIT) return null; + if (!stats.isFile()) return null; + if (stats.size > 1024 * 1024 && (process.platform === "win32" || process.platform === "darwin")) { + try { + // Keep large originals out of the renderer; Windows thumbnails only honor width. + const thumbnail = await nativeImage.createThumbnailFromPath(imagePath, { width: 840, height: 840 }); + const size = thumbnail.getSize(); + const scale = Math.min(1, 840 / Math.max(size.width, size.height)); + if (!thumbnail.isEmpty()) { + return (scale < 1 ? thumbnail.resize({ + width: Math.max(1, Math.round(size.width * scale)), + height: Math.max(1, Math.round(size.height * scale)), + }) : thumbnail).toDataURL(); + } + } catch { + // Some image formats have no system thumbnail provider. + } + } + if (stats.size > IMAGE_PREVIEW_LIMIT) return null; const data = await fs.promises.readFile(imagePath); return `data:${MIME_TYPES[extension]};base64,${data.toString("base64")}`; } catch { @@ -442,6 +470,24 @@ ipcMain.handle("fs:readImageAsDataUrl", async (event, imagePath: string) => { } }); +ipcMain.handle("fs:readImageOutput", async (event, imagePath: string, outputDirectory: string, kind: ImageOutputKind) => { + trustedSender(event); + if (typeof imagePath !== "string" || typeof outputDirectory !== "string" || (kind !== "caption" && kind !== "tag")) { + throw new Error("Invalid preview request."); + } + if (!IMAGE_EXTENSIONS.has(path.extname(imagePath).toLowerCase()) || !isGranted(imagePath)) { + throw new Error("Choose an image using the browse button."); + } + if (outputDirectory && !isGranted(outputDirectory)) throw new Error("Choose an output folder using the browse button."); + const adjacent = sidecarPaths(imagePath, path.dirname(imagePath), kind); + const canRead = (candidate: string) => isGranted(candidate) || + (adjacent.includes(candidate) && canonicalPath(candidate) === path.resolve(candidate)); + const generated = generatedOutputs[kind].get(path.resolve(imagePath)); + const matchingOutput = generated && outputDirectory && + canonicalPath(path.dirname(generated)) === canonicalPath(outputDirectory) ? generated : undefined; + return readImageOutput(imagePath, outputDirectory, kind, canRead, matchingOutput); +}); + async function initialize() { await createSplashWindow(); const setup = getSetupManager(); diff --git a/src/preload.ts b/src/preload.ts index f74be48..97a7026 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -62,6 +62,7 @@ const electronAPI = { isValidModelFolder: (path) => ipcRenderer.invoke("fs:isValidModelFolder", path), listImages: (directoryPath) => ipcRenderer.invoke("fs:listImages", directoryPath), readImageAsDataUrl: (imagePath) => ipcRenderer.invoke("fs:readImageAsDataUrl", imagePath), + readImageOutput: (imagePath, outputDirectory, kind) => ipcRenderer.invoke("fs:readImageOutput", imagePath, outputDirectory, kind), openExternal: (url) => ipcRenderer.invoke("shell:openExternal", url), openLogFile: () => ipcRenderer.invoke("log:openFile"), openLogsFolder: () => ipcRenderer.invoke("log:openFolder"), diff --git a/src/setup-manager.test.ts b/src/setup-manager.test.ts new file mode 100644 index 0000000..fe1bf71 --- /dev/null +++ b/src/setup-manager.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SetupManager } from "./setup-manager"; + +const mocks = vi.hoisted(() => ({ root: "", spawn: vi.fn(), execFile: vi.fn() })); +vi.mock("child_process", () => ({ spawn: mocks.spawn, execFile: mocks.execFile })); +vi.mock("electron", () => ({ app: { getVersion: () => "1.3.1", isPackaged: true } })); +vi.mock("./runtime-paths", () => ({ getRuntimePaths: () => ({ + runtimePath: mocks.root, + backendPath: mocks.root, + setupCachePath: path.join(mocks.root, ".cache"), + temporaryPath: path.join(mocks.root, ".tmp"), + legacyRuntimePath: path.join(mocks.root, "backend-runtime"), +}) })); + +let proc: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; +const marker = () => path.join(mocks.root, ".setup_complete.json"); +const python = () => path.join(mocks.root, ".venv", process.platform === "win32" ? "Scripts/python.exe" : "bin/python"); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.root = fs.mkdtempSync(path.join(os.tmpdir(), "trainkit-setup-")); + fs.writeFileSync(path.join(mocks.root, "uv.lock"), "locked dependencies"); + fs.mkdirSync(path.dirname(python()), { recursive: true }); + fs.writeFileSync(python(), "test interpreter"); + proc = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() }); + mocks.spawn.mockReturnValue(proc); + mocks.execFile.mockImplementation((_command, _args, callback) => callback(null)); +}); +afterEach(() => fs.rmSync(mocks.root, { recursive: true, force: true })); + +describe("dependency setup", () => { + it("reuses a healthy environment across app versions but notices dependency changes or a missing interpreter", () => { + const lockHash = createHash("sha256").update("locked dependencies").digest("hex"); + fs.writeFileSync(marker(), JSON.stringify({ version: "1.3.0", lockHash })); + const manager = new SetupManager(); + expect(manager.isSetupRequired()).toBe(false); + fs.writeFileSync(path.join(mocks.root, "uv.lock"), "updated dependencies"); + expect(manager.isSetupRequired()).toBe(true); + fs.writeFileSync(path.join(mocks.root, "uv.lock"), "locked dependencies"); + fs.unlinkSync(python()); + expect(manager.isSetupRequired()).toBe(true); + }); + + it("treats a broken completion marker as incomplete setup", () => { + fs.writeFileSync(marker(), "{broken"); + expect(new SetupManager().isSetupRequired()).toBe(true); + }); + + it("keeps setup pending after individual packages download and completes only after a successful installer exit", async () => { + const progress = vi.fn(); + const setup = new SetupManager().runSetup(progress); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce()); + proc.stderr.emit("data", Buffer.from("Downloaded numpy\nDownloaded torch\n")); + expect(progress.mock.calls.some(([event]) => event.status === "complete")).toBe(false); + expect(fs.existsSync(marker())).toBe(false); + proc.emit("close", 0); + expect(await setup).toBe(true); + expect(progress).toHaveBeenLastCalledWith({ status: "complete", message: "Setup complete!" }); + expect(new SetupManager().isSetupRequired()).toBe(false); + expect(fs.existsSync(path.join(mocks.root, ".cache"))).toBe(false); + }); + + it("does not leave a completion marker after a failed install", async () => { + const progress = vi.fn(); + const setup = new SetupManager().runSetup(progress); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce()); + proc.emit("close", 1); + expect(await setup).toBe(false); + expect(fs.existsSync(marker())).toBe(false); + expect(progress).toHaveBeenLastCalledWith({ status: "error", message: "Setup failed: Command failed with code 1" }); + }); +}); diff --git a/src/setup-manager.ts b/src/setup-manager.ts index 573e53a..b7bc23c 100644 --- a/src/setup-manager.ts +++ b/src/setup-manager.ts @@ -31,15 +31,16 @@ export class SetupManager { } isSetupRequired(): boolean { - const venvPath = path.join(this.runtimePath, ".venv"); + const pythonPath = process.platform === "win32" + ? path.join(this.runtimePath, ".venv", "Scripts", "python.exe") + : path.join(this.runtimePath, ".venv", "bin", "python"); const markerPath = path.join(this.runtimePath, ".setup_complete.json"); - if (!fs.existsSync(venvPath) || !fs.existsSync(markerPath)) return true; + if (!fs.existsSync(pythonPath) || !fs.existsSync(markerPath)) return true; try { const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { - version?: string; lockHash?: string; }; - return marker.version !== app.getVersion() || marker.lockHash !== this.lockHash(); + return marker.lockHash !== this.lockHash(); } catch { return true; } diff --git a/src/splash.html b/src/splash.html index 23022a9..9784daf 100644 --- a/src/splash.html +++ b/src/splash.html @@ -143,7 +143,7 @@ .status { margin-top: 16px; font-size: 11px; - color: #52525b; + color: #a1a1aa; letter-spacing: 0.1em; text-transform: uppercase; animation: fadeIn 0.5s ease-out 0.6s both; @@ -154,6 +154,17 @@ white-space: nowrap; } + .setup-hint { + max-width: 340px; + margin-top: 10px; + color: #a1a1aa; + font-size: 12px; + line-height: 1.5; + text-align: center; + } + + [hidden] { display: none; } + .console-container { margin-top: 16px; width: 350px; @@ -356,7 +367,8 @@

TRAINKIT

-

Initializing...

+

Initializing...

+
@@ -369,6 +381,7 @@

TRAINKIT

const consoleEl = document.getElementById("console"); const consoleContainer = document.getElementById("consoleContainer"); const statusEl = document.getElementById("status"); + const setupHint = document.getElementById("setupHint"); const container = document.getElementById("container"); function addConsoleLine(message, type = "default") { @@ -376,6 +389,7 @@

TRAINKIT

line.className = `console-line ${type}`; line.textContent = message; consoleEl.appendChild(line); + if (consoleEl.childElementCount > 200) consoleEl.firstElementChild.remove(); consoleEl.scrollTop = consoleEl.scrollHeight; } @@ -399,6 +413,7 @@

TRAINKIT

window.electronAPI.onSplashStatus((status) => { statusEl.textContent = status; + setupHint.hidden = true; if (status.toLowerCase().includes("failed")) { showErrorState(); } @@ -406,6 +421,7 @@

TRAINKIT

window.electronAPI.onSetupMode(() => { container.classList.add("setup-state"); + setupHint.hidden = false; showConsole(); }); @@ -415,9 +431,11 @@

TRAINKIT

}); window.electronAPI.onSetupProgress((data) => { - if (data.message.length < 60 && !data.message.includes('UV_LINK')) { - statusEl.textContent = data.message; - } + const installing = data.status === "installing" || data.status === "downloading"; + setupHint.hidden = !installing; + statusEl.textContent = installing ? "Setting up dependencies..." : + data.status === "checking" ? "Checking prerequisites..." : + data.status === "complete" ? "Setup complete" : "Setup failed. See details below."; if (data.status === "error") { showErrorState(); } diff --git a/src/types/contracts.ts b/src/types/contracts.ts index 87b0880..5a42c19 100644 --- a/src/types/contracts.ts +++ b/src/types/contracts.ts @@ -16,6 +16,7 @@ export type JobStatus = | "completed" | "failed"; export type CollisionPolicy = "fail" | "skip" | "overwrite" | "rename"; +export type ImageOutputKind = "caption" | "tag"; export interface JobRecord { job_id: string; @@ -29,6 +30,8 @@ export interface JobRecord { error: string | null; created_at: string; updated_at: string; + preview_source?: string | null; + preview_output?: string | null; } export interface JobEvent extends JobRecord { @@ -99,7 +102,9 @@ export function isJobRecord(value: unknown): value is JobRecord { isNullableString(event.manifest_path) && isNullableString(event.error) && typeof event.created_at === "string" && - typeof event.updated_at === "string" + typeof event.updated_at === "string" && + (event.preview_source === undefined || isNullableString(event.preview_source)) && + (event.preview_output === undefined || isNullableString(event.preview_output)) ); } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 923c932..e63a275 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,4 +1,4 @@ -import type { BackendEvent, BackendResponse, LogLevel, LogSource } from "./contracts"; +import type { BackendEvent, BackendResponse, ImageOutputKind, LogLevel, LogSource } from "./contracts"; import type { CloudProvider, ProviderSettings, ProviderUpdate } from "./providers"; export interface MainLogEntry { @@ -64,6 +64,7 @@ export interface ElectronAPI { ) => Promise<{ valid: boolean; name?: string }>; listImages: (sourcePath: string) => Promise; readImageAsDataUrl: (imagePath: string) => Promise; + readImageOutput: (imagePath: string, outputDirectory: string, kind: ImageOutputKind) => Promise; openExternal: (url: string) => Promise; openLogFile: () => Promise; openLogsFolder: () => Promise; From 1edc81b1a560dfaab67650ae8523681f7859fbd8 Mon Sep 17 00:00:00 2001 From: Jaymie Date: Thu, 8 Oct 2026 14:59:13 -0400 Subject: [PATCH 2/7] Fix saved output previews through Windows junctions --- CHANGELOG.md | 1 + src/main.test.ts | 23 +++++++++++++++++++++++ src/main.ts | 10 +++++++--- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9920758..2d11409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ TrainKit 1.3.1 improves image previews, shows saved captions and tags, and makes ### Fixed +- Existing caption and tag previews resolve Windows folder junctions. - Tagging closes images after inference and stops before saving when cancellation is requested. - Fully skipped tagging batches no longer load a model. - Preview folder listings exclude directories with image extensions, and failed image decoding shows a readable error. diff --git a/src/main.test.ts b/src/main.test.ts index f0c13fe..0aeeb02 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -109,6 +109,29 @@ describe("preview IPC", () => { await expect(invoke("fs:readImageOutput", image, "", "other")).rejects.toThrow("Invalid preview request"); }); + it("reads adjacent captions and tags through a junction without granting the whole folder", async () => { + const selected = path.join(directory, "selected"); + const alias = path.join(directory, "alias"); + fs.mkdirSync(selected); + fs.symlinkSync(selected, alias, process.platform === "win32" ? "junction" : "dir"); + try { + const image = path.join(alias, "image.png"); + fs.writeFileSync(image, "image"); + fs.writeFileSync(path.join(alias, "image.txt"), "Existing caption"); + fs.writeFileSync(path.join(alias, "image.tags.txt"), "existing, tags"); + const otherImage = path.join(alias, "other.png"); + fs.writeFileSync(otherImage, "other image"); + fs.writeFileSync(path.join(alias, "other.txt"), "Other caption"); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [image] }); + await invoke("dialog:openFile"); + expect(await invoke("fs:readImageOutput", image, "", "caption")).toBe("Existing caption"); + expect(await invoke("fs:readImageOutput", image, "", "tag")).toBe("existing, tags"); + await expect(invoke("fs:readImageOutput", otherImage, "", "caption")).rejects.toThrow("Choose an image"); + } finally { + fs.unlinkSync(alias); + } + }); + it("lists files in natural order without including directories named like images", async () => { fs.writeFileSync(path.join(directory, "image10.png"), "image"); fs.writeFileSync(path.join(directory, "image2.png"), "image"); diff --git a/src/main.ts b/src/main.ts index 9de2b6a..3c83999 100644 --- a/src/main.ts +++ b/src/main.ts @@ -479,9 +479,13 @@ ipcMain.handle("fs:readImageOutput", async (event, imagePath: string, outputDire throw new Error("Choose an image using the browse button."); } if (outputDirectory && !isGranted(outputDirectory)) throw new Error("Choose an output folder using the browse button."); - const adjacent = sidecarPaths(imagePath, path.dirname(imagePath), kind); - const canRead = (candidate: string) => isGranted(candidate) || - (adjacent.includes(candidate) && canonicalPath(candidate) === path.resolve(candidate)); + const selectedImage = canonicalPath(imagePath); + const adjacent = sidecarPaths(selectedImage, path.dirname(selectedImage), kind); + const canRead = (candidate: string) => { + if (isGranted(candidate)) return true; + const resolved = canonicalPath(candidate); + return adjacent.some(sidecar => path.relative(sidecar, resolved) === ""); + }; const generated = generatedOutputs[kind].get(path.resolve(imagePath)); const matchingOutput = generated && outputDirectory && canonicalPath(path.dirname(generated)) === canonicalPath(outputDirectory) ? generated : undefined; From d054da0e59a7333a161050117a74536ff8948f4c Mon Sep 17 00:00:00 2001 From: Jaymie Date: Thu, 8 Oct 2026 15:28:07 -0400 Subject: [PATCH 3/7] Fix renamed output previews for linked input folders --- src/main.test.ts | 30 ++++++++++++++++++++++++++++++ src/main.ts | 4 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 0aeeb02..742ef1f 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -167,6 +167,36 @@ describe("preview IPC", () => { expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("Old caption"); }); + it.each(["caption", "tag"] as const)("uses renamed outputs through linked input folders for %s", async (operation) => { + const selected = path.join(directory, "selected"); + const alias = path.join(directory, "alias"); + const output = path.join(directory, "output"); + fs.mkdirSync(selected); fs.mkdirSync(output); + fs.symlinkSync(selected, alias, process.platform === "win32" ? "junction" : "dir"); + try { + const image = path.join(alias, "image.png"); + const suffix = operation === "caption" ? ".txt" : ".tags.txt"; + const renamed = path.join(output, `image_1${suffix}`); + fs.writeFileSync(image, "image"); + fs.writeFileSync(path.join(output, `image${suffix}`), "Old output"); + fs.writeFileSync(renamed, "New output"); + mocks.dialog.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [alias] }) + .mockResolvedValueOnce({ canceled: false, filePaths: [output] }); + await invoke("dialog:openDirectory"); + await invoke("dialog:openDirectory"); + const event: JobEvent = { + type: "job", job_id: "linked-preview", operation, status: "completed", + current: 1, total: 1, percent: 100, message: "Saved output", manifest_path: null, error: null, + created_at: "2026-10-08T00:00:00+00:00", updated_at: "2026-10-08T00:00:01+00:00", + preview_source: fs.realpathSync.native(image), preview_output: renamed, + }; + mocks.backend.onEvent.mock.calls[0][0](event); + expect(await invoke("fs:readImageOutput", image, output, operation)).toBe("New output"); + } finally { + fs.unlinkSync(alias); + } + }); + it("bounds tall system thumbnails and falls back to the original if no thumbnail provider is available", async () => { if (process.platform !== "win32" && process.platform !== "darwin") return; const image = path.join(directory, "large.png"); diff --git a/src/main.ts b/src/main.ts index 3c83999..5745e3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -254,7 +254,7 @@ backendManager.onEvent((event) => { const outputs = generatedOutputs[event.operation]; if (event.status === "queued") outputs.clear(); if (event.preview_source && event.preview_output) { - outputs.set(path.resolve(event.preview_source), event.preview_output); + outputs.set(canonicalPath(event.preview_source), event.preview_output); } } mainWindow?.webContents.send("backend:event", event); @@ -486,7 +486,7 @@ ipcMain.handle("fs:readImageOutput", async (event, imagePath: string, outputDire const resolved = canonicalPath(candidate); return adjacent.some(sidecar => path.relative(sidecar, resolved) === ""); }; - const generated = generatedOutputs[kind].get(path.resolve(imagePath)); + const generated = generatedOutputs[kind].get(selectedImage); const matchingOutput = generated && outputDirectory && canonicalPath(path.dirname(generated)) === canonicalPath(outputDirectory) ? generated : undefined; return readImageOutput(imagePath, outputDirectory, kind, canRead, matchingOutput); From 5b04937316418cdaa2255e064cb0acc8464bec93 Mon Sep 17 00:00:00 2001 From: Jaymie Date: Thu, 8 Oct 2026 15:29:56 -0400 Subject: [PATCH 4/7] Keep newest generated previews during event recovery --- src/main.test.ts | 37 ++++++++++++++++++++++++++++++++++++- src/main.ts | 12 +++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 742ef1f..94d6b98 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -163,7 +163,7 @@ describe("preview IPC", () => { mocks.backend.onEvent.mock.calls[0][0](event); expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("New caption"); expect(await invoke("fs:readImageOutput", source, otherOutput, "caption")).toBe("Other folder"); - mocks.backend.onEvent.mock.calls[0][0]({ ...event, status: "queued", preview_source: null, preview_output: null }); + mocks.backend.onEvent.mock.calls[0][0]({ ...event, job_id: "next", status: "queued", created_at: "2026-10-08T00:00:02Z", preview_source: null, preview_output: null }); expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("Old caption"); }); @@ -197,6 +197,41 @@ describe("preview IPC", () => { } }); + it.each(["caption", "tag"] as const)("retains newest saved outputs when older %s jobs are recovered", async (operation) => { + const source = path.join(directory, "image.png"); + const suffix = operation === "caption" ? ".txt" : ".tags.txt"; + const olderOutput = path.join(directory, `image_1${suffix}`); + const newerOutput = path.join(directory, `image_2${suffix}`); + fs.writeFileSync(source, "image"); + fs.writeFileSync(path.join(directory, `image${suffix}`), "Original output"); + fs.writeFileSync(olderOutput, "Older output"); + fs.writeFileSync(newerOutput, "Newer output"); + mocks.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [directory] }); + await invoke("dialog:openDirectory"); + const older: JobEvent = { + type: "job", job_id: "older", operation, status: "completed", + current: 1, total: 1, percent: 100, message: "Saved output", manifest_path: null, error: null, + created_at: "2026-10-08T00:00:00+00:00", updated_at: "2026-10-08T00:00:01+00:00", + preview_source: source, preview_output: olderOutput, + }; + const newer: JobEvent = { + ...older, job_id: "newer", preview_output: newerOutput, + created_at: "2026-10-08T00:00:02+00:00", updated_at: "2026-10-08T00:00:03+00:00", + }; + const emit = mocks.backend.onEvent.mock.calls[0][0]; + emit(older); + emit(newer); + emit(older); + expect(await invoke("fs:readImageOutput", source, directory, operation)).toBe("Newer output"); + for (const event of [older, newer]) { + emit({ ...event, status: "queued", updated_at: event.created_at, preview_source: null, preview_output: null }); + expect(await invoke("fs:readImageOutput", source, directory, operation)).toBe("Newer output"); + } + emit({ ...newer, job_id: "next", status: "queued", created_at: "2026-10-08T00:00:04+00:00", preview_source: null, preview_output: null }); + emit(older); + expect(await invoke("fs:readImageOutput", source, directory, operation)).toBe("Original output"); + }); + it("bounds tall system thumbnails and falls back to the original if no thumbnail provider is available", async () => { if (process.platform !== "win32" && process.platform !== "darwin") return; const image = path.join(directory, "large.png"); diff --git a/src/main.ts b/src/main.ts index 5745e3a..130a530 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import { ProviderSettingsStore, testProviderKey } from "./provider-settings"; import { isCloudProvider, type CloudProvider, type ProviderUpdate } from "./types/providers"; import { canonicalPath } from "./path-grants"; import { readImageOutput, sidecarPaths } from "./image-output"; -import { isJobEvent, type ImageOutputKind } from "./types/contracts"; +import { isJobEvent, type ImageOutputKind, type JobEvent } from "./types/contracts"; const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.quit(); @@ -35,6 +35,7 @@ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp"]); const MODEL_EXTENSIONS = new Set([".safetensors", ".param", ".bin"]); const IMAGE_PREVIEW_LIMIT = 32 * 1024 * 1024; const generatedOutputs: Record> = { caption: new Map(), tag: new Map() }; +const latestPreviewJobs = new Map(); const MIME_TYPES: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", @@ -252,8 +253,13 @@ function sendSetupProgress(progress: SetupProgress) { backendManager.onEvent((event) => { if (isJobEvent(event) && (event.operation === "caption" || event.operation === "tag")) { const outputs = generatedOutputs[event.operation]; - if (event.status === "queued") outputs.clear(); - if (event.preview_source && event.preview_output) { + let latestJob = latestPreviewJobs.get(event.operation); + if (!latestJob || event.created_at > latestJob.created_at) { + latestJob = event; + latestPreviewJobs.set(event.operation, latestJob); + outputs.clear(); + } + if (event.job_id === latestJob.job_id && event.preview_source && event.preview_output) { outputs.set(canonicalPath(event.preview_source), event.preview_output); } } From d795965197b91074ba93bba54593553a7ad6bbf5 Mon Sep 17 00:00:00 2001 From: Jaymie Date: Thu, 8 Oct 2026 15:39:59 -0400 Subject: [PATCH 5/7] Test mounted preview loading, navigation, and refresh --- CHANGELOG.md | 1 + package-lock.json | 504 +++++++++++++++++- package.json | 1 + .../trainkit/shared/ImagePreview.test.ts | 142 ++++- src/main.test.ts | 4 +- 5 files changed, 639 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d11409..8d9659f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ TrainKit 1.3.1 improves image previews, shows saved captions and tags, and makes ### Fixed - Existing caption and tag previews resolve Windows folder junctions. +- Renamed caption and tag previews use canonical input paths and retain the newest result after event recovery. - Tagging closes images after inference and stops before saving when cancellation is requested. - Fully skipped tagging batches no longer load a model. - Preview folder listings exclude directories with image extensions, and failed image decoding shows a readable error. diff --git a/package-lock.json b/package-lock.json index dcf1f64..3c9e3da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "electron": "44.6.0", "eslint": "^10.12.0", "globals": "^17.1.0", + "jsdom": "^29.1.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", @@ -86,6 +87,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -110,6 +175,146 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@electron-forge/cli": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-8.0.1.tgz", @@ -634,6 +839,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1981,6 +2204,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -2243,6 +2476,20 @@ "node": ">=12.10" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2250,6 +2497,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2268,6 +2529,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2342,6 +2610,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -2890,6 +3171,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", @@ -3054,6 +3348,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -3087,6 +3388,47 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3560,6 +3902,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -3829,6 +4178,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -4118,6 +4480,16 @@ "read-binary-file-arch": "cli.js" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resedit": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.3.tgz", @@ -4197,6 +4569,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4363,6 +4748,13 @@ "node": ">= 8.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -4474,6 +4866,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4545,7 +4983,6 @@ "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=20.18.1" } @@ -5042,6 +5479,54 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -5148,6 +5633,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -5158,6 +5653,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 7abb32e..189f270 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "electron": "44.6.0", "eslint": "^10.12.0", "globals": "^17.1.0", + "jsdom": "^29.1.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", diff --git a/src/components/trainkit/shared/ImagePreview.test.ts b/src/components/trainkit/shared/ImagePreview.test.ts index 1e6000a..e7a4e1e 100644 --- a/src/components/trainkit/shared/ImagePreview.test.ts +++ b/src/components/trainkit/shared/ImagePreview.test.ts @@ -1,16 +1,138 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom + +import { act, createElement, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ElectronAPI } from "@/types/electron"; import { ImagePreview } from "./ImagePreview"; -describe("preview initial render", () => { - it("renders safely before a file or folder has been selected", () => { - expect(renderToStaticMarkup(createElement(ImagePreview, { directoryPath: "" }))).toContain("Select an image or folder to preview"); +const firstImage = "/images/first.png"; +const secondImage = "/images/second.png"; +const firstDataUrl = "data:image/png;base64,Zmlyc3Q="; +const secondDataUrl = "data:image/png;base64,c2Vjb25k"; +const api = { + listImages: vi.fn(), + readImageAsDataUrl: vi.fn(), + readImageOutput: vi.fn(), +}; +let container: HTMLDivElement; +let root: Root; +const render = (props: ComponentProps) => act(async () => { + root.render(createElement(ImagePreview, props)); +}); + +beforeEach(() => { + api.listImages.mockReset().mockResolvedValue([firstImage, secondImage]); + api.readImageAsDataUrl.mockReset().mockResolvedValue(firstDataUrl); + api.readImageOutput.mockReset().mockResolvedValue("Saved caption"); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("electronAPI", api); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("mounted image preview", () => { + it("shows a selection prompt without issuing filesystem reads", async () => { + await render({ directoryPath: "" }); + expect(container.textContent).toContain("Select an image or folder to preview"); + expect(api.listImages).not.toHaveBeenCalled(); + expect(api.readImageAsDataUrl).not.toHaveBeenCalled(); + expect(api.readImageOutput).not.toHaveBeenCalled(); + }); + + it("loads a folder, image, and saved caption through their pending states", async () => { + const listing = Promise.withResolvers(); + const image = Promise.withResolvers(); + const caption = Promise.withResolvers(); + api.listImages.mockReturnValueOnce(listing.promise); + api.readImageAsDataUrl.mockReturnValueOnce(image.promise); + api.readImageOutput.mockReturnValueOnce(caption.promise); + await render({ directoryPath: "/images", outputKind: "caption", outputDirectory: "/captions" }); + expect(container.textContent).toContain("Loading images..."); + await act(async () => listing.resolve([firstImage, secondImage])); + expect(container.textContent).toContain("Loading image..."); + expect(container.textContent).toContain("Reading saved output..."); + expect(api.readImageOutput).toHaveBeenCalledWith(firstImage, "/captions", "caption"); + await act(async () => { + image.resolve(firstDataUrl); + caption.resolve("Caption for the first image"); + }); + expect(container.querySelector("img")?.getAttribute("src")).toBe(firstDataUrl); + expect(container.querySelector("img")?.alt).toBe("first.png"); + expect(container.textContent).toContain("Caption for the first image"); + }); + + it("ignores late image and tag reads after navigating, including with the keyboard", async () => { + const image = Promise.withResolvers(); + const tags = Promise.withResolvers(); + api.readImageAsDataUrl.mockImplementation(source => source === firstImage ? image.promise : Promise.resolve(secondDataUrl)); + api.readImageOutput.mockImplementation(source => source === firstImage ? tags.promise : Promise.resolve("second, tags")); + await render({ directoryPath: "/images", outputKind: "tag", outputDirectory: "/tags" }); + await act(async () => container.querySelector('[aria-label="Next image"]')!.click()); + await act(async () => { + image.resolve(firstDataUrl); + tags.resolve("first, tags"); + }); + expect(container.querySelector("img")?.getAttribute("src")).toBe(secondDataUrl); + expect(container.textContent).toContain("second, tags"); + expect(container.textContent).not.toContain("first, tags"); + expect(api.readImageOutput).toHaveBeenLastCalledWith(secondImage, "/tags", "tag"); + await act(async () => container.querySelector('[aria-label="Previous image"]')!.click()); + expect(container.querySelector("img")?.alt).toBe("first.png"); + await act(async () => container.querySelector('[aria-label^="Image preview;"]')!.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + )); + expect(container.querySelector("img")?.alt).toBe("second.png"); + expect(container.textContent).toContain("second, tags"); + }); + + it("refreshes saved captions without reloading the image and ignores stale refreshes", async () => { + const props = { directoryPath: "/images", outputKind: "caption" as const, outputDirectory: "/captions" }; + await render({ ...props, refreshKey: "initial" }); + expect(container.textContent).toContain("Saved caption"); + const older = Promise.withResolvers(); + const newer = Promise.withResolvers(); + api.readImageOutput.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + await render({ ...props, refreshKey: "first-completion" }); + await render({ ...props, refreshKey: "second-completion" }); + await act(async () => newer.resolve("Newest caption")); + await act(async () => older.resolve("Older caption")); + expect(container.textContent).toContain("Newest caption"); + expect(container.textContent).not.toContain("Older caption"); + expect(api.readImageAsDataUrl).toHaveBeenCalledOnce(); + expect(api.readImageOutput).toHaveBeenCalledTimes(3); + }); + + it("ignores a previous folder listing when it arrives after the current one", async () => { + const older = Promise.withResolvers(); + const newer = Promise.withResolvers(); + api.listImages.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + await render({ directoryPath: "/older" }); + await render({ directoryPath: "/newer" }); + await act(async () => newer.resolve(["/newer/current.png"])); + await act(async () => older.resolve(["/older/stale.png"])); + expect(container.querySelector("img")?.alt).toBe("current.png"); + expect(api.readImageAsDataUrl).toHaveBeenCalledOnce(); + expect(api.readImageAsDataUrl).toHaveBeenCalledWith("/newer/current.png"); }); - it("renders safely before the selected folder's async list has arrived", () => { - expect(() => renderToStaticMarkup(createElement(ImagePreview, { - directoryPath: "/images", outputKind: "caption", outputDirectory: "/captions", - }))).not.toThrow(); + it("ignores output from a previous folder and displays read errors", async () => { + const older = Promise.withResolvers(); + const newer = Promise.withResolvers(); + api.readImageOutput.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + await render({ directoryPath: "/images", outputKind: "caption", outputDirectory: "/older" }); + await render({ directoryPath: "/images", outputKind: "caption", outputDirectory: "/newer" }); + await act(async () => older.resolve("Caption from the previous folder")); + expect(container.textContent).toContain("Reading saved output..."); + expect(container.textContent).not.toContain("Caption from the previous folder"); + await act(async () => newer.reject(new Error("Saved output could not be read."))); + expect(container.textContent).toContain("Saved output could not be read."); }); }); diff --git a/src/main.test.ts b/src/main.test.ts index 94d6b98..dcefc8d 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -163,7 +163,7 @@ describe("preview IPC", () => { mocks.backend.onEvent.mock.calls[0][0](event); expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("New caption"); expect(await invoke("fs:readImageOutput", source, otherOutput, "caption")).toBe("Other folder"); - mocks.backend.onEvent.mock.calls[0][0]({ ...event, job_id: "next", status: "queued", created_at: "2026-10-08T00:00:02Z", preview_source: null, preview_output: null }); + mocks.backend.onEvent.mock.calls[0][0]({ ...event, job_id: "next", status: "queued", created_at: "2026-10-08T00:00:02Z", updated_at: "2026-10-08T00:00:02Z", preview_source: null, preview_output: null }); expect(await invoke("fs:readImageOutput", source, output, "caption")).toBe("Old caption"); }); @@ -227,7 +227,7 @@ describe("preview IPC", () => { emit({ ...event, status: "queued", updated_at: event.created_at, preview_source: null, preview_output: null }); expect(await invoke("fs:readImageOutput", source, directory, operation)).toBe("Newer output"); } - emit({ ...newer, job_id: "next", status: "queued", created_at: "2026-10-08T00:00:04+00:00", preview_source: null, preview_output: null }); + emit({ ...newer, job_id: "next", status: "queued", created_at: "2026-10-08T00:00:04+00:00", updated_at: "2026-10-08T00:00:04+00:00", preview_source: null, preview_output: null }); emit(older); expect(await invoke("fs:readImageOutput", source, directory, operation)).toBe("Original output"); }); From 92868167d7b3029f2b426c42c0ddecc34b9d75cd Mon Sep 17 00:00:00 2001 From: Jaymie Date: Thu, 8 Oct 2026 16:27:39 -0400 Subject: [PATCH 6/7] Center setup loading and add a window drag region --- CHANGELOG.md | 1 + src/splash.html | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9659f..7d15c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ TrainKit 1.3.1 improves image previews, shows saved captions and tags, and makes - Existing caption and tag previews resolve Windows folder junctions. - Renamed caption and tag previews use canonical input paths and retain the newest result after event recovery. +- The setup loading bar stays centered, and its window has a dedicated draggable area. - Tagging closes images after inference and stops before saving when cancellation is requested. - Fully skipped tagging batches no longer load a model. - Preview folder listings exclude directories with image extensions, and failed image decoding shows a readable error. diff --git a/src/splash.html b/src/splash.html index 9784daf..cfdd2f5 100644 --- a/src/splash.html +++ b/src/splash.html @@ -46,6 +46,16 @@ padding-top: 100px; } + .window-drag-region { + position: fixed; + top: 0; + left: 0; + right: 68px; + height: 40px; + z-index: 1; + -webkit-app-region: drag; + } + .window-controls { position: absolute; top: 8px; @@ -117,6 +127,9 @@ } .loader-container { + display: flex; + flex-direction: column; + align-items: center; margin-top: 32px; animation: fadeIn 0.5s ease-out 0.4s both; } @@ -324,6 +337,7 @@ +