diff --git a/src/hooks/__tests__/useApplySpeedCurve.test.ts b/src/hooks/__tests__/useApplySpeedCurve.test.ts index 963fca90..771ebb86 100644 --- a/src/hooks/__tests__/useApplySpeedCurve.test.ts +++ b/src/hooks/__tests__/useApplySpeedCurve.test.ts @@ -82,7 +82,7 @@ vi.mock("mediabunny", () => { vi.mock("@/lib/video-encoding", () => ({ createAvcEncodingConfig: vi.fn(() => ({})), - AVC_LEVEL_4_0: "avc1.420028", + AVC_LEVEL_4_0: "avc1.640028", AVC_LEVEL_5_1: "avc1.640033", })); diff --git a/src/hooks/__tests__/useStitchVideos.test.ts b/src/hooks/__tests__/useStitchVideos.test.ts index e8419e57..1eb44869 100644 --- a/src/hooks/__tests__/useStitchVideos.test.ts +++ b/src/hooks/__tests__/useStitchVideos.test.ts @@ -114,7 +114,7 @@ vi.mock("mediabunny", () => { vi.mock("@/lib/video-encoding", () => ({ createAvcEncodingConfig: vi.fn(() => ({})), - AVC_LEVEL_4_0: "avc1.420028", + AVC_LEVEL_4_0: "avc1.640028", AVC_LEVEL_5_1: "avc1.640033", })); diff --git a/src/hooks/useStitchVideos.ts b/src/hooks/useStitchVideos.ts index 509915c2..c8ca4c55 100644 --- a/src/hooks/useStitchVideos.ts +++ b/src/hooks/useStitchVideos.ts @@ -39,7 +39,7 @@ const normalizeRotation = (value: unknown): Rotation => { const probeVideoMetadata = async ( blob: Blob -): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number }> => { +): Promise<{ width: number; height: number; rotation: Rotation; bitrate: number; duration: number }> => { const source = new BlobSource(blob); const input = new Input({ source, @@ -71,11 +71,23 @@ const probeVideoMetadata = async ( console.warn('Failed to compute packet stats for bitrate', e); } + // Probe duration + let duration = 0; + try { + const d = await input.computeDuration(); + if (Number.isFinite(d) && d > 0) { + duration = d; + } + } catch (e) { + console.warn('Failed to compute duration', e); + } + return { width: ensureEvenDimension(widthCandidate), height: ensureEvenDimension(heightCandidate), rotation: normalizeRotation(track.rotation), bitrate, + duration, }; } finally { input.dispose(); @@ -84,18 +96,20 @@ const probeVideoMetadata = async ( const determineEncodeParameters = async ( blobs: Blob[] -): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number }> => { +): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number; totalDuration: number }> => { let maxWidth = 0; let maxHeight = 0; let maxSourceBitrate = 0; + let totalDuration = 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]); + const { width, height, rotation: trackRotation, bitrate, duration } = await probeVideoMetadata(blobs[i]); maxWidth = Math.max(maxWidth, width); maxHeight = Math.max(maxHeight, height); maxSourceBitrate = Math.max(maxSourceBitrate, bitrate); + totalDuration += duration; if (rotation === null) { rotation = trackRotation; } else if (trackRotation !== rotation) { @@ -114,6 +128,7 @@ const determineEncodeParameters = async ( height: FALLBACK_HEIGHT, rotation: rotation ?? (0 as Rotation), maxSourceBitrate, + totalDuration, }; } @@ -122,6 +137,7 @@ const determineEncodeParameters = async ( height: ensureEvenDimension(maxHeight), rotation: rotation ?? (0 as Rotation), maxSourceBitrate, + totalDuration, }; }; @@ -225,6 +241,7 @@ export async function stitchVideosAsync( height: probedHeight, rotation: aggregateRotation, maxSourceBitrate, + totalDuration: probedVideoDuration, } = await determineEncodeParameters(videoBlobs); const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH; @@ -312,6 +329,18 @@ export async function stitchVideosAsync( await output.start(); outputStarted = true; + // Feed audio early so the muxer can interleave audio packets with video frames. + // Writing audio after all video causes broken interleaving (Discord won't play audio). + if (audioSource && pendingAudioBuffer) { + updateProgress('processing', 'Encoding audio track...', 12); + const trimTarget = probedVideoDuration > 0 ? probedVideoDuration : audioData!.duration; + const trimmedBuffer = trimAudioBuffer(pendingAudioBuffer, trimTarget); + await audioSource.add(trimmedBuffer); + await audioSource.close(); + audioSource = null; + pendingAudioBuffer = null; + } + try { // Track the highest timestamp we've written to ensure monotonicity // Start at -frameInterval so first frame can be at timestamp 0 @@ -414,17 +443,6 @@ export async function stitchVideosAsync( } } - // Phase 2: Apply audio, trimmed to match the stitched video duration - if (audioSource && pendingAudioBuffer) { - updateProgress('processing', 'Encoding audio track...', 92); - - const videoDuration = highestWrittenTimestamp + frameInterval; - const trimmedBuffer = trimAudioBuffer(pendingAudioBuffer, videoDuration); - await audioSource.add(trimmedBuffer); - await audioSource.close(); - audioSource = null; // Already closed - } - // Flush encoder before finalizing container await videoSource!.close(); videoSource = null; // Already closed, prevent double-close in finally diff --git a/src/lib/__tests__/video-encoding.test.ts b/src/lib/__tests__/video-encoding.test.ts index eeb00ff6..e474a847 100644 --- a/src/lib/__tests__/video-encoding.test.ts +++ b/src/lib/__tests__/video-encoding.test.ts @@ -8,7 +8,7 @@ import { describe("video-encoding", () => { describe("constants", () => { it("AVC_LEVEL_4_0 is correct codec string", () => { - expect(AVC_LEVEL_4_0).toBe("avc1.420028"); + expect(AVC_LEVEL_4_0).toBe("avc1.640028"); }); it("AVC_LEVEL_5_1 is correct codec string", () => { diff --git a/src/lib/video-encoding.ts b/src/lib/video-encoding.ts index 8356929d..4876f3c5 100644 --- a/src/lib/video-encoding.ts +++ b/src/lib/video-encoding.ts @@ -4,7 +4,7 @@ import type { VideoEncodingConfig } from 'mediabunny'; const DEFAULT_KEYFRAME_INTERVAL = 1.0; const MAX_OUTPUT_FPS = 60; -export const AVC_LEVEL_4_0 = 'avc1.420028'; +export const AVC_LEVEL_4_0 = 'avc1.640028'; export const AVC_LEVEL_5_1 = 'avc1.640033'; export const createAvcEncodingConfig = ( @@ -22,6 +22,7 @@ export const createAvcEncodingConfig = ( latencyMode: 'quality', fullCodecString: codecString, hardwareAcceleration: useHardwareAcceleration ? 'prefer-hardware' : 'prefer-software', + sizeChangeBehavior: 'cover', onEncoderConfig: (config) => { config.avc = { ...(config.avc ?? {}), format: 'avc' }; if (!config.latencyMode) {