@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +3 -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 +1 -1
  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/plugins/video-pipeline/skills/video-pipeline/references/providers.md +11 -0
  56. package/scripts/deep-validate.js +2 -2
  57. package/scripts/html-renderer/new-project.mjs +2 -2
  58. package/src/cli/doctor.js +12 -5
  59. package/src/harness/bin/harness.mjs +20 -7
  60. package/src/mcp-server/server.js +1 -1
  61. package/src/video-renderer/renderer.js +222 -60
  62. package/src/video-renderer/scene-animator.js +459 -0
  63. package/src/workflows/assets.js +4 -0
  64. 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: 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
+ * 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({ sceneIndex, imagePath, videoPath, voicePath, caption = '', camera, focalPoint, manifest, renderWidth = 1920, renderHeight = 1080 } = {}) {
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 mediaSource = videoPath || imagePath;
124
- const captionFilter = this._captionFilter(caption);
166
+ const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
125
167
 
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(',');
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
- '-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.
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
- '-vf', vf,
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
- '-pix_fmt', 'yuv420p',
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
- throw new Error(`Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`);
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
- const isVideoInput = Boolean(videoPath) || /\.(mp4|mov|webm|mkv|m4v)$/i.test(mediaSource);
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 (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];
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 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();
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
- const safeFont = this.font.replace(/\\/g, '/').replace(/:/g, '\\:');
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='${safe}':fontsize=68:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
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 ───────────────────────────────────────────────────────────────