diff --git a/Dockerfile b/Dockerfile index 2efa03a..7726e97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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* ./ diff --git a/docker-compose.yml b/docker-compose.yml index 2ba7078..e714b70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/index.js b/index.js index 0a37504..b7e3e7b 100644 --- a/index.js +++ b/index.js @@ -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,'; 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,'; - // 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 diff --git a/src/ffmpegConfig.js b/src/ffmpegConfig.js index 9e766bd..11b24a8 100644 --- a/src/ffmpegConfig.js +++ b/src/ffmpegConfig.js @@ -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 }; diff --git a/src/geocode.js b/src/geocode.js index de5f744..c0dd988 100644 --- a/src/geocode.js +++ b/src/geocode.js @@ -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); diff --git a/src/pageLoader.js b/src/pageLoader.js index 39a16af..1f3812b 100644 --- a/src/pageLoader.js +++ b/src/pageLoader.js @@ -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 }; diff --git a/src/streamHandler.js b/src/streamHandler.js index 2763fc3..e534b5f 100644 --- a/src/streamHandler.js +++ b/src/streamHandler.js @@ -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} options.lateGeocodePromise - Optional promise for late URL update + * @param {Promise} 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)); - return canvasObj.toBuffer('image/jpeg', { quality: 0.5 }); + + // 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 {