Spaces:
Running
Running
| 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() | |