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