|
import puppeteer from 'puppeteer';
|
|
|
|
class ScreenshotService {
|
|
constructor() {
|
|
this.browser = 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;
|
|
}
|
|
|
|
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'
|
|
]
|
|
};
|
|
|
|
|
|
if (this.isHuggingFaceSpace) {
|
|
console.log('检测到Hugging Face Space环境,使用保守配置');
|
|
launchOptions.args.push(
|
|
'--single-process',
|
|
'--no-zygote',
|
|
'--disable-web-security',
|
|
'--disable-features=site-per-process',
|
|
'--disable-site-isolation-trials'
|
|
);
|
|
}
|
|
|
|
this.browser = await puppeteer.launch(launchOptions);
|
|
|
|
|
|
this.browser.on('disconnected', () => {
|
|
console.log('Puppeteer浏览器连接断开');
|
|
this.browser = null;
|
|
this.isClosing = false;
|
|
});
|
|
|
|
console.log('Puppeteer浏览器初始化完成');
|
|
this.browserLaunchRetries = 0;
|
|
} catch (error) {
|
|
console.error('Puppeteer浏览器初始化失败:', error);
|
|
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();
|
|
} catch (error) {
|
|
console.warn('浏览器连接已断开,重新初始化:', error.message);
|
|
this.browser = null;
|
|
return this.initBrowser();
|
|
}
|
|
}
|
|
|
|
return this.browser;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
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') {
|
|
|
|
const svg = `
|
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="100%" height="100%" fill="#f0f0f0"/>
|
|
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
|
font-family="Arial, sans-serif" font-size="24" fill="#666">
|
|
${message}
|
|
</text>
|
|
<text x="50%" y="60%" text-anchor="middle" dominant-baseline="middle"
|
|
font-family="Arial, sans-serif" font-size="16" fill="#999">
|
|
Size: ${width}x${height}
|
|
</text>
|
|
</svg>
|
|
`;
|
|
|
|
|
|
const base64Svg = Buffer.from(svg).toString('base64');
|
|
const dataUrl = `data:image/svg+xml;base64,${base64Svg}`;
|
|
|
|
|
|
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 {
|
|
const browser = await this.initBrowser();
|
|
if (!browser) {
|
|
throw new Error('浏览器初始化失败');
|
|
}
|
|
|
|
console.log(`开始生成截图... (Puppeteer尝试 ${retryCount + 1}/${this.maxRetries + 1})`);
|
|
|
|
|
|
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();
|
|
|
|
|
|
page.setDefaultTimeout(45000);
|
|
page.setDefaultNavigationTimeout(45000);
|
|
|
|
|
|
await page.setViewport({
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
deviceScaleFactor: options.deviceScaleFactor || 1,
|
|
});
|
|
|
|
|
|
await page.setContent(optimizedHtml, {
|
|
waitUntil: ['load', 'domcontentloaded'],
|
|
timeout: 45000
|
|
});
|
|
|
|
|
|
await page.waitForTimeout(1500);
|
|
|
|
|
|
const screenshot = await page.screenshot({
|
|
type: options.format || 'jpeg',
|
|
quality: options.quality || 90,
|
|
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();
|
|
} 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) {
|
|
console.log(`等待 ${(retryCount + 1) * 3} 秒后重试...`);
|
|
await this.delay((retryCount + 1) * 3000);
|
|
return this.generateScreenshotWithPuppeteer(htmlContent, options, retryCount + 1);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async generateScreenshot(htmlContent, options = {}) {
|
|
try {
|
|
|
|
return await this.generateScreenshotWithPuppeteer(htmlContent, options);
|
|
} catch (puppeteerError) {
|
|
console.warn('Puppeteer截图失败,使用fallback方法:', puppeteerError.message);
|
|
|
|
|
|
const dimensions = this.extractPPTDimensions(htmlContent);
|
|
const fallbackImage = this.generateFallbackImage(
|
|
dimensions.width,
|
|
dimensions.height,
|
|
'PPT Preview'
|
|
);
|
|
|
|
console.log(`生成fallback图片,尺寸: ${dimensions.width}x${dimensions.height}`);
|
|
return fallbackImage;
|
|
}
|
|
}
|
|
|
|
|
|
async captureScreenshot(htmlContent, width, height, options = {}) {
|
|
console.log('使用兼容方法captureScreenshot,建议使用generateScreenshot');
|
|
return this.generateScreenshot(htmlContent, options);
|
|
}
|
|
|
|
|
|
async cleanup() {
|
|
await this.closeBrowser();
|
|
}
|
|
}
|
|
|
|
|
|
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; |