From 07ab8affc9375853560d89631f91f3cc37c0be33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 01:18:51 +0000 Subject: [PATCH] fix(faceless): preserve Chinese caption spacing --- skills-manifest.json | 4 +- .../faceless-explainer/scripts/captions.mjs | 162 ++++++++++++++---- .../scripts/captions.test.mjs | 60 +++++++ 3 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 skills/faceless-explainer/scripts/captions.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index 67a31a58ef..51b53d12e7 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,8 +6,8 @@ "files": 144 }, "faceless-explainer": { - "hash": "0ed7d992d246bea4", - "files": 19 + "hash": "2d1cb6eabafebdda", + "files": 20 }, "figma": { "hash": "0e6e96f5a76ff824", diff --git a/skills/faceless-explainer/scripts/captions.mjs b/skills/faceless-explainer/scripts/captions.mjs index 433248deb8..e7e7e401d4 100644 --- a/skills/faceless-explainer/scripts/captions.mjs +++ b/skills/faceless-explainer/scripts/captions.mjs @@ -32,7 +32,13 @@ // Grouping mirrors the proven heuristics (frame boundary · sentence-end punct · // silence gap · density-aware word cap); word timings come inline from audio_meta. -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { dirname, join, resolve } from "node:path"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; @@ -64,26 +70,42 @@ function runBuild(argv) { }; const hyperframesDir = resolve(flag(argv, "hyperframes", ".")); - const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md"))); - const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json"))); - const outPath = resolve(flag(argv, "out", join(hyperframesDir, "caption_groups.json"))); + const storyboardPath = resolve( + flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")), + ); + const audioMetaPath = resolve( + flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")), + ); + const outPath = resolve( + flag(argv, "out", join(hyperframesDir, "caption_groups.json")), + ); const htmlPath = join(hyperframesDir, "compositions/captions.html"); const overridesPath = join(hyperframesDir, "caption-overrides.json"); const skinArg = flag(argv, "skin", null); - const hiddenSkinPath = join(hyperframesDir, ".hyperframes", "caption-skin.html"); + const hiddenSkinPath = join( + hyperframesDir, + ".hyperframes", + "caption-skin.html", + ); const legacySkinPath = join(hyperframesDir, "caption-skin.html"); const skinPath = resolve( skinArg ?? (existsSync(hiddenSkinPath) ? hiddenSkinPath : legacySkinPath), ); - const framePath = resolve(flag(argv, "frame", join(hyperframesDir, "frame.md"))); + const framePath = resolve( + flag(argv, "frame", join(hyperframesDir, "frame.md")), + ); - if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`); + if (!existsSync(storyboardPath)) + die(`STORYBOARD.md not found at ${storyboardPath}`); const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8")); const { width: W, height: H } = parseFormat(manifest.globals.format); + const language = String(manifest.globals.extra?.language ?? "").toLowerCase(); + const compactWordSpacing = /^(?:zh|ja)(?:[-_]|$)/.test(language); if (!existsSync(audioMetaPath)) skip("no audio_meta.json (silent film)"); const meta = JSON.parse(readFileSync(audioMetaPath, "utf8")); - if (!Array.isArray(meta.voices) || meta.voices.length === 0) skip("no narration"); + if (!Array.isArray(meta.voices) || meta.voices.length === 0) + skip("no narration"); // cumulative frame starts (by frame number) + total duration, from STORYBOARD. const startByFrame = new Map(); @@ -103,7 +125,12 @@ function runBuild(argv) { const text = String(w.text ?? "").trim(); if (!text || /^[.?!,;:—–-]+$/.test(text)) continue; // drop empties + bare punctuation if (!isFinite(w.start) || !isFinite(w.end)) continue; - words.push({ text, start: r3(base + w.start), end: r3(base + w.end), frame: v.frame }); + words.push({ + text, + start: r3(base + w.start), + end: r3(base + w.end), + frame: v.frame, + }); } } words.sort((a, b) => a.start - b.start); @@ -113,7 +140,12 @@ function runBuild(argv) { const densityAt = (i) => { const t0 = words[i].start; let n = 0; - for (let j = i; j < words.length && words[j].start < t0 + DENSITY_WINDOW; j++) n++; + for ( + let j = i; + j < words.length && words[j].start < t0 + DENSITY_WINDOW; + j++ + ) + n++; return n / DENSITY_WINDOW; }; @@ -151,7 +183,7 @@ function runBuild(argv) { frame: g.frame, start: r3(first.start), end, - text: g.words.map((w) => w.text).join(" "), + text: g.words.map((w) => w.text).join(compactWordSpacing ? "" : " "), words: g.words.map((w, wi) => ({ id: `caption-word-${gi}-${wi}`, text: w.text, @@ -165,7 +197,11 @@ function runBuild(argv) { mkdirSync(dirname(outPath), { recursive: true }); writeFileSync( outPath, - JSON.stringify({ total_duration_s: total, width: W, height: H, groups: finalized }, null, 2), + JSON.stringify( + { total_duration_s: total, width: W, height: H, groups: finalized }, + null, + 2, + ), ); // ── write compositions/captions.html (preset skin if present, else default) ── @@ -174,7 +210,9 @@ function runBuild(argv) { if (existsSync(skinPath)) { const tokens = frameTokensCss(framePath, H); const faces = brandFontFaces(framePath, hyperframesDir); - const fonts = existsSync(framePath) ? parseFonts(readFileSync(framePath, "utf8")) : {}; + const fonts = existsSync(framePath) + ? parseFonts(readFileSync(framePath, "utf8")) + : {}; writeFileSync( htmlPath, buildFromSkin( @@ -187,11 +225,15 @@ function runBuild(argv) { die, faces, fonts, + compactWordSpacing, ), ); source = `preset skin (${skinPath.replace(hyperframesDir + "/", "")})`; } else { - writeFileSync(htmlPath, buildCaptionsHtml(finalized, total, W, H)); + writeFileSync( + htmlPath, + buildCaptionsHtml(finalized, total, W, H, compactWordSpacing), + ); source = "default (built-in pill)"; } @@ -225,10 +267,22 @@ function runBuild(argv) { // so the active-word highlight clips — a line-height floor + word padding fixes it // · data-composition-id + dimensions on the