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",
|
"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",
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs);
|
const ffmpegProcess = spawn("ffmpeg", ffmpegArgs);
|
||||||
|
|
||||||
ffmpegProcess.on("error", (err: Error) => {
|
ffmpegProcess.on("error", (err: Error) => {
|
||||||
console.error(
|
console.error(
|
||||||
`[Timelapse] FFmpeg error for ${streamInfo.streamId}:`,
|
`[${getTimestamp()}] [Timelapse] FFmpeg spawn error: ${err.message}`,
|
||||||
err,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpegProcess.on("close", (code: number | null) => {
|
ffmpegProcess.on("close", (code: number | null) => {
|
||||||
|
try {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Timelapse] Captured frame for ${streamInfo.streamId}: ${filename}`,
|
`[${getTimestamp()}] [Timelapse] Captured frame: ${filename}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
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() {
|
function initializeTimelapse() {
|
||||||
@@ -73,17 +156,34 @@ function initializeTimelapse() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
ensureOutputDirectory();
|
ensureOutputDirectory();
|
||||||
console.log(`[Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`);
|
console.log(
|
||||||
console.log("[Timelapse] Starting hourly capture job...");
|
`[${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 {
|
||||||
|
console.log(`[${getTimestamp()}] [Timelapse] Running capture job...`);
|
||||||
captureTimelapseFrame();
|
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) {
|
||||||
|
try {
|
||||||
captureJob.cancel();
|
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,
|
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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
try {
|
||||||
|
if (!controllerClosed) {
|
||||||
controller.enqueue(chunk);
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Stream ${streamId}] Error enqueueing chunk:`, error);
|
||||||
|
controllerClosed = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
nodeStream.on("end", () => {
|
nodeStream.on("end", () => {
|
||||||
|
try {
|
||||||
|
if (!controllerClosed) {
|
||||||
controller.close();
|
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();
|
controller.close();
|
||||||
|
controllerClosed = true;
|
||||||
|
}
|
||||||
|
} catch (closeError) {
|
||||||
|
console.error(
|
||||||
|
`[Stream ${streamId}] Error closing controller on stream error:`,
|
||||||
|
closeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
|
try {
|
||||||
|
controllerClosed = true;
|
||||||
removeStreamClient(streamId, clientId);
|
removeStreamClient(streamId, clientId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Stream ${streamId}] Error during cancel:`, error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user