reshot 0.3.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. reshot/__init__.py +58 -0
  2. reshot/_version.py +1 -0
  3. reshot/backends/__init__.py +23 -0
  4. reshot/backends/base.py +34 -0
  5. reshot/backends/fake.py +28 -0
  6. reshot/backends/vda.py +140 -0
  7. reshot/cli.py +169 -0
  8. reshot/config.py +62 -0
  9. reshot/errors.py +45 -0
  10. reshot/io.py +196 -0
  11. reshot/pipeline.py +435 -0
  12. reshot/planning.py +81 -0
  13. reshot/postprocess.py +73 -0
  14. reshot/py.typed +0 -0
  15. reshot/reporter.py +27 -0
  16. reshot/targets.py +53 -0
  17. reshot/third_party/__init__.py +0 -0
  18. reshot/third_party/video_depth_anything/LICENSE +201 -0
  19. reshot/third_party/video_depth_anything/__init__.py +0 -0
  20. reshot/third_party/video_depth_anything/dinov2.py +415 -0
  21. reshot/third_party/video_depth_anything/dinov2_layers/__init__.py +11 -0
  22. reshot/third_party/video_depth_anything/dinov2_layers/attention.py +83 -0
  23. reshot/third_party/video_depth_anything/dinov2_layers/block.py +252 -0
  24. reshot/third_party/video_depth_anything/dinov2_layers/drop_path.py +35 -0
  25. reshot/third_party/video_depth_anything/dinov2_layers/layer_scale.py +28 -0
  26. reshot/third_party/video_depth_anything/dinov2_layers/mlp.py +41 -0
  27. reshot/third_party/video_depth_anything/dinov2_layers/patch_embed.py +89 -0
  28. reshot/third_party/video_depth_anything/dinov2_layers/swiglu_ffn.py +63 -0
  29. reshot/third_party/video_depth_anything/dpt.py +160 -0
  30. reshot/third_party/video_depth_anything/dpt_temporal.py +125 -0
  31. reshot/third_party/video_depth_anything/motion_module/__init__.py +0 -0
  32. reshot/third_party/video_depth_anything/motion_module/attention.py +429 -0
  33. reshot/third_party/video_depth_anything/motion_module/motion_module.py +321 -0
  34. reshot/third_party/video_depth_anything/util/__init__.py +0 -0
  35. reshot/third_party/video_depth_anything/util/align.py +74 -0
  36. reshot/third_party/video_depth_anything/util/blocks.py +162 -0
  37. reshot/third_party/video_depth_anything/util/transform.py +158 -0
  38. reshot/third_party/video_depth_anything/video_depth.py +163 -0
  39. reshot/third_party/video_depth_anything/video_depth_stream.py +161 -0
  40. reshot-0.3.7.dist-info/METADATA +62 -0
  41. reshot-0.3.7.dist-info/RECORD +46 -0
  42. reshot-0.3.7.dist-info/WHEEL +5 -0
  43. reshot-0.3.7.dist-info/entry_points.txt +2 -0
  44. reshot-0.3.7.dist-info/licenses/LICENSE +202 -0
  45. reshot-0.3.7.dist-info/licenses/NOTICE +12 -0
  46. reshot-0.3.7.dist-info/top_level.txt +1 -0
reshot/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """ReShot — turn any video into a depth-map video for video-generation control.
2
+
3
+ from pathlib import Path
4
+ from reshot import RunConfig, run
5
+ run(RunConfig(input=Path("in.mp4"), output=Path("out.mp4"), target="h3"))
6
+
7
+ from reshot import extract, to_gray, write_gray_video
8
+ depths, fps = extract("in.mp4") # float32 [T, H, W], larger = closer
9
+ write_gray_video(to_gray(depths), "out.mp4", fps)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ # Apple Silicon: cap the MPS allocator at 60 % of the recommended working set. Unified
17
+ # memory means GPU allocations count against the same RAM as everything else; without a
18
+ # cap a long clip grows until the OS kills processes (seen 2026-09-11). Must be set
19
+ # before torch is imported, which is why it lives here.
20
+ os.environ.setdefault("PYTORCH_MPS_HIGH_WATERMARK_RATIO", "0.6")
21
+
22
+ from ._version import __version__
23
+ from .config import RunConfig
24
+ from .errors import BackendError, InputError, RamBudgetError, ReshotError, ToolMissingError
25
+ from .io import probe_video, read_video, write_gray_video
26
+ from .pipeline import Plan, RunResult, extract, model_input_resolution, plan, resolve_input_size, run, run_many
27
+ from .planning import memory_verdict, processing_max_res
28
+ from .postprocess import to_gray, upsample_frames
29
+ from .targets import TARGETS, Target, center_crop, fit_dimensions
30
+
31
+ __all__ = [
32
+ "TARGETS",
33
+ "BackendError",
34
+ "InputError",
35
+ "Plan",
36
+ "RamBudgetError",
37
+ "ReshotError",
38
+ "RunConfig",
39
+ "RunResult",
40
+ "Target",
41
+ "ToolMissingError",
42
+ "__version__",
43
+ "center_crop",
44
+ "extract",
45
+ "fit_dimensions",
46
+ "memory_verdict",
47
+ "model_input_resolution",
48
+ "plan",
49
+ "probe_video",
50
+ "processing_max_res",
51
+ "read_video",
52
+ "resolve_input_size",
53
+ "run",
54
+ "run_many",
55
+ "to_gray",
56
+ "upsample_frames",
57
+ "write_gray_video",
58
+ ]
reshot/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.7"
@@ -0,0 +1,23 @@
1
+ """Backend registry. `get_backend("vda", model="small", device="auto")`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import BackendInfo, DepthBackend
8
+ from .fake import FakeBackend
9
+ from .vda import VDABackend, pick_device
10
+
11
+
12
+ def get_backend(name: str, **kwargs: Any) -> DepthBackend:
13
+ """Instantiate a backend by name. Unknown names raise `BackendError`."""
14
+ from ..errors import BackendError
15
+
16
+ if name == "vda":
17
+ return VDABackend(**kwargs)
18
+ if name == "fake":
19
+ return FakeBackend()
20
+ raise BackendError(f"unknown backend {name!r}")
21
+
22
+
23
+ __all__ = ["BackendInfo", "DepthBackend", "FakeBackend", "VDABackend", "get_backend", "pick_device"]
@@ -0,0 +1,34 @@
1
+ """Backend protocol: a depth model that turns a frame stack into per-frame depth.
2
+
3
+ Every backend returns *relative inverse depth* (larger = closer to camera) as float32
4
+ `[T, H, W]` at the input frame resolution, already temporally aligned across the whole
5
+ clip. Normalisation to 8-bit grey happens later in :mod:`reshot.postprocess`,
6
+ so backends never decide what "white" means.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Protocol
13
+
14
+ import numpy as np
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BackendInfo:
19
+ name: str
20
+ variant: str
21
+ license: str
22
+ commercial_ok: bool
23
+
24
+
25
+ class DepthBackend(Protocol):
26
+ info: BackendInfo
27
+
28
+ def infer(self, frames: np.ndarray, fps: float, *, input_size: int = 518) -> np.ndarray:
29
+ """`frames`: uint8 RGB `[T, H, W, 3]` → float32 inverse depth `[T, H, W]`."""
30
+ ...
31
+
32
+ def peak_memory_bytes(self) -> dict[str, int]:
33
+ """Accelerator memory peak of the last `infer()`; `{}` when there is none to report."""
34
+ ...
@@ -0,0 +1,28 @@
1
+ """A backend that returns a plausible depth field without loading a model.
2
+
3
+ For exercising everything *around* inference — I/O, encoding, presets, metrics,
4
+ remote job runners — in CI or on a laptop, in milliseconds and without downloads.
5
+ Select with `--backend fake` (or the env var `RESHOT_FAKE_BACKEND=1`)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from .base import BackendInfo
12
+
13
+
14
+ class FakeBackend:
15
+ info = BackendInfo(name="fake", variant="none", license="n/a", commercial_ok=True)
16
+ fp32 = True
17
+ device = "cpu"
18
+
19
+ def infer(self, frames: np.ndarray, fps: float, *, input_size: int = 518) -> np.ndarray:
20
+ t, h, w = frames.shape[:3]
21
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
22
+ # a soft radial "subject" in the middle that brightens over time
23
+ base = 1.0 / (1.0 + ((xx - w / 2) ** 2 + (yy - h / 2) ** 2) / (0.15 * w * h))
24
+ ramp = np.linspace(0.8, 1.0, num=max(t, 1), dtype=np.float32)
25
+ return (base[None] * ramp[:, None, None]).astype(np.float32)
26
+
27
+ def peak_memory_bytes(self) -> dict[str, int]:
28
+ return {}
reshot/backends/vda.py ADDED
@@ -0,0 +1,140 @@
1
+ """Video Depth Anything backend (default).
2
+
3
+ Why this model: it is the only open video-depth model that ships a temporal module
4
+ *and* an Apache-2.0 checkpoint (Small). Per-frame models (Depth Anything V2/3) flicker
5
+ on video unless you bolt on your own smoothing; diffusion models (DepthCrafter) are
6
+ ~100× slower. VDA-Small at 28M params runs on an 8 GB consumer GPU or an Apple M-series.
7
+
8
+ Precision policy (measured 2026-09-11 on M2 Max, 32 frames @ 736×1280):
9
+ mps fp32 → 7.0 s mps fp16 → did not finish in 3 min (autocast pathological)
10
+ cpu fp32 → 59 s
11
+ Full 294-frame clip end-to-end on mps fp32: 147 s (~500 ms/frame incl. alignment).
12
+ So fp16 is enabled on CUDA only. Do not "optimise" this by turning fp16 on for MPS.
13
+
14
+ Licence policy: `small` is Apache-2.0. `base`/`large` are CC-BY-NC-4.0 — loading them
15
+ prints a warning and sets ``info.commercial_ok = False``; the CLI surfaces this.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import warnings
22
+
23
+ import numpy as np
24
+ import torch
25
+
26
+ from .base import BackendInfo
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ _VARIANTS = {
31
+ "small": {"encoder": "vits", "features": 64, "out_channels": [48, 96, 192, 384]},
32
+ "base": {"encoder": "vitb", "features": 128, "out_channels": [96, 192, 384, 768]},
33
+ "large": {"encoder": "vitl", "features": 256, "out_channels": [256, 512, 1024, 1024]},
34
+ }
35
+ _LICENSES = {"small": "Apache-2.0", "base": "CC-BY-NC-4.0", "large": "CC-BY-NC-4.0"}
36
+ _HF_REPOS = {
37
+ "small": "depth-anything/Video-Depth-Anything-Small",
38
+ "base": "depth-anything/Video-Depth-Anything-Base",
39
+ "large": "depth-anything/Video-Depth-Anything-Large",
40
+ }
41
+
42
+
43
+ def pick_device(requested: str = "auto") -> str:
44
+ if requested != "auto":
45
+ return requested
46
+ if torch.cuda.is_available():
47
+ return "cuda"
48
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
49
+ return "mps"
50
+ return "cpu"
51
+
52
+
53
+ def _weights_path(variant: str, checkpoint: str | None) -> str:
54
+ if checkpoint:
55
+ return checkpoint
56
+ # huggingface_hub honours HF_ENDPOINT, so users in China can point it at a mirror
57
+ # (e.g. https://hf-mirror.com) without us special-casing anything.
58
+ from huggingface_hub import hf_hub_download
59
+
60
+ filename = f"video_depth_anything_{_VARIANTS[variant]['encoder']}.pth"
61
+ return hf_hub_download(_HF_REPOS[variant], filename)
62
+
63
+
64
+ class VDABackend:
65
+ def __init__(
66
+ self,
67
+ model: str = "small",
68
+ device: str = "auto",
69
+ checkpoint: str | None = None,
70
+ cuda_memory_fraction: float | None = None,
71
+ ) -> None:
72
+ variant = model
73
+ if variant not in _VARIANTS:
74
+ from ..errors import BackendError
75
+
76
+ raise BackendError(f"unknown model variant {variant!r}; choose from {sorted(_VARIANTS)}")
77
+ from ..third_party.video_depth_anything.video_depth import VideoDepthAnything
78
+
79
+ self.variant = variant
80
+ self.device = pick_device(device)
81
+ # fp16 only where it is known to be fast; see module docstring for the measurement.
82
+ self.fp32 = self.device != "cuda"
83
+ self.info = BackendInfo(
84
+ name="video-depth-anything",
85
+ variant=variant,
86
+ license=_LICENSES[variant],
87
+ commercial_ok=_LICENSES[variant].startswith("Apache"),
88
+ )
89
+ if not self.info.commercial_ok:
90
+ warnings.warn(
91
+ f"Video Depth Anything '{variant}' weights are {_LICENSES[variant]}: "
92
+ "non-commercial use only. Use 'small' for commercial projects.",
93
+ stacklevel=2,
94
+ )
95
+
96
+ try:
97
+ path = _weights_path(variant, checkpoint)
98
+ except Exception as exc: # hub / network / offline-cache errors all land here
99
+ from ..errors import BackendError
100
+
101
+ raise BackendError(
102
+ f"could not obtain weights for '{variant}': {exc}. "
103
+ "Set HF_ENDPOINT to a mirror, or pass --checkpoint with a local .pth."
104
+ ) from exc
105
+ if cuda_memory_fraction is not None and self.device == "cuda":
106
+ # Emulate a smaller card: allocations beyond the fraction raise OOM instead of
107
+ # silently using the whole GPU. Used to verify the "runs on N GB" claim.
108
+ torch.cuda.set_per_process_memory_fraction(cuda_memory_fraction)
109
+ log.info("cuda memory capped at %.0f%% of the card", cuda_memory_fraction * 100)
110
+ log.info("loading %s from %s on %s (fp32=%s)", variant, path, self.device, self.fp32)
111
+ model = VideoDepthAnything(**_VARIANTS[variant])
112
+ model.load_state_dict(torch.load(path, map_location="cpu", weights_only=True), strict=True)
113
+ self.model = model.to(self.device).eval()
114
+
115
+ def infer(self, frames: np.ndarray, fps: float, *, input_size: int = 518) -> np.ndarray:
116
+ if frames.ndim != 4 or frames.shape[-1] != 3:
117
+ raise ValueError(f"expected [T, H, W, 3] uint8 RGB, got {frames.shape}")
118
+ # Upstream's infer_video_depth already does 32-frame windows with 10-frame overlap,
119
+ # keyframe-based scale/shift alignment across windows and interpolation over the
120
+ # seam. That is the temporal-consistency machinery — do not re-chunk outside it.
121
+ if self.device == "cuda":
122
+ torch.cuda.reset_peak_memory_stats()
123
+ with torch.inference_mode(): # upstream uses no_grad; inference_mode also skips version counters
124
+ depths, _ = self.model.infer_video_depth(
125
+ frames, fps, input_size=input_size, device=self.device, fp32=self.fp32
126
+ )
127
+ return np.asarray(depths, dtype=np.float32)
128
+
129
+ def peak_memory_bytes(self) -> dict[str, int]:
130
+ """Peak VRAM of the last `infer()`: what the model actually touched (`allocated`)
131
+ and what the caching allocator held (`reserved`, ≈ what nvidia-smi shows)."""
132
+ if self.device != "cuda":
133
+ return {}
134
+ return {
135
+ "gpu_peak_allocated_bytes": int(torch.cuda.max_memory_allocated()),
136
+ "gpu_peak_reserved_bytes": int(torch.cuda.max_memory_reserved()),
137
+ }
138
+
139
+
140
+ __all__ = ["VDABackend", "pick_device"]
reshot/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """`reshot` command line: argv → RunConfig(s) → pipeline.run() / run_many().
2
+
3
+ reshot in.mp4 -o out.mp4 # Apache-2.0 Small model, auto device
4
+ reshot in.mp4 -o out.mp4 --target h3 # 24 fps, ×32 dims, ≤15 s for MiniMax H3
5
+ reshot in.mp4 -o out.mp4 --npz d.npz --metrics run.json
6
+ reshot clips/*.mp4 -o depth/ --target seedance # batch: one model load, out/<name>_depth.mp4
7
+
8
+ Batch mode (several inputs, or `-o` naming a directory): `--npz` / `--metrics`, if given,
9
+ are directories too and get `<name>.npz` / `<name>.json` per clip. One bad clip is
10
+ reported and skipped; the exit code is that of the first failure.
11
+
12
+ Exit codes: 0 ok · 1 unexpected · 2 bad input/arguments · 3 over RAM budget ·
13
+ 4 ffmpeg missing · 5 backend/weights problem.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import logging
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from ._version import __version__
24
+ from .config import BACKENDS, MODEL_VARIANTS, QUALITIES, RunConfig
25
+ from .errors import ReshotError
26
+ from .pipeline import run, run_many
27
+ from .reporter import StderrReporter
28
+ from .targets import TARGETS
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ p = argparse.ArgumentParser(
33
+ prog="reshot",
34
+ description="Video → depth-map video for video-generation ControlNets.",
35
+ epilog="Docs: https://github.com/maosika-ai/reshot",
36
+ )
37
+ p.add_argument("input", type=Path, nargs="+", help="input video(s) (anything ffmpeg/OpenCV can read)")
38
+ p.add_argument(
39
+ "-o", "--output", type=Path, required=True, help="output .mp4, or a directory when there are several inputs"
40
+ )
41
+ g = p.add_argument_group("model")
42
+ g.add_argument(
43
+ "--model",
44
+ default="small",
45
+ choices=MODEL_VARIANTS,
46
+ help="Video Depth Anything variant; only 'small' is Apache-2.0 (default)",
47
+ )
48
+ g.add_argument("--backend", default="vda", choices=BACKENDS, help=argparse.SUPPRESS)
49
+ g.add_argument("--device", default="auto", help="auto | cuda | mps | cpu")
50
+ g.add_argument(
51
+ "--quality",
52
+ default="fast",
53
+ choices=QUALITIES,
54
+ help="what the model sees: fast = 644x364 for 16:9 / 364x644 for 9:16, ~3 GB VRAM (default); "
55
+ "full = 924x518 / 518x924, ~11 GB VRAM, 2.5x slower, sharper fine detail",
56
+ )
57
+ g.add_argument(
58
+ "--input-size",
59
+ type=int,
60
+ default=None,
61
+ help="expert: model short side in px, multiple of 14; overrides --quality",
62
+ )
63
+ g.add_argument("--checkpoint", type=Path, help="local .pth instead of the Hugging Face download")
64
+ g = p.add_argument_group("output")
65
+ g.add_argument(
66
+ "--target",
67
+ default="none",
68
+ choices=sorted(TARGETS),
69
+ help="fps / frame-size preset for a generator: " + ", ".join(f"{k}={v.note}" for k, v in TARGETS.items()),
70
+ )
71
+ g.add_argument("--fps", type=float, help="override output fps (default: preset or source)")
72
+ g.add_argument(
73
+ "--max-res", type=int, default=1280, help="cap the OUTPUT's longer side (inference runs at model resolution)"
74
+ )
75
+ g.add_argument("--max-frames", type=int, help="stop after N frames")
76
+ g.add_argument("--invert", action="store_true", help="far = white instead of near = white")
77
+ g.add_argument(
78
+ "--clip", type=float, default=0.0, metavar="PCT", help="percentile clip on both tails before scaling"
79
+ )
80
+ g.add_argument("--gamma", type=float, default=1.0, help=">1 darkens mid-tones")
81
+ g.add_argument("--crf", type=int, default=12, help="x264 CRF; lower = larger, cleaner (default 12)")
82
+ g.add_argument("--npz", type=Path, help="also save raw float depth (processing resolution)")
83
+ g.add_argument("--metrics", type=Path, help="write timing / memory / size metrics as JSON")
84
+ g = p.add_argument_group("safety")
85
+ g.add_argument("--force", action="store_true", help="run even if the RAM estimate exceeds the budget")
86
+ # Verification aid, not a user feature: makes a big card behave like a small one so a
87
+ # "runs on N GB" claim can be tested (torch.cuda.set_per_process_memory_fraction).
88
+ g.add_argument("--cuda-memory-fraction", type=float, default=None, help=argparse.SUPPRESS)
89
+ p.add_argument("-v", "--verbose", action="store_true")
90
+ p.add_argument("--version", action="version", version=f"reshot {__version__}")
91
+ return p
92
+
93
+
94
+ def _is_batch(args: argparse.Namespace) -> bool:
95
+ """Several inputs, or an output that is (or is spelled like) a directory."""
96
+ o = args.output
97
+ return len(args.input) > 1 or o.is_dir() or str(o).endswith(("/", "\\"))
98
+
99
+
100
+ def configs_from_args(args: argparse.Namespace) -> list[RunConfig]:
101
+ """One RunConfig per input. In batch mode the outputs are derived from the input names."""
102
+ batch = _is_batch(args)
103
+
104
+ def per_clip(option: Path | None, stem: str, ext: str) -> Path | None:
105
+ if option is None:
106
+ return None
107
+ return option / f"{stem}{ext}" if batch else option
108
+
109
+ cfgs = []
110
+ for src in args.input:
111
+ stem = src.stem
112
+ cfgs.append(
113
+ RunConfig(
114
+ input=src,
115
+ output=args.output / f"{stem}_depth.mp4" if batch else args.output,
116
+ model=args.model,
117
+ backend=args.backend,
118
+ device=args.device,
119
+ target=args.target,
120
+ fps=args.fps,
121
+ max_res=args.max_res,
122
+ max_frames=args.max_frames,
123
+ quality=args.quality,
124
+ input_size=args.input_size,
125
+ invert=args.invert,
126
+ clip_percent=args.clip,
127
+ gamma=args.gamma,
128
+ crf=args.crf,
129
+ npz=per_clip(args.npz, stem, ".npz"),
130
+ metrics=per_clip(args.metrics, stem, ".json"),
131
+ checkpoint=args.checkpoint,
132
+ force=args.force,
133
+ cuda_memory_fraction=args.cuda_memory_fraction,
134
+ )
135
+ )
136
+ return cfgs
137
+
138
+
139
+ def config_from_args(args: argparse.Namespace) -> RunConfig:
140
+ """Single-clip form, kept for callers that build a Namespace themselves."""
141
+ return configs_from_args(args)[0]
142
+
143
+
144
+ def main(argv: list[str] | None = None) -> int:
145
+ args = build_parser().parse_args(argv)
146
+ logging.basicConfig(
147
+ level=logging.INFO if args.verbose else logging.WARNING, format="%(levelname)s %(name)s: %(message)s"
148
+ )
149
+ try:
150
+ cfgs = configs_from_args(args)
151
+ if len(cfgs) == 1 and not _is_batch(args):
152
+ run(cfgs[0], StderrReporter())
153
+ return 0
154
+ results = run_many(cfgs, StderrReporter())
155
+ failures = [r for r in results if isinstance(r, ReshotError)]
156
+ if failures:
157
+ print(f"reshot: {len(failures)} of {len(results)} clips failed", file=sys.stderr)
158
+ return failures[0].exit_code
159
+ return 0
160
+ except ReshotError as exc:
161
+ print(f"reshot: {exc}", file=sys.stderr)
162
+ return exc.exit_code
163
+ except KeyboardInterrupt:
164
+ print("reshot: interrupted", file=sys.stderr)
165
+ return 130
166
+
167
+
168
+ if __name__ == "__main__": # pragma: no cover
169
+ sys.exit(main())
reshot/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Everything a run needs, in one immutable object. Built by the CLI from argv, or by
2
+ library users directly. Validation that does not need I/O happens in `__post_init__`
3
+ so mistakes surface before any work starts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .errors import InputError
11
+ from .targets import TARGETS
12
+
13
+ MODEL_VARIANTS = ("small", "base", "large")
14
+ QUALITIES = ("fast", "full") # model working size: fast = 644×364 for 16:9 (default), full = 924×518
15
+ BACKENDS = ("vda", "fake")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RunConfig:
20
+ input: Path
21
+ output: Path
22
+ model: str = "small"
23
+ backend: str = "vda"
24
+ device: str = "auto"
25
+ target: str = "none"
26
+ fps: float | None = None # None = preset or source
27
+ max_res: int = 1280 # cap on the OUTPUT's longer side
28
+ max_frames: int | None = None
29
+ quality: str = "fast" # fast | full — see QUALITIES; `input_size` overrides it
30
+ input_size: int | None = None # model short side (multiple of 14); expert override of `quality`
31
+ invert: bool = False
32
+ clip_percent: float = 0.0
33
+ gamma: float = 1.0
34
+ crf: int = 12
35
+ npz: Path | None = None
36
+ metrics: Path | None = None
37
+ checkpoint: Path | None = None
38
+ force: bool = False # ignore the RAM budget
39
+ cuda_memory_fraction: float | None = None # cap VRAM to this share of the card (verification aid)
40
+ extra: dict = field(default_factory=dict) # free-form, echoed into metrics
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.target not in TARGETS:
44
+ raise InputError(f"unknown --target {self.target!r}; choose from {', '.join(sorted(TARGETS))}")
45
+ if self.model not in MODEL_VARIANTS:
46
+ raise InputError(f"unknown --model {self.model!r}; choose from {', '.join(MODEL_VARIANTS)}")
47
+ if self.backend not in BACKENDS:
48
+ raise InputError(f"unknown backend {self.backend!r}; choose from {', '.join(BACKENDS)}")
49
+ if self.quality not in QUALITIES:
50
+ raise InputError(f"unknown --quality {self.quality!r}; choose from {', '.join(QUALITIES)}")
51
+ if self.input_size is not None and (self.input_size < 14 or self.input_size % 14):
52
+ raise InputError("--input-size must be a positive multiple of 14 (ViT patch size)")
53
+ if not 0 <= self.clip_percent < 50:
54
+ raise InputError("--clip must be in [0, 50)")
55
+ if self.gamma <= 0:
56
+ raise InputError("--gamma must be > 0")
57
+ if not 0 <= self.crf <= 51:
58
+ raise InputError("--crf must be in [0, 51]")
59
+ if self.max_frames is not None and self.max_frames < 1:
60
+ raise InputError("--max-frames must be ≥ 1")
61
+ if self.cuda_memory_fraction is not None and not 0 < self.cuda_memory_fraction <= 1:
62
+ raise InputError("--cuda-memory-fraction must be in (0, 1]")
reshot/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """User-facing errors. The CLI prints `str(exc)` and maps each to an exit code; library
2
+ users can catch `ReshotError` for anything the tool considers a *user* problem
3
+ (bad input, over budget, missing tool) as opposed to a bug."""
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class ReshotError(Exception):
9
+ """Base class; `exit_code` is what the CLI returns."""
10
+
11
+ exit_code = 1
12
+
13
+
14
+ class InputError(ReshotError):
15
+ """The input video could not be opened or decoded."""
16
+
17
+ exit_code = 2
18
+
19
+
20
+ class RamBudgetError(ReshotError):
21
+ """The planned run would exceed the host-RAM budget (see planning.py)."""
22
+
23
+ exit_code = 3
24
+
25
+ def __init__(self, estimate: int, budget: int, max_frames_ok: int) -> None:
26
+ self.estimate, self.budget, self.max_frames_ok = estimate, budget, max_frames_ok
27
+ from .planning import gib
28
+
29
+ super().__init__(
30
+ f"estimated host RAM {gib(estimate)} exceeds the budget {gib(budget)}. "
31
+ f"Try --max-frames {max_frames_ok} (or split the clip), a smaller --input-size, "
32
+ "or --force if you know the machine can take it."
33
+ )
34
+
35
+
36
+ class ToolMissingError(ReshotError):
37
+ """A required external tool (ffmpeg) is missing."""
38
+
39
+ exit_code = 4
40
+
41
+
42
+ class BackendError(ReshotError):
43
+ """Unknown backend / model variant, or weights could not be loaded."""
44
+
45
+ exit_code = 5