Skip to content

perf: merge packshot into single ffmpeg command with caching#131

Open
SecurityQQ wants to merge 1 commit intomainfrom
feat/packshot-single-command
Open

perf: merge packshot into single ffmpeg command with caching#131
SecurityQQ wants to merge 1 commit intomainfrom
feat/packshot-single-command

Conversation

@SecurityQQ
Copy link
Copy Markdown
Contributor

Summary

  • Reduces packshot rendering from 3 sequential Rendi round-trips (~77s) to 1 round-trip (~25-35s) by building a unified filter_complex
  • Adds deterministic caching for packshot output via ctx.cache — identical packshots are instant on re-render
  • Splits blinking-button.ts into composable pieces: renderBlinkingButtonPngs() (Sharp only) + buildBlinkingButtonFilter() (filter graph parts)

What changed

packshot.ts — Major rewrite

  • New buildPackshotFilter() constructs the complete filter graph directly, bypassing editly() entirely
  • Single backend.run() call produces the final video (background + logo + title + blinking CTA in one command)
  • computePackshotCacheKey() generates deterministic cache keys from all props + dimensions
  • Cache check before rendering, cache write after — uses the same ctx.cache as the rest of the SDK

blinking-button.ts — Refactored into composable parts

  • renderBlinkingButtonPngs() — Sharp operations only (text + button SVG + glow), returns temp PNG paths
  • buildBlinkingButtonFilter() — Returns filter_complex lines with configurable input indices for merging into a larger graph
  • All helpers exported (oscExpr, hexToRgb, even, getButtonYPosition, etc.)
  • Legacy createBlinkingButton() preserved for backward compat

layers.ts — Minor exports

  • escapeDrawText(), parseSize(), resolvePositionForOverlay() now exported for reuse

New packshot.test.ts

  • 10 unit tests covering helpers (hexToRgb, even, oscExpr, getButtonYPosition) and buildBlinkingButtonFilter() (structure, input indices, animation expressions, dimensions)

Performance impact

Scenario Before After Improvement
Single packshot (blinking CTA) ~77s ~25-35s ~55-65%
9 identical packshots (batch) ~6-7min ~25-35s (1st) + ~0s (cached) ~95%
9 different packshots, same CTA ~6-7min ~2.5-3min ~55%

Resolves #128

Reduces packshot rendering from 3 sequential Rendi round-trips (~77s) to
1 round-trip (~25-35s) by building a unified filter_complex that combines:
- Background (fill-color or image with cover mode)
- Logo overlay (scaled + positioned)
- Title drawtext
- Blinking CTA button animation (elastic scale/brightness pulse)
- Final composite

Key changes:
- packshot.ts: New buildPackshotFilter() builds complete filter graph directly,
  bypassing editly(). Added deterministic cache via ctx.cache.
- blinking-button.ts: Split into renderBlinkingButtonPngs() (Sharp only) and
  buildBlinkingButtonFilter() (returns filter_complex parts for merging).
  Legacy createBlinkingButton() preserved for backward compat.
- layers.ts: Export escapeDrawText(), parseSize(), resolvePositionForOverlay()
  for reuse without duplicating editly logic.
- New packshot.test.ts with 10 unit tests for helpers and filter builder.

Resolves #128
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 26, 2026

📝 Walkthrough

Walkthrough

refactors packshot rendering from 3 sequential ffmpeg jobs to a unified single-pass filter graph, exports layer helpers for reuse, separates blinking-button concerns into pure png generation and filter-building functions, and adds caching to avoid redundant renders.

Changes

Cohort / File(s) Summary
layer helpers export
src/ai-sdk/providers/editly/layers.ts
exports 3 previously private functions (escapeDrawText, parseSize, resolvePositionForOverlay) to enable reuse across renderers for text escaping, size parsing, and overlay positioning.
unified packshot pipeline
src/react/renderers/packshot.ts
replaces multi-step backend invocations with single unified ffmpeg filter_complex; introduces computePackshotCacheKey and caching layer; reworks background/cta handling; integrates newly exported layer helpers and blinking-button filter builders.
blinking-button modularization
src/react/renderers/packshot/blinking-button.ts
separates png rendering from filter building: new renderBlinkingButtonPngs generates pngs without backend, buildBlinkingButtonFilter constructs ffmpeg filters; exports 6 helper functions (hexToRgb, createButtonSvg, escapeXml, getButtonYPosition, even, oscExpr); legacy createBlinkingButton now orchestrates new flow.
blinking-button test suite
src/react/renderers/packshot/packshot.test.ts
new test file covering hexToRgb parsing, even function, oscExpr generation, getButtonYPosition positioning, and buildBlinkingButtonFilter structure/dimensions/animation expressions.

Sequence Diagram(s)

sequenceDiagram
    participant old as old packshot flow
    participant backend as ffmpeg backend
    old->>backend: job 1: editly base (bg + title)
    backend-->>old: base video
    old->>backend: job 2: blinking button (pngs + filter)
    backend-->>old: button video
    old->>backend: job 3: overlay (composite)
    backend-->>old: final output
    note over old,backend: 3 sequential round-trips (~75s)
Loading
sequenceDiagram
    participant new as new packshot flow
    participant cache as cache layer
    participant builder as filter builder
    participant backend as ffmpeg backend
    new->>cache: check cache key
    alt cache hit
        cache-->>new: cached output
    else cache miss
        new->>builder: compose unified filter_complex<br/>(bg + title + cta layers)
        builder-->>new: single filter graph
        new->>backend: single ffmpeg run
        backend-->>new: final output
        new->>cache: store result
    end
    note over new,backend: 1 round-trip (~25s) + zero intermediates
Loading

Estimated code review effort

🎯 4 (complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

Poem

🎬 three jobs became one swift glide,
filter graphs merged side by side,
no more waits for videos to fly,
caching speeds the render sky,
from 77 seconds down we go — meow! 🐱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed the title clearly and concisely summarizes the main optimization: merging packshot rendering into a single ffmpeg command with caching, matching the primary change in the changeset.
Description check ✅ Passed the description is detailed and directly related to the changeset, covering the major rewrites in packshot.ts, blinking-button.ts refactoring, layers.ts exports, test additions, and quantified performance improvements.
Linked Issues check ✅ Passed the pr successfully addresses issue #128 by merging three sequential ffmpeg jobs into one, implementing deterministic caching, and achieving the target ~55-65% performance improvement stated in the issue.
Out of Scope Changes check ✅ Passed all changes directly support the core objective of reducing round-trips and adding caching; the refactoring of blinking-button.ts, layers.ts exports, and new tests are all necessary scaffolding for the unified ffmpeg approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/packshot-single-command

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/react/renderers/packshot.ts`:
- Around line 285-312: The drawtext block for the static CTA incorrectly uses
opts.staticCtaColor for fontcolor; change it to prefer a text-color prop (e.g.,
opts.staticCtaTextColor) and/or the shared cta text color (opts.ctaTextColor)
before falling back to "white" so text color aligns with the blinking CTA;
update the fontcolor expression in the drawtext filter (the code that builds the
drawtext string using opts.staticCtaText, opts.staticCtaColor, staticCtaPosition
and ctaOutLabel) to use opts.staticCtaTextColor ?? opts.ctaTextColor ?? "white"
instead of opts.staticCtaColor ?? "white".

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9679ef1 and ea35fa3.

📒 Files selected for processing (4)
  • src/ai-sdk/providers/editly/layers.ts
  • src/react/renderers/packshot.ts
  • src/react/renderers/packshot/blinking-button.ts
  • src/react/renderers/packshot/packshot.test.ts

Comment on lines +285 to +312
if (opts.staticCtaText) {
const text = escapeDrawText(opts.staticCtaText);
const color = opts.staticCtaColor ?? "white";

const maxFontSize = Math.round(Math.min(width, height) * 0.08);
const maxTextWidth = width * 0.9;
const fittedFontSize = Math.floor(
maxTextWidth / (opts.staticCtaText.length * 0.55),
);
const fontSize = Math.max(16, Math.min(maxFontSize, fittedFontSize));

let x = "(w-text_w)/2";
let y = "(h-text_h)/2";

const pos = resolvePosition(opts.staticCtaPosition ?? "bottom");
if (typeof pos === "string") {
if (pos.includes("left")) x = "w*0.1";
if (pos.includes("right")) x = "w*0.9-text_w";
if (pos.includes("top")) y = "h*0.1";
if (pos.includes("bottom")) y = "h*0.9-text_h";
}

const ctaOutLabel = "cta_out";
filters.push(
`[${currentLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${color}:x=${x}:y=${y}[${ctaOutLabel}]`,
);
currentLabel = ctaOutLabel;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

static cta color might be using wrong prop

line 287 uses staticCtaColor ?? "white" as fontcolor, but looking at how it's called (lines 479-480), staticCtaColor gets props.ctaColor ?? "white".

however, ctaColor is the button background color (default #FF6B00 orange), not the text color. for the blinking version, ctaTextColor is used for text. shouldn't static cta use ctaTextColor for consistency?

suggested fix
-    staticCtaColor:
-      props.cta && !props.blinkCta ? (props.ctaColor ?? "white") : undefined,
+    staticCtaColor:
+      props.cta && !props.blinkCta ? (props.ctaTextColor ?? "#FFFFFF") : undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/react/renderers/packshot.ts` around lines 285 - 312, The drawtext block
for the static CTA incorrectly uses opts.staticCtaColor for fontcolor; change it
to prefer a text-color prop (e.g., opts.staticCtaTextColor) and/or the shared
cta text color (opts.ctaTextColor) before falling back to "white" so text color
aligns with the blinking CTA; update the fontcolor expression in the drawtext
filter (the code that builds the drawtext string using opts.staticCtaText,
opts.staticCtaColor, staticCtaPosition and ctaOutLabel) to use
opts.staticCtaTextColor ?? opts.ctaTextColor ?? "white" instead of
opts.staticCtaColor ?? "white".

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.

perf: reduce packshot Rendi round-trips from 3 to 1-2

1 participant