diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml new file mode 100644 index 0000000..36b0d60 --- /dev/null +++ b/.github/workflows/build-installers.yml @@ -0,0 +1,47 @@ +name: Build Installers + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.platform }} installer + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-latest + - platform: windows + runner: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install . pyinstaller + + - name: Build installer bundle + run: python scripts/build_installers.py --platform ${{ matrix.platform }} + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: openbeat-${{ matrix.platform }}-installer + path: | + dist/installers/*.pkg + dist/installers/*installer.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 59811f7..aee88a3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ Music for Test/ __pycache__/ *.pyc *.egg-info/ +build/ +dist/ # Generated OpenBeat outputs *.openbeat-clicks.wav diff --git a/README.md b/README.md index ce2afbf..f050429 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,19 @@ Available actions: ## Installation -### 1. Create the Python environment +### Option A (recommended): single-file installer download (no Python required) + +CI now builds platform-specific single-file installers that bundle the OpenBeat CLI runtime: + +- macOS artifact: `OpenBeat-macos-.pkg` +- Windows artifact: `OpenBeat-windows--installer.exe` + +On macOS, run the `.pkg` in Installer.app (multi-step guided setup). On Windows, run the installer `.exe` directly. +Restart Resolve after install. + +### Option B: developer install from source + +#### 1. Create the Python environment ```bash cd /Users/parker/Documents/Code/OpenBeat @@ -36,7 +48,7 @@ python3 -m venv .venv python -m pip install -e . ``` -### 2. Link the Resolve scripts +#### 2. Link the Resolve scripts ```bash cd /Users/parker/Documents/Code/OpenBeat @@ -45,6 +57,17 @@ cd /Users/parker/Documents/Code/OpenBeat Restart Resolve after linking the scripts if it is already open. +## Building installers locally + +Install build dependencies, then run: + +```bash +python -m pip install . pyinstaller +python scripts/build_installers.py --platform all +``` + +Generated installer archives are written to `dist/installers/`. + ## Outputs - Click tracks are written next to the source file: diff --git a/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua b/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua index 6a42c74..63b4a83 100644 --- a/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua +++ b/resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua @@ -1,12 +1,24 @@ local OpenBeat = {} +local function is_windows() + return package.config:sub(1, 1) == "\\" +end + +local function path_sep() + if is_windows() then + return "\\" + end + return "/" +end + local function dirname(path) - return path:match("^(.*)/[^/]+$") or "." + local normalized = tostring(path):gsub("\\", "/") + return normalized:match("^(.*)/[^/]+$") or "." end local function join_path(...) local items = { ... } - return table.concat(items, "/") + return table.concat(items, path_sep()) end local function file_exists(path) @@ -29,7 +41,11 @@ local function first_value(items) end local function shell_quote(value) - return "'" .. tostring(value):gsub("'", [['"'"']]) .. "'" + local as_string = tostring(value) + if is_windows() then + return '"' .. as_string:gsub('"', '\\"') .. '"' + end + return "'" .. as_string:gsub("'", [['"'"']]) .. "'" end local function current_script_path() @@ -66,7 +82,25 @@ local function repo_root() end local function log(message) - local path = os.getenv("HOME") .. "/Library/Application Support/Blackmagic Design/DaVinci Resolve/logs/OpenBeat.log" + local home = os.getenv("HOME") + if not home or home == "" then + home = os.getenv("USERPROFILE") + end + if not home or home == "" then + return + end + + local path + if is_windows() then + local appdata = os.getenv("APPDATA") + if appdata and appdata ~= "" then + path = join_path(appdata, "Blackmagic Design", "DaVinci Resolve", "Support", "logs", "OpenBeat.log") + else + path = join_path(home, "AppData", "Roaming", "Blackmagic Design", "DaVinci Resolve", "Support", "logs", "OpenBeat.log") + end + else + path = join_path(home, "Library", "Application Support", "Blackmagic Design", "DaVinci Resolve", "logs", "OpenBeat.log") + end local handle = io.open(path, "a") if not handle then return @@ -89,20 +123,38 @@ end local function temp_path(prefix, extension) local name = string.format("%s_%d_%d%s", prefix, os.time(), math.random(1000, 9999), extension or "") - return "/tmp/" .. name + local base = os.getenv("TMPDIR") or os.getenv("TEMP") or os.getenv("TMP") + if not base or base == "" then + base = is_windows() and "C:\\Windows\\Temp" or "/tmp" + end + return join_path(base, name) end local function python_bin() if local_config.python_bin and file_exists(local_config.python_bin) then return local_config.python_bin end - local candidate = join_path(repo_root(), ".venv", "bin", "python") - if file_exists(candidate) then - return candidate + local candidates = { + join_path(repo_root(), ".venv", "bin", "python"), + join_path(repo_root(), ".venv", "Scripts", "python.exe"), + } + for _, candidate in ipairs(candidates) do + if file_exists(candidate) then + return candidate + end end return "python3" end +local function command_prefix() + local bin = python_bin() + local lowered = string.lower(bin) + if lowered:match("python%.exe$") or lowered:match("python$") then + return shell_quote(bin) .. " -m openbeat.cli" + end + return shell_quote(bin) +end + local function project_context() local resolve_app = resolve or app:GetResolve() if not resolve_app then @@ -260,8 +312,8 @@ end local function analyze_source(source_path) local output = temp_path("openbeat_analysis", ".lua") - local final_command = shell_quote(python_bin()) - .. " -m openbeat.cli analyze --audio " + local final_command = command_prefix() + .. " analyze --audio " .. shell_quote(source_path) .. " --format lua --output " .. shell_quote(output) @@ -348,8 +400,8 @@ end local function render_click_track(source_path, mode) local suffix = mode == "raw" and ".openbeat-raw-clicks" or ".openbeat-clicks" local output = output_path_for(source_path, suffix, ".wav") - local command = shell_quote(python_bin()) - .. " -m openbeat.cli click-track --audio " + local command = command_prefix() + .. " click-track --audio " .. shell_quote(source_path) .. " --mode " .. shell_quote(mode) diff --git a/scripts/build_installers.py b/scripts/build_installers.py new file mode 100644 index 0000000..1d07ac9 --- /dev/null +++ b/scripts/build_installers.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DIST_ROOT = ROOT / "dist" +DIST_INSTALLERS = DIST_ROOT / "installers" + + +def run(cmd: list[str], cwd: Path | None = None) -> None: + subprocess.run(cmd, check=True, cwd=cwd or ROOT) + + +def clean() -> None: + DIST_INSTALLERS.mkdir(parents=True, exist_ok=True) + + +def parse_version() -> str: + pyproject = (ROOT / "pyproject.toml").read_text() + for line in pyproject.splitlines(): + if line.strip().startswith("version ="): + return line.split("=", 1)[1].strip().strip('"') + raise ValueError("Unable to find version in pyproject.toml") + + +def pyinstaller_binary(output_name: str, entry_script: Path, python_bin: str = "python") -> Path: + run( + [ + python_bin, + "-m", + "PyInstaller", + "--clean", + "--noconfirm", + "--onefile", + "--name", + output_name, + str(entry_script), + ] + ) + candidates = [DIST_ROOT / output_name, DIST_ROOT / f"{output_name}.exe"] + built = next((path for path in candidates if path.exists()), None) + if built is None: + raise FileNotFoundError(f"Expected bundled binary at one of: {candidates}") + return built + + +def build_cli_binary(python_bin: str = "python") -> Path: + return pyinstaller_binary("openbeat", ROOT / "openbeat" / "cli.py", python_bin=python_bin) + + +def copy_payload(payload_root: Path) -> None: + shutil.copytree( + ROOT / "resolve" / "Fusion" / "Scripts" / "Utility" / "OpenBeat", + payload_root / "Utility" / "OpenBeat", + dirs_exist_ok=True, + ) + shutil.copytree( + ROOT / "resolve" / "Fusion" / "Modules" / "OpenBeat", + payload_root / "Modules" / "OpenBeat", + dirs_exist_ok=True, + ) + + +def create_macos_pkg(cli_binary: Path, version: str) -> Path: + with tempfile.TemporaryDirectory(prefix="openbeat-macos-installer-") as tmp: + tmp_path = Path(tmp) + root_dir = tmp_path / "root" + payload_dir = root_dir / "payload" + copy_payload(payload_dir) + + bin_dir = payload_dir / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cli_binary, bin_dir / "openbeat") + (bin_dir / "openbeat").chmod(0o755) + + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + postinstall = scripts_dir / "postinstall" + postinstall.write_text( + """#!/usr/bin/env bash +set -euo pipefail + +PAYLOAD_ROOT="$3/private/tmp/OpenBeatInstaller/payload" +CONSOLE_USER="$(stat -f %Su /dev/console)" +if [[ -z "${CONSOLE_USER}" || "${CONSOLE_USER}" == "root" ]]; then + CONSOLE_USER="${SUDO_USER:-$USER}" +fi +USER_HOME="$(dscl . -read /Users/"${CONSOLE_USER}" NFSHomeDirectory | awk '{print $2}')" + +RESOLVE_ROOT="$USER_HOME/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion" +UTILITY_TARGET="$RESOLVE_ROOT/Scripts/Utility/OpenBeat" +MODULE_TARGET="$RESOLVE_ROOT/Modules/OpenBeat" + +mkdir -p "$RESOLVE_ROOT/Scripts/Utility" "$RESOLVE_ROOT/Modules" +rm -rf "$UTILITY_TARGET" "$MODULE_TARGET" +cp -R "$PAYLOAD_ROOT/Utility/OpenBeat" "$UTILITY_TARGET" +cp -R "$PAYLOAD_ROOT/Modules/OpenBeat" "$MODULE_TARGET" +mkdir -p "$MODULE_TARGET/bin" +cp "$PAYLOAD_ROOT/bin/openbeat" "$MODULE_TARGET/bin/openbeat" +chmod +x "$MODULE_TARGET/bin/openbeat" + +cat > "$MODULE_TARGET/OpenBeatConfig.local.lua" < Path: + output_exe = DIST_INSTALLERS / f"OpenBeat-windows-{version}-installer.exe" + with tempfile.TemporaryDirectory(prefix="openbeat-win-installer-") as tmp: + tmp_path = Path(tmp) + payload_dir = tmp_path / "payload" + copy_payload(payload_dir) + payload_bin_dir = payload_dir / "bin" + payload_bin_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cli_binary, payload_bin_dir / "openbeat.exe") + + installer_entry = tmp_path / "windows_installer.py" + installer_entry.write_text( + """from __future__ import annotations + +import shutil +import sys +from pathlib import Path + + +def resolve_payload_root() -> Path: + if hasattr(sys, '_MEIPASS'): + return Path(getattr(sys, '_MEIPASS')) / 'payload' + return Path(__file__).resolve().parent / 'payload' + + +def main() -> int: + payload = resolve_payload_root() + + appdata = Path.home() / 'AppData' / 'Roaming' + if 'APPDATA' in __import__('os').environ: + appdata = Path(__import__('os').environ['APPDATA']) + + resolve_root = appdata / 'Blackmagic Design' / 'DaVinci Resolve' / 'Support' / 'Fusion' + utility_target = resolve_root / 'Scripts' / 'Utility' / 'OpenBeat' + module_target = resolve_root / 'Modules' / 'OpenBeat' + + (resolve_root / 'Scripts' / 'Utility').mkdir(parents=True, exist_ok=True) + (resolve_root / 'Modules').mkdir(parents=True, exist_ok=True) + + if utility_target.exists(): + shutil.rmtree(utility_target) + if module_target.exists(): + shutil.rmtree(module_target) + + shutil.copytree(payload / 'Utility' / 'OpenBeat', utility_target) + shutil.copytree(payload / 'Modules' / 'OpenBeat', module_target) + (module_target / 'bin').mkdir(parents=True, exist_ok=True) + shutil.copy2(payload / 'bin' / 'openbeat.exe', module_target / 'bin' / 'openbeat.exe') + + exe_path = (module_target / 'bin' / 'openbeat.exe').as_posix() + config_text = f'return {{\\n python_bin = "{exe_path}",\\n}}\\n' + (module_target / 'OpenBeatConfig.local.lua').write_text(config_text, encoding='utf-8') + + print(f'OpenBeat installed to: {resolve_root}') + print('Restart Resolve if it is open.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) +""" + ) + + run( + [ + python_bin, + "-m", + "PyInstaller", + "--clean", + "--noconfirm", + "--onefile", + "--name", + "openbeat-installer", + "--add-data", + f"{payload_dir};payload", + str(installer_entry), + ], + cwd=tmp_path, + ) + + generated = tmp_path / "dist" / "openbeat-installer.exe" + if not generated.exists(): + raise FileNotFoundError(f"Expected installer executable at {generated}") + shutil.copy2(generated, output_exe) + + return output_exe + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build OpenBeat single-file installers") + parser.add_argument("--platform", choices=["macos", "windows", "all"], default="all") + parser.add_argument("--python", default="python", help="Python executable to run PyInstaller") + args = parser.parse_args() + + clean() + version = parse_version() + cli_binary = build_cli_binary(python_bin=args.python) + + outputs: list[Path] = [] + if args.platform in ("macos", "all"): + outputs.append(create_macos_pkg(cli_binary=cli_binary, version=version)) + + if args.platform in ("windows", "all"): + outputs.append(create_windows_exe(cli_binary=cli_binary, version=version, python_bin=args.python)) + + for output in outputs: + print(output) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())