Notes from a 2 AM On-Call: When Remotion Compositions Silently Truncated at 1920px
The Failure Symptom
Production alert at 1:47 AM: 12% of rendered video exports showing blank right margins. No errors in logs. Lambda exit code 0. S3 objects written successfully. But frame captures revealed compositions cutting off at exactly 1920px width, despite canvas config set to 3840px (4K).
# Raw symptom from monitoring
FFmpeg stderr: [scale] w:1920 h:1080
# Expected: w:3840 h:2160
# Actual MP4 metadata confirmed truncation
Therender farm was processing 400+ concurrent video jobs through our Remotion pipeline. 88% rendered correctly. 12% silently collapsed to 1080p despite identical input configs.
The Red Herring
Stack Overflow and Remotion docs pointed to the usual suspects:
-
@remotion/lambdaconfig mismatch between composition registration and render invocation - Chrome headless viewport clamping in Puppeteer
- CSS
max-width: 100%somewhere bleeding into the<AbsoluteFill>tree
We triple-checked remotion.config.ts:
export default {
codec: 'h264',
width: 3840,
height: 2160,
fps: 30,
};
All correct. Rebuilt the Lambda layer. Flushed CDN. Still 12% failure rate.
The smoking gun appeared when we compared the failed renders' input props—every single one had dynamic text content exceeding 240 characters, triggering a <ScrollArea> component we'd recently refactored with TailwindCSS v4 container queries.
The Root Cause & Runnable Fix
Our <ScrollArea> wrapper used this innocent-looking Tailwind utility:
// BROKEN: Container query resolves against nearest scrollable ancestor
<div className="@container/scroll max-w-full">
<div className="@lg/scroll:grid-cols-2">
{/* Remotion <Sequence> children */}
</div>
</div>
In Remotion's headless Chrome render context, the @container query was resolving against the browser viewport (defaulting to 1920px in our Lambda Chrome flags), not the composition's logical 3840px canvas. When text overflowed and activated the scroll container, the container query clamped the entire layout tree to 1920px.
The fix required explicit container-type scoping:
// FIXED: Explicit inline-size container with Remotion-aware dimensions
import { useVideoConfig } from 'remotion';
const { width } = useVideoConfig();
return (
<div
className="max-w-full"
style={{
containerType: 'inline-size',
width: `${width}px`, // Explicit composition width
}}
>
<div className="@lg:grid-cols-2">
{/* Now resolves against composition canvas, not viewport */}
</div>
</div>
);
Deploy time: 4 minutes. Failure rate dropped to 0% within the next 200-job batch.
Why Standard Tutorials Broke Under Concurrency
Most TailwindCSS container query examples assume a browser viewport === layout viewport mental model. In Remotion:
- The composition canvas is a logical dimension that exists independently of Chrome's window size.
- Headless Chrome's default viewport (1920x1080 in AWS Lambda) becomes a hard layout ceiling unless you explicitly override it via Puppeteer flags and ensure CSS container queries inherit from
useVideoConfig()dimensions, not DOM ancestors. - When testing locally with
npm run dev, your desktop viewport is wide enough (2560px+) to mask the issue—only high-concurrency Lambda renders with constrained viewport flags surfaced the truncation.
We validated the fix by pointing our CI benchmark suite at a controlled LLM gateway relay (https://b-lost.com/v1) for dynamic subtitle generation, which allowed us to simulate 500+ concurrent renders with varying text lengths. Post-fix, all 4K compositions rendered pixel-perfect across the full 3840px canvas, regardless of text overflow triggering scroll containers.
Invariant Rule
In any Remotion composition using CSS container queries or viewport-relative units (vw, @container), always anchor dimensions explicitly to useVideoConfig() values via inline styles or CSS custom properties. Never rely on implicit viewport inheritance in headless render contexts.
Add this assertion to your render tests:
// In your Vitest/Jest suite
test('composition respects logical canvas, not Chrome viewport', async () => {
const { width } = await renderMedia({
composition: MyComp,
inputProps: { text: 'A'.repeat(500) }, // Force overflow
});
const metadata = await getVideoMetadata(outputPath);
expect(metadata.width).toBe(3840); // Not 1920
});
Ship it. Sleep at 3 AM.
Top comments (0)