diff --git a/skills/embedded-captions/scripts/make-theme.cjs b/skills/embedded-captions/scripts/make-theme.cjs index e99cdc32c5..b0d6b71657 100644 --- a/skills/embedded-captions/scripts/make-theme.cjs +++ b/skills/embedded-captions/scripts/make-theme.cjs @@ -8537,7 +8537,7 @@ if (!bodyInBg || fx.html || fx.js || setp.fgHtml) { } // _postfx.sh: plate reaction after the matte composite (subject+text move as one) -const P = HEROLESS ? { grain: (dna.plate || {}).grain || 5 } : dna.plate || {}; +const P = HEROLESS ? { grain: (dna.plate || {}).grain ?? 5 } : dna.plate || {}; // punchOffset: themes whose impact is NOT the hero onset (e.g. flapboard's // lock-complete clack) shift the plate punch anchor; default keeps onset+2f const anchorT = (heroIn + (P.punchOffset ?? 0.045)).toFixed(3); @@ -8562,6 +8562,8 @@ if (P.punch) { const rgba = P.rgbashift ? `,rgbashift=rh=-${P.rgbashift}:bh=${P.rgbashift}:enable='between(t,${anchorT},${(heroIn + 0.125).toFixed(3)})+between(t,${(LASTWORD.start + 0.04).toFixed(3)},${(LASTWORD.start + 0.17).toFixed(3)})',format=yuv420p` : ""; +const grain = P.grain ?? 5; +const noise = grain === 0 ? "" : `,\n noise=alls=${grain}:allf=t+u`; fs.writeFileSync( path.join(PROJECT, "_postfx.sh"), `#!/usr/bin/env bash @@ -8569,8 +8571,7 @@ fs.writeFileSync( set -euo pipefail cd "$(dirname "$0")" ffmpeg -y -v error -i final.mp4 -filter_complex " - [0:v]${filter}${rgba}, - noise=alls=${P.grain || 5}:allf=t+u[v]" \\ + [0:v]${filter}${rgba}${noise}[v]" \\ -map "[v]" -map 0:a -c:v libx264 -crf 14 -preset slow -profile:v high -c:a copy \\ final_fx.mp4 echo "[postfx] ${dna.name} → final_fx.mp4" diff --git a/skills/embedded-captions/scripts/make-theme.test.mjs b/skills/embedded-captions/scripts/make-theme.test.mjs new file mode 100644 index 0000000000..1df25a0e0a --- /dev/null +++ b/skills/embedded-captions/scripts/make-theme.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = fileURLToPath(new URL("./make-theme.cjs", import.meta.url)); +const SKILL = dirname(dirname(SCRIPT)); + +function compilePostFx(grain) { + const root = mkdtempSync(join(tmpdir(), "embedded-captions-theme-")); + const skill = join(root, "skill"); + const project = join(root, "project"); + + mkdirSync(join(skill, "scripts"), { recursive: true }); + mkdirSync(join(skill, "themes"), { recursive: true }); + mkdirSync(project); + cpSync(SCRIPT, join(skill, "scripts", "make-theme.cjs"), { recursive: true }); + const dna = JSON.parse(readFileSync(join(SKILL, "themes", "anchor.json"), "utf8")); + if (grain === undefined) delete dna.plate.grain; + else dna.plate.grain = grain; + writeFileSync(join(skill, "themes", "anchor.json"), JSON.stringify(dna)); + + writeFileSync( + join(project, "theme.json"), + JSON.stringify({ + dna: "anchor", + lines: [["HELLO"]], + width: 480, + height: 270, + fps: 24, + duration: 2, + }), + ); + writeFileSync( + join(project, "transcript.json"), + JSON.stringify({ words: [{ text: "HELLO", start: 0.3, end: 0.7 }] }), + ); + + try { + execFileSync(process.execPath, [join(skill, "scripts", "make-theme.cjs"), project]); + return readFileSync(join(project, "_postfx.sh"), "utf8"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test("explicit plate grain zero omits the noise filter", () => { + const postFx = compilePostFx(0); + + assert.doesNotMatch(postFx, /noise=alls=/); + assert.match(postFx, /\[0:v\]null\[v\]/); +}); + +test("missing plate grain retains the default noise level", () => { + const postFx = compilePostFx(undefined); + + assert.match(postFx, /noise=alls=5:allf=t\+u/); +});