From 067a0b689a75e7afc5990589319adb0a02482e59 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 3 Feb 2026 21:13:16 +1300 Subject: [PATCH] feat(41-01): port audio and video processing hooks from easy-peasy-ease - Port useAudioVisualization.ts with waveform peak extraction - Port useAudioMixing.ts with prepareAudioAsync standalone function - Port useStitchVideos.ts with stitchVideosAsync and checkEncoderSupport - Export applyFades from useAudioMixing for shared use - Remove preview mode logic from useStitchVideos (always full quality) - Inline constants (DEFAULT_BITRATE, MAX_OUTPUT_FPS) in useStitchVideos - All hooks use MediaBunny for audio/video processing Co-Authored-By: Claude Opus 4.5 --- src/hooks/useAudioMixing.ts | 213 ++++++++++++ src/hooks/useAudioVisualization.ts | 144 +++++++++ src/hooks/useStitchVideos.ts | 500 +++++++++++++++++++++++++++++ 3 files changed, 857 insertions(+) create mode 100644 src/hooks/useAudioMixing.ts create mode 100644 src/hooks/useAudioVisualization.ts create mode 100644 src/hooks/useStitchVideos.ts diff --git a/src/hooks/useAudioMixing.ts b/src/hooks/useAudioMixing.ts new file mode 100644 index 00000000..3870f7a0 --- /dev/null +++ b/src/hooks/useAudioMixing.ts @@ -0,0 +1,213 @@ +'use client'; + +import { useCallback } from 'react'; +import { + Input, + AudioBufferSink, + BlobSource, + ALL_FORMATS, +} from 'mediabunny'; + +const MINIMUM_AUDIO_DURATION = 0.1; + +interface AudioProcessingOptions { + offset?: number; + fadeIn?: number; + fadeOut?: number; +} + +interface AudioMixProgress { + message: string; + progress: number; +} + +interface AudioData { + buffer: AudioBuffer; + duration: number; +} + +interface UseAudioMixingReturn { + prepareAudio: ( + audioBlob: Blob, + videoDuration: number, + onProgress?: (progress: AudioMixProgress) => void, + options?: AudioProcessingOptions + ) => Promise; +} + +/** + * Standalone async function to prepare audio for mixing with video + */ +export async function prepareAudioAsync( + audioBlob: Blob, + videoDuration: number, + onProgress?: (progress: AudioMixProgress) => void, + options?: AudioProcessingOptions +): Promise { + try { + onProgress?.({ message: 'Loading audio file...', progress: 10 }); + const blobSource = new BlobSource(audioBlob); + const input = new Input({ source: blobSource, formats: ALL_FORMATS }); + + onProgress?.({ message: 'Reading audio tracks...', progress: 20 }); + const audioTracks = await input.getAudioTracks(); + if (audioTracks.length === 0) { + throw new Error('No audio tracks found in file'); + } + + const audioTrack = audioTracks[0]; + const sink = new AudioBufferSink(audioTrack); + const audioDuration = await input.computeDuration(); + + onProgress?.({ message: 'Decoding audio...', progress: 30 }); + const decodedBuffers: AudioBuffer[] = []; + for await (const wrappedBuffer of sink.buffers(0, Math.max(videoDuration, audioDuration))) { + if (wrappedBuffer?.buffer) { + decodedBuffers.push(wrappedBuffer.buffer); + } + } + + if (decodedBuffers.length === 0) { + throw new Error('Failed to decode audio'); + } + + const sampleRate = decodedBuffers[0].sampleRate; + const channels = decodedBuffers[0].numberOfChannels; + const targetDuration = Math.max(MINIMUM_AUDIO_DURATION, videoDuration); + const totalSamples = Math.max(1, Math.floor(targetDuration * sampleRate)); + + const mergedBuffer = new AudioBuffer({ + length: totalSamples, + numberOfChannels: channels, + sampleRate, + }); + + const offsetSeconds = options?.offset ?? 0; + const offsetSamples = Math.floor(offsetSeconds * sampleRate); + + let writeOffset = 0; + let sourceSkipSamples = 0; + + if (offsetSamples > 0) { + writeOffset = Math.min(offsetSamples, totalSamples); + } else if (offsetSamples < 0) { + sourceSkipSamples = Math.abs(offsetSamples); + } + + let samplesToSkip = sourceSkipSamples; + let bufferIndex = 0; + let bufferOffset = 0; + + while (samplesToSkip > 0 && bufferIndex < decodedBuffers.length) { + const buffer = decodedBuffers[bufferIndex]; + const availableInBuffer = buffer.length - bufferOffset; + if (samplesToSkip >= availableInBuffer) { + samplesToSkip -= availableInBuffer; + bufferIndex++; + bufferOffset = 0; + } else { + bufferOffset = samplesToSkip; + samplesToSkip = 0; + } + } + + while (writeOffset < totalSamples && bufferIndex < decodedBuffers.length) { + const buffer = decodedBuffers[bufferIndex]; + const remainingSamples = totalSamples - writeOffset; + const availableInBuffer = buffer.length - bufferOffset; + const writeLength = Math.min(availableInBuffer, remainingSamples); + + for (let channel = 0; channel < channels; channel++) { + const channelData = buffer.getChannelData(channel).subarray(bufferOffset, bufferOffset + writeLength); + mergedBuffer.getChannelData(channel).set(channelData, writeOffset); + } + + writeOffset += writeLength; + bufferOffset += writeLength; + + if (bufferOffset >= buffer.length) { + bufferIndex++; + bufferOffset = 0; + } + } + + while (writeOffset < totalSamples && decodedBuffers.length > 0) { + for (const buffer of decodedBuffers) { + const remainingSamples = totalSamples - writeOffset; + if (remainingSamples <= 0) break; + const writeLength = Math.min(buffer.length, remainingSamples); + for (let channel = 0; channel < channels; channel++) { + const channelData = buffer.getChannelData(channel).subarray(0, writeLength); + mergedBuffer.getChannelData(channel).set(channelData, writeOffset); + } + writeOffset += writeLength; + } + } + + applyFades(mergedBuffer, options); + onProgress?.({ message: 'Audio ready for mixing', progress: 95 }); + + return { + buffer: mergedBuffer, + duration: totalSamples / sampleRate, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Audio mixing error:', error); + throw new Error(`Failed to process audio: ${errorMessage}`); + } +} + +/** + * React hook wrapper for prepareAudioAsync + */ +export const useAudioMixing = (): UseAudioMixingReturn => { + const prepareAudio = useCallback( + async ( + audioBlob: Blob, + videoDuration: number, + onProgress?: (progress: AudioMixProgress) => void, + options?: AudioProcessingOptions + ): Promise => { + return prepareAudioAsync(audioBlob, videoDuration, onProgress, options); + }, + [] + ); + + return { prepareAudio }; +}; + +export function applyFades(buffer: AudioBuffer, options?: { fadeIn?: number; fadeOut?: number }) { + if (!options) return; + const fadeInSeconds = Math.max(0, options.fadeIn ?? 0); + const fadeOutSeconds = Math.max(0, options.fadeOut ?? 0); + if (fadeInSeconds === 0 && fadeOutSeconds === 0) return; + + const totalSamples = buffer.length; + if (totalSamples === 0) return; + + let fadeInSamples = Math.min(totalSamples, Math.floor(fadeInSeconds * buffer.sampleRate)); + let fadeOutSamples = Math.min(totalSamples, Math.floor(fadeOutSeconds * buffer.sampleRate)); + + if (fadeInSamples + fadeOutSamples > totalSamples) { + const scale = totalSamples / Math.max(1, fadeInSamples + fadeOutSamples); + fadeInSamples = Math.floor(fadeInSamples * scale); + fadeOutSamples = Math.floor(fadeOutSamples * scale); + } + + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + const channelData = buffer.getChannelData(channel); + if (fadeInSamples > 0) { + for (let i = 0; i < fadeInSamples; i++) { + channelData[i] *= i / fadeInSamples; + } + } + if (fadeOutSamples > 0) { + for (let i = 0; i < fadeOutSamples; i++) { + const sampleIndex = totalSamples - fadeOutSamples + i; + if (sampleIndex < 0 || sampleIndex >= totalSamples) continue; + channelData[sampleIndex] *= (fadeOutSamples - i) / fadeOutSamples; + } + } + } +} diff --git a/src/hooks/useAudioVisualization.ts b/src/hooks/useAudioVisualization.ts new file mode 100644 index 00000000..389f70eb --- /dev/null +++ b/src/hooks/useAudioVisualization.ts @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react'; + +export interface WaveformData { + channelData: Float32Array[]; + sampleRate: number; + duration: number; + peaks: number[]; +} + +const waveformCache = new Map(); +const blobKeyStore = new WeakMap(); +let blobKeyCounter = 0; +let sharedAudioContext: AudioContext | null = null; + +const getBlobCacheKey = (audioFile: File | Blob | null) => { + if (!audioFile) return null; + if (audioFile instanceof File) { + return `${audioFile.name}-${audioFile.size}-${audioFile.lastModified}`; + } + const existing = blobKeyStore.get(audioFile); + if (existing) return existing; + const generated = `blob-${blobKeyCounter++}-${audioFile.size}-${audioFile.type ?? 'unknown'}`; + blobKeyStore.set(audioFile, generated); + return generated; +}; + +const getSharedAudioContext = () => { + if (sharedAudioContext) { + return sharedAudioContext; + } + const AudioCtor = + typeof window !== 'undefined' + ? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + : null; + if (!AudioCtor) { + throw new Error('AudioContext is not supported in this environment'); + } + sharedAudioContext = new AudioCtor(); + return sharedAudioContext; +}; + +const decodeAudioBuffer = async (context: AudioContext, arrayBuffer: ArrayBuffer) => { + if ('decodeAudioData' in context && context.decodeAudioData.length === 1) { + return context.decodeAudioData(arrayBuffer); + } + return new Promise((resolve, reject) => { + context.decodeAudioData(arrayBuffer, resolve, reject); + }); +}; + +export function useAudioVisualization(audioFile: File | Blob | null) { + const [waveformData, setWaveformData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!audioFile) { + setWaveformData(null); + setError(null); + setIsLoading(false); + return; + } + + const cacheKey = getBlobCacheKey(audioFile); + if (cacheKey && waveformCache.has(cacheKey)) { + setWaveformData(waveformCache.get(cacheKey)!); + setIsLoading(false); + setError(null); + return; + } + + let isCancelled = false; + + const processAudio = async () => { + try { + setIsLoading(true); + setError(null); + + const arrayBuffer = await audioFile.arrayBuffer(); + const audioContext = getSharedAudioContext(); + const audioBuffer = await decodeAudioBuffer(audioContext, arrayBuffer); + + const channelData = Array.from({ length: audioBuffer.numberOfChannels }, (_, i) => + audioBuffer.getChannelData(i) + ); + + const peaks = calculatePeaks(channelData, 256); + + const nextWaveform: WaveformData = { + channelData, + sampleRate: audioBuffer.sampleRate, + duration: audioBuffer.duration, + peaks, + }; + + if (cacheKey) { + waveformCache.set(cacheKey, nextWaveform); + } + + if (!isCancelled) { + setWaveformData(nextWaveform); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to process audio'; + setError(message); + console.error('Audio processing error:', err); + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + processAudio(); + + return () => { + isCancelled = true; + }; + }, [audioFile]); + + return { waveformData, isLoading, error }; +} + +function calculatePeaks(channelData: Float32Array[], resolution: number): number[] { + const peaks: number[] = []; + const totalSamples = channelData[0]?.length ?? 0; + const samplesPerPeak = Math.max(1, Math.floor(totalSamples / resolution)); + + for (let i = 0; i < resolution; i++) { + let max = 0; + const start = i * samplesPerPeak; + const end = Math.min(start + samplesPerPeak, totalSamples); + + for (const channel of channelData) { + for (let j = start; j < end; j++) { + max = Math.max(max, Math.abs(channel[j] ?? 0)); + } + } + + peaks.push(max); + } + + return peaks; +} diff --git a/src/hooks/useStitchVideos.ts b/src/hooks/useStitchVideos.ts new file mode 100644 index 00000000..f95efc91 --- /dev/null +++ b/src/hooks/useStitchVideos.ts @@ -0,0 +1,500 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { + Input, + Output, + VideoSampleSink, + VideoSampleSource, + AudioBufferSource, + BlobSource, + ALL_FORMATS, + BufferTarget, + Mp4OutputFormat, + getFirstEncodableAudioCodec, + canEncodeVideo, +} from 'mediabunny'; +import type { Rotation } from 'mediabunny'; +import { createAvcEncodingConfig, AVC_LEVEL_4_0, AVC_LEVEL_5_1 } from '@/lib/video-encoding'; + +// Inlined constants from easy-peasy-ease +const DEFAULT_BITRATE = 8_000_000; // 8 Mbps +const MAX_OUTPUT_FPS = 60; + +const FALLBACK_WIDTH = 1920; +const FALLBACK_HEIGHT = 1080; +const BASELINE_PIXEL_LIMIT = 1920 * 1080; + +const ensureEvenDimension = (value: number): number => { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + const even = value % 2 === 0 ? value : value - 1; + return even > 0 ? even : 2; +}; + +const normalizeRotation = (value: unknown): Rotation => { + return value === 0 || value === 90 || value === 180 || value === 270 ? value : 0; +}; + +const probeVideoMetadata = async ( + blob: Blob +): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number }> => { + const source = new BlobSource(blob); + const input = new Input({ + source, + formats: ALL_FORMATS, + }); + try { + const videoTracks = await input.getVideoTracks(); + if (videoTracks.length === 0) { + throw new Error('No video tracks found while probing dimensions.'); + } + const track = videoTracks[0]; + const widthCandidate = + (typeof track.displayWidth === 'number' && track.displayWidth > 0 + ? track.displayWidth + : track.codedWidth) ?? FALLBACK_WIDTH; + const heightCandidate = + (typeof track.displayHeight === 'number' && track.displayHeight > 0 + ? track.displayHeight + : track.codedHeight) ?? FALLBACK_HEIGHT; + + // Probe bitrate from packet stats + let bitrate = 0; + try { + const packetStats = await track.computePacketStats(); + if (packetStats?.averageBitrate && Number.isFinite(packetStats.averageBitrate)) { + bitrate = packetStats.averageBitrate; + } + } catch (e) { + console.warn('Failed to compute packet stats for bitrate', e); + } + + return { + width: ensureEvenDimension(widthCandidate), + height: ensureEvenDimension(heightCandidate), + rotation: normalizeRotation(track.rotation), + bitrate, + }; + } finally { + input.dispose(); + } +}; + +const determineEncodeParameters = async ( + blobs: Blob[] +): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number }> => { + let maxWidth = 0; + let maxHeight = 0; + let maxSourceBitrate = 0; + let rotation: Rotation | null = null; + + for (let i = 0; i < blobs.length; i++) { + try { + const { width, height, rotation: trackRotation, bitrate } = await probeVideoMetadata(blobs[i]); + maxWidth = Math.max(maxWidth, width); + maxHeight = Math.max(maxHeight, height); + maxSourceBitrate = Math.max(maxSourceBitrate, bitrate); + if (rotation === null) { + rotation = trackRotation; + } else if (trackRotation !== rotation) { + console.warn( + `Rotation mismatch detected for video ${i + 1} (got ${trackRotation}, expected ${rotation}). Using the first rotation value.` + ); + } + } catch (error) { + console.warn(`Failed to probe metadata for video ${i + 1}`, error); + } + } + + if (maxWidth <= 0 || maxHeight <= 0) { + return { + width: FALLBACK_WIDTH, + height: FALLBACK_HEIGHT, + rotation: rotation ?? (0 as Rotation), + maxSourceBitrate, + }; + } + + return { + width: ensureEvenDimension(maxWidth), + height: ensureEvenDimension(maxHeight), + rotation: rotation ?? (0 as Rotation), + maxSourceBitrate, + }; +}; + +export interface StitchProgress { + status: 'idle' | 'processing' | 'complete' | 'error'; + message: string; + progress: number; // 0-100 + currentVideo?: number; // Which video is being processed (1-indexed) + totalVideos?: number; + error?: string; +} + +export interface AudioData { + buffer: AudioBuffer; + duration: number; +} + +/** + * Check if the device encoder supports AVC encoding + */ +export async function checkEncoderSupport(): Promise { + try { + return await canEncodeVideo('avc', { + width: 1920, + height: 1080, + bitrate: DEFAULT_BITRATE, + }); + } catch { + return false; + } +} + +/** + * Standalone async function to stitch multiple video blobs together sequentially + */ +export async function stitchVideosAsync( + videoBlobs: Blob[], + audioData?: AudioData | null, + onProgress?: (progress: StitchProgress) => void +): Promise { + try { + // Initialize progress + const initialProgress: StitchProgress = { + status: 'processing', + message: 'Initializing stitching...', + progress: 0, + totalVideos: videoBlobs.length, + }; + onProgress?.(initialProgress); + + if (videoBlobs.length === 0) { + throw new Error('No videos to stitch'); + } + + // Helper to update progress + const updateProgress = ( + status: StitchProgress['status'], + message: string, + progressValue: number, + currentVideo?: number + ) => { + const p: StitchProgress = { + status, + message, + progress: progressValue, + currentVideo, + totalVideos: videoBlobs.length, + }; + onProgress?.(p); + }; + + updateProgress('processing', 'Analyzing video metadata...', 5); + const { + width: probedWidth, + height: probedHeight, + rotation: aggregateRotation, + maxSourceBitrate, + } = await determineEncodeParameters(videoBlobs); + + const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH; + const safeHeight = probedHeight > 0 ? probedHeight : FALLBACK_HEIGHT; + + const codecProfile = + safeWidth * safeHeight > BASELINE_PIXEL_LIMIT ? AVC_LEVEL_5_1 : AVC_LEVEL_4_0; + + // Use highest of default/source bitrate + const candidateBitrate = Math.max( + DEFAULT_BITRATE, + Number.isFinite(maxSourceBitrate) && maxSourceBitrate > 0 ? maxSourceBitrate : 0 + ); + const resolvedBitrate = Math.max(1, Math.floor(candidateBitrate)); + + const supportsConfig = await canEncodeVideo('avc', { + width: safeWidth, + height: safeHeight, + bitrate: resolvedBitrate, + fullCodecString: codecProfile, + }); + + if (!supportsConfig) { + throw new Error( + `Device encoder cannot output ${safeWidth}x${safeHeight} using profile ${codecProfile}. Reduce the resolution or bitrate and try again.` + ); + } + + console.log('Stitch encoder configuration', { + width: safeWidth, + height: safeHeight, + codecProfile, + bitrate: resolvedBitrate, + maxSourceBitrate, + rotation: aggregateRotation, + }); + + // Create output + updateProgress('processing', 'Creating output container...', 10); + + const videoSource = new VideoSampleSource( + createAvcEncodingConfig(resolvedBitrate, safeWidth, safeHeight, codecProfile) + ); + + const bufferTarget = new BufferTarget(); + const output = new Output({ + format: new Mp4OutputFormat({ fastStart: 'in-memory' }), + target: bufferTarget, + }); + + output.addVideoTrack(videoSource, { rotation: aggregateRotation, frameRate: MAX_OUTPUT_FPS }); + + // Add audio track if provided + let audioSource: AudioBufferSource | undefined; + let pendingAudioBuffer: AudioBuffer | null = null; + if (audioData) { + updateProgress('processing', 'Detecting supported audio codec...', 8); + + // Detect the best supported audio codec for MP4 + // Try common codecs in order of preference: aac, mp3 (no opus - Twitter doesn't support it) + const audioCodec = await getFirstEncodableAudioCodec(['aac', 'mp3'], { + numberOfChannels: audioData.buffer.numberOfChannels, + sampleRate: audioData.buffer.sampleRate, + bitrate: 128000, + }); + + if (!audioCodec) { + console.warn('No supported audio codec found, continuing without audio'); + } else { + updateProgress('processing', `Adding audio track (${audioCodec})...`, 10); + + // Create audio source from the decoded audio buffer + audioSource = new AudioBufferSource({ + codec: audioCodec, + bitrate: 128000, + }); + + output.addAudioTrack(audioSource); + pendingAudioBuffer = audioData.buffer; + } + } + + await output.start(); + + // Track the highest timestamp we've written to ensure monotonicity + // Start at -frameInterval so first frame can be at timestamp 0 + const frameInterval = 1 / MAX_OUTPUT_FPS; + let highestWrittenTimestamp = -frameInterval; + + // Process each video blob + for (let videoIndex = 0; videoIndex < videoBlobs.length; videoIndex++) { + const videoBlob = videoBlobs[videoIndex]; + const videoNumber = videoIndex + 1; + + updateProgress( + 'processing', + `Processing video ${videoNumber}/${videoBlobs.length}...`, + 5 + (videoIndex / videoBlobs.length) * 90, + videoNumber + ); + + // Create input for this video + const blobSource = new BlobSource(videoBlob); + const input = new Input({ + source: blobSource, + formats: ALL_FORMATS, + }); + + try { + const videoTracks = await input.getVideoTracks(); + if (videoTracks.length === 0) { + console.warn(`No video tracks in video ${videoNumber}`); + continue; + } + + const videoTrack = videoTracks[0]; + const sink = new VideoSampleSink(videoTrack); + + // Get duration of this video + const videoDuration = await input.computeDuration(); + + // Track the base offset for this video segment + const segmentBaseTime = highestWrittenTimestamp; + // Track minimum timestamp in this segment to normalize + let segmentMinTimestamp: number | null = null; + + // Read and write samples from this video + let samplesFromThisVideo = 0; + for await (const sample of sink.samples(0, videoDuration)) { + const originalTimestamp = sample.timestamp ?? 0; + + // On first sample, record the minimum timestamp to normalize from + if (segmentMinTimestamp === null) { + segmentMinTimestamp = originalTimestamp; + } + + // Normalize timestamp relative to segment start, then offset by base time + const normalizedTimestamp = originalTimestamp - segmentMinTimestamp; + const adjustedTimestamp = segmentBaseTime + normalizedTimestamp; + + // Snap to 60fps grid for consistent framerate + const snappedTimestamp = Math.round(adjustedTimestamp / frameInterval) * frameInterval; + + // Skip duplicate frames that land on the same timestamp slot + // This ensures strict 60fps without exceeding the target rate + if (snappedTimestamp <= highestWrittenTimestamp) { + sample.close(); + continue; + } + + sample.setTimestamp(snappedTimestamp); + sample.setDuration(frameInterval); + await videoSource.add(sample); + + // Update highest written timestamp + highestWrittenTimestamp = snappedTimestamp; + + sample.close(); + samplesFromThisVideo++; + + // Update progress + if (samplesFromThisVideo % 10 === 0) { + const videoProgress = samplesFromThisVideo / 300; // Rough estimate + const overallProgress = + 5 + ((videoIndex + videoProgress) / videoBlobs.length) * 90; + updateProgress( + 'processing', + `Processing video ${videoNumber}/${videoBlobs.length}: ${samplesFromThisVideo} frames...`, + overallProgress, + videoNumber + ); + } + } + } catch (videoError) { + const errorMsg = + videoError instanceof Error + ? videoError.message + : `Failed to process video ${videoNumber}`; + console.error(`Error processing video ${videoNumber}:`, videoError); + throw new Error(errorMsg); + } finally { + input.dispose(); + } + } + + // Encode audio after all video frames have been queued so container metadata stays accurate + if (audioSource && pendingAudioBuffer) { + updateProgress('processing', 'Encoding audio track...', 92); + await audioSource.add(pendingAudioBuffer); + await audioSource.close(); + } + + // Flush encoder before finalizing container + await videoSource.close(); + updateProgress('processing', 'Finalizing stitched video...', 97); + + // Finalize output + await output.finalize(); + const buffer = bufferTarget.buffer; + + if (!buffer) { + throw new Error('Failed to generate output buffer'); + } + + const outputBlob = new Blob([buffer], { type: 'video/mp4' }); + + updateProgress( + 'complete', + `Successfully stitched ${videoBlobs.length} videos into ${(outputBlob.size / 1024 / 1024).toFixed(2)}MB file`, + 100 + ); + + return outputBlob; + } catch (error) { + const normalizedError = + error instanceof Error ? error : new Error(String(error)); + console.error('Video stitching error:', normalizedError); + + const errorProgress: StitchProgress = { + status: 'error', + message: `Error: ${normalizedError.message}`, + progress: 0, + error: normalizedError.message, + }; + + onProgress?.(errorProgress); + + throw normalizedError; + } +} + +interface UseStitchVideosReturn { + stitchVideos: ( + videoBlobs: Blob[], + audioData?: AudioData | null, + onProgress?: (progress: StitchProgress) => void + ) => Promise; + progress: StitchProgress; + reset: () => void; +} + +/** + * Hook for stitching multiple video blobs together sequentially + * Reads frames from each video and writes them to output in order + */ +export const useStitchVideos = (): UseStitchVideosReturn => { + const [progress, setProgress] = useState({ + status: 'idle', + message: 'Ready', + progress: 0, + }); + + const stitchVideos = useCallback( + async ( + videoBlobs: Blob[], + audioData?: AudioData | null, + onProgress?: (progress: StitchProgress) => void + ): Promise => { + try { + setProgress({ + status: 'processing', + message: 'Initializing...', + progress: 0, + }); + + const result = await stitchVideosAsync(videoBlobs, audioData, (p) => { + setProgress(p); + onProgress?.(p); + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Video stitching failed'; + setProgress({ + status: 'error', + message: errorMessage, + progress: 0, + error: errorMessage, + }); + return null; + } + }, + [] + ); + + const reset = useCallback(() => { + setProgress({ + status: 'idle', + message: 'Ready', + progress: 0, + }); + }, []); + + return { + stitchVideos, + progress, + reset, + }; +};