Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

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

- 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.
- Missing packaged backend files are restored before startup; deleted environments are rebuilt without removing existing models.
- 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.
Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions backend/core/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)


Expand Down
2 changes: 2 additions & 0 deletions backend/models/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 6 additions & 1 deletion backend/service/image_captioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
22 changes: 18 additions & 4 deletions backend/service/image_tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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"}:
Expand All @@ -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)
Expand All @@ -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 ""
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_cloud_captioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
141 changes: 141 additions & 0 deletions backend/tests/test_output_preview.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 7 additions & 0 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ const config: ForgeConfig = {
...(windowsSign ? { windowsSign } : {}),
},
hooks: {
packageAfterCopy: async (_config, buildPath) => {
copyDirExclude(
resolve(process.cwd(), "backend"),
join(buildPath, ".vite", "build", "backend"),
["tests"],
);
},
postPackage: async (_config, options) => {
const backendSource = resolve(process.cwd(), "backend");
for (const outputPath of options.outputPaths) {
Expand Down
Loading
Loading