Engineering

Leonardo AI Share Cards: Building Cinematic Social Graphics with Playwright and Base64 Embedding

Most share card pipelines fail at the seams between generation, rendering, and delivery. Here's how we solved all three at once.

March 9, 2026
8 min read
#playwright#leonardo-ai#share-cards
Leonardo AI Share Cards: Building Cinematic Social Graphics with Playwright and Base64 Embedding⊕ zoom
Share

The hardest part of building a share card pipeline isn't generating the image. It's the moment when your renderer, your filesystem, and your image source all live in different execution contexts — and you discover they can't see each other.

Most engineers solve this by adding layers: a temp directory, a static server, a CDN pre-upload step. Every added layer is a new failure mode. We solved it differently. The entire share card pipeline for devlog-engine — AI-generated backgrounds from Leonardo Phoenix 1.0, SVG performance charts, and Playwright screenshot rendering — runs as a single self-contained execution unit. No intermediary filesystem reads. No asset serving. No race conditions between write and render.

The mechanism is base64 data URL embedding. Everything the renderer needs is embedded directly in the HTML string passed to Playwright. This is not a workaround. It's the correct architecture for this problem class.

Most Share Card Pipelines Fail at Handoff, Not Generation

The standard architecture looks like this: generate an image, write it to disk, reference it by path in your HTML template, launch a headless browser, take a screenshot. Simple on paper. In practice, Playwright's page context doesn't resolve local filesystem paths the way Node does. Pass file:///tmp/background.png into a <img src> tag inside a setContent() call and watch it silently fail. The background renders as a broken image. Your share card ships with a blank canvas.

Teams work around this with a local static server. Spin up Express, serve the assets directory, reference http://localhost:3001/background.png in the template. Now you're managing server lifecycle inside your image generation job. You're handling port conflicts. You're adding startup latency. You've converted a generation problem into a distributed systems problem.

WARNING

The local static server workaround adds 3 new failure modes: port conflicts, server startup race conditions, and teardown cleanup. Every workaround that introduces process coordination is a bug waiting to surface in CI.

The insight that changes the design: Playwright doesn't need to resolve assets. It needs the assets to already be resolved — encoded directly into the document it receives. Base64 data URLs satisfy that constraint completely. No external references. No filesystem reads. No server required.

Leonardo Phoenix 1.0 Generates the Cinematic Layer

The background isn't a stock photo or a gradient. Each share card gets a custom AI-generated image, produced via the Leonardo AI API using their Phoenix 1.0 model. The prompt is constructed dynamically — it incorporates the article's category, emotional register, and key subject matter. An engineering post about distributed systems gets a different visual texture than a market analysis post.

The generation call returns a URL pointing to Leonardo's CDN. That URL has a limited TTL. You cannot store it as a reference — by the time your renderer tries to fetch it, the session may have expired. You also cannot pass it directly into the HTML template as a <img src> value inside Playwright's setContent() — the headless browser context won't necessarily have access to fetch that CDN URL, depending on network policy and context isolation.

The correct step: immediately after receiving the Leonardo response, fetch the image buffer and convert it to a base64 data URL.

const response = await fetch(leonardoImageUrl);
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
const dataUrl = `data:image/jpeg;base64,${base64}`;

That dataUrl string is now the background image. It travels into the HTML template as a CSS background-image property. Playwright receives a fully self-contained document. The cinematic background renders at full fidelity — no CDN dependency, no TTL risk, no fetch inside the headless context.

Image Resolution
1200×630
OG standard — optimized for Twitter/X and LinkedIn card previews

SVG Charts Solve the Same Problem a Different Way

Performance charts on the share card — portfolio curves, signal accuracy plots — are generated as SVG strings. SVG is already text. You can embed it inline in HTML without any encoding step. No file write, no fetch, no base64 conversion needed.

The chart component produces a raw SVG string. That string gets interpolated directly into the HTML template:

const chartSvg = generatePerformanceCurve(signalData);
const html = `
  <div class="chart-container">
    ${chartSvg}
  </div>
`;

This is the underappreciated advantage of SVG in rendering pipelines: it's not an asset. It's markup. Playwright renders it exactly as a browser would — no resolution step required. The chart and the background are now unified under the same embedding model: everything is either inline SVG or a base64 data URL. The renderer has zero external dependencies.

INSIGHT

SVG charts and base64 backgrounds solve the same root problem from opposite directions. SVG is already text — embed directly. Raster images are binary — encode to text first. The result is identical: a document that carries all its own assets.

Playwright Is a Precision Instrument, Not a Screenshot Tool

Most engineers reach for Playwright's screenshot API and call it done. The production-quality share card pipeline requires more surgical control. Viewport dimensions, device pixel ratio, and clip region all affect the output.

The viewport must match the target dimensions exactly — 1200×630 for OG standard. Device pixel ratio at 2 gives you a 2400×1260 physical pixel output that scales down cleanly without aliasing artifacts. The screenshot is taken with fullPage: false and a explicit clip boundary to prevent any overflow from adjacent elements bleeding into the capture.

const browser = await chromium.launch();
const page = await browser.newPage();

await page.setViewportSize({ width: 1200, height: 630 });
await page.setContent(html, { waitUntil: "networkidle" });

const screenshot = await page.screenshot({
  type: "png",
  clip: { x: 0, y: 0, width: 1200, height: 630 },
});

await browser.close();

waitUntil: "networkidle" is critical. Even with base64 embedding, there are CSS transitions and font loading events that need to settle. Without this, you occasionally capture a frame mid-render — fonts not fully loaded, layout slightly shifted. The idle wait adds ~200ms and eliminates the entire class of partial-render artifacts.

The self-contained rendering unit pattern — no external assets, no server dependencies, explicit viewport control, idle-wait before capture — is what separates a share card pipeline from a screenshot hack. The former is reproducible. The latter is eventually unreliable.

The Pattern Generalizes Beyond Share Cards

This architecture is a direct application of Boyd's closure principle: reduce the number of open loops in a system. Every external dependency in a rendering pipeline is an open loop — a reference that must be resolved at runtime, by an external party, under time pressure. Base64 embedding closes those loops at generation time.

Operate inside the adversary's OODA loop — or in systems terms, resolve all dependencies before the execution phase begins.

John Boyd · Patterns of Conflict

The pattern applies anywhere you're driving a headless browser with assets you don't control: PDF generation from dynamic data, email template rendering, automated report capture. The constraint is always the same — the renderer's resolution context differs from the generator's. The solution is always the same: make the document self-describing. Encode everything into the payload. Don't trust runtime asset resolution.

What we built for devlog-engine share cards is a microcosm of a broader principle: systems that carry their own context don't fail at handoffs. The handoff is where complexity lives. Eliminate the handoff.

The share card pipeline runs in under four seconds end-to-end — Leonardo generation, base64 conversion, SVG chart injection, Playwright capture, and write to S3. No temp files. No local servers. No race conditions. The architecture earned that performance by refusing to introduce the coordination overhead that makes these pipelines fragile in the first place.

Visual Summary
click to expand
Article visual summary

Explore the Invictus Labs Ecosystem

// Join the Network

Follow the Signal

If this was useful, follow along. Daily intelligence across AI, crypto, and strategy — before the mainstream catches on.

No spam. Unsubscribe anytime.

Share
// More SignalsAll Posts →