Speed up reel encoding (the dominant cost) and skip a duplicate intro render#3
Conversation
write_reel() pushed every duplicated video frame's raw pixels through imageio's writer one append at a time (90 appends for a 3s hold at 30fps, hundreds for a typical reel), which dominated runtime: profiling a small fixture showed it taking 11.6s out of a 13.4s total run. Each unique frame is now written to disk once and ffmpeg's concat filter (-loop + -t per slide) holds and joins them in a single call at the veryfast x264 preset, which is a safe choice here since static-slide slideshows compress well regardless of preset. This cuts write_reel from 11.6s to ~8s on the same fixture, verified frame-accurate via reel duration/content checks. Also stop rendering the intro slide twice: render_section() and render_reel() both called render_intro() with identical arguments, redoing the same font-fitting work for a pixel-identical result. cli.py now passes the already-rendered intro into render_reel().
|
Warning Review limit reached
Next review available in: 24 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthrough
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jg/crowing/writing.py`:
- Around line 53-57: The temporary silent MP4 is being created in output_dir,
which can leave a stray .reel-silent.mp4 behind on mux failure and can collide
across concurrent runs. Update the writing flow in _encode_silent and the
surrounding tempfile.TemporaryDirectory block so silent is created inside
tmp_dir, then call _mux_music before the temporary directory context exits and
remove the separate silent.unlink cleanup.
- Around line 74-81: The frame export logic in writing.py silently truncates
when `paths` and `durations` differ, while the ffmpeg concat input still assumes
every frame exists. Update the frame-building flow around the `zip(paths,
durations)` usage to fail fast with an explicit length check, or switch to
`zip(..., strict=True)` if supported, so the `concat` input stays consistent
with `frames`.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5b437518-abe6-409f-bb89-0d12a0906cf2
📒 Files selected for processing (3)
src/jg/crowing/cli.pysrc/jg/crowing/rendering.pysrc/jg/crowing/writing.py
…n length mismatch The silent mp4 lived in output_dir, so a mux failure could leave a stray .reel-silent.mp4 behind and concurrent runs in the same directory could collide. It now lives inside the same temporary directory as the frame PNGs and is muxed before that directory is cleaned up. Also use zip(..., strict=True) when pairing frames/paths with durations, so a length mismatch fails loudly instead of silently truncating the encoded reel.
honzajavorek
left a comment
There was a problem hiding this comment.
Verified the core fix is correct (ran the full test suite + ruff locally against this branch, 97 passed, no new lint issues). Two small follow-ups left as inline comments: missing test coverage for the new render_reel(intro=...) parameter, and a comment that references the PR description instead of stating its rationale inline.
Generated by Claude Code
Add a test that passes a marker intro image and asserts render_reel reflects it, instead of only covering the default rendering path. Also inline the actual profiling numbers behind the veryfast preset choice instead of pointing at the commit/PR description for them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jr5K6Dv2SJew56N2cvcUGA
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/test_rendering.py`:
- Around line 325-329: The test currently only checks a yellow pixel in the
first frame, which does not prove the fallback intro renderer was skipped.
Update test_render_reel_reuses_a_given_intro_instead_of_rendering_one to patch
jg.crowing.rendering.render_intro, call render_reel with an intro image, and
assert render_intro is not called when intro is provided. Keep the existing
render_reel and intro setup, but make the assertion target the render_intro call
behavior rather than pixel color alone.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 19039097-1ede-4e5c-9d82-44fa7770eee4
📒 Files selected for processing (2)
src/jg/crowing/writing.pytests/test_rendering.py
🚧 Files skipped from review as they are similar to previous changes (1)
- src/jg/crowing/writing.py
A matching pixel color alone doesn't prove the fallback renderer was bypassed - render_intro could coincidentally produce the same color. Patch it and assert it's not called when intro is supplied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jr5K6Dv2SJew56N2cvcUGA
Performance audit
Profiled a full
crowingrun withcProfileagainst the localtests/fixtures/handbook-git.htmlfixture (2 paragraphs → 4 slides, reel ≈ 35s). Breakdown before this PR:render_section(images)render_reel(reel frames)write_images(PNGs)write_carousel(PDF)write_reel(the MP4)write_reelalone is 86% of total runtime, even on this tiny 2-paragraph fixture. It's not a serial-vs-parallel issue (the steps are inherently sequential — each depends on the previous one's output) — it's that the video encoding was doing far more work than it needed to.Root cause
write_reel()built the silent video by converting every frame to a numpy array and callingimageio's ffmpeg writer'sappend_data()once per output frame — including duplicates. A 3s hold at 30fps meant 90 separate Python→pipe round-trips of ~6.2MB of raw RGB pixels each, just to show the same static image. For a real handbook section (more paragraphs, up to the 90s reel cap) this scales linearly and easily explains a run "taking a minute".Fix
write_reel()now writes each unique frame to disk once, then makes a singleffmpegcall:-loop 1 -framerate <fps> -t <duration> -i <frame>per slide, joined with theconcatfilter, encoded at theveryfastx264 preset. ffmpeg handles holding/duplicating frames internally in C instead of Python pushing raw pixels through a pipe one frame at a time.veryfastis safe quality-wise here because slideshows of static, hard-cut slides compress extremely well at any preset — the visual difference is negligible, the speed difference isn't.Measured on the same fixture:
write_reeldrops from 11.6s → ~8s (verified frame-accurate: correct frame count, correct total duration, and correct slide shown at each timestamp — checked by sampling frames at multiple points in the video and comparing background colors against the expected slide).I also explored an even faster approach (ffmpeg's concat demuxer with a
durationdirective directly on each PNG, skipping per-frame decode entirely — measured ~5s instead of ~8s), but it produced incorrect output durations at low frame rates (verified by writing/running tests atfps=5, which the test suite already exercises for speed) due to an ffmpeg quirk in how the demuxer's implicit per-image framerate interacts with the requested duration. Given a wrong-length reel is worse than a slower one, I shipped the slower-but-verified-correct version. Worth revisiting if someone wants to dig deeper into that ffmpeg behavior.Secondary fix: duplicate intro render
render_section()(for the square Instagram images) andrender_reel()(for the reel) both calledrender_intro(section.title, section.heading)with identical arguments, redoing the same font-fitting search for a pixel-identical image.render_reel()now accepts an optional already-renderedintroimage, andcli.pypasses in the onerender_section()already produced.Net result
Same fixture, full pipeline: 13.4s → ~9.4s (~30% faster), with the win growing for longer reels since the removed per-frame Python/pipe overhead scales with frame count while everything else stays roughly fixed.
Suggestions not implemented here (for consideration)
fit_words/fit_intro/fit_cloud/fit_plaininrendering.pypick the largest font size that fits by scanning sizes linearly in steps of 2 (~50 candidate sizes), each doing a full text-measurement pass. Since "fits at size N" is monotonic, this is a textbook binary-search candidate (~6 iterations instead of ~50). Didn't change it here since it's a smaller win than the video fix and I wanted to keep this PR's diff verifiable; flagging it as a follow-up.Test plan
uv run pytest— all 97 tests pass, including the reel duration/dimensions/music testsmake smoke— full real run against the live junior.guru git handbook page produces images,carousel.pdf, andreel.mp4correctlyGenerated by Claude Code