diff --git a/README.md b/README.md index 8769fcb..fa22a5a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ dated video folders into a simple daily view that is easy to search and play. - Shows encoding progress, completed files, skipped files, and errors. - Never overwrites an existing transcoded file. - Keeps all original AVI recordings unchanged. +- Saves named highlight periods for later review or automated processing. ## Start The Player @@ -78,6 +79,30 @@ using the player while encoding runs. Files that already have an MP4 copy are reported as skipped. A temporary file is used during each conversion, so an interrupted conversion cannot damage an existing file. +## Highlights And AI Automation + +To create a highlight in the player, play the recording, press **Mark start**, +move to the end of the interesting part, press **Mark end**, enter a name, and +press **Add highlight**. Highlights are saved in `media-mp4/highlights.json`. + +The highlight API is intentionally simple. An AI or script can list highlights: + +```sh +curl http://127.0.0.1:8765/api/highlights +``` + +It can append a highlight with a date, seconds from midnight, and a label: + +```sh +curl -X POST http://127.0.0.1:8765/api/highlights \ + -H 'Content-Type: application/json' \ + -d '{"date":"20260827","start":34000,"end":34120,"label":"Person at entrance"}' +``` + +The API stores the entry without changing any video. Times are seconds from +midnight, so `34000` means `09:26:40`. This makes it straightforward for an +AI to append detections, labels, or review segments programmatically. + ## Custom Folders And Ports To use a different output folder or port: diff --git a/index.html b/index.html index 892698a..fb5235d 100644 --- a/index.html +++ b/index.html @@ -23,9 +23,13 @@ /* player wrapper */ .player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; } .player-wrap video { display: block; width: 100%; aspect-ratio: 16/9; object-fit: contain; background: #000; } + .empty-state { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; color: #222; background: #f1f1f1; pointer-events: none; } + .empty-state svg { width: 52px; height: 52px; fill: #222; } + .empty-state span { font-size: 14px; } + .player-wrap.has-video .empty-state { display: none; } /* big center play */ - .big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(0,0,0,.6); border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; } + .big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(255,255,255,.92); color: #111; border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; box-shadow: 0 2px 12px rgba(0,0,0,.35); } .player-wrap.paused .big-play { opacity: 1; } .big-play svg { width: 28px; height: 28px; } @@ -65,6 +69,17 @@ .legend span::before { content: ""; display: block; width: 14px; height: 6px; border-radius: 2px; } .legend .rec::before { background: var(--accent); } .legend .gap::before { background: #2a2a2a; border: 1px solid #444; } + .highlights { margin-top: 18px; background: var(--surface); border-radius: 8px; padding: 14px 16px; } + .highlights h2 { font-size: 14px; font-weight: 500; margin-bottom: 10px; } + .highlight-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + .highlight-actions button, .highlight-actions input { background: #333; border: 1px solid #555; color: var(--text); padding: 7px 10px; border-radius: 3px; font-size: 12px; } + .highlight-actions button { cursor: pointer; } + .highlight-actions button:hover { background: #444; } + .mark-time { color: var(--accent); font-size: 12px; min-width: 70px; } + .highlight-list { margin-top: 12px; display: grid; gap: 6px; } + .highlight-item { display: flex; align-items: center; gap: 10px; padding: 8px; background: #1a1a1a; border-radius: 4px; font-size: 12px; } + .highlight-item .highlight-label { flex: 1; } + .highlight-item button { background: none; border: none; color: var(--dim); cursor: pointer; } /* encoding panel */ .encode-panel { margin-top: 16px; background: var(--surface); border-radius: 8px; overflow: hidden; } @@ -109,6 +124,7 @@
+
Select a recording from the timeline
@@ -151,6 +167,17 @@
RecordedNo recording
+
+

Highlights

+
+ --:--:-- + --:--:-- + + +
+
No highlights yet.
+
+
@@ -174,6 +201,8 @@ let initialOffset = 0; let zoom = 1; let focus = 43200; + let highlightStart = null; + let highlightEnd = null; let encodingWasRunning = false; // encoding @@ -246,6 +275,61 @@ return [s/3600, s%3600/60, s%60].map(v => String(Math.floor(v)).padStart(2,"0")).join(":"); } + function archiveTime() { + return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : null; + } + function setMark(which) { + const value = archiveTime(); + if (value === null) return; + if (which === "start") { + highlightStart = value; + document.querySelector("#highlight-start").textContent = timeOfDay(value); + } else { + highlightEnd = value; + document.querySelector("#highlight-end").textContent = timeOfDay(value); + } + } + async function loadHighlights() { + const response = await fetch("/api/highlights"); + const items = (await response.json()).filter(item => item.date === dateSelect.value); + const list = document.querySelector("#highlight-list"); + list.replaceChildren(); + if (!items.length) { + list.innerHTML = 'No highlights yet.'; + return; + } + items.forEach(item => { + const row = document.createElement("div"); + row.className = "highlight-item"; + const label = document.createElement("span"); + label.className = "highlight-label"; + label.textContent = `${item.label} (${timeOfDay(item.start)} - ${timeOfDay(item.end)})`; + const play = document.createElement("button"); + play.textContent = "Play"; + play.onclick = () => { + const clip = clips.find(c => item.start >= c.start && item.start < c.start + c.duration); + if (clip) playClip(clip, item.start - clip.start); + }; + const remove = document.createElement("button"); + remove.textContent = "Remove"; + remove.onclick = async () => { await fetch(`/api/highlights?id=${item.id}`, {method: "DELETE"}); loadHighlights(); }; + row.append(label, play, remove); + list.append(row); + }); + } + document.querySelector("#mark-start").onclick = () => setMark("start"); + document.querySelector("#mark-end").onclick = () => setMark("end"); + document.querySelector("#save-highlight").onclick = async () => { + if (highlightStart === null || highlightEnd === null || highlightStart >= highlightEnd) return; + await fetch("/api/highlights", {method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({date: dateSelect.value, start: highlightStart, end: highlightEnd, label: document.querySelector("#highlight-label").value || "Untitled highlight"})}); + highlightStart = highlightEnd = null; + document.querySelector("#highlight-start").textContent = "--:--:--"; + document.querySelector("#highlight-end").textContent = "--:--:--"; + document.querySelector("#highlight-label").value = ""; + loadHighlights(); + }; + // scrub bar function updateScrub() { if (!activeClip || !video.duration) { scrubFill.style.width = "0%"; return; } @@ -348,7 +432,7 @@ // load date async function loadDate(date) { - video.pause(); video.removeAttribute("src"); activeClip = null; setPlaying(false); + video.pause(); video.removeAttribute("src"); activeClip = null; wrap.classList.remove("has-video"); setPlaying(false); const r = await fetch(`/api/recordings?date=${encodeURIComponent(date)}`); const d = await r.json(); clips = d.recordings; @@ -359,12 +443,14 @@ document.querySelector("#time-dur").textContent = "0:00"; scrubFill.style.width = "0%"; drawTimeline(); + loadHighlights(); if (!date && d.dates[0]) loadDate(d.dates[0]); } function playClip(clip, offset = 0) { activeClip = clip; initialOffset = offset; + wrap.classList.add("has-video"); document.querySelector("#clock").textContent = timeOfDay(clip.start + offset); video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`; video.play().catch(() => {}); diff --git a/server.py b/server.py index a35d05c..06023b5 100644 --- a/server.py +++ b/server.py @@ -60,6 +60,8 @@ class PlayerHandler(BaseHTTPRequestHandler): return self.send_recordings(request.query) if request.path == "/api/encode/status": return self.send_json(self.server.encode_status) + if request.path == "/api/highlights": + return self.send_json(self.server.highlights) if request.path == "/video": return self.send_video(request.query) self.send_error(404) @@ -69,8 +71,41 @@ class PlayerHandler(BaseHTTPRequestHandler): if not self.server.encode_status["running"]: threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start() return self.send_json(self.server.encode_status) + if urlparse(self.path).path == "/api/highlights": + try: + length = int(self.headers.get("Content-Length", "0")) + item = json.loads(self.rfile.read(length)) + date = str(item["date"]) + start = float(item["start"]) + end = float(item["end"]) + if not DATE_RE.fullmatch(date) or not (0 <= start < end <= 86400): + raise ValueError + highlight = { + "id": max((entry["id"] for entry in self.server.highlights), default=0) + 1, + "date": date, + "start": start, + "end": end, + "label": str(item.get("label", "Untitled highlight"))[:120], + } + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return self.send_error(400, "Invalid highlight") + self.server.highlights.append(highlight) + save_highlights(self.server.highlights_path, self.server.highlights) + return self.send_json(highlight) self.send_error(404) + def do_DELETE(self): + request = urlparse(self.path) + if request.path != "/api/highlights": + return self.send_error(404) + try: + highlight_id = int(parse_qs(request.query).get("id", [""])[0]) + except ValueError: + return self.send_error(400, "Invalid highlight id") + self.server.highlights[:] = [item for item in self.server.highlights if item["id"] != highlight_id] + save_highlights(self.server.highlights_path, self.server.highlights) + self.send_json({"ok": True}) + def send_json(self, payload): data = json.dumps(payload).encode() self.send_response(200) @@ -215,6 +250,8 @@ def main(): server.recordings = recordings server.source_root = args.source.expanduser().resolve() server.output_root = args.root.expanduser().resolve() + server.highlights_path = server.output_root / "highlights.json" + server.highlights = load_highlights(server.highlights_path) server.encode_status = {"running": False, "total": 0, "completed": 0, "skipped": 0, "failed": 0} count = sum(map(len, recordings.values())) print(f"Indexed {count} recordings across {len(recordings)} days") @@ -265,5 +302,20 @@ def run_encoding(server): status["running"] = False +def load_highlights(path): + try: + data = json.loads(path.read_text()) + return data if isinstance(data, list) else [] + except (FileNotFoundError, json.JSONDecodeError, OSError): + return [] + + +def save_highlights(path, highlights): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(highlights, indent=2) + "\n") + temporary.replace(path) + + if __name__ == "__main__": main()