返回 presentation-ai
useRecording.ts
根目录 / src / hooks / presentation / useRecording.ts
1 import { toPng } from "html-to-image";
2 import { startTransition, useCallback, useEffect, useRef } from "react";
3
4 import { usePresentationRecordingState } from "@/states/presentation-recording-state";
5 import { usePresentationState } from "@/states/presentation-state";
6
7 export function useRecording() {
8 const canvasRef = useRef<HTMLCanvasElement | null>(null);
9 const videoElementRef = useRef<HTMLVideoElement | null>(null);
10 const mediaRecorderRef = useRef<MediaRecorder | null>(null);
11 const animationFrameRef = useRef<number | null>(null);
12 const chunksRef = useRef<Blob[]>([]);
13 const isRecordingRef = useRef(false);
14 const lastSlideImageRef = useRef<string | null>(null);
15 const slideDimensionsRef = useRef<{ width: number; height: number } | null>(
16 null,
17 );
18
19 const overlayX = usePresentationRecordingState((s) => s.overlayX);
20 const overlayY = usePresentationRecordingState((s) => s.overlayY);
21
22 const beginRecording = usePresentationRecordingState((s) => s.beginRecording);
23 const endRecording = usePresentationRecordingState((s) => s.endRecording);
24 const setIsStarting = usePresentationRecordingState((s) => s.setIsStarting);
25 const setIsStopping = usePresentationRecordingState((s) => s.setIsStopping);
26 const setBlobUrl = usePresentationRecordingState((s) => s.setBlobUrl);
27 const currentSlideId = usePresentationState((s) => s.currentSlideId);
28 /* slides unused */
29
30 const camStream = usePresentationRecordingState((s) => s.camStream);
31 // Fixed canvas dimensions
32 const CANVAS_WIDTH = 1920;
33 const CANVAS_HEIGHT = 1080;
34
35 // Initialize canvas and video element
36 const initializeCanvas = useCallback(() => {
37 if (!canvasRef.current) {
38 canvasRef.current = document.createElement("canvas") as HTMLCanvasElement;
39 canvasRef.current.width = CANVAS_WIDTH;
40 canvasRef.current.height = CANVAS_HEIGHT;
41 }
42
43 if (!videoElementRef.current) {
44 videoElementRef.current = document.querySelector(
45 ".presentation-webcam-overlay",
46 ) as HTMLVideoElement;
47 }
48 }, []);
49
50 // Calculate fitted dimensions for letterboxing
51 const calculateFittedDimensions = useCallback(
52 (srcWidth: number, srcHeight: number) => {
53 const canvasAspect = CANVAS_WIDTH / CANVAS_HEIGHT;
54 const srcAspect = srcWidth / srcHeight;
55
56 let drawWidth: number;
57 let drawHeight: number;
58 let offsetX: number;
59 let offsetY: number;
60
61 if (srcAspect > canvasAspect) {
62 // Source is wider - fit to width
63 drawWidth = CANVAS_WIDTH;
64 drawHeight = CANVAS_WIDTH / srcAspect;
65 offsetX = 0;
66 offsetY = (CANVAS_HEIGHT - drawHeight) / 2;
67 } else {
68 // Source is taller - fit to height
69 drawHeight = CANVAS_HEIGHT;
70 drawWidth = CANVAS_HEIGHT * srcAspect;
71 offsetX = (CANVAS_WIDTH - drawWidth) / 2;
72 offsetY = 0;
73 }
74
75 return { drawWidth, drawHeight, offsetX, offsetY };
76 },
77 [],
78 );
79
80 // Capture content element to canvas
81 const captureContentToCanvas = useCallback(async () => {
82 if (!canvasRef.current) return;
83
84 await document.fonts.ready;
85 if (!currentSlideId) return;
86
87 const contentElement = document.querySelector(
88 `.slide-container-${currentSlideId}`,
89 ) as HTMLElement;
90
91 if (!contentElement) return;
92
93 // Get actual slide dimensions
94 const rect = contentElement.getBoundingClientRect();
95 slideDimensionsRef.current = {
96 width: rect.width,
97 height: rect.height,
98 };
99
100 try {
101 const options = {
102 quality: 1,
103 pixelRatio: 2,
104 skipAutoScale: false,
105 cacheBust: true,
106 filter: (node: HTMLElement) => {
107 if (node.tagName === "use") return false;
108 if (node.tagName === "IFRAME") return false;
109 if (node.tagName === "VIDEO") return false;
110 return true;
111 },
112 };
113
114 try {
115 const screenshot = await toPng(contentElement, {
116 ...options,
117 onImageErrorHandler: (error) => {
118 console.error("Error capturing image:", error);
119 },
120 });
121
122 lastSlideImageRef.current = screenshot;
123 } catch (error) {
124 console.error("Error converting html to PNG:", error);
125 }
126 } catch (error) {
127 console.error("Error capturing content:", error);
128 }
129 }, [currentSlideId]);
130
131 // Draw video overlay on canvas with rounded corners
132 const drawVideoOverlay = useCallback(
133 (ctx: CanvasRenderingContext2D) => {
134 if (!videoElementRef.current) return;
135 const video = videoElementRef.current;
136 const rect = video.getBoundingClientRect();
137 if (video.readyState >= video.HAVE_CURRENT_DATA) {
138 const width = 320;
139 const height = 180;
140 const x = rect.left;
141 const y = rect.top;
142 const radius = 12;
143
144 // Calculate aspect ratios for object-fit: cover behavior
145 const videoAspect = video.videoWidth / video.videoHeight;
146 const targetAspect = width / height;
147
148 let sx = 0,
149 sy = 0,
150 sWidth = video.videoWidth,
151 sHeight = video.videoHeight;
152
153 if (videoAspect > targetAspect) {
154 // Video is wider - crop sides
155 sWidth = video.videoHeight * targetAspect;
156 sx = (video.videoWidth - sWidth) / 2;
157 } else {
158 // Video is taller - crop top/bottom
159 sHeight = video.videoWidth / targetAspect;
160 sy = (video.videoHeight - sHeight) / 2;
161 }
162
163 ctx.save();
164 // Create rounded rectangle clipping path
165 ctx.beginPath();
166 ctx.moveTo(x + radius, y);
167 ctx.lineTo(x + width - radius, y);
168 ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
169 ctx.lineTo(x + width, y + height - radius);
170 ctx.quadraticCurveTo(
171 x + width,
172 y + height,
173 x + width - radius,
174 y + height,
175 );
176 ctx.lineTo(x + radius, y + height);
177 ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
178 ctx.lineTo(x, y + radius);
179 ctx.quadraticCurveTo(x, y, x + radius, y);
180 ctx.closePath();
181 ctx.clip();
182
183 // Draw cropped video using 9-parameter drawImage
184 ctx.drawImage(video, sx, sy, sWidth, sHeight, x, y, width, height);
185
186 ctx.restore();
187 // Draw border
188 ctx.strokeStyle = "rgba(255, 255, 255, 0.8)";
189 ctx.lineWidth = 3;
190 ctx.beginPath();
191 ctx.moveTo(x + radius, y);
192 ctx.lineTo(x + width - radius, y);
193 ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
194 ctx.lineTo(x + width, y + height - radius);
195 ctx.quadraticCurveTo(
196 x + width,
197 y + height,
198 x + width - radius,
199 y + height,
200 );
201 ctx.lineTo(x + radius, y + height);
202 ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
203 ctx.lineTo(x, y + radius);
204 ctx.quadraticCurveTo(x, y, x + radius, y);
205 ctx.closePath();
206 ctx.stroke();
207 }
208 },
209 [overlayX, overlayY],
210 );
211 // Animation loop - composite slide + video overlay with letterboxing
212 const renderLoop = useCallback(() => {
213 if (!isRecordingRef.current || !canvasRef.current) return;
214
215 const canvas = canvasRef.current;
216 const ctx = canvas.getContext("2d");
217
218 if (!ctx) return;
219
220 // Fill entire canvas with black background
221 ctx.fillStyle = "#000000";
222 ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
223
224 // Draw the last captured slide image with letterboxing
225 if (lastSlideImageRef.current && slideDimensionsRef.current) {
226 const image = new Image();
227 image.src = lastSlideImageRef.current;
228
229 // Calculate fitted dimensions
230 const { drawWidth, drawHeight, offsetX, offsetY } =
231 calculateFittedDimensions(
232 slideDimensionsRef.current.width,
233 slideDimensionsRef.current.height,
234 );
235
236 ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
237
238 // Draw video overlay on top with proper positioning
239 drawVideoOverlay(ctx);
240 }
241
242 // Continue loop
243 animationFrameRef.current = requestAnimationFrame(renderLoop);
244 }, [drawVideoOverlay, calculateFittedDimensions]);
245
246 // Update slide content when slide changes
247 useEffect(() => {
248 if (isRecordingRef.current) {
249 startTransition(() => {
250 captureContentToCanvas();
251 });
252 }
253 }, [currentSlideId, captureContentToCanvas]);
254
255 // Start recording
256 const startRecording = useCallback(async () => {
257 if (isRecordingRef.current) return;
258 setIsStarting(true);
259 initializeCanvas();
260
261 // Capture initial content
262 await captureContentToCanvas();
263
264 // Wait for video to be ready
265 if (videoElementRef.current) {
266 await videoElementRef.current.play().catch(console.error);
267 }
268
269 // Get canvas stream
270 const canvas = canvasRef.current;
271 if (!canvas) return;
272 const canvasStream = canvas.captureStream(30);
273
274 // We only mark recording begun after stream capture is ready
275 isRecordingRef.current = true;
276 renderLoop();
277 beginRecording();
278
279 // Add audio track if available
280 if (camStream) {
281 const audioTracks = camStream.getAudioTracks();
282 if (audioTracks.length > 0) {
283 canvasStream.addTrack(audioTracks[0]!);
284 }
285 }
286
287 // Create MediaRecorder
288 const mediaRecorder = new MediaRecorder(canvasStream, {
289 mimeType: "video/webm;codecs=vp9",
290 videoBitsPerSecond: 2500000,
291 });
292
293 chunksRef.current = [];
294
295 mediaRecorder.ondataavailable = (event) => {
296 if (event.data.size > 0) {
297 chunksRef.current.push(event.data);
298 }
299 };
300
301 mediaRecorderRef.current = mediaRecorder;
302 mediaRecorder.start();
303 setIsStarting(false);
304
305 return true;
306 }, [
307 beginRecording,
308 initializeCanvas,
309 captureContentToCanvas,
310 renderLoop,
311 camStream,
312 setIsStarting,
313 ]);
314
315 // Stop recording
316 const stopRecording = useCallback(() => {
317 return new Promise((resolve) => {
318 setIsStopping(true);
319 if (!isRecordingRef.current || !mediaRecorderRef.current) {
320 endRecording();
321 setIsStopping(false);
322 resolve(null);
323 return;
324 }
325
326 isRecordingRef.current = false;
327
328 // Stop animation loop
329 if (animationFrameRef.current) {
330 cancelAnimationFrame(animationFrameRef.current);
331 }
332
333 // Stop media recorder
334 const mediaRecorder = mediaRecorderRef.current;
335
336 mediaRecorder.onstop = () => {
337 const blob = new Blob(chunksRef.current, { type: "video/webm" });
338 chunksRef.current = [];
339 setBlobUrl(URL.createObjectURL(blob));
340 endRecording();
341 setIsStopping(false);
342 resolve(blob);
343 };
344
345 // Give a short grace period to flush final frames before stopping
346 // and ensure all tracks are ended after stop to avoid early cutoff
347 try {
348 mediaRecorder.requestData?.();
349 } catch {}
350 mediaRecorder.stop();
351 });
352 }, [endRecording, setBlobUrl, setIsStopping]);
353
354 // Cleanup
355 const cleanup = useCallback(() => {
356 if (animationFrameRef.current) {
357 cancelAnimationFrame(animationFrameRef.current);
358 }
359
360 if (videoElementRef.current) {
361 videoElementRef.current.srcObject = null;
362 }
363
364 isRecordingRef.current = false;
365 lastSlideImageRef.current = null;
366 slideDimensionsRef.current = null;
367 }, []);
368
369 return {
370 start: startRecording,
371 stop: stopRecording,
372 cleanup,
373 };
374 }
375
375 lines TYPESCRIPT