返回 oh-my-ppt
html-thumbnail-service.ts
根目录 / src / main / io / thumbnails / html-thumbnail-service.ts
1 import { app, BrowserWindow, type WebContents } from 'electron'
2 import { is } from '@electron-toolkit/utils'
3 import { createHash } from 'node:crypto'
4 import fs from 'node:fs'
5 import path from 'node:path'
6 import { pathToFileURL } from 'node:url'
7 import pLimit from 'p-limit'
8 import type { PPTDatabase, ThumbnailRecord } from '../../db/database'
9 import type { HtmlThumbnailResourceType } from '@shared/thumbnail'
10 import { allowLocalAssetRoot } from '../local-asset-roots'
11 import { FREEZE_PAGE_FOR_EXPORT_SCRIPT } from '../html-pptx/browser-scripts'
12
13 const DEFAULT_CAPTURE_WIDTH = 1600
14 const DEFAULT_CAPTURE_HEIGHT = 900
15 const DEFAULT_THUMBNAIL_WIDTH = 640
16 const DEFAULT_THUMBNAIL_HEIGHT = 360
17 export const HTML_THUMBNAIL_CONCURRENCY = 2
18 const PRINT_READY_PREFIX = '__PPT_PRINT_READY__'
19 const PRINT_READY_DEFAULT_TIMEOUT_MS = 8000
20 const PRINT_READY_SETTLE_MS = 120
21 const PRINT_READY_PASS_TWO_DELAY_MS = 450
22 const PRINT_READY_PASS_THREE_DELAY_MS = 80
23 const MAX_SOURCE_STABILITY_ATTEMPTS = 2
24
25 export type HtmlThumbnailRequest = {
26 resourceType: HtmlThumbnailResourceType
27 resourceId: string
28 variant?: string
29 sourcePath: string
30 pageId?: string
31 query?: Record<string, string>
32 captureWidth?: number
33 captureHeight?: number
34 thumbnailWidth?: number
35 thumbnailHeight?: number
36 }
37
38 export type HtmlThumbnailTaskStatus = 'queued' | 'running' | 'completed' | 'failed'
39
40 export type HtmlThumbnailTask = {
41 resourceType: HtmlThumbnailResourceType
42 resourceId: string
43 variant: string
44 status: HtmlThumbnailTaskStatus
45 thumbnailPath: string | null
46 error?: string
47 }
48
49 export type HtmlPageScreenshotRequest = {
50 sourcePath: string
51 pageId: string
52 width: number
53 height: number
54 }
55
56 let thumbnailDb: PPTDatabase | null = null
57 const thumbnailLimit = pLimit(HTML_THUMBNAIL_CONCURRENCY)
58 const backgroundTasks = new Map<string, HtmlThumbnailTask>()
59 const taskListeners = new Set<(task: HtmlThumbnailTask) => void>()
60
61 export function configureHtmlThumbnailService(db: PPTDatabase): void {
62 thumbnailDb = db
63 const cacheRoot = resolveHtmlThumbnailCacheRoot()
64 fs.mkdirSync(cacheRoot, { recursive: true })
65 for (const entry of fs.readdirSync(cacheRoot, { withFileTypes: true })) {
66 if (entry.isFile() && entry.name.endsWith('.tmp')) {
67 try {
68 fs.rmSync(path.join(cacheRoot, entry.name), { force: true })
69 } catch {
70 // A stale temp file must not prevent the app from starting.
71 }
72 }
73 }
74 allowLocalAssetRoot(cacheRoot)
75 }
76
77 export function onHtmlThumbnailTaskChanged(
78 listener: (task: HtmlThumbnailTask) => void
79 ): () => void {
80 taskListeners.add(listener)
81 return () => taskListeners.delete(listener)
82 }
83
84 function emitTaskChanged(task: HtmlThumbnailTask): void {
85 for (const listener of taskListeners) listener({ ...task })
86 }
87
88 function getDb(): PPTDatabase {
89 if (!thumbnailDb) throw new Error('Thumbnail service is not initialized')
90 return thumbnailDb
91 }
92
93 function thumbnailTaskKey(
94 resourceType: HtmlThumbnailResourceType,
95 resourceId: string,
96 variant: string
97 ): string {
98 return `${resourceType}\u0000${resourceId}\u0000${variant}`
99 }
100
101 function normalizeDimension(value: number | undefined, fallback: number): number {
102 return typeof value === 'number' && Number.isFinite(value)
103 ? Math.max(64, Math.min(4096, Math.round(value)))
104 : fallback
105 }
106
107 function normalizeRequest(request: HtmlThumbnailRequest): Required<HtmlThumbnailRequest> {
108 const query = Object.fromEntries(
109 Object.entries(request.query || {})
110 .map(([key, value]) => [String(key), String(value)] as const)
111 .sort(([left], [right]) => left.localeCompare(right))
112 )
113 return {
114 resourceType: request.resourceType,
115 resourceId: String(request.resourceId || '').trim(),
116 variant: String(request.variant || 'default').trim() || 'default',
117 sourcePath: path.resolve(request.sourcePath),
118 pageId: String(request.pageId || '').trim(),
119 query,
120 captureWidth: normalizeDimension(request.captureWidth, DEFAULT_CAPTURE_WIDTH),
121 captureHeight: normalizeDimension(request.captureHeight, DEFAULT_CAPTURE_HEIGHT),
122 thumbnailWidth: normalizeDimension(request.thumbnailWidth, DEFAULT_THUMBNAIL_WIDTH),
123 thumbnailHeight: normalizeDimension(request.thumbnailHeight, DEFAULT_THUMBNAIL_HEIGHT)
124 }
125 }
126
127 function validateRequest(request: Required<HtmlThumbnailRequest>): void {
128 if (!request.resourceType) throw new Error('Thumbnail resourceType is required')
129 if (!request.resourceId) throw new Error('Thumbnail resourceId is required')
130 }
131
132 function requestSignature(request: Required<HtmlThumbnailRequest>): string {
133 return JSON.stringify(request)
134 }
135
136 export function resolveHtmlThumbnailCacheRoot(): string {
137 return path.join(app.getPath('userData'), is.dev ? 'html-thumbnails-dev' : 'html-thumbnails')
138 }
139
140 export function resolveHtmlThumbnailPath(
141 resourceType: HtmlThumbnailResourceType,
142 resourceId: string,
143 variant = 'default',
144 size?: { width: number; height: number }
145 ): string {
146 const key = createHash('sha256')
147 .update(
148 JSON.stringify({
149 resourceType,
150 resourceId,
151 variant,
152 width: size?.width || DEFAULT_CAPTURE_WIDTH,
153 height: size?.height || DEFAULT_CAPTURE_HEIGHT
154 })
155 )
156 .digest('hex')
157 .slice(0, 32)
158 return path.join(resolveHtmlThumbnailCacheRoot(), `${key}.png`)
159 }
160
161 function recordToTask(record: ThumbnailRecord | undefined): HtmlThumbnailTask | null {
162 if (!record) return null
163 return {
164 resourceType: record.resourceType,
165 resourceId: record.resourceId,
166 variant: record.variant,
167 status: record.status,
168 thumbnailPath:
169 record.status === 'completed' && record.thumbnailPath && fs.existsSync(record.thumbnailPath)
170 ? record.thumbnailPath
171 : null,
172 error: record.error || undefined
173 }
174 }
175
176 export async function getHtmlThumbnailTask(
177 resourceType: HtmlThumbnailResourceType,
178 resourceId: string,
179 variant = 'default'
180 ): Promise<HtmlThumbnailTask | null> {
181 const normalizedVariant = variant.trim() || 'default'
182 const key = thumbnailTaskKey(resourceType, resourceId, normalizedVariant)
183 const activeTask = backgroundTasks.get(key)
184 if (activeTask) return { ...activeTask }
185 const record = await getDb().getThumbnailRecord(resourceType, resourceId, normalizedVariant)
186 return recordToTask(record)
187 }
188
189 export async function waitForHtmlThumbnailTask(
190 resourceType: HtmlThumbnailResourceType,
191 resourceId: string,
192 variant = 'default',
193 timeoutMs = 60_000
194 ): Promise<HtmlThumbnailTask> {
195 const normalizedVariant = variant.trim() || 'default'
196 return new Promise((resolve, reject) => {
197 let finished = false
198 let timeoutRef: NodeJS.Timeout | null = null
199
200 const finish = (task: HtmlThumbnailTask): void => {
201 if (finished) return
202 finished = true
203 if (timeoutRef) clearTimeout(timeoutRef)
204 unsubscribe()
205 if (task.status === 'completed' && task.thumbnailPath) {
206 resolve(task)
207 return
208 }
209 reject(new Error(task.error || 'Thumbnail generation failed'))
210 }
211
212 const unsubscribe = onHtmlThumbnailTaskChanged((task) => {
213 if (
214 task.resourceType !== resourceType ||
215 task.resourceId !== resourceId ||
216 task.variant !== normalizedVariant ||
217 (task.status !== 'completed' && task.status !== 'failed')
218 ) {
219 return
220 }
221 finish(task)
222 })
223
224 timeoutRef = setTimeout(
225 () => {
226 finish({
227 resourceType,
228 resourceId,
229 variant: normalizedVariant,
230 status: 'failed',
231 thumbnailPath: null,
232 error: 'Thumbnail generation timed out'
233 })
234 },
235 Math.max(1_000, timeoutMs)
236 )
237
238 void getHtmlThumbnailTask(resourceType, resourceId, normalizedVariant)
239 .then((task) => {
240 if (task && (task.status === 'completed' || task.status === 'failed')) finish(task)
241 })
242 .catch((error) => {
243 finish({
244 resourceType,
245 resourceId,
246 variant: normalizedVariant,
247 status: 'failed',
248 thumbnailPath: null,
249 error: error instanceof Error ? error.message : String(error)
250 })
251 })
252 })
253 }
254
255 export async function getFreshHtmlThumbnailPath(
256 request: HtmlThumbnailRequest
257 ): Promise<string | null> {
258 const normalized = normalizeRequest(request)
259 validateRequest(normalized)
260 if (!fs.existsSync(normalized.sourcePath)) return null
261 const record = await getDb().getThumbnailRecord(
262 normalized.resourceType,
263 normalized.resourceId,
264 normalized.variant
265 )
266 if (!record || record.status !== 'completed' || !fs.existsSync(record.thumbnailPath)) return null
267
268 try {
269 const sourceMtimeMs = Math.floor(fs.statSync(normalized.sourcePath).mtimeMs)
270 return record.signature === requestSignature(normalized) &&
271 record.sourceMtimeMs >= sourceMtimeMs
272 ? record.thumbnailPath
273 : null
274 } catch {
275 return null
276 }
277 }
278
279 export async function getFreshHtmlThumbnailPaths(
280 requests: HtmlThumbnailRequest[]
281 ): Promise<Map<string, string>> {
282 const result = new Map<string, string>()
283 if (requests.length === 0) return result
284
285 const validRaw = requests.filter((request) => {
286 const resourceType = String(request.resourceType || '').trim()
287 const resourceId = String(request.resourceId || '').trim()
288 const sourcePath = typeof request.sourcePath === 'string' ? request.sourcePath.trim() : ''
289 return resourceType.length > 0 && resourceId.length > 0 && sourcePath.length > 0
290 })
291 if (validRaw.length === 0) return result
292
293 const normalized = validRaw.map((request) => {
294 const item = normalizeRequest(request)
295 return { request: item, sourceExists: fs.existsSync(item.sourcePath) }
296 })
297
298 const groups = new Map<string, Required<HtmlThumbnailRequest>[]>()
299 for (const entry of normalized) {
300 if (!entry.sourceExists) continue
301 const groupKey = `${entry.request.resourceType}\u0000${entry.request.variant}`
302 const arr = groups.get(groupKey) || []
303 arr.push(entry.request)
304 groups.set(groupKey, arr)
305 }
306
307 const db = getDb()
308 for (const arr of groups.values()) {
309 const resourceType = arr[0].resourceType
310 const variant = arr[0].variant
311 const records = await db.getThumbnailRecords(
312 resourceType,
313 arr.map((item) => item.resourceId),
314 variant
315 )
316 const recordByResourceId = new Map(records.map((record) => [record.resourceId, record]))
317 for (const request of arr) {
318 const record = recordByResourceId.get(request.resourceId)
319 if (!record || record.status !== 'completed') continue
320 if (!record.thumbnailPath || !fs.existsSync(record.thumbnailPath)) continue
321 try {
322 const sourceMtimeMs = Math.floor(fs.statSync(request.sourcePath).mtimeMs)
323 if (
324 record.signature === requestSignature(request) &&
325 record.sourceMtimeMs >= sourceMtimeMs
326 ) {
327 result.set(request.resourceId, record.thumbnailPath)
328 }
329 } catch {
330 // Skip entries whose source can no longer be stat'd.
331 }
332 }
333 }
334
335 return result
336 }
337
338 async function ensureThumbnailCacheRoot(): Promise<void> {
339 const cacheRoot = resolveHtmlThumbnailCacheRoot()
340 await fs.promises.mkdir(cacheRoot, { recursive: true })
341 allowLocalAssetRoot(cacheRoot)
342 }
343
344 function createCaptureWindow(): BrowserWindow {
345 return new BrowserWindow({
346 show: false,
347 width: DEFAULT_CAPTURE_WIDTH,
348 height: DEFAULT_CAPTURE_HEIGHT,
349 backgroundColor: '#ffffff',
350 webPreferences: {
351 contextIsolation: true,
352 sandbox: false,
353 nodeIntegration: false,
354 backgroundThrottling: false,
355 offscreen: false
356 }
357 })
358 }
359
360 const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
361
362 function waitForPrintReady(
363 webContents: WebContents,
364 pageId: string,
365 timeoutMs: number
366 ): Promise<{ timedOut: boolean; reportedPageId?: string }> {
367 return new Promise((resolve) => {
368 let done = false
369 let timeoutRef: NodeJS.Timeout | null = null
370
371 const finalize = (timedOut: boolean, reportedPageId?: string): void => {
372 if (done) return
373 done = true
374 if (timeoutRef) clearTimeout(timeoutRef)
375 webContents.removeListener('console-message', onConsoleMessage)
376 resolve({ timedOut, reportedPageId })
377 }
378
379 const onConsoleMessage = (...rawArgs: unknown[]): void => {
380 const message =
381 rawArgs.length >= 3 && typeof rawArgs[2] === 'string'
382 ? rawArgs[2]
383 : ((rawArgs[0] as { message?: unknown } | undefined)?.message ?? '')
384 if (typeof message !== 'string') return
385 const prefixIndex = message.indexOf(PRINT_READY_PREFIX)
386 if (prefixIndex < 0) return
387 const suffix = message.slice(prefixIndex + PRINT_READY_PREFIX.length)
388 const colonIndex = suffix.indexOf(':')
389 const reported = colonIndex >= 0 ? suffix.slice(colonIndex + 1).trim() : ''
390 if (reported === pageId || reported === 'page-unknown') {
391 finalize(false, reported)
392 }
393 }
394
395 timeoutRef = setTimeout(() => finalize(true), Math.max(500, timeoutMs))
396 webContents.on('console-message', onConsoleMessage as (...args: unknown[]) => void)
397 })
398 }
399
400 async function captureThumbnail(
401 window: BrowserWindow,
402 request: Required<HtmlThumbnailRequest>
403 ): Promise<Buffer> {
404 window.webContents.setZoomFactor(1)
405 window.setContentSize(request.captureWidth, request.captureHeight)
406
407 if (request.pageId) {
408 // Export strategy: drive the page in print/export mode so the runtime
409 // emits PRINT_READY, then run FREEZE in three passes mirroring the
410 // renderPageToPdfBuffer flow used by PNG/PDF/PPTX export.
411 const pageUrl = new URL(pathToFileURL(request.sourcePath).toString())
412 pageUrl.searchParams.set('fit', 'off')
413 pageUrl.searchParams.set('print', '1')
414 pageUrl.searchParams.set('export', '1')
415 pageUrl.searchParams.set('pageId', request.pageId)
416 pageUrl.searchParams.set('printTimeoutMs', String(PRINT_READY_DEFAULT_TIMEOUT_MS))
417 pageUrl.searchParams.set('_ts', String(Date.now()))
418 for (const [key, value] of Object.entries(request.query)) {
419 pageUrl.searchParams.set(key, value)
420 }
421 pageUrl.searchParams.set(
422 '_pptMasterExpected',
423 fs.existsSync(path.join(path.dirname(request.sourcePath), 'master', 'master.css')) ? '1' : '0'
424 )
425 pageUrl.searchParams.set(
426 '_pptMasterElementsExpected',
427 fs.existsSync(path.join(path.dirname(request.sourcePath), 'master', 'master.html'))
428 ? '1'
429 : '0'
430 )
431
432 const readyWaitPromise = waitForPrintReady(
433 window.webContents,
434 request.pageId,
435 PRINT_READY_DEFAULT_TIMEOUT_MS
436 )
437 await window.loadURL(pageUrl.toString())
438 await window.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
439 await readyWaitPromise
440 await sleep(PRINT_READY_SETTLE_MS)
441 await window.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
442 await sleep(PRINT_READY_PASS_TWO_DELAY_MS)
443 await window.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
444 await sleep(PRINT_READY_PASS_THREE_DELAY_MS)
445 } else {
446 await window.loadFile(request.sourcePath, {
447 query: {
448 ...request.query,
449 _pptMasterExpected: fs.existsSync(
450 path.join(path.dirname(request.sourcePath), 'master', 'master.css')
451 )
452 ? '1'
453 : '0',
454 _pptMasterElementsExpected: fs.existsSync(
455 path.join(path.dirname(request.sourcePath), 'master', 'master.html')
456 )
457 ? '1'
458 : '0'
459 }
460 })
461 await window.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
462 await window.webContents.executeJavaScript(
463 `new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))`
464 )
465 }
466
467 const image = await window.webContents.capturePage({
468 x: 0,
469 y: 0,
470 width: request.captureWidth,
471 height: request.captureHeight
472 })
473 return image
474 .resize({
475 width: request.thumbnailWidth,
476 height: request.thumbnailHeight,
477 quality: 'best'
478 })
479 .toPNG()
480 }
481
482 /** Captures one persisted page at its logical canvas size without creating a thumbnail record. */
483 export async function captureHtmlPageScreenshot(args: HtmlPageScreenshotRequest): Promise<Buffer> {
484 const sourcePath = path.resolve(args.sourcePath)
485 const pageId = String(args.pageId || '').trim()
486 if (!pageId) throw new Error('Page screenshot requires a page id')
487 if (!fs.existsSync(sourcePath)) throw new Error('Page screenshot source does not exist')
488
489 const request = normalizeRequest({
490 resourceType: 'session',
491 resourceId: 'temporary-page-screenshot',
492 variant: 'temporary',
493 sourcePath,
494 pageId,
495 captureWidth: args.width,
496 captureHeight: args.height,
497 thumbnailWidth: args.width,
498 thumbnailHeight: args.height
499 })
500
501 return thumbnailLimit(async () => {
502 const window = createCaptureWindow()
503 try {
504 return await captureThumbnail(window, request)
505 } finally {
506 if (!window.isDestroyed()) window.destroy()
507 }
508 })
509 }
510
511 async function persistTask(
512 request: Required<HtmlThumbnailRequest>,
513 status: HtmlThumbnailTaskStatus,
514 thumbnailPath: string,
515 error?: string,
516 sourceMtimeMsOverride?: number
517 ): Promise<void> {
518 const sourceMtimeMs =
519 sourceMtimeMsOverride ??
520 (fs.existsSync(request.sourcePath)
521 ? Math.floor((await fs.promises.stat(request.sourcePath)).mtimeMs)
522 : 0)
523 await getDb().upsertThumbnailRecord({
524 resourceType: request.resourceType,
525 resourceId: request.resourceId,
526 variant: request.variant,
527 sourcePath: request.sourcePath,
528 sourceMtimeMs,
529 signature: requestSignature(request),
530 thumbnailPath,
531 status,
532 error: error || null
533 })
534 }
535
536 export async function enqueueHtmlThumbnail(
537 request: HtmlThumbnailRequest,
538 options: { force?: boolean; delayMs?: number } = {}
539 ): Promise<HtmlThumbnailTask> {
540 const normalized = normalizeRequest(request)
541 validateRequest(normalized)
542 const key = thumbnailTaskKey(normalized.resourceType, normalized.resourceId, normalized.variant)
543 const existing = backgroundTasks.get(key)
544 if (existing?.status === 'queued' || existing?.status === 'running') return { ...existing }
545
546 if (!options.force) {
547 const thumbnailPath = await getFreshHtmlThumbnailPath(normalized)
548 if (thumbnailPath) {
549 const completed: HtmlThumbnailTask = {
550 resourceType: normalized.resourceType,
551 resourceId: normalized.resourceId,
552 variant: normalized.variant,
553 status: 'completed',
554 thumbnailPath
555 }
556 return { ...completed }
557 }
558 }
559
560 const queued: HtmlThumbnailTask = {
561 resourceType: normalized.resourceType,
562 resourceId: normalized.resourceId,
563 variant: normalized.variant,
564 status: 'queued',
565 thumbnailPath: null
566 }
567 backgroundTasks.set(key, queued)
568 await persistTask(normalized, 'queued', queued.thumbnailPath || '')
569 emitTaskChanged(queued)
570
571 const readyAt = Date.now() + Math.max(0, options.delayMs || 0)
572 void thumbnailLimit(async () => {
573 const remainingDelayMs = readyAt - Date.now()
574 if (remainingDelayMs > 0) {
575 await new Promise((resolve) => setTimeout(resolve, remainingDelayMs))
576 }
577 let pendingPath = ''
578 try {
579 const running = { ...queued, status: 'running' as const }
580 backgroundTasks.set(key, running)
581 await persistTask(normalized, 'running', '')
582 emitTaskChanged(running)
583 await ensureThumbnailCacheRoot()
584 const thumbnailPath = resolveHtmlThumbnailPath(
585 normalized.resourceType,
586 normalized.resourceId,
587 normalized.variant,
588 { width: normalized.captureWidth, height: normalized.captureHeight }
589 )
590 pendingPath = `${thumbnailPath}.tmp`
591 let png: Buffer | null = null
592 let capturedSourceMtimeMs = 0
593 for (let attempt = 0; attempt < MAX_SOURCE_STABILITY_ATTEMPTS; attempt += 1) {
594 const sourceMtimeBefore = Math.floor(
595 (await fs.promises.stat(normalized.sourcePath)).mtimeMs
596 )
597 const window = createCaptureWindow()
598 try {
599 png = await captureThumbnail(window, normalized)
600 } finally {
601 if (!window.isDestroyed()) window.destroy()
602 }
603 const sourceMtimeAfter = Math.floor((await fs.promises.stat(normalized.sourcePath)).mtimeMs)
604 if (sourceMtimeBefore === sourceMtimeAfter) {
605 capturedSourceMtimeMs = sourceMtimeAfter
606 break
607 }
608 png = null
609 }
610 if (!png) throw new Error('Thumbnail source changed during capture')
611 await fs.promises.writeFile(pendingPath, png)
612 await fs.promises.rename(pendingPath, thumbnailPath)
613 const completed: HtmlThumbnailTask = {
614 resourceType: normalized.resourceType,
615 resourceId: normalized.resourceId,
616 variant: normalized.variant,
617 status: 'completed',
618 thumbnailPath
619 }
620 await persistTask(normalized, 'completed', thumbnailPath, undefined, capturedSourceMtimeMs)
621 emitTaskChanged(completed)
622 backgroundTasks.delete(key)
623 } catch (error) {
624 if (pendingPath) await fs.promises.rm(pendingPath, { force: true }).catch(() => undefined)
625 const message = error instanceof Error ? error.message : String(error)
626 const failed: HtmlThumbnailTask = {
627 ...queued,
628 status: 'failed',
629 error: message
630 }
631 backgroundTasks.set(key, failed)
632 await persistTask(normalized, 'failed', '', message).catch(() => undefined)
633 emitTaskChanged(failed)
634 backgroundTasks.delete(key)
635 }
636 }).catch(() => backgroundTasks.delete(key))
637
638 return { ...queued }
639 }
640
641 export async function enqueueHtmlThumbnails(
642 requests: HtmlThumbnailRequest[],
643 options: { force?: boolean; delayMs?: number } = {}
644 ): Promise<HtmlThumbnailTask[]> {
645 const tasks: HtmlThumbnailTask[] = []
646 for (let index = 0; index < requests.length; index += 1) {
647 tasks.push(
648 await enqueueHtmlThumbnail(requests[index], {
649 force: options.force,
650 delayMs: options.delayMs
651 })
652 )
653 }
654 return tasks
655 }
656
656 lines TYPESCRIPT