返回 oh-my-ppt
exporter.ts
根目录 / src / main / io / html-video / exporter.ts
1 import { BrowserWindow, type NativeImage } from 'electron'
2 import { is } from '@electron-toolkit/utils'
3 import log from 'electron-log/main.js'
4 import fs from 'fs'
5 import os from 'os'
6 import path from 'path'
7 import { pathToFileURL } from 'url'
8 import { spawn } from 'child_process'
9 import type { SessionPageFile } from '../../ipc/context'
10 import type { ExportProgressStage } from '@shared/export-progress'
11
12 const VIDEO_WIDTH = 2560
13 const VIDEO_HEIGHT = 1440
14 const DEFAULT_FPS = 30
15 const DEFAULT_CAPTURE_FPS = 15
16 const DEFAULT_SECONDS_PER_PAGE = 4
17 const MAX_ANIMATED_PAGE_CAPTURE_FRAMES = 240
18 export const VIDEO_EXPORT_FRAME_SIZE = Object.freeze({
19 width: VIDEO_WIDTH,
20 height: VIDEO_HEIGHT
21 })
22 export const VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER =
23 'scale=ceil(iw/2)*2:ceil(ih/2)*2,setsar=1'
24
25 const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
26
27 const WAIT_FOR_VIDEO_CAPTURE_FRAME_SCRIPT = `
28 (async () => {
29 const master = document.querySelector('link[data-ppt-master="1"]');
30 const expectsMaster = new URLSearchParams(window.location.search).get('_pptMasterExpected') === '1';
31 if (master && expectsMaster && !(master.dataset.pptMasterExportReady === '1' && master.sheet)) {
32 const masterUrl = new URL(master.href, window.location.href);
33 masterUrl.searchParams.set('_pptMasterExport', String(Date.now()));
34 master.href = masterUrl.toString();
35 await new Promise((resolve, reject) => {
36 let settled = false;
37 const finish = (callback) => {
38 if (settled) return;
39 settled = true;
40 clearTimeout(timeout);
41 master.removeEventListener('load', onLoad);
42 master.removeEventListener('error', onError);
43 callback();
44 };
45 const onLoad = () => finish(() => {
46 master.dataset.pptMasterExportReady = '1';
47 resolve(true);
48 });
49 const onError = () => finish(() => reject(new Error('母版样式表加载失败')));
50 const timeout = setTimeout(
51 () => finish(() => reject(new Error('母版样式表加载超时'))),
52 5000
53 );
54 master.addEventListener('load', onLoad, { once: true });
55 master.addEventListener('error', onError, { once: true });
56 });
57 }
58 if (window.PPT?.whenReadyForPrint) {
59 await window.PPT.whenReadyForPrint(5000);
60 }
61 const expectsMasterElements =
62 new URLSearchParams(window.location.search).get('_pptMasterElementsExpected') === '1';
63 if (expectsMasterElements && !window.PPT?.assertMasterElementsReady) {
64 throw new Error('母版全局元素运行时不可用');
65 }
66 if (window.PPT?.assertMasterElementsReady) {
67 await window.PPT.assertMasterElementsReady(5000);
68 }
69 void document.body.offsetHeight;
70 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
71 void document.body.offsetHeight;
72 return true;
73 })()
74 `
75
76 const PREPARE_PAGE_FOR_STATIC_VIDEO_SCRIPT = `
77 (async () => {
78 const root =
79 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
80 document.querySelector('.ppt-page-root') ||
81 document.body;
82
83 const existing = document.getElementById('ohmyppt-video-static-export');
84 if (existing) existing.remove();
85 const style = document.createElement('style');
86 style.id = 'ohmyppt-video-static-export';
87 style.textContent = [
88 'html { scroll-behavior: auto !important; }',
89 '*, *::before, *::after { transition: none !important; animation: none !important; transition-delay: 0s !important; animation-delay: 0s !important; }',
90 '.opacity-0, [data-anime], [data-animate], [data-anim] { opacity: 1 !important; transform: none !important; }'
91 ].join('\\n');
92 document.head.appendChild(style);
93
94 try {
95 document.getAnimations?.().forEach((animation) => {
96 try {
97 animation.finish();
98 } catch (_err) {
99 try {
100 animation.cancel();
101 } catch (_cancelErr) {}
102 }
103 });
104 } catch (_err) {}
105
106 try {
107 const ChartCtor = window.Chart;
108 if (ChartCtor?.defaults) {
109 ChartCtor.defaults.animation = false;
110 ChartCtor.defaults.animations = false;
111 }
112 const charts = [];
113 if (window.__PPT_CHART_REGISTRY__ instanceof Map) {
114 window.__PPT_CHART_REGISTRY__.forEach((chart) => chart && charts.push(chart));
115 }
116 root.querySelectorAll('canvas').forEach((canvas) => {
117 try {
118 const chart = ChartCtor?.getChart?.(canvas);
119 if (chart) charts.push(chart);
120 } catch (_err) {}
121 });
122 charts.forEach((chart) => {
123 try {
124 if (chart?.options) {
125 chart.options.animation = false;
126 chart.options.animations = false;
127 chart.options.responsive = false;
128 chart.options.maintainAspectRatio = false;
129 }
130 chart.stop?.();
131 chart.resize?.();
132 chart.update?.('none');
133 chart.render?.();
134 chart.draw?.();
135 } catch (_err) {}
136 });
137 } catch (_err) {}
138
139 if (document.fonts?.ready) {
140 try {
141 await document.fonts.ready;
142 } catch (_err) {}
143 }
144 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
145 return true;
146 })()
147 `
148
149 const PREPARE_PAGE_FOR_ANIMATED_VIDEO_SCRIPT = `
150 (async () => {
151 const root =
152 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
153 document.querySelector('.ppt-page-root') ||
154 document.body;
155 const pageRect = root.getBoundingClientRect();
156 const supportedTypes = new Set([
157 'fade',
158 'fade-up',
159 'fade-down',
160 'fade-left',
161 'fade-right',
162 'scale-in',
163 'slide-up',
164 'slide-left',
165 'fly-in',
166 'wipe',
167 'zoom-in',
168 'spin-in',
169 'grow-shrink',
170 'pulse',
171 'exit-fade',
172 'exit-fly'
173 ]);
174 const normalizeType = (value) => {
175 const type = String(value || 'fade-up').trim().toLowerCase();
176 if (type === 'none') return 'none';
177 if (type === 'fly' || type === 'flyin') return 'fly-in';
178 if (type === 'zoom' || type === 'zoomin') return 'zoom-in';
179 if (type === 'spin' || type === 'spinin') return 'spin-in';
180 if (type === 'grow' || type === 'growshrink') return 'grow-shrink';
181 if (type === 'emphasis') return 'pulse';
182 if (type === 'path') return 'fade-up';
183 return supportedTypes.has(type) ? type : 'fade-up';
184 };
185 const normalizeTrigger = (value) => {
186 const trigger = String(value || 'load').trim().toLowerCase();
187 if (trigger === 'on-click') return 'click';
188 if (trigger === 'after-previous') return 'after';
189 if (trigger === 'with-previous') return 'with';
190 return trigger === 'click' || trigger === 'after' || trigger === 'with' ? trigger : 'load';
191 };
192 const defaultFrom = (type) => {
193 if (type === 'fade-down') return 'top';
194 if (type === 'fade-left' || type === 'slide-left') return 'right';
195 if (type === 'fade-right') return 'left';
196 return 'bottom';
197 };
198 const normalizeFrom = (value, fallback) => {
199 const from = String(value || fallback || 'bottom').trim().toLowerCase();
200 if (from === 'up' || from === 'top') return 'top';
201 if (from === 'down' || from === 'bottom') return 'bottom';
202 if (from === 'start') return 'left';
203 if (from === 'end') return 'right';
204 if (from === 'left' || from === 'right' || from === 'center') return from;
205 return fallback || 'bottom';
206 };
207 const parseDelay = (raw, counters, key) => {
208 const value = String(raw || '0').trim();
209 if (value.indexOf('stagger') === 0) {
210 const match = value.match(/stagger\\s*\\(\\s*(\\d+)\\s*\\)/);
211 const gap = match ? Number(match[1]) : 50;
212 if (counters[key] === undefined) counters[key] = 0;
213 const delay = counters[key] * gap;
214 counters[key] += 1;
215 return delay;
216 }
217 return Math.max(0, Number(value) || 0);
218 };
219 const style = document.getElementById('ohmyppt-video-animated-export') || document.createElement('style');
220 style.id = 'ohmyppt-video-animated-export';
221 style.textContent = [
222 'html { scroll-behavior: auto !important; }',
223 '*, *::before, *::after { transition: none !important; animation: none !important; transition-delay: 0s !important; animation-delay: 0s !important; }'
224 ].join('\\n');
225 if (!style.parentElement) document.head.appendChild(style);
226
227 if (document.fonts?.ready) {
228 try {
229 await document.fonts.ready;
230 } catch (_err) {}
231 }
232
233 const collectChartSettleMs = () => {
234 const ChartCtor = window.Chart;
235 const charts = [];
236 try {
237 if (window.__PPT_CHART_REGISTRY__ instanceof Map) {
238 window.__PPT_CHART_REGISTRY__.forEach((chart) => chart && charts.push(chart));
239 }
240 } catch (_err) {}
241 try {
242 root.querySelectorAll('canvas').forEach((canvas) => {
243 try {
244 const chart = ChartCtor?.getChart?.(canvas);
245 if (chart) charts.push(chart);
246 } catch (_err) {}
247 });
248 } catch (_err) {}
249 let maxDuration = 0;
250 charts.forEach((chart) => {
251 try {
252 const animation = chart?.options?.animation;
253 const duration = typeof animation === 'object'
254 ? Number(animation.duration)
255 : animation === false
256 ? 0
257 : 1000;
258 if (Number.isFinite(duration)) maxDuration = Math.max(maxDuration, duration);
259 } catch (_err) {}
260 });
261 return Math.max(0, Math.min(3000, maxDuration || (charts.length > 0 ? 900 : 0)));
262 };
263
264 const elements = Array.from(root.querySelectorAll('[data-anim]'));
265 const counters = {};
266 let lastSequenceStart = 0;
267 let lastSequenceEnd = 0;
268 let clickStep = 0;
269 const animations = [];
270
271 elements.forEach((el, order) => {
272 const type = normalizeType(el.getAttribute('data-anim'));
273 if (type === 'none') return;
274 const trigger = normalizeTrigger(el.getAttribute('data-anim-trigger'));
275 const duration = Math.max(100, Math.min(5000, Number(el.getAttribute('data-anim-duration')) || 500));
276 const from = normalizeFrom(el.getAttribute('data-anim-from'), defaultFrom(type));
277 let start = parseDelay(el.getAttribute('data-anim-delay'), counters, trigger);
278 if (trigger === 'click') {
279 start += 900 + clickStep * 1200;
280 clickStep += 1;
281 } else if (trigger === 'after') {
282 start += lastSequenceEnd;
283 lastSequenceStart = start;
284 lastSequenceEnd = Math.max(lastSequenceEnd, start + duration);
285 } else if (trigger === 'with') {
286 start += lastSequenceStart;
287 lastSequenceEnd = Math.max(lastSequenceEnd, start + duration);
288 } else {
289 lastSequenceStart = start;
290 lastSequenceEnd = Math.max(lastSequenceEnd, start + duration);
291 }
292 const rect = el.getBoundingClientRect();
293 animations.push({
294 el,
295 type,
296 from,
297 start,
298 duration,
299 order,
300 rect: {
301 x: Math.round(rect.left - pageRect.left),
302 y: Math.round(rect.top - pageRect.top),
303 w: Math.round(rect.width),
304 h: Math.round(rect.height)
305 }
306 });
307 });
308
309 window.__OHMYPPT_VIDEO_ANIMS__ = animations;
310 window.__OHMYPPT_VIDEO_PAGE_RECT__ = {
311 width: Math.round(pageRect.width),
312 height: Math.round(pageRect.height)
313 };
314 const animationEndMs = Math.max(0, ...animations.map((item) => item.start + item.duration));
315 const chartSettleMs = collectChartSettleMs();
316 const suggestedDurationMs = Math.max(
317 animationEndMs > 0 ? animationEndMs + 700 : 0,
318 chartSettleMs > 0 ? chartSettleMs + 300 : 0
319 );
320 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
321 return {
322 animationCount: animations.length,
323 clickStepCount: clickStep,
324 animationEndMs,
325 chartSettleMs,
326 suggestedDurationMs
327 };
328 })()
329 `
330
331 const SEEK_PAGE_FOR_ANIMATED_VIDEO_SCRIPT = (timeMs: number): string => `
332 (() => {
333 const animations = Array.isArray(window.__OHMYPPT_VIDEO_ANIMS__)
334 ? window.__OHMYPPT_VIDEO_ANIMS__
335 : [];
336 const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
337 const ease = (t) => 1 - Math.pow(1 - clamp(t, 0, 1), 3);
338 const offsetFor = (from, distance) => {
339 if (from === 'top') return { x: 0, y: -distance };
340 if (from === 'left') return { x: -distance, y: 0 };
341 if (from === 'right') return { x: distance, y: 0 };
342 if (from === 'center') return { x: 0, y: 0 };
343 return { x: 0, y: distance };
344 };
345 animations.forEach((item) => {
346 const el = item.el;
347 if (!el || !el.style) return;
348 const raw = (${JSON.stringify(timeMs)} - item.start) / Math.max(1, item.duration);
349 const p = ease(raw);
350 const before = raw <= 0;
351 const after = raw >= 1;
352 const isExit = item.type === 'exit-fade' || item.type === 'exit-fly';
353 let opacity = isExit ? 1 - p : p;
354 let transform = 'none';
355 let clipPath = '';
356 const baseDistance = Math.max(32, Math.min(140, Math.max(item.rect?.w || 0, item.rect?.h || 0) * 0.26));
357 const distance = item.type === 'fade-left' || item.type === 'fade-right' ? Math.min(baseDistance, 52) : baseDistance;
358 if (item.type === 'fade') {
359 transform = 'none';
360 } else if (item.type === 'scale-in' || item.type === 'zoom-in') {
361 const scale = isExit ? 1 + p * 0.08 : 0.88 + p * 0.12;
362 transform = 'scale(' + scale.toFixed(4) + ')';
363 } else if (item.type === 'spin-in') {
364 transform = 'rotate(' + ((1 - p) * -12).toFixed(3) + 'deg) scale(' + (0.92 + p * 0.08).toFixed(4) + ')';
365 } else if (item.type === 'grow-shrink' || item.type === 'pulse') {
366 const wave = Math.sin(clamp(raw, 0, 1) * Math.PI);
367 transform = 'scale(' + (1 + wave * 0.055).toFixed(4) + ')';
368 opacity = 1;
369 } else if (item.type === 'wipe') {
370 clipPath = 'inset(0 ' + ((1 - p) * 100).toFixed(3) + '% 0 0)';
371 transform = 'none';
372 } else {
373 const offset = offsetFor(item.from, distance);
374 const factor = isExit ? p : 1 - p;
375 transform = 'translate(' + (offset.x * factor).toFixed(2) + 'px, ' + (offset.y * factor).toFixed(2) + 'px)';
376 }
377 if (before && !isExit) opacity = 0;
378 if (after && !isExit) opacity = 1;
379 if (before && isExit) opacity = 1;
380 if (after && isExit) opacity = 0;
381 el.style.setProperty('opacity', String(clamp(opacity, 0, 1)), 'important');
382 el.style.setProperty('transform', transform, 'important');
383 el.style.setProperty('transition', 'none', 'important');
384 el.style.setProperty('animation', 'none', 'important');
385 if (clipPath) {
386 el.style.setProperty('clip-path', clipPath, 'important');
387 } else {
388 el.style.removeProperty('clip-path');
389 }
390 });
391 void document.body.offsetHeight;
392 return true;
393 })()
394 `
395
396 export type VideoExportPage = SessionPageFile
397
398 export type VideoExportOptions = {
399 pages: VideoExportPage[]
400 outputPath: string
401 tempRootDir: string
402 slideSize?: {
403 width: number
404 height: number
405 }
406 waitForPrintReadySignal: (args: {
407 win: BrowserWindow
408 pageId: string
409 timeoutMs: number
410 }) => Promise<{ timedOut: boolean }>
411 timeoutMs: number
412 settleMs: number
413 fps?: number
414 captureFps?: number
415 secondsPerPage?: number
416 width?: number
417 height?: number
418 onProgress?: (payload: {
419 stage: Extract<ExportProgressStage, 'rendering' | 'writing'>
420 current?: number
421 total?: number
422 }) => void
423 }
424
425 export type VideoExportResult = {
426 pageCount: number
427 frameCount: number
428 durationMs: number
429 warnings: string[]
430 }
431
432 type VideoPageTimeline = {
433 animationCount: number
434 clickStepCount: number
435 animationEndMs: number
436 chartSettleMs: number
437 suggestedDurationMs: number
438 }
439
440 export type VideoExportFrameLayout = {
441 frameWidth: number
442 frameHeight: number
443 slideWidth: number
444 slideHeight: number
445 scale: number
446 left: number
447 top: number
448 }
449
450 const clampInteger = (value: unknown, fallback: number, min: number, max: number): number => {
451 const n = Math.floor(Number(value))
452 if (!Number.isFinite(n)) return fallback
453 return Math.max(min, Math.min(max, n))
454 }
455
456 export const normalizeVideoExportFps = (value: unknown): number =>
457 clampInteger(value, DEFAULT_FPS, 12, 60)
458
459 export const normalizeVideoExportCaptureFps = (value: unknown, outputFps = DEFAULT_FPS): number =>
460 Math.min(outputFps, clampInteger(value, DEFAULT_CAPTURE_FPS, 8, 30))
461
462 export const normalizeVideoExportSecondsPerPage = (value: unknown): number =>
463 clampInteger(value, DEFAULT_SECONDS_PER_PAGE, 1, 30)
464
465 export const resolveVideoExportFrameLayout = (args: {
466 frameWidth: number
467 frameHeight: number
468 slideWidth: number
469 slideHeight: number
470 }): VideoExportFrameLayout => {
471 const frameWidth = clampInteger(args.frameWidth, VIDEO_WIDTH, 1, 8192)
472 const frameHeight = clampInteger(args.frameHeight, VIDEO_HEIGHT, 1, 8192)
473 const slideWidth = clampInteger(args.slideWidth, frameWidth, 1, 8192)
474 const slideHeight = clampInteger(args.slideHeight, frameHeight, 1, 8192)
475 const scale = Math.min(frameWidth / slideWidth, frameHeight / slideHeight)
476 const renderedWidth = slideWidth * scale
477 const renderedHeight = slideHeight * scale
478 return {
479 frameWidth,
480 frameHeight,
481 slideWidth,
482 slideHeight,
483 scale,
484 left: Math.max(0, (frameWidth - renderedWidth) / 2),
485 top: Math.max(0, (frameHeight - renderedHeight) / 2)
486 }
487 }
488
489 const buildApplyVideoFrameLayoutScript = (layout: VideoExportFrameLayout): string => `
490 (() => {
491 const frameWidth = ${JSON.stringify(layout.frameWidth)};
492 const frameHeight = ${JSON.stringify(layout.frameHeight)};
493 const slideWidth = ${JSON.stringify(layout.slideWidth)};
494 const slideHeight = ${JSON.stringify(layout.slideHeight)};
495 const scale = ${JSON.stringify(layout.scale)};
496 const left = ${JSON.stringify(layout.left)};
497 const top = ${JSON.stringify(layout.top)};
498 const root =
499 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
500 document.querySelector('.ppt-page-root') ||
501 document.body;
502 document.documentElement.style.width = frameWidth + 'px';
503 document.documentElement.style.height = frameHeight + 'px';
504 document.documentElement.style.margin = '0';
505 document.documentElement.style.overflow = 'hidden';
506 document.documentElement.style.background = '#000';
507 document.body.style.width = frameWidth + 'px';
508 document.body.style.height = frameHeight + 'px';
509 document.body.style.margin = '0';
510 document.body.style.overflow = 'hidden';
511 document.body.style.background = '#000';
512 if (root) {
513 root.style.position = 'absolute';
514 root.style.left = left + 'px';
515 root.style.top = top + 'px';
516 root.style.width = slideWidth + 'px';
517 root.style.height = slideHeight + 'px';
518 root.style.transformOrigin = 'top left';
519 root.style.transform = 'scale(' + scale.toFixed(6) + ')';
520 }
521 return true;
522 })()
523 `
524
525 const platformArchKey = (): string => `${process.platform}-${process.arch}`
526
527 const bundledFfmpegFileName = (): string => (process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')
528
529 const resourceRoots = (): string[] => {
530 return is.dev
531 ? [path.join(process.cwd(), 'resources')]
532 : [path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')]
533 }
534
535 const candidateBundledFfmpegPaths = (): string[] => {
536 const key = platformArchKey()
537 const fileName = bundledFfmpegFileName()
538 const candidates: string[] = []
539 for (const root of resourceRoots()) {
540 if (path.basename(root) === 'ffmpeg') {
541 candidates.push(path.join(root, fileName))
542 candidates.push(path.join(root, key, fileName))
543 continue
544 }
545 candidates.push(path.join(root, 'ffmpeg', fileName))
546 candidates.push(path.join(root, 'ffmpeg', key, fileName))
547 }
548
549 const legacyFileNames: string[] = []
550 if (process.platform === 'darwin' && process.arch === 'arm64') {
551 legacyFileNames.push('ffmpeg-arm')
552 } else if (process.platform === 'darwin' && process.arch === 'x64') {
553 legacyFileNames.push('ffmpeg-intel')
554 }
555
556 for (const legacyFileName of legacyFileNames) {
557 for (const root of resourceRoots()) {
558 if (path.basename(root) === 'ffmpeg') {
559 candidates.push(path.join(root, legacyFileName))
560 } else {
561 candidates.push(path.join(root, 'ffmpeg', legacyFileName))
562 }
563 }
564 }
565
566 return candidates
567 }
568
569 const isExecutableFile = async (filePath: string): Promise<boolean> => {
570 try {
571 const stat = await fs.promises.stat(filePath)
572 return stat.isFile()
573 } catch {
574 return false
575 }
576 }
577
578 export const resolveBundledFfmpegPath = async (): Promise<string | null> => {
579 for (const candidate of candidateBundledFfmpegPaths()) {
580 if (await isExecutableFile(candidate)) {
581 if (process.platform !== 'win32') {
582 await fs.promises.chmod(candidate, 0o755).catch(() => {})
583 }
584 return candidate
585 }
586 }
587 return null
588 }
589
590 const createVideoBrowserWindow = (width: number, height: number): BrowserWindow => {
591 const win = new BrowserWindow({
592 show: false,
593 width,
594 height,
595 backgroundColor: '#ffffff',
596 webPreferences: {
597 contextIsolation: true,
598 sandbox: false,
599 nodeIntegration: false,
600 backgroundThrottling: false,
601 offscreen: false
602 }
603 })
604 win.webContents.setZoomFactor(1)
605 win.setContentSize(width, height)
606 return win
607 }
608
609 const loadVideoPage = async (args: {
610 win: BrowserWindow
611 page: VideoExportPage
612 layout: VideoExportFrameLayout
613 timeoutMs: number
614 settleMs: number
615 waitForPrintReadySignal: VideoExportOptions['waitForPrintReadySignal']
616 }): Promise<{ timedOut: boolean }> => {
617 const pageUrl = new URL(pathToFileURL(args.page.htmlPath).toString())
618 pageUrl.searchParams.set('fit', 'off')
619 pageUrl.searchParams.set('print', '1')
620 pageUrl.searchParams.set('export', '1')
621 pageUrl.searchParams.set('video', '1')
622 pageUrl.searchParams.set('pageId', args.page.pageId)
623 pageUrl.searchParams.set('printTimeoutMs', String(args.timeoutMs))
624 pageUrl.searchParams.set(
625 '_pptMasterExpected',
626 fs.existsSync(path.join(path.dirname(args.page.htmlPath), 'master', 'master.css')) ? '1' : '0'
627 )
628 pageUrl.searchParams.set(
629 '_pptMasterElementsExpected',
630 fs.existsSync(path.join(path.dirname(args.page.htmlPath), 'master', 'master.html')) ? '1' : '0'
631 )
632 pageUrl.searchParams.set('_ts', String(Date.now()))
633
634 const readyWaitPromise = args.waitForPrintReadySignal({
635 win: args.win,
636 pageId: args.page.pageId,
637 timeoutMs: args.timeoutMs
638 })
639
640 await args.win.loadURL(pageUrl.toString())
641 await args.win.webContents.executeJavaScript(buildApplyVideoFrameLayoutScript(args.layout), true)
642 const readyResult = await readyWaitPromise
643 if (readyResult.timedOut) {
644 log.warn('[export:video] print ready timeout', {
645 pageId: args.page.pageId,
646 htmlPath: args.page.htmlPath,
647 timeoutMs: args.timeoutMs
648 })
649 }
650 await sleep(args.settleMs)
651 await args.win.webContents.executeJavaScript(buildApplyVideoFrameLayoutScript(args.layout), true)
652 await args.win.webContents.executeJavaScript(WAIT_FOR_VIDEO_CAPTURE_FRAME_SCRIPT, true)
653 return readyResult
654 }
655
656 const captureFullFrame = async (
657 win: BrowserWindow,
658 width = VIDEO_WIDTH,
659 height = VIDEO_HEIGHT
660 ): Promise<NativeImage> => {
661 await win.webContents.executeJavaScript(WAIT_FOR_VIDEO_CAPTURE_FRAME_SCRIPT, true)
662 return win.webContents.capturePage({
663 x: 0,
664 y: 0,
665 width,
666 height
667 })
668 }
669
670 const warmUpCapture = async (win: BrowserWindow, width: number, height: number): Promise<void> => {
671 await win.webContents.executeJavaScript(WAIT_FOR_VIDEO_CAPTURE_FRAME_SCRIPT, true)
672 await sleep(process.platform === 'win32' ? 120 : 60)
673 await captureFullFrame(win, width, height).catch(() => null)
674 await win.webContents.executeJavaScript(WAIT_FOR_VIDEO_CAPTURE_FRAME_SCRIPT, true)
675 }
676
677 export const normalizeCapturedVideoFrameImage = (
678 image: NativeImage,
679 width: number = VIDEO_EXPORT_FRAME_SIZE.width,
680 height: number = VIDEO_EXPORT_FRAME_SIZE.height
681 ): NativeImage =>
682 image.resize({
683 width,
684 height,
685 quality: 'best'
686 })
687
688 const runFfmpeg = async (args: {
689 ffmpegPath: string
690 concatPath: string
691 tempDir: string
692 outputPath: string
693 fps: number
694 }): Promise<void> => {
695 await new Promise<void>((resolve, reject) => {
696 const ffmpegArgs = buildVideoExportFfmpegArgs({
697 concatPath: args.concatPath,
698 outputPath: args.outputPath,
699 fps: args.fps
700 })
701 log.info('[export:video] run ffmpeg', {
702 ffmpegPath: args.ffmpegPath,
703 args: ffmpegArgs,
704 cwd: args.tempDir
705 })
706 const child = spawn(args.ffmpegPath, ffmpegArgs, { cwd: args.tempDir })
707 let stderr = ''
708 child.stderr.on('data', (chunk) => {
709 stderr += String(chunk)
710 })
711 child.on('error', reject)
712 child.on('close', (code, signal) => {
713 if (code === 0) {
714 resolve()
715 return
716 }
717 const hint = stderr.trim().slice(-2000)
718 log.error('[export:video] ffmpeg failed', {
719 code,
720 signal,
721 stderr: hint,
722 concatPath: args.concatPath
723 })
724 reject(
725 new Error(
726 `ffmpeg 编码失败(退出码 ${code ?? 'unknown'}${signal ? `,信号 ${signal}` : ''})${
727 hint ? `:${hint}` : ''
728 }`
729 )
730 )
731 })
732 })
733 }
734
735 export const buildVideoExportFfmpegArgs = (args: {
736 concatPath: string
737 outputPath: string
738 fps: number
739 }): string[] => [
740 '-y',
741 '-f',
742 'concat',
743 '-safe',
744 '0',
745 '-i',
746 args.concatPath,
747 '-r',
748 String(args.fps),
749 '-vf',
750 VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER,
751 '-c:v',
752 'libx264',
753 '-threads',
754 '0',
755 '-pix_fmt',
756 'yuv420p',
757 '-crf',
758 '18',
759 '-preset',
760 'medium',
761 '-movflags',
762 '+faststart',
763 args.outputPath
764 ]
765
766 const escapeConcatPath = (filePath: string): string =>
767 filePath.split(path.sep).join('/').replace(/'/g, "'\\''")
768
769 const normalizeTimeline = (value: unknown): VideoPageTimeline => {
770 const record = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
771 const animationCount = Math.max(0, Math.floor(Number(record.animationCount) || 0))
772 const clickStepCount = Math.max(0, Math.floor(Number(record.clickStepCount) || 0))
773 const animationEndMs = Math.max(0, Math.floor(Number(record.animationEndMs) || 0))
774 const chartSettleMs = Math.max(0, Math.floor(Number(record.chartSettleMs) || 0))
775 const suggestedDurationMs = Math.max(0, Math.floor(Number(record.suggestedDurationMs) || 0))
776 return { animationCount, clickStepCount, animationEndMs, chartSettleMs, suggestedDurationMs }
777 }
778
779 const writeCapturedFrame = async (args: {
780 win: BrowserWindow
781 frameDir: string
782 frameIndex: number
783 width: number
784 height: number
785 }): Promise<string> => {
786 const image = normalizeCapturedVideoFrameImage(
787 await captureFullFrame(args.win, args.width, args.height),
788 args.width,
789 args.height
790 )
791 const png = image.toPNG({ scaleFactor: 1 })
792 const imagePath = path.join(args.frameDir, `frame-${String(args.frameIndex).padStart(6, '0')}.png`)
793 await fs.promises.writeFile(imagePath, png)
794 return imagePath
795 }
796
797 const appendConcatImage = (args: {
798 concatEntries: string[]
799 imagePath: string
800 frameDir: string
801 durationSeconds: number
802 }): void => {
803 const relativePath = path.relative(args.frameDir, args.imagePath)
804 args.concatEntries.push(`file '${escapeConcatPath(path.join('pages', relativePath))}'`)
805 args.concatEntries.push(`duration ${Math.max(0.001, args.durationSeconds).toFixed(6)}`)
806 }
807
808 export const exportHtmlPagesToVideo = async (
809 options: VideoExportOptions
810 ): Promise<VideoExportResult> => {
811 if (options.pages.length === 0) {
812 throw new Error('没有可导出的视频页面')
813 }
814
815 const ffmpegPath = await resolveBundledFfmpegPath()
816 if (!ffmpegPath) {
817 throw new Error('视频编码器缺失,无法导出视频。请确认 resources/ffmpeg 中包含当前平台的 ffmpeg。')
818 }
819
820 const fps = normalizeVideoExportFps(options.fps)
821 const captureFps = normalizeVideoExportCaptureFps(options.captureFps, fps)
822 const secondsPerPage = normalizeVideoExportSecondsPerPage(options.secondsPerPage)
823 const width = clampInteger(options.width, VIDEO_WIDTH, 1, 8192)
824 const height = clampInteger(options.height, VIDEO_HEIGHT, 1, 8192)
825 const layout = resolveVideoExportFrameLayout({
826 frameWidth: width,
827 frameHeight: height,
828 slideWidth: options.slideSize?.width ?? width,
829 slideHeight: options.slideSize?.height ?? height
830 })
831 const framesPerPage = fps * secondsPerPage
832 const tempRootDir = path.join(options.tempRootDir || os.tmpdir(), '.ohmyppt-tmp')
833 await fs.promises.mkdir(tempRootDir, { recursive: true })
834 const tempDir = await fs.promises.mkdtemp(path.join(tempRootDir, 'video-export-'))
835 const frameDir = path.join(tempDir, 'pages')
836 const concatPath = path.join(tempDir, 'concat.txt')
837 await fs.promises.mkdir(frameDir, { recursive: true })
838
839 const warnings: string[] = []
840 let imageIndex = 0
841 let frameCount = 0
842 const win = createVideoBrowserWindow(width, height)
843 const concatEntries: string[] = []
844 let lastConcatImagePath = ''
845
846 try {
847 for (const [pageIndex, page] of options.pages.entries()) {
848 log.info('[export:video] capture page', {
849 pageId: page.pageId,
850 htmlPath: page.htmlPath,
851 framesPerPage,
852 fps,
853 captureFps
854 })
855 const readyResult = await loadVideoPage({
856 win,
857 page,
858 layout,
859 timeoutMs: options.timeoutMs,
860 settleMs: options.settleMs,
861 waitForPrintReadySignal: options.waitForPrintReadySignal
862 })
863 if (readyResult.timedOut) {
864 warnings.push(`页面 ${page.pageId} 未收到打印就绪信号,已按当前状态导出`)
865 }
866
867 const timeline = normalizeTimeline(
868 await win.webContents.executeJavaScript(PREPARE_PAGE_FOR_ANIMATED_VIDEO_SCRIPT, true)
869 )
870 const pageDurationMs = Math.max(secondsPerPage * 1000, timeline.suggestedDurationMs)
871 const rawPageFrameCount = Math.max(1, Math.ceil((pageDurationMs / 1000) * captureFps))
872 const pageFrameCount = Math.min(MAX_ANIMATED_PAGE_CAPTURE_FRAMES, rawPageFrameCount)
873 const frameDurationSeconds = pageDurationMs / 1000 / pageFrameCount
874
875 if (timeline.animationCount > 0) {
876 log.info('[export:video] capture animated page frames', {
877 pageId: page.pageId,
878 animationCount: timeline.animationCount,
879 clickStepCount: timeline.clickStepCount,
880 animationEndMs: timeline.animationEndMs,
881 chartSettleMs: timeline.chartSettleMs,
882 pageFrameCount,
883 rawPageFrameCount,
884 pageDurationMs
885 })
886 await win.webContents.executeJavaScript(SEEK_PAGE_FOR_ANIMATED_VIDEO_SCRIPT(0), true)
887 await warmUpCapture(win, width, height)
888 const firstAnimatedFrameIndex = pageIndex === 0 ? 1 : 0
889 if (pageIndex === 0) {
890 imageIndex += 1
891 const posterImagePath = await writeCapturedFrame({
892 win,
893 frameDir,
894 frameIndex: imageIndex,
895 width,
896 height
897 })
898 appendConcatImage({
899 concatEntries,
900 imagePath: posterImagePath,
901 frameDir,
902 durationSeconds: frameDurationSeconds
903 })
904 lastConcatImagePath = posterImagePath
905 log.info('[export:video] prepended first page poster frame', {
906 pageId: page.pageId,
907 durationSeconds: frameDurationSeconds
908 })
909 }
910 for (let i = firstAnimatedFrameIndex; i < pageFrameCount; i += 1) {
911 const timeMs = Math.min(pageDurationMs, Math.round(i * frameDurationSeconds * 1000))
912 await win.webContents.executeJavaScript(SEEK_PAGE_FOR_ANIMATED_VIDEO_SCRIPT(timeMs), true)
913 imageIndex += 1
914 const imagePath = await writeCapturedFrame({
915 win,
916 frameDir,
917 frameIndex: imageIndex,
918 width,
919 height
920 })
921 appendConcatImage({
922 concatEntries,
923 imagePath,
924 frameDir,
925 durationSeconds: frameDurationSeconds
926 })
927 lastConcatImagePath = imagePath
928 }
929 frameCount += pageFrameCount
930 options.onProgress?.({
931 stage: 'rendering',
932 current: pageIndex + 1,
933 total: options.pages.length
934 })
935 continue
936 }
937
938 await win.webContents.executeJavaScript(PREPARE_PAGE_FOR_STATIC_VIDEO_SCRIPT, true)
939 await sleep(120)
940 await warmUpCapture(win, width, height)
941 const staticDurationSeconds = Math.max(
942 secondsPerPage,
943 timeline.suggestedDurationMs > 0 ? timeline.suggestedDurationMs / 1000 : 0
944 )
945 imageIndex += 1
946 const imagePath = await writeCapturedFrame({
947 win,
948 frameDir,
949 frameIndex: imageIndex,
950 width,
951 height
952 })
953 appendConcatImage({
954 concatEntries,
955 imagePath,
956 frameDir,
957 durationSeconds: staticDurationSeconds
958 })
959 lastConcatImagePath = imagePath
960 frameCount += Math.max(1, Math.ceil(staticDurationSeconds * fps))
961 options.onProgress?.({
962 stage: 'rendering',
963 current: pageIndex + 1,
964 total: options.pages.length
965 })
966 }
967 } finally {
968 if (!win.isDestroyed()) win.destroy()
969 }
970
971 if (lastConcatImagePath) {
972 const relativePath = path.relative(frameDir, lastConcatImagePath)
973 concatEntries.push(`file '${escapeConcatPath(path.join('pages', relativePath))}'`)
974 }
975 await fs.promises.writeFile(concatPath, `${concatEntries.join('\n')}\n`, 'utf-8')
976 log.info('[export:video] concat prepared', {
977 tempDir,
978 concatPath,
979 imageCount: imageIndex,
980 frameCount,
981 firstLines: concatEntries.slice(0, 8)
982 })
983
984 try {
985 options.onProgress?.({
986 stage: 'writing',
987 current: options.pages.length,
988 total: options.pages.length
989 })
990 await runFfmpeg({
991 ffmpegPath,
992 concatPath,
993 tempDir,
994 outputPath: options.outputPath,
995 fps
996 })
997 return {
998 pageCount: options.pages.length,
999 frameCount,
1000 durationMs: Math.round((frameCount / fps) * 1000),
1001 warnings
1002 }
1003 } finally {
1004 if (!is.dev || process.env.OHMYPPT_KEEP_VIDEO_EXPORT_TMP !== '1') {
1005 await fs.promises.rm(tempDir, { recursive: true, force: true }).catch((error) => {
1006 log.warn('[export:video] cleanup failed', {
1007 tempDir,
1008 message: error instanceof Error ? error.message : String(error)
1009 })
1010 })
1011 } else {
1012 log.info('[export:video] temp dir kept for debugging', { tempDir })
1013 }
1014 }
1015 }
1016
1016 lines TYPESCRIPT