diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d21a43..798d618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ["3.9", "3.12"] + python: ["3.10", "3.12"] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 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/Eternal2x.lua b/Installer/Eternal2x.lua index cf02183..9a87ae4 100644 --- a/Installer/Eternal2x.lua +++ b/Installer/Eternal2x.lua @@ -14,6 +14,11 @@ end local function trim_trailing_sep(path) if not path then return "" end + -- A drive or the disk's root keeps its separator. D: alone means the + -- folder that drive was last in, and / trimmed is nothing at all. + if path:match("^%a:[/\\]+$") or path:match("^[/\\]+$") then + return path:sub(1, path:match("^%a:") and 3 or 1) + end local out = path:gsub("[/\\]+$", "") return out end @@ -52,7 +57,10 @@ end local function shell_quote(s) if not s then return "" end if is_windows() then - return '"' .. s:gsub('"', '""') .. '"' + -- A backslash before the closing quote escapes it, so a folder like + -- D:\Cache\ ran on into the next argument. Doubled, it stays one. + local body = s:gsub('"', '""'):gsub("(\\+)$", "%1%1") + return '"' .. body .. '"' end -- Single quotes, so $, backticks and backslashes in a path stay literal. -- Inside double quotes the shell would expand `$HOME` in a file name, or @@ -934,10 +942,18 @@ local TIPS = { upscale = "Draw every frame at 2x with Real-ESRGAN's anime model, on the GPU on Windows. Without a GPU, Resolve's Super Scale does it.\nIt shows on a timeline bigger than the clip.", interp = "Draw the missing in-betweens with the RIFE network, on the GPU on Windows. Its first use downloads about 50 MB.\nThe render plays at a third of the clip's speed, ready to time remap.\nOff keeps every drawing on its frame and the clip's length, and draws nothing, to upscale only.", format = "Image sequence is lossless and imports most reliably. MP4 is one small file but lossy, and MKV is one lossless file. All are 8-bit RGB.", + -- The same list as Installer/install_upflow2x.py's REQUIREMENTS. + needs = "Upflow2x needs:\n" + .. "DaVinci Resolve Studio 18 or newer. The free edition blocks script panels.\n" + .. "Windows 10 or 11, or macOS 12 or newer.\n" + .. "A DirectX 12 GPU on Windows draws and upscales. Without one, and on a Mac, " + .. "the in-betweens run on the CPU and Super Scale upscales.\n" + .. "An internet connection for setup and the first render, which downloads about 50 MB.", + folder = "Where renders are saved, a folder for each project. By default that's an Upflow2x folder beside the project's Resolve cache, or Resolve's own folder. Choose a folder to keep them somewhere else, like a drive with more space.", } -local function info(tip) - return ui:Label{ Text = "i", ToolTip = tip, StyleSheet = INFO, Weight = 0, +local function info(tip, id) + return ui:Label{ ID = id, Text = "i", ToolTip = tip, StyleSheet = INFO, Weight = 0, Alignment = { AlignCenter = true }, MinimumSize = { 16, 16 }, MaximumSize = { 16, 16 } } end @@ -971,6 +987,7 @@ local win = disp:AddWindow({ ui:Label{ ID = "SubTitle", Text = "v" .. CURRENT_VERSION, StyleSheet = VERSION, Weight = 0, Alignment = { AlignRight = true, AlignVCenter = true } }, ui:Button{ ID = "UpdateBtn", Text = "Check for Updates", StyleSheet = LINK, Weight = 0 }, + info(TIPS.needs, "NeedsInfo"), }, ui:Label{ ID = "SourceLabel", Text = "No clip under the playhead.", StyleSheet = CARD, @@ -995,6 +1012,7 @@ local win = disp:AddWindow({ Checked = DO_INTERPOLATE, StyleSheet = TOGGLE, ToolTip = TIPS.interp }, }, row("Output", ui:ComboBox{ ID = "FormatCombo", Weight = 0.8 }, TIPS.format), + row("Save to", ui:ComboBox{ ID = "FolderCombo", Weight = 0.8 }, TIPS.folder), ui:Button{ ID = "SmoothBtn", Text = RENDER_LABEL, StyleSheet = START, ToolTip = TIPS.render, MinimumSize = { 0, 44 }, MaximumSize = { 16777215, 44 } }, @@ -1026,6 +1044,22 @@ local items = win:GetItems() for _, label in ipairs(FORMAT_LABELS) do items.FormatCombo:AddItem(label) end items.FormatCombo.CurrentIndex = index_of(FORMATS, FORMAT) +-- Save to: Resolve's folder, the folder chosen last, and a way to choose one. +-- Refilling it changes the selection, which is not a choice. +local FILLING_FOLDERS = false +local function fill_folders() + FILLING_FOLDERS = true + items.FolderCombo:Clear() + items.FolderCombo:AddItem("Resolve's folder") + if OUTPUT_DIR ~= "" then items.FolderCombo:AddItem(short_path(OUTPUT_DIR, 30)) end + items.FolderCombo:AddItem("Choose a folder...") + items.FolderCombo.CurrentIndex = OUTPUT_DIR ~= "" and 1 or 0 + items.FolderCombo.ToolTip = OUTPUT_DIR ~= "" and OUTPUT_DIR + or "Beside the project's Resolve cache, or in Resolve's own folder." + FILLING_FOLDERS = false +end +fill_folders() + -- --------------------------------------------------------------------------- -- state -- --------------------------------------------------------------------------- @@ -1207,6 +1241,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 +1285,16 @@ 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 + -- Renders go in a folder per project: in the folder chosen under Save to, + -- or in one Upflow2x folder beside Resolve's cache, 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 OUTPUT_DIR ~= "" then + args = args .. " --output-dir " .. shell_quote(OUTPUT_DIR) + elseif cache ~= "" then + args = args .. " --cache-dir " .. shell_quote(cache) + end args = args .. " --status-file " .. shell_quote(STATUS_PATH) return args .. (extra or "") end @@ -1916,6 +1973,30 @@ function win.On.FormatCombo.CurrentIndexChanged(ev) save_conf() end +function win.On.FolderCombo.CurrentIndexChanged(ev) + if FILLING_FOLDERS then return end + local index = items.FolderCombo.CurrentIndex or 0 + -- Showing the folder already in use is no choice. Resolve may deliver + -- the refill's own events after it, and refilling for them would loop. + if index == (OUTPUT_DIR ~= "" and 1 or 0) then return end + if index == 0 then + OUTPUT_DIR = "" + elseif index == (OUTPUT_DIR ~= "" and 2 or 1) then + -- Fusion's folder dialog, which Resolve's scripts share. Cancelled, + -- it hands back nothing and the folder stays as it was. + local ok, chosen = pcall(function() + return global("fu"):RequestDir(OUTPUT_DIR ~= "" and OUTPUT_DIR or nil) + end) + if ok and type(chosen) == "string" and chosen ~= "" then + OUTPUT_DIR = trim_trailing_sep(chosen) + elseif not ok then + set_status("Resolve could not open a folder dialog here.") + end + end + save_conf() + fill_folders() +end + function win.On.UpscaleCB.Clicked(ev) DO_UPSCALE = items.UpscaleCB.Checked save_conf() 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..29b104e 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). """ @@ -29,11 +29,13 @@ # root is not on the path, so fall back to importing the sibling module. try: from Installer.install_upflow2x import ( - _resolve_scripts_dir, remove_old_install, write_config) + REQUIREMENTS, _resolve_scripts_dir, _resolve_scripts_root, remove_old_install, + write_config) except ImportError: # pragma: no cover - depends on how this was launched sys.path.insert(0, str(Path(__file__).resolve().parent)) from install_upflow2x import ( - _resolve_scripts_dir, remove_old_install, write_config) + REQUIREMENTS, _resolve_scripts_dir, _resolve_scripts_root, remove_old_install, + write_config) # --------------------------------------------------------------------------- # Python download sources @@ -88,16 +90,27 @@ 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: + """DaVinci Resolve's own folder for this user, beside Fusion and not in + its Scripts folder, which Resolve would list every file of as a script.""" + # Support/Fusion/Scripts on Windows, Fusion/Scripts on a Mac. + return _resolve_scripts_root().parent.parent / "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 +121,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 +179,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 +277,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 @@ -363,8 +380,13 @@ 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("Upflow2x needs:", "accent") + for line in REQUIREMENTS: + self._log(f"• {line}", "dim") + self._log("\nClick Install. Setup handles Python and the plugin for you.", "dim") + self._log("Keep your internet connection on until setup finishes.\n", "dim") + # The list comes first, however many lines it wraps to. + self.log_text.see("1.0") # ---- Logging / progress ----------------------------------------------- @@ -564,7 +586,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 +617,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 +643,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 +652,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 +674,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 +715,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 +728,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/Installer/install_upflow2x.py b/Installer/install_upflow2x.py index e20f022..f4061b3 100644 --- a/Installer/install_upflow2x.py +++ b/Installer/install_upflow2x.py @@ -10,6 +10,23 @@ "sidebandstudio/Upflow2x/main/update/latest.json" ) +# What Upflow2x needs, as the download page lists it. Both installers show it +# before installing, and the panel shows it on its (i). +REQUIREMENTS = ( + "DaVinci Resolve Studio 18 or newer. The free edition blocks script panels.", + "Windows 10 or 11, or macOS 12 or newer.", + "A DirectX 12 GPU on Windows draws and upscales. Without one, and on a Mac, " + "the in-betweens run on the CPU and Super Scale upscales.", + "An internet connection for setup and the first render, which downloads about 50 MB.", +) + +# Setup brings its own Python. This installer runs on yours, so it needs one +# ONNX Runtime has a build for. +MANUAL_PYTHON = ( + "Python 3.10 to 3.14. An Intel Mac or macOS 13 needs 3.13 or older, and macOS 12 " + "needs 3.12 or older." +) + def _resolve_scripts_root() -> Path: """Resolve's per-user Fusion/Scripts folder, as Blackmagic documents it.""" @@ -165,6 +182,11 @@ def main() -> int: repo_root = Path(__file__).resolve().parent.parent src_lua = repo_root / "Installer" / "Eternal2xLauncher.lua" + print("Upflow2x needs:") + for line in (*REQUIREMENTS, MANUAL_PYTHON): + print(f" - {line}") + print() + if not src_lua.exists(): print(f"Missing launcher script: {src_lua}") return 1 diff --git a/Pipeline/render.py b/Pipeline/render.py index 2aab97e..7f9a60a 100644 --- a/Pipeline/render.py +++ b/Pipeline/render.py @@ -369,10 +369,21 @@ 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)) +# OpenCV's threads for each of them. Eight workers each spreading every +# remap over all 32 threads of a 5950X slowed the thread feeding the GPU: a +# render took 34.6 s that took 31.6 s with two each. +CV_THREADS = max(1, (os.cpu_count() or 2) // (2 * WORKERS)) + + +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 +480,12 @@ 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() + threads = cv2.getNumThreads() + cv2.setNumThreads(CV_THREADS) + 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,43 +500,67 @@ 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, "last_in": None, "last_out": None} + state = {"writer": None, "written": 0, "flushed": 0, "width": 0, "height": 0, + "upscaler": upscaler, "upscale_note": upscale_note} - 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 - 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 " + 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']}.") - 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"])) + 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: - if state["writer"] is None: + # 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) + + 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, 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 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: @@ -534,7 +571,12 @@ def emit(frame: np.ndarray) -> 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 @@ -600,14 +642,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, pool=pool) 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() @@ -618,14 +665,15 @@ 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) + cv2.setNumThreads(threads) 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 7ed71ea..f44186b 100644 --- a/Pipeline/rife.py +++ b/Pipeline/rife.py @@ -21,8 +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 @@ -52,6 +54,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 +188,91 @@ 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: + 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 +281,17 @@ 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) + # 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() @@ -222,11 +323,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, names: Optional[List[str]] = None) -> list: try: - return self.session.run(None, {self.input: x})[0] + return self.session.run(names, {self.input: x}) except Exception: # onnxruntime raises its own exception types if not self.on_gpu: raise @@ -234,30 +335,54 @@ 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] - - def _run(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + 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). 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 - 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) + 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, 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, 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) 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"{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.") + 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. + 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) -> List[np.ndarray]: + unrelated: Optional[bool] = None, + 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 @@ -267,32 +392,56 @@ 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. With `pool` too, those in-betweens come back + as Futures drawn there, so the network starts on the next meanwhile. """ 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, pool)) return out - def _between(self, a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray: + 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: + 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, pool=None): """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 +449,12 @@ 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) + # 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 (known[lo] if local < 0.5 else known[hi]).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._run(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/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/README.md b/README.md index 07ee45e..d62e79f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ holds for three times its length. - Draws the in-between frames that were never drawn with the RIFE network, run on your own machine, on the GPU on Windows wherever DirectML loads. Moving characters stay in one piece. -- Makes no in-between by warping pixels, which doubles lines and smears fast +- Never falls back to plain optical flow, which doubles lines and smears fast characters on drawn animation. Where the network cannot run, the drawings are kept as drawn. - Keeps deliberate held poses still, instead of smearing them into drift. @@ -50,9 +50,10 @@ holds for three times its length. shots it snaps instead. A cut right beside another cut or very fast motion can still blend for a frame, so check montages. On a marked clip it checks nothing and draws every gap, so mark the last frame before a cut as well. -- Upscales 2x with Real-ESRGAN's anime model, on the GPU on Windows. It clears - compression blocks and keeps thin lines crisp, where Super Scale enlarges - the blocks. Without a GPU, Resolve's Super Scale does the upscale. +- Upscales each drawing 2x with Real-ESRGAN's anime model, on the GPU on + Windows, then draws the in-betweens at 2x from the upscaled drawings. It + clears compression blocks and keeps thin lines crisp, where Super Scale + enlarges the blocks. Without a GPU, Resolve's Super Scale does the upscale. It does a job like Twixtor's, but finds the held drawings first, so it only generates the frames that were never drawn. It draws them with a network @@ -64,29 +65,45 @@ 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. +- Windows 10 or 11, or macOS 12 or newer. +- A DirectX 12 GPU on Windows draws the in-betweens and the 2x upscale. Without + one, and on a Mac, the in-betweens run on the CPU and Super Scale upscales. +- An internet connection during setup and the first render, which downloads + about 50 MB. Setup installs a private Python for Upflow2x automatically. + +The installer window lists these before it installs anything, and the (i) +at the top of the panel shows them again. ## 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. + Setup isn't signed yet. If Windows says it protected your PC, click + **More info**, then **Run anyway**. If macOS says it can't check the app, + open System Settings → Privacy & Security and click **Open Anyway**. +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 DaVinci Resolve's own + folder, `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Upflow2x` on Windows or + `~/Library/Application Support/Blackmagic Design/DaVinci Resolve/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/). An Intel Mac +or macOS 13 needs 3.13 or older, and macOS 12 needs 3.12 or older. From the +folder that contains `Installer`, run: + ``` python -m pip install numpy opencv-python python Installer/install_upflow2x.py @@ -104,8 +121,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 @@ -156,6 +173,30 @@ Then: original to line up with. - **Show in Folder** opens the render's location. +Renders go in an `Upflow2x` folder beside the project's cache folder, with a +folder for each project. The cache folder is the Cache files location in +Project Settings → Master Settings → Working Folders. With the cache in +`D:\Resolve\CacheClip`, a project called Episode 1 renders to +`D:\Resolve\Upflow2x\Episode 1`. If the project has no cache folder, its +drive is unplugged or read only, or it is in OneDrive, renders go in +Resolve's own folder instead, again a folder for each project: +`%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Upflow2x\Renders` on Windows, +or `~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Upflow2x/Renders` +on a Mac. Show in Folder opens it. Up to v0.5.0 each render was saved beside its +clip, and those stay where they are. A render you stop, or one that fails, is +deleted, since it never goes into Resolve. A finished one is never deleted, +because your timeline uses it. + +To keep renders somewhere else, like a drive with more space, open **Save to** +under Output and pick **Choose a folder...**. Renders then go there, still a +folder for each project, until you pick **Resolve's folder** again. If that +folder can't be written to, for example because its drive is unplugged, the +render stops and says so. + +A 4K image sequence takes about 6 MB a frame, so a few seconds of footage can +fill a gigabyte. Upflow2x never deletes a finished render, so delete a +project's folder once its renders are off your timelines. + Only the part of the file the timeline clip uses is analysed and rebuilt. To reach it, the file is still decoded from its start, once for Analyse and again for the render, so a short cut late in a long episode shows Seeking for a @@ -171,8 +212,8 @@ pointer on one to see what it does. |---|---| | **Threshold** | How big a change Analyse counts as a new drawing, 1 to 9. Lower catches small moves, like only a mouth or a slow drift. Higher counts only big ones, like the whole shot moving. It does nothing to frames you have marked. | | **Output** | `Image sequence` is lossless and the most reliable to import. `MP4` is a single compact file but lossy. `MKV` is a lossless single file. All three are 8-bit RGB: transparency and 10-bit colour in the source are not kept. MP4 and MKV need an even width and height. | -| **Upscaling** | Draws every frame at 2x with Real-ESRGAN's anime video model, on the GPU through DirectML, about 0.14 s a 1080p frame on a Radeon. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 to 7 MB, four to five times its 1080p size. | -| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, about 0.13 s an in-between at 2K on a Radeon, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. With Upscaling on, that only upscales. | +| **Upscaling** | Upscales each drawing 2x with Real-ESRGAN's anime video model, on the GPU through DirectML. The in-betweens are then drawn at 2x from the upscaled drawings, so the model runs once a drawing rather than once a frame. The model ships with Upflow2x. Without a GPU, on a Mac for one, Resolve's Super Scale is applied to the imported clip instead. Either way it only shows on a timeline bigger than the clip, such as 3840x2160 for 1080p footage. A 2x render has four times the pixels. As an image sequence, a 4K frame of anime is about 6 MB, four to five times its 1080p size. | +| **Interpolate** | On, the RIFE network draws every missing in-between. On Windows it runs on the GPU through DirectML, and falls back to the CPU, about ten times slower. If its Python already has ONNX Runtime without DirectML, that one is used, on the CPU. Its first run installs ONNX Runtime and downloads the model, about 50 MB in all, so it needs a connection once; without one, every drawing is kept as drawn and the panel says why. On, the render is three times longer, so it takes three times as long to render and an image sequence takes three times the disk. Off skips the in-betweens, so every drawing comes back on its original frame and the render keeps the clip's length. With Upscaling on, that only upscales. | Settings are remembered between sessions. @@ -200,8 +241,13 @@ python -m Stages.resolve_smooth --video shot.mov --base-hold 3 --no-interpolate python -m Stages.resolve_smooth --video grainy.mov --grain ``` -`--video` skips Resolve entirely and writes the result to an `Upflow2x` folder -beside the source, or to `--output-dir`. Add `--network-upscale` and, where +`--video` skips Resolve entirely and writes the result to `--output-dir`. Without +it, the result goes in an `Upflow2x` folder beside `--cache-dir`, Resolve's cache +folder, in a folder named by `--project`. With no cache folder, or one in +OneDrive, it goes in Resolve's own folder instead, +`%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Upflow2x\Renders` on Windows or +`~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Upflow2x/Renders` on +a Mac. The panel passes the project and cache folder itself. Add `--network-upscale` and, where Real-ESRGAN runs on a GPU, the render is 2x. Without it, or without a GPU, the 2x upscale is Resolve's Super Scale, applied when the panel imports the result, so a `--video` run stays at source resolution. @@ -222,16 +268,17 @@ afterwards. | Problem | Fix | |---|---| | Resolve says you need Studio | Since 19.1 the free edition blocks script panels. Upflow2x needs Studio. | -| Clicking Upflow2x does nothing, or "Plugin folder not configured" | The plugin folder moved after installing, and Workspace → Console says `UI script not found`. Re-run `python Installer/install_upflow2x.py` from where it is now. | +| Clicking Upflow2x does nothing, or "Plugin folder not configured" | The plugin folder moved or was deleted after installing, and Workspace → Console says `UI script not found`. Run the installer again. After a manual install, re-run `python Installer/install_upflow2x.py` from where the folder is now. | | "Could not reach Resolve" | Open the panel from Workspace → Scripts, not from a terminal. | | "No clip under the playhead" | Move the playhead over the clip, and the panel picks it up. | | "No duplicate frames. This clip is already on 1s." | The clip has no duplicated frames. It is live action, or already on 1s. | | "Every frame differs, but the differences look like grain" | Mark the drawings with M, then press **Render**. From the command line, `--grain` raises the threshold above the grain. | | "...has a variable frame rate" | Render the clip to a constant frame rate first, from Resolve or with ffmpeg, as the message says. | +| Rendering is slow | The time grows with the length of the clip on your timeline, and only the part it uses is rebuilt, so trimming helps. On a Radeon RX 7900 XT, under three seconds of 1080p, upscaled and slowed to 190 frames at 4K, takes about 33 seconds. Without a GPU the in-betweens are drawn on the CPU, about ten times slower, and Super Scale upscales. | | Progress seems stuck | Press **Refresh Progress**. If Python stopped, the panel says so and shows its last error. If a run was interrupted, press **Reset**. | | Resolve would not import the render | The render still finished. The panel prints where it is, so drag it into your media pool. Switching Output to `Image sequence` usually avoids this. | | Upscale skipped | Without a GPU the upscale is Super Scale, which needs Resolve Studio. | -| Something else | Email the panel's status text and the `.upflow2x_last_run.log` file from the plugin folder. | +| Something else | Email the panel's status text and the `.upflow2x_last_run.log` file from the plugin folder. Setup puts it in `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Upflow2x` on Windows or `~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Upflow2x` on a Mac. A manual install, or one updated from 0.5.0 or earlier, keeps the folder it was installed in. | ## Development @@ -239,11 +286,11 @@ afterwards. ./test.sh ``` -360+ tests, no Resolve required. The suite fakes the Resolve scripting API and +800+ tests, no Resolve required. The suite fakes the Resolve scripting API and runs the Lua panel under an embedded Lua runtime, so buttons, commands and the full render pipeline are all covered headlessly. See `tests/README.md`. -Tests run on Linux, macOS and Windows against Python 3.9 and 3.12. +Tests run on Linux, macOS and Windows against Python 3.10 and 3.12. To see which scripting API methods a Resolve build offers, turn on Preferences → System → General → External scripting using → Local, set up @@ -253,7 +300,7 @@ the environment variables from Blackmagic's scripting README, then run To cut a release, bump `VERSION` and push a matching tag: ``` -git tag v0.5.0 && git push origin v0.5.0 +git tag v0.5.1 && git push origin v0.5.1 ``` The release workflow builds the archives, publishes them, checks the published @@ -264,11 +311,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/Stages/resolve_smooth.py b/Stages/resolve_smooth.py index 428b88d..a635cd3 100644 --- a/Stages/resolve_smooth.py +++ b/Stages/resolve_smooth.py @@ -10,7 +10,10 @@ import argparse import contextlib import json +import os +import shutil import sys +import tempfile import time import traceback from pathlib import Path @@ -18,6 +21,8 @@ # numpy and OpenCV load through these. A Python without them must still be # able to tell the panel so, or the panel waits on a run that never started. +from Installer.install_upflow2x import _resolve_scripts_root + try: from Pipeline import resolve_bridge as bridge from Pipeline.config import Upflow2xConfig @@ -170,10 +175,73 @@ def _finished(plan, result) -> str: return message +_DEVICES = {"CON", "PRN", "AUX", "NUL"} | {f"{port}{n}" for port in ("COM", "LPT") + for n in range(1, 10)} + + +def _in_onedrive(path: Path) -> bool: + """Whether OneDrive syncs `path`, from the folders it sets in the environment. + Both are resolved first, so a short 8.3 name or a .. cannot hide it.""" + path = path.resolve() + roots = [os.environ.get(key) for key in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial")] + return any(root and (path == Path(root).resolve() or Path(root).resolve() in path.parents) + for root in roots) + + +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, by default Videos\\CacheClip. Where a project has none, or it is + in OneDrive, which would upload gigabytes of frames, renders go in + Resolve's own folder, the one its scripts are installed in. + """ + if cache and Path(cache).is_absolute() and not _in_onedrive(Path(cache).parent): + base = Path(cache).parent / "Upflow2x" + else: + # Support/Fusion/Scripts on Windows, Fusion/Scripts on a Mac. Setup + # puts the plugin in its Upflow2x folder, so renders get one of their + # own there, and a project called Pipeline can't land among its code. + base = _resolve_scripts_root().parent.parent / "Upflow2x" / "Renders" + return _per_project(base, project) + + +def _per_project(base: Path, project: Optional[str]) -> Path: + """`base`, with a folder in it for the project's renders.""" + # A project name can hold anything. A folder name on Windows cannot, nor + # can it be a device's, like NUL or COM1, even with an extension. + name = "".join("_" if c in '<>:"/\\|?*' or ord(c) < 32 else c for c in project or "") + name = name.strip(" .") + if name.split(".")[0].strip().upper() in _DEVICES: + name = "_" + name + return base / name if name else base + + 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: + # A folder chosen under Save to, which gets a folder per project too. + base = _per_project(Path(output_dir), project) + try: + base.mkdir(parents=True, exist_ok=True) + tempfile.TemporaryFile(dir=base).close() + except OSError as exc: + raise SourceError(f"Upflow2x cannot save renders in {output_dir} " + f"({exc.strerror or exc}). Plug in its drive, or choose " + f"another folder under Save to.") from exc + else: + base = default_output_dir(project, cache) + try: + base.mkdir(parents=True, exist_ok=True) + # A folder that is already there can still refuse files. + tempfile.TemporaryFile(dir=base).close() + except OSError: + # The cache can be on a drive that is not plugged in, or read only. + 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. @@ -182,17 +250,30 @@ def _output_path(source: Path, output_dir: Optional[str], fmt: str, part += f"_{slow}x" stem = f"{source.stem}{part}_smooth" - def taken(path: Path) -> bool: - if not path.exists(): - return False - return any(path.iterdir()) if path.is_dir() else True - target = base / f"{stem}{suffix}" n = 1 - while taken(target): - n += 1 - target = base / f"{stem}_{n:02d}{suffix}" - return target + while True: + # Made here, so two runs at once never share a path, and a run that + # stops or fails only ever deletes its own render. + try: + if suffix: + target.open("x").close() + else: + target.mkdir() + return target + except FileExistsError: + n += 1 + target = base / f"{stem}_{n:02d}{suffix}" + + +def _discard(*renders: Path) -> None: + """Delete a render that will not be used, from the paths this run made.""" + for render in dict.fromkeys(renders): + with contextlib.suppress(OSError): + if render.is_dir(): + shutil.rmtree(render, ignore_errors=True) + else: + render.unlink(missing_ok=True) def drawing_marks(plan) -> list: @@ -230,7 +311,14 @@ 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 Resolve's own " + "folder).") + parser.add_argument("--project", default=None, + help="The Resolve project's name. 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. " @@ -433,50 +521,73 @@ def _run(args, summary: dict) -> int: status.write(done=True, ok=True, message=plan.describe()) return 0 - output = _output_path(source, args.output_dir, args.format, None if whole else frames, - cfg.slow_motion) + try: + output = _output_path(source, args.output_dir, args.format, None if whole else frames, + cfg.slow_motion, args.project, args.cache_dir) + except SourceError as exc: + print(f"Error: {exc}") + summary["error"] = str(exc) + status.write(done=True, ok=False, message=str(exc)) + return 2 print(f"Rendering to: {output}") + # A video is drawn into a file of this run's own and moved onto its name + # once done. The encoder deletes a file it cannot start, and another run + # could then take the name, and lose its render to this one's cleanup. + drawing = output if args.format == "png" else output.with_name( + f"{output.stem}.{os.getpid()}.part{output.suffix}") + # A render that stops or fails never goes into Resolve, so its frames + # would only take space. A 4K sequence is about 6 MB a frame. Once its + # import has begun it stays, since the timeline may already use it. try: - result = render_plan(source, plan, output, cfg, progress=progress, + result = render_plan(source, plan, drawing, cfg, progress=progress, sequence=(args.format == "png"), frames=frames) + # Stopped while the last frames were written: nothing goes into Resolve. + status.check_stop() + if drawing != output: + os.replace(drawing, output) + result.output = output + + print(f"Wrote {result.frames_written} frames at {result.fps:.3f} fps.") + if result.note: + print(result.note) + if result.upscaled: + print(f"Upscaled 2x to {result.width}x{result.height} with Real-ESRGAN.") + elif cfg.network_upscale: + print(f"Real-ESRGAN did not run, so Super Scale does the upscale. " + f"{result.upscale_note}") + status.upscaled = result.upscaled + status.slow_motion = cfg.slow_motion + summary["render"] = result.to_dict() + + # Resolve imports an image sequence from its first frame, not its folder. + if result.is_sequence: + frames = sorted(result.output.glob("*.png")) + status.import_path = str(frames[0]) if frames else str(result.output) + if frames: + status.sequence_pattern = str(result.output / SEQUENCE_PATTERN) + status.sequence_frames = len(frames) + status.fps = result.fps + else: + status.import_path = str(result.output) + + if resolve is None: + # The panel has its own Resolve connection and finishes the import + # from there, which avoids depending on external scripting being + # enabled. A Stop can still come while this last status waits. + print(f"Rendered to: {result.output}") + summary["ok"] = True + summary["import_path"] = status.import_path + status.write(done=True, ok=True, message=_finished(plan, result)) + return 0 except SourceError as exc: + _discard(drawing, output) print(f"Error: {exc}") summary["error"] = str(exc) status.write(done=True, ok=False, message=str(exc)) return 2 - # Stopped while the last frames were written: nothing goes into Resolve. - status.check_stop() - - print(f"Wrote {result.frames_written} frames at {result.fps:.3f} fps.") - if result.note: - print(result.note) - if result.upscaled: - print(f"Upscaled 2x to {result.width}x{result.height} with Real-ESRGAN.") - elif cfg.network_upscale: - print(f"Real-ESRGAN did not run, so Super Scale does the upscale. {result.upscale_note}") - status.upscaled = result.upscaled - status.slow_motion = cfg.slow_motion - summary["render"] = result.to_dict() - - # Resolve imports an image sequence from its first frame, not its folder. - if result.is_sequence: - frames = sorted(result.output.glob("*.png")) - status.import_path = str(frames[0]) if frames else str(result.output) - if frames: - status.sequence_pattern = str(result.output / SEQUENCE_PATTERN) - status.sequence_frames = len(frames) - status.fps = result.fps - else: - status.import_path = str(result.output) - - if resolve is None: - # The panel has its own Resolve connection and finishes the import from - # there, which avoids depending on external scripting being enabled. - print(f"Rendered to: {result.output}") - summary["ok"] = True - summary["import_path"] = status.import_path - status.write(done=True, ok=True, message=_finished(plan, result)) - return 0 + except BaseException: + _discard(drawing, output) + raise # Too late to stop from here, or the clip would be left half placed. status.committed = True 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..9822b47 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 AI runtime and the in-between model, about 50 MB in all.

@@ -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. If Windows or macOS warns about it, the Windows and Mac steps show how to open it.

  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 DaVinci Resolve's own folder. You can delete the download afterwards.

  3. Open

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

    Older versions