Timelapse: Tagesvideo-Download (ffmpeg/MP4, sonst MJPEG-AVI in reinem Python)

This commit is contained in:
Joachim Hummel committed 2026-10-18 13:49:43 +02:00
1 parent 304c202f46
commit 13e86bb818
2 files changed
+129 -1

No files matched your search

+103
View File
@@ -12,6 +12,7 @@ Endpunkte:
POST /<name>/setup ruft die in SETUP_<name> hinterlegte Kamera-URL auf
/<name>/timelapse.json Liste der Timelapse-Bilder je Tag
/<name>/timelapse/<tag>/<datei> einzelnes Timelapse-Bild
/<name>/timelapse/<tag>/video?fps=10 Tagesvideo (MP4 mit ffmpeg, sonst MJPEG-AVI)
/stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität)
/health Status aller Kameras
@@ -32,6 +33,9 @@ import json
import os
import re
import shutil
import struct
import subprocess
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -196,6 +200,79 @@ def prune(cam_dir):
shutil.rmtree(os.path.join(cam_dir, day), ignore_errors=True)
def jpeg_size(data):
"""Breite und Höhe aus dem SOF-Marker eines JPEG."""
i = 2
while i + 9 < len(data):
if data[i] != 0xFF:
i += 1
continue
m = data[i + 1]
if m in (0xC0, 0xC1, 0xC2):
h, w = struct.unpack(">HH", data[i + 5:i + 9])
return w, h
i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0]
raise ValueError("kein JPEG")
def write_avi(path, files, fps):
"""Schreibt die JPEGs als MJPEG-AVI (ohne ffmpeg)."""
with open(files[0], "rb") as f:
w, h = jpeg_size(f.read())
sizes = [os.path.getsize(x) for x in files]
n, big = len(files), max(sizes)
pad = lambda v: v + (v & 1)
movi = 4 + sum(8 + pad(v) for v in sizes)
hdrl = (b"avih" + struct.pack("<I14I", 56, 1000000 // fps, big * fps, 0, 0x10, n, 0, 1, big, w, h, 0, 0, 0, 0)
+ b"LIST" + struct.pack("<I", 4 + 64 + 48) + b"strl"
+ b"strh" + struct.pack("<I4s4sIHHIIIIIIIIhhhh", 56, b"vids", b"MJPG", 0, 0, 0, 0, 1, fps, 0, n, big, 0xFFFFFFFF, 0, 0, 0, w, h)
+ b"strf" + struct.pack("<IIiiHH4sIIIII", 40, 40, w, h, 1, 24, b"MJPG", w * h * 3, 0, 0, 0, 0))
riff = 4 + 8 + 4 + len(hdrl) + 8 + movi + 8 + 16 * n
with open(path, "wb") as out:
out.write(b"RIFF" + struct.pack("<I", riff) + b"AVI ")
out.write(b"LIST" + struct.pack("<I", 4 + len(hdrl)) + b"hdrl" + hdrl)
out.write(b"LIST" + struct.pack("<I", movi) + b"movi")
idx, off = [], 4
for x, size in zip(files, sizes):
with open(x, "rb") as f:
data = f.read()
out.write(b"00dc" + struct.pack("<I", len(data)) + data + (b"\0" if len(data) & 1 else b""))
idx.append(b"00dc" + struct.pack("<III", 0x10, off, len(data)))
off += 8 + pad(len(data))
out.write(b"idx1" + struct.pack("<I", 16 * n) + b"".join(idx))
video_lock = threading.Lock()
def make_video(name, day, fps):
"""Erzeugt das Tagesvideo; liefert (Pfad, Dateiendung) oder None, wenn es keine Bilder gibt."""
folder = os.path.join(TL_DIR, name, day)
try:
files = sorted(os.path.join(folder, f) for f in os.listdir(folder) if FILE_RE.match(f))
except FileNotFoundError:
files = []
if not files:
return None
with video_lock:
if shutil.which("ffmpeg"):
fd, path = tempfile.mkstemp(suffix=".mp4")
os.close(fd)
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps),
"-pattern_type", "glob", "-i", os.path.join(folder, "*.jpg"),
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", path],
capture_output=True, timeout=600)
if r.returncode == 0:
return path, "mp4"
print(f"{name}: ffmpeg fehlgeschlagen: {r.stderr.decode(errors='replace')[:300]}", flush=True)
os.unlink(path)
fd, path = tempfile.mkstemp(suffix=".avi")
os.close(fd)
write_avi(path, files, fps)
return path, "avi"
def timelapse_index(name):
"""{"interval": sek, "days": {tag: [dateien]}} aus dem Dateisystem."""
days = {}
@@ -255,6 +332,32 @@ class Handler(BaseHTTPRequestHandler):
name = parts[0]
if name in hubs and len(parts) == 2 and parts[1] == "timelapse.json":
return self.send_bytes(200, "application/json", json.dumps(timelapse_index(name)).encode())
if name in hubs and len(parts) == 4 and parts[1:2] == ["timelapse"] and parts[3] == "video":
if not DAY_RE.match(parts[2]):
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
q = dict(kv.split("=", 1) for kv in self.path.partition("?")[2].split("&") if "=" in kv)
fps = int(q["fps"]) if q.get("fps", "").isdigit() and 1 <= int(q["fps"]) <= 60 else 10
try:
res = make_video(name, parts[2], fps)
except (OSError, ValueError, subprocess.SubprocessError) as ex:
print(f"{name}: Video fehlgeschlagen: {ex}", flush=True)
return self.send_bytes(500, "text/plain", b"Video konnte nicht erstellt werden")
if res is None:
return self.send_bytes(404, "text/plain", b"Keine Bilder")
path, ext = res
try:
self.send_response(200)
self.send_header("Content-Type", "video/mp4" if ext == "mp4" else "video/x-msvideo")
self.send_header("Content-Length", str(os.path.getsize(path)))
self.send_header("Content-Disposition", f'attachment; filename="{name}-{parts[2]}.{ext}"')
self.end_headers()
with open(path, "rb") as f:
shutil.copyfileobj(f, self.wfile)
except (BrokenPipeError, ConnectionResetError):
pass
finally:
os.unlink(path)
return
if name in hubs and len(parts) == 4 and parts[1] == "timelapse":
if not (DAY_RE.match(parts[2]) and FILE_RE.match(parts[3])):
return self.send_bytes(404, "text/plain", b"Nicht gefunden")