Ooops forgot to commit
This commit is contained in:
@@ -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* ./
|
||||
|
||||
@@ -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
|
||||
|
||||
52
index.js
52
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,<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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
147
src/geocode.js
147
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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user