From ce650def622745c9316a8d6c1d61f3973fe45aff Mon Sep 17 00:00:00 2001 From: MirSob Date: Wed, 6 May 2026 22:43:01 +0200 Subject: [PATCH] time-laps paroci by codex gpt-5.4 --- README.md | 113 +++++++ fern_timelapse.py | 560 ++++++++++++++++++++++++++++++++++ gemini-code-1778096684998.txt | 31 ++ 3 files changed, 704 insertions(+) create mode 100644 README.md create mode 100755 fern_timelapse.py create mode 100755 gemini-code-1778096684998.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..4999257 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Fern RTSP Timelapse + +## What + +`fern_timelapse.py` is a small Linux-first Python 3.10+ CLI for two jobs: + +- `capture`: take one JPEG from an RTSP camera, but only inside a configured daily window. +- `compile`: turn the saved JPEG sequence into a high-quality H.264 MP4. + +It uses `ffmpeg` through `subprocess`, keeps timestamp-based filenames, writes rotating logs to disk, and retries failed captures with exponential backoff. + +## Why + +The design is intentionally cron-friendly. Each `capture` run is single-shot and idempotent enough for scheduled execution, which keeps the runtime simple and easy to recover after reboots or network drops. The `compile` path builds a temporary numbered sequence from symlinks so you can keep human-readable timestamp filenames without fighting ffmpeg's numeric-image input rules. + +## Capture behavior + +- Default window: `10:00` to `16:00` +- End of window is exclusive, so the default window captures from `10:00` through `15:50` +- Default quality: high-quality JPEG via `-q:v 2` +- Default output path shape: `captures/YYYY/MM/DD/YYYY-MM-DD_HH-MM-SS_ferngrowth.jpg` +- Default log file: `logs/fern_timelapse.log` + +## Usage + +Show command help: + +```bash +python3 fern_timelapse.py --help +python3 fern_timelapse.py capture --help +python3 fern_timelapse.py compile --help +``` + +Capture one frame: + +```bash +python3 fern_timelapse.py capture \ + --camera-host 192.168.1.50 \ + --camera-user user \ + --camera-password 'password' \ + --camera-path /stream1 \ + --output-dir /srv/fern-timelapse/captures \ + --window-start 10:00 \ + --window-end 16:00 +``` + +Capture one frame even outside the allowed window: + +```bash +python3 fern_timelapse.py capture \ + --camera-host 10.1.1.33 \ + --camera-user admin \ + --camera-password 'mirekadmin' \ + --camera-path /stream1 \ + --output-dir /mnt/main-pool/Mirek/kamera \ + --ignore-window +``` + +Your current camera parameters, assuming the RTSP path is `/stream1`: + +```bash +python3 fern_timelapse.py capture \ + --camera-host 10.1.1.33 \ + --camera-user admin \ + --camera-password 'mirekadmin' \ + --camera-path /stream1 \ + --output-dir /srv/fern-timelapse/captures \ + --window-start 10:00 \ + --window-end 16:00 +``` + +Compile the final MP4 at 30 FPS: + +```bash +python3 fern_timelapse.py compile \ + --input-dir /srv/fern-timelapse/captures \ + --output-file /srv/fern-timelapse/output/ferngrowth_timelapse.mp4 \ + --fps 30 \ + --crf 17 \ + --preset slow +``` + +## Cron + +This entry runs every 10 minutes during the 6-hour daylight window and should produce about 36 frames per day: + +```cron +*/10 10-15 * * * /usr/bin/python3 /home/ms/projekty/paproc-rt/fern_timelapse.py capture --camera-host 10.1.1.33 --camera-user admin --camera-password 'mirekadmin' --camera-path /stream1 --output-dir /srv/fern-timelapse/captures --window-start 10:00 --window-end 16:00 >> /srv/fern-timelapse/cron.log 2>&1 +``` + +If you prefer a simpler cron expression, you can also run it more broadly and let the script self-skip outside the window. + +If your camera does not expose RTSP on `/stream1`, adjust `--camera-path`. Common alternatives include `/Streaming/Channels/101`, `/h264Preview_01_main`, or vendor-specific paths. + +## Notes on exposure and white balance + +There is no reliable generic ffmpeg flag that can force an RTSP camera to lock sensor-side auto-exposure or auto white balance. In practice, the cleanest fix for color flicker is: + +1. Lock exposure and white balance in the camera's own web UI, ONVIF controls, or vendor app. +2. Keep lighting stable and avoid direct sun shifts. +3. Use `--ffmpeg-input-arg` or `--ffmpeg-output-arg` only for camera- or workflow-specific ffmpeg flags that your device actually supports. + +Examples: + +```bash +python3 fern_timelapse.py capture \ + --camera-host 192.168.1.50 \ + --camera-user user \ + --camera-password 'password' \ + --camera-path /stream1 \ + --ffmpeg-input-arg=-fflags \ + --ffmpeg-input-arg=+discardcorrupt +``` diff --git a/fern_timelapse.py b/fern_timelapse.py new file mode 100755 index 0000000..52866a0 --- /dev/null +++ b/fern_timelapse.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import logging +from logging.handlers import RotatingFileHandler +import os +from dataclasses import dataclass +from datetime import datetime, time +from enum import Enum +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time as sleep_time +from urllib.parse import quote, urlsplit, urlunsplit +from uuid import uuid4 + + +DEFAULT_LABEL = "ferngrowth" +DEFAULT_WINDOW_START = "10:00" +DEFAULT_WINDOW_END = "16:00" +DEFAULT_LOG_FILE = Path("logs/fern_timelapse.log") +DEFAULT_CAPTURE_DIR = Path("captures") +DEFAULT_VIDEO_FILE = Path("timelapse/ferngrowth_timelapse.mp4") +RTSP_ENV_VAR = "FERN_RTSP_URL" +CAMERA_HOST_ENV_VAR = "FERN_CAMERA_HOST" +CAMERA_USER_ENV_VAR = "FERN_CAMERA_USER" +CAMERA_PASSWORD_ENV_VAR = "FERN_CAMERA_PASSWORD" +CAMERA_PORT_ENV_VAR = "FERN_CAMERA_PORT" +CAMERA_PATH_ENV_VAR = "FERN_CAMERA_PATH" + + +class CaptureOutcome(Enum): + CAPTURED = "captured" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass(frozen=True) +class TimeWindow: + start: time + end: time + + def contains(self, candidate: time) -> bool: + if self.start == self.end: + return True + + if self.start < self.end: + return self.start <= candidate < self.end + + return candidate >= self.start or candidate < self.end + + +@dataclass(frozen=True) +class RetryPolicy: + max_attempts: int + initial_backoff_seconds: float + backoff_multiplier: float + max_backoff_seconds: float + + +@dataclass(frozen=True) +class CaptureConfig: + camera_url: str + output_dir: Path + label: str + window: TimeWindow + ignore_window: bool + transport: str + read_timeout_seconds: float + process_timeout_seconds: float + jpeg_quality: int + ffmpeg_bin: str + ffmpeg_input_args: tuple[str, ...] + ffmpeg_output_args: tuple[str, ...] + retry_policy: RetryPolicy + + +@dataclass(frozen=True) +class CompileConfig: + input_dir: Path + output_file: Path + label: str + fps: int + crf: int + preset: str + ffmpeg_bin: str + process_timeout_seconds: float + + +def parse_clock_time(raw_value: str) -> time: + try: + return datetime.strptime(raw_value, "%H:%M").time() + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"Invalid time value '{raw_value}'. Expected HH:MM in 24-hour format." + ) from exc + + +def positive_int(raw_value: str) -> int: + value = int(raw_value) + if value <= 0: + raise argparse.ArgumentTypeError("Value must be greater than zero.") + return value + + +def positive_float(raw_value: str) -> float: + value = float(raw_value) + if value <= 0: + raise argparse.ArgumentTypeError("Value must be greater than zero.") + return value + + +def non_negative_float(raw_value: str) -> float: + value = float(raw_value) + if value < 0: + raise argparse.ArgumentTypeError("Value must be zero or greater.") + return value + + +def jpeg_quality_value(raw_value: str) -> int: + value = int(raw_value) + if not 1 <= value <= 31: + raise argparse.ArgumentTypeError("JPEG quality must be between 1 and 31. Lower is better.") + return value + + +def create_logger(log_file: Path) -> logging.Logger: + log_file.parent.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("fern_timelapse") + logger.setLevel(logging.INFO) + logger.handlers.clear() + logger.propagate = False + + formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + file_handler = RotatingFileHandler( + log_file, + maxBytes=5_000_000, + backupCount=5, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +def ensure_ffmpeg_available(ffmpeg_bin: str) -> None: + if shutil.which(ffmpeg_bin) is None: + raise FileNotFoundError(f"ffmpeg binary not found: {ffmpeg_bin}") + + +def sanitize_url(url: str) -> str: + parsed = urlsplit(url) + if not parsed.netloc: + return url + + username = parsed.username or "" + masked_user = username if username else "user" + host = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + + masked_netloc = f"{masked_user}:***@{host}{port}" if parsed.username or parsed.password else parsed.netloc + return urlunsplit((parsed.scheme, masked_netloc, parsed.path, parsed.query, parsed.fragment)) + + +def redact_text(text: str, secret: str, replacement: str) -> str: + if not text or not secret: + return text + return text.replace(secret, replacement) + + +def sanitize_label(raw_label: str) -> str: + cleaned = "".join(character if character.isalnum() or character in {"-", "_"} else "-" for character in raw_label.strip()) + cleaned = cleaned.strip("-_") + if not cleaned: + raise ValueError("Label must contain at least one letter or digit.") + return cleaned + + +def normalize_camera_path(path: str) -> str: + normalized = path.strip() + if not normalized: + raise ValueError("Camera path must not be empty.") + if not normalized.startswith("/"): + normalized = f"/{normalized}" + return normalized + + +def build_rtsp_url( + host: str, + path: str, + port: int, + username: str | None = None, + password: str | None = None, +) -> str: + host = host.strip() + if not host: + raise ValueError("Camera host must not be empty.") + + normalized_path = normalize_camera_path(path) + auth = "" + if username: + encoded_user = quote(username, safe="") + encoded_password = quote(password or "", safe="") + auth = f"{encoded_user}:{encoded_password}@" + + return f"rtsp://{auth}{host}:{port}{normalized_path}" + + +def build_frame_path(output_dir: Path, label: str, captured_at: datetime) -> Path: + date_dir = output_dir / f"{captured_at:%Y}" / f"{captured_at:%m}" / f"{captured_at:%d}" + filename = f"{captured_at:%Y-%m-%d_%H-%M-%S}_{label}.jpg" + return date_dir / filename + + +def format_window(window: TimeWindow) -> str: + return f"{window.start.strftime('%H:%M')} - {window.end.strftime('%H:%M')}" + + +def tail_stderr(stderr_text: str, limit: int = 10) -> str: + if not stderr_text: + return "" + lines = [line.strip() for line in stderr_text.splitlines() if line.strip()] + return "\n".join(lines[-limit:]) + + +def build_capture_command(config: CaptureConfig, target_path: Path) -> list[str]: + input_timeout_microseconds = str(int(config.read_timeout_seconds * 1_000_000)) + + return [ + config.ffmpeg_bin, + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-rtsp_transport", + config.transport, + "-timeout", + input_timeout_microseconds, + *config.ffmpeg_input_args, + "-i", + config.camera_url, + "-map", + "0:v:0", + "-an", + "-sn", + "-dn", + "-frames:v", + "1", + "-c:v", + "mjpeg", + "-q:v", + str(config.jpeg_quality), + *config.ffmpeg_output_args, + "-y", + str(target_path), + ] + + +def run_subprocess(command: list[str], timeout_seconds: float) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + ) + + +def capture_frame(config: CaptureConfig, logger: logging.Logger) -> CaptureOutcome: + now = datetime.now().astimezone() + if not config.ignore_window and not config.window.contains(now.time()): + logger.info( + "Skipped capture outside window. now=%s window=%s", + now.strftime("%Y-%m-%d %H:%M:%S %Z"), + format_window(config.window), + ) + return CaptureOutcome.SKIPPED + + target_path = build_frame_path(config.output_dir, config.label, now) + target_path.parent.mkdir(parents=True, exist_ok=True) + + if target_path.exists() and target_path.stat().st_size > 0: + logger.warning("Skipped capture because target already exists: %s", target_path) + return CaptureOutcome.SKIPPED + + sanitized_url = sanitize_url(config.camera_url) + delay_seconds = config.retry_policy.initial_backoff_seconds + + for attempt in range(1, config.retry_policy.max_attempts + 1): + temp_path = target_path.with_name(f"{target_path.stem}.{uuid4().hex}.tmp.jpg") + command = build_capture_command(config, temp_path) + + try: + logger.info( + "Starting capture attempt=%s/%s target=%s source=%s transport=%s", + attempt, + config.retry_policy.max_attempts, + target_path, + sanitized_url, + config.transport, + ) + + completed = run_subprocess(command, config.process_timeout_seconds) + + if completed.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0: + temp_path.replace(target_path) + logger.info("Capture succeeded target=%s bytes=%s", target_path, target_path.stat().st_size) + return CaptureOutcome.CAPTURED + + stderr_tail = tail_stderr(redact_text(completed.stderr, config.camera_url, sanitized_url)) + logger.warning( + "Capture attempt failed attempt=%s/%s returncode=%s stderr=%s", + attempt, + config.retry_policy.max_attempts, + completed.returncode, + stderr_tail or "", + ) + except subprocess.TimeoutExpired: + logger.warning( + "Capture attempt timed out attempt=%s/%s timeout=%.1fs", + attempt, + config.retry_policy.max_attempts, + config.process_timeout_seconds, + ) + finally: + if temp_path.exists(): + temp_path.unlink() + + if attempt < config.retry_policy.max_attempts: + logger.info("Retrying capture in %.1f seconds", delay_seconds) + sleep_time.sleep(delay_seconds) + delay_seconds = min(delay_seconds * config.retry_policy.backoff_multiplier, config.retry_policy.max_backoff_seconds) + + logger.error("All capture attempts failed target=%s source=%s", target_path, sanitized_url) + return CaptureOutcome.FAILED + + +def list_frames(input_dir: Path, label: str) -> list[Path]: + if not input_dir.exists(): + raise FileNotFoundError(f"Input directory does not exist: {input_dir}") + + pattern = f"*_{label}.jpg" + return sorted( + (path for path in input_dir.rglob(pattern) if path.is_file()), + key=lambda path: path.relative_to(input_dir).as_posix(), + ) + + +def compile_video(config: CompileConfig, logger: logging.Logger) -> Path: + frames = list_frames(config.input_dir, config.label) + if not frames: + raise FileNotFoundError( + f"No frames found in {config.input_dir} matching '*_{config.label}.jpg'." + ) + + config.output_file.parent.mkdir(parents=True, exist_ok=True) + ensure_ffmpeg_available(config.ffmpeg_bin) + + logger.info( + "Compiling timelapse frames=%s fps=%s crf=%s preset=%s output=%s", + len(frames), + config.fps, + config.crf, + config.preset, + config.output_file, + ) + + with tempfile.TemporaryDirectory(prefix="fern-seq-", dir=str(config.output_file.parent)) as temp_dir: + sequence_dir = Path(temp_dir) + for index, frame in enumerate(frames, start=1): + sequence_frame = sequence_dir / f"frame_{index:06d}.jpg" + try: + os.symlink(frame.resolve(), sequence_frame) + except OSError: + shutil.copy2(frame, sequence_frame) + + input_pattern = sequence_dir / "frame_%06d.jpg" + command = [ + config.ffmpeg_bin, + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-y", + "-framerate", + str(config.fps), + "-start_number", + "1", + "-i", + str(input_pattern), + "-c:v", + "libx264", + "-preset", + config.preset, + "-crf", + str(config.crf), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(config.output_file), + ] + + completed = run_subprocess(command, config.process_timeout_seconds) + if completed.returncode != 0: + stderr_tail = tail_stderr(completed.stderr) + raise RuntimeError(f"ffmpeg compile failed: {stderr_tail or ''}") + + logger.info("Timelapse compile succeeded output=%s", config.output_file) + return config.output_file + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Capture RTSP fern-growth frames and compile them into a timelapse video.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + capture_parser = subparsers.add_parser( + "capture", + help="Capture one frame if the current time is inside the allowed daily window.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + capture_parser.add_argument("--camera-url", help=f"RTSP URL. Falls back to ${RTSP_ENV_VAR} when omitted.") + capture_parser.add_argument("--camera-host", help=f"Camera IP or hostname. Falls back to ${CAMERA_HOST_ENV_VAR} when omitted.") + capture_parser.add_argument("--camera-user", help=f"Camera username. Falls back to ${CAMERA_USER_ENV_VAR} when omitted.") + capture_parser.add_argument("--camera-password", help=f"Camera password. Falls back to ${CAMERA_PASSWORD_ENV_VAR} when omitted.") + capture_parser.add_argument("--camera-port", type=positive_int, default=554, help=f"RTSP port. Falls back to ${CAMERA_PORT_ENV_VAR} when set and --camera-url is omitted.") + capture_parser.add_argument("--camera-path", default="/stream1", help=f"RTSP path. Falls back to ${CAMERA_PATH_ENV_VAR} when set and --camera-url is omitted.") + capture_parser.add_argument("--output-dir", type=Path, default=DEFAULT_CAPTURE_DIR, help="Base directory for JPEG output.") + capture_parser.add_argument("--label", default=DEFAULT_LABEL, help="Filename suffix used in each frame.") + capture_parser.add_argument("--window-start", type=parse_clock_time, default=parse_clock_time(DEFAULT_WINDOW_START), help="Daily capture window start.") + capture_parser.add_argument("--window-end", type=parse_clock_time, default=parse_clock_time(DEFAULT_WINDOW_END), help="Daily capture window end. End is exclusive.") + capture_parser.add_argument("--ignore-window", action="store_true", help="Capture even when the current time is outside the configured window.") + capture_parser.add_argument("--transport", choices=("tcp", "udp"), default="tcp", help="RTSP transport mode.") + capture_parser.add_argument("--read-timeout", type=positive_float, default=15.0, help="Per-read network timeout passed to ffmpeg, in seconds.") + capture_parser.add_argument("--process-timeout", type=positive_float, default=45.0, help="Maximum wall-clock time allowed for one ffmpeg capture attempt.") + capture_parser.add_argument("--jpeg-quality", type=jpeg_quality_value, default=2, help="ffmpeg MJPEG quality. Lower values are higher quality.") + capture_parser.add_argument("--retries", type=positive_int, default=4, help="How many capture attempts to make before giving up.") + capture_parser.add_argument("--initial-backoff", type=non_negative_float, default=2.0, help="Initial retry delay in seconds.") + capture_parser.add_argument("--backoff-multiplier", type=positive_float, default=2.0, help="Multiplier used for exponential backoff.") + capture_parser.add_argument("--max-backoff", type=positive_float, default=60.0, help="Maximum retry delay in seconds.") + capture_parser.add_argument("--ffmpeg-bin", default="ffmpeg", help="Path to ffmpeg binary.") + capture_parser.add_argument("--ffmpeg-input-arg", action="append", default=[], help="Extra ffmpeg input argument. Repeat for multiple values.") + capture_parser.add_argument("--ffmpeg-output-arg", action="append", default=[], help="Extra ffmpeg output argument. Repeat for multiple values.") + capture_parser.add_argument("--log-file", type=Path, default=DEFAULT_LOG_FILE, help="Path to the rotating log file.") + + compile_parser = subparsers.add_parser( + "compile", + help="Compile captured JPEGs into an H.264 MP4 timelapse.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + compile_parser.add_argument("--input-dir", type=Path, default=DEFAULT_CAPTURE_DIR, help="Base directory that contains captured JPEGs.") + compile_parser.add_argument("--label", default=DEFAULT_LABEL, help="Only include frames with this filename suffix.") + compile_parser.add_argument("--output-file", type=Path, default=DEFAULT_VIDEO_FILE, help="Target MP4 file.") + compile_parser.add_argument("--fps", type=positive_int, default=30, help="Output frames per second.") + compile_parser.add_argument("--crf", type=positive_int, default=17, help="libx264 CRF value. Lower is higher quality.") + compile_parser.add_argument("--preset", default="slow", help="libx264 preset.") + compile_parser.add_argument("--ffmpeg-bin", default="ffmpeg", help="Path to ffmpeg binary.") + compile_parser.add_argument("--process-timeout", type=positive_float, default=3600.0, help="Maximum wall-clock time allowed for ffmpeg video compilation.") + compile_parser.add_argument("--log-file", type=Path, default=DEFAULT_LOG_FILE, help="Path to the rotating log file.") + + return parser + + +def build_capture_config(args: argparse.Namespace) -> CaptureConfig: + label = sanitize_label(args.label) + camera_url = args.camera_url or os.environ.get(RTSP_ENV_VAR, "") + if not camera_url: + camera_host = args.camera_host or os.environ.get(CAMERA_HOST_ENV_VAR, "") + camera_user = args.camera_user or os.environ.get(CAMERA_USER_ENV_VAR) + camera_password = args.camera_password or os.environ.get(CAMERA_PASSWORD_ENV_VAR) + camera_port = int(os.environ.get(CAMERA_PORT_ENV_VAR, args.camera_port)) + camera_path = os.environ.get(CAMERA_PATH_ENV_VAR, args.camera_path) + + if not camera_host: + raise ValueError( + "Missing camera source. Pass --camera-url, set " + f"{RTSP_ENV_VAR}, or provide --camera-host/{CAMERA_HOST_ENV_VAR}." + ) + + camera_url = build_rtsp_url( + host=camera_host, + path=camera_path, + port=camera_port, + username=camera_user, + password=camera_password, + ) + + return CaptureConfig( + camera_url=camera_url, + output_dir=args.output_dir, + label=label, + window=TimeWindow(start=args.window_start, end=args.window_end), + ignore_window=args.ignore_window, + transport=args.transport, + read_timeout_seconds=args.read_timeout, + process_timeout_seconds=args.process_timeout, + jpeg_quality=args.jpeg_quality, + ffmpeg_bin=args.ffmpeg_bin, + ffmpeg_input_args=tuple(args.ffmpeg_input_arg), + ffmpeg_output_args=tuple(args.ffmpeg_output_arg), + retry_policy=RetryPolicy( + max_attempts=args.retries, + initial_backoff_seconds=args.initial_backoff, + backoff_multiplier=args.backoff_multiplier, + max_backoff_seconds=args.max_backoff, + ), + ) + + +def build_compile_config(args: argparse.Namespace) -> CompileConfig: + return CompileConfig( + input_dir=args.input_dir, + output_file=args.output_file, + label=sanitize_label(args.label), + fps=args.fps, + crf=args.crf, + preset=args.preset, + ffmpeg_bin=args.ffmpeg_bin, + process_timeout_seconds=args.process_timeout, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + logger: logging.Logger | None = None + + try: + logger = create_logger(args.log_file) + + if args.command == "capture": + config = build_capture_config(args) + ensure_ffmpeg_available(config.ffmpeg_bin) + outcome = capture_frame(config, logger) + return 0 if outcome in {CaptureOutcome.CAPTURED, CaptureOutcome.SKIPPED} else 1 + + if args.command == "compile": + config = build_compile_config(args) + output_file = compile_video(config, logger) + logger.info("Created timelapse video %s", output_file) + return 0 + + parser.error(f"Unsupported command: {args.command}") + except Exception as exc: + if logger is not None: + logger.exception("Command failed: %s", exc) + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gemini-code-1778096684998.txt b/gemini-code-1778096684998.txt new file mode 100755 index 0000000..6e915a5 --- /dev/null +++ b/gemini-code-1778096684998.txt @@ -0,0 +1,31 @@ +Act as an experienced Senior Fullstack/Linux Developer. Write a robust Python 3.10+ program to capture images from an RTSP home camera for a professional-grade time-lapse of a fern growing over 30-40 days. + +**Project Context & Strategy:** +- To ensure consistent lighting and minimize "shadow flicker," frames will be captured only during a 6-hour window daily (e.g., 10:00 AM - 4:00 PM). +- Interval: Capture one frame every 10 minutes. +- Target: ~36 frames/day, resulting in ~1440 frames over 40 days (~48 seconds of video at 30 FPS). + +**Core Requirements:** +1. **Target Platform:** Linux. +2. **Capture Engine:** Use `ffmpeg` via Python `subprocess`. + - *Technical Note:* If possible, suggest/implement flags to lock or stabilize auto-exposure/white balance to prevent color flickering between frames. +3. **Execution Logic:** + - The script should check the current system time. It must only capture and save a frame if the time falls within the predefined "Golden Window" (e.g., 10:00 - 16:00). + - Alternatively, design the script to take a single frame and exit, so it can be triggered by a `cron` job. +4. **Resilience:** + - Implement robust error handling for RTSP stream timeouts or network drops. + - Use exponential backoff for retries. + - Log all activities (success, skips, errors) to a local file. +5. **Output Management:** + - Save high-quality JPEGs to a structured directory. + - Filename format: `YYYY-MM-DD_HH-MM-SS_ferngrowth.jpg`. +6. **Video Compilation:** + - Provide a separate function or command to compile the final `.mp4` using `ffmpeg` at 30 FPS. Use high-quality libx264 settings. + +**Strict Coding Guidelines:** +- Clean, modular code (SOLID/DRY). +- Use modern Python 3.10+ (type hinting, logging). +- **Do not use Polish diacritics in code comments.** +- If any C/C++ is used, follow LLVM brace style. +- Provide a brief summary of "What" and "Why" regarding the architecture. +- Include a sample `crontab` entry to automate the 10-minute interval during the 6-hour daily window. \ No newline at end of file