367 lines
17 KiB
JavaScript
367 lines
17 KiB
JavaScript
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 '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
|
}
|
|
|
|
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
|
|
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
|
: '';
|
|
}
|
|
|
|
if (regionType === 'webpage') {
|
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
|
return src
|
|
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
|
: '';
|
|
}
|
|
|
|
if (regionType === 'html') {
|
|
const html = String(rawValue || '').trim();
|
|
return html
|
|
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
|
: '';
|
|
}
|
|
|
|
if (regionType === 'rtmp') {
|
|
const label = String(rawValue || '').trim() || 'RTMP source';
|
|
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
|
}
|
|
|
|
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
|
|
? '<img src="' + escapeHtml(mediaUrl) + '" alt="' + escapeHtml(slide.title || 'slide') + '" />'
|
|
: '';
|
|
|
|
return [
|
|
'<!doctype html>',
|
|
'<html>',
|
|
' <head>',
|
|
' <meta charset="utf-8" />',
|
|
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
|
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
|
' <style>',
|
|
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
|
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
|
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
|
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
|
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
|
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
|
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
|
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
|
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
|
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
|
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
|
' </style>',
|
|
' </head>',
|
|
' <body>',
|
|
' <div class="thumbnail-stage">',
|
|
mediaMarkup ? '<div class="slide"><div class="slide-canvas slide-media" style="width:' + canvasSize.width + 'px;height:' + canvasSize.height + 'px;">' + mediaMarkup + '</div></div>' : '',
|
|
' </div>',
|
|
' </body>',
|
|
'</html>'
|
|
].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 [
|
|
'<div class="template-region ' + escapeHtml(regionType) + '" style="left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';">',
|
|
innerHtml,
|
|
'</div>'
|
|
].join('');
|
|
}).join('');
|
|
|
|
return [
|
|
'<!doctype html>',
|
|
'<html>',
|
|
' <head>',
|
|
' <meta charset="utf-8" />',
|
|
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
|
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
|
' <style>',
|
|
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
|
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
|
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
|
' .thumbnail-stage .template-stage { position: relative; width: 100%; height: 100%; }',
|
|
' .thumbnail-stage .template-stage .template-background { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; z-index: 0; }',
|
|
' .thumbnail-stage .template-region { position: absolute; overflow: hidden; box-sizing: border-box; }',
|
|
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
|
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
|
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
|
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
|
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
|
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
|
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
|
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
|
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
|
' </style>',
|
|
' </head>',
|
|
' <body>',
|
|
' <div class="thumbnail-stage">',
|
|
' <div class="template-stage" style="background-color:' + escapeHtml(backgroundColor) + ';">',
|
|
backgroundImagePath ? ' <img class="template-background" src="' + escapeHtml(backgroundImagePath) + '" alt="" />' : '',
|
|
regionsHtml,
|
|
' </div>',
|
|
' </div>',
|
|
' </body>',
|
|
'</html>'
|
|
].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
|
|
}; |