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
- Overview
- How rendering works
- Parameters the template receives
- The template contract
- Canvas and environment
- Animating with seekTo
- Minimal template skeleton
- Worked example the two line template
- Registering a new template
- Testing locally
- Authoring checklist
1. Overview
A lower-third template is an HTML document stored under templates/lowerThirds/.
For each translated graphic, the renderer:
- Loads your template in a headless Chromium page sized to 1920x1080.
- Calls
window.renderLowerThird(params)to build the DOM for that one graphic. - 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 optionalwindow.__ltTimingsandwindow.__ltBoundshints (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. - 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). - Composites the clip over the source video, visible only during the graphic's
in_timetoout_timewindow.
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@keyframesanimation 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 betweenintroEndMsandoutroStartMswill 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/bodybackground 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 (
renderLowerThirdis 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
kickerandheadlinefrom the translated lower-third (translations are stored askicker\nheadline; a single line becomes a headline with an empty kicker). Thedirectionis 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:
| Parameter | Type | Meaning |
|---|---|---|
kicker | string | First line — the location or topic label (for example Washington). May be empty. |
headline | string | Second 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. |
durationMs | number | Total on-screen time for this graphic, in milliseconds. Equals (out_time - in_time) * 1000. |
fps | number | Capture frame rate (default 30). Useful if you want to align animation steps to frames. |
Notes:
- Treat
kickeras optional. When it is empty or missing, render a headline-only layout (do not draw an empty pill). - Use
durationMsto scale your in/out animation so short graphics still get a visible intro and outro (see the animation section). headlineandkickerare plain text. Set them withtextContent, neverinnerHTML, 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)
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 exampleel.innerHTML = '') before drawing the new graphic. - It should also apply direction (
dirattribute and alignment) based onparams.direction.
seekTo(ms)
window.seekTo = function (ms) { /* set the exact visual state at time ms */ };
- Called for every frame, with
msranging from0todurationMs. - Must be a pure function of
ms. Compute opacity, transforms, and any animated property directly frommsand set them on the element. Do not rely on CSS transitions or@keyframesthat advance on the real clock — the browser is paused between frames, so only whatseekTosets is captured. - This is where your in/out animation lives (intro reveal, hold, outro exit).
__ltReady
window.__ltReady = false; // set true once fonts are loaded
- Set to
trueonce fonts are ready so text metrics are stable before the first capture. The renderer also awaitsdocument.fonts.ready, so usingdocument.fonts.ready.then(...)to flip this flag is the recommended pattern.
__ltTimings (optional)
window.__ltTimings = { introEndMs: 640, outroStartMs: 5360 };
- Optional but strongly recommended. Declares the window during which your
animation is moving. Everything before
introEndMsis the intro, everything afteroutroStartMsis 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 fromparams.durationMs(andfps), using the same math yourseekTouses to decide when motion ends/begins. Be conservative: if anything still moves during the "hold", it will be frozen.
__ltBounds (optional)
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.
htmlandbodymust havebackground: 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-facerule using adata:URL (base64) rather than a network URL — the renderer has no guaranteed network access — and gate the first capture ondocument.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 flexboxalign-items. The flex cross-axis flips underdir, which makesflex-start/flex-endambiguous;text-align: leftforltrandtext-align: rightforrtlon a block container is deterministic. Always set thedirattribute 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:
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
renderLowerThirdaswindow.__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.
<!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:
<!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:
- Create the file under
templates/lowerThirds/, for exampletemplates/lowerThirds/myBrand.html. Filenames use camelCase. - Register the id in the
TEMPLATE_FILESmap inservices/lowerThirdRenderer.ts, mapping yourtemplateIdto the filename:
const TEMPLATE_FILES: Record<string, string> = {
'mtv-two-line': 'mtvTwoLine.html',
'my-brand': 'myBrand.html',
};
- Select it at render time by passing your
templateIdto 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:
- Open the template file directly in Chrome.
- In the DevTools console, build a graphic and jump to a timestamp:
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
- Try
direction: 'rtl'with Arabic text to verify right-anchoring and shaping, and try an emptykickerto 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
seekToacross0,durationMs / 2, anddurationMsto confirm the intro, hold, and outro all look right.
11. Authoring checklist
Before shipping a template, confirm:
htmlandbodybackgrounds aretransparent.window.renderLowerThird,window.seekTo, andwindow.__ltReadyare all defined.renderLowerThirdfully resets previous content (the page is reused per graphic).seekTo(ms)is a pure function ofms— no real-time CSS animations.- An empty
kickerproduces 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 (
dirset,text-alignused 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.__ltTimingsis published and the hold betweenintroEndMsandoutroStartMsis genuinely static. - (Optional, for speed)
window.__ltBoundscovers the full animated extent of the graphic (with a little padding).
