diff --git a/Dockerfile b/Dockerfile index 0a68b75..a38404a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,7 @@ WORKDIR /streaming-app COPY package.json yarn.lock* ./ RUN npm install -g yarn && yarn install --frozen-lockfile || yarn install COPY index.js ./ +COPY src/ ./src/ # Expose both ports EXPOSE 3000 8080 diff --git a/README.md b/README.md index 9bd782e..5bef0e1 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,12 @@ ffmpeg -i "http://localhost:3000/stream?url=http://example.com" \ - `showRadar`: Weather radar - `showAQI`: Air Quality Index - `showAlmanac`: Weather almanac (historical data, records) + - **Specialized sections** (default: false): - `showLatestObservations`: Real-time data from nearby stations - `showRegionalForecast`: Regional forecast for surrounding areas - - **Specialized sections** (default: false): - `showTravel`: Travel forecast for major destinations - `showMarineForecast`: Marine/coastal forecast + - **Note**: Sections disabled by default (`showLatestObservations`, `showRegionalForecast`, `showTravel`, `showMarineForecast`) may display incorrect or incomplete information for locations outside the United States, as they rely on NOAA data sources. ## How It Works diff --git a/index.js b/index.js index 3347241..05af489 100644 --- a/index.js +++ b/index.js @@ -1,726 +1,34 @@ const express = require('express'); -const puppeteer = require('puppeteer'); -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const https = require('https'); +const { streamHandler } = require('./src/streamHandler'); +const { geocodeCity } = require('./src/geocode'); const app = express(); const PORT = process.env.PORT || 3000; const MUSIC_PATH = process.env.MUSIC_PATH || '/music'; -// Geocode city to lat/lon using Nominatim (OpenStreetMap) -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 options = { - headers: { - 'User-Agent': 'webpage-to-hls-streaming-app/1.0' - } - }; +/** + * Build WS4KP weather URL with given coordinates and settings + */ +function buildWeatherUrl(latitude, longitude, settings) { + const { + city, + showHazards, + showCurrent, + showLatestObservations, + showHourly, + showHourlyGraph, + showTravel, + showRegionalForecast, + showLocalForecast, + showExtendedForecast, + showAlmanac, + showRadar, + showMarineForecast, + showAQI, + units, + timeFormat + } = settings; - https.get(url, options, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - const results = JSON.parse(data); - if (results && results.length > 0) { - resolve({ - lat: parseFloat(results[0].lat), - lon: parseFloat(results[0].lon), - displayName: results[0].display_name - }); - } else { - reject(new Error('No results found')); - } - } catch (error) { - reject(error); - } - }); - }).on('error', (error) => { - reject(error); - }); - }); -} - -// Get random music file -function getRandomMusicFile() { - try { - if (!fs.existsSync(MUSIC_PATH)) { - return null; - } - const files = fs.readdirSync(MUSIC_PATH).filter(f => f.endsWith('.ogg')); - if (files.length === 0) { - return null; - } - const randomFile = files[Math.floor(Math.random() * files.length)]; - return path.join(MUSIC_PATH, randomFile); - } catch (error) { - console.error('Error getting music file:', error); - return null; - } -} - -// Get all music files for shuffling -function getAllMusicFiles() { - try { - if (!fs.existsSync(MUSIC_PATH)) { - return []; - } - const files = fs.readdirSync(MUSIC_PATH).filter(f => f.endsWith('.ogg')); - return files.map(f => path.join(MUSIC_PATH, f)); - } catch (error) { - console.error('Error getting music files:', error); - return []; - } -} - -// Shuffle array in place -function shuffleArray(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; -} - -// Main streaming handler -async function streamHandler(req, res, useMusic = false, lateGeocodePromise = null) { - const { url, width = 1920, height = 1080, fps = 30, hideLogo = 'false' } = req.query; - - if (!url) { - return res.status(400).send('URL parameter is required'); - } - - // Validate URL - try { - new URL(url); - } catch (error) { - return res.status(400).send('Invalid URL'); - } - - let browser = null; - let ffmpegProcess = null; - let isCleaningUp = false; - - try { - // Set HLS headers - res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - - // Build FFmpeg command and playlist in parallel with browser launch - const ffmpegArgs = []; - let playlistFile = null; - - const prepareFFmpegPromise = (async () => { - if (useMusic) { - // Get all music files and shuffle them - const allMusicFiles = getAllMusicFiles(); - console.log(`Found ${allMusicFiles.length} music files in ${MUSIC_PATH}`); - if (allMusicFiles.length > 0) { - // Create a temporary concat playlist file - playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`); - - // Build playlist content - repeat enough times for ~24 hours of playback - // Assuming avg 3 min per track, repeat enough to cover a full day - const currentShuffle = shuffleArray([...allMusicFiles]); - const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); // At least 480 tracks (~24hrs) - const playlistLines = []; - for (let i = 0; i < repetitions; i++) { - // Re-shuffle each repetition for more variety - const shuffled = shuffleArray([...allMusicFiles]); - shuffled.forEach(f => playlistLines.push(`file '${f}'`)); - } - - fs.writeFileSync(playlistFile, playlistLines.join('\n')); - // console.log(`Created playlist with ${allMusicFiles.length} tracks x${repetitions} repetitions (~${playlistLines.length} total tracks)`); - - // Input 0: video frames - ffmpegArgs.push( - '-use_wallclock_as_timestamps', '1', - '-f', 'image2pipe', - '-framerate', fps.toString(), - '-i', 'pipe:0' - ); - // Input 1: audio from concat playlist - ffmpegArgs.push( - '-f', 'concat', - '-safe', '0', - '-i', playlistFile - ); - // Encoding with audio filtering for smooth transitions - ffmpegArgs.push( - // Use audio filter to ensure smooth transitions and consistent format - '-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0,aformat=sample_rates=44100:channel_layouts=stereo', - '-c:v', 'libx264', - '-preset', 'ultrafast', - '-tune', 'zerolatency', - '-pix_fmt', 'yuv420p', - '-g', fps.toString(), // Keyframe every second for 1s segments - '-bf', '0', // No B-frames for lower latency - '-x264opts', 'nal-hrd=cbr:no-scenecut', // Constant bitrate, no scene detection - '-b:v', '2500k', // Target bitrate for stable encoding - '-maxrate', '2500k', - '-bufsize', '5000k', - '-c:a', 'aac', - '-b:a', '128k', - '-ar', '44100', // Set explicit audio sample rate - '-ac', '2', // Stereo output - '-avoid_negative_ts', 'make_zero', // Prevent timestamp issues - '-fflags', '+genpts+igndts', // Generate presentation timestamps, ignore decode timestamps - '-max_interleave_delta', '0', // Reduce audio/video sync issues during transitions - '-f', 'hls', - '-hls_time', '1', // Smaller segments for faster startup - '-hls_list_size', '3', // Fewer segments in playlist - '-hls_flags', 'delete_segments+omit_endlist', - '-hls_start_number_source', 'epoch', - '-start_number', '0', // Start from segment 0 - '-flush_packets', '1', // Flush packets immediately - 'pipe:1' - ); - return true; - } - } - - // Video only (no music) - ffmpegArgs.push( - '-use_wallclock_as_timestamps', '1', - '-f', 'image2pipe', - '-framerate', fps.toString(), - '-i', 'pipe:0', - '-c:v', 'libx264', - '-preset', 'ultrafast', - '-tune', 'zerolatency', - '-pix_fmt', 'yuv420p', - '-g', fps.toString(), // Keyframe every second for 1s segments - '-bf', '0', - '-x264opts', 'nal-hrd=cbr:no-scenecut', - '-b:v', '2500k', - '-maxrate', '2500k', - '-bufsize', '5000k', - '-f', 'hls', - '-hls_time', '1', // Smaller segments for faster startup - '-hls_list_size', '3', // Fewer segments in playlist - '-hls_flags', 'delete_segments+omit_endlist', - '-hls_start_number_source', 'epoch', - '-start_number', '0', - '-flush_packets', '1', // Flush packets immediately - 'pipe:1' - ); - return false; - })(); - - // Launch browser in parallel with FFmpeg preparation - const browserPromise = puppeteer.launch({ - headless: true, - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - '--disable-extensions', - '--disable-default-apps', - '--disable-sync', - '--disable-translate', - '--disable-background-networking', - '--disable-background-timer-throttling', - '--no-first-run', - '--mute-audio', - '--disable-breakpad', - '--disable-component-update', - `--window-size=${width},${height}`, - `--force-device-scale-factor=1` // Ensure no DPI scaling issues - ], - defaultViewport: { width: parseInt(width), height: parseInt(height), deviceScaleFactor: 1 } - }); - - // Wait for both to complete in parallel - const [hasMusic] = await Promise.all([prepareFFmpegPromise, browserPromise.then(b => { browser = b; })]); - - console.log('Starting stream with black frames...'); - - // Start FFmpeg immediately - don't wait for page - ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegArgs], { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - // Pipe FFmpeg output to response immediately - ffmpegProcess.stdout.pipe(res); - - ffmpegProcess.stderr.on('data', (data) => { - const message = data.toString(); - // Log important warnings about audio discontinuities or errors - if (message.includes('Non-monotonous DTS') || - message.includes('Application provided invalid') || - message.includes('past duration') || - message.includes('Error while decoding stream') || - message.includes('Invalid data found')) { - console.warn(`FFmpeg warning: ${message.trim()}`); - } - // Uncomment for full FFmpeg output during debugging: - // console.error(`FFmpeg: ${message}`); - }); - - ffmpegProcess.on('error', (error) => { - console.error('FFmpeg error:', error); - cleanup(); - }); - - ffmpegProcess.on('close', (code) => { - if (code && code !== 0 && !isCleaningUp) { - console.error(`FFmpeg exited with code ${code}`); - cleanup(); - } - }); - - ffmpegProcess.stdin.on('error', (error) => { - // Ignore EPIPE errors when client disconnects - if (error.code !== 'EPIPE') { - console.error('FFmpeg stdin error:', error); - } - }); - - // Start creating page in parallel with FFmpeg starting - const page = await browser.newPage(); - - // Reduce memory usage by disabling caching - await page.setCacheEnabled(false); - - // Inject CSS early to prevent white flash during page load - await page.evaluateOnNewDocument(() => { - const style = document.createElement('style'); - style.textContent = ` - html, body { - background-color: #000 !important; - } - `; - document.head?.appendChild(style) || document.documentElement.appendChild(style); - }); - - // Always start with black frames until the CORRECT page loads - let sendBlackFrames = true; - let waitingForCorrectUrl = !!lateGeocodePromise; // Track if we're waiting for geocoding to complete - - // Helper function to wait for page to be fully loaded with all resources - const waitForPageFullyLoaded = async (page, hideLogo) => { - try { - // Wait for networkidle2 (waits until no more than 2 network connections for 500ms) - await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 }); - console.log('Page network idle, waiting for content to render...'); - - // Additional wait for dynamic content to fully render (weather data, radar, etc.) - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Verify content is actually visible before switching - const hasVisibleContent = await page.evaluate(() => { - // Check if there's actual content beyond just background - const body = document.body; - if (!body) return false; - - // Check for common weather page elements - const hasContent = document.querySelector('canvas') || - document.querySelector('.weather-display') || - document.querySelector('img[src*="radar"]') || - document.querySelectorAll('div').length > 10; - return hasContent; - }).catch(() => false); - - if (!hasVisibleContent) { - console.log('Content not fully visible yet, waiting additional time...'); - await new Promise(resolve => setTimeout(resolve, 2000)); - } - - console.log('Page fully loaded with all resources, switching to live frames'); - return true; - } catch (err) { - console.error('Page load error:', err.message); - // Still show the page even if timeout occurs - return true; - } - }; - - // Start loading the page in the background - don't wait for it - const pageLoadPromise = waitForPageFullyLoaded(page, hideLogo) - .then(async (loaded) => { - // Only switch to live frames if we're not waiting for the correct URL - if (!waitingForCorrectUrl && loaded) { - sendBlackFrames = false; - - // Hide logo if requested - if (hideLogo === 'true') { - page.evaluate(() => { - const images = document.querySelectorAll('img'); - images.forEach(img => { - if (img.src && img.src.includes('Logo3.png')) { - img.style.display = 'none'; - } - }); - }).catch(err => console.error('Logo hide error:', err)); - } - } - }) - .catch(err => { - console.error('Page load promise error:', err.message); - if (!waitingForCorrectUrl) { - // Show page anyway after error to avoid black screen forever - sendBlackFrames = false; - } - }); - - // If we have a late geocoding promise, navigate to correct URL when ready - if (lateGeocodePromise) { - lateGeocodePromise.then(async (updatedUrl) => { - if (!isCleaningUp && page && !page.isClosed() && updatedUrl && updatedUrl !== url) { - try { - console.log('Updating to correct location...'); - // Ensure black frames continue during navigation - sendBlackFrames = true; - - // Wait for networkidle2 to ensure all resources load - await page.goto(updatedUrl, { waitUntil: 'networkidle2', timeout: 45000 }); - console.log('Correct location network idle, waiting for content...'); - - // Additional wait for dynamic content - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Verify content is visible - const hasVisibleContent = await page.evaluate(() => { - const body = document.body; - if (!body) return false; - const hasContent = document.querySelector('canvas') || - document.querySelector('.weather-display') || - document.querySelector('img[src*="radar"]') || - document.querySelectorAll('div').length > 10; - return hasContent; - }).catch(() => false); - - if (!hasVisibleContent) { - console.log('Content not fully visible yet, waiting additional time...'); - await new Promise(resolve => setTimeout(resolve, 2000)); - } - - console.log('Correct location fully loaded, switching to live frames'); - waitingForCorrectUrl = false; - sendBlackFrames = false; // Now show real frames - - if (hideLogo === 'true') { - await page.evaluate(() => { - const images = document.querySelectorAll('img'); - images.forEach(img => { - if (img.src && img.src.includes('Logo3.png')) { - img.style.display = 'none'; - } - }); - }).catch(() => {}); - } - } catch (err) { - console.error('Location update error:', err.message); - waitingForCorrectUrl = false; - // Show page anyway to avoid black screen forever - sendBlackFrames = false; - } - } else if (!updatedUrl || updatedUrl === url) { - // Geocoding completed but URL is the same (was already correct) - // Wait for the initial page load to complete before switching - console.log('Using initial URL, waiting for page load to complete...'); - } - }).catch(() => { - // Geocoding failed - use fallback location - console.warn('Geocoding failed, waiting for fallback location to load'); - // Let the initial page load complete before switching - }); - } - - // Add periodic page refresh to prevent memory leaks and stale content - // Refresh every 30 minutes to keep stream stable - const pageRefreshInterval = setInterval(async () => { - if (!isCleaningUp && page && !page.isClosed()) { - try { - console.log('Refreshing page for stability...'); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 }); - if (hideLogo === 'true') { - await page.evaluate(() => { - const images = document.querySelectorAll('img'); - images.forEach(img => { - if (img.src && img.src.includes('Logo3.png')) { - img.style.display = 'none'; - } - }); - }).catch(() => {}); - } - } catch (err) { - console.error('Page refresh error:', err.message); - } - } - }, 30 * 60 * 1000); // 30 minutes - - // Capture frames using a sequential loop (avoids overlapping screenshots) - // and handle backpressure on ffmpeg.stdin (wait for 'drain' with timeout). - const frameInterval = 1000 / fps; - let captureLoopActive = true; - let consecutiveErrors = 0; - const MAX_CONSECUTIVE_ERRORS = 5; - - // Create a black frame buffer once (reused for all black frames) - const createBlackFrame = () => { - try { - // Create a minimal black JPEG with exact dimensions matching the stream - const canvas = require('canvas'); - const canvasObj = canvas.createCanvas(parseInt(width), parseInt(height)); - const ctx = canvasObj.getContext('2d'); - ctx.fillStyle = '#000000'; - ctx.fillRect(0, 0, parseInt(width), parseInt(height)); - return canvasObj.toBuffer('image/jpeg', { quality: 0.5 }); - } catch (err) { - console.error('Error creating black frame:', err); - // Fallback: create a minimal valid JPEG header (won't look good but won't crash) - return Buffer.alloc(0); - } - }; - - let blackFrameBuffer = null; - - const captureLoop = async () => { - while (!isCleaningUp && captureLoopActive && ffmpegProcess && !ffmpegProcess.killed) { - const start = Date.now(); - try { - // Check if page is still valid - if (page.isClosed()) { - console.error('Page was closed unexpectedly'); - break; - } - - let screenshot; - - // Send black frames if we're waiting for page load - if (sendBlackFrames) { - if (!blackFrameBuffer) { - blackFrameBuffer = createBlackFrame(); - } - screenshot = blackFrameBuffer; - } else { - // Take a screenshot with optimized settings - screenshot = await page.screenshot({ - type: 'jpeg', - quality: 80, - optimizeForSpeed: true, - fromSurface: true - }); - } - - // Ensure we have valid data before writing - if (screenshot && screenshot.length > 0 && ffmpegProcess && ffmpegProcess.stdin.writable && !isCleaningUp) { - const canWrite = ffmpegProcess.stdin.write(screenshot); - if (!canWrite) { - // Backpressure — wait for drain but don't block forever - await new Promise((resolve) => { - let resolved = false; - const currentProcess = ffmpegProcess; // Capture reference to avoid null access - const onDrain = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }; - const onError = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }; - const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }, 800); - function cleanupListeners() { - clearTimeout(timeout); - if (currentProcess && currentProcess.stdin) { - currentProcess.stdin.removeListener('drain', onDrain); - currentProcess.stdin.removeListener('error', onError); - } - } - if (currentProcess && currentProcess.stdin) { - currentProcess.stdin.once('drain', onDrain); - currentProcess.stdin.once('error', onError); - } else { - resolve(); - } - }); - } - } - - // Reset error counter on success - consecutiveErrors = 0; - - } catch (error) { - if (!isCleaningUp) { - consecutiveErrors++; - console.error(`Capture error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error.message || error); - - // If too many consecutive errors, give up - if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - console.error('Too many consecutive errors, stopping stream'); - try { await cleanup(); } catch (e) {} - break; - } - - // Wait a bit before retrying on error - await new Promise(r => setTimeout(r, 1000)); - } else { - break; - } - } - - const elapsed = Date.now() - start; - const wait = Math.max(0, frameInterval - elapsed); - // Wait the remaining time before next frame - if (wait > 0) await new Promise(r => setTimeout(r, wait)); - } - }; - - // Start the capture loop (no overlapping runs) - captureLoop(); - - // Cleanup function - const cleanup = async () => { - if (isCleaningUp) return; - isCleaningUp = true; - console.log('Cleaning up stream...'); - - // stop capture loop - try { captureLoopActive = false; } catch (e) {} - - // Clear page refresh interval - try { clearInterval(pageRefreshInterval); } catch (e) {} - - if (ffmpegProcess && !ffmpegProcess.killed) { - try { - ffmpegProcess.stdin.end(); - } catch (err) { - // Ignore errors during cleanup - } - ffmpegProcess.kill('SIGTERM'); - - // Force kill after 5 seconds if still running - setTimeout(() => { - if (ffmpegProcess && !ffmpegProcess.killed) { - ffmpegProcess.kill('SIGKILL'); - } - }, 5000); - - ffmpegProcess = null; - } - - // Clean up temporary playlist file - if (playlistFile) { - try { - fs.unlinkSync(playlistFile); - } catch (err) { - // Ignore errors during cleanup - } - } - - if (browser) { - try { - await browser.close(); - } catch (err) { - // Ignore errors during cleanup - } - browser = null; - } - - if (!res.headersSent && !res.writableEnded) { - res.end(); - } - }; - - // Handle client disconnect - let disconnectLogged = false; - - req.on('close', () => { - if (!disconnectLogged) { - console.log('Client disconnected'); - disconnectLogged = true; - } - cleanup(); - }); - - req.on('error', (error) => { - // Ignore expected disconnect errors - if (error.code === 'ECONNRESET' || error.code === 'EPIPE') { - if (!disconnectLogged) { - console.log('Client disconnected'); - disconnectLogged = true; - } - } else { - console.error('Request error:', error); - } - cleanup(); - }); - - res.on('error', (error) => { - // Ignore expected disconnect errors - if (error.code === 'ECONNRESET' || error.code === 'EPIPE') { - if (!disconnectLogged) { - console.log('Client disconnected'); - disconnectLogged = true; - } - } else { - console.error('Response error:', error); - } - cleanup(); - }); - - // Add keepalive monitoring - const keepaliveInterval = setInterval(() => { - if (isCleaningUp || !ffmpegProcess || ffmpegProcess.killed) { - clearInterval(keepaliveInterval); - return; - } - // Check if connection is still alive - if (res.writableEnded || res.socket?.destroyed) { - console.log('Connection lost, cleaning up'); - clearInterval(keepaliveInterval); - cleanup(); - } - }, 10000); // Check every 10 seconds - - } catch (error) { - console.error('Error:', error); - if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill(); - if (browser) await browser.close(); - if (!res.headersSent) { - res.status(500).send('Internal server error'); - } - } -} - -// Stream endpoint -app.get('/stream', (req, res) => streamHandler(req, res, false)); - -app.get('/weather', async (req, res) => { - const { - city = 'Toronto, ON, CAN', - width = 1920, - height = 1080, - fps = 30, - hideLogo = 'false', - units = 'metric', // 'metric' or 'imperial' - timeFormat = '24h', // '12h' or '24h' - // Forecast section toggles - commonly used sections (default: true) - showHazards = 'true', - showCurrent = 'true', - showHourly = 'true', - showHourlyGraph = 'true', - showLocalForecast = 'true', - showExtendedForecast = 'true', - showRadar = 'true', - showAQI = 'true', - showAlmanac = 'true', - showLatestObservations = 'true', - showRegionalForecast = 'true', - // Less common sections (default: false) - showTravel = 'false', - showMarineForecast = 'false' - } = req.query; - - // Unit conversions for ws4kp const isMetric = units.toLowerCase() === 'metric'; const temperatureUnit = isMetric ? '1.00' : '2.00'; const windUnit = isMetric ? '1.00' : '2.00'; @@ -730,101 +38,152 @@ app.get('/weather', async (req, res) => { const ws4kpBaseUrl = process.env.WS4KP_URL || 'http://localhost:8080'; - // Function to build URL with given coordinates - const buildUrl = (latitude, longitude) => { - const ws4kpParams = new URLSearchParams({ - 'hazards-checkbox': showHazards, - 'current-weather-checkbox': showCurrent, - 'latest-observations-checkbox': showLatestObservations, - 'hourly-checkbox': showHourly, - 'hourly-graph-checkbox': showHourlyGraph, - 'travel-checkbox': showTravel, - 'regional-forecast-checkbox': showRegionalForecast, - 'local-forecast-checkbox': showLocalForecast, - 'extended-forecast-checkbox': showExtendedForecast, - 'almanac-checkbox': showAlmanac, - 'radar-checkbox': showRadar, - 'marine-forecast-checkbox': showMarineForecast, - 'aqi-forecast-checkbox': showAQI, - 'settings-experimentalFeatures-checkbox': 'false', - 'settings-hideWebamp-checkbox': 'true', - 'settings-kiosk-checkbox': 'false', - 'settings-scanLines-checkbox': 'false', - 'settings-wide-checkbox': 'true', - 'chkAutoRefresh': 'true', - 'settings-windUnits-select': windUnit, - 'settings-marineWindUnits-select': '1.00', - 'settings-marineWaveHeightUnits-select': '1.00', - 'settings-temperatureUnits-select': temperatureUnit, - 'settings-distanceUnits-select': distanceUnit, - 'settings-pressureUnits-select': pressureUnit, - 'settings-hoursFormat-select': hoursFormat, - 'settings-speed-select': '1.00', - 'latLonQuery': city, - 'latLon': JSON.stringify({ lat: latitude, lon: longitude }), - 'kiosk': 'true' - }); - return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`; + const ws4kpParams = new URLSearchParams({ + 'hazards-checkbox': showHazards, + 'current-weather-checkbox': showCurrent, + 'latest-observations-checkbox': showLatestObservations, + 'hourly-checkbox': showHourly, + 'hourly-graph-checkbox': showHourlyGraph, + 'travel-checkbox': showTravel, + 'regional-forecast-checkbox': showRegionalForecast, + 'local-forecast-checkbox': showLocalForecast, + 'extended-forecast-checkbox': showExtendedForecast, + 'almanac-checkbox': showAlmanac, + 'radar-checkbox': showRadar, + 'marine-forecast-checkbox': showMarineForecast, + 'aqi-forecast-checkbox': showAQI, + 'settings-experimentalFeatures-checkbox': 'false', + 'settings-hideWebamp-checkbox': 'true', + 'settings-kiosk-checkbox': 'false', + 'settings-scanLines-checkbox': 'false', + 'settings-wide-checkbox': 'true', + 'chkAutoRefresh': 'true', + 'settings-windUnits-select': windUnit, + 'settings-marineWindUnits-select': '1.00', + 'settings-marineWaveHeightUnits-select': '1.00', + 'settings-temperatureUnits-select': temperatureUnit, + 'settings-distanceUnits-select': distanceUnit, + 'settings-pressureUnits-select': pressureUnit, + 'settings-hoursFormat-select': hoursFormat, + 'settings-speed-select': '1.00', + 'latLonQuery': city, + 'latLon': JSON.stringify({ lat: latitude, lon: longitude }), + 'kiosk': 'true' + }); + + return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`; +} + +// Basic stream endpoint (no music) +app.get('/stream', (req, res) => { + streamHandler(req, res, { + useMusic: false, + musicPath: MUSIC_PATH + }); +}); + +// Weather endpoint (with music) +app.get('/weather', async (req, res) => { + const { + city = 'Toronto, ON, CAN', + width = 1920, + height = 1080, + fps = 30, + hideLogo = 'false', + units = 'metric', + timeFormat = '24h', + showHazards = 'true', + showCurrent = 'true', + showHourly = 'true', + showHourlyGraph = 'true', + showLocalForecast = 'true', + showExtendedForecast = 'true', + showRadar = 'true', + showAQI = 'true', + showAlmanac = 'true', + showLatestObservations = 'false', + showRegionalForecast = 'false', + showTravel = 'false', + showMarineForecast = 'false' + } = req.query; + + const weatherSettings = { + city, + showHazards, + showCurrent, + showLatestObservations, + showHourly, + showHourlyGraph, + showTravel, + showRegionalForecast, + showLocalForecast, + showExtendedForecast, + showAlmanac, + showRadar, + showMarineForecast, + showAQI, + units, + timeFormat }; - // Start geocoding immediately in the background - don't block anything let lateGeocodePromise = null; - let initialUrl = 'data:text/html,'; // Dummy black page - + let initialUrl = 'data:text/html,'; + if (city && city !== 'Toronto, ON, CAN') { - // Start geocoding - this will be the only URL we load + // Try quick geocode first const geocodePromise = Promise.race([ geocodeCity(city), new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000)) ]).then(geoResult => { console.log(`Geocoded: ${city} -> ${geoResult.displayName}`); - const finalUrl = buildUrl(geoResult.lat, geoResult.lon); + const finalUrl = buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings); console.log(`URL: ${finalUrl}`); return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon }; }).catch(error => { - // Geocoding timed out or failed - continue in background + // Continue geocoding in background return geocodeCity(city).then(geoResult => { - const finalUrl = buildUrl(geoResult.lat, geoResult.lon); + const finalUrl = buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings); console.log(`Geocoding completed: ${geoResult.displayName} (${geoResult.lat}, ${geoResult.lon})`); console.log(`Final URL: ${finalUrl}`); return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon, isLate: true }; }).catch(err => { console.warn(`Geocoding failed: ${err.message}`); - // Fall back to Toronto if geocoding completely fails - const fallbackUrl = buildUrl(43.6532, -79.3832); + // Fallback to Toronto + const fallbackUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings); return { url: fallbackUrl, lat: 43.6532, lon: -79.3832, isLate: true }; }); }); - - // Always wait for geocoding to complete (or timeout and continue in background) - lateGeocodePromise = geocodePromise.then(result => { - return result.url; - }); + + lateGeocodePromise = geocodePromise.then(result => result.url); } else { - // Toronto - use directly - const lat = 43.6532; - const lon = -79.3832; - initialUrl = buildUrl(lat, lon); + // Toronto default + initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings); console.log(`URL: ${initialUrl}`); } - + console.log(`Stream starting: ${city}`); - - // Forward to the main stream endpoint WITH MUSIC + + // Update request query for stream handler req.query.url = initialUrl; req.query.width = width; req.query.height = height; req.query.fps = fps; req.query.hideLogo = hideLogo; - - // Call the stream handler with music enabled and late geocode promise - return streamHandler(req, res, true, lateGeocodePromise); + + // Call stream handler with music enabled + return streamHandler(req, res, { + useMusic: true, + musicPath: MUSIC_PATH, + lateGeocodePromise + }); }); +// Health check endpoint app.get('/health', (req, res) => { res.send('OK'); }); +// Start server app.listen(PORT, () => { console.log(`Webpage to HLS server running on port ${PORT}`); console.log(`Usage: http://localhost:${PORT}/stream?url=http://example.com`); diff --git a/src/ffmpegConfig.js b/src/ffmpegConfig.js new file mode 100644 index 0000000..1f7fa96 --- /dev/null +++ b/src/ffmpegConfig.js @@ -0,0 +1,102 @@ +const { createPlaylist } = require('./musicPlaylist'); + +/** + * Build FFmpeg arguments for HLS streaming + * @param {Object} options - Configuration options + * @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}>} + */ +async function buildFFmpegArgs({ fps, useMusic, musicPath }) { + const ffmpegArgs = []; + let playlistFile = null; + let hasMusic = false; + + if (useMusic) { + const playlistInfo = createPlaylist(musicPath); + + if (playlistInfo) { + playlistFile = playlistInfo.playlistFile; + hasMusic = true; + + // Input 0: video frames + ffmpegArgs.push( + '-use_wallclock_as_timestamps', '1', + '-f', 'image2pipe', + '-framerate', fps.toString(), + '-i', 'pipe:0' + ); + + // Input 1: audio from concat playlist + ffmpegArgs.push( + '-f', 'concat', + '-safe', '0', + '-i', playlistFile + ); + + // Encoding with audio filtering for smooth transitions + ffmpegArgs.push( + // Use audio filter to ensure smooth transitions and consistent format + '-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0,aformat=sample_rates=44100:channel_layouts=stereo', + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-tune', 'zerolatency', + '-pix_fmt', 'yuv420p', + '-g', fps.toString(), // Keyframe every second for 1s segments + '-bf', '0', // No B-frames for lower latency + '-x264opts', 'nal-hrd=cbr:no-scenecut', // Constant bitrate, no scene detection + '-b:v', '2500k', // Target bitrate for stable encoding + '-maxrate', '2500k', + '-bufsize', '5000k', + '-c:a', 'aac', + '-b:a', '128k', + '-ar', '44100', // Set explicit audio sample rate + '-ac', '2', // Stereo output + '-avoid_negative_ts', 'make_zero', // Prevent timestamp issues + '-fflags', '+genpts+igndts', // Generate presentation timestamps, ignore decode timestamps + '-max_interleave_delta', '0', // Reduce audio/video sync issues during transitions + '-f', 'hls', + '-hls_time', '1', // Smaller segments for faster startup + '-hls_list_size', '3', // Fewer segments in playlist + '-hls_flags', 'delete_segments+omit_endlist', + '-hls_start_number_source', 'epoch', + '-start_number', '0', // Start from segment 0 + '-flush_packets', '1', // Flush packets immediately + 'pipe:1' + ); + + return { args: ffmpegArgs, playlistFile, hasMusic }; + } + } + + // Video only (no music) + ffmpegArgs.push( + '-use_wallclock_as_timestamps', '1', + '-f', 'image2pipe', + '-framerate', fps.toString(), + '-i', 'pipe:0', + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-tune', 'zerolatency', + '-pix_fmt', 'yuv420p', + '-g', fps.toString(), // Keyframe every second for 1s segments + '-bf', '0', + '-x264opts', 'nal-hrd=cbr:no-scenecut', + '-b:v', '2500k', + '-maxrate', '2500k', + '-bufsize', '5000k', + '-f', 'hls', + '-hls_time', '1', // Smaller segments for faster startup + '-hls_list_size', '3', // Fewer segments in playlist + '-hls_flags', 'delete_segments+omit_endlist', + '-hls_start_number_source', 'epoch', + '-start_number', '0', + '-flush_packets', '1', // Flush packets immediately + 'pipe:1' + ); + + return { args: ffmpegArgs, playlistFile, hasMusic }; +} + +module.exports = { buildFFmpegArgs }; diff --git a/src/geocode.js b/src/geocode.js new file mode 100644 index 0000000..919863b --- /dev/null +++ b/src/geocode.js @@ -0,0 +1,48 @@ +const https = require('https'); + +/** + * Geocode city to lat/lon using Nominatim (OpenStreetMap) + * @param {string} cityQuery - City name to geocode + * @returns {Promise<{lat: number, lon: number, displayName: string}>} + */ +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 options = { + headers: { + 'User-Agent': 'webpage-to-hls-streaming-app/1.0' + } + }; + + https.get(url, options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const results = JSON.parse(data); + if (results && results.length > 0) { + resolve({ + lat: parseFloat(results[0].lat), + lon: parseFloat(results[0].lon), + displayName: results[0].display_name + }); + } else { + reject(new Error('No results found')); + } + } catch (error) { + reject(error); + } + }); + }).on('error', (error) => { + reject(error); + }); + }); +} + +module.exports = { geocodeCity }; diff --git a/src/musicPlaylist.js b/src/musicPlaylist.js new file mode 100644 index 0000000..577121a --- /dev/null +++ b/src/musicPlaylist.js @@ -0,0 +1,98 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Get a random music file from the music directory + * @param {string} musicPath - Path to music directory + * @returns {string|null} Path to random music file or null + */ +function getRandomMusicFile(musicPath) { + try { + if (!fs.existsSync(musicPath)) { + return null; + } + const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg')); + if (files.length === 0) { + return null; + } + const randomFile = files[Math.floor(Math.random() * files.length)]; + return path.join(musicPath, randomFile); + } catch (error) { + console.error('Error getting music file:', error); + return null; + } +} + +/** + * Get all music files for playlist generation + * @param {string} musicPath - Path to music directory + * @returns {string[]} Array of music file paths + */ +function getAllMusicFiles(musicPath) { + try { + if (!fs.existsSync(musicPath)) { + return []; + } + const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg')); + return files.map(f => path.join(musicPath, f)); + } catch (error) { + console.error('Error getting music files:', error); + return []; + } +} + +/** + * Shuffle array in place using Fisher-Yates algorithm + * @param {Array} array - Array to shuffle + * @returns {Array} Shuffled array (same reference) + */ +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + +/** + * Create a playlist file for FFmpeg concat demuxer + * @param {string} musicPath - Path to music directory + * @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null + */ +function createPlaylist(musicPath) { + const allMusicFiles = getAllMusicFiles(musicPath); + console.log(`Found ${allMusicFiles.length} music files in ${musicPath}`); + + if (allMusicFiles.length === 0) { + return null; + } + + // Create a temporary concat playlist file + const playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`); + + // Build playlist content - repeat enough times for ~24 hours of playback + // Assuming avg 3 min per track, repeat enough to cover a full day + const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); // At least 480 tracks (~24hrs) + const playlistLines = []; + + for (let i = 0; i < repetitions; i++) { + // Re-shuffle each repetition for more variety + const shuffled = shuffleArray([...allMusicFiles]); + shuffled.forEach(f => playlistLines.push(`file '${f}'`)); + } + + fs.writeFileSync(playlistFile, playlistLines.join('\n')); + console.log(`Created playlist with ${allMusicFiles.length} tracks x${repetitions} repetitions (~${playlistLines.length} total tracks)`); + + return { + playlistFile, + trackCount: playlistLines.length + }; +} + +module.exports = { + getRandomMusicFile, + getAllMusicFiles, + shuffleArray, + createPlaylist +}; diff --git a/src/pageLoader.js b/src/pageLoader.js new file mode 100644 index 0000000..6c85221 --- /dev/null +++ b/src/pageLoader.js @@ -0,0 +1,76 @@ +/** + * Setup and configure a Puppeteer page + * @param {Browser} browser - Puppeteer browser instance + * @param {Object} options - Configuration options + * @param {number} options.width - Page width + * @param {number} options.height - Page height + * @returns {Promise} Configured Puppeteer page + */ +async function setupPage(browser, { width, height }) { + const page = await browser.newPage(); + + // Reduce memory usage by disabling caching + await page.setCacheEnabled(false); + + // Inject CSS early to prevent white flash during page load + await page.evaluateOnNewDocument(() => { + const style = document.createElement('style'); + style.textContent = ` + html, body { + background-color: #000 !important; + } + `; + document.head?.appendChild(style) || document.documentElement.appendChild(style); + }); + + return page; +} + +/** + * Wait for page to be fully loaded with stylesheet + * @param {Page} page - Puppeteer page + * @param {string} url - URL to navigate to + * @returns {Promise} True if page loaded successfully + */ +async function waitForPageFullyLoaded(page, url) { + try { + // Wait for DOM content and stylesheet to load + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + console.log('Page DOM loaded, waiting for stylesheet...'); + + // Wait a brief moment for stylesheet to apply + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log('Page stylesheet loaded, switching to live frames'); + return true; + } catch (err) { + console.error('Page load error:', err.message); + // Still show the page even if timeout occurs + return true; + } +} + +/** + * Hide logo elements on the page + * @param {Page} page - Puppeteer page + */ +async function hideLogo(page) { + try { + await page.evaluate(() => { + const images = document.querySelectorAll('img'); + images.forEach(img => { + if (img.src && img.src.includes('Logo3.png')) { + img.style.display = 'none'; + } + }); + }); + } catch (err) { + console.error('Logo hide error:', err); + } +} + +module.exports = { + setupPage, + waitForPageFullyLoaded, + hideLogo +}; diff --git a/src/streamHandler.js b/src/streamHandler.js new file mode 100644 index 0000000..541f974 --- /dev/null +++ b/src/streamHandler.js @@ -0,0 +1,412 @@ +const puppeteer = require('puppeteer'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const { buildFFmpegArgs } = require('./ffmpegConfig'); +const { setupPage, waitForPageFullyLoaded, hideLogo } = require('./pageLoader'); + +/** + * Main streaming handler - captures webpage and streams as HLS + * @param {Express.Request} req - Express request object + * @param {Express.Response} res - Express response object + * @param {Object} options - Configuration options + * @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 + */ +async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null }) { + const { url, width = 1920, height = 1080, fps = 30, hideLogo: hideLogoFlag = 'false' } = req.query; + + if (!url) { + return res.status(400).send('URL parameter is required'); + } + + // Validate URL + try { + new URL(url); + } catch (error) { + return res.status(400).send('Invalid URL'); + } + + let browser = null; + let ffmpegProcess = null; + let isCleaningUp = false; + let playlistFile = null; + + try { + // Set HLS headers + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + // Build FFmpeg command and launch browser in parallel + const ffmpegConfigPromise = buildFFmpegArgs({ + fps: parseInt(fps), + useMusic, + musicPath + }); + + const browserPromise = puppeteer.launch({ + headless: true, + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--disable-extensions', + '--disable-default-apps', + '--disable-sync', + '--disable-translate', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--no-first-run', + '--mute-audio', + '--disable-breakpad', + '--disable-component-update', + `--window-size=${width},${height}`, + `--force-device-scale-factor=1` + ], + defaultViewport: { width: parseInt(width), height: parseInt(height), deviceScaleFactor: 1 } + }); + + // Wait for both to complete in parallel + const [ffmpegConfig, browserInstance] = await Promise.all([ffmpegConfigPromise, browserPromise]); + browser = browserInstance; + playlistFile = ffmpegConfig.playlistFile; + + console.log('Starting stream with black frames...'); + + // Start FFmpeg immediately + ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegConfig.args], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Pipe FFmpeg output to response + ffmpegProcess.stdout.pipe(res); + + ffmpegProcess.stderr.on('data', (data) => { + const message = data.toString(); + // Log important warnings about audio discontinuities or errors + if (message.includes('Non-monotonous DTS') || + message.includes('Application provided invalid') || + message.includes('past duration') || + message.includes('Error while decoding stream') || + message.includes('Invalid data found')) { + console.warn(`FFmpeg warning: ${message.trim()}`); + } + }); + + ffmpegProcess.on('error', (error) => { + console.error('FFmpeg error:', error); + cleanup(); + }); + + ffmpegProcess.on('close', (code) => { + if (code && code !== 0 && !isCleaningUp) { + console.error(`FFmpeg exited with code ${code}`); + cleanup(); + } + }); + + ffmpegProcess.stdin.on('error', (error) => { + if (error.code !== 'EPIPE') { + console.error('FFmpeg stdin error:', error); + } + }); + + // Setup Puppeteer page + const page = await setupPage(browser, { width: parseInt(width), height: parseInt(height) }); + + // Black frame control + let sendBlackFrames = true; + let waitingForCorrectUrl = !!lateGeocodePromise; + + // Load initial page only if we're not waiting for late geocoding + if (!waitingForCorrectUrl) { + waitForPageFullyLoaded(page, url) + .then(async (loaded) => { + if (loaded) { + sendBlackFrames = false; + if (hideLogoFlag === 'true') { + await hideLogo(page); + } + } + }) + .catch(err => { + console.error('Page load promise error:', err.message); + sendBlackFrames = false; + }); + } + + // Handle late geocoding if provided + if (lateGeocodePromise) { + lateGeocodePromise.then(async (updatedUrl) => { + if (!isCleaningUp && page && !page.isClosed() && updatedUrl && updatedUrl !== url) { + try { + console.log('Updating to correct location...'); + sendBlackFrames = true; + + await waitForPageFullyLoaded(page, updatedUrl); + + console.log('Correct location fully loaded, switching to live frames'); + waitingForCorrectUrl = false; + sendBlackFrames = false; + + if (hideLogoFlag === 'true') { + await hideLogo(page); + } + } catch (err) { + console.error('Location update error:', err.message); + waitingForCorrectUrl = false; + sendBlackFrames = false; + } + } else if (!updatedUrl || updatedUrl === url) { + console.log('Using initial URL, waiting for page load to complete...'); + // URL is the same, so load it now + try { + await waitForPageFullyLoaded(page, url); + waitingForCorrectUrl = false; + sendBlackFrames = false; + if (hideLogoFlag === 'true') { + await hideLogo(page); + } + } catch (err) { + console.error('Initial page load error:', err.message); + waitingForCorrectUrl = false; + sendBlackFrames = false; + } + } + }).catch(() => { + console.warn('Geocoding failed, waiting for fallback location to load'); + // Load the fallback URL + waitForPageFullyLoaded(page, url) + .then(async () => { + waitingForCorrectUrl = false; + sendBlackFrames = false; + if (hideLogoFlag === 'true') { + await hideLogo(page); + } + }) + .catch(() => { + waitingForCorrectUrl = false; + sendBlackFrames = false; + }); + }); + } + + // Periodic page refresh + const pageRefreshInterval = setInterval(async () => { + if (!isCleaningUp && page && !page.isClosed()) { + try { + console.log('Refreshing page for stability...'); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 }); + if (hideLogoFlag === 'true') { + await hideLogo(page); + } + } catch (err) { + console.error('Page refresh error:', err.message); + } + } + }, 30 * 60 * 1000); + + // Frame capture + const frameInterval = 1000 / fps; + let captureLoopActive = true; + let consecutiveErrors = 0; + const MAX_CONSECUTIVE_ERRORS = 5; + + const createBlackFrame = () => { + try { + const canvas = require('canvas'); + const canvasObj = canvas.createCanvas(parseInt(width), parseInt(height)); + const ctx = canvasObj.getContext('2d'); + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, parseInt(width), parseInt(height)); + return canvasObj.toBuffer('image/jpeg', { quality: 0.5 }); + } catch (err) { + console.error('Error creating black frame:', err); + return Buffer.alloc(0); + } + }; + + let blackFrameBuffer = null; + + const captureLoop = async () => { + while (!isCleaningUp && captureLoopActive && ffmpegProcess && !ffmpegProcess.killed) { + const start = Date.now(); + try { + if (page.isClosed()) { + console.error('Page was closed unexpectedly'); + break; + } + + let screenshot; + + if (sendBlackFrames) { + if (!blackFrameBuffer) { + blackFrameBuffer = createBlackFrame(); + } + screenshot = blackFrameBuffer; + } else { + screenshot = await page.screenshot({ + type: 'jpeg', + quality: 80, + optimizeForSpeed: true, + fromSurface: true + }); + } + + if (screenshot && screenshot.length > 0 && ffmpegProcess && ffmpegProcess.stdin.writable && !isCleaningUp) { + const canWrite = ffmpegProcess.stdin.write(screenshot); + if (!canWrite) { + await new Promise((resolve) => { + let resolved = false; + const currentProcess = ffmpegProcess; + const onDrain = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }; + const onError = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }; + const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }, 800); + function cleanupListeners() { + clearTimeout(timeout); + if (currentProcess && currentProcess.stdin) { + currentProcess.stdin.removeListener('drain', onDrain); + currentProcess.stdin.removeListener('error', onError); + } + } + if (currentProcess && currentProcess.stdin) { + currentProcess.stdin.once('drain', onDrain); + currentProcess.stdin.once('error', onError); + } else { + resolve(); + } + }); + } + } + + consecutiveErrors = 0; + + } catch (error) { + if (!isCleaningUp) { + consecutiveErrors++; + console.error(`Capture error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error.message || error); + + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + console.error('Too many consecutive errors, stopping stream'); + try { await cleanup(); } catch (e) {} + break; + } + + await new Promise(r => setTimeout(r, 1000)); + } else { + break; + } + } + + const elapsed = Date.now() - start; + const wait = Math.max(0, frameInterval - elapsed); + if (wait > 0) await new Promise(r => setTimeout(r, wait)); + } + }; + + captureLoop(); + + // Cleanup function + const cleanup = async () => { + if (isCleaningUp) return; + isCleaningUp = true; + console.log('Cleaning up stream...'); + + try { captureLoopActive = false; } catch (e) {} + try { clearInterval(pageRefreshInterval); } catch (e) {} + + if (ffmpegProcess && !ffmpegProcess.killed) { + try { + ffmpegProcess.stdin.end(); + } catch (err) {} + ffmpegProcess.kill('SIGTERM'); + + setTimeout(() => { + if (ffmpegProcess && !ffmpegProcess.killed) { + ffmpegProcess.kill('SIGKILL'); + } + }, 5000); + + ffmpegProcess = null; + } + + if (playlistFile) { + try { + fs.unlinkSync(playlistFile); + } catch (err) {} + } + + if (browser) { + try { + await browser.close(); + } catch (err) {} + browser = null; + } + + if (!res.headersSent && !res.writableEnded) { + res.end(); + } + }; + + // Handle client disconnect + let disconnectLogged = false; + + req.on('close', () => { + if (!disconnectLogged) { + console.log('Client disconnected'); + disconnectLogged = true; + } + cleanup(); + }); + + req.on('error', (error) => { + if (error.code === 'ECONNRESET' || error.code === 'EPIPE') { + if (!disconnectLogged) { + console.log('Client disconnected'); + disconnectLogged = true; + } + } else { + console.error('Request error:', error); + } + cleanup(); + }); + + res.on('error', (error) => { + if (error.code === 'ECONNRESET' || error.code === 'EPIPE') { + if (!disconnectLogged) { + console.log('Client disconnected'); + disconnectLogged = true; + } + } else { + console.error('Response error:', error); + } + cleanup(); + }); + + // Keepalive monitoring + const keepaliveInterval = setInterval(() => { + if (isCleaningUp || !ffmpegProcess || ffmpegProcess.killed) { + clearInterval(keepaliveInterval); + return; + } + if (res.writableEnded || res.socket?.destroyed) { + console.log('Connection lost, cleaning up'); + clearInterval(keepaliveInterval); + cleanup(); + } + }, 10000); + + } catch (error) { + console.error('Error:', error); + if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill(); + if (browser) await browser.close(); + if (!res.headersSent) { + res.status(500).send('Internal server error'); + } + } +} + +module.exports = { streamHandler };