← Files HiggsfieldARCHIVED FILE

skills/thumbnail-generation/references/text-overlay-bake.md

11.1 KB · Oct 2, 2026 · 00:02 UTC

↓ Download file

# Big-bold thumbnail text overlay & bake (HTML/CSS + canvas)

How to lay a big bold viral-style headline over the image and "bake" it into a flat PNG.
Two layers: (1) a **live HTML/CSS preview** to tune the text in a browser, (2) a
**canvas bake** — the same look re-rendered to pixels at the image's native resolution.

This is the implementation of the 5 overlay styles named in the skill's Text policy. The
overlay is the DEFAULT delivery for headline text (zero generation credits, always legible);
baking text INTO the generation is the fallback, only on an explicit ask.

## What makes text high-impact

Not one trick but a stack of six — remove any one and it falls apart.

| Trait | Value | Why |
|---|---|---|
| Font | **Anton** (or Anton SC), one weight, reads `900`-heavy | fat condensed grotesk — the signature |
| Case | **ALL CAPS** | maximum density and aggression |
| Stroke | manual, **thick** (8–14% of cap size), drawn UNDER the fill | separates letters from any background |
| Shadow | hard, dark, offset down, blur shifted | "sticker" depth |
| Color | white / yellow→orange→red gradient / acid lime `#D4FF3F` | punchy contrast, reads in the feed |
| Tracking | tight (`-0.01…-0.02em`), line-height `0.9` | letters lock together like a logo |

Placement rules:
- **2–4 words max.** A headline, not a sentence.
- **Not on the face.** Text in a free quarter (bottom / corner / side).
- **Large.** Cap height 12–18% of frame height. When in doubt, bigger.
- **Margins:** keep text within the frame with comfortable padding, off the extreme edges.

## Layer 1 — live HTML/CSS preview

Feed the background into `--bg`, put the text in `<h1>`, tune it in a browser.

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: url('photo.jpg');   /* local path or image URL */
    --W: 1280px;              /* frame size (16:9 = 1280×720) */
    --H: 720px;
  }
  * { margin: 0; box-sizing: border-box; }
  #poster {
    position: relative;
    width: var(--W); height: var(--H);
    background: var(--bg) center/cover no-repeat;
    overflow: hidden;
    font-family: 'Anton', sans-serif;
  }
  /* text block — bottom-center, comfortable margin */
  #headline {
    position: absolute;
    left: 50%; bottom: 6%;
    transform: translateX(-50%);
    text-align: center;
    line-height: 0.9;
    letter-spacing: -0.01em;
    text-transform: uppercase;
    white-space: pre-line;         /* line breaks via \n in the text */
  }
  #headline .line {
    font-size: 120px;              /* ≈16% of 720 */
    color: #fff;
    /* CRITICAL: paint-order draws the stroke UNDER the fill — otherwise it eats the letters */
    -webkit-text-stroke: 14px #000;
    paint-order: stroke fill;
    /* hard "sticker" shadow */
    text-shadow: 0 8px 0 rgba(0,0,0,.35), 0 14px 24px rgba(0,0,0,.55);
  }
</style>
</head>
<body>
  <div id="poster">
    <div id="headline">
      <div class="line">I SPENT</div>
      <div class="line" style="color:#D4FF3F">100 DAYS</div>
    </div>
  </div>
</body>
</html>
```

> `paint-order: stroke fill` is critical. Without it the stroke paints ON TOP and "eats" half
> the letter — the #1 bug in 90% of home-made big-bold thumbnail text.

## 5 proven presets (swap the `.line` block) — same 5 as the skill's Text policy

### Beast — white + thick black stroke (default)
```css
.line { color:#fff; -webkit-text-stroke:14px #000; paint-order:stroke fill;
        text-shadow:0 8px 0 rgba(0,0,0,.35), 0 14px 24px rgba(0,0,0,.55); }
```

### Fire — yellow→orange→red gradient
```css
.line { -webkit-text-stroke:14px #1a0a00; paint-order:stroke fill;
        background:linear-gradient(#FFE24B 0%, #FF9A1F 45%, #FF2E2E 100%);
        -webkit-background-clip:text; background-clip:text; color:transparent;
        filter:drop-shadow(0 0 22px rgba(255,120,0,.55)) drop-shadow(0 10px 0 rgba(0,0,0,.4)); }
```

### Neon Lime — acid lime + glow
```css
.line { color:#D4FF3F; -webkit-text-stroke:12px #0a1400; paint-order:stroke fill;
        text-shadow:0 0 26px rgba(180,255,40,.7), 0 10px 0 rgba(0,0,0,.4); }
```

### Clean Glass — Inter 800 on a frosted pill
```css
#headline { font-family:'Inter',sans-serif; }
.line { font-weight:800; color:#fff; letter-spacing:-0.02em;
        background:rgba(20,20,25,.45); backdrop-filter:blur(16px);
        padding:.12em .5em; border-radius:.2em;
        box-shadow:0 20px 60px rgba(0,0,0,.5); }
```

### Marker — black Anton on lime line-boxes
```css
.line { color:#0a0a0a; background:#D4FF3F; box-decoration-break:clone;
        padding:0 .18em; box-shadow:0 8px 0 rgba(0,0,0,.5); }
```

## Layer 2 — bake to PNG (canvas)

The same look, re-rendered in canvas at native resolution — a clean PNG, zero dependencies.
**Important: wait for the font to load (`document.fonts.load`) BEFORE the first draw, or it
bakes the system font.**

```html
<canvas id="c"></canvas>
<script>
async function bake({ src, lines, W, H, out='baked.png' }) {
  // 1) load Anton BEFORE drawing
  const fontLink = document.createElement('link');
  fontLink.rel = 'stylesheet';
  fontLink.href = 'https://fonts.googleapis.com/css2?family=Anton&display=swap';
  document.head.appendChild(fontLink);
  await document.fonts.load('120px "Anton"');
  await document.fonts.ready;

  // 2) background
  const img = new Image();
  img.crossOrigin = 'anonymous';        // else toDataURL fails on CORS (for URLs)
  await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = src; });

  const cv = document.getElementById('c');
  cv.width = W; cv.height = H;
  const ctx = cv.getContext('2d');
  // cover-fit the image
  const s = Math.max(W / img.width, H / img.height);
  const dw = img.width * s, dh = img.height * s;
  ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);

  // 3) text
  const fontPx = Math.round(H * 0.16);   // ≈16% of frame height
  const lineH  = fontPx * 0.9;
  const stroke = Math.round(fontPx * 0.11);
  ctx.font = `${fontPx}px "Anton"`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'alphabetic';
  ctx.letterSpacing = '-2px';            // tight tracking (Chrome/Edge)

  const cx = W / 2;
  const totalH = lines.length * lineH;
  let y = H * 0.94 - totalH + lineH;     // block pinned to the bottom, comfortable margin

  for (const ln of lines) {
    // hard shadow
    ctx.save();
    ctx.shadowColor = 'rgba(0,0,0,.55)';
    ctx.shadowBlur = 24; ctx.shadowOffsetY = 14;
    // paint-order: stroke FIRST
    ctx.lineWidth = stroke;
    ctx.strokeStyle = '#000';
    ctx.lineJoin = 'round';               // no sharp spikes on thick-stroke corners
    ctx.miterLimit = 2;
    ctx.strokeText(ln.text, cx, y);
    ctx.restore();

    // fill on top (white / gradient)
    if (ln.gradient) {
      const g = ctx.createLinearGradient(0, y - fontPx, 0, y);
      ln.gradient.forEach(([stop, col]) => g.addColorStop(stop, col));
      ctx.fillStyle = g;
    } else {
      ctx.fillStyle = ln.color || '#fff';
    }
    ctx.fillText(ln.text, cx, y);
    y += lineH;
  }

  // 4) export
  cv.toBlob(b => {
    const a = document.createElement('a');
    a.download = out; a.href = URL.createObjectURL(b); a.click();
  }, 'image/png');
}

// example call
bake({
  src: 'photo.jpg',
  W: 1280, H: 720,
  lines: [
    { text: 'I SPENT', color: '#fff' },
    { text: '100 DAYS', gradient: [[0,'#FFE24B'],[0.5,'#FF9A1F'],[1,'#FF2E2E']] },
  ],
});
</script>
```

Mechanics that are easy to forget:
- **`strokeText` before `fillText`** — stroke under fill (the equivalent of `paint-order: stroke fill`).
- **`document.fonts.load(...)` + `await document.fonts.ready`** before the first draw — else the first frame bakes the system font.
- **`lineJoin='round'`** — removes sharp artifacts on the corners of a thick stroke.
- **`img.crossOrigin='anonymous'`** only when the background is a URL; for a local file
  (`<input type=file>` → `URL.createObjectURL`) it isn't needed and CORS doesn't block.
- Draw the shadow inside `save()/restore()`, else `shadowBlur` bleeds onto the fill and smears the color.

## 4K / retina export

For a sharp PNG larger than the preview, compute everything from the image's NATIVE size,
not from the preview viewport:

```js
bake({ src: '4k_render.png', W: 3840, H: 2160, lines: [...] });
```

Cap size, stroke and margins are all tied to `H` (in percent), so at 2160px everything scales itself.

## Font menu (Anton is the default — these are the overrides)

If the user asks for a different font, pick from this menu (all on Google Fonts, so they
load through the same mechanism as Anton). Named font → use it; "something else / not Anton"
→ pick the closest fit below and say which you chose.

**A — Top 3 on YouTube (punchy display, the Anton alternates)**

| Font | Google Fonts weight | Vibe / use |
|---|---|---|
| Bebas Neue | 400 (reads bold) | tall narrow ALL-CAPS — the #1 Anton alternative, maximum cap height |
| Oswald | 600–700 | condensed grotesk, a touch softer than Anton |
| Archivo Black | 400 (ultra-bold) | blocky, wide, very loud headline |

**B — 5 most-used bold workhorses**

| Font | Weight | Vibe / use |
|---|---|---|
| Montserrat | 900 (Black) | clean geometric sans, modern headline |
| Poppins | 800 (ExtraBold) | rounded geometric, friendly |
| Roboto Condensed | 700 (Bold) | neutral condensed workhorse |
| Inter | 800 | modern UI sans (same face as the Clean Glass preset) |
| Barlow Condensed | 800 (ExtraBold) | condensed, energetic |

**C — 5 aesthetic (girls' vlogs / soft-elegant)**

| Font | Weight | Vibe / use |
|---|---|---|
| Playfair Display | 700–900 | elegant high-contrast serif — classic "aesthetic" title |
| Cormorant Garamond | 600–700 | delicate refined serif, editorial feel |
| DM Serif Display | 400 | high-end display serif |
| Fraunces | 600–900 | soft "old-style" serif, trendy |
| Sacramento | 400 (script) | handwritten script — ACCENT / secondary line only, never the hero word (a script fails the ~120px legibility test as the main headline) |

**How to swap (keep everything else):**
- HTML/CSS: `#headline { font-family: '<Family>', sans-serif; }` and set `.line { font-weight: <w>; }`; update the Google-Fonts `<link>` to that family.
- Canvas: `await document.fonts.load('120px "<Family>"')` and `ctx.font = \`<weight> ${fontPx}px "<Family>"\``.
- Stroke / shadow / `paint-order` / tracking stay as in the presets — only the family (and weight) change.

**Stroke caveat by font class:** heavy condensed faces (Anton, Bebas Neue, Oswald, Archivo Black)
take the full 8–14% stroke. The delicate serifs and the script (Playfair, Cormorant, DM Serif,
Fraunces, Sacramento) CLOG with a thick stroke — drop it to 3–6% (or none) and lean on a soft
drop-shadow for separation instead. Sacramento (and any script) is an accent line, not the punch word.

## Quick check before baking

- [ ] Font **Anton**, ALL CAPS, 2–4 words.
- [ ] Stroke manual and **thick**, drawn under the fill (`paint-order` / `strokeText` first).
- [ ] Text does **not** cover the face; sits in a free quarter.
- [ ] Text within the frame, off the extreme edges (comfortable padding).
- [ ] One punchy color: white / fire gradient / lime — one per headline.
- [ ] Waited for the font to load before `fillText`.

SHA-256: 62664100fcd5a2022a72cdf58ad5d3ec9d9a21804e3c8c5b90d4fae8a52cfbdd