// Slide thumbnail capture helpers for player-sourced screenshots.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const vm = require('vm');
const {
escapeHtml,
mediaKind,
renderEditorJsContent,
sanitizeFontFamily,
sanitizeFontSize,
sanitizeTextColor
} = require('#src/player/render-helpers');
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const placeholderUtils = (() => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8'), sandbox);
return sandbox.window.placeholderUtils || {};
})();
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 normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.html !== undefined) {
return normalizeRenderableValue(value.html);
}
if (value.url !== undefined) {
return normalizeRenderableValue(value.url);
}
if (value.href !== undefined) {
return normalizeRenderableValue(value.href);
}
if (value.src !== undefined) {
return normalizeRenderableValue(value.src);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
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 getThumbnailCanvasSize(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 buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0;
const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0;
const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0;
const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0;
return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';';
}
function hasVisibleContent(html) {
var raw = String(html || '').trim();
if (!raw) {
return false;
}
if (/
]+>/g, '').trim());
}
function resolvePlaceholderPath(value, expression) {
const pathValue = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
return pathValue.split('.').reduce(function (current, segment) {
return current === undefined || current === null ? '' : current[segment];
}, value);
}
function getImagePlaceholderConfig(expression) {
const match = String(expression || '').match(/\.image\s*\(\s*([0-9]+)?\s*,?\s*([0-9]+)?\s*\)/i);
return {
width: match && match[1] ? Number(match[1]) : 0,
height: match && match[2] ? Number(match[2]) : 0
};
}
function normalizeWeatherExpression(expression) {
const value = String(expression || '').trim();
const currentAliases = { temp: 'current.temperature_2m', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', wind: 'current.wind_speed_10m', precip: 'current.precipitation', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' };
const globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' };
if (globalAliases[value]) return globalAliases[value];
const currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || '');
const indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/);
if (!indexed) return value;
const fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
const field = fieldMatch ? fieldMatch[1] : indexed[3];
const aliases = indexed[1] === 'daily' ? { time: 'time', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' };
if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value;
return field === 'icon' ? indexed[1] + '.' + aliases[field] + '.' + indexed[2] + '.icon' + (fieldMatch[2] || '') : indexed[1] + '.' + aliases[field] + '.' + indexed[2] + (fieldMatch[2] || '');
}
function weatherIconForCode(code) {
const value = Number(code);
if (value === 0) return 'bi-sun';
if (value <= 3) return 'bi-cloud-sun';
if (value <= 48) return 'bi-cloud-fog';
if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain';
if (value <= 77) return 'bi-cloud-snow';
return 'bi-cloud-lightning-rain';
}
function substitutePlaceholders(html, regionContent, options) {
const source = String(html || '');
const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
if (!item) return source;
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
const weatherExpression = type === 'weather' ? normalizeWeatherExpression(expression) : expression;
const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
? placeholderUtils.resolvePlaceholderExpression(item, weatherExpression, { timeZone: item.timezone })
: resolvePlaceholderPath(item, expression);
if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(expression)) {
return placeholderUtils.renderProgressPlaceholder(item, expression);
}
if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) {
const codeExpression = weatherExpression.replace(/\.icon(?:\(.*\))?$/, '');
const iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\)$/);
const width = iconSize ? Number(iconSize[1]) : 0;
const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width;
const sizeStyle = width ? ' style="display:inline-block;width:' + width + 'px;height:' + height + 'px;font-size:' + width + 'px;line-height:' + height + 'px;"' : '';
return '';
}
const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
return escapeHtml(value);
}
const remoteUrl = String(value || '').trim();
if (!/^https?:\/\//i.test(remoteUrl)) return '';
const imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : getImagePlaceholderConfig(expression);
const cacheFile = options && typeof options.getCachedImagePath === 'function' ? options.getCachedImagePath(remoteUrl) : '';
const imageUrl = cacheFile || remoteUrl;
const style = imageConfig.width && imageConfig.height
? 'display:block;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;object-fit:contain;'
: 'display:block;max-width:' + (imageConfig.width || 100) + 'px;max-height:' + (imageConfig.height || 100) + 'px;width:auto;height:auto;';
return '
';
});
}
function buildTextRegionMarkup(region, regionContent, options) {
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(substitutePlaceholders(regionContent.value || '', regionContent, options));
if (!hasVisibleContent(renderedBody)) {
return '';
}
return '
';
}
function buildRegionInnerHtml(region, regionContent, baseUrl, options) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? ''
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? ''
: '';
}
if (regionType === 'webpage') {
return 'Webpage preview unavailable
';
}
if (regionType === 'qr-code') {
const src = String(regionContent.qr_preview || '').trim();
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
return src
? ''
: '';
}
if (regionType === 'html') {
const html = String(rawValue || '').trim();
if (!html) {
return '';
}
if (/^';
}
return '';
}
if (regionType === 'rtmp') {
const label = String(rawValue || '').trim() || 'RTMP source';
return '' + escapeHtml(label) + '
';
}
return buildTextRegionMarkup(region, regionContent, Object.assign({}, options, { baseUrl: baseUrl }));
}
async function loadChromium() {
const chromiumModule = await import('@sparticuz/chromium');
const resolved = chromiumModule && chromiumModule.default ? chromiumModule.default : chromiumModule;
return resolved;
}
async function loadPuppeteer() {
return require('puppeteer-core');
}
function loadSharp() {
return require('sharp');
}
function buildThumbnailPreviewMarkup(slide, baseUrl, options) {
const template = slide && slide.template ? slide.template : null;
if (!template) {
return '';
}
const canvasSize = getThumbnailCanvasSize(slide);
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region);
const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
});
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl, options);
}).join('');
}
function buildThumbnailPreviewPayload(slide, options) {
const template = slide && slide.template ? slide.template : null;
const canvasSize = getThumbnailCanvasSize(slide);
return {
thumbnailPreview: true,
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options)
};
}
async function launchBrowser() {
const puppeteer = await loadPuppeteer();
const chromium = await loadChromium();
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'
],
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 sharp = loadSharp();
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 rssFeedsData = typeof common.fetchRssFeedsData === 'function' ? await common.fetchRssFeedsData(pool) : { rssFeeds: [] };
const rssFeeds = await Promise.all((rssFeedsData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, feed.id) : [];
return Object.assign({}, feed, { items: items });
}));
const apiSourcesData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const apiSources = (apiSourcesData.apiSources || []).map(function (source) {
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
});
const weatherLocationsData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
const weatherLocations = (weatherLocationsData.weatherLocations || []).map(function (location) {
let snapshot = null;
try {
snapshot = JSON.parse(location.last_response_json || '');
} catch (_error) {
snapshot = null;
}
if (!snapshot || typeof snapshot !== 'object') return null;
const temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
const windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
const precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm';
const data = Object.assign({}, convertWeatherSnapshot(snapshot, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' }), { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: temperatureUnit, wind_unit: windUnit, precip_unit: precipitationUnit });
data.current = Object.assign({}, data.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
data.daily = Object.assign({}, data.daily || {}, { temperature_unit: temperatureUnit });
return { id: location.id, data: data };
}).filter(Boolean);
function getApiItems(source) {
const response = source && source.responseJson;
const itemsPath = String(source && source.items_path || '').trim();
if (itemsPath) {
const selected = itemsPath.split('.').reduce(function (current, segment) {
return current === undefined || current === null ? '' : current[segment];
}, response);
return Array.isArray(selected) ? selected : [];
}
if (Array.isArray(response)) return response;
if (response && Array.isArray(response.items)) return response.items;
if (response && Array.isArray(response.results)) return response.results;
if (response && Array.isArray(response.data)) return response.data;
return response ? [response] : [];
}
function getThumbnailItem(type, regionContent) {
const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1);
if (type === 'api') {
const source = apiSources.find(function (entry) { return Number(entry.id) === Number(regionContent.source_id); });
return getApiItems(source)[index] || null;
}
if (type === 'rss') {
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
return feed && Array.isArray(feed.items) ? feed.items[index] || null : null;
}
if (type === 'weather') {
const location = weatherLocations.find(function (entry) { return Number(entry.id) === Number(regionContent.weather_location_id); });
return location ? location.data : null;
}
return null;
}
function getCachedImagePath(remoteUrl) {
const hash = crypto.createHash('sha256').update(String(remoteUrl || '')).digest('hex');
const cacheDir = path.join(mediaDir, 'player-cache', 'remote-images');
const candidates = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
const candidate = candidates.map(function (extension) { return path.join(cacheDir, hash + extension); }).find(function (filePath) { return fs.existsSync(filePath); });
return candidate ? '/media/player-cache/remote-images/' + path.basename(candidate) : '';
}
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 });
async function waitForThumbnailRender(page) {
await page.waitForFunction(function () {
return document.readyState === 'complete' && Boolean(document.querySelector('#popup-preview-canvas'));
}, { timeout: 30000 });
await page.waitForFunction(function () {
var videos = Array.prototype.slice.call(document.querySelectorAll('#popup-preview-canvas video'));
return videos.every(function (video) {
return video.readyState >= 2;
});
}, { timeout: 30000 });
await page.evaluate(async function () {
if (document.fonts && document.fonts.ready) {
try {
await document.fonts.ready;
} catch (_error) {
return null;
}
}
});
await new Promise(function (resolve) {
setTimeout(resolve, 1000);
});
}
const browser = await launchBrowser();
try {
const page = await browser.newPage();
try {
const previewPath = '/api/internal/slide-thumbnails/' + encodeURIComponent(String(slide.id)) + '/popup-preview';
const previewUrl = baseUrl + previewPath;
const canvasSize = getThumbnailCanvasSize(slide);
await page.setViewport({
width: Math.max(1, Number(canvasSize.width || PLAYER_VIEWPORT.width)),
height: Math.max(1, Number(canvasSize.height || PLAYER_VIEWPORT.height)),
deviceScaleFactor: 1
});
await page.setExtraHTTPHeaders(Object.assign({}, createRequestAuthHeaders({
method: 'GET',
pathname: previewPath
}), {
'x-pulse-page-auth': createPageAuthToken({ scope: 'thumbnail-preview', slideId: slide.id })
}));
const previewResponse = await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
if (!previewResponse || previewResponse.status() >= 400) {
throw new Error('Popup preview returned HTTP ' + (previewResponse ? previewResponse.status() : 'no response') + '.');
}
await waitForThumbnailRender(page);
const canvas = await page.$('#popup-preview-canvas');
if (!canvas) {
throw new Error('Popup preview did not produce a slide canvas.');
}
await page.evaluate(function () {
var canvasElement = document.querySelector('#popup-preview-canvas');
if (canvasElement) {
canvasElement.style.transform = 'none';
canvasElement.style.transformOrigin = 'top left';
}
});
const canvasBounds = await canvas.boundingBox();
if (!canvasBounds) {
throw new Error('Popup preview canvas has no screenshot bounds.');
}
await page.screenshot({
path: fullSizePath,
clip: {
x: Math.max(0, canvasBounds.x),
y: Math.max(0, canvasBounds.y),
width: Math.max(1, canvasBounds.width),
height: Math.max(1, canvasBounds.height)
}
});
} 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 c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
return {
slideId: slide.id,
thumbnailPath: thumbnailPath,
filePath: filePath,
fullSizePath: fullSizePath,
mediaKind: mediaKind('')
};
}
module.exports = {
captureSlideThumbnail: captureSlideThumbnail,
buildThumbnailPreviewPayload: buildThumbnailPreviewPayload,
buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup
};