Skip to content

fix: Windows ffmpeg filter paths + signalstats bit-depth normalisation - #127

Open
vitorfuzo wants to merge 2 commits into
browser-use:mainfrom
vitorfuzo:fix/windows-ffmpeg-paths-and-signalstats-depth
Open

fix: Windows ffmpeg filter paths + signalstats bit-depth normalisation#127
vitorfuzo wants to merge 2 commits into
browser-use:mainfrom
vitorfuzo:fix/windows-ffmpeg-paths-and-signalstats-depth

Conversation

@vitorfuzo

@vitorfuzo vitorfuzo commented Aug 11, 2026

Copy link
Copy Markdown

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_composite escapes 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:

[Parsed_subtitles_0] Unable to open C:UsersvitorAppDataLocalTempmaster.srt
[AVFilterGraph] Error initializing filters

Converting to forward slashes before escaping the colon fixes it — ffmpeg accepts forward slashes on Windows.

Reproduction, with any 1s video and any .srt:

# fails
ffmpeg -i base.mp4 -filter_complex "[0:v]subtitles='C\:\Users\me\test.srt'[outv]" -map "[outv]" out.mp4
# works
ffmpeg -i base.mp4 -filter_complex "[0:v]subtitles='C\:/Users/me/test.srt'[outv]" -map "[outv]" out.mp4

Two smaller items in the same commit:

  • SUB_FORCE_STYLE requests FontName=Helvetica, which Windows does not ship. libass substitutes its default face, so the carefully tuned FontSize/Outline/MarginV no longer describe what is rendered. Switched to Arial, which is metric-compatible and present on Windows and macOS both.
  • timeline_view.FONT_CANDIDATES lists only macOS and Linux paths, so on Windows load_font falls through to ImageFont.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.py normalises by YBITDEPTH, which inverts the auto-grade

_sample_frame_stats divides YAVG/YMIN/YMAX/SATAVG by (2**YBITDEPTH - 1), with a comment describing YBITDEPTH as "the NATIVE bit depth of the decoded frame".

It is not. signalstats computes 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, all yuv420p 8-bit:

source Y binary popcount YBITDEPTH
flat black 16 00010000 1 1
flat white 235 11101011 6 6
flat grey 126 01111110 6 6
gradient 192–193 OR 11000001 3 3
full-range testsrc2 8 8

Reproduce:

ffmpeg -f lavfi -i color=c=white:s=320x240 -t 0.2 \
  -vf "signalstats,metadata=print" -pix_fmt yuv420p -f null -  2>&1 | grep -E "YBITDEPTH|YAVG"
# lavfi.signalstats.YAVG=235
# lavfi.signalstats.YBITDEPTH=6      <- not 8

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_mean sat_mean emitted filter
before 18.3955 3.4061 eq=contrast=1.030:gamma=0.970:saturation=0.960
after 0.2164 0.0401 eq=contrast=1.030:gamma=1.100:saturation=1.040

Because 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 reads pix_fmt via 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 -vf parser 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 to DEVNULL, so the analysis silently fell back to neutral defaults.

Verification

  • helpers/*.py --help all still import and run.
  • grade.py --analyze, --list-presets, and --preset warm_cinematic verified end to end.
  • Full render.py run 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.
  • Bit-depth handling spot-checked on flat white (0.9216 = 235/255), flat black (0.0627 = 16/255), full-range testsrc2, 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.

  • Bug Fixes
    • Windows paths: convert backslashes to forward slashes, escape the drive colon, and quote filter args so ffmpeg parses subtitles= and metadata=print:file= correctly; captions now render.
    • Fonts: switch SUB_FORCE_STYLE to Arial and add Windows font candidates so QC filmstrip labels are legible.
    • Auto‑grade: stop using signalstats YBITDEPTH; add _source_bit_depth() to read pix_fmt via ffprobe and normalize by the format’s max value.

Written for commit 0ae9b8a. Summary will update on new commits.

Review in cubic

vitorfuzo and others added 2 commits August 11, 2026 03:24
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread helpers/grade.py
# 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"\:")

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
meta_arg = metadata_path.replace("\\", "/").replace(":", r"\:")
meta_arg = metadata_path.replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
Fix with cubic

Comment thread helpers/grade.py
y_maxs: list[float] = []
sat_avgs: list[float] = []
bit_depth: int = 8
bit_depth = _source_bit_depth(video)

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread helpers/render.py
# 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,"

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant