From 8d8f0dff4d3b0de09f3f361e3b4b25bdf620b5ec Mon Sep 17 00:00:00 2001
From: LightCyan01 <29302715+LightCyan01@users.noreply.github.com>
Date: Wed, 15 Jul 2026 00:48:43 -0400
Subject: [PATCH] Release 1.2.1
---
.github/workflows/release.yml | 81 +++++----
CHANGELOG.md | 22 +++
CONTRIBUTING.md | 4 +-
README.md | 35 ++--
backend/models/requests.py | 9 +-
backend/service/image_captioning.py | 23 ++-
backend/service/image_rename.py | 33 ++--
backend/service/image_tagging.py | 23 ++-
backend/service/image_upscaling.py | 29 ++-
backend/service/manifest.py | 12 +-
backend/service/ncnn_upscaling.py | 44 ++++-
backend/tests/test_file_safety.py | 7 +
backend/tests/test_manifest.py | 19 ++
backend/tests/test_ncnn.py | 30 +++-
backend/tests/test_upscale_batch.py | 72 +++++++-
backend/utils/file_util.py | 8 +-
docs/architecture.md | 8 +-
docs/model-support.md | 9 +-
forge.config.ts | 9 +-
package-lock.json | 168 +-----------------
package.json | 7 +-
.../trainkit/panels/caption-panel.tsx | 13 +-
.../trainkit/panels/rename-panel.tsx | 15 +-
src/components/trainkit/panels/tag-panel.tsx | 13 +-
.../trainkit/panels/upscale-panel.tsx | 47 +++--
.../trainkit/shared/BatchOptions.tsx | 45 +++--
.../trainkit/shared/ImagePreview.tsx | 4 +-
src/components/trainkit/shared/Input.tsx | 60 +++++--
src/lib/config.ts | 1 +
src/main.ts | 18 +-
src/types/electron.d.ts | 2 +-
31 files changed, 511 insertions(+), 359 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 889089e..1423946 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,7 +22,6 @@ jobs:
id-token: write
env:
RELEASE_TAG: ${{ github.ref_name }}
- WINDOWS_CERTIFICATE_FILE: ${{ runner.temp }}/trainkit-signing.pfx
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
steps:
@@ -46,7 +45,7 @@ jobs:
with:
enable-cache: false
- - name: Validate tag and signing configuration
+ - name: Validate tag and configure optional signing
id: metadata
env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
@@ -56,16 +55,22 @@ jobs:
if ($env:RELEASE_TAG -ne "v$version") {
throw "Tag $env:RELEASE_TAG does not match package version v$version"
}
- if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_BASE64)) {
- throw "WINDOWS_CERTIFICATE_BASE64 is required for release signing"
+ $hasCertificate = ![string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_BASE64)
+ $hasPassword = ![string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)
+ if ($hasCertificate -ne $hasPassword) {
+ throw "WINDOWS_CERTIFICATE_BASE64 and WINDOWS_CERTIFICATE_PASSWORD must be configured together"
}
- if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)) {
- throw "WINDOWS_CERTIFICATE_PASSWORD is required for release signing"
+ if ($hasCertificate) {
+ $certificateFile = Join-Path $env:RUNNER_TEMP "trainkit-signing.pfx"
+ [IO.File]::WriteAllBytes(
+ $certificateFile,
+ [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64)
+ )
+ "WINDOWS_CERTIFICATE_FILE=$certificateFile" >> $env:GITHUB_ENV
+ "signed=true" >> $env:GITHUB_OUTPUT
+ } else {
+ "signed=false" >> $env:GITHUB_OUTPUT
}
- [IO.File]::WriteAllBytes(
- $env:WINDOWS_CERTIFICATE_FILE,
- [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64)
- )
"version=$version" >> $env:GITHUB_ENV
"version=$version" >> $env:GITHUB_OUTPUT
@@ -78,40 +83,30 @@ jobs:
- name: Verify source
run: npm run verify
- - name: Build signed Windows artifacts
+ - name: Build Windows ZIP
run: npm run make
- name: Verify packaged layout
run: npm run verify:package
- name: Verify Authenticode signatures
+ if: steps.metadata.outputs.signed == 'true'
shell: pwsh
run: |
- $targets = @(
- "out/TrainKit-win32-x64/TrainKit.exe"
- Get-ChildItem "out/make/squirrel.windows/x64" -Filter "*.exe" |
- Select-Object -ExpandProperty FullName
- )
- if ($targets.Count -lt 2) {
- throw "Expected the application and installer executables"
- }
- foreach ($target in $targets) {
- $signature = Get-AuthenticodeSignature $target
- if ($signature.Status -ne "Valid") {
- throw "Invalid Authenticode signature for ${target}: $($signature.Status)"
- }
+ $target = "out/TrainKit-win32-x64/TrainKit.exe"
+ $signature = Get-AuthenticodeSignature $target
+ if ($signature.Status -ne "Valid") {
+ throw "Invalid Authenticode signature for ${target}: $($signature.Status)"
}
- name: Prepare release artifacts and checksums
shell: pwsh
run: |
- $installer = "out/TrainKit-Windows-Installer-$env:version.zip"
- Compress-Archive -Path "out/make/squirrel.windows/x64/*" -DestinationPath $installer -Force
- $portable = "out/make/zip/win32/x64/TrainKit-win32-x64-$env:version.zip"
- if (!(Test-Path $portable)) {
- throw "Portable archive was not produced: $portable"
+ $archive = "out/make/zip/win32/x64/TrainKit-win32-x64-$env:version.zip"
+ if (!(Test-Path $archive)) {
+ throw "Windows archive was not produced: $archive"
}
- @($installer, $portable) | ForEach-Object {
+ @($archive) | ForEach-Object {
$hash = (Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $(Split-Path $_ -Leaf)"
} | Set-Content "out/SHA256SUMS.txt" -Encoding utf8
@@ -128,19 +123,31 @@ jobs:
if (!$match.Success) {
throw "CHANGELOG.md does not contain a section for $env:version"
}
- $provenance = @"
+ if ('${{ steps.metadata.outputs.signed }}' -eq 'true') {
+ $releaseNotice = @"
---
- Windows artifacts are Authenticode-signed and accompanied by SHA-256 checksums and a GitHub build-provenance attestation.
+ The Windows executable is Authenticode-signed. The ZIP is accompanied by a SHA-256 checksum and GitHub build-provenance attestation.
"@
- "$($match.Value.Trim())`n`n$($provenance.Trim())" |
+ } else {
+ $releaseNotice = @'
+ ---
+
+ > [!WARNING]
+ > This release is currently unsigned. Windows SmartScreen or antivirus software may display a warning or false-positive detection. Download only from this GitHub release and verify the ZIP against `SHA256SUMS.txt` before running it.
+
+ Download the Windows ZIP, extract the entire archive to a writable folder on the drive where you want TrainKit stored, then run `TrainKit.exe`. The ZIP is the standard Windows distribution; there is no installer.
+
+ The ZIP is accompanied by a SHA-256 checksum and GitHub build-provenance attestation.
+ '@
+ }
+ "$($match.Value.Trim())`n`n$($releaseNotice.Trim())" |
Set-Content "out/RELEASE_NOTES.md" -Encoding utf8
- name: Attest release provenance
uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3
with:
subject-path: |
- out/TrainKit-Windows-Installer-${{ steps.metadata.outputs.version }}.zip
out/make/zip/win32/x64/TrainKit-win32-x64-${{ steps.metadata.outputs.version }}.zip
out/SHA256SUMS.txt
@@ -150,7 +157,6 @@ jobs:
shell: pwsh
run: |
gh release create $env:RELEASE_TAG `
- "out/TrainKit-Windows-Installer-$env:version.zip" `
"out/make/zip/win32/x64/TrainKit-win32-x64-$env:version.zip" `
"out/SHA256SUMS.txt" `
--verify-tag `
@@ -160,4 +166,7 @@ jobs:
- name: Remove signing certificate
if: always()
shell: pwsh
- run: Remove-Item -LiteralPath $env:WINDOWS_CERTIFICATE_FILE -Force -ErrorAction SilentlyContinue
+ run: |
+ if (![string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_FILE)) {
+ Remove-Item -LiteralPath $env:WINDOWS_CERTIFICATE_FILE -Force -ErrorAction SilentlyContinue
+ }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ca7afb..102b08a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,27 @@
All notable TrainKit changes are documented here. TrainKit follows semantic versioning.
+## [1.2.1] - 2026-07-15
+
+TrainKit 1.2.1 fixes NCNN graph compatibility, makes manifests opt-in, and adds individual-image workflows across every operation.
+
+### Added
+
+- Added individual-image selection and processing alongside the existing folder workflow for captioning, upscaling, tagging, and renaming.
+
+### Changed
+
+- NCNN input and output blobs are now discovered from the loaded graph automatically. Manual blob fields remain available only as overrides for graphs with multiple inputs or outputs.
+- Resumable manifest files are now opt-in for normal runs. Dry runs still create a manifest, and resumed jobs continue updating the selected manifest.
+- The matching NCNN `.bin` file is now discovered beside its `.param` graph unless an override is selected.
+- The Windows ZIP is now the sole release format and standard distribution; the Squirrel installer and its runtime hooks were removed.
+- Tagged releases can publish unsigned builds with an explicit SmartScreen/antivirus warning, while still signing and verifying the executable when both certificate secrets are configured.
+
+### Fixed
+
+- Fixed single-input NCNN graphs failing with `NCNN input blob not found: in0` when their tensor used another name.
+- Kept the first per-image processing error visible when manifest persistence is disabled.
+
## [1.2.0] - 2026-07-14
TrainKit 1.2.0 completes the original processing roadmap, adds resumable and collision-safe batch execution, hardens the Electron/backend boundary, and restores a self-contained portable runtime layout.
@@ -148,6 +169,7 @@ TrainKit 1.2.0 completes the original processing roadmap, adds resumable and col
- Initial TrainKit release with local image captioning, Spandrel upscaling, batch renaming, and an Electron desktop interface.
+[1.2.1]: https://github.com/LightCyan01/TrainKit/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/LightCyan01/TrainKit/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/LightCyan01/TrainKit/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/LightCyan01/TrainKit/releases/tag/v1.0.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b5a56a2..5a156e9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -46,7 +46,7 @@ User-visible changes must update `CHANGELOG.md` under the target version. The re
## Maintainer release setup
-Tagged releases are fail-closed and must match `package.json` exactly (for example, version `1.1.0` requires tag `v1.1.0`). Configure these GitHub Actions secrets:
+Tagged releases must match `package.json` exactly (for example, version `1.2.1` requires tag `v1.2.1`). Releases are unsigned by default. To Authenticode-sign the packaged executable, configure both GitHub Actions secrets:
- `WINDOWS_CERTIFICATE_BASE64`: Base64-encoded PFX code-signing certificate.
- `WINDOWS_CERTIFICATE_PASSWORD`: PFX password.
@@ -57,4 +57,4 @@ PowerShell can encode a PFX without printing binary data:
[Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\TrainKit.pfx"))
```
-The release workflow verifies the application and installer Authenticode signatures, produces SHA-256 checksums, and creates a GitHub build-provenance attestation before publishing.
+When both secrets are configured, the release workflow verifies the application Authenticode signature. Without them, it publishes an explicit unsigned-build warning. Every release contains only the standard Windows ZIP plus its SHA-256 checksum and GitHub build-provenance attestation.
diff --git a/README.md b/README.md
index 5297b96..4b2f25d 100644
--- a/README.md
+++ b/README.md
@@ -23,29 +23,28 @@
Architecture
-TrainKit prepares image datasets without uploading them to a hosted service. Every batch is deterministic, cancellable, collision-aware, and recorded in a resumable manifest.
+TrainKit prepares individual images or complete image folders without uploading them to a hosted service. Every run is deterministic, cancellable, and collision-aware; resumable manifest files are optional.
-## What's new in 1.2.0
+## What's new in 1.2.1
-- NCNN `.param` + `.bin` upscaling with CPU/Vulkan and tiled inference.
-- Local image tagging with JSON, training-text, and paired outputs.
-- Standard multimodal, BLIP, and InstructBLIP caption adapters with auto-detection.
-- Dry runs, collision policies, atomic manifests, cancellation, and failed-item resume across every operation.
-- A self-contained packaged Python runtime and logs stored beside the application.
-- A hardened Electron/backend boundary plus signed-release, checksum, and provenance automation.
+- Automatic NCNN input/output blob discovery instead of hard-coded `in0` and `out0` names.
+- Individual-image selection for captioning, upscaling, tagging, and renaming.
+- Manifests disabled by default, with explicit save, dry-run, and resume options.
+- First processing errors remain visible even when no manifest is written.
+- A single standard Windows ZIP distribution with no installer.
-See the [complete 1.2.0 changelog](CHANGELOG.md#120---2026-07-14) for fixes, security changes, migration notes, and verification results.
+See the [complete 1.2.1 changelog](CHANGELOG.md#121---2026-07-15) for details.
## Features
- **Captioning:** local standard Transformers multimodal-chat, BLIP, and InstructBLIP models; auto-detection, explicit preload/unload, prompt control, and token-level cancellation.
-- **Upscaling:** safe `.safetensors` models through Spandrel, or NCNN `.param` + `.bin` models through the Python bindings with CPU/Vulkan and tiled inference.
+- **Upscaling:** safe `.safetensors` models through Spandrel, or NCNN `.param` + `.bin` models through the Python bindings with automatic blob discovery, CPU/Vulkan, and tiled inference.
- **Tagging:** local Hugging Face image-classification models with threshold/top-K controls and scored JSON, training-text, or paired sidecars.
- **Renaming:** natural input ordering, configurable zero padding, optional visual-duplicate filtering, and atomic copies.
-- **Safe batch behavior:** dry-run manifests, `fail`/`skip`/`rename`/`overwrite` collision policies, per-file status, atomic output replacement, and failed-item resume.
+- **Safe processing:** individual-image or folder input, `fail`/`skip`/`rename`/`overwrite` collision policies, per-file status, atomic output replacement, and optional resumable manifests.
- **Hardened desktop boundary:** sandboxed renderers, narrow IPC, user-granted file capabilities, an authenticated ephemeral loopback backend, and restricted navigation.
-Input images can be PNG, JPEG, BMP, or WebP. Upscaled output can be PNG, JPEG, BMP, or WebP.
+Input can be one PNG, JPEG, BMP, or WebP image or a folder containing those formats. Upscaled output can be PNG, JPEG, BMP, or WebP.
## Install a release
@@ -54,7 +53,7 @@ TrainKit currently targets 64-bit Windows 10/11. Before the first launch, instal
1. [uv](https://docs.astral.sh/uv/getting-started/installation/)
2. [Microsoft Visual C++ Redistributable x64](https://aka.ms/vs/17/release/vc_redist.x64.exe)
-Download the signed installer or portable archive from [GitHub Releases](https://github.com/LightCyan01/TrainKit/releases). On first launch, TrainKit uses the committed `uv.lock` to install Python 3.12 and the backend dependencies directly under `resources/backend` in the TrainKit folder. Setup downloads and temporary files stay there and are removed after a successful install. This requires a network connection and several gigabytes of free space.
+Download the Windows ZIP from [GitHub Releases](https://github.com/LightCyan01/TrainKit/releases), extract the entire archive to a writable folder on the drive where you want TrainKit stored, and run `TrainKit.exe`. The current release is unsigned, so Windows SmartScreen or antivirus software may warn; download only from the GitHub release and verify `SHA256SUMS.txt`. On first launch, TrainKit uses the committed `uv.lock` to install Python 3.12 and the backend dependencies directly under `resources/backend` in the TrainKit folder. Setup downloads and temporary files stay there and are removed after a successful install. This requires a network connection and several gigabytes of free space.
Persistent session logs are written to `logs` beside `TrainKit.exe`. The **Logs** panel shows the exact current path and can open either the folder or current log. Older builds used `%APPDATA%\TrainKit\backend-runtime`; a successful self-contained setup removes that obsolete generated runtime.
@@ -81,23 +80,23 @@ npm run package
npm run verify:package
```
-`npm run make` creates the Squirrel installer and portable ZIP. Local packages are unsigned unless the signing environment variables described in [CONTRIBUTING.md](CONTRIBUTING.md) are set.
+`npm run make` creates the standard Windows ZIP. Local packages are unsigned unless the signing environment variables described in [CONTRIBUTING.md](CONTRIBUTING.md) are set.
-## Using batch manifests
+## Using optional manifests
-Each new job writes a manifest to:
+Normal runs do not write a manifest. Enable **Save resumable manifest** when you want progress saved to:
```text
/.trainkit/manifests/.json
```
-Use **Dry run** to preflight source ordering and destinations without processing. A resume uses the same operation and the same input/output directories, preserves completed and skipped items, and retries failed items. Manifests are treated as untrusted input: paths outside the selected roots are rejected.
+Dry runs always write a manifest because the plan is their output. A resume uses the same operation and input/output paths, preserves completed and skipped items, retries failed items, and updates the selected manifest. Manifests are treated as untrusted input: paths outside the selected roots are rejected.
See [model support](docs/model-support.md) for model layouts and NCNN assumptions, [architecture](docs/architecture.md) for the desktop/backend trust boundaries, and the [changelog](CHANGELOG.md) for complete release history.
## Project status
-Version 1.2.0 completes the original NCNN, additional caption-adapter, and image-tagging roadmap. New feature proposals and model-compatibility reports are welcome through GitHub issues.
+Version 1.2.1 completes the original processing roadmap and improves NCNN compatibility, input selection, and output control. New feature proposals and model-compatibility reports are welcome through GitHub issues.
## Contributing and security
diff --git a/backend/models/requests.py b/backend/models/requests.py
index e97da62..4e0c85c 100644
--- a/backend/models/requests.py
+++ b/backend/models/requests.py
@@ -15,6 +15,7 @@ class BatchRequest(BaseModel):
save_path: str
collision_policy: CollisionPolicy = "fail"
dry_run: bool = False
+ save_manifest: bool = False
resume_manifest_path: str | None = None
@@ -32,8 +33,8 @@ class UpscaleRequest(BatchRequest):
tile_size: int = Field(default=512, ge=64, le=4096)
tile_overlap: int = Field(default=16, ge=0, le=512)
ncnn_model_bin_path: str | None = None
- ncnn_input_blob: str = "in0"
- ncnn_output_blob: str = "out0"
+ ncnn_input_blob: str | None = None
+ ncnn_output_blob: str | None = None
ncnn_scale: int = Field(default=4, ge=1, le=16)
ncnn_use_vulkan: bool = True
@@ -69,8 +70,8 @@ class ModelInfoRequest(BaseModel):
backend: Literal["spandrel", "ncnn"] = "spandrel"
model_bin_path: str | None = None
scale: int = Field(default=4, ge=1, le=16)
- input_blob: str = "in0"
- output_blob: str = "out0"
+ input_blob: str | None = None
+ output_blob: str | None = None
use_vulkan: bool = True
diff --git a/backend/service/image_captioning.py b/backend/service/image_captioning.py
index ee1bf93..61354f6 100644
--- a/backend/service/image_captioning.py
+++ b/backend/service/image_captioning.py
@@ -47,7 +47,7 @@ async def load(self):
if not self.loaded:
await asyncio.to_thread(self.adapter.load)
- async def process(self, request: CaptionRequest, context: JobContext) -> Path:
+ async def process(self, request: CaptionRequest, context: JobContext) -> Path | None:
load_path = Path(request.load_path).resolve()
save_path = Path(request.save_path).resolve()
if request.resume_manifest_path:
@@ -66,16 +66,23 @@ async def process(self, request: CaptionRequest, context: JobContext) -> Path:
destination_for=lambda source, _index: save_path / f"{source.stem}.txt",
collision_policy=request.collision_policy,
)
- output_manifest = manifest_path(save_path, context.job_id)
- manifest.save(output_manifest)
+ output_manifest = (
+ manifest_path(save_path, context.job_id)
+ if request.save_manifest or request.dry_run
+ else None
+ )
+ if output_manifest is not None:
+ manifest.save(output_manifest)
total = len(manifest.items)
completed = sum(item.status in {"completed", "skipped"} for item in manifest.items)
- await context.progress(completed, total, "Caption manifest ready", output_manifest)
+ message = "Caption manifest ready" if output_manifest is not None else "Caption plan ready"
+ await context.progress(completed, total, message, output_manifest)
if request.dry_run:
return output_manifest
await self.load()
failures = 0
+ first_error: str | None = None
for item in manifest.items:
if item.status in {"completed", "skipped"}:
continue
@@ -94,13 +101,17 @@ async def process(self, request: CaptionRequest, context: JobContext) -> Path:
item.status = "failed"
item.error = str(exc)
failures += 1
+ first_error = first_error or f"{Path(item.source).name}: {str(exc)[:500]}"
completed += 1
- manifest.save(output_manifest)
+ if output_manifest is not None:
+ manifest.save(output_manifest)
await context.progress(
completed, total, f"Captioned {Path(item.source).name}", output_manifest
)
if failures:
- raise ProcessingError(f"Captioning failed for {failures} item(s); see the manifest")
+ detail = f": {first_error}" if first_error else ""
+ suffix = "; see the manifest" if output_manifest is not None else ""
+ raise ProcessingError(f"Captioning failed for {failures} item(s){detail}{suffix}")
return output_manifest
def cleanup(self):
diff --git a/backend/service/image_rename.py b/backend/service/image_rename.py
index f7bff65..78ede8a 100644
--- a/backend/service/image_rename.py
+++ b/backend/service/image_rename.py
@@ -10,14 +10,14 @@
from core.jobs import JobContext
from models import RenameRequest
from service.manifest import BatchManifest, build_manifest, manifest_path
-from utils.file_util import atomic_copy, is_image
+from utils.file_util import atomic_copy, list_images
class RenameService:
def __init__(self):
self._duplicates_cache: set[Path] | None = None
- async def process(self, request: RenameRequest, context: JobContext) -> Path:
+ async def process(self, request: RenameRequest, context: JobContext) -> Path | None:
load_path = Path(request.load_path).resolve()
save_path = Path(request.save_path).resolve()
if request.resume_manifest_path:
@@ -28,13 +28,9 @@ async def process(self, request: RenameRequest, context: JobContext) -> Path:
manifest.preflight_resume(request.collision_policy)
else:
duplicates: set[Path] = set()
- if request.skip_duplicates:
+ if request.skip_duplicates and load_path.is_dir():
duplicates = await asyncio.to_thread(self.skip_duplicates, load_path)
- sources = [
- path
- for path in load_path.iterdir()
- if path.is_file() and is_image(path) and path not in duplicates
- ]
+ sources = [path for path in list_images(load_path) if path not in duplicates]
def destination(source: Path, index: int) -> Path:
number = str(index).zfill(request.zero_pad)
@@ -53,15 +49,22 @@ def destination(source: Path, index: int) -> Path:
destination_for=destination,
collision_policy=request.collision_policy,
)
- output_manifest = manifest_path(save_path, context.job_id)
- manifest.save(output_manifest)
+ output_manifest = (
+ manifest_path(save_path, context.job_id)
+ if request.save_manifest or request.dry_run
+ else None
+ )
+ if output_manifest is not None:
+ manifest.save(output_manifest)
total = len(manifest.items)
completed = sum(item.status in {"completed", "skipped"} for item in manifest.items)
- await context.progress(completed, total, "Rename manifest ready", output_manifest)
+ message = "Rename manifest ready" if output_manifest is not None else "Rename plan ready"
+ await context.progress(completed, total, message, output_manifest)
if request.dry_run:
return output_manifest
failures = 0
+ first_error: str | None = None
for item in manifest.items:
if item.status in {"completed", "skipped"}:
continue
@@ -74,13 +77,17 @@ def destination(source: Path, index: int) -> Path:
item.status = "failed"
item.error = str(exc)
failures += 1
+ first_error = first_error or f"{Path(item.source).name}: {str(exc)[:500]}"
completed += 1
- manifest.save(output_manifest)
+ if output_manifest is not None:
+ manifest.save(output_manifest)
await context.progress(
completed, total, f"Renamed {Path(item.source).name}", output_manifest
)
if failures:
- raise ProcessingError(f"Rename failed for {failures} item(s); see the manifest")
+ detail = f": {first_error}" if first_error else ""
+ suffix = "; see the manifest" if output_manifest is not None else ""
+ raise ProcessingError(f"Rename failed for {failures} item(s){detail}{suffix}")
return output_manifest
def skip_duplicates(self, load_path: Path) -> set[Path]:
diff --git a/backend/service/image_tagging.py b/backend/service/image_tagging.py
index 153019e..9502dfd 100644
--- a/backend/service/image_tagging.py
+++ b/backend/service/image_tagging.py
@@ -140,7 +140,7 @@ def _tag(self, image, threshold: float, top_k: int) -> list[dict[str, float | st
]
return tags
- async def process(self, request: TagRequest, context: JobContext) -> Path:
+ async def process(self, request: TagRequest, context: JobContext) -> Path | None:
load_path = Path(request.load_path).resolve()
save_path = Path(request.save_path).resolve()
if request.resume_manifest_path:
@@ -173,16 +173,23 @@ async def process(self, request: TagRequest, context: JobContext) -> Path:
collision_policy=request.collision_policy,
)
_set_tag_destinations(manifest, request.output, request.collision_policy)
- output_manifest = manifest_path(save_path, context.job_id)
- manifest.save(output_manifest)
+ output_manifest = (
+ manifest_path(save_path, context.job_id)
+ if request.save_manifest or request.dry_run
+ else None
+ )
+ if output_manifest is not None:
+ manifest.save(output_manifest)
total = len(manifest.items)
completed = sum(item.status in {"completed", "skipped"} for item in manifest.items)
- await context.progress(completed, total, "Tag manifest ready", output_manifest)
+ 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:
return output_manifest
await self.load()
failures = 0
+ first_error: str | None = None
for item in manifest.items:
if item.status in {"completed", "skipped"}:
continue
@@ -215,13 +222,17 @@ async def process(self, request: TagRequest, context: JobContext) -> Path:
item.status = "failed"
item.error = str(exc)
failures += 1
+ first_error = first_error or f"{Path(item.source).name}: {str(exc)[:500]}"
completed += 1
- manifest.save(output_manifest)
+ if output_manifest is not None:
+ manifest.save(output_manifest)
await context.progress(
completed, total, f"Tagged {Path(item.source).name}", output_manifest
)
if failures:
- raise ProcessingError(f"Tagging failed for {failures} item(s); see the manifest")
+ detail = f": {first_error}" if first_error else ""
+ suffix = "; see the manifest" if output_manifest is not None else ""
+ raise ProcessingError(f"Tagging failed for {failures} item(s){detail}{suffix}")
return output_manifest
def cleanup(self):
diff --git a/backend/service/image_upscaling.py b/backend/service/image_upscaling.py
index ae4892b..c94cfc5 100644
--- a/backend/service/image_upscaling.py
+++ b/backend/service/image_upscaling.py
@@ -100,7 +100,7 @@ def _upscale_with_tiler(self, image: Image.Image, cancelled) -> Image.Image:
def _direct_upscale(self, image: Image.Image) -> Image.Image:
return self._process_tile(image)
- async def process(self, request: UpscaleRequest, context: JobContext) -> Path:
+ async def process(self, request: UpscaleRequest, context: JobContext) -> Path | None:
return await process_upscale_batch(self, request, context)
def upscale_image(self, image: Image.Image, use_tiling: bool, cancelled) -> Image.Image:
@@ -122,7 +122,7 @@ def cleanup(self):
async def prepare_upscale_manifest(
request: UpscaleRequest, context: JobContext, service=None
-) -> tuple[BatchManifest, Path, dict[str, str]]:
+) -> tuple[BatchManifest, Path | None, dict[str, str]]:
load_path = Path(request.load_path).resolve()
save_path = Path(request.save_path).resolve()
format_info = SUPPORTED_OUTPUT_FORMATS[request.format.casefold()]
@@ -166,15 +166,23 @@ async def prepare_upscale_manifest(
item.metadata["scale"] = service.scale
elif request.backend == "ncnn":
item.metadata["scale"] = request.ncnn_scale
- output_manifest = manifest_path(save_path, context.job_id)
- manifest.save(output_manifest)
+ output_manifest = (
+ manifest_path(save_path, context.job_id)
+ if request.save_manifest or request.dry_run
+ else None
+ )
+ if output_manifest is not None:
+ manifest.save(output_manifest)
total = len(manifest.items)
completed = sum(item.status in {"completed", "skipped"} for item in manifest.items)
- await context.progress(completed, total, "Upscale manifest ready", output_manifest)
+ message = "Upscale manifest ready" if output_manifest is not None else "Upscale plan ready"
+ await context.progress(completed, total, message, output_manifest)
return manifest, output_manifest, format_info
-async def process_upscale_batch(service, request: UpscaleRequest, context: JobContext) -> Path:
+async def process_upscale_batch(
+ service, request: UpscaleRequest, context: JobContext
+) -> Path | None:
manifest, output_manifest, format_info = await prepare_upscale_manifest(
request, context, service
)
@@ -184,6 +192,7 @@ async def process_upscale_batch(service, request: UpscaleRequest, context: JobCo
total = len(manifest.items)
completed = sum(item.status in {"completed", "skipped"} for item in manifest.items)
failures = 0
+ first_error: str | None = None
for item in manifest.items:
if item.status in {"completed", "skipped"}:
continue
@@ -207,11 +216,15 @@ async def process_upscale_batch(service, request: UpscaleRequest, context: JobCo
item.status = "failed"
item.error = str(exc)
failures += 1
+ first_error = first_error or f"{Path(item.source).name}: {str(exc)[:500]}"
completed += 1
- manifest.save(output_manifest)
+ if output_manifest is not None:
+ manifest.save(output_manifest)
await context.progress(
completed, total, f"Upscaled {Path(item.source).name}", output_manifest
)
if failures:
- raise ProcessingError(f"Upscaling failed for {failures} item(s); see the manifest")
+ detail = f": {first_error}" if first_error else ""
+ suffix = "; see the manifest" if output_manifest is not None else ""
+ raise ProcessingError(f"Upscaling failed for {failures} item(s){detail}{suffix}")
return output_manifest
diff --git a/backend/service/manifest.py b/backend/service/manifest.py
index 65e30fe..ef04caf 100644
--- a/backend/service/manifest.py
+++ b/backend/service/manifest.py
@@ -66,14 +66,12 @@ def validate_scope(self, load_path: Path, save_path: Path):
load_root = load_path.resolve()
save_root = save_path.resolve()
if _path_key(Path(self.load_path)) != _path_key(load_root):
- raise InvalidPathError("Manifest input directory does not match this request")
+ raise InvalidPathError("Manifest input path does not match this request")
if _path_key(Path(self.save_path)) != _path_key(save_root):
raise InvalidPathError("Manifest output directory does not match this request")
for item in self.items:
if not _is_within(Path(item.source), load_root):
- raise InvalidPathError(
- f"Manifest source escapes the input directory: {item.source}"
- )
+ raise InvalidPathError(f"Manifest source escapes the selected input: {item.source}")
if not _is_within(Path(item.destination), save_root):
raise InvalidPathError(
f"Manifest destination escapes the output directory: {item.destination}"
@@ -175,8 +173,8 @@ def build_manifest(
destination_for: Callable[[Path, int], Path],
collision_policy: str,
) -> BatchManifest:
- if not load_path.is_dir():
- raise InvalidPathError(f"Input directory does not exist: {load_path}")
+ if not load_path.is_dir() and not load_path.is_file():
+ raise InvalidPathError(f"Input image or directory does not exist: {load_path}")
save_path.mkdir(parents=True, exist_ok=True)
reserved: set[str] = set()
items: list[ManifestItem] = []
@@ -186,7 +184,7 @@ def build_manifest(
for index, source in enumerate(ordered_sources, start=1):
destination = destination_for(source, index)
if not _is_within(source, load_path):
- raise InvalidPathError(f"Source escapes the input directory: {source}")
+ raise InvalidPathError(f"Source escapes the selected input: {source}")
if not _is_within(destination, save_path):
raise InvalidPathError(f"Destination escapes the output directory: {destination}")
source_key = _path_key(source)
diff --git a/backend/service/ncnn_upscaling.py b/backend/service/ncnn_upscaling.py
index 170e6a8..2867372 100644
--- a/backend/service/ncnn_upscaling.py
+++ b/backend/service/ncnn_upscaling.py
@@ -17,8 +17,8 @@ def __init__(
self,
model_param_path: Path,
model_bin_path: Path | None,
- input_blob: str,
- output_blob: str,
+ input_blob: str | None,
+ output_blob: str | None,
scale: int,
use_vulkan: bool,
tile_size: int = 512,
@@ -28,8 +28,14 @@ def __init__(
self.model_bin_path = (
model_bin_path.resolve() if model_bin_path else self.model_path.with_suffix(".bin")
)
- self.input_blob = input_blob
- self.output_blob = output_blob
+ self.input_blob_override = input_blob.strip() if input_blob and input_blob.strip() else None
+ self.output_blob_override = (
+ output_blob.strip() if output_blob and output_blob.strip() else None
+ )
+ self.input_blob = ""
+ self.output_blob = ""
+ self.input_blobs: list[str] = []
+ self.output_blobs: list[str] = []
self.scale = scale
self.use_vulkan = use_vulkan
self.tile_size = tile_size
@@ -51,11 +57,37 @@ def _load(self):
raise RuntimeError("load_param returned an error")
if self.net.load_model(str(self.model_bin_path)) != 0:
raise RuntimeError("load_model returned an error")
+ self.input_blobs = list(self.net.input_names())
+ self.output_blobs = list(self.net.output_names())
+ self.input_blob = self._resolve_blob(
+ self.input_blob_override, self.input_blobs, "input"
+ )
+ self.output_blob = self._resolve_blob(
+ self.output_blob_override, self.output_blobs, "output"
+ )
except Exception as exc:
raise ModelLoadError(f"Could not load NCNN model: {exc}") from exc
+ @staticmethod
+ def _resolve_blob(override: str | None, available: list[str], kind: str) -> str:
+ if override:
+ if override not in available:
+ names = ", ".join(available) or "none"
+ raise RuntimeError(
+ f"NCNN {kind} blob '{override}' was not found. Available: {names}"
+ )
+ return override
+ if len(available) == 1:
+ return available[0]
+ if not available:
+ raise RuntimeError(f"NCNN graph does not expose an {kind} blob")
+ names = ", ".join(available)
+ raise RuntimeError(
+ f"NCNN graph exposes multiple {kind} blobs ({names}); choose one explicitly"
+ )
+
@property
- def info(self) -> dict[str, str | int | bool]:
+ def info(self) -> dict[str, str | int | bool | list[str]]:
return {
"backend": self.backend_name,
"scale": self.scale,
@@ -65,6 +97,8 @@ def info(self) -> dict[str, str | int | bool]:
"vulkan": self.use_vulkan,
"input_blob": self.input_blob,
"output_blob": self.output_blob,
+ "input_blobs": self.input_blobs,
+ "output_blobs": self.output_blobs,
}
def _process_tile(self, image: Image.Image) -> Image.Image:
diff --git a/backend/tests/test_file_safety.py b/backend/tests/test_file_safety.py
index 5be66cc..0968f1d 100644
--- a/backend/tests/test_file_safety.py
+++ b/backend/tests/test_file_safety.py
@@ -20,3 +20,10 @@ def test_image_listing_is_natural_and_atomic_outputs_replace(tmp_path: Path):
atomic_save_image(Image.new("RGB", (3, 3), "blue"), copied, "PNG")
with Image.open(copied) as image:
assert image.size == (3, 3)
+
+
+def test_image_listing_accepts_an_individual_image(tmp_path: Path):
+ source = tmp_path / "single.webp"
+ Image.new("RGB", (2, 2), "green").save(source)
+
+ assert list_images(source) == [source]
diff --git a/backend/tests/test_manifest.py b/backend/tests/test_manifest.py
index c4f8d65..d69399f 100644
--- a/backend/tests/test_manifest.py
+++ b/backend/tests/test_manifest.py
@@ -99,6 +99,25 @@ def test_new_manifest_rejects_destination_outside_output_root(tmp_path: Path):
)
+def test_manifest_accepts_and_validates_a_single_input_file(tmp_path: Path):
+ source = tmp_path / "image.png"
+ output = tmp_path / "output"
+ source.write_bytes(b"x")
+
+ manifest = build_manifest(
+ job_id="job",
+ operation="caption",
+ load_path=source,
+ save_path=output,
+ sources=[source],
+ destination_for=lambda item, _index: output / f"{item.stem}.txt",
+ collision_policy="fail",
+ )
+
+ manifest.validate_scope(source, output)
+ assert [Path(item.source) for item in manifest.items] == [source]
+
+
def test_overwrite_never_replaces_another_source_file(tmp_path: Path):
dataset = tmp_path / "dataset"
dataset.mkdir()
diff --git a/backend/tests/test_ncnn.py b/backend/tests/test_ncnn.py
index 928e1f9..9ef9580 100644
--- a/backend/tests/test_ncnn.py
+++ b/backend/tests/test_ncnn.py
@@ -1,7 +1,9 @@
from pathlib import Path
+import pytest
from PIL import Image
+from core.exceptions import ModelLoadError
from service.ncnn_upscaling import NCNNUpscaleService
@@ -9,15 +11,15 @@ def test_ncnn_python_binding_runs_minimal_image_graph(tmp_path: Path):
param = tmp_path / "identity.param"
weights = tmp_path / "identity.bin"
param.write_text(
- "7767517\n2 2\nInput in0 0 1 in0\nReLU relu_0 1 1 in0 out0\n",
+ "7767517\n2 2\nInput data 0 1 data\nReLU relu_0 1 1 data result\n",
encoding="utf-8",
)
weights.write_bytes(b"")
service = NCNNUpscaleService(
model_param_path=param,
model_bin_path=weights,
- input_blob="in0",
- output_blob="out0",
+ input_blob=None,
+ output_blob=None,
scale=1,
use_vulkan=False,
)
@@ -25,6 +27,28 @@ def test_ncnn_python_binding_runs_minimal_image_graph(tmp_path: Path):
output = service._process_tile(image)
+ assert service.input_blob == "data"
+ assert service.output_blob == "result"
assert output.size == image.size
assert output.getpixel((0, 0)) == (32, 64, 128)
service.cleanup()
+
+
+def test_ncnn_blob_override_reports_discovered_names(tmp_path: Path):
+ param = tmp_path / "identity.param"
+ weights = tmp_path / "identity.bin"
+ param.write_text(
+ "7767517\n2 2\nInput data 0 1 data\nReLU relu_0 1 1 data result\n",
+ encoding="utf-8",
+ )
+ weights.write_bytes(b"")
+
+ with pytest.raises(ModelLoadError, match="Available: data"):
+ NCNNUpscaleService(
+ model_param_path=param,
+ model_bin_path=weights,
+ input_blob="in0",
+ output_blob=None,
+ scale=1,
+ use_vulkan=False,
+ )
diff --git a/backend/tests/test_upscale_batch.py b/backend/tests/test_upscale_batch.py
index 77b9b1a..65e5003 100644
--- a/backend/tests/test_upscale_batch.py
+++ b/backend/tests/test_upscale_batch.py
@@ -4,6 +4,7 @@
import pytest
from PIL import Image
+from core.exceptions import ProcessingError
from models import UpscaleRequest
from service.image_upscaling import process_upscale_batch
@@ -30,8 +31,16 @@ def upscale_image(self, image, _use_tiling, _cancelled):
return image
+class FailingUpscaler:
+ backend_name = "spandrel"
+ scale = 1
+
+ def upscale_image(self, _image, _use_tiling, _cancelled):
+ raise RuntimeError("model input shape is unsupported")
+
+
@pytest.mark.asyncio
-async def test_upscale_batch_writes_output_and_terminal_progress(tmp_path: Path):
+async def test_upscale_batch_writes_output_without_manifest_by_default(tmp_path: Path):
source = tmp_path / "input"
output = tmp_path / "output"
source.mkdir()
@@ -47,5 +56,64 @@ async def test_upscale_batch_writes_output_and_terminal_progress(tmp_path: Path)
manifest = await process_upscale_batch(IdentityUpscaler(), request, context)
assert (output / "image.png").is_file()
- assert manifest.is_file()
+ assert manifest is None
+ assert not (output / ".trainkit").exists()
assert context.updates[-1][0:2] == (1, 1)
+
+
+@pytest.mark.asyncio
+async def test_upscale_batch_can_save_manifest_and_process_one_image(tmp_path: Path):
+ source = tmp_path / "image.png"
+ output = tmp_path / "output"
+ Image.new("RGB", (2, 2), "blue").save(source)
+ context = FakeContext()
+ request = UpscaleRequest(
+ upscale_model_path=str(tmp_path / "unused.safetensors"),
+ load_path=str(source),
+ save_path=str(output),
+ use_tiling=False,
+ save_manifest=True,
+ )
+
+ manifest = await process_upscale_batch(IdentityUpscaler(), request, context)
+
+ assert (output / "image.png").is_file()
+ assert manifest is not None and manifest.is_file()
+ assert context.updates[-1][0:2] == (1, 1)
+
+
+@pytest.mark.asyncio
+async def test_upscale_dry_run_always_writes_manifest(tmp_path: Path):
+ source = tmp_path / "input"
+ output = tmp_path / "output"
+ source.mkdir()
+ Image.new("RGB", (2, 2), "blue").save(source / "image.png")
+ context = FakeContext()
+ request = UpscaleRequest(
+ upscale_model_path=str(tmp_path / "unused.safetensors"),
+ load_path=str(source),
+ save_path=str(output),
+ use_tiling=False,
+ dry_run=True,
+ )
+
+ manifest = await process_upscale_batch(IdentityUpscaler(), request, context)
+
+ assert manifest is not None and manifest.is_file()
+ assert not (output / "image.png").exists()
+
+
+@pytest.mark.asyncio
+async def test_upscale_failure_is_visible_without_manifest(tmp_path: Path):
+ source = tmp_path / "image.png"
+ output = tmp_path / "output"
+ Image.new("RGB", (2, 2), "blue").save(source)
+ request = UpscaleRequest(
+ upscale_model_path=str(tmp_path / "unused.safetensors"),
+ load_path=str(source),
+ save_path=str(output),
+ use_tiling=False,
+ )
+
+ with pytest.raises(ProcessingError, match="image.png: model input shape is unsupported"):
+ await process_upscale_batch(FailingUpscaler(), request, FakeContext())
diff --git a/backend/utils/file_util.py b/backend/utils/file_util.py
index 96c1ef7..87fed91 100644
--- a/backend/utils/file_util.py
+++ b/backend/utils/file_util.py
@@ -54,13 +54,15 @@ def is_image(file_path: Path) -> bool:
return False
-def list_images(directory: Path) -> list[Path]:
- validate_directory(directory)
+def list_images(source: Path) -> list[Path]:
from service.manifest import sorted_files
+ if source.is_file():
+ return [validate_image_path(source)]
+ validate_directory(source)
return sorted_files(
path
- for path in directory.iterdir()
+ for path in source.iterdir()
if path.is_file() and path.suffix.casefold() in SUPPORTED_INPUT_EXTENSIONS
)
diff --git a/docs/architecture.md b/docs/architecture.md
index 863f2b7..92d20fa 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -9,7 +9,7 @@ flowchart LR
M -->|"token-authenticated HTTP"| B["FastAPI backend on ephemeral loopback port"]
B --> J["single-active-job manager"]
J --> S["caption / upscale / rename / tag services"]
- S --> F["atomic outputs + resumable manifests"]
+ S --> F["atomic outputs + optional resumable manifests"]
B -->|"authenticated WebSocket events"| M
M -->|"validated job/log events"| R
```
@@ -22,7 +22,7 @@ flowchart LR
`src/runtime-paths.ts` is the single authority for packaged paths. `src/setup-manager.ts` installs the locked Python environment beside the packaged backend under `resources/backend`, including its managed Python interpreter. uv's cache and temporary directory also stay there during setup and are removed after success. A marker binds the environment to the desktop version and `uv.lock` SHA-256. Session logs are stored under `logs` beside the executable.
-Because the packaged runtime is self-contained, TrainKit must be extracted or installed in a folder the current user can write to. Setup checks this before downloading multi-gigabyte dependencies and reports the exact rejected path.
+Because the packaged runtime is self-contained, TrainKit must be extracted to a folder the current user can write to. Setup checks this before downloading multi-gigabyte dependencies and reports the exact rejected path.
## Backend boundary
@@ -34,9 +34,9 @@ Because the packaged runtime is self-contained, TrainKit must be extracted or in
## Data pipeline
-Every operation enumerates verified images in deterministic natural order and creates a schema-versioned manifest before processing. Collision policy is resolved before the first output. Writes use a temporary file in the destination directory followed by atomic replacement.
+Every operation accepts one verified image or enumerates a selected folder in deterministic natural order. It creates an in-memory execution plan and resolves collision policy before the first output. The plan is persisted as a schema-versioned manifest only when requested, for dry runs, or while resuming an existing manifest. Writes use a temporary file in the destination directory followed by atomic replacement.
-Resume accepts only a matching operation and identical input/output roots. Completed and skipped items remain terminal; failed items return to pending. Every source and destination is resolved and checked against the request roots, including paired tagging outputs.
+Resume accepts only a matching operation and identical input/output paths. Completed and skipped items remain terminal; failed items return to pending. Every source and destination is resolved and checked against the selected input and output directory, including paired tagging outputs.
Image safety defaults are 128 MiB per encoded file and 100 million decoded pixels. Supported input extensions are PNG, JPEG, BMP, and WebP.
diff --git a/docs/model-support.md b/docs/model-support.md
index f21cc68..84ed03b 100644
--- a/docs/model-support.md
+++ b/docs/model-support.md
@@ -24,14 +24,15 @@ Legacy `.pt`, `.pth`, and `.ckpt` files can execute pickle payloads and are not
NCNN uses the official Python bindings. Supply:
- a text `.param` graph;
-- its `.bin` weights file;
-- the input and output blob names (defaults: `in0` and `out0`);
+- its `.bin` weights file, or keep it beside the graph with the same stem;
- the integer model scale (default: 4);
- whether to request Vulkan compute.
-If the `.bin` path is omitted at the API layer, TrainKit looks beside the graph with the same stem. The desktop UI requires both files to be selected.
+TrainKit reads `input_names()` and `output_names()` from the loaded NCNN graph. A graph with one input and one output needs no blob configuration. Blob overrides are available for models exposing multiple inputs or outputs; an invalid override reports the discovered names. If the `.bin` path is omitted, TrainKit looks beside the graph with the same stem.
-The current generic adapter feeds contiguous RGB CHW float32 values normalized to `0..1`. It accepts CHW output with one, three, or four channels, keeps RGB, and treats output maxima up to `1.5` as normalized values. Choose or export an NCNN image upscaler with that contract. Models requiring BGR input, custom mean/normalization, multiple inputs, recurrent state, or special pre/post-processing need a dedicated adapter.
+Blob names identify tensor edges in an NCNN graph. The extractor needs them because NCNN supports general graphs with more than one input or output, even though image upscalers normally expose only one of each.
+
+The current generic adapter feeds contiguous RGB CHW float32 values normalized to `0..1`. It accepts CHW output with one, three, or four channels, keeps RGB, and treats output maxima up to `1.5` as normalized values. Choose or export an NCNN image upscaler with that contract. Models requiring BGR input, custom mean/normalization, multiple simultaneous inputs, recurrent state, or special pre/post-processing need a dedicated adapter.
Vulkan availability depends on the NCNN wheel, GPU, and driver. Disable Vulkan to use CPU inference. Tiling reduces peak memory and checks cancellation between tiles.
diff --git a/forge.config.ts b/forge.config.ts
index 122a15d..5fbcf01 100644
--- a/forge.config.ts
+++ b/forge.config.ts
@@ -1,5 +1,4 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
-import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import { VitePlugin } from "@electron-forge/plugin-vite";
import { FusesPlugin } from "@electron-forge/plugin-fuses";
@@ -75,13 +74,7 @@ const config: ForgeConfig = {
},
},
rebuildConfig: {},
- makers: [
- new MakerSquirrel({
- setupIcon: iconPath + ".ico",
- ...(windowsSign ? { windowsSign } : {}),
- }),
- new MakerZIP({}, ["win32"]),
- ],
+ makers: [new MakerZIP({}, ["win32"])],
plugins: [
new VitePlugin({
build: [
diff --git a/package-lock.json b/package-lock.json
index 521dc39..7de4d54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,15 @@
{
"name": "trainkit",
- "version": "1.2.0",
+ "version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trainkit",
- "version": "1.2.0",
+ "version": "1.2.1",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
- "electron-squirrel-startup": "^1.0.1",
"lucide-react": "^0.563.0",
"react": "^19.2.7",
"react-compare-slider": "^3.1.0",
@@ -21,14 +20,12 @@
},
"devDependencies": {
"@electron-forge/cli": "^7.11.2",
- "@electron-forge/maker-squirrel": "^7.11.2",
"@electron-forge/maker-zip": "^7.11.2",
"@electron-forge/plugin-fuses": "^7.11.2",
"@electron-forge/plugin-vite": "^7.11.2",
"@electron/fuses": "^1.0.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/vite": "^4.3.2",
- "@types/electron-squirrel-startup": "^1.0.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
@@ -198,24 +195,6 @@
"node": ">= 16.4.0"
}
},
- "node_modules/@electron-forge/maker-squirrel": {
- "version": "7.11.2",
- "resolved": "https://registry.npmjs.org/@electron-forge/maker-squirrel/-/maker-squirrel-7.11.2.tgz",
- "integrity": "sha512-4CILo57ZDEQH1mJxjhYCSXuv+WaU7oPq67KqiTLEUOEzmiPg9u9/z7FXE34H/Tn5aKWN3dy+ngAETzv6iERCGg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@electron-forge/maker-base": "7.11.2",
- "@electron-forge/shared-types": "7.11.2",
- "fs-extra": "^10.0.0"
- },
- "engines": {
- "node": ">= 16.4.0"
- },
- "optionalDependencies": {
- "electron-winstaller": "^5.3.0"
- }
- },
"node_modules/@electron-forge/maker-zip": {
"version": "7.11.2",
"resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.11.2.tgz",
@@ -2379,13 +2358,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/electron-squirrel-startup": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@types/electron-squirrel-startup/-/electron-squirrel-startup-1.0.2.tgz",
- "integrity": "sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4429,30 +4401,6 @@
"node": ">= 22.12.0"
}
},
- "node_modules/electron-squirrel-startup": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/electron-squirrel-startup/-/electron-squirrel-startup-1.0.1.tgz",
- "integrity": "sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==",
- "license": "Apache-2.0",
- "dependencies": {
- "debug": "^2.2.0"
- }
- },
- "node_modules/electron-squirrel-startup/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "license": "MIT",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/electron-squirrel-startup/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "license": "MIT"
- },
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -4460,66 +4408,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/electron-winstaller": {
- "version": "5.4.0",
- "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
- "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@electron/asar": "^3.2.1",
- "debug": "^4.1.1",
- "fs-extra": "^7.0.1",
- "lodash": "^4.17.21",
- "temp": "^0.9.0"
- },
- "engines": {
- "node": ">=8.0.0"
- },
- "optionalDependencies": {
- "@electron/windows-sign": "^1.1.2"
- }
- },
- "node_modules/electron-winstaller/node_modules/fs-extra": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
- "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "graceful-fs": "^4.1.2",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
- "node_modules/electron-winstaller/node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/electron-winstaller/node_modules/universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 4.0.0"
- }
- },
"node_modules/electron/node_modules/@electron/get": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
@@ -7190,14 +7078,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/lodash": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
- "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -9826,50 +9706,6 @@
"node": ">=18"
}
},
- "node_modules/temp": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
- "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "mkdirp": "^0.5.1",
- "rimraf": "~2.6.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/temp/node_modules/mkdirp": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
- "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "minimist": "^1.2.6"
- },
- "bin": {
- "mkdirp": "bin/cmd.js"
- }
- },
- "node_modules/temp/node_modules/rimraf": {
- "version": "2.6.3",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
- "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "dev": true,
- "license": "ISC",
- "optional": true,
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- }
- },
"node_modules/terser": {
"version": "5.44.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
diff --git a/package.json b/package.json
index 4c57f42..6ef3f44 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "trainkit",
"productName": "TrainKit",
- "version": "1.2.0",
+ "version": "1.2.1",
"description": "A dataset preparation toolkit for AI image training",
"main": ".vite/build/main.js",
"scripts": {
@@ -30,21 +30,19 @@
"license": "MIT",
"devDependencies": {
"@electron-forge/cli": "^7.11.2",
- "@electron-forge/maker-squirrel": "^7.11.2",
"@electron-forge/maker-zip": "^7.11.2",
"@electron-forge/plugin-fuses": "^7.11.2",
"@electron-forge/plugin-vite": "^7.11.2",
"@electron/fuses": "^1.0.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/vite": "^4.3.2",
- "@types/electron-squirrel-startup": "^1.0.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
+ "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.4.23",
- "@types/ws": "^8.18.1",
"electron": "43.1.1",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
@@ -57,7 +55,6 @@
},
"dependencies": {
"clsx": "^2.1.1",
- "electron-squirrel-startup": "^1.0.1",
"lucide-react": "^0.563.0",
"react": "^19.2.7",
"react-compare-slider": "^3.1.0",
diff --git a/src/components/trainkit/panels/caption-panel.tsx b/src/components/trainkit/panels/caption-panel.tsx
index 8c144fb..ec1036b 100644
--- a/src/components/trainkit/panels/caption-panel.tsx
+++ b/src/components/trainkit/panels/caption-panel.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Brain, Play, Square, Upload, Unplug } from "lucide-react";
import { apiRequest } from "@/lib/api";
+import { IMAGE_EXTENSIONS } from "@/lib/config";
import { useJobOperation } from "@/lib/use-job-operation";
import { useWebSocket } from "@/lib/websocket-context";
import type { CollisionPolicy } from "@/types/contracts";
@@ -19,6 +20,7 @@ export function CaptionPanel({ isBackendOnline }: { isBackendOnline: boolean })
const [prompt, setPrompt] = useState("Write a detailed, factual training caption.");
const [collisionPolicy, setCollisionPolicy] = useState("fail");
const [dryRun, setDryRun] = useState(false);
+ const [saveManifest, setSaveManifest] = useState(false);
const [resumeManifestPath, setResumeManifestPath] = useState("");
const [modelValid, setModelValid] = useState(false);
const [modelLoaded, setModelLoaded] = useState(false);
@@ -74,6 +76,12 @@ export function CaptionPanel({ isBackendOnline }: { isBackendOnline: boolean })
const selected = await window.electronAPI.openDirectory();
if (selected) setLoadPath(selected);
};
+ const browseImage = async () => {
+ const selected = await window.electronAPI.openFile({
+ filters: [{ name: "Supported images", extensions: IMAGE_EXTENSIONS }],
+ });
+ if (selected) setLoadPath(selected);
+ };
const browseSave = async () => {
const selected = await window.electronAPI.openDirectory();
if (selected) setSavePath(selected);
@@ -111,6 +119,7 @@ export function CaptionPanel({ isBackendOnline }: { isBackendOnline: boolean })
prompt,
collision_policy: collisionPolicy,
dry_run: dryRun,
+ save_manifest: saveManifest,
resume_manifest_path: resumeManifestPath || null,
});
} catch (caught) {
@@ -150,10 +159,10 @@ export function CaptionPanel({ isBackendOnline }: { isBackendOnline: boolean })
-
+
-
+
{error && {error}
}
diff --git a/src/components/trainkit/panels/rename-panel.tsx b/src/components/trainkit/panels/rename-panel.tsx
index 3fbed55..46f3092 100644
--- a/src/components/trainkit/panels/rename-panel.tsx
+++ b/src/components/trainkit/panels/rename-panel.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { FileEdit, Play, Square } from "lucide-react";
+import { IMAGE_EXTENSIONS } from "@/lib/config";
import { useJobOperation } from "@/lib/use-job-operation";
import type { CollisionPolicy } from "@/types/contracts";
import { BatchOptions, Button, Input, JobProgress } from "../shared";
@@ -14,12 +15,19 @@ export function RenamePanel({ isBackendOnline }: { isBackendOnline: boolean }) {
const [skipDuplicates, setSkipDuplicates] = useState(false);
const [collisionPolicy, setCollisionPolicy] = useState
("fail");
const [dryRun, setDryRun] = useState(false);
+ const [saveManifest, setSaveManifest] = useState(false);
const [resumeManifestPath, setResumeManifestPath] = useState("");
const [error, setError] = useState("");
const browseLoad = async () => {
const selected = await window.electronAPI.openDirectory();
if (selected) setLoadPath(selected);
};
+ const browseImage = async () => {
+ const selected = await window.electronAPI.openFile({
+ filters: [{ name: "Supported images", extensions: IMAGE_EXTENSIONS }],
+ });
+ if (selected) setLoadPath(selected);
+ };
const browseSave = async () => {
const selected = await window.electronAPI.openDirectory();
if (selected) setSavePath(selected);
@@ -35,6 +43,7 @@ export function RenamePanel({ isBackendOnline }: { isBackendOnline: boolean }) {
skip_duplicates: skipDuplicates,
collision_policy: collisionPolicy,
dry_run: dryRun,
+ save_manifest: saveManifest,
resume_manifest_path: resumeManifestPath || null,
});
} catch (caught) {
@@ -46,14 +55,14 @@ export function RenamePanel({ isBackendOnline }: { isBackendOnline: boolean }) {
- DETERMINISTIC RENAME Natural ordering, collision preflight, atomic copies, and resumable manifests
-
+ DETERMINISTIC RENAME Natural ordering, collision preflight, atomic copies, and optional resumable manifests
+
Naming mode setMode(event.target.value as typeof mode)} disabled={isActive} className="w-full border border-border bg-input px-4 py-3 text-sm">00001.jpg DSC_00001.jpg
Number width setZeroPad(Number(event.target.value))} disabled={isActive} className="w-full border border-border bg-input px-4 py-3 text-sm" />
setSkipDuplicates(event.target.checked)} disabled={isActive} className="h-4 w-4 accent-primary" />Skip visually duplicate images
-
+
{error && {error}
}
{dryRun ? "Create manifest" : "Start rename"} Cancel
diff --git a/src/components/trainkit/panels/tag-panel.tsx b/src/components/trainkit/panels/tag-panel.tsx
index e23b5e3..4367bd1 100644
--- a/src/components/trainkit/panels/tag-panel.tsx
+++ b/src/components/trainkit/panels/tag-panel.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Play, Square, Tags } from "lucide-react";
+import { IMAGE_EXTENSIONS } from "@/lib/config";
import { useJobOperation } from "@/lib/use-job-operation";
import type { CollisionPolicy } from "@/types/contracts";
import { BatchOptions, Button, Input, JobProgress, ModelStatus } from "../shared";
@@ -16,6 +17,7 @@ export function TagPanel({ isBackendOnline }: { isBackendOnline: boolean }) {
const [output, setOutput] = useState<"json" | "txt" | "both">("both");
const [collisionPolicy, setCollisionPolicy] = useState("fail");
const [dryRun, setDryRun] = useState(false);
+ const [saveManifest, setSaveManifest] = useState(false);
const [resumeManifestPath, setResumeManifestPath] = useState("");
const [error, setError] = useState("");
useEffect(() => {
@@ -37,6 +39,12 @@ export function TagPanel({ isBackendOnline }: { isBackendOnline: boolean }) {
const selected = await window.electronAPI.openDirectory();
if (selected) setter(selected);
};
+ const chooseImage = async () => {
+ const selected = await window.electronAPI.openFile({
+ filters: [{ name: "Supported images", extensions: IMAGE_EXTENSIONS }],
+ });
+ if (selected) setLoadPath(selected);
+ };
const run = async () => {
setError("");
try {
@@ -49,6 +57,7 @@ export function TagPanel({ isBackendOnline }: { isBackendOnline: boolean }) {
output,
collision_policy: collisionPolicy,
dry_run: dryRun,
+ save_manifest: saveManifest,
resume_manifest_path: resumeManifestPath || null,
});
} catch (caught) {
@@ -63,13 +72,13 @@ export function TagPanel({ isBackendOnline }: { isBackendOnline: boolean }) {
IMAGE TAGGING Local Hugging Face classifiers with scored JSON and training-text sidecars
chooseDirectory(setModelPath)} disabled={isActive} />
- chooseDirectory(setLoadPath)} disabled={isActive} /> chooseDirectory(setSavePath)} disabled={isActive} />
+ chooseDirectory(setLoadPath)} onBrowseFile={chooseImage} disabled={isActive} /> chooseDirectory(setSavePath)} disabled={isActive} />
Threshold setThreshold(Number(event.target.value))} className="w-full border border-border bg-input px-3 py-2 text-sm" />
Top K setTopK(Number(event.target.value))} className="w-full border border-border bg-input px-3 py-2 text-sm" />
Sidecars setOutput(event.target.value as typeof output)} className="w-full border border-border bg-input px-3 py-2 text-sm">JSON + TXT JSON TXT
-
+
{error && {error}
}
{dryRun ? "Create manifest" : "Start tagging"} Cancel
diff --git a/src/components/trainkit/panels/upscale-panel.tsx b/src/components/trainkit/panels/upscale-panel.tsx
index 85afbc0..ceaa475 100644
--- a/src/components/trainkit/panels/upscale-panel.tsx
+++ b/src/components/trainkit/panels/upscale-panel.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Cpu, Maximize2, Play, Search, Square } from "lucide-react";
import { apiRequest } from "@/lib/api";
+import { IMAGE_EXTENSIONS } from "@/lib/config";
import { useJobOperation } from "@/lib/use-job-operation";
import type { CollisionPolicy } from "@/types/contracts";
import { BatchOptions, Button, Input, JobProgress, ModelStatus } from "../shared";
@@ -14,7 +15,14 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
const [modelPath, setModelPath] = useState("");
const [modelBinPath, setModelBinPath] = useState("");
const [modelValid, setModelValid] = useState(false);
- const [modelInfo, setModelInfo] = useState<{ scale: number; architecture: string; name: string; vulkan?: boolean } | null>(null);
+ const [modelInfo, setModelInfo] = useState<{
+ scale: number;
+ architecture: string;
+ name: string;
+ vulkan?: boolean;
+ input_blob?: string;
+ output_blob?: string;
+ } | null>(null);
const [inspecting, setInspecting] = useState(false);
const [loadPath, setLoadPath] = useState("");
const [savePath, setSavePath] = useState("");
@@ -22,12 +30,13 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
const [useTiling, setUseTiling] = useState(true);
const [tileSize, setTileSize] = useState(512);
const [tileOverlap, setTileOverlap] = useState(16);
- const [inputBlob, setInputBlob] = useState("in0");
- const [outputBlob, setOutputBlob] = useState("out0");
+ const [inputBlob, setInputBlob] = useState("");
+ const [outputBlob, setOutputBlob] = useState("");
const [ncnnScale, setNcnnScale] = useState(4);
const [useVulkan, setUseVulkan] = useState(true);
const [collisionPolicy, setCollisionPolicy] = useState("fail");
const [dryRun, setDryRun] = useState(false);
+ const [saveManifest, setSaveManifest] = useState(false);
const [resumeManifestPath, setResumeManifestPath] = useState("");
const [error, setError] = useState("");
@@ -35,6 +44,8 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
setModelPath(value);
setModelValid(false);
setModelInfo(null);
+ setInputBlob("");
+ setOutputBlob("");
};
const browseModel = async () => {
@@ -61,6 +72,12 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
const selected = await window.electronAPI.openDirectory();
if (selected) setLoadPath(selected);
};
+ const browseImage = async () => {
+ const selected = await window.electronAPI.openFile({
+ filters: [{ name: "Supported images", extensions: IMAGE_EXTENSIONS }],
+ });
+ if (selected) setLoadPath(selected);
+ };
const browseSave = async () => {
const selected = await window.electronAPI.openDirectory();
if (selected) setSavePath(selected);
@@ -75,8 +92,8 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
model_path: modelPath,
backend,
model_bin_path: modelBinPath || null,
- input_blob: inputBlob,
- output_blob: outputBlob,
+ input_blob: inputBlob.trim() || null,
+ output_blob: outputBlob.trim() || null,
scale: ncnnScale,
use_vulkan: useVulkan,
},
@@ -95,8 +112,8 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
upscale_model_path: modelPath,
backend,
ncnn_model_bin_path: modelBinPath || null,
- ncnn_input_blob: inputBlob,
- ncnn_output_blob: outputBlob,
+ ncnn_input_blob: inputBlob.trim() || null,
+ ncnn_output_blob: outputBlob.trim() || null,
ncnn_scale: ncnnScale,
ncnn_use_vulkan: useVulkan,
load_path: loadPath,
@@ -107,6 +124,7 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
tile_overlap: tileOverlap,
collision_policy: collisionPolicy,
dry_run: dryRun,
+ save_manifest: saveManifest,
resume_manifest_path: resumeManifestPath || null,
});
} catch (caught) {
@@ -118,7 +136,6 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
modelValid &&
loadPath &&
savePath &&
- (backend === "spandrel" || modelBinPath) &&
!isActive;
return (
@@ -126,30 +143,30 @@ export function UpscalePanel({ isBackendOnline }: { isBackendOnline: boolean })
IMAGE UPSCALING Safe Spandrel descriptors or NCNN CPU/Vulkan inference
- Backend { setBackend(event.target.value as UpscaleBackend); setModelPath(""); setModelBinPath(""); setModelValid(false); setModelInfo(null); }} disabled={isActive} className="w-full border border-border bg-input px-4 py-3 text-sm">Spandrel NCNN Python
+ Backend { setBackend(event.target.value as UpscaleBackend); setModelPath(""); setModelBinPath(""); setInputBlob(""); setOutputBlob(""); setModelValid(false); setModelInfo(null); }} disabled={isActive} className="w-full border border-border bg-input px-4 py-3 text-sm">Spandrel NCNN Python
{backend === "ncnn" && (
)}
Inspect model
-
-
+
+
Output format setFormat(event.target.value as typeof format)} className="w-full border border-border bg-input px-3 py-2 text-sm">PNG JPEG WebP BMP
Tile size setTileSize(Number(event.target.value))} className="w-full border border-border bg-input px-3 py-2 text-sm" />
Overlap setTileOverlap(Number(event.target.value))} className="w-full border border-border bg-input px-3 py-2 text-sm" />
setUseTiling(event.target.checked)} className="h-4 w-4 accent-primary" /> Tile large images to limit memory
-
+
{error && {error}
}
{dryRun ? "Create manifest" : "Start upscaling"} Cancel
diff --git a/src/components/trainkit/shared/BatchOptions.tsx b/src/components/trainkit/shared/BatchOptions.tsx
index 9d6fbeb..b2238a1 100644
--- a/src/components/trainkit/shared/BatchOptions.tsx
+++ b/src/components/trainkit/shared/BatchOptions.tsx
@@ -6,6 +6,8 @@ interface BatchOptionsProps {
onCollisionPolicyChange: (value: CollisionPolicy) => void;
dryRun: boolean;
onDryRunChange: (value: boolean) => void;
+ saveManifest: boolean;
+ onSaveManifestChange: (value: boolean) => void;
resumeManifestPath: string;
onResumeManifestPathChange: (value: string) => void;
disabled?: boolean;
@@ -16,6 +18,8 @@ export function BatchOptions({
onCollisionPolicyChange,
dryRun,
onDryRunChange,
+ saveManifest,
+ onSaveManifestChange,
resumeManifestPath,
onResumeManifestPathChange,
disabled = false,
@@ -47,16 +51,28 @@ export function BatchOptions({
Overwrite atomically
-
- onDryRunChange(event.target.checked)}
- className="h-4 w-4 accent-primary"
- />
- Dry run: write manifest only
-
+
+
+ onDryRunChange(event.target.checked)}
+ className="h-4 w-4 accent-primary"
+ />
+ Dry run: write manifest only
+
+
+ onSaveManifestChange(event.target.checked)}
+ className="h-4 w-4 accent-primary"
+ />
+ Save resumable manifest
+
+
+
+ {dryRun
+ ? "Dry runs always save their plan as a manifest."
+ : resumeManifestPath
+ ? "The selected resume manifest will be updated during this run."
+ : saveManifest
+ ? "Progress will be saved so this run can be resumed later."
+ : "No manifest file will be written for this run."}
+
);
}
diff --git a/src/components/trainkit/shared/ImagePreview.tsx b/src/components/trainkit/shared/ImagePreview.tsx
index e99f906..f364dbf 100644
--- a/src/components/trainkit/shared/ImagePreview.tsx
+++ b/src/components/trainkit/shared/ImagePreview.tsx
@@ -147,7 +147,7 @@ export const ImagePreview = memo(function ImagePreview({
>
- Select a directory to preview images
+ Select an image or folder to preview
);
@@ -189,7 +189,7 @@ export const ImagePreview = memo(function ImagePreview({
>
- No images found in directory
+ No supported images found
);
diff --git a/src/components/trainkit/shared/Input.tsx b/src/components/trainkit/shared/Input.tsx
index 22797f1..102d228 100644
--- a/src/components/trainkit/shared/Input.tsx
+++ b/src/components/trainkit/shared/Input.tsx
@@ -1,6 +1,6 @@
import { memo } from "react";
import { cn } from "@/lib/utils";
-import { Folder } from "lucide-react";
+import { FileImage, Folder } from "lucide-react";
interface InputProps {
label: string;
@@ -9,6 +9,7 @@ interface InputProps {
placeholder?: string;
type?: "text" | "path";
onBrowse?: () => void;
+ onBrowseFile?: () => void;
disabled?: boolean;
}
@@ -19,6 +20,7 @@ export const Input = memo(function Input({
placeholder,
type = "text",
onBrowse,
+ onBrowseFile,
disabled = false,
}: InputProps) {
return (
@@ -47,25 +49,49 @@ export const Input = memo(function Input({
"transition-all duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
type === "path" && "font-mono text-xs",
- type === "path" && onBrowse && "pr-12",
+ type === "path" && (onBrowse || onBrowseFile) && "pr-12",
+ type === "path" && onBrowse && onBrowseFile && "pr-20",
)}
/>
- {type === "path" && onBrowse && (
-
+ {onBrowse && (
+
+
+
)}
- >
-
-
+ {onBrowseFile && (
+
+
+
+ )}
+
)}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 3de5904..9c11f92 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,4 +1,5 @@
export const SPANDREL_MODEL_EXTENSIONS = ["safetensors"];
export const NCNN_MODEL_EXTENSIONS = ["param"];
+export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "bmp", "webp"];
export const OUTPUT_FORMATS = ["JPG", "PNG", "BMP", "WebP"] as const;
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
diff --git a/src/main.ts b/src/main.ts
index edcb420..69cb9a2 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,13 +9,10 @@ import {
} from "electron";
import path from "node:path";
import fs from "node:fs";
-import started from "electron-squirrel-startup";
import { getBackendManager, BackendManager } from "./backend-manager";
import { getSetupManager, type SetupProgress } from "./setup-manager";
import { getLogger, closeLogger } from "./logger";
-if (started) app.quit();
-
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
@@ -382,13 +379,20 @@ ipcMain.handle("fs:isValidModelFolder", async (event, folderPath: string) => {
return { valid: false };
}
});
-ipcMain.handle("fs:listImages", async (event, directoryPath: string) => {
+ipcMain.handle("fs:listImages", async (event, sourcePath: string) => {
trustedSender(event);
- if (!isGranted(directoryPath)) return [];
+ if (!isGranted(sourcePath)) return [];
try {
- return (await fs.promises.readdir(directoryPath))
+ const stats = await fs.promises.stat(sourcePath);
+ if (stats.isFile()) {
+ return IMAGE_EXTENSIONS.has(path.extname(sourcePath).toLowerCase())
+ ? [sourcePath]
+ : [];
+ }
+ if (!stats.isDirectory()) return [];
+ return (await fs.promises.readdir(sourcePath))
.filter((file) => IMAGE_EXTENSIONS.has(path.extname(file).toLowerCase()))
- .map((file) => path.join(directoryPath, file))
+ .map((file) => path.join(sourcePath, file))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
} catch {
return [];
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index d35ad07..ff5c5a4 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -57,7 +57,7 @@ export interface ElectronAPI {
isValidModelFolder: (
path: string,
) => Promise<{ valid: boolean; name?: string }>;
- listImages: (directoryPath: string) => Promise;
+ listImages: (sourcePath: string) => Promise;
readImageAsDataUrl: (imagePath: string) => Promise;
openExternal: (url: string) => Promise;
openLogFile: () => Promise;