moonlantern1 commited on
Commit
d4996c8
·
verified ·
1 Parent(s): a733514

Harden mobile recorder flush

Browse files
Files changed (1) hide show
  1. src/hooks/useGuidedRecording.ts +126 -64
src/hooks/useGuidedRecording.ts CHANGED
@@ -23,6 +23,10 @@ const audioRecorderMimePriority = [
23
  'audio/mp4',
24
  ];
25
 
 
 
 
 
26
  function pickRecorderMime(mediaType: 'video' | 'audio') {
27
  if (typeof MediaRecorder === 'undefined') return undefined;
28
  const mimes = mediaType === 'audio' ? audioRecorderMimePriority : recorderMimePriority;
@@ -36,13 +40,41 @@ function extFromMime(mime: string | undefined): 'webm' | 'mp4' | 'mov' {
36
  if (!mime) return 'webm';
37
  if (mime.includes('mp4')) return 'mp4';
38
  if (mime.includes('quicktime')) return 'mov';
39
- return 'webm';
40
- }
41
-
42
- export type UseGuidedRecordingOptions = {
43
- prompt: ClipPrompt;
44
- /** Called once a recording finishes (either user-stopped or auto-stopped on hard cap). */
45
- onClipReady: (clip: { blob: Blob; durationSeconds: number; ext: 'webm' | 'mp4' | 'mov' }) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  };
47
 
48
  export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOptions) {
@@ -57,10 +89,10 @@ export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOp
57
  const chunksRef = useRef<BlobPart[]>([]);
58
  const startedAtRef = useRef<number>(0);
59
  const tickRef = useRef<number | null>(null);
60
- const autoStopRef = useRef<number | null>(null);
61
-
62
- const stopTicking = useCallback(() => {
63
- if (tickRef.current !== null) {
64
  window.clearInterval(tickRef.current);
65
  tickRef.current = null;
66
  }
@@ -74,10 +106,33 @@ export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOp
74
  }, []);
75
 
76
  const stopStream = useCallback(() => {
77
- streamRef.current?.getTracks().forEach((t) => t.stop());
78
- streamRef.current = null;
79
- }, []);
80
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  const requestPermissionAndPreview = useCallback(async () => {
82
  setError(null);
83
  setState('requesting_permission');
@@ -111,15 +166,11 @@ export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOp
111
  // shots should keep the same camera warm instead of tearing it down.
112
  useEffect(() => {
113
  void requestPermissionAndPreview();
114
- return () => {
115
- stopTicking();
116
- stopAutoStop();
117
- try {
118
- recorderRef.current?.stop();
119
- } catch {
120
- /* ignore */
121
- }
122
- stopStream();
123
  };
124
  // eslint-disable-next-line react-hooks/exhaustive-deps
125
  }, [prompt.camera, mediaType]);
@@ -140,55 +191,66 @@ export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOp
140
  recorderRef.current = recorder;
141
  chunksRef.current = [];
142
 
143
- recorder.ondataavailable = (e) => {
144
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
145
- };
146
-
147
- recorder.onstop = () => {
148
- stopTicking();
149
- stopAutoStop();
150
- const elapsedSeconds = Math.max(
151
- 0,
152
- Math.round((Date.now() - startedAtRef.current) / 1000),
153
- );
154
- const blobMime = mime ?? 'video/webm';
155
- const blob = new Blob(chunksRef.current, { type: blobMime });
156
- chunksRef.current = [];
157
- setState('finalizing');
158
- onClipReady({
159
- blob,
160
- durationSeconds: elapsedSeconds,
161
- ext: extFromMime(mime),
162
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  // Brief finalize state for UX, then return to ready (parent may unmount).
164
  window.setTimeout(() => setState('ready'), 200);
165
  };
166
 
167
- startedAtRef.current = Date.now();
168
- setElapsedMs(0);
169
- setState('recording');
170
- recorder.start();
171
 
172
  tickRef.current = window.setInterval(() => {
173
  setElapsedMs(Date.now() - startedAtRef.current);
174
  }, 100);
175
 
176
- autoStopRef.current = window.setTimeout(() => {
177
- try {
178
- recorderRef.current?.stop();
179
- } catch {
180
- /* ignore */
181
- }
182
- }, prompt.maxSeconds * 1000);
183
- }, [mediaType, onClipReady, prompt.maxSeconds, stopAutoStop, stopTicking]);
184
-
185
- const stopRecording = useCallback(() => {
186
- try {
187
- recorderRef.current?.stop();
188
- } catch {
189
- /* ignore */
190
- }
191
- }, []);
192
 
193
  const liveProgress = Math.min(1, elapsedMs / (prompt.maxSeconds * 1000));
194
 
 
23
  'audio/mp4',
24
  ];
25
 
26
+ const RECORDER_TIMESLICE_MS = 500;
27
+ const MIN_VIDEO_BLOB_BYTES = 16 * 1024;
28
+ const MIN_AUDIO_BLOB_BYTES = 4 * 1024;
29
+
30
  function pickRecorderMime(mediaType: 'video' | 'audio') {
31
  if (typeof MediaRecorder === 'undefined') return undefined;
32
  const mimes = mediaType === 'audio' ? audioRecorderMimePriority : recorderMimePriority;
 
40
  if (!mime) return 'webm';
41
  if (mime.includes('mp4')) return 'mp4';
42
  if (mime.includes('quicktime')) return 'mov';
43
+ return 'webm';
44
+ }
45
+
46
+ async function canReadRecordedMedia(blob: Blob, mediaType: 'video' | 'audio') {
47
+ return await new Promise<boolean>((resolve) => {
48
+ const url = URL.createObjectURL(blob);
49
+ const media = document.createElement(mediaType);
50
+ let settled = false;
51
+
52
+ const finish = (ok: boolean) => {
53
+ if (settled) return;
54
+ settled = true;
55
+ URL.revokeObjectURL(url);
56
+ resolve(ok);
57
+ };
58
+
59
+ const timer = window.setTimeout(() => finish(false), 4000);
60
+ media.preload = 'metadata';
61
+ media.muted = true;
62
+ media.onloadedmetadata = () => {
63
+ window.clearTimeout(timer);
64
+ finish(true);
65
+ };
66
+ media.onerror = () => {
67
+ window.clearTimeout(timer);
68
+ finish(false);
69
+ };
70
+ media.src = url;
71
+ });
72
+ }
73
+
74
+ export type UseGuidedRecordingOptions = {
75
+ prompt: ClipPrompt;
76
+ /** Called once a recording finishes (either user-stopped or auto-stopped on hard cap). */
77
+ onClipReady: (clip: { blob: Blob; durationSeconds: number; ext: 'webm' | 'mp4' | 'mov' }) => void;
78
  };
79
 
80
  export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOptions) {
 
89
  const chunksRef = useRef<BlobPart[]>([]);
90
  const startedAtRef = useRef<number>(0);
91
  const tickRef = useRef<number | null>(null);
92
+ const autoStopRef = useRef<number | null>(null);
93
+
94
+ const stopTicking = useCallback(() => {
95
+ if (tickRef.current !== null) {
96
  window.clearInterval(tickRef.current);
97
  tickRef.current = null;
98
  }
 
106
  }, []);
107
 
108
  const stopStream = useCallback(() => {
109
+ streamRef.current?.getTracks().forEach((t) => t.stop());
110
+ streamRef.current = null;
111
+ }, []);
112
+
113
+ const stopActiveRecorder = useCallback(() => {
114
+ const recorder = recorderRef.current;
115
+ if (!recorder || recorder.state === 'inactive') return;
116
+
117
+ setState('finalizing');
118
+ stopTicking();
119
+ stopAutoStop();
120
+
121
+ try {
122
+ recorder.requestData();
123
+ } catch {
124
+ /* ignore */
125
+ }
126
+
127
+ window.setTimeout(() => {
128
+ try {
129
+ if (recorder.state !== 'inactive') recorder.stop();
130
+ } catch {
131
+ /* ignore */
132
+ }
133
+ }, 100);
134
+ }, [stopAutoStop, stopTicking]);
135
+
136
  const requestPermissionAndPreview = useCallback(async () => {
137
  setError(null);
138
  setState('requesting_permission');
 
166
  // shots should keep the same camera warm instead of tearing it down.
167
  useEffect(() => {
168
  void requestPermissionAndPreview();
169
+ return () => {
170
+ stopTicking();
171
+ stopAutoStop();
172
+ stopActiveRecorder();
173
+ stopStream();
 
 
 
 
174
  };
175
  // eslint-disable-next-line react-hooks/exhaustive-deps
176
  }, [prompt.camera, mediaType]);
 
191
  recorderRef.current = recorder;
192
  chunksRef.current = [];
193
 
194
+ recorder.ondataavailable = (e) => {
195
+ if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
196
+ };
197
+
198
+ recorder.onerror = () => {
199
+ setError('Recording failed on this device. Please try this clip again.');
200
+ setState('ready');
201
+ };
202
+
203
+ recorder.onstop = async () => {
204
+ stopTicking();
205
+ stopAutoStop();
206
+ const elapsedSeconds = Math.max(
207
+ 0,
208
+ Math.round((Date.now() - startedAtRef.current) / 1000),
209
+ );
210
+ const blobMime = mime ?? 'video/webm';
211
+ const chunks = chunksRef.current.filter((chunk) => {
212
+ if (chunk instanceof Blob) return chunk.size > 0;
213
+ return true;
214
+ });
215
+ chunksRef.current = [];
216
+ setState('finalizing');
217
+
218
+ const blob = new Blob(chunks, { type: blobMime });
219
+ const minBytes = mediaType === 'audio' ? MIN_AUDIO_BLOB_BYTES : MIN_VIDEO_BLOB_BYTES;
220
+ const readable = blob.size >= minBytes && (await canReadRecordedMedia(blob, mediaType));
221
+
222
+ if (!readable) {
223
+ setError('That clip did not finish saving cleanly. Please record this prompt again.');
224
+ setState('ready');
225
+ return;
226
+ }
227
+
228
+ onClipReady({
229
+ blob,
230
+ durationSeconds: elapsedSeconds,
231
+ ext: extFromMime(mime),
232
+ });
233
  // Brief finalize state for UX, then return to ready (parent may unmount).
234
  window.setTimeout(() => setState('ready'), 200);
235
  };
236
 
237
+ startedAtRef.current = Date.now();
238
+ setElapsedMs(0);
239
+ setState('recording');
240
+ recorder.start(RECORDER_TIMESLICE_MS);
241
 
242
  tickRef.current = window.setInterval(() => {
243
  setElapsedMs(Date.now() - startedAtRef.current);
244
  }, 100);
245
 
246
+ autoStopRef.current = window.setTimeout(() => {
247
+ stopActiveRecorder();
248
+ }, prompt.maxSeconds * 1000);
249
+ }, [mediaType, onClipReady, prompt.maxSeconds, stopActiveRecorder, stopAutoStop, stopTicking]);
250
+
251
+ const stopRecording = useCallback(() => {
252
+ stopActiveRecorder();
253
+ }, [stopActiveRecorder]);
 
 
 
 
 
 
 
 
254
 
255
  const liveProgress = Math.min(1, elapsedMs / (prompt.maxSeconds * 1000));
256