mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(voice): record the real length and write it into the file
The recording clock counted setInterval ticks, which a background tab throttles away: a recording that kept going looked frozen at 13 seconds and was saved with that length. It now reads the wall clock instead. MediaRecorder also writes WebM with no usable duration. Chrome omits the element entirely, Firefox reserves a Duration of 0.0 it never fills in, so players had no length to show and played past the end of the seek bar. lib/webm-duration.ts stamps the recorded length into Segment > Info when the recording stops, in place where the browser reserved room for it.
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
|||||||
validateImageFile,
|
validateImageFile,
|
||||||
} from '@/components/video-page/image-upload-utils';
|
} from '@/components/video-page/image-upload-utils';
|
||||||
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
|
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
|
||||||
|
import { withWebmDuration } from '@/lib/webm-duration';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const audioChunksRef = useRef<BlobPart[]>([]);
|
const audioChunksRef = useRef<BlobPart[]>([]);
|
||||||
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const recordingStartedAtRef = useRef(0);
|
||||||
|
|
||||||
// Drag-drop state
|
// Drag-drop state
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
@@ -717,8 +719,13 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
recorder.ondataavailable = (e) => {
|
recorder.ondataavailable = (e) => {
|
||||||
if (e.data.size > 0) audioChunksRef.current.push(e.data);
|
if (e.data.size > 0) audioChunksRef.current.push(e.data);
|
||||||
};
|
};
|
||||||
recorder.onstop = () => {
|
recorder.onstop = async () => {
|
||||||
const blob = new Blob(audioChunksRef.current, { type: mimeType });
|
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);
|
setAudioBlob(blob);
|
||||||
setAudioBlobUrl((prev) => {
|
setAudioBlobUrl((prev) => {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
@@ -730,7 +737,12 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
recorder.start(100);
|
recorder.start(100);
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setRecordingTime(0);
|
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 {
|
} catch {
|
||||||
toast.error('Could not access microphone');
|
toast.error('Could not access microphone');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
validateImageFile,
|
validateImageFile,
|
||||||
} from '@/components/video-page/image-upload-utils';
|
} from '@/components/video-page/image-upload-utils';
|
||||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||||
|
import { withWebmDuration } from '@/lib/webm-duration';
|
||||||
|
|
||||||
interface UseCommentActionsParams extends CommentActionsConfig {
|
interface UseCommentActionsParams extends CommentActionsConfig {
|
||||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||||
@@ -97,6 +98,7 @@ export function useCommentActions({
|
|||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const audioChunksRef = useRef<Blob[]>([]);
|
const audioChunksRef = useRef<Blob[]>([]);
|
||||||
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const recordingStartedAtRef = useRef(0);
|
||||||
|
|
||||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
@@ -113,6 +115,7 @@ export function useCommentActions({
|
|||||||
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const replyAudioChunksRef = useRef<Blob[]>([]);
|
const replyAudioChunksRef = useRef<Blob[]>([]);
|
||||||
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const replyRecordingStartedAtRef = useRef(0);
|
||||||
|
|
||||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||||
const [editText, setEditText] = useState('');
|
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 recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||||
const blob = new Blob(audioChunksRef.current, { type: recordedMime });
|
const raw = new Blob(audioChunksRef.current, { type: recordedMime });
|
||||||
setAudioBlob(blob);
|
// 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());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
if (recordingTimerRef.current) {
|
if (recordingTimerRef.current) {
|
||||||
clearInterval(recordingTimerRef.current);
|
clearInterval(recordingTimerRef.current);
|
||||||
@@ -457,8 +464,10 @@ export function useCommentActions({
|
|||||||
mediaRecorder.start(100);
|
mediaRecorder.start(100);
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setRecordingTime(0);
|
setRecordingTime(0);
|
||||||
|
// Background tabs throttle timers, so read the clock instead of counting ticks.
|
||||||
|
recordingStartedAtRef.current = Date.now();
|
||||||
recordingTimerRef.current = setInterval(() => {
|
recordingTimerRef.current = setInterval(() => {
|
||||||
setRecordingTime((prev) => prev + 0.1);
|
setRecordingTime((Date.now() - recordingStartedAtRef.current) / 1000);
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start recording:', err);
|
console.error('Failed to start recording:', err);
|
||||||
@@ -849,10 +858,12 @@ export function useCommentActions({
|
|||||||
mediaRecorder.ondataavailable = (e) => {
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
|
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 recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||||
const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime });
|
const raw = new Blob(replyAudioChunksRef.current, { type: recordedMime });
|
||||||
setReplyAudioBlob(blob);
|
setReplyAudioBlob(await withWebmDuration(raw, elapsedMs));
|
||||||
|
setReplyRecordingTime(elapsedMs / 1000);
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
if (replyRecordingTimerRef.current) {
|
if (replyRecordingTimerRef.current) {
|
||||||
clearInterval(replyRecordingTimerRef.current);
|
clearInterval(replyRecordingTimerRef.current);
|
||||||
@@ -862,8 +873,9 @@ export function useCommentActions({
|
|||||||
mediaRecorder.start(100);
|
mediaRecorder.start(100);
|
||||||
setIsReplyRecording(true);
|
setIsReplyRecording(true);
|
||||||
setReplyRecordingTime(0);
|
setReplyRecordingTime(0);
|
||||||
|
replyRecordingStartedAtRef.current = Date.now();
|
||||||
replyRecordingTimerRef.current = setInterval(() => {
|
replyRecordingTimerRef.current = setInterval(() => {
|
||||||
setReplyRecordingTime((prev) => prev + 0.1);
|
setReplyRecordingTime((Date.now() - replyRecordingStartedAtRef.current) / 1000);
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start reply recording:', err);
|
console.error('Failed to start reply recording:', err);
|
||||||
|
|||||||
@@ -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<Blob> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||||
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
||||||
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
||||||
|
import { buildLiveWebm, readWebmDuration } from '../../helpers/webm-fixture';
|
||||||
|
|
||||||
const toastError = vi.fn();
|
const toastError = vi.fn();
|
||||||
const toastSuccess = vi.fn();
|
const toastSuccess = vi.fn();
|
||||||
@@ -871,3 +872,147 @@ describe('useCommentActions background refresh', () => {
|
|||||||
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
|
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<number | null> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user