Add highlight marking and AI API
This commit is contained in:
@@ -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.
|
- Shows encoding progress, completed files, skipped files, and errors.
|
||||||
- Never overwrites an existing transcoded file.
|
- Never overwrites an existing transcoded file.
|
||||||
- Keeps all original AVI recordings unchanged.
|
- Keeps all original AVI recordings unchanged.
|
||||||
|
- Saves named highlight periods for later review or automated processing.
|
||||||
|
|
||||||
## Start The Player
|
## Start The Player
|
||||||
|
|
||||||
@@ -58,6 +59,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
|
reported as skipped. A temporary file is used during each conversion, so an
|
||||||
interrupted conversion cannot damage an existing file.
|
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
|
## Custom Folders And Ports
|
||||||
|
|
||||||
To use a different output folder or port:
|
To use a different output folder or port:
|
||||||
|
|||||||
+88
-2
@@ -23,9 +23,13 @@
|
|||||||
/* player wrapper */
|
/* player wrapper */
|
||||||
.player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; }
|
.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; }
|
.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 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; }
|
.player-wrap.paused .big-play { opacity: 1; }
|
||||||
.big-play svg { width: 28px; height: 28px; }
|
.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 span::before { content: ""; display: block; width: 14px; height: 6px; border-radius: 2px; }
|
||||||
.legend .rec::before { background: var(--accent); }
|
.legend .rec::before { background: var(--accent); }
|
||||||
.legend .gap::before { background: #2a2a2a; border: 1px solid #444; }
|
.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 */
|
/* encoding panel */
|
||||||
.encode-panel { margin-top: 16px; background: var(--surface); border-radius: 8px; overflow: hidden; }
|
.encode-panel { margin-top: 16px; background: var(--surface); border-radius: 8px; overflow: hidden; }
|
||||||
@@ -109,6 +124,7 @@
|
|||||||
|
|
||||||
<div class="player-wrap paused" id="player-wrap">
|
<div class="player-wrap paused" id="player-wrap">
|
||||||
<video id="video" playsinline></video>
|
<video id="video" playsinline></video>
|
||||||
|
<div class="empty-state" id="empty-state"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h10a2 2 0 0 1 2 2v2.2l3-1.7v9l-3-1.7V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v10h10V7H4zm12 4.5v2.1l1-.6v-1z"/></svg><span>Select a recording from the timeline</span></div>
|
||||||
<div class="big-play"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
|
<div class="big-play"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>
|
||||||
<div class="controls-bar">
|
<div class="controls-bar">
|
||||||
<div class="scrub" id="scrub">
|
<div class="scrub" id="scrub">
|
||||||
@@ -151,6 +167,17 @@
|
|||||||
<div class="legend"><span class="rec">Recorded</span><span class="gap">No recording</span></div>
|
<div class="legend"><span class="rec">Recorded</span><span class="gap">No recording</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="highlights">
|
||||||
|
<h2>Highlights</h2>
|
||||||
|
<div class="highlight-actions">
|
||||||
|
<button id="mark-start">Mark start</button><span class="mark-time" id="highlight-start">--:--:--</span>
|
||||||
|
<button id="mark-end">Mark end</button><span class="mark-time" id="highlight-end">--:--:--</span>
|
||||||
|
<input id="highlight-label" placeholder="Highlight name" maxlength="120">
|
||||||
|
<button id="save-highlight">Add highlight</button>
|
||||||
|
</div>
|
||||||
|
<div class="highlight-list" id="highlight-list"><span style="color:var(--dim);font-size:12px">No highlights yet.</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="encode-panel">
|
<div class="encode-panel">
|
||||||
<button class="encode-toggle" id="encode-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Encoding</button>
|
<button class="encode-toggle" id="encode-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Encoding</button>
|
||||||
<div class="encode-body" id="encode-body">
|
<div class="encode-body" id="encode-body">
|
||||||
@@ -174,6 +201,8 @@
|
|||||||
let initialOffset = 0;
|
let initialOffset = 0;
|
||||||
let zoom = 1;
|
let zoom = 1;
|
||||||
let focus = 43200;
|
let focus = 43200;
|
||||||
|
let highlightStart = null;
|
||||||
|
let highlightEnd = null;
|
||||||
let encodingWasRunning = false;
|
let encodingWasRunning = false;
|
||||||
|
|
||||||
// encoding
|
// encoding
|
||||||
@@ -246,6 +275,61 @@
|
|||||||
return [s/3600, s%3600/60, s%60].map(v => String(Math.floor(v)).padStart(2,"0")).join(":");
|
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 = '<span style="color:var(--dim);font-size:12px">No highlights yet.</span>';
|
||||||
|
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
|
// scrub bar
|
||||||
function updateScrub() {
|
function updateScrub() {
|
||||||
if (!activeClip || !video.duration) { scrubFill.style.width = "0%"; return; }
|
if (!activeClip || !video.duration) { scrubFill.style.width = "0%"; return; }
|
||||||
@@ -348,7 +432,7 @@
|
|||||||
|
|
||||||
// load date
|
// load date
|
||||||
async function loadDate(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 r = await fetch(`/api/recordings?date=${encodeURIComponent(date)}`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
clips = d.recordings;
|
clips = d.recordings;
|
||||||
@@ -359,12 +443,14 @@
|
|||||||
document.querySelector("#time-dur").textContent = "0:00";
|
document.querySelector("#time-dur").textContent = "0:00";
|
||||||
scrubFill.style.width = "0%";
|
scrubFill.style.width = "0%";
|
||||||
drawTimeline();
|
drawTimeline();
|
||||||
|
loadHighlights();
|
||||||
if (!date && d.dates[0]) loadDate(d.dates[0]);
|
if (!date && d.dates[0]) loadDate(d.dates[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playClip(clip, offset = 0) {
|
function playClip(clip, offset = 0) {
|
||||||
activeClip = clip;
|
activeClip = clip;
|
||||||
initialOffset = offset;
|
initialOffset = offset;
|
||||||
|
wrap.classList.add("has-video");
|
||||||
document.querySelector("#clock").textContent = timeOfDay(clip.start + offset);
|
document.querySelector("#clock").textContent = timeOfDay(clip.start + offset);
|
||||||
video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`;
|
video.src = `/video?date=${dateSelect.value}&name=${encodeURIComponent(clip.name)}&offset=${offset.toFixed(3)}`;
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
return self.send_recordings(request.query)
|
return self.send_recordings(request.query)
|
||||||
if request.path == "/api/encode/status":
|
if request.path == "/api/encode/status":
|
||||||
return self.send_json(self.server.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":
|
if request.path == "/video":
|
||||||
return self.send_video(request.query)
|
return self.send_video(request.query)
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
@@ -68,8 +70,41 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
if not self.server.encode_status["running"]:
|
if not self.server.encode_status["running"]:
|
||||||
threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start()
|
threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start()
|
||||||
return self.send_json(self.server.encode_status)
|
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)
|
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):
|
def send_json(self, payload):
|
||||||
data = json.dumps(payload).encode()
|
data = json.dumps(payload).encode()
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
@@ -213,6 +248,8 @@ def main():
|
|||||||
server.recordings = recordings
|
server.recordings = recordings
|
||||||
server.source_root = args.source.expanduser().resolve()
|
server.source_root = args.source.expanduser().resolve()
|
||||||
server.output_root = args.root.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}
|
server.encode_status = {"running": False, "total": 0, "completed": 0, "skipped": 0, "failed": 0}
|
||||||
count = sum(map(len, recordings.values()))
|
count = sum(map(len, recordings.values()))
|
||||||
print(f"Indexed {count} recordings across {len(recordings)} days")
|
print(f"Indexed {count} recordings across {len(recordings)} days")
|
||||||
@@ -263,5 +300,20 @@ def run_encoding(server):
|
|||||||
status["running"] = False
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user