@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -2
  3. package/out/frames/f000000.jpg +0 -0
  4. package/out/frames/f000001.jpg +0 -0
  5. package/out/frames/f000002.jpg +0 -0
  6. package/out/frames/f000003.jpg +0 -0
  7. package/out/frames/f000004.jpg +0 -0
  8. package/out/frames/f000005.jpg +0 -0
  9. package/out/frames/f000006.jpg +0 -0
  10. package/out/frames/f000007.jpg +0 -0
  11. package/out/frames/f000008.jpg +0 -0
  12. package/out/frames/f000009.jpg +0 -0
  13. package/out/frames/f000010.jpg +0 -0
  14. package/out/frames/f000011.jpg +0 -0
  15. package/out/frames/f000012.jpg +0 -0
  16. package/out/frames/f000013.jpg +0 -0
  17. package/out/frames/f000014.jpg +0 -0
  18. package/out/frames/f000015.jpg +0 -0
  19. package/out/frames/f000016.jpg +0 -0
  20. package/out/frames/f000017.jpg +0 -0
  21. package/out/frames/f000018.jpg +0 -0
  22. package/out/frames/f000019.jpg +0 -0
  23. package/out/frames/f000020.jpg +0 -0
  24. package/out/frames/f000021.jpg +0 -0
  25. package/out/frames/f000022.jpg +0 -0
  26. package/out/frames/f000023.jpg +0 -0
  27. package/out/frames/f000024.jpg +0 -0
  28. package/out/frames/f000025.jpg +0 -0
  29. package/out/frames/f000026.jpg +0 -0
  30. package/out/frames/f000027.jpg +0 -0
  31. package/out/frames/f000028.jpg +0 -0
  32. package/out/frames/f000029.jpg +0 -0
  33. package/out/frames/f000030.jpg +0 -0
  34. package/out/frames/f000031.jpg +0 -0
  35. package/out/frames/f000032.jpg +0 -0
  36. package/out/frames/f000033.jpg +0 -0
  37. package/out/frames/f000034.jpg +0 -0
  38. package/out/frames/f000035.jpg +0 -0
  39. package/out/frames/f000036.jpg +0 -0
  40. package/out/frames/f000037.jpg +0 -0
  41. package/out/frames/f000038.jpg +0 -0
  42. package/out/frames/f000039.jpg +0 -0
  43. package/out/frames/f000040.jpg +0 -0
  44. package/out/frames/f000041.jpg +0 -0
  45. package/out/frames/f000042.jpg +0 -0
  46. package/out/frames/f000043.jpg +0 -0
  47. package/out/frames/f000044.jpg +0 -0
  48. package/out/frames/f000045.jpg +0 -0
  49. package/out/frames/f000046.jpg +0 -0
  50. package/out/frames/f000047.jpg +0 -0
  51. package/package.json +2 -2
  52. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +25 -12
  53. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +2 -2
  54. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1 -1
  55. package/scripts/deep-validate.js +2 -2
  56. package/scripts/html-renderer/new-project.mjs +2 -2
  57. package/src/cli/make.js +6 -0
  58. package/src/harness/bin/harness.mjs +20 -7
  59. package/src/mcp-server/server.js +57 -7
  60. package/src/video-renderer/renderer.js +268 -60
  61. package/src/video-renderer/scene-animator.js +1241 -0
  62. package/src/workflows/assets.js +23 -2
  63. package/src/workflows/render.js +32 -9
  64. 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: image or video clip + voice → scene_N_out.mp4
109
+ * Render a single scene: code-driven animated video or clip + voice → scene_N_out.mp4
84
110
  *
85
111
  * @param {{
86
- * sceneIndex: number,
87
- * imagePath?: string,
88
- * videoPath?: string, // optional user clip / B-roll
89
- * voicePath: string,
90
- * caption?: string,
91
- * camera?: string, // camera_hint from scene schema v1.0.0
92
- * focalPoint?: { x: number, y: number },
93
- * manifest?: ArtifactManifest // optional — enables idempotency
94
- * renderWidth?: number, // default 1920
95
- * renderHeight?: number, // default 1080
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({ sceneIndex, imagePath, videoPath, voicePath, caption = '', camera, focalPoint, manifest, renderWidth = 1920, renderHeight = 1080 } = {}) {
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 mediaSource = videoPath || imagePath;
124
- const captionFilter = this._captionFilter(caption);
178
+ const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
125
179
 
126
- // No image and no video: procedural motion-graphics scene.
127
- // Cycles through sleek modern palettes with vignette shading.
128
- if (!mediaSource) {
129
- const PALETTES = ['0d1117', '0f172a', '13111c', '0c1a24', '18181b'];
130
- const bgColor = PALETTES[sceneIndex % PALETTES.length];
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
- '-f', 'lavfi', '-i', `color=c=#${bgColor}:s=${renderWidth}x${renderHeight}:r=30`,
138
- '-i', voicePath,
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
- '-vf', vf,
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
- '-pix_fmt', 'yuv420p',
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
- throw new Error(`Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`);
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
- const isVideoInput = Boolean(videoPath) || /\.(mp4|mov|webm|mkv|m4v)$/i.test(mediaSource);
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 (isVideoInput) {
168
- // Loop video clip if shorter than duration, scale/crop to target resolution
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 all characters that need ffmpeg escaping or that break the drawtext
281
- // filter syntax. After stripping : and %, re-escaping them is a no-op — removed.
282
- const safe = caption
283
- .replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '')
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
- const safeFont = this.font.replace(/\\/g, '/').replace(/:/g, '\\:');
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='${safe}':fontsize=68:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
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 ───────────────────────────────────────────────────────────────