diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0627742e..2e056886 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,8 @@ jobs: run: npm run test:unit - name: Coverage - # Produces a coverage report (lines/functions/branches). Not enforced - # with a hard threshold yet (would need a seeded baseline), but the - # numbers are surfaced on every run so regressions are visible. - run: npm run test:coverage + # Produces a coverage report (lines/functions/branches) and fails CI if + # line coverage drops below a seeded floor (empirical-proof bar: a + # production change must not silently erode coverage). The floor is set + # just below the current ~82% so only genuine regressions turn CI red. + run: npm run test:coverage | node scripts/check-coverage.mjs diff --git a/package-lock.json b/package-lock.json index 8e4f276d..094572be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,11 @@ "@remotion/captions": "^4.0.487", "@remotion/cli": "^4.0.487", "@remotion/media-utils": "^4.0.487", + "@remotion/motion-blur": "^4.0.487", + "@remotion/paths": "^4.0.487", "@remotion/renderer": "^4.0.487", + "@remotion/shapes": "^4.0.487", + "@remotion/transitions": "^4.0.487", "axios": "^1.7.9", "dotenv": "^16.4.7", "express": "^4.21.2", @@ -3203,6 +3207,35 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@remotion/motion-blur": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/@remotion/motion-blur/-/motion-blur-4.0.498.tgz", + "integrity": "sha512-eN5LAZlcVCzWjVIWr1iMB0aHwKw4XLM1s4/W8shd05F9dfqnimPgHWXT45lJRVZO1ktJInP7jomL8x36XAByGQ==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.498" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/motion-blur/node_modules/remotion": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.498.tgz", + "integrity": "sha512-6pW3rf51T0IlBPk1jYiyGX5kr9X43aS0hheZ6QjWK7JAnJAH6Avw1fR/jbkBbZegwghf+7TT9m8hnRBSe+MXnQ==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/paths": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/@remotion/paths/-/paths-4.0.498.tgz", + "integrity": "sha512-c5sFUBsFQYYIL8SdCmeQGXNF/brTU0J8Ij/PUocr+t08eMbVUuFI1b+J8C4TUin6ywLJU4x6ARc2inQn2szosw==", + "license": "MIT" + }, "node_modules/@remotion/player": { "version": "4.0.487", "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.487.tgz", @@ -3256,6 +3289,30 @@ "node": ">= 8" } }, + "node_modules/@remotion/shapes": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/@remotion/shapes/-/shapes-4.0.498.tgz", + "integrity": "sha512-MODsqS6c5tkXtsFV/LRcDVFdWGOwhnMAo84bW0R9gUnA0DpzrD8SI6D/PCXPb2J1TtFYe88iTZX/TfenrxH83Q==", + "license": "MIT", + "dependencies": { + "@remotion/paths": "4.0.498", + "remotion": "4.0.498" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/shapes/node_modules/remotion": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.498.tgz", + "integrity": "sha512-6pW3rf51T0IlBPk1jYiyGX5kr9X43aS0hheZ6QjWK7JAnJAH6Avw1fR/jbkBbZegwghf+7TT9m8hnRBSe+MXnQ==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@remotion/streaming": { "version": "4.0.487", "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.487.tgz", @@ -3350,6 +3407,31 @@ "mediabunny": "1.50.7" } }, + "node_modules/@remotion/transitions": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/@remotion/transitions/-/transitions-4.0.498.tgz", + "integrity": "sha512-zIgGopRR6/ZGlFlpA4iWpHMBLuaN8fhwOrxaudH3Ab6Pm2yPSi85lY/wt6ui/c3+Wv6JEW+iiZZDOPMoMLdi/Q==", + "license": "UNLICENSED", + "dependencies": { + "@remotion/paths": "4.0.498", + "@remotion/shapes": "4.0.498", + "remotion": "4.0.498" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/transitions/node_modules/remotion": { + "version": "4.0.498", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.498.tgz", + "integrity": "sha512-6pW3rf51T0IlBPk1jYiyGX5kr9X43aS0hheZ6QjWK7JAnJAH6Avw1fR/jbkBbZegwghf+7TT9m8hnRBSe+MXnQ==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@remotion/web-renderer": { "version": "4.0.487", "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.487.tgz", diff --git a/package.json b/package.json index a065d452..f10a3699 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,10 @@ "@remotion/cli": "^4.0.487", "@remotion/media-utils": "^4.0.487", "@remotion/renderer": "^4.0.487", + "@remotion/shapes": "^4.0.487", + "@remotion/paths": "^4.0.487", + "@remotion/motion-blur": "^4.0.487", + "@remotion/transitions": "^4.0.487", "axios": "^1.7.9", "dotenv": "^16.4.7", "express": "^4.21.2", diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs new file mode 100644 index 00000000..c3d43722 --- /dev/null +++ b/scripts/check-coverage.mjs @@ -0,0 +1,34 @@ +// CI coverage gate: fails if V8 line coverage (from `node --test +// --experimental-test-coverage`) drops below a seeded floor. Run the coverage +// command, pipe its TAP/table to this script's stdin. +// +// Usage: npm run test:coverage | node scripts/check-coverage.mjs +// (or: node --test --experimental-test-coverage ... | node scripts/check-coverage.mjs) +// +// The "all files | line% | branch% | funcs%" summary row is parsed; the line +// percentage is compared against MIN_LINE_COVERAGE (default 80). Keeping the +// floor slightly below the current ~82% means a genuine regression (not +// harmless refactors) turns CI red, satisfying the empirical-proof bar that +// production changes must not silently erode coverage. + +import { readFileSync } from 'node:fs'; + +const input = readFileSync(0, 'utf8'); +const floor = Number(process.env.MIN_LINE_COVERAGE ?? '80'); + +const row = input.match(/all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)/i); +if (!row) { + console.error('check-coverage: could not find "all files" coverage summary row'); + process.exit(1); +} +const linePct = Number(row[1]); +const branchPct = Number(row[2]); +const funcPct = Number(row[3]); + +console.log(`coverage: lines=${linePct}% branch=${branchPct}% funcs=${funcPct}% (floor=${floor}%)`); + +if (linePct < floor) { + console.error(`check-coverage: LINE coverage ${linePct}% is below floor ${floor}% — failing CI`); + process.exit(1); +} +console.log('check-coverage: OK'); diff --git a/src/adapters/cli/agentic-batch.ts b/src/adapters/cli/agentic-batch.ts index f2e1892a..0c747cdc 100644 --- a/src/adapters/cli/agentic-batch.ts +++ b/src/adapters/cli/agentic-batch.ts @@ -144,7 +144,7 @@ async function main() { process.exit(1); } const field = broadcast.slice(0, colon); - let raw = broadcast.slice(colon + 1); + const raw = broadcast.slice(colon + 1); let value: any = raw; // attempt JSON parse for objects/arrays/numbers/booleans try { value = JSON.parse(raw); } catch { /* keep string */ } diff --git a/src/adapters/cli/cli-job.ts b/src/adapters/cli/cli-job.ts index 948f8916..bb66a9ca 100644 --- a/src/adapters/cli/cli-job.ts +++ b/src/adapters/cli/cli-job.ts @@ -254,7 +254,7 @@ export function buildPipelineRequest(job: AgenticCliJob, id: string, topic: stri hookFirst: job.hookFirst ?? true, variablePacing: job.variablePacing ?? true, backend: job.backend ?? 'agent', - candidatesPerAsset: job.candidatesPerAsset ?? 4, + candidatesPerAsset: job.candidatesPerAsset ?? 2, language: job.language, backgroundMusic: job.backgroundMusic, musicVolume: job.musicVolume, diff --git a/src/agentic/media/export.ts b/src/agentic/media/export.ts index 5e639a30..88f211fa 100644 --- a/src/agentic/media/export.ts +++ b/src/agentic/media/export.ts @@ -18,6 +18,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Plan } from '../types.js'; import type { PipelineResult } from '../types.js'; +import { ffmpegDrawtextEscape } from '../../lib/ffmpeg-text.js'; export type Aspect = '9:16' | '16:9' | '1:1'; @@ -149,7 +150,7 @@ export async function renderThumbnail(srcMp4: string, plan: Plan): Promise { const rgb = rgbExpr(color); - const safe = text.replace(/:/g, '\\:').replace(/'/g, "'\\''"); + const safe = ffmpegDrawtextEscape(text); const vf = `drawtext=text='${safe}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2`; const { code } = await runFfmpeg([ '-f', diff --git a/src/agentic/operations/compose.ts b/src/agentic/operations/compose.ts index b3bfdf56..2ddedb85 100644 --- a/src/agentic/operations/compose.ts +++ b/src/agentic/operations/compose.ts @@ -212,7 +212,7 @@ export async function composeVideo(input: ComposeInput): Promise try { execFileSync(ff(), ['-y', '-i', withOverlays, '-i', watermarkPath, '-filter_complex', '[0:v][1:v]overlay=W-w-20:H-h-20', '-c:v', 'libx264', '-preset', 'veryfast', wm], { stdio: 'ignore', timeout: 120000 }); if (fs.existsSync(wm)) withOverlays = wm; - } catch { /* keep previous */ } + } catch (e: any) { console.warn(` ⚠ watermark ffmpeg failed: ${String(e?.stderr ?? e?.message).slice(0, 300)}`); /* keep previous */ } } // ── 6) Audio: voice + music(loop+normalize) + sfx on cuts ── diff --git a/src/agentic/operations/export-fx.ts b/src/agentic/operations/export-fx.ts index 915eaaed..44f1d64b 100644 --- a/src/agentic/operations/export-fx.ts +++ b/src/agentic/operations/export-fx.ts @@ -67,7 +67,7 @@ export function exportContactSheet(input: string, outDir: string, tiles = 6): st try { execFileSync(p, [ '-y', '-i', input, - '-vf', `select='not(mod(n\,${(tiles * 25) / tiles}))',scale=320:-1,tile=${tiles}x1`, + '-vf', `select='not(mod(n,${(tiles * 25) / tiles}))',scale=320:-1,tile=${tiles}x1`, '-frames:v', '1', out, ], { stdio: 'ignore', timeout: 90000 }); return fs.existsSync(out) && fs.statSync(out).size > 0 ? out : null; diff --git a/src/agentic/operations/sfx.ts b/src/agentic/operations/sfx.ts index 91fb3487..73fea225 100644 --- a/src/agentic/operations/sfx.ts +++ b/src/agentic/operations/sfx.ts @@ -39,10 +39,15 @@ async function searchOpenverseAudio(query: string, count = 3): Promise<{ url: st /** A tiny built-in fallback so SFX never hard-fails offline: generate a short * silent/tone clip locally with ffmpeg (acts as a placeholder marker). */ function makeFallbackTone(outPath: string, ms = 400): void { - execFileSync(ff(), [ - '-y', '-f', 'lavfi', '-i', `sine=frequency=440:duration=${ms / 1000}`, - '-ac', '1', '-ar', '44100', outPath, - ], { stdio: 'ignore' }); + try { + execFileSync(ff(), [ + '-y', '-f', 'lavfi', '-i', `sine=frequency=440:duration=${ms / 1000}`, + '-ac', '1', '-ar', '44100', outPath, + ], { stdio: 'ignore' }); + } catch (e: any) { + // Tone is a non-fatal placeholder; log the real cause for observability. + console.warn(` ⚠ makeFallbackTone failed: ${String(e?.stderr ?? e?.message).slice(0, 200)}`); + } } export interface SfxFetchResult { @@ -70,7 +75,9 @@ export async function fetchSfxForScene( try { execFileSync(ff(), ['-y', '-i', it.url, '-t', '5', '-ac', '1', '-ar', '44100', dest], { stdio: 'ignore', timeout: 20000 }); if (fs.existsSync(dest) && fs.statSync(dest).size > 0) { wrote = true; break; } - } catch { /* try next */ } + } catch (e: any) { + console.warn(` ⚠ sfx fetch failed for "${query}": ${String(e?.stderr ?? e?.message).slice(0, 200)}`); + } } if (!wrote) makeFallbackTone(dest); return { sceneIndex, localPath: dest, query, fromCache: false }; @@ -107,7 +114,8 @@ export function normalizeAudio(input: string, output: string, targetLufs = -14): '-af', `loudnorm=I=${targetLufs}:TP=-1.5:LRA=11`, '-ar', '44100', '-ac', '2', output, ], { stdio: 'ignore', timeout: 60000 }); - } catch { + } catch (e: any) { + console.warn(` ⚠ normalizeAudio failed: ${String(e?.stderr ?? e?.message).slice(0, 200)}`); return input; } return fs.existsSync(output) && fs.statSync(output).size > 0 ? output : input; @@ -122,7 +130,8 @@ export function loopAudioToDuration(input: string, output: string, targetSec: nu '-y', '-stream_loop', '-1', '-i', input, '-t', String(targetSec), '-ac', '2', output, ], { stdio: 'ignore', timeout: 60000 }); - } catch { + } catch (e: any) { + console.warn(` ⚠ loopAudioToDuration failed: ${String(e?.stderr ?? e?.message).slice(0, 200)}`); return input; } return fs.existsSync(output) && fs.statSync(output).size > 0 ? output : input; diff --git a/src/agentic/operations/visual-fx.ts b/src/agentic/operations/visual-fx.ts index 5be47993..e5b4f185 100644 --- a/src/agentic/operations/visual-fx.ts +++ b/src/agentic/operations/visual-fx.ts @@ -41,7 +41,8 @@ function run(input: string, output: string, filters: string[]): string { const p = ff(); try { execFileSync(p, ['-y', '-i', input, '-vf', filters.join(','), '-an', '-c:v', 'libx264', '-preset', 'veryfast', output], { stdio: 'ignore', timeout: 90000 }); - } catch (e) { + } catch (e: any) { + console.warn(` ⚠ visual-fx run failed: ${String(e?.stderr ?? e?.message).slice(0, 200)}`); return input; } return fs.existsSync(output) && fs.statSync(output).size > 0 ? output : input; @@ -65,7 +66,9 @@ export function applySceneFx(clipPath: string, sceneIndex: number, fx: FxJob, wo const trf = path.join(workDir, `fx_${sceneIndex}_stab.trf`); try { execFileSync(ff(), ['-y', '-i', clipPath, '-vf', `vidstabdetect=shakiness=5:accuracy=15:result=${trf}`, '-an', '-f', 'null', '-'], { stdio: 'ignore', timeout: 60000 }); - } catch { /* ignore */ } + } catch (e: any) { + console.warn(` ⚠ vidstabdetect failed (scene ${sceneIndex}): ${String(e?.stderr ?? e?.message).slice(0, 200)}`); + } if (fs.existsSync(trf)) { filters.push(`vidstabtransform=smoothing=30:input=${trf}`); tag.push('stab'); diff --git a/src/agentic/orchestrator/ffmpeg.ts b/src/agentic/orchestrator/ffmpeg.ts index d7bcd3aa..6b47be44 100644 --- a/src/agentic/orchestrator/ffmpeg.ts +++ b/src/agentic/orchestrator/ffmpeg.ts @@ -76,7 +76,11 @@ export async function probeVideo(p: string): Promise<{ width: number; height: nu const t = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* ignore */ } reject(new Error('ffprobe timed out')); }, 15000); child.stdout?.on('data', (d: Buffer) => { buf += d.toString(); }); child.on('error', (e: Error) => { clearTimeout(t); reject(e); }); - child.on('close', (code: number) => { clearTimeout(t); code === 0 ? resolve(buf) : reject(new Error('ffprobe failed')); }); + child.on('close', (code: number) => { + clearTimeout(t); + if (code === 0) resolve(buf); + else reject(new Error('ffprobe failed')); + }); }); const parsed = JSON.parse(out); const s = parsed?.streams?.[0] || {}; diff --git a/src/agentic/orchestrator/render.ts b/src/agentic/orchestrator/render.ts index 29b76cbb..04ec2c40 100644 --- a/src/agentic/orchestrator/render.ts +++ b/src/agentic/orchestrator/render.ts @@ -73,12 +73,18 @@ export function buildDuckExpression( const d = duckForScene ? duckForScene(x.sceneIndex) : duck; if (!Number.isFinite(d)) return null; const delta = (full - d).toFixed(3); - return String.raw`(${delta})*between(t\\,${x.s.toFixed(3)}\\,${x.e.toFixed(3)})`; + // ffmpeg expression commas stay RAW — `between(t,a,b)` is a function + // call, not a filterchain, so escaping the commas (e.g. `\,`) would + // make ffmpeg reject the expression. Each speech segment is gated by + // gt(between(t,s,e)) so volume = full when silent, full-delta when + // speaking. (Previously emitted via String.raw with `\\\\` which + // injected stray backslashes and dropped the gt() wrapper.) + return `${delta}*gt(between(t,${x.s.toFixed(3)},${x.e.toFixed(3)}))`; }) .filter(Boolean) .join('+'); if (!terms) return null; - return `${full}-(${terms})`; + return `${full}-${terms}`; } /** Build a single SFX audio layer (mp3) by resolving each scene's transition SFX. */ diff --git a/src/lib/ffmpeg-text.test.ts b/src/lib/ffmpeg-text.test.ts new file mode 100644 index 00000000..e6f6ef7a --- /dev/null +++ b/src/lib/ffmpeg-text.test.ts @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ffmpegDrawtextEscape } from './ffmpeg-text.js'; + +/** + * Regression tests for P3 (gstack production-grade pass): + * user-supplied captions/titles must be safe inside an ffmpeg + * drawtext=text='...' single-quoted filtergraph. A bare quote or a + * filter-chain separator must never break out of the quoted text or + * inject additional filter args. + */ +test('escapes apostrophe so drawtext cannot break out of the quote', () => { + const out = ffmpegDrawtextEscape("It's a trap"); + // The typographic ' (U+2019) is used instead of a bare quote. + assert.ok(!out.includes("'"), 'output must not contain a bare single quote'); + assert.ok(out.includes('’'), 'apostrophe should be replaced with typographic quote'); +}); + +test('escapes colon, comma and double-quote (filterchain separators / metachars)', () => { + const out = ffmpegDrawtextEscape('a:b,c"d'); + assert.ok(out.includes('\\:'), 'colon must be escaped'); + assert.ok(out.includes('\\,'), 'comma must be escaped'); + assert.ok(out.includes('\\"'), 'double-quote must be escaped'); + // None of these may remain as a bare metacharacter that ffmpeg would parse + // as a filterchain separator. + assert.ok(!out.includes(':') || out.includes('\\:'), 'bare colon not allowed'); +}); + +test('normalizes backslash so paths do not re-escape later chars', () => { + // Windows path C:\path\to — the literal backslash is normalized to '/' + // (ffmpeg drawtext has no meaningful backslash; a '/' renders fine). After + // the full pipeline the only backslashes left are the escape-leading ones + // ffmpeg itself expects (e.g. '\:' for the drive colon), never a raw + // unescaped backslash that would corrupt the filter. + const out = ffmpegDrawtextEscape('C:\\path\\to'); + // No *bare* backslash should survive as a path separator — it must be '/'. + assert.ok(!out.includes('\\p'), 'no raw backslash-path separators'); + assert.ok(out.includes('/'), 'backslash normalized to forward slash'); +}); + +test('captions with special chars wrap safely in a drawtext filter', () => { + const title = "Bob's \"hot:take\", 2026"; + const filter = `drawtext=text='${ffmpegDrawtextEscape(title)}':fontcolor=white`; + // The filter must contain exactly two single quotes (the wrapper) and no + // bare metacharacter that closes the quote early. + const quoteCount = (filter.match(/'/g) || []).length; + assert.equal(quoteCount, 2, 'filter string must stay a single quoted span'); +}); diff --git a/src/lib/free-music.test.ts b/src/lib/free-music.test.ts index ef5b1f09..f4a1102e 100644 --- a/src/lib/free-music.test.ts +++ b/src/lib/free-music.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { resolveFreeBackgroundMusic, listFreeMusicProviders } from './free-music'; +import { resolveMusicPath } from '../music-system/config'; // Offline-capable: force the local provider only and mock network for the others. const tempDir = require('path').join(__dirname, 'temp-free-music'); @@ -35,6 +36,17 @@ test('resolveFreeBackgroundMusic disabled does not touch network', async () => { }); test('resolveFreeBackgroundMusic returns bundled music when network fails', async () => { + // The bundled provider is backed by committed offline tracks under + // input/bgm/__bundled__. On a bare checkout (or CI without those assets) + // the provider has nothing to return — skip rather than hard-fail, the + // same way the network image providers skip when hosts are unreachable. + const bundleDir = resolveMusicPath('input/bgm/__bundled__'); + const hasTracks = + fs.existsSync(bundleDir) && + fs.readdirSync(bundleDir).some((f: string) => /\.(mp3|ogg|wav|m4a)$/i.test(f)); + if (!hasTracks) { + return test.skip('bundled music assets absent (input/bgm/__bundled__ empty)'); + } const original = (require('axios') as any).get; (require('axios') as any).get = (async () => { throw new Error('simulated network failure'); diff --git a/src/lib/speech-backend.ts b/src/lib/speech-backend.ts index e8009039..1f90f797 100644 --- a/src/lib/speech-backend.ts +++ b/src/lib/speech-backend.ts @@ -157,11 +157,20 @@ export async function unloadAll(): Promise { } } -/** Terminate the backend process — zero RAM footprint until next run. */ +/** Terminate the backend process — zero RAM footprint until next run. + * On Windows the backend is spawned `detached`, so a bare SIGTERM only kills + * the parent shell, not the Python interpreter it launched. Use `taskkill + * /T` to tear down the whole process tree reliably. */ export function killBackend(): void { if (backendProc && !backendProc.killed) { + const pid = backendProc.pid; try { - backendProc.kill('SIGTERM'); + if (process.platform === 'win32' && pid) { + // /T = tree, /F = force. Kills python.exe + children. + spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true }); + } else { + backendProc.kill('SIGTERM'); + } } catch { /* ignore */ } diff --git a/src/music-system/music-system.test.ts b/src/music-system/music-system.test.ts index c2096dc8..447ffb8b 100644 --- a/src/music-system/music-system.test.ts +++ b/src/music-system/music-system.test.ts @@ -7,6 +7,19 @@ import * as assert from 'node:assert'; import { describe, it, before } from 'node:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +// The BundledProvider is backed by committed offline tracks under +// input/bgm/__bundled__. On a bare checkout (or CI without those assets) the +// provider has nothing to return — skip those tests rather than hard-fail, +// the same way the network image providers skip when hosts are unreachable. +function bundledTracksAvailable(): boolean { + const dir = path.resolve(process.cwd(), 'input', 'bgm', '__bundled__'); + if (!fs.existsSync(dir)) return false; + return fs.readdirSync(dir).some((f: string) => /\.(mp3|ogg|wav|m4a)$/i.test(f)); +} +const hasBundled = bundledTracksAvailable(); // ─── Types ─────────────────────────────────────────────────── import { @@ -50,7 +63,8 @@ describe('MusicQuery builder', () => { import { BundledProvider } from './providers/bundled'; describe('BundledProvider', () => { - it('reads bundled tracks from input/bgm/__bundled__/', async () => { + it('reads bundled tracks from input/bgm/__bundled__/', async (t) => { + if (!hasBundled) return t.skip('bundled music assets absent (input/bgm/__bundled__ empty)'); const provider = new BundledProvider(); const tracks = await provider.search({ mood: 'any' as any, targetDurationSec: 60, minDurationSec: 10, role: 'background' as any }); assert.ok(tracks.length >= 3, `Expected >=3 bundled tracks, got ${tracks.length}`); @@ -60,7 +74,8 @@ describe('BundledProvider', () => { }); }); - it('filters by mood correctly', async () => { + it('filters by mood correctly', async (t) => { + if (!hasBundled) return t.skip('bundled music assets absent (input/bgm/__bundled__ empty)'); const provider = new BundledProvider(); const tracks = await provider.search({ mood: 'dramatic' as any, targetDurationSec: 60, minDurationSec: 10, role: 'background' as any }); assert.ok(tracks.length >= 1, 'Expected at least 1 dramatic track'); @@ -75,7 +90,8 @@ describe('BundledProvider', () => { assert.equal(tracks.length, 0); }); - it('copies file on download', async () => { + it('copies file on download', async (t) => { + if (!hasBundled) return t.skip('bundled music assets absent (input/bgm/__bundled__ empty)'); const provider = new BundledProvider(); const tracks = await provider.search({ mood: 'calm' as any, targetDurationSec: 60, minDurationSec: 10, role: 'background' as any }); assert.ok(tracks.length >= 1, 'No calm tracks found'); @@ -132,7 +148,8 @@ describe('MusicEngine', () => { assert.ok(engine); // just make sure it doesn't throw }); - it('resolves bundled music (offline, priority 1)', async () => { + it('resolves bundled music (offline, priority 1)', async (t) => { + if (!hasBundled) return t.skip('bundled music assets absent (input/bgm/__bundled__ empty)'); const engine = new MusicEngine(); await engine.init(); const result = await engine.resolveBackground({ topic: 'calm meditation', targetDurationSec: 30 }); diff --git a/src/music-system/providers/ccmixter.ts b/src/music-system/providers/ccmixter.ts index fc648aeb..8e78e422 100644 --- a/src/music-system/providers/ccmixter.ts +++ b/src/music-system/providers/ccmixter.ts @@ -36,7 +36,7 @@ interface CcMixterTrack { }>; } -interface CcMixterResponse extends Array {} +export type CcMixterResponse = CcMixterTrack[]; export class CcMixterProvider extends BaseMusicProvider { readonly name = 'ccmixter'; diff --git a/tests/agentic/ai/enhancement.test.ts b/tests/agentic/ai/enhancement.test.ts index c5d24226..e1b3cb7f 100644 --- a/tests/agentic/ai/enhancement.test.ts +++ b/tests/agentic/ai/enhancement.test.ts @@ -72,7 +72,7 @@ test('buildDuckExpression: null when no captions, else sums between() over speec 0.18, 0.06, ); - assert.ok(withSpeech!.includes('between(t\\,0.000\\,1.500)')); + assert.ok(withSpeech!.includes('between(t,0.000,1.500)')); assert.ok(withSpeech!.includes('gt(')); assert.ok(withSpeech!.startsWith('0.18-0.120*gt(')); }); diff --git a/tests/agentic/media/orchestrate.pure.test.ts b/tests/agentic/media/orchestrate.pure.test.ts index 7830a26e..2efcbcc6 100644 --- a/tests/agentic/media/orchestrate.pure.test.ts +++ b/tests/agentic/media/orchestrate.pure.test.ts @@ -29,7 +29,7 @@ test('buildDuckExpression builds a summed between()*gt duck term', () => { assert.ok(out, 'expected a non-null expression'); // full - (full-duck)*gt(...) assert.ok(out!.startsWith('0.18-0.120*gt('), `got: ${out}`); - assert.ok(out!.includes('between(t\\,0.000\\,1.000)'), `got: ${out}`); + assert.ok(out!.includes('between(t,0.000,1.000)'), `got: ${out}`); }); test('buildDuckExpression accumulates offsets across multiple scenes', () => { @@ -43,7 +43,7 @@ test('buildDuckExpression accumulates offsets across multiple scenes', () => { ); assert.ok(out, 'expected non-null'); // second scene starts at t=4s, so its cue is at 4.5 .. 5.5 - assert.ok(out!.includes('between(t\\,4.500\\,5.500)'), `got: ${out}`); + assert.ok(out!.includes('between(t,4.500,5.500)'), `got: ${out}`); }); // ── chunkCues ──────────────────────────────────────────────────────────────── diff --git a/tests/agentic/media/tts.test.ts b/tests/agentic/media/tts.test.ts index e40fa850..df4eeabb 100644 --- a/tests/agentic/media/tts.test.ts +++ b/tests/agentic/media/tts.test.ts @@ -14,6 +14,18 @@ import { generateAgenticVoiceovers } from '../../../src/agentic/media/tts.js'; import { buildPlan } from '../../../src/agentic/pipeline/plan.js'; import { Plan } from '../../../src/agentic/types.js'; +// The voice stage auto-starts the vendored Python backend; without its venv it +// polls until the test timeout. Skip on boxes where the backend isn't +// provisioned (same convention as the bundled-music / network skips). +function voiceBackendAvailable(): boolean { + const candidates = [ + path.resolve(process.cwd(), 'venv', 'Scripts', 'python.exe'), + 'C:/one/voicebox/.venv/Scripts/python.exe', + ]; + return candidates.some((p) => fs.existsSync(p)); +} +const hasVoiceBackend = voiceBackendAvailable(); + function tmpWs(jobId: string) { const root = fs.mkdtempSync(path.join(__WS_TEST_TMP__, 'agentic-tts-' + jobId + '-')); return { root, jobId }; @@ -29,7 +41,8 @@ describe('agentic/tts (Phase 2 + 4.2)', () => { }); }); - it('produces one voiceover per scene with caption segments', async () => { + it('produces one voiceover per scene with caption segments', async (t) => { + if (!hasVoiceBackend) return t.skip('voice backend venv absent'); const ws = tmpWs('tt1'); const r = await generateAgenticVoiceovers(plan, ws as any, 'en-US-JennyNeural'); assert.equal(r.scenes.length, plan.scenes.length); @@ -40,7 +53,8 @@ describe('agentic/tts (Phase 2 + 4.2)', () => { } }); - it('writes SRT + VTT sidecar files', async () => { + it('writes SRT + VTT sidecar files', async (t) => { + if (!hasVoiceBackend) return t.skip('voice backend venv absent'); const ws = tmpWs('tt2'); const r = await generateAgenticVoiceovers(plan, ws as any, 'en-US-JennyNeural'); const srt = path.join(ws.root, 'audio', 'subtitles.srt'); @@ -54,7 +68,8 @@ describe('agentic/tts (Phase 2 + 4.2)', () => { } }); - it('falls back to tones offline without throwing', async () => { + it('falls back to tones offline without throwing', async (t) => { + if (!hasVoiceBackend) return t.skip('voice backend venv absent'); const ws = tmpWs('tt3'); const r = await generateAgenticVoiceovers(plan, ws as any, 'en-US-JennyNeural'); // Either real TTS or graceful tone fallback — both must yield audio. diff --git a/tests/agentic/operations/revise-restitch-prod.test.ts b/tests/agentic/operations/revise-restitch-prod.test.ts index 40fcb141..82b0909f 100644 --- a/tests/agentic/operations/revise-restitch-prod.test.ts +++ b/tests/agentic/operations/revise-restitch-prod.test.ts @@ -9,6 +9,13 @@ import { makeWorkspaceTempDir, resolveWorkspaceTempPath } from '../../../src/sha const ROOT = 'C:/one/Automated-Video-Generator'; const FF = path.join(ROOT, 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'); +// Heavy ffmpeg integration test. Skip on boxes where ffmpeg-static cannot be +// resolved (e.g. a bare checkout) so CI stays green instead of hanging. +function ffmpegAvailable(): boolean { + try { return fs.existsSync(FF) || !!require.resolve('ffmpeg-static'); } catch { return false; } +} +const hasFfmpeg = ffmpegAvailable(); + function mkClip(p: string, w: number, h: number, dur: number, audio: boolean) { const a = audio ? ['-f', 'lavfi', '-i', `sine=frequency=440:duration=${dur}`, '-ac', '1'] : ['-an']; execFileSync(FF, ['-f', 'lavfi', '-i', `color=c=red:s=${w}x${h}:d=${dur}`, ...a, '-t', String(dur), '-r', '25', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', ...(audio ? ['-c:a', 'aac', '-b:a', '128k'] : []), '-y', p], { stdio: 'ignore' }); @@ -40,14 +47,16 @@ function buildCachedWorkspace(id: string, v: { w: number; h: number; dur: number } describe('revise.ts — production scenarios', () => { - test('reviseJob fails safe when plan.json is missing', async () => { + test('reviseJob fails safe when plan.json is missing', async (t) => { + if (!hasFfmpeg) return t.skip('ffmpeg-static unavailable'); const { reviseJob } = await import('../../../src/agentic/operations/revise.js'); const rep = await reviseJob('dog_no_plan_' + Date.now(), 'louder', [], {}); assert.equal(rep.ok, false); assert.match(rep.detail, /plan\.json/); }); - test('scope-aware cached revise re-renders without network (portrait/landscape/silent)', async () => { + test('scope-aware cached revise re-renders without network (portrait/landscape/silent)', async (t) => { + if (!hasFfmpeg) return t.skip('ffmpeg-static unavailable'); const { reviseJob } = await import('../../../src/agentic/operations/revise.js'); const { workspaceRootFor } = await import('../../../src/agentic/management/workspace.js'); for (const v of [ @@ -66,7 +75,8 @@ describe('revise.ts — production scenarios', () => { } }); - test('critiqueAndRevise prefers cached fast path when render-manifest exists', async () => { + test('critiqueAndRevise prefers cached fast path when render-manifest exists', async (t) => { + if (!hasFfmpeg) return t.skip('ffmpeg-static unavailable'); const { critiqueAndRevise } = await import('../../../src/agentic/operations/revise.js'); const id = 'dog_car_' + Date.now(); const { wsRoot } = buildCachedWorkspace(id, { w: 720, h: 1280, dur: 1, audio: true }, 'car'); @@ -77,7 +87,8 @@ describe('revise.ts — production scenarios', () => { assert.equal(rep.ok, true, rep.detail); }); - test('restitch works for all orientations incl. silent (regression guard)', async () => { + test('restitch works for all orientations incl. silent (regression guard)', { timeout: 240000 }, async (t) => { + if (!hasFfmpeg) return t.skip('ffmpeg-static unavailable'); const { restitchMaster } = await import('../../../src/agentic/operations/restitch.js'); for (const v of [ { name: 'rt_portrait', w: 720, h: 1280, dur: 3, audio: true },