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"/>', 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"/>', 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"/>', 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"/>', 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"/>', 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"/>' 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))); TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v)));
ivSel.value = tiles[name].tl.interval; ivSel.value = tiles[name].tl.interval;
ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value }); 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"; bar.querySelector(".sp").style.flex = "1";
const body = el("div", "body"); const body = el("div", "body");
tl.replaceChildren(bar, body); tl.replaceChildren(bar, body);
@@ -268,6 +274,25 @@ async function openTimelapse(name) {
show(files.length - 1); show(files.length - 1);
}; };
daySel.onchange = loadDay; 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 = () => { play.onclick = () => {
if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; } if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; }
play.querySelector("span").textContent = "Pause"; 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 POST /<name>/setup ruft die in SETUP_<name> hinterlegte Kamera-URL auf
/<name>/timelapse.json Liste der Timelapse-Bilder je Tag /<name>/timelapse.json Liste der Timelapse-Bilder je Tag
/<name>/timelapse/<tag>/<datei> einzelnes Timelapse-Bild /<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) /stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität)
/health Status aller Kameras /health Status aller Kameras
@@ -32,6 +33,9 @@ import json
import os import os
import re import re
import shutil import shutil
import struct
import subprocess
import tempfile
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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) 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): def timelapse_index(name):
"""{"interval": sek, "days": {tag: [dateien]}} aus dem Dateisystem.""" """{"interval": sek, "days": {tag: [dateien]}} aus dem Dateisystem."""
days = {} days = {}
@@ -255,6 +332,32 @@ class Handler(BaseHTTPRequestHandler):
name = parts[0] name = parts[0]
if name in hubs and len(parts) == 2 and parts[1] == "timelapse.json": 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()) 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 name in hubs and len(parts) == 4 and parts[1] == "timelapse":
if not (DAY_RE.match(parts[2]) and FILE_RE.match(parts[3])): if not (DAY_RE.match(parts[2]) and FILE_RE.match(parts[3])):
return self.send_bytes(404, "text/plain", b"Nicht gefunden") return self.send_bytes(404, "text/plain", b"Nicht gefunden")