@amg555/frameforge-mcp 2.3.4 → 2.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +2 -2
- package/out/frames/f000000.jpg +0 -0
- package/out/frames/f000001.jpg +0 -0
- package/out/frames/f000002.jpg +0 -0
- package/out/frames/f000003.jpg +0 -0
- package/out/frames/f000004.jpg +0 -0
- package/out/frames/f000005.jpg +0 -0
- package/out/frames/f000006.jpg +0 -0
- package/out/frames/f000007.jpg +0 -0
- package/out/frames/f000008.jpg +0 -0
- package/out/frames/f000009.jpg +0 -0
- package/out/frames/f000010.jpg +0 -0
- package/out/frames/f000011.jpg +0 -0
- package/out/frames/f000012.jpg +0 -0
- package/out/frames/f000013.jpg +0 -0
- package/out/frames/f000014.jpg +0 -0
- package/out/frames/f000015.jpg +0 -0
- package/out/frames/f000016.jpg +0 -0
- package/out/frames/f000017.jpg +0 -0
- package/out/frames/f000018.jpg +0 -0
- package/out/frames/f000019.jpg +0 -0
- package/out/frames/f000020.jpg +0 -0
- package/out/frames/f000021.jpg +0 -0
- package/out/frames/f000022.jpg +0 -0
- package/out/frames/f000023.jpg +0 -0
- package/out/frames/f000024.jpg +0 -0
- package/out/frames/f000025.jpg +0 -0
- package/out/frames/f000026.jpg +0 -0
- package/out/frames/f000027.jpg +0 -0
- package/out/frames/f000028.jpg +0 -0
- package/out/frames/f000029.jpg +0 -0
- package/out/frames/f000030.jpg +0 -0
- package/out/frames/f000031.jpg +0 -0
- package/out/frames/f000032.jpg +0 -0
- package/out/frames/f000033.jpg +0 -0
- package/out/frames/f000034.jpg +0 -0
- package/out/frames/f000035.jpg +0 -0
- package/out/frames/f000036.jpg +0 -0
- package/out/frames/f000037.jpg +0 -0
- package/out/frames/f000038.jpg +0 -0
- package/out/frames/f000039.jpg +0 -0
- package/out/frames/f000040.jpg +0 -0
- package/out/frames/f000041.jpg +0 -0
- package/out/frames/f000042.jpg +0 -0
- package/out/frames/f000043.jpg +0 -0
- package/out/frames/f000044.jpg +0 -0
- package/out/frames/f000045.jpg +0 -0
- package/out/frames/f000046.jpg +0 -0
- package/out/frames/f000047.jpg +0 -0
- package/package.json +2 -2
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +25 -12
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +2 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1 -1
- package/scripts/deep-validate.js +2 -2
- package/scripts/html-renderer/new-project.mjs +2 -2
- package/src/cli/make.js +6 -0
- package/src/harness/bin/harness.mjs +20 -7
- package/src/mcp-server/server.js +57 -7
- package/src/video-renderer/renderer.js +268 -60
- package/src/video-renderer/scene-animator.js +1241 -0
- package/src/workflows/assets.js +23 -2
- package/src/workflows/render.js +32 -9
- package/src/workflows/script.js +2 -0
|
@@ -29,6 +29,26 @@ const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '
|
|
|
29
29
|
|
|
30
30
|
const { buildCameraFilter, selectCameraHint } = require('../animation/animation-system');
|
|
31
31
|
const { AudioMixer } = require('../audio-mixer/mixer');
|
|
32
|
+
const { generateSceneHtml, wrapCodeInHarnessHtml, detectAesthetic, AESTHETICS } = require('./scene-animator');
|
|
33
|
+
const { findChromePath } = require('../harness/lib/browser.mjs');
|
|
34
|
+
|
|
35
|
+
// ── Cross-platform Indic / Malayalam font resolver ────────────────────────────
|
|
36
|
+
const INDIC_FONTS = [
|
|
37
|
+
// Windows
|
|
38
|
+
'C:\\Windows\\Fonts\\Nirmala.ttc',
|
|
39
|
+
'C:\\Windows\\Fonts\\NirmalaB.ttf',
|
|
40
|
+
'C:\\Windows\\Fonts\\Nirmala.ttf',
|
|
41
|
+
'C:\\Windows\\Fonts\\kartika.ttf',
|
|
42
|
+
'C:\\Windows\\Fonts\\kartikab.ttf',
|
|
43
|
+
// macOS
|
|
44
|
+
'/System/Library/Fonts/Supplemental/Malayalam Sangam MN.ttc',
|
|
45
|
+
'/System/Library/Fonts/Supplemental/Malayalam MN.ttc',
|
|
46
|
+
// Linux
|
|
47
|
+
'/usr/share/fonts/opentype/noto/NotoSansMalayalam-Bold.ttf',
|
|
48
|
+
'/usr/share/fonts/truetype/noto/NotoSansMalayalam-Bold.ttf',
|
|
49
|
+
'/usr/share/fonts/truetype/malayalam/Meera.ttf',
|
|
50
|
+
'/usr/share/fonts/truetype/lohit-malayalam/Lohit-Malayalam.ttf',
|
|
51
|
+
];
|
|
32
52
|
|
|
33
53
|
// ── Cross-platform font resolver ──────────────────────────────────────────────
|
|
34
54
|
// Checked in priority order. First existing file wins. Covers:
|
|
@@ -61,10 +81,16 @@ const FONTS = [
|
|
|
61
81
|
];
|
|
62
82
|
|
|
63
83
|
/** Return the first font file that exists, or '' if none found. */
|
|
64
|
-
function resolveFont() {
|
|
84
|
+
function resolveFont(text = '') {
|
|
65
85
|
// Allow explicit override via FFMPEG_FONT env var
|
|
66
86
|
const override = process.env.FFMPEG_FONT;
|
|
67
87
|
if (override && fs.existsSync(override)) return override;
|
|
88
|
+
|
|
89
|
+
const hasIndic = /[\u0D00-\u0D7F\u0900-\u0DFF]/.test(text || '');
|
|
90
|
+
if (hasIndic) {
|
|
91
|
+
const indicFont = INDIC_FONTS.find(f => f && fs.existsSync(f));
|
|
92
|
+
if (indicFont) return indicFont;
|
|
93
|
+
}
|
|
68
94
|
return FONTS.find(f => f && fs.existsSync(f)) || '';
|
|
69
95
|
}
|
|
70
96
|
|
|
@@ -80,23 +106,58 @@ class VideoRenderer {
|
|
|
80
106
|
}
|
|
81
107
|
|
|
82
108
|
/**
|
|
83
|
-
* Render a single scene:
|
|
109
|
+
* Render a single scene: code-driven animated video or clip + voice → scene_N_out.mp4
|
|
84
110
|
*
|
|
85
111
|
* @param {{
|
|
86
|
-
* sceneIndex:
|
|
87
|
-
* imagePath?:
|
|
88
|
-
* videoPath?:
|
|
89
|
-
* voicePath:
|
|
90
|
-
* caption?:
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
112
|
+
* sceneIndex: number,
|
|
113
|
+
* imagePath?: string,
|
|
114
|
+
* videoPath?: string, // optional user clip / B-roll
|
|
115
|
+
* voicePath: string,
|
|
116
|
+
* caption?: string,
|
|
117
|
+
* narration?: string,
|
|
118
|
+
* visualDescription?: string,
|
|
119
|
+
* visualType?: string,
|
|
120
|
+
* topic?: string,
|
|
121
|
+
* style?: string,
|
|
122
|
+
* theme?: string,
|
|
123
|
+
* title?: string,
|
|
124
|
+
* language?: string,
|
|
125
|
+
* musicMood?: string,
|
|
126
|
+
* camera?: string, // camera_hint from scene schema v1.0.0
|
|
127
|
+
* focalPoint?: { x: number, y: number },
|
|
128
|
+
* html?: string, // direct agent/user pure-t HTML scene
|
|
129
|
+
* code?: string, // direct agent/user canvas seek(t) drawing code
|
|
130
|
+
* manifest?: ArtifactManifest // optional — enables idempotency
|
|
131
|
+
* renderWidth?: number, // default 1920
|
|
132
|
+
* renderHeight?: number, // default 1080
|
|
133
|
+
* fps?: number, // default 24
|
|
96
134
|
* }} opts
|
|
97
135
|
* @returns {string} outputPath
|
|
98
136
|
*/
|
|
99
|
-
renderScene({
|
|
137
|
+
renderScene({
|
|
138
|
+
sceneIndex,
|
|
139
|
+
imagePath,
|
|
140
|
+
videoPath,
|
|
141
|
+
voicePath,
|
|
142
|
+
caption = '',
|
|
143
|
+
narration = '',
|
|
144
|
+
visualDescription = '',
|
|
145
|
+
visualType = 'code_animation',
|
|
146
|
+
topic = '',
|
|
147
|
+
style = '',
|
|
148
|
+
theme = '',
|
|
149
|
+
title = '',
|
|
150
|
+
language = '',
|
|
151
|
+
musicMood = 'neutral',
|
|
152
|
+
camera,
|
|
153
|
+
focalPoint,
|
|
154
|
+
html = null,
|
|
155
|
+
code = null,
|
|
156
|
+
manifest,
|
|
157
|
+
renderWidth = 1920,
|
|
158
|
+
renderHeight = 1080,
|
|
159
|
+
fps = 24,
|
|
160
|
+
} = {}) {
|
|
100
161
|
const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
|
|
101
162
|
|
|
102
163
|
// Idempotency: skip if already rendered and manifest says so
|
|
@@ -108,83 +169,111 @@ class VideoRenderer {
|
|
|
108
169
|
fs.mkdirSync(this.vdir, { recursive: true });
|
|
109
170
|
|
|
110
171
|
// Duration from voice file.
|
|
111
|
-
// No hard 60s cap — long scenes (documentary, lecture, podcast) can run as long as
|
|
112
|
-
// the narration dictates. The per-scene ffmpeg timeout (5 min) is the real ceiling.
|
|
113
|
-
//
|
|
114
|
-
// Fix: guard voicePath=null/undefined before calling ffprobe.
|
|
115
|
-
// _getAudioDuration(null) → ffprobe stdout empty → parseFloat('') = NaN → clampedDur=NaN
|
|
116
|
-
// → ffmpeg receives "-t NaN" → scene silently produces 0-byte output → concat breaks A/V sync.
|
|
117
172
|
if (!voicePath || !fs.existsSync(voicePath)) {
|
|
118
173
|
throw new Error(`Scene ${sceneIndex} render failed: voicePath is missing or does not exist (${voicePath})`);
|
|
119
174
|
}
|
|
120
175
|
const dur = this._getAudioDuration(voicePath);
|
|
121
176
|
const clampedDur = Math.max(2, Number.isFinite(dur) ? dur + 0.15 : 3.15);
|
|
122
177
|
|
|
123
|
-
const
|
|
124
|
-
const captionFilter = this._captionFilter(caption);
|
|
178
|
+
const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
|
|
125
179
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const vfFilters = ['vignette=PI/4'];
|
|
132
|
-
if (captionFilter) vfFilters.push(captionFilter);
|
|
133
|
-
const vf = vfFilters.join(',');
|
|
180
|
+
// Priority 1: User-supplied video clip
|
|
181
|
+
if (isVideoInput) {
|
|
182
|
+
const captionFilter = this._captionFilter(caption);
|
|
183
|
+
const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
|
|
184
|
+
const vf = [scaleFilter, captionFilter].filter(Boolean).join(',');
|
|
134
185
|
|
|
135
186
|
const result = spawnSync('ffmpeg', [
|
|
136
187
|
'-nostdin', '-y', '-threads', '2',
|
|
137
|
-
'-
|
|
138
|
-
'-
|
|
139
|
-
'-map', '0:v:0', '-map', '1:a:0',
|
|
140
|
-
// apad=whole_dur pads audio to exactly clampedDur so -t never truncates mid-word.
|
|
141
|
-
// Specifying whole_dur is mandatory: without it -apad on a color source runs forever.
|
|
188
|
+
'-stream_loop', '-1', '-i', videoPath, '-i', voicePath,
|
|
189
|
+
'-vf', vf || `scale=${renderWidth}:${renderHeight},format=yuv420p`,
|
|
142
190
|
'-af', `apad=whole_dur=${clampedDur}`,
|
|
143
191
|
'-t', String(clampedDur),
|
|
144
|
-
'-
|
|
192
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
145
193
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
|
|
146
194
|
'-profile:v', 'high', '-level', '4.1',
|
|
147
195
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
148
196
|
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
149
|
-
'-
|
|
197
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
150
198
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
151
199
|
'-movflags', '+faststart',
|
|
200
|
+
'-pix_fmt', 'yuv420p',
|
|
152
201
|
outputPath,
|
|
153
202
|
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
154
203
|
|
|
155
204
|
if (result.status !== 0) {
|
|
156
|
-
|
|
205
|
+
const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
|
|
206
|
+
manifest?.setSceneFailed(sceneIndex, err);
|
|
207
|
+
throw new Error(err);
|
|
157
208
|
}
|
|
209
|
+
|
|
158
210
|
manifest?.setSceneRendered(sceneIndex, outputPath);
|
|
159
211
|
return outputPath;
|
|
160
212
|
}
|
|
161
213
|
|
|
162
|
-
|
|
214
|
+
// Priority 2: Code-driven animated video scene via Chromium + Harness
|
|
215
|
+
// Produces pure-function-of-t 24fps motion graphics (kinetic typography,
|
|
216
|
+
// spring motion, animated particles, graphs, code typing, full multilingual Indic/Malayalam support).
|
|
217
|
+
const chrome = findChromePath();
|
|
218
|
+
if (chrome && process.env.FRAMEFORGE_NO_BROWSER !== '1') {
|
|
219
|
+
try {
|
|
220
|
+
return this._renderCodeAnimationScene({
|
|
221
|
+
sceneIndex,
|
|
222
|
+
imagePath,
|
|
223
|
+
voicePath,
|
|
224
|
+
caption,
|
|
225
|
+
narration,
|
|
226
|
+
visualDescription,
|
|
227
|
+
topic,
|
|
228
|
+
style,
|
|
229
|
+
theme,
|
|
230
|
+
title,
|
|
231
|
+
language,
|
|
232
|
+
html,
|
|
233
|
+
code,
|
|
234
|
+
clampedDur,
|
|
235
|
+
renderWidth,
|
|
236
|
+
renderHeight,
|
|
237
|
+
fps,
|
|
238
|
+
outputPath,
|
|
239
|
+
manifest,
|
|
240
|
+
musicMood,
|
|
241
|
+
});
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.warn(`[Renderer] Code-driven browser scene render failed (${err.message}), falling back to procedural animation`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
163
246
|
|
|
247
|
+
// Priority 3: Procedural dynamic animated fallback (dynamic moving gradients / camera drift)
|
|
248
|
+
const captionFilter = this._captionFilter(caption);
|
|
164
249
|
let inputArgs = [];
|
|
165
250
|
let vf = '';
|
|
166
251
|
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
inputArgs = ['-stream_loop', '-1', '-i', mediaSource, '-i', voicePath];
|
|
170
|
-
const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
|
|
171
|
-
vf = [scaleFilter, captionFilter].filter(Boolean).join(',');
|
|
172
|
-
} else {
|
|
173
|
-
// Image input with AnimationSystem camera motion
|
|
174
|
-
inputArgs = ['-loop', '1', '-i', mediaSource, '-i', voicePath];
|
|
252
|
+
if (imagePath && fs.existsSync(imagePath)) {
|
|
253
|
+
inputArgs = ['-loop', '1', '-i', imagePath, '-i', voicePath];
|
|
175
254
|
const hint = camera || selectCameraHint(sceneIndex, focalPoint || {});
|
|
176
255
|
const fx = focalPoint ? { focalX: focalPoint.x, focalY: focalPoint.y } : {};
|
|
177
256
|
const cameraFilter = buildCameraFilter(hint, { duration: clampedDur, w: renderWidth, h: renderHeight, ...fx });
|
|
178
257
|
vf = [cameraFilter, captionFilter].filter(Boolean).join(',');
|
|
258
|
+
} else {
|
|
259
|
+
const aestheticKey = detectAesthetic({ topic, style, theme, title, narration, visualDescription, caption, language, musicMood });
|
|
260
|
+
const aesthetic = AESTHETICS[aestheticKey] || AESTHETICS.cyber_tech;
|
|
261
|
+
const pal = aesthetic.palettes[sceneIndex % aesthetic.palettes.length];
|
|
262
|
+
const c0 = (pal.bg0 || '#0d1117').replace('#', '');
|
|
263
|
+
const c1 = (pal.accent || '#38bdf8').replace('#', '');
|
|
264
|
+
inputArgs = [
|
|
265
|
+
'-f', 'lavfi', '-i', `gradients=s=${renderWidth}x${renderHeight}:r=30:c0=0x${c0}:c1=0x${c1}:speed=0.5:type=radial`,
|
|
266
|
+
'-i', voicePath,
|
|
267
|
+
];
|
|
268
|
+
const vfFilters = ['vignette=PI/4'];
|
|
269
|
+
if (captionFilter) vfFilters.push(captionFilter);
|
|
270
|
+
vf = vfFilters.join(',');
|
|
179
271
|
}
|
|
180
272
|
|
|
181
273
|
const result = spawnSync('ffmpeg', [
|
|
182
274
|
'-nostdin', '-y', '-threads', '2',
|
|
183
275
|
...inputArgs,
|
|
184
276
|
'-vf', vf || `scale=${renderWidth}:${renderHeight},format=yuv420p`,
|
|
185
|
-
// apad=whole_dur pads audio to exactly clampedDur, preventing mid-word truncation
|
|
186
|
-
// when voice is fractionally shorter than clampedDur and avoiding infinite padding
|
|
187
|
-
// on looped-image inputs. -t then hard-caps output length at clampedDur.
|
|
188
277
|
'-af', `apad=whole_dur=${clampedDur}`,
|
|
189
278
|
'-t', String(clampedDur),
|
|
190
279
|
'-map', '0:v:0', '-map', '1:a:0',
|
|
@@ -192,6 +281,7 @@ class VideoRenderer {
|
|
|
192
281
|
'-profile:v', 'high', '-level', '4.1',
|
|
193
282
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
194
283
|
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
284
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
195
285
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
196
286
|
'-movflags', '+faststart',
|
|
197
287
|
'-pix_fmt', 'yuv420p',
|
|
@@ -199,7 +289,7 @@ class VideoRenderer {
|
|
|
199
289
|
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
200
290
|
|
|
201
291
|
if (result.status !== 0) {
|
|
202
|
-
const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
|
|
292
|
+
const err = `Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`;
|
|
203
293
|
manifest?.setSceneFailed(sceneIndex, err);
|
|
204
294
|
throw new Error(err);
|
|
205
295
|
}
|
|
@@ -208,6 +298,121 @@ class VideoRenderer {
|
|
|
208
298
|
return outputPath;
|
|
209
299
|
}
|
|
210
300
|
|
|
301
|
+
_renderCodeAnimationScene({
|
|
302
|
+
sceneIndex,
|
|
303
|
+
imagePath,
|
|
304
|
+
voicePath,
|
|
305
|
+
caption,
|
|
306
|
+
narration,
|
|
307
|
+
visualDescription,
|
|
308
|
+
topic = '',
|
|
309
|
+
style = '',
|
|
310
|
+
theme = '',
|
|
311
|
+
title = '',
|
|
312
|
+
language = '',
|
|
313
|
+
html = null,
|
|
314
|
+
code = null,
|
|
315
|
+
clampedDur,
|
|
316
|
+
renderWidth,
|
|
317
|
+
renderHeight,
|
|
318
|
+
fps = 24,
|
|
319
|
+
outputPath,
|
|
320
|
+
manifest,
|
|
321
|
+
musicMood,
|
|
322
|
+
}) {
|
|
323
|
+
let htmlContent;
|
|
324
|
+
if (html && typeof html === 'string' && html.trim().length > 0) {
|
|
325
|
+
htmlContent = html;
|
|
326
|
+
if (!htmlContent.includes('window.DURATION')) {
|
|
327
|
+
htmlContent = htmlContent.replace('<script>', `<script>\nwindow.DURATION = ${clampedDur};\n`);
|
|
328
|
+
}
|
|
329
|
+
} else if (code && typeof code === 'string' && code.trim().length > 0) {
|
|
330
|
+
htmlContent = wrapCodeInHarnessHtml({
|
|
331
|
+
code,
|
|
332
|
+
duration: clampedDur,
|
|
333
|
+
width: renderWidth,
|
|
334
|
+
height: renderHeight,
|
|
335
|
+
language,
|
|
336
|
+
style,
|
|
337
|
+
topic,
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
htmlContent = generateSceneHtml({
|
|
341
|
+
caption,
|
|
342
|
+
narration,
|
|
343
|
+
visualDescription,
|
|
344
|
+
imagePath,
|
|
345
|
+
topic,
|
|
346
|
+
style,
|
|
347
|
+
theme,
|
|
348
|
+
title,
|
|
349
|
+
language,
|
|
350
|
+
duration: clampedDur,
|
|
351
|
+
width: renderWidth,
|
|
352
|
+
height: renderHeight,
|
|
353
|
+
sceneIndex,
|
|
354
|
+
musicMood,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const animHtmlPath = path.join(this.vdir, `scene_${sceneIndex}_anim.html`);
|
|
359
|
+
const framesDir = path.join(this.vdir, `scene_${sceneIndex}_frames`);
|
|
360
|
+
fs.writeFileSync(animHtmlPath, htmlContent, 'utf8');
|
|
361
|
+
fs.mkdirSync(framesDir, { recursive: true });
|
|
362
|
+
|
|
363
|
+
const harnessBin = path.resolve(__dirname, '../harness/bin/harness.mjs');
|
|
364
|
+
const totalFrames = Math.max(1, Math.round(clampedDur * fps));
|
|
365
|
+
const lastFrame = totalFrames - 1;
|
|
366
|
+
|
|
367
|
+
const res = spawnSync(process.execPath, [
|
|
368
|
+
harnessBin,
|
|
369
|
+
'frames',
|
|
370
|
+
'--html', animHtmlPath,
|
|
371
|
+
'--out', framesDir,
|
|
372
|
+
'--fps', String(fps),
|
|
373
|
+
'--first', '0',
|
|
374
|
+
'--last', String(lastFrame),
|
|
375
|
+
'--workers', '4',
|
|
376
|
+
], { encoding: 'utf8', timeout: 5 * 60 * 1000 });
|
|
377
|
+
|
|
378
|
+
if (res.status !== 0) {
|
|
379
|
+
throw new Error(`Harness frame rendering failed: ${(res.stderr || res.stdout || '').slice(-400)}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const framesPattern = path.join(framesDir, 'f%06d.jpg');
|
|
383
|
+
const encResult = spawnSync('ffmpeg', [
|
|
384
|
+
'-nostdin', '-y', '-threads', '2',
|
|
385
|
+
'-framerate', String(fps),
|
|
386
|
+
'-pattern_type', 'sequence',
|
|
387
|
+
'-i', framesPattern,
|
|
388
|
+
'-i', voicePath,
|
|
389
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
390
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
|
391
|
+
'-profile:v', 'high', '-level', '4.1',
|
|
392
|
+
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
393
|
+
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
394
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
395
|
+
'-pix_fmt', 'yuv420p',
|
|
396
|
+
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
397
|
+
'-af', `apad=whole_dur=${clampedDur}`,
|
|
398
|
+
'-t', String(clampedDur),
|
|
399
|
+
'-movflags', '+faststart',
|
|
400
|
+
outputPath,
|
|
401
|
+
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
402
|
+
|
|
403
|
+
if (encResult.status !== 0) {
|
|
404
|
+
throw new Error(`Scene ${sceneIndex} video encode failed: ${(encResult.stderr || '').slice(-300)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
409
|
+
fs.unlinkSync(animHtmlPath);
|
|
410
|
+
} catch (_) {}
|
|
411
|
+
|
|
412
|
+
manifest?.setSceneRendered(sceneIndex, outputPath);
|
|
413
|
+
return outputPath;
|
|
414
|
+
}
|
|
415
|
+
|
|
211
416
|
/**
|
|
212
417
|
* Assemble all scene_N_out.mp4 files into final_video.mp4,
|
|
213
418
|
* then pass through AudioMixer for sidechain ducking + LUFS normalisation.
|
|
@@ -276,19 +481,22 @@ class VideoRenderer {
|
|
|
276
481
|
|
|
277
482
|
_captionFilter(caption) {
|
|
278
483
|
if (!caption || !this.font) return '';
|
|
484
|
+
const resolvedFont = resolveFont(caption) || this.font;
|
|
485
|
+
if (!resolvedFont) return '';
|
|
486
|
+
|
|
487
|
+
const hasIndic = /[\u0D00-\u0D7F\u0900-\u0DFF]/.test(caption);
|
|
279
488
|
// Sanitize caption for ffmpeg drawtext.
|
|
280
|
-
// Strip
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.replace(/\s+/g, ' ')
|
|
285
|
-
.trim()
|
|
286
|
-
.slice(0, 45)
|
|
287
|
-
.toUpperCase();
|
|
489
|
+
// Strip syntax-breaking characters; preserve full Unicode letters & viramas.
|
|
490
|
+
const safe = Array.from(
|
|
491
|
+
caption.replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '').replace(/\s+/g, ' ').trim()
|
|
492
|
+
).slice(0, 45).join('');
|
|
288
493
|
if (!safe) return '';
|
|
289
|
-
|
|
494
|
+
|
|
495
|
+
const safeText = hasIndic ? safe : safe.toUpperCase();
|
|
496
|
+
const safeFont = resolvedFont.replace(/\\/g, '/').replace(/:/g, '\\:');
|
|
497
|
+
const shaping = hasIndic ? ':text_shaping=1' : '';
|
|
290
498
|
// Bottom-center, safe zone: 100px from bottom + UI button zone
|
|
291
|
-
return `drawtext=fontfile='${safeFont}':text='${
|
|
499
|
+
return `drawtext=fontfile='${safeFont}'${shaping}:text='${safeText}':fontsize=68:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
|
|
292
500
|
}
|
|
293
501
|
|
|
294
502
|
// ── Helpers ───────────────────────────────────────────────────────────────
|