Brand Image Logo — Design

Status: Shipped

Goal

The brand layer’s logo was text-only: a name and an optional tagline, cascade-animated letter-by-letter by the logo-assemble-lockup blueprint (see Brand Typography Ramp). That’s a fine default, but a lot of brands already have an actual logo mark and want that on screen instead of a generated wordmark. This doc covers the v1 design for logo.image — letting a brand supply a real image asset that renders inside the shot-sequence pipeline’s brand lockup scene.

The URL-only decision

logo.image takes exactly one shape: an https URL, validated by a Zod .refine() against isOurCdnUrl() — the URL’s origin must match the deployment’s own configured public asset host (R2_PUBLIC_URL, or the exact R2_PUBLIC_FALLBACK_DOMAIN). Anything else — a private-IP literal, an arbitrary third-party image host, a non-https scheme — is rejected at schema parse time, before it ever reaches a render.

Why this is the right boundary to enforce, and why here specifically: the image is rendered by Remotion’s <Img> inside a headless browser during the render job — the browser fetches whatever URL it’s given directly. An unconstrained URL field on a server-triggered render job is a textbook server-side-request-forgery (SSRF) shape: an attacker-supplied brand could otherwise point logo.image at an internal service, a cloud metadata endpoint, or any other address the render infrastructure can reach. Locking the field to our own CDN’s exact origin removes that class of attack entirely, at the cost of a real (accepted) limitation — see “Deferred fast-follows” below.

One schema, not three copies. logo.image lives inside BrandTokens, which is validated by a single Zod schema (brandTokensSchema in plan-schemas.ts) reused wherever a shot-sequence plan is parsed:

That’s not three independent implementations of the check — it’s the same .refine(isOurCdnUrl) clause imported and re-applied at each layer, so there is exactly one place to fix or audit if the allowlist logic ever needs to change. A test in plan-validate-logo.test.ts (specifically: it rejects an external logo.image in a pre-baked plan via the direct-render SSRF gate) asserts that the ingress a caller is most likely to try to sneak an external URL through — posting a pre-baked plan straight to /v1/render-video/plan — is rejected identically to the authored-brief path.

This is also, in practice, a stricter check than audio.src (which lives in the same shot-sequence plan schema): audio.src uses the general syntactic SSRF gate (safeUrlSchema — blocks localhost/private-IP literals but allows any other public http(s) host), while logo.image pins the origin to our own CDN specifically, because unlike a Node-side download, nothing else re-validates the URL before Remotion’s browser fetches it. The general image reveal element added since this doc shipped (see “general image element” below) uses that same strict gate, not a looser one — shotImageElementSchema.src is validated by cdnMediaUrlSchema, the exact same isOurCdnUrl() check factored into its own leaf module (cdn-media-url.ts) so both plan-schemas.ts and blueprint-params.ts can import it without a cycle. logo.image’s posture was the template, not an outlier. (An unrelated, same-named image field also exists in the separate, older scene-graph plan schema — sgMediaSegmentSchema in sceneGraphPlanSchema — a different rendering pipeline, out of scope here.)

Contained in the blueprint

The image capability doesn’t introduce a new element type, and the logo-assemble-lockup blueprint’s own params schema is unchanged (brand, tagline, accentColor — the same three fields as before). The image comes from the resolved BrandTokens object already threaded to every blueprint as an ambient brand prop, not from anything the brief author passes to this specific blueprint. Concretely: LogoAssembleLockup reads brand.logo?.image / brand.logo?.imageBackdrop directly, and chooseMediaRender() — a pure decision (render the image when one is present and hasn’t errored, else fall back to the existing per-letter cascade of params.brand, the wordmark text) rendered via the shared <MediaFrame> component — makes the call.

logo.image the field is still contained to one call site: no other blueprint, and no part of the shot-sequence element schema (text / shape / image reveals), reads BrandTokens.logo. The rendering mechanism is no longer private to this file, though — chooseMediaRender/MediaFrame now live in a shared lib/media-frame.tsx module reused by device-surface-showcase, cursor-ui-demo, and the general image reveal element (see “general image element” below — since shipped), so a change to that shared decision/render logic has a blast radius across all of them, not just the logo lockup.

The deterministic-net guarantee

A logo image is only worth uploading if it’s actually guaranteed to appear somewhere in the video — an LLM-authored brief that “forgets” to include a logo-assemble-lockup reveal would silently make the feature a no-op. Two layers make the appearance deterministic:

  1. Director steering. buildBrandBlock() appends a MUST-include instruction to the authoring prompt whenever brand.logo.image is set: include exactly one logo-assemble-lockup reveal (intro or outro). This is the cheap, common-case path — the LLM almost always complies.
  2. A pure deterministic backstop. ensureLogoLockupScene(brief, brand) runs after authoring, independent of what the LLM actually produced: if the brand has a logo image and the brief has no logo-assemble-lockup reveal anywhere, it appends a trailing branding scene with one. Identity in every other case — a brief that already includes the reveal, or a run with no logo image, comes back byte-identical. Wired into the orchestrator’s briefToBake helper, so it runs on both the main bake and the self-repair re-bake (see the resolve-repair behavior documented in Video Director).

The appended reveal anchors 100ms past the last narration cue’s end rather than exactly at it. Without the offset, an authored closing reveal that also anchors to “last cue, end” (a common CTA-holds-to-the-end pattern) would tie frameAbs with the appended scene, and the baker’s overlap guard hard-rejects exact ties instead of applying its normal tail-clamp recovery (that recovery only fires when the earlier scene’s own anchor frame is strictly before the new scene’s start). 100ms guarantees at least one frame of separation even at the schema’s lowest fps floor (15fps → 66.7ms/frame) — enough for the clamp to trim the previous scene’s tail instead of erroring.

Net effect: setting logo.image on a brand and calling create_explainer / create_launch_video reliably puts that logo on screen, whether or not the authoring LLM remembered to ask for it.

Fallback behavior

If the image URL 404s, times out, or is otherwise unreachable at render time, <Img>’s onError flips chooseMediaRender() back to the text cascade — the render always completes; it just falls back to the wordmark instead of failing the job. Remotion’s <Img> retries a failing load (default: 2 retries, exponential backoff) before calling onError, holding the render frame each time — so the fallback typically doesn’t trigger until a few seconds after the initial failed load. That matters operationally: if a render is kicked off immediately after uploading the logo image, and CDN propagation hasn’t finished, the retry window may not be long enough to bridge the gap — the render will fall back to the wordmark even though the URL is valid and will resolve moments later. There’s no special-cased retry or wait added for this; a re-render after the image has propagated picks it up normally.

Accepted risk: no upload dimension cap

Stated explicitly: this feature does not add a pixel-dimension cap to image uploads. logo.image must come from the same general-purpose /v1/upload (or /v1/upload/image) endpoint every other node’s image uploads go through — it has no notion of “this upload is a logo.” Uploads are stored as-is (no resizing, no dimension changes) subject to the existing file-size ceiling and image-format allowlist — note that HEIC/HEIF images are transcoded to JPEG before storage, but pixel dimensions are preserved. There’s no per-pixel-dimension limit at any layer.

Adding one specifically for logos wasn’t feasible without either (a) a new, logo-specific upload path (more surface area, another thing to keep in sync with the general uploader), or (b) a global dimension cap on /v1/upload, which would risk rejecting legitimate large-image uploads for unrelated node types that have no such constraint today. Neither seemed proportionate to the actual risk: a logo URL, once validated, is fetched the same way and is no riskier than the audio.src URLs render-video already accepts elsewhere in the same plan — and the render route’s existing rate limit (10 requests/minute) already bounds how fast either could be abused. The blueprint itself contain-fits the image into a fixed box regardless of source resolution, so an oversized source image doesn’t distort layout — it just costs more render-time decode work than a right-sized one would.

Deferred fast-follows

None of these are ruled out — they’re out of scope for v1 because the URL path alone already covers the common case (a marketing team has a logo PNG somewhere and can paste a link to it):