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
6 changes: 4 additions & 2 deletions bin/agentic-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ async function main() {
const noDucking = bool('no-ducking');
const noKenBurns = bool('no-ken-burns');
const dryRun = bool('dry-run');
const preset = arg('preset', 'cinematic');
const noKinetic = bool('no-kinetic');

if (noDucking) process.env.AUDIO_DUCK_LEVEL = ''; // empty => ducking expr skipped
console.log(`\n🎬 Agentic run | backend=${backend} renderer=${renderer} quality=${quality} intro=${introMode} outro=${outroMode}`);
Expand Down Expand Up @@ -78,11 +80,11 @@ async function main() {
out = await renderAgenticWithRemotion(res, { intro, outro, kenBurns: !noKenBurns, quality });
} catch (e: any) {
console.warn(`⚠ Remotion render failed (${e?.message ?? e}); falling back to ffmpeg.`);
out = await renderAgenticSlideshow(res, { crossfadeSec: 0.5, burnCaptions: true, sfx });
out = await renderAgenticSlideshow(res, { crossfadeSec: 0.5, burnCaptions: true, sfx, preset, kinetic: !noKinetic });
}
} else {
console.log(`\n🎞 Rendering MP4 (ffmpeg-static)...`);
out = await renderAgenticSlideshow(res, { crossfadeSec: 0.5, burnCaptions: true, sfx });
out = await renderAgenticSlideshow(res, { crossfadeSec: 0.5, burnCaptions: true, sfx, preset, kinetic: !noKinetic });
}

// Phase 8.4 — print post-render verification (X7-X9)
Expand Down
6 changes: 4 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default tseslint.config(
{
languageOptions: {
parserOptions: {
project: true,
projectService: true,
allowDefaultProject: ['eslint.config.mjs'],
tsconfigRootDir: __dirname,
},
},
Expand All @@ -27,6 +28,7 @@ export default tseslint.config(
'public/',
'.tmp/',
'tmp/',
'eslint.config.mjs',
],
},
{
Expand All @@ -37,7 +39,7 @@ export default tseslint.config(
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-require-imports': 'warn',
'no-console': 'off',
'prefer-const': 'error',
'no-var': 'error',
Expand Down
57 changes: 49 additions & 8 deletions src/agentic/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
emit({ stage: 'verify', percent: 100, message: 'Verification complete' });
const { decisions } = await runGateway(plan, candidates, gatewayDeps);
emit({ stage: 'decide', percent: 100, message: `${decisions.filter((d) => d.decision === 'approved').length} assets approved` });
let manifest = readJson<RenderManifest>(workspace, 'render-manifest.json');

Check failure on line 251 in src/agentic/orchestrate.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

'manifest' is never reassigned. Use 'const' instead
const gate = runFinalGate(plan, candidates, decisions, manifest);
emit({ stage: 'gate', percent: 100, message: gate.pass ? 'GATE PASS' : 'GATE FAIL' });

Expand Down Expand Up @@ -425,7 +425,7 @@

export async function renderAgenticSlideshow(
res: PipelineResult,
opts: { outPath?: string; crossfadeSec?: number; burnCaptions?: boolean; sfx?: boolean; transition?: string } = {},
opts: { outPath?: string; crossfadeSec?: number; burnCaptions?: boolean; sfx?: boolean; transition?: string; preset?: string; kinetic?: boolean } = {},
): Promise<string> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpeg: string = require('ffmpeg-static');
Expand All @@ -439,6 +439,14 @@
const music = res.manifest.assets.find((a) => a.kind === 'music');
if (visuals.length === 0) throw new Error('No approved visuals to render.');

// ── Editing engine v1: compute the per-scene style plan (transitions,
// color grade, kinetic text). Deterministic from title + preset. ──
const { computeStylePlan, gradeFilter, xfadeName } = await import('./style-engine.js');
const stylePlan = computeStylePlan(res.plan, {
preset: (opts.preset as any) ?? 'cinematic',
kinetic: opts.kinetic,
});

const xf = opts.crossfadeSec ?? 0.5;
const burn = opts.burnCaptions ?? true;
const runFfmpeg = (args: string[]): Promise<void> =>
Expand Down Expand Up @@ -482,27 +490,36 @@
}
}

// ── Each scene: scale+pad, optional Ken Burns zoom, hold for its VO duration. ──
// ── Each scene: scale+pad, optional Ken Burns zoom, color grade, hold for VO. ──
const W = 720, H = 1280;
const sceneFilters = visuals.map((a, i) => {
const dur = a.durationSec ?? 4;
// Gentle Ken Burns zoom (spec 7.1). Comma inside the min() expression is
// escaped as '\,' and NO single quotes (filtergraph isn't shell-parsed).
// escaped as '\\,' and NO single quotes (filtergraph isn't shell-parsed).
const zoom = a.kind === 'image' ? `,zoompan=z=min(zoom+0.0008\\,1.04):d=1:s=${W}x${H}` : '';
// Editing engine v1: per-scene color grade (no LUT file needed).
const grade = gradeFilter(stylePlan.scenes[i]?.grade ?? 'neutral');
const tag = '[' + i + ':v]';
return `${tag}scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,trim=duration=${dur},setpts=PTS-STARTPTS${zoom},format=yuv420p[v${i}]`;
return `${tag}scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,trim=duration=${dur},setpts=PTS-STARTPTS${zoom},${grade},format=yuv420p[v${i}]`;
});

// ── Concat the scene videos with crossfade transitions (fade if >1 scene). ──
// ── Concat the scene videos with per-scene transitions from the style plan. ──
let videoChain: string;
if (visuals.length === 1) {
videoChain = '[v0]';
} else {
// chain crossfades pairwise
// chain transitions pairwise; a 'cut' = hard concat (no overlap),
// otherwise xfade with the chosen transition name.
let prev = 'v0';
for (let i = 1; i < visuals.length; i++) {
const tk: any = stylePlan.scenes[i]?.transitionIn ?? 'fade';
const outTag = i === visuals.length - 1 ? 'vout' : 'vx' + i;
sceneFilters.push(`[${prev}][v${i}]xfade=transition=fade:duration=${xf}:offset=${offsetFor(visuals, i, xf)}[${outTag}]`);
if (tk === 'cut') {
sceneFilters.push(`[${prev}][v${i}]concat=n=2:v=1:a=0[${outTag}]`);
} else {
const xname = xfadeName(tk);
sceneFilters.push(`[${prev}][v${i}]xfade=transition=${xname}:duration=${xf}:offset=${offsetFor(visuals, i, xf)}[${outTag}]`);
}
prev = outTag;
}
videoChain = '[vout]';
Expand All @@ -514,11 +531,35 @@

// ── Caption burn-in (subtitles filter) applied to the chained video. ──
if (captionFile) {
// In an ffmpeg filtergraph, colons must be escaped as '\:' and backslashes
// In an ffmpeg filtergraph, colons must be escaped as '\\:' and backslashes
// as '/'. Do NOT wrap the path in quotes (filtergraph isn't shell-parsed).
vfArgs.push(`${videoChain}subtitles=${escapeFilterPath(captionFile)}:force_style='FontSize=28,PrimaryColour=&Hffffff&,OutlineColour=&H000000&,Outline=2,Alignment=2'[vcap]`);
videoMap = '[vcap]';
}
// ── Editing engine v1: kinetic text overlays (lower-third reveal + word-pop). ──
// Each cue is placed at its absolute timeline position. drawtext enable= drives
// the on/off window; a tiny fade gives the "pop". Apostrophes are swapped for ’
// because drawtext breaks on bare single quotes in the text string.
if (stylePlan && opts.kinetic !== false) {
let t = 0;
const sceneStarts = visuals.map((a) => { const s = t; t += (a.durationSec ?? 4); return s; });
let ktag = videoMap;
for (let i = 0; i < visuals.length; i++) {
const base = sceneStarts[i];
for (const cue of stylePlan.scenes[i]?.kinetic ?? []) {
const start = (base + cue.atSec).toFixed(2);
const end = (base + cue.atSec + (cue.kind === 'wordpop' ? 0.9 : 2.6)).toFixed(2);
const safe = cue.text.replace(/'/g, '’').replace(/:/g, '\\:');

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This does not escape backslash characters in the input.
if (cue.kind === 'lowerthird') {
vfArgs.push(`${ktag}drawtext=text='${safe}':fontcolor=white:fontsize=34:box=1:boxcolor=black@0.45:boxborderw=12:x=(w-text_w)/2:y=h-text_h-90:enable='between(t\\,${start}\\,${end})':alpha='if(lt(t\\,${start}+0.25)\\,((t-${start})/0.25)\\,if(gt(t\\,${end}-0.3)\\,(((${end}-t)/0.3))\\,1))'[k${i}]`);
} else {
vfArgs.push(`${ktag}drawtext=text='${safe}':fontcolor=white:fontsize=64:fontcolor_expr=white:box=1:boxcolor=black@0.0:borderw=3:bordercolor=yellow:x=(w-text_w)/2:y=(h-text_h)/2:enable='between(t\\,${start}\\,${end})':alpha='if(lt(t\\,${start}+0.15)\\,((t-${start})/0.15)\\,if(gt(t\\,${end}-0.2)\\,(((${end}-t)/0.2))\\,1))'[k${i}]`);
}
ktag = `[k${i}]`;
}
}
if (ktag !== videoMap) { videoMap = ktag; }
}
// Phase 2.3 — cinematic vignette over the final video chain.
vfArgs.push(`${videoMap}vignette=PI/5[vig]`);
videoMap = '[vig]';
Expand Down
144 changes: 144 additions & 0 deletions src/agentic/style-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* style-engine.ts — the "human-feel" editing brain for the agentic pipeline.
*
* Given a Plan + chosen AgenticStyle, it deterministically computes, per scene:
* - which TRANSITION to use into the next scene (fade / slide / zoom-blur / cut)
* - a per-scene COLOR GRADE (warm / cool / cinematic / neutral) for variety
* - KINETIC TEXT cues (lower-third reveal + word-pop on emphasis words)
* - BEAT splits: where to cut/hit on the voiceover cadence (speech-synced)
*
* Everything is deterministic (hashed from topic + scene index) so the same input
* always yields the same edit — no randomness, no external model. The ffmpeg
* renderer (orchestrate.ts) consumes the resulting StylePlan. This file is pure
* computation; it touches no network, no ffmpeg.
*/

export type TransitionKind = 'fade' | 'slide' | 'zoomblur' | 'cut';
export type GradeKind = 'neutral' | 'warm' | 'cool' | 'cinematic' | 'vivid';

export interface KineticCue {
/** seconds from scene start */
atSec: number;
text: string;
kind: 'lowerthird' | 'wordpop';
}

export interface SceneStyle {
sceneIndex: number;
transitionIn: TransitionKind; // transition used to ENTER this scene (scene 0 = none)
grade: GradeKind;
kinetic: KineticCue[];
}

export interface StylePlan {
name: string;
transitions: TransitionKind[]; // per-scene transition INTO next (last is unused)
scenes: SceneStyle[];
}

export interface AgenticStyle {
/** preset name; drives the whole look */
preset?: 'cinematic' | 'reels' | 'documentary' | 'documentary-cool' | 'neutral';
/** override specific knobs */
transitionBias?: TransitionKind[];
gradeBias?: GradeKind[];
kinetic?: boolean; // animated captions (default true)
beatSplit?: boolean; // cut on VO cadence (default false — kept subtle)
}

const TRANSITIONS: TransitionKind[] = ['fade', 'slide', 'zoomblur', 'cut'];

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable TRANSITIONS.
const GRADES: GradeKind[] = ['cinematic', 'warm', 'cool', 'vivid', 'neutral'];

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable GRADES.

/** Stable, dependency-free string hash (FNV-1a-ish). */
function hash(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}

/** Pick a deterministic item from a list using a numeric seed. */
function pick<T>(list: T[], seed: number): T {
return list[seed % list.length];
}

/**
* Compute the per-scene editing plan. Deterministic: identical (preset, topic,
* sceneCount) always produces the identical StylePlan.
*/
export function computeStylePlan(
plan: { title: string; scenes: { sceneNumber: number; voiceoverText: string; durationSec?: number }[] },
style: AgenticStyle = {},
): StylePlan {
const preset = style.preset ?? 'cinematic';
const seedBase = hash(plan.title + '|' + preset);

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable seedBase.

// Preset tweaks: reels = punchy (more cuts/slides), documentary = calm (fades).
const transitionPool: TransitionKind[] =
preset === 'reels' ? ['slide', 'zoomblur', 'cut', 'fade'] :
preset === 'documentary' || preset === 'documentary-cool' ? ['fade', 'slide', 'zoomblur'] :
preset === 'neutral' ? ['fade'] :
['fade', 'slide', 'zoomblur']; // cinematic
const gradePool: GradeKind[] =
preset === 'documentary-cool' ? ['cool', 'cinematic', 'neutral'] :
preset === 'reels' ? ['vivid', 'cinematic', 'warm'] :
preset === 'neutral' ? ['neutral'] :
['cinematic', 'warm', 'cool', 'vivid'];

const scenes: SceneStyle[] = plan.scenes.map((s, i) => {
const seed = hash(plan.title + '|scene' + i + '|' + preset);
const transitionIn: TransitionKind =
i === 0 ? 'fade' :
style.transitionBias?.[i] ?? pick(transitionPool, seed >> 3);
const grade: GradeKind =
style.gradeBias?.[i] ?? pick(gradePool, seed >> 7);

const kinetic: KineticCue[] = [];
if (style.kinetic !== false) {
const dur = s.durationSec ?? 4;
// Lower-third reveal at scene start (the scene's spoken hook).
kinetic.push({ atSec: 0.15, text: s.voiceoverText.split(/[.!?]/)[0].slice(0, 60), kind: 'lowerthird' });
// Word-pop on an emphasis word if present.
const emph = ['secret', 'amazing', 'important', 'never', 'always', 'real', 'truth', 'best', 'worst']
.find((w) => s.voiceoverText.toLowerCase().includes(w));
if (emph) kinetic.push({ atSec: Math.max(0.4, dur * 0.45), text: emph.toUpperCase(), kind: 'wordpop' });
}
return { sceneIndex: i, transitionIn, grade, kinetic };
});

const transitions = scenes.map((sc) => sc.transitionIn);
return { name: preset, transitions, scenes };
}

/**
* Map a TransitionKind to an ffmpeg xfade transition name (the subset ffmpeg
* supports reliably). 'cut' is handled by the renderer as a hard concat.
*/
export function xfadeName(kind: TransitionKind): string {
switch (kind) {
case 'slide': return 'slideleft';
case 'zoomblur': return 'zoomblurin';
case 'cut': return 'fade'; // renderer upgrades cuts separately
case 'fade':
default: return 'fade';
}
}

/**
* Map a GradeKind to an ffmpeg `eq` filter string (cheap, no LUT file needed).
* Only uses options that exist in ffmpeg's eq filter (contrast/brightness/
* saturation/gamma) — `temperature` is NOT an eq option in this build, so warm/
* cool looks are approximated via saturation + brightness instead.
*/
export function gradeFilter(kind: GradeKind): string {
switch (kind) {
case 'warm': return 'eq=contrast=1.05:brightness=1.04:saturation=1.22:gamma=0.96';
case 'cool': return 'eq=contrast=1.0:brightness=0.97:saturation=1.08:gamma=1.05';
case 'cinematic': return 'eq=contrast=1.12:brightness=0.97:saturation=1.1:gamma=0.95';
case 'vivid': return 'eq=contrast=1.08:saturation=1.35:brightness=1.0';
case 'neutral':
default: return 'eq=contrast=1.02:saturation=1.05';
}
}
13 changes: 11 additions & 2 deletions src/views/home/scripts/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ export function browserLogic(): string {
let currentBrowserType = 'media';
let currentParentPath = '';

function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function openSystemBrowser(type) {
console.log('Opening browser for:', type);
currentBrowserType = type;
Expand Down Expand Up @@ -100,8 +109,8 @@ async function loadSidebar() {
const drivesRes = await fetch('/api/fs/drives');
const drivesJson = await drivesRes.json();
if (drivesJson.success && drivesList) {
drivesList.innerHTML = drivesJson.data.map(d =>
'<div class="sidebar-item" data-path="' + d + '"><span>💽</span> ' + d + ' Drive</div>'
drivesList.innerHTML = drivesJson.data.map(d =>
'<div class="sidebar-item" data-path="' + escapeHtml(d) + '"><span>💽</span> ' + escapeHtml(d) + ' Drive</div>'
).join('');
}
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion src/views/job-status.view.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request } from 'express';
import { PROJECT_NAME } from '../constants/config';
import { layout, escapeHtml } from './layout.view';
import { layout } from './layout.view';

// ═══════════════════════════════════════════════════════════════════════════════
// JOB STATUS PAGE — Premium Timeline Editor V3 (Studio Style)
Expand Down
2 changes: 1 addition & 1 deletion src/views/video-download.view.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Request } from 'express';
import { layout } from './layout.view';
import { absoluteUrl } from '../shared/http/public-url';
import { PROJECT_NAME, DEFAULT_SITE_DESCRIPTION, DEFAULT_SITE_KEYWORDS } from '../constants/config';
import { PROJECT_NAME, DEFAULT_SITE_KEYWORDS } from '../constants/config';
import { browserModalComponent } from './home/components/browser-modal.component';

export function videoDownloadPage(req: Request, cspNonce?: string): string {
Expand Down
Loading