time-laps paroci by codex gpt-5.4

This commit is contained in:
MirSob
2026-05-06 22:43:01 +02:00
commit ce650def62
3 changed files with 704 additions and 0 deletions

113
README.md Normal file
View File

@@ -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
```

560
fern_timelapse.py Executable file
View File

@@ -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 "<empty>",
)
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 '<empty>'}")
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())

31
gemini-code-1778096684998.txt Executable file
View File

@@ -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.