|
import puppeteer from 'puppeteer';
|
|
|
|
import { chromium } from 'playwright';
|
|
|
|
class ScreenshotService {
|
|
constructor() {
|
|
this.browser = null;
|
|
this.playwrightBrowser = null;
|
|
this.isInitialized = false;
|
|
this.maxRetries = 2;
|
|
this.browserLaunchRetries = 0;
|
|
this.maxBrowserLaunchRetries = 2;
|
|
this.isClosing = false;
|
|
this.isHuggingFaceSpace = process.env.SPACE_ID || process.env.HF_SPACE_ID;
|
|
this.preferredEngine = 'puppeteer';
|
|
}
|
|
|
|
async initBrowser() {
|
|
if (this.isClosing) {
|
|
console.log('浏览器正在关闭中,等待重新初始化...');
|
|
this.browser = null;
|
|
this.isClosing = false;
|
|
}
|
|
|
|
if (!this.browser) {
|
|
try {
|
|
console.log('初始化Puppeteer浏览器...');
|
|
|
|
const launchOptions = {
|
|
headless: 'new',
|
|
timeout: 60000,
|
|
protocolTimeout: 60000,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-accelerated-2d-canvas',
|
|
'--no-first-run',
|
|
'--disable-gpu',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-features=TranslateUI',
|
|
'--disable-extensions',
|
|
'--hide-scrollbars',
|
|
'--mute-audio',
|
|
'--no-default-browser-check',
|
|
'--disable-default-apps',
|
|
'--disable-background-networking',
|
|
'--disable-sync',
|
|
'--metrics-recording-only',
|
|
'--disable-domain-reliability',
|
|
'--force-device-scale-factor=1',
|
|
'--disable-features=VizDisplayCompositor',
|
|
'--run-all-compositor-stages-before-draw',
|
|
'--disable-new-content-rendering-timeout',
|
|
'--disable-ipc-flooding-protection',
|
|
'--disable-hang-monitor',
|
|
'--disable-prompt-on-repost',
|
|
'--memory-pressure-off',
|
|
'--max_old_space_size=1024',
|
|
'--disable-background-media-suspend',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-field-trial-config',
|
|
'--disable-component-extensions-with-background-pages',
|
|
'--disable-permissions-api',
|
|
'--disable-client-side-phishing-detection',
|
|
'--no-zygote',
|
|
'--disable-web-security',
|
|
'--allow-running-insecure-content',
|
|
'--disable-features=VizDisplayCompositor,AudioServiceOutOfProcess',
|
|
'--disable-software-rasterizer',
|
|
'--disable-canvas-aa',
|
|
'--disable-2d-canvas-clip-aa',
|
|
'--disable-gl-drawing-for-tests',
|
|
'--use-gl=swiftshader'
|
|
]
|
|
};
|
|
|
|
if (this.isHuggingFaceSpace) {
|
|
console.log('检测到Hugging Face Space环境,使用单进程模式');
|
|
launchOptions.args.push(
|
|
'--single-process',
|
|
'--disable-features=site-per-process',
|
|
'--disable-site-isolation-trials',
|
|
'--disable-features=BlockInsecurePrivateNetworkRequests'
|
|
);
|
|
}
|
|
|
|
try {
|
|
const chromePath = process.env.CHROME_BIN ||
|
|
process.env.GOOGLE_CHROME_BIN ||
|
|
'/usr/bin/google-chrome-stable' ||
|
|
'/usr/bin/chromium-browser';
|
|
|
|
if (chromePath && require('fs').existsSync(chromePath)) {
|
|
launchOptions.executablePath = chromePath;
|
|
console.log(`使用Chrome路径: ${chromePath}`);
|
|
}
|
|
} catch (pathError) {
|
|
console.log('未找到自定义Chrome路径,使用默认配置');
|
|
}
|
|
|
|
this.browser = await puppeteer.launch(launchOptions);
|
|
console.log('✅ Puppeteer浏览器初始化成功');
|
|
|
|
this.browser.on('disconnected', () => {
|
|
console.log('Puppeteer浏览器连接断开');
|
|
this.browser = null;
|
|
this.isClosing = false;
|
|
});
|
|
|
|
this.browserLaunchRetries = 0;
|
|
} catch (error) {
|
|
console.error('❌ Puppeteer浏览器初始化失败:', error.message);
|
|
this.browserLaunchRetries++;
|
|
|
|
if (this.browserLaunchRetries <= this.maxBrowserLaunchRetries) {
|
|
console.log(`尝试重新初始化浏览器 (${this.browserLaunchRetries}/${this.maxBrowserLaunchRetries})`);
|
|
await this.delay(3000);
|
|
return this.initBrowser();
|
|
} else {
|
|
console.warn('⚠️ Puppeteer初始化完全失败,将使用fallback方法');
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.browser) {
|
|
try {
|
|
await this.browser.version();
|
|
console.log('✅ 浏览器连接正常');
|
|
} catch (error) {
|
|
console.warn('浏览器连接已断开,重新初始化:', error.message);
|
|
this.browser = null;
|
|
return this.initBrowser();
|
|
}
|
|
}
|
|
|
|
return this.browser;
|
|
}
|
|
|
|
async initPlaywrightBrowser() {
|
|
if (this.playwrightBrowser) {
|
|
try {
|
|
const context = await this.playwrightBrowser.newContext();
|
|
await context.close();
|
|
return this.playwrightBrowser;
|
|
} catch (error) {
|
|
console.warn('Playwright浏览器连接已断开,重新初始化');
|
|
this.playwrightBrowser = null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
console.log('初始化Playwright浏览器...');
|
|
|
|
const launchOptions = {
|
|
headless: true,
|
|
timeout: 30000,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-gpu',
|
|
'--disable-extensions',
|
|
'--no-first-run',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-backgrounding-occluded-windows'
|
|
]
|
|
};
|
|
|
|
if (this.isHuggingFaceSpace) {
|
|
console.log('检测到Hugging Face Space环境,使用Playwright优化配置');
|
|
launchOptions.args.push(
|
|
'--single-process',
|
|
'--no-zygote',
|
|
'--disable-web-security'
|
|
);
|
|
}
|
|
|
|
this.playwrightBrowser = await chromium.launch(launchOptions);
|
|
console.log('✅ Playwright浏览器初始化成功');
|
|
|
|
return this.playwrightBrowser;
|
|
} catch (error) {
|
|
console.error('❌ Playwright浏览器初始化失败:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async closeBrowser() {
|
|
if (this.browser && !this.isClosing) {
|
|
try {
|
|
this.isClosing = true;
|
|
console.log('正在关闭Puppeteer浏览器...');
|
|
await this.browser.close();
|
|
console.log('Puppeteer浏览器已关闭');
|
|
} catch (error) {
|
|
console.warn('关闭浏览器时出错:', error.message);
|
|
} finally {
|
|
this.browser = null;
|
|
this.isClosing = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async closePlaywrightBrowser() {
|
|
if (this.playwrightBrowser) {
|
|
try {
|
|
console.log('正在关闭Playwright浏览器...');
|
|
await this.playwrightBrowser.close();
|
|
console.log('Playwright浏览器已关闭');
|
|
} catch (error) {
|
|
console.warn('关闭Playwright浏览器时出错:', error.message);
|
|
} finally {
|
|
this.playwrightBrowser = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
extractPPTDimensions(htmlContent) {
|
|
try {
|
|
const dimensionMatch = htmlContent.match(/window\.PPT_DIMENSIONS\s*=\s*{\s*width:\s*(\d+),\s*height:\s*(\d+)\s*}/);
|
|
if (dimensionMatch) {
|
|
return {
|
|
width: parseInt(dimensionMatch[1]),
|
|
height: parseInt(dimensionMatch[2])
|
|
};
|
|
}
|
|
|
|
const viewportMatch = htmlContent.match(/width=(\d+),\s*height=(\d+)/);
|
|
if (viewportMatch) {
|
|
return {
|
|
width: parseInt(viewportMatch[1]),
|
|
height: parseInt(viewportMatch[2])
|
|
};
|
|
}
|
|
|
|
return { width: 960, height: 720 };
|
|
} catch (error) {
|
|
console.warn('提取PPT尺寸失败,使用默认尺寸:', error.message);
|
|
return { width: 960, height: 720 };
|
|
}
|
|
}
|
|
|
|
generateFallbackImage(width, height, message = 'Screenshot not available') {
|
|
console.log(`🔄 生成fallback图片: ${width}x${height}`);
|
|
|
|
const svg = `
|
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="100%" height="100%" fill="#f8f9fa"/>
|
|
<rect x="10" y="10" width="${width-20}" height="${height-20}"
|
|
fill="none" stroke="#dee2e6" stroke-width="2" stroke-dasharray="10,5"/>
|
|
<text x="50%" y="40%" text-anchor="middle" dominant-baseline="middle"
|
|
font-family="Arial, sans-serif" font-size="28" fill="#495057" font-weight="bold">
|
|
PPT 预览图
|
|
</text>
|
|
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
|
font-family="Arial, sans-serif" font-size="16" fill="#6c757d">
|
|
${message}
|
|
</text>
|
|
<text x="50%" y="60%" text-anchor="middle" dominant-baseline="middle"
|
|
font-family="Arial, sans-serif" font-size="14" fill="#adb5bd">
|
|
尺寸: ${width} × ${height}
|
|
</text>
|
|
<circle cx="50%" cy="70%" r="20" fill="none" stroke="#28a745" stroke-width="3">
|
|
<animate attributeName="stroke-dasharray" values="0,126;126,126" dur="2s" repeatCount="indefinite"/>
|
|
</circle>
|
|
</svg>
|
|
`;
|
|
|
|
return Buffer.from(svg, 'utf8');
|
|
}
|
|
|
|
optimizeHtmlForScreenshot(htmlContent, targetWidth, targetHeight) {
|
|
console.log(`优化HTML for精确截图, 目标尺寸: ${targetWidth}x${targetHeight}`);
|
|
|
|
const optimizedHtml = htmlContent.replace(
|
|
/(<head[^>]*>)/i,
|
|
`$1
|
|
<meta name="screenshot-mode" content="true">
|
|
<style id="screenshot-precise-control">
|
|
*, *::before, *::after {
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
box-sizing: border-box !important;
|
|
border: none !important;
|
|
outline: none !important;
|
|
}
|
|
|
|
html {
|
|
width: ${targetWidth}px !important;
|
|
height: ${targetHeight}px !important;
|
|
min-width: ${targetWidth}px !important;
|
|
min-height: ${targetHeight}px !important;
|
|
max-width: ${targetWidth}px !important;
|
|
max-height: ${targetHeight}px !important;
|
|
overflow: hidden !important;
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
transform: none !important;
|
|
transform-origin: top left !important;
|
|
}
|
|
|
|
body {
|
|
width: ${targetWidth}px !important;
|
|
height: ${targetHeight}px !important;
|
|
min-width: ${targetWidth}px !important;
|
|
min-height: ${targetHeight}px !important;
|
|
max-width: ${targetWidth}px !important;
|
|
max-height: ${targetHeight}px !important;
|
|
overflow: hidden !important;
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
transform: none !important;
|
|
transform-origin: top left !important;
|
|
}
|
|
|
|
.slide-container {
|
|
width: ${targetWidth}px !important;
|
|
height: ${targetHeight}px !important;
|
|
min-width: ${targetWidth}px !important;
|
|
min-height: ${targetHeight}px !important;
|
|
max-width: ${targetWidth}px !important;
|
|
max-height: ${targetHeight}px !important;
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
overflow: hidden !important;
|
|
transform: none !important;
|
|
transform-origin: top left !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
box-shadow: none !important;
|
|
z-index: 1 !important;
|
|
}
|
|
|
|
html::-webkit-scrollbar,
|
|
body::-webkit-scrollbar,
|
|
*::-webkit-scrollbar {
|
|
display: none !important;
|
|
width: 0 !important;
|
|
height: 0 !important;
|
|
}
|
|
|
|
html { scrollbar-width: none !important; }
|
|
|
|
* {
|
|
-webkit-user-select: none !important;
|
|
-moz-user-select: none !important;
|
|
-ms-user-select: none !important;
|
|
user-select: none !important;
|
|
pointer-events: none !important;
|
|
}
|
|
</style>`
|
|
);
|
|
|
|
return optimizedHtml;
|
|
}
|
|
|
|
async generateScreenshotWithPuppeteer(htmlContent, options = {}, retryCount = 0) {
|
|
try {
|
|
console.log(`🎯 开始Puppeteer截图生成... (尝试 ${retryCount + 1}/${this.maxRetries + 1})`);
|
|
|
|
const browser = await this.initBrowser();
|
|
if (!browser) {
|
|
throw new Error('浏览器初始化失败');
|
|
}
|
|
|
|
const dimensions = this.extractPPTDimensions(htmlContent);
|
|
console.log(`📐 检测到PPT尺寸: ${dimensions.width}x${dimensions.height}`);
|
|
|
|
const optimizedHtml = this.optimizeHtmlForScreenshot(htmlContent, dimensions.width, dimensions.height);
|
|
|
|
let page = null;
|
|
try {
|
|
page = await browser.newPage();
|
|
console.log('📄 创建新页面成功');
|
|
|
|
page.setDefaultTimeout(20000);
|
|
page.setDefaultNavigationTimeout(20000);
|
|
|
|
await page.setViewport({
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
deviceScaleFactor: 1,
|
|
});
|
|
console.log(`🖥️ 设置viewport: ${dimensions.width}x${dimensions.height}`);
|
|
|
|
await page.setContent(optimizedHtml, {
|
|
waitUntil: ['load', 'domcontentloaded'],
|
|
timeout: 20000
|
|
});
|
|
console.log('📝 页面内容设置完成');
|
|
|
|
await page.waitForTimeout(1000);
|
|
console.log('⏱️ 等待渲染完成');
|
|
|
|
console.log('📸 开始执行截图...');
|
|
const screenshot = await page.screenshot({
|
|
type: 'jpeg',
|
|
quality: 85,
|
|
clip: {
|
|
x: 0,
|
|
y: 0,
|
|
width: dimensions.width,
|
|
height: dimensions.height
|
|
},
|
|
omitBackground: false,
|
|
captureBeyondViewport: false,
|
|
});
|
|
|
|
console.log(`✅ Puppeteer截图成功生成,尺寸: ${dimensions.width}x${dimensions.height}, 数据大小: ${screenshot.length} 字节`);
|
|
return screenshot;
|
|
|
|
} finally {
|
|
if (page) {
|
|
try {
|
|
await page.close();
|
|
console.log('📄 页面已关闭');
|
|
} catch (error) {
|
|
console.warn('关闭页面时出错:', error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Puppeteer截图生成失败 (尝试 ${retryCount + 1}):`, error.message);
|
|
|
|
if (error.message.includes('Target closed') ||
|
|
error.message.includes('Connection closed') ||
|
|
error.message.includes('Protocol error')) {
|
|
console.log('🔄 检测到浏览器连接问题,重置浏览器实例');
|
|
this.browser = null;
|
|
this.isClosing = false;
|
|
}
|
|
|
|
if (retryCount < this.maxRetries) {
|
|
const waitTime = (retryCount + 1) * 2;
|
|
console.log(`⏳ 等待 ${waitTime} 秒后重试...`);
|
|
await this.delay(waitTime * 1000);
|
|
return this.generateScreenshotWithPuppeteer(htmlContent, options, retryCount + 1);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async generateScreenshotWithPlaywright(htmlContent, options = {}) {
|
|
try {
|
|
console.log('🎭 开始Playwright截图生成...');
|
|
|
|
const browser = await this.initPlaywrightBrowser();
|
|
if (!browser) {
|
|
throw new Error('Playwright浏览器初始化失败');
|
|
}
|
|
|
|
const dimensions = this.extractPPTDimensions(htmlContent);
|
|
console.log(`📐 Playwright检测到PPT尺寸: ${dimensions.width}x${dimensions.height}`);
|
|
|
|
const context = await browser.newContext({
|
|
viewport: {
|
|
width: dimensions.width,
|
|
height: dimensions.height
|
|
},
|
|
deviceScaleFactor: 1,
|
|
hasTouch: false,
|
|
isMobile: false
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
|
|
await page.setContent(htmlContent, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 15000
|
|
});
|
|
|
|
await page.waitForTimeout(800);
|
|
|
|
const screenshot = await page.screenshot({
|
|
type: 'jpeg',
|
|
quality: 90,
|
|
clip: {
|
|
x: 0,
|
|
y: 0,
|
|
width: dimensions.width,
|
|
height: dimensions.height
|
|
},
|
|
animations: 'disabled'
|
|
});
|
|
|
|
await context.close();
|
|
|
|
console.log(`✅ Playwright截图成功生成,尺寸: ${dimensions.width}x${dimensions.height}, 数据大小: ${screenshot.length} 字节`);
|
|
return screenshot;
|
|
|
|
} catch (error) {
|
|
console.error('❌ Playwright截图生成失败:', error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async generateScreenshot(htmlContent, options = {}) {
|
|
console.log('🎯 开始生成截图...');
|
|
|
|
if (this.preferredEngine === 'puppeteer') {
|
|
try {
|
|
console.log('🚀 尝试使用Puppeteer生成截图');
|
|
const screenshot = await this.generateScreenshotWithPuppeteer(htmlContent, options);
|
|
console.log('✅ Puppeteer截图生成成功');
|
|
return screenshot;
|
|
} catch (puppeteerError) {
|
|
console.warn('⚠️ Puppeteer截图失败,尝试Playwright:', puppeteerError.message);
|
|
|
|
try {
|
|
const screenshot = await this.generateScreenshotWithPlaywright(htmlContent, options);
|
|
console.log('✅ Playwright截图生成成功');
|
|
this.preferredEngine = 'playwright';
|
|
return screenshot;
|
|
} catch (playwrightError) {
|
|
console.warn('⚠️ Playwright截图也失败,使用fallback方法:', playwrightError.message);
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
console.log('🎭 尝试使用Playwright生成截图');
|
|
const screenshot = await this.generateScreenshotWithPlaywright(htmlContent, options);
|
|
console.log('✅ Playwright截图生成成功');
|
|
return screenshot;
|
|
} catch (playwrightError) {
|
|
console.warn('⚠️ Playwright截图失败,尝试Puppeteer:', playwrightError.message);
|
|
|
|
try {
|
|
const screenshot = await this.generateScreenshotWithPuppeteer(htmlContent, options);
|
|
console.log('✅ Puppeteer截图生成成功');
|
|
return screenshot;
|
|
} catch (puppeteerError) {
|
|
console.warn('⚠️ Puppeteer截图也失败,使用fallback方法:', puppeteerError.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
const dimensions = this.extractPPTDimensions(htmlContent);
|
|
const fallbackImage = this.generateFallbackImage(
|
|
dimensions.width,
|
|
dimensions.height,
|
|
'截图引擎不可用,显示占位图'
|
|
);
|
|
|
|
console.log(`📋 生成fallback图片,尺寸: ${dimensions.width}x${dimensions.height}`);
|
|
return fallbackImage;
|
|
}
|
|
|
|
setPreferredEngine(engine) {
|
|
if (['puppeteer', 'playwright'].includes(engine)) {
|
|
this.preferredEngine = engine;
|
|
console.log(`截图引擎偏好设置为: ${engine}`);
|
|
}
|
|
}
|
|
|
|
async captureScreenshot(htmlContent, width, height, options = {}) {
|
|
console.log('使用兼容方法captureScreenshot,建议使用generateScreenshot');
|
|
return this.generateScreenshot(htmlContent, options);
|
|
}
|
|
|
|
async cleanup() {
|
|
await Promise.all([
|
|
this.closeBrowser(),
|
|
this.closePlaywrightBrowser()
|
|
]);
|
|
}
|
|
}
|
|
|
|
const screenshotService = new ScreenshotService();
|
|
|
|
process.on('exit', async () => {
|
|
await screenshotService.cleanup();
|
|
});
|
|
|
|
process.on('SIGINT', async () => {
|
|
await screenshotService.cleanup();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', async () => {
|
|
await screenshotService.cleanup();
|
|
process.exit(0);
|
|
});
|
|
|
|
export default screenshotService; |