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",
"prepare": "svelte-kit sync || echo ''",
"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": {
"@lucide/svelte": "^0.577.0",

View File

@@ -2,42 +2,46 @@ import schedule from "node-schedule";
import { spawn } from "child_process";
import { existsSync, mkdirSync } from "fs";
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 =
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 initialized = false;
function getTimestamp() {
return new Date().toISOString();
}
function ensureOutputDirectory() {
if (!existsSync(TIMELAPSE_OUTPUT_PATH)) {
mkdirSync(TIMELAPSE_OUTPUT_PATH, { recursive: true });
}
}
function captureTimelapseFrame() {
const streams = getAllStreams();
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;
}
function captureFrameForStream(streamId: string, rtspUrl: string) {
try {
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 ffmpegArgs = [
"-rtsp_transport",
"tcp",
"-i",
streamInfo.rtspUrl,
rtspUrl,
"-vframes",
"1",
"-q:v",
@@ -45,27 +49,106 @@ function captureTimelapseFrame() {
outputPath,
];
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs);
try {
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs);
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 {
ffmpegProcess.on("error", (err: 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() {
@@ -73,17 +156,34 @@ function initializeTimelapse() {
return;
}
ensureOutputDirectory();
console.log(`[Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`);
console.log("[Timelapse] Starting hourly capture job...");
try {
ensureOutputDirectory();
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, () => {
console.log("[Timelapse] Running hourly capture job...");
captureTimelapseFrame();
});
captureJob = schedule.scheduleJob(CAPTURE_INTERVAL, () => {
try {
console.log(`[${getTimestamp()}] [Timelapse] Running capture job...`);
captureTimelapseFrame();
} catch (err) {
console.error(
`[${getTimestamp()}] [Timelapse] Error in scheduled capture job: ${err}`,
);
}
});
initialized = true;
console.log("[Timelapse] Hourly capture job initialized");
initialized = true;
console.log(`[${getTimestamp()}] [Timelapse] Capture job initialized`);
} catch (error) {
console.error(
`[${getTimestamp()}] [Timelapse] Error initializing timelapse: ${error}`,
);
}
}
export async function handle({ event, resolve }) {
@@ -98,7 +198,13 @@ export async function handle({ event, resolve }) {
// Cleanup on shutdown
process.on("exit", () => {
if (captureJob) {
captureJob.cancel();
console.log("[Timelapse] Capture job cancelled");
try {
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,
// Scale video to 1920x1080 (Full HD)
"-vf",
"scale=1920:1080",
"scale=2560:1440",
// Video output - MJPEG format
"-c:v",
"mjpeg",

View File

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