Spaces:
Running
Running
Commit ·
bdadca1
1
Parent(s): e67f931
Bubble text
Browse files- utils/bubble/Bubble.js +13 -43
- utils/bubble/bg-utils.js +2 -2
- utils/bubble/helpers.js +1 -2
utils/bubble/Bubble.js
CHANGED
|
@@ -25,11 +25,14 @@ const DEFAULT_FONT_FILE = (() => {
|
|
| 25 |
const BaseBubbleTemplates = {
|
| 26 |
// Simple text box: black text on white rounded box at top-center
|
| 27 |
'simple-top-center': {
|
|
|
|
|
|
|
|
|
|
| 28 |
bubbleText: {
|
| 29 |
fontSize: 48,
|
| 30 |
fontColor: '#000000',
|
| 31 |
-
fontName: '
|
| 32 |
-
//
|
| 33 |
box: 1,
|
| 34 |
boxColor: 'white',
|
| 35 |
boxBorderW: 10
|
|
@@ -163,30 +166,9 @@ class BubbleMaker {
|
|
| 163 |
let audioMap = '-map "[aout]" -c:a aac';
|
| 164 |
let extraAudioInput = '';
|
| 165 |
if (bubble.audioEffectFile) {
|
| 166 |
-
let audioPath = bubble.audioEffectFile;
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
// Resolve stock or relative audio names dynamically
|
| 170 |
-
// prefer audio-effects folder
|
| 171 |
-
const assetsDir = path.join(process.cwd(), 'public', 'assets', 'audio-effects');
|
| 172 |
-
if (audioPath === 'typewriter') audioPath = 'click';
|
| 173 |
-
if (fs.existsSync(assetsDir)) {
|
| 174 |
-
const files = fs.readdirSync(assetsDir);
|
| 175 |
-
const match = files.find(f => path.parse(f).name === audioPath);
|
| 176 |
-
if (match) {
|
| 177 |
-
audioPath = path.join(assetsDir, match);
|
| 178 |
-
}
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
// If still not an absolute path, check relative to cwd
|
| 182 |
-
if (audioPath && !path.isAbsolute(audioPath)) {
|
| 183 |
-
const candidate = path.join(process.cwd(), audioPath);
|
| 184 |
-
if (fs.existsSync(candidate)) {
|
| 185 |
-
audioPath = candidate;
|
| 186 |
-
} else {
|
| 187 |
-
(onLog || console.log)(`Audio effect file not found at ${candidate}, skipping audio effect`);
|
| 188 |
-
audioPath = null;
|
| 189 |
-
}
|
| 190 |
}
|
| 191 |
|
| 192 |
if (audioPath) {
|
|
@@ -241,7 +223,7 @@ class BubbleMaker {
|
|
| 241 |
if (needsBg) {
|
| 242 |
const paddingXpx = t.boxBorderW || 14;
|
| 243 |
const paddingYpx = Math.max(8, Math.round(fontSize * 0.6));
|
| 244 |
-
const bgInfo = await createTextBackgroundPng(t.text || '', fontSize, t.fontName,
|
| 245 |
bgPath = bgInfo.path;
|
| 246 |
const { x: bgX, y: bgY } = computeXY(bgInfo.width, bgInfo.height, extra, vw, vh);
|
| 247 |
// center text within bg box
|
|
@@ -258,20 +240,8 @@ class BubbleMaker {
|
|
| 258 |
|
| 259 |
if (bubble.audioEffectFile) {
|
| 260 |
// Use filter_complex to mix audio
|
| 261 |
-
let audioPath = bubble.audioEffectFile;
|
| 262 |
-
|
| 263 |
-
const assetsDir = path.join(process.cwd(), 'public', 'assets', 'audio-effects');
|
| 264 |
-
if (audioPath === 'typewriter') audioPath = 'click';
|
| 265 |
-
if (fs.existsSync(assetsDir)) {
|
| 266 |
-
const files = fs.readdirSync(assetsDir);
|
| 267 |
-
const match = files.find(f => path.parse(f).name === audioPath);
|
| 268 |
-
if (match) audioPath = path.join(assetsDir, match);
|
| 269 |
-
}
|
| 270 |
-
if (audioPath && !path.isAbsolute(audioPath)) {
|
| 271 |
-
const candidate = path.join(process.cwd(), audioPath);
|
| 272 |
-
if (fs.existsSync(candidate)) audioPath = candidate;
|
| 273 |
-
else audioPath = null;
|
| 274 |
-
}
|
| 275 |
|
| 276 |
if (audioPath) {
|
| 277 |
const aVol = typeof bubble.audioEffectVolume === 'number' ? bubble.audioEffectVolume : 1.0;
|
|
@@ -325,9 +295,9 @@ export async function test() {
|
|
| 325 |
const outDir = path.join(cwd, 'out');
|
| 326 |
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
| 327 |
|
| 328 |
-
// Single sample:
|
| 329 |
const typingSample = {
|
| 330 |
-
templateName: '
|
| 331 |
bubbleText: { text: 'TYPING TITLE' },
|
| 332 |
fromSec: 0.5,
|
| 333 |
toSec: 5.0
|
|
|
|
| 25 |
const BaseBubbleTemplates = {
|
| 26 |
// Simple text box: black text on white rounded box at top-center
|
| 27 |
'simple-top-center': {
|
| 28 |
+
// top-level bubble background controls whether a box is rendered
|
| 29 |
+
backgroundColor: 'white',
|
| 30 |
+
borderRadius: 10,
|
| 31 |
bubbleText: {
|
| 32 |
fontSize: 48,
|
| 33 |
fontColor: '#000000',
|
| 34 |
+
fontName: 'Gill Sans',
|
| 35 |
+
// keep box flag for compatibility (generator will prefer generated rounded background PNG)
|
| 36 |
box: 1,
|
| 37 |
boxColor: 'white',
|
| 38 |
boxBorderW: 10
|
|
|
|
| 166 |
let audioMap = '-map "[aout]" -c:a aac';
|
| 167 |
let extraAudioInput = '';
|
| 168 |
if (bubble.audioEffectFile) {
|
| 169 |
+
let audioPath = resolveAudioPath(bubble.audioEffectFile);
|
| 170 |
+
if (!audioPath) {
|
| 171 |
+
(onLog || console.log)(`Audio effect file not found for ${bubble.audioEffectFile}, skipping audio effect`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
|
| 174 |
if (audioPath) {
|
|
|
|
| 223 |
if (needsBg) {
|
| 224 |
const paddingXpx = t.boxBorderW || 14;
|
| 225 |
const paddingYpx = Math.max(8, Math.round(fontSize * 0.6));
|
| 226 |
+
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);
|
| 227 |
bgPath = bgInfo.path;
|
| 228 |
const { x: bgX, y: bgY } = computeXY(bgInfo.width, bgInfo.height, extra, vw, vh);
|
| 229 |
// center text within bg box
|
|
|
|
| 240 |
|
| 241 |
if (bubble.audioEffectFile) {
|
| 242 |
// Use filter_complex to mix audio
|
| 243 |
+
let audioPath = resolveAudioPath(bubble.audioEffectFile);
|
| 244 |
+
if (!audioPath) audioPath = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
if (audioPath) {
|
| 247 |
const aVol = typeof bubble.audioEffectVolume === 'number' ? bubble.audioEffectVolume : 1.0;
|
|
|
|
| 295 |
const outDir = path.join(cwd, 'out');
|
| 296 |
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
| 297 |
|
| 298 |
+
// Single sample: simple-top-center template. Only provide the text content.
|
| 299 |
const typingSample = {
|
| 300 |
+
templateName: 'simple-top-center',
|
| 301 |
bubbleText: { text: 'TYPING TITLE' },
|
| 302 |
fromSec: 0.5,
|
| 303 |
toSec: 5.0
|
utils/bubble/bg-utils.js
CHANGED
|
@@ -4,14 +4,14 @@ import { tempPath, escapeXml } from './helpers.js';
|
|
| 4 |
|
| 5 |
// Accept positional args for compatibility with Bubble.js caller
|
| 6 |
export async function createTextBackgroundPng(text, fontSize=40, fontName='Arial', boxColor='#ffffff', boxBorderW=0, paddingX=20, paddingY=8, radius=10, fontColor='#000000'){
|
| 7 |
-
// estimate size and create an SVG with rounded rect
|
| 8 |
const paddingXPx = Math.round(paddingX || 20);
|
| 9 |
const paddingYPx = Math.round(paddingY || 8);
|
| 10 |
const safeText = (text == null) ? '' : String(text);
|
| 11 |
const estimatedWidth = Math.max(60, Math.round((fontSize || 40) * Math.max(1, safeText.length) * 0.6) + paddingXPx * 2);
|
| 12 |
const estimatedHeight = Math.max(24, Math.round((fontSize || 40) * 1.4) + paddingYPx * 2);
|
| 13 |
const rx = Math.max(0, Math.round(radius || 0));
|
| 14 |
-
const svg = `<?xml version="1.0" encoding="utf-8"?>\n<svg xmlns='http://www.w3.org/2000/svg' width='${estimatedWidth}' height='${estimatedHeight}'>\n <rect x='0' y='0' width='100%' height='100%' rx='${rx}' ry='${rx}' fill='${boxColor || '#ffffff'}' stroke='none' />\n
|
| 15 |
const tmp = tempPath('text-bg','png');
|
| 16 |
await sharp(Buffer.from(svg)).png().toFile(tmp);
|
| 17 |
const meta = await sharp(tmp).metadata();
|
|
|
|
| 4 |
|
| 5 |
// Accept positional args for compatibility with Bubble.js caller
|
| 6 |
export async function createTextBackgroundPng(text, fontSize=40, fontName='Arial', boxColor='#ffffff', boxBorderW=0, paddingX=20, paddingY=8, radius=10, fontColor='#000000'){
|
| 7 |
+
// estimate size and create an SVG with rounded rect only (text is drawn by ffmpeg drawtext to ensure crisp font)
|
| 8 |
const paddingXPx = Math.round(paddingX || 20);
|
| 9 |
const paddingYPx = Math.round(paddingY || 8);
|
| 10 |
const safeText = (text == null) ? '' : String(text);
|
| 11 |
const estimatedWidth = Math.max(60, Math.round((fontSize || 40) * Math.max(1, safeText.length) * 0.6) + paddingXPx * 2);
|
| 12 |
const estimatedHeight = Math.max(24, Math.round((fontSize || 40) * 1.4) + paddingYPx * 2);
|
| 13 |
const rx = Math.max(0, Math.round(radius || 0));
|
| 14 |
+
const svg = `<?xml version="1.0" encoding="utf-8"?>\n<svg xmlns='http://www.w3.org/2000/svg' width='${estimatedWidth}' height='${estimatedHeight}'>\n <rect x='0' y='0' width='100%' height='100%' rx='${rx}' ry='${rx}' fill='${boxColor || '#ffffff'}' stroke='none' />\n</svg>`;
|
| 15 |
const tmp = tempPath('text-bg','png');
|
| 16 |
await sharp(Buffer.from(svg)).png().toFile(tmp);
|
| 17 |
const meta = await sharp(tmp).metadata();
|
utils/bubble/helpers.js
CHANGED
|
@@ -18,8 +18,7 @@ export function escapeText(s) {
|
|
| 18 |
|
| 19 |
export function resolveAudioPath(audioKey) {
|
| 20 |
if (!audioKey) return null;
|
| 21 |
-
|
| 22 |
-
if (key === 'typewriter') key = 'click';
|
| 23 |
const assetsDir = path.join(process.cwd(), 'public', 'assets', 'audio-effects');
|
| 24 |
if (fs.existsSync(assetsDir)) {
|
| 25 |
const files = fs.readdirSync(assetsDir);
|
|
|
|
| 18 |
|
| 19 |
export function resolveAudioPath(audioKey) {
|
| 20 |
if (!audioKey) return null;
|
| 21 |
+
const key = audioKey;
|
|
|
|
| 22 |
const assetsDir = path.join(process.cwd(), 'public', 'assets', 'audio-effects');
|
| 23 |
if (fs.existsSync(assetsDir)) {
|
| 24 |
const files = fs.readdirSync(assetsDir);
|