fix: capture when no stream is running
This commit is contained in:
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
try {
|
||||
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs);
|
||||
|
||||
ffmpegProcess.on("error", (err: Error) => {
|
||||
console.error(
|
||||
`[Timelapse] FFmpeg error for ${streamInfo.streamId}:`,
|
||||
err,
|
||||
`[${getTimestamp()}] [Timelapse] FFmpeg spawn error: ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
ffmpegProcess.on("close", (code: number | null) => {
|
||||
try {
|
||||
if (code === 0) {
|
||||
console.log(
|
||||
`[Timelapse] Captured frame for ${streamInfo.streamId}: ${filename}`,
|
||||
`[${getTimestamp()}] [Timelapse] Captured frame: ${filename}`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[Timelapse] FFmpeg exited with code ${code} for ${streamInfo.streamId}`,
|
||||
`[${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;
|
||||
}
|
||||
|
||||
try {
|
||||
ensureOutputDirectory();
|
||||
console.log(`[Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`);
|
||||
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, () => {
|
||||
console.log("[Timelapse] Running hourly capture job...");
|
||||
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");
|
||||
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) {
|
||||
try {
|
||||
captureJob.cancel();
|
||||
console.log("[Timelapse] Capture job cancelled");
|
||||
console.log(`[${getTimestamp()}] [Timelapse] Capture job cancelled`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${getTimestamp()}] [Timelapse] Error cancelling capture job: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
try {
|
||||
if (!controllerClosed) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Stream ${streamId}] Error enqueueing chunk:`, error);
|
||||
controllerClosed = true;
|
||||
}
|
||||
});
|
||||
|
||||
nodeStream.on("end", () => {
|
||||
try {
|
||||
if (!controllerClosed) {
|
||||
controller.close();
|
||||
controllerClosed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Stream ${streamId}] Error closing controller:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
nodeStream.on("error", () => {
|
||||
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() {
|
||||
try {
|
||||
controllerClosed = true;
|
||||
removeStreamClient(streamId, clientId);
|
||||
} catch (error) {
|
||||
console.error(`[Stream ${streamId}] Error during cancel:`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user