@amg555/frameforge-mcp 2.3.3 → 2.3.5
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 +44 -0
- package/README.md +3 -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 +1 -1
- 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/plugins/video-pipeline/skills/video-pipeline/references/providers.md +11 -0
- package/scripts/deep-validate.js +2 -2
- package/scripts/html-renderer/new-project.mjs +2 -2
- package/src/cli/doctor.js +12 -5
- package/src/harness/bin/harness.mjs +20 -7
- package/src/mcp-server/server.js +1 -1
- package/src/video-renderer/renderer.js +222 -60
- package/src/video-renderer/scene-animator.js +459 -0
- package/src/workflows/assets.js +4 -0
- package/src/workflows/render.js +12 -7
|
@@ -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 } = 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,46 @@ 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
|
+
* language?: string,
|
|
121
|
+
* musicMood?: string,
|
|
122
|
+
* camera?: string, // camera_hint from scene schema v1.0.0
|
|
123
|
+
* focalPoint?: { x: number, y: number },
|
|
124
|
+
* manifest?: ArtifactManifest // optional — enables idempotency
|
|
125
|
+
* renderWidth?: number, // default 1920
|
|
126
|
+
* renderHeight?: number, // default 1080
|
|
127
|
+
* fps?: number, // default 24
|
|
96
128
|
* }} opts
|
|
97
129
|
* @returns {string} outputPath
|
|
98
130
|
*/
|
|
99
|
-
renderScene({
|
|
131
|
+
renderScene({
|
|
132
|
+
sceneIndex,
|
|
133
|
+
imagePath,
|
|
134
|
+
videoPath,
|
|
135
|
+
voicePath,
|
|
136
|
+
caption = '',
|
|
137
|
+
narration = '',
|
|
138
|
+
visualDescription = '',
|
|
139
|
+
visualType = 'code_animation',
|
|
140
|
+
language = '',
|
|
141
|
+
musicMood = 'neutral',
|
|
142
|
+
camera,
|
|
143
|
+
focalPoint,
|
|
144
|
+
manifest,
|
|
145
|
+
renderWidth = 1920,
|
|
146
|
+
renderHeight = 1080,
|
|
147
|
+
fps = 24,
|
|
148
|
+
} = {}) {
|
|
100
149
|
const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
|
|
101
150
|
|
|
102
151
|
// Idempotency: skip if already rendered and manifest says so
|
|
@@ -108,83 +157,107 @@ class VideoRenderer {
|
|
|
108
157
|
fs.mkdirSync(this.vdir, { recursive: true });
|
|
109
158
|
|
|
110
159
|
// 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
160
|
if (!voicePath || !fs.existsSync(voicePath)) {
|
|
118
161
|
throw new Error(`Scene ${sceneIndex} render failed: voicePath is missing or does not exist (${voicePath})`);
|
|
119
162
|
}
|
|
120
163
|
const dur = this._getAudioDuration(voicePath);
|
|
121
164
|
const clampedDur = Math.max(2, Number.isFinite(dur) ? dur + 0.15 : 3.15);
|
|
122
165
|
|
|
123
|
-
const
|
|
124
|
-
const captionFilter = this._captionFilter(caption);
|
|
166
|
+
const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
|
|
125
167
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const vfFilters = ['vignette=PI/4'];
|
|
132
|
-
if (captionFilter) vfFilters.push(captionFilter);
|
|
133
|
-
const vf = vfFilters.join(',');
|
|
168
|
+
// Priority 1: User-supplied video clip
|
|
169
|
+
if (isVideoInput) {
|
|
170
|
+
const captionFilter = this._captionFilter(caption);
|
|
171
|
+
const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
|
|
172
|
+
const vf = [scaleFilter, captionFilter].filter(Boolean).join(',');
|
|
134
173
|
|
|
135
174
|
const result = spawnSync('ffmpeg', [
|
|
136
175
|
'-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.
|
|
176
|
+
'-stream_loop', '-1', '-i', videoPath, '-i', voicePath,
|
|
177
|
+
'-vf', vf || `scale=${renderWidth}:${renderHeight},format=yuv420p`,
|
|
142
178
|
'-af', `apad=whole_dur=${clampedDur}`,
|
|
143
179
|
'-t', String(clampedDur),
|
|
144
|
-
'-
|
|
180
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
145
181
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
|
|
146
182
|
'-profile:v', 'high', '-level', '4.1',
|
|
147
183
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
148
184
|
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
149
|
-
'-
|
|
185
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
150
186
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
151
187
|
'-movflags', '+faststart',
|
|
188
|
+
'-pix_fmt', 'yuv420p',
|
|
152
189
|
outputPath,
|
|
153
190
|
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
154
191
|
|
|
155
192
|
if (result.status !== 0) {
|
|
156
|
-
|
|
193
|
+
const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
|
|
194
|
+
manifest?.setSceneFailed(sceneIndex, err);
|
|
195
|
+
throw new Error(err);
|
|
157
196
|
}
|
|
197
|
+
|
|
158
198
|
manifest?.setSceneRendered(sceneIndex, outputPath);
|
|
159
199
|
return outputPath;
|
|
160
200
|
}
|
|
161
201
|
|
|
162
|
-
|
|
202
|
+
// Priority 2: Code-driven animated video scene via Chromium + Harness
|
|
203
|
+
// Produces pure-function-of-t 24fps motion graphics (kinetic typography,
|
|
204
|
+
// spring motion, animated particles, graphs, code typing, full multilingual Indic/Malayalam support).
|
|
205
|
+
const chrome = findChromePath();
|
|
206
|
+
if (chrome && process.env.FRAMEFORGE_NO_BROWSER !== '1') {
|
|
207
|
+
try {
|
|
208
|
+
return this._renderCodeAnimationScene({
|
|
209
|
+
sceneIndex,
|
|
210
|
+
imagePath,
|
|
211
|
+
voicePath,
|
|
212
|
+
caption,
|
|
213
|
+
narration,
|
|
214
|
+
visualDescription,
|
|
215
|
+
clampedDur,
|
|
216
|
+
renderWidth,
|
|
217
|
+
renderHeight,
|
|
218
|
+
fps,
|
|
219
|
+
outputPath,
|
|
220
|
+
manifest,
|
|
221
|
+
musicMood,
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.warn(`[Renderer] Code-driven browser scene render failed (${err.message}), falling back to procedural animation`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
163
227
|
|
|
228
|
+
// Priority 3: Procedural dynamic animated fallback (dynamic moving gradients / camera drift)
|
|
229
|
+
const captionFilter = this._captionFilter(caption);
|
|
164
230
|
let inputArgs = [];
|
|
165
231
|
let vf = '';
|
|
166
232
|
|
|
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];
|
|
233
|
+
if (imagePath && fs.existsSync(imagePath)) {
|
|
234
|
+
inputArgs = ['-loop', '1', '-i', imagePath, '-i', voicePath];
|
|
175
235
|
const hint = camera || selectCameraHint(sceneIndex, focalPoint || {});
|
|
176
236
|
const fx = focalPoint ? { focalX: focalPoint.x, focalY: focalPoint.y } : {};
|
|
177
237
|
const cameraFilter = buildCameraFilter(hint, { duration: clampedDur, w: renderWidth, h: renderHeight, ...fx });
|
|
178
238
|
vf = [cameraFilter, captionFilter].filter(Boolean).join(',');
|
|
239
|
+
} else {
|
|
240
|
+
const PALETTES = [
|
|
241
|
+
{ bg: '0d1117', c1: '38bdf8' },
|
|
242
|
+
{ bg: '0f172a', c1: '818cf8' },
|
|
243
|
+
{ bg: '13111c', c1: 'c084fc' },
|
|
244
|
+
{ bg: '0c1a24', c1: '34d399' },
|
|
245
|
+
{ bg: '18181b', c1: 'fbbf24' },
|
|
246
|
+
];
|
|
247
|
+
const pal = PALETTES[sceneIndex % PALETTES.length];
|
|
248
|
+
inputArgs = [
|
|
249
|
+
'-f', 'lavfi', '-i', `gradients=s=${renderWidth}x${renderHeight}:r=30:c0=0x${pal.bg}:c1=0x${pal.c1}:speed=0.5:type=radial`,
|
|
250
|
+
'-i', voicePath,
|
|
251
|
+
];
|
|
252
|
+
const vfFilters = ['vignette=PI/4'];
|
|
253
|
+
if (captionFilter) vfFilters.push(captionFilter);
|
|
254
|
+
vf = vfFilters.join(',');
|
|
179
255
|
}
|
|
180
256
|
|
|
181
257
|
const result = spawnSync('ffmpeg', [
|
|
182
258
|
'-nostdin', '-y', '-threads', '2',
|
|
183
259
|
...inputArgs,
|
|
184
260
|
'-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
261
|
'-af', `apad=whole_dur=${clampedDur}`,
|
|
189
262
|
'-t', String(clampedDur),
|
|
190
263
|
'-map', '0:v:0', '-map', '1:a:0',
|
|
@@ -192,6 +265,7 @@ class VideoRenderer {
|
|
|
192
265
|
'-profile:v', 'high', '-level', '4.1',
|
|
193
266
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
194
267
|
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
268
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
195
269
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
196
270
|
'-movflags', '+faststart',
|
|
197
271
|
'-pix_fmt', 'yuv420p',
|
|
@@ -199,7 +273,7 @@ class VideoRenderer {
|
|
|
199
273
|
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
200
274
|
|
|
201
275
|
if (result.status !== 0) {
|
|
202
|
-
const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
|
|
276
|
+
const err = `Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`;
|
|
203
277
|
manifest?.setSceneFailed(sceneIndex, err);
|
|
204
278
|
throw new Error(err);
|
|
205
279
|
}
|
|
@@ -208,6 +282,91 @@ class VideoRenderer {
|
|
|
208
282
|
return outputPath;
|
|
209
283
|
}
|
|
210
284
|
|
|
285
|
+
_renderCodeAnimationScene({
|
|
286
|
+
sceneIndex,
|
|
287
|
+
imagePath,
|
|
288
|
+
voicePath,
|
|
289
|
+
caption,
|
|
290
|
+
narration,
|
|
291
|
+
visualDescription,
|
|
292
|
+
clampedDur,
|
|
293
|
+
renderWidth,
|
|
294
|
+
renderHeight,
|
|
295
|
+
fps = 24,
|
|
296
|
+
outputPath,
|
|
297
|
+
manifest,
|
|
298
|
+
musicMood,
|
|
299
|
+
}) {
|
|
300
|
+
const htmlContent = generateSceneHtml({
|
|
301
|
+
caption,
|
|
302
|
+
narration,
|
|
303
|
+
visualDescription,
|
|
304
|
+
imagePath,
|
|
305
|
+
duration: clampedDur,
|
|
306
|
+
width: renderWidth,
|
|
307
|
+
height: renderHeight,
|
|
308
|
+
sceneIndex,
|
|
309
|
+
musicMood,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const animHtmlPath = path.join(this.vdir, `scene_${sceneIndex}_anim.html`);
|
|
313
|
+
const framesDir = path.join(this.vdir, `scene_${sceneIndex}_frames`);
|
|
314
|
+
fs.writeFileSync(animHtmlPath, htmlContent, 'utf8');
|
|
315
|
+
fs.mkdirSync(framesDir, { recursive: true });
|
|
316
|
+
|
|
317
|
+
const harnessBin = path.resolve(__dirname, '../harness/bin/harness.mjs');
|
|
318
|
+
const totalFrames = Math.max(1, Math.round(clampedDur * fps));
|
|
319
|
+
const lastFrame = totalFrames - 1;
|
|
320
|
+
|
|
321
|
+
const res = spawnSync(process.execPath, [
|
|
322
|
+
harnessBin,
|
|
323
|
+
'frames',
|
|
324
|
+
'--html', animHtmlPath,
|
|
325
|
+
'--out', framesDir,
|
|
326
|
+
'--fps', String(fps),
|
|
327
|
+
'--first', '0',
|
|
328
|
+
'--last', String(lastFrame),
|
|
329
|
+
'--workers', '4',
|
|
330
|
+
], { encoding: 'utf8', timeout: 5 * 60 * 1000 });
|
|
331
|
+
|
|
332
|
+
if (res.status !== 0) {
|
|
333
|
+
throw new Error(`Harness frame rendering failed: ${(res.stderr || res.stdout || '').slice(-400)}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const framesPattern = path.join(framesDir, 'f%06d.jpg');
|
|
337
|
+
const encResult = spawnSync('ffmpeg', [
|
|
338
|
+
'-nostdin', '-y', '-threads', '2',
|
|
339
|
+
'-framerate', String(fps),
|
|
340
|
+
'-pattern_type', 'sequence',
|
|
341
|
+
'-i', framesPattern,
|
|
342
|
+
'-i', voicePath,
|
|
343
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
344
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
|
345
|
+
'-profile:v', 'high', '-level', '4.1',
|
|
346
|
+
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
347
|
+
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
348
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
349
|
+
'-pix_fmt', 'yuv420p',
|
|
350
|
+
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
351
|
+
'-af', `apad=whole_dur=${clampedDur}`,
|
|
352
|
+
'-t', String(clampedDur),
|
|
353
|
+
'-movflags', '+faststart',
|
|
354
|
+
outputPath,
|
|
355
|
+
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
356
|
+
|
|
357
|
+
if (encResult.status !== 0) {
|
|
358
|
+
throw new Error(`Scene ${sceneIndex} video encode failed: ${(encResult.stderr || '').slice(-300)}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
363
|
+
fs.unlinkSync(animHtmlPath);
|
|
364
|
+
} catch (_) {}
|
|
365
|
+
|
|
366
|
+
manifest?.setSceneRendered(sceneIndex, outputPath);
|
|
367
|
+
return outputPath;
|
|
368
|
+
}
|
|
369
|
+
|
|
211
370
|
/**
|
|
212
371
|
* Assemble all scene_N_out.mp4 files into final_video.mp4,
|
|
213
372
|
* then pass through AudioMixer for sidechain ducking + LUFS normalisation.
|
|
@@ -276,19 +435,22 @@ class VideoRenderer {
|
|
|
276
435
|
|
|
277
436
|
_captionFilter(caption) {
|
|
278
437
|
if (!caption || !this.font) return '';
|
|
438
|
+
const resolvedFont = resolveFont(caption) || this.font;
|
|
439
|
+
if (!resolvedFont) return '';
|
|
440
|
+
|
|
441
|
+
const hasIndic = /[\u0D00-\u0D7F\u0900-\u0DFF]/.test(caption);
|
|
279
442
|
// Sanitize caption for ffmpeg drawtext.
|
|
280
|
-
// Strip
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.replace(/\s+/g, ' ')
|
|
285
|
-
.trim()
|
|
286
|
-
.slice(0, 45)
|
|
287
|
-
.toUpperCase();
|
|
443
|
+
// Strip syntax-breaking characters; preserve full Unicode letters & viramas.
|
|
444
|
+
const safe = Array.from(
|
|
445
|
+
caption.replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '').replace(/\s+/g, ' ').trim()
|
|
446
|
+
).slice(0, 45).join('');
|
|
288
447
|
if (!safe) return '';
|
|
289
|
-
|
|
448
|
+
|
|
449
|
+
const safeText = hasIndic ? safe : safe.toUpperCase();
|
|
450
|
+
const safeFont = resolvedFont.replace(/\\/g, '/').replace(/:/g, '\\:');
|
|
451
|
+
const shaping = hasIndic ? ':text_shaping=1' : '';
|
|
290
452
|
// Bottom-center, safe zone: 100px from bottom + UI button zone
|
|
291
|
-
return `drawtext=fontfile='${safeFont}':text='${
|
|
453
|
+
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
454
|
}
|
|
293
455
|
|
|
294
456
|
// ── Helpers ───────────────────────────────────────────────────────────────
|