Ooops forgot to commit
This commit is contained in:
@@ -56,6 +56,14 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
|||||||
WS4KP_PORT=8080 \
|
WS4KP_PORT=8080 \
|
||||||
MUSIC_PATH=/music
|
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
|
# Install our streaming app
|
||||||
WORKDIR /streaming-app
|
WORKDIR /streaming-app
|
||||||
COPY package.json yarn.lock* ./
|
COPY package.json yarn.lock* ./
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
ws4kp-to-hls:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:${PORT:-3000}"
|
- "${PORT:-3000}:${PORT:-3000}"
|
||||||
# WS4KP port - comment out this line if you don't need external access to WS4KP
|
# 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
|
shm_size: 2gb
|
||||||
environment:
|
environment:
|
||||||
- PORT=${PORT:-3000}
|
- 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:
|
volumes:
|
||||||
- ./cache:/streaming-app/cache
|
- ./cache:/streaming-app/cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
52
index.js
52
index.js
@@ -7,6 +7,10 @@ const app = express();
|
|||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const WS4KP_PORT = process.env.WS4KP_PORT || 8080;
|
const WS4KP_PORT = process.env.WS4KP_PORT || 8080;
|
||||||
const MUSIC_PATH = process.env.MUSIC_PATH || '/music';
|
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
|
* Build WS4KP weather URL with given coordinates and settings
|
||||||
@@ -77,15 +81,19 @@ function buildWeatherUrl(latitude, longitude, settings) {
|
|||||||
return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`;
|
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) => {
|
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 useMusic = music === 'true';
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
streamHandler(req, res, {
|
streamHandler(req, res, {
|
||||||
useMusic,
|
useMusic,
|
||||||
musicPath: MUSIC_PATH,
|
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 lateGeocodePromise = null;
|
||||||
|
let geocodeDataPromise = null;
|
||||||
let initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>';
|
let initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>';
|
||||||
|
|
||||||
if (city && city !== 'Toronto, ON, CAN') {
|
if (city && city !== 'Toronto, ON, CAN') {
|
||||||
// Start geocoding (only call once)
|
// Start geocoding in background - don't wait for it
|
||||||
const geocodePromise = geocodeCity(city);
|
const geocodePromise = geocodeCity(city);
|
||||||
|
geocodeDataPromise = geocodePromise; // Save for city name overlay
|
||||||
|
|
||||||
// Try to use quick result if available within 1 second
|
// Always start with black screen immediately
|
||||||
const quickResult = Promise.race([
|
initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>';
|
||||||
geocodePromise,
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000))
|
|
||||||
]).catch(() => null); // Timeout = null, will use late result
|
|
||||||
|
|
||||||
// Build URL from quick result if available
|
// Late geocode promise will load the actual weather page
|
||||||
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)
|
|
||||||
lateGeocodePromise = geocodePromise.then(geoResult => {
|
lateGeocodePromise = geocodePromise.then(geoResult => {
|
||||||
return buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings);
|
return buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
@@ -166,6 +164,8 @@ app.get('/weather', async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
// Toronto default
|
// Toronto default
|
||||||
initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings);
|
initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings);
|
||||||
|
// Create resolved promise with Toronto data
|
||||||
|
geocodeDataPromise = Promise.resolve({ cityName: 'Toronto' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -178,11 +178,17 @@ app.get('/weather', async (req, res) => {
|
|||||||
req.query.hideLogo = hideLogo;
|
req.query.hideLogo = hideLogo;
|
||||||
|
|
||||||
// Call stream handler with music enabled
|
// Call stream handler with music enabled
|
||||||
|
const { debug = DEBUG_MODE ? 'true' : 'false' } = req.query;
|
||||||
return streamHandler(req, res, {
|
return streamHandler(req, res, {
|
||||||
useMusic: true,
|
useMusic: true,
|
||||||
musicPath: MUSIC_PATH,
|
musicPath: MUSIC_PATH,
|
||||||
lateGeocodePromise,
|
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) {
|
if (process.env.WS4KP_EXTERNAL_PORT) {
|
||||||
console.log(`WS4KP weather service on port ${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`);
|
console.log(`Weather: http://localhost:${PORT}/weather?city=YourCity`);
|
||||||
|
|
||||||
// Pre-validate music files on startup to cache results
|
// 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 {number} options.fps - Frames per second
|
||||||
* @param {boolean} options.useMusic - Whether to include audio from music files
|
* @param {boolean} options.useMusic - Whether to include audio from music files
|
||||||
* @param {string} options.musicPath - Path to music directory
|
* @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 = [];
|
const ffmpegArgs = [];
|
||||||
let playlistFile = null;
|
let playlistFile = null;
|
||||||
let hasMusic = false;
|
let hasMusic = false;
|
||||||
@@ -24,7 +51,8 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
|
|||||||
ffmpegArgs.push(
|
ffmpegArgs.push(
|
||||||
'-use_wallclock_as_timestamps', '1',
|
'-use_wallclock_as_timestamps', '1',
|
||||||
'-f', 'image2pipe',
|
'-f', 'image2pipe',
|
||||||
'-framerate', fps.toString(),
|
'-vcodec', inputFormat === 'png' ? 'png' : 'mjpeg',
|
||||||
|
'-framerate', captureFps.toString(),
|
||||||
'-i', 'pipe:0'
|
'-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
|
'aformat=sample_rates=44100:sample_fmts=fltp:channel_layouts=stereo', // Force format
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
'-preset', 'ultrafast',
|
'-preset', 'ultrafast',
|
||||||
|
...(captureAtHigherFps || debugMode || cityNameFilter ? ['-vf', [
|
||||||
|
captureAtHigherFps ? `fps=${fps}` : null,
|
||||||
|
cityNameFilter,
|
||||||
|
debugFilter
|
||||||
|
].filter(Boolean).join(',')] : []),
|
||||||
|
...(captureAtHigherFps ? ['-r', fps.toString()] : []),
|
||||||
'-tune', 'zerolatency',
|
'-tune', 'zerolatency',
|
||||||
'-pix_fmt', 'yuv420p',
|
'-pix_fmt', 'yuv420p',
|
||||||
'-g', (fps * 2).toString(), // Keyframe every 2 seconds for 2s segments
|
'-g', (fps * 2).toString(), // Keyframe every 2 seconds for 2s segments
|
||||||
@@ -86,7 +120,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
|
|||||||
'pipe:1'
|
'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(
|
ffmpegArgs.push(
|
||||||
'-use_wallclock_as_timestamps', '1',
|
'-use_wallclock_as_timestamps', '1',
|
||||||
'-f', 'image2pipe',
|
'-f', 'image2pipe',
|
||||||
'-framerate', fps.toString(),
|
'-vcodec', inputFormat === 'png' ? 'png' : 'mjpeg',
|
||||||
|
'-framerate', captureFps.toString(),
|
||||||
'-i', 'pipe:0',
|
'-i', 'pipe:0',
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
'-preset', 'ultrafast',
|
'-preset', 'ultrafast',
|
||||||
|
...(captureAtHigherFps || debugMode || cityNameFilter ? ['-vf', [
|
||||||
|
captureAtHigherFps ? `fps=${fps}` : null,
|
||||||
|
cityNameFilter,
|
||||||
|
debugFilter
|
||||||
|
].filter(Boolean).join(',')] : []),
|
||||||
|
...(captureAtHigherFps ? ['-r', fps.toString()] : []),
|
||||||
'-tune', 'zerolatency',
|
'-tune', 'zerolatency',
|
||||||
'-pix_fmt', 'yuv420p',
|
'-pix_fmt', 'yuv420p',
|
||||||
'-g', (fps * 2).toString(), // Keyframe every 2 seconds for 2s segments
|
'-g', (fps * 2).toString(), // Keyframe every 2 seconds for 2s segments
|
||||||
@@ -118,7 +159,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
|
|||||||
'pipe:1'
|
'pipe:1'
|
||||||
);
|
);
|
||||||
|
|
||||||
return { args: ffmpegArgs, playlistFile, hasMusic };
|
return { args: ffmpegArgs, playlistFile, hasMusic, captureFps };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { buildFFmpegArgs };
|
module.exports = { buildFFmpegArgs };
|
||||||
|
|||||||
147
src/geocode.js
147
src/geocode.js
@@ -4,6 +4,8 @@ const path = require('path');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const CACHE_DIR = path.join(__dirname, '..', 'cache');
|
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
|
// Ensure cache directory exists
|
||||||
if (!fs.existsSync(CACHE_DIR)) {
|
if (!fs.existsSync(CACHE_DIR)) {
|
||||||
@@ -11,13 +13,54 @@ if (!fs.existsSync(CACHE_DIR)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a safe filename from a city query
|
* Normalize a query string for matching
|
||||||
* @param {string} cityQuery - City name
|
* @param {string} query - City query string
|
||||||
* @returns {string} Safe filename
|
* @returns {string} Normalized query
|
||||||
*/
|
*/
|
||||||
function getCacheFileName(cityQuery) {
|
function normalizeQuery(query) {
|
||||||
const hash = crypto.createHash('md5').update(cityQuery.toLowerCase().trim()).digest('hex');
|
return query.toLowerCase().trim().replace(/\s+/g, ' ');
|
||||||
return `geocode_${hash}.json`;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
function getCachedGeocode(cityQuery) {
|
||||||
try {
|
try {
|
||||||
const cacheFile = path.join(CACHE_DIR, getCacheFileName(cityQuery));
|
const cache = loadCache();
|
||||||
if (fs.existsSync(cacheFile)) {
|
const normalized = normalizeQuery(cityQuery);
|
||||||
const data = fs.readFileSync(cacheFile, 'utf8');
|
|
||||||
const cached = JSON.parse(data);
|
// Check if we have this query
|
||||||
// Verify the query matches
|
const locationKey = cache.queries[normalized];
|
||||||
if (cached.query && cached.query.toLowerCase().trim() === cityQuery.toLowerCase().trim()) {
|
if (locationKey && cache.locations[locationKey]) {
|
||||||
console.log(`Geocode: ${cityQuery} (cached)`);
|
const location = cache.locations[locationKey];
|
||||||
return cached;
|
console.log(`Geocode: ${cityQuery} -> ${location.cityName} (cached)`);
|
||||||
}
|
return {
|
||||||
|
...location,
|
||||||
|
query: cityQuery // Return with original query
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silent fail
|
console.error('Error reading geocode cache:', error.message);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -46,14 +92,51 @@ function getCachedGeocode(cityQuery) {
|
|||||||
/**
|
/**
|
||||||
* Save geocode data to cache
|
* Save geocode data to cache
|
||||||
* @param {string} cityQuery - City name
|
* @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) {
|
function saveCachedGeocode(cityQuery, data) {
|
||||||
try {
|
try {
|
||||||
const cacheFile = path.join(CACHE_DIR, getCacheFileName(cityQuery));
|
const cache = loadCache();
|
||||||
fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2), 'utf8');
|
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) {
|
} 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const encodedQuery = encodeURIComponent(cityQuery);
|
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 = {
|
const options = {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -89,13 +172,27 @@ async function geocodeCity(cityQuery) {
|
|||||||
try {
|
try {
|
||||||
const results = JSON.parse(data);
|
const results = JSON.parse(data);
|
||||||
if (results && results.length > 0) {
|
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 = {
|
const geocodeResult = {
|
||||||
query: cityQuery,
|
query: cityQuery,
|
||||||
lat: parseFloat(results[0].lat),
|
lat: parseFloat(result.lat),
|
||||||
lon: parseFloat(results[0].lon),
|
lon: parseFloat(result.lon),
|
||||||
displayName: results[0].display_name
|
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
|
// Save to cache
|
||||||
saveCachedGeocode(cityQuery, geocodeResult);
|
saveCachedGeocode(cityQuery, geocodeResult);
|
||||||
resolve(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 = {
|
module.exports = {
|
||||||
setupPage,
|
setupPage,
|
||||||
waitForPageFullyLoaded,
|
waitForPageFullyLoaded,
|
||||||
hideLogo
|
hideLogo,
|
||||||
|
startAutoScroll
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const puppeteer = require('puppeteer');
|
|||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { buildFFmpegArgs } = require('./ffmpegConfig');
|
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
|
* 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 {boolean} options.useMusic - Whether to include music
|
||||||
* @param {string} options.musicPath - Path to music directory
|
* @param {string} options.musicPath - Path to music directory
|
||||||
* @param {Promise<string>} options.lateGeocodePromise - Optional promise for late URL update
|
* @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.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() }) {
|
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 = 30, hideLogo: hideLogoFlag = 'false', refreshInterval = 90 } = req.query;
|
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) {
|
if (!url) {
|
||||||
return res.status(400).send('URL parameter is required');
|
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 playlistFile = null;
|
||||||
let cleanup; // Declare cleanup function variable early
|
let cleanup; // Declare cleanup function variable early
|
||||||
let streamId = '[STREAM]'; // Default stream ID
|
let streamId = '[STREAM]'; // Default stream ID
|
||||||
|
let stopAutoScroll = null; // Function to stop auto-scrolling
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set HLS headers
|
// Set HLS headers
|
||||||
@@ -42,10 +51,29 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
// Build FFmpeg command and launch browser in parallel
|
// 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({
|
const ffmpegConfigPromise = buildFFmpegArgs({
|
||||||
fps: parseInt(fps),
|
fps: parseInt(fps),
|
||||||
useMusic,
|
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({
|
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]);
|
const [ffmpegConfig, browserInstance] = await Promise.all([ffmpegConfigPromise, browserPromise]);
|
||||||
browser = browserInstance;
|
browser = browserInstance;
|
||||||
playlistFile = ffmpegConfig.playlistFile;
|
playlistFile = ffmpegConfig.playlistFile;
|
||||||
|
const captureFps = ffmpegConfig.captureFps || fps; // Use capture FPS (may be 2x target FPS)
|
||||||
|
|
||||||
// Update stream identifier from browser process PID
|
// Update stream identifier from browser process PID
|
||||||
streamId = `[${browser.process()?.pid || 'STREAM'}]`;
|
streamId = `[${browser.process()?.pid || 'STREAM'}]`;
|
||||||
@@ -151,6 +180,14 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
// Setup Puppeteer page
|
// Setup Puppeteer page
|
||||||
const page = await setupPage(browser, { width: parseInt(width), height: parseInt(height) });
|
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
|
// Black frame control
|
||||||
let sendBlackFrames = true;
|
let sendBlackFrames = true;
|
||||||
let waitingForCorrectUrl = !!lateGeocodePromise;
|
let waitingForCorrectUrl = !!lateGeocodePromise;
|
||||||
@@ -166,6 +203,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
if (hideLogoFlag === 'true') {
|
if (hideLogoFlag === 'true') {
|
||||||
await hideLogo(page);
|
await hideLogo(page);
|
||||||
}
|
}
|
||||||
|
if (scroll === 'true') {
|
||||||
|
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -191,6 +231,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
if (hideLogoFlag === 'true') {
|
if (hideLogoFlag === 'true') {
|
||||||
await hideLogo(page);
|
await hideLogo(page);
|
||||||
}
|
}
|
||||||
|
if (scroll === 'true') {
|
||||||
|
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`${streamId} Location update error:`, err.message);
|
console.error(`${streamId} Location update error:`, err.message);
|
||||||
waitingForCorrectUrl = false;
|
waitingForCorrectUrl = false;
|
||||||
@@ -207,6 +250,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
if (hideLogoFlag === 'true') {
|
if (hideLogoFlag === 'true') {
|
||||||
await hideLogo(page);
|
await hideLogo(page);
|
||||||
}
|
}
|
||||||
|
if (scroll === 'true') {
|
||||||
|
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause) });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`${streamId} Page load error:`, err.message);
|
console.error(`${streamId} Page load error:`, err.message);
|
||||||
waitingForCorrectUrl = false;
|
waitingForCorrectUrl = false;
|
||||||
@@ -224,6 +270,9 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
if (hideLogoFlag === 'true') {
|
if (hideLogoFlag === 'true') {
|
||||||
await hideLogo(page);
|
await hideLogo(page);
|
||||||
}
|
}
|
||||||
|
if (scroll === 'true') {
|
||||||
|
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
waitingForCorrectUrl = false;
|
waitingForCorrectUrl = false;
|
||||||
@@ -241,6 +290,10 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
if (hideLogoFlag === 'true') {
|
if (hideLogoFlag === 'true') {
|
||||||
await hideLogo(page);
|
await hideLogo(page);
|
||||||
}
|
}
|
||||||
|
if (scroll === 'true') {
|
||||||
|
if (stopAutoScroll) stopAutoScroll();
|
||||||
|
stopAutoScroll = await startAutoScroll(page, { pauseAtTop: parseInt(scrollPause), fps: parseInt(fps) });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silent
|
// Silent
|
||||||
}
|
}
|
||||||
@@ -248,7 +301,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
}, refreshIntervalMs);
|
}, refreshIntervalMs);
|
||||||
|
|
||||||
// Frame capture
|
// Frame capture
|
||||||
const frameInterval = 1000 / fps;
|
const frameInterval = 1000 / captureFps; // Use capture FPS for interval (may be 2x target FPS)
|
||||||
let captureLoopActive = true;
|
let captureLoopActive = true;
|
||||||
let consecutiveErrors = 0;
|
let consecutiveErrors = 0;
|
||||||
const MAX_CONSECUTIVE_ERRORS = 5;
|
const MAX_CONSECUTIVE_ERRORS = 5;
|
||||||
@@ -260,7 +313,13 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
const ctx = canvasObj.getContext('2d');
|
const ctx = canvasObj.getContext('2d');
|
||||||
ctx.fillStyle = '#000000';
|
ctx.fillStyle = '#000000';
|
||||||
ctx.fillRect(0, 0, parseInt(width), parseInt(height));
|
ctx.fillRect(0, 0, parseInt(width), parseInt(height));
|
||||||
|
|
||||||
|
// Use the same format as screenshots
|
||||||
|
if (screenshotFormat === 'png') {
|
||||||
|
return canvasObj.toBuffer('image/png');
|
||||||
|
} else {
|
||||||
return canvasObj.toBuffer('image/jpeg', { quality: 0.5 });
|
return canvasObj.toBuffer('image/jpeg', { quality: 0.5 });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating black frame:', err);
|
console.error('Error creating black frame:', err);
|
||||||
return Buffer.alloc(0);
|
return Buffer.alloc(0);
|
||||||
@@ -286,12 +345,18 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
|||||||
}
|
}
|
||||||
screenshot = blackFrameBuffer;
|
screenshot = blackFrameBuffer;
|
||||||
} else {
|
} else {
|
||||||
screenshot = await page.screenshot({
|
const screenshotOptions = {
|
||||||
type: 'jpeg',
|
type: screenshotFormat,
|
||||||
quality: 80,
|
optimizeForSpeed: false,
|
||||||
optimizeForSpeed: true,
|
|
||||||
fromSurface: true
|
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) {
|
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 { captureLoopActive = false; } catch (e) {}
|
||||||
try { clearInterval(pageRefreshInterval); } catch (e) {}
|
try { clearInterval(pageRefreshInterval); } catch (e) {}
|
||||||
|
try { if (stopAutoScroll) stopAutoScroll(); } catch (e) {}
|
||||||
|
|
||||||
if (ffmpegProcess && !ffmpegProcess.killed) {
|
if (ffmpegProcess && !ffmpegProcess.killed) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user