Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable OpenBeat changes should be summarized here for users. GitHub Release
- Installer updates now prepare and verify the new files before replacing the installed ones. If an update fails or is interrupted, the previous installation is kept or restored by running the installer again.
- Release builds use one resolved commit for testing, building, and tagging, and pinned build dependencies.
- When beat analysis or click-track rendering fails, OpenBeat now shows the helper's error instead of a missing-file error, and no longer imports an older click track as if it were new.
- On Windows, OpenBeat should now work when the Windows user name or the media path contains non-English characters, such as `José` or `Música`. Subtitle export to such folders is not fixed yet.

## 0.0.2 - 2026-04-19

Expand Down
16 changes: 15 additions & 1 deletion openbeat/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import json
import sys
from dataclasses import asdict
from pathlib import Path

Expand Down Expand Up @@ -59,9 +60,22 @@ def build_parser() -> argparse.ArgumentParser:
return parser


def expand_args_file(argv: list[str]) -> list[str]:
"""Replace a leading ``--args-file PATH`` with the arguments listed in that UTF-8 JSON file.

The Resolve scripts use it on Windows, where command lines pass through the ANSI code page.
"""
if len(argv) < 2 or argv[0] != "--args-file":
return argv
listed = json.loads(Path(argv[1]).read_text(encoding="utf-8"))
if not isinstance(listed, list) or not all(isinstance(item, str) for item in listed):
raise SystemExit(f"{argv[1]} must contain a JSON list of strings")
return listed + argv[2:]


def main() -> int:
parser = build_parser()
args = parser.parse_args()
args = parser.parse_args(expand_args_file(sys.argv[1:]))
return args.func(args)


Expand Down
78 changes: 63 additions & 15 deletions resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ local function file_size(path)
end

local function python_bin()
-- Lua 5.1 on Windows opens files and starts processes through the ANSI code page, so the
-- installer also records the runtime path spelled for it when the UTF-8 path is not ASCII.
if is_windows() and local_config.python_bin_ansi and file_exists(local_config.python_bin_ansi) then
return local_config.python_bin_ansi
end
if local_config.python_bin and file_exists(local_config.python_bin) then
return local_config.python_bin
end
Expand All @@ -260,6 +265,59 @@ local function command_prefix()
return shell_quote(bin)
end

local function is_ascii(value)
return not tostring(value):find("[\128-\255]")
end

local JSON_ESCAPES = { ['"'] = '\\"', ["\\"] = "\\\\", ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" }

local function json_string(value)
local escaped = tostring(value):gsub('[%c"\\]', function(char)
return JSON_ESCAPES[char] or string.format("\\u%04x", char:byte())
end)
return '"' .. escaped .. '"'
end

local function command_arguments(values)
local quoted = {}
for _, value in ipairs(values or {}) do
-- Subcommands, options and modes stay readable in the log.
table.insert(quoted, " " .. (value:match("^[%w%-]+$") and value or shell_quote(value)))
end
return table.concat(quoted)
end

-- Runs the helper with `resolve_args`, which include text from Resolve such as media paths,
-- followed by `local_args`, which OpenBeat builds from the environment (temporary files).
-- Resolve's strings are UTF-8, but Lua 5.1 on Windows starts processes through the ANSI
-- code page, which garbles any non-ASCII character. On Windows the Resolve arguments are
-- therefore written to a UTF-8 file that the helper reads; environment-derived paths are
-- already in the ANSI code page and stay on the command line.
local function run_helper(resolve_args, local_args)
if not is_windows() then
return run_command(command_prefix() .. command_arguments(resolve_args) .. command_arguments(local_args))
end

local args_path = temp_path("openbeat_args", ".json")
local encoded = {}
for _, value in ipairs(resolve_args) do
table.insert(encoded, json_string(value))
end
local handle, open_error = io.open(args_path, "wb")
if not handle then
error("OpenBeat could not write its helper arguments: " .. tostring(open_error), 0)
end
handle:write("[", table.concat(encoded, ", "), "]\n")
handle:close()
local command = command_prefix() .. " --args-file " .. shell_quote(args_path) .. command_arguments(local_args)
local ok, result = pcall(run_command, command)
os.remove(args_path)
if not ok then
error(result, 0)
end
return result
end

local function project_context()
local resolve_app = resolve or (app and app:GetResolve())
if not resolve_app then
Expand Down Expand Up @@ -319,12 +377,7 @@ end

local function analyze_source(source_path)
local output = temp_path("openbeat_analysis", ".lua")
local final_command = command_prefix()
.. " analyze --audio "
.. shell_quote(source_path)
.. " --format lua --output "
.. shell_quote(output)
local ran, run_error = pcall(run_command, final_command)
local ran, run_error = pcall(run_helper, { "analyze", "--audio", source_path }, { "--format", "lua", "--output", output })
local analysis, load_error
if ran then
analysis, load_error = load_analysis(output)
Expand Down Expand Up @@ -402,15 +455,10 @@ 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 = command_prefix()
.. " click-track --audio "
.. shell_quote(source_path)
.. " --mode "
.. shell_quote(mode)
.. " --output "
.. shell_quote(output)
run_command(command)
if (file_size(output) or 0) == 0 then
run_helper({ "click-track", "--audio", source_path, "--mode", mode, "--output", output })
-- On Windows, Lua cannot open a non-ASCII path from Resolve; the helper's success stands.
local checkable = is_ascii(output) or not is_windows()
if checkable and (file_size(output) or 0) == 0 then
error("OpenBeat's helper finished but did not write the click track " .. output, 0)
end
return output
Expand Down
62 changes: 60 additions & 2 deletions scripts/staged_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,64 @@ def runtime_executable(modules: Path) -> Path:
return modules / "bin" / "openbeat.exe"


def windows_short_path(path: str) -> str | None:
"""Return the 8.3 short form of an existing ``path``, or ``None`` where unavailable."""
try:
import ctypes
from ctypes import wintypes

get_short_path = ctypes.windll.kernel32.GetShortPathNameW
except Exception:
return None
get_short_path.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
get_short_path.restype = wintypes.DWORD
size = get_short_path(path, None, 0)
if not size:
return None
buffer = ctypes.create_unicode_buffer(size)
if not 0 < get_short_path(path, buffer, size) < size:
return None
return buffer.value


def ansi_path(path: Path, anchor: Path, encoding: str = "mbcs") -> bytes | None:
"""Spell a non-ASCII ``path`` for Lua 5.1 on Windows, or return ``None`` if it cannot.

Resolve's Lua opens files and starts processes through the ANSI code page, so the
UTF-8 path never matches. The 8.3 short path is plain ASCII where the volume has
short names; otherwise the path encoded in the ANSI code page works if each of its
characters exists there. ``path`` need not exist yet: it is spelled as the short
form of the existing folder ``anchor`` followed by the rest of ``path``.
"""
text = path.as_posix()
if text.isascii():
return None
rest = path.relative_to(anchor).parts
short = windows_short_path(str(anchor))
if short and short.isascii() and all(name.isascii() for name in rest):
return "/".join([short.replace("\\", "/").rstrip("/"), *rest]).encode("ascii")
try:
return text.encode(encoding)
except (UnicodeError, LookupError):
return None


def lua_string(value: bytes) -> str:
"""Quote ``value`` as a Lua string literal made of ASCII characters only."""
return '"' + "".join(
chr(byte) if 32 <= byte < 127 and byte not in (34, 92) else f"\\{byte:03d}" for byte in value
) + '"'


def runtime_config(runtime: Path, anchor: Path, encoding: str = "mbcs") -> str:
"""The ``OpenBeatConfig.local.lua`` that points the Resolve scripts at ``runtime``."""
lines = [f" python_bin = {lua_string(runtime.as_posix().encode('utf-8'))},"]
ansi = ansi_path(runtime, anchor, encoding)
if ansi is not None:
lines.append(f" python_bin_ansi = {lua_string(ansi)},")
return "return {\n" + "\n".join(lines) + "\n}\n"


def manifest_path(directory: Path) -> Path:
return directory.with_name(f"{directory.name}.sha256")

Expand Down Expand Up @@ -189,8 +247,8 @@ def _stage(payload_root: Path, targets: list[Path]) -> Path:
if not staged_runtime.is_file():
raise InstallError("The installer payload does not contain the OpenBeat runtime.")
runtime = modules / staged_runtime.relative_to(staged_modules)
config = f'return {{\n python_bin = "{runtime.as_posix()}",\n}}\n'
(staged_modules / CONFIG_NAME).write_text(config, encoding="utf-8")
config = runtime_config(runtime, anchor=modules.parent)
(staged_modules / CONFIG_NAME).write_text(config, encoding="ascii")
return runtime


Expand Down
2 changes: 1 addition & 1 deletion tests/lua/test_helper.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ local function upvalue(fn, name)
end
local analyze_source = upvalue(upvalue(OpenBeat.run, "create_timeline_markers"), "analyze_source")
local render_click_track = upvalue(upvalue(OpenBeat.run, "create_click_audio"), "render_click_track")
local run_command = upvalue(analyze_source, "run_command")
local run_command = upvalue(upvalue(analyze_source, "run_helper"), "run_command")
local temp_path = upvalue(analyze_source, "temp_path")
local output_path_for = upvalue(render_click_track, "output_path_for")

Expand Down
103 changes: 103 additions & 0 deletions tests/lua/test_windows_paths.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
-- On Windows, Resolve's Lua starts processes through the ANSI code page, so UTF-8 text from
-- Resolve must reach the helper in a file, and the runtime is found by its ANSI spelling.
local directory = assert(arg[1])
local open_file = io.open
io.open = function(path, mode)
if tostring(path):match("OpenBeat%.log$") then return nil end
return open_file(path, mode)
end

local OpenBeat = dofile("resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua")
local function upvalue(fn, name)
for index = 1, 200 do
local key, value = debug.getupvalue(fn, index)
if key == name then return value, index end
if not key then break end
end
error("Missing upvalue " .. name)
end
local analyze_source = upvalue(upvalue(OpenBeat.run, "create_timeline_markers"), "analyze_source")
local render_click_track = upvalue(upvalue(OpenBeat.run, "create_click_audio"), "render_click_track")
local run_helper = upvalue(analyze_source, "run_helper")
local python_bin = upvalue(upvalue(run_helper, "command_prefix"), "python_bin")
local function exists(path)
local handle = open_file(path, "rb")
if handle then handle:close() end
return handle ~= nil
end
local function read(path)
local handle = assert(open_file(path, "rb")); local text = handle:read("*a"); handle:close(); return text
end

-- Installed configuration: the UTF-8 runtime path does not open through the ANSI code page,
-- so the ANSI spelling the installer recorded is used.
local ansi_runtime = directory .. "/openbeat~1.exe"
local utf8_runtime = directory .. "/Jos\195\169/openbeat.exe"
local _, config_index = upvalue(python_bin, "local_config")
debug.setupvalue(python_bin, config_index, { python_bin = utf8_runtime, python_bin_ansi = ansi_runtime })
local handle = assert(open_file(ansi_runtime, "wb"))
handle:close()

local posix_config = package.config
local getenv = os.getenv
package.config = "\\" .. posix_config:sub(2)
-- On Linux the backslash makes "tmp\\<name>" a file inside the test folder.
os.getenv = function(name) if name == "TEMP" then return directory .. "/tmp" end return getenv(name) end
assert(python_bin() == ansi_runtime)

local media = "C:\\M\195\186sica\\canci\195\179n.wav"
local behavior
local seen = {}
io.popen = function(command)
assert(not command:find("[\128-\255]"), "Non-ASCII text on the command line: " .. command)
local prefix = '""' .. ansi_runtime .. '" --args-file "'
assert(command:sub(1, #prefix) == prefix, command)
local args_path = assert(command:match('%-%-args%-file "([^"]+)"'))
local marker = assert(command:match('&& echo (OPENBEAT_HELPER_OK_%x+)"$'), command)
seen.args_path, seen.args, seen.command = args_path, read(args_path), command
local result = behavior(command)
local output = result.output or ""
if not result.fail then output = output .. marker .. "\r\n" end
return { read = function() return output end, close = function() return true end }
end

-- Analysis: the media path travels as UTF-8 JSON; the temporary output stays on the command line.
behavior = function(command)
local output = assert(command:match('%-%-format lua %-%-output "([^"]+)" 2>&1'), command)
assert(output:sub(1, #directory + 5) == directory .. "/tmp\\", output)
local file = assert(open_file(output, "wb"))
file:write("return { raw_beats = { 1 }, quantized_beats = { 2 } }")
file:close()
return {}
end
assert(analyze_source(media).quantized_beats[1] == 2)
assert(seen.args == '["analyze", "--audio", "C:\\\\M\195\186sica\\\\canci\195\179n.wav"]\n', seen.args)
assert(not exists(seen.args_path), "The arguments file must be removed")

-- Click track: every argument comes from Resolve, and Lua cannot check the non-ASCII output path.
behavior = function(command)
assert(command:match('%-%-args%-file "[^"]+" 2>&1'), command)
return { output = '{"output_path": "C:\\\\M\\u00fasica\\\\canci\\u00f3n.openbeat-clicks.wav"}' }
end
local click = render_click_track(media, "raw")
assert(click == "C:\\M\195\186sica\\canci\195\179n.openbeat-raw-clicks.wav", click)
assert(seen.args == '["click-track", "--audio", "C:\\\\M\195\186sica\\\\canci\195\179n.wav", "--mode", "raw", "--output", '
.. '"C:\\\\M\195\186sica\\\\canci\195\179n.openbeat-raw-clicks.wav"]\n', seen.args)
assert(not exists(seen.args_path))

-- ...but an ASCII output path is still checked, and failures still remove the arguments file.
assert(not pcall(render_click_track, "C:\\Music\\missing.wav", "raw"))
behavior = function() return { output = "LibsndfileError: Error opening", fail = true } end
local ok, message = pcall(analyze_source, media)
assert(not ok and message:find("LibsndfileError", 1, true), message)
assert(not exists(seen.args_path))

-- JSON escaping of quotes and control characters.
behavior = function() return {} end
pcall(run_helper, { "a\"b", "tab\tnew\nline\1" }, {})
assert(seen.args == '["a\\"b", "tab\\tnew\\nline\\u0001"]\n', seen.args)

-- Elsewhere the ANSI spelling is ignored and arguments stay on the command line.
package.config = posix_config
os.getenv = getenv
assert(python_bin() ~= ansi_runtime)
Loading
Loading