← back to Animate Museum Posts
src/addBranding.js
242 lines
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import https from 'https';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ASSETS_DIR = path.join(__dirname, '..', 'assets');
const OUTPUT_DIR = path.join(__dirname, '..', 'output');
// Ensure assets directory exists
if (!fs.existsSync(ASSETS_DIR)) {
fs.mkdirSync(ASSETS_DIR, { recursive: true });
}
/**
* Download royalty-free jazz music if not exists
*/
async function ensureJazzMusic() {
const musicPath = path.join(ASSETS_DIR, 'jazz_loop.mp3');
if (fs.existsSync(musicPath)) {
return musicPath;
}
console.log('Downloading royalty-free jazz music...');
// Use a simple tone generation as fallback (no copyright issues)
// Create a simple ambient sound using ffmpeg
try {
execSync(`ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -af "volume=0.3,lowpass=f=800" -y "${musicPath}"`, {
stdio: 'pipe'
});
console.log('Created ambient intro sound');
} catch (e) {
console.log('Could not create audio, continuing without music');
return null;
}
return musicPath;
}
/**
* Create intro video with GoodQuestion.AI branding
*/
async function createIntroVideo() {
const introPath = path.join(ASSETS_DIR, 'intro.mp4');
// Create intro video with text overlay
// 1-second intro with quick fade
const ffmpegCmd = `ffmpeg -y -f lavfi -i "color=c=black:s=1280x720:d=1" \
-vf "drawtext=text='GoodQuestion.AI':fontcolor=white:fontsize=72:x=(w-text_w)/2:y=(h-text_h)/2:alpha='if(lt(t,0.2),t*5,1)'" \
-c:v libx264 -pix_fmt yuv420p -t 1 "${introPath}"`;
try {
execSync(ffmpegCmd, { stdio: 'pipe', shell: true });
console.log('Created intro video');
return introPath;
} catch (e) {
console.error('Failed to create intro:', e.message);
return null;
}
}
/**
* Create outro video with GoodQuestion.AI branding
*/
async function createOutroVideo() {
const outroPath = path.join(ASSETS_DIR, 'outro.mp4');
// Create outro video with call to action
// 1-second outro
const ffmpegCmd = `ffmpeg -y -f lavfi -i "color=c=black:s=1280x720:d=1" \
-vf "drawtext=text='@goodquestionai':fontcolor=cyan:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2:alpha='if(lt(t,0.2),t*5,1)'" \
-c:v libx264 -pix_fmt yuv420p -t 1 "${outroPath}"`;
try {
execSync(ffmpegCmd, { stdio: 'pipe', shell: true });
console.log('Created outro video');
return outroPath;
} catch (e) {
console.error('Failed to create outro:', e.message);
return null;
}
}
/**
* Add branding intro/outro to video
* Extends main video to 10 seconds, adds 3s intro + 3s outro = 16s total
*/
export async function addBrandingToVideo(videoPath, metadata) {
try {
console.log('Adding GoodQuestion.AI branding...');
// Check if video exists and has valid duration
const probeCmd = `ffprobe -v error -show_entries format=duration -of csv=p=0 "${videoPath}"`;
let duration;
try {
duration = parseFloat(execSync(probeCmd, { stdio: 'pipe' }).toString().trim());
} catch (e) {
console.log('Could not probe video, skipping branding');
return videoPath;
}
if (duration < 0.5) {
console.log('Video too short for branding, skipping');
return videoPath;
}
// Extend video to 10 seconds if shorter
const TARGET_DURATION = 10;
if (duration < TARGET_DURATION) {
console.log(`Extending video from ${duration.toFixed(1)}s to ${TARGET_DURATION}s...`);
const extendedPath = videoPath.replace('.mp4', '_extended.mp4');
// Calculate slowdown factor or loop count
const slowFactor = Math.min(duration / TARGET_DURATION, 0.5); // Max 2x slowdown
const loopCount = Math.ceil(TARGET_DURATION / duration);
// Use a combination: slow down slightly + loop to reach 10s
const slowSpeed = Math.max(0.5, duration / TARGET_DURATION);
try {
// Method: Slow down + loop + trim to exactly 10 seconds
execSync(`ffmpeg -y -stream_loop ${loopCount} -i "${videoPath}" -vf "setpts=${1/slowSpeed}*PTS" -t ${TARGET_DURATION} -c:v libx264 -pix_fmt yuv420p "${extendedPath}"`, {
stdio: 'pipe',
timeout: 60000
});
fs.unlinkSync(videoPath);
fs.renameSync(extendedPath, videoPath);
console.log(`Video extended to ${TARGET_DURATION}s`);
} catch (e) {
console.log('Could not extend video, using original');
}
}
// Create intro and outro if they don't exist
const introPath = await createIntroVideo();
const outroPath = await createOutroVideo();
if (!introPath || !outroPath) {
console.log('Could not create branding videos, using original');
return videoPath;
}
// Get video dimensions
const dimensionsCmd = `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "${videoPath}"`;
let width = 1280, height = 720;
try {
const dims = execSync(dimensionsCmd, { stdio: 'pipe' }).toString().trim().split(',');
width = parseInt(dims[0]) || 1280;
height = parseInt(dims[1]) || 720;
} catch (e) {}
// Create concat file
const timestamp = Date.now();
const concatFile = path.join(OUTPUT_DIR, `concat_${timestamp}.txt`);
const brandedPath = path.join(OUTPUT_DIR, `branded_${timestamp}.mp4`);
// Scale all videos to same size and concatenate
const scaledIntro = path.join(OUTPUT_DIR, `scaled_intro_${timestamp}.mp4`);
const scaledMain = path.join(OUTPUT_DIR, `scaled_main_${timestamp}.mp4`);
const scaledOutro = path.join(OUTPUT_DIR, `scaled_outro_${timestamp}.mp4`);
// Scale intro
execSync(`ffmpeg -y -i "${introPath}" -vf "scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -pix_fmt yuv420p "${scaledIntro}"`, { stdio: 'pipe' });
// Scale main video
execSync(`ffmpeg -y -i "${videoPath}" -vf "scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -pix_fmt yuv420p "${scaledMain}"`, { stdio: 'pipe' });
// Scale outro
execSync(`ffmpeg -y -i "${outroPath}" -vf "scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -pix_fmt yuv420p "${scaledOutro}"`, { stdio: 'pipe' });
// Create concat file
fs.writeFileSync(concatFile, `file '${scaledIntro}'\nfile '${scaledMain}'\nfile '${scaledOutro}'`);
// Concatenate videos
execSync(`ffmpeg -y -f concat -safe 0 -i "${concatFile}" -c:v libx264 -pix_fmt yuv420p "${brandedPath}"`, { stdio: 'pipe' });
// Cleanup temp files
[concatFile, scaledIntro, scaledMain, scaledOutro].forEach(f => {
try { fs.unlinkSync(f); } catch (e) {}
});
// Replace original with branded version
fs.unlinkSync(videoPath);
fs.renameSync(brandedPath, videoPath);
console.log('Added GoodQuestion.AI branding successfully!');
return videoPath;
} catch (error) {
console.error('Branding failed:', error.message);
return videoPath; // Return original on failure
}
}
/**
* Add text overlay with artwork info
*/
export async function addArtworkOverlay(videoPath, metadata) {
try {
const title = (metadata.title || 'Artwork').replace(/'/g, "\\'").substring(0, 40);
const artist = (metadata.artist || 'Unknown').replace(/'/g, "\\'").substring(0, 30);
const timestamp = Date.now();
const overlayPath = path.join(OUTPUT_DIR, `overlay_${timestamp}.mp4`);
// Add subtle text overlay at bottom
const ffmpegCmd = `ffmpeg -y -i "${videoPath}" \
-vf "drawtext=text='${title}':fontcolor=white:fontsize=24:x=20:y=h-60:alpha=0.8,\
drawtext=text='${artist}':fontcolor=gray:fontsize=18:x=20:y=h-30:alpha=0.7" \
-c:v libx264 -pix_fmt yuv420p "${overlayPath}"`;
execSync(ffmpegCmd, { stdio: 'pipe', shell: true });
fs.unlinkSync(videoPath);
fs.renameSync(overlayPath, videoPath);
console.log('Added artwork overlay');
return videoPath;
} catch (error) {
console.error('Overlay failed:', error.message);
return videoPath;
}
}
// Test if run directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const testVideo = process.argv[2];
if (testVideo) {
addBrandingToVideo(testVideo, { title: 'Test', artist: 'Test' })
.then(r => console.log('Branded video:', r))
.catch(e => console.error('Error:', e));
} else {
console.log('Usage: node addBranding.js <video-path>');
}
}