shiveshnavin commited on
Commit
f8a99c0
·
1 Parent(s): 0eec79d

Bubble text

Browse files
server-plugins/crop.js CHANGED
@@ -10,8 +10,8 @@ export class CropPlugin extends Plugin {
10
 
11
  async applyPrerender(originalManuscript, jobId) {
12
  const transcript = originalManuscript.transcript || [];
13
- const targetWidth = this.options.width || this.options.targetWidth || 1080;
14
- const targetHeight = this.options.height || this.options.targetHeight || 1920;
15
  const targetAspect = targetWidth / targetHeight;
16
 
17
  for (let item of transcript) {
 
10
 
11
  async applyPrerender(originalManuscript, jobId) {
12
  const transcript = originalManuscript.transcript || [];
13
+ const targetWidth = +this.options.width || +this.options.targetWidth || 1080;
14
+ const targetHeight = +this.options.height || +this.options.targetHeight || 1920;
15
  const targetAspect = targetWidth / targetHeight;
16
 
17
  for (let item of transcript) {
utils/bubble/Bubble.js CHANGED
@@ -6,19 +6,56 @@ import { FFMpegUtils } from 'common-utils';
6
  import os from 'os';
7
  import sharp from 'sharp';
8
  import crypto from 'crypto';
9
- import { tempPath, escapeXml, escapeText, resolveAudioPath } from './helpers.js';
10
- import { createTextBackgroundPng, processImageWithBg } from './bg-utils.js';
 
11
  import { computeXY } from './layout.js';
12
 
13
 
14
- const DEFAULT_FONT_FILE = (() => {
15
- const winFonts = process.env.WINDIR ? path.join(process.env.WINDIR, 'Fonts') : 'C:\\Windows\\Fonts';
16
- const preferred = path.join(winFonts, 'CascadiaCode.ttf');
17
- const fallback = path.join(winFonts, 'Arial.ttf');
18
- if (fs.existsSync(preferred)) return preferred.replace(/\\/g, '/');
19
- if (fs.existsSync(fallback)) return fallback.replace(/\\/g, '/');
20
- return preferred.replace(/\\/g, '/');
21
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  import { BaseBubbleTemplates } from './bubble-templates.js';
@@ -52,7 +89,6 @@ class BubbleMaker {
52
  if (bubble.bubbleText) {
53
  const bt = bubble.bubbleText;
54
  if (typeof bt.fontSize === 'number') bt.fontSize = Math.max(1, Math.round(bt.fontSize * scale));
55
- if (typeof bt.boxBorderW === 'number') bt.boxBorderW = Math.max(1, Math.round(bt.boxBorderW * scale));
56
  if (typeof bt.shadowSize === 'number') bt.shadowSize = Math.max(0, Math.round(bt.shadowSize * scale));
57
  }
58
  } else {
@@ -191,27 +227,45 @@ class BubbleMaker {
191
  } else {
192
  // enable between
193
  const enable = `between(t,${from},${to})`;
194
- const needsBg = !!(bubble.backgroundColor || (typeof bubble.borderRadius === 'number') || t.box);
195
- const fontfileEsc = DEFAULT_FONT_FILE.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
 
 
 
 
 
196
 
197
- let bgPath = null;
198
  let videoFilter;
199
- if (needsBg) {
200
- const paddingXpx = t.boxBorderW || 14;
201
- const paddingYpx = Math.max(8, Math.round(fontSize * 0.6));
202
- const bgInfo = await createTextBackgroundPng(t.text || '', fontSize, t.fontName, bubble.backgroundColor || t.boxColor || 'white', t.boxBorderW || 14, paddingXpx, paddingYpx, bubble.borderRadius || 0, t.fontColor || fontColor);
203
- bgPath = bgInfo.path;
204
- const { x: bgX, y: bgY } = computeXY(bgInfo.width, bgInfo.height, extra, vw, vh);
205
- // center text within bg box
206
- const xFor = `${bgX}+(${bgInfo.width}-text_w)/2`;
207
- const yFor = `${bgY}+(${bgInfo.height}-text_h)/2`;
208
- const drawTextBoxPart = '';
209
- const draw = `drawtext=text='${escapeText(t.text)}':font=${t.fontName || 'Arial'}:fontfile='${fontfileEsc}':fontsize=${fontSize}:fontcolor=${t.fontColor || '#FFFFFF'}:x=${xFor}:y=${yFor}:enable='${enable}'${drawTextBoxPart}`;
210
- videoFilter = `[1:v]scale=${bgInfo.width}:${bgInfo.height},format=rgba[bg];[0:v][bg]overlay=${bgX}:${bgY}:enable='between(t,${from},${to})'[mid];[mid]${draw}[vout]`;
211
- } else {
212
- const drawTextBoxPart = t.box ? `:box=1:boxcolor=${t.boxColor || 'white'}@1:boxborderw=${t.boxBorderW || 10}` : '';
213
- const draw = `drawtext=text='${escapeText(t.text)}':font=${t.fontName || 'Arial'}:fontfile='${fontfileEsc}':fontsize=${fontSize}:fontcolor=${t.fontColor || '#FFFFFF'}:x=${xExpr}:y=${yExpr}:enable='${enable}'${drawTextBoxPart}`;
214
- videoFilter = `[0:v]${draw}[vout]`;
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  }
216
 
217
  if (bubble.audioEffectFile) {
@@ -222,7 +276,7 @@ class BubbleMaker {
222
  if (audioPath) {
223
  const aVol = typeof bubble.audioEffectVolume === 'number' ? bubble.audioEffectVolume : 1.0;
224
  const delayMs = Math.round(from * 1000);
225
- const audioInputIndex = bgPath ? 2 : 1;
226
  const clipSec = Math.min((typeof bubble.audioEffectDurationSec === 'number' ? bubble.audioEffectDurationSec : (to - from)), (to - from));
227
  const fc = `${videoFilter};[0:a]aresample=async=1[a0];[${audioInputIndex}:a]atrim=0:${clipSec},asetpts=PTS-STARTPTS,volume=${aVol}[aeff];[aeff]adelay=${delayMs}|${delayMs}[aeffd];[a0][aeffd]amix=inputs=2:duration=first:dropout_transition=0[aout]`;
228
  if (bgPath) {
@@ -274,7 +328,7 @@ export async function test() {
274
  // Single sample: simple-top-center template. Only provide the text content.
275
  const typingSample = {
276
  templateName: 'simple-top-center',
277
- bubbleText: { text: 'TYPING TITLE' },
278
  fromSec: 0.5,
279
  toSec: 5.0
280
  };
 
6
  import os from 'os';
7
  import sharp from 'sharp';
8
  import crypto from 'crypto';
9
+ import { tempPath, escapeXml, escapeText, resolveAudioPath, ensureFontFile } from './helpers.js';
10
+ import { processImageWithBg } from './bg-utils.js';
11
+ import { createAssSubtitle } from './ass-utils.js';
12
  import { computeXY } from './layout.js';
13
 
14
 
15
+ const resolveFontFile = (fontName, fontWeight) => {
16
+ // Platform-independent: prefer fonts bundled in public/assets/fonts. Do not rely on system (Windows) font folder.
17
+ if (!fontName) return null;
18
+ const publicFontsDir = path.join(process.cwd(), 'public', 'assets', 'fonts');
19
+ try {
20
+ if (fs.existsSync(publicFontsDir)) {
21
+ const files = fs.readdirSync(publicFontsDir);
22
+ const normalized = (s) => s.replace(/\s+/g, '').toLowerCase();
23
+ const target = fontName.toLowerCase();
24
+ // exact name match (with or without spaces)
25
+ let match = files.find(f => {
26
+ const name = path.parse(f).name;
27
+ return name.toLowerCase() === target || normalized(name) === normalized(fontName);
28
+ });
29
+ if (!match) {
30
+ match = files.find(f => path.parse(f).name.toLowerCase().includes(target));
31
+ }
32
+ if (!match && fontWeight) {
33
+ const weightNormalized = ('' + fontWeight).toLowerCase();
34
+ match = files.find(f => {
35
+ const name = path.parse(f).name.toLowerCase();
36
+ return name.includes(weightNormalized) || name.includes(target.replace(/\s+/g, '').toLowerCase() + weightNormalized) || name.includes(target + '-' + weightNormalized);
37
+ });
38
+ }
39
+ if (match) return path.join(publicFontsDir, match).replace(/\\/g, '/');
40
+ }
41
+ } catch (e) {
42
+ // ignore
43
+ }
44
+ // also allow font files directly under public/ for legacy compatibility
45
+ try {
46
+ const publicRoot = path.join(process.cwd(), 'public');
47
+ if (fs.existsSync(publicRoot)) {
48
+ const files = fs.readdirSync(publicRoot);
49
+ const target = fontName.toLowerCase();
50
+ const match = files.find(f => path.parse(f).name.toLowerCase().includes(target));
51
+ if (match) return path.join(publicRoot, match).replace(/\\/g, '/');
52
+ }
53
+ } catch (e) {
54
+ // ignore
55
+ }
56
+ // Not found locally; return null and let ensureFontFile handle downloading when requested
57
+ return null;
58
+ };
59
 
60
 
61
  import { BaseBubbleTemplates } from './bubble-templates.js';
 
89
  if (bubble.bubbleText) {
90
  const bt = bubble.bubbleText;
91
  if (typeof bt.fontSize === 'number') bt.fontSize = Math.max(1, Math.round(bt.fontSize * scale));
 
92
  if (typeof bt.shadowSize === 'number') bt.shadowSize = Math.max(0, Math.round(bt.shadowSize * scale));
93
  }
94
  } else {
 
227
  } else {
228
  // enable between
229
  const enable = `between(t,${from},${to})`;
230
+ const needsBg = !!(bubble.backgroundColor || (typeof bubble.borderRadius === 'number'));
231
+ let fontfilePath = null;
232
+ if (t.fontName) {
233
+ try { fontfilePath = await ensureFontFile(t.fontName, t.fontWeight); } catch (e) { fontfilePath = null; }
234
+ }
235
+ const fontfileToUse = fontfilePath || resolveFontFile(t.fontName, t.fontWeight);
236
+ const fontfileEsc = (fontfileToUse || '').replace(/\\/g, '\\\\').replace(/:/g, '\\:');
237
 
238
+ // Use ASS for text and background (no PNGs). Compute position and generate ASS with boxColor from bubble.backgroundColor.
239
  let videoFilter;
240
+ // compute absolute position for ASS based on extra position
241
+ let absX = Math.round(vw / 2);
242
+ let absY = Math.round(vh / 2);
243
+ if (posX === 'left') absX = Math.round(vw * (padX / 100));
244
+ else if (posX === 'right') absX = Math.round(vw - vw * (padX / 100));
245
+ if (posY === 'top') absY = Math.round(vh * (padY / 100));
246
+ else if (posY === 'bottom') absY = Math.round(vh - vh * (padY / 100));
247
+ // create ASS subtitle using boxColor from bubble.backgroundColor (if any)
248
+ const assInfo = await createAssSubtitle(t.text || '', { vw, vh, fontName: t.fontName || 'Arial', fontSize, fontColor: t.fontColor || fontColor, fontWeight: t.fontWeight, boxColor: bubble.backgroundColor || null, x: absX, y: absY, from, to });
249
+ let assPath = assInfo.path.replace(/\\/g, '/').replace(/:/g, '\\:'); // escape colon for ffmpeg filter parser on Windows
250
+ const fontsDir = fontfilePath ? path.dirname(fontfilePath).replace(/\\/g, '/') : path.join(process.cwd(), 'public', 'assets', 'fonts').replace(/\\/g, '/');
251
+ const assEsc = assPath.replace(/'/g, "\\'");
252
+ const fontsDirEsc = fontsDir.replace(/:/g, '\\:').replace(/'/g, "\\'");
253
+ // Default to ASS-only subtitle render
254
+ // If ASS background doesn't render properly, fall back to overlaying a generated PNG box behind the subtitles.
255
+ videoFilter = `[0:v]subtitles='${assEsc}':fontsdir='${fontsDirEsc}'[vout]`;
256
+ let bgPath = null;
257
+ if (bubble.backgroundColor) {
258
+ try {
259
+ const bgInfo = await createTextBackgroundPng(t.text || '', fontSize, t.fontName || 'Arial', bubble.backgroundColor, 0, 20, 8, bubble.borderRadius || 0, t.fontColor || fontColor);
260
+ const bgLocal = bgInfo.path.replace(/\\/g, '/');
261
+ const overlayX = Math.round(absX - bgInfo.width / 2);
262
+ const overlayY = Math.round(absY - bgInfo.height / 2);
263
+ // Overlay the PNG (input index 1) then apply subtitles on top of the composed stream
264
+ videoFilter = `[1:v]scale=${bgInfo.width}:${bgInfo.height},format=rgba[bg];[0:v][bg]overlay=${overlayX}:${overlayY}:enable='between(t,${from},${to})'[mid];[mid]subtitles='${assEsc}':fontsdir='${fontsDirEsc}'[vout]`;
265
+ bgPath = bgLocal;
266
+ } catch (e) {
267
+ // keep ASS-only videoFilter on failure
268
+ }
269
  }
270
 
271
  if (bubble.audioEffectFile) {
 
276
  if (audioPath) {
277
  const aVol = typeof bubble.audioEffectVolume === 'number' ? bubble.audioEffectVolume : 1.0;
278
  const delayMs = Math.round(from * 1000);
279
+ const audioInputIndex = 1;
280
  const clipSec = Math.min((typeof bubble.audioEffectDurationSec === 'number' ? bubble.audioEffectDurationSec : (to - from)), (to - from));
281
  const fc = `${videoFilter};[0:a]aresample=async=1[a0];[${audioInputIndex}:a]atrim=0:${clipSec},asetpts=PTS-STARTPTS,volume=${aVol}[aeff];[aeff]adelay=${delayMs}|${delayMs}[aeffd];[a0][aeffd]amix=inputs=2:duration=first:dropout_transition=0[aout]`;
282
  if (bgPath) {
 
328
  // Single sample: simple-top-center template. Only provide the text content.
329
  const typingSample = {
330
  templateName: 'simple-top-center',
331
+ bubbleText: { text: 'A QUICK BROWN FOX JUMPED OVER' },
332
  fromSec: 0.5,
333
  toSec: 5.0
334
  };
utils/bubble/ass-utils.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { tempPath, escapeXml } from './helpers.js';
4
+
5
+ function secToAssTime(s) {
6
+ const sign = s < 0 ? -1 : 1;
7
+ s = Math.abs(s);
8
+ const h = Math.floor(s / 3600);
9
+ const m = Math.floor((s % 3600) / 60);
10
+ const sec = Math.floor(s % 60);
11
+ const cs = Math.floor((s - Math.floor(s)) * 100);
12
+ return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
13
+ }
14
+
15
+ function assColorFromHex(hex) {
16
+ if (!hex) return '&H00FFFFFF';
17
+ // support simple named colors and rgb/rgba strings
18
+ const named = {
19
+ red: '#FF0000',
20
+ white: '#FFFFFF',
21
+ black: '#000000',
22
+ transparent: '#00000000',
23
+ blue: '#0000FF',
24
+ green: '#00FF00',
25
+ yellow: '#FFFF00',
26
+ gray: '#808080',
27
+ grey: '#808080'
28
+ };
29
+ if (typeof hex === 'string' && !hex.startsWith('#')) {
30
+ const key = hex.toLowerCase();
31
+ if (named[key]) hex = named[key];
32
+ else {
33
+ // try to parse rgb()/rgba()
34
+ const m = hex.match(/rgba?\s*\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i);
35
+ if (m) {
36
+ const r = parseInt(m[1], 10);
37
+ const g = parseInt(m[2], 10);
38
+ const b = parseInt(m[3], 10);
39
+ const a = m[4] !== undefined ? Math.round(parseFloat(m[4]) * 255) : 255;
40
+ const aa = a.toString(16).padStart(2, '0').toUpperCase();
41
+ hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${aa}`;
42
+ }
43
+ }
44
+ }
45
+
46
+ let h = String(hex).replace(/^#/, '');
47
+ // support RRGGBB or RRGGBBAA or short 3-char
48
+ if (h.length === 3) h = h.split('').map(c => c + c).join('');
49
+ if (h.length === 6) h = 'FF' + h; // add alpha FF (opaque) at front
50
+ if (h.length === 8) {
51
+ const a = h.slice(0, 2);
52
+ const r = h.slice(2, 4);
53
+ const g = h.slice(4, 6);
54
+ const b = h.slice(6, 8);
55
+ // ASS uses &HAABBGGRR where AA is alpha (00 opaque)
56
+ // convert alpha from hex (FF opaque) to ASS alpha where 00 opaque -> ASSalpha = (255 - alpha)
57
+ const parsed = parseInt(a, 16);
58
+ const alpha = isNaN(parsed) ? 255 : 255 - parsed;
59
+ const aa = alpha.toString(16).padStart(2, '0').toUpperCase();
60
+ return `&H${aa}${b}${g}${r}`;
61
+ }
62
+ return '&H00FFFFFF';
63
+ }
64
+
65
+ export async function createAssSubtitle(text, opts = {}) {
66
+ const { vw = 1080, vh = 1920, fontName = 'Arial', fontSize = 40, fontColor = '#FFFFFF', fontWeight, boxColor = null, x = vw / 2, y = vh / 2, from = 0, to = 3 } = opts;
67
+ const safeText = (text == null) ? '' : String(text).replace(/\r/g, '').replace(/\n/g, '\\N').replace(/([{}])/g, '\\$1');
68
+ const assPath = tempPath('bubble', 'ass');
69
+ const styleName = 'BubbleStyle';
70
+ const primary = assColorFromHex(fontColor);
71
+ const back = assColorFromHex(boxColor);
72
+ const bold = ('' + fontWeight).indexOf('7') === 0 || (String(fontWeight) === '700' || String(fontWeight).toLowerCase().includes('bold')) ? '1' : '0';
73
+ const fontSz = Math.round(fontSize);
74
+ const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${vw}\nPlayResY: ${vh}\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n`;
75
+
76
+ // Simple style: use BackColour (BorderStyle=3) when boxColor provided
77
+ const styleLine = boxColor
78
+ ? `Style: ${styleName},${fontName},${fontSz},${primary},&H00FFFFFF,&H00000000,${back},${bold},0,0,0,100,100,0,0,3,0,0,2,10,10,10,1\n\n`
79
+ : `Style: ${styleName},${fontName},${fontSz},${primary},&H00FFFFFF,&H00000000,&H00000000,${bold},0,0,0,100,100,0,0,1,1,0,2,10,10,10,1\n\n`;
80
+
81
+ const eventsHeader = `[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
82
+ const start = secToAssTime(from);
83
+ const end = secToAssTime(to);
84
+ const dialog = `Dialogue: 0,${start},${end},${styleName},,0,0,0, ,{\\pos(${Math.round(x)},${Math.round(y)})}${safeText}\n`;
85
+ let content = header + styleLine + eventsHeader + dialog;
86
+ content = `[Script Info]
87
+ Title: Song Lyrics
88
+
89
+ [V4+ Styles]
90
+ Format: Name, Fontname, Fontsize, PrimaryColour, BackColour, BorderStyle, Outline, Shadow
91
+ Style: Default,Arial,20,&H00FFFFFF,&H80000000,3,0,0
92
+
93
+ [Events]
94
+ Format: Layer, Start, End, Style, Text
95
+ Dialogue: 0,0:00:12.50,0:00:15.80,Default,When the night has come`
96
+ fs.writeFileSync(assPath, content, 'utf8');
97
+ const meta = { path: assPath };
98
+ return meta;
99
+ }
100
+
101
+ export default createAssSubtitle;
utils/bubble/bubble-templates.js CHANGED
@@ -3,23 +3,18 @@ export const BaseBubbleTemplates = {
3
  // Simple text box: black text on white rounded box at top-center
4
  'simple-top-center': {
5
  // top-level bubble background controls whether a box is rendered
6
- backgroundColor: 'white',
7
- borderRadius: 10,
 
8
  bubbleText: {
9
- fontSize: 48,
10
- fontColor: '#000000',
11
- fontName: 'Arial',
12
- // keep box flag for compatibility (generator will prefer generated rounded background PNG)
13
- box: 1,
14
- boxColor: 'white',
15
- boxBorderW: 10
16
  },
17
  bubbleExtra: {
18
  positionX: 'center',
19
- positionY: 'top',
20
- size: 'full',
21
- paddingX: 2,
22
- paddingY: 2
23
  }
24
  }
25
  };
 
3
  // Simple text box: black text on white rounded box at top-center
4
  'simple-top-center': {
5
  // top-level bubble background controls whether a box is rendered
6
+ backgroundColor: 'red',
7
+ borderRadius: 20,
8
+ audioEffectFile: 'woosh',
9
  bubbleText: {
10
+ fontSize: 150,
11
+ fontColor: '#1a1a1c',
12
+ fontName: 'Rubik Pixels',
13
+ fontWeight: '700',
 
 
 
14
  },
15
  bubbleExtra: {
16
  positionX: 'center',
17
+ positionY: 'center'
 
 
 
18
  }
19
  }
20
  };
utils/bubble/helpers.js CHANGED
@@ -31,3 +31,124 @@ export function resolveAudioPath(audioKey) {
31
  if (fs.existsSync(candidate)) return candidate;
32
  return null;
33
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  if (fs.existsSync(candidate)) return candidate;
32
  return null;
33
  }
34
+
35
+ // Ensure font file for given fontName exists in public/assets/fonts (or public root). If missing, try downloading from Google Fonts.
36
+ export async function ensureFontFile(fontName, fontWeight) {
37
+ if (!fontName) return null;
38
+ const assetsFontsDir = path.join(process.cwd(), 'public', 'assets', 'fonts');
39
+ if (!fs.existsSync(assetsFontsDir)) fs.mkdirSync(assetsFontsDir, { recursive: true });
40
+ const normalized = s => ('' + s).replace(/\s+/g, '').toLowerCase();
41
+ const target = ('' + fontName).toLowerCase();
42
+
43
+ // check assets/fonts
44
+ const files = fs.readdirSync(assetsFontsDir);
45
+ let match = files.find(f => {
46
+ const name = path.parse(f).name;
47
+ return name.toLowerCase() === target || normalized(name) === normalized(fontName) || name.toLowerCase().includes(target);
48
+ });
49
+ if (!match && fontWeight) {
50
+ const weightNormalized = ('' + fontWeight).toLowerCase();
51
+ match = files.find(f => {
52
+ const name = path.parse(f).name.toLowerCase();
53
+ return name.includes(weightNormalized) || name.includes(target.replace(/\s+/g, '').toLowerCase() + weightNormalized) || name.includes(target + '-' + weightNormalized);
54
+ });
55
+ }
56
+ if (match) return path.join(assetsFontsDir, match);
57
+
58
+ // check public root
59
+ const publicRootFiles = fs.readdirSync(process.cwd()).filter(f => /\.(ttf|otf|woff2?|woff)$/i.test(f));
60
+ match = publicRootFiles.find(f => {
61
+ const name = path.parse(f).name;
62
+ return name.toLowerCase() === target || normalized(name) === normalized(fontName) || name.toLowerCase().includes(target);
63
+ });
64
+ if (!match && fontWeight) {
65
+ const weightNormalized = ('' + fontWeight).toLowerCase();
66
+ match = publicRootFiles.find(f => {
67
+ const name = path.parse(f).name.toLowerCase();
68
+ return name.includes(weightNormalized) || name.includes(target.replace(/\s+/g, '').toLowerCase() + weightNormalized) || name.includes(target + '-' + weightNormalized);
69
+ });
70
+ }
71
+ if (match) return path.join(process.cwd(), match);
72
+
73
+ // Try downloading from Google Fonts - best-effort with redirect support
74
+ try {
75
+ // Build Google Fonts family parameter. Include weight when provided so CSS contains specific weight ranges.
76
+ const familyBase = fontName.trim().replace(/\s+/g, '+');
77
+ const familyParam = fontWeight ? `${familyBase}:wght@${fontWeight}` : familyBase;
78
+ const cssUrl = `https://fonts.googleapis.com/css2?family=${familyParam}&display=swap`;
79
+ console.log('Attempting to download font from Google Fonts:', cssUrl);
80
+ const css = await fetchUrl(cssUrl, 5);
81
+ console.log('Fetched CSS for font:', cssUrl);
82
+ if (css) {
83
+ // find first url(...) occurrence and extract URL
84
+ const urlMatches = css.match(/url\((['"]?)[^'"\)]+\1\)/g) || [];
85
+ let fontUrl = null;
86
+ for (const m of urlMatches) {
87
+ let u = m.replace(/^url\((['"]?)/, '').replace(/(['"]?)\)$/, '');
88
+ if (u.startsWith('//')) u = 'https:' + u;
89
+ if (/^https?:\/\//i.test(u)) { fontUrl = u; break; }
90
+ }
91
+ if (fontUrl) {
92
+ const parsed = new URL(fontUrl);
93
+ const ext = path.parse(parsed.pathname).ext || '.woff2';
94
+ const weightSuffix = fontWeight ? `_${String(fontWeight).replace(/\s+/g, '')}` : '';
95
+ const outFile = path.join(assetsFontsDir, `${fontName.replace(/\s+/g, '_')}${weightSuffix}${ext}`);
96
+ await downloadUrl(fontUrl, outFile, 5);
97
+ return outFile;
98
+ }
99
+ }
100
+ } catch (e) {
101
+ // ignore download errors
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ function fetchUrl(url, redirects = 5) {
108
+ return new Promise((resolve, reject) => {
109
+ try {
110
+ const https = require('https');
111
+ const http = require('http');
112
+ const doGet = (u, remaining) => {
113
+ const client = u.startsWith('https:') ? https : http;
114
+ const opts = { headers: { 'User-Agent': 'curl/7.64.1' } };
115
+ client.get(u, opts, (res) => {
116
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && remaining > 0) {
117
+ const loc = res.headers.location.startsWith('http') ? res.headers.location : (u.startsWith('https:') ? 'https:' + res.headers.location : res.headers.location);
118
+ return doGet(loc, remaining - 1);
119
+ }
120
+ if (res.statusCode && res.statusCode >= 400) return reject(new Error('Failed to fetch ' + u + ' status ' + res.statusCode));
121
+ let data = '';
122
+ res.setEncoding('utf8');
123
+ res.on('data', chunk => data += chunk);
124
+ res.on('end', () => resolve(data));
125
+ }).on('error', reject);
126
+ };
127
+ doGet(url, redirects);
128
+ } catch (e) { reject(e); }
129
+ });
130
+ }
131
+
132
+ function downloadUrl(url, outPath, redirects = 5) {
133
+ return new Promise((resolve, reject) => {
134
+ try {
135
+ const https = require('https');
136
+ const http = require('http');
137
+ const doGet = (u, remaining) => {
138
+ const client = u.startsWith('https:') ? https : http;
139
+ client.get(u, (res) => {
140
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && remaining > 0) {
141
+ const loc = res.headers.location.startsWith('http') ? res.headers.location : (u.startsWith('https:') ? 'https:' + res.headers.location : res.headers.location);
142
+ return doGet(loc, remaining - 1);
143
+ }
144
+ if (res.statusCode && res.statusCode >= 400) return reject(new Error('Failed to download ' + u + ' status ' + res.statusCode));
145
+ const file = fs.createWriteStream(outPath);
146
+ res.pipe(file);
147
+ file.on('finish', () => file.close(() => resolve(outPath)));
148
+ file.on('error', (err) => { try { fs.unlinkSync(outPath); } catch (e) { }; reject(err); });
149
+ }).on('error', (err) => reject(err));
150
+ };
151
+ doGet(url, redirects);
152
+ } catch (e) { reject(e); }
153
+ });
154
+ }