From 193d0196aa0f883d4c81f732d9ad01746d0f3fce Mon Sep 17 00:00:00 2001 From: Alitleis123 Date: Tue, 10 Nov 2026 15:48:48 -0500 Subject: [PATCH 01/38] Upscale only the drawings and draw the in-betweens at 2x Real-ESRGAN upscaled every output frame, so a 190-frame render ran it 190 times for 10 drawings. RIFE's own motion, its last flow and blend mask, is now added to the model's outputs when it loads, and each in-between is drawn at 2x by moving and mixing the upscaled drawings with it. On Hero's walk that came within 46 dB of upscaling the network's frame. The conversions around both networks now use OpenCV instead of numpy transposes, and the network's input is reused between in-betweens of the same two frames. --- Pipeline/render.py | 62 ++++++++------ Pipeline/rife.py | 180 ++++++++++++++++++++++++++++++++++------- Pipeline/upscaler.py | 12 ++- tests/fake_rife.py | 14 +++- tests/test_rife.py | 80 +++++++++++++----- tests/test_upscaler.py | 10 +++ 6 files changed, 279 insertions(+), 79 deletions(-) diff --git a/Pipeline/render.py b/Pipeline/render.py index 2aab97e..924b767 100644 --- a/Pipeline/render.py +++ b/Pipeline/render.py @@ -488,27 +488,38 @@ def write_png(frame: np.ndarray, target: Path, number: int) -> None: "has space and the folder is writable.") from exc state = {"writer": None, "written": 0, "width": 0, "height": 0, "upscaler": upscaler, - "upscale_note": upscale_note, "last_in": None, "last_out": None} - - def emit(frame: np.ndarray) -> None: - """Upscale if asked, then write one output frame.""" - if state["upscaler"] is not None: - # A held drawing comes through once for every frame it is held, - # and the network would draw the same picture again each time. - if state["last_in"] is not None and np.array_equal(frame, state["last_in"]): - frame = state["last_out"] - else: - try: - big = state["upscaler"].upscale(frame) - except AIUnavailable as exc: - # Past the first frame, the frames so far are 2x and the rest - # cannot be left at 1x, so the render stops and says why. - if state["written"]: - raise - state["upscaler"], state["upscale_note"] = None, str(exc) - else: - state["last_in"], state["last_out"] = frame, big - frame = big + "upscale_note": upscale_note} + # The last two frames upscaled and their 2x versions: the pair of drawings + # being drawn between, or a held drawing. + drawn: Deque[Tuple[np.ndarray, np.ndarray]] = deque(maxlen=2) + + def upscaled(frame: np.ndarray) -> Optional[np.ndarray]: + """`frame` at 2x, or None where the network does not run.""" + if state["upscaler"] is None: + return None + # A drawing is in two pairs, and a held one comes through once for + # every frame it is held. The network would draw it again each time. + for given, big in drawn: + if given is frame or np.array_equal(given, frame): + return big + try: + big = state["upscaler"].upscale(frame) + except AIUnavailable as exc: + # Past the first frame, the frames so far are 2x and the rest + # cannot be left at 1x, so the render stops and says why. + if state["written"]: + raise + state["upscaler"], state["upscale_note"] = None, str(exc) + return None + drawn.append((frame, big)) + return big + + def emit(frame: np.ndarray, is_2x: bool = False) -> None: + """Upscale if asked and not done, then write one output frame.""" + if not is_2x: + big = upscaled(frame) + if big is not None: + frame = big if factor > 1: frame = upscale(frame, factor) if state["written"] and frame.shape[:2] != (state["height"], state["width"]): @@ -600,14 +611,19 @@ def change_from(slot: int) -> Optional[float]: # 30 GB before the first was written. for at in range(0, len(times), BATCH): part = times[at:at + BATCH] + big = None if pair is not None: - produced = pair(prev, curr, part, unrelated=unrelated) + # Only the drawings are upscaled. The in-betweens are drawn + # at 2x from them. + big = upscaled(prev), upscaled(curr) + big = None if big[0] is None or big[1] is None else big + produced = pair(prev, curr, part, unrelated=unrelated, big=big) else: # De-duplicate only: every output frame is a real drawing, # nothing is synthesised, and each starts where it did. produced = [(prev if t < 1.0 else curr).copy() for t in part] for frame in produced: - emit(frame) + emit(frame, is_2x=big is not None) if moves: before, here = here, after window.popleft() diff --git a/Pipeline/rife.py b/Pipeline/rife.py index 7ed71ea..06f6d2c 100644 --- a/Pipeline/rife.py +++ b/Pipeline/rife.py @@ -52,6 +52,14 @@ # A millionth of a gap: closer to a drawing than any real frame can be. _ROUNDING = 1e-6 +# The network draws an in-between by moving each drawing's pixels and mixing +# the two. These are the inner values it does that with: how far each pixel +# comes from in each drawing, and how much of each drawing it takes. Applied +# to the drawings at 2x, they draw the in-between at 2x, so the upscaler only +# has to draw the drawings. vs-mlrt's export keeps them inside the model, so +# they are added to its outputs when it loads. +_MOTION = ("/Add_7_output_0", "/Sigmoid_output_0") + ProgressFn = Callable[[str, float], None] @@ -178,6 +186,86 @@ def ensure_model(progress: Optional[ProgressFn] = None, path: Path = MODEL_PATH, part.unlink(missing_ok=True) +def _varint(n: int) -> bytes: + out = bytearray() + while True: + low, n = n & 0x7F, n >> 7 + out.append(low | (0x80 if n else 0)) + if not n: + return bytes(out) + + +def _read_varint(data: bytes, i: int): + n = shift = 0 + while True: + b = data[i] + i += 1 + n |= (b & 0x7F) << shift + shift += 7 + if not b & 0x80: + return n, i + + +def _with_outputs(model: bytes, names) -> bytes: + """An ONNX model with inner values `names` added to its outputs. + + The file is a protobuf ModelProto, and a field written again is added to a + repeated one, so each name goes on the end of the graph's outputs without + the onnx package. Its type is left for ONNX Runtime to work out. + """ + i = 0 + while i < len(model): + start = i + key, i = _read_varint(model, i) + field, wire = key >> 3, key & 7 + if wire == 0: + _, i = _read_varint(model, i) + elif wire == 1: + i += 8 + elif wire == 5: + i += 4 + elif wire == 2: + n, j = _read_varint(model, i) + if field == 7: # ModelProto.graph + graph = model[j:j + n] + for name in names: + info = b"\x0a" + _varint(len(name)) + name.encode() # ValueInfoProto.name + graph += b"\x62" + _varint(len(info)) + info # GraphProto.output + return model[:start] + b"\x3a" + _varint(len(graph)) + graph + model[j + n:] + i = j + n + else: + break + raise AIUnavailable("The AI model file is not one Upflow2x can read. Delete it and " + "render again to download it fresh.") + + +# The pixel positions of a frame, for each size drawn at. +_GRIDS: dict = {} + + +def _warp2x(big0: np.ndarray, big1: np.ndarray, flow: np.ndarray, + mask: np.ndarray) -> np.ndarray: + """The in-between at 2x, from the drawings at 2x and the network's motion. + + It moves and mixes the pixels as the network does, with the motion + stretched to the larger size. On Hero's walk this came within 46 dB of + upscaling the network's own frame, near drawings too, where an in-between + is drawn from a halfway frame and was moved twice. + """ + h, w = big0.shape[:2] + if (h, w) not in _GRIDS: + x, y = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32)) + _GRIDS[(h, w)] = np.dstack([x, y]) + grid = _GRIDS[(h, w)] + moved = [] + for frame, dx, dy in ((big0, flow[0], flow[1]), (big1, flow[2], flow[3])): + part = cv2.resize(cv2.merge([dx, dy]), (w, h), interpolation=cv2.INTER_LINEAR) + moved.append(cv2.remap(frame, cv2.scaleAdd(part, 2.0, grid), None, cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE)) + weight = cv2.resize(mask, (w, h), interpolation=cv2.INTER_LINEAR) + return cv2.blendLinear(moved[0], moved[1], weight, 1.0 - weight) + + class Rife: """One loaded model, reused for every pair of drawings in a render.""" @@ -186,11 +274,14 @@ class Rife: # at a time, so they are kept until the next pair. _pair = None _known = None + # The last two frames given to the network and the input made from them. + # The next in-between of the same two only needs its time changed. + _input = None def __init__(self, progress: Optional[ProgressFn] = None): ort = ensure_runtime(progress) model = ensure_model(progress) - self._ort, self._model = ort, model + self._ort, self._model = ort, _with_outputs(Path(model).read_bytes(), _MOTION) # DirectML runs it on any Windows GPU, AMD included, when the # onnxruntime-directml package is the one installed. self.on_gpu = "DmlExecutionProvider" in ort.get_available_providers() @@ -222,11 +313,11 @@ def _session(self): # One core left over, so Resolve stays responsive while it works. options.intra_op_num_threads = max(1, (os.cpu_count() or 2) - 1) providers = ["CPUExecutionProvider"] - return ort.InferenceSession(str(self._model), options, providers=providers) + return ort.InferenceSession(self._model, options, providers=providers) - def _infer(self, x: np.ndarray) -> np.ndarray: + def _infer(self, x: np.ndarray) -> list: try: - return self.session.run(None, {self.input: x})[0] + return self.session.run(None, {self.input: x}) except Exception: # onnxruntime raises its own exception types if not self.on_gpu: raise @@ -234,30 +325,42 @@ def _infer(self, x: np.ndarray) -> np.ndarray: # more than is left. The CPU is slower but has room. self.on_gpu = False self.session = self._session() - return self.session.run(None, {self.input: x})[0] + return self.session.run(None, {self.input: x}) - def _run(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + def _run(self, a: np.ndarray, b: np.ndarray, t: float): + """The network's frame at t, and the motion it drew it with: for each + pixel, how far it comes from in a and in b (4 x h x w), and how much + of a it takes (h x w).""" h, w = a.shape[:2] ph, pw = -h % _PAD, -w % _PAD - planes = [] - for frame in (a, b): - padded = cv2.copyMakeBorder(frame, 0, ph, 0, pw, cv2.BORDER_REPLICATE) - rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB) - planes.append(rgb.astype(np.float32).transpose(2, 0, 1) / 255.0) - planes.append(np.full((1, h + ph, w + pw), t, np.float32)) - y = self._infer(np.concatenate(planes)[None]) + if self._input is None or self._input[0] is not a or self._input[1] is not b: + x = np.empty((1, 7, h + ph, w + pw), np.float32) + for k, frame in enumerate((a, b)): + padded = cv2.copyMakeBorder(frame, 0, ph, 0, pw, cv2.BORDER_REPLICATE) + for c in range(3): + # RGB from 0 to 1, from OpenCV's BGR from 0 to 255. + cv2.multiply(cv2.extractChannel(padded, 2 - c), 1.0 / 255.0, + dst=x[0, 3 * k + c], dtype=cv2.CV_32F) + self._input = (a, b, x) + x = self._input[2] + x[0, 6] = t + y = self._infer(x) # A frame of another size would be written as it came, and a video # writer drops it without a word, so the render would come out short. - expected = (1, 3, h + ph, w + pw) - if getattr(y, "shape", None) != expected or not np.isfinite(y).all(): + expected = [(1, c, h + ph, w + pw) for c in (3, 4, 1)] + shapes = [getattr(part, "shape", None) for part in y] + if shapes != expected or not all(np.isfinite(part).all() for part in y): raise AIUnavailable(f"The AI model returned a broken frame (shape " - f"{getattr(y, 'shape', '?')}, expected {expected}), so the " - f"render was stopped.") - rgb = np.clip(y[0, :, :h, :w].transpose(1, 2, 0) * 255.0 + 0.5, 0, 255) - return cv2.cvtColor(rgb.astype(np.uint8), cv2.COLOR_RGB2BGR) + f"{shapes[0] if shapes else '?'}, expected {expected[0]}), so " + f"the render was stopped.") + # Rounded and held to 0..255 as it goes back to 8 bits. + frame = cv2.merge([cv2.multiply(y[0][0, c, :h, :w], 255.0, dtype=cv2.CV_8U) + for c in (2, 1, 0)]) + return frame, (y[1][0, :, :h, :w], y[2][0, 0, :h, :w]) def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], - unrelated: Optional[bool] = None) -> List[np.ndarray]: + unrelated: Optional[bool] = None, + big: Optional[tuple] = None) -> List[np.ndarray]: """In-betweens for `times` (each in 0..1) between drawings a and b. t == 0 returns `a` untouched and t == 1 returns `b` untouched, so real @@ -267,32 +370,47 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], the pair is two shots, made with the pairs around it in view, or False where the editor's markers already said. Without it the pair is judged on its own. + + With `big`, a and b upscaled 2x, every frame comes back at 2x: the + drawings as given, and each in-between drawn from them with the + network's motion. Upscaling the in-betweens one by one instead took + most of a render's time. """ if not times: return [] + ends = big or (a, b) if np.array_equal(a, b): - return [a.copy() for _ in times] + return [ends[0].copy() for _ in times] if unrelated is None: unrelated = frames_are_a_cut(a, b) or frames_are_unrelated(a, b) if unrelated: - return [(a.copy() if t < 1.0 else b.copy()) for t in times] + return [(ends[0].copy() if t < 1.0 else ends[1].copy()) for t in times] out: List[np.ndarray] = [] for t in times: t = float(min(1.0, max(0.0, t))) # This close to a drawing is rounding in the time map, not a frame. if t <= _ROUNDING: - out.append(a.copy()) + out.append(ends[0].copy()) elif t >= 1.0 - _ROUNDING: - out.append(b.copy()) + out.append(ends[1].copy()) else: - out.append(self._between(a, b, t)) + out.append(self._between(a, b, t, big)) return out - def _between(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + def _step(self, lo: tuple, hi: tuple, t: float) -> tuple: + """The frame at t between two (frame, 2x frame) pairs, as another.""" + frame, (flow, mask) = self._run(lo[0], hi[0], t) + return frame, (None if lo[1] is None else _warp2x(lo[1], hi[1], flow, mask)) + + def _between(self, a: np.ndarray, b: np.ndarray, t: float, + big: Optional[tuple] = None) -> np.ndarray: """The frame at t between a and b, asking the network only for 0.25 to 0.75.""" - if self._pair is None or self._pair[0] is not a or self._pair[1] is not b: - self._pair, self._known = (a, b), {0.0: a, 1.0: b} + ends = big or (None, None) + pair = (a, b, ends[0]) + if self._pair is None or any(x is not y for x, y in zip(self._pair, pair)): + self._pair, self._known = pair, {0.0: (a, ends[0]), 1.0: (b, ends[1])} known = self._known + which = 0 if big is None else 1 lo, hi = 0.0, 1.0 while True: local = (t - lo) / (hi - lo) @@ -300,11 +418,11 @@ def _between(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: # distance from the nearer end. A gap of 5000 frames slowed three # times needs 12 halvings and keeps 23 halfway frames. if _EVEN_FROM <= local <= 1.0 - _EVEN_FROM: - return self._run(known[lo], known[hi], local) + return self._step(known[lo], known[hi], local)[which] if hi - lo < 2.0 ** -30: # Only rounding gets here, where both ends are one picture. - return (known[lo] if local < 0.5 else known[hi]).copy() + return (known[lo] if local < 0.5 else known[hi])[which].copy() mid = (lo + hi) / 2.0 if mid not in known: - known[mid] = self._run(known[lo], known[hi], 0.5) + known[mid] = self._step(known[lo], known[hi], 0.5) lo, hi = (lo, mid) if local < 0.5 else (mid, hi) diff --git a/Pipeline/upscaler.py b/Pipeline/upscaler.py index 54540c4..02827ac 100644 --- a/Pipeline/upscaler.py +++ b/Pipeline/upscaler.py @@ -93,12 +93,16 @@ def _infer(self, x: np.ndarray) -> np.ndarray: def upscale(self, frame: np.ndarray) -> np.ndarray: """`frame` at twice its width and height, BGR in and out.""" h, w = frame.shape[:2] - rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - y = self._infer(rgb.astype(np.float32).transpose(2, 0, 1)[None] / 255.0) + x = np.empty((1, 3, h, w), np.float32) + for c in range(3): + # RGB from 0 to 1, from OpenCV's BGR from 0 to 255. + cv2.multiply(cv2.extractChannel(frame, 2 - c), 1.0 / 255.0, dst=x[0, c], + dtype=cv2.CV_32F) + y = self._infer(x) expected = (1, 3, 2 * h, 2 * w) if getattr(y, "shape", None) != expected or not np.isfinite(y).all(): raise AIUnavailable(f"The upscaler returned a broken frame (shape " f"{getattr(y, 'shape', '?')}, expected {expected}).") self.frames += 1 - out = np.clip(y[0].transpose(1, 2, 0) * 255.0 + 0.5, 0, 255) - return cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_RGB2BGR) + # Rounded and held to 0..255 as it goes back to 8 bits. + return cv2.merge([cv2.multiply(y[0, c], 255.0, dtype=cv2.CV_8U) for c in (2, 1, 0)]) diff --git a/tests/fake_rife.py b/tests/fake_rife.py index e12a65e..0fe90c3 100644 --- a/tests/fake_rife.py +++ b/tests/fake_rife.py @@ -8,10 +8,17 @@ from __future__ import annotations import cv2 +import numpy as np from Pipeline import rife +def still(a, t): + """The motion of a plain blend: no pixel moves, and each takes 1 - t of a.""" + h, w = a.shape[:2] + return np.zeros((4, h, w), np.float32), np.full((h, w), 1.0 - t, np.float32) + + class StandInRife(rife.Rife): made = 0 @@ -20,13 +27,14 @@ def __init__(self, progress=None): self.on_gpu = False def _run(self, a, b, t): - return cv2.addWeighted(a, 1.0 - t, b, t, 0) + return cv2.addWeighted(a, 1.0 - t, b, t, 0), still(a, t) # Written over Pipeline/rife.py in a copied plugin, for the tests that run # the real stage in a separate Python, which this module cannot reach. MODULE = '''"""Test stand-in for the RIFE network: a plain blend, no model.""" import cv2 +import numpy as np from Pipeline import _rife_real from Pipeline._rife_real import AIUnavailable, SourceError # noqa: F401 @@ -37,7 +45,9 @@ def __init__(self, progress=None): self.on_gpu = False def _run(self, a, b, t): - return cv2.addWeighted(a, 1.0 - t, b, t, 0) + h, w = a.shape[:2] + return cv2.addWeighted(a, 1.0 - t, b, t, 0), (np.zeros((4, h, w), np.float32), + np.full((h, w), 1.0 - t, np.float32)) ''' # Written over Pipeline/upscaler.py in a copied plugin, which imports what the diff --git a/tests/test_rife.py b/tests/test_rife.py index a2a2571..74e2ff9 100644 --- a/tests/test_rife.py +++ b/tests/test_rife.py @@ -263,11 +263,14 @@ def run(self, _names, feed): if self.fail_on_gpu_run and "DmlExecutionProvider" in self.providers: raise RuntimeError("E_OUTOFMEMORY") x = feed["input"] - return [x[:, :3]] + return [x[:, :3], np.zeros_like(x[:, :4]), x[:, 6:7]] -def _fake_ort(monkeypatch, **fail): +def _fake_ort(monkeypatch, tmp_path, **fail): made = [] + # An ONNX model with an empty graph, for the motion outputs to be added to. + model = tmp_path / "model.onnx" + model.write_bytes(b"\x3a\x00") class Ort: ExecutionMode = type("ExecutionMode", (), {"ORT_SEQUENTIAL": 1}) @@ -286,33 +289,33 @@ def InferenceSession(_path, _options, providers): return session monkeypatch.setattr(rife, "ensure_runtime", lambda progress=None: Ort) - monkeypatch.setattr(rife, "ensure_model", lambda progress=None: "model.onnx") + monkeypatch.setattr(rife, "ensure_model", lambda progress=None: model) return made -def test_the_gpu_is_used_when_it_starts(monkeypatch): - made = _fake_ort(monkeypatch) +def test_the_gpu_is_used_when_it_starts(monkeypatch, tmp_path): + made = _fake_ort(monkeypatch, tmp_path) net = rife.Rife() assert net.on_gpu and made == [["DmlExecutionProvider", "CPUExecutionProvider"]] -def test_a_gpu_that_will_not_start_falls_back_to_the_cpu(monkeypatch): - made = _fake_ort(monkeypatch, fail_on_gpu_start=True) +def test_a_gpu_that_will_not_start_falls_back_to_the_cpu(monkeypatch, tmp_path): + made = _fake_ort(monkeypatch, tmp_path, fail_on_gpu_start=True) net = rife.Rife() assert not net.on_gpu and made == [["CPUExecutionProvider"]] -def test_a_gpu_that_onnx_runtime_quietly_swaps_for_the_cpu_is_not_taken_for_one(monkeypatch): +def test_a_gpu_that_onnx_runtime_quietly_swaps_for_the_cpu_is_not_taken_for_one(monkeypatch, tmp_path): """ONNX Runtime 1.31 catches a DirectML start-up error and builds a CPU session instead, which would then use every core.""" - made = _fake_ort(monkeypatch, gpu_quietly_on_cpu=True) + made = _fake_ort(monkeypatch, tmp_path, gpu_quietly_on_cpu=True) net = rife.Rife() assert not net.on_gpu and made[-1] == ["CPUExecutionProvider"] -def test_a_gpu_that_runs_out_of_memory_finishes_on_the_cpu(monkeypatch): +def test_a_gpu_that_runs_out_of_memory_finishes_on_the_cpu(monkeypatch, tmp_path): """Resolve holds much of the GPU's memory while a render runs.""" - _fake_ort(monkeypatch, fail_on_gpu_run=True) + _fake_ort(monkeypatch, tmp_path, fail_on_gpu_run=True) net = rife.Rife() a, b = cel(120, 120), cel(150, 116) out = net.interpolate_pair(a, b, [0.5])[0] @@ -321,11 +324,12 @@ def test_a_gpu_that_runs_out_of_memory_finishes_on_the_cpu(monkeypatch): assert not net.on_gpu -def _net_returning(monkeypatch, output): - """A Rife whose session returns `output(x)` for its input tensor.""" - _fake_ort(monkeypatch) +def _net_returning(monkeypatch, tmp_path, output): + """A Rife whose session returns the frame `output(x)` for its input tensor, + and no motion.""" + _fake_ort(monkeypatch, tmp_path) net = rife.Rife() - monkeypatch.setattr(net, "_infer", output) + monkeypatch.setattr(net, "_infer", lambda x: [output(x), np.zeros_like(x[:, :4]), x[:, 6:7]]) return net @@ -334,20 +338,42 @@ def _net_returning(monkeypatch, output): lambda x: np.full_like(x[:, :3], np.nan), # not numbers lambda x: x[:, :3, :, :, None], # wrong rank ]) -def test_a_broken_frame_from_the_model_stops_the_render(monkeypatch, broken): +def test_a_broken_frame_from_the_model_stops_the_render(monkeypatch, tmp_path, broken): """Written as it came, a smaller frame made a video short by a frame, and a frame of NaN came out black, both reported as a full render.""" - net = _net_returning(monkeypatch, broken) + net = _net_returning(monkeypatch, tmp_path, broken) with pytest.raises(rife.AIUnavailable, match="broken frame"): net.interpolate_pair(cel(120, 120), cel(150, 116), [0.5]) -def test_a_good_frame_from_the_model_is_used(monkeypatch): - net = _net_returning(monkeypatch, lambda x: x[:, :3]) +def test_a_good_frame_from_the_model_is_used(monkeypatch, tmp_path): + net = _net_returning(monkeypatch, tmp_path, lambda x: x[:, :3]) out = net.interpolate_pair(cel(120, 120), cel(150, 116), [0.5])[0] assert np.array_equal(out, cel(120, 120)) +def test_the_motion_is_added_to_the_models_outputs(): + """vs-mlrt's export keeps the network's motion inside the model.""" + graph = b"\x0a\x00" # one empty node + model = b"\x08\x07\x3a\x02" + graph + b"\x42\x00" + added = b"\x62\x03\x0a\x01m" # an output named m + assert rife._with_outputs(model, ["m"]) == b"\x08\x07\x3a\x07" + graph + added + b"\x42\x00" + + +def test_a_model_without_a_graph_is_refused(): + with pytest.raises(rife.AIUnavailable, match="Delete it"): + rife._with_outputs(b"\x08\x07", ["m"]) + + +def test_at_2x_a_cut_holds_the_first_drawing_as_given(): + from tests.fake_rife import StandInRife + a, b = cel(140, 120), city() + big = np.full((480, 640, 3), 10, np.uint8), np.full((480, 640, 3), 20, np.uint8) + out = StandInRife().interpolate_pair(a, b, [0.0, 0.5, 1.0], big=big) + assert [int(f[0, 0, 0]) for f in out] == [10, 10, 20] + assert all(f is not big[0] and f is not big[1] for f in out) + + def test_a_frame_of_another_size_never_reaches_the_file(monkeypatch, make_anime, tmp_path): class Shrinking: def __init__(self, progress=None): @@ -526,3 +552,19 @@ def test_the_network_holds_across_a_cut(model): a, b = cel(140, 120), city() out = model.interpolate_pair(a, b, [0.25, 0.5, 0.75]) assert all(np.array_equal(f, a) for f in out) + + +@pytest.mark.parametrize("dx,t", [(30, 0.5), (120, 0.5), (60, 0.1)]) +def test_at_2x_the_in_between_is_the_networks_frame_drawn_bigger(model, dx, t): + """Drawn from the drawings at 2x with the network's motion, it came within + 40 to 45 dB of upscaling the network's own frame, near a drawing too.""" + import cv2 + + def up(frame): + return cv2.resize(frame, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) + + a, b = cel(80, 120), cel(80 + dx, 120) + small = model.interpolate_pair(a, b, [t])[0] + big = model.interpolate_pair(a, b, [t], big=(up(a), up(b)))[0] + assert big.shape == (2 * a.shape[0], 2 * a.shape[1], 3) + assert cv2.PSNR(big, up(small)) > 38 diff --git a/tests/test_upscaler.py b/tests/test_upscaler.py index b6a9977..3b8e31c 100644 --- a/tests/test_upscaler.py +++ b/tests/test_upscaler.py @@ -201,6 +201,16 @@ def no_gpu(progress=None): assert _render(make_anime, tmp_path / "b", network_upscale=True).width == 200 +def test_only_the_drawings_are_upscaled(twice_as_big, make_anime, tmp_path, read_frames): + """Upscaling every in-between too took most of a render's time. They are + drawn at 2x from the drawings instead.""" + result = _render(make_anime, tmp_path, network_upscale=True, upscale_enabled=False) + frames = read_frames(result.output) + assert twice_as_big.drawn == 6 and len(frames) == 12 + between = cv2.addWeighted(frames[0], 0.5, frames[2], 0.5, 0) + assert int(cv2.absdiff(frames[1], between).max()) <= 1 + + def test_a_held_drawing_is_drawn_at_2x_once(twice_as_big, make_anime, tmp_path, read_frames): """With Interpolate off, a drawing on 2s comes through twice. The network drew it twice, so an upscale-only render took twice as long.""" From 044659626f2d39a963d2cef23563fd2a5946a234 Mon Sep 17 00:00:00 2001 From: Alitleis123 Date: Tue, 10 Nov 2026 16:00:52 -0500 Subject: [PATCH 02/38] Draw and write frames in a pool while the GPU draws the next The 2x in-betweens were drawn on the main thread and only three threads wrote PNGs, so on a 7900 XT the GPU sat at 20%. The 2x in-betweens are now Futures drawn in a pool of up to eight threads, which also writes the frames, and the network starts on the next frame meanwhile. A video still gets its frames in order. --- Pipeline/render.py | 87 +++++++++++++++++++++++++++--------------- Pipeline/rife.py | 32 +++++++++++----- tests/test_rife.py | 17 +++++++++ tests/test_upscaler.py | 11 ++++-- 4 files changed, 104 insertions(+), 43 deletions(-) diff --git a/Pipeline/render.py b/Pipeline/render.py index 924b767..1eebf8d 100644 --- a/Pipeline/render.py +++ b/Pipeline/render.py @@ -369,10 +369,17 @@ def _iter_plan_frames(cap: cv2.VideoCapture, indices: Sequence[int]) -> Iterator # How many in-betweens are drawn before they are written. BATCH = 8 -# Threads that encode and write PNG frames. A 4K PNG takes about as long to -# encode as the GPU takes to draw and upscale a frame, and OpenCV lets go of -# Python while it encodes, so the two overlap. One core is left for Resolve. -WRITERS = max(1, min(3, (os.cpu_count() or 2) - 1)) +# Threads that draw the in-betweens at 2x and encode and write the frames, +# while the network works on the next. Each 4K frame takes them longer than +# the GPU takes to draw one, and OpenCV lets go of Python while it works, so +# with three the GPU sat idle most of a render. Half the cores, so Resolve +# keeps the rest. +WORKERS = max(1, min(8, (os.cpu_count() or 2) // 2)) + + +def _ready(frame) -> np.ndarray: + """A frame, waiting for it if it is still being drawn.""" + return frame.result() if isinstance(frame, Future) else frame # Encoders to try per container, best quality first. _ENCODERS = { @@ -469,10 +476,10 @@ def render_plan( buckets[i].append(p - i) cap = _open(source, frames, progress) - pool = ThreadPoolExecutor(max_workers=WRITERS) if is_sequence else None - # Frames still being written, oldest first. A few at most, so a fast GPU - # can't fill memory with 4K frames waiting for the disk. - pending: Deque[Future] = deque() + pool = ThreadPoolExecutor(max_workers=WORKERS) + # Frames still being drawn or written, oldest first. A few at most, so a + # fast GPU can't fill memory with 4K frames waiting for the disk. + pending: Deque = deque() def write_png(frame: np.ndarray, target: Path, number: int) -> None: # Encode in memory and let Python write the file. cv2.imwrite @@ -487,8 +494,30 @@ def write_png(frame: np.ndarray, target: Path, number: int) -> None: raise SourceError(f"Could not write {target}: {exc}. Check the disk " "has space and the folder is writable.") from exc - state = {"writer": None, "written": 0, "width": 0, "height": 0, "upscaler": upscaler, - "upscale_note": upscale_note} + state = {"writer": None, "written": 0, "flushed": 0, "width": 0, "height": 0, + "upscaler": upscaler, "upscale_note": upscale_note} + + def finish(frame, number: int) -> None: + """Write one frame once it is drawn. In the pool for a sequence.""" + frame = _ready(frame) + if frame.shape[:2] != (state["height"], state["width"]): + raise SourceError(f"Frame {number} came out " + f"{frame.shape[1]}x{frame.shape[0]}, not " + f"{state['width']}x{state['height']}.") + if is_sequence: + write_png(frame, output / f"frame_{number:06d}.png", number) + else: + state["writer"].write(frame) + + def flush() -> None: + """Wait for the oldest pending frame. One that failed raises here.""" + item = pending.popleft() + if is_sequence: + item.result() + else: + # A video takes its frames in order, so they are written here. + finish(item, state["flushed"]) + state["flushed"] += 1 # The last two frames upscaled and their 2x versions: the pair of drawings # being drawn between, or a held drawing. drawn: Deque[Tuple[np.ndarray, np.ndarray]] = deque(maxlen=2) @@ -514,27 +543,18 @@ def upscaled(frame: np.ndarray) -> Optional[np.ndarray]: drawn.append((frame, big)) return big - def emit(frame: np.ndarray, is_2x: bool = False) -> None: - """Upscale if asked and not done, then write one output frame.""" + def emit(frame, is_2x: bool = False) -> None: + """Upscale if asked and not done, then write one output frame, or a + Future of one still being drawn.""" if not is_2x: big = upscaled(frame) if big is not None: frame = big if factor > 1: frame = upscale(frame, factor) - if state["written"] and frame.shape[:2] != (state["height"], state["width"]): - raise SourceError(f"Frame {state['written']} came out " - f"{frame.shape[1]}x{frame.shape[0]}, not " - f"{state['width']}x{state['height']}.") - state["height"], state["width"] = frame.shape[:2] - if is_sequence: - target = output / f"frame_{state['written']:06d}.png" - # A write that failed raises here, on the next frame. - while len(pending) >= 2 * WRITERS: - pending.popleft().result() - pending.append(pool.submit(write_png, frame, target, state["written"])) - else: - if state["writer"] is None: + if not state["written"]: + state["height"], state["width"] = _ready(frame).shape[:2] + if not is_sequence: # Every writer here rounds an odd size down to even without a # word, so the file would be a pixel short of what it reports. if state["width"] % 2 or state["height"] % 2: @@ -545,7 +565,12 @@ def emit(frame: np.ndarray, is_2x: bool = False) -> None: f"any size.") state["writer"] = _writer(output, (state["width"], state["height"]), plan.fps) - state["writer"].write(frame) + while len(pending) >= 2 * WORKERS: + flush() + if is_sequence: + pending.append(pool.submit(finish, frame, state["written"])) + else: + pending.append(frame) state["written"] += 1 if progress and positions: # A GPU that gave out mid-render leaves the rest to the CPU, which @@ -617,7 +642,7 @@ def change_from(slot: int) -> Optional[float]: # at 2x from them. big = upscaled(prev), upscaled(curr) big = None if big[0] is None or big[1] is None else big - produced = pair(prev, curr, part, unrelated=unrelated, big=big) + produced = pair(prev, curr, part, unrelated=unrelated, big=big, pool=pool) else: # De-duplicate only: every output frame is a real drawing, # nothing is synthesised, and each starts where it did. @@ -634,14 +659,14 @@ def change_from(slot: int) -> Optional[float]: while state["written"] < len(positions): emit(prev) while pending: - pending.popleft().result() + flush() finally: + # Even on the way out of an error, no write is left going, and no + # frame is drawn that will not be written. + pool.shutdown(wait=True, cancel_futures=True) cap.release() if state["writer"] is not None: state["writer"].release() - if pool is not None: - # Even on the way out of an error, no write is left going. - pool.shutdown(wait=True) return RenderResult( output=output, diff --git a/Pipeline/rife.py b/Pipeline/rife.py index 06f6d2c..76d734a 100644 --- a/Pipeline/rife.py +++ b/Pipeline/rife.py @@ -21,6 +21,7 @@ import sys import tempfile import urllib.request +from concurrent.futures import Future from pathlib import Path from typing import Callable, List, Optional @@ -186,6 +187,11 @@ def ensure_model(progress: Optional[ProgressFn] = None, path: Path = MODEL_PATH, part.unlink(missing_ok=True) +def _ready(frame) -> np.ndarray: + """A frame, waiting for it if it is still being drawn.""" + return frame.result() if isinstance(frame, Future) else frame + + def _varint(n: int) -> bytes: out = bytearray() while True: @@ -360,7 +366,7 @@ def _run(self, a: np.ndarray, b: np.ndarray, t: float): def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], unrelated: Optional[bool] = None, - big: Optional[tuple] = None) -> List[np.ndarray]: + big: Optional[tuple] = None, pool=None) -> list: """In-betweens for `times` (each in 0..1) between drawings a and b. t == 0 returns `a` untouched and t == 1 returns `b` untouched, so real @@ -374,7 +380,8 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], With `big`, a and b upscaled 2x, every frame comes back at 2x: the drawings as given, and each in-between drawn from them with the network's motion. Upscaling the in-betweens one by one instead took - most of a render's time. + most of a render's time. With `pool` too, those in-betweens come back + as Futures drawn there, so the network starts on the next meanwhile. """ if not times: return [] @@ -394,16 +401,23 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], elif t >= 1.0 - _ROUNDING: out.append(ends[1].copy()) else: - out.append(self._between(a, b, t, big)) + out.append(self._between(a, b, t, big, pool)) return out - def _step(self, lo: tuple, hi: tuple, t: float) -> tuple: + def _step(self, lo: tuple, hi: tuple, t: float, pool=None) -> tuple: """The frame at t between two (frame, 2x frame) pairs, as another.""" frame, (flow, mask) = self._run(lo[0], hi[0], t) - return frame, (None if lo[1] is None else _warp2x(lo[1], hi[1], flow, mask)) + if lo[1] is None: + return frame, None + if pool is None: + return frame, _warp2x(lo[1], hi[1], flow, mask) + # Either end may be a halfway frame still being drawn. It went into + # the pool first, so it is drawn or being drawn by the time this runs. + big0, big1 = lo[1], hi[1] + return frame, pool.submit(lambda: _warp2x(_ready(big0), _ready(big1), flow, mask)) def _between(self, a: np.ndarray, b: np.ndarray, t: float, - big: Optional[tuple] = None) -> np.ndarray: + big: Optional[tuple] = None, pool=None): """The frame at t between a and b, asking the network only for 0.25 to 0.75.""" ends = big or (None, None) pair = (a, b, ends[0]) @@ -418,11 +432,11 @@ def _between(self, a: np.ndarray, b: np.ndarray, t: float, # distance from the nearer end. A gap of 5000 frames slowed three # times needs 12 halvings and keeps 23 halfway frames. if _EVEN_FROM <= local <= 1.0 - _EVEN_FROM: - return self._step(known[lo], known[hi], local)[which] + return self._step(known[lo], known[hi], local, pool)[which] if hi - lo < 2.0 ** -30: # Only rounding gets here, where both ends are one picture. - return (known[lo] if local < 0.5 else known[hi])[which].copy() + return _ready((known[lo] if local < 0.5 else known[hi])[which]).copy() mid = (lo + hi) / 2.0 if mid not in known: - known[mid] = self._step(known[lo], known[hi], 0.5) + known[mid] = self._step(known[lo], known[hi], 0.5, pool) lo, hi = (lo, mid) if local < 0.5 else (mid, hi) diff --git a/tests/test_rife.py b/tests/test_rife.py index 74e2ff9..64d84f0 100644 --- a/tests/test_rife.py +++ b/tests/test_rife.py @@ -365,6 +365,23 @@ def test_a_model_without_a_graph_is_refused(): rife._with_outputs(b"\x08\x07", ["m"]) +def test_in_a_pool_the_2x_in_betweens_are_the_same_frames(): + from concurrent.futures import Future, ThreadPoolExecutor + + import cv2 + + from tests.fake_rife import StandInRife + a, b = cel(80, 120), cel(110, 120) + big = tuple(cv2.resize(f, None, fx=2, fy=2) for f in (a, b)) + times = [0.0, 0.05, 0.1, 0.4, 0.9, 1.0] + plain = StandInRife().interpolate_pair(a, b, times, big=big) + with ThreadPoolExecutor(2) as pool: + drawn = StandInRife().interpolate_pair(a, b, times, big=big, pool=pool) + assert isinstance(drawn[1], Future) + drawn = [f.result() if isinstance(f, Future) else f for f in drawn] + assert all(np.array_equal(x, y) for x, y in zip(plain, drawn)) + + def test_at_2x_a_cut_holds_the_first_drawing_as_given(): from tests.fake_rife import StandInRife a, b = cel(140, 120), city() diff --git a/tests/test_upscaler.py b/tests/test_upscaler.py index 3b8e31c..a5e14f4 100644 --- a/tests/test_upscaler.py +++ b/tests/test_upscaler.py @@ -201,10 +201,15 @@ def no_gpu(progress=None): assert _render(make_anime, tmp_path / "b", network_upscale=True).width == 200 -def test_only_the_drawings_are_upscaled(twice_as_big, make_anime, tmp_path, read_frames): +@pytest.mark.parametrize("name", ["out", "out.mkv"]) +def test_only_the_drawings_are_upscaled(twice_as_big, make_anime, tmp_path, read_frames, name): """Upscaling every in-between too took most of a render's time. They are - drawn at 2x from the drawings instead.""" - result = _render(make_anime, tmp_path, network_upscale=True, upscale_enabled=False) + drawn at 2x from the drawings instead, in the pool, and a video still gets + them in order.""" + src = make_anime(holds=(2,) * 6) + plan, _, _ = analyse_video(src, Upflow2xConfig()) + result = render_plan(src, plan, tmp_path / name, Upflow2xConfig( + network_upscale=True, upscale_enabled=False), sequence=name == "out") frames = read_frames(result.output) assert twice_as_big.drawn == 6 and len(frames) == 12 between = cv2.addWeighted(frames[0], 0.5, frames[2], 0.5, 0) From ad28a3e7fe9bd45250c7a64bce049f335cda9cb1 Mon Sep 17 00:00:00 2001 From: Alitleis123 Date: Tue, 10 Nov 2026 16:00:52 -0500 Subject: [PATCH 03/38] Put renders beside Resolve's cache, a folder per project Renders went in an Upflow2x folder beside each clip, so they ended up among the recordings in every folder clips came from. The panel now passes the project's name and Resolve's cache folder, and renders go in /Upflow2x/. Without a cache folder, or with one on a drive that is not there, they go in Videos/Upflow2x, or Movies on a Mac. An output_dir in the settings still wins. --- Installer/Eternal2x.lua | 24 ++++++++++++++++++- Stages/resolve_smooth.py | 45 ++++++++++++++++++++++++++++++++---- tests/lua_harness.lua | 5 ++++ tests/test_end_to_end.py | 10 +++++--- tests/test_lua_ui.py | 23 ++++++++++++++++++ tests/test_resolve_smooth.py | 31 +++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 9 deletions(-) diff --git a/Installer/Eternal2x.lua b/Installer/Eternal2x.lua index cf02183..59b83a6 100644 --- a/Installer/Eternal2x.lua +++ b/Installer/Eternal2x.lua @@ -1207,6 +1207,20 @@ local function mark_drawings(clip, marks) return placed, shared end +-- The open project's name and Resolve's cache folder, which decide where the +-- stage puts a render. Either can be missing, and the stage then falls back. +local function render_folder_hints() + local name, cache = "", "" + pcall(function() + local project = get_resolve():GetProjectManager():GetCurrentProject() + local got = project:GetName() + if type(got) == "string" then name = got end + got = project:GetSetting("perfCacheClipsLocation") + if type(got) == "string" then cache = got end + end) + return name, cache +end + local function current_args(extra) local args = "" if SOURCE_PATH and SOURCE_PATH ~= "" then @@ -1237,7 +1251,15 @@ local function current_args(extra) -- Each step doubles or halves how big a change counts, around the default. args = args .. " --threshold " .. string.format("%.6f", 0.004 * 2 ^ ((THRESHOLD - 5) / 2)):gsub(",", ".") end - if OUTPUT_DIR ~= "" then args = args .. " --output-dir " .. shell_quote(OUTPUT_DIR) end + if OUTPUT_DIR ~= "" then + args = args .. " --output-dir " .. shell_quote(OUTPUT_DIR) + else + -- Renders go in one Upflow2x folder beside Resolve's cache, a folder + -- per project, rather than among the clips they came from. + local project, cache = render_folder_hints() + if project ~= "" then args = args .. " --project " .. shell_quote(project) end + if cache ~= "" then args = args .. " --cache-dir " .. shell_quote(cache) end + end args = args .. " --status-file " .. shell_quote(STATUS_PATH) return args .. (extra or "") end diff --git a/Stages/resolve_smooth.py b/Stages/resolve_smooth.py index 428b88d..5c1b993 100644 --- a/Stages/resolve_smooth.py +++ b/Stages/resolve_smooth.py @@ -170,10 +170,39 @@ def _finished(plan, result) -> str: return message +def default_output_dir(project: Optional[str] = None, cache: Optional[str] = None) -> Path: + """Where renders go unless the panel is told otherwise: an Upflow2x folder + beside Resolve's cache folder, with a folder for each project. + + Beside each clip, a render landed in whatever folder the clip came from, + among the recordings. Resolve keeps its own working files in its cache + folder. Where that is not set, renders go in the Videos folder, or Movies + on a Mac. + """ + if cache and Path(cache).is_absolute(): + root = Path(cache).parent + else: + root = Path.home() / ("Movies" if sys.platform == "darwin" else "Videos") + # A project name can hold anything. A folder name on Windows cannot. + name = "".join("_" if c in '<>:"/\\|?*' or ord(c) < 32 else c for c in project or "") + name = name.strip(" .") + return root / "Upflow2x" / name if name else root / "Upflow2x" + + def _output_path(source: Path, output_dir: Optional[str], fmt: str, - frames: Optional[tuple] = None, slow: int = 1) -> Path: - base = Path(output_dir) if output_dir else source.parent / "Upflow2x" - base.mkdir(parents=True, exist_ok=True) + frames: Optional[tuple] = None, slow: int = 1, + project: Optional[str] = None, cache: Optional[str] = None) -> Path: + if output_dir: + base = Path(output_dir) + base.mkdir(parents=True, exist_ok=True) + else: + base = default_output_dir(project, cache) + try: + base.mkdir(parents=True, exist_ok=True) + except OSError: + # The cache can be on a drive that is not plugged in. + base = default_output_dir(project) + base.mkdir(parents=True, exist_ok=True) suffix = FORMATS.get(fmt, "") # Name a trimmed render after its range, so two cuts of one file differ, # and a slowed one after how much slower. @@ -230,7 +259,13 @@ def build_parser() -> argparse.ArgumentParser: help="Seconds into the file where the clip ends.") parser.add_argument("--output-dir", default=None, help="Where to write the result (default: an Upflow2x " - "folder beside the source).") + "folder beside Resolve's cache folder, or in Videos).") + parser.add_argument("--project", default=None, + help="The Resolve project's name. Without --output-dir, its " + "renders get a folder of their own.") + parser.add_argument("--cache-dir", default=None, + help="Resolve's cache folder. Without --output-dir, renders go " + "in an Upflow2x folder beside it.") parser.add_argument("--format", default="png", choices=sorted(FORMATS), help="png: lossless image sequence (default). " "mp4: compact H.264 file. " @@ -434,7 +469,7 @@ def _run(args, summary: dict) -> int: return 0 output = _output_path(source, args.output_dir, args.format, None if whole else frames, - cfg.slow_motion) + cfg.slow_motion, args.project, args.cache_dir) print(f"Rendering to: {output}") try: result = render_plan(source, plan, output, cfg, progress=progress, diff --git a/tests/lua_harness.lua b/tests/lua_harness.lua index 603c809..6965ab8 100644 --- a/tests/lua_harness.lua +++ b/tests/lua_harness.lua @@ -388,6 +388,11 @@ function H.make_resolve(clip_file_path) local project = { GetCurrentTimeline = function(_self) return timeline end, GetMediaPool = function(_self) return pool end, + GetName = function(_self) return H.project_name or "Untitled Project" end, + GetSetting = function(_self, key) + if key == "perfCacheClipsLocation" then return H.cache_location end + return nil + end, } local pm = { GetCurrentProject = function(_self) return project end } return { GetProjectManager = function(_self) return pm end } diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index a3b5586..a35b2fc 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -24,6 +24,8 @@ from tests import fake_rife # noqa: E402 HARNESS = Path(__file__).resolve().parent / "lua_harness.lua" +# A name a Windows folder cannot take as it is, and the folder it gets. +PROJECT, PROJECT_FOLDER = "Episode 1: Final?", "Episode 1_ Final_" @pytest.fixture @@ -50,8 +52,8 @@ def install(tmp_path, repo_root, make_anime, folder): write_config(comp, plugin, sys.executable) def _open(holds=(2,) * 12, clip_name="scene 01.avi"): - # Keep the media well away from the plugin folder: the default output - # directory is an Eternal2x folder beside the source. + # Keep the media well away from the plugin folder. Renders go in an + # Upflow2x folder beside Resolve's cache, here in the media folder. media = tmp_path / "media" media.mkdir(exist_ok=True) src = media / clip_name @@ -63,6 +65,8 @@ def _open(holds=(2,) * 12, clip_name="scene 01.avi"): # Lua accepts forward slashes on Windows. H = lua.eval(f'dofile("{HARNESS.as_posix()}")') H.real_execute = True + H.project_name = PROJECT + H.cache_location = str(media / "CacheClip") H.load(str(comp / "Upflow2x.lua"), str(src)) H.plugin_root = str(plugin) return H, src @@ -105,7 +109,7 @@ def finish(H, timeout: float = FINISH_TIMEOUT): def outputs(src: Path): - folder = src.parent / "Upflow2x" + folder = src.parent / "Upflow2x" / PROJECT_FOLDER return sorted(folder.iterdir()) if folder.exists() else [] diff --git a/tests/test_lua_ui.py b/tests/test_lua_ui.py index e2050b3..39108bf 100644 --- a/tests/test_lua_ui.py +++ b/tests/test_lua_ui.py @@ -149,6 +149,29 @@ def test_output_format_selection_reaches_the_command(lua, direct): assert "--format mp4" in H.last_command() +def test_the_stage_hears_the_project_and_resolves_cache_folder(lua, direct): + """Renders landed beside each clip, among the recordings. They go beside + Resolve's cache now, a folder per project.""" + H = load(lua, ui_script(direct)) + H.project_name, H.cache_location = "Show One", "D:/Cache/CacheClip" + H.click("SmoothBtn") + cmd = H.last_command() + quote = '"' if sys.platform.startswith("win") else "'" + assert f"--project {quote}Show One{quote}" in cmd + assert f"--cache-dir {quote}D:/Cache/CacheClip{quote}" in cmd + assert "--output-dir" not in cmd + + +def test_an_output_folder_in_the_settings_wins(lua, direct): + conf = direct / "Installer" / "Eternal2x.conf" + conf.write_text(conf.read_text() + "output_dir=/renders\n") + H = load(lua, ui_script(direct)) + H.cache_location = "D:/Cache/CacheClip" + H.click("SmoothBtn") + cmd = H.last_command() + assert "--output-dir" in cmd and "--cache-dir" not in cmd and "--project" not in cmd + + def test_every_render_plays_at_a_third_of_the_speed(lua, direct): """There is no setting for it: every render comes out three times longer, with more drawn frames to time remap. An upscale alone has diff --git a/tests/test_resolve_smooth.py b/tests/test_resolve_smooth.py index b5af58b..7529985 100644 --- a/tests/test_resolve_smooth.py +++ b/tests/test_resolve_smooth.py @@ -94,6 +94,37 @@ def test_the_rendered_file_lands_where_it_says(anime_in_resolve, monkeypatch, ca assert rendered[0].name in capsys.readouterr().out +def test_renders_go_beside_resolves_cache_in_a_folder_per_project(tmp_path): + """Beside each clip, a render landed among the recordings it came from.""" + cache = tmp_path / "work" / "CacheClip" + assert smooth.default_output_dir("Show", str(cache)) == tmp_path / "work" / "Upflow2x" / "Show" + + +@pytest.mark.parametrize("cache", [None, "", "CacheClip"]) +def test_without_a_cache_folder_renders_go_in_videos(monkeypatch, tmp_path, cache): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + videos = "Movies" if sys.platform == "darwin" else "Videos" + assert smooth.default_output_dir("Show", cache) == tmp_path / videos / "Upflow2x" / "Show" + assert smooth.default_output_dir(None, cache) == tmp_path / videos / "Upflow2x" + + +def test_a_project_name_becomes_a_folder_windows_can_make(tmp_path): + got = smooth.default_output_dir('a:c"d/e\\f|g?h*. ', str(tmp_path / "CacheClip")) + assert got == tmp_path / "Upflow2x" / "a_b__c_d_e_f_g_h_" + + +def test_a_cache_on_a_missing_drive_leaves_renders_in_videos(make_anime, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("USERPROFILE", str(tmp_path / "home")) + blocker = tmp_path / "drive" + blocker.write_text("a file where the cache's drive would be") + out = smooth._output_path(tmp_path / "clip.avi", None, "png", project="Show", + cache=str(blocker / "CacheClip")) + videos = "Movies" if sys.platform == "darwin" else "Videos" + assert out.parent == tmp_path / "home" / videos / "Upflow2x" / "Show" + + def test_the_default_output_is_a_lossless_sequence(anime_in_resolve, monkeypatch, tmp_path): """Quality is the point of the tool, so the default must not be lossy.""" From 2a087c8198060ae65d16bdb704eeeaa76c9e33eb Mon Sep 17 00:00:00 2001 From: Alitleis123 Date: Tue, 10 Nov 2026 16:03:49 -0500 Subject: [PATCH 04/38] Give each test a home folder of its own Stage runs in the tests that name no output folder now render into the Videos folder, which was the real one on the machine running them. --- tests/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 6ea9d09..343bea4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,15 @@ def no_gpu(progress=None): monkeypatch.setattr(upscaler, "Upscaler", no_gpu) +@pytest.fixture(autouse=True) +def _a_home_of_its_own(tmp_path, monkeypatch): + """A render told nowhere to go lands in the Videos folder. A test's lands + in its own, and so does one from a stage run in another Python.""" + home = tmp_path / "user-home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + + @pytest.fixture def repo_root() -> Path: return REPO_ROOT From 85175c1982101b61c9df403d8b56d8e15c203387 Mon Sep 17 00:00:00 2001 From: Alitleis123 Date: Tue, 10 Nov 2026 16:13:56 -0500 Subject: [PATCH 05/38] Cut the CPU work around each RIFE call On a 7900 XT each call spent 83 ms on the GPU and 64 ms on the CPU around it, on the thread that feeds the GPU. An in-between drawn at 2x now only brings back the network's motion, not a frame nobody uses, and each frame's planes are kept for the next call that takes the same frame. --- Pipeline/rife.py | 62 +++++++++++++++++++++++++-------------- tests/fake_rife.py | 4 +-- tests/test_interpolate.py | 8 ++--- tests/test_rife.py | 24 +++++++++++++-- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/Pipeline/rife.py b/Pipeline/rife.py index 76d734a..f44186b 100644 --- a/Pipeline/rife.py +++ b/Pipeline/rife.py @@ -21,9 +21,10 @@ import sys import tempfile import urllib.request +from collections import deque from concurrent.futures import Future from pathlib import Path -from typing import Callable, List, Optional +from typing import Callable, Deque, List, Optional import cv2 import numpy as np @@ -288,6 +289,9 @@ def __init__(self, progress: Optional[ProgressFn] = None): ort = ensure_runtime(progress) model = ensure_model(progress) self._ort, self._model = ort, _with_outputs(Path(model).read_bytes(), _MOTION) + # The last few frames given to the network, as it takes them. A pair's + # drawings and halfway frames come back again and again. + self._seen: Deque[tuple] = deque(maxlen=4) # DirectML runs it on any Windows GPU, AMD included, when the # onnxruntime-directml package is the one installed. self.on_gpu = "DmlExecutionProvider" in ort.get_available_providers() @@ -321,9 +325,9 @@ def _session(self): providers = ["CPUExecutionProvider"] return ort.InferenceSession(self._model, options, providers=providers) - def _infer(self, x: np.ndarray) -> list: + def _infer(self, x: np.ndarray, names: Optional[List[str]] = None) -> list: try: - return self.session.run(None, {self.input: x}) + return self.session.run(names, {self.input: x}) except Exception: # onnxruntime raises its own exception types if not self.on_gpu: raise @@ -331,38 +335,50 @@ def _infer(self, x: np.ndarray) -> list: # more than is left. The CPU is slower but has room. self.on_gpu = False self.session = self._session() - return self.session.run(None, {self.input: x}) - - def _run(self, a: np.ndarray, b: np.ndarray, t: float): + return self.session.run(names, {self.input: x}) + + def _planes(self, frame: np.ndarray, ph: int, pw: int) -> np.ndarray: + """`frame` as the network takes it: padded RGB planes from 0 to 1.""" + for given, planes in self._seen: + if given is frame: + return planes + padded = cv2.copyMakeBorder(frame, 0, ph, 0, pw, cv2.BORDER_REPLICATE) + planes = np.empty((3,) + padded.shape[:2], np.float32) + for c in range(3): + # From OpenCV's BGR from 0 to 255. + cv2.multiply(cv2.extractChannel(padded, 2 - c), 1.0 / 255.0, dst=planes[c], + dtype=cv2.CV_32F) + self._seen.append((frame, planes)) + return planes + + def _run(self, a: np.ndarray, b: np.ndarray, t: float, frame: bool = True): """The network's frame at t, and the motion it drew it with: for each pixel, how far it comes from in a and in b (4 x h x w), and how much - of a it takes (h x w).""" + of a it takes (h x w). Without `frame`, only the motion, which spares + copying the frame back from the GPU.""" h, w = a.shape[:2] ph, pw = -h % _PAD, -w % _PAD if self._input is None or self._input[0] is not a or self._input[1] is not b: x = np.empty((1, 7, h + ph, w + pw), np.float32) - for k, frame in enumerate((a, b)): - padded = cv2.copyMakeBorder(frame, 0, ph, 0, pw, cv2.BORDER_REPLICATE) - for c in range(3): - # RGB from 0 to 1, from OpenCV's BGR from 0 to 255. - cv2.multiply(cv2.extractChannel(padded, 2 - c), 1.0 / 255.0, - dst=x[0, 3 * k + c], dtype=cv2.CV_32F) + x[0, :3], x[0, 3:6] = self._planes(a, ph, pw), self._planes(b, ph, pw) self._input = (a, b, x) x = self._input[2] x[0, 6] = t - y = self._infer(x) + y = self._infer(x, None if frame else list(_MOTION)) # A frame of another size would be written as it came, and a video # writer drops it without a word, so the render would come out short. - expected = [(1, c, h + ph, w + pw) for c in (3, 4, 1)] + expected = [(1, c, h + ph, w + pw) for c in ((3, 4, 1) if frame else (4, 1))] shapes = [getattr(part, "shape", None) for part in y] if shapes != expected or not all(np.isfinite(part).all() for part in y): raise AIUnavailable(f"The AI model returned a broken frame (shape " f"{shapes[0] if shapes else '?'}, expected {expected[0]}), so " f"the render was stopped.") + motion = (y[-2][0, :, :h, :w], y[-1][0, 0, :h, :w]) + if not frame: + return None, motion # Rounded and held to 0..255 as it goes back to 8 bits. - frame = cv2.merge([cv2.multiply(y[0][0, c, :h, :w], 255.0, dtype=cv2.CV_8U) - for c in (2, 1, 0)]) - return frame, (y[1][0, :, :h, :w], y[2][0, 0, :h, :w]) + return cv2.merge([cv2.multiply(y[0][0, c, :h, :w], 255.0, dtype=cv2.CV_8U) + for c in (2, 1, 0)]), motion def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], unrelated: Optional[bool] = None, @@ -404,9 +420,10 @@ def interpolate_pair(self, a: np.ndarray, b: np.ndarray, times: List[float], out.append(self._between(a, b, t, big, pool)) return out - def _step(self, lo: tuple, hi: tuple, t: float, pool=None) -> tuple: - """The frame at t between two (frame, 2x frame) pairs, as another.""" - frame, (flow, mask) = self._run(lo[0], hi[0], t) + def _step(self, lo: tuple, hi: tuple, t: float, pool=None, small: bool = True) -> tuple: + """The frame at t between two (frame, 2x frame) pairs, as another. + Without `small`, the 1x frame is left out where there is a 2x one.""" + frame, (flow, mask) = self._run(lo[0], hi[0], t, small or lo[1] is None) if lo[1] is None: return frame, None if pool is None: @@ -432,7 +449,8 @@ def _between(self, a: np.ndarray, b: np.ndarray, t: float, # distance from the nearer end. A gap of 5000 frames slowed three # times needs 12 halvings and keeps 23 halfway frames. if _EVEN_FROM <= local <= 1.0 - _EVEN_FROM: - return self._step(known[lo], known[hi], local, pool)[which] + # Only a halfway frame is ever given back to the network. + return self._step(known[lo], known[hi], local, pool, small=big is None)[which] if hi - lo < 2.0 ** -30: # Only rounding gets here, where both ends are one picture. return _ready((known[lo] if local < 0.5 else known[hi])[which]).copy() diff --git a/tests/fake_rife.py b/tests/fake_rife.py index 0fe90c3..a90d3d2 100644 --- a/tests/fake_rife.py +++ b/tests/fake_rife.py @@ -26,7 +26,7 @@ def __init__(self, progress=None): StandInRife.made += 1 self.on_gpu = False - def _run(self, a, b, t): + def _run(self, a, b, t, frame=True): return cv2.addWeighted(a, 1.0 - t, b, t, 0), still(a, t) @@ -44,7 +44,7 @@ class Rife(_rife_real.Rife): def __init__(self, progress=None): self.on_gpu = False - def _run(self, a, b, t): + def _run(self, a, b, t, frame=True): h, w = a.shape[:2] return cv2.addWeighted(a, 1.0 - t, b, t, 0), (np.zeros((4, h, w), np.float32), np.full((h, w), 1.0 - t, np.float32)) diff --git a/tests/test_interpolate.py b/tests/test_interpolate.py index 04a6c00..57850b1 100644 --- a/tests/test_interpolate.py +++ b/tests/test_interpolate.py @@ -12,7 +12,7 @@ import pytest from Pipeline.interpolate import CUT_THRESHOLD, frames_are_a_cut, upscale -from tests.fake_rife import StandInRife +from tests.fake_rife import StandInRife, still interpolate_pair = StandInRife().interpolate_pair @@ -71,12 +71,12 @@ def __init__(self): super().__init__() self.asked = [] - def _run(self, a, b, t): + def _run(self, a, b, t, frame=True): self.asked.append(t) if t <= 0.1: - return a.copy() + return a.copy(), still(a, 0.0) if t >= 0.9: - return b.copy() + return b.copy(), still(b, 1.0) return super()._run(a, b, t) diff --git a/tests/test_rife.py b/tests/test_rife.py index 64d84f0..fc68437 100644 --- a/tests/test_rife.py +++ b/tests/test_rife.py @@ -259,11 +259,13 @@ def get_providers(self): def get_inputs(self): return [type("Input", (), {"name": "input"})()] - def run(self, _names, feed): + def run(self, names, feed): if self.fail_on_gpu_run and "DmlExecutionProvider" in self.providers: raise RuntimeError("E_OUTOFMEMORY") x = feed["input"] - return [x[:, :3], np.zeros_like(x[:, :4]), x[:, 6:7]] + out = {"output": x[:, :3], "/Add_7_output_0": np.zeros_like(x[:, :4]), + "/Sigmoid_output_0": x[:, 6:7]} + return [out[name] for name in names or out] def _fake_ort(monkeypatch, tmp_path, **fail): @@ -329,7 +331,8 @@ def _net_returning(monkeypatch, tmp_path, output): and no motion.""" _fake_ort(monkeypatch, tmp_path) net = rife.Rife() - monkeypatch.setattr(net, "_infer", lambda x: [output(x), np.zeros_like(x[:, :4]), x[:, 6:7]]) + monkeypatch.setattr(net, "_infer", + lambda x, names=None: [output(x), np.zeros_like(x[:, :4]), x[:, 6:7]]) return net @@ -352,6 +355,21 @@ def test_a_good_frame_from_the_model_is_used(monkeypatch, tmp_path): assert np.array_equal(out, cel(120, 120)) +def test_at_2x_an_in_between_only_brings_back_the_motion(monkeypatch, tmp_path): + """Its 1x frame is never used, and copying it back from the GPU took time. + A halfway frame is given to the network again, so it comes back whole.""" + _fake_ort(monkeypatch, tmp_path) + net = rife.Rife() + asked, run = [], net.session.run + net.session.run = lambda names, feed: asked.append(names) or run(names, feed) + a, b = cel(120, 120), cel(150, 116) + big = tuple(np.repeat(np.repeat(f, 2, 0), 2, 1) for f in (a, b)) + out = net.interpolate_pair(a, b, [0.1, 0.5], big=big) + motion = list(rife._MOTION) + assert asked == [None, None, motion, motion] + assert all(f.shape == big[0].shape for f in out) + + def test_the_motion_is_added_to_the_models_outputs(): """vs-mlrt's export keeps the network's motion inside the model.""" graph = b"\x0a\x00" # one empty node From 5f152b5e6e0c774f453557f605d4c92048b806e9 Mon Sep 17 00:00:00 2001 From: Ali Tleis Date: Tue, 10 Nov 2026 16:20:59 -0500 Subject: [PATCH 06/38] Package standalone installers with automatic Python setup --- .github/workflows/release.yml | 45 +++++- Installer/build_installer.py | 41 +++-- Installer/build_release.py | 30 +++- Installer/gui_installer.py | 70 ++++++--- README.md | 37 +++-- VERSION | 2 +- docs/download.html | 24 +-- docs/help.html | 53 +++---- docs/index.html | 2 +- .../upflow2x-mac/Installer/build_installer.py | 41 +++-- .../upflow2x-mac/Installer/build_release.py | 30 +++- .../upflow2x-mac/Installer/gui_installer.py | 70 ++++++--- release/upflow2x-mac/LICENSE.md | 91 +++++++++++ release/upflow2x-mac/README.md | 37 +++-- release/upflow2x-mac/VERSION | 2 +- .../upflow2x-win/Installer/build_installer.py | 41 +++-- .../upflow2x-win/Installer/build_release.py | 30 +++- .../upflow2x-win/Installer/gui_installer.py | 70 ++++++--- release/upflow2x-win/LICENSE.md | 91 +++++++++++ release/upflow2x-win/README.md | 37 +++-- release/upflow2x-win/VERSION | 2 +- tests/test_installer.py | 2 + tests/test_release_build.py | 26 ++++ tests/test_site.py | 14 ++ tests/test_standalone_installer.py | 146 ++++++++++++++++++ 25 files changed, 843 insertions(+), 191 deletions(-) create mode 100644 release/upflow2x-mac/LICENSE.md create mode 100644 release/upflow2x-win/LICENSE.md create mode 100644 tests/test_standalone_installer.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d1b6d8..f1e074e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,45 @@ jobs: pip install -r requirements.txt -r requirements-dev.txt python -m pytest -q - publish: + installers: needs: test + strategy: + matrix: + os: [windows-latest, macos-15-intel] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + if: runner.os == 'Windows' + with: + python-version: "3.13" + - name: Install universal Python for the Mac app + if: runner.os == 'macOS' + run: | + curl --fail --location --output "$RUNNER_TEMP/python.pkg" https://www.python.org/ftp/python/3.13.17/python-3.13.17-macos11.pkg + echo "3053592af66128269efab37fe7851a4894920b27c442aeec99ce4d34d17c07e3 $RUNNER_TEMP/python.pkg" | shasum -a 256 --check + sudo installer -pkg "$RUNNER_TEMP/python.pkg" -target / + echo "/Library/Frameworks/Python.framework/Versions/3.13/bin" >> "$GITHUB_PATH" + - name: Build Windows setup + if: runner.os == 'Windows' + run: | + python -m pip install pyinstaller==6.22.3 + python Installer/build_installer.py + - name: Build Mac setup + if: runner.os == 'macOS' + run: | + python3 -m pip install pyinstaller==6.22.3 + python3 Installer/build_installer.py + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: installer-${{ runner.os }} + path: | + dist/Upflow2xInstaller.exe + dist/Upflow2xInstaller-mac.zip + if-no-files-found: error + + publish: + needs: [test, installers] runs-on: ubuntu-latest permissions: contents: write @@ -70,6 +107,12 @@ jobs: with: python-version: "3.12" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: installer-* + merge-multiple: true + path: dist + - name: Build, publish and verify env: GH_TOKEN: ${{ github.token }} diff --git a/Installer/build_installer.py b/Installer/build_installer.py index 8d224df..52a3230 100644 --- a/Installer/build_installer.py +++ b/Installer/build_installer.py @@ -7,10 +7,9 @@ Produces: dist/Upflow2xInstaller.exe (Windows) - dist/Upflow2xInstaller (macOS/Linux) + dist/Upflow2xInstaller-mac.zip (macOS, universal app) -The compiled exe should be placed in the root of the release zip -so buyers can double-click it alongside the plugin folder contents. +Each installer includes the plugin files. It can be run on its own. """ from __future__ import annotations @@ -19,8 +18,15 @@ import subprocess import sys +import tempfile from pathlib import Path +try: + from Installer.build_release import PAYLOAD, _copy +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from Installer.build_release import PAYLOAD, _copy + def main() -> int: repo_root = Path(__file__).resolve().parent.parent @@ -46,23 +52,38 @@ def main() -> int: cmd = [ sys.executable, "-m", "PyInstaller", - "--onefile", + "--onedir" if sys.platform == "darwin" else "--onefile", "--windowed", + "--noconfirm", + "--paths", str(repo_root), "--name", name, "--distpath", str(repo_root / "dist"), "--workpath", str(repo_root / "build"), "--specpath", str(repo_root / "build"), *icon_arg, - str(script), ] - print(f"Building {name}...") - print(f" Command: {' '.join(cmd)}\n") - result = subprocess.run(cmd) + if sys.platform == "darwin": + cmd += ["--target-arch", "universal2"] + + with tempfile.TemporaryDirectory(prefix="upflow2x-setup-") as staging: + payload = Path(staging) / "plugin" + for item in PAYLOAD: + _copy(repo_root / item, payload / item) + cmd += ["--add-data", f"{payload}:plugin", str(script)] + print(f"Building {name}...") + result = subprocess.run(cmd) if result.returncode == 0: - ext = ".exe" if sys.platform.startswith("win") else "" - output = repo_root / "dist" / f"{name}{ext}" + output = repo_root / "dist" / (name + ".exe" if sys.platform.startswith("win") else name) + if sys.platform == "darwin": + output = repo_root / "dist" / "Upflow2xInstaller-mac.zip" + result = subprocess.run([ + "ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", + str(repo_root / "dist" / f"{name}.app"), str(output), + ]) + if result.returncode: + return result.returncode print(f"\nBuild complete: {output}") print(f"Size: {output.stat().st_size / 1024 / 1024:.1f} MB") else: diff --git a/Installer/build_release.py b/Installer/build_release.py index ae2f4cb..3fd10dc 100644 --- a/Installer/build_release.py +++ b/Installer/build_release.py @@ -31,7 +31,8 @@ # What ships to users. Tests, release artefacts and dev tooling stay behind. PAYLOAD = ["Pipeline", "Stages", "Installer", "VERSION", "README.md", - "requirements.txt"] + "requirements.txt", "LICENSE.md"] +INSTALLERS = ("Upflow2xInstaller.exe", "Upflow2xInstaller-mac.zip") SKIP_DIRS = {"__pycache__", ".pytest_cache"} SKIP_SUFFIXES = {".pyc", ".pyo"} @@ -95,10 +96,35 @@ def verify_feed(meta: dict) -> bool: return ok +def verify_installers(version: str) -> bool: + """Check that the standalone downloads contain the binaries just built.""" + import urllib.error + import urllib.request + + for name in INSTALLERS: + url = f"https://github.com/{REPO}/releases/download/{release_tag(version)}/{name}" + try: + with urllib.request.urlopen(url, timeout=60) as response: + digest = hashlib.sha256(response.read()).hexdigest() + except (urllib.error.URLError, OSError) as exc: + print(f" {name}: FAILED, {exc}") + return False + if digest != sha256(REPO_ROOT / "dist" / name): + print(f" {name}: checksum mismatch against the published file") + return False + return True + + def publish(version: str) -> bool: """Create the GitHub release, with the tag derived from VERSION.""" tag = release_tag(version) archives = [str(REPO_ROOT / "release" / f"{name}.zip") for name in PLATFORMS] + installers = [REPO_ROOT / "dist" / name for name in INSTALLERS] + missing = [path.name for path in installers if not path.is_file()] + if missing: + print(" Missing standalone installers: " + ", ".join(missing)) + return False + archives.extend(str(path) for path in installers) existing = subprocess.run(["gh", "release", "view", tag, "--repo", REPO], capture_output=True, text=True) if existing.returncode == 0: @@ -241,7 +267,7 @@ def main() -> int: if not publish(version): return 1 print("Verifying the published downloads") - if not verify_feed(meta): + if not verify_feed(meta) or not verify_installers(version): print("The published downloads do not match this build, so " "update/latest.json was left untouched. If shipped files changed " f"since {tag} went out, bump VERSION and release again.") diff --git a/Installer/gui_installer.py b/Installer/gui_installer.py index 6ed93de..cd37f3e 100644 --- a/Installer/gui_installer.py +++ b/Installer/gui_installer.py @@ -2,8 +2,8 @@ Upflow2x GUI Installer Run it with `python Installer/gui_installer.py`, or compile it with -Installer/build_installer.py. When it runs as a compiled app and finds no -system Python, it downloads a private portable Python into the plugin folder. +Installer/build_installer.py. The compiled app installs its bundled plugin +files and a private Python into the user's application data folder. That works on both Windows (official embeddable package) and macOS (python-build-standalone). """ @@ -88,16 +88,28 @@ def _find_repo_root() -> Path: if getattr(sys, "frozen", False): - here = Path(sys.executable).resolve().parent - # A macOS app runs from Upflow2xInstaller.app/Contents/MacOS, so the - # plugin folder is the one holding the bundle, not the bundle itself. - for parent in (here, *here.parents): - if parent.suffix == ".app": - return parent.parent - return here + return Path(sys._MEIPASS) / "plugin" return Path(__file__).resolve().parent.parent +def _install_dir() -> Path: + if sys.platform.startswith("win"): + return Path(os.environ["LOCALAPPDATA"]) / "Upflow2x" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "Upflow2x" + return Path.home() / ".local" / "share" / "Upflow2x" + + +def _copy_plugin(source: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + for item in source.iterdir(): + if item.is_dir(): + shutil.copytree(item, destination / item.name, dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + else: + shutil.copy2(item, destination / item.name) + + def _read_version(repo_root: Path) -> str: vf = repo_root / "VERSION" if vf.exists(): @@ -108,7 +120,7 @@ def _read_version(repo_root: Path) -> str: def _check_python(path: str) -> bool: try: result = subprocess.run( - [path, "--version"], + [path, "-c", "import sys; sys.exit(not ((3, 10) <= sys.version_info[:2] < (3, 15)))"], capture_output=True, text=True, timeout=10, **_NO_WINDOW, ) return result.returncode == 0 @@ -166,7 +178,10 @@ def _find_embedded_python(repo_root: Path) -> str | None: except OSError: pass if exe.exists() and _check_python(str(exe)): - return str(exe) + result = subprocess.run([str(exe), "-m", "pip", "--version"], + capture_output=True, text=True, timeout=10, **_NO_WINDOW) + if result.returncode == 0: + return str(exe) return None @@ -261,8 +276,9 @@ def __init__(self): sh = self.root.winfo_screenheight() self.root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - self.repo_root = _find_repo_root() - self.version = _read_version(self.repo_root) + self.source_root = _find_repo_root() + self.repo_root = _install_dir() if getattr(sys, "frozen", False) else self.source_root + self.version = _read_version(self.source_root) self.installing = False self.install_done = False self.python_path: str | None = None @@ -364,7 +380,8 @@ def _build_ui(self): self.close_btn.pack(side="left", padx=8) self._log("Ready to install Upflow2x.", "accent") - self._log("Click Install to begin.\n", "dim") + self._log("Click Install. Setup handles Python and the plugin for you.", "dim") + self._log("Keep your internet connection on until setup finishes.\n", "dim") # ---- Logging / progress ----------------------------------------------- @@ -564,7 +581,7 @@ def _setup_embedded_python(self) -> str | None: # ---- Package installation --------------------------------------------- def _install_packages(self, python_path: str) -> bool: - req_file = self.repo_root / "requirements.txt" + req_file = self.source_root / "requirements.txt" if req_file.exists(): pip_cmd = [python_path, "-m", "pip", "install", "-r", str(req_file), "--no-warn-script-location", "--quiet"] @@ -595,10 +612,10 @@ def _do_install(self): self.root.after(0, self._set_progress, 5, "Checking files...") required = [ - self.repo_root / "Installer" / "Eternal2xLauncher.lua", - self.repo_root / "Installer" / "Eternal2x.lua", - self.repo_root / "Stages" / "resolve_smooth.py", - self.repo_root / "Pipeline" / "config.py", + self.source_root / "Installer" / "Eternal2xLauncher.lua", + self.source_root / "Installer" / "Eternal2x.lua", + self.source_root / "Stages" / "resolve_smooth.py", + self.source_root / "Pipeline" / "config.py", ] missing = [str(p) for p in required if not p.exists()] if missing: @@ -621,7 +638,7 @@ def _do_install(self): if python_path: self.root.after(0, self._log, f" Using bundled Python: {_python_version_str(python_path)}", "ok") - else: + elif not getattr(sys, "frozen", False): python_path = _find_system_python() python_source = "system" @@ -630,7 +647,6 @@ def _do_install(self): f" Found: {_python_version_str(python_path)}", "ok") if not python_path: - self.root.after(0, self._log, " Python not found on this system.", "dim") self.root.after(0, self._log, " Setting up a private Python for the plugin...", "accent") python_path = self._setup_embedded_python() @@ -653,6 +669,10 @@ def _do_install(self): self.root.after(0, self._set_progress, 75, "") self.root.after(0, self._log, " Packages installed.", "ok") + if self.source_root != self.repo_root: + self.root.after(0, self._log, "\nInstalling plugin files...") + _copy_plugin(self.source_root, self.repo_root) + # --- Step 4: Copy launcher into Resolve --- self.root.after(0, self._log, "\nInstalling plugin into DaVinci Resolve...") self.root.after(0, self._set_progress, 80, "Copying plugin files...") @@ -690,9 +710,7 @@ def _do_install(self): if python_source == "embedded": self.root.after(0, self._log, "") self.root.after(0, self._log, - "Note: A private Python was installed in the plugin", "dim") - self.root.after(0, self._log, - "folder. Do not delete the 'python' subfolder.", "dim") + "Python is set up for Upflow2x. No other setup is needed.", "dim") self.install_done = True self.root.after(0, lambda: self.install_btn.configure( @@ -705,6 +723,10 @@ def run(self): def main(): app = InstallerApp() + if getattr(sys, "frozen", False) and sys.platform.startswith("win"): + # External Python must load its own DLLs, not the installer's copies. + import ctypes + ctypes.windll.kernel32.SetDllDirectoryW(None) app.run() diff --git a/README.md b/README.md index 07ee45e..b49f4a6 100644 --- a/README.md +++ b/README.md @@ -64,29 +64,35 @@ which is where retimers tear, melt and double lines. - **DaVinci Resolve Studio** 18 or newer. Since 19.1 the free edition blocks script panels like this one, so it cannot open Upflow2x. Without a GPU, the 2x upscale falls back to Super Scale, which is Studio only too. -- Python 3.10 to 3.14 on your PATH, from - [python.org](https://www.python.org/downloads/latest/python3.14/). 3.15 is too - new for the AI runtime, and on an Intel Mac it stops at 3.13. +- An internet connection during setup and the first render. Setup installs + a private Python for Upflow2x automatically. ## Install ### With the installer window -1. Extract the download somewhere permanent. -2. From the folder that contains `Installer`, run `python Installer/gui_installer.py` - (`python3` on macOS) and click **Install**. -3. It installs `numpy` and `opencv-python`, copies the plugin into Resolve's - scripts folder and writes the config. +1. Download `Upflow2xInstaller.exe` on Windows, or extract + `Upflow2xInstaller-mac.zip` and open `Upflow2xInstaller.app` on a Mac. +2. Click **Install** and wait for setup to finish. No separate Python download + or terminal commands are needed. +3. Setup stores the plugin and its private Python in `%LOCALAPPDATA%\Upflow2x` + on Windows, or `~/Library/Application Support/Upflow2x` on a Mac. The + downloaded installer can be deleted afterwards. 4. Restart Resolve and open **Workspace → Scripts → Upflow2x**. Up to v0.4.0 the plugin was called Eternal2x, and those releases list it as **Workspace → Scripts → Eternal2x**. -If the window does not open, your Python was built without Tk. Use the manual -install below, which does the same job. +Standalone installers ship with v0.5.1 and newer. Older releases and source +ZIPs use the manual install below. ### Manual +Extract the source ZIP somewhere permanent. This method requires Python +3.10 to 3.14 from [python.org](https://www.python.org/downloads/). On an Intel +Mac use at most 3.13, and on macOS 12 use at most 3.12. From the folder that +contains `Installer`, run: + ``` python -m pip install numpy opencv-python python Installer/install_upflow2x.py @@ -104,8 +110,8 @@ under Workspace → Scripts on every page: Earlier versions used the Comp folder, which Resolve only lists on the Fusion page. Re-running the installer moves it. -The config records where the plugin folder is, so re-run the installer if you -move it. +For manual installs, the config records where the extracted folder is, so +re-run the installer if you move it. ## Using it @@ -264,11 +270,18 @@ drift apart. To build locally without publishing: ``` +python -m pip install pyinstaller==6.22.3 +python Installer/build_installer.py # build this platform's standalone setup python Installer/build_release.py # build the folders and zips python Installer/build_release.py --publish # build, tag, upload, verify python Installer/build_release.py --verify # check the live feed is sound ``` +Publishing requires both `dist/Upflow2xInstaller.exe` and +`dist/Upflow2xInstaller-mac.zip`. The release workflow builds each on its native +platform before publishing. The Mac build uses a universal Python and produces +one app for Apple Silicon and Intel. Source ZIPs remain the update packages. + `update/latest.json` always describes the release that is live, so only `--publish` writes it, and only once the uploaded files match the build. A plain build never touches it. Files already on a release are never replaced. diff --git a/VERSION b/VERSION index 8f0916f..4b9fcbe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.5.1 diff --git a/docs/download.html b/docs/download.html index ea5004b..4b23dbd 100644 --- a/docs/download.html +++ b/docs/download.html @@ -43,7 +43,7 @@

Download

-

Each zip holds the panel, the Python pipeline, the upscaler and the installer. The in-between model isn't in it. The first render downloads it, about 50 MB, and checks it against its SHA-256.

+

Open setup and click Install. It installs Upflow2x and its own Python automatically. Keep your internet connection on while setup runs. The first render downloads the in-between model, about 50 MB.

@@ -57,10 +57,10 @@

Windows

Latest - - upflow2x-win.zip + Upflow2xInstaller.exe

Draws the in-betweens and the 2x upscale on the GPU through DirectML where the card supports it. Without one, the in-betweens run on the CPU and Super Scale upscales.

@@ -72,15 +72,17 @@

macOS

Latest - - upflow2x-mac.zip + Upflow2xInstaller-mac.zip -

In-betweens on the CPU, upscale with Super Scale. Python 3.14 needs macOS 14 and Apple Silicon. The Mac guide lists the rest.

+

Extract the zip and open Upflow2xInstaller.app. The same app works on Apple Silicon and Intel. In-betweens run on the CPU, and Super Scale upscales.

+

Standalone setup starts with v0.5.1. For v0.5.0 and earlier, use the manual install guide.

+

Upflow2x is free. If it works for you, a star on GitHub helps other editors find it.

@@ -90,16 +92,16 @@

macOS

Install in three steps

-

You need DaVinci Resolve Studio 18 or newer and Python 3.10 to 3.14. Python 3.15 is too new for the AI runtime. The help page walks through every click.

+

You need DaVinci Resolve Studio 18 or newer. Setup handles Python. The help page walks through every click.

  1. -

    Extract

    -

    Unzip it somewhere permanent, not your Downloads folder. The panel remembers where it lives, so moving it later means running the installer again.

    +

    Open setup

    +

    Open the Windows installer, or extract the Mac zip and open the app inside.

  2. Install

    -

    From the folder that contains Installer, run python Installer/gui_installer.py, or python3 on macOS, and click Install.

    +

    Click Install. Setup puts the plugin and its private Python in your application data folder. You can delete the download afterwards.

  3. Open

    @@ -140,7 +142,7 @@

    Older versions