diff --git a/frontend.html b/frontend.html
index ccbabc2..140ec84 100644
--- a/frontend.html
+++ b/frontend.html
@@ -124,6 +124,7 @@ const ICONS = {
stop: '',
reload: '',
full: '',
+ down: '',
rec: '',
film: '',
off: ''
@@ -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";
diff --git a/relay.py b/relay.py
index 81fa8b0..9316b45 100644
--- a/relay.py
+++ b/relay.py
@@ -12,6 +12,7 @@ Endpunkte:
POST //setup ruft die in SETUP_ hinterlegte Kamera-URL auf
//timelapse.json Liste der Timelapse-Bilder je Tag
//timelapse// einzelnes Timelapse-Bild
+ //timelapse//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("