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()