Spaces:
Running
Running
Commit ·
f8a99c0
1
Parent(s): 0eec79d
Bubble text
Browse files- server-plugins/crop.js +2 -2
- utils/bubble/Bubble.js +86 -32
- utils/bubble/ass-utils.js +101 -0
- utils/bubble/bubble-templates.js +8 -13
- utils/bubble/helpers.js +121 -0
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 {
|
|
|
|
| 11 |
import { computeXY } from './layout.js';
|
| 12 |
|
| 13 |
|
| 14 |
-
const
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
const
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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')
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
let videoFilter;
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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: '
|
| 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: '
|
| 7 |
-
borderRadius:
|
|
|
|
| 8 |
bubbleText: {
|
| 9 |
-
fontSize:
|
| 10 |
-
fontColor: '#
|
| 11 |
-
fontName: '
|
| 12 |
-
|
| 13 |
-
box: 1,
|
| 14 |
-
boxColor: 'white',
|
| 15 |
-
boxBorderW: 10
|
| 16 |
},
|
| 17 |
bubbleExtra: {
|
| 18 |
positionX: 'center',
|
| 19 |
-
positionY: '
|
| 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 |
+
}
|