返回 presentation-ai
PresentationThumbnailCaptureManager.tsx
根目录 / src / components / presentation / slides / PresentationThumbnailCaptureManager.tsx
1 "use client";
2
3 import { toBlob } from "html-to-image";
4 import { useQueryClient } from "@tanstack/react-query";
5 import { useEffect, useMemo, useRef } from "react";
6
7 import { updatePresentationThumbnailUrl } from "@/app/_actions/presentation/presentation-thumbnail-actions";
8 import StaticPresentationEditor from "@/components/notebook/presentation/editor/presentation-editor-static";
9 import { slideSignature } from "@/components/notebook/presentation/editor/utils/slideSignature";
10 import { uploadFiles } from "@/hooks/globals/useUploadthing";
11 import { getPresentationImageGenerationKey } from "@/lib/presentation/image-generation";
12 import { usePresentationState } from "@/states/presentation-state";
13
14 const THUMBNAIL_WIDTH = 1280;
15 const THUMBNAIL_HEIGHT = 720;
16 const THUMBNAIL_CAPTURE_DELAY_MS = 900;
17
18 type PresentationGenerationSnapshot = Pick<
19 ReturnType<typeof usePresentationState.getState>,
20 | "isGeneratingPresentation"
21 | "shouldStartImageSlideGeneration"
22 | "shouldStartPresentationGeneration"
23 >;
24
25 function isPresentationGenerationPending(
26 state: PresentationGenerationSnapshot,
27 ): boolean {
28 return (
29 state.isGeneratingPresentation ||
30 state.shouldStartPresentationGeneration ||
31 state.shouldStartImageSlideGeneration
32 );
33 }
34
35 function waitForAnimationFrame(): Promise<void> {
36 return new Promise((resolve) => {
37 requestAnimationFrame(() => {
38 requestAnimationFrame(() => resolve());
39 });
40 });
41 }
42
43 async function waitForImageElement(image: HTMLImageElement): Promise<void> {
44 if (!image.complete) {
45 await new Promise<void>((resolve) => {
46 image.addEventListener("load", () => resolve(), { once: true });
47 image.addEventListener("error", () => resolve(), { once: true });
48 });
49 }
50
51 if (typeof image.decode === "function") {
52 await image.decode().catch(() => undefined);
53 }
54 }
55
56 async function waitForRenderableAssets(element: HTMLElement): Promise<void> {
57 await document.fonts.ready.catch(() => undefined);
58
59 const images = Array.from(element.querySelectorAll("img"));
60 await Promise.all(images.map((image) => waitForImageElement(image)));
61 await waitForAnimationFrame();
62 }
63
64 function hasActiveImageGenerationForSlide(
65 presentationId: string,
66 slideId: string,
67 ): boolean {
68 const { rootImageGeneration } = usePresentationState.getState();
69
70 return Object.entries(rootImageGeneration).some(([key, job]) => {
71 if (job.presentationId !== presentationId) {
72 return false;
73 }
74
75 if (job.target.slideId !== slideId) {
76 return false;
77 }
78
79 if (getPresentationImageGenerationKey(job.target) !== key) {
80 return false;
81 }
82
83 return job.status === "queued" || job.status === "generating";
84 });
85 }
86
87 async function captureSlideThumbnailBlob(
88 element: HTMLElement,
89 ): Promise<Blob> {
90 await waitForRenderableAssets(element);
91
92 const blob = await toBlob(element, {
93 cacheBust: true,
94 fontEmbedCSS: "",
95 height: THUMBNAIL_HEIGHT,
96 pixelRatio: 1,
97 quality: 0.95,
98 skipFonts: true,
99 skipAutoScale: true,
100 width: THUMBNAIL_WIDTH,
101 filter: (node) => {
102 if (!(node instanceof HTMLElement)) {
103 return true;
104 }
105
106 return node.tagName !== "IFRAME" && node.tagName !== "VIDEO";
107 },
108 });
109
110 if (!blob) {
111 throw new Error("Failed to capture presentation thumbnail");
112 }
113
114 return blob;
115 }
116
117 export function PresentationThumbnailCaptureManager() {
118 const queryClient = useQueryClient();
119 const captureElementRef = useRef<HTMLDivElement | null>(null);
120 const activeCaptureKeyRef = useRef<string | null>(null);
121 const completedCaptureKeyRef = useRef<string | null>(null);
122 const initializedPresentationIdRef = useRef<string | null>(null);
123 const wasThumbnailCaptureBlockedRef = useRef(false);
124
125 const currentPresentationId = usePresentationState(
126 (state) => state.currentPresentationId,
127 );
128 const firstSlide = usePresentationState((state) => state.slides[0]);
129 const isGeneratingPresentation = usePresentationState(
130 (state) => state.isGeneratingPresentation,
131 );
132 const shouldStartPresentationGeneration = usePresentationState(
133 (state) => state.shouldStartPresentationGeneration,
134 );
135 const shouldStartImageSlideGeneration = usePresentationState(
136 (state) => state.shouldStartImageSlideGeneration,
137 );
138 const thumbnailUrl = usePresentationState((state) => state.thumbnailUrl);
139 const setThumbnailUrl = usePresentationState((state) => state.setThumbnailUrl);
140 const rootImageGeneration = usePresentationState(
141 (state) => state.rootImageGeneration,
142 );
143
144 const firstSlideSignature = useMemo(
145 () => slideSignature(firstSlide),
146 [firstSlide],
147 );
148 const captureKey =
149 currentPresentationId && firstSlide
150 ? `${currentPresentationId}:${firstSlideSignature}`
151 : null;
152 const isThumbnailCaptureBlockedByGeneration =
153 isPresentationGenerationPending({
154 isGeneratingPresentation,
155 shouldStartImageSlideGeneration,
156 shouldStartPresentationGeneration,
157 });
158
159 useEffect(() => {
160 if (!currentPresentationId) {
161 initializedPresentationIdRef.current = null;
162 completedCaptureKeyRef.current = null;
163 wasThumbnailCaptureBlockedRef.current = false;
164 return;
165 }
166
167 if (
168 captureKey &&
169 thumbnailUrl &&
170 initializedPresentationIdRef.current !== currentPresentationId
171 ) {
172 initializedPresentationIdRef.current = currentPresentationId;
173 completedCaptureKeyRef.current = captureKey;
174 }
175 }, [captureKey, currentPresentationId, thumbnailUrl]);
176
177 useEffect(() => {
178 if (!currentPresentationId) {
179 wasThumbnailCaptureBlockedRef.current = false;
180 return;
181 }
182
183 if (
184 wasThumbnailCaptureBlockedRef.current &&
185 !isThumbnailCaptureBlockedByGeneration &&
186 captureKey
187 ) {
188 completedCaptureKeyRef.current = null;
189 }
190
191 wasThumbnailCaptureBlockedRef.current =
192 isThumbnailCaptureBlockedByGeneration;
193 }, [
194 captureKey,
195 currentPresentationId,
196 isThumbnailCaptureBlockedByGeneration,
197 ]);
198
199 useEffect(() => {
200 if (
201 !currentPresentationId ||
202 !firstSlide ||
203 !captureKey ||
204 isThumbnailCaptureBlockedByGeneration
205 ) {
206 return;
207 }
208
209 if (
210 activeCaptureKeyRef.current === captureKey ||
211 completedCaptureKeyRef.current === captureKey
212 ) {
213 return;
214 }
215
216 if (hasActiveImageGenerationForSlide(currentPresentationId, firstSlide.id)) {
217 return;
218 }
219
220 const timeout = window.setTimeout(() => {
221 const expectedCaptureKey = captureKey;
222 const expectedPresentationId = currentPresentationId;
223 const expectedSlideId = firstSlide.id;
224
225 void (async () => {
226 const captureElement = captureElementRef.current;
227 const latestState = usePresentationState.getState();
228
229 if (
230 !captureElement ||
231 latestState.currentPresentationId !== expectedPresentationId ||
232 latestState.slides[0]?.id !== expectedSlideId ||
233 isPresentationGenerationPending(latestState) ||
234 hasActiveImageGenerationForSlide(
235 expectedPresentationId,
236 expectedSlideId,
237 )
238 ) {
239 return;
240 }
241
242 activeCaptureKeyRef.current = expectedCaptureKey;
243
244 try {
245 const blob = await captureSlideThumbnailBlob(captureElement);
246 const stateAfterCapture = usePresentationState.getState();
247
248 if (
249 stateAfterCapture.currentPresentationId !==
250 expectedPresentationId ||
251 stateAfterCapture.slides[0]?.id !== expectedSlideId ||
252 isPresentationGenerationPending(stateAfterCapture) ||
253 hasActiveImageGenerationForSlide(
254 expectedPresentationId,
255 expectedSlideId,
256 )
257 ) {
258 return;
259 }
260
261 const file = new File(
262 [blob],
263 `presentation-thumbnail-${expectedPresentationId}.png`,
264 { type: "image/png" },
265 );
266
267 const uploadedFiles = await uploadFiles("imageUploader", {
268 files: [file],
269 });
270
271 const uploadedUrl = uploadedFiles?.[0]?.ufsUrl;
272 if (!uploadedUrl) {
273 throw new Error("Failed to upload presentation thumbnail");
274 }
275
276 const stateAfterUpload = usePresentationState.getState();
277 if (
278 stateAfterUpload.currentPresentationId !== expectedPresentationId ||
279 stateAfterUpload.slides[0]?.id !== expectedSlideId ||
280 isPresentationGenerationPending(stateAfterUpload)
281 ) {
282 return;
283 }
284
285 const result = await updatePresentationThumbnailUrl({
286 id: expectedPresentationId,
287 thumbnailUrl: uploadedUrl,
288 });
289
290 if (!result.success) {
291 throw new Error(result.message);
292 }
293
294 if (
295 usePresentationState.getState().currentPresentationId ===
296 expectedPresentationId
297 ) {
298 completedCaptureKeyRef.current = expectedCaptureKey;
299 setThumbnailUrl(uploadedUrl);
300 void Promise.all([
301 queryClient.invalidateQueries({
302 queryKey: ["presentation", expectedPresentationId],
303 }),
304 queryClient.invalidateQueries({ queryKey: ["presentations"] }),
305 queryClient.invalidateQueries({ queryKey: ["presentations-all"] }),
306 queryClient.invalidateQueries({ queryKey: ["recent-items"] }),
307 ]);
308 }
309 } catch (error) {
310 console.error("Failed to generate presentation thumbnail:", error);
311 } finally {
312 if (activeCaptureKeyRef.current === expectedCaptureKey) {
313 activeCaptureKeyRef.current = null;
314 }
315 }
316 })();
317 }, THUMBNAIL_CAPTURE_DELAY_MS);
318
319 return () => window.clearTimeout(timeout);
320 }, [
321 captureKey,
322 currentPresentationId,
323 firstSlide,
324 firstSlideSignature,
325 isThumbnailCaptureBlockedByGeneration,
326 queryClient,
327 rootImageGeneration,
328 setThumbnailUrl,
329 ]);
330
331 if (!firstSlide) {
332 return null;
333 }
334
335 return (
336 <div
337 aria-hidden="true"
338 className="pointer-events-none fixed top-0 -left-[10000px] -z-50 overflow-hidden"
339 style={{
340 height: THUMBNAIL_HEIGHT,
341 width: THUMBNAIL_WIDTH,
342 }}
343 >
344 <div
345 ref={captureElementRef}
346 className="overflow-hidden bg-(--presentation-background)"
347 style={{
348 height: THUMBNAIL_HEIGHT,
349 width: THUMBNAIL_WIDTH,
350 }}
351 >
352 <StaticPresentationEditor
353 id={`thumbnail-${firstSlide.id}`}
354 initialContent={firstSlide}
355 isPresenting
356 className="!h-[720px] !min-h-[720px] !w-[1280px] !border-0"
357 />
358 </div>
359 </div>
360 );
361 }
362
362 lines Plain Text