remote-rdr / utils /bubble /Bubble.js
shiveshnavin's picture
Fix media
b93e09e
import fs from 'fs';
import path from 'path';
import _ from 'lodash';
import { FFMpegUtils } from 'common-utils';
import { tempPath, resolveAudioPath, ensureFontFile } from './helpers.js';
import { processImageWithBg } from './bg-utils.js';
import { createAssSubtitle } from './ass-utils.js';
import { computeXY } from './layout.js';
const resolveFontFile = (fontName, fontWeight) => {
// Platform-independent: prefer fonts bundled in public/assets/fonts. Do not rely on system (Windows) font folder.
if (!fontName) return null;
const publicFontsDir = path.join(process.cwd(), 'public', 'assets', 'fonts');
try {
if (fs.existsSync(publicFontsDir)) {
const files = fs.readdirSync(publicFontsDir);
const normalized = (s) => s.replace(/\s+/g, '').toLowerCase();
const target = fontName.toLowerCase();
// exact name match (with or without spaces)
let match = files.find(f => {
const name = path.parse(f).name;
return name.toLowerCase() === target || normalized(name) === normalized(fontName);
});
if (!match) {
match = files.find(f => path.parse(f).name.toLowerCase().includes(target));
}
if (!match && fontWeight) {
const weightNormalized = ('' + fontWeight).toLowerCase();
match = files.find(f => {
const name = path.parse(f).name.toLowerCase();
return name.includes(weightNormalized) || name.includes(target.replace(/\s+/g, '').toLowerCase() + weightNormalized) || name.includes(target + '-' + weightNormalized);
});
}
if (match) return path.join(publicFontsDir, match).replace(/\\/g, '/');
}
} catch (e) {
// ignore
}
// also allow font files directly under public/ for legacy compatibility
try {
const publicRoot = path.join(process.cwd(), 'public');
if (fs.existsSync(publicRoot)) {
const files = fs.readdirSync(publicRoot);
const target = fontName.toLowerCase();
const match = files.find(f => path.parse(f).name.toLowerCase().includes(target));
if (match) return path.join(publicRoot, match).replace(/\\/g, '/');
}
} catch (e) {
// ignore
}
// Not found locally; return null and let ensureFontFile handle downloading when requested
return null;
};
import { BaseBubbleTemplates } from './bubble-templates.js';
class BubbleMaker {
constructor() { }
async buildMixedAudio(videoPath, bubbles, outputAudioPath, onLog) {
const meta = await FFMpegUtils.getMediaMetadata(videoPath);
if (!meta || !meta.audio) return false;
const sfxBubbles = bubbles.filter(
b => b.audioEffectFile && resolveAudioPath(b.audioEffectFile)
);
if (!sfxBubbles.length) {
const ac = outputAudioPath.toLowerCase().endsWith(".mp3") ? "libmp3lame" : "aac";
await FFMpegUtils.execute(
`ffmpeg -i "${videoPath}" -vn -c:a ${ac} "${outputAudioPath}" -y`
);
return true;
}
const inputs = [`-i "${videoPath}"`];
const filters = [];
const mixInputs = ["[0:a]"];
// build trimmed/delayed sfx streams and labels
sfxBubbles.forEach((b, i) => {
const from = typeof b.fromSec === 'number' ? b.fromSec : 0;
const to = typeof b.toSec === 'number' ? b.toSec : (from + (b.durationSec || 3));
const clipSec = typeof b.audioEffectDurationSec === 'number' ? b.audioEffectDurationSec : (to - from);
const sfxPath = resolveAudioPath(b.audioEffectFile);
const inputIdx = i + 1;
const delayMs = Math.round(from * 1000);
const volume = b.audioEffectVolume ?? 1;
const label = `[sfx${i}]`;
inputs.push(`-i "${sfxPath}"`);
// trim to requested length, reset pts, apply volume and delay
filters.push(
`[${inputIdx}:a]atrim=0:${clipSec},asetpts=PTS-STARTPTS,volume=${volume},adelay=${delayMs}|${delayMs}${label}`
);
mixInputs.push(label);
});
// sequential two-input mixing chain to avoid averaging when there are
// more than two streams. each step uses normalize=0 safely.
if (mixInputs.length === 1) {
// no effects – just copy original
filters.push(`${mixInputs[0]}anull[aout]`);
} else {
let prev = mixInputs[0];
for (let idx = 1; idx < mixInputs.length; idx++) {
const cur = mixInputs[idx];
const out = idx === mixInputs.length - 1 ? '[aout]' : `[mix${idx}]`;
filters.push(
`${prev}${cur}amix=inputs=2:duration=first:dropout_transition=0:normalize=0${out}`
);
prev = out;
}
}
const fc = filters.join(";");
const ac = outputAudioPath.toLowerCase().endsWith(".mp3")
? "libmp3lame"
: "aac";
const cmd =
`ffmpeg ${inputs.join(" ")} ` +
`-filter_complex "${fc}" ` +
`-map "[aout]" -c:a ${ac} "${outputAudioPath}" -y`;
if (onLog) onLog("buildMixedAudio: " + cmd);
await FFMpegUtils.execute(cmd, onLog, undefined, null, 'ffmpeg');
return true;
}
/**
* Create an animated bubble (image or text) over a video.
* The second argument may also be an array of bubble objects; in that case
* each entry is rendered in sequence, layering the overlays.
*
* Audio strategy: video and audio are processed completely separately.
* 1. Render all visual overlays on a silent copy of the source video.
* 2. Build a mixed audio track (original + all SFX) via a single ffmpeg pass.
* 3. Mux the silent rendered video with the mixed audio.
*
* @param {string} videoPath - path to base video
* @param {Object|Object[]} bubble - bubble config(s) (see OriginalManuscriptModel.Bubble)
* @param {string} outputPath - output video path
* @param {Function} onLog - optional logger
* @param {boolean} detachAudio - kept for API compatibility; audio is always handled separately now
* @param {boolean} [normalizeAudio] - unused (normalize=0 is always applied); kept for API compatibility
*/
async makeBubble(videoPath, bubble, outputPath, onLog, detachAudio = true, normalizeAudio) {
// --- 1. Prepare: strip audio from source so all visual rendering is on a silent video ---
const meta0 = await FFMpegUtils.getMediaMetadata(videoPath);
const originalHasAudio = !!(meta0 && meta0.audio);
const silentVideo = tempPath('noaudio', 'mp4');
await FFMpegUtils.execute(`ffmpeg -i "${videoPath}" -c copy -an "${silentVideo}" -y`);
// normalise bubble to array for uniform handling
const bubbleArr = Array.isArray(bubble) ? bubble : [bubble];
// --- 2. Render all visual overlays onto the silent video (no audio filters needed) ---
const visualOutput = tempPath('visual', 'mp4');
await this._renderVisuals(silentVideo, bubbleArr, visualOutput, onLog);
// --- 3. Build mixed audio (original + SFX) in one shot ---
let finalAudioPath = null;
if (originalHasAudio) {
// allow the caller to force the mixed audio to be written to a specific
// directory (useful for debugging). if BUBBLE_AUDIO_DIR is set we create
// a file there, otherwise fall back to a temp file as before. the
// extension can be controlled with BUBBLE_AUDIO_EXT (default aac). a
// companion flag BUBBLE_KEEP_AUDIO prevents the file from being deleted
// after muxing so it remains for inspection.
if (process.env.BUBBLE_AUDIO_DIR) {
const dir = process.env.BUBBLE_AUDIO_DIR;
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const ext = (process.env.BUBBLE_AUDIO_EXT || 'aac').replace(/^\.?/, '');
const base = path.basename(outputPath, path.extname(outputPath));
finalAudioPath = path.join(dir, `${base}.${ext}`);
} else {
finalAudioPath = tempPath('mixedaudio', 'aac');
}
await this.buildMixedAudio(videoPath, bubbleArr, finalAudioPath, onLog);
}
// --- 4. Mux visual output with mixed audio ---
if (finalAudioPath && fs.existsSync(finalAudioPath)) {
await FFMpegUtils.execute(
`ffmpeg -i "${visualOutput}" -i "${finalAudioPath}" -c:v copy -c:a copy -shortest "${outputPath}" -y`
);
} else {
// no audio — just copy the visual output
fs.copyFileSync(visualOutput, outputPath);
}
// cleanup temp files (respect BUBBLE_KEEP_AUDIO for the final audio
// track so that developers can look at the intermediate mix if desired).
for (const f of [silentVideo, visualOutput, finalAudioPath]) {
if (!f || !fs.existsSync(f)) continue;
if (process.env.BUBBLE_KEEP_AUDIO && f === finalAudioPath) continue;
try { fs.unlinkSync(f); } catch (_) { }
}
return outputPath;
}
/**
* Render all visual bubble overlays onto a silent source video.
* Handles both array (batch or sequential) and single-bubble cases.
* No audio filters are emitted here at all.
*/
async _renderVisuals(silentVideo, bubbleArr, outputPath, onLog) {
// Expand templates for each bubble
const meta = await FFMpegUtils.getMediaMetadata(silentVideo);
const vw = meta.video.width;
const vh = meta.video.height;
bubbleArr.map(b => Object.assign(b, this._applyTemplate(b, vw, vh), b));
const expandedBubbles = bubbleArr
const hasText = expandedBubbles.some(b => b.bubbleText && b.bubbleText.text);
const hasMedia = expandedBubbles.some(b => {
const mf = b.mediaAbsPaths || b.mediaAbsPath || b.mediaAbs;
return !!mf;
});
// If there are text bubbles we must go sequential (ASS subtitle filter can't be batched easily)
if (hasText) {
let current = silentVideo;
for (let i = 0; i < expandedBubbles.length; i++) {
const tmp = tempPath('vstep', 'mp4');
await this._renderSingleVisual(current, expandedBubbles[i], tmp, onLog, meta);
if (current !== silentVideo && fs.existsSync(current)) fs.unlinkSync(current);
current = tmp;
}
fs.copyFileSync(current, outputPath);
if (current !== silentVideo && fs.existsSync(current)) fs.unlinkSync(current);
return;
}
// All media bubbles — try to batch into one ffmpeg pass
if (hasMedia) {
try {
await this._renderMediaBatch(silentVideo, expandedBubbles, outputPath, onLog, meta);
return;
} catch (err) {
onLog && onLog('Batch media render failed, falling back to sequential: ' + err);
}
}
// fallback sequential
let current = silentVideo;
for (let i = 0; i < expandedBubbles.length; i++) {
const tmp = tempPath('vstep', 'mp4');
await this._renderSingleVisual(current, expandedBubbles[i], tmp, onLog, meta);
if (current !== silentVideo && fs.existsSync(current)) fs.unlinkSync(current);
current = tmp;
}
fs.copyFileSync(current, outputPath);
if (current !== silentVideo && fs.existsSync(current)) fs.unlinkSync(current);
}
/**
* Apply template defaults to a bubble config for a given video size.
*/
_applyTemplate(bubble, vw, vh) {
if (!bubble) return bubble;
let templateName = bubble.templateName;
let tpl = templateName ? BaseBubbleTemplates[templateName] : null;
if (!tpl) {
const entries = Object.entries(BaseBubbleTemplates || {});
const hasMedia = !!(bubble.mediaAbsPaths || bubble.mediaAbsPath || bubble.mediaAbs);
const hasText = !!bubble.bubbleText;
const fallback = hasMedia
? entries.find(([name]) => name.toLowerCase().includes('media'))
: (hasText ? entries.find(([name]) => !name.toLowerCase().includes('media')) : null);
if (fallback) {
templateName = fallback[0];
tpl = fallback[1];
}
}
if (!tpl) return bubble;
let b = _.merge({}, tpl, bubble);
if (!b.templateName && templateName) b.templateName = templateName;
if (!b.animExtra) {
if (tpl.mediaAbsPath && tpl.mediaAbsPath.animExtra) b.animExtra = tpl.mediaAbsPath.animExtra;
else if (tpl.mediaAbsPaths && Array.isArray(tpl.mediaAbsPaths) && tpl.mediaAbsPaths[0] && tpl.mediaAbsPaths[0].animExtra) {
b.animExtra = tpl.mediaAbsPaths[0].animExtra;
}
}
const REF_W = 1080, REF_H = 1920;
const scale = Math.min(vw / REF_W, vh / REF_H) || 1;
if (b.bubbleText) {
if (typeof b.bubbleText.fontSize === 'number') b.bubbleText.fontSize = Math.max(1, Math.round(b.bubbleText.fontSize * scale));
if (typeof b.bubbleText.shadowSize === 'number') b.bubbleText.shadowSize = Math.max(0, Math.round(b.bubbleText.shadowSize * scale));
}
return b;
}
/**
* Render multiple media (non-text) bubbles in a single ffmpeg pass (no audio).
*/
async _renderMediaBatch(silentVideo, bubbles, outputPath, onLog, meta) {
const vw = meta.video.width;
const vh = meta.video.height;
const mainDuration = meta.duration || meta.video?.duration || 0;
const inputs = [`-i "${silentVideo}"`];
const filterParts = [];
let currentLabel = '[0:v]';
let inputIndex = 1;
for (const b of bubbles) {
const extra = b.bubbleExtra || {};
const from = typeof b.fromSec === 'number' ? b.fromSec : 0;
const to = typeof b.toSec === 'number' ? b.toSec : (from + (b.durationSec || 3));
const mediaField = b.mediaAbsPaths || b.mediaAbsPath || b.mediaAbs;
if (!mediaField) continue;
const mediaItem = Array.isArray(mediaField) ? mediaField[0] : mediaField;
let overlayPath = typeof mediaItem === 'string' ? mediaItem : (mediaItem && mediaItem.path);
if (!overlayPath || !fs.existsSync(overlayPath)) continue;
const imgExts = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'];
const isImageFile = imgExts.includes(path.extname(overlayPath).toLowerCase());
const padX = extra.paddingX || 5;
const padY = extra.paddingY || 5;
let ow = null, oh = null;
if (extra.size === 'half') ow = Math.round(vw * 0.45);
else if (extra.size !== 'full') ow = Math.round(vw * 0.30);
let overlayMeta;
try { overlayMeta = await FFMpegUtils.getMediaMetadata(overlayPath); } catch (e) { overlayMeta = null; }
if (extra.size === 'full') {
const maxW = Math.max(1, Math.round(vw * (1 - 2 * padX / 100)));
ow = maxW;
if (overlayMeta && overlayMeta.video && overlayMeta.video.width && overlayMeta.video.height) {
const ar = overlayMeta.video.width / overlayMeta.video.height;
oh = Math.max(1, Math.round(ow / ar));
} else {
oh = Math.max(1, Math.round(vh * (1 - 2 * padY / 100)));
}
} else if (overlayMeta && overlayMeta.video && overlayMeta.video.width && overlayMeta.video.height) {
const ar = overlayMeta.video.width / overlayMeta.video.height;
if (!ow) ow = Math.round(vw * 0.30);
if (!oh) oh = Math.round(ow / ar);
} else if (!oh) {
oh = Math.round((ow || Math.round(vw * 0.30)) * 0.75);
}
if (!ow) ow = Math.round(vw * 0.30);
if (!oh) oh = Math.round(ow * 0.75);
if (isImageFile && (b.backgroundColor || typeof b.borderRadius === 'number')) {
try { overlayPath = await processImageWithBg(overlayPath, ow, oh, b.backgroundColor, b.borderRadius || 0); } catch (_) { }
}
const { x, y } = computeXY(ow, oh, extra, vw, vh);
const anim = (mediaItem && mediaItem.animExtra) || b.animExtra || {};
const animTpl = anim && typeof anim.template === 'string' ? anim.template : null;
const animDur = typeof anim.durationSec === 'number' ? anim.durationSec : Math.min(0.5, (to - from) / 2);
const wrapVal = (v) => typeof v === 'number' ? `(${v})` : v;
const quote = (expr) => `'${expr.replace(/'/g, "\\'")}'`;
let xExpr = x, yExpr = y;
if (animTpl === 'slide_down') { const s = -oh; yExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(y)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(y)})`; }
else if (animTpl === 'slide_up') { const s = vh; yExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(y)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(y)})`; }
else if (animTpl === 'slide_left') { const s = -ow; xExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(x)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(x)})`; }
else if (animTpl === 'slide_right') { const s = vw; xExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(x)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(x)})`; }
if (typeof xExpr === 'string') xExpr = quote(xExpr);
if (typeof yExpr === 'string') yExpr = quote(yExpr);
const isFade = animTpl === 'fade' || animTpl === 'popup';
const fadeOut = Math.max(from, to - animDur);
const overlayFlag = isImageFile
? (mainDuration > 0 ? `-loop 1 -t ${mainDuration} -i "${overlayPath}"` : `-loop 1 -i "${overlayPath}"`)
: `-i "${overlayPath}"`;
inputs.push(overlayFlag);
let scalePart = `[${inputIndex}:v]scale=${ow}:${oh},format=rgba`;
if (isFade) scalePart += `,fade=t=in:st=${from}:d=${animDur}:alpha=1,fade=t=out:st=${fadeOut}:d=${animDur}:alpha=1`;
scalePart += `[ov${inputIndex}]`;
filterParts.push(scalePart);
filterParts.push(`${currentLabel}[ov${inputIndex}]overlay=${xExpr}:${yExpr}:enable='between(t,${from},${to})'[v${inputIndex}]`);
currentLabel = `[v${inputIndex}]`;
inputIndex++;
}
const fc = filterParts.join(';');
const mapLabel = currentLabel === '[0:v]' ? '0:v' : currentLabel.replace(/^\[|\]$/g, '');
const cmd = `ffmpeg ${inputs.join(' ')} -filter_complex "${fc}" -map "[${mapLabel}]" -an -c:v libx264 -preset veryfast -crf 23 "${outputPath}" -y`;
onLog && onLog('_renderMediaBatch: ' + cmd);
await FFMpegUtils.execute(cmd);
}
/**
* Render a single bubble's visual overlay onto a silent source video (no audio).
*/
async _renderSingleVisual(silentVideo, bubble, outputPath, onLog, metaHint) {
const meta = metaHint || await FFMpegUtils.getMediaMetadata(silentVideo);
const vw = meta.video.width;
const vh = meta.video.height;
const extra = bubble.bubbleExtra || {};
const from = typeof bubble.fromSec === 'number' ? bubble.fromSec : 0;
const to = typeof bubble.toSec === 'number' ? bubble.toSec : (from + (bubble.durationSec || 3));
const mediaField = bubble.mediaAbsPaths || bubble.mediaAbsPath || bubble.mediaAbs;
const mediaItem = mediaField ? (Array.isArray(mediaField) ? mediaField[0] : mediaField) : null;
let overlayPath = mediaItem ? (typeof mediaItem === 'string' ? mediaItem : mediaItem.path) : null;
const isMedia = !!(overlayPath && fs.existsSync(overlayPath));
let cmd;
if (isMedia) {
const imgExts = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'];
const isImageFile = imgExts.includes(path.extname(overlayPath).toLowerCase());
const padX = extra.paddingX || 5;
const padY = extra.paddingY || 5;
let ow = null, oh = null;
if (extra.size === 'half') ow = Math.round(vw * 0.45);
else if (extra.size !== 'full') ow = Math.round(vw * 0.30);
let overlayMeta;
try { overlayMeta = await FFMpegUtils.getMediaMetadata(overlayPath); } catch (e) { overlayMeta = null; }
if (extra.size === 'full') {
const maxW = Math.max(1, Math.round(vw * (1 - 2 * padX / 100)));
ow = maxW;
if (overlayMeta && overlayMeta.video && overlayMeta.video.width && overlayMeta.video.height) {
const ar = overlayMeta.video.width / overlayMeta.video.height;
oh = Math.max(1, Math.round(ow / ar));
} else {
oh = Math.max(1, Math.round(vh * (1 - 2 * padY / 100)));
}
} else if (overlayMeta && overlayMeta.video && overlayMeta.video.width && overlayMeta.video.height) {
const ar = overlayMeta.video.width / overlayMeta.video.height;
if (!ow) ow = Math.round(vw * 0.30);
if (!oh) oh = Math.round(ow / ar);
} else if (!oh) {
oh = Math.round((ow || Math.round(vw * 0.30)) * 0.75);
}
if (!ow) ow = Math.round(vw * 0.30);
if (!oh) oh = Math.round(ow * 0.75);
if (isImageFile && (bubble.backgroundColor || typeof bubble.borderRadius === 'number')) {
try { overlayPath = await processImageWithBg(overlayPath, ow, oh, bubble.backgroundColor, bubble.borderRadius || 0); } catch (_) { }
}
const { x, y } = computeXY(ow, oh, extra, vw, vh);
const anim = (mediaItem && mediaItem.animExtra) || bubble.animExtra || {};
const animTpl = anim && typeof anim.template === 'string' ? anim.template : null;
const animDur = typeof anim.durationSec === 'number' ? anim.durationSec : Math.min(0.5, (to - from) / 2);
const wrapVal = (v) => typeof v === 'number' ? `(${v})` : v;
const quote = (expr) => `'${expr.replace(/'/g, "\\'")}'`;
let xExpr = x, yExpr = y;
if (animTpl === 'slide_down') { const s = -oh; yExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(y)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(y)})`; }
else if (animTpl === 'slide_up') { const s = vh; yExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(y)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(y)})`; }
else if (animTpl === 'slide_left') { const s = -ow; xExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(x)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(x)})`; }
else if (animTpl === 'slide_right') { const s = vw; xExpr = `if(lt(t,${from + animDur}),${wrapVal(s)}+(${wrapVal(x)}-${wrapVal(s)})*(t-${from})/${animDur},${wrapVal(x)})`; }
if (typeof xExpr === 'string') xExpr = quote(xExpr);
if (typeof yExpr === 'string') yExpr = quote(yExpr);
const isFade = animTpl === 'fade' || animTpl === 'popup';
const fadeOut = Math.max(from, to - animDur);
const mainDuration = meta.duration || meta.video?.duration || 0;
const overlayFlag = isImageFile
? (mainDuration > 0 ? `-loop 1 -t ${mainDuration} -i "${overlayPath}"` : `-loop 1 -i "${overlayPath}"`)
: `-i "${overlayPath}"`;
let overlayFilter = `[1:v]scale=${ow}:${oh},format=rgba`;
if (isFade) overlayFilter += `,fade=t=in:st=${from}:d=${animDur}:alpha=1,fade=t=out:st=${fadeOut}:d=${animDur}:alpha=1`;
overlayFilter += `[ov];[0:v][ov]overlay=${xExpr}:${yExpr}:enable='between(t,${from},${to})'[vout]`;
cmd = `ffmpeg -i "${silentVideo}" ${overlayFlag} -filter_complex "${overlayFilter}" -map "[vout]" -an -c:v libx264 -preset veryfast -crf 23 "${outputPath}" -y`;
} else if (bubble.bubbleText && bubble.bubbleText.text) {
const t = bubble.bubbleText;
const fontSize = t.fontSize || 40;
const fontColor = t.fontColor || '#FFFFFF';
const posX = extra.positionX || 'center';
const posY = extra.positionY || 'center';
const padX = extra.paddingX || 5;
const padY = extra.paddingY || 5;
let fontfilePath = null;
if (t.fontName) {
try { fontfilePath = await ensureFontFile(t.fontName, t.fontWeight); } catch (e) { fontfilePath = null; }
}
let absX = Math.round(vw / 2);
let absY = Math.round(vh / 2);
if (posX === 'left') absX = Math.round(vw * (padX / 100));
else if (posX === 'right') absX = Math.round(vw - vw * (padX / 100));
if (posY === 'top') absY = Math.round(vh * (padY / 100));
else if (posY === 'bottom') absY = Math.round(vh - vh * (padY / 100));
const horizAlign = (t.textAlign || posX) || 'center';
let alignment = 5;
if (horizAlign === 'left' && posY === 'top') alignment = 7;
else if (horizAlign === 'center' && posY === 'top') alignment = 8;
else if (horizAlign === 'right' && posY === 'top') alignment = 9;
else if (horizAlign === 'left' && posY === 'center') alignment = 4;
else if (horizAlign === 'center' && posY === 'center') alignment = 5;
else if (horizAlign === 'right' && posY === 'center') alignment = 6;
else if (horizAlign === 'left' && posY === 'bottom') alignment = 1;
else if (horizAlign === 'center' && posY === 'bottom') alignment = 2;
else if (horizAlign === 'right' && posY === 'bottom') alignment = 3;
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, alignment });
let assPath = assInfo.path.replace(/\\/g, '/').replace(/:/g, '\\:');
const fontsDir = fontfilePath ? path.dirname(fontfilePath).replace(/\\/g, '/') : path.join(process.cwd(), 'public', 'assets', 'fonts').replace(/\\/g, '/');
const assEsc = assPath.replace(/'/g, "\\'");
const fontsDirEsc = fontsDir.replace(/:/g, '\\:').replace(/'/g, "\\'");
let videoFilter = `[0:v]subtitles='${assEsc}':fontsdir='${fontsDirEsc}'[vout]`;
let bgPath = null;
if (bubble.backgroundColor) {
try {
const bgInfo = await createTextBackgroundPng(t.text || '', fontSize, t.fontName || 'Arial', bubble.backgroundColor, 0, 20, 8, bubble.borderRadius || 0, t.fontColor || fontColor);
const bgLocal = bgInfo.path.replace(/\\/g, '/');
const overlayX = Math.round(absX - bgInfo.width / 2);
const overlayY = Math.round(absY - bgInfo.height / 2);
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]`;
bgPath = bgLocal;
} catch (e) { /* keep ASS-only */ }
}
if (bgPath) {
cmd = `ffmpeg -i "${silentVideo}" -i "${bgPath}" -filter_complex "${videoFilter}" -map "[vout]" -an -c:v libx264 -preset veryfast -crf 23 "${outputPath}" -y`;
} else {
cmd = `ffmpeg -i "${silentVideo}" -filter_complex "${videoFilter}" -map "[vout]" -an -c:v libx264 -preset veryfast -crf 23 "${outputPath}" -y`;
}
} else {
throw new Error('No valid bubble source (media or text) found');
}
onLog && onLog('_renderSingleVisual: ' + cmd);
await FFMpegUtils.execute(cmd);
}
}
export default new BubbleMaker();
// --- Test runner (executes when run directly) ---
export async function test() {
const bubbleMaker = new BubbleMaker();
const cwd = process.cwd();
const baseVideo = path.join(cwd, 'public', 'media.mp4');
const outDir = path.join(cwd, 'out');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
// Single sample: simple-top-center template. Only provide the text content.
const typingSample = {
templateName: 'simple-top-center',
bubbleText: { text: 'A QUICK BROWN FOX JUMPED OVER' },
fromSec: 0.5,
toSec: 5.0
};
// Additional sample: simple-top-center-media uses an image or video asset centered at top with text
const imageSample = {
templateName: 'simple-top-center-media',
// provide mediaAbsPath (what makeBubble expects) pointing to an image
mediaAbsPath: path.join(cwd, 'public', 'media2.png'),
bubbleText: { text: 'IMAGE ABOVE, TEXT CENTERED' },
fromSec: 2,
toSec: 4
};
try {
// const outPath = path.join(outDir, 'media_typing_template_bubble.mp4');
// await bubbleMaker.makeBubble(baseVideo, typingSample, outPath, console.log);
// console.log('Created:', outPath);
// const outPath2 = path.join(outDir, 'media_image_template_bubble.mp4');
// await bubbleMaker.makeBubble(baseVideo, imageSample, outPath2, console.log);
// console.log('Created:', outPath2);
// // same first sample but request normalization on the audio mix (requires modern ffmpeg)
// // here we keep the base video audio in‑place (no detach) so the filter_complex
// // path that mixes the original track is exercised. the normalizeAudio arg
// // is left undefined, so the probe should automatically enable it if
// // supported.
// const outPathNorm = path.join(outDir, 'media_typing_template_bubble_norm.mp4');
// await bubbleMaker.makeBubble(baseVideo, typingSample, outPathNorm, console.log, false);
// console.log('Created with normalization (autodetect):', outPathNorm);
// combined example: two bubbles layered one after the other
const comboSample = [
imageSample,
{
templateName: 'simple-top-center',
bubbleText: { text: 'Agar ladai hui?' },
fromSec: 5,
toSec: 6
},
{
templateName: 'simple-top-center',
bubbleText: { text: 'Ladaku Viman bade tez' },
fromSec: 7,
toSec: 8
}
];
const outPath3 = path.join(outDir, 'media_combo_bubbles.mp4');
await bubbleMaker.makeBubble(baseVideo, comboSample, outPath3, console.log);
console.log('Created combo:', outPath3);
// for debugging it's often handy to look at the intermediate audio mix. if
// you run this script manually we'll also generate an MP3 in the out
// directory so you can inspect it. environment variables BUBBLE_AUDIO_DIR can
// be used to override the default location and BUBBLE_KEEP_AUDIO will prevent
// the file from being deleted when makeBubble is used elsewhere.
const debugAudioPath = path.join(outDir, 'combo_audio.mp3');
await bubbleMaker.buildMixedAudio(baseVideo, comboSample, debugAudioPath, console.log);
console.log('Created debug audio track:', debugAudioPath);
} catch (e) {
console.error('Test failed:', e);
}
}
// test()