Skip to content
Open
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
43 changes: 41 additions & 2 deletions helpers/grade.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
import tempfile
from pathlib import Path

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


PRESETS: dict[str, str] = {
# Subtle baseline — barely perceptible cleanup. No color shift.
Expand Down Expand Up @@ -75,6 +85,25 @@ def get_preset(name: str) -> str:
# -------- Auto grade (data-driven, per-clip) --------------------------------


def _metadata_filter_target(path: str) -> tuple[str, str]:
r"""Return (cwd, filter_value) for writing an ffmpeg metadata file safely.

ffmpeg filtergraphs treat ':' and '\' as delimiters, so embedding a Windows
path like C:\Users\me\tmp.txt in 'metadata=print:file=...' breaks parsing
and ffmpeg exits with an error. Instead we pass the bare filename and run
ffmpeg with cwd set to the file's directory, so no special character ever
reaches the filtergraph parser.

Split on either separator ('/' or '\') rather than host-native pathlib, so
the bare filename is extracted the same way on any OS — the filter value
must never contain ':' or a path separator regardless of where this runs.
"""
idx = max(path.rfind("/"), path.rfind("\\"))
if idx == -1:
return ".", path
return path[:idx], path[idx + 1:]


def _sample_frame_stats(
video: Path,
start: float,
Expand All @@ -93,23 +122,33 @@ def _sample_frame_stats(
"sat_mean": mean saturation in 0..1,
}
"""
# Resolve to an absolute path before the ffmpeg call below: that call sets
# cwd to the temp dir (for the metadata file), so a relative `-i <video>`
# would otherwise resolve against the temp dir and fail.
video = Path(video).resolve()

# Use signalstats + metadata=print to get per-frame stats
# Sample fps = n_samples / duration, clamped so we don't over-sample short clips
fps = max(0.5, min(n_samples / max(duration, 0.1), 10.0))

with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt", delete=False) as f:
metadata_path = f.name

# Pass a bare filename + run ffmpeg from the file's directory so no Windows
# path (with ':' and '\') ever reaches the filtergraph parser. See
# _metadata_filter_target.
metadata_dir, metadata_name = _metadata_filter_target(metadata_path)

try:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-ss", f"{start:.3f}",
"-i", str(video),
"-t", f"{duration:.3f}",
"-vf", f"fps={fps:.2f},signalstats,metadata=print:file={metadata_path}",
"-vf", f"fps={fps:.2f},signalstats,metadata=print:file={metadata_name}",
"-f", "null", "-",
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(cmd, check=True, cwd=metadata_dir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

# Parse signalstats metadata. Signalstats reports values in the NATIVE
# bit depth of the decoded frame (8-bit → 0-255, 10-bit → 0-1023). We
Expand Down
10 changes: 10 additions & 0 deletions helpers/pack_transcripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
import sys
from pathlib import Path

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


def format_time(seconds: float) -> str:
"""Format a time in seconds as "NNN.NN" with fixed 6-char width for alignment."""
Expand Down
10 changes: 10 additions & 0 deletions helpers/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
import sys
from pathlib import Path

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass

try:
from grade import get_preset, auto_grade_for_clip # same directory
except Exception:
Expand Down
10 changes: 10 additions & 0 deletions helpers/transcribe_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@

from transcribe import load_api_key, transcribe_one

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


VIDEO_EXTS = {".mp4", ".MP4", ".mov", ".MOV", ".mkv", ".MKV", ".avi", ".AVI", ".m4v"}

Expand Down
83 changes: 83 additions & 0 deletions tests/test_grade_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
r"""Regression tests for the Windows cross-platform fixes in the helpers.

Bug 1 (ffmpeg path): grade.py --analyze crashed on Windows because the
signalstats metadata filter embedded a temp path (e.g. C:\Users\me\Temp\stats.txt),
and the ':' and '\' in that path collide with ffmpeg filtergraph delimiters, so
ffmpeg exited with an error. Fix: _metadata_filter_target() returns the file's
directory (for ffmpeg's cwd) and the bare filename (for the filter), so no path
separator or drive colon reaches the filtergraph parser.

Bug 2 (cp1252 print crash): status print() calls emit non-ASCII glyphs (U+2192
arrow, U+2014 em-dash) which raise UnicodeEncodeError on a Windows cp1252 console.
Fix: each helper reconfigures stdout/stderr to UTF-8 at import.

These tests are written to pass identically on Windows and on Linux CI.
"""
import subprocess
import sys
import unittest
from pathlib import Path

HELPERS_DIR = Path(__file__).resolve().parent.parent / "helpers"
sys.path.insert(0, str(HELPERS_DIR))
import grade # noqa: E402


class MetadataFilterTargetTest(unittest.TestCase):
"""The filter value must be a bare filename (no ':' or path separator) for
BOTH Windows and POSIX inputs, regardless of which OS runs the test."""

def test_windows_path_yields_bare_name(self):
cwd, name = grade._metadata_filter_target(r"C:\Users\me\AppData\Local\Temp\stats.txt")
self.assertEqual(name, "stats.txt")
self.assertEqual(cwd, r"C:\Users\me\AppData\Local\Temp")
for ch in (":", "/", "\\"):
self.assertNotIn(ch, name)

def test_posix_path_yields_bare_name(self):
cwd, name = grade._metadata_filter_target("/var/folders/xy/stats.txt")
self.assertEqual(name, "stats.txt")
self.assertEqual(cwd, "/var/folders/xy")

def test_bare_filename_has_no_dir(self):
cwd, name = grade._metadata_filter_target("stats.txt")
self.assertEqual(name, "stats.txt")
self.assertEqual(cwd, ".")


class Utf8ConsoleTest(unittest.TestCase):
"""Regression for the cp1252 print crash. Runs in a subprocess so we can
force a cp1252 stdout without disturbing the test runner's own streams."""

def test_arrow_glyphs_survive_cp1252_after_reconfigure(self):
# Force the Windows-default cp1252 stdout, apply the same reconfigure the
# helpers do at import, then print the exact glyphs that crashed. Without
# the reconfigure line this raises UnicodeEncodeError; with it, exit 0.
code = (
"import sys, io;"
"sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding='cp1252');"
"sys.stdout.reconfigure(encoding='utf-8');"
"print('grading a → b — c')"
)
r = subprocess.run([sys.executable, "-c", code], capture_output=True)
self.assertEqual(r.returncode, 0, r.stderr.decode("utf-8", "replace"))

def test_helpers_force_utf8_stdout_on_import(self):
# After import, each patched helper must leave stdout UTF-8-capable, so
# an accidental deletion of the reconfigure block is caught here.
for mod in ("grade", "pack_transcripts"):
code = (
f"import sys; sys.path.insert(0, r'{HELPERS_DIR}');"
f"import {mod};"
"enc=(sys.stdout.encoding or '').lower().replace('-', '');"
"sys.exit(0 if 'utf8' in enc else 1)"
)
r = subprocess.run([sys.executable, "-c", code], capture_output=True)
self.assertEqual(
r.returncode, 0,
f"{mod}: stdout not UTF-8 after import; {r.stderr.decode('utf-8', 'replace')}",
)


if __name__ == "__main__":
unittest.main()