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