1
0

Ooops forgot to commit

This commit is contained in:
2025-11-25 12:20:00 -05:00
parent 4964211254
commit 5209e8cf26
7 changed files with 407 additions and 67 deletions

View File

@@ -56,6 +56,14 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
WS4KP_PORT=8080 \
MUSIC_PATH=/music
# Copy all Star fonts and remove spaces from filenames for FFmpeg
RUN mkdir -p /fonts && \
find / -name "Star*.ttf" -type f 2>/dev/null | while read font; do \
newname=$(basename "$font" | tr -d ' '); \
cp "$font" "/fonts/$newname"; \
done && \
echo "✓ Fonts available:" && ls -1 /fonts/ || echo "⚠ No fonts found"
# Install our streaming app
WORKDIR /streaming-app
COPY package.json yarn.lock* ./

View File

@@ -1,14 +1,18 @@
services:
app:
ws4kp-to-hls:
build: .
ports:
- "${PORT:-3000}:${PORT:-3000}"
# WS4KP port - comment out this line if you don't need external access to WS4KP
# - "${WS4KP_EXTERNAL_PORT:-8080}:8080"
# - "${WS4KP_EXTERNAL_PORT:-8080}:${WS4KP_EXTERNAL_PORT:-8080}"
shm_size: 2gb
environment:
- PORT=${PORT:-3000}
- WS4KP_PORT=8080
- WS4KP_PORT=${WS4KP_PORT:-8080}
- DEFAULT_FPS=${DEFAULT_FPS:-30}
- SCREENSHOT_FORMAT=${SCREENSHOT_FORMAT:-jpeg}
- SCREENSHOT_QUALITY=${SCREENSHOT_QUALITY:-95}
- DEBUG_MODE=${DEBUG_MODE:-false}
volumes:
- ./cache:/streaming-app/cache
restart: unless-stopped

View File

@@ -7,6 +7,10 @@ const app = express();
const PORT = process.env.PORT || 3000;
const WS4KP_PORT = process.env.WS4KP_PORT || 8080;
const MUSIC_PATH = process.env.MUSIC_PATH || '/music';
const DEFAULT_FPS = parseInt(process.env.DEFAULT_FPS || '30');
const SCREENSHOT_FORMAT = process.env.SCREENSHOT_FORMAT || 'jpeg';
const SCREENSHOT_QUALITY = parseInt(process.env.SCREENSHOT_QUALITY || '95');
const DEBUG_MODE = process.env.DEBUG_MODE === 'true';
/**
* Build WS4KP weather URL with given coordinates and settings
@@ -77,15 +81,19 @@ function buildWeatherUrl(latitude, longitude, settings) {
return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`;
}
// Basic stream endpoint (with optional music parameter)
// Basic stream endpoint (with optional music and scroll parameters)
app.get('/stream', (req, res) => {
const { music = 'false' } = req.query;
const { music = 'false', debug = DEBUG_MODE ? 'true' : 'false' } = req.query;
const useMusic = music === 'true';
const startTime = Date.now();
streamHandler(req, res, {
useMusic,
musicPath: MUSIC_PATH,
startTime
startTime,
defaultFps: DEFAULT_FPS,
screenshotFormat: SCREENSHOT_FORMAT,
screenshotQuality: SCREENSHOT_QUALITY,
debugMode: debug === 'true'
});
});
@@ -134,28 +142,18 @@ app.get('/weather', async (req, res) => {
};
let lateGeocodePromise = null;
let geocodeDataPromise = null;
let initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>';
if (city && city !== 'Toronto, ON, CAN') {
// Start geocoding (only call once)
// Start geocoding in background - don't wait for it
const geocodePromise = geocodeCity(city);
geocodeDataPromise = geocodePromise; // Save for city name overlay
// Try to use quick result if available within 1 second
const quickResult = Promise.race([
geocodePromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000))
]).catch(() => null); // Timeout = null, will use late result
// Always start with black screen immediately
initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>';
// Build URL from quick result if available
const urlPromise = quickResult.then(geoResult => {
if (geoResult) {
// Got quick result
return buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings);
}
return null; // Will use initial black screen
});
// Late geocode promise (reuses the same geocode call)
// Late geocode promise will load the actual weather page
lateGeocodePromise = geocodePromise.then(geoResult => {
return buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings);
}).catch(err => {
@@ -166,6 +164,8 @@ app.get('/weather', async (req, res) => {
} else {
// Toronto default
initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings);
// Create resolved promise with Toronto data
geocodeDataPromise = Promise.resolve({ cityName: 'Toronto' });
}
const startTime = Date.now();
@@ -178,11 +178,17 @@ app.get('/weather', async (req, res) => {
req.query.hideLogo = hideLogo;
// Call stream handler with music enabled
const { debug = DEBUG_MODE ? 'true' : 'false' } = req.query;
return streamHandler(req, res, {
useMusic: true,
musicPath: MUSIC_PATH,
lateGeocodePromise,
startTime
geocodeDataPromise,
startTime,
defaultFps: DEFAULT_FPS,
screenshotFormat: SCREENSHOT_FORMAT,
screenshotQuality: SCREENSHOT_QUALITY,
debugMode: debug === 'true'
});
});
@@ -197,7 +203,11 @@ app.listen(PORT, () => {
if (process.env.WS4KP_EXTERNAL_PORT) {
console.log(`WS4KP weather service on port ${process.env.WS4KP_EXTERNAL_PORT}`);
}
console.log(`Usage: http://localhost:${PORT}/stream?url=http://example.com`);
console.log(`\nConfiguration:`);
console.log(` Default FPS: ${DEFAULT_FPS}`);
console.log(` Screenshot Format: ${SCREENSHOT_FORMAT}`);
console.log(` Screenshot Quality: ${SCREENSHOT_QUALITY}${SCREENSHOT_FORMAT === 'jpeg' ? '%' : ' (ignored for PNG)'}`);
console.log(`\nUsage: http://localhost:${PORT}/stream?url=http://example.com`);
console.log(`Weather: http://localhost:${PORT}/weather?city=YourCity`);
// Pre-validate music files on startup to cache results

View File

@@ -6,9 +6,36 @@ const { createPlaylist } = require('./musicPlaylist');
* @param {number} options.fps - Frames per second
* @param {boolean} options.useMusic - Whether to include audio from music files
* @param {string} options.musicPath - Path to music directory
* @returns {Promise<{args: string[], playlistFile: string|null, hasMusic: boolean}>}
* @param {string} options.inputFormat - Input image format (jpeg or png)
* @param {boolean} options.captureAtHigherFps - Whether input is captured at 2x FPS for smoother output
* @param {boolean} options.debugMode - Whether to show debug stats overlay
* @param {string} options.cityName - City name to display (optional)
* @param {number} options.videoWidth - Video width for centering text
* @param {number} options.videoHeight - Video height for scaling text
* @returns {Promise<{args: string[], playlistFile: string|null, hasMusic: boolean, captureFps: number}>}
*/
async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', captureAtHigherFps = false, debugMode = false, cityName = null, videoWidth = 1920, videoHeight = 1080 }) {
const captureFps = captureAtHigherFps ? fps * 2 : fps;
// Scale text sizes and positions based on video height (1080p as reference)
const scale = videoHeight / 1080;
const debugFontSize = Math.round(20 * scale);
const debugY = Math.round(10 * scale);
const cityFontSize = Math.round(50 * scale);
const cityY = Math.round(15 * scale);
const cityBorderW = Math.round(2 * scale);
// Build debug overlay text if enabled
const debugFilter = debugMode ?
`drawtext=fontfile=/fonts/Star4000.ttf:text='FPS\\: ${fps} | Capture\\: ${captureFps} | Format\\: ${inputFormat.toUpperCase()}':fontcolor=yellow:fontsize=${debugFontSize}:box=1:boxcolor=black@0.7:boxborderw=5:x=10:y=${debugY}` :
null;
// Build city name overlay if provided - centered at top, larger, with border for contrast
// Escape the text properly for FFmpeg - replace special characters
const escapedCityName = cityName ? cityName.replace(/\\/g, '\\\\\\\\').replace(/'/g, "\\\\'").replace(/:/g, '\\\\:') : '';
const cityNameFilter = cityName ?
`drawtext=fontfile=/fonts/Star4000.ttf:text='${escapedCityName}':fontcolor=white:fontsize=${cityFontSize}:x=(w-text_w)/2:y=${cityY}:borderw=${cityBorderW}:bordercolor=black` :
null;
const ffmpegArgs = [];
let playlistFile = null;
let hasMusic = false;
@@ -24,7 +51,8 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
ffmpegArgs.push(
'-use_wallclock_as_timestamps', '1',
'-f', 'image2pipe',
'-framerate', fps.toString(),
'-vcodec', inputFormat === 'png' ? 'png' : 'mjpeg',
'-framerate', captureFps.toString(),
'-i', 'pipe:0'
);
@@ -54,6 +82,12 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
'aformat=sample_rates=44100:sample_fmts=fltp:channel_layouts=stereo', // Force format
'-c:v', 'libx264',
'-preset', 'ultrafast',
...(captureAtHigherFps || debugMode || cityNameFilter ? ['-vf', [
captureAtHigherFps ? `fps=${fps}` : null,
cityNameFilter,
debugFilter
].filter(Boolean).join(',')] : []),
...(captureAtHigherFps ? ['-r', fps.toString()] : []),
'-tune', 'zerolatency',
'-pix_fmt', 'yuv420p',
'-g', (fps * 2).toString(), // Keyframe every 2 seconds for 2s segments
@@ -86,7 +120,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
'pipe:1'
);
return { args: ffmpegArgs, playlistFile, hasMusic };
return { args: ffmpegArgs, playlistFile, hasMusic, captureFps };
}
}
@@ -94,10 +128,17 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
ffmpegArgs.push(
'-use_wallclock_as_timestamps', '1',
'-f', 'image2pipe',
'-framerate', fps.toString(),
'-vcodec', inputFormat === 'png' ? 'png' : 'mjpeg',
'-framerate', captureFps.toString(),
'-i', 'pipe:0',
'-c:v', 'libx264',
'-preset', 'ultrafast',
...(captureAtHigherFps || debugMode || cityNameFilter ? ['-vf', [
captureAtHigherFps ? `fps=${fps}` : null,
cityNameFilter,
debugFilter
].filter(Boolean).join(',')] : []),
...(captureAtHigherFps ? ['-r', fps.toString()] : []),
'-tune', 'zerolatency',
'-pix_fmt', 'yuv420p',
'-g', (fps * 2).toString(), // Keyframe every 2 seconds for 2s segments
@@ -118,7 +159,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
'pipe:1'
);
return { args: ffmpegArgs, playlistFile, hasMusic };
return { args: ffmpegArgs, playlistFile, hasMusic, captureFps };
}
module.exports = { buildFFmpegArgs };

View File

@@ -4,6 +4,8 @@ const path = require('path');
const crypto = require('crypto');
const CACHE_DIR = path.join(__dirname, '..', 'cache');
const CACHE_FILE = path.join(CACHE_DIR, 'geocode_cache.json');
const CACHE_VERSION = 2;
// Ensure cache directory exists
if (!fs.existsSync(CACHE_DIR)) {
@@ -11,13 +13,54 @@ if (!fs.existsSync(CACHE_DIR)) {
}
/**
* Generate a safe filename from a city query
* @param {string} cityQuery - City name
* @returns {string} Safe filename
* Normalize a query string for matching
* @param {string} query - City query string
* @returns {string} Normalized query
*/
function getCacheFileName(cityQuery) {
const hash = crypto.createHash('md5').update(cityQuery.toLowerCase().trim()).digest('hex');
return `geocode_${hash}.json`;
function normalizeQuery(query) {
return query.toLowerCase().trim().replace(/\s+/g, ' ');
}
/**
* Generate a location key from coordinates (rounded to 4 decimal places ~11m precision)
* @param {number} lat - Latitude
* @param {number} lon - Longitude
* @returns {string} Location key
*/
function getLocationKey(lat, lon) {
return `${lat.toFixed(4)},${lon.toFixed(4)}`;
}
/**
* Load the entire geocode cache
* @returns {Object} Cache object
*/
function loadCache() {
try {
if (fs.existsSync(CACHE_FILE)) {
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
// Validate cache version
if (data.version === CACHE_VERSION) {
return data;
}
console.log('Geocode cache version mismatch, rebuilding...');
}
} catch (error) {
console.error('Error loading geocode cache:', error.message);
}
return { version: CACHE_VERSION, locations: {}, queries: {} };
}
/**
* Save the entire geocode cache
* @param {Object} cache - Cache object to save
*/
function saveCache(cache) {
try {
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf8');
} catch (error) {
console.error('Error saving geocode cache:', error.message);
}
}
/**
@@ -27,18 +70,21 @@ function getCacheFileName(cityQuery) {
*/
function getCachedGeocode(cityQuery) {
try {
const cacheFile = path.join(CACHE_DIR, getCacheFileName(cityQuery));
if (fs.existsSync(cacheFile)) {
const data = fs.readFileSync(cacheFile, 'utf8');
const cached = JSON.parse(data);
// Verify the query matches
if (cached.query && cached.query.toLowerCase().trim() === cityQuery.toLowerCase().trim()) {
console.log(`Geocode: ${cityQuery} (cached)`);
return cached;
}
const cache = loadCache();
const normalized = normalizeQuery(cityQuery);
// Check if we have this query
const locationKey = cache.queries[normalized];
if (locationKey && cache.locations[locationKey]) {
const location = cache.locations[locationKey];
console.log(`Geocode: ${cityQuery} -> ${location.cityName} (cached)`);
return {
...location,
query: cityQuery // Return with original query
};
}
} catch (error) {
// Silent fail
console.error('Error reading geocode cache:', error.message);
}
return null;
}
@@ -46,14 +92,51 @@ function getCachedGeocode(cityQuery) {
/**
* Save geocode data to cache
* @param {string} cityQuery - City name
* @param {Object} data - Geocode data to cache
* @param {Object} data - Geocode data to cache (with lat, lon, cityName, etc.)
*/
function saveCachedGeocode(cityQuery, data) {
try {
const cacheFile = path.join(CACHE_DIR, getCacheFileName(cityQuery));
fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2), 'utf8');
const cache = loadCache();
const normalized = normalizeQuery(cityQuery);
const locationKey = getLocationKey(data.lat, data.lon);
// Store location data (without query field)
const locationData = {
lat: data.lat,
lon: data.lon,
displayName: data.displayName,
cityName: data.cityName,
state: data.state,
stateDistrict: data.stateDistrict,
county: data.county,
country: data.country,
countryCode: data.countryCode
};
// Update or create location entry
if (!cache.locations[locationKey]) {
cache.locations[locationKey] = {
...locationData,
queries: [normalized]
};
} else {
// Add query to existing location if not already present
if (!cache.locations[locationKey].queries.includes(normalized)) {
cache.locations[locationKey].queries.push(normalized);
}
// Update location data in case it's more detailed
cache.locations[locationKey] = {
...cache.locations[locationKey],
...locationData
};
}
// Map query to location
cache.queries[normalized] = locationKey;
saveCache(cache);
} catch (error) {
// Silent fail
console.error('Error saving geocode cache:', error.message);
}
}
@@ -70,7 +153,7 @@ async function geocodeCity(cityQuery) {
}
return new Promise((resolve, reject) => {
const encodedQuery = encodeURIComponent(cityQuery);
const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=json&limit=1`;
const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=json&limit=1&addressdetails=1`;
const options = {
headers: {
@@ -89,13 +172,27 @@ async function geocodeCity(cityQuery) {
try {
const results = JSON.parse(data);
if (results && results.length > 0) {
const result = results[0];
const address = result.address || {};
// Extract clean city name - prefer city, town, village, municipality, or the name field
const cityName = address.city || address.town || address.village ||
address.municipality || address.hamlet || result.name ||
cityQuery.split(',')[0].trim();
const geocodeResult = {
query: cityQuery,
lat: parseFloat(results[0].lat),
lon: parseFloat(results[0].lon),
displayName: results[0].display_name
lat: parseFloat(result.lat),
lon: parseFloat(result.lon),
displayName: result.display_name,
cityName: cityName,
state: address.state || null,
stateDistrict: address.state_district || null,
county: address.county || null,
country: address.country || null,
countryCode: address.country_code ? address.country_code.toUpperCase() : null
};
console.log(`Geocode: ${cityQuery} -> ${geocodeResult.displayName} (API)`);
console.log(`Geocode: ${cityQuery} -> ${geocodeResult.cityName}, ${geocodeResult.state || geocodeResult.country} (API)`);
// Save to cache
saveCachedGeocode(cityQuery, geocodeResult);
resolve(geocodeResult);

View File

@@ -67,8 +67,122 @@ async function hideLogo(page) {
}
}
/**
* Start smooth auto-scrolling on the page
* @param {Page} page - Puppeteer page
* @param {Object} options - Scrolling options
* @param {number} options.pauseAtTop - Seconds to wait at top before scrolling (default: 30)
* @param {number} options.fps - Frame rate for smooth scrolling (default: 30)
* @returns {Function} Stop function to cancel scrolling
*/
async function startAutoScroll(page, { pauseAtTop = 30, fps = 30 } = {}) {
let stopScrolling = false;
const stopFunction = () => {
stopScrolling = true;
};
// Inject scrolling logic into the page
await page.evaluate((pauseMs, captureFrameRate) => {
window.__autoScrollState = {
stopScrolling: false,
isScrolling: false
};
function smoothScroll() {
if (window.__autoScrollState.stopScrolling) return;
const maxScroll = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
) - window.innerHeight;
if (maxScroll <= 0) {
// Page is not scrollable, just wait
setTimeout(() => {
if (!window.__autoScrollState.stopScrolling) {
smoothScroll();
}
}, pauseMs);
return;
}
// Wait at top, then start smooth scroll
setTimeout(() => {
if (window.__autoScrollState.stopScrolling) return;
// Slow scroll speed for smooth appearance
// At 60fps with 30px/s = 0.5 pixels per frame
const pixelsPerSecond = 30;
const pixelsPerFrame = pixelsPerSecond / captureFrameRate;
const frameInterval = 1000 / captureFrameRate;
function scrollDown() {
if (window.__autoScrollState.stopScrolling) return;
let currentPos = window.scrollY;
const interval = setInterval(() => {
if (window.__autoScrollState.stopScrolling) {
clearInterval(interval);
return;
}
currentPos += pixelsPerFrame;
if (currentPos >= maxScroll) {
window.scrollTo(0, maxScroll);
clearInterval(interval);
setTimeout(() => {
if (!window.__autoScrollState.stopScrolling) {
scrollUp();
}
}, 2000);
} else {
window.scrollTo(0, currentPos);
}
}, frameInterval);
}
function scrollUp() {
if (window.__autoScrollState.stopScrolling) return;
let currentPos = window.scrollY;
const interval = setInterval(() => {
if (window.__autoScrollState.stopScrolling) {
clearInterval(interval);
return;
}
currentPos -= pixelsPerFrame;
if (currentPos <= 0) {
window.scrollTo(0, 0);
clearInterval(interval);
if (!window.__autoScrollState.stopScrolling) {
smoothScroll();
}
} else {
window.scrollTo(0, currentPos);
}
}, frameInterval);
}
scrollDown();
}, pauseMs);
}
// Start scrolling
smoothScroll();
}, pauseAtTop * 1000, fps);
return stopFunction;
}
module.exports = {
setupPage,
waitForPageFullyLoaded,
hideLogo
hideLogo,
startAutoScroll
};

View File

@@ -2,7 +2,7 @@ const puppeteer = require('puppeteer');
const { spawn } = require('child_process');
const fs = require('fs');
const { buildFFmpegArgs } = require('./ffmpegConfig');
const { setupPage, waitForPageFullyLoaded, hideLogo } = require('./pageLoader');
const { setupPage, waitForPageFullyLoaded, hideLogo, startAutoScroll } = require('./pageLoader');
/**
* Main streaming handler - captures webpage and streams as HLS
@@ -12,10 +12,18 @@ const { setupPage, waitForPageFullyLoaded, hideLogo } = require('./pageLoader');
* @param {boolean} options.useMusic - Whether to include music
* @param {string} options.musicPath - Path to music directory
* @param {Promise<string>} options.lateGeocodePromise - Optional promise for late URL update
* @param {Promise<Object>} options.geocodeDataPromise - Optional promise for geocode data (for city name overlay)
* @param {number} options.startTime - Request start timestamp
* @param {number} options.defaultFps - Default FPS if not specified in query
* @param {string} options.screenshotFormat - Screenshot format (jpeg or png)
* @param {number} options.screenshotQuality - JPEG quality (1-100)
* @param {boolean} options.debugMode - Whether to show debug stats overlay
*/
async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null, startTime = Date.now() }) {
const { url, width = 1920, height = 1080, fps = 30, hideLogo: hideLogoFlag = 'false', refreshInterval = 90 } = req.query;
async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null, geocodeDataPromise = null, startTime = Date.now(), defaultFps = 30, screenshotFormat = 'jpeg', screenshotQuality = 95, debugMode = false }) {
const { url, width = 1920, height = 1080, fps: fpsParam, hideLogo: hideLogoFlag = 'false', refreshInterval = 90, scroll = 'false', scrollPause = 30 } = req.query;
// Parse numeric parameters - use query param if provided, otherwise use default from env
const fps = fpsParam ? parseInt(fpsParam) : defaultFps;
if (!url) {
return res.status(400).send('URL parameter is required');
@@ -34,6 +42,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
let playlistFile = null;
let cleanup; // Declare cleanup function variable early
let streamId = '[STREAM]'; // Default stream ID
let stopAutoScroll = null; // Function to stop auto-scrolling
try {
// Set HLS headers
@@ -42,10 +51,29 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
res.setHeader('Connection', 'keep-alive');
// Build FFmpeg command and launch browser in parallel
const useScroll = scroll === 'true';
// Get city name if geocode data is available
let cityName = null;
if (geocodeDataPromise) {
try {
const geocodeData = await geocodeDataPromise;
cityName = geocodeData?.cityName || null;
} catch (err) {
// Silently ignore geocode errors
}
}
const ffmpegConfigPromise = buildFFmpegArgs({
fps: parseInt(fps),
useMusic,
musicPath
musicPath,
inputFormat: screenshotFormat,
captureAtHigherFps: useScroll, // Capture at 2x FPS when scrolling for smoother output
debugMode,
cityName,
videoWidth: parseInt(width),
videoHeight: parseInt(height)
});
const browserPromise = puppeteer.launch({
@@ -88,6 +116,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
const [ffmpegConfig, browserInstance] = await Promise.all([ffmpegConfigPromise, browserPromise]);
browser = browserInstance;
playlistFile = ffmpegConfig.playlistFile;
const captureFps = ffmpegConfig.captureFps || fps; // Use capture FPS (may be 2x target FPS)
// Update stream identifier from browser process PID
streamId = `[${browser.process()?.pid || 'STREAM'}]`;
@@ -151,6 +180,14 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
// Setup Puppeteer page
const page = await setupPage(browser, { width: parseInt(width), height: parseInt(height) });
// Capture browser console logs for debugging scroll
page.on('console', msg => {
const text = msg.text();
if (text.includes('[Scroll]')) {
console.log(`${streamId} ${text}`);
}
});
// Black frame control
let sendBlackFrames = true;
let waitingForCorrectUrl = !!lateGeocodePromise;
@@ -166,6 +203,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
if (hideLogoFlag === 'true') {
await hideLogo(page);
}
if (scroll === 'true') {
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
}
}
})
.catch(err => {
@@ -191,6 +231,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
if (hideLogoFlag === 'true') {
await hideLogo(page);
}
if (scroll === 'true') {
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
}
} catch (err) {
console.error(`${streamId} Location update error:`, err.message);
waitingForCorrectUrl = false;
@@ -207,6 +250,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
if (hideLogoFlag === 'true') {
await hideLogo(page);
}
if (scroll === 'true') {
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause) });
}
} catch (err) {
console.error(`${streamId} Page load error:`, err.message);
waitingForCorrectUrl = false;
@@ -224,6 +270,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
if (hideLogoFlag === 'true') {
await hideLogo(page);
}
if (scroll === 'true') {
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
}
})
.catch(() => {
waitingForCorrectUrl = false;
@@ -241,6 +290,10 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
if (hideLogoFlag === 'true') {
await hideLogo(page);
}
if (scroll === 'true') {
if (stopAutoScroll) stopAutoScroll();
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
}
} catch (err) {
// Silent
}
@@ -248,7 +301,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
}, refreshIntervalMs);
// Frame capture
const frameInterval = 1000 / fps;
const frameInterval = 1000 / captureFps; // Use capture FPS for interval (may be 2x target FPS)
let captureLoopActive = true;
let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 5;
@@ -260,7 +313,13 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
const ctx = canvasObj.getContext('2d');
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, parseInt(width), parseInt(height));
// Use the same format as screenshots
if (screenshotFormat === 'png') {
return canvasObj.toBuffer('image/png');
} else {
return canvasObj.toBuffer('image/jpeg', { quality: 0.5 });
}
} catch (err) {
console.error('Error creating black frame:', err);
return Buffer.alloc(0);
@@ -286,12 +345,18 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
}
screenshot = blackFrameBuffer;
} else {
screenshot = await page.screenshot({
type: 'jpeg',
quality: 80,
optimizeForSpeed: true,
const screenshotOptions = {
type: screenshotFormat,
optimizeForSpeed: false,
fromSurface: true
});
};
// Only add quality for JPEG format
if (screenshotFormat === 'jpeg') {
screenshotOptions.quality = screenshotQuality;
}
screenshot = await page.screenshot(screenshotOptions);
}
if (screenshot && screenshot.length > 0 && ffmpegProcess && ffmpegProcess.stdin.writable && !isCleaningUp) {
@@ -354,6 +419,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
try { captureLoopActive = false; } catch (e) {}
try { clearInterval(pageRefreshInterval); } catch (e) {}
try { if (stopAutoScroll) stopAutoScroll(); } catch (e) {}
if (ffmpegProcess && !ffmpegProcess.killed) {
try {