[object Object]

← back to Allnewsdaily

Fix daily-short crash: reject SVG stock images instead of crashing the whole render

ba6d50f6cbbd79622c472679007737efdc5f9224 · 2026-09-11 06:30:14 -0700 · Steve Abrams

Root cause (TK-11458): Openverse sometimes returns an SVG (a world/BRICS map)
tagged as a photo match; fetch-stock.mjs only checked the content-type against
/image\// which matches image/svg+xml, so the SVG got saved as e.g. img/2.jpg.
ImageMagick content-sniffs the file regardless of extension, renders it as
vector art via its SVG coder, and tries to render embedded <text> labels
through its font engine — an unresolvable font-family there throws
"unable to read font `'" from RenderFreetype and kills the entire 8-card
render (reproduced standalone: `magick img/2.jpg ...` -> identical error).

This was NOT a font-resolution bug in render-short.js's FONT_PRIMARY/
FONT_FALLBACK logic (unrelated, correctly resolved) and NOT a concurrency
bug (the pipeline is fully synchronous execFileSync, no Promise.all/async).

Fix, both ends:
- fetch-stock.mjs: reject image/svg+xml in downloadFirstUsable(), mirroring
  the exclusion grabLogo() already had for logos but that was never mirrored
  onto the stock-photo downloader.
- render-short.js: imgPath() now validates real magic bytes (JPEG/PNG/GIF/
  WEBP/BMP) before handing a file to magick; anything else (SVG, HTML error
  page, truncated download) is treated as "no image" and falls back to the
  existing gradient-card background instead of crashing the pipeline.

Verified: re-ran render-short.js directly against today's already-fetched
stories.json/script.json/vo.mp3/img (reused, no ElevenLabs re-spend). The new
guard caught TWO bad SVGs in this batch (img/2.jpg and img/3.jpg, the latter
a literal "BRICS members" Wikipedia map matching the BRICS headline) and both
cards rendered cleanly on the gradient fallback. All 8 cards + final out.mp4
(1080x1920, 46.30s, VO AAC) produced with no errors or residual warnings.

Files touched

Diff

commit ba6d50f6cbbd79622c472679007737efdc5f9224
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 06:30:14 2026 -0700

    Fix daily-short crash: reject SVG stock images instead of crashing the whole render
    
    Root cause (TK-11458): Openverse sometimes returns an SVG (a world/BRICS map)
    tagged as a photo match; fetch-stock.mjs only checked the content-type against
    /image\// which matches image/svg+xml, so the SVG got saved as e.g. img/2.jpg.
    ImageMagick content-sniffs the file regardless of extension, renders it as
    vector art via its SVG coder, and tries to render embedded <text> labels
    through its font engine — an unresolvable font-family there throws
    "unable to read font `'" from RenderFreetype and kills the entire 8-card
    render (reproduced standalone: `magick img/2.jpg ...` -> identical error).
    
    This was NOT a font-resolution bug in render-short.js's FONT_PRIMARY/
    FONT_FALLBACK logic (unrelated, correctly resolved) and NOT a concurrency
    bug (the pipeline is fully synchronous execFileSync, no Promise.all/async).
    
    Fix, both ends:
    - fetch-stock.mjs: reject image/svg+xml in downloadFirstUsable(), mirroring
      the exclusion grabLogo() already had for logos but that was never mirrored
      onto the stock-photo downloader.
    - render-short.js: imgPath() now validates real magic bytes (JPEG/PNG/GIF/
      WEBP/BMP) before handing a file to magick; anything else (SVG, HTML error
      page, truncated download) is treated as "no image" and falls back to the
      existing gradient-card background instead of crashing the pipeline.
    
    Verified: re-ran render-short.js directly against today's already-fetched
    stories.json/script.json/vo.mp3/img (reused, no ElevenLabs re-spend). The new
    guard caught TWO bad SVGs in this batch (img/2.jpg and img/3.jpg, the latter
    a literal "BRICS members" Wikipedia map matching the BRICS headline) and both
    cards rendered cleanly on the gradient fallback. All 8 cards + final out.mp4
    (1080x1920, 46.30s, VO AAC) produced with no errors or residual warnings.
---
 scripts/short/fetch-stock.mjs |  6 ++++--
 scripts/short/render-short.js | 35 ++++++++++++++++++++++++++++++++++-
 2 files changed, 38 insertions(+), 3 deletions(-)

diff --git a/scripts/short/fetch-stock.mjs b/scripts/short/fetch-stock.mjs
index 5891aa1..0e10786 100644
--- a/scripts/short/fetch-stock.mjs
+++ b/scripts/short/fetch-stock.mjs
@@ -40,8 +40,10 @@ async function downloadFirstUsable(results, outPath) {
       try {
         const r = await tfetch(src, {}, 10000);
         if (!r.ok) continue;
-        const ct = r.headers.get('content-type') || '';
-        if (!/image\//.test(ct)) continue;
+        const ct = (r.headers.get('content-type') || '').split(';')[0].trim();
+        if (!/image\//.test(ct) || ct === 'image/svg+xml') continue; // skip svg — not a raster image; IM will
+        // content-sniff it as vector and try to render any embedded <text> via its font engine, which can
+        // crash the whole render on an unresolvable font-family (see TK-11458).
         const buf = Buffer.from(await r.arrayBuffer());
         if (buf.length < 6000) continue; // skip tiny/broken
         fs.writeFileSync(outPath, buf);
diff --git a/scripts/short/render-short.js b/scripts/short/render-short.js
index 5f0a294..86faab2 100644
--- a/scripts/short/render-short.js
+++ b/scripts/short/render-short.js
@@ -149,7 +149,40 @@ if (target > HARD_CAP_SEC) {
 // ---------------------------------------------------------------------------
 function cleanIntro(t) { return String(t).replace(/^\s*All News Daily\.\s*/i, '').trim() || t; }
 
-function imgPath(name) { const p = path.join(IMG_DIR, `${name}.jpg`); return fs.existsSync(p) ? p : null; }
+// Guard against a stock "photo" that is actually SVG/HTML/text saved with a .jpg extension
+// (TK-11458 — Openverse handed back an SVG world-map that fetch-stock.mjs's content-type check
+// let through; ImageMagick content-sniffs the file regardless of extension, treats it as vector
+// art, and tries to render any embedded <text> label via its font engine — an unresolvable
+// font-family there crashes the ENTIRE 8-card render with a cryptic RenderFreetype error). This
+// checks real magic bytes so any non-raster file (this SVG case, a truncated download, an HTML
+// error page saved by mistake) is treated as "no image" and falls back to the gradient card
+// below instead of taking the whole pipeline down.
+function isRasterImage(p) {
+  try {
+    const fd = fs.openSync(p, 'r');
+    const buf = Buffer.alloc(16);
+    const n = fs.readSync(fd, buf, 0, 16, 0);
+    fs.closeSync(fd);
+    const b = buf.subarray(0, n);
+    if (b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return true; // JPEG
+    if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return true; // PNG
+    if (b.length >= 6 && ['GIF87a', 'GIF89a'].includes(b.toString('ascii', 0, 6))) return true; // GIF
+    if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return true; // WEBP
+    if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4D) return true; // BMP
+    return false; // anything else (SVG/XML/HTML/text/etc.) — not a raster image, reject
+  } catch (_) { return false; }
+}
+
+function imgPath(name) {
+  const p = path.join(IMG_DIR, `${name}.jpg`);
+  if (!fs.existsSync(p)) return null;
+  if (!isRasterImage(p)) {
+    console.warn(`[render-short] WARN: ${name}.jpg is not a real raster image (magic-byte check ` +
+      `failed — likely an SVG/HTML mis-saved as .jpg) — using gradient background instead.`);
+    return null;
+  }
+  return p;
+}
 function logoPath(n) { try { const f = fs.readdirSync(LOGO_DIR).find((x) => x.startsWith(String(n) + '.')); return f ? path.join(LOGO_DIR, f) : null; } catch { return null; } }
 
 const segments = [];

← c46e8cf auto-data-snapshot: 2026-09-10T20:45:30 (1 data files) — ver  ·  back to Allnewsdaily  ·  Vary daily Short layouts and narration with verified media p 40c4f7b →