rastermoves 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. rastermoves/__init__.py +14 -0
  2. rastermoves/__main__.py +2 -0
  3. rastermoves/backends/__init__.py +0 -0
  4. rastermoves/backends/base.py +17 -0
  5. rastermoves/backends/onnx_backend.py +110 -0
  6. rastermoves/backends/spandrel_backend.py +158 -0
  7. rastermoves/catalog.py +127 -0
  8. rastermoves/cli.py +353 -0
  9. rastermoves/downloads.py +184 -0
  10. rastermoves/errors.py +14 -0
  11. rastermoves/models/2x-NomosUni-span-multijpg.json +29 -0
  12. rastermoves/models/4x-LexicaHAT.json +26 -0
  13. rastermoves/models/4x-Remacri.json +27 -0
  14. rastermoves/models/4x-SPANkendata.json +27 -0
  15. rastermoves/models/4x-UltraSharpV2.json +40 -0
  16. rastermoves/models/4x-realesr-animevideo-v3.json +28 -0
  17. rastermoves/models/4x-realesr-general-x4v3.json +29 -0
  18. rastermoves/models/4x-realesrgan-x4plus.json +29 -0
  19. rastermoves/network.py +86 -0
  20. rastermoves/paths.py +16 -0
  21. rastermoves/pipeline.py +283 -0
  22. rastermoves/plugins.py +63 -0
  23. rastermoves/py.typed +0 -0
  24. rastermoves/refinement/__init__.py +5 -0
  25. rastermoves/refinement/api.py +359 -0
  26. rastermoves/refinement/cli.py +159 -0
  27. rastermoves/refinement/diffusers_backend.py +371 -0
  28. rastermoves/refinement/specs.py +146 -0
  29. rastermoves/refinement/tiles.py +114 -0
  30. rastermoves/registry.py +84 -0
  31. rastermoves/specs.py +103 -0
  32. rastermoves/sweep.py +296 -0
  33. rastermoves/tiling.py +117 -0
  34. rastermoves/tracing.py +350 -0
  35. rastermoves-0.3.1.dist-info/METADATA +547 -0
  36. rastermoves-0.3.1.dist-info/RECORD +40 -0
  37. rastermoves-0.3.1.dist-info/WHEEL +5 -0
  38. rastermoves-0.3.1.dist-info/entry_points.txt +2 -0
  39. rastermoves-0.3.1.dist-info/licenses/LICENSE +674 -0
  40. rastermoves-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ """RasterMoves: modular image upscaling and enhancement for DanceFlow."""
2
+ __version__ = "0.3.1"
3
+
4
+ # Keep importing the package lightweight; neural frameworks load only on use.
5
+ def __getattr__(name):
6
+ if name == "Upscaler":
7
+ from .pipeline import Upscaler
8
+ return Upscaler
9
+ if name == "Registry":
10
+ from .registry import Registry
11
+ return Registry
12
+ raise AttributeError(name)
13
+
14
+ __all__ = ["Upscaler", "Registry", "__version__"]
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+ from typing import Protocol
3
+ import numpy as np
4
+
5
+
6
+ class LoadedModel(Protocol):
7
+ """Backend contract: HWC float32 RGB/gray arrays in [0,1], no batch axis."""
8
+ scale: int
9
+ input_channels: int
10
+ output_channels: int
11
+ tiling: str # supported | discouraged | internal
12
+ device: str
13
+ precision: str
14
+
15
+ def predict(self, image: np.ndarray) -> np.ndarray: ...
16
+ def clear_cache(self) -> None: ...
17
+ def close(self) -> None: ...
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import gc
4
+ from pathlib import Path
5
+ import numpy as np
6
+
7
+ from ..errors import BackendOOM, UnsupportedModelError, UpscaleError
8
+
9
+
10
+ class OnnxModel:
11
+ """Single-file, single-image ONNX models; normalized float NCHW/NHWC tensors."""
12
+ def __init__(self, path: Path, spec, *, device="auto", precision="fp32", **_):
13
+ try:
14
+ import onnxruntime as ort
15
+ except ImportError as e:
16
+ raise UpscaleError("Install the onnx extra (CPU), or onnx-gpu for CUDA. Do not install both runtime packages.") from e
17
+ available = ort.get_available_providers()
18
+ if device == "auto":
19
+ device = "cuda" if "CUDAExecutionProvider" in available else "cpu"
20
+ if device == "cpu":
21
+ providers = ["CPUExecutionProvider"]
22
+ elif device == "cuda" or device.startswith("cuda:"):
23
+ if "CUDAExecutionProvider" not in available:
24
+ raise UpscaleError("The installed ONNX Runtime has no CUDA provider. Install onnxruntime-gpu or use cpu.")
25
+ index = int(device.split(":")[1]) if ":" in device else 0
26
+ providers = [("CUDAExecutionProvider", {"device_id": index}), "CPUExecutionProvider"]
27
+ else:
28
+ raise UpscaleError("The ONNX backend currently accepts auto, cpu, cuda, or cuda:N.")
29
+ try:
30
+ # Bytes disable external-data path resolution; multi-file ONNX needs a custom backend.
31
+ self.session = ort.InferenceSession(path.read_bytes(), providers=providers)
32
+ except Exception as e:
33
+ raise UnsupportedModelError(f"ONNX load failed ({type(e).__name__}). Only self-contained ONNX files are supported.") from e
34
+ active = self.session.get_providers()
35
+ if device.startswith("cuda") and "CUDAExecutionProvider" not in active:
36
+ raise UpscaleError("ONNX CUDA initialization failed and fell back to CPU; check CUDA/cuDNN libraries.")
37
+ inputs, outputs = self.session.get_inputs(), self.session.get_outputs()
38
+ if len(inputs) != 1 or len(outputs) != 1 or len(inputs[0].shape) != 4 or len(outputs[0].shape) != 4:
39
+ raise UnsupportedModelError("ONNX model must expose one rank-four image input and one rank-four image output.")
40
+ self.input, self.output = inputs[0], outputs[0]
41
+ types = {"tensor(float)": np.float32, "tensor(float16)": np.float16}
42
+ if self.input.type not in types:
43
+ raise UnsupportedModelError("Only normalized float32/float16 ONNX image inputs are supported.")
44
+ self.dtype = types[self.input.type]
45
+ actual = "fp16" if self.dtype == np.float16 else "fp32"
46
+ if precision != actual:
47
+ raise UpscaleError(f"ONNX precision is fixed by the export ({actual}); select --precision {actual}.")
48
+ self.layout = spec.options.get("layout", "NCHW")
49
+ if self.layout not in {"NCHW", "NHWC"}:
50
+ raise UnsupportedModelError("ONNX layout must be NCHW or NHWC.")
51
+ self.input_channels, self.output_channels, self.scale = spec.input_channels, spec.output_channels, spec.scale
52
+ if self.input_channels not in (1, 3) or self.output_channels not in (1, 3):
53
+ raise UnsupportedModelError("Only RGB/grayscale ONNX models are supported.")
54
+ channel_axis = 1 if self.layout == "NCHW" else 3
55
+ for info, expected in ((self.input, self.input_channels), (self.output, self.output_channels)):
56
+ if isinstance(info.shape[0], int) and info.shape[0] != 1:
57
+ raise UnsupportedModelError("ONNX batch size must be one or dynamic.")
58
+ if isinstance(info.shape[channel_axis], int) and info.shape[channel_axis] != expected:
59
+ raise UnsupportedModelError("ONNX channel shape disagrees with the model plugin.")
60
+ dims = self.input.shape[2:4] if self.layout == "NCHW" else self.input.shape[1:3]
61
+ self.fixed = tuple(v if isinstance(v, int) and v > 0 else None for v in dims)
62
+ self.multiple = int(spec.options.get("multiple_of", 1))
63
+ self.minimum = int(spec.options.get("minimum_size", 1))
64
+ self.square = bool(spec.options.get("square", False))
65
+ self.bgr = spec.options.get("channel_order", "RGB") == "BGR"
66
+ if self.multiple < 1 or self.minimum < 1:
67
+ raise UpscaleError("ONNX padding requirements must be positive.")
68
+ self.tiling = spec.options.get("tiling", "supported")
69
+ self.device, self.precision = device, actual
70
+
71
+ def predict(self, image: np.ndarray) -> np.ndarray:
72
+ h, w, _ = image.shape
73
+ sizes = [max(self.minimum, ((v + self.multiple - 1) // self.multiple) * self.multiple) for v in (h, w)]
74
+ if self.square:
75
+ sizes = [max(sizes)] * 2
76
+ sizes = [fixed if fixed is not None else v for fixed, v in zip(self.fixed, sizes)]
77
+ if sizes[0] < h or sizes[1] < w:
78
+ raise UpscaleError(f"ONNX input exceeds fixed shape {self.fixed}; lower --tile and --tile-pad, or use a dynamic export.")
79
+ x = np.pad(image, ((0, sizes[0] - h), (0, sizes[1] - w), (0, 0)), mode="edge")
80
+ if self.bgr and self.input_channels == 3:
81
+ x = x[..., ::-1]
82
+ if self.layout == "NCHW":
83
+ x = x.transpose(2, 0, 1)
84
+ try:
85
+ out = self.session.run([self.output.name], {
86
+ self.input.name: np.ascontiguousarray(x[None], dtype=self.dtype)
87
+ })[0]
88
+ except Exception as e:
89
+ if any(k in str(e).lower() for k in ("out of memory", "cuda_error_out_of_memory")):
90
+ raise BackendOOM("ONNX device ran out of memory.") from None
91
+ raise UpscaleError(f"ONNX inference failed ({type(e).__name__}); check shape/padding options in the model plugin.") from e
92
+ if not isinstance(out, np.ndarray) or out.ndim != 4 or out.shape[0] != 1:
93
+ raise UnsupportedModelError("Unexpected ONNX image output.")
94
+ out = out[0]
95
+ if self.layout == "NCHW":
96
+ out = out.transpose(1, 2, 0)
97
+ expected = (sizes[0] * self.scale, sizes[1] * self.scale, self.output_channels)
98
+ if out.shape != expected:
99
+ raise UnsupportedModelError(f"ONNX output shape {out.shape} does not match {expected}; check native scale/layout.")
100
+ out = out[:h * self.scale, :w * self.scale]
101
+ if self.bgr and self.output_channels == 3:
102
+ out = out[..., ::-1]
103
+ return np.ascontiguousarray(out, dtype=np.float32)
104
+
105
+ def clear_cache(self):
106
+ gc.collect()
107
+
108
+ def close(self):
109
+ self.session = None
110
+ self.clear_cache()
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ import gc
5
+ from pathlib import Path
6
+ import zipfile
7
+
8
+ import numpy as np
9
+
10
+ from ..errors import BackendOOM, UnsupportedModelError, UpscaleError
11
+
12
+
13
+ def safe_state_dict(path: Path):
14
+ import torch
15
+ major, minor = map(int, torch.__version__.split("+")[0].split(".")[:2])
16
+ if (major, minor) < (2, 6):
17
+ raise UpscaleError("PyTorch >=2.6 is required for restricted checkpoint loading. Upgrade torch/torchvision.")
18
+ if path.suffix.lower() == ".safetensors":
19
+ from safetensors.torch import load_file
20
+ state = load_file(str(path), device="cpu")
21
+ else:
22
+ if zipfile.is_zipfile(path):
23
+ with zipfile.ZipFile(path) as z:
24
+ if any("/code/" in n or n.endswith("/constants.pkl") for n in z.namelist()):
25
+ raise UnsupportedModelError("TorchScript archives are not accepted; use state-dict weights or safetensors.")
26
+ # Never fall back to weights_only=False or torch.hub/trust_remote_code.
27
+ state = torch.load(path, map_location="cpu", weights_only=True)
28
+ if not isinstance(state, Mapping):
29
+ raise UnsupportedModelError("Expected a tensor state dictionary, not a serialized Python model.")
30
+ return state
31
+
32
+
33
+ def normalize_state_dict(state):
34
+ """Unwrap common training checkpoints without invoking private Spandrel APIs.
35
+
36
+ Spandrel's public load_from_state_dict expects the actual parameter mapping,
37
+ unlike its load_from_file convenience method. Only passive mappings are read.
38
+ """
39
+ wrappers = ("model_state_dict", "state_dict", "params_ema", "params-ema", "params", "model", "net")
40
+ for _ in range(8):
41
+ child = next((state[k] for k in wrappers if k in state and isinstance(state[k], Mapping)), None)
42
+ if child is None and len(state) == 1:
43
+ value = next(iter(state.values()))
44
+ child = value if isinstance(value, Mapping) else None
45
+ if child is None:
46
+ break
47
+ state = child
48
+ if not state or not all(isinstance(k, str) for k in state):
49
+ raise UnsupportedModelError("Expected a nonempty state dictionary with string parameter names.")
50
+ for _ in range(8):
51
+ prefix = next((p for p in ("module.", "netG.") if all(k.startswith(p) for k in state)), None)
52
+ if prefix is None:
53
+ break
54
+ state = {k[len(prefix):]: v for k, v in state.items()}
55
+ return state
56
+
57
+
58
+ _EXTRA_ARCHES_INSTALLED = False
59
+
60
+
61
+ def resolve_device(requested: str):
62
+ import torch
63
+ if requested == "auto":
64
+ requested = "cuda" if torch.cuda.is_available() else (
65
+ "mps" if torch.backends.mps.is_available() else "cpu")
66
+ if requested not in {"cpu", "mps", "cuda"} and not requested.startswith("cuda:"):
67
+ raise UpscaleError("PyTorch device must be auto, cpu, mps, cuda, or cuda:N.")
68
+ try:
69
+ device = torch.device(requested)
70
+ except (RuntimeError, ValueError) as e:
71
+ raise UpscaleError("Invalid PyTorch device; use cpu, mps, cuda, or cuda:N.") from e
72
+ if device.type == "cuda":
73
+ if not torch.cuda.is_available():
74
+ raise UpscaleError("CUDA is unavailable. Install the matching PyTorch GPU build or use --device cpu.")
75
+ if device.index is not None and device.index >= torch.cuda.device_count():
76
+ raise UpscaleError("Requested CUDA device index does not exist.")
77
+ if device.type == "mps" and not torch.backends.mps.is_available():
78
+ raise UpscaleError("Apple MPS is unavailable on this machine/PyTorch build.")
79
+ return device
80
+
81
+
82
+ class SpandrelModel:
83
+ def __init__(self, path: Path, spec, *, device="auto", precision="fp32", extra_arches=False,
84
+ validate_metadata=True):
85
+ try:
86
+ import torch
87
+ from spandrel import ImageModelDescriptor, ModelLoader
88
+ except (ImportError, RuntimeError, OSError) as e:
89
+ raise UpscaleError("PyTorch backend unavailable. Install the torch extra and matching torch/torchvision builds.") from e
90
+ global _EXTRA_ARCHES_INSTALLED
91
+ if extra_arches and not _EXTRA_ARCHES_INSTALLED:
92
+ try:
93
+ import spandrel_extra_arches
94
+ spandrel_extra_arches.install()
95
+ _EXTRA_ARCHES_INSTALLED = True
96
+ except ImportError as e:
97
+ raise UpscaleError("Install the extra-arches extra from this project and review the architecture licences.") from e
98
+ target = resolve_device(device)
99
+ try:
100
+ state = normalize_state_dict(safe_state_dict(path))
101
+ descriptor = ModelLoader(device="cpu").load_from_state_dict(state)
102
+ if not isinstance(descriptor, ImageModelDescriptor):
103
+ raise UnsupportedModelError("This is not an image-to-image model; masked/inpainting models need another plugin.")
104
+ if descriptor.purpose == "FaceSR":
105
+ raise UnsupportedModelError("Face restoration needs face detection/alignment; this is not a general image upscaler.")
106
+ if descriptor.input_channels not in (1, 3) or descriptor.output_channels not in (1, 3):
107
+ raise UnsupportedModelError("This image pipeline supports only RGB and grayscale neural models.")
108
+ if validate_metadata and (descriptor.scale != spec.scale or
109
+ descriptor.input_channels != spec.input_channels or
110
+ descriptor.output_channels != spec.output_channels):
111
+ raise UnsupportedModelError("Detected scale/channels disagree with the manifest. Fix the plugin or use a standalone local model.")
112
+ if precision not in {"fp32", "fp16"}:
113
+ raise UpscaleError("Precision must be fp32 or fp16.")
114
+ if precision == "fp16" and (target.type != "cuda" or not descriptor.supports_half):
115
+ raise UpscaleError("FP16 requires CUDA and a model advertising half-precision support; use fp32.")
116
+ descriptor.to(device=target, dtype=torch.float16 if precision == "fp16" else torch.float32)
117
+ descriptor.eval()
118
+ except UpscaleError:
119
+ raise
120
+ except Exception as e:
121
+ raise UnsupportedModelError(f"Unable to safely load this checkpoint ({type(e).__name__}). "
122
+ "Check the architecture, dependency versions and weight format; unsafe pickle loading is disabled.") from e
123
+ self.descriptor = descriptor
124
+ self.scale = int(descriptor.scale)
125
+ self.input_channels = descriptor.input_channels
126
+ self.output_channels = descriptor.output_channels
127
+ self.tiling = descriptor.tiling.name.lower()
128
+ self.device, self.precision = str(target), precision
129
+ self.architecture = str(descriptor.architecture.id)
130
+
131
+ def predict(self, image: np.ndarray) -> np.ndarray:
132
+ import torch
133
+ tensor = output = None
134
+ try:
135
+ tensor = torch.from_numpy(np.ascontiguousarray(image.transpose(2, 0, 1))).unsqueeze(0)
136
+ tensor = tensor.to(device=self.descriptor.device, dtype=self.descriptor.dtype)
137
+ with torch.inference_mode():
138
+ # The descriptor pads to its size/window requirements and crops output back.
139
+ output = self.descriptor(tensor)
140
+ return output.detach().float().cpu().squeeze(0).permute(1, 2, 0).numpy()
141
+ except RuntimeError as e:
142
+ if isinstance(e, torch.OutOfMemoryError) or "out of memory" in str(e).lower():
143
+ raise BackendOOM("Device ran out of memory during inference.") from None
144
+ raise UpscaleError(f"PyTorch inference failed: {e}") from e
145
+ finally:
146
+ del tensor, output
147
+
148
+ def clear_cache(self):
149
+ import torch
150
+ gc.collect()
151
+ if self.device.startswith("cuda"):
152
+ torch.cuda.empty_cache()
153
+ elif self.device == "mps":
154
+ torch.mps.empty_cache()
155
+
156
+ def close(self):
157
+ self.descriptor = None
158
+ self.clear_cache()
rastermoves/catalog.py ADDED
@@ -0,0 +1,127 @@
1
+ """Import OpenModelDB's exported JSON, individual entries, or a checked-out data directory."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ import json
6
+ from pathlib import Path, PurePosixPath
7
+ import tarfile
8
+ import tempfile
9
+
10
+ from filelock import FileLock
11
+
12
+ from .errors import DownloadError, UpscaleError
13
+ from .network import atomic_json, stream_download
14
+ from .specs import ModelSpec, Resource, validate_id
15
+
16
+ API_URL = "https://openmodeldb.info/api/v1/models.json"
17
+ ARCHIVE_URL = "https://codeload.github.com/OpenModelDB/open-model-database/tar.gz/refs/heads/main"
18
+ RAW_BASE = "https://raw.githubusercontent.com/OpenModelDB/open-model-database/main/data/models/"
19
+
20
+
21
+ def from_openmodeldb(model_id: str, data: dict) -> ModelSpec:
22
+ validate_id(model_id)
23
+ resources = []
24
+ for r in data.get("resources", []):
25
+ fmt = str(r.get("type", "unknown")).lower().lstrip(".")
26
+ platform = str(r.get("platform", "unknown")).lower()
27
+ backend = {"pytorch": "spandrel", "onnx": "onnx"}.get(platform, platform)
28
+ resources.append(Resource(format=fmt, urls=tuple(r.get("urls", [])),
29
+ sha256=r.get("sha256"), size=r.get("size"), backend=backend))
30
+ author = data.get("author", "unknown")
31
+ if isinstance(author, list):
32
+ author = ", ".join(str(x) for x in author)
33
+ license_value = data.get("license", "unknown")
34
+ if not isinstance(license_value, str):
35
+ license_value = json.dumps(license_value, ensure_ascii=False)
36
+ return ModelSpec(id=model_id, name=data["name"], scale=data["scale"],
37
+ architecture=data.get("architecture", "unknown"), license=license_value,
38
+ author=str(author), description=data.get("description", ""),
39
+ tags=tuple(data.get("tags", [])), resources=tuple(resources),
40
+ source_page=f"https://openmodeldb.info/models/{model_id}",
41
+ input_channels=data.get("inputChannels", 3),
42
+ output_channels=data.get("outputChannels", 3))
43
+
44
+
45
+ def convert_catalog(raw) -> tuple[list[ModelSpec], list[str]]:
46
+ if not isinstance(raw, dict):
47
+ raise UpscaleError("Expected an OpenModelDB JSON object keyed by model ID.")
48
+ if "models" in raw and isinstance(raw["models"], dict):
49
+ raw = raw["models"]
50
+ converted, skipped = [], []
51
+ for key, value in raw.items():
52
+ try:
53
+ if not isinstance(value, dict):
54
+ raise UpscaleError("model entry is not an object")
55
+ converted.append(from_openmodeldb(key, value))
56
+ except (KeyError, TypeError, ValueError, UpscaleError) as e:
57
+ skipped.append(f"{key}: {e}")
58
+ if not converted:
59
+ raise UpscaleError("No valid models found; the previous catalogue was not changed.")
60
+ return converted, skipped
61
+
62
+
63
+ def _read_archive(path: Path) -> dict:
64
+ data, expanded = {}, 0
65
+ with tarfile.open(path, "r:gz") as archive:
66
+ # Read model JSON entries only. Do not extract any paths or execute repository code.
67
+ for member in archive:
68
+ parts = PurePosixPath(member.name).parts
69
+ if (len(parts) != 4 or parts[1:3] != ("data", "models")
70
+ or not parts[-1].endswith(".json") or not member.isfile()):
71
+ continue
72
+ if member.size > 1024 * 1024:
73
+ raise UpscaleError("Unexpectedly large model metadata entry.")
74
+ expanded += member.size
75
+ if expanded > 64 * 1024**2 or len(data) > 20000:
76
+ raise UpscaleError("Catalogue archive exceeds metadata limits.")
77
+ f = archive.extractfile(member)
78
+ if f is not None:
79
+ with f:
80
+ data[parts[-1][:-5]] = json.load(f)
81
+ return data
82
+
83
+
84
+ def sync_catalog(cache: Path, source: str | None = None, *, offline=False) -> dict:
85
+ cache = Path(cache)
86
+ cache.mkdir(parents=True, exist_ok=True)
87
+ origin = source or API_URL
88
+ with FileLock(str(cache / "catalog.lock"), timeout=600), tempfile.TemporaryDirectory() as tmp:
89
+ path = Path(origin).expanduser() if not origin.startswith("https://") else None
90
+ if path is not None and path.is_dir():
91
+ data_dir = path / "data" / "models" if (path / "data" / "models").is_dir() else path
92
+ raw = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted(data_dir.glob("*.json"))}
93
+ elif path is not None and path.is_file():
94
+ raw = json.loads(path.read_text(encoding="utf-8"))
95
+ elif path is not None:
96
+ raise UpscaleError(f"Catalogue source does not exist: {path}")
97
+ else:
98
+ if offline:
99
+ raise DownloadError("Offline mode forbids catalogue network requests; use a local JSON file.")
100
+ dest = Path(tmp) / "catalog.json"
101
+ try:
102
+ stream_download(origin, dest, max_bytes=64 * 1024**2)
103
+ raw = json.loads(dest.read_text(encoding="utf-8"))
104
+ except (DownloadError, ValueError):
105
+ if source is not None:
106
+ raise
107
+ origin = ARCHIVE_URL
108
+ dest = Path(tmp) / "catalog.tar.gz"
109
+ stream_download(origin, dest, max_bytes=128 * 1024**2)
110
+ raw = _read_archive(dest)
111
+ specs, skipped = convert_catalog(raw)
112
+ payload = {"schema_version": 1, "synced_at": datetime.now(timezone.utc).isoformat(),
113
+ "source": origin, "models": [s.to_dict() for s in specs], "skipped": skipped}
114
+ atomic_json(cache / "catalog.json", payload)
115
+ return {"imported": len(specs), "skipped": skipped, "source": origin}
116
+
117
+
118
+ def fetch_one(model_id: str, cache: Path, *, offline=False) -> ModelSpec:
119
+ validate_id(model_id)
120
+ if offline:
121
+ raise DownloadError(f"{model_id} is not in the local catalogue; run sync online first.")
122
+ with tempfile.TemporaryDirectory() as tmp:
123
+ path = Path(tmp) / "model.json"
124
+ stream_download(RAW_BASE + model_id + ".json", path, max_bytes=1024**2)
125
+ spec = from_openmodeldb(model_id, json.loads(path.read_text(encoding="utf-8")))
126
+ atomic_json(Path(cache) / "imported-models" / f"{model_id}.json", spec.to_dict())
127
+ return spec