Timelapse: Tagesvideo-Download (ffmpeg/MP4, sonst MJPEG-AVI in reinem Python)
This commit is contained in:
1 parent
304c202f46
commit
13e86bb818
2 files changed
+129
-1
No files matched your search
+26
-1
@@ -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";
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user