Designing Lower-Third Templates

This guide explains how to build the HTML templates used to burn translated lower-thirds into dubbed videos, and exactly what parameters each template receives.

Lower-third templates power the dubbing "Compile with graphics" step. When an editor compiles a dubbed video with graphics, the system takes every translated lower-third (a kicker plus a headline, with an on-screen time window) and renders it with one of these templates, then composites the result over the source video.

A template is just a self-contained HTML file. It is rendered in a real headless browser, so you have the full power of HTML, CSS, and JavaScript — including web fonts, gradients, shadows, and smooth in/out animation. The only hard rules are a small JavaScript contract (three required globals, plus two optional hints that make rendering dramatically faster) and a transparent background.


Table of Contents

  1. Overview
  2. How rendering works
  3. Parameters the template receives
  4. The template contract
  5. Canvas and environment
  6. Animating with seekTo
  7. Minimal template skeleton
  8. Worked example the two line template
  9. Registering a new template
  10. Testing locally
  11. Authoring checklist

1. Overview

A lower-third template is an HTML document stored under templates/lowerThirds/. For each translated graphic, the renderer:

  1. Loads your template in a headless Chromium page sized to 1920x1080.
  2. Calls window.renderLowerThird(params) to build the DOM for that one graphic.
  3. Captures transparent PNG frames by calling window.seekTo(ms) and screenshotting. By default it captures every frame of the full canvas; when the template publishes the optional window.__ltTimings and window.__ltBounds hints (see the contract), it captures only the intro + outro windows + a single held frame, cropped to the graphic's band — far fewer, far smaller screenshots.
  4. Encodes those frames into a transparent (alpha) Animated PNG (.apng) clip (re-expanding the held frame to fill the static middle; APNG's inter-frame DEFLATE deltas then collapse the expansion back to almost nothing).
  5. Composites the clip over the source video, visible only during the graphic's in_time to out_time window.

Two consequences matter for authoring:

  • Everything you do not paint is transparent, and the source video shows through it. Only draw the lower-third itself; leave the rest of the frame empty.
  • The original detected position is ignored. Your template owns its own placement. Design at full 1920x1080 and place the graphic where you want it (broadcast lower-thirds usually sit in the bottom band). The template should cover the original baked-in lower-third by drawing an opaque bar over it.

2. How rendering works

Understanding the pipeline explains the few constraints the contract imposes.

  • Headless browser. Templates are driven by Playwright/Chromium in the background consumer process (services/lowerThirdRenderer.ts). There is no visible window and no user interaction.
  • The browser is stepped, not played. The renderer does not let your animation run in real time. It jumps to each frame's timestamp by calling window.seekTo(ms) and then takes a screenshot. This is why your animation must be a pure function of time (see the contract below) — a CSS @keyframes animation playing on the wall clock will not be captured correctly.
  • The static hold is captured once. Most lower-thirds animate in, hold a perfectly still state, then animate out. Re-screenshotting hundreds of identical hold frames is the single biggest cost. If your template declares its animation window via window.__ltTimings, the renderer captures only the moving intro and outro plus one held frame and lets the encoder repeat it — so a 5-second and a 60-second graphic cost almost the same. This only works if the hold really is static; anything that keeps moving between introEndMs and outroStartMs will be frozen to that single frame.
  • Only your band is captured. If your template declares window.__ltBounds, the renderer screenshots just that rectangle instead of the full 1920x1080 frame, then scales/positions it back at composite time. Smaller screenshots encode much faster.
  • Transparent capture. Each frame is captured with a transparent background, so your html/body background must be transparent.
  • One clip per graphic, rendered in parallel. Each lower-third is rendered by its own background task, so a video with several lower-thirds renders them concurrently rather than one after another. Your template is loaded fresh for each graphic (renderLowerThird is called once per clip).
  • Alpha clip, then overlay. Each graphic's frames are encoded into a lossless, alpha-preserving Animated PNG (.apng) clip, scaled/positioned to the source resolution, and overlaid only within that graphic's time window. APNG was chosen for its inter-frame DEFLATE compression — static holds collapse to near-zero bytes, so a long lower-third costs as little as a short one. Areas you leave transparent let the source video through. A final pass composites every clip onto the operator-chosen base — either the source video with a selected compiled-audio track, or a selected lip-synced video (which keeps its own audio) — so the render step itself generates neither audio nor lip-sync.
  • Where values come from. The dubbing job builds each graphic's kicker and headline from the translated lower-third (translations are stored as kicker\nheadline; a single line becomes a headline with an empty kicker). The direction is derived from the dubbing target language (right-to-left for Arabic, Hebrew, Persian, Urdu, and similar). You do not fetch any of this yourself — it arrives as parameters.

3. Parameters the template receives

The renderer calls window.renderLowerThird(params) once per graphic. params is a plain object with the following fields:

ParameterTypeMeaning
kickerstringFirst line — the location or topic label (for example Washington). May be empty.
headlinestringSecond line — the main headline. Always present. Graphics with an empty headline are skipped.
direction'rtl' | 'ltr'Text direction. rtl for Arabic, Hebrew, Persian, Urdu, and similar; otherwise ltr.
durationMsnumberTotal on-screen time for this graphic, in milliseconds. Equals (out_time - in_time) * 1000.
fpsnumberCapture frame rate (default 30). Useful if you want to align animation steps to frames.

Notes:

  • Treat kicker as optional. When it is empty or missing, render a headline-only layout (do not draw an empty pill).
  • Use durationMs to scale your in/out animation so short graphics still get a visible intro and outro (see the animation section).
  • headline and kicker are plain text. Set them with textContent, never innerHTML, so punctuation and quotes render literally and you avoid injection.

4. The template contract

Your template must define three required globals on window, and may define two optional hints (__ltTimings, __ltBounds) that let the renderer skip the static hold and crop to your band. The renderer depends on the required three; nothing else about your markup is constrained.

renderLowerThird(params)

js
window.renderLowerThird = function (params) { /* build the DOM */ };
  • Called once per graphic, before any frames are captured.
  • Build (or rebuild) the DOM from params. The same page is reused across multiple graphics, so this function must fully reset previous content (for example el.innerHTML = '') before drawing the new graphic.
  • It should also apply direction (dir attribute and alignment) based on params.direction.

seekTo(ms)

js
window.seekTo = function (ms) { /* set the exact visual state at time ms */ };
  • Called for every frame, with ms ranging from 0 to durationMs.
  • Must be a pure function of ms. Compute opacity, transforms, and any animated property directly from ms and set them on the element. Do not rely on CSS transitions or @keyframes that advance on the real clock — the browser is paused between frames, so only what seekTo sets is captured.
  • This is where your in/out animation lives (intro reveal, hold, outro exit).

__ltReady

js
window.__ltReady = false; // set true once fonts are loaded
  • Set to true once fonts are ready so text metrics are stable before the first capture. The renderer also awaits document.fonts.ready, so using document.fonts.ready.then(...) to flip this flag is the recommended pattern.

__ltTimings (optional)

js
window.__ltTimings = { introEndMs: 640, outroStartMs: 5360 };
  • Optional but strongly recommended. Declares the window during which your animation is moving. Everything before introEndMs is the intro, everything after outroStartMs is the outro, and the span in between must be a single, perfectly static frame.
  • When present, the renderer captures only the intro frames, the outro frames, and one held frame from the middle — then repeats the held frame to fill the gap. This makes long graphics nearly free to render. When absent, every frame is captured (correct, just slower).
  • Set it inside renderLowerThird, derived from params.durationMs (and fps), using the same math your seekTo uses to decide when motion ends/begins. Be conservative: if anything still moves during the "hold", it will be frozen.

__ltBounds (optional)

js
window.__ltBounds = { x: 0, y: 692, w: 1920, h: 388 }; // design-space (1920x1080)
  • Optional. Declares the bounding rectangle (in the 1920x1080 design space) that your graphic can ever occupy across the whole animation. The renderer screenshots only this band and the composite scales/positions it back into the video.
  • Include any animation overshoot/slide in the rectangle, and pad a few pixels. Values are clamped to the canvas and rounded to even numbers. If omitted (or invalid), the full 1920x1080 frame is captured.

5. Canvas and environment

  • Resolution. Design for a fixed 1920x1080 canvas. The clip is scaled to the source resolution at composite time, so author at 1080p regardless of the source.
  • Transparency. html and body must have background: transparent. Only the pixels you paint appear over the video.
  • Placement and coverage. Position the graphic yourself (typically the bottom band). Because the original detected lower-third is ignored, include an opaque background bar so the original baked-in graphic underneath is fully covered.
  • Fonts. System fonts are available through fontconfig, including Noto Sans Arabic and Noto Sans (Latin). For a brand font, embed it with an @font-face rule using a data: URL (base64) rather than a network URL — the renderer has no guaranteed network access — and gate the first capture on document.fonts.ready.
  • Crisp edges. Use -webkit-font-smoothing: antialiased. The browser is launched with LCD subpixel text disabled, which keeps anti-aliased edges as clean grayscale alpha (no colored fringing over transparency).
  • Direction (RTL and LTR). Control horizontal placement with text-align (or logical properties), not flexbox align-items. The flex cross-axis flips under dir, which makes flex-start/flex-end ambiguous; text-align: left for ltr and text-align: right for rtl on a block container is deterministic. Always set the dir attribute too so bidirectional text shapes correctly.

6. Animating with seekTo

Because frames are captured by stepping seekTo(ms), animation is just math: map ms to the visual state you want at that instant.

A simple and robust pattern is intro / hold / outro driven by two progress values:

js
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

window.seekTo = function (ms) {
  var dur = Math.max(1, P.durationMs);
  var intro = Math.min(450, dur * 0.28); // intro length (ms), capped
  var outro = Math.min(450, dur * 0.28); // outro length (ms), capped

  var pIn = clamp(ms / intro, 0, 1);            // 0..1 across the intro
  var pOut = clamp((dur - ms) / outro, 0, 1);   // 1..0 across the outro
  var introE = easeOutCubic(pIn);
  var outE = easeOutCubic(pOut);

  // Fade in during intro, fade out during outro.
  var opacity = Math.max(0, Math.min(introE, outE));
  // Slide up on intro, slide down slightly on outro.
  var ty = Math.max((1 - introE) * 48, (1 - outE) * 24);

  el.style.opacity = String(opacity);
  el.style.transform = 'translateY(' + ty.toFixed(2) + 'px)';
};

Key points:

  • Capping the intro and outro lengths (here at 450 ms) keeps short graphics from being all-animation with no readable hold.
  • Everything is derived from ms, so the same frame always looks the same — exactly what frame-accurate capture needs.
  • Apply transforms to a wrapper element to avoid re-laying-out text every frame.
  • Once you know your intro/outro lengths here, publish them from renderLowerThird as window.__ltTimings = { introEndMs, outroStartMs } so the renderer can skip the (static) hold. Keep everything still between those times — that frame is captured once and repeated.

7. Minimal template skeleton

This is the smallest template that satisfies the contract. Copy it, then style it however you like.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <style>
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        background: transparent; /* required: source video shows through */
        overflow: hidden;
      }
      #lt {
        position: absolute;
        left: 120px;
        right: 120px;
        bottom: 96px;
        will-change: transform, opacity;
        font-family: 'Noto Sans Arabic', 'Noto Sans', sans-serif;
      }
      #lt.ltr { text-align: left; }
      #lt.rtl { text-align: right; }
      .headline {
        display: inline-block;
        background: rgba(11, 15, 20, 0.94); /* opaque bar covers the original */
        color: #fff;
        font-size: 46px;
        font-weight: 800;
        padding: 18px 30px;
      }
    </style>
  </head>
  <body>
    <div id="lt"></div>
    <script>
      (function () {
        var el = document.getElementById('lt');
        var P = { kicker: '', headline: '', direction: 'ltr', durationMs: 4000, fps: 30 };
        function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }

        window.renderLowerThird = function (params) {
          P = Object.assign({}, P, params || {});
          var dir = P.direction === 'rtl' ? 'rtl' : 'ltr';
          el.className = dir;
          el.setAttribute('dir', dir);
          el.innerHTML = ''; // reset previous graphic (page is reused)

          var h = document.createElement('span');
          h.className = 'headline';
          h.textContent = P.headline || '';
          el.appendChild(h);

          window.seekTo(0);
          return true;
        };

        window.seekTo = function (ms) {
          var dur = Math.max(1, P.durationMs);
          var fade = Math.min(400, dur * 0.25);
          var opacity = Math.min(clamp(ms / fade, 0, 1), clamp((dur - ms) / fade, 0, 1));
          el.style.opacity = String(Math.max(0, opacity));
        };

        window.__ltReady = false;
        if (document.fonts && document.fonts.ready) {
          document.fonts.ready.then(function () { window.__ltReady = true; });
        } else {
          window.__ltReady = true;
        }
      })();
    </script>
  </body>
</html>

8. Worked example the two line template

The shipped default template is templates/lowerThirds/mtvTwoLine.html. It is a modern broadcast-style two-line lower-third: a cinematic bottom scrim, a gradient kicker pill, and a rounded headline card with a rounded gradient accent bar. It animates in with a staggered reveal (the card slides up and fades, the accent bar grows from the bottom, and the kicker pops), is right-anchored for RTL and left-anchored for LTR, and stays opaque enough to cover the original baked-in lower-third while the scrim darkens the rest of the band.

The full file:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Lower Third — Two Line (Modern)</title>
    <style>
      html,
      body {
        margin: 0;
        padding: 0;
        width: 1920px;
        height: 1080px;
        background: transparent; /* required: source video shows through */
        overflow: hidden;
      }
      * {
        box-sizing: border-box;
        -webkit-font-smoothing: antialiased; /* clean grayscale alpha edges */
        text-rendering: geometricPrecision;
      }

      /* Cinematic bottom scrim — full-width gradient that grounds the graphic
         and helps cover the original lower-third across the whole band. */
      #lt-scrim {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 380px;
        background: linear-gradient(to top, rgba(6, 8, 12, 0.92) 0%, rgba(6, 8, 12, 0.55) 38%, rgba(6, 8, 12, 0) 78%);
        opacity: 0;
        will-change: opacity;
      }

      /* The card stack — placed in the bottom band, animated by seekTo. */
      #lt {
        position: absolute;
        left: 120px;
        right: 120px;
        bottom: 100px;
        will-change: transform, opacity;
        font-family: 'Noto Sans Arabic', 'Noto Sans', 'Arial', sans-serif;
      }
      /* Horizontal placement via text-align (deterministic under dir). */
      #lt.ltr { text-align: left; }
      #lt.rtl { text-align: right; }

      .lt-row { display: block; }
      .lt-row.lt-row-kicker { margin-bottom: 14px; }

      /* Kicker pill (line 1) — gradient, fully rounded, tracked uppercase.
         Hidden by renderLowerThird when kicker is empty. */
      .lt-kicker {
        display: inline-block;
        max-width: 100%;
        vertical-align: top;
        background: linear-gradient(135deg, #ff5a4d 0%, #d11a2a 100%);
        color: #ffffff;
        font-size: 26px;
        font-weight: 800;
        line-height: 1;
        letter-spacing: 1.6px;
        text-transform: uppercase;
        padding: 11px 20px;
        border-radius: 999px;
        box-shadow: 0 8px 20px rgba(209, 26, 42, 0.42);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        will-change: transform, opacity;
      }
      #lt.ltr .lt-kicker { transform-origin: left center; }
      #lt.rtl .lt-kicker { transform-origin: right center; }

      /* Headline card (line 2) — rounded, gradient, top highlight + soft shadow.
         Near-opaque so it covers the original baked-in lower-third. */
      .lt-headline {
        display: inline-block;
        position: relative;
        max-width: 1380px;
        vertical-align: top;
        background: linear-gradient(135deg, rgba(22, 26, 35, 0.96) 0%, rgba(12, 15, 21, 0.97) 100%);
        color: #ffffff;
        font-size: 46px;
        font-weight: 800;
        line-height: 1.14;
        letter-spacing: -0.4px;
        padding: 20px 32px;
        border-radius: 16px;
        border-top: 1px solid rgba(255, 255, 255, 0.1);
        box-shadow: 0 14px 34px rgba(0, 0, 0, 0.46);
        will-change: transform;
      }
      .lt-text { display: block; }
      /* Rounded gradient accent bar — a real element so seekTo can grow it. */
      .lt-accent {
        position: absolute;
        top: 16px;
        bottom: 16px;
        width: 6px;
        border-radius: 999px;
        background: linear-gradient(180deg, #ff5a4d 0%, #d11a2a 100%);
        transform-origin: center bottom;
        will-change: transform;
      }
      #lt.ltr .lt-headline { padding-left: 40px; }
      #lt.ltr .lt-accent { left: 16px; }
      #lt.rtl .lt-headline { padding-right: 40px; text-align: right; }
      #lt.rtl .lt-accent { right: 16px; }
    </style>
  </head>
  <body>
    <div id="lt-scrim"></div>
    <div id="lt"></div>
    <script>
      (function () {
        var root = document.getElementById('lt');
        var scrim = document.getElementById('lt-scrim');
        var P = { kicker: '', headline: '', direction: 'ltr', durationMs: 4000, fps: 30 };

        // Element refs rebuilt per graphic (used by seekTo).
        var kickerEl = null;
        var headlineEl = null;
        var accentEl = null;

        function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
        function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
        // Mild overshoot for a modern "pop" (peaks slightly above 1, settles to 1).
        function easeOutBack(t) {
          var c1 = 1.2;
          var c3 = c1 + 1;
          return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
        }

        function row(child, extraClass) {
          var r = document.createElement('div');
          r.className = 'lt-row' + (extraClass ? ' ' + extraClass : '');
          r.appendChild(child);
          return r;
        }

        // Capture hints (optional): mirror seekTo's intro/outro math so the
        // renderer can skip the static hold, and report the scrim band to crop.
        function computeTimings(dur, fps) {
          var d = Math.max(1, dur || 0);
          var introC = Math.min(560, d * 0.34);
          var outro = Math.min(440, d * 0.3);
          var frameMs = 1000 / (fps && fps > 0 ? fps : 30);
          var introEnd = Math.ceil(Math.max(introC + 50, 200 + introC * 0.7)) + frameMs;
          var outroStart = Math.floor(d - outro) - frameMs;
          return { introEndMs: Math.max(0, Math.min(d, introEnd)), outroStartMs: Math.max(0, Math.min(d, outroStart)) };
        }
        function computeBounds() {
          var pad = 8;
          var top = scrim.offsetTop, height = scrim.offsetHeight;
          if (!isFinite(top) || !isFinite(height) || height <= 0) return { x: 0, y: 0, w: 1920, h: 1080 };
          var y = Math.max(0, top - pad);
          var h = Math.min(1080 - y, height + pad * 2);
          return { x: 0, y: y - (y % 2), w: 1920, h: h - (h % 2) };
        }

        // Build the DOM for one lower-third. Called once per graphic; the page is
        // reused, so fully reset previous content first.
        window.renderLowerThird = function (params) {
          P = Object.assign({ kicker: '', headline: '', direction: 'ltr', durationMs: 4000, fps: 30 }, params || {});
          var dir = P.direction === 'rtl' ? 'rtl' : 'ltr';
          root.className = dir;
          root.setAttribute('dir', dir);
          root.innerHTML = '';
          kickerEl = null;

          // Kicker is optional — only draw the pill when present.
          var kicker = (P.kicker == null ? '' : String(P.kicker)).trim();
          if (kicker.length > 0) {
            kickerEl = document.createElement('span');
            kickerEl.className = 'lt-kicker';
            kickerEl.textContent = kicker; // textContent, never innerHTML
            root.appendChild(row(kickerEl, 'lt-row-kicker'));
          }

          headlineEl = document.createElement('span');
          headlineEl.className = 'lt-headline';
          accentEl = document.createElement('span');
          accentEl.className = 'lt-accent';
          var textEl = document.createElement('span');
          textEl.className = 'lt-text';
          textEl.textContent = P.headline == null ? '' : String(P.headline);
          headlineEl.appendChild(accentEl);
          headlineEl.appendChild(textEl);
          root.appendChild(row(headlineEl));

          // Publish the capture hints now that the DOM + layout are settled.
          window.__ltTimings = computeTimings(P.durationMs, P.fps);
          window.__ltBounds = computeBounds();

          window.seekTo(0);
          return true;
        };

        // Deterministically position the staggered in/out animation at time `ms`.
        //   intro: scrim fades up, card slides up + fades, headline settles in
        //          from the accent side, accent bar grows, kicker pops.
        //   outro: everything fades + drifts down.
        window.seekTo = function (ms) {
          var dur = Math.max(1, P.durationMs);
          var introC = Math.min(560, dur * 0.34);
          var outro = Math.min(440, dur * 0.3);
          var pIn = clamp(ms / introC, 0, 1);
          var pOut = clamp((dur - ms) / outro, 0, 1);
          var cIn = easeOutCubic(pIn);
          var cOut = easeOutCubic(pOut);
          var vis = Math.min(cIn, cOut);
          var sign = P.direction === 'rtl' ? 1 : -1;

          // Scrim — fades in a touch faster than the card, out with the card.
          scrim.style.opacity = Math.min(easeOutCubic(clamp(ms / (introC * 0.8), 0, 1)), cOut).toFixed(3);

          // Card container — overall opacity + slide (up on intro, down on outro).
          var ty = Math.max((1 - cIn) * 44, (1 - cOut) * 22);
          root.style.opacity = vis.toFixed(3);
          root.style.transform = 'translateY(' + ty.toFixed(2) + 'px)';

          // Headline settles horizontally from the accent side.
          if (headlineEl) {
            var hP = easeOutCubic(clamp((ms - 50) / introC, 0, 1));
            headlineEl.style.transform = 'translateX(' + ((1 - hP) * 30 * sign).toFixed(2) + 'px)';
          }

          // Accent bar grows from the bottom with a subtle overshoot.
          if (accentEl) {
            var aP = clamp(easeOutBack(clamp((ms - 40) / (introC * 0.85), 0, 1)), 0, 1.08);
            accentEl.style.transform = 'scaleY(' + aP.toFixed(3) + ')';
          }

          // Kicker pops in slightly after the card.
          if (kickerEl) {
            var kRaw = clamp((ms - 200) / (introC * 0.7), 0, 1);
            var kE = easeOutCubic(kRaw);
            var kScale = 0.9 + 0.1 * clamp(easeOutBack(kRaw), 0, 1.1);
            kickerEl.style.opacity = Math.min(kE, cOut).toFixed(3);
            kickerEl.style.transform = 'translateY(' + ((1 - kE) * 8).toFixed(2) + 'px) scale(' + kScale.toFixed(3) + ')';
          }
        };

        // Flip ready once fonts have loaded so text metrics are stable.
        window.__ltReady = false;
        if (document.fonts && document.fonts.ready) {
          document.fonts.ready.then(function () { window.__ltReady = true; });
        } else {
          window.__ltReady = true;
        }
      })();
    </script>
  </body>
</html>

9. Registering a new template

Templates are referenced by a stable templateId. To add one:

  1. Create the file under templates/lowerThirds/, for example templates/lowerThirds/myBrand.html. Filenames use camelCase.
  2. Register the id in the TEMPLATE_FILES map in services/lowerThirdRenderer.ts, mapping your templateId to the filename:
ts
   const TEMPLATE_FILES: Record<string, string> = {
     'mtv-two-line': 'mtvTwoLine.html',
     'my-brand': 'myBrand.html',
   };
  1. Select it at render time by passing your templateId to the render job (the dubbed-video render currently uses 'mtv-two-line'). If a requested id is unknown, the renderer falls back to the default template.

Deployment note: the consumer reads templates from disk at runtime relative to its working directory (templates/lowerThirds/). Make sure the templates/ directory ships alongside the built consumer, and that the Playwright Chromium browser is installed in that environment.


10. Testing locally

Because a template is a normal HTML file, you can preview any frame in a desktop browser without the render pipeline:

  1. Open the template file directly in Chrome.
  2. In the DevTools console, build a graphic and jump to a timestamp:
js
   renderLowerThird({
     kicker: 'Washington',
     headline: '"Hezbollah" is an integrated terrorist organization',
     direction: 'ltr',
     durationMs: 4000,
     fps: 30,
   });
   seekTo(800);  // 0.8s into the 4s graphic — mid intro
   seekTo(2000); // hold
   seekTo(3800); // outro
  1. Try direction: 'rtl' with Arabic text to verify right-anchoring and shaping, and try an empty kicker to verify the headline-only layout.

Tips:

  • The page background will look white in a normal browser tab; that area is transparent in the actual capture. Focus on the lower-third itself.
  • Scrub seekTo across 0, durationMs / 2, and durationMs to confirm the intro, hold, and outro all look right.

11. Authoring checklist

Before shipping a template, confirm:

  • html and body backgrounds are transparent.
  • window.renderLowerThird, window.seekTo, and window.__ltReady are all defined.
  • renderLowerThird fully resets previous content (the page is reused per graphic).
  • seekTo(ms) is a pure function of ms — no real-time CSS animations.
  • An empty kicker produces a clean headline-only layout.
  • Long headlines stay within the safe area (wrap or cap with max-width).
  • RTL and LTR both anchor and shape correctly (dir set, text-align used for placement).
  • An opaque background bar covers the original baked-in lower-third.
  • Custom fonts are embedded and gated on document.fonts.ready.
  • Designed at 1920x1080.
  • (Optional, for speed) window.__ltTimings is published and the hold between introEndMs and outroStartMs is genuinely static.
  • (Optional, for speed) window.__ltBounds covers the full animated extent of the graphic (with a little padding).