Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
82 changes: 82 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions scripts/check-coverage.mjs
Original file line number Diff line number Diff line change
@@ -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');
2 changes: 1 addition & 1 deletion src/adapters/cli/agentic-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cli/cli-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/agentic/media/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -149,7 +150,7 @@ export async function renderThumbnail(srcMp4: string, plan: Plan): Promise<strin
fontArg = `fontfile='${c}':`;
break;
}
const filter = `drawtext=${fontArg}text='${title}':fontcolor=white:fontsize=48:box=1:boxcolor=black@0.55:boxborderw=20:line_spacing=8:x=(w-text_w)/2:y=(h-text_h)/2`;
const filter = `drawtext=${fontArg}text='${ffmpegDrawtextEscape(title)}':fontcolor=white:fontsize=48:box=1:boxcolor=black@0.55:boxborderw=20:line_spacing=8:x=(w-text_w)/2:y=(h-text_h)/2`;
try {
const code = await runFfmpeg(['-y', '-ss', '00:00:01', '-i', srcMp4, '-frames:v', '1', '-vf', filter, out]);
return code === 0 && fs.existsSync(out) ? out : null;
Expand Down
5 changes: 3 additions & 2 deletions src/agentic/operations/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { runFfmpeg } from './edit.js';
import { probeMedia, type ProbeRunner } from './probe.js';
import { ffmpegDrawtextEscape } from '../../lib/ffmpeg-text.js';

export interface BrandKit {
/** brand display name (used for wordmark + intro/outro text). */
Expand Down Expand Up @@ -69,15 +70,15 @@ export function buildBrandFilter(kit: BrandKit, w = 720, h = 1280): string {
const lo = kit.logo.replace(/\\/g, '/');
parts.push(`movie='${lo}'[lg];[in][lg]overlay=W-w-24:24[ovl]`);
} else if (kit.name) {
const safe = kit.name.replace(/:/g, '\\:').replace(/'/g, "'\\''");
const safe = ffmpegDrawtextEscape(kit.name);
parts.push(`drawtext=text='${safe}':fontcolor=white:fontsize=34:x=w-tw-24:y=24`);
}
return parts.join(',');
}

async function makeCard(text: string, color: string, dur: number, w: number, h: number, out: string): Promise<boolean> {
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',
Expand Down
2 changes: 1 addition & 1 deletion src/agentic/operations/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export async function composeVideo(input: ComposeInput): Promise<ComposeResult>
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 ──
Expand Down
2 changes: 1 addition & 1 deletion src/agentic/operations/export-fx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 16 additions & 7 deletions src/agentic/operations/sfx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions src/agentic/operations/visual-fx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion src/agentic/orchestrator/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] || {};
Expand Down
10 changes: 8 additions & 2 deletions src/agentic/orchestrator/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading