Back to Blog

One Source of Truth for Aspect Ratio: Retrofitting 16:9 Into a Portrait-Only Pipeline

By · 9 min read
Video Rendering Aspect Ratio Feature Flags TypeScript

The main video-generation service was portrait-only, and not by a config flag — 9:16 was hardcoded in roughly thirty places: FFmpeg boxes, a Gemini environment constant read once at import, an OpenAI size parameter, ten English prompt strings, twelve Tailwind classes, the caption canvas. None of those thirty sites referenced each other. "Add 16:9" wasn't one change, it was thirty independent edits with no shared seam, and no way to verify you'd found all thirty until a landscape run either worked or silently didn't.

When a value is duplicated in thirty places with no shared reference, you don't have thirty features. You have one feature that will break in thirty places, one at a time, on a schedule you don't control.

The inert table first

PR #241 built a single resolution table for aspect ratio and shipped it doing nothing — no consumer read it yet. That's deliberate: this value touches the delivery path of every existing run, so each PR that follows carries its own "vertical is unchanged" assertion. Ship the seam, verify it changes nothing, then move consumers onto it one at a time instead of swapping thirty call sites in one PR and hoping the diff review catches every miss.

Settings that recorded a choice nobody made

Before aspect ratio could vary per run, per-job settings had to actually apply. PR #224 found per-job settings not reaching the render call; PR #238 found four separate values recorded as "the editor's choice" when no editor had touched them — defaults masquerading as explicit decisions, which matters the moment you need to know whether a run's shape was chosen or assumed. PR #235 found two editor-facing routes that could spend money outside any run entirely, unrelated to aspect ratio but caught by the same settings audit. PR #234 fixed a promise mismatch — editors were told they could upload up to 500MB and the server refused at 200. PR #239 made the create-form's request body reachable by a test at all, which is what let #233 catch that the start route's input mapping was the one untested line in the whole feature.

Containers before pixels

PR #242 through #245 made the containers aspect-aware: the organic pass was squeezing a landscape master into a portrait box, uploaded b-roll was pillarboxed on any non-vertical run, captions had two separate aspect bugs stacked on top of each other, and the render target was resolved once per process instead of per run — meaning a portrait run and a landscape run started close together could read each other's setting.

The frame side is what decides the shape

PR #246 is the pivot from containers to pixels, and it's the harder half: most of it isn't typed at all. Two module-level constants — one in the Gemini image client, three system-prompt constants in the prompt author — were evaluated once per process import, so they could not vary per run even in principle, no matter what the settings table said. Same defect as the earlier render constants, different file, same root cause: a value computed once at cold start pretending to be a per-request parameter.

// before: evaluated once at import, frozen for the process lifetime
const ASPECT_RATIO = process.env.GEMINI_IMAGE_ASPECT ?? '9:16';

// after: a function, resolved per run
function resolveAspectRatio(settings: RunSettings): AspectRatio {
  return process.env.GEMINI_IMAGE_ASPECT // operator override still wins
    ?? settings.aspectRatio
    ?? '9:16';
}

The operator override — GEMINI_IMAGE_ASPECT — deliberately stays first in resolution order even after the fix. It's an existing escape hatch for pinning every generation during an incident, and the aspect-ratio work preserved it rather than removing an operational lever nobody asked to lose.

A defense with nothing to fail loudly

fal-kling and fal-motion, the clip vendors downstream of the frame, take no aspect parameter at all — the clip's shape simply follows whatever shape the input frame already is. PR #247's insight is that this makes them correct by construction once #246 lands, and that's exactly the danger: there is no field to get wrong, so there is nothing that can fail loudly. If a frame comes back the wrong shape — a vendor silently ignoring a size hint, a stale cached frame from before the aspect pin took effect, an operator override left on from a previous incident — every downstream step just accepts it and renders the wrong shape end to end with no error anywhere in the chain. The fix adds an explicit shape check with nothing implicit to rely on.

Flipping the switch

PR #249 is the commit that actually lets a run be something other than vertical, and the diff is almost nothing — because everything from #241 onward had been inert or shape-preserving. State lives entirely in settings.aspectRatio; the form already posts one settings blob, and parseSettingsField already iterates a registry. The submit path, input schema, and start route are untouched. And because the settings blob only ever carries values moved off their default, picking 9:16 — still the default — sends nothing at all. Absence stays absence for free, with no phantom flag to clean up later, which is the same property PR #238 had to hunt down and remove by hand earlier in the same cluster.

The bug real rendering caught that types couldn't

PR #270 is the one I'd flag to anyone who thinks a type-safe settings pipeline is sufficient. It was found by rendering a vertical A/B test and looking at the actual frames: both came back 1536×2752 — identical shape, differing only by seed. The size setting had zero effect, because the model doing the rendering was Gemini ("Nano Banana"), not gpt-image, and preferredImageModel() silently routes to Gemini whenever a Gemini key is configured — which is the default. renderFrameApi only pulled the prompt and image blobs out of the request; the size field was read by nobody. The entire frameSizeFinal/frameSizePreview resolution table, correct end to end, was feeding a parameter the active model didn't consume. Types verify a value exists and has the right shape. They don't verify the function on the other end reads it.

PR #265 caught the sibling failure mode on the output side: landscape frames were rendered at 3:2 and cropped at the final stitch rather than at the correct ratio from the start — a "close enough" ratio compounding error at the one step nobody was watching. PR #266 shipped the vertical frame size itself behind its own switch, matching the pattern the whole cluster had established: ship inert, verify unchanged, then flip.

Closing the gap with a real render, not a unit test

PR #269 is the test that would have caught #270 before it shipped: an actual rendered clip, not a mock, asserting the real output shape — plus pinning what a $0.20 vendor success response is actually worth, so the cost of running this check on every merge is a known, bounded number rather than an unbounded "however much fal charges today."

The pattern: ship the seam, then move consumers one at a time

Nine-plus PRs to add a second aspect ratio to a mature pipeline sounds like overkill until you count how many independent things "aspect ratio" actually touches: settings plumbing, container layout, model routing, vendor request shape, caption placement, and the render/stitch pipeline. Doing it in one PR means one enormous diff with no place to put a "this specific thing is unchanged" assertion. Doing it as an inert table first, then one shape-preserving consumer migration at a time, means every PR after the first has a narrow, falsifiable claim attached to it — and the two bugs that mattered most in this whole cluster, #270 and #247, were only findable because someone looked at an actual rendered frame instead of trusting that a correct-looking settings pipeline implied a correct output.

Related Articles

  • Dual Aspect-Ratio Rendering
    The same 16:9/9:16 problem, solved a few days earlier in a sibling tool with a shared audio track
  • Ship It Inert First
    Same rollout discipline — build the seam inert, verify, then wire consumers one at a time
  • Voice Pipeline Economics
    The same week, the same service — billing, caching, and a voice clone dropped on the floor