data-archetype commited on
Commit
ba06200
·
verified ·
1 Parent(s): 864fbd7

UI improvements, added live generation preview via latent linear probe

Browse files

Update the moving Canter code on main. Preserve the existing v0001 checkpoint and add the small standalone latent RGB preview projection.

API.md CHANGED
@@ -30,6 +30,7 @@ image = output.image
30
  | `config` | `CanterPipelineConfig()` | Inference and output settings. |
31
  | `initial_noise` | `None` | Optional float32 latent noise tensor with the configured batch and spatial shape. |
32
  | `progress` | `None` | Optional callback receiving completed and total solver updates. |
 
33
 
34
  `CanterPipeline` loads the flow-matching denoiser and text tokenizer with the
35
  bundled
@@ -117,6 +118,32 @@ output = pipe("A portrait lit by a large north-facing window", config=config)
117
  Every output also contains the descending float32 solver schedule in
118
  `output.schedule`.
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  ## Inference configuration
121
 
122
  ### Defaults
@@ -437,6 +464,7 @@ schedule = output.schedule
437
  | `config` | `CanterInferenceConfig()` | Latent inference settings. |
438
  | `initial_noise` | `None` | Optional float32 noise with shape `[batch, 128, height / 16, width / 16]`. |
439
  | `progress` | `None` | Optional callback receiving `(completed_updates, total_updates)`. |
 
440
 
441
  An empty or whitespace-only prompt uses the learned unconditional token without
442
  running the text encoder. Every prompt in a batch must be either blank or
 
30
  | `config` | `CanterPipelineConfig()` | Inference and output settings. |
31
  | `initial_noise` | `None` | Optional float32 latent noise tensor with the configured batch and spatial shape. |
32
  | `progress` | `None` | Optional callback receiving completed and total solver updates. |
33
+ | `preview` | `None` | Optional callback receiving asynchronous latent-RGB PIL images plus completed and total solver updates. |
34
 
35
  `CanterPipeline` loads the flow-matching denoiser and text tokenizer with the
36
  bundled
 
118
  Every output also contains the descending float32 solver schedule in
119
  `output.schedule`.
120
 
121
+ ## Live latent previews
122
+
123
+ ```python
124
+ def show_preview(images, completed, total):
125
+ images[0].show()
126
+
127
+
128
+ output = pipe(
129
+ "A lighthouse in a winter storm",
130
+ preview=show_preview,
131
+ )
132
+ ```
133
+
134
+ The bundled float32 linear projection maps the whitened 128-channel DINAC
135
+ latent directly to RGB at one eighth of the requested image size. Every solver
136
+ update is eligible by default. If the previous preview is still transferring,
137
+ converting, or running the callback, the new update is dropped instead of
138
+ blocking sampling. The callback receives this native one-eighth-size image;
139
+ the web UI lets the browser scale it for display.
140
+
141
+ Accepted previews perform only a 1×1 projection and pixel shuffle on CUDA.
142
+ The small RGB result is copied non-blockingly into pinned host memory. CUDA
143
+ event waiting, PIL conversion, and the application callback all run on a
144
+ single background worker. Every step is eligible; the one-worker busy-drop
145
+ policy supplies backpressure without a separate callback cap.
146
+
147
  ## Inference configuration
148
 
149
  ### Defaults
 
464
  | `config` | `CanterInferenceConfig()` | Latent inference settings. |
465
  | `initial_noise` | `None` | Optional float32 noise with shape `[batch, 128, height / 16, width / 16]`. |
466
  | `progress` | `None` | Optional callback receiving `(completed_updates, total_updates)`. |
467
+ | `state_callback` | `None` | Optional synchronous callback receiving `(state, completed_updates, total_updates)` after every solver update. Prefer pipeline previews when full CUDA latent access is not required. |
468
 
469
  An empty or whitespace-only prompt uses the learned unconditional token without
470
  running the text encoder. Every prompt in a batch must be either blank or
README.md CHANGED
@@ -99,6 +99,11 @@ python app.py --in-browser
99
  downloads the latest compatible DINAC-AE-D2 VAE.
100
  The interface appears immediately and reports model loading and pytorch dynamo compilation
101
  progress.
 
 
 
 
 
102
  Downloaded PNG files contain the prompt, effective per-image settings, Canter
103
  code version, and numbered checkpoint release as JSON metadata.
104
 
 
99
  downloads the latest compatible DINAC-AE-D2 VAE.
100
  The interface appears immediately and reports model loading and pytorch dynamo compilation
101
  progress.
102
+ During sampling, each output slot receives asynchronous previews from the
103
+ bundled one-eighth-scale latent-RGB projection. Busy preview work is skipped,
104
+ so the sampler never waits for browser publication. The native one-eighth-size
105
+ preview is sent directly and scaled for display by the browser. The UI checkbox
106
+ below the size preset disables previews.
107
  Downloaded PNG files contain the prompt, effective per-image settings, Canter
108
  code version, and numbered checkpoint release as JSON metadata.
109
 
RELEASES.md CHANGED
@@ -6,6 +6,13 @@
6
  Checkpoint tags are immutable snapshots. The installed package supplies the
7
  inference code, so current code can load any compatible checkpoint tag without
8
  executing the Python files stored in that historical snapshot.
 
 
 
 
 
 
 
9
 
10
  | Release | Date | Weight storage | Status |
11
  | --- | --- | --- | --- |
 
6
  Checkpoint tags are immutable snapshots. The installed package supplies the
7
  inference code, so current code can load any compatible checkpoint tag without
8
  executing the Python files stored in that historical snapshot.
9
+ The current package also supplies the small latent-RGB preview projection;
10
+ preview behavior therefore follows the installed code rather than changing an
11
+ older denoiser checkpoint tag.
12
+
13
+ Canter `0.3.0` adds asynchronous native-resolution latent previews and the
14
+ responsive preview grid. The browser scales previews for display; final
15
+ decoded images remain full resolution.
16
 
17
  | Release | Date | Weight storage | Status |
18
  | --- | --- | --- | --- |
canter/__init__.py CHANGED
@@ -30,8 +30,9 @@ from .pipeline import (
30
  CanterPipelineMetadata,
31
  CanterPipelineOutput,
32
  )
 
33
  from .schedules import Schedule
34
- from .solvers import Solver, SolverProgress
35
  from .text_encoder import CanterTextEncoder, TextBackboneOutput
36
  from .vae import CanterVae
37
  from .version import CANTER_VERSION, __version__
@@ -54,6 +55,7 @@ __all__ = [
54
  "CanterPipelineConfig",
55
  "CanterPipelineMetadata",
56
  "CanterPipelineOutput",
 
57
  "CanterReleaseMetadata",
58
  "CanterTextEncoder",
59
  "CanterVae",
@@ -65,6 +67,7 @@ __all__ = [
65
  "Schedule",
66
  "Solver",
67
  "SolverProgress",
 
68
  "TextAttentionBackend",
69
  "TextBackboneOutput",
70
  "WeightDType",
 
30
  CanterPipelineMetadata,
31
  CanterPipelineOutput,
32
  )
33
+ from .preview import CanterPreviewCallback
34
  from .schedules import Schedule
35
+ from .solvers import Solver, SolverProgress, SolverStateCallback
36
  from .text_encoder import CanterTextEncoder, TextBackboneOutput
37
  from .vae import CanterVae
38
  from .version import CANTER_VERSION, __version__
 
55
  "CanterPipelineConfig",
56
  "CanterPipelineMetadata",
57
  "CanterPipelineOutput",
58
+ "CanterPreviewCallback",
59
  "CanterReleaseMetadata",
60
  "CanterTextEncoder",
61
  "CanterVae",
 
67
  "Schedule",
68
  "Solver",
69
  "SolverProgress",
70
+ "SolverStateCallback",
71
  "TextAttentionBackend",
72
  "TextBackboneOutput",
73
  "WeightDType",
canter/inference.py CHANGED
@@ -15,7 +15,13 @@ from torch.amp import autocast
15
  from .modeling_canter import CanterPath, PreparedText
16
  from .runtime import CANTER_AMP_DTYPE, validate_common_runtime
17
  from .schedules import Schedule, build_schedule
18
- from .solvers import LOGSNR_SOLVER_START_EPS, Solver, SolverProgress, solve
 
 
 
 
 
 
19
 
20
  if TYPE_CHECKING:
21
  from .loading import CanterComponents
@@ -550,6 +556,7 @@ class CanterInferenceEngine:
550
  config: CanterInferenceConfig = _DEFAULT_INFERENCE_CONFIG,
551
  initial_noise: Tensor | None = None,
552
  progress: SolverProgress | None = None,
 
553
  ) -> CanterLatentOutput:
554
  """Generate float32 Canter latents for one prompt or prompt batch."""
555
 
@@ -632,6 +639,7 @@ class CanterInferenceEngine:
632
  euler_maruyama_multiplier=config.euler_maruyama_multiplier,
633
  er_sde_noise_multiplier=config.er_sde_noise_multiplier,
634
  progress=progress,
 
635
  )
636
  return CanterLatentOutput(latents=latents, schedule=schedule)
637
 
 
15
  from .modeling_canter import CanterPath, PreparedText
16
  from .runtime import CANTER_AMP_DTYPE, validate_common_runtime
17
  from .schedules import Schedule, build_schedule
18
+ from .solvers import (
19
+ LOGSNR_SOLVER_START_EPS,
20
+ Solver,
21
+ SolverProgress,
22
+ SolverStateCallback,
23
+ solve,
24
+ )
25
 
26
  if TYPE_CHECKING:
27
  from .loading import CanterComponents
 
556
  config: CanterInferenceConfig = _DEFAULT_INFERENCE_CONFIG,
557
  initial_noise: Tensor | None = None,
558
  progress: SolverProgress | None = None,
559
+ state_callback: SolverStateCallback | None = None,
560
  ) -> CanterLatentOutput:
561
  """Generate float32 Canter latents for one prompt or prompt batch."""
562
 
 
639
  euler_maruyama_multiplier=config.euler_maruyama_multiplier,
640
  er_sde_noise_multiplier=config.er_sde_noise_multiplier,
641
  progress=progress,
642
+ state_callback=state_callback,
643
  )
644
  return CanterLatentOutput(latents=latents, schedule=schedule)
645
 
canter/latent_rgb_preview.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0760245660c120a79ea536e3c77bb53f5230f22801874623c339ef58fc218866
3
+ size 6832
canter/pipeline.py CHANGED
@@ -24,11 +24,16 @@ from .loading import (
24
  CanterReleaseMetadata,
25
  WeightDType,
26
  )
 
 
 
 
 
27
  from .vae import CanterVae
28
  from .version import CANTER_VERSION
29
 
30
  if TYPE_CHECKING:
31
- from .solvers import SolverProgress
32
 
33
 
34
  class CanterOutputType(Enum):
@@ -100,12 +105,39 @@ class _LatentEngine(Protocol):
100
  config: CanterInferenceConfig,
101
  initial_noise: Tensor | None,
102
  progress: SolverProgress | None,
 
103
  ) -> CanterLatentOutput:
104
  """Generate deterministic whitened Canter latents."""
105
 
106
  ...
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  _DEFAULT_PIPELINE_CONFIG = CanterPipelineConfig()
110
 
111
 
@@ -117,6 +149,7 @@ class CanterPipeline:
117
  engine: _LatentEngine,
118
  vae: CanterVae,
119
  metadata: CanterPipelineMetadata,
 
120
  ) -> None:
121
  """Construct a pipeline from validated inference components."""
122
 
@@ -128,6 +161,7 @@ class CanterPipeline:
128
  raise ValueError("Pipeline metadata and DINAC-AE revision disagree.")
129
  self.engine = engine
130
  self.vae = vae
 
131
  self.metadata = metadata
132
  self.device = engine.device
133
 
@@ -177,6 +211,10 @@ class CanterPipeline:
177
  CanterInferenceEngine(components),
178
  resolved_vae,
179
  metadata,
 
 
 
 
180
  )
181
 
182
  def __call__(
@@ -187,18 +225,23 @@ class CanterPipeline:
187
  config: CanterPipelineConfig = _DEFAULT_PIPELINE_CONFIG,
188
  initial_noise: Tensor | None = None,
189
  progress: SolverProgress | None = None,
 
190
  ) -> CanterPipelineOutput:
191
  """Generate the configured output without recording gradients."""
192
 
193
  if not isinstance(config, CanterPipelineConfig):
194
  raise TypeError("config must be a CanterPipelineConfig.")
 
 
195
  latent_output = self.engine.generate(
196
  prompts,
197
  negative_prompts=negative_prompts,
198
  config=config.inference,
199
  initial_noise=initial_noise,
200
  progress=progress,
 
201
  )
 
202
  match config.output_type:
203
  case CanterOutputType.LATENT:
204
  return CanterPipelineOutput(
@@ -235,6 +278,32 @@ class CanterPipeline:
235
  case _ as unreachable:
236
  raise RuntimeError(f"Unsupported output type: {unreachable}")
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  def _validate_vae_selection(
240
  vae: CanterVae,
 
24
  CanterReleaseMetadata,
25
  WeightDType,
26
  )
27
+ from .preview import (
28
+ LATENT_RGB_PREVIEW_FILENAME,
29
+ CanterPreviewCallback,
30
+ LatentRgbPreviewer,
31
+ )
32
  from .vae import CanterVae
33
  from .version import CANTER_VERSION
34
 
35
  if TYPE_CHECKING:
36
+ from .solvers import SolverProgress, SolverStateCallback
37
 
38
 
39
  class CanterOutputType(Enum):
 
105
  config: CanterInferenceConfig,
106
  initial_noise: Tensor | None,
107
  progress: SolverProgress | None,
108
+ state_callback: SolverStateCallback | None,
109
  ) -> CanterLatentOutput:
110
  """Generate deterministic whitened Canter latents."""
111
 
112
  ...
113
 
114
 
115
+ class _LatentPreviewer(Protocol):
116
+ """Asynchronous latent preview surface consumed by the pipeline."""
117
+
118
+ def try_submit(
119
+ self,
120
+ latents: Tensor,
121
+ *,
122
+ callback: CanterPreviewCallback,
123
+ completed: int,
124
+ total: int,
125
+ ) -> bool:
126
+ """Submit one preview if the worker is idle."""
127
+
128
+ ...
129
+
130
+ def raise_if_failed(self) -> None:
131
+ """Propagate one completed asynchronous failure."""
132
+
133
+ ...
134
+
135
+ def close(self) -> None:
136
+ """Finish preview work and release worker resources."""
137
+
138
+ ...
139
+
140
+
141
  _DEFAULT_PIPELINE_CONFIG = CanterPipelineConfig()
142
 
143
 
 
149
  engine: _LatentEngine,
150
  vae: CanterVae,
151
  metadata: CanterPipelineMetadata,
152
+ previewer: _LatentPreviewer,
153
  ) -> None:
154
  """Construct a pipeline from validated inference components."""
155
 
 
161
  raise ValueError("Pipeline metadata and DINAC-AE revision disagree.")
162
  self.engine = engine
163
  self.vae = vae
164
+ self.previewer = previewer
165
  self.metadata = metadata
166
  self.device = engine.device
167
 
 
211
  CanterInferenceEngine(components),
212
  resolved_vae,
213
  metadata,
214
+ LatentRgbPreviewer.from_file(
215
+ Path(__file__).with_name(LATENT_RGB_PREVIEW_FILENAME),
216
+ device=components.model.input_projection.weight.device,
217
+ ),
218
  )
219
 
220
  def __call__(
 
225
  config: CanterPipelineConfig = _DEFAULT_PIPELINE_CONFIG,
226
  initial_noise: Tensor | None = None,
227
  progress: SolverProgress | None = None,
228
+ preview: CanterPreviewCallback | None = None,
229
  ) -> CanterPipelineOutput:
230
  """Generate the configured output without recording gradients."""
231
 
232
  if not isinstance(config, CanterPipelineConfig):
233
  raise TypeError("config must be a CanterPipelineConfig.")
234
+ self.previewer.raise_if_failed()
235
+ state_callback = self._state_callback(preview)
236
  latent_output = self.engine.generate(
237
  prompts,
238
  negative_prompts=negative_prompts,
239
  config=config.inference,
240
  initial_noise=initial_noise,
241
  progress=progress,
242
+ state_callback=state_callback,
243
  )
244
+ self.previewer.raise_if_failed()
245
  match config.output_type:
246
  case CanterOutputType.LATENT:
247
  return CanterPipelineOutput(
 
278
  case _ as unreachable:
279
  raise RuntimeError(f"Unsupported output type: {unreachable}")
280
 
281
+ def close(self) -> None:
282
+ """Finish preview work before releasing pipeline resources."""
283
+
284
+ self.previewer.close()
285
+
286
+ def _state_callback(
287
+ self,
288
+ preview: CanterPreviewCallback | None,
289
+ ) -> SolverStateCallback | None:
290
+ """Build a non-blocking solver-state callback for one request."""
291
+
292
+ if preview is None:
293
+ return None
294
+
295
+ def submit(state: Tensor, completed: int, total: int) -> None:
296
+ """Drop busy updates and enqueue every eligible idle update."""
297
+
298
+ self.previewer.try_submit(
299
+ state,
300
+ callback=preview,
301
+ completed=completed,
302
+ total=total,
303
+ )
304
+
305
+ return submit
306
+
307
 
308
  def _validate_vae_selection(
309
  vae: CanterVae,
canter/preview.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Asynchronous DINAC latent-to-RGB previews for Canter inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ from concurrent.futures import Future, ThreadPoolExecutor
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from PIL import Image
16
+ from safetensors import safe_open
17
+ from safetensors.torch import load_file
18
+ from torch import Tensor
19
+
20
+ LATENT_RGB_PREVIEW_FILENAME = "latent_rgb_preview.safetensors"
21
+ LATENT_RGB_PREVIEW_FORMAT = "canter-latent-rgb-preview-v1"
22
+ LATENT_RGB_PREVIEW_METADATA_KEY = "canter"
23
+ _WEIGHT_KEY = "projection.weight"
24
+ _BIAS_KEY = "projection.bias"
25
+ _EXPECTED_LATENT_CHANNELS = 128
26
+ _EXPECTED_RGB_CHANNELS = 3
27
+ _EXPECTED_SPATIAL_EXPANSION = 2
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ class CanterPreviewCallback(Protocol):
32
+ """Consumer of one asynchronously rendered solver preview."""
33
+
34
+ def __call__(
35
+ self,
36
+ images: tuple[Image.Image, ...],
37
+ completed: int,
38
+ total: int,
39
+ ) -> None:
40
+ """Consume preview images for one completed solver update."""
41
+
42
+ ...
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _PreviewWork:
47
+ """Inputs consumed by one asynchronous CPU preview job."""
48
+
49
+ host_rgb: Tensor
50
+ ready: torch.cuda.Event
51
+ callback: CanterPreviewCallback
52
+ completed: int
53
+ total: int
54
+
55
+
56
+ class LatentRgbPreviewer:
57
+ """Project solver states on CUDA and finish previews on one CPU worker."""
58
+
59
+ def __init__(
60
+ self,
61
+ weight: Tensor,
62
+ bias: Tensor,
63
+ *,
64
+ device: torch.device,
65
+ ) -> None:
66
+ """Move the tiny projection to CUDA and initialize one-slot dispatch."""
67
+
68
+ if device.type != "cuda":
69
+ raise ValueError("Canter latent previews require a CUDA device.")
70
+ self._weight = weight.to(device=device, dtype=torch.float32)
71
+ self._bias = bias.to(device=device, dtype=torch.float32)
72
+ self.device = self._weight.device
73
+ self._executor = ThreadPoolExecutor(
74
+ max_workers=1,
75
+ thread_name_prefix="canter-preview",
76
+ )
77
+ self._lock = threading.Lock()
78
+ self._future: Future[None] | None = None
79
+ self._host_buffer: Tensor | None = None
80
+ self._closed = False
81
+
82
+ @classmethod
83
+ def from_file(
84
+ cls,
85
+ path: Path,
86
+ *,
87
+ device: torch.device,
88
+ ) -> LatentRgbPreviewer:
89
+ """Load one strict standalone latent-RGB Safetensors artifact."""
90
+
91
+ weight, bias = _load_projection_weights(path)
92
+ return cls(weight, bias, device=device)
93
+
94
+ def try_submit(
95
+ self,
96
+ latents: Tensor,
97
+ *,
98
+ callback: CanterPreviewCallback,
99
+ completed: int,
100
+ total: int,
101
+ ) -> bool:
102
+ """Submit a preview only when the prior CPU job has fully completed."""
103
+
104
+ with self._lock:
105
+ self._raise_completed_error_locked()
106
+ if self._closed:
107
+ raise RuntimeError("Canter latent previewer is closed.")
108
+ if self._future is not None:
109
+ return False
110
+ with (
111
+ torch.inference_mode(),
112
+ torch.autocast(device_type="cuda", enabled=False),
113
+ ):
114
+ projected = F.conv2d(latents.float(), self._weight, self._bias)
115
+ small_rgb = F.pixel_shuffle(
116
+ projected,
117
+ upscale_factor=_EXPECTED_SPATIAL_EXPANSION,
118
+ ).clamp(-1.0, 1.0)
119
+ host_buffer = self._host_buffer_for(small_rgb)
120
+ host_buffer.copy_(small_rgb, non_blocking=True)
121
+ ready = torch.cuda.Event()
122
+ ready.record(torch.cuda.current_stream(self.device))
123
+ self._future = self._executor.submit(
124
+ _finish_preview,
125
+ _PreviewWork(
126
+ host_rgb=host_buffer,
127
+ ready=ready,
128
+ callback=callback,
129
+ completed=completed,
130
+ total=total,
131
+ ),
132
+ )
133
+ self._future.add_done_callback(_log_preview_failure)
134
+ return True
135
+
136
+ def raise_if_failed(self) -> None:
137
+ """Propagate a completed asynchronous preview failure without waiting."""
138
+
139
+ with self._lock:
140
+ self._raise_completed_error_locked()
141
+
142
+ def close(self) -> None:
143
+ """Finish accepted preview work and stop the worker."""
144
+
145
+ with self._lock:
146
+ self._closed = True
147
+ self._executor.shutdown(wait=True, cancel_futures=False)
148
+ self.raise_if_failed()
149
+
150
+ def _host_buffer_for(self, small_rgb: Tensor) -> Tensor:
151
+ """Return a shape-compatible pinned float32 host buffer."""
152
+
153
+ shape = tuple(int(dimension) for dimension in small_rgb.shape)
154
+ current = self._host_buffer
155
+ if current is None or tuple(current.shape) != shape:
156
+ current = torch.empty(
157
+ shape,
158
+ device="cpu",
159
+ dtype=torch.float32,
160
+ pin_memory=True,
161
+ )
162
+ self._host_buffer = current
163
+ return current
164
+
165
+ def _raise_completed_error_locked(self) -> None:
166
+ """Resolve and clear a completed worker future while holding the lock."""
167
+
168
+ future = self._future
169
+ if future is None or not future.done():
170
+ return
171
+ self._future = None
172
+ future.result()
173
+
174
+
175
+ def _finish_preview(work: _PreviewWork) -> None:
176
+ """Wait off-thread, convert the native preview, and publish it."""
177
+
178
+ work.ready.synchronize()
179
+ with torch.inference_mode():
180
+ images = _to_pil_images(work.host_rgb)
181
+ work.callback(images, work.completed, work.total)
182
+
183
+
184
+ def _to_pil_images(images: Tensor) -> tuple[Image.Image, ...]:
185
+ """Convert clamped CPU float32 BCHW images into RGB PIL images."""
186
+
187
+ pixels = (
188
+ images.add(1.0)
189
+ .mul(127.5)
190
+ .round()
191
+ .clamp(0.0, 255.0)
192
+ .to(torch.uint8)
193
+ .permute(0, 2, 3, 1)
194
+ .contiguous()
195
+ .numpy()
196
+ )
197
+ return tuple(Image.fromarray(image, mode="RGB") for image in pixels)
198
+
199
+
200
+ def _load_projection_weights(path: Path) -> tuple[Tensor, Tensor]:
201
+ """Validate metadata and tensors from one preview artifact."""
202
+
203
+ metadata = _load_preview_metadata(path)
204
+ expected_metadata = {
205
+ "format": LATENT_RGB_PREVIEW_FORMAT,
206
+ "latent_channels": str(_EXPECTED_LATENT_CHANNELS),
207
+ "rgb_channels": str(_EXPECTED_RGB_CHANNELS),
208
+ "spatial_expansion_factor": str(_EXPECTED_SPATIAL_EXPANSION),
209
+ "target_downsample_factor": "8",
210
+ }
211
+ mismatches = {
212
+ key: (metadata.get(key), expected)
213
+ for key, expected in expected_metadata.items()
214
+ if metadata.get(key) != expected
215
+ }
216
+ if mismatches:
217
+ raise RuntimeError(
218
+ f"Latent preview Safetensors metadata is incompatible: {mismatches}."
219
+ )
220
+ tensors = load_file(path, device="cpu")
221
+ if set(tensors) != {_WEIGHT_KEY, _BIAS_KEY}:
222
+ raise RuntimeError(
223
+ "Latent preview Safetensors must contain only projection.weight "
224
+ "and projection.bias."
225
+ )
226
+ weight = tensors[_WEIGHT_KEY]
227
+ bias = tensors[_BIAS_KEY]
228
+ expected_weight_shape = (
229
+ _EXPECTED_RGB_CHANNELS * _EXPECTED_SPATIAL_EXPANSION**2,
230
+ _EXPECTED_LATENT_CHANNELS,
231
+ 1,
232
+ 1,
233
+ )
234
+ if tuple(weight.shape) != expected_weight_shape:
235
+ raise RuntimeError(
236
+ "Latent preview projection.weight has an incompatible shape: "
237
+ f"expected {expected_weight_shape}, got {tuple(weight.shape)}."
238
+ )
239
+ if tuple(bias.shape) != (expected_weight_shape[0],):
240
+ raise RuntimeError("Latent preview projection.bias has an incompatible shape.")
241
+ if weight.dtype is not torch.float32 or bias.dtype is not torch.float32:
242
+ raise RuntimeError("Latent preview projection weights must use float32.")
243
+ return weight.contiguous(), bias.contiguous()
244
+
245
+
246
+ def _load_preview_metadata(path: Path) -> dict[str, str]:
247
+ """Load deterministic JSON metadata from one preview artifact."""
248
+
249
+ with safe_open(path, framework="pt", device="cpu") as file:
250
+ header = file.metadata()
251
+ if header is None or set(header) != {LATENT_RGB_PREVIEW_METADATA_KEY}:
252
+ raise RuntimeError("Latent preview Safetensors metadata is missing.")
253
+ value = json.loads(header[LATENT_RGB_PREVIEW_METADATA_KEY])
254
+ if not isinstance(value, dict) or any(
255
+ not isinstance(key, str) or not isinstance(item, str)
256
+ for key, item in value.items()
257
+ ):
258
+ raise TypeError("Latent preview metadata must map strings to strings.")
259
+ return value
260
+
261
+
262
+ def _log_preview_failure(future: Future[None]) -> None:
263
+ """Log asynchronous callback failures immediately for console diagnosis."""
264
+
265
+ error = future.exception()
266
+ if error is not None:
267
+ _LOGGER.error(
268
+ "Asynchronous Canter preview failed",
269
+ exc_info=(type(error), error, error.__traceback__),
270
+ )
canter/solvers.py CHANGED
@@ -46,12 +46,40 @@ class SolverProgress(Protocol):
46
  ...
47
 
48
 
 
 
 
 
 
 
 
 
 
49
  def _ignore_progress(completed: int, total: int) -> None:
50
  """Discard solver progress for non-interactive inference."""
51
 
52
  del completed, total
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def _time_batch(time: Tensor, batch: int, device: torch.device) -> Tensor:
56
  """Expand one scalar schedule value to a float32 batch vector."""
57
 
@@ -80,6 +108,7 @@ def _euler(
80
  state: Tensor,
81
  schedule: Tensor,
82
  progress: SolverProgress,
 
83
  ) -> Tensor:
84
  """Integrate one first-order Euler update."""
85
 
@@ -89,7 +118,7 @@ def _euler(
89
  time = _time_batch(schedule[index], batch, state.device)
90
  step = schedule[index + 1] - schedule[index]
91
  state = state + step * velocity(state, time, index)
92
- progress(index + 1, intervals)
93
  return state
94
 
95
 
@@ -110,6 +139,7 @@ def _euler_maruyama(
110
  generator: torch.Generator,
111
  multiplier: float,
112
  progress: SolverProgress,
 
113
  ) -> Tensor:
114
  """Integrate the reverse SDE with an Euler-Maruyama update."""
115
 
@@ -129,7 +159,7 @@ def _euler_maruyama(
129
  terminal = index == intervals - 1 and next_value == 0.0
130
  if terminal:
131
  state = state - time_value * predicted
132
- progress(index + 1, intervals)
133
  continue
134
  drift = predicted - time.view((batch, 1, 1, 1)) * _score(
135
  state,
@@ -145,7 +175,7 @@ def _euler_maruyama(
145
  diffusion = 2.0 * time_value
146
  noise_scale = float(multiplier) * math.sqrt(diffusion) * math.sqrt(abs(step))
147
  state = state + step * drift + noise_scale * noise
148
- progress(index + 1, intervals)
149
  return state
150
 
151
 
@@ -200,6 +230,7 @@ def _dpmpp_2m(
200
  state: Tensor,
201
  schedule: Tensor,
202
  progress: SolverProgress,
 
203
  ) -> Tensor:
204
  """Integrate with the flow-matching DPM++ 2M formulation."""
205
 
@@ -230,7 +261,7 @@ def _dpmpp_2m(
230
  )
231
  previous_denoised = denoised
232
  previous_lambda = lambdas[index]
233
- progress(index + 1, intervals)
234
  return state
235
 
236
 
@@ -347,6 +378,7 @@ def _er_sde(
347
  generator: torch.Generator,
348
  noise_multiplier: float,
349
  progress: SolverProgress,
 
350
  ) -> Tensor:
351
  """Integrate with third-stage VP ER-SDE and Gauss-Legendre quadrature."""
352
 
@@ -383,7 +415,7 @@ def _er_sde(
383
  noise_multiplier=float(noise_multiplier),
384
  )
385
  old_denoised = denoised
386
- progress(index + 1, intervals)
387
  return state
388
 
389
 
@@ -407,6 +439,7 @@ def _abm2(
407
  state: Tensor,
408
  schedule: Tensor,
409
  progress: SolverProgress,
 
410
  ) -> Tensor:
411
  """Integrate with ABM2, including corrected-state reevaluation."""
412
 
@@ -424,7 +457,7 @@ def _abm2(
424
  _time_batch(schedule[1], batch, state.device),
425
  1,
426
  )
427
- progress(1, intervals)
428
  for index in range(1, intervals):
429
  previous_step = schedule[index] - schedule[index - 1]
430
  current_step = schedule[index + 1] - schedule[index]
@@ -450,7 +483,7 @@ def _abm2(
450
  _time_batch(schedule[index + 1], batch, state.device),
451
  index + 1,
452
  )
453
- progress(index + 1, intervals)
454
  return state
455
 
456
 
@@ -464,6 +497,7 @@ def solve(
464
  euler_maruyama_multiplier: float,
465
  er_sde_noise_multiplier: float = 1.0,
466
  progress: SolverProgress | None = None,
 
467
  ) -> Tensor:
468
  """Integrate Canter velocity predictions over one validated schedule."""
469
 
@@ -477,9 +511,18 @@ def solve(
477
  if not math.isfinite(er_multiplier) or er_multiplier < 0.0:
478
  raise ValueError("er_sde_noise_multiplier must be finite and non-negative.")
479
  resolved_progress = _ignore_progress if progress is None else progress
 
 
 
480
  match solver:
481
  case Solver.EULER:
482
- return _euler(velocity, initial_state, schedule, resolved_progress)
 
 
 
 
 
 
483
  case Solver.EULER_MARUYAMA:
484
  return _euler_maruyama(
485
  velocity,
@@ -488,6 +531,7 @@ def solve(
488
  generator=generator,
489
  multiplier=em_multiplier,
490
  progress=resolved_progress,
 
491
  )
492
  case Solver.ER_SDE:
493
  return _er_sde(
@@ -497,6 +541,7 @@ def solve(
497
  generator=generator,
498
  noise_multiplier=er_multiplier,
499
  progress=resolved_progress,
 
500
  )
501
  case Solver.DPMPP_2M:
502
  return _dpmpp_2m(
@@ -504,6 +549,7 @@ def solve(
504
  initial_state,
505
  schedule,
506
  resolved_progress,
 
507
  )
508
  case Solver.ABM2:
509
  return _abm2(
@@ -511,6 +557,7 @@ def solve(
511
  initial_state,
512
  schedule,
513
  resolved_progress,
 
514
  )
515
  case _ as unreachable:
516
  raise RuntimeError(f"Unsupported Canter solver: {unreachable}")
 
46
  ...
47
 
48
 
49
+ class SolverStateCallback(Protocol):
50
+ """Callback receiving the state after one completed solver update."""
51
+
52
+ def __call__(self, state: Tensor, completed: int, total: int) -> None:
53
+ """Observe one completed state update."""
54
+
55
+ ...
56
+
57
+
58
  def _ignore_progress(completed: int, total: int) -> None:
59
  """Discard solver progress for non-interactive inference."""
60
 
61
  del completed, total
62
 
63
 
64
+ def _ignore_state(state: Tensor, completed: int, total: int) -> None:
65
+ """Discard solver states when no observer is configured."""
66
+
67
+ del state, completed, total
68
+
69
+
70
+ def _report_update(
71
+ state: Tensor,
72
+ completed: int,
73
+ total: int,
74
+ progress: SolverProgress,
75
+ state_callback: SolverStateCallback,
76
+ ) -> None:
77
+ """Report one completed update before exposing its resulting state."""
78
+
79
+ progress(completed, total)
80
+ state_callback(state, completed, total)
81
+
82
+
83
  def _time_batch(time: Tensor, batch: int, device: torch.device) -> Tensor:
84
  """Expand one scalar schedule value to a float32 batch vector."""
85
 
 
108
  state: Tensor,
109
  schedule: Tensor,
110
  progress: SolverProgress,
111
+ state_callback: SolverStateCallback,
112
  ) -> Tensor:
113
  """Integrate one first-order Euler update."""
114
 
 
118
  time = _time_batch(schedule[index], batch, state.device)
119
  step = schedule[index + 1] - schedule[index]
120
  state = state + step * velocity(state, time, index)
121
+ _report_update(state, index + 1, intervals, progress, state_callback)
122
  return state
123
 
124
 
 
139
  generator: torch.Generator,
140
  multiplier: float,
141
  progress: SolverProgress,
142
+ state_callback: SolverStateCallback,
143
  ) -> Tensor:
144
  """Integrate the reverse SDE with an Euler-Maruyama update."""
145
 
 
159
  terminal = index == intervals - 1 and next_value == 0.0
160
  if terminal:
161
  state = state - time_value * predicted
162
+ _report_update(state, index + 1, intervals, progress, state_callback)
163
  continue
164
  drift = predicted - time.view((batch, 1, 1, 1)) * _score(
165
  state,
 
175
  diffusion = 2.0 * time_value
176
  noise_scale = float(multiplier) * math.sqrt(diffusion) * math.sqrt(abs(step))
177
  state = state + step * drift + noise_scale * noise
178
+ _report_update(state, index + 1, intervals, progress, state_callback)
179
  return state
180
 
181
 
 
230
  state: Tensor,
231
  schedule: Tensor,
232
  progress: SolverProgress,
233
+ state_callback: SolverStateCallback,
234
  ) -> Tensor:
235
  """Integrate with the flow-matching DPM++ 2M formulation."""
236
 
 
261
  )
262
  previous_denoised = denoised
263
  previous_lambda = lambdas[index]
264
+ _report_update(state, index + 1, intervals, progress, state_callback)
265
  return state
266
 
267
 
 
378
  generator: torch.Generator,
379
  noise_multiplier: float,
380
  progress: SolverProgress,
381
+ state_callback: SolverStateCallback,
382
  ) -> Tensor:
383
  """Integrate with third-stage VP ER-SDE and Gauss-Legendre quadrature."""
384
 
 
415
  noise_multiplier=float(noise_multiplier),
416
  )
417
  old_denoised = denoised
418
+ _report_update(state, index + 1, intervals, progress, state_callback)
419
  return state
420
 
421
 
 
439
  state: Tensor,
440
  schedule: Tensor,
441
  progress: SolverProgress,
442
+ state_callback: SolverStateCallback,
443
  ) -> Tensor:
444
  """Integrate with ABM2, including corrected-state reevaluation."""
445
 
 
457
  _time_batch(schedule[1], batch, state.device),
458
  1,
459
  )
460
+ _report_update(state, 1, intervals, progress, state_callback)
461
  for index in range(1, intervals):
462
  previous_step = schedule[index] - schedule[index - 1]
463
  current_step = schedule[index + 1] - schedule[index]
 
483
  _time_batch(schedule[index + 1], batch, state.device),
484
  index + 1,
485
  )
486
+ _report_update(state, index + 1, intervals, progress, state_callback)
487
  return state
488
 
489
 
 
497
  euler_maruyama_multiplier: float,
498
  er_sde_noise_multiplier: float = 1.0,
499
  progress: SolverProgress | None = None,
500
+ state_callback: SolverStateCallback | None = None,
501
  ) -> Tensor:
502
  """Integrate Canter velocity predictions over one validated schedule."""
503
 
 
511
  if not math.isfinite(er_multiplier) or er_multiplier < 0.0:
512
  raise ValueError("er_sde_noise_multiplier must be finite and non-negative.")
513
  resolved_progress = _ignore_progress if progress is None else progress
514
+ resolved_state_callback = (
515
+ _ignore_state if state_callback is None else state_callback
516
+ )
517
  match solver:
518
  case Solver.EULER:
519
+ return _euler(
520
+ velocity,
521
+ initial_state,
522
+ schedule,
523
+ resolved_progress,
524
+ resolved_state_callback,
525
+ )
526
  case Solver.EULER_MARUYAMA:
527
  return _euler_maruyama(
528
  velocity,
 
531
  generator=generator,
532
  multiplier=em_multiplier,
533
  progress=resolved_progress,
534
+ state_callback=resolved_state_callback,
535
  )
536
  case Solver.ER_SDE:
537
  return _er_sde(
 
541
  generator=generator,
542
  noise_multiplier=er_multiplier,
543
  progress=resolved_progress,
544
+ state_callback=resolved_state_callback,
545
  )
546
  case Solver.DPMPP_2M:
547
  return _dpmpp_2m(
 
549
  initial_state,
550
  schedule,
551
  resolved_progress,
552
+ resolved_state_callback,
553
  )
554
  case Solver.ABM2:
555
  return _abm2(
 
557
  initial_state,
558
  schedule,
559
  resolved_progress,
560
+ resolved_state_callback,
561
  )
562
  case _ as unreachable:
563
  raise RuntimeError(f"Unsupported Canter solver: {unreachable}")
canter/version.py CHANGED
@@ -1,4 +1,4 @@
1
  """Single source of truth for the installable Canter code version."""
2
 
3
- __version__ = "0.2.0"
4
  CANTER_VERSION = __version__
 
1
  """Single source of truth for the installable Canter code version."""
2
 
3
+ __version__ = "0.3.0"
4
  CANTER_VERSION = __version__
canter/webui.py CHANGED
@@ -1,1822 +1,68 @@
1
- """Dark Gradio application for the standalone Canter pipeline."""
2
 
3
  from __future__ import annotations
4
 
5
  import argparse
6
- import json
7
- import math
8
- import secrets
9
- import threading
10
- import time
11
- from collections.abc import Callable, Generator, Mapping, Sequence
12
- from dataclasses import dataclass, replace
13
- from enum import Enum
14
- from hashlib import sha256
15
  from pathlib import Path
16
- from typing import Protocol, TypeAlias, TypeVar
17
-
18
- import gradio as gr
19
- from PIL import Image
20
 
21
  from .blocks import TextAttentionBackend
22
- from .inference import (
23
- CanterInferenceConfig,
24
- CfgGuidance,
25
- PdgCurve,
26
- PdgGuidance,
27
- PdgMode,
28
- )
29
  from .loading import WeightDType
30
- from .pipeline import (
31
- CanterOutputType,
32
- CanterPipeline,
33
- CanterPipelineConfig,
34
- CanterPipelineMetadata,
35
- CanterPipelineOutput,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  )
37
- from .schedules import Schedule
38
- from .solvers import Solver, SolverProgress
39
 
40
  _DEFAULT_MODEL = "data-archetype/canter"
41
  _DEFAULT_SERVER_NAME = "0.0.0.0"
42
  _DEFAULT_SERVER_PORT = 7860
43
- _MAX_QUEUE_SIZE = 8
44
- _DEFAULT_IMAGE_COUNT = 4
45
- _MAX_IMAGE_COUNT = 4
46
- _MAX_SEED = 2**63 - 1
47
- _GPU_CONCURRENCY_ID = "canter-gpu"
48
- _IMAGE_SIZE_MULTIPLE = 32
49
- _PNG_METADATA_KEY = "canter"
50
-
51
- _DEFAULT_INFERENCE = CanterInferenceConfig()
52
- _SOLVERS = {value.value: value for value in Solver}
53
- _SCHEDULES = {value.value: value for value in Schedule}
54
- _PDG_MODES = {value.value: value for value in PdgMode if value is not PdgMode.NONE}
55
- _PDG_CURVES = {value.value: value for value in PdgCurve}
56
  _WEIGHT_DTYPES = {value.value: value for value in WeightDType}
57
  _TEXT_BACKENDS = {value.value: value for value in TextAttentionBackend}
58
 
59
- _SOLVER_CHOICES = (
60
- ("ABM2", Solver.ABM2.value),
61
- ("DPM++ 2M", Solver.DPMPP_2M.value),
62
- ("Euler-Maruyama", Solver.EULER_MARUYAMA.value),
63
- ("ER-SDE", Solver.ER_SDE.value),
64
- ("Euler", Solver.EULER.value),
65
- )
66
- _SCHEDULE_CHOICES = (
67
- ("Beta (0.6, 0.6)", Schedule.BETA.value),
68
- ("Linear", Schedule.LINEAR.value),
69
- )
70
- _PDG_MODE_CHOICES = (
71
- ("Full path drop", PdgMode.FULL.value),
72
- ("Three-quarter path", PdgMode.THREE_QUARTER.value),
73
- ("Alternate: PDG first", PdgMode.ALTERNATE_PDG_FIRST.value),
74
- ("Alternate: CFG first", PdgMode.ALTERNATE_CFG_FIRST.value),
75
- ("Combined CFG + PDG", PdgMode.COMBINED_CFG_PDG.value),
76
- ("PDG with alternating CFG", PdgMode.PDG_WITH_ALTERNATING_CFG.value),
77
- ("CFG then PDG", PdgMode.CFG_TO_PDG.value),
78
- )
79
- _PDG_CURVE_CHOICES = (
80
- ("Constant", PdgCurve.CONSTANT.value),
81
- ("Linear", PdgCurve.LINEAR.value),
82
- ("Power", PdgCurve.POWER.value),
83
- )
84
- _DEFAULT_PROMPT = (
85
- "Wide angle photo of a weathered wooden boardwalk descending steeply from "
86
- "rocky cliffs toward a rugged coastline. The boardwalk, constructed from "
87
- "aged planks with visible cracks and moss growth, is bordered by rustic "
88
- "wooden railings on both sides. Surrounding terrain features uneven, eroded "
89
- "rock formations covered in patches of green vegetation and low-lying "
90
- "shrubs. Below, a crescent-shaped sandy beach meets crashing waves of a "
91
- "deep blue ocean under a stormy, overcast sky with dark clouds and hints "
92
- "of distant sunlight breaking through. The horizon shows turbulent waves "
93
- "colliding with rocky outcrops, creating white foam."
94
- )
95
-
96
-
97
- class SizePreset(Enum):
98
- """UI presets that jointly select image shape and attention gain."""
99
-
100
- DEFAULT_PORTRAIT = "default_portrait"
101
- WIDE = "wide"
102
- TALL_PORTRAIT = "tall_portrait"
103
-
104
-
105
- @dataclass(frozen=True)
106
- class _SizePresetValues:
107
- """Concrete image controls selected by one size preset."""
108
-
109
- width: int
110
- height: int
111
- self_attention_gain: float
112
-
113
-
114
- _SIZE_PRESETS = {value.value: value for value in SizePreset}
115
- _SIZE_PRESET_CHOICES = (
116
- ("832 × 1216 · gain −0.03", SizePreset.DEFAULT_PORTRAIT.value),
117
- ("1536 × 832 · gain −0.02", SizePreset.WIDE.value),
118
- ("1024 × 1536 · gain −0.02", SizePreset.TALL_PORTRAIT.value),
119
- )
120
- _SIZE_PRESET_VALUES = {
121
- SizePreset.DEFAULT_PORTRAIT: _SizePresetValues(832, 1216, -0.03),
122
- SizePreset.WIDE: _SizePresetValues(1536, 832, -0.02),
123
- SizePreset.TALL_PORTRAIT: _SizePresetValues(1024, 1536, -0.02),
124
- }
125
-
126
- _DARK_HEAD = """
127
- <script>
128
- localStorage.setItem("theme", "dark");
129
- document.documentElement.classList.add("dark");
130
- document.documentElement.style.colorScheme = "dark";
131
- </script>
132
- """
133
-
134
- _CSS = """
135
- :root, body {
136
- background: #09090b;
137
- }
138
- .gradio-container {
139
- max-width: none !important;
140
- width: 100% !important;
141
- padding: 1rem !important;
142
- }
143
- #canter-title h1 {
144
- font-size: 2.1rem;
145
- letter-spacing: -0.04em;
146
- margin-bottom: 0.15rem;
147
- }
148
- #canter-workspace {
149
- align-items: flex-start !important;
150
- }
151
- #canter-controls {
152
- max-width: 440px;
153
- min-width: 360px;
154
- }
155
- #canter-results {
156
- min-width: 0;
157
- }
158
- #canter-image-grid {
159
- display: grid !important;
160
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 832px), 1fr));
161
- align-items: start !important;
162
- gap: 1rem !important;
163
- overflow: visible !important;
164
- }
165
- #canter-image-grid > div {
166
- min-width: 0 !important;
167
- }
168
- .canter-image-cell {
169
- position: relative;
170
- }
171
- .canter-total-progress {
172
- margin-bottom: 0.75rem;
173
- }
174
- .canter-total-progress:not(:has(.canter-progress-panel)) {
175
- display: none !important;
176
- }
177
- .canter-image-progress {
178
- position: absolute !important;
179
- top: 0.75rem;
180
- left: 0.75rem;
181
- right: 0.75rem;
182
- z-index: 10;
183
- pointer-events: none;
184
- }
185
- .canter-image-progress:not(:has(.canter-progress-panel)) {
186
- display: none !important;
187
- }
188
- .canter-progress-panel {
189
- padding: 0.65rem 0.75rem;
190
- color: #f4f4f5;
191
- background: rgb(9 9 11 / 88%);
192
- border: 1px solid #3f3f46;
193
- border-radius: 0.35rem;
194
- box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 35%);
195
- backdrop-filter: blur(0.35rem);
196
- }
197
- .canter-progress-label {
198
- margin-bottom: 0.4rem;
199
- font-size: 0.85rem;
200
- font-weight: 600;
201
- }
202
- .canter-progress-row {
203
- display: grid;
204
- grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem;
205
- align-items: center;
206
- gap: 0.45rem;
207
- font-size: 0.72rem;
208
- color: #d4d4d8;
209
- }
210
- .canter-progress-track {
211
- height: 0.35rem;
212
- overflow: hidden;
213
- background: #27272a;
214
- border-radius: 999px;
215
- }
216
- .canter-progress-track > div {
217
- height: 100%;
218
- background: var(--primary-500);
219
- border-radius: inherit;
220
- transition: width 120ms linear;
221
- }
222
- .canter-output-image,
223
- .canter-output-image > div {
224
- overflow: visible !important;
225
- }
226
- .canter-output-image img {
227
- display: block !important;
228
- width: auto !important;
229
- max-width: 100% !important;
230
- height: auto !important;
231
- max-height: none !important;
232
- object-fit: contain !important;
233
- margin: 0 auto !important;
234
- }
235
- #canter-status {
236
- color: #a1a1aa;
237
- }
238
- @media (max-width: 900px) {
239
- #canter-controls {
240
- max-width: none;
241
- min-width: 0;
242
- }
243
- }
244
- """
245
-
246
- _Choice = TypeVar("_Choice")
247
-
248
-
249
- class _ImagePipeline(Protocol):
250
- """Typed Canter surface used by the web controller and its tests."""
251
-
252
- metadata: CanterPipelineMetadata
253
-
254
- def __call__(
255
- self,
256
- prompts: str | Sequence[str],
257
- *,
258
- config: CanterPipelineConfig,
259
- initial_noise: None,
260
- progress: SolverProgress | None,
261
- ) -> CanterPipelineOutput:
262
- """Generate decoded images for one web request."""
263
-
264
- ...
265
-
266
-
267
- @dataclass(frozen=True)
268
- class CanterWebRequest:
269
- """Validated sequential image request and frozen base configuration."""
270
-
271
- prompt: str
272
- image_count: int
273
- config: CanterPipelineConfig
274
-
275
-
276
- @dataclass(frozen=True)
277
- class CanterWebLaunchConfig:
278
- """Process-level model and server configuration for the bundled application."""
279
-
280
- model: str
281
- revision: str | None
282
- dtype: WeightDType
283
- text_backend: TextAttentionBackend
284
- device: str
285
- cache_dir: Path | None
286
- server_name: str
287
- server_port: int
288
- share: bool
289
- in_browser: bool
290
-
291
-
292
- @dataclass(frozen=True)
293
- class _BasicInputs:
294
- """Prompt and basic generation controls rendered above advanced settings."""
295
-
296
- prompt: gr.Textbox
297
- image_count: gr.Slider
298
- size_preset: gr.Dropdown
299
- width: gr.Number
300
- height: gr.Number
301
- seed: gr.Number
302
-
303
-
304
- @dataclass(frozen=True)
305
- class _WebInputs:
306
- """Gradio components in the exact callback argument order."""
307
-
308
- prompt: gr.Textbox
309
- image_count: gr.Slider
310
- width: gr.Number
311
- height: gr.Number
312
- seed: gr.Number
313
- steps: gr.Slider
314
- solver: gr.Dropdown
315
- schedule: gr.Dropdown
316
- log_snr_shift: gr.Slider
317
- self_attention_gain: gr.Slider
318
- euler_maruyama_multiplier: gr.Slider
319
- cfg_enabled: gr.Checkbox
320
- cfg_scale: gr.Slider
321
- cfg_start_step: gr.Number
322
- cfg_stop_step: gr.Textbox
323
- pdg_enabled: gr.Checkbox
324
- pdg_mode: gr.Dropdown
325
- pdg_curve: gr.Dropdown
326
- pdg_noisy_scale: gr.Slider
327
- pdg_clean_scale: gr.Slider
328
- pdg_power: gr.Slider
329
- pdg_start_step: gr.Number
330
- pdg_stop_step: gr.Textbox
331
-
332
- def ordered(self) -> tuple[gr.Component, ...]:
333
- """Return components in the order accepted by ``generate``."""
334
-
335
- return (
336
- self.prompt,
337
- self.image_count,
338
- self.width,
339
- self.height,
340
- self.seed,
341
- self.steps,
342
- self.solver,
343
- self.schedule,
344
- self.log_snr_shift,
345
- self.self_attention_gain,
346
- self.euler_maruyama_multiplier,
347
- self.cfg_enabled,
348
- self.cfg_scale,
349
- self.cfg_start_step,
350
- self.cfg_stop_step,
351
- self.pdg_enabled,
352
- self.pdg_mode,
353
- self.pdg_curve,
354
- self.pdg_noisy_scale,
355
- self.pdg_clean_scale,
356
- self.pdg_power,
357
- self.pdg_start_step,
358
- self.pdg_stop_step,
359
- )
360
-
361
-
362
- @dataclass(frozen=True)
363
- class _WebOutputs:
364
- """Image slots and status component in callback output order."""
365
-
366
- images: tuple[gr.Image, gr.Image, gr.Image, gr.Image]
367
- status: gr.Markdown
368
- progress_timer: gr.Timer
369
-
370
- def ordered(self) -> tuple[gr.Component, ...]:
371
- """Return image, status, and progress-lifecycle callback outputs."""
372
-
373
- return (*self.images, self.status, self.progress_timer)
374
-
375
-
376
- _ImageSlotUpdate: TypeAlias = Image.Image | dict[str, str] | None
377
- _GenerationUpdate: TypeAlias = tuple[
378
- _ImageSlotUpdate,
379
- _ImageSlotUpdate,
380
- _ImageSlotUpdate,
381
- _ImageSlotUpdate,
382
- str,
383
- dict[str, object],
384
- ]
385
-
386
-
387
- class _GenerationStoppedError(RuntimeError):
388
- """Cooperative-cancellation signal for one web generation."""
389
-
390
-
391
- @dataclass(frozen=True)
392
- class _WebProgressState:
393
- """Current solver position for one browser generation request."""
394
-
395
- image_index: int
396
- image_count: int
397
- completed_updates: int
398
- total_updates: int
399
-
400
-
401
- @dataclass(frozen=True)
402
- class _WebGenerationState:
403
- """Cancellation and progress state owned by one browser generation."""
404
-
405
- stop_requested: threading.Event
406
- progress: _WebProgressState
407
-
408
-
409
- class CanterWebController:
410
- """Translate Gradio values into one deterministic pipeline invocation."""
411
-
412
- def __init__(
413
- self,
414
- pipeline: _ImagePipeline | None,
415
- *,
416
- load_config: CanterWebLaunchConfig | None = None,
417
- ) -> None:
418
- """Retain a pipeline or the exact configuration needed to load it."""
419
-
420
- if pipeline is None and load_config is None:
421
- raise ValueError("A lazy Canter web controller requires load_config.")
422
- if pipeline is not None and load_config is not None:
423
- raise ValueError("Provide either pipeline or load_config, not both.")
424
-
425
- self.pipeline = pipeline
426
- self._load_config = load_config
427
- self._load_lock = threading.Lock()
428
- self._session_lock = threading.Lock()
429
- self._generation_by_session: dict[str, _WebGenerationState] = {}
430
-
431
- def load(self) -> tuple[str, str, dict[str, object], dict[str, object]]:
432
- """Load once, then return ready-state UI updates for every browser session."""
433
-
434
- pipeline = self._ensure_loaded()
435
- return (
436
- _model_summary(pipeline.metadata),
437
- "Ready. The first request includes compiled graph warm-up.",
438
- gr.update(interactive=True),
439
- gr.update(interactive=True),
440
- )
441
-
442
- def request_stop(self, request: gr.Request | None = None) -> str:
443
- """Cancel only the active generation owned by the requesting browser."""
444
-
445
- session_hash = _session_hash(request)
446
- with self._session_lock:
447
- state = self._generation_by_session.get(session_hash)
448
- if state is None:
449
- return "No generation is currently active in this browser session."
450
- state.stop_requested.set()
451
- return "Stopping after the current solver step…"
452
-
453
- def generate( # noqa: PLR0917 - Gradio supplies the explicit form fields.
454
- self,
455
- prompt: str,
456
- image_count: int | float,
457
- width: int | float,
458
- height: int | float,
459
- seed: int | float,
460
- steps: int | float,
461
- solver: str,
462
- schedule: str,
463
- log_snr_shift: int | float,
464
- self_attention_gain: int | float,
465
- euler_maruyama_multiplier: int | float,
466
- cfg_enabled: bool,
467
- cfg_scale: int | float,
468
- cfg_start_step: int | float,
469
- cfg_stop_step: int | float | str | None,
470
- pdg_enabled: bool,
471
- pdg_mode: str,
472
- pdg_curve: str,
473
- pdg_noisy_scale: int | float,
474
- pdg_clean_scale: int | float,
475
- pdg_power: int | float,
476
- pdg_start_step: int | float,
477
- pdg_stop_step: int | float | str | None,
478
- browser_request: gr.Request | None = None,
479
- ) -> Generator[_GenerationUpdate, None, None]:
480
- """Yield each batch-one image as soon as its decoding completes."""
481
-
482
- session_hash = _session_hash(browser_request)
483
- pipeline = self._ensure_loaded()
484
- request = build_web_request(
485
- prompt=prompt,
486
- image_count=image_count,
487
- width=width,
488
- height=height,
489
- seed=seed,
490
- steps=steps,
491
- solver=solver,
492
- schedule=schedule,
493
- log_snr_shift=log_snr_shift,
494
- self_attention_gain=self_attention_gain,
495
- euler_maruyama_multiplier=euler_maruyama_multiplier,
496
- cfg_enabled=cfg_enabled,
497
- cfg_scale=cfg_scale,
498
- cfg_start_step=cfg_start_step,
499
- cfg_stop_step=cfg_stop_step,
500
- pdg_enabled=pdg_enabled,
501
- pdg_mode=pdg_mode,
502
- pdg_curve=pdg_curve,
503
- pdg_noisy_scale=pdg_noisy_scale,
504
- pdg_clean_scale=pdg_clean_scale,
505
- pdg_power=pdg_power,
506
- pdg_start_step=pdg_start_step,
507
- pdg_stop_step=pdg_stop_step,
508
- )
509
- _log_generation_request(request)
510
- started = time.perf_counter()
511
- guidance = _pdg_status(request.config.inference)
512
- stop_requested = self._begin_generation(
513
- session_hash,
514
- image_index=0,
515
- image_count=request.image_count,
516
- completed_updates=0,
517
- total_updates=request.config.inference.steps,
518
- )
519
- try:
520
- yield _generation_update(
521
- _cleared_image_slots(),
522
- f"Generating **{request.image_count} images** with batch size `1` · "
523
- f"{guidance}…",
524
- progress_active=True,
525
- )
526
- for image_index in range(request.image_count):
527
- self._raise_if_stopped(stop_requested)
528
- image_number = image_index + 1
529
- self._set_progress(
530
- session_hash,
531
- image_index=image_index,
532
- image_count=request.image_count,
533
- completed_updates=0,
534
- total_updates=request.config.inference.steps,
535
- )
536
- image_config = _image_config(request, image_index)
537
- output = pipeline(
538
- request.prompt,
539
- config=image_config,
540
- initial_noise=None,
541
- progress=_step_progress(
542
- stopped=stop_requested.is_set,
543
- update_web=lambda completed, total, index=image_index: (
544
- self._set_progress(
545
- session_hash,
546
- image_index=index,
547
- image_count=request.image_count,
548
- completed_updates=completed,
549
- total_updates=total,
550
- )
551
- ),
552
- ),
553
- )
554
- self._raise_if_stopped(stop_requested)
555
- if output.images is None or len(output.images) != 1:
556
- raise RuntimeError(
557
- "Each batch-one Canter web invocation must return one PIL image."
558
- )
559
- image = _attach_png_metadata(
560
- output.images[0],
561
- prompt=request.prompt,
562
- config=image_config,
563
- metadata=pipeline.metadata,
564
- )
565
- elapsed = time.perf_counter() - started
566
- status = _generation_status(
567
- pipeline.metadata,
568
- request=request,
569
- elapsed=elapsed,
570
- completed=image_number,
571
- )
572
- yield _generation_update(
573
- _completed_image_slots(
574
- image=image,
575
- image_index=image_index,
576
- image_count=request.image_count,
577
- ),
578
- status,
579
- progress_active=None,
580
- )
581
- except _GenerationStoppedError:
582
- yield _generation_update(
583
- _preserved_image_slots(),
584
- "Stopped. Ready for another request.",
585
- progress_active=None,
586
- )
587
- finally:
588
- self._finish_generation(session_hash, stop_requested)
589
-
590
- def poll_progress(
591
- self,
592
- request: gr.Request | None = None,
593
- ) -> tuple[str, str, str, str, str, dict[str, object]]:
594
- """Return progress HTML and whether this session still needs polling."""
595
-
596
- session_hash = _session_hash(request)
597
- with self._session_lock:
598
- generation = self._generation_by_session.get(session_hash)
599
- state = None if generation is None else generation.progress
600
- return (*_progress_html(state), gr.update(active=generation is not None))
601
-
602
- def _raise_if_stopped(self, stop_requested: threading.Event) -> None:
603
- """Abort at the next safe boundary after a web stop request."""
604
-
605
- if stop_requested.is_set():
606
- raise _GenerationStoppedError
607
-
608
- def _begin_generation(
609
- self,
610
- session_hash: str,
611
- *,
612
- image_index: int,
613
- image_count: int,
614
- completed_updates: int,
615
- total_updates: int,
616
- ) -> threading.Event:
617
- """Register and return cancellation state for a new browser generation."""
618
-
619
- progress = _WebProgressState(
620
- image_index=image_index,
621
- image_count=image_count,
622
- completed_updates=completed_updates,
623
- total_updates=total_updates,
624
- )
625
- stop_requested = threading.Event()
626
- state = _WebGenerationState(
627
- stop_requested=stop_requested,
628
- progress=progress,
629
- )
630
- with self._session_lock:
631
- if session_hash in self._generation_by_session:
632
- raise RuntimeError(
633
- "A browser session cannot run multiple Canter generations."
634
- )
635
- self._generation_by_session[session_hash] = state
636
- return stop_requested
637
-
638
- def _set_progress(
639
- self,
640
- session_hash: str,
641
- *,
642
- image_index: int,
643
- image_count: int,
644
- completed_updates: int,
645
- total_updates: int,
646
- ) -> None:
647
- """Publish one solver position for the requesting browser session."""
648
-
649
- progress = _WebProgressState(
650
- image_index=image_index,
651
- image_count=image_count,
652
- completed_updates=completed_updates,
653
- total_updates=total_updates,
654
- )
655
- with self._session_lock:
656
- state = self._generation_by_session.get(session_hash)
657
- if state is None:
658
- raise RuntimeError(
659
- "Cannot update progress without an active generation."
660
- )
661
- self._generation_by_session[session_hash] = replace(
662
- state,
663
- progress=progress,
664
- )
665
-
666
- def _finish_generation(
667
- self,
668
- session_hash: str,
669
- stop_requested: threading.Event,
670
- ) -> None:
671
- """Remove exactly the browser generation that has finished."""
672
-
673
- with self._session_lock:
674
- state = self._generation_by_session.get(session_hash)
675
- if state is None or state.stop_requested is not stop_requested:
676
- raise RuntimeError(
677
- "Canter browser generation state became inconsistent."
678
- )
679
- del self._generation_by_session[session_hash]
680
-
681
- def _ensure_loaded(self) -> _ImagePipeline:
682
- """Return the process pipeline, loading it exactly once when necessary."""
683
-
684
- with self._load_lock:
685
- if self.pipeline is None:
686
- config = self._load_config
687
- if config is None:
688
- raise RuntimeError(
689
- "Lazy Canter loading is missing its launch configuration."
690
- )
691
- self.pipeline = load_web_pipeline(config)
692
- return self.pipeline
693
-
694
-
695
- def _preserved_image_slots() -> tuple[
696
- dict[str, str],
697
- dict[str, str],
698
- dict[str, str],
699
- dict[str, str],
700
- ]:
701
- """Return four no-op updates that keep displayed images visible."""
702
-
703
- return (gr.skip(), gr.skip(), gr.skip(), gr.skip())
704
-
705
-
706
- def _cleared_image_slots() -> tuple[None, None, None, None]:
707
- """Clear all prior results before starting a new generation."""
708
-
709
- return (None, None, None, None)
710
-
711
-
712
- def _completed_image_slots(
713
- *,
714
- image: Image.Image,
715
- image_index: int,
716
- image_count: int,
717
- ) -> tuple[_ImageSlotUpdate, _ImageSlotUpdate, _ImageSlotUpdate, _ImageSlotUpdate]:
718
- """Update only the new image and clear unused slots after the final image."""
719
-
720
- updates: list[_ImageSlotUpdate] = list(_preserved_image_slots())
721
- updates[image_index] = image
722
- if image_index + 1 == image_count:
723
- for unused_index in range(image_count, _MAX_IMAGE_COUNT):
724
- updates[unused_index] = None
725
- return (updates[0], updates[1], updates[2], updates[3])
726
-
727
-
728
- def _generation_update(
729
- image_slots: Sequence[_ImageSlotUpdate],
730
- status: str,
731
- *,
732
- progress_active: bool | None,
733
- ) -> _GenerationUpdate:
734
- """Return image, status, and timer updates expected by Gradio."""
735
-
736
- if len(image_slots) != _MAX_IMAGE_COUNT:
737
- raise ValueError(
738
- f"Expected {_MAX_IMAGE_COUNT} web image slots, got {len(image_slots)}."
739
- )
740
- return (
741
- image_slots[0],
742
- image_slots[1],
743
- image_slots[2],
744
- image_slots[3],
745
- status,
746
- (gr.skip() if progress_active is None else gr.update(active=progress_active)),
747
- )
748
-
749
-
750
- def _step_progress(
751
- *,
752
- stopped: Callable[[], bool],
753
- update_web: Callable[[int, int], None],
754
- ) -> SolverProgress:
755
- """Create an exact solver-update reporter for one sequential image."""
756
-
757
- def report(completed: int, total: int) -> None:
758
- """Update the current image bar while retaining the total image bar."""
759
-
760
- if stopped():
761
- raise _GenerationStoppedError
762
- update_web(completed, total)
763
-
764
- return report
765
-
766
-
767
- def _session_hash(request: gr.Request | None) -> str:
768
- """Return the required Gradio browser-session identifier."""
769
-
770
- if request is None or request.session_hash is None or not request.session_hash:
771
- raise ValueError("Web generation requires a Gradio browser session hash.")
772
- return request.session_hash
773
-
774
-
775
- def _progress_html(
776
- state: _WebProgressState | None,
777
- ) -> tuple[str, str, str, str, str]:
778
- """Render the total bar and the active image's solver overlay."""
779
-
780
- slots = ["", "", "", ""]
781
- if state is None:
782
- return ("", slots[0], slots[1], slots[2], slots[3])
783
- image_fraction = state.completed_updates / state.total_updates
784
- total_fraction = (state.image_index + image_fraction) / state.image_count
785
- image_percent = 100.0 * image_fraction
786
- total_percent = 100.0 * total_fraction
787
- total_html = f"""
788
- <div class="canter-progress-panel">
789
- <div class="canter-progress-label">
790
- Total progress · Image {state.image_index + 1}/{state.image_count}
791
- </div>
792
- <div class="canter-progress-row">
793
- <span>Total</span>
794
- <div class="canter-progress-track">
795
- <div style="width: {total_percent:.2f}%"></div>
796
- </div>
797
- <span>{total_percent:.0f}%</span>
798
- </div>
799
- </div>
800
- """
801
- slots[state.image_index] = f"""
802
- <div class="canter-progress-panel">
803
- <div class="canter-progress-label">
804
- Image {state.image_index + 1}/{state.image_count}
805
- · {state.completed_updates}/{state.total_updates} updates
806
- </div>
807
- <div class="canter-progress-row">
808
- <span>Image</span>
809
- <div class="canter-progress-track">
810
- <div style="width: {image_percent:.2f}%"></div>
811
- </div>
812
- <span>{image_percent:.0f}%</span>
813
- </div>
814
- </div>
815
- """
816
- return (total_html, slots[0], slots[1], slots[2], slots[3])
817
-
818
-
819
- def _choice(
820
- value: str | None,
821
- choices: Mapping[str, _Choice],
822
- name: str,
823
- ) -> _Choice:
824
- """Resolve one exact enum-backed form choice or fail with its field name."""
825
-
826
- if value is None:
827
- raise ValueError(f"{name} is required.")
828
- selected = choices.get(value)
829
- if selected is None:
830
- raise ValueError(f"Unsupported {name}: {value!r}.")
831
- return selected
832
-
833
-
834
- def _integer(
835
- value: int | float | None,
836
- name: str,
837
- *,
838
- minimum: int,
839
- maximum: int | None,
840
- ) -> int:
841
- """Convert one integral form number and enforce its explicit bounds."""
842
-
843
- if value is None or isinstance(value, bool):
844
- raise TypeError(f"{name} must be an integer.")
845
- number = float(value)
846
- if not math.isfinite(number) or not number.is_integer():
847
- raise ValueError(f"{name} must be a finite integer.")
848
- result = int(number)
849
- if result < minimum:
850
- raise ValueError(f"{name} must be at least {minimum}.")
851
- if maximum is not None and result > maximum:
852
- raise ValueError(f"{name} must not exceed {maximum}.")
853
- return result
854
-
855
-
856
- def _optional_integer(
857
- value: int | float | str | None,
858
- name: str,
859
- ) -> int | None:
860
- """Convert a blank solver stop field to ``None`` or validate an index."""
861
-
862
- if value is None:
863
- return None
864
- if isinstance(value, str):
865
- stripped = value.strip()
866
- if not stripped:
867
- return None
868
- try:
869
- numeric = float(stripped)
870
- except ValueError as exc:
871
- raise ValueError(f"{name} must be blank or a finite integer.") from exc
872
- return _integer(numeric, name, minimum=0, maximum=None)
873
- return _integer(value, name, minimum=0, maximum=None)
874
-
875
-
876
- def _number(value: int | float, name: str) -> float:
877
- """Require one finite floating-point form value."""
878
-
879
- if isinstance(value, bool):
880
- raise TypeError(f"{name} must be a number.")
881
- result = float(value)
882
- if not math.isfinite(result):
883
- raise ValueError(f"{name} must be finite.")
884
- return result
885
-
886
-
887
- def _image_dimension(value: int | float | None, name: str) -> int:
888
- """Snap one finite image dimension to the supported 32-pixel lattice."""
889
-
890
- if value is None:
891
- raise ValueError(f"{name} is required.")
892
- number = _number(value, name)
893
- if number < _IMAGE_SIZE_MULTIPLE:
894
- raise ValueError(f"{name} must be at least {_IMAGE_SIZE_MULTIPLE}.")
895
- return snap_image_dimension(number)
896
-
897
-
898
- def snap_image_dimension(value: int | float | None) -> int:
899
- """Snap one browser image dimension to its nearest valid multiple."""
900
-
901
- if value is None:
902
- raise ValueError("image size is required.")
903
- number = _number(value, "image size")
904
- snapped = math.floor(
905
- (max(number, _IMAGE_SIZE_MULTIPLE) + _IMAGE_SIZE_MULTIPLE / 2)
906
- / _IMAGE_SIZE_MULTIPLE
907
- )
908
- return snapped * _IMAGE_SIZE_MULTIPLE
909
-
910
-
911
- def _validated_prompt(prompt: str) -> str:
912
- """Return a string prompt without changing the user's text."""
913
-
914
- if not isinstance(prompt, str):
915
- raise TypeError("prompt must be a string.")
916
- return prompt
917
-
918
-
919
- def _pdg_mode_uses_cfg(mode: PdgMode) -> bool:
920
- """Return whether one public PDG mode consumes the CFG scale."""
921
-
922
- match mode:
923
- case (
924
- PdgMode.ALTERNATE_PDG_FIRST
925
- | PdgMode.ALTERNATE_CFG_FIRST
926
- | PdgMode.COMBINED_CFG_PDG
927
- | PdgMode.PDG_WITH_ALTERNATING_CFG
928
- | PdgMode.CFG_TO_PDG
929
- ):
930
- return True
931
- case PdgMode.FULL | PdgMode.THREE_QUARTER:
932
- return False
933
- case PdgMode.NONE:
934
- raise ValueError("The enabled web PDG selection cannot use mode none.")
935
- case _ as unreachable:
936
- raise RuntimeError(f"Unsupported PDG mode: {unreachable}")
937
-
938
-
939
- def update_pdg_clean_scale(
940
- curve: str,
941
- noisy_scale: int | float,
942
- ) -> dict[str, object]:
943
- """Disable and synchronize the unused clean endpoint for constant PDG."""
944
-
945
- resolved_curve = _choice(curve, _PDG_CURVES, "PDG curve")
946
- match resolved_curve:
947
- case PdgCurve.CONSTANT:
948
- return gr.update(
949
- value=_number(noisy_scale, "PDG noisy scale"),
950
- interactive=False,
951
- )
952
- case PdgCurve.LINEAR | PdgCurve.POWER:
953
- return gr.update(interactive=True)
954
- case _ as unreachable:
955
- raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
956
-
957
-
958
- def build_web_request(
959
- *,
960
- prompt: str,
961
- image_count: int | float,
962
- width: int | float,
963
- height: int | float,
964
- seed: int | float,
965
- steps: int | float,
966
- solver: str,
967
- schedule: str,
968
- log_snr_shift: int | float,
969
- self_attention_gain: int | float,
970
- euler_maruyama_multiplier: int | float,
971
- cfg_enabled: bool,
972
- cfg_scale: int | float,
973
- cfg_start_step: int | float,
974
- cfg_stop_step: int | float | str | None,
975
- pdg_enabled: bool,
976
- pdg_mode: str,
977
- pdg_curve: str,
978
- pdg_noisy_scale: int | float,
979
- pdg_clean_scale: int | float,
980
- pdg_power: int | float,
981
- pdg_start_step: int | float,
982
- pdg_stop_step: int | float | str | None,
983
- ) -> CanterWebRequest:
984
- """Validate raw Gradio values and construct the frozen Canter dataclasses."""
985
-
986
- resolved_pdg_mode = _choice(pdg_mode, _PDG_MODES, "PDG mode")
987
- active_pdg_mode = resolved_pdg_mode if pdg_enabled else PdgMode.NONE
988
- uses_cfg_scale = pdg_enabled and _pdg_mode_uses_cfg(resolved_pdg_mode)
989
- resolved_cfg_scale = (
990
- _number(cfg_scale, "CFG scale") if cfg_enabled or uses_cfg_scale else None
991
- )
992
- cfg = CfgGuidance(
993
- enabled=cfg_enabled,
994
- scale=resolved_cfg_scale,
995
- start_step=_integer(
996
- cfg_start_step,
997
- "CFG start step",
998
- minimum=0,
999
- maximum=None,
1000
- ),
1001
- stop_step=_optional_integer(cfg_stop_step, "CFG stop step"),
1002
- )
1003
- resolved_pdg_curve = _choice(pdg_curve, _PDG_CURVES, "PDG curve")
1004
- resolved_pdg_noisy_scale = _number(pdg_noisy_scale, "PDG noisy scale")
1005
- resolved_pdg_clean_scale = (
1006
- resolved_pdg_noisy_scale
1007
- if resolved_pdg_curve is PdgCurve.CONSTANT
1008
- else _number(pdg_clean_scale, "PDG clean scale")
1009
- )
1010
- pdg = PdgGuidance(
1011
- enabled=pdg_enabled,
1012
- mode=active_pdg_mode,
1013
- curve=resolved_pdg_curve,
1014
- noisy_scale=resolved_pdg_noisy_scale,
1015
- clean_scale=resolved_pdg_clean_scale,
1016
- power=_number(pdg_power, "PDG power"),
1017
- start_step=_integer(
1018
- pdg_start_step,
1019
- "PDG start step",
1020
- minimum=0,
1021
- maximum=None,
1022
- ),
1023
- stop_step=_optional_integer(pdg_stop_step, "PDG stop step"),
1024
- )
1025
- resolved_image_count = _integer(
1026
- image_count,
1027
- "image count",
1028
- minimum=1,
1029
- maximum=_MAX_IMAGE_COUNT,
1030
- )
1031
- resolved_seed = _integer(
1032
- seed,
1033
- "seed",
1034
- minimum=0,
1035
- maximum=_MAX_SEED - resolved_image_count + 1,
1036
- )
1037
- inference = CanterInferenceConfig(
1038
- height=_image_dimension(height, "height"),
1039
- width=_image_dimension(width, "width"),
1040
- steps=_integer(steps, "steps", minimum=1, maximum=None),
1041
- solver=_choice(solver, _SOLVERS, "solver"),
1042
- schedule=_choice(schedule, _SCHEDULES, "schedule"),
1043
- log_snr_shift=_number(log_snr_shift, "log-SNR shift"),
1044
- cfg=cfg,
1045
- pdg=pdg,
1046
- self_attention_gain=_number(
1047
- self_attention_gain,
1048
- "self-attention gain",
1049
- ),
1050
- euler_maruyama_multiplier=_number(
1051
- euler_maruyama_multiplier,
1052
- "SDE noise multiplier",
1053
- ),
1054
- er_sde_noise_multiplier=_number(
1055
- euler_maruyama_multiplier,
1056
- "SDE noise multiplier",
1057
- ),
1058
- seed=resolved_seed,
1059
- generator=None,
1060
- )
1061
- return CanterWebRequest(
1062
- prompt=_validated_prompt(prompt),
1063
- image_count=resolved_image_count,
1064
- config=CanterPipelineConfig(
1065
- inference=inference,
1066
- output_type=CanterOutputType.PIL,
1067
- ),
1068
- )
1069
-
1070
-
1071
- def _image_config(
1072
- request: CanterWebRequest,
1073
- image_index: int,
1074
- ) -> CanterPipelineConfig:
1075
- """Return a batch-one config using the next deterministic image seed."""
1076
-
1077
- seed = request.config.inference.seed
1078
- if seed is None:
1079
- raise RuntimeError("A validated Canter web request must contain a seed.")
1080
- inference = replace(
1081
- request.config.inference,
1082
- seed=seed + image_index,
1083
- generator=None,
1084
- )
1085
- return replace(request.config, inference=inference)
1086
-
1087
-
1088
- def _pdg_metadata(pdg: PdgGuidance) -> dict[str, object]:
1089
- """Return PDG fields in public API order."""
1090
-
1091
- return {
1092
- "enabled": pdg.enabled,
1093
- "mode": pdg.mode.value,
1094
- "curve": pdg.curve.value,
1095
- "noisy_scale": pdg.noisy_scale,
1096
- "clean_scale": pdg.clean_scale,
1097
- "power": pdg.power,
1098
- "start_step": pdg.start_step,
1099
- "stop_step": pdg.stop_step,
1100
- }
1101
-
1102
-
1103
- def _cfg_metadata(cfg: CfgGuidance) -> dict[str, object]:
1104
- """Return CFG fields in public API order."""
1105
-
1106
- return {
1107
- "enabled": cfg.enabled,
1108
- "scale": cfg.scale,
1109
- "start_step": cfg.start_step,
1110
- "stop_step": cfg.stop_step,
1111
- }
1112
-
1113
-
1114
- def _png_metadata_json(
1115
- *,
1116
- prompt: str,
1117
- config: CanterPipelineConfig,
1118
- metadata: CanterPipelineMetadata,
1119
- ) -> str:
1120
- """Serialize reproducible UI settings with primary inputs first."""
1121
-
1122
- inference = config.inference
1123
- if inference.seed is None:
1124
- raise RuntimeError("PNG metadata requires the resolved per-image seed.")
1125
- payload: dict[str, object] = {
1126
- "prompt": prompt,
1127
- "seed": inference.seed,
1128
- "width": inference.width,
1129
- "height": inference.height,
1130
- "steps": inference.steps,
1131
- "solver": inference.solver.value,
1132
- "schedule": inference.schedule.value,
1133
- "pdg": _pdg_metadata(inference.pdg),
1134
- "cfg": _cfg_metadata(inference.cfg),
1135
- "self_attention_gain": inference.self_attention_gain,
1136
- "log_snr_shift": inference.log_snr_shift,
1137
- "euler_maruyama_multiplier": inference.euler_maruyama_multiplier,
1138
- "er_sde_noise_multiplier": inference.er_sde_noise_multiplier,
1139
- "code_version": metadata.code_version,
1140
- "release": metadata.canter.release,
1141
- "weight_dtype": metadata.canter.weight_dtype.value,
1142
- }
1143
- return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
1144
-
1145
-
1146
- def _attach_png_metadata(
1147
- image: Image.Image,
1148
- *,
1149
- prompt: str,
1150
- config: CanterPipelineConfig,
1151
- metadata: CanterPipelineMetadata,
1152
- ) -> Image.Image:
1153
- """Attach the ordered API query for Gradio's PNG encoder."""
1154
-
1155
- image.info[_PNG_METADATA_KEY] = _png_metadata_json(
1156
- prompt=prompt,
1157
- config=config,
1158
- metadata=metadata,
1159
- )
1160
- return image
1161
-
1162
-
1163
- def random_seed() -> int:
1164
- """Return a non-negative seed accepted by the Canter web form."""
1165
-
1166
- return secrets.randbelow(_MAX_SEED + 1)
1167
-
1168
-
1169
- def _model_summary(metadata: CanterPipelineMetadata) -> str:
1170
- """Render immutable model and decoder provenance above the controls."""
1171
-
1172
- canter = metadata.canter
1173
- return (
1174
- f"**Canter checkpoint {canter.release}** · "
1175
- f"code `{metadata.code_version}` · "
1176
- f"{canter.weight_dtype.value} EMA weights \n"
1177
- f"VAE `{metadata.vae_repository}` @ `{metadata.vae_revision[:12]}`"
1178
- )
1179
-
1180
-
1181
- def _log_generation_request(request: CanterWebRequest) -> None:
1182
- """Print the exact effective controls consumed by one generation request."""
1183
-
1184
- inference = request.config.inference
1185
- prompt_digest = sha256(request.prompt.encode("utf-8")).hexdigest()[:12]
1186
- print(
1187
- "Canter request | "
1188
- f"prompt_sha256={prompt_digest} | images={request.image_count} | "
1189
- f"size={inference.width}x{inference.height} | seed={inference.seed} | "
1190
- f"steps={inference.steps} | solver={inference.solver.value} | "
1191
- f"schedule={inference.schedule.value} | "
1192
- f"log_snr_shift={inference.log_snr_shift:g} | "
1193
- f"self_attention_gain={inference.self_attention_gain:g} | "
1194
- f"{_pdg_status(inference)} | cfg_enabled={inference.cfg.enabled} | "
1195
- f"cfg_scale={inference.cfg.scale}",
1196
- flush=True,
1197
- )
1198
-
1199
-
1200
- def _pdg_status(inference: CanterInferenceConfig) -> str:
1201
- """Render the effective PDG request consumed by latent inference."""
1202
-
1203
- pdg = inference.pdg
1204
- if not pdg.enabled:
1205
- return "PDG off"
1206
- stop = inference.steps - 1 if pdg.stop_step is None else pdg.stop_step
1207
- match pdg.curve:
1208
- case PdgCurve.CONSTANT:
1209
- scale = f"{float(pdg.noisy_scale):g}"
1210
- case PdgCurve.LINEAR:
1211
- scale = f"linear {float(pdg.noisy_scale):g}→{float(pdg.clean_scale):g}"
1212
- case PdgCurve.POWER:
1213
- scale = (
1214
- f"power {float(pdg.noisy_scale):g}→"
1215
- f"{float(pdg.clean_scale):g} (p={float(pdg.power):g})"
1216
- )
1217
- case _ as unreachable:
1218
- raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
1219
- return f"PDG `{pdg.mode.value}` {scale}, updates {pdg.start_step}–{stop}"
1220
-
1221
-
1222
- def _generation_status(
1223
- metadata: CanterPipelineMetadata,
1224
- *,
1225
- request: CanterWebRequest,
1226
- elapsed: float,
1227
- completed: int,
1228
- ) -> str:
1229
- """Render incremental timing and deterministic request provenance."""
1230
-
1231
- inference = request.config.inference
1232
- image_word = "image" if completed == 1 else "images"
1233
- if inference.seed is None:
1234
- raise RuntimeError("A validated Canter web request must contain a seed.")
1235
- final_seed = inference.seed + completed - 1
1236
- seed_text = (
1237
- str(inference.seed) if completed == 1 else f"{inference.seed}–{final_seed}"
1238
- )
1239
- progress_text = (
1240
- f"Generated **{completed}/{request.image_count} {image_word}**"
1241
- if completed < request.image_count
1242
- else f"Generated **{completed} {image_word}**"
1243
- )
1244
- return (
1245
- f"{progress_text} in **{elapsed:.2f}s** · "
1246
- f"seeds `{seed_text}` · batch size `1` · "
1247
- f"{inference.width}×{inference.height} · "
1248
- f"{inference.steps} {inference.solver.value} updates · "
1249
- f"{_pdg_status(inference)} · "
1250
- f"Canter code `{metadata.code_version}` · "
1251
- f"checkpoint `{metadata.canter.release}`"
1252
- )
1253
-
1254
-
1255
- def _prompt_input() -> gr.Textbox:
1256
- """Create the prompt control without rendering it yet."""
1257
-
1258
- return gr.Textbox(
1259
- label="Prompt",
1260
- value=_DEFAULT_PROMPT,
1261
- lines=3,
1262
- placeholder="Describe the image you want to generate.",
1263
- render=False,
1264
- )
1265
-
1266
-
1267
- def _basic_inputs(prompt: gr.Textbox) -> _BasicInputs:
1268
- """Render prompt, image-count, preset, shape, and seed controls."""
1269
-
1270
- prompt.render()
1271
- image_count = gr.Slider(
1272
- minimum=1,
1273
- maximum=_MAX_IMAGE_COUNT,
1274
- value=_DEFAULT_IMAGE_COUNT,
1275
- step=1,
1276
- precision=0,
1277
- label="Images",
1278
- info="Generated sequentially with batch size 1.",
1279
- )
1280
- size_preset = gr.Dropdown(
1281
- choices=_SIZE_PRESET_CHOICES,
1282
- value=SizePreset.DEFAULT_PORTRAIT.value,
1283
- label="Size preset",
1284
- allow_custom_value=False,
1285
- )
1286
- with gr.Row():
1287
- width = gr.Number(
1288
- value=_DEFAULT_INFERENCE.width,
1289
- label="Width",
1290
- precision=0,
1291
- minimum=_IMAGE_SIZE_MULTIPLE,
1292
- step=_IMAGE_SIZE_MULTIPLE,
1293
- )
1294
- height = gr.Number(
1295
- value=_DEFAULT_INFERENCE.height,
1296
- label="Height",
1297
- precision=0,
1298
- minimum=_IMAGE_SIZE_MULTIPLE,
1299
- step=_IMAGE_SIZE_MULTIPLE,
1300
- )
1301
- seed = gr.Number(
1302
- value=_DEFAULT_INFERENCE.seed,
1303
- label="Seed",
1304
- precision=0,
1305
- minimum=0,
1306
- maximum=_MAX_SEED,
1307
- )
1308
- return _BasicInputs(
1309
- prompt=prompt,
1310
- image_count=image_count,
1311
- size_preset=size_preset,
1312
- width=width,
1313
- height=height,
1314
- seed=seed,
1315
- )
1316
-
1317
-
1318
- def apply_size_preset(value: str | None) -> tuple[int, int, float]:
1319
- """Return width, height, and attention gain for one exact UI preset."""
1320
-
1321
- preset = _choice(value, _SIZE_PRESETS, "size preset")
1322
- selected = _SIZE_PRESET_VALUES[preset]
1323
- return selected.width, selected.height, selected.self_attention_gain
1324
-
1325
-
1326
- def schedule_log_snr_shift(value: str | None) -> float:
1327
- """Return the UI log-SNR preset associated with one schedule."""
1328
-
1329
- schedule = _choice(value, _SCHEDULES, "schedule")
1330
- match schedule:
1331
- case Schedule.BETA:
1332
- return 0.0
1333
- case Schedule.LINEAR:
1334
- return -2.3
1335
- case _ as unreachable:
1336
- raise RuntimeError(f"Unsupported Canter schedule: {unreachable}")
1337
-
1338
-
1339
- def _solver_inputs() -> tuple[
1340
- gr.Slider,
1341
- gr.Dropdown,
1342
- gr.Dropdown,
1343
- gr.Slider,
1344
- gr.Slider,
1345
- gr.Slider,
1346
- ]:
1347
- """Create solver, schedule, and model-gain controls."""
1348
-
1349
- steps = gr.Slider(
1350
- minimum=1,
1351
- maximum=100,
1352
- value=_DEFAULT_INFERENCE.steps,
1353
- step=1,
1354
- precision=0,
1355
- label="Solver updates",
1356
- )
1357
- with gr.Row():
1358
- solver = gr.Dropdown(
1359
- choices=_SOLVER_CHOICES,
1360
- value=_DEFAULT_INFERENCE.solver.value,
1361
- label="Solver",
1362
- allow_custom_value=False,
1363
- )
1364
- schedule = gr.Dropdown(
1365
- choices=_SCHEDULE_CHOICES,
1366
- value=_DEFAULT_INFERENCE.schedule.value,
1367
- label="Schedule",
1368
- allow_custom_value=False,
1369
- )
1370
- log_snr_shift = gr.Slider(
1371
- minimum=-4.0,
1372
- maximum=4.0,
1373
- value=_DEFAULT_INFERENCE.log_snr_shift,
1374
- step=0.05,
1375
- label="Log-SNR shift",
1376
- )
1377
- self_attention_gain = gr.Slider(
1378
- minimum=-0.05,
1379
- maximum=0.02,
1380
- value=_DEFAULT_INFERENCE.self_attention_gain,
1381
- step=0.005,
1382
- label="Main self-attention gain",
1383
- )
1384
- euler_maruyama_multiplier = gr.Slider(
1385
- minimum=0.0,
1386
- maximum=2.0,
1387
- value=_DEFAULT_INFERENCE.euler_maruyama_multiplier,
1388
- step=0.05,
1389
- label="SDE noise multiplier",
1390
- )
1391
- return (
1392
- steps,
1393
- solver,
1394
- schedule,
1395
- log_snr_shift,
1396
- self_attention_gain,
1397
- euler_maruyama_multiplier,
1398
- )
1399
-
1400
-
1401
- def _cfg_inputs() -> tuple[
1402
- gr.Checkbox,
1403
- gr.Slider,
1404
- gr.Number,
1405
- gr.Textbox,
1406
- ]:
1407
- """Create classifier-free guidance controls."""
1408
-
1409
- enabled = gr.Checkbox(
1410
- value=_DEFAULT_INFERENCE.cfg.enabled,
1411
- label="Enable CFG",
1412
- )
1413
- scale = gr.Slider(
1414
- minimum=0.0,
1415
- maximum=10.0,
1416
- value=3.0,
1417
- step=0.05,
1418
- label="CFG scale",
1419
- info="Also used by CFG-dependent PDG modes.",
1420
- )
1421
- with gr.Row():
1422
- start = gr.Number(
1423
- value=_DEFAULT_INFERENCE.cfg.start_step,
1424
- label="CFG start",
1425
- precision=0,
1426
- minimum=0,
1427
- )
1428
- stop = gr.Textbox(
1429
- value="",
1430
- label="CFG stop",
1431
- info="Blank means the final solver update.",
1432
- placeholder="Final",
1433
- lines=1,
1434
- )
1435
- return enabled, scale, start, stop
1436
-
1437
-
1438
- def _pdg_inputs() -> tuple[
1439
- gr.Checkbox,
1440
- gr.Dropdown,
1441
- gr.Dropdown,
1442
- gr.Slider,
1443
- gr.Slider,
1444
- gr.Slider,
1445
- gr.Number,
1446
- gr.Textbox,
1447
- ]:
1448
- """Create path-drop guidance and curve controls."""
1449
-
1450
- enabled = gr.Checkbox(
1451
- value=_DEFAULT_INFERENCE.pdg.enabled,
1452
- label="Enable PDG",
1453
- )
1454
- with gr.Row():
1455
- mode = gr.Dropdown(
1456
- choices=_PDG_MODE_CHOICES,
1457
- value=_DEFAULT_INFERENCE.pdg.mode.value,
1458
- label="PDG mode",
1459
- allow_custom_value=False,
1460
- )
1461
- curve = gr.Dropdown(
1462
- choices=_PDG_CURVE_CHOICES,
1463
- value=_DEFAULT_INFERENCE.pdg.curve.value,
1464
- label="PDG curve",
1465
- allow_custom_value=False,
1466
- )
1467
- noisy_scale = gr.Slider(
1468
- minimum=0.0,
1469
- maximum=10.0,
1470
- value=_DEFAULT_INFERENCE.pdg.noisy_scale,
1471
- step=0.05,
1472
- label="PDG scale / noisy endpoint",
1473
- )
1474
- clean_scale = gr.Slider(
1475
- minimum=0.0,
1476
- maximum=10.0,
1477
- value=_DEFAULT_INFERENCE.pdg.clean_scale,
1478
- step=0.05,
1479
- label="PDG clean endpoint",
1480
- info="Endpoint used by linear and power curves.",
1481
- interactive=_DEFAULT_INFERENCE.pdg.curve is not PdgCurve.CONSTANT,
1482
- )
1483
- power = gr.Slider(
1484
- minimum=0.05,
1485
- maximum=10.0,
1486
- value=_DEFAULT_INFERENCE.pdg.power,
1487
- step=0.05,
1488
- label="PDG power",
1489
- )
1490
- with gr.Row():
1491
- start = gr.Number(
1492
- value=_DEFAULT_INFERENCE.pdg.start_step,
1493
- label="PDG start",
1494
- precision=0,
1495
- minimum=0,
1496
- )
1497
- stop = gr.Textbox(
1498
- value="",
1499
- label="PDG stop",
1500
- info="Blank means the final solver update.",
1501
- placeholder="Final",
1502
- lines=1,
1503
- )
1504
- return enabled, mode, curve, noisy_scale, clean_scale, power, start, stop
1505
-
1506
-
1507
- def _create_inputs(
1508
- basic: _BasicInputs,
1509
- ) -> _WebInputs:
1510
- """Create advanced controls around an existing basic control group."""
1511
-
1512
- gr.Markdown("### Solver")
1513
- (
1514
- steps,
1515
- solver,
1516
- schedule,
1517
- log_snr_shift,
1518
- self_attention_gain,
1519
- euler_maruyama_multiplier,
1520
- ) = _solver_inputs()
1521
- gr.Markdown("### Path-drop guidance")
1522
- (
1523
- pdg_enabled,
1524
- pdg_mode,
1525
- pdg_curve,
1526
- pdg_noisy_scale,
1527
- pdg_clean_scale,
1528
- pdg_power,
1529
- pdg_start,
1530
- pdg_stop,
1531
- ) = _pdg_inputs()
1532
- gr.Markdown("### Classifier-free guidance")
1533
- cfg_enabled, cfg_scale, cfg_start, cfg_stop = _cfg_inputs()
1534
- return _WebInputs(
1535
- prompt=basic.prompt,
1536
- image_count=basic.image_count,
1537
- width=basic.width,
1538
- height=basic.height,
1539
- seed=basic.seed,
1540
- steps=steps,
1541
- solver=solver,
1542
- schedule=schedule,
1543
- log_snr_shift=log_snr_shift,
1544
- self_attention_gain=self_attention_gain,
1545
- euler_maruyama_multiplier=euler_maruyama_multiplier,
1546
- cfg_enabled=cfg_enabled,
1547
- cfg_scale=cfg_scale,
1548
- cfg_start_step=cfg_start,
1549
- cfg_stop_step=cfg_stop,
1550
- pdg_enabled=pdg_enabled,
1551
- pdg_mode=pdg_mode,
1552
- pdg_curve=pdg_curve,
1553
- pdg_noisy_scale=pdg_noisy_scale,
1554
- pdg_clean_scale=pdg_clean_scale,
1555
- pdg_power=pdg_power,
1556
- pdg_start_step=pdg_start,
1557
- pdg_stop_step=pdg_stop,
1558
- )
1559
-
1560
-
1561
- def _connect_generation(
1562
- controller: CanterWebController,
1563
- inputs: _WebInputs,
1564
- generate: gr.Button,
1565
- stop: gr.Button,
1566
- outputs: _WebOutputs,
1567
- ) -> None:
1568
- """Connect button and prompt submission to the serial GPU queue."""
1569
-
1570
- generate.click(
1571
- fn=controller.generate,
1572
- inputs=inputs.ordered(),
1573
- outputs=outputs.ordered(),
1574
- api_name="generate",
1575
- api_description="Generate images with the loaded Canter release.",
1576
- show_progress="hidden",
1577
- concurrency_limit=1,
1578
- concurrency_id=_GPU_CONCURRENCY_ID,
1579
- )
1580
- inputs.prompt.submit(
1581
- fn=controller.generate,
1582
- inputs=inputs.ordered(),
1583
- outputs=outputs.ordered(),
1584
- api_name=None,
1585
- show_progress="hidden",
1586
- concurrency_limit=1,
1587
- concurrency_id=_GPU_CONCURRENCY_ID,
1588
- api_visibility="private",
1589
- )
1590
- stop.click(
1591
- fn=controller.request_stop,
1592
- inputs=None,
1593
- outputs=outputs.status,
1594
- queue=False,
1595
- api_name=None,
1596
- api_visibility="private",
1597
- )
1598
-
1599
-
1600
- def _create_web_app(
1601
- controller: CanterWebController,
1602
- *,
1603
- model_status_text: str,
1604
- ready: bool,
1605
- load_config: CanterWebLaunchConfig | None,
1606
- ) -> gr.Blocks:
1607
- """Build the UI for either an already-loaded or lazy process pipeline."""
1608
-
1609
- with gr.Blocks(title="Canter", fill_width=True) as application:
1610
- gr.Markdown("# Canter", elem_id="canter-title")
1611
- model_status = gr.Markdown(model_status_text, elem_id="canter-model-status")
1612
- with gr.Row(elem_id="canter-workspace"):
1613
- with gr.Column(scale=3, min_width=360, elem_id="canter-controls"):
1614
- prompt = _prompt_input()
1615
- basic = _basic_inputs(prompt)
1616
- with gr.Row():
1617
- generate = gr.Button(
1618
- "Generate",
1619
- variant="primary",
1620
- scale=3,
1621
- interactive=ready,
1622
- )
1623
- stop = gr.Button(
1624
- "Stop",
1625
- variant="stop",
1626
- scale=1,
1627
- interactive=ready,
1628
- )
1629
- randomize = gr.Button("Random seed", variant="secondary", scale=1)
1630
- inputs = _create_inputs(basic)
1631
- with gr.Column(scale=8, min_width=640, elem_id="canter-results"):
1632
- status = gr.Markdown(
1633
- (
1634
- "Ready. The first request includes compiled graph warm-up."
1635
- if ready
1636
- else "Loading Canter weights and compiling the selected "
1637
- "backend…"
1638
- ),
1639
- elem_id="canter-status",
1640
- )
1641
- total_progress = gr.HTML(
1642
- "",
1643
- container=False,
1644
- elem_classes="canter-total-progress",
1645
- )
1646
- progress_outputs: list[gr.HTML] = []
1647
- image_outputs: list[gr.Image] = []
1648
- with gr.Row(elem_id="canter-image-grid"):
1649
- for index in range(_MAX_IMAGE_COUNT):
1650
- with gr.Column(
1651
- min_width=0,
1652
- elem_classes="canter-image-cell",
1653
- ):
1654
- progress_outputs.append(
1655
- gr.HTML(
1656
- "",
1657
- container=False,
1658
- elem_classes="canter-image-progress",
1659
- )
1660
- )
1661
- image_outputs.append(
1662
- gr.Image(
1663
- label=f"Image {index + 1}",
1664
- format="png",
1665
- height=None,
1666
- width=None,
1667
- type="pil",
1668
- show_label=False,
1669
- buttons=["download", "fullscreen"],
1670
- container=False,
1671
- interactive=False,
1672
- elem_classes="canter-output-image",
1673
- )
1674
- )
1675
- progress_timer = gr.Timer(value=0.2, active=False)
1676
- outputs = _WebOutputs(
1677
- images=(
1678
- image_outputs[0],
1679
- image_outputs[1],
1680
- image_outputs[2],
1681
- image_outputs[3],
1682
- ),
1683
- status=status,
1684
- progress_timer=progress_timer,
1685
- )
1686
- progress_timer.tick(
1687
- fn=controller.poll_progress,
1688
- inputs=None,
1689
- outputs=(
1690
- total_progress,
1691
- progress_outputs[0],
1692
- progress_outputs[1],
1693
- progress_outputs[2],
1694
- progress_outputs[3],
1695
- progress_timer,
1696
- ),
1697
- queue=False,
1698
- show_progress="hidden",
1699
- api_name=None,
1700
- api_visibility="private",
1701
- )
1702
- randomize.click(
1703
- fn=random_seed,
1704
- inputs=None,
1705
- outputs=inputs.seed,
1706
- queue=False,
1707
- api_name=None,
1708
- api_visibility="private",
1709
- )
1710
- basic.size_preset.change(
1711
- fn=apply_size_preset,
1712
- inputs=basic.size_preset,
1713
- outputs=(
1714
- basic.width,
1715
- basic.height,
1716
- inputs.self_attention_gain,
1717
- ),
1718
- queue=False,
1719
- api_name=None,
1720
- api_visibility="private",
1721
- )
1722
- inputs.schedule.change(
1723
- fn=schedule_log_snr_shift,
1724
- inputs=inputs.schedule,
1725
- outputs=inputs.log_snr_shift,
1726
- queue=False,
1727
- api_name=None,
1728
- api_visibility="private",
1729
- )
1730
- for dimension in (basic.width, basic.height):
1731
- dimension.blur(
1732
- fn=snap_image_dimension,
1733
- inputs=dimension,
1734
- outputs=dimension,
1735
- queue=False,
1736
- api_name=None,
1737
- api_visibility="private",
1738
- )
1739
- inputs.pdg_curve.change(
1740
- fn=update_pdg_clean_scale,
1741
- inputs=(inputs.pdg_curve, inputs.pdg_noisy_scale),
1742
- outputs=inputs.pdg_clean_scale,
1743
- queue=False,
1744
- api_name=None,
1745
- api_visibility="private",
1746
- )
1747
- _connect_generation(controller, inputs, generate, stop, outputs)
1748
- if load_config is not None:
1749
- application.load(
1750
- fn=controller.load,
1751
- inputs=None,
1752
- outputs=(model_status, status, generate, stop),
1753
- show_progress="minimal",
1754
- show_progress_on=status,
1755
- concurrency_limit=1,
1756
- concurrency_id=_GPU_CONCURRENCY_ID,
1757
- api_name=None,
1758
- api_visibility="private",
1759
- )
1760
- application.queue(
1761
- max_size=_MAX_QUEUE_SIZE,
1762
- default_concurrency_limit=1,
1763
- api_open=False,
1764
- )
1765
- return application
1766
-
1767
-
1768
- def create_web_app(pipeline: _ImagePipeline) -> gr.Blocks:
1769
- """Build the bundled application around one already-loaded pipeline."""
1770
-
1771
- return _create_web_app(
1772
- CanterWebController(pipeline),
1773
- model_status_text=_model_summary(pipeline.metadata),
1774
- ready=True,
1775
- load_config=None,
1776
- )
1777
-
1778
-
1779
- def create_loading_web_app(config: CanterWebLaunchConfig) -> gr.Blocks:
1780
- """Build an immediately visible UI that loads its process pipeline once."""
1781
-
1782
- return _create_web_app(
1783
- CanterWebController(None, load_config=config),
1784
- model_status_text=(
1785
- f"Loading Canter `{config.dtype.value}` weights and compiling the "
1786
- f"`{config.text_backend.value}` backend…"
1787
- ),
1788
- ready=False,
1789
- load_config=config,
1790
- )
1791
-
1792
-
1793
- def _theme() -> gr.themes.Base:
1794
- """Return Canter's compact charcoal-and-sky-blue dark theme."""
1795
-
1796
- return gr.themes.Base(
1797
- primary_hue="sky",
1798
- secondary_hue="blue",
1799
- neutral_hue="zinc",
1800
- spacing_size="sm",
1801
- radius_size="sm",
1802
- text_size="md",
1803
- font=[
1804
- gr.themes.Font("Inter"),
1805
- gr.themes.Font("Segoe UI"),
1806
- gr.themes.Font("sans-serif"),
1807
- ],
1808
- font_mono=[
1809
- gr.themes.Font("IBM Plex Mono"),
1810
- gr.themes.Font("Consolas"),
1811
- gr.themes.Font("monospace"),
1812
- ],
1813
- ).set(
1814
- body_background_fill_dark="#09090b",
1815
- block_background_fill_dark="#111113",
1816
- block_border_color_dark="#27272a",
1817
- input_background_fill_dark="#18181b",
1818
- )
1819
-
1820
 
1821
  def _argument_parser() -> argparse.ArgumentParser:
1822
  """Create the command-line surface with the installed-package model default."""
@@ -1881,19 +127,28 @@ def parse_launch_config(
1881
  )
1882
 
1883
 
1884
- def load_web_pipeline(config: CanterWebLaunchConfig) -> CanterPipeline:
1885
- """Load and compile exactly one selected Canter backend for the web process."""
 
 
 
1886
 
1887
- return CanterPipeline.from_pretrained(
1888
- config.model,
1889
- dtype=config.dtype,
1890
- text_backend=config.text_backend,
1891
- device=config.device,
1892
- revision=config.revision,
1893
- cache_dir=config.cache_dir,
1894
- compile_model=True,
1895
- vae=None,
1896
- )
 
 
 
 
 
 
1897
 
1898
 
1899
  def launch_web_app(
@@ -1901,6 +156,12 @@ def launch_web_app(
1901
  ) -> None:
1902
  """Serve the UI immediately and load Canter from its browser load event."""
1903
 
 
 
 
 
 
 
1904
  application = create_loading_web_app(config)
1905
  application.launch(
1906
  server_name=config.server_name,
 
1
+ """Public entry point for the standalone Canter web application."""
2
 
3
  from __future__ import annotations
4
 
5
  import argparse
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from collections.abc import Sequence
 
 
 
 
11
  from pathlib import Path
 
 
 
 
12
 
13
  from .blocks import TextAttentionBackend
 
 
 
 
 
 
 
14
  from .loading import WeightDType
15
+ from .webui_app import (
16
+ _CSS,
17
+ _DARK_HEAD,
18
+ SizePreset,
19
+ _theme,
20
+ apply_size_preset,
21
+ create_loading_web_app,
22
+ create_web_app,
23
+ image_grid_style,
24
+ image_grid_visibility,
25
+ prepare_image_grid,
26
+ schedule_log_snr_shift,
27
+ )
28
+ from .webui_runtime import (
29
+ CanterWebController,
30
+ CanterWebLaunchConfig,
31
+ _choice,
32
+ _integer,
33
+ build_web_request,
34
+ snap_image_dimension,
35
+ update_pdg_clean_scale,
36
+ )
37
+
38
+ __all__ = (
39
+ "CanterWebController",
40
+ "CanterWebLaunchConfig",
41
+ "SizePreset",
42
+ "apply_size_preset",
43
+ "build_web_request",
44
+ "create_loading_web_app",
45
+ "create_web_app",
46
+ "image_grid_style",
47
+ "image_grid_visibility",
48
+ "launch_web_app",
49
+ "main",
50
+ "parse_launch_config",
51
+ "prepare_image_grid",
52
+ "schedule_log_snr_shift",
53
+ "snap_image_dimension",
54
+ "update_pdg_clean_scale",
55
  )
 
 
56
 
57
  _DEFAULT_MODEL = "data-archetype/canter"
58
  _DEFAULT_SERVER_NAME = "0.0.0.0"
59
  _DEFAULT_SERVER_PORT = 7860
60
+ _TEMP_DIRECTORY_PREFIX = "canter-gradio"
61
+ _TEMP_DIRECTORY_MARKER = ".canter-owned-temp-directory"
62
+ _LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
 
 
 
 
 
 
 
 
 
 
63
  _WEIGHT_DTYPES = {value.value: value for value in WeightDType}
64
  _TEXT_BACKENDS = {value.value: value for value in TextAttentionBackend}
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  def _argument_parser() -> argparse.ArgumentParser:
68
  """Create the command-line surface with the installed-package model default."""
 
127
  )
128
 
129
 
130
+ def _prepare_canter_temp_directory(
131
+ base_directory: Path,
132
+ server_port: int,
133
+ ) -> Path:
134
+ """Replace one marked Canter cache directory and select it for Gradio."""
135
 
136
+ directory = base_directory.resolve() / f"{_TEMP_DIRECTORY_PREFIX}-{server_port}"
137
+ marker = directory / _TEMP_DIRECTORY_MARKER
138
+ if directory.exists():
139
+ if not directory.is_dir():
140
+ raise NotADirectoryError(
141
+ f"Canter temporary path is not a directory: {directory}"
142
+ )
143
+ if not marker.is_file():
144
+ raise RuntimeError(
145
+ f"Refusing to clear an unmarked temporary directory: {directory}"
146
+ )
147
+ shutil.rmtree(directory)
148
+ directory.mkdir()
149
+ marker.write_text("Owned by the Canter web UI.\n", encoding="utf-8")
150
+ os.environ["GRADIO_TEMP_DIR"] = str(directory)
151
+ return directory
152
 
153
 
154
  def launch_web_app(
 
156
  ) -> None:
157
  """Serve the UI immediately and load Canter from its browser load event."""
158
 
159
+ logging.basicConfig(format=_LOG_FORMAT)
160
+ logging.getLogger(__package__).setLevel(logging.INFO)
161
+ _prepare_canter_temp_directory(
162
+ Path(tempfile.gettempdir()),
163
+ config.server_port,
164
+ )
165
  application = create_loading_web_app(config)
166
  application.launch(
167
  server_name=config.server_name,
canter/webui_app.py ADDED
@@ -0,0 +1,1281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio component tree and callbacks for the bundled Canter web UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ import gradio as gr
9
+
10
+ from .inference import CanterInferenceConfig, PdgCurve, PdgMode
11
+ from .schedules import Schedule
12
+ from .solvers import Solver
13
+ from .webui_runtime import (
14
+ _IMAGE_SIZE_MULTIPLE,
15
+ _MAX_IMAGE_COUNT,
16
+ _MAX_SEED,
17
+ _SCHEDULES,
18
+ CanterWebController,
19
+ CanterWebLaunchConfig,
20
+ _choice,
21
+ _cleared_image_slots,
22
+ _GenerationOutputUpdate,
23
+ _image_dimension,
24
+ _ImagePipeline,
25
+ _integer,
26
+ _model_summary,
27
+ _number,
28
+ _unloaded_model_status,
29
+ random_seed,
30
+ snap_image_dimension,
31
+ update_pdg_clean_scale,
32
+ )
33
+
34
+ _MAX_QUEUE_SIZE = 8
35
+ _DEFAULT_IMAGE_COUNT = 4
36
+ _GPU_CONCURRENCY_ID = "canter-gpu"
37
+
38
+ _DEFAULT_INFERENCE = CanterInferenceConfig()
39
+ _SOLVER_CHOICES = (
40
+ ("ABM2", Solver.ABM2.value),
41
+ ("DPM++ 2M", Solver.DPMPP_2M.value),
42
+ ("Euler-Maruyama", Solver.EULER_MARUYAMA.value),
43
+ ("ER-SDE", Solver.ER_SDE.value),
44
+ ("Euler", Solver.EULER.value),
45
+ )
46
+ _SCHEDULE_CHOICES = (
47
+ ("Beta (0.6, 0.6)", Schedule.BETA.value),
48
+ ("Linear", Schedule.LINEAR.value),
49
+ )
50
+ _PDG_MODE_CHOICES = (
51
+ ("Full path drop", PdgMode.FULL.value),
52
+ ("Three-quarter path", PdgMode.THREE_QUARTER.value),
53
+ ("Alternate: PDG first", PdgMode.ALTERNATE_PDG_FIRST.value),
54
+ ("Alternate: CFG first", PdgMode.ALTERNATE_CFG_FIRST.value),
55
+ ("Combined CFG + PDG", PdgMode.COMBINED_CFG_PDG.value),
56
+ ("PDG with alternating CFG", PdgMode.PDG_WITH_ALTERNATING_CFG.value),
57
+ ("CFG then PDG", PdgMode.CFG_TO_PDG.value),
58
+ )
59
+ _PDG_CURVE_CHOICES = (
60
+ ("Constant", PdgCurve.CONSTANT.value),
61
+ ("Linear", PdgCurve.LINEAR.value),
62
+ ("Power", PdgCurve.POWER.value),
63
+ )
64
+ _DEFAULT_PROMPT = (
65
+ "Wide angle photo of a weathered wooden boardwalk descending steeply from "
66
+ "rocky cliffs toward a rugged coastline. The boardwalk, constructed from "
67
+ "aged planks with visible cracks and moss growth, is bordered by rustic "
68
+ "wooden railings on both sides. Surrounding terrain features uneven, eroded "
69
+ "rock formations covered in patches of green vegetation and low-lying "
70
+ "shrubs. Below, a crescent-shaped sandy beach meets crashing waves of a "
71
+ "deep blue ocean under a stormy, overcast sky with dark clouds and hints "
72
+ "of distant sunlight breaking through. The horizon shows turbulent waves "
73
+ "colliding with rocky outcrops, creating white foam."
74
+ )
75
+
76
+
77
+ class SizePreset(Enum):
78
+ """UI presets that jointly select image shape and attention gain."""
79
+
80
+ DEFAULT_PORTRAIT = "default_portrait"
81
+ WIDE = "wide"
82
+ TALL_PORTRAIT = "tall_portrait"
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class _SizePresetValues:
87
+ """Concrete image controls selected by one size preset."""
88
+
89
+ width: int
90
+ height: int
91
+ self_attention_gain: float
92
+
93
+
94
+ _SIZE_PRESETS = {value.value: value for value in SizePreset}
95
+ _SIZE_PRESET_CHOICES = (
96
+ ("832 × 1216 · gain −0.03", SizePreset.DEFAULT_PORTRAIT.value),
97
+ ("1536 × 832 · gain −0.02", SizePreset.WIDE.value),
98
+ ("1024 × 1536 · gain −0.02", SizePreset.TALL_PORTRAIT.value),
99
+ )
100
+ _SIZE_PRESET_VALUES = {
101
+ SizePreset.DEFAULT_PORTRAIT: _SizePresetValues(832, 1216, -0.03),
102
+ SizePreset.WIDE: _SizePresetValues(1536, 832, -0.02),
103
+ SizePreset.TALL_PORTRAIT: _SizePresetValues(1024, 1536, -0.02),
104
+ }
105
+
106
+ _DARK_HEAD = """
107
+ <script>
108
+ localStorage.setItem("theme", "dark");
109
+ document.documentElement.classList.add("dark");
110
+ document.documentElement.style.colorScheme = "dark";
111
+ const resizeCanterImages = () => {
112
+ const grid = document.querySelector("#canter-image-grid");
113
+ if (grid === null) {
114
+ return;
115
+ }
116
+ const margin = 16;
117
+ const documentTop = grid.getBoundingClientRect().top + window.scrollY;
118
+ const available = documentTop < window.innerHeight
119
+ ? window.innerHeight - documentTop - margin
120
+ : window.innerHeight - 2 * margin;
121
+ grid.style.setProperty(
122
+ "--canter-image-max-height",
123
+ `${Math.max(available, 1)}px`
124
+ );
125
+ };
126
+ new MutationObserver(() => requestAnimationFrame(resizeCanterImages)).observe(
127
+ document.documentElement,
128
+ {childList: true, subtree: true}
129
+ );
130
+ window.addEventListener("resize", resizeCanterImages);
131
+ requestAnimationFrame(resizeCanterImages);
132
+ document.addEventListener("click", (event) => {
133
+ const target = event.target;
134
+ if (!(target instanceof Element)) {
135
+ return;
136
+ }
137
+ const image = target.closest(".canter-output-image img");
138
+ if (image === null) {
139
+ return;
140
+ }
141
+ const component = image.closest(".canter-output-image");
142
+ if (component === null) {
143
+ throw new Error("Canter image component is missing.");
144
+ }
145
+ const button = component.querySelector(
146
+ 'button[aria-label="Fullscreen"], '
147
+ + 'button[aria-label="Exit fullscreen mode"]'
148
+ );
149
+ if (button === null) {
150
+ throw new Error("Canter image fullscreen control is missing.");
151
+ }
152
+ event.preventDefault();
153
+ event.stopImmediatePropagation();
154
+ button.click();
155
+ });
156
+ </script>
157
+ """
158
+
159
+ _CSS = """
160
+ :root, body {
161
+ background: #09090b;
162
+ }
163
+ .gradio-container {
164
+ max-width: none !important;
165
+ width: 100% !important;
166
+ padding: 1rem !important;
167
+ }
168
+ #canter-title h1 {
169
+ font-size: 2.1rem;
170
+ letter-spacing: -0.04em;
171
+ line-height: 1.05;
172
+ margin: 0;
173
+ }
174
+ #canter-header {
175
+ flex-wrap: nowrap !important;
176
+ align-items: center !important;
177
+ gap: 0.5rem !important;
178
+ }
179
+ #canter-title {
180
+ flex: 1 1 0 !important;
181
+ min-width: 0 !important;
182
+ overflow: visible !important;
183
+ }
184
+ .canter-header-action {
185
+ flex: 0 0 auto !important;
186
+ width: auto !important;
187
+ overflow: visible !important;
188
+ }
189
+ #canter-api-button {
190
+ margin-top: 0.75rem;
191
+ }
192
+ footer .show-api {
193
+ display: none !important;
194
+ }
195
+ #canter-workspace {
196
+ align-items: flex-start !important;
197
+ }
198
+ #canter-controls {
199
+ max-width: 440px;
200
+ min-width: 360px;
201
+ }
202
+ #canter-results {
203
+ min-width: 0;
204
+ }
205
+ #canter-image-grid {
206
+ display: grid !important;
207
+ grid-template-columns: repeat(
208
+ auto-fit,
209
+ minmax(min(100%, var(--canter-image-min-width, 640px)), 1fr)
210
+ );
211
+ align-items: start !important;
212
+ gap: 1rem !important;
213
+ height: auto !important;
214
+ max-height: none !important;
215
+ overflow: visible !important;
216
+ }
217
+ #canter-image-grid > div {
218
+ min-width: 0 !important;
219
+ }
220
+ .canter-image-cell {
221
+ position: relative;
222
+ }
223
+ .canter-total-progress {
224
+ margin-bottom: 0.75rem;
225
+ }
226
+ .canter-total-progress:not(:has(.canter-progress-panel)) {
227
+ display: none !important;
228
+ }
229
+ .canter-image-progress {
230
+ position: absolute !important;
231
+ top: 0.75rem;
232
+ left: 0.75rem;
233
+ right: 0.75rem;
234
+ z-index: 10;
235
+ pointer-events: none;
236
+ }
237
+ .canter-image-progress:not(:has(.canter-progress-panel)) {
238
+ display: none !important;
239
+ }
240
+ .canter-progress-panel {
241
+ padding: 0.65rem 0.75rem;
242
+ color: #f4f4f5;
243
+ background: rgb(9 9 11 / 88%);
244
+ border: 1px solid #3f3f46;
245
+ border-radius: 0.35rem;
246
+ box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 35%);
247
+ backdrop-filter: blur(0.35rem);
248
+ }
249
+ .canter-progress-label {
250
+ margin-bottom: 0.4rem;
251
+ font-size: 0.85rem;
252
+ font-weight: 600;
253
+ }
254
+ .canter-progress-row {
255
+ display: grid;
256
+ grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem;
257
+ align-items: center;
258
+ gap: 0.45rem;
259
+ font-size: 0.72rem;
260
+ color: #d4d4d8;
261
+ }
262
+ .canter-progress-track {
263
+ height: 0.35rem;
264
+ overflow: hidden;
265
+ background: #27272a;
266
+ border-radius: 999px;
267
+ }
268
+ .canter-progress-track > div {
269
+ height: 100%;
270
+ background: var(--primary-500);
271
+ border-radius: inherit;
272
+ transition: width 120ms linear;
273
+ }
274
+ .canter-output-image,
275
+ .canter-output-image > div {
276
+ height: auto !important;
277
+ max-height: none !important;
278
+ overflow: visible !important;
279
+ }
280
+ .canter-output-image img {
281
+ display: block !important;
282
+ width: 100% !important;
283
+ max-width: 100% !important;
284
+ height: auto !important;
285
+ max-height: none !important;
286
+ object-fit: contain !important;
287
+ margin: 0 auto !important;
288
+ cursor: zoom-in;
289
+ }
290
+ .canter-output-image:not(
291
+ :has(button[aria-label="Exit fullscreen mode"])
292
+ ) img {
293
+ max-height: var(
294
+ --canter-image-max-height,
295
+ calc(100dvh - 2rem)
296
+ ) !important;
297
+ }
298
+ .canter-output-image:not(
299
+ :has(button[aria-label="Exit fullscreen mode"])
300
+ ) > div > button > div {
301
+ width: min(
302
+ 100%,
303
+ var(--canter-image-min-width, 640px)
304
+ ) !important;
305
+ }
306
+ .canter-output-image:has(button[aria-label="Exit fullscreen mode"]) img {
307
+ cursor: zoom-out;
308
+ }
309
+ .canter-output-image:has(button[aria-label="Exit fullscreen mode"]) {
310
+ box-sizing: border-box !important;
311
+ height: 100dvh !important;
312
+ max-height: 100dvh !important;
313
+ overflow: auto !important;
314
+ padding: 8px !important;
315
+ }
316
+ #canter-status {
317
+ color: #a1a1aa;
318
+ }
319
+ @media (max-width: 900px) {
320
+ #canter-controls {
321
+ max-width: none;
322
+ min-width: 0;
323
+ }
324
+ }
325
+ """
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class _BasicInputs:
330
+ """Prompt and basic generation controls rendered above advanced settings."""
331
+
332
+ prompt: gr.Textbox
333
+ image_count: gr.Number
334
+ size_preset: gr.Dropdown
335
+ preview_enabled: gr.Checkbox
336
+ width: gr.Number
337
+ height: gr.Number
338
+ seed: gr.Number
339
+
340
+
341
+ @dataclass(frozen=True)
342
+ class _WebInputs:
343
+ """Gradio components in the exact callback argument order."""
344
+
345
+ prompt: gr.Textbox
346
+ image_count: gr.Number
347
+ size_preset: gr.Dropdown
348
+ preview_enabled: gr.Checkbox
349
+ width: gr.Number
350
+ height: gr.Number
351
+ seed: gr.Number
352
+ steps: gr.Slider
353
+ solver: gr.Dropdown
354
+ schedule: gr.Dropdown
355
+ log_snr_shift: gr.Slider
356
+ self_attention_gain: gr.Slider
357
+ euler_maruyama_multiplier: gr.Slider
358
+ cfg_enabled: gr.Checkbox
359
+ cfg_scale: gr.Slider
360
+ cfg_start_step: gr.Number
361
+ cfg_stop_step: gr.Textbox
362
+ pdg_enabled: gr.Checkbox
363
+ pdg_mode: gr.Dropdown
364
+ pdg_curve: gr.Dropdown
365
+ pdg_noisy_scale: gr.Slider
366
+ pdg_clean_scale: gr.Slider
367
+ pdg_power: gr.Slider
368
+ pdg_start_step: gr.Number
369
+ pdg_stop_step: gr.Textbox
370
+
371
+ def ordered(self) -> tuple[gr.Component, ...]:
372
+ """Return components in the order accepted by ``generate``."""
373
+
374
+ return (
375
+ self.prompt,
376
+ self.image_count,
377
+ self.width,
378
+ self.height,
379
+ self.seed,
380
+ self.steps,
381
+ self.solver,
382
+ self.schedule,
383
+ self.log_snr_shift,
384
+ self.self_attention_gain,
385
+ self.euler_maruyama_multiplier,
386
+ self.cfg_enabled,
387
+ self.cfg_scale,
388
+ self.cfg_start_step,
389
+ self.cfg_stop_step,
390
+ self.pdg_enabled,
391
+ self.pdg_mode,
392
+ self.pdg_curve,
393
+ self.pdg_noisy_scale,
394
+ self.pdg_clean_scale,
395
+ self.pdg_power,
396
+ self.pdg_start_step,
397
+ self.pdg_stop_step,
398
+ self.preview_enabled,
399
+ )
400
+
401
+
402
+ @dataclass(frozen=True)
403
+ class _WebOutputs:
404
+ """Image slots and status components in callback output order."""
405
+
406
+ image_containers: tuple[gr.Column, ...]
407
+ images: tuple[gr.Image, ...]
408
+ grid_style: gr.HTML
409
+ model_status: gr.Markdown
410
+ status: gr.Markdown
411
+ progress_timer: gr.Timer
412
+
413
+ def ordered(self) -> tuple[gr.Component, ...]:
414
+ """Return images, status, and progress-lifecycle callback outputs."""
415
+
416
+ return (*self.images, self.status, self.progress_timer)
417
+
418
+
419
+ @dataclass(frozen=True)
420
+ class _GenerationControls:
421
+ """Buttons controlling generation, cancellation, randomization, and API help."""
422
+
423
+ generate: gr.Button
424
+ stop: gr.Button
425
+ randomize: gr.Button
426
+ api: gr.Button
427
+
428
+
429
+ def _prompt_input() -> gr.Textbox:
430
+ """Create the prompt control without rendering it yet."""
431
+
432
+ return gr.Textbox(
433
+ label="Prompt",
434
+ value=_DEFAULT_PROMPT,
435
+ lines=3,
436
+ placeholder="Describe the image you want to generate.",
437
+ render=False,
438
+ )
439
+
440
+
441
+ def _basic_inputs(prompt: gr.Textbox) -> _BasicInputs:
442
+ """Render prompt, image-count, preset, shape, and seed controls."""
443
+
444
+ prompt.render()
445
+ image_count = gr.Number(
446
+ minimum=1,
447
+ maximum=_MAX_IMAGE_COUNT,
448
+ value=_DEFAULT_IMAGE_COUNT,
449
+ step=1,
450
+ precision=0,
451
+ label="Images",
452
+ info="Generated sequentially with batch size 1.",
453
+ )
454
+ size_preset = gr.Dropdown(
455
+ choices=_SIZE_PRESET_CHOICES,
456
+ value=SizePreset.DEFAULT_PORTRAIT.value,
457
+ label="Size preset",
458
+ allow_custom_value=False,
459
+ )
460
+ preview_enabled = gr.Checkbox(
461
+ value=True,
462
+ label="Preview generation",
463
+ )
464
+ with gr.Row():
465
+ width = gr.Number(
466
+ value=_DEFAULT_INFERENCE.width,
467
+ label="Width",
468
+ precision=0,
469
+ minimum=_IMAGE_SIZE_MULTIPLE,
470
+ step=_IMAGE_SIZE_MULTIPLE,
471
+ )
472
+ height = gr.Number(
473
+ value=_DEFAULT_INFERENCE.height,
474
+ label="Height",
475
+ precision=0,
476
+ minimum=_IMAGE_SIZE_MULTIPLE,
477
+ step=_IMAGE_SIZE_MULTIPLE,
478
+ )
479
+ seed = gr.Number(
480
+ value=_DEFAULT_INFERENCE.seed,
481
+ label="Seed",
482
+ precision=0,
483
+ minimum=0,
484
+ maximum=_MAX_SEED,
485
+ )
486
+ return _BasicInputs(
487
+ prompt=prompt,
488
+ image_count=image_count,
489
+ size_preset=size_preset,
490
+ preview_enabled=preview_enabled,
491
+ width=width,
492
+ height=height,
493
+ seed=seed,
494
+ )
495
+
496
+
497
+ def image_grid_visibility(
498
+ image_count: int | float,
499
+ ) -> tuple[dict[str, object], ...]:
500
+ """Show exactly the requested number of fixed image-grid containers."""
501
+
502
+ resolved_count = _integer(
503
+ image_count,
504
+ "image count",
505
+ minimum=1,
506
+ maximum=_MAX_IMAGE_COUNT,
507
+ )
508
+ return tuple(
509
+ gr.update(visible=index < resolved_count) for index in range(_MAX_IMAGE_COUNT)
510
+ )
511
+
512
+
513
+ def image_grid_style(width: int | float, height: int | float) -> str:
514
+ """Return responsive grid CSS using the requested image width."""
515
+
516
+ resolved_width = _image_dimension(width, "width")
517
+ _image_dimension(height, "height")
518
+ return (
519
+ "<style>"
520
+ "#canter-image-grid {"
521
+ f"--canter-image-min-width: {resolved_width}px;"
522
+ "}"
523
+ "</style>"
524
+ )
525
+
526
+
527
+ def prepare_image_grid(
528
+ image_count: int | float,
529
+ width: int | float,
530
+ height: int | float,
531
+ ) -> tuple[dict[str, object] | str, ...]:
532
+ """Configure slot visibility and aspect-aware sizing before generation."""
533
+
534
+ return (
535
+ *image_grid_visibility(image_count),
536
+ image_grid_style(width, height),
537
+ )
538
+
539
+
540
+ def apply_size_preset(value: str | None) -> tuple[int, int, float]:
541
+ """Return width, height, and attention gain for one exact UI preset."""
542
+
543
+ preset = _choice(value, _SIZE_PRESETS, "size preset")
544
+ selected = _SIZE_PRESET_VALUES[preset]
545
+ return selected.width, selected.height, selected.self_attention_gain
546
+
547
+
548
+ def schedule_log_snr_shift(value: str | None) -> float:
549
+ """Return the UI log-SNR preset associated with one schedule."""
550
+
551
+ schedule = _choice(value, _SCHEDULES, "schedule")
552
+ match schedule:
553
+ case Schedule.BETA:
554
+ return 0.0
555
+ case Schedule.LINEAR:
556
+ return -2.3
557
+ case _ as unreachable:
558
+ raise RuntimeError(f"Unsupported Canter schedule: {unreachable}")
559
+
560
+
561
+ def _solver_inputs() -> tuple[
562
+ gr.Slider,
563
+ gr.Dropdown,
564
+ gr.Dropdown,
565
+ gr.Slider,
566
+ gr.Slider,
567
+ gr.Slider,
568
+ ]:
569
+ """Create solver, schedule, and model-gain controls."""
570
+
571
+ steps = gr.Slider(
572
+ minimum=1,
573
+ maximum=100,
574
+ value=_DEFAULT_INFERENCE.steps,
575
+ step=1,
576
+ precision=0,
577
+ label="Solver updates",
578
+ )
579
+ with gr.Row():
580
+ solver = gr.Dropdown(
581
+ choices=_SOLVER_CHOICES,
582
+ value=_DEFAULT_INFERENCE.solver.value,
583
+ label="Solver",
584
+ allow_custom_value=False,
585
+ )
586
+ schedule = gr.Dropdown(
587
+ choices=_SCHEDULE_CHOICES,
588
+ value=_DEFAULT_INFERENCE.schedule.value,
589
+ label="Schedule",
590
+ allow_custom_value=False,
591
+ )
592
+ log_snr_shift = gr.Slider(
593
+ minimum=-4.0,
594
+ maximum=4.0,
595
+ value=_DEFAULT_INFERENCE.log_snr_shift,
596
+ step=0.05,
597
+ label="Log-SNR shift",
598
+ )
599
+ self_attention_gain = gr.Slider(
600
+ minimum=-0.05,
601
+ maximum=0.02,
602
+ value=_DEFAULT_INFERENCE.self_attention_gain,
603
+ step=0.005,
604
+ label="Main self-attention gain",
605
+ )
606
+ euler_maruyama_multiplier = gr.Slider(
607
+ minimum=0.0,
608
+ maximum=2.0,
609
+ value=_DEFAULT_INFERENCE.euler_maruyama_multiplier,
610
+ step=0.05,
611
+ label="SDE noise multiplier",
612
+ )
613
+ return (
614
+ steps,
615
+ solver,
616
+ schedule,
617
+ log_snr_shift,
618
+ self_attention_gain,
619
+ euler_maruyama_multiplier,
620
+ )
621
+
622
+
623
+ def _cfg_inputs() -> tuple[
624
+ gr.Checkbox,
625
+ gr.Slider,
626
+ gr.Number,
627
+ gr.Textbox,
628
+ ]:
629
+ """Create classifier-free guidance controls."""
630
+
631
+ enabled = gr.Checkbox(
632
+ value=_DEFAULT_INFERENCE.cfg.enabled,
633
+ label="Enable CFG",
634
+ )
635
+ scale = gr.Slider(
636
+ minimum=0.0,
637
+ maximum=10.0,
638
+ value=3.0,
639
+ step=0.05,
640
+ label="CFG scale",
641
+ info="Also used by CFG-dependent PDG modes.",
642
+ )
643
+ with gr.Row():
644
+ start = gr.Number(
645
+ value=_DEFAULT_INFERENCE.cfg.start_step,
646
+ label="CFG start",
647
+ precision=0,
648
+ minimum=0,
649
+ )
650
+ stop = gr.Textbox(
651
+ value="",
652
+ label="CFG stop",
653
+ info="Blank means the final solver update.",
654
+ placeholder="Final",
655
+ lines=1,
656
+ )
657
+ return enabled, scale, start, stop
658
+
659
+
660
+ def _pdg_inputs() -> tuple[
661
+ gr.Checkbox,
662
+ gr.Dropdown,
663
+ gr.Dropdown,
664
+ gr.Slider,
665
+ gr.Slider,
666
+ gr.Slider,
667
+ gr.Number,
668
+ gr.Textbox,
669
+ ]:
670
+ """Create path-drop guidance and curve controls."""
671
+
672
+ enabled = gr.Checkbox(
673
+ value=_DEFAULT_INFERENCE.pdg.enabled,
674
+ label="Enable PDG",
675
+ )
676
+ with gr.Row():
677
+ mode = gr.Dropdown(
678
+ choices=_PDG_MODE_CHOICES,
679
+ value=_DEFAULT_INFERENCE.pdg.mode.value,
680
+ label="PDG mode",
681
+ allow_custom_value=False,
682
+ )
683
+ curve = gr.Dropdown(
684
+ choices=_PDG_CURVE_CHOICES,
685
+ value=_DEFAULT_INFERENCE.pdg.curve.value,
686
+ label="PDG curve",
687
+ allow_custom_value=False,
688
+ )
689
+ noisy_scale = gr.Slider(
690
+ minimum=0.0,
691
+ maximum=10.0,
692
+ value=_DEFAULT_INFERENCE.pdg.noisy_scale,
693
+ step=0.05,
694
+ label="PDG scale / noisy endpoint",
695
+ )
696
+ clean_scale = gr.Slider(
697
+ minimum=0.0,
698
+ maximum=10.0,
699
+ value=_DEFAULT_INFERENCE.pdg.clean_scale,
700
+ step=0.05,
701
+ label="PDG clean endpoint",
702
+ info="Endpoint used by linear and power curves.",
703
+ interactive=_DEFAULT_INFERENCE.pdg.curve is not PdgCurve.CONSTANT,
704
+ )
705
+ power = gr.Slider(
706
+ minimum=0.05,
707
+ maximum=10.0,
708
+ value=_DEFAULT_INFERENCE.pdg.power,
709
+ step=0.05,
710
+ label="PDG power",
711
+ )
712
+ with gr.Row():
713
+ start = gr.Number(
714
+ value=_DEFAULT_INFERENCE.pdg.start_step,
715
+ label="PDG start",
716
+ precision=0,
717
+ minimum=0,
718
+ )
719
+ stop = gr.Textbox(
720
+ value="",
721
+ label="PDG stop",
722
+ info="Blank means the final solver update.",
723
+ placeholder="Final",
724
+ lines=1,
725
+ )
726
+ return enabled, mode, curve, noisy_scale, clean_scale, power, start, stop
727
+
728
+
729
+ def _create_inputs(
730
+ basic: _BasicInputs,
731
+ ) -> _WebInputs:
732
+ """Create advanced controls around an existing basic control group."""
733
+
734
+ gr.Markdown("### Solver")
735
+ (
736
+ steps,
737
+ solver,
738
+ schedule,
739
+ log_snr_shift,
740
+ self_attention_gain,
741
+ euler_maruyama_multiplier,
742
+ ) = _solver_inputs()
743
+ gr.Markdown("### Path-drop guidance")
744
+ (
745
+ pdg_enabled,
746
+ pdg_mode,
747
+ pdg_curve,
748
+ pdg_noisy_scale,
749
+ pdg_clean_scale,
750
+ pdg_power,
751
+ pdg_start,
752
+ pdg_stop,
753
+ ) = _pdg_inputs()
754
+ gr.Markdown("### Classifier-free guidance")
755
+ cfg_enabled, cfg_scale, cfg_start, cfg_stop = _cfg_inputs()
756
+ return _WebInputs(
757
+ prompt=basic.prompt,
758
+ image_count=basic.image_count,
759
+ size_preset=basic.size_preset,
760
+ preview_enabled=basic.preview_enabled,
761
+ width=basic.width,
762
+ height=basic.height,
763
+ seed=basic.seed,
764
+ steps=steps,
765
+ solver=solver,
766
+ schedule=schedule,
767
+ log_snr_shift=log_snr_shift,
768
+ self_attention_gain=self_attention_gain,
769
+ euler_maruyama_multiplier=euler_maruyama_multiplier,
770
+ cfg_enabled=cfg_enabled,
771
+ cfg_scale=cfg_scale,
772
+ cfg_start_step=cfg_start,
773
+ cfg_stop_step=cfg_stop,
774
+ pdg_enabled=pdg_enabled,
775
+ pdg_mode=pdg_mode,
776
+ pdg_curve=pdg_curve,
777
+ pdg_noisy_scale=pdg_noisy_scale,
778
+ pdg_clean_scale=pdg_clean_scale,
779
+ pdg_power=pdg_power,
780
+ pdg_start_step=pdg_start,
781
+ pdg_stop_step=pdg_stop,
782
+ )
783
+
784
+
785
+ def _connect_backend_unload(
786
+ controller: CanterWebController,
787
+ unload: gr.Button,
788
+ outputs: _WebOutputs,
789
+ ) -> None:
790
+ """Connect one-click global cancellation and backend cleanup."""
791
+
792
+ def begin_unload() -> tuple[_GenerationOutputUpdate, ...]:
793
+ """Stop active work and clear browser image slots immediately."""
794
+
795
+ status = controller.request_backend_unload()
796
+ return (
797
+ *_cleared_image_slots(),
798
+ status,
799
+ gr.update(interactive=False),
800
+ )
801
+
802
+ def finish_unload() -> tuple[str, str, dict[str, object]]:
803
+ """Release model resources while retaining generated temporary files."""
804
+
805
+ status = controller.unload_backend()
806
+ return (
807
+ status,
808
+ controller.model_status(),
809
+ gr.update(value="Unload model", interactive=True),
810
+ )
811
+
812
+ def unload_failed() -> tuple[str, dict[str, object]]:
813
+ """Keep generation blocked while allowing explicit cleanup retry."""
814
+
815
+ return (
816
+ "Unload failed. Retry cleanup or restart the server; generation "
817
+ "remains blocked.",
818
+ gr.update(value="Retry unload", interactive=True),
819
+ )
820
+
821
+ begin_event = unload.click(
822
+ fn=begin_unload,
823
+ inputs=None,
824
+ outputs=(
825
+ *outputs.images,
826
+ outputs.status,
827
+ unload,
828
+ ),
829
+ queue=False,
830
+ api_name=None,
831
+ api_visibility="private",
832
+ )
833
+ finish_event = begin_event.then(
834
+ fn=finish_unload,
835
+ inputs=None,
836
+ outputs=(
837
+ outputs.status,
838
+ outputs.model_status,
839
+ unload,
840
+ ),
841
+ concurrency_limit=1,
842
+ concurrency_id=_GPU_CONCURRENCY_ID,
843
+ api_name=None,
844
+ api_visibility="private",
845
+ )
846
+ finish_event.failure(
847
+ fn=unload_failed,
848
+ inputs=None,
849
+ outputs=(outputs.status, unload),
850
+ queue=False,
851
+ api_name=None,
852
+ api_visibility="private",
853
+ )
854
+
855
+
856
+ def _connect_generation(
857
+ controller: CanterWebController,
858
+ inputs: _WebInputs,
859
+ generate: gr.Button,
860
+ stop: gr.Button,
861
+ outputs: _WebOutputs,
862
+ ) -> None:
863
+ """Connect button and prompt submission to the serial GPU queue."""
864
+
865
+ generate_event = generate.click(
866
+ fn=prepare_image_grid,
867
+ inputs=(inputs.image_count, inputs.width, inputs.height),
868
+ outputs=(*outputs.image_containers, outputs.grid_style),
869
+ queue=False,
870
+ api_name=None,
871
+ api_visibility="private",
872
+ )
873
+ generation = generate_event.then(
874
+ fn=controller.generate,
875
+ inputs=inputs.ordered(),
876
+ outputs=outputs.ordered(),
877
+ api_name="generate",
878
+ api_description="Generate images with the loaded Canter release.",
879
+ show_progress="hidden",
880
+ concurrency_limit=1,
881
+ concurrency_id=_GPU_CONCURRENCY_ID,
882
+ )
883
+ generation.then(
884
+ fn=controller.model_status,
885
+ inputs=None,
886
+ outputs=outputs.model_status,
887
+ queue=False,
888
+ api_name=None,
889
+ api_visibility="private",
890
+ )
891
+ prompt_event = inputs.prompt.submit(
892
+ fn=prepare_image_grid,
893
+ inputs=(inputs.image_count, inputs.width, inputs.height),
894
+ outputs=(*outputs.image_containers, outputs.grid_style),
895
+ queue=False,
896
+ api_name=None,
897
+ api_visibility="private",
898
+ )
899
+ prompt_generation = prompt_event.then(
900
+ fn=controller.generate,
901
+ inputs=inputs.ordered(),
902
+ outputs=outputs.ordered(),
903
+ api_name=None,
904
+ show_progress="hidden",
905
+ concurrency_limit=1,
906
+ concurrency_id=_GPU_CONCURRENCY_ID,
907
+ api_visibility="private",
908
+ )
909
+ prompt_generation.then(
910
+ fn=controller.model_status,
911
+ inputs=None,
912
+ outputs=outputs.model_status,
913
+ queue=False,
914
+ api_name=None,
915
+ api_visibility="private",
916
+ )
917
+ stop.click(
918
+ fn=controller.request_stop,
919
+ inputs=None,
920
+ outputs=outputs.status,
921
+ queue=False,
922
+ api_name=None,
923
+ api_visibility="private",
924
+ )
925
+
926
+
927
+ def _create_header(
928
+ *,
929
+ model_status_text: str,
930
+ unload_supported: bool,
931
+ ready: bool,
932
+ ) -> tuple[gr.Button, gr.Markdown]:
933
+ """Create the title, one-click unload control, and model status."""
934
+
935
+ with gr.Row(elem_id="canter-header"):
936
+ gr.Markdown(
937
+ "# Canter",
938
+ elem_id="canter-title",
939
+ scale=1,
940
+ min_width=200,
941
+ )
942
+ unload = gr.Button(
943
+ "Unload model",
944
+ variant="secondary",
945
+ size="sm",
946
+ scale=0,
947
+ min_width=104,
948
+ visible=unload_supported,
949
+ interactive=ready,
950
+ elem_classes="canter-header-action",
951
+ )
952
+ model_status = gr.Markdown(model_status_text, elem_id="canter-model-status")
953
+ return unload, model_status
954
+
955
+
956
+ def _create_result_outputs(
957
+ controller: CanterWebController,
958
+ model_status: gr.Markdown,
959
+ *,
960
+ ready: bool,
961
+ ) -> _WebOutputs:
962
+ """Create responsive image slots and their per-session progress polling."""
963
+
964
+ status = gr.Markdown(
965
+ (
966
+ "Ready. The first request includes compiled graph warm-up."
967
+ if ready
968
+ else "Loading Canter weights and compiling the selected backend…"
969
+ ),
970
+ elem_id="canter-status",
971
+ )
972
+ total_progress = gr.HTML(
973
+ "",
974
+ container=False,
975
+ elem_classes="canter-total-progress",
976
+ )
977
+ grid_style = gr.HTML(
978
+ image_grid_style(
979
+ _DEFAULT_INFERENCE.width,
980
+ _DEFAULT_INFERENCE.height,
981
+ ),
982
+ container=False,
983
+ )
984
+ progress_outputs: list[gr.HTML] = []
985
+ image_outputs: list[gr.Image] = []
986
+ image_containers: list[gr.Column] = []
987
+ with gr.Row(elem_id="canter-image-grid"):
988
+ for index in range(_MAX_IMAGE_COUNT):
989
+ with gr.Column(
990
+ min_width=0,
991
+ visible=index < _DEFAULT_IMAGE_COUNT,
992
+ elem_classes="canter-image-cell",
993
+ ) as image_container:
994
+ image_containers.append(image_container)
995
+ progress_outputs.append(
996
+ gr.HTML(
997
+ "",
998
+ container=False,
999
+ elem_classes="canter-image-progress",
1000
+ )
1001
+ )
1002
+ image_outputs.append(
1003
+ gr.Image(
1004
+ label=f"Image {index + 1}",
1005
+ format="png",
1006
+ height=None,
1007
+ width=None,
1008
+ type="pil",
1009
+ show_label=False,
1010
+ buttons=["download", "fullscreen"],
1011
+ container=False,
1012
+ interactive=False,
1013
+ elem_classes="canter-output-image",
1014
+ )
1015
+ )
1016
+ progress_timer = gr.Timer(value=0.2, active=False)
1017
+ outputs = _WebOutputs(
1018
+ image_containers=tuple(image_containers),
1019
+ images=tuple(image_outputs),
1020
+ grid_style=grid_style,
1021
+ model_status=model_status,
1022
+ status=status,
1023
+ progress_timer=progress_timer,
1024
+ )
1025
+ progress_timer.tick(
1026
+ fn=controller.poll_progress,
1027
+ inputs=None,
1028
+ outputs=(
1029
+ *image_outputs,
1030
+ total_progress,
1031
+ *progress_outputs,
1032
+ progress_timer,
1033
+ ),
1034
+ queue=False,
1035
+ show_progress="hidden",
1036
+ api_name=None,
1037
+ api_visibility="private",
1038
+ )
1039
+ return outputs
1040
+
1041
+
1042
+ def _create_workspace(
1043
+ controller: CanterWebController,
1044
+ model_status: gr.Markdown,
1045
+ *,
1046
+ ready: bool,
1047
+ ) -> tuple[_WebInputs, _GenerationControls, _WebOutputs]:
1048
+ """Create the controls column and responsive results column."""
1049
+
1050
+ with gr.Row(elem_id="canter-workspace"):
1051
+ with gr.Column(scale=3, min_width=360, elem_id="canter-controls"):
1052
+ prompt = _prompt_input()
1053
+ basic = _basic_inputs(prompt)
1054
+ with gr.Row():
1055
+ generate = gr.Button(
1056
+ "Generate",
1057
+ variant="primary",
1058
+ scale=3,
1059
+ interactive=ready,
1060
+ )
1061
+ stop = gr.Button(
1062
+ "Stop",
1063
+ variant="stop",
1064
+ scale=1,
1065
+ interactive=ready,
1066
+ )
1067
+ randomize = gr.Button("Random seed", variant="secondary", scale=1)
1068
+ inputs = _create_inputs(basic)
1069
+ api = gr.Button(
1070
+ "Use via API",
1071
+ variant="secondary",
1072
+ elem_id="canter-api-button",
1073
+ )
1074
+ with gr.Column(scale=8, min_width=640, elem_id="canter-results"):
1075
+ outputs = _create_result_outputs(
1076
+ controller,
1077
+ model_status,
1078
+ ready=ready,
1079
+ )
1080
+ controls = _GenerationControls(
1081
+ generate=generate,
1082
+ stop=stop,
1083
+ randomize=randomize,
1084
+ api=api,
1085
+ )
1086
+ return inputs, controls, outputs
1087
+
1088
+
1089
+ def _connect_control_callbacks(
1090
+ inputs: _WebInputs,
1091
+ controls: _GenerationControls,
1092
+ outputs: _WebOutputs,
1093
+ ) -> None:
1094
+ """Connect form-only callbacks that do not invoke the model."""
1095
+
1096
+ controls.randomize.click(
1097
+ fn=random_seed,
1098
+ inputs=None,
1099
+ outputs=inputs.seed,
1100
+ queue=False,
1101
+ api_name=None,
1102
+ api_visibility="private",
1103
+ )
1104
+ size_preset_event = inputs.size_preset.change(
1105
+ fn=apply_size_preset,
1106
+ inputs=inputs.size_preset,
1107
+ outputs=(
1108
+ inputs.width,
1109
+ inputs.height,
1110
+ inputs.self_attention_gain,
1111
+ ),
1112
+ queue=False,
1113
+ api_name=None,
1114
+ api_visibility="private",
1115
+ )
1116
+ size_preset_event.then(
1117
+ fn=image_grid_style,
1118
+ inputs=(inputs.width, inputs.height),
1119
+ outputs=outputs.grid_style,
1120
+ queue=False,
1121
+ api_name=None,
1122
+ api_visibility="private",
1123
+ )
1124
+ inputs.schedule.change(
1125
+ fn=schedule_log_snr_shift,
1126
+ inputs=inputs.schedule,
1127
+ outputs=inputs.log_snr_shift,
1128
+ queue=False,
1129
+ api_name=None,
1130
+ api_visibility="private",
1131
+ )
1132
+ for dimension in (inputs.width, inputs.height):
1133
+ dimension_event = dimension.blur(
1134
+ fn=snap_image_dimension,
1135
+ inputs=dimension,
1136
+ outputs=dimension,
1137
+ queue=False,
1138
+ api_name=None,
1139
+ api_visibility="private",
1140
+ )
1141
+ dimension_event.then(
1142
+ fn=image_grid_style,
1143
+ inputs=(inputs.width, inputs.height),
1144
+ outputs=outputs.grid_style,
1145
+ queue=False,
1146
+ api_name=None,
1147
+ api_visibility="private",
1148
+ )
1149
+ inputs.pdg_curve.change(
1150
+ fn=update_pdg_clean_scale,
1151
+ inputs=(inputs.pdg_curve, inputs.pdg_noisy_scale),
1152
+ outputs=inputs.pdg_clean_scale,
1153
+ queue=False,
1154
+ api_name=None,
1155
+ api_visibility="private",
1156
+ )
1157
+ controls.api.click(
1158
+ fn=None,
1159
+ inputs=None,
1160
+ outputs=None,
1161
+ queue=False,
1162
+ js=(
1163
+ "() => {"
1164
+ "const button = document.querySelector('footer button.show-api');"
1165
+ "if (button === null) {"
1166
+ "throw new Error('Gradio API footer control is missing.');"
1167
+ "}"
1168
+ "button.click();"
1169
+ "}"
1170
+ ),
1171
+ api_name=None,
1172
+ api_visibility="private",
1173
+ )
1174
+
1175
+
1176
+ def _create_web_app(
1177
+ controller: CanterWebController,
1178
+ *,
1179
+ model_status_text: str,
1180
+ ready: bool,
1181
+ load_config: CanterWebLaunchConfig | None,
1182
+ ) -> gr.Blocks:
1183
+ """Build the UI for either an already-loaded or lazy process pipeline."""
1184
+
1185
+ with gr.Blocks(title="Canter", fill_width=True) as application:
1186
+ unload, model_status = _create_header(
1187
+ model_status_text=model_status_text,
1188
+ unload_supported=load_config is not None,
1189
+ ready=ready,
1190
+ )
1191
+ inputs, controls, outputs = _create_workspace(
1192
+ controller,
1193
+ model_status,
1194
+ ready=ready,
1195
+ )
1196
+ _connect_control_callbacks(inputs, controls, outputs)
1197
+ _connect_generation(
1198
+ controller,
1199
+ inputs,
1200
+ controls.generate,
1201
+ controls.stop,
1202
+ outputs,
1203
+ )
1204
+ if load_config is not None:
1205
+ _connect_backend_unload(controller, unload, outputs)
1206
+ application.load(
1207
+ fn=controller.load,
1208
+ inputs=None,
1209
+ outputs=(
1210
+ model_status,
1211
+ outputs.status,
1212
+ controls.generate,
1213
+ controls.stop,
1214
+ unload,
1215
+ ),
1216
+ show_progress="minimal",
1217
+ show_progress_on=outputs.status,
1218
+ concurrency_limit=1,
1219
+ concurrency_id=_GPU_CONCURRENCY_ID,
1220
+ api_name=None,
1221
+ api_visibility="private",
1222
+ )
1223
+ application.queue(
1224
+ max_size=_MAX_QUEUE_SIZE,
1225
+ default_concurrency_limit=1,
1226
+ api_open=False,
1227
+ )
1228
+ return application
1229
+
1230
+
1231
+ def create_web_app(pipeline: _ImagePipeline) -> gr.Blocks:
1232
+ """Build the bundled application around one already-loaded pipeline."""
1233
+
1234
+ return _create_web_app(
1235
+ CanterWebController(pipeline),
1236
+ model_status_text=_model_summary(pipeline.metadata),
1237
+ ready=True,
1238
+ load_config=None,
1239
+ )
1240
+
1241
+
1242
+ def create_loading_web_app(config: CanterWebLaunchConfig) -> gr.Blocks:
1243
+ """Build an immediately visible UI that loads its process pipeline once."""
1244
+
1245
+ return _create_web_app(
1246
+ CanterWebController(None, load_config=config),
1247
+ model_status_text=(
1248
+ f"Loading Canter `{config.dtype.value}` weights and compiling the "
1249
+ f"`{config.text_backend.value}` backend…"
1250
+ ),
1251
+ ready=False,
1252
+ load_config=config,
1253
+ )
1254
+
1255
+
1256
+ def _theme() -> gr.themes.Base:
1257
+ """Return Canter's compact charcoal-and-sky-blue dark theme."""
1258
+
1259
+ return gr.themes.Base(
1260
+ primary_hue="sky",
1261
+ secondary_hue="blue",
1262
+ neutral_hue="zinc",
1263
+ spacing_size="sm",
1264
+ radius_size="sm",
1265
+ text_size="md",
1266
+ font=[
1267
+ gr.themes.Font("Inter"),
1268
+ gr.themes.Font("Segoe UI"),
1269
+ gr.themes.Font("sans-serif"),
1270
+ ],
1271
+ font_mono=[
1272
+ gr.themes.Font("IBM Plex Mono"),
1273
+ gr.themes.Font("Consolas"),
1274
+ gr.themes.Font("monospace"),
1275
+ ],
1276
+ ).set(
1277
+ body_background_fill_dark="#09090b",
1278
+ block_background_fill_dark="#111113",
1279
+ block_border_color_dark="#27272a",
1280
+ input_background_fill_dark="#18181b",
1281
+ )
canter/webui_runtime.py ADDED
@@ -0,0 +1,1171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generation runtime and model lifecycle for the bundled Canter web UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import json
7
+ import logging
8
+ import math
9
+ import secrets
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable, Generator, Mapping, Sequence
13
+ from dataclasses import dataclass, replace
14
+ from hashlib import sha256
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Protocol, TypeAlias, TypeVar
17
+
18
+ import gradio as gr
19
+ import torch
20
+ from PIL import Image
21
+
22
+ from .inference import (
23
+ CanterInferenceConfig,
24
+ CfgGuidance,
25
+ PdgCurve,
26
+ PdgGuidance,
27
+ PdgMode,
28
+ )
29
+ from .pipeline import (
30
+ CanterOutputType,
31
+ CanterPipeline,
32
+ CanterPipelineConfig,
33
+ CanterPipelineMetadata,
34
+ CanterPipelineOutput,
35
+ )
36
+ from .schedules import Schedule
37
+ from .solvers import Solver, SolverProgress
38
+
39
+ if TYPE_CHECKING:
40
+ from .blocks import TextAttentionBackend
41
+ from .loading import WeightDType
42
+ from .preview import CanterPreviewCallback
43
+
44
+ _MAX_IMAGE_COUNT = 50
45
+ _MAX_SEED = 2**63 - 1
46
+ _IMAGE_SIZE_MULTIPLE = 32
47
+ _PNG_METADATA_KEY = "canter"
48
+ _LOGGER = logging.getLogger(__name__)
49
+
50
+ _SOLVERS = {value.value: value for value in Solver}
51
+ _SCHEDULES = {value.value: value for value in Schedule}
52
+ _PDG_MODES = {value.value: value for value in PdgMode if value is not PdgMode.NONE}
53
+ _PDG_CURVES = {value.value: value for value in PdgCurve}
54
+
55
+ _Choice = TypeVar("_Choice")
56
+
57
+
58
+ class _ImagePipeline(Protocol):
59
+ """Typed Canter surface used by the web controller and its tests."""
60
+
61
+ metadata: CanterPipelineMetadata
62
+
63
+ def __call__(
64
+ self,
65
+ prompts: str | Sequence[str],
66
+ *,
67
+ config: CanterPipelineConfig,
68
+ initial_noise: None,
69
+ progress: SolverProgress | None,
70
+ preview: CanterPreviewCallback | None,
71
+ ) -> CanterPipelineOutput:
72
+ """Generate decoded images for one web request."""
73
+
74
+ ...
75
+
76
+ def close(self) -> None:
77
+ """Finish background work before backend unload."""
78
+
79
+ ...
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class CanterWebRequest:
84
+ """Validated sequential image request and frozen base configuration."""
85
+
86
+ prompt: str
87
+ image_count: int
88
+ config: CanterPipelineConfig
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class CanterWebLaunchConfig:
93
+ """Process-level model and server configuration for the bundled application."""
94
+
95
+ model: str
96
+ revision: str | None
97
+ dtype: WeightDType
98
+ text_backend: TextAttentionBackend
99
+ device: str
100
+ cache_dir: Path | None
101
+ server_name: str
102
+ server_port: int
103
+ share: bool
104
+ in_browser: bool
105
+
106
+
107
+ _ImageSlotUpdate: TypeAlias = Image.Image | dict[str, str] | None
108
+ _GenerationOutputUpdate: TypeAlias = _ImageSlotUpdate | str | dict[str, object]
109
+ _GenerationUpdate: TypeAlias = tuple[_GenerationOutputUpdate, ...]
110
+
111
+
112
+ class _GenerationStoppedError(RuntimeError):
113
+ """Cooperative-cancellation signal for one web generation."""
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class _WebProgressState:
118
+ """Current solver position for one browser generation request."""
119
+
120
+ image_index: int
121
+ image_count: int
122
+ completed_updates: int
123
+ total_updates: int
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _WebPreview:
128
+ """Newest unconsumed preview for one output image slot."""
129
+
130
+ image_index: int
131
+ image: Image.Image
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class _WebGenerationState:
136
+ """Cancellation and progress state owned by one browser generation."""
137
+
138
+ stop_requested: threading.Event
139
+ progress: _WebProgressState
140
+ preview_image_index: int | None
141
+ pending_preview: _WebPreview | None
142
+
143
+
144
+ class CanterWebController:
145
+ """Translate Gradio values into one deterministic pipeline invocation."""
146
+
147
+ def __init__(
148
+ self,
149
+ pipeline: _ImagePipeline | None,
150
+ *,
151
+ load_config: CanterWebLaunchConfig | None = None,
152
+ ) -> None:
153
+ """Retain a pipeline or the exact configuration needed to load it."""
154
+
155
+ if pipeline is None and load_config is None:
156
+ raise ValueError("A lazy Canter web controller requires load_config.")
157
+ if pipeline is not None and load_config is not None:
158
+ raise ValueError("Provide either pipeline or load_config, not both.")
159
+
160
+ self.pipeline = pipeline
161
+ self._load_config = load_config
162
+ self._load_lock = threading.Lock()
163
+ self._session_lock = threading.Lock()
164
+ self._generation_by_session: dict[str, _WebGenerationState] = {}
165
+ self._explicitly_unloaded = False
166
+ self._unload_pending = False
167
+
168
+ def load(
169
+ self,
170
+ ) -> tuple[str, str, dict[str, object], dict[str, object], dict[str, object]]:
171
+ """Load initially without undoing an explicit backend unload."""
172
+
173
+ with self._load_lock:
174
+ if self._explicitly_unloaded:
175
+ unloaded = _unloaded_model_status()
176
+ return (
177
+ unloaded,
178
+ unloaded,
179
+ gr.update(interactive=True),
180
+ gr.update(interactive=True),
181
+ gr.update(interactive=True),
182
+ )
183
+ pipeline = self._load_pipeline_locked()
184
+ return (
185
+ _model_summary(pipeline.metadata),
186
+ "Ready. The first request includes compiled graph warm-up.",
187
+ gr.update(interactive=True),
188
+ gr.update(interactive=True),
189
+ gr.update(interactive=True),
190
+ )
191
+
192
+ def request_stop(self, request: gr.Request | None = None) -> str:
193
+ """Cancel only the active generation owned by the requesting browser."""
194
+
195
+ session_hash = _session_hash(request)
196
+ with self._session_lock:
197
+ state = self._generation_by_session.get(session_hash)
198
+ if state is None:
199
+ return "No generation is currently active in this browser session."
200
+ state.stop_requested.set()
201
+ return "Stopping after the current solver step…"
202
+
203
+ def request_backend_unload(self) -> str:
204
+ """Stop every active request and block new loads until cleanup completes."""
205
+
206
+ with self._load_lock:
207
+ self._unload_pending = True
208
+ with self._session_lock:
209
+ active_states = tuple(self._generation_by_session.values())
210
+ for state in active_states:
211
+ state.stop_requested.set()
212
+ active_count = len(active_states)
213
+ _LOGGER.info(
214
+ "Canter backend unload requested | active_requests=%d",
215
+ active_count,
216
+ )
217
+ if active_count == 0:
218
+ return "Unloading Canter and clearing generated images…"
219
+ request_word = "request" if active_count == 1 else "requests"
220
+ return (
221
+ f"Stopping **{active_count} active {request_word}** at the next solver "
222
+ "boundary, then unloading Canter…"
223
+ )
224
+
225
+ def unload_backend(self) -> str:
226
+ """Release the reloadable pipeline and flush compiler and CUDA caches."""
227
+
228
+ with self._load_lock:
229
+ with self._session_lock:
230
+ active_count = len(self._generation_by_session)
231
+ if active_count != 0:
232
+ raise RuntimeError(
233
+ "Cannot unload Canter while generation state remains active."
234
+ )
235
+ if self._load_config is None:
236
+ raise RuntimeError(
237
+ "This embedded Canter pipeline has no reload configuration."
238
+ )
239
+ pipeline = self.pipeline
240
+ self.pipeline = None
241
+ self._explicitly_unloaded = True
242
+ if pipeline is not None:
243
+ pipeline.close()
244
+ del pipeline
245
+ try:
246
+ torch.compiler.reset()
247
+ gc.collect()
248
+ torch.cuda.empty_cache()
249
+ except (OSError, RuntimeError):
250
+ _LOGGER.exception("Canter backend unload cleanup failed")
251
+ raise
252
+ self._unload_pending = False
253
+ _LOGGER.info("Canter backend unloaded")
254
+ return "Canter is unloaded. The next generation will reload and compile it."
255
+
256
+ def model_status(self) -> str:
257
+ """Return the current process-wide model residency state."""
258
+
259
+ with self._load_lock:
260
+ if self.pipeline is None:
261
+ return _unloaded_model_status()
262
+ return _model_summary(self.pipeline.metadata)
263
+
264
+ def generate( # noqa: PLR0917 - Gradio supplies the explicit form fields.
265
+ self,
266
+ prompt: str,
267
+ image_count: int | float,
268
+ width: int | float,
269
+ height: int | float,
270
+ seed: int | float,
271
+ steps: int | float,
272
+ solver: str,
273
+ schedule: str,
274
+ log_snr_shift: int | float,
275
+ self_attention_gain: int | float,
276
+ euler_maruyama_multiplier: int | float,
277
+ cfg_enabled: bool,
278
+ cfg_scale: int | float,
279
+ cfg_start_step: int | float,
280
+ cfg_stop_step: int | float | str | None,
281
+ pdg_enabled: bool,
282
+ pdg_mode: str,
283
+ pdg_curve: str,
284
+ pdg_noisy_scale: int | float,
285
+ pdg_clean_scale: int | float,
286
+ pdg_power: int | float,
287
+ pdg_start_step: int | float,
288
+ pdg_stop_step: int | float | str | None,
289
+ preview_enabled: bool,
290
+ browser_request: gr.Request | None = None,
291
+ ) -> Generator[_GenerationUpdate, None, None]:
292
+ """Yield each batch-one image as soon as its decoding completes."""
293
+
294
+ session_hash = _session_hash(browser_request)
295
+ request = build_web_request(
296
+ prompt=prompt,
297
+ image_count=image_count,
298
+ width=width,
299
+ height=height,
300
+ seed=seed,
301
+ steps=steps,
302
+ solver=solver,
303
+ schedule=schedule,
304
+ log_snr_shift=log_snr_shift,
305
+ self_attention_gain=self_attention_gain,
306
+ euler_maruyama_multiplier=euler_maruyama_multiplier,
307
+ cfg_enabled=cfg_enabled,
308
+ cfg_scale=cfg_scale,
309
+ cfg_start_step=cfg_start_step,
310
+ cfg_stop_step=cfg_stop_step,
311
+ pdg_enabled=pdg_enabled,
312
+ pdg_mode=pdg_mode,
313
+ pdg_curve=pdg_curve,
314
+ pdg_noisy_scale=pdg_noisy_scale,
315
+ pdg_clean_scale=pdg_clean_scale,
316
+ pdg_power=pdg_power,
317
+ pdg_start_step=pdg_start_step,
318
+ pdg_stop_step=pdg_stop_step,
319
+ )
320
+ _log_generation_request(request)
321
+ pipeline = self._ensure_loaded()
322
+ started = time.perf_counter()
323
+ guidance = _pdg_status(request.config.inference)
324
+ stop_requested = self._begin_generation(
325
+ session_hash,
326
+ image_index=0,
327
+ image_count=request.image_count,
328
+ completed_updates=0,
329
+ total_updates=request.config.inference.steps,
330
+ )
331
+ try:
332
+ yield _generation_update(
333
+ _cleared_image_slots(),
334
+ f"Generating **{request.image_count} images** with batch size `1` · "
335
+ f"{guidance}…",
336
+ progress_active=True,
337
+ )
338
+ for image_index in range(request.image_count):
339
+ self._raise_if_stopped(stop_requested)
340
+ image_number = image_index + 1
341
+ self._set_preview_target(
342
+ session_hash,
343
+ stop_requested=stop_requested,
344
+ target=image_index if preview_enabled else None,
345
+ )
346
+ self._set_progress(
347
+ session_hash,
348
+ image_index=image_index,
349
+ image_count=request.image_count,
350
+ completed_updates=0,
351
+ total_updates=request.config.inference.steps,
352
+ )
353
+ image_config = _image_config(request, image_index)
354
+ output = pipeline(
355
+ request.prompt,
356
+ config=image_config,
357
+ initial_noise=None,
358
+ progress=_step_progress(
359
+ stopped=stop_requested.is_set,
360
+ update_web=lambda completed, total, index=image_index: (
361
+ self._set_progress(
362
+ session_hash,
363
+ image_index=index,
364
+ image_count=request.image_count,
365
+ completed_updates=completed,
366
+ total_updates=total,
367
+ )
368
+ ),
369
+ ),
370
+ preview=(
371
+ (
372
+ lambda images, completed, total, index=image_index: (
373
+ self._publish_preview(
374
+ session_hash,
375
+ stop_requested=stop_requested,
376
+ image_index=index,
377
+ images=images,
378
+ )
379
+ )
380
+ )
381
+ if preview_enabled
382
+ else None
383
+ ),
384
+ )
385
+ self._set_preview_target(
386
+ session_hash,
387
+ stop_requested=stop_requested,
388
+ target=None,
389
+ )
390
+ self._raise_if_stopped(stop_requested)
391
+ if output.images is None or len(output.images) != 1:
392
+ raise RuntimeError(
393
+ "Each batch-one Canter web invocation must return one PIL image."
394
+ )
395
+ image = _attach_png_metadata(
396
+ output.images[0],
397
+ prompt=request.prompt,
398
+ config=image_config,
399
+ metadata=pipeline.metadata,
400
+ )
401
+ elapsed = time.perf_counter() - started
402
+ status = _generation_status(
403
+ pipeline.metadata,
404
+ request=request,
405
+ elapsed=elapsed,
406
+ completed=image_number,
407
+ )
408
+ yield _generation_update(
409
+ _completed_image_slots(
410
+ image=image,
411
+ image_index=image_index,
412
+ ),
413
+ status,
414
+ progress_active=None,
415
+ )
416
+ except _GenerationStoppedError:
417
+ yield _generation_update(
418
+ _preserved_image_slots(),
419
+ "Stopped. Ready for another request.",
420
+ progress_active=None,
421
+ )
422
+ finally:
423
+ self._finish_generation(session_hash, stop_requested)
424
+
425
+ def poll_progress(
426
+ self,
427
+ request: gr.Request | None = None,
428
+ ) -> tuple[_GenerationOutputUpdate, ...]:
429
+ """Consume one newest preview, progress HTML, and polling lifecycle."""
430
+
431
+ session_hash = _session_hash(request)
432
+ with self._session_lock:
433
+ generation = self._generation_by_session.get(session_hash)
434
+ if generation is not None and generation.pending_preview is not None:
435
+ pending = generation.pending_preview
436
+ self._generation_by_session[session_hash] = replace(
437
+ generation,
438
+ pending_preview=None,
439
+ )
440
+ else:
441
+ pending = None
442
+ state = None if generation is None else generation.progress
443
+ image_updates = (
444
+ _preserved_image_slots()
445
+ if pending is None
446
+ else _completed_image_slots(
447
+ image=pending.image,
448
+ image_index=pending.image_index,
449
+ )
450
+ )
451
+ return (
452
+ *image_updates,
453
+ *_progress_html(state),
454
+ gr.update(active=generation is not None),
455
+ )
456
+
457
+ def _raise_if_stopped(self, stop_requested: threading.Event) -> None:
458
+ """Abort at the next safe boundary after a web stop request."""
459
+
460
+ if stop_requested.is_set():
461
+ raise _GenerationStoppedError
462
+
463
+ def _begin_generation(
464
+ self,
465
+ session_hash: str,
466
+ *,
467
+ image_index: int,
468
+ image_count: int,
469
+ completed_updates: int,
470
+ total_updates: int,
471
+ ) -> threading.Event:
472
+ """Register and return cancellation state for a new browser generation."""
473
+
474
+ progress = _WebProgressState(
475
+ image_index=image_index,
476
+ image_count=image_count,
477
+ completed_updates=completed_updates,
478
+ total_updates=total_updates,
479
+ )
480
+ stop_requested = threading.Event()
481
+ state = _WebGenerationState(
482
+ stop_requested=stop_requested,
483
+ progress=progress,
484
+ preview_image_index=image_index,
485
+ pending_preview=None,
486
+ )
487
+ with self._session_lock:
488
+ if session_hash in self._generation_by_session:
489
+ raise RuntimeError(
490
+ "A browser session cannot run multiple Canter generations."
491
+ )
492
+ self._generation_by_session[session_hash] = state
493
+ return stop_requested
494
+
495
+ def _set_progress(
496
+ self,
497
+ session_hash: str,
498
+ *,
499
+ image_index: int,
500
+ image_count: int,
501
+ completed_updates: int,
502
+ total_updates: int,
503
+ ) -> None:
504
+ """Publish one solver position for the requesting browser session."""
505
+
506
+ progress = _WebProgressState(
507
+ image_index=image_index,
508
+ image_count=image_count,
509
+ completed_updates=completed_updates,
510
+ total_updates=total_updates,
511
+ )
512
+ with self._session_lock:
513
+ state = self._generation_by_session.get(session_hash)
514
+ if state is None:
515
+ raise RuntimeError(
516
+ "Cannot update progress without an active generation."
517
+ )
518
+ self._generation_by_session[session_hash] = replace(
519
+ state,
520
+ progress=progress,
521
+ )
522
+
523
+ def _set_preview_target(
524
+ self,
525
+ session_hash: str,
526
+ *,
527
+ stop_requested: threading.Event,
528
+ target: int | None,
529
+ ) -> None:
530
+ """Select one image for previews, or reject late previews with None."""
531
+
532
+ with self._session_lock:
533
+ state = self._generation_by_session.get(session_hash)
534
+ if state is None or state.stop_requested is not stop_requested:
535
+ raise RuntimeError("Cannot update preview for an inactive generation.")
536
+ self._generation_by_session[session_hash] = replace(
537
+ state,
538
+ preview_image_index=target,
539
+ pending_preview=None,
540
+ )
541
+
542
+ def _publish_preview(
543
+ self,
544
+ session_hash: str,
545
+ *,
546
+ stop_requested: threading.Event,
547
+ image_index: int,
548
+ images: tuple[Image.Image, ...],
549
+ ) -> None:
550
+ """Publish only the newest preview for the still-active image."""
551
+
552
+ pending = _WebPreview(image_index=image_index, image=images[0])
553
+ with self._session_lock:
554
+ state = self._generation_by_session.get(session_hash)
555
+ if (
556
+ state is None
557
+ or state.stop_requested is not stop_requested
558
+ or state.preview_image_index != image_index
559
+ ):
560
+ return
561
+ self._generation_by_session[session_hash] = replace(
562
+ state,
563
+ pending_preview=pending,
564
+ )
565
+
566
+ def _finish_generation(
567
+ self,
568
+ session_hash: str,
569
+ stop_requested: threading.Event,
570
+ ) -> None:
571
+ """Remove exactly the browser generation that has finished."""
572
+
573
+ with self._session_lock:
574
+ state = self._generation_by_session.get(session_hash)
575
+ if state is None or state.stop_requested is not stop_requested:
576
+ raise RuntimeError(
577
+ "Canter browser generation state became inconsistent."
578
+ )
579
+ del self._generation_by_session[session_hash]
580
+
581
+ def _ensure_loaded(self) -> _ImagePipeline:
582
+ """Return the process pipeline, loading it exactly once when necessary."""
583
+
584
+ with self._load_lock:
585
+ if self._unload_pending:
586
+ raise RuntimeError("Canter backend unload is still in progress.")
587
+ return self._load_pipeline_locked()
588
+
589
+ def _load_pipeline_locked(self) -> _ImagePipeline:
590
+ """Load under the lifecycle lock and return the resident pipeline."""
591
+
592
+ if self.pipeline is None:
593
+ config = self._load_config
594
+ if config is None:
595
+ raise RuntimeError(
596
+ "Lazy Canter loading is missing its launch configuration."
597
+ )
598
+ self.pipeline = load_web_pipeline(config)
599
+ self._explicitly_unloaded = False
600
+ return self.pipeline
601
+
602
+
603
+ def _preserved_image_slots() -> tuple[dict[str, str], ...]:
604
+ """Return no-op updates that retain every displayed image."""
605
+
606
+ return tuple(gr.skip() for _ in range(_MAX_IMAGE_COUNT))
607
+
608
+
609
+ def _cleared_image_slots() -> tuple[None, ...]:
610
+ """Clear every image before one new sequential generation."""
611
+
612
+ return (None,) * _MAX_IMAGE_COUNT
613
+
614
+
615
+ def _completed_image_slots(
616
+ *,
617
+ image: Image.Image,
618
+ image_index: int,
619
+ ) -> tuple[_ImageSlotUpdate, ...]:
620
+ """Update exactly one newly completed image in the fixed output boundary."""
621
+
622
+ updates: list[_ImageSlotUpdate] = list(_preserved_image_slots())
623
+ updates[image_index] = image
624
+ return tuple(updates)
625
+
626
+
627
+ def _generation_update(
628
+ images: Sequence[_ImageSlotUpdate],
629
+ status: str,
630
+ *,
631
+ progress_active: bool | None,
632
+ ) -> _GenerationUpdate:
633
+ """Return image, status, and timer updates expected by Gradio."""
634
+
635
+ if len(images) != _MAX_IMAGE_COUNT:
636
+ raise ValueError(
637
+ f"Expected {_MAX_IMAGE_COUNT} web image slots, got {len(images)}."
638
+ )
639
+ return (
640
+ *images,
641
+ status,
642
+ (gr.skip() if progress_active is None else gr.update(active=progress_active)),
643
+ )
644
+
645
+
646
+ def _step_progress(
647
+ *,
648
+ stopped: Callable[[], bool],
649
+ update_web: Callable[[int, int], None],
650
+ ) -> SolverProgress:
651
+ """Create an exact solver-update reporter for one sequential image."""
652
+
653
+ def report(completed: int, total: int) -> None:
654
+ """Update the current image bar while retaining the total image bar."""
655
+
656
+ if stopped():
657
+ raise _GenerationStoppedError
658
+ update_web(completed, total)
659
+
660
+ return report
661
+
662
+
663
+ def _session_hash(request: gr.Request | None) -> str:
664
+ """Return the required Gradio browser-session identifier."""
665
+
666
+ if request is None or request.session_hash is None or not request.session_hash:
667
+ raise ValueError("Web generation requires a Gradio browser session hash.")
668
+ return request.session_hash
669
+
670
+
671
+ def _progress_html(
672
+ state: _WebProgressState | None,
673
+ ) -> tuple[str, ...]:
674
+ """Render total progress and the active image's solver overlay."""
675
+
676
+ slots = [""] * _MAX_IMAGE_COUNT
677
+ if state is None:
678
+ return ("", *slots)
679
+ image_fraction = state.completed_updates / state.total_updates
680
+ total_fraction = (state.image_index + image_fraction) / state.image_count
681
+ image_percent = 100.0 * image_fraction
682
+ total_percent = 100.0 * total_fraction
683
+ total_html = f"""
684
+ <div class="canter-progress-panel">
685
+ <div class="canter-progress-label">
686
+ Total progress · Image {state.image_index + 1}/{state.image_count}
687
+ </div>
688
+ <div class="canter-progress-row">
689
+ <span>Total</span>
690
+ <div class="canter-progress-track">
691
+ <div style="width: {total_percent:.2f}%"></div>
692
+ </div>
693
+ <span>{total_percent:.0f}%</span>
694
+ </div>
695
+ </div>
696
+ """
697
+ slots[state.image_index] = f"""
698
+ <div class="canter-progress-panel">
699
+ <div class="canter-progress-label">
700
+ Image {state.image_index + 1}/{state.image_count}
701
+ · {state.completed_updates}/{state.total_updates} updates
702
+ </div>
703
+ <div class="canter-progress-row">
704
+ <span>Image</span>
705
+ <div class="canter-progress-track">
706
+ <div style="width: {image_percent:.2f}%"></div>
707
+ </div>
708
+ <span>{image_percent:.0f}%</span>
709
+ </div>
710
+ </div>
711
+ """
712
+ return (total_html, *slots)
713
+
714
+
715
+ def _choice(
716
+ value: str | None,
717
+ choices: Mapping[str, _Choice],
718
+ name: str,
719
+ ) -> _Choice:
720
+ """Resolve one exact enum-backed form choice or fail with its field name."""
721
+
722
+ if value is None:
723
+ raise ValueError(f"{name} is required.")
724
+ selected = choices.get(value)
725
+ if selected is None:
726
+ raise ValueError(f"Unsupported {name}: {value!r}.")
727
+ return selected
728
+
729
+
730
+ def _integer(
731
+ value: int | float | None,
732
+ name: str,
733
+ *,
734
+ minimum: int,
735
+ maximum: int | None,
736
+ ) -> int:
737
+ """Convert one integral form number and enforce its explicit bounds."""
738
+
739
+ if value is None or isinstance(value, bool):
740
+ raise TypeError(f"{name} must be an integer.")
741
+ number = float(value)
742
+ if not math.isfinite(number) or not number.is_integer():
743
+ raise ValueError(f"{name} must be a finite integer.")
744
+ result = int(number)
745
+ if result < minimum:
746
+ raise ValueError(f"{name} must be at least {minimum}.")
747
+ if maximum is not None and result > maximum:
748
+ raise ValueError(f"{name} must not exceed {maximum}.")
749
+ return result
750
+
751
+
752
+ def _optional_integer(
753
+ value: int | float | str | None,
754
+ name: str,
755
+ ) -> int | None:
756
+ """Convert a blank solver stop field to ``None`` or validate an index."""
757
+
758
+ if value is None:
759
+ return None
760
+ if isinstance(value, str):
761
+ stripped = value.strip()
762
+ if not stripped:
763
+ return None
764
+ try:
765
+ numeric = float(stripped)
766
+ except ValueError as exc:
767
+ raise ValueError(f"{name} must be blank or a finite integer.") from exc
768
+ return _integer(numeric, name, minimum=0, maximum=None)
769
+ return _integer(value, name, minimum=0, maximum=None)
770
+
771
+
772
+ def _number(value: int | float, name: str) -> float:
773
+ """Require one finite floating-point form value."""
774
+
775
+ if isinstance(value, bool):
776
+ raise TypeError(f"{name} must be a number.")
777
+ result = float(value)
778
+ if not math.isfinite(result):
779
+ raise ValueError(f"{name} must be finite.")
780
+ return result
781
+
782
+
783
+ def _image_dimension(value: int | float | None, name: str) -> int:
784
+ """Snap one finite image dimension to the supported 32-pixel lattice."""
785
+
786
+ if value is None:
787
+ raise ValueError(f"{name} is required.")
788
+ number = _number(value, name)
789
+ if number < _IMAGE_SIZE_MULTIPLE:
790
+ raise ValueError(f"{name} must be at least {_IMAGE_SIZE_MULTIPLE}.")
791
+ return snap_image_dimension(number)
792
+
793
+
794
+ def snap_image_dimension(value: int | float | None) -> int:
795
+ """Snap one browser image dimension to its nearest valid multiple."""
796
+
797
+ if value is None:
798
+ raise ValueError("image size is required.")
799
+ number = _number(value, "image size")
800
+ snapped = math.floor(
801
+ (max(number, _IMAGE_SIZE_MULTIPLE) + _IMAGE_SIZE_MULTIPLE / 2)
802
+ / _IMAGE_SIZE_MULTIPLE
803
+ )
804
+ return snapped * _IMAGE_SIZE_MULTIPLE
805
+
806
+
807
+ def _validated_prompt(prompt: str) -> str:
808
+ """Return a string prompt without changing the user's text."""
809
+
810
+ if not isinstance(prompt, str):
811
+ raise TypeError("prompt must be a string.")
812
+ return prompt
813
+
814
+
815
+ def _pdg_mode_uses_cfg(mode: PdgMode) -> bool:
816
+ """Return whether one public PDG mode consumes the CFG scale."""
817
+
818
+ match mode:
819
+ case (
820
+ PdgMode.ALTERNATE_PDG_FIRST
821
+ | PdgMode.ALTERNATE_CFG_FIRST
822
+ | PdgMode.COMBINED_CFG_PDG
823
+ | PdgMode.PDG_WITH_ALTERNATING_CFG
824
+ | PdgMode.CFG_TO_PDG
825
+ ):
826
+ return True
827
+ case PdgMode.FULL | PdgMode.THREE_QUARTER:
828
+ return False
829
+ case PdgMode.NONE:
830
+ raise ValueError("The enabled web PDG selection cannot use mode none.")
831
+ case _ as unreachable:
832
+ raise RuntimeError(f"Unsupported PDG mode: {unreachable}")
833
+
834
+
835
+ def update_pdg_clean_scale(
836
+ curve: str,
837
+ noisy_scale: int | float,
838
+ ) -> dict[str, object]:
839
+ """Disable and synchronize the unused clean endpoint for constant PDG."""
840
+
841
+ resolved_curve = _choice(curve, _PDG_CURVES, "PDG curve")
842
+ match resolved_curve:
843
+ case PdgCurve.CONSTANT:
844
+ return gr.update(
845
+ value=_number(noisy_scale, "PDG noisy scale"),
846
+ interactive=False,
847
+ )
848
+ case PdgCurve.LINEAR | PdgCurve.POWER:
849
+ return gr.update(interactive=True)
850
+ case _ as unreachable:
851
+ raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
852
+
853
+
854
+ def build_web_request(
855
+ *,
856
+ prompt: str,
857
+ image_count: int | float,
858
+ width: int | float,
859
+ height: int | float,
860
+ seed: int | float,
861
+ steps: int | float,
862
+ solver: str,
863
+ schedule: str,
864
+ log_snr_shift: int | float,
865
+ self_attention_gain: int | float,
866
+ euler_maruyama_multiplier: int | float,
867
+ cfg_enabled: bool,
868
+ cfg_scale: int | float,
869
+ cfg_start_step: int | float,
870
+ cfg_stop_step: int | float | str | None,
871
+ pdg_enabled: bool,
872
+ pdg_mode: str,
873
+ pdg_curve: str,
874
+ pdg_noisy_scale: int | float,
875
+ pdg_clean_scale: int | float,
876
+ pdg_power: int | float,
877
+ pdg_start_step: int | float,
878
+ pdg_stop_step: int | float | str | None,
879
+ ) -> CanterWebRequest:
880
+ """Validate raw Gradio values and construct the frozen Canter dataclasses."""
881
+
882
+ resolved_pdg_mode = _choice(pdg_mode, _PDG_MODES, "PDG mode")
883
+ active_pdg_mode = resolved_pdg_mode if pdg_enabled else PdgMode.NONE
884
+ uses_cfg_scale = pdg_enabled and _pdg_mode_uses_cfg(resolved_pdg_mode)
885
+ resolved_cfg_scale = (
886
+ _number(cfg_scale, "CFG scale") if cfg_enabled or uses_cfg_scale else None
887
+ )
888
+ cfg = CfgGuidance(
889
+ enabled=cfg_enabled,
890
+ scale=resolved_cfg_scale,
891
+ start_step=_integer(
892
+ cfg_start_step,
893
+ "CFG start step",
894
+ minimum=0,
895
+ maximum=None,
896
+ ),
897
+ stop_step=_optional_integer(cfg_stop_step, "CFG stop step"),
898
+ )
899
+ resolved_pdg_curve = _choice(pdg_curve, _PDG_CURVES, "PDG curve")
900
+ resolved_pdg_noisy_scale = _number(pdg_noisy_scale, "PDG noisy scale")
901
+ resolved_pdg_clean_scale = (
902
+ resolved_pdg_noisy_scale
903
+ if resolved_pdg_curve is PdgCurve.CONSTANT
904
+ else _number(pdg_clean_scale, "PDG clean scale")
905
+ )
906
+ pdg = PdgGuidance(
907
+ enabled=pdg_enabled,
908
+ mode=active_pdg_mode,
909
+ curve=resolved_pdg_curve,
910
+ noisy_scale=resolved_pdg_noisy_scale,
911
+ clean_scale=resolved_pdg_clean_scale,
912
+ power=_number(pdg_power, "PDG power"),
913
+ start_step=_integer(
914
+ pdg_start_step,
915
+ "PDG start step",
916
+ minimum=0,
917
+ maximum=None,
918
+ ),
919
+ stop_step=_optional_integer(pdg_stop_step, "PDG stop step"),
920
+ )
921
+ resolved_image_count = _integer(
922
+ image_count,
923
+ "image count",
924
+ minimum=1,
925
+ maximum=_MAX_IMAGE_COUNT,
926
+ )
927
+ resolved_seed = _integer(
928
+ seed,
929
+ "seed",
930
+ minimum=0,
931
+ maximum=_MAX_SEED - resolved_image_count + 1,
932
+ )
933
+ inference = CanterInferenceConfig(
934
+ height=_image_dimension(height, "height"),
935
+ width=_image_dimension(width, "width"),
936
+ steps=_integer(steps, "steps", minimum=1, maximum=None),
937
+ solver=_choice(solver, _SOLVERS, "solver"),
938
+ schedule=_choice(schedule, _SCHEDULES, "schedule"),
939
+ log_snr_shift=_number(log_snr_shift, "log-SNR shift"),
940
+ cfg=cfg,
941
+ pdg=pdg,
942
+ self_attention_gain=_number(
943
+ self_attention_gain,
944
+ "self-attention gain",
945
+ ),
946
+ euler_maruyama_multiplier=_number(
947
+ euler_maruyama_multiplier,
948
+ "SDE noise multiplier",
949
+ ),
950
+ er_sde_noise_multiplier=_number(
951
+ euler_maruyama_multiplier,
952
+ "SDE noise multiplier",
953
+ ),
954
+ seed=resolved_seed,
955
+ generator=None,
956
+ )
957
+ return CanterWebRequest(
958
+ prompt=_validated_prompt(prompt),
959
+ image_count=resolved_image_count,
960
+ config=CanterPipelineConfig(
961
+ inference=inference,
962
+ output_type=CanterOutputType.PIL,
963
+ ),
964
+ )
965
+
966
+
967
+ def _image_config(
968
+ request: CanterWebRequest,
969
+ image_index: int,
970
+ ) -> CanterPipelineConfig:
971
+ """Return a batch-one config using the next deterministic image seed."""
972
+
973
+ seed = request.config.inference.seed
974
+ if seed is None:
975
+ raise RuntimeError("A validated Canter web request must contain a seed.")
976
+ inference = replace(
977
+ request.config.inference,
978
+ seed=seed + image_index,
979
+ generator=None,
980
+ )
981
+ return replace(request.config, inference=inference)
982
+
983
+
984
+ def _pdg_metadata(pdg: PdgGuidance) -> dict[str, object]:
985
+ """Return PDG fields in public API order."""
986
+
987
+ return {
988
+ "enabled": pdg.enabled,
989
+ "mode": pdg.mode.value,
990
+ "curve": pdg.curve.value,
991
+ "noisy_scale": pdg.noisy_scale,
992
+ "clean_scale": pdg.clean_scale,
993
+ "power": pdg.power,
994
+ "start_step": pdg.start_step,
995
+ "stop_step": pdg.stop_step,
996
+ }
997
+
998
+
999
+ def _cfg_metadata(cfg: CfgGuidance) -> dict[str, object]:
1000
+ """Return CFG fields in public API order."""
1001
+
1002
+ return {
1003
+ "enabled": cfg.enabled,
1004
+ "scale": cfg.scale,
1005
+ "start_step": cfg.start_step,
1006
+ "stop_step": cfg.stop_step,
1007
+ }
1008
+
1009
+
1010
+ def _png_metadata_json(
1011
+ *,
1012
+ prompt: str,
1013
+ config: CanterPipelineConfig,
1014
+ metadata: CanterPipelineMetadata,
1015
+ ) -> str:
1016
+ """Serialize reproducible UI settings with primary inputs first."""
1017
+
1018
+ inference = config.inference
1019
+ if inference.seed is None:
1020
+ raise RuntimeError("PNG metadata requires the resolved per-image seed.")
1021
+ payload: dict[str, object] = {
1022
+ "prompt": prompt,
1023
+ "seed": inference.seed,
1024
+ "width": inference.width,
1025
+ "height": inference.height,
1026
+ "steps": inference.steps,
1027
+ "solver": inference.solver.value,
1028
+ "schedule": inference.schedule.value,
1029
+ "pdg": _pdg_metadata(inference.pdg),
1030
+ "cfg": _cfg_metadata(inference.cfg),
1031
+ "self_attention_gain": inference.self_attention_gain,
1032
+ "log_snr_shift": inference.log_snr_shift,
1033
+ "euler_maruyama_multiplier": inference.euler_maruyama_multiplier,
1034
+ "er_sde_noise_multiplier": inference.er_sde_noise_multiplier,
1035
+ "code_version": metadata.code_version,
1036
+ "release": metadata.canter.release,
1037
+ "weight_dtype": metadata.canter.weight_dtype.value,
1038
+ }
1039
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
1040
+
1041
+
1042
+ def _attach_png_metadata(
1043
+ image: Image.Image,
1044
+ *,
1045
+ prompt: str,
1046
+ config: CanterPipelineConfig,
1047
+ metadata: CanterPipelineMetadata,
1048
+ ) -> Image.Image:
1049
+ """Attach the ordered API query for Gradio's PNG encoder."""
1050
+
1051
+ image.info[_PNG_METADATA_KEY] = _png_metadata_json(
1052
+ prompt=prompt,
1053
+ config=config,
1054
+ metadata=metadata,
1055
+ )
1056
+ return image
1057
+
1058
+
1059
+ def random_seed() -> int:
1060
+ """Return a non-negative seed accepted by the Canter web form."""
1061
+
1062
+ return secrets.randbelow(_MAX_SEED + 1)
1063
+
1064
+
1065
+ def _model_summary(metadata: CanterPipelineMetadata) -> str:
1066
+ """Render immutable model and decoder provenance above the controls."""
1067
+
1068
+ canter = metadata.canter
1069
+ return (
1070
+ f"**Canter checkpoint {canter.release}** · "
1071
+ f"code `{metadata.code_version}` · "
1072
+ f"{canter.weight_dtype.value} EMA weights \n"
1073
+ f"VAE `{metadata.vae_repository}` @ `{metadata.vae_revision[:12]}`"
1074
+ )
1075
+
1076
+
1077
+ def _unloaded_model_status() -> str:
1078
+ """Render the explicit lazy-reload state above the controls."""
1079
+
1080
+ return (
1081
+ "**Canter model unloaded** · CUDA and compiler caches cleared \n"
1082
+ "The next generation will reload and compile the selected checkpoint."
1083
+ )
1084
+
1085
+
1086
+ def _log_generation_request(request: CanterWebRequest) -> None:
1087
+ """Log the exact effective controls consumed by one generation request."""
1088
+
1089
+ inference = request.config.inference
1090
+ prompt_digest = sha256(request.prompt.encode("utf-8")).hexdigest()[:12]
1091
+ _LOGGER.info(
1092
+ "Canter request | "
1093
+ f"prompt_sha256={prompt_digest} | images={request.image_count} | "
1094
+ f"size={inference.width}x{inference.height} | seed={inference.seed} | "
1095
+ f"steps={inference.steps} | solver={inference.solver.value} | "
1096
+ f"schedule={inference.schedule.value} | "
1097
+ f"log_snr_shift={inference.log_snr_shift:g} | "
1098
+ f"self_attention_gain={inference.self_attention_gain:g} | "
1099
+ f"{_pdg_status(inference)} | cfg_enabled={inference.cfg.enabled} | "
1100
+ f"cfg_scale={inference.cfg.scale}",
1101
+ )
1102
+
1103
+
1104
+ def _pdg_status(inference: CanterInferenceConfig) -> str:
1105
+ """Render the effective PDG request consumed by latent inference."""
1106
+
1107
+ pdg = inference.pdg
1108
+ if not pdg.enabled:
1109
+ return "PDG off"
1110
+ stop = inference.steps - 1 if pdg.stop_step is None else pdg.stop_step
1111
+ match pdg.curve:
1112
+ case PdgCurve.CONSTANT:
1113
+ scale = f"{float(pdg.noisy_scale):g}"
1114
+ case PdgCurve.LINEAR:
1115
+ scale = f"linear {float(pdg.noisy_scale):g}→{float(pdg.clean_scale):g}"
1116
+ case PdgCurve.POWER:
1117
+ scale = (
1118
+ f"power {float(pdg.noisy_scale):g}→"
1119
+ f"{float(pdg.clean_scale):g} (p={float(pdg.power):g})"
1120
+ )
1121
+ case _ as unreachable:
1122
+ raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
1123
+ return f"PDG `{pdg.mode.value}` {scale}, updates {pdg.start_step}–{stop}"
1124
+
1125
+
1126
+ def _generation_status(
1127
+ metadata: CanterPipelineMetadata,
1128
+ *,
1129
+ request: CanterWebRequest,
1130
+ elapsed: float,
1131
+ completed: int,
1132
+ ) -> str:
1133
+ """Render incremental timing and deterministic request provenance."""
1134
+
1135
+ inference = request.config.inference
1136
+ image_word = "image" if completed == 1 else "images"
1137
+ if inference.seed is None:
1138
+ raise RuntimeError("A validated Canter web request must contain a seed.")
1139
+ final_seed = inference.seed + completed - 1
1140
+ seed_text = (
1141
+ str(inference.seed) if completed == 1 else f"{inference.seed}–{final_seed}"
1142
+ )
1143
+ progress_text = (
1144
+ f"Generated **{completed}/{request.image_count} {image_word}**"
1145
+ if completed < request.image_count
1146
+ else f"Generated **{completed} {image_word}**"
1147
+ )
1148
+ return (
1149
+ f"{progress_text} in **{elapsed:.2f}s** · "
1150
+ f"seeds `{seed_text}` · batch size `1` · "
1151
+ f"{inference.width}×{inference.height} · "
1152
+ f"{inference.steps} {inference.solver.value} updates · "
1153
+ f"{_pdg_status(inference)} · "
1154
+ f"Canter code `{metadata.code_version}` · "
1155
+ f"checkpoint `{metadata.canter.release}`"
1156
+ )
1157
+
1158
+
1159
+ def load_web_pipeline(config: CanterWebLaunchConfig) -> CanterPipeline:
1160
+ """Load and compile exactly one selected Canter backend for the web process."""
1161
+
1162
+ return CanterPipeline.from_pretrained(
1163
+ config.model,
1164
+ dtype=config.dtype,
1165
+ text_backend=config.text_backend,
1166
+ device=config.device,
1167
+ revision=config.revision,
1168
+ cache_dir=config.cache_dir,
1169
+ compile_model=True,
1170
+ vae=None,
1171
+ )
release.json CHANGED
@@ -2,6 +2,11 @@
2
  "dtype": "bfloat16",
3
  "exporter_sha256": "df8a6cf46d78b38d8297b0c75a76c1eaaf859bb55ec3375a623dc4804d5571d1",
4
  "exporter_source_commit": "e75e83d792f45eb82e60e022500edead452be79a",
 
 
 
 
 
5
  "license": "MG-BY-SA-2.0",
6
  "parameter_count": 2061680772,
7
  "parity_result_ids": [
 
2
  "dtype": "bfloat16",
3
  "exporter_sha256": "df8a6cf46d78b38d8297b0c75a76c1eaaf859bb55ec3375a623dc4804d5571d1",
4
  "exporter_source_commit": "e75e83d792f45eb82e60e022500edead452be79a",
5
+ "latent_rgb_preview_source_checkpoint_sha256": "f42fd1af65010acba63582288d1802e054ab1cde4c3226dc02494c882cdea78e",
6
+ "latent_rgb_preview_source_config_sha256": "8aff4041a12fe2165442867299f82e14853c2f71d9c94b6705e67521d6c042fb",
7
+ "latent_rgb_preview_source_run_hash": "553803c53a59848a",
8
+ "latent_rgb_preview_source_run_id": "625",
9
+ "latent_rgb_preview_source_step": 5600,
10
  "license": "MG-BY-SA-2.0",
11
  "parameter_count": 2061680772,
12
  "parity_result_ids": [
weights_manifest.json CHANGED
@@ -6207,6 +6207,35 @@
6207
  ],
6208
  "total_size": 4132975120
6209
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6210
  "text_encoder": {
6211
  "parameter_count": 283161600,
6212
  "tensor_count": 217,
 
6207
  ],
6208
  "total_size": 4132975120
6209
  },
6210
+ "latent_rgb_preview": {
6211
+ "filename": "canter/latent_rgb_preview.safetensors",
6212
+ "parameter_count": 1548,
6213
+ "tensor_count": 2,
6214
+ "tensors": [
6215
+ {
6216
+ "dtype": "float32",
6217
+ "name": "projection.bias",
6218
+ "nbytes": 48,
6219
+ "numel": 12,
6220
+ "shape": [
6221
+ 12
6222
+ ]
6223
+ },
6224
+ {
6225
+ "dtype": "float32",
6226
+ "name": "projection.weight",
6227
+ "nbytes": 6144,
6228
+ "numel": 1536,
6229
+ "shape": [
6230
+ 12,
6231
+ 128,
6232
+ 1,
6233
+ 1
6234
+ ]
6235
+ }
6236
+ ],
6237
+ "total_size": 6192
6238
+ },
6239
  "text_encoder": {
6240
  "parameter_count": 283161600,
6241
  "tensor_count": 217,