1/** 2 * VideoTranscoder - Browser-based video transcoding using WebCodecs + mp4-muxer 3 * 4 * Transcodes any video to a unified H.264 720p MP4 format at 30fps. 5 * Handles different input formats, resolutions, and codecs. 6 * 7 * Requirements: 8 * - Chrome 94+ or Edge 94+ (WebCodecs API) 9 * - mp4-muxer library for MP4 container creation 10 * 11 * @module utils/VideoTranscoder 12 */ 13 14// Import mp4-muxer from local lib (downloaded from npm) 15import { Muxer, ArrayBufferTarget } from '../lib/mp4-muxer.mjs'; 16 17/** 18 * Check if WebCodecs is supported in this browser 19 * @returns {boolean} 20 */ 21export function isWebCodecsSupported() { 22 return typeof VideoEncoder !== 'undefined' && 23 typeof VideoDecoder !== 'undefined' && 24 typeof VideoFrame !== 'undefined' && 25 typeof AudioEncoder !== 'undefined'; 26} 27 28/** 29 * VideoTranscoder class - transcodes and concatenates videos to MP4 30 */ 31export class VideoTranscoder { 32 /** 33 * @param {Object} options - Transcoding options 34 * @param {number} options.width - Output width (default: 1280) 35 * @param {number} options.height - Output height (default: 720) 36 * @param {number} options.fps - Output frame rate (default: 24) 37 * @param {number} options.videoBitrate - Video bitrate in bps (default: 6 Mbps) 38 * @param {number} options.audioBitrate - Audio bitrate in bps (default: 192 kbps) 39 */ 40 constructor(options = {}) { 41 this.width = options.width || 1280; 42 this.height = options.height || 720; 43 this.fps = options.fps || 30; // 30fps smooth frame rate 44 this.videoBitrate = options.videoBitrate || 8_000_000; 45 this.audioBitrate = options.audioBitrate || 192_000; 46 this.sampleRate = 48000; 47 this.channels = 2; 48 49 // Keyframe interval: every 15 frames (0.5s) for smooth scene transitions 50 this.keyframeInterval = options.keyframeInterval || 15; 51 52 // Frame timing 53 this.frameDuration = 1_000_000 / this.fps; // microseconds per frame 54 55 // State 56 this.aborted = false; 57 } 58 59 /** 60 * Transcode and concatenate multiple videos into a single MP4 61 * @param {string[]} videoUrls - Array of video URLs to concatenate 62 * @param {Blob|null} audioBlob - Pre-mixed audio blob (AAC) or null 63 * @param {Function} onProgress - Progress callback 64 * @param {Array<{trimStart?: number, trimEnd?: number}>} [trimData] - Optional trim data for each video 65 * @returns {Promise<Blob>} - MP4 blob 66 */ 67 async transcodeAndConcat(videoUrls, audioBlob, onProgress, trimData = null) { 68 this.onProgress = onProgress; 69 if (!isWebCodecsSupported()) { 70 throw new Error('WebCodecs not supported. Please use Chrome or Edge browser.'); 71 } 72 73 console.log('[VideoTranscoder] Starting transcode:', { 74 videoCount: videoUrls.length, 75 hasAudio: !!audioBlob, 76 output: `${this.width}x${this.height} @ ${this.fps}fps` 77 }); 78 79 // Create MP4 muxer 80 const muxer = new Muxer({ 81 target: new ArrayBufferTarget(), 82 video: { 83 codec: 'avc', 84 width: this.width, 85 height: this.height 86 }, 87 audio: audioBlob ? { 88 codec: 'aac', 89 numberOfChannels: this.channels, 90 sampleRate: this.sampleRate 91 } : undefined, 92 fastStart: 'in-memory' 93 }); 94 95 // Create video encoder 96 const videoEncoder = new VideoEncoder({ 97 output: (chunk, meta) => { 98 muxer.addVideoChunk(chunk, meta); 99 }, 100 error: (err) => { 101 console.error('[VideoTranscoder] VideoEncoder error:', err); 102 } 103 }); 104 105 await videoEncoder.configure({ 106 codec: 'avc1.640028', // H.264 High Profile Level 4.0 107 width: this.width, 108 height: this.height, 109 bitrate: this.videoBitrate, 110 framerate: this.fps, 111 latencyMode: 'quality', 112 avc: { format: 'annexb' } 113 }); 114 115 // Process each video 116 let totalFrames = 0; 117 let processedVideos = 0; 118 119 for (let i = 0; i < videoUrls.length; i++) { 120 if (this.aborted) { 121 videoEncoder.close(); 122 throw new Error('Transcode aborted'); 123 } 124 125 const progress = 10 + (i / videoUrls.length) * 70; 126 onProgress?.({ 127 phase: 'transcoding', 128 progress, 129 message: `Processing scene ${i + 1}/${videoUrls.length}...` 130 }); 131
132 try { 133 const trim = trimData?.[i] || {}; 134 const trimStart = trim.trimStart || 0; 135 const trimEnd = trim.trimEnd || null; 136 137 totalFrames = await this.processVideo(videoUrls[i], videoEncoder, totalFrames, trimStart, trimEnd); 138 processedVideos++; 139 140 // Flush encoder between scenes to ensure clean scene boundaries 141 // This forces all pending frames to be written before the next scene starts 142 await videoEncoder.flush(); 143 144 console.log(`[VideoTranscoder] Scene ${i + 1} complete, total frames: ${totalFrames}`); 145 } catch (err) { 146 console.error(`[VideoTranscoder] Failed to process scene ${i + 1}:`, err); 147 throw new Error(`Failed to process scene ${i + 1}: ${err.message}`); 148 } 149 } 150 151 // Flush video encoder 152 await videoEncoder.flush(); 153 videoEncoder.close(); 154 155 console.log(`[VideoTranscoder] Video encoding complete: ${totalFrames} frames, ${processedVideos} scenes`); 156 157 // Process audio if present 158 if (audioBlob) { 159 onProgress?.({ phase: 'audio', progress: 85, message: 'Adding audio track...' }); 160 try { 161 await this.processAudio(audioBlob, muxer); 162 console.log('[VideoTranscoder] Audio track added'); 163 } catch (err) { 164 console.error('[VideoTranscoder] Audio processing failed:', err); 165 } 166 } 167 168 // Finalize 169 onProgress?.({ phase: 'finalizing', progress: 95, message: 'Finalizing MP4...' }); 170 muxer.finalize(); 171 const { buffer } = muxer.target; 172 173 console.log(`[VideoTranscoder] MP4 complete: ${(buffer.byteLength / 1024 / 1024).toFixed(2)} MB`); 174 return new Blob([buffer], { type: 'video/mp4' }); 175 } 176 177 /** 178 * Process a single video â load, seek frame-by-frame, and encode at target FPS. 179 * Uses raw trim values for frame count calculation (matches timeline behavior). 180 * Seek positions are clamped to [0, video.duration] so the browser shows the 181 * nearest valid frame when trims exceed actual video length. 182 */ 183 async processVideo(url, encoder, startFrame, trimStart = 0, trimEnd = null) { 184 const response = await fetch(url); 185 if (!response.ok) throw new Error(`Failed to fetch video: ${response.status}`); 186 187 const blob = await response.blob(); 188 const video = document.createElement('video'); 189 video.src = URL.createObjectURL(blob); 190 video.muted = true; 191 video.playsInline = true; 192 video.crossOrigin = 'anonymous'; 193 video.preload = 'auto'; 194 195 // Wait for metadata 196 await new Promise((resolve, reject) => { 197 video.onloadedmetadata = resolve; 198 video.onerror = () => reject(new Error('Failed to load video metadata')); 199 setTimeout(() => reject(new Error('Video metadata timeout')), 30000); 200 }); 201 202 // Wait for full buffer 203 await new Promise((resolve) => { 204 if (video.readyState >= 4) resolve(); 205 else { video.oncanplaythrough = resolve; video.load(); } 206 }); 207 208 // Scaling (letterbox) 209 const scale = Math.min(this.width / video.videoWidth, this.height / video.videoHeight); 210 const scaledW = Math.round(video.videoWidth * scale); 211 const scaledH = Math.round(video.videoHeight * scale); 212 const offsetX = Math.round((this.width - scaledW) / 2); 213 const offsetY = Math.round((this.height - scaledH) / 2); 214 215 // TRIM: Use RAW trim values for duration/frame count (matches timeline). 216 // Do NOT clamp to video.duration â this handles split scenes where 217 // trimStart > video.duration (browser clamps seek to end, shows last frame). 218 const fullDuration = video.duration; 219 const effectiveTrimEnd = trimEnd != null ? trimEnd : fullDuration; 220 const effectiveTrimStart = Math.max(0, trimStart); 221 const effectiveDuration = Math.max(0, effectiveTrimEnd - effectiveTrimStart); 222 const totalFrames = Math.max(0, Math.ceil(effectiveDuration * this.fps)); 223 224 console.log(`[VideoTranscoder] Video: ${video.videoWidth}x${video.videoHeight} @ ${fullDuration.toFixed(2)}s -> trimmed ${effectiveTrimStart.toFixed(2)}s-${effectiveTrimEnd.toFixed(2)}s (${effectiveDuration.toFixed(2)}s) -> ${totalFrames} frames at ${this.fps}fps`); 225 226 if (totalFrames <= 0) { 227 console.warn(`[VideoTranscoder] Skipping scene: 0 frames`); 228 URL.revokeObjectURL(video.src); 229 return startFrame; 230 } 231 232 const canvas = new OffscreenCanvas(this.width, this.height); 233 const ctx = canvas.getContext('2d', { alpha: false }); 234 235 // Warm up decoder: double-seek to prime the decoder before capture. 236 // First seek initializes the decoder, second ensures the frame is fully decoded. 237 const warmupPos = Math.min(Math.max(0, effectiveTrimStart), fullDuration - 0.001); 238 const seekWithTimeout = (pos) => new Promise((resolve) => { 239 const timeout = setTimeout(resolve, 5000); 240 const onSeeked = () => { clearTimeout(timeout); video.removeEventListener('seeked', onSeeked); resolve(); }; 241 video.addEventListener('seeked', onSeeked); 242 video.currentTime = pos; 243 });
244 await seekWithTimeout(warmupPos); 245 await seekWithTimeout(warmupPos); 246 247 // Capture via seeking 248 return this.captureViaSeeking(video, encoder, canvas, ctx, scaledW, scaledH, offsetX, offsetY, totalFrames, startFrame, effectiveTrimStart); 249 } 250 251 /** 252 * Seek to each frame position and capture. Guarantees exactly totalFrames 253 * output regardless of source fps. Seek positions are clamped to valid range. 254 */ 255 async captureViaSeeking(video, encoder, canvas, ctx, scaledW, scaledH, offsetX, offsetY, totalFrames, startFrame, trimOffset) { 256 let frameCount = startFrame; 257 258 // Allow seeking very close to the end â only avoid the absolute last millisecond 259 const maxSeekTime = Math.max(0, video.duration - 0.001); 260 261 console.log(`[VideoTranscoder] Seeking capture: offset=${trimOffset.toFixed(2)}s -> ${totalFrames} frames at ${this.fps}fps, keyframe every ${this.keyframeInterval} frames`); 262 263 for (let i = 0; i < totalFrames; i++) { 264 if (this.aborted) { 265 URL.revokeObjectURL(video.src); 266 throw new Error('Aborted'); 267 } 268 269 // Target time for this frame â clamped to valid video range 270 const rawTime = (i / this.fps) + trimOffset; 271 const targetTime = Math.min(Math.max(0, rawTime), maxSeekTime); 272 273 // Seek with retry â transient seek errors are common in some codecs/browsers 274 let seeked = false; 275 for (let attempt = 0; attempt < 3 && !seeked; attempt++) { 276 try { 277 video.currentTime = targetTime; 278 await new Promise((resolve, reject) => { 279 const timeout = setTimeout(() => { cleanup(); resolve(); }, 5000); // 5s timeout fallback 280 const cleanup = () => { clearTimeout(timeout); video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onError); }; 281 const onSeeked = () => { cleanup(); resolve(); }; 282 const onError = () => { cleanup(); reject(new Error('Seek error')); }; 283 video.addEventListener('seeked', onSeeked); 284 video.addEventListener('error', onError); 285 }); 286 seeked = true; 287 } catch { 288 if (attempt < 2) { 289 console.warn(`[VideoTranscoder] Seek retry ${attempt + 1}/3 for frame ${i} at ${targetTime.toFixed(2)}s`); 290 await new Promise(r => setTimeout(r, 100 * (attempt + 1))); 291 } 292 // On final failure, use whatever frame the video currently shows 293 seeked = true; 294 } 295 } 296 297 // Capture frame 298 ctx.fillStyle = '#000000'; 299 ctx.fillRect(0, 0, this.width, this.height); 300 ctx.drawImage(video, offsetX, offsetY, scaledW, scaledH); 301 302 // Keyframe strategy: every keyframeInterval frames (0.5s) + first frame + last frame 303 const isKeyFrame = (i % this.keyframeInterval) === 0 || i === totalFrames - 1; 304 305 const frame = new VideoFrame(canvas, { 306 timestamp: Math.round(frameCount * this.frameDuration), 307 duration: Math.round(this.frameDuration) 308 }); 309 encoder.encode(frame, { keyFrame: isKeyFrame }); 310 frame.close(); 311 frameCount++; 312 313 // Progress every 30 frames 314 if (i % 30 === 0 && this.onProgress) { 315 this.onProgress({ 316 phase: 'transcoding', 317 progress: 10 + (i / totalFrames) * 70, 318 message: `Encoding frame ${i + 1}/${totalFrames}...` 319 }); 320 } 321 } 322 323 URL.revokeObjectURL(video.src); 324 console.log(`[VideoTranscoder] Encoded ${totalFrames} frames`); 325 return frameCount; 326 } 327 328 /** 329 * Process audio blob and add to muxer 330 */ 331 async processAudio(audioBlob, muxer) { 332 const buffer = await audioBlob.arrayBuffer(); 333 const data = new Uint8Array(buffer); 334 const view = new DataView(buffer); 335 const potentialFrameCount = view.getUint32(0, false); 336 const headerSize = 4 + potentialFrameCount * 4; 337 338 if (potentialFrameCount > 0 && potentialFrameCount < 100000 && headerSize < buffer.byteLength) { 339 const frameSizes = []; 340 for (let i = 0; i < potentialFrameCount; i++) { 341 frameSizes.push(view.getUint32(4 + i * 4, false)); 342 } 343 344 let offset = headerSize; 345 let timestamp = 0; 346 const frameDuration = Math.round(1024 / this.sampleRate * 1_000_000); 347 348 for (let i = 0; i < frameSizes.length; i++) { 349 const frameSize = frameSizes[i]; 350 if (offset + frameSize > buffer.byteLength) break; 351 352 const frameData = data.slice(offset, offset + frameSize); 353 const chunk = new EncodedAudioChunk({ 354 type: 'key', 355 timestamp, 356 duration: frameDuration, 357 data: frameData 358 }); 359 muxer.addAudioChunk(chunk);
360 361 offset += frameSize; 362 timestamp += frameDuration; 363 } 364 365 console.log(`[VideoTranscoder] Added ${frameSizes.length} AAC frames`); 366 } else { 367 console.warn('[VideoTranscoder] Audio format not recognized, skipping'); 368 } 369 } 370 371 /** 372 * Abort the current transcoding operation 373 */ 374 abort() { 375 this.aborted = true; 376 console.log('[VideoTranscoder] Abort requested'); 377 } 378} 379 380export default VideoTranscoder;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.