diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index 521f220..a2acab3 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -41,6 +41,7 @@ import { validateImageFile, } from '@/components/video-page/image-upload-utils'; import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media'; +import { withWebmDuration } from '@/lib/webm-duration'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cn } from '@/lib/utils'; @@ -170,6 +171,7 @@ export const AssetsPane = memo(function AssetsPane({ const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const recordingTimerRef = useRef | null>(null); + const recordingStartedAtRef = useRef(0); // Drag-drop state const [isDragOver, setIsDragOver] = useState(false); @@ -717,8 +719,13 @@ export const AssetsPane = memo(function AssetsPane({ recorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; - recorder.onstop = () => { - const blob = new Blob(audioChunksRef.current, { type: mimeType }); + recorder.onstop = async () => { + const elapsedMs = Date.now() - recordingStartedAtRef.current; + const raw = new Blob(audioChunksRef.current, { type: mimeType }); + // MediaRecorder leaves the WebM duration unset, so stamp it in before the + // blob reaches a player or the upload. + const blob = await withWebmDuration(raw, elapsedMs); + setRecordingTime(elapsedMs / 1000); setAudioBlob(blob); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); @@ -730,7 +737,12 @@ export const AssetsPane = memo(function AssetsPane({ recorder.start(100); setIsRecording(true); setRecordingTime(0); - recordingTimerRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000); + // Background tabs throttle timers, so read the clock instead of counting ticks. + recordingStartedAtRef.current = Date.now(); + recordingTimerRef.current = setInterval( + () => setRecordingTime((Date.now() - recordingStartedAtRef.current) / 1000), + 250 + ); } catch { toast.error('Could not access microphone'); } diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts index fa89ddf..02cc6cd 100644 --- a/components/video-page/hooks/use-comment-actions.ts +++ b/components/video-page/hooks/use-comment-actions.ts @@ -27,6 +27,7 @@ import { validateImageFile, } from '@/components/video-page/image-upload-utils'; import { validateAnnotationStrokes } from '@/lib/validation'; +import { withWebmDuration } from '@/lib/webm-duration'; interface UseCommentActionsParams extends CommentActionsConfig { setVideo: Dispatch>; @@ -97,6 +98,7 @@ export function useCommentActions({ const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const recordingTimerRef = useRef | null>(null); + const recordingStartedAtRef = useRef(0); const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(''); @@ -113,6 +115,7 @@ export function useCommentActions({ const replyMediaRecorderRef = useRef(null); const replyAudioChunksRef = useRef([]); const replyRecordingTimerRef = useRef | null>(null); + const replyRecordingStartedAtRef = useRef(0); const [editingCommentId, setEditingCommentId] = useState(null); const [editText, setEditText] = useState(''); @@ -443,10 +446,14 @@ export function useCommentActions({ } }; - mediaRecorder.onstop = () => { + mediaRecorder.onstop = async () => { + const elapsedMs = Date.now() - recordingStartedAtRef.current; const recordedMime = mediaRecorder.mimeType || 'audio/webm'; - const blob = new Blob(audioChunksRef.current, { type: recordedMime }); - setAudioBlob(blob); + const raw = new Blob(audioChunksRef.current, { type: recordedMime }); + // MediaRecorder leaves the WebM duration unset, so stamp it in before the + // blob reaches a player or the upload. + setAudioBlob(await withWebmDuration(raw, elapsedMs)); + setRecordingTime(elapsedMs / 1000); stream.getTracks().forEach((track) => track.stop()); if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); @@ -457,8 +464,10 @@ export function useCommentActions({ mediaRecorder.start(100); setIsRecording(true); setRecordingTime(0); + // Background tabs throttle timers, so read the clock instead of counting ticks. + recordingStartedAtRef.current = Date.now(); recordingTimerRef.current = setInterval(() => { - setRecordingTime((prev) => prev + 0.1); + setRecordingTime((Date.now() - recordingStartedAtRef.current) / 1000); }, 100); } catch (err) { console.error('Failed to start recording:', err); @@ -849,10 +858,12 @@ export function useCommentActions({ mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) replyAudioChunksRef.current.push(e.data); }; - mediaRecorder.onstop = () => { + mediaRecorder.onstop = async () => { + const elapsedMs = Date.now() - replyRecordingStartedAtRef.current; const recordedMime = mediaRecorder.mimeType || 'audio/webm'; - const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime }); - setReplyAudioBlob(blob); + const raw = new Blob(replyAudioChunksRef.current, { type: recordedMime }); + setReplyAudioBlob(await withWebmDuration(raw, elapsedMs)); + setReplyRecordingTime(elapsedMs / 1000); stream.getTracks().forEach((track) => track.stop()); if (replyRecordingTimerRef.current) { clearInterval(replyRecordingTimerRef.current); @@ -862,8 +873,9 @@ export function useCommentActions({ mediaRecorder.start(100); setIsReplyRecording(true); setReplyRecordingTime(0); + replyRecordingStartedAtRef.current = Date.now(); replyRecordingTimerRef.current = setInterval(() => { - setReplyRecordingTime((prev) => prev + 0.1); + setReplyRecordingTime((Date.now() - replyRecordingStartedAtRef.current) / 1000); }, 100); } catch (err) { console.error('Failed to start reply recording:', err); diff --git a/lib/webm-duration.ts b/lib/webm-duration.ts new file mode 100644 index 0000000..e4ca2b2 --- /dev/null +++ b/lib/webm-duration.ts @@ -0,0 +1,207 @@ +/** + * MediaRecorder writes WebM in live mode: the Segment header carries an unknown + * size and the Info block has no Duration, so nothing downstream can tell how + * long a recording is. Browsers report `Infinity` for `audio.duration`, and + * desktop players (mpv, VLC) keep playing past the end of a seek bar they never + * got a length for. + * + * Live-mode output also has no SeekHead and no Cues, which means no stored byte + * offsets, which means we can splice a Duration element into Info without + * invalidating anything. If a file does carry a SeekHead we leave it alone: a + * missing duration is better than shifted seek positions. + */ + +const ID_SEGMENT = 0x18538067; +const ID_SEEK_HEAD = 0x114d9b74; +const ID_INFO = 0x1549a966; +const ID_TIMECODE_SCALE = 0x2ad7b1; +const ID_DURATION = 0x4489; + +const DEFAULT_TIMECODE_SCALE = 1_000_000; // nanoseconds per tick, i.e. 1ms + +type EbmlElement = { + id: number; + sizePos: number; + sizeLength: number; + contentStart: number; + contentEnd: number; + unknownSize: boolean; +}; + +// An EBML variable-length integer encodes its own width in the leading bits: +// 1xxxxxxx is one byte, 01xxxxxx two, and so on down to eight. +function vintLength(firstByte: number): number { + for (let i = 0; i < 8; i++) { + if (firstByte & (0x80 >> i)) return i + 1; + } + return 0; +} + +function readSize(bytes: Uint8Array, pos: number): { value: number; length: number } | null { + const length = vintLength(bytes[pos]); + if (length === 0 || pos + length > bytes.length) return null; + let value = bytes[pos] & (0xff >> length); + for (let i = 1; i < length; i++) value = value * 0x100 + bytes[pos + i]; + return { value, length }; +} + +function isUnknownSize(value: number, length: number): boolean { + return value === Math.pow(2, 7 * length) - 1; +} + +function readElement(bytes: Uint8Array, pos: number, end: number): EbmlElement | null { + if (pos >= end) return null; + const idLength = vintLength(bytes[pos]); + if (idLength === 0 || idLength > 4 || pos + idLength > end) return null; + + let id = 0; + for (let i = 0; i < idLength; i++) id = id * 0x100 + bytes[pos + i]; + + const sizePos = pos + idLength; + const size = readSize(bytes, sizePos); + if (!size) return null; + + const contentStart = sizePos + size.length; + const unknownSize = isUnknownSize(size.value, size.length); + const contentEnd = unknownSize ? end : contentStart + size.value; + if (contentEnd > end) return null; + + return { id, sizePos, sizeLength: size.length, contentStart, contentEnd, unknownSize }; +} + +function findChild( + bytes: Uint8Array, + start: number, + end: number, + wantedId: number +): EbmlElement | null { + let pos = start; + while (pos < end) { + const element = readElement(bytes, pos, end); + if (!element) return null; + if (element.id === wantedId) return element; + if (element.unknownSize) return null; + pos = element.contentEnd; + } + return null; +} + +function readUnsigned(bytes: Uint8Array, element: EbmlElement): number | null { + const length = element.contentEnd - element.contentStart; + if (length < 1 || length > 8) return null; + let value = 0; + for (let i = 0; i < length; i++) value = value * 0x100 + bytes[element.contentStart + i]; + return value; +} + +function minVintLength(value: number): number { + for (let length = 1; length <= 8; length++) { + if (value < Math.pow(2, 7 * length) - 1) return length; + } + return 0; +} + +function writeVint(target: Uint8Array, pos: number, value: number, length: number): void { + let rest = value; + for (let i = length - 1; i >= 0; i--) { + target[pos + i] = rest % 0x100; + rest = Math.floor(rest / 0x100); + } + target[pos] |= 0x80 >> (length - 1); +} + +/** + * Returns a copy of `source` with Segment > Info > Duration set to `durationMs`, + * or null when the file is not shaped the way MediaRecorder writes it. + */ +export function injectWebmDuration(source: Uint8Array, durationMs: number): Uint8Array | null { + if (!Number.isFinite(durationMs) || durationMs <= 0) return null; + + const segment = findChild(source, 0, source.length, ID_SEGMENT); + if (!segment) return null; + + const segmentEnd = segment.contentEnd; + const info = findChild(source, segment.contentStart, segmentEnd, ID_INFO); + if (!info || info.unknownSize) return null; + + const scaleElement = findChild(source, info.contentStart, info.contentEnd, ID_TIMECODE_SCALE); + const timecodeScale = scaleElement ? readUnsigned(source, scaleElement) : DEFAULT_TIMECODE_SCALE; + if (!timecodeScale) return null; + + // Duration is expressed in timecode ticks, not milliseconds. + const scaledDuration = (durationMs * 1_000_000) / timecodeScale; + + // Firefox reserves a Duration of 0 it never gets to fill in, which is the easy + // case: overwriting it in place moves no bytes, so any SeekHead stays valid. + const existing = findChild(source, info.contentStart, info.contentEnd, ID_DURATION); + if (existing) { + const width = existing.contentEnd - existing.contentStart; + if (width !== 4 && width !== 8) return null; + const out = source.slice(); + const view = new DataView(out.buffer, out.byteOffset, out.byteLength); + if (width === 4) view.setFloat32(existing.contentStart, scaledDuration); + else view.setFloat64(existing.contentStart, scaledDuration); + return out; + } + + // Chrome reserves nothing, so the element has to be spliced in and everything + // after Info shifts. A SeekHead with entries in it stores those positions, and + // rewriting them is more than this is worth: leave the file as it was instead. + const seekHead = findChild(source, segment.contentStart, segmentEnd, ID_SEEK_HEAD); + if (seekHead && seekHead.contentEnd > seekHead.contentStart) return null; + + // Duration element: 2-byte id, 1-byte size, 8-byte float. + const DURATION_ELEMENT_SIZE = 11; + const oldInfoSize = info.contentEnd - info.contentStart; + const newInfoSize = oldInfoSize + DURATION_ELEMENT_SIZE; + const newInfoSizeLength = Math.max(minVintLength(newInfoSize), info.sizeLength); + if (newInfoSizeLength === 0) return null; + + const delta = DURATION_ELEMENT_SIZE + (newInfoSizeLength - info.sizeLength); + const out = new Uint8Array(source.length + delta); + + out.set(source.subarray(0, info.sizePos), 0); + writeVint(out, info.sizePos, newInfoSize, newInfoSizeLength); + + const newContentStart = info.sizePos + newInfoSizeLength; + out.set(source.subarray(info.contentStart, info.contentEnd), newContentStart); + + const durationPos = newContentStart + oldInfoSize; + out[durationPos] = 0x44; + out[durationPos + 1] = 0x89; + out[durationPos + 2] = 0x88; // 8 data bytes + new DataView(out.buffer, out.byteOffset, out.byteLength).setFloat64( + durationPos + 3, + scaledDuration + ); + + out.set(source.subarray(info.contentEnd), durationPos + DURATION_ELEMENT_SIZE); + + // A live-mode Segment has an unknown size and needs no fixup; a sized one does. + if (!segment.unknownSize) { + const newSegmentSize = segmentEnd - segment.contentStart + delta; + if (minVintLength(newSegmentSize) > segment.sizeLength) return null; + out.fill(0, segment.sizePos, segment.sizePos + segment.sizeLength); + writeVint(out, segment.sizePos, newSegmentSize, segment.sizeLength); + } + + return out; +} + +/** + * Stamps a recorded duration into a WebM blob. Non-WebM blobs (Safari records + * MP4, which already carries its duration) and unexpected layouts pass through + * untouched, so callers can apply this unconditionally. + */ +export async function withWebmDuration(blob: Blob, durationMs: number): Promise { + if (!blob.type.toLowerCase().includes('webm')) return blob; + try { + const source = new Uint8Array(await blob.arrayBuffer()); + const patched = injectWebmDuration(source, durationMs); + // The patched array owns its buffer outright, so handing the buffer to Blob + // copies exactly the bytes we wrote. + return patched ? new Blob([patched.buffer as ArrayBuffer], { type: blob.type }) : blob; + } catch { + return blob; + } +} diff --git a/tests/component/hooks/use-comment-actions.test.ts b/tests/component/hooks/use-comment-actions.test.ts index 6f8f935..e875b20 100644 --- a/tests/component/hooks/use-comment-actions.test.ts +++ b/tests/component/hooks/use-comment-actions.test.ts @@ -3,6 +3,7 @@ import { useState } from 'react'; import { act, renderHook, type RenderHookResult } from '@testing-library/react'; import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions'; import type { Comment, CommentTag, VideoData } from '@/components/video-page/types'; +import { buildLiveWebm, readWebmDuration } from '../../helpers/webm-fixture'; const toastError = vi.fn(); const toastSuccess = vi.fn(); @@ -871,3 +872,147 @@ describe('useCommentActions background refresh', () => { expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled(); }); }); + +// Two bugs lived here. The recording clock counted setInterval ticks, which a +// background tab throttles away, so a recording that kept going looked frozen +// and was saved with the short length. And MediaRecorder writes WebM with no +// duration at all, so the uploaded file played past a length no player knew. +describe('useCommentActions voice recording', () => { + let recorders: FakeMediaRecorder[]; + let recordedChunk: Uint8Array; + + class FakeMediaRecorder { + static isTypeSupported = () => true; + state: 'inactive' | 'recording' = 'inactive'; + mimeType: string; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + + constructor(_stream: unknown, options: { mimeType: string }) { + this.mimeType = options.mimeType; + recorders.push(this); + } + + start() { + this.state = 'recording'; + } + + stop() { + this.state = 'inactive'; + this.ondataavailable?.({ + data: new Blob([recordedChunk.buffer as ArrayBuffer], { type: this.mimeType }), + }); + this.onstop?.(); + } + } + + beforeEach(() => { + vi.useFakeTimers(); + recorders = []; + recordedChunk = buildLiveWebm(); + vi.stubGlobal('MediaRecorder', FakeMediaRecorder); + vi.stubGlobal('navigator', { + ...navigator, + mediaDevices: { + getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }), + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + async function durationOf(blob: Blob | null): Promise { + if (!blob) return null; + return readWebmDuration(new Uint8Array(await blob.arrayBuffer())); + } + + it('counts the time the tab spent in the background', async () => { + const harness = renderActions(); + + await act(async () => { + await harness.result.current.actions.startRecording(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(13_000); + }); + expect(harness.result.current.actions.recordingTime).toBeCloseTo(13, 1); + + // The tab goes to the background: the clock moves on, the interval does not fire. + vi.setSystemTime(Date.now() + 10_000); + await act(async () => { + harness.result.current.actions.stopRecording(); + }); + + expect(harness.result.current.actions.recordingTime).toBeCloseTo(23, 1); + }); + + it('saves the comment with the length that was actually recorded', async () => { + const harness = renderActions(); + + await act(async () => { + await harness.result.current.actions.startRecording(); + }); + vi.setSystemTime(Date.now() + 23_000); + await act(async () => { + harness.result.current.actions.stopRecording(); + }); + await act(async () => { + await harness.result.current.actions.submitCommentWithMedia(); + }); + + const [post] = callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST'); + expect(bodyOf(post).voiceDuration).toBeCloseTo(23, 1); + }); + + it('stamps the recorded length into the uploaded webm', async () => { + const harness = renderActions(); + + await act(async () => { + await harness.result.current.actions.startRecording(); + }); + expect(await durationOf(new Blob([recordedChunk.buffer as ArrayBuffer]))).toBeNull(); + + vi.setSystemTime(Date.now() + 9_000); + await act(async () => { + harness.result.current.actions.stopRecording(); + }); + + expect(await durationOf(harness.result.current.actions.audioBlob)).toBeCloseTo(9_000, 0); + }); + + it('does the same for a voice reply', async () => { + const harness = renderActions(); + + await act(async () => { + await harness.result.current.actions.startReplyRecording(); + }); + vi.setSystemTime(Date.now() + 17_000); + await act(async () => { + harness.result.current.actions.stopReplyRecording(); + }); + + expect(harness.result.current.actions.replyRecordingTime).toBeCloseTo(17, 1); + expect(await durationOf(harness.result.current.actions.replyAudioBlob)).toBeCloseTo(17_000, 0); + }); + + it('leaves a non-webm recording untouched', async () => { + recordedChunk = new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // MP4 'ftyp' + const originalIsTypeSupported = FakeMediaRecorder.isTypeSupported; + FakeMediaRecorder.isTypeSupported = () => false; + const harness = renderActions(); + + await act(async () => { + await harness.result.current.actions.startRecording(); + }); + vi.setSystemTime(Date.now() + 4_000); + await act(async () => { + harness.result.current.actions.stopRecording(); + }); + + const blob = harness.result.current.actions.audioBlob!; + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(recordedChunk); + FakeMediaRecorder.isTypeSupported = originalIsTypeSupported; + }); +}); diff --git a/tests/helpers/webm-fixture.ts b/tests/helpers/webm-fixture.ts new file mode 100644 index 0000000..0f76f58 --- /dev/null +++ b/tests/helpers/webm-fixture.ts @@ -0,0 +1,87 @@ +/** + * Builds WebM bytes in the shapes MediaRecorder emits them. The two browsers + * differ in ways this code has to survive: + * + * Chrome writes no SeekHead and no Duration at all, with compact 1-byte + * element sizes. + * Firefox writes an empty SeekHead, a Duration reserved as 0.0 that it never + * fills in, and 8-byte element sizes. + * + * Nothing here is a real audio stream; these tests only care about the + * container fields a player reads to learn how long a recording is. + */ + +const UNKNOWN_SIZE = [0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; +const INFO_ID = [0x15, 0x49, 0xa9, 0x66]; +const SEEK_HEAD_ID = [0x11, 0x4d, 0x9b, 0x74]; + +/** A Duration of 0.0 in an 8-byte float, the placeholder Firefox leaves behind. */ +export const DURATION_PLACEHOLDER = [0x44, 0x89, 0x88, 0, 0, 0, 0, 0, 0, 0, 0]; + +export function ebmlElement(id: number[], content: number[], sizeWidth = 1): number[] { + if (sizeWidth === 1) { + if (content.length >= 127) throw new Error('a 1-byte size holds at most 126 bytes'); + return [...id, 0x80 | content.length, ...content]; + } + + // Division, not `>>`: JS shifts wrap at 32 bits and an 8-byte size needs 56. + const size = Array.from({ length: sizeWidth }, (_, i) => + i === 0 + ? 0x100 >> sizeWidth + : Math.floor(content.length / 2 ** (8 * (sizeWidth - 1 - i))) & 0xff + ); + return [...id, ...size, ...content]; +} + +export function timecodeScaleElement(nanoseconds: number): number[] { + return ebmlElement( + [0x2a, 0xd7, 0xb1], + [(nanoseconds >> 16) & 0xff, (nanoseconds >> 8) & 0xff, nanoseconds & 0xff] + ); +} + +export const CLUSTER_BYTES = ebmlElement([0x1f, 0x43, 0xb6, 0x75], [0xe7, 0x81, 0x00]); + +export function buildLiveWebm({ + info = timecodeScaleElement(1_000_000), + seekHead = 'none', + sizeWidth = 1, +}: { + info?: number[]; + seekHead?: 'none' | 'empty' | 'entries'; + sizeWidth?: number; +} = {}): Uint8Array { + const header = ebmlElement([0x1a, 0x45, 0xdf, 0xa3], [0x42, 0x86, 0x81, 0x01]); + const seekHeadBytes = + seekHead === 'none' + ? [] + : ebmlElement(SEEK_HEAD_ID, seekHead === 'empty' ? [] : [0x53, 0xac, 0x81, 0xa1], sizeWidth); + const segmentBody = [ + ...seekHeadBytes, + ...ebmlElement(INFO_ID, info, sizeWidth), + ...CLUSTER_BYTES, + ]; + return new Uint8Array([...header, 0x18, 0x53, 0x80, 0x67, ...UNKNOWN_SIZE, ...segmentBody]); +} + +/** Walks the file the way a player would and pulls Segment > Info > Duration. */ +export function readWebmDuration(bytes: Uint8Array): number | null { + for (let i = 0; i < bytes.length - 4; i++) { + if (!INFO_ID.every((byte, offset) => bytes[i + offset] === byte)) continue; + + const sizeWidth = 8 - Math.floor(Math.log2(bytes[i + 4])); + const infoStart = i + 4 + sizeWidth; + let infoSize = bytes[i + 4] & (0xff >> sizeWidth); + for (let k = 1; k < sizeWidth; k++) infoSize = infoSize * 0x100 + bytes[i + 4 + k]; + + for (let j = infoStart; j < infoStart + infoSize - 1; j++) { + if (bytes[j] === 0x44 && bytes[j + 1] === 0x89) { + const width = bytes[j + 2] & 0x7f; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return width === 4 ? view.getFloat32(j + 3) : view.getFloat64(j + 3); + } + } + return null; + } + return null; +} diff --git a/tests/unit/lib/webm-duration.test.ts b/tests/unit/lib/webm-duration.test.ts new file mode 100644 index 0000000..ec46ab0 --- /dev/null +++ b/tests/unit/lib/webm-duration.test.ts @@ -0,0 +1,115 @@ +// The fixtures here are WebM files in the shape MediaRecorder emits them (see +// tests/helpers/webm-fixture.ts). The assertions parse the patched bytes back, +// because the failure mode that matters is a file that still opens but reports +// the wrong length. + +import { describe, expect, it } from 'vitest'; +import { injectWebmDuration } from '@/lib/webm-duration'; +import { + CLUSTER_BYTES, + DURATION_PLACEHOLDER, + buildLiveWebm, + ebmlElement, + readWebmDuration, + timecodeScaleElement, +} from '../../helpers/webm-fixture'; + +describe('injectWebmDuration', () => { + it('adds a Duration to a live-mode recording that has none', () => { + const source = buildLiveWebm(); + expect(readWebmDuration(source)).toBeNull(); + + const patched = injectWebmDuration(source, 23_400); + + expect(patched).not.toBeNull(); + expect(readWebmDuration(patched!)).toBeCloseTo(23_400, 3); + expect(patched!.length).toBe(source.length + 11); + }); + + it('expresses the duration in timecode ticks, not milliseconds', () => { + // A 100us scale means one tick is a tenth of a millisecond. + const source = buildLiveWebm({ info: timecodeScaleElement(100_000) }); + + const patched = injectWebmDuration(source, 5_000); + + expect(readWebmDuration(patched!)).toBeCloseTo(50_000, 3); + }); + + it('keeps the trailing clusters intact', () => { + const patched = injectWebmDuration(buildLiveWebm(), 1_000)!; + + expect(Array.from(patched.subarray(patched.length - CLUSTER_BYTES.length))).toEqual( + CLUSTER_BYTES + ); + }); + + it('overwrites a Duration that is already there', () => { + const existing = [0x44, 0x89, 0x88, 0x40, 0x59, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 100.0 + const source = buildLiveWebm({ info: [...timecodeScaleElement(1_000_000), ...existing] }); + expect(readWebmDuration(source)).toBeCloseTo(100, 3); + + const patched = injectWebmDuration(source, 8_250)!; + + expect(patched.length).toBe(source.length); + expect(readWebmDuration(patched)).toBeCloseTo(8_250, 3); + }); + + // Firefox writes an empty SeekHead, a Duration reserved as 0.0, and 8-byte + // element sizes. Its recordings were the ones still playing past their end. + it('fills in the duration Firefox reserves and never writes', () => { + const source = buildLiveWebm({ + info: [...timecodeScaleElement(1_000_000), ...DURATION_PLACEHOLDER], + seekHead: 'empty', + sizeWidth: 8, + }); + expect(readWebmDuration(source)).toBe(0); + + const patched = injectWebmDuration(source, 9_500)!; + + expect(patched.length).toBe(source.length); + expect(readWebmDuration(patched)).toBeCloseTo(9_500, 3); + }); + + it('splices into a file whose SeekHead is empty, because it holds no offsets', () => { + const source = buildLiveWebm({ seekHead: 'empty' }); + + const patched = injectWebmDuration(source, 3_000)!; + + expect(patched.length).toBe(source.length + 11); + expect(readWebmDuration(patched)).toBeCloseTo(3_000, 3); + }); + + it('leaves a SeekHead that has entries alone rather than shifting its offsets', () => { + const source = buildLiveWebm({ seekHead: 'entries' }); + + expect(injectWebmDuration(source, 1_000)).toBeNull(); + }); + + it('still fills in a reserved Duration when the SeekHead has entries', () => { + const source = buildLiveWebm({ + info: [...timecodeScaleElement(1_000_000), ...DURATION_PLACEHOLDER], + seekHead: 'entries', + }); + + // Overwriting in place moves no bytes, so the stored offsets stay correct. + expect(readWebmDuration(injectWebmDuration(source, 6_000)!)).toBeCloseTo(6_000, 3); + }); + + it('defaults to a 1ms timecode scale when Info does not declare one', () => { + const source = buildLiveWebm({ info: ebmlElement([0x73, 0xa4], [0x01, 0x02]) }); // SegmentUID + + expect(readWebmDuration(injectWebmDuration(source, 12_000)!)).toBeCloseTo(12_000, 3); + }); + + it('refuses durations that are not usable', () => { + const source = buildLiveWebm(); + + expect(injectWebmDuration(source, 0)).toBeNull(); + expect(injectWebmDuration(source, -5)).toBeNull(); + expect(injectWebmDuration(source, Number.POSITIVE_INFINITY)).toBeNull(); + }); + + it('returns null for bytes that are not a WebM segment', () => { + expect(injectWebmDuration(new Uint8Array([0x00, 0x01, 0x02, 0x03]), 1_000)).toBeNull(); + }); +});