11/**
22 * ReplayRecorder - Records canvas gameplay as video for replay and download
33 * Uses MediaRecorder API to capture canvas stream
4+ * Supports MP4 conversion and native sharing on mobile
45 */
56export class ReplayRecorder {
67 constructor ( canvas ) {
@@ -11,6 +12,26 @@ export class ReplayRecorder {
1112 this . videoURL = null ;
1213 this . isRecording = false ;
1314 this . stream = null ;
15+
16+ // MP4 conversion state
17+ this . mp4Blob = null ;
18+ this . mp4URL = null ;
19+ this . isConverting = false ;
20+ this . ffmpegLoaded = false ;
21+ }
22+
23+ /**
24+ * Check if running on mobile device
25+ */
26+ static isMobile ( ) {
27+ return / i P h o n e | i P a d | i P o d | A n d r o i d / i. test ( navigator . userAgent ) ;
28+ }
29+
30+ /**
31+ * Check if Web Share API is supported with files
32+ */
33+ static canShare ( ) {
34+ return navigator . share && navigator . canShare ;
1435 }
1536
1637 /**
@@ -165,6 +186,174 @@ export class ReplayRecorder {
165186 document . body . removeChild ( a ) ;
166187 }
167188
189+ /**
190+ * Load FFmpeg for MP4 conversion
191+ */
192+ async loadFFmpeg ( ) {
193+ if ( this . ffmpegLoaded ) return true ;
194+
195+ try {
196+ // FFmpeg is loaded via script tag, access via window
197+ if ( typeof FFmpeg === 'undefined' ) {
198+ console . error ( 'FFmpeg not loaded. Make sure the script is included.' ) ;
199+ return false ;
200+ }
201+
202+ this . ffmpeg = new FFmpeg . FFmpeg ( ) ;
203+
204+ // Set up logging
205+ this . ffmpeg . on ( 'log' , ( { message } ) => {
206+ console . log ( 'FFmpeg:' , message ) ;
207+ } ) ;
208+
209+ this . ffmpeg . on ( 'progress' , ( { progress } ) => {
210+ console . log ( 'FFmpeg progress:' , Math . round ( progress * 100 ) + '%' ) ;
211+ } ) ;
212+
213+ console . log ( 'Loading FFmpeg core...' ) ;
214+ await this . ffmpeg . load ( {
215+ coreURL : 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js' ,
216+ wasmURL : 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm'
217+ } ) ;
218+
219+ this . ffmpegLoaded = true ;
220+ console . log ( 'FFmpeg loaded successfully' ) ;
221+ return true ;
222+ } catch ( error ) {
223+ console . error ( 'Failed to load FFmpeg:' , error ) ;
224+ return false ;
225+ }
226+ }
227+
228+ /**
229+ * Convert WebM to MP4 for mobile compatibility
230+ * @returns {Promise<Blob> } MP4 video blob
231+ */
232+ async getMP4Blob ( ) {
233+ // Return cached version if available
234+ if ( this . mp4Blob ) {
235+ return this . mp4Blob ;
236+ }
237+
238+ if ( ! this . videoBlob ) {
239+ console . warn ( 'No WebM video to convert' ) ;
240+ return null ;
241+ }
242+
243+ if ( this . isConverting ) {
244+ console . warn ( 'Conversion already in progress' ) ;
245+ return null ;
246+ }
247+
248+ this . isConverting = true ;
249+
250+ try {
251+ // Load FFmpeg if not already loaded
252+ const loaded = await this . loadFFmpeg ( ) ;
253+ if ( ! loaded ) {
254+ throw new Error ( 'Failed to load FFmpeg' ) ;
255+ }
256+
257+ console . log ( 'Converting WebM to MP4...' ) ;
258+
259+ // Write WebM to virtual filesystem
260+ const webmData = new Uint8Array ( await this . videoBlob . arrayBuffer ( ) ) ;
261+ await this . ffmpeg . writeFile ( 'input.webm' , webmData ) ;
262+
263+ // Convert to MP4 with H.264 codec
264+ await this . ffmpeg . exec ( [
265+ '-i' , 'input.webm' ,
266+ '-c:v' , 'libx264' ,
267+ '-preset' , 'fast' ,
268+ '-crf' , '23' ,
269+ '-c:a' , 'aac' ,
270+ '-b:a' , '128k' ,
271+ '-movflags' , '+faststart' ,
272+ 'output.mp4'
273+ ] ) ;
274+
275+ // Read the output
276+ const mp4Data = await this . ffmpeg . readFile ( 'output.mp4' ) ;
277+ this . mp4Blob = new Blob ( [ mp4Data ] , { type : 'video/mp4' } ) ;
278+ this . mp4URL = URL . createObjectURL ( this . mp4Blob ) ;
279+
280+ // Clean up virtual filesystem
281+ await this . ffmpeg . deleteFile ( 'input.webm' ) ;
282+ await this . ffmpeg . deleteFile ( 'output.mp4' ) ;
283+
284+ console . log ( 'MP4 conversion complete:' , this . mp4Blob . size , 'bytes' ) ;
285+ return this . mp4Blob ;
286+ } catch ( error ) {
287+ console . error ( 'MP4 conversion failed:' , error ) ;
288+ return null ;
289+ } finally {
290+ this . isConverting = false ;
291+ }
292+ }
293+
294+ /**
295+ * Share video using native share API (mobile)
296+ * @param {string } filename - Name for the shared file
297+ * @returns {Promise<boolean> } Whether share was successful
298+ */
299+ async shareVideo ( filename = 'reflections-replay.mp4' ) {
300+ if ( ! ReplayRecorder . canShare ( ) ) {
301+ console . warn ( 'Web Share API not supported' ) ;
302+ return false ;
303+ }
304+
305+ try {
306+ // Convert to MP4 for mobile compatibility
307+ const mp4Blob = await this . getMP4Blob ( ) ;
308+ if ( ! mp4Blob ) {
309+ throw new Error ( 'Failed to get MP4 video' ) ;
310+ }
311+
312+ // Create file for sharing
313+ const file = new File ( [ mp4Blob ] , filename , { type : 'video/mp4' } ) ;
314+
315+ // Check if we can share this file type
316+ if ( ! navigator . canShare ( { files : [ file ] } ) ) {
317+ console . warn ( 'Cannot share MP4 files on this device' ) ;
318+ return false ;
319+ }
320+
321+ // Share with only files property for iOS compatibility
322+ await navigator . share ( { files : [ file ] } ) ;
323+
324+ console . log ( 'Video shared successfully' ) ;
325+ return true ;
326+ } catch ( error ) {
327+ if ( error . name === 'AbortError' ) {
328+ console . log ( 'Share cancelled by user' ) ;
329+ } else {
330+ console . error ( 'Share failed:' , error ) ;
331+ }
332+ return false ;
333+ }
334+ }
335+
336+ /**
337+ * Download MP4 version (for mobile fallback)
338+ * @param {string } filename - Name for the downloaded file
339+ */
340+ async downloadMP4 ( filename = 'reflections-replay.mp4' ) {
341+ const mp4Blob = await this . getMP4Blob ( ) ;
342+ if ( ! mp4Blob ) {
343+ console . warn ( 'No MP4 video to download' ) ;
344+ return ;
345+ }
346+
347+ const url = this . mp4URL ;
348+ const a = document . createElement ( 'a' ) ;
349+ a . style . display = 'none' ;
350+ a . href = url ;
351+ a . download = filename ;
352+ document . body . appendChild ( a ) ;
353+ a . click ( ) ;
354+ document . body . removeChild ( a ) ;
355+ }
356+
168357 /**
169358 * Clean up resources
170359 */
@@ -178,9 +367,17 @@ export class ReplayRecorder {
178367 URL . revokeObjectURL ( this . videoURL ) ;
179368 this . videoURL = null ;
180369 }
370+
371+ if ( this . mp4URL ) {
372+ URL . revokeObjectURL ( this . mp4URL ) ;
373+ this . mp4URL = null ;
374+ }
375+
181376 this . videoBlob = null ;
377+ this . mp4Blob = null ;
182378 this . recordedChunks = [ ] ;
183379 this . mediaRecorder = null ;
184380 this . isRecording = false ;
381+ this . isConverting = false ;
185382 }
186383}
0 commit comments