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

+26 -1
View File
@@ -124,6 +124,7 @@ const ICONS = {
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>',
full: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>',
down: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
rec: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
film: '<rect x="2" y="3" width="20" height="18" rx="2"/><path d="M7 3v18M17 3v18M2 8h5M2 16h5M17 8h5M17 16h5"/>',
off: '<path d="M1 1l22 22M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56"/>'
@@ -218,7 +219,12 @@ async function openTimelapse(name) {
TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v)));
ivSel.value = tiles[name].tl.interval;
ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value });
bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, el("span", "sp"), close);
const fpsSel = el("select");
fpsSel.title = "Bilder pro Sekunde im Video";
[5, 10, 25].forEach(v => fpsSel.append(new Option(v + " fps", v)));
fpsSel.value = 10;
const video = btn("Video", "down", () => {}, "alt");
bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, fpsSel, video, el("span", "sp"), close);
bar.querySelector(".sp").style.flex = "1";
const body = el("div", "body");
tl.replaceChildren(bar, body);
@@ -268,6 +274,25 @@ async function openTimelapse(name) {
show(files.length - 1);
};
daySel.onchange = loadDay;
video.onclick = async () => {
const label = video.querySelector("span");
video.disabled = true;
label.textContent = "erstelle …";
try {
const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value);
if (!r.ok) throw new Error(r.status);
const a = el("a");
a.href = URL.createObjectURL(await r.blob());
a.download = (r.headers.get("Content-Disposition") || "").match(/filename="(.+)"/)?.[1] || name + "-" + daySel.value + ".mp4";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
label.textContent = "Video";
} catch (e) {
label.textContent = "Fehler ✗";
setTimeout(() => { label.textContent = "Video"; }, 2500);
}
video.disabled = false;
};
play.onclick = () => {
if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; }
play.querySelector("span").textContent = "Pause";
+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")