video-obs-youtube-music — OBS YouTube Music
Remux OBS MKV → MP4, add stunning intro/outro videos with WebGL shaders (FloquetTopoCA, smeared_life, SpinGlassCA), replace audio with YouTube music, and overlay animated song credits.
Quick Start
# Basic: remux with single song
ffmpeg -i "recording.mkv" -i "song.mp3" \
-map 0:v -map 1:a -c:v copy -c:a aac -b:a 192k \
-movflags +faststart "output.mp4"
Workflow
- Get video path, title, description, and song names from user
- Probe video duration:
ffprobe -v error -show_entries format=duration -of csv=p=0 "file" - Download songs:
yt-dlp -x --audio-format mp3 "ytsearch1:Song Name" - Check total music duration covers video
- Optional: Generate intro/outro videos with WebGL shaders (see below)
- Optional: Generate animated song credit cards (see below)
- Optional: Use remote machines for parallel rendering (see below)
- Concatenate intro + recording + outro, add audio and overlays
- Audio should start at intro (t=0), not when screen recording starts
- Encode platform-specific versions (see Platform Export section)
Platform Export Requirements
YouTube (Maximum Quality)
- Resolution: Up to 8K
- Frame rate: Up to 60fps
- Codec: H.264 or HEVC
- No special requirements beyond
-movflags +faststart
X/Twitter (CRITICAL - Different Limits!)
| Spec | Limit |
|---|---|
| Resolution | Max 1920x1200 (NOT 4K!) |
| Frame rate | Up to 60fps |
| Bitrate | Max 25 Mbps |
| Duration | 2:20 regular, 4 hours Premium |
| File size | 512MB regular, 16GB Premium |
| Codec | H.264 + AAC only |
X-Compatible Encoding Command:
ffmpeg -i source_4k.mp4 \
-vf "scale=1920:1080:flags=lanczos" \
-c:v libx264 -preset medium -crf 18 \
-profile:v high -level:v 4.2 -pix_fmt yuv420p \
-b:v 20M -maxrate 25M -bufsize 25M \
-c:a aac -b:a 192k \
-movflags +faststart \
output_for_x.mp4
QuickTime Compatibility
- HEVC may not play - always provide H.264 version
- Use standard resolutions (3840x2160, 1920x1080)
- Avoid non-standard aspect ratios like 4096x2304
Intro Video
Generate stunning 5-second intro videos with:
- WebGL Background: Animated cellular automata (cosmic, plasma, aurora, neon, nebula effects)
- 3D Particle Effects: Three.js floating particles with additive blending
- Animated Text: Title, description, author info with smooth fade transitions
- Professional Layout: Date in corner, social handles at bottom
Generate Intro
# Basic intro
./scripts/generate-intro.py \
--title "Building a CLI Tool in Rust" \
--output intro.mp4
# Full customization
./scripts/generate-intro.py \
--title "React Performance Deep Dive" \
--description "Optimizing renders and reducing bundle size" \
--effect plasma \
--duration 5 \
--width 1920 --height 1080 \
--output intro.mp4
Effect Types
| Effect | Description |
|---|---|
cosmic |
Default - deep purples and blues, organic flow |
plasma |
Warm oranges and reds, energetic movement |
aurora |
Greens and cyans, smooth northern lights feel |
neon |
High contrast pinks and cyans, electric vibe |
nebula |
Rich colors with energy glow, space-like |
Options
| Option | Default | Description |
|---|---|---|
--title |
required | Video title (large, centered) |
--description |
"" | Subtitle text |
--name |
Jeffrey Emanuel | Author name |
--twitter |
@doodlestein | X/Twitter handle |
--github |
Dicklesworthstone | GitHub username |
--date |
today | Date displayed |
--effect |
cosmic | Background effect type |
--random-effect |
false | Choose random effect |
--duration |
5 | Duration in seconds |
--width |
1920 | Video width |
--height |
1080 | Video height |
--fps |
60 | Frames per second |
Prepend Intro to Recording
# Concatenate intro + recording (same resolution/codec)
ffmpeg -i intro.mp4 -i recording.mp4 \
-filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v]" \
-map "[v]" \
-c:v libx264 -preset fast -crf 18 \
"with_intro.mp4"
# With audio from songs
ffmpeg -i intro.mp4 -i recording.mp4 -i song.mp3 \
-filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v]" \
-map "[v]" -map 2:a \
-c:v libx264 -preset fast -crf 18 -c:a aac -b:a 192k \
-shortest \
"final.mp4"
Song Credit Cards
Animated "now playing" cards with polished visual effects:
- Scale animation: Cards grow from 85% with overshoot bounce on entry/exit
- Floating motion: Subtle up/down floating while displayed
- Pulsing music icon: Rhythmic scale animation on the music note
- Shimmer highlight: Animated gradient sweep across top edge
- Glow pulse: Subtle ambient glow that pulses
- Custom branding: "Doodlestein's Choice" text with Pacifico font
Card Dimensions
| Use Case | Width | Height | Notes |
|---|---|---|---|
| Standard | 2280px | 480px | Full-width overlay |
| Narrow (transparent) | 1900px | 480px | Avoids white rectangle artifacts |
| 4K compatible | 1900px | 480px | Scales well at any resolution |
Important: Use narrower cards (1900px) to avoid visible white rectangles on sides.
Generate Cards with Playwright
// render_cards.js - Render card animation to PNG frames
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
async function renderCard(cardNum) {
const cardPath = "./cards/0" + cardNum + "_card.html";
const outDir = "./cards/frames_0" + cardNum;
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setViewportSize({ width: 1900, height: 480 });
await page.goto("file://" + path.resolve(cardPath));
await page.waitForTimeout(500);
const FPS = 60, DURATION = 6, TOTAL = FPS * DURATION;
for (let f = 0; f < TOTAL; f++) {
const framePath = path.join(outDir, "frame_" + String(f).padStart(4, "0") + ".png");
await page.screenshot({ path: framePath, omitBackground: true }); // Alpha!
await page.evaluate(() => window.stepFrame());
}
await browser.close();
}
renderCard(process.argv[2] || "1");
Encode Cards with Alpha (WebM VP9)
# Encode PNG frames to WebM with transparency
ffmpeg -framerate 60 -i "frames/frame_%04d.png" \
-c:v libvpx-vp9 -pix_fmt yuva420p -b:v 2M \
card.webm
Card Overlay FFmpeg
Position cards in upper-right corner (not bottom-right) for better visibility:
# Single song with animated card (6s video, card at t=10s after intro)
ffmpeg -i "video.mp4" -i "song.mp3" -i "card.webm" \
-filter_complex \
"[0:v][2:v]overlay=x=W-w-50:y=50:enable='between(t,10,16)'[v]" \
-map "[v]" -map 1:a -c:v libx264 -preset fast -crf 18 -c:a aac -b:a 192k \
-movflags +faststart "output.mp4"
# With intro + multiple songs (audio starts at intro)
ffmpeg -i intro.mp4 -i recording.mp4 -i outro.mp4 -i songs.m4a \
-i card1.webm -i card2.webm \
-filter_complex "
[0:v][1:v][2:v]concat=n=3:v=1:a=0[base];
[4:v]setpts=PTS+10/TB[c1];
[5:v]setpts=PTS+205/TB[c2];
[base][c1]overlay=x=W-w-50:y=50:enable='between(t,10,16)'[tmp1];
[tmp1][c2]overlay=x=W-w-50:y=50:enable='between(t,205,211)'[vout]
" \
-map "[vout]" -map 3:a \
-c:v libx264 -preset medium -crf 18 -c:a aac -b:a 192k \
final.mp4
Card Timing Formula
| Song | Card Appears | Card Duration | Animation |
|---|---|---|---|
| 1 | 0s | 6s | Baked into video |
| 2 | song1_duration | 6s | Baked into video |
| 3 | song1 + song2 duration | 6s | Baked into video |
Overlay pattern: enable='between(t,{start},{start+6})':eof_action=pass
Multi-Song (No Cards)
# Concat audio, copy video
ffmpeg -i "video.mkv" -i "s1.mp3" -i "s2.mp3" \
-filter_complex "[1:a][2:a]concat=n=2:v=0:a=1[music]" \
-map 0:v -map "[music]" \
-c:v copy -c:a aac -b:a 192k \
-movflags +faststart "output.mp4"
Core Commands
| Task | Command |
|---|---|
| Duration | ffprobe -v error -show_entries format=duration -of csv=p=0 "file" |
| Download song | yt-dlp -x --audio-format mp3 "ytsearch1:Artist Song" |
| Get metadata | yt-dlp --print-json --no-download "ytsearch1:query" |
| Download thumbnail | yt-dlp --write-thumbnail --convert-thumbnails jpg "URL" |
Dependencies
ffmpeg+ffprobeyt-dlpnode+playwrightnpm package (for intro and card video recording)
Install dependencies:
cd /path/to/video-obs-youtube-music
npm install playwright
npx playwright install chromium
Check: ./scripts/check-deps.sh
Remote Machine Parallel Rendering
For complex projects with long intro/outro sequences, distribute rendering across multiple machines.
Available Machines
| Host | CPU | GPU | Best For |
|---|---|---|---|
trj |
High core | Dual 4090 | NVENC encoding |
jain |
High core | None | CPU encoding, fast |
csd |
Medium | None | Frame rendering |
css |
Medium | None | Frame rendering |
fmd |
Medium | None | Frame rendering |
Setup Remote Machines
# Each machine needs: node, playwright, chromium
ssh machine "npm install playwright && npx playwright install chromium"
# Copy HTML files to remote
scp intro.html machine:/tmp/
scp render_script.js machine:/tmp/
Robust Render Script (with timeouts)
// render-frames.js - Distributed rendering with retries
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
const START_FRAME = parseInt(process.argv[2]) || 0;
const END_FRAME = parseInt(process.argv[3]) || 100;
const HTML_FILE = process.argv[4] || '/tmp/intro.html';
const OUTPUT_DIR = process.argv[5] || '/tmp/frames';
const WIDTH = parseInt(process.argv[6]) || 1920;
const HEIGHT = parseInt(process.argv[7]) || 1080;
async function render() {
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
timeout: 120000 // 2 minute browser launch timeout
});
const page = await browser.newPage();
page.setDefaultTimeout(60000); // 60s for page operations
await page.setViewportSize({ width: WIDTH, height: HEIGHT });
await page.goto('file://' + path.resolve(HTML_FILE), { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
// Jump to start frame
for (let i = 0; i < START_FRAME; i++) {
await page.evaluate(() => window.stepFrame && window.stepFrame());
}
console.log(`Rendering frames ${START_FRAME} to ${END_FRAME}...`);
const startTime = Date.now();
for (let f = START_FRAME; f < END_FRAME; f++) {
const framePath = path.join(OUTPUT_DIR, `frame_${String(f).padStart(5, '0')}.png`);
// Retry logic for flaky screenshots
for (let retry = 0; retry < 3; retry++) {
try {
await page.screenshot({ path: framePath });
break;
} catch (e) {
if (retry === 2) throw e;
await page.waitForTimeout(500);
}
}
await page.evaluate(() => window.stepFrame && window.stepFrame());
if ((f - START_FRAME) % 100 === 0) {
const elapsed = (Date.now() - startTime) / 1000;
const fps = (f - START_FRAME) / elapsed;
console.log(`Frame ${f}/${END_FRAME} (${fps.toFixed(1)} fps)`);
}
}
await browser.close();
console.log(`Done! Rendered ${END_FRAME - START_FRAME} frames.`);
}
render().catch(e => { console.error(e); process.exit(1); });
Parallel Frame Rendering
Split frame ranges across machines and render in parallel:
# Calculate total frames: duration_seconds * fps
TOTAL_FRAMES=5430 # 90.5 seconds at 60fps
# Distribute across 5 machines
ssh css "node /tmp/render.js 0 1086 /tmp/intro.html /tmp/frames" &
ssh csd "node /tmp/render.js 1086 2172 /tmp/intro.html /tmp/frames" &
ssh jain "node /tmp/render.js 2172 3258 /tmp/intro.html /tmp/frames" &
ssh fmd "node /tmp/render.js 3258 4344 /tmp/intro.html /tmp/frames" &
ssh trj "node /tmp/render.js 4344 5430 /tmp/intro.html /tmp/frames" &
wait # Wait for all to complete
Reallocating Slow Machines
If a machine is too slow, stop it and redistribute:
# Check progress
ssh slow_machine 'ls /tmp/frames/*.png | wc -l'
# Stop slow machine at frame N, redistribute to faster machines
ssh slow_machine 'pkill -f node'
# Give remaining frames to faster machines
ssh fast1 "node /tmp/render.js $STOPPED_AT $MIDPOINT /tmp/intro.html /tmp/frames" &
ssh fast2 "node /tmp/render.js $MIDPOINT $END /tmp/intro.html /tmp/frames" &
Collect and Assemble Frames
# Download frames from each machine
rsync -avz css:/tmp/frames/ ./frames/
rsync -avz csd:/tmp/frames/ ./frames/
rsync -avz jain:/tmp/frames/ ./frames/
rsync -avz fmd:/tmp/frames/ ./frames/
rsync -avz trj:/tmp/frames/ ./frames/
# Verify frame count
ls frames/*.png | wc -l # Should match TOTAL_FRAMES
# Assemble into video
ffmpeg -framerate 60 -i "frames/frame_%05d.png" \
-c:v libx264 -pix_fmt yuv420p -crf 18 \
output.mp4
GPU-Accelerated Encoding on Remote
For machines with NVIDIA GPUs (e.g., dual 4090s), use NVENC:
# HEVC for 4K - fast encoding
ssh trj 'ffmpeg -i input.mp4 -c:v hevc_nvenc -preset p4 -b:v 20M output.mp4'
# H.264 for max compatibility
ssh trj 'ffmpeg -i input.mp4 \
-c:v h264_nvenc -preset p4 -profile:v high -b:v 20M output.mp4'
Frame Rate Matching
Critical: When concatenating videos, all inputs must have the same frame rate:
# Convert 30fps source to 60fps before concat
ffmpeg -i source_30fps.mkv -r 60 -c:v libx264 -crf 18 source_60fps.mp4
# Now concat with 60fps intro/outro
ffmpeg -i intro_60fps.mp4 -i source_60fps.mp4 -i outro_60fps.mp4 \
-filter_complex "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v]" \
-map "[v]" output.mp4
4K Video Production
Resolution Considerations
| Resolution | H.264 Support | HEVC Support | QuickTime | X/Twitter |
|---|---|---|---|---|
| 1920x1080 | Yes | Yes | Yes | Yes |
| 3840x2160 | Yes (level 5.2) | Yes | Yes | NO |
| 4096x2304 | No (exceeds level) | Yes | Maybe | NO |
Recommended 4K Workflow
# 1. Render intro/outro at desired resolution and 60fps
# 2. Convert source to 60fps (matching)
ffmpeg -i source.mkv -r 60 -c:v libx264 -crf 18 source_60fps.mp4
# 3. Assemble master at full resolution
ffmpeg -i intro.mp4 -i source_60fps.mp4 -i outro.mp4 -i audio.m4a \
-filter_complex "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v]" \
-map "[v]" -map 3:a \
-c:v libx264 -crf 18 -c:a aac -b:a 192k \
master.mp4
# 4. Export platform-specific versions
# YouTube (keep 4K):
cp master.mp4 youtube_4k.mp4
# X/Twitter (MUST downscale to 1080p):
ffmpeg -i master.mp4 \
-vf "scale=1920:1080:flags=lanczos" \
-c:v libx264 -preset medium -crf 18 \
-profile:v high -level:v 4.2 -pix_fmt yuv420p \
-c:a aac -b:a 192k \
-movflags +faststart \
x_twitter_1080p60.mp4
Gotchas
Encoding
ytsearch1:returns first result — include artist name for accuracy- Cards/intros require video re-encode (
-c:v libx264), basic remux can use-c:v copy -movflags +faststartrequired for YouTube/X streaming- Frame rate mismatch: Concat of different fps causes bitrate/quality issues - convert first!
- H.264 level limits: 4096x2304 exceeds level 5.2, use HEVC or scale to 3840x2160
Platform Compatibility
- X/Twitter rejects 4K - Always encode 1080p version for X, even if video plays in preview
- QuickTime HEVC: May not play - provide H.264 version for compatibility
- X Premium allows 4 hour videos, but still max 1920x1200 resolution
Card Overlays
- Card overlay position:
x=W-w-50:y=50= upper-right corner (50px margins) - Card videos are 6 seconds with animation baked in (no ffmpeg fade needed)
- WebM format supports transparency for clean overlay compositing
- Card transparency: Use narrower cards (1900px) to avoid white rectangle artifacts
Audio
- Audio timing: Start audio at intro (t=0), cards appear when screen recording starts
Remote Rendering
- Remote machines may need SwiftShader for software WebGL rendering
- Always use timeout and retry logic in render scripts
- Monitor render speed - reallocate slow machines' work to faster ones
- Check disk space on remote machines before large frame transfers