argus-proof 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. argus_proof/__init__.py +16 -0
  2. argus_proof/_version.py +24 -0
  3. argus_proof/acceptance.py +118 -0
  4. argus_proof/archive.py +168 -0
  5. argus_proof/backends/__init__.py +67 -0
  6. argus_proof/backends/a1111.py +150 -0
  7. argus_proof/backends/base.py +170 -0
  8. argus_proof/backends/comfyui.py +228 -0
  9. argus_proof/backends/diffusers.py +256 -0
  10. argus_proof/backends/http.py +94 -0
  11. argus_proof/backends/pnginfo.py +73 -0
  12. argus_proof/backends/remote.py +157 -0
  13. argus_proof/backends/workflow.py +144 -0
  14. argus_proof/cli.py +467 -0
  15. argus_proof/crossrun.py +341 -0
  16. argus_proof/evaluate.py +269 -0
  17. argus_proof/experiment.py +314 -0
  18. argus_proof/explore.py +283 -0
  19. argus_proof/grid.py +324 -0
  20. argus_proof/hashing.py +37 -0
  21. argus_proof/models.py +661 -0
  22. argus_proof/moderation.py +356 -0
  23. argus_proof/py.typed +1 -0
  24. argus_proof/recommend.py +280 -0
  25. argus_proof/refinement.py +117 -0
  26. argus_proof/reports.py +258 -0
  27. argus_proof/scoring/__init__.py +33 -0
  28. argus_proof/scoring/base.py +94 -0
  29. argus_proof/scoring/gate.py +66 -0
  30. argus_proof/scoring/orchestrator.py +135 -0
  31. argus_proof/scoring/scorers/__init__.py +42 -0
  32. argus_proof/scoring/scorers/_util.py +84 -0
  33. argus_proof/scoring/scorers/identity.py +156 -0
  34. argus_proof/scoring/scorers/phash.py +138 -0
  35. argus_proof/scoring/scorers/quality.py +196 -0
  36. argus_proof/scoring/scorers/safety.py +144 -0
  37. argus_proof/scoring/summary.py +111 -0
  38. argus_proof/server/__init__.py +5 -0
  39. argus_proof/server/app.py +451 -0
  40. argus_proof/stats.py +83 -0
  41. argus_proof/templates/comfyui_sdxl_lora.json +55 -0
  42. argus_proof-0.2.0.dist-info/METADATA +456 -0
  43. argus_proof-0.2.0.dist-info/RECORD +46 -0
  44. argus_proof-0.2.0.dist-info/WHEEL +4 -0
  45. argus_proof-0.2.0.dist-info/entry_points.txt +2 -0
  46. argus_proof-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,16 @@
1
+ """argus-proof — Post-training LoRA evaluation and optimisation: generate samples from a trained LoRA and score them against the curated dataset it was trained from"""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ # Written by hatch-vcs at build time (see pyproject [tool.hatch.build.hooks.vcs]).
7
+ from argus_proof._version import __version__
8
+ except ImportError: # running from a source checkout that hasn't been built
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("argus-proof")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,118 @@
1
+ """The CI acceptance gate (#12): a machine-readable pass/fail on an EvalReport.
2
+
3
+ Closes the loop for CI — "was this dataset/LoRA good enough?" becomes an
4
+ automatable yes/no against declared :class:`~argus_proof.models.AcceptanceThresholds`
5
+ (a min pass-rate, a statistically-defensible pass-rate lower bound, an identity
6
+ floor, a safety ceiling). Only the configured checks run; the run is accepted
7
+ iff every check passes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from argus_proof.models import (
13
+ AcceptanceThresholds,
14
+ EvalReport,
15
+ GateCheck,
16
+ GateResult,
17
+ )
18
+ from argus_proof.stats import wilson_lower_bound
19
+
20
+
21
+ def _unsafe_rate(report: EvalReport, safety_floor: float) -> float:
22
+ """Fraction of images that are unsafe.
23
+
24
+ An image counts as unsafe if its ``safety`` metric is below *safety_floor*
25
+ OR it carries an ``unsafe`` reject reason. Keying off the metric (not only
26
+ the reject reason) matters: the ``unsafe`` reason is only produced when the
27
+ gate has a ``safety`` hard gate, so a run scored with the default gate could
28
+ otherwise report a 0% unsafe rate while containing unsafe imagery.
29
+ """
30
+ n = len(report.images)
31
+ if n == 0:
32
+ return 0.0
33
+ unsafe = sum(
34
+ 1
35
+ for img in report.images
36
+ if (img.metrics.safety is not None and img.metrics.safety < safety_floor)
37
+ or any(r.code == "unsafe" for r in img.reject_reasons)
38
+ )
39
+ return unsafe / n
40
+
41
+
42
+ def evaluate_acceptance(report: EvalReport, thresholds: AcceptanceThresholds) -> GateResult:
43
+ """Evaluate *report* against *thresholds* → a :class:`GateResult`.
44
+
45
+ A configured metric that wasn't measured (e.g. ``min_identity_mean`` with no
46
+ identity scorer) fails its check rather than passing silently — you can't
47
+ accept on evidence you don't have.
48
+ """
49
+ checks: list[GateCheck] = []
50
+ agg = report.aggregate
51
+ # The pass-rate is over near-dup groups when deduped, else over images.
52
+ n = agg.n_groups if agg.n_groups is not None else agg.n_images
53
+ n_passed = min(agg.n_passed, n) # defensive: never let successes exceed the denominator
54
+
55
+ if thresholds.min_pass_rate is not None:
56
+ ok = agg.pass_rate >= thresholds.min_pass_rate
57
+ checks.append(
58
+ GateCheck(
59
+ name="pass_rate",
60
+ passed=ok,
61
+ actual=agg.pass_rate,
62
+ threshold=thresholds.min_pass_rate,
63
+ detail=f"pass_rate {agg.pass_rate:.3f} {'>=' if ok else '<'} {thresholds.min_pass_rate:.3f}",
64
+ )
65
+ )
66
+
67
+ if thresholds.min_pass_rate_ci_lower is not None:
68
+ lb = wilson_lower_bound(n_passed, n, thresholds.confidence)
69
+ ok = lb >= thresholds.min_pass_rate_ci_lower
70
+ checks.append(
71
+ GateCheck(
72
+ name="pass_rate_ci_lower",
73
+ passed=ok,
74
+ actual=lb,
75
+ threshold=thresholds.min_pass_rate_ci_lower,
76
+ detail=(
77
+ f"{thresholds.confidence:.0%} Wilson lower bound {lb:.3f} "
78
+ f"({n_passed}/{n}) {'>=' if ok else '<'} {thresholds.min_pass_rate_ci_lower:.3f}"
79
+ ),
80
+ )
81
+ )
82
+
83
+ if thresholds.min_identity_mean is not None:
84
+ val = agg.means.identity
85
+ ok = val is not None and val >= thresholds.min_identity_mean
86
+ detail = (
87
+ "identity not measured"
88
+ if val is None
89
+ else f"identity mean {val:.3f} {'>=' if ok else '<'} {thresholds.min_identity_mean:.3f}"
90
+ )
91
+ checks.append(
92
+ GateCheck(
93
+ name="identity_mean", passed=ok, actual=val, threshold=thresholds.min_identity_mean, detail=detail
94
+ )
95
+ )
96
+
97
+ if thresholds.max_unsafe_rate is not None:
98
+ rate = _unsafe_rate(report, thresholds.unsafe_safety_floor)
99
+ ok = rate <= thresholds.max_unsafe_rate
100
+ checks.append(
101
+ GateCheck(
102
+ name="unsafe_rate",
103
+ passed=ok,
104
+ actual=rate,
105
+ threshold=thresholds.max_unsafe_rate,
106
+ detail=f"unsafe rate {rate:.3f} {'<=' if ok else '>'} {thresholds.max_unsafe_rate:.3f}",
107
+ )
108
+ )
109
+
110
+ if not checks:
111
+ # No thresholds configured -> nothing was verified. Refuse rather than
112
+ # accept on zero evidence (all([]) would otherwise be True).
113
+ return GateResult(
114
+ passed=False,
115
+ checks=[GateCheck(name="no_checks", passed=False, detail="no acceptance thresholds configured")],
116
+ )
117
+
118
+ return GateResult(passed=all(c.passed for c in checks), checks=checks)
argus_proof/archive.py ADDED
@@ -0,0 +1,168 @@
1
+ """Metadata-only archival of a reviewed run (#9).
2
+
3
+ Keep the analytical value of a run without hoarding imagery: after review, a run
4
+ directory is reduced to
5
+
6
+ * ``manifest.json`` + ``eval_report.json`` — the run's params and scores;
7
+ * ``passing_images.zip`` — the images that passed, retained zipped;
8
+ * ``reject_archive.json`` — a :class:`~argus_proof.models.RejectArchive`: params
9
+ (the embedded :class:`~argus_proof.models.RunManifest`) + scores + structured
10
+ reject reasons + rater id for every rejected image, and **no image or
11
+ thumbnail reference** (explicit product decision — the metadata is the useful
12
+ signal for diagnosing failure modes and could later train an auto-classifier).
13
+
14
+ With ``blank_slate=True`` the loose per-image files that were archived (passing →
15
+ zipped, rejected → recorded) are deleted, leaving only the artifacts above.
16
+ Images still awaiting review (``passed is None``) are left untouched. Every
17
+ record joins back to its run by ``run_id``, so a reject is traceable to its exact
18
+ config without the pixels.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import zipfile
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ import structlog
28
+
29
+ from argus_proof.models import (
30
+ EvalReport,
31
+ GeneratedImage,
32
+ RejectArchive,
33
+ RejectRecord,
34
+ RunManifest,
35
+ )
36
+
37
+ logger = structlog.get_logger()
38
+
39
+ # Version of the on-disk archive layout (the set/naming of artifacts below).
40
+ # Bump if the directory contract changes so consumers can refuse an old layout.
41
+ ARCHIVE_VERSION = "1.0"
42
+
43
+ MANIFEST_NAME = "manifest.json"
44
+ REPORT_NAME = "eval_report.json"
45
+ PASSING_ZIP_NAME = "passing_images.zip"
46
+ REJECT_ARCHIVE_NAME = "reject_archive.json"
47
+
48
+
49
+ @dataclass
50
+ class ArchiveResult:
51
+ """What :func:`archive_run` produced: counts + the artifact paths."""
52
+
53
+ run_id: str
54
+ n_passing: int
55
+ n_rejected: int
56
+ n_pending: int # images left for review (passed is None)
57
+ passing_zip: Path | None
58
+ reject_archive_path: Path
59
+ deleted_images: list[str] = field(default_factory=list)
60
+
61
+
62
+ def build_reject_archive(report: EvalReport, manifest: RunManifest, *, rater_id: str | None = None) -> RejectArchive:
63
+ """A :class:`RejectArchive` of the report's rejected images (``passed is False``).
64
+
65
+ Embeds *manifest* (keyed by ``run_id``) so the archive is self-describing, and
66
+ stamps ``rater_id`` on each record when a human reviewed the run. Carries zero
67
+ image references by construction.
68
+ """
69
+ records = [
70
+ RejectRecord(
71
+ run_id=report.run_id,
72
+ seed=img.seed,
73
+ metrics=img.metrics,
74
+ hitl_rating=img.hitl_rating,
75
+ reasons=img.reject_reasons,
76
+ rater_id=rater_id,
77
+ )
78
+ for img in report.images
79
+ if img.passed is False
80
+ ]
81
+ return RejectArchive(manifests={manifest.run_id: manifest}, records=records)
82
+
83
+
84
+ def archive_run(
85
+ run_dir: Path,
86
+ images: list[GeneratedImage],
87
+ report: EvalReport,
88
+ manifest: RunManifest,
89
+ *,
90
+ rater_id: str | None = None,
91
+ blank_slate: bool = True,
92
+ ) -> ArchiveResult:
93
+ """Reduce a reviewed *run_dir* to metadata + a zip of the passing images.
94
+
95
+ *images* supplies the on-disk paths (joined to *report* by ``image_id``).
96
+ Writes ``manifest.json``, ``eval_report.json``, ``passing_images.zip``, and
97
+ ``reject_archive.json``; with ``blank_slate`` deletes the loose image files
98
+ that were archived (passing + rejected), leaving pending images in place.
99
+ """
100
+ run_dir.mkdir(parents=True, exist_ok=True)
101
+ verdict_by_id = {img.image_id: img.passed for img in report.images}
102
+ by_id = {img.image_id: img for img in images}
103
+
104
+ (run_dir / MANIFEST_NAME).write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
105
+ (run_dir / REPORT_NAME).write_text(report.model_dump_json(indent=2), encoding="utf-8")
106
+
107
+ reject_archive = build_reject_archive(report, manifest, rater_id=rater_id)
108
+ reject_path = run_dir / REJECT_ARCHIVE_NAME
109
+ reject_path.write_text(reject_archive.model_dump_json(indent=2), encoding="utf-8")
110
+
111
+ # Warn about report images with no matching file — they can't be archived.
112
+ missing = [i.image_id for i in report.images if i.passed is not None and i.image_id not in by_id]
113
+ if missing:
114
+ logger.warning("archive.missing_images", run_id=report.run_id, image_ids=missing)
115
+
116
+ # Zip the passing images (that still exist on disk). Arcname is the unique
117
+ # image_id (+ suffix) so two passing images that share a basename in
118
+ # different dirs don't collide and silently drop one.
119
+ passing = [img for img in images if verdict_by_id.get(img.image_id) is True]
120
+ passing_zip: Path | None = None
121
+ if passing:
122
+ passing_zip = run_dir / PASSING_ZIP_NAME
123
+ with zipfile.ZipFile(passing_zip, "w", zipfile.ZIP_DEFLATED) as zf:
124
+ for img in passing:
125
+ src = Path(img.path)
126
+ if src.is_file():
127
+ zf.write(src, arcname=f"{img.image_id}{src.suffix}")
128
+
129
+ run_resolved = run_dir.resolve()
130
+ n_pending = sum(1 for img in report.images if img.passed is None)
131
+ deleted: list[str] = []
132
+ if blank_slate:
133
+ # Delete the loose files that are now archived: passing (in the zip) and
134
+ # rejected (recorded as metadata). Pending images are left for review.
135
+ # Only touch files inside run_dir — never delete an image the run doesn't own.
136
+ for image_id, passed in verdict_by_id.items():
137
+ if passed is None:
138
+ continue # not yet reviewed
139
+ img = by_id.get(image_id)
140
+ if img is None:
141
+ continue
142
+ src = Path(img.path)
143
+ if not src.is_file():
144
+ continue
145
+ if not src.resolve().is_relative_to(run_resolved):
146
+ logger.warning("archive.skip_delete_outside_run_dir", run_id=report.run_id, path=str(src))
147
+ continue
148
+ src.unlink()
149
+ deleted.append(src.name)
150
+
151
+ result = ArchiveResult(
152
+ run_id=report.run_id,
153
+ n_passing=len(passing),
154
+ n_rejected=len(reject_archive.records),
155
+ n_pending=n_pending,
156
+ passing_zip=passing_zip,
157
+ reject_archive_path=reject_path,
158
+ deleted_images=deleted,
159
+ )
160
+ logger.info(
161
+ "archive.run",
162
+ run_id=result.run_id,
163
+ passing=result.n_passing,
164
+ rejected=result.n_rejected,
165
+ pending=result.n_pending,
166
+ blank_slate=blank_slate,
167
+ )
168
+ return result
@@ -0,0 +1,67 @@
1
+ """Generation backends — one module per engine, selected by name (config).
2
+
3
+ Swapping the engine that generates the eval grid is a config change, not a code
4
+ change: a caller names a backend and constructs it through :func:`get_backend`,
5
+ and scoring/report code is unchanged regardless of which one produced the run.
6
+
7
+ * ``comfyui`` — a running ComfyUI instance (the first adapter).
8
+ * ``diffusers`` — in-process diffusers SDXL pipeline (deterministic, no service).
9
+ * ``a1111`` — an AUTOMATIC1111 / SD.Next ``/sdapi`` server.
10
+ * ``remote`` — a hosted/cloud endpoint that speaks the proof wire.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from argus_proof.backends.a1111 import A1111Backend
16
+ from argus_proof.backends.base import (
17
+ BackendError,
18
+ GenBackend,
19
+ GenResult,
20
+ ModelResolver,
21
+ ProgressSink,
22
+ build_local_manifest,
23
+ hash_model,
24
+ )
25
+ from argus_proof.backends.comfyui import ComfyUIBackend
26
+ from argus_proof.backends.diffusers import DiffusersBackend
27
+ from argus_proof.backends.remote import RemoteBackend
28
+
29
+ # The backends this build knows how to construct, by name.
30
+ _BACKENDS: dict[str, type] = {
31
+ "comfyui": ComfyUIBackend,
32
+ "diffusers": DiffusersBackend,
33
+ "a1111": A1111Backend,
34
+ "remote": RemoteBackend,
35
+ }
36
+ KNOWN_BACKENDS: tuple[str, ...] = tuple(_BACKENDS)
37
+
38
+
39
+ def get_backend(name: str, **kwargs: object) -> GenBackend:
40
+ """Construct the backend called *name*, forwarding ``kwargs`` to it.
41
+
42
+ Raises :class:`BackendError` for an unknown name so a bad config fails
43
+ loudly instead of silently doing nothing.
44
+ """
45
+ try:
46
+ backend_cls = _BACKENDS[name]
47
+ except KeyError:
48
+ known = ", ".join(KNOWN_BACKENDS)
49
+ raise BackendError(f"unknown generation backend {name!r} (known: {known})") from None
50
+ return backend_cls(**kwargs) # type: ignore[arg-type]
51
+
52
+
53
+ __all__ = [
54
+ "KNOWN_BACKENDS",
55
+ "A1111Backend",
56
+ "BackendError",
57
+ "ComfyUIBackend",
58
+ "DiffusersBackend",
59
+ "GenBackend",
60
+ "GenResult",
61
+ "ModelResolver",
62
+ "ProgressSink",
63
+ "RemoteBackend",
64
+ "build_local_manifest",
65
+ "get_backend",
66
+ "hash_model",
67
+ ]
@@ -0,0 +1,150 @@
1
+ """AUTOMATIC1111 / SD.Next generation backend — drives their ``/sdapi`` HTTP API.
2
+
3
+ Submits a ``txt2img`` request per seed, decodes the base64 PNG(s) it returns, and
4
+ emits a reproducible :class:`~argus_proof.models.RunManifest`. The base checkpoint
5
+ is selected per-request via ``override_settings`` and LoRA(s) via the A1111
6
+ ``<lora:name:weight>`` prompt syntax. Model files are SHA256-pinned from disk (via
7
+ ``resolve_model``), so this targets an A1111/SD.Next whose models are locally
8
+ resolvable — the manifest reconstructs the run exactly.
9
+
10
+ The HTTP layer is the shared injectable :class:`~argus_proof.backends.http.Transport`,
11
+ so the adapter is unit-testable without a live A1111.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import structlog
19
+
20
+ from argus_proof.backends.base import (
21
+ BackendError,
22
+ GenResult,
23
+ ModelResolver,
24
+ ProgressSink,
25
+ build_local_manifest,
26
+ write_manifest,
27
+ )
28
+ from argus_proof.backends.http import Transport, UrllibTransport, decode_base64_image
29
+ from argus_proof.backends.pnginfo import read_dimensions
30
+ from argus_proof.models import BackendCapabilities, GeneratedImage, ProgressEvent, RunSpec
31
+
32
+ logger = structlog.get_logger()
33
+
34
+ BACKEND_NAME = "a1111"
35
+ DEFAULT_BASE_URL = "http://127.0.0.1:7860"
36
+
37
+
38
+ class A1111Backend:
39
+ """Generate images through an AUTOMATIC1111 / SD.Next ``/sdapi`` server.
40
+
41
+ ``resolve_model`` hashes the base checkpoint + LoRA files into the manifest;
42
+ ``transport`` defaults to :class:`UrllibTransport` against ``base_url`` and is
43
+ injectable for testing. ``engine_version`` is recorded on the manifest (A1111
44
+ exposes no portable version endpoint); pass it to pin the server build.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ resolve_model: ModelResolver,
50
+ base_url: str = DEFAULT_BASE_URL,
51
+ transport: Transport | None = None,
52
+ engine_version: str | None = None,
53
+ timeout: float = 600.0,
54
+ ) -> None:
55
+ self.resolve_model = resolve_model
56
+ self.transport = transport or UrllibTransport(base_url, timeout=timeout, label="A1111")
57
+ self._engine_version = engine_version or "unknown"
58
+
59
+ def capabilities(self) -> BackendCapabilities:
60
+ return BackendCapabilities(
61
+ name=BACKEND_NAME,
62
+ supports_seed_set=True,
63
+ max_loras=None,
64
+ reads_pnginfo=False,
65
+ streams_progress=True,
66
+ )
67
+
68
+ def generate(self, spec: RunSpec, out_dir: Path, progress: ProgressSink | None = None) -> GenResult:
69
+ """Run *spec*, writing one image per seed (+ manifest.json) under *out_dir*."""
70
+ logger.debug("a1111.generate", run_id=spec.run_id, seeds=len(spec.seeds), out_dir=str(out_dir))
71
+ out_dir.mkdir(parents=True, exist_ok=True)
72
+
73
+ def emit(event: ProgressEvent) -> None:
74
+ if progress is not None:
75
+ progress(event)
76
+
77
+ total = len(spec.seeds)
78
+ emit(ProgressEvent(run_id=spec.run_id, type="start", total=total))
79
+
80
+ images: list[GeneratedImage] = []
81
+ try:
82
+ manifest = build_local_manifest(
83
+ spec, resolve_model=self.resolve_model, engine=BACKEND_NAME, engine_version=self._engine_version
84
+ )
85
+ for done, seed in enumerate(spec.seeds, start=1):
86
+ images.extend(self._generate_seed(spec, seed, out_dir, emit))
87
+ emit(ProgressEvent(run_id=spec.run_id, type="progress", completed=done, total=total))
88
+ except BackendError as exc:
89
+ emit(ProgressEvent(run_id=spec.run_id, type="error", message=str(exc)))
90
+ raise
91
+
92
+ write_manifest(out_dir, manifest)
93
+ emit(ProgressEvent(run_id=spec.run_id, type="done", completed=total, total=total))
94
+ return GenResult(manifest=manifest, images=images)
95
+
96
+ def _generate_seed(self, spec: RunSpec, seed: int, out_dir: Path, emit: ProgressSink) -> list[GeneratedImage]:
97
+ resp = self.transport.post_json("/sdapi/v1/txt2img", self._payload(spec, seed))
98
+ encoded = resp.get("images") or []
99
+ if not encoded:
100
+ raise BackendError(f"A1111 produced no images for seed {seed}")
101
+ produced: list[GeneratedImage] = []
102
+ for index, b64 in enumerate(encoded):
103
+ data = decode_base64_image(b64, context=f"A1111 seed {seed}")
104
+ suffix = "" if index == 0 else f"-{index}"
105
+ image_id = f"{spec.run_id}-{seed}{suffix}"
106
+ path = out_dir / f"{image_id}.png"
107
+ path.write_bytes(data)
108
+ dims = read_dimensions(data) or (spec.sampling.width, spec.sampling.height)
109
+ produced.append(
110
+ GeneratedImage(
111
+ image_id=image_id,
112
+ run_id=spec.run_id,
113
+ seed=seed,
114
+ path=str(path),
115
+ width=dims[0],
116
+ height=dims[1],
117
+ )
118
+ )
119
+ emit(ProgressEvent(run_id=spec.run_id, type="image", seed=seed, image_id=image_id))
120
+ return produced
121
+
122
+ def _payload(self, spec: RunSpec, seed: int) -> dict:
123
+ # A1111 applies LoRAs via the prompt: "<lora:name:weight>" — keep any
124
+ # subdirectory (with_suffix drops only the extension) so a nested LoRA
125
+ # (loras/char/subject.safetensors) resolves as "<lora:char/subject:...>".
126
+ lora_tags = "".join(f" <lora:{Path(lo.name).with_suffix('')}:{lo.weight}>" for lo in spec.loras)
127
+ # Pin the checkpoint, VAE, and clip-skip via override_settings so the run
128
+ # matches the manifest; restore_afterwards=False keeps the checkpoint loaded
129
+ # across the run's seeds instead of reloading it per request.
130
+ override: dict = {
131
+ "sd_model_checkpoint": spec.base_checkpoint,
132
+ "CLIP_stop_at_last_layers": spec.sampling.clip_skip,
133
+ }
134
+ if spec.vae:
135
+ override["sd_vae"] = spec.vae
136
+ return {
137
+ "prompt": spec.prompt + lora_tags,
138
+ "negative_prompt": spec.negative_prompt,
139
+ "steps": spec.sampling.steps,
140
+ "cfg_scale": spec.sampling.cfg,
141
+ "width": spec.sampling.width,
142
+ "height": spec.sampling.height,
143
+ "seed": seed,
144
+ "sampler_name": spec.sampling.sampler,
145
+ "scheduler": spec.sampling.scheduler,
146
+ "batch_size": 1,
147
+ "n_iter": 1,
148
+ "override_settings": override,
149
+ "override_settings_restore_afterwards": False,
150
+ }