Skip to content

render: add --height and --crf; resolution-independent per-segment zoom - #158

Open
shoaib90 wants to merge 2 commits into
browser-use:mainfrom
shoaib90:pr/output-quality
Open

render: add --height and --crf; resolution-independent per-segment zoom#158
shoaib90 wants to merge 2 commits into
browser-use:mainfrom
shoaib90:pr/output-quality

Conversation

@shoaib90

@shoaib90 shoaib90 commented Sep 7, 2026

Copy link
Copy Markdown

The problem

render.py encodes the video twice — once per segment in extract_segment, then again in build_final_composite to composite overlays and burn in subtitles. The concat and the loudness pass are both -c copy, so those are lossless.

Both CRFs were hardcoded (extract 20/22/28, composite a literal 18), and the scale was an unconditional scale=1920:-2. So there was no way to deliver a 4K source at anything above 1080p, and no way to raise the extract CRF — which, being the first generation, is the quality ceiling the composite can only add loss on top of.

I hit this on a 4K talking-head edit that came out visibly soft. The diagnostic that isolated it was bits per pixel, not bitrate:

resolution bitrate bits/px
source 3840×2160 50.4 Mbps 0.253
render (before) 1920×1080 12.7 Mbps 0.255

Per-pixel quality was already matched — the loss was discarded pixels plus a second generation, not bitrate starvation. No flag could fix that.

Changes

--height — output height, default 1080 (720 with --draft). Width follows the source aspect. --height 2160 delivers at the source resolution and skips the downscale generation entirely. Re-rendering the same EDL that way gave 0.451 bits/px.

--crf — CRF for the extract, i.e. the ceiling. Defaults 16 final / 22 preview / 28 draft. The composite encode is now derived as crf - 2 rather than a hardcoded 18, so the second generation adds minimal further loss. Final also moves from fast/20 to slow/16.

ranges[].zoom (plus zoom_x, a 0–1 horizontal crop bias) — an optional per-segment push-in, for disguising jump cuts on a static single-camera shot.

It is deliberately a number, not a filter string. A hardcoded crop=1812:1018,scale=1920:1080 is only correct at one output height, and any per-segment dimension mismatch breaks the -c copy concat (Hard Rule 2). Relative ffmpeg expressions do not rescue it either: rounding to even dimensions stops crop round-tripping to the exact original size — 1920 comes back as 1918, while segments with no filter stay 1920, and the concat fails. Instead probe_scaled_dims() mirrors the scale expression, so the crop is computed from the real post-scale dimensions. Verified with 13 mixed 1.00×/1.06×/1.12× segments all landing at exactly 3840×2160.

audio_filter — an optional global audio chain applied per segment before the 30ms fades, so the fades stay on the true segment edges (Hard Rule 3). Intended for denoise/EQ on noisy location audio; I needed it for car-interior road noise.

display_dims() factors out the rotation-aware probe that is_portrait_source() was already doing inline.

Compatibility

All flags and EDL fields are optional. The only behaviour change without them is the final-mode default moving to slow/CRF 16 — better output, slower encode. Happy to leave the old fast/20 default and make the improvement strictly opt-in if you'd rather.

tests/ passes (16 tests, 15 subtests), including the existing fps and orientation suites.

Note

Touches build_final_composite's signature and main(), so it overlaps slightly with #(the subtitle PR) if both land — whichever merges second needs a trivial rebase.


Summary by cubic

Adds --height and --crf to render.py plus per-segment zoom and global audio_filter EDL fields. Previously every render was downscaled to 1080p with hardcoded CRFs; now 4K sources can be delivered at native resolution and the extract CRF, the quality ceiling, is configurable.

New Features

  • --height sets output height; default stays 1080 (720 with --draft) and width follows source aspect. It must be a positive even number since yuv420p requires it.
  • --crf sets the extract CRF (defaults 16 final, 22 preview, 28 draft; fractional values like 16.5 are valid); the composite CRF is derived as crf - 2 instead of hardcoded 18.
  • ranges[].zoom and zoom_x add an optional per-segment push-in expressed as a number, not a filter string, so one EDL stays correct at any output height. The crop is computed from the source's measured post-scale dimensions, so zoomed and unzoomed segments always match and the lossless concat is preserved.
  • audio_filter applies a global audio chain per segment before the 30ms fades, keeping fades on true segment edges.

Compatibility

  • All new flags and EDL fields are optional; without them, the only behavior change is final mode defaulting to slow/CRF 16 instead of fast/20, which means better output but slower encodes.

Written for commit 764f007. Summary will update on new commits.

Review in cubic

The renderer encodes video twice — once per segment on extract, then
again to composite overlays and burn in subtitles. Both CRFs were
hardcoded and the scale was an unconditional `scale=1920:-2`, so there
was no way to deliver a 4K source at anything but 1080p, and no way to
raise the extract CRF that sets the quality ceiling.

Symptom: a 4K talking-head render looked visibly soft. Bits-per-pixel
turned out to be identical between source and output (0.253 vs 0.255) —
the loss was discarded pixels plus a second generation, not bitrate.

  --height  output height (default 1080, 720 with --draft); width follows
            the source aspect
  --crf     CRF for the extract, i.e. the quality ceiling (default 16
            final / 22 preview / 28 draft)

The composite encode is now derived as (crf - 2) instead of a hardcoded
18, so the second generation adds minimal further loss, and final
defaults to slow/16 rather than fast/20. Re-rendering one edit at
--height 2160 --crf 16 gave 0.451 bits/px with no downscale generation.

Also adds two EDL fields:

  ranges[].zoom (+ zoom_x)  per-segment push-in, to disguise jump cuts on
    a static single-camera shot. Deliberately a number, not a filter
    string: a hardcoded crop is only correct at one output height, and any
    per-segment dimension mismatch breaks the -c copy concat. Relative
    ffmpeg expressions do not fix this either — rounding to even
    dimensions stops crop round-tripping to the exact original size
    (1920 comes back as 1918). probe_scaled_dims() mirrors the scale
    expression instead, so the crop is computed from the real post-scale
    dimensions. Verified with 13 mixed 1.00x/1.06x/1.12x segments all
    landing at exactly 3840x2160.

  audio_filter  a global audio chain applied per segment BEFORE the 30ms
    fades, so the fades stay on the true segment edges (Hard Rule 3).
    Intended for denoise/EQ on noisy location audio.

display_dims() factors out the rotation-aware probe that
is_portrait_source() was already doing inline.
shoaib90 pushed a commit to shoaib90/video-use that referenced this pull request Sep 7, 2026
Forked to shoaib90/video-use and opened three focused upstream PRs
(browser-use#158 quality controls, browser-use#159 configurable subtitles, browser-use#160 Deepgram).

Records the branch layout and, more usefully, why each PR branch was
built by replaying changes onto clean main rather than cherry-picking out
of local: local's commits bundle render.py work with kb/ updates, and two
separate concerns touch build_final_composite and main(), so hunk-level
splitting would have produced fragile branches.

Also notes that transcribe_whisper.py is deliberately held back, since
SKILL.md's anti-patterns name local Whisper explicitly.

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.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread helpers/render.py Outdated
Comment thread helpers/render.py Outdated
Comment thread helpers/render.py Outdated
Comment thread SKILL.md
Four review findings, all valid.

1. The zoom path predicted ffmpeg's `scale=-2` width in Python and then
   hardcoded it as the scale-back target. I tested the arithmetic against
   ffmpeg across several non-16:9 aspects and it agreed in every valid
   case, but the concern holds for a reason the test could not reach:
   `-2` also honours sample aspect ratio, which arithmetic over coded
   dimensions ignores entirely, so an anamorphic source would diverge.

   Rather than reimplement ffmpeg's rounding, probe_scaled_dims() now
   *measures* it — one frame through the real scale filter, cached per
   (source, height) — and that measured value is used as an EXPLICIT
   scale target for every segment. Zoomed and unzoomed siblings therefore
   match by construction rather than by agreement, which also removes the
   subtler hazard: a zoomed segment has a cropped input aspect, so `-2`
   could legitimately hand it a width 2px off its siblings.

   Falls back to the previous arithmetic with a warning if the probe
   fails; correct for square-pixel sources, which is everything a camera
   produces.

2. --height accepted odd and non-positive values, surfacing as an ffmpeg
   failure partway through extraction. Now an argparse type that requires
   a positive even height (yuv420p needs even), failing before any work
   starts.

3. --crf 16.5 is valid for x264 but main() did int(args.crf) and raised
   ValueError after extraction had already run. CRF is now parsed as a
   float, range-checked to x264's 0-51, and the composite CRF derived
   numerically. Verified end to end: --crf 16.5 completes a full render.

4. SKILL.md's "Output spec" still told agents to pass --filter or
   hand-edit the extract command for non-1080p targets, contradicting the
   new --height guidance. Updated to point at --height.

Also sorts the import block, which I had appended to out of order.

Verified: 3 segments at mixed zoom 1.0/1.08/1.15 all land at identical
dimensions; tests pass.
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