Skip to content

Commit 57a1aaa

Browse files
feat: generate composite demo video for homepage
1 parent ba46383 commit 57a1aaa

File tree

3 files changed

+253
-10
lines changed

3 files changed

+253
-10
lines changed

package-lock.json

Lines changed: 30 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"postbuild": "pagefind --site .next/server/app --output-path out/_pagefind && next-sitemap",
99
"export": "next build",
1010
"start": "next start",
11-
"lint": "next lint"
11+
"lint": "next lint",
12+
"composite-video": "npx tsx scripts/generate-realtime-sync-demo-video.ts"
1213
},
1314
"dependencies": {
1415
"@tailwindcss/postcss": "^4.1.11",
@@ -20,10 +21,12 @@
2021
"react-dom": "19.1.1"
2122
},
2223
"devDependencies": {
23-
"@types/node": "^20.0.0",
24+
"@types/commander": "^2.12.0",
25+
"@types/node": "^20.19.11",
2426
"@types/react": "19.1.9",
2527
"@types/react-dom": "19.1.7",
2628
"autoprefixer": "^10.4.16",
29+
"commander": "^14.0.0",
2730
"eslint": "^8.0.0",
2831
"eslint-config-next": "15.4.5",
2932
"ignore-loader": "^0.1.2",
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
#!/usr/bin/env npx tsx
2+
3+
import { Command } from 'commander';
4+
import { execSync } from 'child_process';
5+
import * as fs from 'fs';
6+
7+
interface VideoInfo {
8+
width: number;
9+
height: number;
10+
duration: number;
11+
path: string;
12+
}
13+
14+
function getVideoInfo(videoPath: string): VideoInfo {
15+
if (!fs.existsSync(videoPath)) {
16+
throw new Error(`Video file not found: ${videoPath}`);
17+
}
18+
19+
try {
20+
const ffprobeCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
21+
const output = execSync(ffprobeCmd, { encoding: 'utf8' });
22+
const data = JSON.parse(output);
23+
24+
const videoStream = data.streams.find((s: any) => s.codec_type === 'video');
25+
if (!videoStream) {
26+
throw new Error(`No video stream found in ${videoPath}`);
27+
}
28+
29+
return {
30+
width: videoStream.width,
31+
height: videoStream.height,
32+
duration: parseFloat(data.format.duration),
33+
path: videoPath
34+
};
35+
} catch (error) {
36+
if ((error as any).code === 'ENOENT') {
37+
throw new Error('ffprobe not found. Please install ffmpeg.');
38+
}
39+
throw error;
40+
}
41+
}
42+
43+
function validateVideos(terminal: VideoInfo, simulator: VideoInfo): void {
44+
// Check duration match (within 1 second tolerance)
45+
const durationDiff = Math.abs(terminal.duration - simulator.duration);
46+
if (durationDiff > 1) {
47+
console.warn(`⚠️ Warning: Video durations differ by ${durationDiff.toFixed(2)} seconds`);
48+
console.warn(` Terminal: ${terminal.duration.toFixed(2)}s`);
49+
console.warn(` Simulator: ${simulator.duration.toFixed(2)}s`);
50+
}
51+
52+
// Simulator should be roughly portrait (taller than wide)
53+
const simulatorAspect = simulator.width / simulator.height;
54+
if (simulatorAspect > 0.7) {
55+
console.warn(`⚠️ Warning: Simulator video doesn't appear to be portrait orientation (${simulatorAspect.toFixed(2)})`);
56+
}
57+
58+
console.log('✅ Video validation complete');
59+
console.log(` Terminal: ${terminal.width}x${terminal.height} (${terminal.duration.toFixed(2)}s)`);
60+
console.log(` Simulator: ${simulator.width}x${simulator.height} (${simulator.duration.toFixed(2)}s)`);
61+
}
62+
63+
function compositeVideos(terminal: VideoInfo, simulator: VideoInfo, outputPath: string): void {
64+
// Container dimensions: 600px wide x 670px tall
65+
// Terminal: 75% of container height (502.5px), positioned top-left
66+
// Flexible aspect ratio: 450-550px width, 450-550px height range
67+
68+
const containerWidth = 600;
69+
const containerHeight = 670;
70+
71+
// Calculate terminal dimensions with flexible aspect ratio
72+
let terminalWidth: number;
73+
let terminalHeight: number;
74+
75+
// Start with 75% of container height
76+
const targetHeight = containerHeight * 0.75; // 502.5px
77+
78+
// Calculate what width would be at this height
79+
const terminalAspect = terminal.width / terminal.height;
80+
const widthAtTargetHeight = targetHeight * terminalAspect;
81+
82+
// Check if dimensions fall within acceptable range (450-550px for both)
83+
if (widthAtTargetHeight > 600) {
84+
throw new Error(`Terminal video would be ${widthAtTargetHeight.toFixed(0)}px wide at 75% height (${targetHeight.toFixed(0)}px), exceeding 600px container width`);
85+
}
86+
87+
if (widthAtTargetHeight > 550) {
88+
// Video is too wide, constrain by width
89+
terminalWidth = 550;
90+
terminalHeight = terminalWidth / terminalAspect;
91+
console.log(`📏 Terminal video constrained by width (550px)`);
92+
} else if (targetHeight > 550) {
93+
// Video would be too tall, constrain by height
94+
terminalHeight = 550;
95+
terminalWidth = terminalHeight * terminalAspect;
96+
console.log(`📏 Terminal video constrained by height (550px)`);
97+
} else if (widthAtTargetHeight < 450) {
98+
// Video is too narrow, set minimum width
99+
terminalWidth = 450;
100+
terminalHeight = terminalWidth / terminalAspect;
101+
console.log(`📏 Terminal video set to minimum width (450px)`);
102+
} else if (targetHeight < 450) {
103+
// Video would be too short, set minimum height
104+
terminalHeight = 450;
105+
terminalWidth = terminalHeight * terminalAspect;
106+
console.log(`📏 Terminal video set to minimum height (450px)`);
107+
} else {
108+
// Dimensions are within acceptable range
109+
terminalHeight = targetHeight;
110+
terminalWidth = widthAtTargetHeight;
111+
}
112+
113+
// Round to integers
114+
terminalWidth = Math.round(terminalWidth);
115+
terminalHeight = Math.round(terminalHeight);
116+
117+
const terminalX = 0;
118+
const terminalY = 0;
119+
120+
// Simulator video dimensions and position
121+
const simWidth = Math.round(containerWidth * 0.5); // 50% of container width
122+
// Calculate sim height to maintain its aspect ratio
123+
const simHeight = Math.round(simWidth * (simulator.height / simulator.width));
124+
const simX = containerWidth - simWidth;
125+
const simY = containerHeight - simHeight;
126+
127+
console.log('\n📐 Calculated layout:');
128+
console.log(` Container: ${containerWidth}x${containerHeight}`);
129+
console.log(` Terminal: ${terminalWidth}x${terminalHeight} at (${terminalX}, ${terminalY})`);
130+
console.log(` Simulator: ${simWidth}x${simHeight} at (${simX}, ${simY})`);
131+
132+
// Build ffmpeg command with complex filter
133+
// Determine codec based on output format
134+
const outputExt = outputPath.toLowerCase();
135+
const isMP4 = outputExt.endsWith('.mp4');
136+
const isWebM = outputExt.endsWith('.webm');
137+
const isMOV = outputExt.endsWith('.mov');
138+
139+
let codecArgs: string[];
140+
141+
if (isMP4) {
142+
codecArgs = ['-c:v', 'libx264', '-crf', '18', '-preset', 'slow', '-pix_fmt', 'yuv420p'];
143+
console.warn('⚠️ Warning: MP4 format does not support transparency. Use .mov or .webm for transparent background.');
144+
} else if (isWebM) {
145+
codecArgs = ['-c:v', 'libvpx-vp9', '-crf', '18', '-b:v', '0', '-pix_fmt', 'yuva420p'];
146+
console.log('✨ Using VP9 codec with alpha channel support for WebM');
147+
} else if (isMOV) {
148+
codecArgs = ['-c:v', 'prores_ks', '-profile:v', '4444', '-pix_fmt', 'yuva444p10le'];
149+
console.log('✨ Using ProRes 4444 codec with alpha channel support for MOV');
150+
} else {
151+
throw new Error('Unknown output format');
152+
}
153+
154+
const ffmpegCmd = [
155+
'ffmpeg',
156+
'-i', `"${terminal.path}"`,
157+
'-i', `"${simulator.path}"`,
158+
'-filter_complex',
159+
`"[0:v]scale=${terminalWidth}:${terminalHeight}[terminal];` +
160+
`[1:v]scale=${simWidth}:${simHeight}[sim];` +
161+
`nullsrc=s=${containerWidth}x${containerHeight}:d=${Math.max(terminal.duration, simulator.duration)}:r=30,format=yuva444p[bg];` +
162+
`[bg][terminal]overlay=${terminalX}:${terminalY}[comp1];` +
163+
`[comp1][sim]overlay=${simX}:${simY}[out]"`,
164+
'-map', '"[out]"',
165+
...codecArgs,
166+
'-y',
167+
`"${outputPath}"`
168+
].join(' ');
169+
170+
console.log('\n🎬 Starting video composition...');
171+
172+
try {
173+
execSync(ffmpegCmd, {
174+
stdio: 'inherit'
175+
});
176+
console.log(`\n✅ Video successfully created: ${outputPath}`);
177+
} catch (error) {
178+
throw new Error('Failed to composite videos with ffmpeg');
179+
}
180+
}
181+
182+
// Main program
183+
const program = new Command();
184+
185+
program
186+
.name('generate-realtime-sync-demo-video')
187+
.description('Composite terminal and simulator videos with transparent background')
188+
.version('1.0.0')
189+
.requiredOption('--terminal-video <path>', 'Path to terminal video file')
190+
.requiredOption('--simulator-video <path>', 'Path to simulator/phone video file')
191+
.requiredOption('-o, --output <path>', 'Output video path (.mov for ProRes, .webm for VP9, .mp4 for H.264)', 'composite-demo.mov')
192+
.action((options) => {
193+
try {
194+
console.log('🎥 Video Compositor for Realtime Sync Demo\n');
195+
196+
console.log('📋 Supported output formats:');
197+
console.log(' • .mov → ProRes 4444 (best quality, transparency support)');
198+
console.log(' • .webm → VP9 (web-friendly, transparency support)');
199+
console.log(' • .mp4 → H.264 (universal compatibility, no transparency)\n');
200+
201+
// Get video information
202+
console.log('📊 Analyzing input videos...');
203+
const terminalInfo = getVideoInfo(options.terminalVideo);
204+
const simulatorInfo = getVideoInfo(options.simulatorVideo);
205+
206+
// Validate videos
207+
validateVideos(terminalInfo, simulatorInfo);
208+
209+
// Composite videos
210+
compositeVideos(terminalInfo, simulatorInfo, options.output);
211+
212+
} catch (error) {
213+
console.error('\n❌ Error:', (error as Error).message);
214+
process.exit(1);
215+
}
216+
});
217+
218+
program.parse();

0 commit comments

Comments
 (0)