PageSourceSearch

https://www.arturum.ai/js/utils/VideoTranscoder.js?v=20251229a

js arturum.ai collected 2026-10-05 15:20:31 UTC 14,600 bytes, 380 lines download raw bytes

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.