web_ppt / backend /src /services /screenshotService.js
CatPtain's picture
Upload screenshotService.js
613c920 verified
raw
history blame
21.1 kB
import puppeteer from 'puppeteer';
// 添加Playwright作为备用截图引擎
import { chromium } from 'playwright';
class ScreenshotService {
constructor() {
this.browser = null;
this.playwrightBrowser = null; // 添加Playwright浏览器实例
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;