fix: Windows ffmpeg filter paths + signalstats bit-depth normalisation - #127
fix: Windows ffmpeg filter paths + signalstats bit-depth normalisation#127vitorfuzo wants to merge 2 commits into
Conversation
Subtitles never rendered at all on Windows. build_final_composite escaped
the drive colon in the subtitles path but left the backslashes, and the
filtergraph parser eats those, so the filter received
C:UsersnameeditmastersrtT
and failed with "No such file or directory". Converting to forward slashes
before escaping the colon fixes it; ffmpeg accepts forward slashes on
Windows. This is Hard Rule 1 material — the failure is silent in the sense
that the render still completes, just with no captions.
SUB_FORCE_STYLE also asked for Helvetica, which Windows does not ship.
libass then substitutes its default face, so the tuned FontSize, Outline
and MarginV values no longer describe what is actually rendered. Arial is
metric-compatible with Helvetica and present on both Windows and macOS.
timeline_view's FONT_CANDIDATES listed only macOS and Linux paths, so on
Windows load_font fell through to ImageFont.load_default() — a fixed ~11px
bitmap face that ignores the requested size. That left the filename and
timestamp labels unreadable in exactly the QC images the skill's self-eval
pass depends on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_sample_frame_stats divided YAVG/YMIN/YMAX/SATAVG by (2**YBITDEPTH - 1),
documented as "the NATIVE bit depth of the decoded frame". YBITDEPTH is not
that. It is the popcount of the bitwise OR of every sample value in the
frame, so it describes the frame's content, not the format. Measured
against synthetic sources:
flat black (Y=16 = 0b00010000) -> YBITDEPTH 1
flat white (Y=235 = 0b11101011) -> YBITDEPTH 6
flat grey (Y=126 = 0b01111110) -> YBITDEPTH 6
192..193 gradient (OR = 0b11000001) -> YBITDEPTH 3
full-range testsrc2 -> YBITDEPTH 8
The parser also kept whichever value appeared last in the metadata, so the
divisor depended on the final sampled frame rather than on the source.
The effect is not a rounding error, it inverts the correction. On a dark
1080p clip whose last frame reported 2, y_mean came out as 18.40 instead of
0.22 and sat_mean as 3.41 instead of 0.04. Because those read as "very
overexposed, very saturated", auto_grade_for_clip emitted
eq=contrast=1.030:gamma=0.970:saturation=0.960
darkening and desaturating footage that was already dark and desaturated.
With the fix the same clip yields
eq=contrast=1.030:gamma=1.100:saturation=1.040
A new _source_bit_depth() reads pix_fmt via ffprobe, which is the only
reliable source for the real depth, and falls back to 8 if the probe fails.
Also quote and slash-normalise the metadata=print:file= path. A raw Windows
path breaks the -vf parser twice: once on the eaten backslashes and once on
the drive colon reading as the filter's own option separator. That failure
was invisible because the subprocess sends stderr to DEVNULL, so the
analysis just silently fell back to neutral defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="helpers/grade.py">
<violation number="1" location="helpers/grade.py:145">
P2: Auto-grade fails when the system temp path contains an apostrophe, such as a Windows profile under `C:\O'Brien\...`, because the new single-quoted `file=` value is not quote-escaped. Escaping `'` in `meta_arg` as the subtitle path does preserves the metadata filter graph.</violation>
<violation number="2" location="helpers/grade.py:166">
P3: The auto-grade bit depth is re-derived from a fresh ffprobe call on every _sample_frame_stats invocation, i.e. once per auto-graded segment even when many segments come from the same source file (see render.py's per-range loop calling auto_grade_for_clip). Since the pix_fmt/bit depth is constant for a given source, this adds one redundant ffprobe subprocess per segment to every auto-grade render. Consider probing once per source (e.g. compute it in auto_grade_for_clip and pass it in, or add a small per-source cache) and reusing the result across segments. Minor, non-blocking.</violation>
</file>
<file name="helpers/render.py">
<violation number="1" location="helpers/render.py:55">
P3: Rendered subtitle style documentation now names Helvetica although the filter applies Arial; update the module description so operators see the font actually used.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| # A raw Windows path breaks the -vf parser twice over: backslashes are eaten | ||
| # and the drive colon reads as the filter's option separator. Forward slashes | ||
| # plus an escaped colon plus single quotes around the value survives both. | ||
| meta_arg = metadata_path.replace("\\", "/").replace(":", r"\:") |
There was a problem hiding this comment.
P2: Auto-grade fails when the system temp path contains an apostrophe, such as a Windows profile under C:\O'Brien\..., because the new single-quoted file= value is not quote-escaped. Escaping ' in meta_arg as the subtitle path does preserves the metadata filter graph.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/grade.py, line 145:
<comment>Auto-grade fails when the system temp path contains an apostrophe, such as a Windows profile under `C:\O'Brien\...`, because the new single-quoted `file=` value is not quote-escaped. Escaping `'` in `meta_arg` as the subtitle path does preserves the metadata filter graph.</comment>
<file context>
@@ -100,26 +139,31 @@ def _sample_frame_stats(
+ # A raw Windows path breaks the -vf parser twice over: backslashes are eaten
+ # and the drive colon reads as the filter's option separator. Forward slashes
+ # plus an escaped colon plus single quotes around the value survives both.
+ meta_arg = metadata_path.replace("\\", "/").replace(":", r"\:")
+
try:
</file context>
| meta_arg = metadata_path.replace("\\", "/").replace(":", r"\:") | |
| meta_arg = metadata_path.replace("\\", "/").replace(":", r"\:").replace("'", r"\'") |
| y_maxs: list[float] = [] | ||
| sat_avgs: list[float] = [] | ||
| bit_depth: int = 8 | ||
| bit_depth = _source_bit_depth(video) |
There was a problem hiding this comment.
P3: The auto-grade bit depth is re-derived from a fresh ffprobe call on every _sample_frame_stats invocation, i.e. once per auto-graded segment even when many segments come from the same source file (see render.py's per-range loop calling auto_grade_for_clip). Since the pix_fmt/bit depth is constant for a given source, this adds one redundant ffprobe subprocess per segment to every auto-grade render. Consider probing once per source (e.g. compute it in auto_grade_for_clip and pass it in, or add a small per-source cache) and reusing the result across segments. Minor, non-blocking.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/grade.py, line 166:
<comment>The auto-grade bit depth is re-derived from a fresh ffprobe call on every _sample_frame_stats invocation, i.e. once per auto-graded segment even when many segments come from the same source file (see render.py's per-range loop calling auto_grade_for_clip). Since the pix_fmt/bit depth is constant for a given source, this adds one redundant ffprobe subprocess per segment to every auto-grade render. Consider probing once per source (e.g. compute it in auto_grade_for_clip and pass it in, or add a small per-source cache) and reusing the result across segments. Minor, non-blocking.</comment>
<file context>
@@ -100,26 +139,31 @@ def _sample_frame_stats(
y_maxs: list[float] = []
sat_avgs: list[float] = []
- bit_depth: int = 8
+ bit_depth = _source_bit_depth(video)
def _parse_value(line: str) -> float | None:
</file context>
| # metric-compatible with Helvetica and present on Windows and macOS both. | ||
| SUB_FORCE_STYLE = ( | ||
| "FontName=Helvetica,FontSize=18,Bold=1," | ||
| "FontName=Arial,FontSize=18,Bold=1," |
There was a problem hiding this comment.
P3: Rendered subtitle style documentation now names Helvetica although the filter applies Arial; update the module description so operators see the font actually used.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/render.py, line 55:
<comment>Rendered subtitle style documentation now names Helvetica although the filter applies Arial; update the module description so operators see the font actually used.</comment>
<file context>
@@ -48,8 +48,11 @@ def auto_grade_for_clip(video, start=0.0, duration=None, verbose=False): # type
+# metric-compatible with Helvetica and present on Windows and macOS both.
SUB_FORCE_STYLE = (
- "FontName=Helvetica,FontSize=18,Bold=1,"
+ "FontName=Arial,FontSize=18,Bold=1,"
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BackColour=&H00000000,"
"BorderStyle=1,Outline=2,Shadow=0,"
</file context>
Two independent problems found while running video-use end to end on Windows 11 against ~3h of gameplay footage. The first commit is Windows-only; the second is a correctness bug that affects every platform.
1. Subtitles never render on Windows, and the QC filmstrips are unreadable
build_final_compositeescapes the drive colon in the subtitles path but leaves the backslashes. The filtergraph parser eats them, so the filter receives a mangled path and fails:Converting to forward slashes before escaping the colon fixes it — ffmpeg accepts forward slashes on Windows.
Reproduction, with any 1s video and any
.srt:Two smaller items in the same commit:
SUB_FORCE_STYLErequestsFontName=Helvetica, which Windows does not ship. libass substitutes its default face, so the carefully tunedFontSize/Outline/MarginVno longer describe what is rendered. Switched to Arial, which is metric-compatible and present on Windows and macOS both.timeline_view.FONT_CANDIDATESlists only macOS and Linux paths, so on Windowsload_fontfalls through toImageFont.load_default()— a fixed ~11px bitmap face that ignores the requested size. The filename and timestamp labels come out unreadable in exactly the QC images the skill's self-eval step depends on. Added the Windows console/Arial paths.2.
grade.pynormalises by YBITDEPTH, which inverts the auto-grade_sample_frame_statsdividesYAVG/YMIN/YMAX/SATAVGby(2**YBITDEPTH - 1), with a comment describing YBITDEPTH as "the NATIVE bit depth of the decoded frame".It is not.
signalstatscomputes it as the popcount of the bitwise OR of every sample value in the frame, so it describes content, not format. Measured on synthetic sources, allyuv420p8-bit:00010000111010110111111011000001testsrc2Reproduce:
The parser also keeps whichever value appears last in the metadata file, so the divisor depends on the final sampled frame.
The consequence is not a rounding error, it flips the sign of the correction. On a dark 1080p clip whose last sampled frame reported 2:
y_meansat_mean18.39553.4061eq=contrast=1.030:gamma=0.970:saturation=0.9600.21640.0401eq=contrast=1.030:gamma=1.100:saturation=1.040Because 18.4 and 3.4 read as "very overexposed, very saturated", the auto-grade darkened and desaturated footage that was already dark and desaturated. Anything shot in a dark interior gets graded the wrong way.
Fixed by adding
_source_bit_depth(), which readspix_fmtvia ffprobe and falls back to 8 if the probe fails.The same commit also quotes and slash-normalises the
metadata=print:file=path. A raw Windows path breaks the-vfparser twice — once on the eaten backslashes, once on the drive colon reading as the filter's own option separator. That one was invisible because the subprocess sends stderr toDEVNULL, so the analysis silently fell back to neutral defaults.Verification
helpers/*.py --helpall still import and run.grade.py --analyze,--list-presets, and--preset warm_cinematicverified end to end.render.pyrun over a 2-segment EDL with a grade, a PTS-shifted overlay and burnt-in subtitles: correct 6.04s output, captions visible above the overlay, 30ms fades intact at the cut.0.9216= 235/255), flat black (0.0627= 16/255), full-rangetestsrc2, and real 8-bit footage.Tested on Windows 11, ffmpeg 9.0, Python 3.14.
Summary by cubic
Fixes Windows subtitle burn-in and QC label readability, and corrects auto‑grade normalization so grades are accurate on all platforms. Paths now parse on Windows and grading uses real source bit depth, preventing inverted corrections on dark footage.
ffmpegparsessubtitles=andmetadata=print:file=correctly; captions now render.SUB_FORCE_STYLEto Arial and add Windows font candidates so QC filmstrip labels are legible.signalstatsYBITDEPTH; add_source_bit_depth()to readpix_fmtviaffprobeand normalize by the format’s max value.Written for commit 0ae9b8a. Summary will update on new commits.