scenario-text-overlay
Use when text must appear letter-perfect on generated media: taglines, CTAs, prices, legal supers, lower thirds, end cards, nameplates, or styled rich cards composited over Scenario images and video. Keywords: text overlay, text card, caption card, CTA, legal super, tagline, end
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-text-overlay
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Scenario Text Overlay
Overview
Generation models drift on type, so text that must be exact is rendered deterministically here and composited over the media. One JSON payload describes one overlay, in one of two kinds: a text layer (plain styled text in a box) or a rich layer (HTML/CSS typography: gradients, strokes, shadows, card layouts). Both substitute Mustache {{variables}} strictly (a missing variable fails the render) on a transparent canvas.
scripts/overlay.py renders the payload to a PNG with an installed Chromium-family browser (both kinds, discovered automatically) or a Pillow fallback (text layers only), embeds the payload JSON into the PNG, and prints the path. Its modules: scripts/templating.py (strict Mustache), scripts/html_render.py (browser engine), scripts/pillow_render.py (fallback). The field-by-field contract and example payloads live in references/payloads.md. The script needs pip install chevron pillow.
Connection and the core MCP loop: see the scenario skill. The overlay meets its content on the platform, never locally: model_run on the compose tool models, model_scenario-compose-image for stills and model_scenario-compose-video for video (fixed ids: each is Scenario's single deterministic compositor for its medium, so discovery would only re-derive them), whose layer contract lives in scenario-video-assembly. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
Quick reference
| Step | Detail |
|---|---|
| 1. Write the payload | Kind by field (text_template or html_template); every changeable string in variables |
| 2. Render | python3 overlay.py --payload card.json --out card.png |
| 3. Verify | Open the PNG: glyphs verbatim, box placement right, background transparent |
| 4. Upload | upload_asset (kind image, multipart path) |
| 5. Persist the template | asset_update sets metadata.description to the payload JSON; asset_add_tags adds text-overlay |
| 6. Composite | model_run on model_scenario-compose-image or model_scenario-compose-video, overlay on top |
Choose the face for its meaning
The face is part of the message: heavy condensed sans shouts (hooks, prices, CTAs), light geometric or grotesque sans reads neutral and premium (legal supers, lower thirds, nameplates), serif signals editorial or heritage weight, rounded forms read friendly and casual. Match the media's art direction rather than fighting it, keep one family per card, and build hierarchy with font_weight (100 to 900) and size instead of mixing faces. font_family reaches any Google Fonts family, so every class above is available by name; a contractual brand face rides font_url instead. Meaning survives only above legibility, so judge the flattened preview at destination size, where a subtle face turns generic.
Worked example: a localized legal super
- Payload, with the changeable line as a variable:
{
"text_template": "{{disclaimer}}",
"variables": [
{
"key": "disclaimer",
"value": "EPA-estimated 310 mi. Actual range varies."
}
],
"font_family": "Inter",
"font_weight": 500,
"size": 34,
"color": "#FFFFFF",
"align": "center",
"bbox": [{ "x": 86, "y": 1560, "w": 908, "h": 120 }],
"canvas_width": 1080,
"canvas_height": 1920,
"overflow": "shrink"
}
python3 overlay.py --payload super.json --out super.png, then opensuper.png: the line must read exactly as written, shrunk to fit the box.- Upload with
upload_asset, thenasset_updatewith the payload JSON asmetadata.descriptionandasset_add_tagswith["text-overlay"](asset_update's ownmetadata.tagsreplaces the whole set): the template now lives on the platform beside its render, and the PNG itself carries it in atEXtchunk. - Composite over the spot:
model_schema_getonmodel_scenario-compose-video, thenmodel_runwith the base video atzIndex0 and the super as an image layer above it (layer fields:scenario-video-assembly);jobs_wait, thenasset_display. - The French variant is the same payload with one
valueedited, never a new template.
Common mistakes
- Baking changeable strings into the template: variants and localizations should be edits to
variables; the uploaded payload is the reproducibility contract. - Using
{{var}}for values holding&,<, or>: it HTML-escapes, so they render as literal entity text;{{{var}}}is the raw opt-out (full Mustache rules: the payload reference). - Leaving images or fonts as remote URLs: inline them as
data:URIs (Google Fonts links excepted) or the render depends on the network and drifts. - Skipping the visual check before upload: font resolution differs per machine, and light text on the transparent canvas previews as blank; flatten over a contrasting backdrop first.
- Letting a generation model paint the text instead: generated type drifts frame to frame; overlays exist to avoid exactly that.
- Generating the plate with no room for the card: brief the base image or video with the overlay's zone kept plain and text-free (a dark right third, an empty top band) for every frame the card is up, so the composite lands on quiet pixels rather than generated lettering or detail.
- Merging the overlay locally with Pillow or ffmpeg: compositing is a
model_runon the compose models, so the finished creative lands on the platform beside its layers. - Sizing the canvas to something other than the destination: match the target resolution so the overlay composites 1:1 with no post-scale blur. Content layers may scale inside the compose run when a generation model cannot hit the exact size; the overlay must not.
- Expecting the fallback to match the browser: Pillow draws plain text only; rich layers need a Chromium-family browser installed.
- Trusting
wrapwith a hard box: it lets text spill below; useshrink(fits or fails loudly) orclipwhen the box is binding.
Files (skills)
-
references
-
payloads.md 8 KB
# Overlay payload reference One JSON object per overlay. The kind is picked by which template field is present: `text_template` (text layer) or `html_template` (rich layer), never both. Shared fields: | Field | Type | Rules | | --------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `variables` | `[{key, value}]` | duplicate keys are an error; a `{{name}}` substitution takes a string (stringify numbers); section data may be a boolean, a list, or an object whose leaf values are strings | | `canvas_width` | int | 1 to 8192 | | `canvas_height` | int | 1 to 8192 | ## Template rules Templates are Mustache, rendered strictly: a `{{name}}` that has no matching variable key fails the render instead of substituting an empty string. `{{var}}` HTML-escapes its value; `{{{var}}}` inserts it raw (use it in rich layers to inject markup on purpose, and in text layers whenever a value contains `&`, `<`, or `>`, which would otherwise render as literal entity text). Sections (`{{#list}}...{{/list}}`), inverted sections, and comments work as in standard Mustache; variables inside a section resolve against the section's own items and are not strict-checked, so a name missing inside a section renders empty. Keep strict-checked text at the top level. Keep every changeable string in `variables` rather than baked into the template, so one template reproduces a whole family of localized or per-variant cards. ## Text layer | Field | Type | Default | Rules | | ---------------- | ------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `text_template` | string | required | plain text; newlines are kept | | `font_family` | string | | a Google Fonts family name; letters, digits, spaces, `+`, `-` | | `font_url` | string | | https URL to a .ttf/.otf/.woff/.woff2 file | | `font_weight` | int | 400 | 100, 200, ... 900 | | `font_style` | string | `normal` | `normal` or `italic` | | `size` | int | required | 4 to 2048 (pixels) | | `color` | string | `#000000` | `#RRGGBB` | | `align` | string | `left` | `left`, `center`, `right` | | `bbox` | list | required | exactly one `{x, y, w, h}`, inside the canvas | | `line_height` | number | 1.2 | 0.5 to 4.0, multiplier | | `letter_spacing` | number | 0.0 | pixels per glyph gap | | `overflow` | string | `wrap` | `wrap` (vertical overflow allowed), `clip` (cut at the box), `shrink` (largest size in [4, `size`] that fits the box height; errors when even 4 overflows) | Exactly one of `font_family` or `font_url`. Text wraps at `bbox.w`; the glyphs of the substituted text appear verbatim. ## Rich layer | Field | Type | Default | Rules | | ---------------------- | -------- | -------- | ---------------------------------------------------------------------- | | `html_template` | string | required | HTML body fragment (Mustache) | | `css` | string | | appended after a transparent-background reset, so it wins | | `device_scale_factor` | number | 1.0 | 0.5 to 4.0; output pixels scale by it (2.0 for retina-sharp overlays) | | `allowed_url_prefixes` | [string] | `[]` | https-only, no `@` or `..`; hosts listed here render without a warning | The page renders on a transparent background at the canvas size. Google Fonts links and `scenario.com` URLs load quietly; any other remote reference earns a warning because a self-contained page (assets inlined as `data:` URIs) reproduces identically anywhere, offline included. Gradients, text strokes (`-webkit-text-stroke`), shadows, flexbox card layouts, and emoji all render. ## Engines | Engine | Layers | Needs | | ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | browser | text + rich | any installed Chromium-family browser (Chrome, Chromium, Edge, Brave); found automatically, or set `--browser` / `SCENARIO_TEXT_OVERLAY_BROWSER` | | pillow | text only | Pillow; plain typography, used automatically when no browser exists | ## Reproducibility Every output PNG carries its own payload JSON in a `tEXt` chunk with the keyword `scenario-text-overlay:payload` (Pillow shows it in `Image.text`), so any variant can be regenerated from the file alone by editing `variables`. The upload-and-persist flow that stores the same payload on the platform is steps 4 and 5 of the SKILL.md quick reference. ## Example payload A text-layer example is the worked example in SKILL.md. Rich layer, a gradient headline card: ```json { "html_template": "<link href=\"https://fonts.googleapis.com/css2?family=Cinzel:ital,wght@0,700&display=block\" rel=\"stylesheet\"><div class=\"card\"><h1>{{title}}</h1><p>{{tagline}}</p></div>", "css": "html, body { height: 100%; } .card { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: 'Cinzel', serif; } h1 { font-size: 96px; margin: 0; background: linear-gradient(120deg, #F8E7B3, #C9A227); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } p { color: #EDE6D6; font-size: 30px; letter-spacing: 0.3em; }", "variables": [ { "key": "title", "value": "Night, distilled" }, { "key": "tagline", "value": "EAU DE PARFUM" } ], "canvas_width": 1080, "canvas_height": 640, "device_scale_factor": 2.0 } ```
-
-
scripts
-
html_render.py 8.9 KB
"""Browser-engine rendering: overlay payloads to transparent PNGs. Pages are captured with a Chromium-family browser's headless screenshot mode, so any installed Chrome, Chromium, Edge, or Brave works and no Python browser bindings are needed. Rich layers get full HTML/CSS typography; text layers are translated to an absolutely positioned block that mirrors the text-layer contract (wrap at the box width; clip and shrink overflow). """ import html import os import re import subprocess import shutil import sys import tempfile from pathlib import Path from urllib.parse import quote_plus, urlparse DEFAULT_STYLE = "html, body { margin:0; padding:0; background: transparent; }" BROWSER_ENV = "SCENARIO_TEXT_OVERLAY_BROWSER" MIN_SHRINK_SIZE = 4 # Virtual time pauses while resource fetches (fonts included) are pending, # and both font paths are paint-blocking (font-display: block), so # --screenshot captures the loaded face, never fallback glyphs. _VIRTUAL_TIME_BUDGET_MS = 10000 # Hostname suffixes a page may reference without a warning. Anything else # should be inlined as a data: URI so the render is self-contained and # reproducible offline. _QUIET_HOSTNAME_SUFFIXES = ( "fonts.googleapis.com", "fonts.gstatic.com", "scenario.com", ) _BROWSER_CANDIDATES = ( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser", "chrome", ) def _resolve_candidate(candidate): return shutil.which(candidate) or (candidate if Path(candidate).is_file() else None) def find_browser(explicit=None): """Return the path of a usable Chromium-family browser, or None. An explicit path (--browser) or $SCENARIO_TEXT_OVERLAY_BROWSER that does not resolve is an error rather than a silent fallback. """ for source in (explicit, os.environ.get(BROWSER_ENV)): if source: found = _resolve_candidate(source) if not found: raise SystemExit(f"browser not found: {source!r}") return found for candidate in _BROWSER_CANDIDATES: found = _resolve_candidate(candidate) if found: return found return None def google_fonts_url(family, weight, style): """Return the css2 URL for one family/weight/style, paint blocked on load.""" ital = 1 if style == "italic" else 0 family_qs = quote_plus(family) return ( "https://fonts.googleapis.com/css2" f"?family={family_qs}:ital,wght@{ital},{weight}" "&display=block" ) def assemble_html(body, css=None): """Wrap a rendered body fragment in the deterministic HTML5 shell. Default style first so payload CSS wins. """ user_css = f"\n{css}" if css else "" return ( "<!doctype html><html><head>" '<meta charset="utf-8">' f"<style>{DEFAULT_STYLE}{user_css}</style>" f"</head><body>{body}</body></html>" ) def text_layer_html(payload, rendered_text, font_size, measure=False): """Return a full HTML document for a text-layer payload at a fixed size. The rendered text is embedded HTML-escaped so its glyphs appear exactly as substituted. With measure=True the page carries a script that waits for fonts, binary-searches the largest size in [MIN_SHRINK_SIZE, font_size] whose wrapped height fits the box, and records the result (-1 when even the minimum overflows) in a data-fitted-size attribute on <body> for --dump-dom to read. """ box = payload["bbox"][0] if payload.get("font_family"): family = payload["font_family"] fonts_url = google_fonts_url(family, payload["font_weight"], payload["font_style"]) head = f'<link rel="stylesheet" href="{fonts_url}">' css = None else: family = "scenario-overlay-font" head = "" css = ( f"@font-face {{ font-family: '{family}'; " f"src: url('{payload['font_url']}'); " f"font-weight: {payload['font_weight']}; " f"font-style: {payload['font_style']}; " "font-display: block; }" ) clip = f" height:{box['h']}px; overflow:hidden;" if payload["overflow"] == "clip" else "" body = ( f"{head}" f'<div style="position:absolute; left:{box["x"]}px; top:{box["y"]}px;' f' width:{box["w"]}px;{clip}">' f'<div id="overlay-text" style="' f"font-family:'{family}'; " f"font-weight:{payload['font_weight']}; " f"font-style:{payload['font_style']}; " f"font-size:{font_size}px; " f"color:{payload['color']}; " f"text-align:{payload['align']}; " f"line-height:{payload['line_height']}; " f"letter-spacing:{payload['letter_spacing']}px; " f'white-space:pre-wrap; overflow-wrap:break-word;">' f"{html.escape(rendered_text)}</div></div>" ) if measure: body += ( "<script>document.fonts.ready.then(function () {" 'var el = document.getElementById("overlay-text");' f"var max = {box['h']}, lo = {MIN_SHRINK_SIZE}, hi = {font_size}, fit = -1;" "function heightAt(px) {" 'el.style.fontSize = px + "px";' "return el.getBoundingClientRect().height; }" "if (heightAt(lo) <= max) { fit = lo;" "while (lo < hi) {" "var mid = Math.floor((lo + hi + 1) / 2);" "if (heightAt(mid) <= max) { fit = mid; lo = mid; } else { hi = mid - 1; } } }" 'document.body.setAttribute("data-fitted-size", String(fit));' "});</script>" ) return assemble_html(body, css) def _run_browser(browser, page_path, extra_args): command = [ browser, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", f"--virtual-time-budget={_VIRTUAL_TIME_BUDGET_MS}", *extra_args, Path(page_path).resolve().as_uri(), ] try: return subprocess.run(command, capture_output=True, text=True, timeout=120) except subprocess.TimeoutExpired: raise SystemExit("browser timed out rendering the page (120s)") def screenshot(browser, html_doc, out_path, width, height, device_scale_factor=1.0): """Capture html_doc as a transparent PNG of width x height (times scale).""" with tempfile.TemporaryDirectory() as tmp: page = Path(tmp) / "overlay.html" page.write_text(html_doc, encoding="utf-8") args = [ f"--window-size={width},{height}", "--default-background-color=00000000", f"--screenshot={Path(out_path).resolve()}", ] if device_scale_factor != 1.0: args.insert(0, f"--force-device-scale-factor={device_scale_factor}") result = _run_browser(browser, page, args) if result.returncode != 0 or not Path(out_path).is_file(): raise SystemExit( f"browser screenshot failed (exit {result.returncode}):\n{result.stderr.strip()}" ) def fitted_size(browser, measure_doc): """Run a measuring page and return the fitted font size (-1: no fit).""" with tempfile.TemporaryDirectory() as tmp: page = Path(tmp) / "measure.html" page.write_text(measure_doc, encoding="utf-8") result = _run_browser(browser, page, ["--dump-dom"]) match = re.search(r'data-fitted-size="(-?\d+)"', result.stdout) if result.returncode != 0 or not match: raise SystemExit( f"browser measurement failed (exit {result.returncode}):\n{result.stderr.strip()}" ) return int(match.group(1)) def warn_remote_urls(html_doc, allowed_url_prefixes=()): """Warn (stderr) about URLs the local browser would fetch over the network. https URLs on the quiet suffixes or on a host named in allowed_url_prefixes pass silently; everything else earns a warning, because a self-contained page (assets inlined as data: URIs) renders reproducibly anywhere, offline included. """ allowed_hosts = set() for prefix in allowed_url_prefixes: host = (urlparse(prefix).hostname or "").lower() if host: allowed_hosts.add(host) warned = set() for url in re.findall(r"""https?://[^\s"'<>)]+""", html_doc, flags=re.IGNORECASE): parsed = urlparse(url) host = (parsed.hostname or "").lower() quiet = parsed.scheme.lower() == "https" and ( host in allowed_hosts or any(host == s or host.endswith("." + s) for s in _QUIET_HOSTNAME_SUFFIXES) ) if quiet or url in warned: continue warned.add(url) print( f"warning: the page references {url}; the local browser will try to " "fetch it. Inline assets as data: URIs for a self-contained render.", file=sys.stderr, ) -
overlay.py 12.4 KB
#!/usr/bin/env python3 """Render a text-overlay payload to a transparent PNG for compositing. The payload is a JSON file holding one layer. Two kinds: text layer (text_template + font and box fields): plain styled text rich layer (html_template, optional css): full HTML/CSS typography Both kinds substitute Mustache {{variables}} strictly (a missing variable is an error) and render on a transparent canvas of canvas_width x canvas_height. The exact payload JSON is embedded into the output PNG as a tEXt chunk (keyword scenario-text-overlay:payload) so any render can be reproduced from the file alone. Engines: a Chromium-family browser (found automatically, or named via --browser or $SCENARIO_TEXT_OVERLAY_BROWSER) renders both kinds; without one, text layers fall back to Pillow (plain typography) and rich layers fail with instructions. Usage: overlay.py --payload card.json --out card.png overlay.py --payload super.json --out super.png --engine pillow Field reference: references/payloads.md next to this skill's SKILL.md. Requires chevron; the fallback engine also needs Pillow. """ import argparse import json import re import struct import sys import zlib from pathlib import Path from templating import MissingVariableError, render_strict, variables_to_dict PAYLOAD_TEXT_CHUNK_KEYWORD = "scenario-text-overlay:payload" _HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}\Z") _FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 +\-]{1,64}\Z") _FONT_WEIGHTS = (100, 200, 300, 400, 500, 600, 700, 800, 900) _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" def _fail(message): raise SystemExit(f"invalid payload: {message}") def _check_int(value, field, low, high): if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high: _fail(f"{field} must be an integer in [{low}, {high}]") return value def _check_number(value, field, low, high): if isinstance(value, bool) or not isinstance(value, (int, float)): _fail(f"{field} must be a number in [{low}, {high}]") if not low <= value <= high: _fail(f"{field} must be a number in [{low}, {high}]") return float(value) def _check_text(value, field): # A JSON \ud800-\udfff escape decodes to a lone surrogate that no # renderer can encode back to UTF-8; reject it as payload data. try: value.encode("utf-8") except UnicodeEncodeError: _fail(f"{field} contains an unpaired surrogate escape") return value def _check_choice(value, field, choices): if value not in choices: _fail(f"{field} must be one of {list(choices)}") return value def _check_value(value, field): # A {{name}} substitution takes a string; Mustache section data may be # a boolean or nest lists and objects, but every leaf is still a string # (numbers would render with float formatting drift, so stringify them). if isinstance(value, bool): return value if isinstance(value, str): return _check_text(value, field) if isinstance(value, list): for index, item in enumerate(value): _check_value(item, f"{field}[{index}]") return value if not isinstance(value, dict): _fail(f"{field} must be a string (stringify numbers), boolean, list, or object") for key, item in value.items(): if not isinstance(key, str) or not key: _fail(f"{field} needs non-empty string keys") _check_text(key, f"{field} key") _check_value(item, f"{field}.{key}") return value def _check_variables(payload): variables = payload.get("variables", []) if not isinstance(variables, list): _fail("variables must be a list of {key, value} objects") for entry in variables: if not isinstance(entry, dict) or not entry.get("key") or not isinstance(entry["key"], str): _fail("each variable needs a non-empty string key") _check_text(entry["key"], "variable key") if "value" not in entry: _fail(f"variable {entry['key']!r} needs a value") _check_value(entry["value"], f"variable {entry['key']!r}") return variables def _validate_rich(data): if not isinstance(data["html_template"], str) or not data["html_template"]: _fail("html_template must be a non-empty string") _check_text(data["html_template"], "html_template") css = data.get("css") if css is not None and not isinstance(css, str): _fail("css must be a string") if css is not None: _check_text(css, "css") data["css"] = css data["device_scale_factor"] = _check_number( data.get("device_scale_factor", 1.0), "device_scale_factor", 0.5, 4.0 ) prefixes = data.get("allowed_url_prefixes", []) if not isinstance(prefixes, list) or not all(isinstance(p, str) for p in prefixes): _fail("allowed_url_prefixes must be a list of strings") for prefix in prefixes: if not prefix.startswith("https://"): _fail(f"allowed_url_prefixes entries must start with https:// ({prefix!r})") if "@" in prefix or ".." in prefix: _fail(f"allowed_url_prefixes entries must not contain '@' or '..' ({prefix!r})") data["allowed_url_prefixes"] = prefixes return data def _validate_text(data): if not isinstance(data["text_template"], str) or not data["text_template"]: _fail("text_template must be a non-empty string") _check_text(data["text_template"], "text_template") family, url = data.get("font_family"), data.get("font_url") if (family is None) == (url is None): _fail("provide exactly one of font_family or font_url") if family is not None and (not isinstance(family, str) or not _FONT_FAMILY_RE.match(family)): _fail("font_family may only contain letters, digits, spaces, '+' and '-' (max 64 chars)") if url is not None and (not isinstance(url, str) or not url.startswith("https://")): _fail("font_url must be an https:// URL") data["font_family"], data["font_url"] = family, url weight = data.get("font_weight", 400) if isinstance(weight, bool) or not isinstance(weight, int): _fail("font_weight must be an integer") data["font_weight"] = _check_choice(weight, "font_weight", _FONT_WEIGHTS) data["font_style"] = _check_choice( data.get("font_style", "normal"), "font_style", ("normal", "italic") ) data["size"] = _check_int(data.get("size"), "size", 4, 2048) color = data.get("color", "#000000") if not isinstance(color, str) or not _HEX_COLOR_RE.match(color): _fail("color must be #RRGGBB hex (e.g. '#1A2B3C')") data["color"] = color data["align"] = _check_choice(data.get("align", "left"), "align", ("left", "center", "right")) bbox = data.get("bbox") if not isinstance(bbox, list) or len(bbox) != 1 or not isinstance(bbox[0], dict): _fail("bbox must be a list holding exactly one {x, y, w, h} object") box = bbox[0] x = _check_int(box.get("x"), "bbox.x", 0, 8192) y = _check_int(box.get("y"), "bbox.y", 0, 8192) w = _check_int(box.get("w"), "bbox.w", 1, 8192) h = _check_int(box.get("h"), "bbox.h", 1, 8192) if x + w > data["canvas_width"] or y + h > data["canvas_height"]: _fail( f"bbox ({x}, {y}, {w}, {h}) does not fit inside canvas " f"{data['canvas_width']}x{data['canvas_height']}" ) data["bbox"] = [{"x": x, "y": y, "w": w, "h": h}] data["line_height"] = _check_number(data.get("line_height", 1.2), "line_height", 0.5, 4.0) data["letter_spacing"] = _check_number( data.get("letter_spacing", 0.0), "letter_spacing", -1000, 1000 ) data["overflow"] = _check_choice( data.get("overflow", "wrap"), "overflow", ("clip", "wrap", "shrink") ) return data def validate_payload(payload): """Return ("text" | "rich", payload with defaults applied) or exit.""" if not isinstance(payload, dict): _fail("payload must be a JSON object") has_text = "text_template" in payload has_rich = "html_template" in payload if has_text == has_rich: _fail("provide exactly one of text_template (text layer) or html_template (rich layer)") data = dict(payload) data["variables"] = _check_variables(payload) _check_int(data.get("canvas_width"), "canvas_width", 1, 8192) _check_int(data.get("canvas_height"), "canvas_height", 1, 8192) if has_rich: return "rich", _validate_rich(data) return "text", _validate_text(data) def embed_payload(png_bytes, payload): """Insert the payload JSON as a tEXt chunk right after IHDR. The value is compact ASCII JSON, so the PNG alone carries everything needed to reproduce its own render. """ if png_bytes[:8] != _PNG_SIGNATURE: raise SystemExit("renderer did not produce a PNG") serialized = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), sort_keys=True) body = PAYLOAD_TEXT_CHUNK_KEYWORD.encode("latin-1") + b"\x00" + serialized.encode("latin-1") chunk = ( struct.pack(">I", len(body)) + b"tEXt" + body + struct.pack(">I", zlib.crc32(b"tEXt" + body) & 0xFFFFFFFF) ) ihdr_end = 8 + 4 + 4 + struct.unpack(">I", png_bytes[8:12])[0] + 4 return png_bytes[:ihdr_end] + chunk + png_bytes[ihdr_end:] def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--payload", required=True, help="path to the payload JSON file") parser.add_argument("--out", required=True, help="output PNG path") parser.add_argument( "--engine", choices=["auto", "browser", "pillow"], default="auto", help="auto tries a browser first, then Pillow for text layers", ) parser.add_argument("--browser", help="path or command of a Chromium-family browser") args = parser.parse_args(argv) try: payload = json.loads(Path(args.payload).read_text(encoding="utf-8")) except (OSError, ValueError) as error: raise SystemExit(f"cannot read payload {args.payload!r}: {error}") kind, data = validate_payload(payload) try: variables = variables_to_dict(data["variables"]) except ValueError as error: _fail(str(error)) template = data["html_template"] if kind == "rich" else data["text_template"] try: rendered = render_strict(template, variables) except MissingVariableError as error: _fail(str(error)) import html_render browser = None if args.engine == "pillow" else html_render.find_browser(args.browser) if kind == "rich": if browser is None: raise SystemExit( "rich layers need a Chromium-family browser (Chrome, Chromium, Edge, " "or Brave); install one or point --browser or " f"${html_render.BROWSER_ENV} at it" ) document = html_render.assemble_html(rendered, data["css"]) html_render.warn_remote_urls(document, tuple(data["allowed_url_prefixes"])) html_render.screenshot( browser, document, args.out, data["canvas_width"], data["canvas_height"], data["device_scale_factor"], ) else: if browser is not None: size = data["size"] if data["overflow"] == "shrink": measure = html_render.text_layer_html(data, rendered, size, measure=True) size = html_render.fitted_size(browser, measure) if size < 0: box = data["bbox"][0] raise SystemExit( f"text cannot fit in bbox ({box['w']}x{box['h']}) " f"even at size {html_render.MIN_SHRINK_SIZE}" ) document = html_render.text_layer_html(data, rendered, size) html_render.screenshot( browser, document, args.out, data["canvas_width"], data["canvas_height"] ) else: if args.engine == "browser": raise SystemExit( "no Chromium-family browser found; install one or use --engine pillow" ) print( "note: no browser found; using the Pillow fallback (plain typography)", file=sys.stderr, ) import pillow_render pillow_render.render_text_payload(data, rendered).save(args.out, "PNG") out = Path(args.out) out.write_bytes(embed_payload(out.read_bytes(), payload)) print(args.out) if __name__ == "__main__": main() -
pillow_render.py 7 KB
"""Pillow fallback for text-layer payloads when no browser is available. Plain typography only: font, size, color, alignment, line height, letter spacing, and the overflow modes are honored, but none of the HTML/CSS a browser render offers, and rich layers are out of reach entirely. Fonts resolve in order: font_url (.ttf/.otf download), font_family via Google Fonts (TrueType variant), then a local system font as a last resort. Requires Pillow (pip install pillow). """ import re import sys import tempfile import urllib.request from pathlib import Path from html_render import MIN_SHRINK_SIZE, google_fonts_url _FONT_FETCH_TIMEOUT_S = 20 _SYSTEM_FONT_CANDIDATES = ( "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Supplemental/Arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", ) def _download(url): request = urllib.request.Request(url, headers={"User-Agent": "python-urllib"}) with urllib.request.urlopen(request, timeout=_FONT_FETCH_TIMEOUT_S) as response: return response.read() def _cache_font(data, suffix): handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) handle.write(data) handle.close() return handle.name def resolve_font_file(payload): """Return a local font path for the payload, or None for a system font. The Google Fonts css2 endpoint serves TrueType URLs to non-browser user agents, which is what Pillow can read. """ font_url = payload.get("font_url") if font_url: if not font_url.lower().endswith((".ttf", ".otf")): raise SystemExit( "the fallback engine reads only .ttf/.otf font_url files; " "use a browser engine for woff/woff2" ) return _cache_font(_download(font_url), Path(font_url).suffix) try: css_url = google_fonts_url( payload["font_family"], payload["font_weight"], payload["font_style"] ) css = _download(css_url).decode("utf-8") match = re.search(r"url\((https://fonts\.gstatic\.com/[^)]+\.ttf)\)", css) if match: return _cache_font(_download(match.group(1)), ".ttf") detail = "no TrueType URL in the css2 response" except OSError as error: detail = error print( f"warning: could not fetch Google Font {payload['font_family']!r} " f"({detail}); falling back to a system font", file=sys.stderr, ) return None def _load_font(font_path, size): from PIL import ImageFont candidates = [font_path] if font_path else list(_SYSTEM_FONT_CANDIDATES) for candidate in candidates: try: return ImageFont.truetype(str(candidate), size) except OSError: if font_path: raise SystemExit(f"cannot load font {font_path!r}") try: return ImageFont.load_default(size=size) except TypeError: return ImageFont.load_default() def _line_width(draw, line, font, letter_spacing): width = draw.textlength(line, font=font) if letter_spacing and len(line) > 1: width += letter_spacing * (len(line) - 1) return width def _wrap(draw, text, font, max_width, letter_spacing): lines = [] for paragraph in text.split("\n"): current = "" for word in paragraph.split(): trial = f"{current} {word}".strip() if _line_width(draw, trial, font, letter_spacing) <= max_width: current = trial continue if current: lines.append(current) # A word wider than the box breaks across lines, matching the # browser engine's overflow-wrap:break-word. current = word while _line_width(draw, current, font, letter_spacing) > max_width and len(current) > 1: head = current while len(head) > 1 and _line_width(draw, head, font, letter_spacing) > max_width: head = head[:-1] lines.append(head) current = current[len(head):] lines.append(current) return lines def _draw_line(draw, x, y, line, font, color, letter_spacing): if not letter_spacing: draw.text((x, y), line, font=font, fill=color) return for char in line: draw.text((x, y), char, font=font, fill=color) x += draw.textlength(char, font=font) + letter_spacing def _layout(draw, payload, text, size, font_path): font = _load_font(font_path, size) lines = _wrap(draw, text, font, payload["bbox"][0]["w"], payload["letter_spacing"]) line_height = round(size * payload["line_height"]) return font, lines, line_height def _shrink_size(draw, payload, text, font_path): """Binary-search the largest size in [MIN_SHRINK_SIZE, size] that fits.""" box = payload["bbox"][0] def height_at(size): # Some fonts cannot be measured at tiny sizes (Pillow raises # OSError); treat those sizes as not fitting. try: _, lines, line_height = _layout(draw, payload, text, size, font_path) except OSError: return None return line_height * len(lines) lo, hi = MIN_SHRINK_SIZE, payload["size"] floor = None for size in range(lo, hi + 1): floor = height_at(size) if floor is not None: lo = size break if floor is None or floor > box["h"]: raise SystemExit( f"text cannot fit in bbox ({box['w']}x{box['h']}) even at size {lo}" ) while lo < hi: mid = (lo + hi + 1) // 2 height = height_at(mid) if height is not None and height <= box["h"]: lo = mid else: hi = mid - 1 return lo def render_text_payload(payload, rendered_text): """Render a validated text-layer payload to a transparent RGBA image.""" from PIL import Image, ImageDraw canvas = Image.new( "RGBA", (payload["canvas_width"], payload["canvas_height"]), (0, 0, 0, 0) ) draw = ImageDraw.Draw(canvas) font_path = resolve_font_file(payload) box = payload["bbox"][0] size = payload["size"] if payload["overflow"] == "shrink": size = _shrink_size(draw, payload, rendered_text, font_path) font, lines, line_height = _layout(draw, payload, rendered_text, size, font_path) clip = payload["overflow"] == "clip" layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) if clip else canvas layer_draw = ImageDraw.Draw(layer) y = box["y"] for line in lines: width = _line_width(layer_draw, line, font, payload["letter_spacing"]) if payload["align"] == "center": x = box["x"] + (box["w"] - width) / 2 elif payload["align"] == "right": x = box["x"] + box["w"] - width else: x = box["x"] _draw_line(layer_draw, x, y, line, font, payload["color"], payload["letter_spacing"]) y += line_height if clip: region = (box["x"], box["y"], box["x"] + box["w"], box["y"] + box["h"]) canvas.paste(layer.crop(region), region) return canvas -
templating.py 2.8 KB
"""Strict Mustache rendering for overlay payloads. chevron substitutes an empty string for a missing key; overlay text must be verbatim, so a template whose top-level {{var}} references an undefined variable is an error, not silent blank output. The template is pre-scanned and MissingVariableError raised before chevron sees it. Names inside a section ({{#x}}...{{/x}}) are not pre-checked: they resolve against the section's own context per Mustache scoping, and a name missing there still renders empty. {{var}} HTML-escapes its value; {{{var}}} inserts it raw. Requires chevron (pip install chevron). """ import re import chevron class MissingVariableError(ValueError): """Raised when a {{var}} reference has no matching key in the variables.""" # Captures Mustache tags. Group 1 is the sigil (#, ^, /, !, >, &, =, or # empty), group 2 is the tag body. Tags with sigil in {"", "&"} (or "{") # are variable references; everything else is structural and skipped. _TAG_RE = re.compile(r"\{\{\{?\s*([#^/!>&=]?)\s*([^}]*?)\s*\}?\}\}") def _referenced_variable_names(template): """Return the top-level variable names the template depends on. Sections (#, ^, /), comments (!), partials (>), and set-delimiter (=) tags are structural. Variables inside a section resolve against the section's own context, so only names outside any section count, and a dotted path contributes its root name (stats.atk -> stats). """ names = set() section_depth = 0 for sigil, raw_body in _TAG_RE.findall(template): tag_body = raw_body.strip() if sigil in {"#", "^"}: section_depth += 1 continue if sigil == "/": section_depth = max(0, section_depth - 1) continue if sigil in {"!", ">", "="}: continue if not tag_body: continue if section_depth == 0: names.add(tag_body.split(".", 1)[0]) return names def variables_to_dict(variables): """Collapse [{"key": ..., "value": ...}, ...] into a dict. Raises ValueError when two entries share the same key: silently keeping either value would render the wrong text. """ out = {} for entry in variables: key = entry["key"] if key in out: raise ValueError(f"duplicate variable key: {key!r}") out[key] = entry["value"] return out def render_strict(template, variables): """Render the template, raising on any missing variable. Sections may bind to lists of dicts (chevron handles iteration). """ referenced = _referenced_variable_names(template) missing = sorted(name for name in referenced if name not in variables) if missing: raise MissingVariableError( f"template references undefined variable(s): {', '.join(missing)}" ) return chevron.render(template, variables)
-
-
SKILL.md 6.9 KB
--- name: scenario-text-overlay description: "Use when text must appear letter-perfect on generated media: taglines, CTAs, prices, legal supers, lower thirds, end cards, nameplates, or styled rich cards composited over Scenario images and video. Keywords: text overlay, text card, caption card, CTA, legal super, tagline, end card, lower third, price card, HUD text, transparent PNG." license: MIT --- # Scenario Text Overlay ## Overview Generation models drift on type, so text that must be exact is rendered deterministically here and composited over the media. One JSON payload describes one overlay, in one of two kinds: a text layer (plain styled text in a box) or a rich layer (HTML/CSS typography: gradients, strokes, shadows, card layouts). Both substitute Mustache `{{variables}}` strictly (a missing variable fails the render) on a transparent canvas. [scripts/overlay.py](scripts/overlay.py) renders the payload to a PNG with an installed Chromium-family browser (both kinds, discovered automatically) or a Pillow fallback (text layers only), embeds the payload JSON into the PNG, and prints the path. Its modules: [scripts/templating.py](scripts/templating.py) (strict Mustache), [scripts/html_render.py](scripts/html_render.py) (browser engine), [scripts/pillow_render.py](scripts/pillow_render.py) (fallback). The field-by-field contract and example payloads live in [references/payloads.md](references/payloads.md). The script needs `pip install chevron pillow`. Connection and the core MCP loop: see the `scenario` skill. The overlay meets its content on the platform, never locally: `model_run` on the compose tool models, `model_scenario-compose-image` for stills and `model_scenario-compose-video` for video (fixed ids: each is Scenario's single deterministic compositor for its medium, so discovery would only re-derive them), whose layer contract lives in `scenario-video-assembly`. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap. ## Quick reference | Step | Detail | | ----------------------- | ---------------------------------------------------------------------------------------------------- | | 1. Write the payload | Kind by field (`text_template` or `html_template`); every changeable string in `variables` | | 2. Render | `python3 overlay.py --payload card.json --out card.png` | | 3. Verify | Open the PNG: glyphs verbatim, box placement right, background transparent | | 4. Upload | `upload_asset` (kind `image`, multipart path) | | 5. Persist the template | `asset_update` sets `metadata.description` to the payload JSON; `asset_add_tags` adds `text-overlay` | | 6. Composite | `model_run` on `model_scenario-compose-image` or `model_scenario-compose-video`, overlay on top | ## Choose the face for its meaning The face is part of the message: heavy condensed sans shouts (hooks, prices, CTAs), light geometric or grotesque sans reads neutral and premium (legal supers, lower thirds, nameplates), serif signals editorial or heritage weight, rounded forms read friendly and casual. Match the media's art direction rather than fighting it, keep one family per card, and build hierarchy with `font_weight` (100 to 900) and size instead of mixing faces. `font_family` reaches any Google Fonts family, so every class above is available by name; a contractual brand face rides `font_url` instead. Meaning survives only above legibility, so judge the flattened preview at destination size, where a subtle face turns generic. ## Worked example: a localized legal super 1. Payload, with the changeable line as a variable: ```json { "text_template": "{{disclaimer}}", "variables": [ { "key": "disclaimer", "value": "EPA-estimated 310 mi. Actual range varies." } ], "font_family": "Inter", "font_weight": 500, "size": 34, "color": "#FFFFFF", "align": "center", "bbox": [{ "x": 86, "y": 1560, "w": 908, "h": 120 }], "canvas_width": 1080, "canvas_height": 1920, "overflow": "shrink" } ``` 2. `python3 overlay.py --payload super.json --out super.png`, then open `super.png`: the line must read exactly as written, shrunk to fit the box. 3. Upload with `upload_asset`, then `asset_update` with the payload JSON as `metadata.description` and `asset_add_tags` with `["text-overlay"]` (`asset_update`'s own `metadata.tags` replaces the whole set): the template now lives on the platform beside its render, and the PNG itself carries it in a `tEXt` chunk. 4. Composite over the spot: `model_schema_get` on `model_scenario-compose-video`, then `model_run` with the base video at `zIndex` 0 and the super as an image layer above it (layer fields: `scenario-video-assembly`); `jobs_wait`, then `asset_display`. 5. The French variant is the same payload with one `value` edited, never a new template. ## Common mistakes - Baking changeable strings into the template: variants and localizations should be edits to `variables`; the uploaded payload is the reproducibility contract. - Using `{{var}}` for values holding `&`, `<`, or `>`: it HTML-escapes, so they render as literal entity text; `{{{var}}}` is the raw opt-out (full Mustache rules: the payload reference). - Leaving images or fonts as remote URLs: inline them as `data:` URIs (Google Fonts links excepted) or the render depends on the network and drifts. - Skipping the visual check before upload: font resolution differs per machine, and light text on the transparent canvas previews as blank; flatten over a contrasting backdrop first. - Letting a generation model paint the text instead: generated type drifts frame to frame; overlays exist to avoid exactly that. - Generating the plate with no room for the card: brief the base image or video with the overlay's zone kept plain and text-free (a dark right third, an empty top band) for every frame the card is up, so the composite lands on quiet pixels rather than generated lettering or detail. - Merging the overlay locally with Pillow or ffmpeg: compositing is a `model_run` on the compose models, so the finished creative lands on the platform beside its layers. - Sizing the canvas to something other than the destination: match the target resolution so the overlay composites 1:1 with no post-scale blur. Content layers may scale inside the compose run when a generation model cannot hit the exact size; the overlay must not. - Expecting the fallback to match the browser: Pillow draws plain text only; rich layers need a Chromium-family browser installed. - Trusting `wrap` with a hard box: it lets text spill below; use `shrink` (fits or fails loudly) or `clip` when the box is binding.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.