const fs = require('fs'); const path = require('path'); const puppeteer = require('puppeteer-core'); const chromiumModule = require('@sparticuz/chromium'); const sharp = require('sharp'); const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function' ? chromiumModule : chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function' ? chromiumModule.default : chromiumModule; const { escapeHtml, mediaKind, renderEditorJsContent, sanitizeFontFamily, sanitizeFontSize, sanitizeTextColor } = require('../../player/render-helpers'); const { createRequestAuthHeaders } = require('../../request-auth'); const SYSTEM_CHROMIUM_PATHS = [ process.env.PUPPETEER_EXECUTABLE_PATH, process.env.CHROMIUM_PATH, '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/local/bin/chromium', '/snap/bin/chromium' ].filter(Boolean); const PLAYER_VIEWPORT = { width: 1920, height: 1080, deviceScaleFactor: 1 }; const THUMBNAIL_MAX_SIZE = { width: 480, height: 270 }; function normalizeBaseUrl(baseUrl) { return String(baseUrl || '').trim().replace(/\/$/, ''); } function resolveAssetUrl(baseUrl, value) { const raw = String(value || '').trim(); if (!raw) { return ''; } if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) { return raw; } const normalizedBaseUrl = normalizeBaseUrl(baseUrl); if (!normalizedBaseUrl) { return raw; } if (raw.startsWith('/')) { return normalizedBaseUrl + raw; } return normalizedBaseUrl + '/' + raw.replace(/^\/+/, ''); } function getCanvasSize(slide) { const template = slide && slide.template ? slide.template : null; return { width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) }; } function getRegionContent(slide, region) { const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {}; return content && typeof content === 'object' ? content : { value: content }; } function hasVisibleContent(html) { return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); } function buildTextRegionMarkup(region, regionContent) { const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family); const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size); const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color); const renderedBody = renderEditorJsContent(regionContent.value || ''); if (!hasVisibleContent(renderedBody)) { return ''; } return '
' + renderedBody + '
'; } function buildRegionInnerHtml(region, regionContent, baseUrl) { const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : ''; if (regionType === 'image') { const src = resolveAssetUrl(baseUrl, rawValue); return src ? '' + escapeHtml(region.label || region.region_key || 'image') + '' : ''; } if (regionType === 'webpage') { const src = resolveAssetUrl(baseUrl, rawValue); return src ? '' : ''; } if (regionType === 'html') { const html = String(rawValue || '').trim(); return html ? '' : ''; } if (regionType === 'rtmp') { const label = String(rawValue || '').trim() || 'RTMP source'; return '
' + escapeHtml(label) + '
'; } return buildTextRegionMarkup(region, regionContent); } function buildSlideHtml(slide, baseUrl) { const canvasSize = getCanvasSize(slide); const template = slide && slide.template ? slide.template : null; const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111'); const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : ''; if (!template) { const mediaPath = String(slide && slide.media_path || '').trim(); const mediaUrl = resolveAssetUrl(baseUrl, mediaPath); const kind = mediaKind(mediaPath || slide.media_type || ''); const mediaMarkup = kind === 'image' && mediaUrl ? '' + escapeHtml(slide.title || 'slide') + '' : ''; return [ '', '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', mediaMarkup ? '
' + mediaMarkup + '
' : '', '
', ' ', '' ].join('\n'); } const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) { return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0); }) : []; const regionsHtml = regions.map(function (region) { const content = getRegionContent(slide, region); const left = Math.max(0, Number(region.x || 0)); const top = Math.max(0, Number(region.y || 0)); const width = Math.max(1, Number(region.width || 1)); const height = Math.max(1, Number(region.height || 1)); const regionType = String(region.region_type || 'text').toLowerCase(); const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1)); const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1)); const innerHtml = regionType === 'text' ? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content) : buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl); if (!innerHtml) { return ''; } return [ '
', innerHtml, '
' ].join(''); }).join(''); return [ '', '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', '
', backgroundImagePath ? ' ' : '', regionsHtml, '
', '
', ' ', '' ].join('\n'); } async function launchBrowser() { let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { return fs.existsSync(candidate); }) || ''; const usingSystemChromium = Boolean(executablePath); if (!executablePath && chromium && typeof chromium.executablePath === 'function') { executablePath = await chromium.executablePath(); } if (!executablePath || !fs.existsSync(executablePath)) { throw new Error('Chromium executable was not found.'); } if (usingSystemChromium) { return puppeteer.launch({ args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu' ], defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 }, executablePath: executablePath, headless: true }); } return puppeteer.launch({ args: puppeteer.defaultArgs({ args: chromium && chromium.args ? chromium.args : [], headless: 'shell' }), defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null, executablePath: executablePath, headless: 'shell' }); } async function captureSlideThumbnail(options) { const pool = options && options.pool; const common = options && options.common; const mediaDir = String(options && options.mediaDir || '').trim(); const baseUrl = normalizeBaseUrl(options && options.baseUrl); const slideId = Number(options && options.slideId || 0); const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim(); if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) { throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.'); } const slide = await common.fetchSlideById(pool, slideId); if (!slide) { throw new Error('Slide not found.'); } const canvasSize = getCanvasSize(slide); const thumbnailDir = path.join(mediaDir, 'thumbnails'); const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); const fullSizePath = filePath.replace(/\.png$/i, '.full.png'); const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png'); const thumbnailPath = thumbnailRelativePath; await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); const browser = await launchBrowser(); try { const page = await browser.newPage(); try { const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview'; const previewUrl = baseUrl + previewPath; await page.setExtraHTTPHeaders(createRequestAuthHeaders({ method: 'GET', pathname: previewPath, body: {} })); await page.setViewport(PLAYER_VIEWPORT); await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true }); const canvas = await page.$('.slide-canvas'); if (!canvas) { throw new Error('Player render did not produce a slide canvas.'); } await canvas.screenshot({ path: fullSizePath }); } finally { await page.close().catch(function () { return null; }); } } finally { await browser.close().catch(function () { return null; }); } await sharp(fullSizePath) .resize({ width: THUMBNAIL_MAX_SIZE.width, height: THUMBNAIL_MAX_SIZE.height, fit: 'inside', withoutEnlargement: true }) .png() .toFile(thumbnailTempPath); await fs.promises.rm(filePath, { force: true }); await fs.promises.rename(thumbnailTempPath, filePath); await fs.promises.unlink(fullSizePath).catch(function (error) { if (!error || error.code !== 'ENOENT') { throw error; } }); await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]); return { slideId: slide.id, thumbnailPath: thumbnailPath, filePath: filePath, fullSizePath: fullSizePath, mediaKind: mediaKind(slide.media_path || '') }; } module.exports = { captureSlideThumbnail: captureSlideThumbnail };