web_ppt / backend /src /services /screenshotService.js
CatPtain's picture
Upload 11 files
b63441d verified
raw
history blame
25.1 kB
import puppeteer from 'puppeteer';
import playwright from 'playwright';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// 获取当前文件的目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class ScreenshotService {
constructor() {
this.puppeteerBrowser = null;
this.playwrightBrowser = null;
this.isInitializing = false;
this.puppeteerReady = false;
this.playwrightReady = false;
// 页面池 - 重用页面以提高性能
this.puppeteerPagePool = [];
this.playwrightPagePool = [];
this.maxPoolSize = 3; // 最大页面池大小
// 字体配置
this.googleFonts = [
'Noto+Sans+SC:400,700',
'Noto+Serif+SC:400,700'
];
console.log('📸 Screenshot service initialized (optimized version)');
// 启动时预热浏览器
this.warmupBrowsers();
}
// 预热浏览器实例
async warmupBrowsers() {
setTimeout(async () => {
try {
console.log('🔥 Warming up browser instances...');
await this.initPuppeteer();
console.log('✅ Browser warmup complete');
} catch (error) {
console.error('❌ Browser warmup failed:', error.message);
}
}, 1000); // 延迟1秒启动,避免与服务器启动冲突
}
// Initialize Puppeteer browser with optimizations
async initPuppeteer() {
if (this.puppeteerBrowser || this.isInitializing) return;
this.isInitializing = true;
try {
console.log('🚀 Starting Puppeteer browser (optimized)...');
// 优化的启动选项
const launchOptions = {
headless: 'new', // 使用新的无头模式
args: [
// 安全性设置
'--no-sandbox',
'--disable-setuid-sandbox',
// 内存和性能优化
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--no-first-run',
'--no-default-browser-check',
'--disable-default-apps',
'--disable-features=TranslateUI',
'--disable-ipc-flooding-protection',
// 内存限制优化
'--js-flags=--max-old-space-size=512', // 降低JS内存使用
'--memory-pressure-off',
// 进程优化
'--single-process', // 使用单进程模式减少资源消耗
'--disable-background-networking',
'--disable-background-mode',
// 字体渲染优化
'--font-render-hinting=none',
// 禁用不必要的功能
'--disable-breakpad',
'--disable-component-update',
'--disable-domain-reliability',
'--disable-sync',
'--disable-hang-monitor',
'--disable-prompt-on-repost',
'--disable-client-side-phishing-detection',
'--disable-component-extensions-with-background-pages',
'--blink-settings=imagesEnabled=true'
],
timeout: 20000, // 减少超时时间
ignoreHTTPSErrors: true,
defaultViewport: null, // 动态设置视口
handleSIGINT: false, // 由我们自己处理
handleSIGTERM: false,
handleSIGHUP: false,
dumpio: false, // 禁用浏览器日志输出到控制台
protocolTimeout: 15000
};
// 检测环境并应用特定优化
const isLowMemoryEnv = process.env.LOW_MEMORY === 'true';
if (isLowMemoryEnv) {
console.log('🔧 Applying low memory optimizations');
launchOptions.args.push(
'--disable-javascript',
'--disable-images',
'--disable-css-animations'
);
}
this.puppeteerBrowser = await puppeteer.launch(launchOptions);
this.puppeteerReady = true;
console.log('✅ Puppeteer browser started successfully');
// 预创建页面池
await this.initPagePool();
// 监控浏览器健康状态
this.startBrowserMonitoring();
// Cleanup when process exits
process.on('exit', () => this.cleanup());
process.on('SIGINT', () => this.cleanup());
process.on('SIGTERM', () => this.cleanup());
} catch (error) {
console.error('❌ Puppeteer initialization failed:', error.message);
this.puppeteerReady = false;
} finally {
this.isInitializing = false;
}
}
// 初始化页面池
async initPagePool() {
if (!this.puppeteerReady || !this.puppeteerBrowser) return;
try {
console.log(`🔄 Initializing page pool (size: ${this.maxPoolSize})...`);
// 清空现有池
for (const page of this.puppeteerPagePool) {
await page.close().catch(console.error);
}
this.puppeteerPagePool = [];
// 创建新页面
for (let i = 0; i < this.maxPoolSize; i++) {
const page = await this.puppeteerBrowser.newPage();
// 优化页面设置
await page.setRequestInterception(true);
page.on('request', (request) => {
// 阻止不必要的资源加载
const resourceType = request.resourceType();
if (resourceType === 'media' || resourceType === 'font') {
// 允许字体资源加载
request.continue();
} else if (resourceType === 'stylesheet' || resourceType === 'script' || resourceType === 'image') {
// 允许基本资源
request.continue();
} else {
// 阻止其他资源
request.abort();
}
});
// 注入Google字体
await this.injectGoogleFonts(page);
// 设置默认视口
await page.setViewport({ width: 1000, height: 562 });
// 添加到池中
this.puppeteerPagePool.push(page);
}
console.log(`✅ Page pool initialized with ${this.puppeteerPagePool.length} pages`);
} catch (error) {
console.error('❌ Failed to initialize page pool:', error.message);
}
}
// 注入Google字体
async injectGoogleFonts(page) {
try {
// 构建Google字体URL
const googleFontsUrl = `https://fonts.googleapis.com/css2?family=${this.googleFonts.join('&family=')}&display=swap`;
// 注入字体CSS
await page.evaluateOnNewDocument((fontsUrl) => {
// 创建link元素加载Google字体
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = fontsUrl;
// 当文档创建时添加到head
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild(link);
});
}, googleFontsUrl);
} catch (error) {
console.warn('⚠️ Failed to inject Google Fonts:', error.message);
}
}
// 监控浏览器健康状态
startBrowserMonitoring() {
const checkInterval = 5 * 60 * 1000; // 5分钟检查一次
setInterval(async () => {
if (!this.puppeteerBrowser) return;
try {
// 检查浏览器是否响应
await this.puppeteerBrowser.version();
// 检查内存使用情况
const pages = await this.puppeteerBrowser.pages();
console.log(`🔍 Browser health check: ${pages.length} pages open`);
// 如果页面过多,关闭多余页面
if (pages.length > this.maxPoolSize * 2) {
console.log(`⚠️ Too many pages open (${pages.length}), cleaning up...`);
// 保留页面池中的页面,关闭其他页面
const poolPageIds = this.puppeteerPagePool.map(p => p.target()._targetId);
for (const page of pages) {
const pageId = page.target()._targetId;
if (!poolPageIds.includes(pageId)) {
await page.close().catch(console.error);
}
}
}
} catch (error) {
console.error('❌ Browser health check failed:', error.message);
// 尝试重启浏览器
await this.restartBrowser();
}
}, checkInterval);
}
// 重启浏览器
async restartBrowser() {
console.log('🔄 Attempting to restart browser...');
try {
// 清理现有资源
await this.cleanup();
// 重新初始化
this.puppeteerReady = false;
this.playwrightReady = false;
await this.initPuppeteer();
console.log('✅ Browser successfully restarted');
} catch (error) {
console.error('❌ Browser restart failed:', error.message);
}
}
// Initialize Playwright browser (fallback) with optimizations
async initPlaywright() {
if (this.playwrightBrowser) return;
try {
console.log('🎭 Starting Playwright browser (optimized)...');
// 优化的启动选项
const launchOptions = {
headless: true,
args: [
// 安全性设置
'--no-sandbox',
'--disable-setuid-sandbox',
// 内存和性能优化
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions',
'--js-flags=--max-old-space-size=512',
'--single-process',
'--disable-background-networking',
// 字体渲染优化
'--font-render-hinting=none'
],
ignoreDefaultArgs: ['--enable-automation'],
chromiumSandbox: false,
handleSIGINT: false,
handleSIGTERM: false,
handleSIGHUP: false,
timeout: 15000
};
this.playwrightBrowser = await playwright.chromium.launch(launchOptions);
// 初始化页面池
await this.initPlaywrightPagePool();
this.playwrightReady = true;
console.log('✅ Playwright browser started successfully');
} catch (error) {
console.error('❌ Playwright initialization failed:', error.message);
this.playwrightReady = false;
}
}
// 初始化Playwright页面池
async initPlaywrightPagePool() {
if (!this.playwrightBrowser) return;
try {
console.log(`🔄 Initializing Playwright page pool...`);
// 清空现有池
for (const page of this.playwrightPagePool) {
await page.close().catch(console.error);
}
this.playwrightPagePool = [];
// 创建新页面
for (let i = 0; i < this.maxPoolSize; i++) {
const context = await this.playwrightBrowser.newContext({
viewport: { width: 1000, height: 562 },
ignoreHTTPSErrors: true
});
const page = await context.newPage();
// 注入Google字体
await this.injectPlaywrightGoogleFonts(page);
// 添加到池中
this.playwrightPagePool.push(page);
}
console.log(`✅ Playwright page pool initialized with ${this.playwrightPagePool.length} pages`);
} catch (error) {
console.error('❌ Failed to initialize Playwright page pool:', error.message);
}
}
// 注入Google字体到Playwright页面
async injectPlaywrightGoogleFonts(page) {
try {
// 构建Google字体URL
const googleFontsUrl = `https://fonts.googleapis.com/css2?family=${this.googleFonts.join('&family=')}&display=swap`;
// 注入字体CSS
await page.addInitScript(({ fontsUrl }) => {
// 创建link元素加载Google字体
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = fontsUrl;
// 当文档创建时添加到head
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild(link);
});
}, { fontsUrl: googleFontsUrl });
} catch (error) {
console.warn('⚠️ Failed to inject Google Fonts to Playwright:', error.message);
}
}
// Generate screenshot
async generateScreenshot(htmlContent, options = {}) {
const {
format = 'jpeg',
quality = 90,
width = 1000,
height = 562,
timeout = 15000
} = options;
console.log(`📸 Generating screenshot: ${width}x${height}, ${format}@${quality}%`);
// Try Puppeteer first
if (!this.puppeteerReady) {
await this.initPuppeteer();
}
if (this.puppeteerReady) {
try {
return await this.generateWithPuppeteer(htmlContent, { format, quality, width, height, timeout });
} catch (error) {
console.warn('Puppeteer screenshot failed, trying Playwright:', error.message);
}
}
// Fallback to Playwright
if (!this.playwrightReady) {
await this.initPlaywright();
}
if (this.playwrightReady) {
try {
return await this.generateWithPlaywright(htmlContent, { format, quality, width, height, timeout });
} catch (error) {
console.warn('Playwright screenshot failed:', error.message);
}
}
// Final fallback: generate SVG
console.warn('All screenshot methods failed, generating fallback SVG');
return this.generateFallbackImage(width, height, 'Screenshot Service', 'Browser unavailable');
}
// Generate screenshot using Puppeteer with page pool optimization
async generateWithPuppeteer(htmlContent, options) {
const { format, quality, width, height, timeout } = options;
let page = null;
let pageFromPool = false;
try {
// 尝试从页面池获取页面
if (this.puppeteerPagePool && this.puppeteerPagePool.length > 0) {
page = this.puppeteerPagePool.shift();
pageFromPool = true;
console.log(`📄 Using page from Puppeteer pool (${this.puppeteerPagePool.length} remaining)`);
} else {
// 如果池为空,创建新页面
page = await this.puppeteerBrowser.newPage();
console.log(`📄 Created new Puppeteer page (pool empty)`);
// 优化新页面设置
await page.setRequestInterception(true);
page.on('request', (request) => {
// 阻止不必要的资源加载
const resourceType = request.resourceType();
if (resourceType === 'media' || resourceType === 'font') {
// 允许字体资源加载
request.continue();
} else if (resourceType === 'stylesheet' || resourceType === 'script' || resourceType === 'image') {
// 允许基本资源
request.continue();
} else {
// 阻止其他资源
request.abort();
}
});
// 为新创建的页面注入Google字体
await this.injectGoogleFonts(page);
}
// 设置视口大小(即使从池中获取的页面也需要重新设置,因为尺寸可能不同)
await page.setViewport({ width, height });
// 设置内容,使用更短的超时时间
await page.setContent(htmlContent, {
waitUntil: 'networkidle0',
timeout: Math.min(timeout, 10000) // 使用较短的超时时间
});
// 等待字体加载
await page.evaluate(() => {
return document.fonts ? document.fonts.ready : Promise.resolve();
});
// 减少等待时间
await page.waitForTimeout(200);
// 优化内存使用
if (global.gc && Math.random() < 0.1) { // 随机触发GC,避免每次都执行
global.gc();
}
// 截图选项
const screenshotOptions = {
type: format,
quality: format === 'jpeg' ? quality : undefined,
fullPage: false,
omitBackground: format === 'png', // PNG格式可以透明背景
clip: { x: 0, y: 0, width, height }
};
const screenshot = await page.screenshot(screenshotOptions);
console.log(`✅ Puppeteer screenshot generated: ${screenshot.length} bytes`);
return screenshot;
} catch (error) {
console.error(`❌ Puppeteer screenshot failed: ${error.message}`);
throw error;
} finally {
if (page) {
if (pageFromPool && this.puppeteerPagePool.length < this.maxPoolSize) {
// 重置页面状态
try {
await page.goto('about:blank').catch(() => {});
// 将页面放回池中
this.puppeteerPagePool.push(page);
console.log(`♻️ Returned Puppeteer page to pool (${this.puppeteerPagePool.length} total)`);
} catch (e) {
console.warn('⚠️ Failed to reset page, closing it:', e.message);
await page.close().catch(console.error);
}
} else if (!pageFromPool) {
// 关闭非池中的页面
await page.close().catch(console.error);
}
}
}
}
// Generate screenshot using Playwright with page pool optimization
async generateWithPlaywright(htmlContent, options) {
const { format, quality, width, height, timeout } = options;
let page = null;
let pageFromPool = false;
try {
// 尝试从页面池获取页面
if (this.playwrightPagePool && this.playwrightPagePool.length > 0) {
page = this.playwrightPagePool.shift();
pageFromPool = true;
console.log(`📄 Using page from Playwright pool (${this.playwrightPagePool.length} remaining)`);
} else {
// 如果池为空,创建新页面
const context = await this.playwrightBrowser.newContext({
viewport: { width, height },
ignoreHTTPSErrors: true
});
page = await context.newPage();
console.log(`📄 Created new Playwright page (pool empty)`);
// 为新创建的页面注入Google字体
await this.injectPlaywrightGoogleFonts(page);
}
// 设置视口大小(即使从池中获取的页面也需要重新设置,因为尺寸可能不同)
await page.setViewportSize({ width, height });
// 设置内容,使用更短的超时时间
await page.setContent(htmlContent, {
waitUntil: 'networkidle',
timeout: Math.min(timeout, 10000) // 使用较短的超时时间
});
// 等待字体加载
await page.evaluate(() => {
return document.fonts ? document.fonts.ready : Promise.resolve();
});
// 减少等待时间
await page.waitForTimeout(200);
// 优化内存使用
if (global.gc && Math.random() < 0.1) { // 随机触发GC,避免每次都执行
global.gc();
}
// 截图选项
const screenshotOptions = {
type: format,
quality: format === 'jpeg' ? quality : undefined,
fullPage: false,
omitBackground: format === 'png', // PNG格式可以透明背景
clip: { x: 0, y: 0, width, height }
};
const screenshot = await page.screenshot(screenshotOptions);
console.log(`✅ Playwright screenshot generated: ${screenshot.length} bytes`);
return screenshot;
} catch (error) {
console.error(`❌ Playwright screenshot failed: ${error.message}`);
throw error;
} finally {
if (page) {
if (pageFromPool && this.playwrightPagePool.length < this.maxPoolSize) {
// 重置页面状态
try {
await page.goto('about:blank').catch(() => {});
// 将页面放回池中
this.playwrightPagePool.push(page);
console.log(`♻️ Returned Playwright page to pool (${this.playwrightPagePool.length} total)`);
} catch (e) {
console.warn('⚠️ Failed to reset page, closing it:', e.message);
await page.close().catch(console.error);
}
} else if (!pageFromPool) {
// 关闭非池中的页面
await page.close().catch(console.error);
}
}
}
}
// Generate fallback SVG image
generateFallbackImage(width = 1000, height = 562, title = 'PPT Screenshot', subtitle = '', message = '') {
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8f9fa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e9ecef;stop-opacity:1" />
</linearGradient>
<pattern id="dots" patternUnits="userSpaceOnUse" width="20" height="20">
<circle cx="2" cy="2" r="1" fill="#dee2e6" opacity="0.5"/>
</pattern>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#bg)"/>
<rect width="100%" height="100%" fill="url(#dots)"/>
<!-- Border -->
<rect x="20" y="20" width="${width-40}" height="${height-40}"
fill="none" stroke="#dee2e6" stroke-width="3"
stroke-dasharray="15,10" rx="10"/>
<!-- Icon -->
<g transform="translate(${width/2}, ${height*0.25})">
<circle cx="0" cy="0" r="30" fill="#6c757d" opacity="0.3"/>
<rect x="-15" y="-10" width="30" height="20" rx="3" fill="#6c757d"/>
<rect x="-12" y="-7" width="24" height="3" fill="white"/>
<rect x="-12" y="-2" width="24" height="3" fill="white"/>
<rect x="-12" y="3" width="16" height="3" fill="white"/>
</g>
<!-- Title -->
<text x="${width/2}" y="${height*0.45}"
text-anchor="middle"
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
font-size="28"
font-weight="bold"
fill="#495057">${title}</text>
${subtitle ? `
<!-- Subtitle -->
<text x="${width/2}" y="${height*0.55}"
text-anchor="middle"
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
font-size="18"
fill="#6c757d">${subtitle}</text>
` : ''}
${message ? `
<!-- Message -->
<text x="${width/2}" y="${height*0.65}"
text-anchor="middle"
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
font-size="14"
fill="#adb5bd">${message}</text>
` : ''}
<!-- Footer -->
<text x="${width/2}" y="${height*0.85}"
text-anchor="middle"
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
font-size="12"
fill="#ced4da">PPT Screenshot Service • ${new Date().toLocaleString('zh-CN')}</text>
<!-- Dimensions info -->
<text x="${width/2}" y="${height*0.92}"
text-anchor="middle"
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
font-size="10"
fill="#ced4da">Size: ${width} × ${height}</text>
</svg>`;
return Buffer.from(svg, 'utf-8');
}
// Get service status
getStatus() {
return {
puppeteerReady: this.puppeteerReady,
playwrightReady: this.playwrightReady,
environment: process.env.NODE_ENV || 'development',
isHuggingFace: !!process.env.SPACE_ID
};
}
// Cleanup resources
async cleanup() {
console.log('🧹 Cleaning up screenshot service...');
if (this.puppeteerBrowser) {
try {
await this.puppeteerBrowser.close();
this.puppeteerBrowser = null;
this.puppeteerReady = false;
console.log('✅ Puppeteer browser closed');
} catch (error) {
console.error('Error closing Puppeteer browser:', error);
}
}
if (this.playwrightBrowser) {
try {
await this.playwrightBrowser.close();
this.playwrightBrowser = null;
this.playwrightReady = false;
console.log('✅ Playwright browser closed');
} catch (error) {
console.error('Error closing Playwright browser:', error);
}
}
}
}
export default new ScreenshotService();