fix: capture when no stream is running

This commit is contained in:
2026-03-11 22:36:03 +01:00
parent 617f8c986b
commit de707294ec
5 changed files with 218 additions and 51 deletions

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
grown:
image: git.klemp.dev/tobi/grown:latest
container_name: grown
restart: unless-stopped
environment:
- NODE_ENV=production
volumes:
- /volume1/docker/grown/keimli2/:/app/timelapse/
networks:
- traefik-proxy
labels:
- "traefik.enable=true"
- traefik.docker.network=traefik-proxy
- "traefik.http.routers.grown.rule=Host(`keimli.klemp.local`)"
- "traefik.http.routers.grown.entrypoints=http"
- "traefik.http.services.grown.loadbalancer.server.port=3000"
- traefik.http.routers.grown.service=grown
networks:
traefik-proxy:
external: true

View File

@@ -9,7 +9,12 @@
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"docker:build": "docker build -t grown:latest .",
"docker:login": "echo $REFURBILY_REGISTRY_ACCESS_TOKEN | docker login ${GITEA_REGISTRY_URL#https://} -u refurbily --password-stdin",
"docker:push": "docker tag grown:latest ${GITEA_REGISTRY_URL#https://}/tobi/grown:latest && docker push ${GITEA_REGISTRY_URL#https://}/tobi/grown:latest",
"docker:build-push": "npm run docker:build && npm run docker:login && npm run docker:push",
"deploy": "docker context create nas --docker \"host=ssh://nas\" 2>/dev/null || true && docker --context nas compose -f docker-compose.yml --env-file .env pull && docker --context nas compose -f docker-compose.yml --env-file .env up -d && docker context use default"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.577.0", "@lucide/svelte": "^0.577.0",

View File

@@ -2,42 +2,46 @@ import schedule from "node-schedule";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { existsSync, mkdirSync } from "fs"; import { existsSync, mkdirSync } from "fs";
import { join } from "path"; import { join } from "path";
import { getAllStreams, getStreamProcess } from "$lib/server/streaming.js"; import {
getAllStreams,
getStreamProcess,
startStream,
stopStream,
} from "$lib/server/streaming.js";
const TIMELAPSE_OUTPUT_PATH = const TIMELAPSE_OUTPUT_PATH =
process.env.TIMELAPSE_OUTPUT_PATH || "./timelapse"; process.env.TIMELAPSE_OUTPUT_PATH || "./timelapse";
const CAPTURE_INTERVAL = "0 * * * *"; // Every hour at minute 0 const CAPTURE_INTERVAL = "*/10 * * * * *"; // Every 10 seconds
// Single camera configuration
const CAMERA_STREAM_ID = process.env.STREAM_ID || "camera-1";
const CAMERA_RTSP_URL =
process.env.RTSP_URL || "rtsp://Kamera_1:kamera_1@192.168.175.49/stream1";
let captureJob: any = null; let captureJob: any = null;
let initialized = false; let initialized = false;
function getTimestamp() {
return new Date().toISOString();
}
function ensureOutputDirectory() { function ensureOutputDirectory() {
if (!existsSync(TIMELAPSE_OUTPUT_PATH)) { if (!existsSync(TIMELAPSE_OUTPUT_PATH)) {
mkdirSync(TIMELAPSE_OUTPUT_PATH, { recursive: true }); mkdirSync(TIMELAPSE_OUTPUT_PATH, { recursive: true });
} }
} }
function captureTimelapseFrame() { function captureFrameForStream(streamId: string, rtspUrl: string) {
const streams = getAllStreams(); try {
streams.forEach((streamInfo) => {
const stream = getStreamProcess(streamInfo.streamId);
if (!stream || !stream.process || !stream.process.stdout) {
console.error(
`[Timelapse] Stream ${streamInfo.streamId} not available for capture`,
);
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `${streamInfo.streamId}_${timestamp}.jpg`; const filename = `${streamId}_${timestamp}.jpg`;
const outputPath = join(TIMELAPSE_OUTPUT_PATH, filename); const outputPath = join(TIMELAPSE_OUTPUT_PATH, filename);
const ffmpegArgs = [ const ffmpegArgs = [
"-rtsp_transport", "-rtsp_transport",
"tcp", "tcp",
"-i", "-i",
streamInfo.rtspUrl, rtspUrl,
"-vframes", "-vframes",
"1", "1",
"-q:v", "-q:v",
@@ -45,27 +49,106 @@ function captureTimelapseFrame() {
outputPath, outputPath,
]; ];
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs); try {
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs);
ffmpegProcess.on("error", (err: Error) => { ffmpegProcess.on("error", (err: Error) => {
console.error(
`[Timelapse] FFmpeg error for ${streamInfo.streamId}:`,
err,
);
});
ffmpegProcess.on("close", (code: number | null) => {
if (code === 0) {
console.log(
`[Timelapse] Captured frame for ${streamInfo.streamId}: ${filename}`,
);
} else {
console.error( console.error(
`[Timelapse] FFmpeg exited with code ${code} for ${streamInfo.streamId}`, `[${getTimestamp()}] [Timelapse] FFmpeg spawn error: ${err.message}`,
); );
});
ffmpegProcess.on("close", (code: number | null) => {
try {
if (code === 0) {
console.log(
`[${getTimestamp()}] [Timelapse] Captured frame: ${filename}`,
);
} else {
console.error(
`[${getTimestamp()}] [Timelapse] FFmpeg exited with code ${code}`,
);
}
} catch (err) {
console.error(
`[${getTimestamp()}] [Timelapse] Error handling ffmpeg close event: ${err}`,
);
}
});
} catch (spawnErr) {
console.error(
`[${getTimestamp()}] [Timelapse] Error spawning ffmpeg: ${spawnErr}`,
);
}
} catch (err) {
console.error(
`[${getTimestamp()}] [Timelapse] Error in captureFrameForStream: ${err}`,
);
}
}
function captureTimelapseFrame() {
const startTime = getTimestamp();
console.log(`[${startTime}] [Timelapse] Starting capture frame...`);
try {
let stream = getStreamProcess(CAMERA_STREAM_ID);
// If no active stream, start one temporarily
if (!stream || !stream.process || !stream.process.stdout) {
console.log(
`[${getTimestamp()}] [Timelapse] No active stream, starting temporary stream`,
);
const result = startStream(CAMERA_STREAM_ID, CAMERA_RTSP_URL);
if (!result.success) {
console.error(
`[${getTimestamp()}] [Timelapse] Failed to start stream: ${result.error}`,
);
return;
} }
});
}); // Give the stream a moment to start
setTimeout(() => {
const startedStream = getStreamProcess(CAMERA_STREAM_ID);
if (
startedStream &&
startedStream.process &&
startedStream.process.stdout
) {
captureFrameForStream(CAMERA_STREAM_ID, CAMERA_RTSP_URL);
// Stop stream after capture if no clients are connected
setTimeout(() => {
const streamStatus = getStreamProcess(CAMERA_STREAM_ID);
if (
streamStatus &&
streamStatus.clients &&
streamStatus.clients.size === 0
) {
console.log(
`[${getTimestamp()}] [Timelapse] Stopping temporary stream after capture`,
);
stopStream(CAMERA_STREAM_ID);
}
}, 1000);
} else {
console.error(
`[${getTimestamp()}] [Timelapse] Failed to start stream process`,
);
}
}, 500);
} else {
// Stream already active, just capture
captureFrameForStream(CAMERA_STREAM_ID, CAMERA_RTSP_URL);
}
} catch (error) {
console.error(
`[${getTimestamp()}] [Timelapse] Fatal error in captureTimelapseFrame: ${error}`,
);
}
const endTime = getTimestamp();
console.log(`[${endTime}] [Timelapse] Capture frame completed`);
} }
function initializeTimelapse() { function initializeTimelapse() {
@@ -73,17 +156,34 @@ function initializeTimelapse() {
return; return;
} }
ensureOutputDirectory(); try {
console.log(`[Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`); ensureOutputDirectory();
console.log("[Timelapse] Starting hourly capture job..."); console.log(
`[${getTimestamp()}] [Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`,
);
console.log(
`[${getTimestamp()}] [Timelapse] Camera: ${CAMERA_STREAM_ID} (${CAMERA_RTSP_URL})`,
);
console.log(`[${getTimestamp()}] [Timelapse] Starting capture job...`);
captureJob = schedule.scheduleJob(CAPTURE_INTERVAL, () => { captureJob = schedule.scheduleJob(CAPTURE_INTERVAL, () => {
console.log("[Timelapse] Running hourly capture job..."); try {
captureTimelapseFrame(); console.log(`[${getTimestamp()}] [Timelapse] Running capture job...`);
}); captureTimelapseFrame();
} catch (err) {
console.error(
`[${getTimestamp()}] [Timelapse] Error in scheduled capture job: ${err}`,
);
}
});
initialized = true; initialized = true;
console.log("[Timelapse] Hourly capture job initialized"); console.log(`[${getTimestamp()}] [Timelapse] Capture job initialized`);
} catch (error) {
console.error(
`[${getTimestamp()}] [Timelapse] Error initializing timelapse: ${error}`,
);
}
} }
export async function handle({ event, resolve }) { export async function handle({ event, resolve }) {
@@ -98,7 +198,13 @@ export async function handle({ event, resolve }) {
// Cleanup on shutdown // Cleanup on shutdown
process.on("exit", () => { process.on("exit", () => {
if (captureJob) { if (captureJob) {
captureJob.cancel(); try {
console.log("[Timelapse] Capture job cancelled"); captureJob.cancel();
console.log(`[${getTimestamp()}] [Timelapse] Capture job cancelled`);
} catch (error) {
console.error(
`[${getTimestamp()}] [Timelapse] Error cancelling capture job: ${error}`,
);
}
} }
}); });

View File

@@ -26,7 +26,7 @@ export function startStream(
rtspUrl, rtspUrl,
// Scale video to 1920x1080 (Full HD) // Scale video to 1920x1080 (Full HD)
"-vf", "-vf",
"scale=1920:1080", "scale=2560:1440",
// Video output - MJPEG format // Video output - MJPEG format
"-c:v", "-c:v",
"mjpeg", "mjpeg",

View File

@@ -21,23 +21,57 @@ export async function GET({ url }) {
// Create a web ReadableStream from the Node.js stream // Create a web ReadableStream from the Node.js stream
const nodeStream = stream.process.stdout; const nodeStream = stream.process.stdout;
let controllerClosed = false;
const webReadableStream = new ReadableStream({ const webReadableStream = new ReadableStream({
start(controller) { start(controller) {
nodeStream.on("data", (chunk) => { nodeStream.on("data", (chunk) => {
controller.enqueue(chunk); try {
if (!controllerClosed) {
controller.enqueue(chunk);
}
} catch (error) {
console.error(`[Stream ${streamId}] Error enqueueing chunk:`, error);
controllerClosed = true;
}
}); });
nodeStream.on("end", () => { nodeStream.on("end", () => {
controller.close(); try {
if (!controllerClosed) {
controller.close();
controllerClosed = true;
}
} catch (error) {
console.error(
`[Stream ${streamId}] Error closing controller:`,
error,
);
}
}); });
nodeStream.on("error", () => { nodeStream.on("error", (error) => {
controller.close(); console.error(`[Stream ${streamId}] Node stream error:`, error);
try {
if (!controllerClosed) {
controller.close();
controllerClosed = true;
}
} catch (closeError) {
console.error(
`[Stream ${streamId}] Error closing controller on stream error:`,
closeError,
);
}
}); });
}, },
cancel() { cancel() {
removeStreamClient(streamId, clientId); try {
controllerClosed = true;
removeStreamClient(streamId, clientId);
} catch (error) {
console.error(`[Stream ${streamId}] Error during cancel:`, error);
}
}, },
}); });