12 Commits
6 changed files with 298 additions and 104 deletions
-4
View File
@@ -1,4 +0,0 @@
.git
__pycache__
server.log
*.pyc
-18
View File
@@ -1,18 +0,0 @@
FROM python:3.13-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server.py index.html README.md ./
ENV C41_SOURCE=/data/source \
C41_OUTPUT=/data/encoded \
C41_HOST=0.0.0.0 \
C41_PORT=8765
EXPOSE 8765
VOLUME ["/data/source", "/data/encoded"]
CMD ["python3", "server.py"]
+28 -20
View File
@@ -8,6 +8,7 @@ dated video folders into a simple daily view that is easy to search and play.
- Shows recordings on a full 24-hour timeline. - Shows recordings on a full 24-hour timeline.
- Uses the time in each filename to place clips at the correct time of day. - Uses the time in each filename to place clips at the correct time of day.
- Shows recorded minutes in light blue and empty periods in dark blue. - Shows recorded minutes in light blue and empty periods in dark blue.
- Shows recorder-detected system events in dark yellow when `fileindex.dat` is present.
- Lets you choose the recording date. - Lets you choose the recording date.
- Plays browser-friendly MP4 files immediately. - Plays browser-friendly MP4 files immediately.
- Converts an AVI clip automatically when no MP4 copy exists. - Converts an AVI clip automatically when no MP4 copy exists.
@@ -21,6 +22,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
@@ -32,26 +34,6 @@ python3 server.py
Open <http://127.0.0.1:8765> in a browser. Open <http://127.0.0.1:8765> in a browser.
## Docker
Docker keeps the application and FFmpeg inside a container while the recorder
files remain on the host. From this directory, run:
```sh
docker compose up -d --build
```
Open <http://127.0.0.1:8765>. The Compose configuration mounts the original
archive read-only at `/data/source` and stores encoded files persistently in
`/data/encoded`. Stop it with:
```sh
docker compose down
```
To use different host folders, edit the two `volumes` entries in
`compose.yaml`. Do not remove `:ro` from the source mount.
## LXC ## LXC
An unprivileged LXC works without Docker. Install Python and FFmpeg inside the An unprivileged LXC works without Docker. Install Python and FFmpeg inside the
@@ -98,6 +80,32 @@ 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 agent can list highlights:
```sh
curl http://127.0.0.1:8765/api/highlights
```
It can append one using normal date and clock values:
```sh
curl -X POST http://127.0.0.1:8765/api/highlights \
-H 'Content-Type: application/json' \
-d '{"date":"2026-08-27","start_time":"09:26:40","end_time":"09:28:40","title":"Person at entrance"}'
```
The API stores the entry without changing any video. `start` and `end` may also
be used as seconds from midnight, and `duration` may be used instead of an end
time. For example, `{"start":"09:26:40","duration":120}` creates a
two-minute highlight. 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:
-12
View File
@@ -1,12 +0,0 @@
services:
c41-player:
build: .
container_name: c41-player
environment:
C41_HOST: 0.0.0.0
ports:
- "8765:8765"
volumes:
- "/home/human/Videos/Yassine Salon/C41/media:/data/source:ro"
- "/home/human/Videos/Yassine Salon/C41/media-mp4:/data/encoded"
restart: unless-stopped
+170 -50
View File
@@ -23,28 +23,25 @@
/* 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; }
/* bottom gradient */ /* bottom gradient */
.controls-bar { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); padding: 30px 12px 10px; } .controls-bar { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); padding: 18px 12px 10px; }
/* scrub bar */
.scrub { position: relative; height: 4px; background: rgba(255,255,255,.2); cursor: pointer; margin-bottom: 8px; border-radius: 2px; transition: height .1s; }
.scrub:hover { height: 6px; }
.scrub-fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--red); border-radius: 2px; pointer-events: none; }
.scrub-hover { position: absolute; top: -28px; background: rgba(0,0,0,.8); color: #fff; font-size: 12px; padding: 3px 7px; border-radius: 3px; pointer-events: none; white-space: nowrap; display: none; }
.scrub:hover .scrub-hover { display: block; }
/* control row */ /* control row */
.ctrl-row { display: flex; align-items: center; gap: 8px; } .ctrl-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.ctrl-row button { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 50%; display: grid; place-items: center; } .ctrl-row button { width: 30px; height: 30px; flex: 0 0 30px; background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 50%; display: grid; place-items: center; }
.ctrl-row button:hover { background: rgba(255,255,255,.15); } .ctrl-row button:hover { background: rgba(255,255,255,.15); }
.ctrl-row svg { width: 22px; height: 22px; fill: currentColor; } .ctrl-row svg { width: 22px; height: 22px; fill: currentColor; }
.time-label { font-size: 13px; color: #ddd; font-variant-numeric: tabular-nums; margin-left: 4px; user-select: none; } .time-label { font-size: 13px; color: #ddd; font-variant-numeric: tabular-nums; margin-left: 4px; user-select: none; white-space: nowrap; }
.spacer { flex: 1; } .spacer { flex: 1; }
.vol-wrap { display: flex; align-items: center; gap: 4px; } .vol-wrap { display: flex; align-items: center; gap: 4px; }
.vol-wrap input { width: 0; opacity: 0; transition: width .2s, opacity .2s; accent-color: #fff; } .vol-wrap input { width: 0; opacity: 0; transition: width .2s, opacity .2s; accent-color: #fff; }
@@ -62,9 +59,23 @@
.hours { display: flex; justify-content: space-between; margin-top: 4px; color: var(--dim); font-size: 10px; user-select: none; } .hours { display: flex; justify-content: space-between; margin-top: 4px; color: var(--dim); font-size: 10px; user-select: none; }
.legend { display: flex; gap: 16px; margin-top: 10px; font-size: 11px; color: var(--dim); } .legend { display: flex; gap: 16px; margin-top: 10px; font-size: 11px; color: var(--dim); }
.legend span { display: flex; align-items: center; gap: 5px; } .legend span { display: flex; align-items: center; gap: 5px; }
.legend span { cursor: pointer; }
.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; }
.legend .highlight::before { background: #ffd43b; }
.legend .system::before { background: #a87900; }
.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; }
@@ -82,7 +93,12 @@
@media (max-width: 600px) { @media (max-width: 600px) {
.page { padding: 8px; } .page { padding: 8px; }
.ctrl-row svg { width: 18px; height: 18px; } .ctrl-row { gap: 3px; }
.ctrl-row button { width: 32px; height: 32px; flex-basis: 32px; }
.ctrl-row svg { width: 20px; height: 20px; }
.time-label { font-size: 11px; }
.ctrl-row .spacer { display: none; }
.vol-wrap input { width: 54px; opacity: 1; }
.hours span:nth-child(even) { display: none; } .hours span:nth-child(even) { display: none; }
} }
</style> </style>
@@ -109,19 +125,16 @@
<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-fill" id="scrub-fill"></div>
<div class="scrub-hover" id="scrub-hover">00:00</div>
</div>
<div class="ctrl-row"> <div class="ctrl-row">
<button id="btn-play" aria-label="Play"> <button id="btn-play" aria-label="Play">
<svg id="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> <svg id="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6zm8-14v14h4V5z"/></svg> <svg id="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6zm8-14v14h4V5z"/></svg>
</button> </button>
<button id="btn-back" aria-label="Back 10s"><svg viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><text x="10" y="16" font-size="7" fill="currentColor" text-anchor="middle" font-family="sans-serif">10</text></svg></button> <button id="btn-back" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24"><path d="M12 5V2L7 7l5 5V8a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"/><text x="12" y="15" font-size="6.5" fill="currentColor" text-anchor="middle" font-family="sans-serif" font-weight="bold">10</text></svg></button>
<button id="btn-fwd" aria-label="Forward 10s"><svg viewBox="0 0 24 24"><path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/><text x="14" y="16" font-size="7" fill="currentColor" text-anchor="middle" font-family="sans-serif">10</text></svg></button> <button id="btn-fwd" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24"><path d="M12 5V2l5 5-5 5V8a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z"/><text x="12" y="15" font-size="6.5" fill="currentColor" text-anchor="middle" font-family="sans-serif" font-weight="bold">10</text></svg></button>
<div class="vol-wrap"> <div class="vol-wrap">
<button id="btn-vol" aria-label="Volume"> <button id="btn-vol" aria-label="Volume">
<svg id="icon-vol-on" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.3v7.4a4.5 4.5 0 0 0 2.5-3.7zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1A9 9 0 0 0 14 3.2z"/></svg> <svg id="icon-vol-on" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.3v7.4a4.5 4.5 0 0 0 2.5-3.7zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1A9 9 0 0 0 14 3.2z"/></svg>
@@ -129,9 +142,8 @@
</button> </button>
<input id="volume" type="range" min="0" max="1" step=".01" value=".8"> <input id="volume" type="range" min="0" max="1" step=".01" value=".8">
</div> </div>
<span class="time-label"><span id="time-cur">0:00</span> / <span id="time-dur">0:00</span></span> <span class="time-label" id="time-cur" style="color:var(--accent);font-weight:500;">--:--:--</span>
<span class="spacer"></span> <span class="spacer"></span>
<span class="time-label" id="clock" style="color:var(--accent);font-weight:500;">--:--:--</span>
</div> </div>
</div> </div>
</div> </div>
@@ -148,9 +160,20 @@
<canvas id="timeline"></canvas> <canvas id="timeline"></canvas>
</div> </div>
<div class="hours" id="hours"></div> <div class="hours" id="hours"></div>
<div class="legend"><span class="rec">Recorded</span><span class="gap">No recording</span></div> <div class="legend"><span class="rec" data-jump="recorded">Recorded</span><span class="highlight" data-jump="highlight">Highlight</span><span class="system" data-jump="system">System highlight</span><span class="gap" data-jump="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">
@@ -166,14 +189,15 @@
const wrap = document.querySelector("#player-wrap"); const wrap = document.querySelector("#player-wrap");
const dateSelect = document.querySelector("#date"); const dateSelect = document.querySelector("#date");
const timeline = document.querySelector("#timeline"); const timeline = document.querySelector("#timeline");
const scrub = document.querySelector("#scrub");
const scrubFill = document.querySelector("#scrub-fill");
const scrubHover = document.querySelector("#scrub-hover");
let clips = []; let clips = [];
let activeClip = null; let activeClip = null;
let initialOffset = 0; let initialOffset = 0;
let zoom = 1; let zoom = 1;
let focus = 43200; let focus = 43200;
let highlights = [];
let systemHighlights = [];
let highlightStart = null;
let highlightEnd = null;
let encodingWasRunning = false; let encodingWasRunning = false;
// encoding // encoding
@@ -246,21 +270,98 @@
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(":");
} }
// scrub bar function archiveTime() {
function updateScrub() { return activeClip ? activeClip.start + initialOffset + (video.currentTime || 0) : focus;
if (!activeClip || !video.duration) { scrubFill.style.width = "0%"; return; }
scrubFill.style.width = (video.currentTime / video.duration * 100) + "%";
} }
scrub.addEventListener("click", e => { function jumpToNext(kind) {
if (!activeClip || !video.duration) return; const now = archiveTime();
video.currentTime = (e.offsetX / scrub.clientWidth) * video.duration; if (kind === "recorded") {
}); const clip = clips.find(item => item.start > now + 1) || clips[0];
scrub.addEventListener("mousemove", e => { if (clip) { focus = clip.start; playClip(clip, 0); }
if (!activeClip || !video.duration) return; return;
const t = (e.offsetX / scrub.clientWidth) * video.duration; }
scrubHover.textContent = fmt(t); const items = kind === "system" ? systemHighlights : highlights;
scrubHover.style.left = e.offsetX + "px"; if (kind === "highlight" || kind === "system") {
}); const item = items.find(entry => entry.start > now + 1) || items[0];
if (!item) return;
const clip = clips.find(entry => item.start >= entry.start && item.start < entry.start + entry.duration)
|| clips.find(entry => entry.start >= item.start);
if (clip) { focus = clip.start; playClip(clip, Math.max(0, item.start - clip.start)); }
return;
}
const gaps = [];
if (clips.length && clips[0].start > 0) gaps.push({start: 0, end: clips[0].start});
clips.forEach((clip, index) => {
const end = clip.start + clip.duration;
const next = clips[index + 1];
if (next && next.start > end) gaps.push({start: end, end: next.start});
});
const gap = gaps.find(item => item.start > now + 1) || gaps[0];
if (gap) {
video.pause(); activeClip = null; focus = gap.start;
document.querySelector("#time-cur").textContent = timeOfDay(gap.start);
drawTimeline();
}
}
document.querySelectorAll(".legend [data-jump]").forEach(item => item.addEventListener("click", () => jumpToNext(item.dataset.jump)));
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");
highlights = (await response.json()).filter(item => item.date === dateSelect.value);
const items = highlights;
drawTimeline();
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);
});
}
async function loadSystemHighlights() {
const response = await fetch(`/api/system-highlights?date=${dateSelect.value}`);
systemHighlights = await response.json();
drawTimeline();
}
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();
};
// seek buttons // seek buttons
function seekBy(sec) { function seekBy(sec) {
@@ -309,6 +410,18 @@
const r = ((i + 1) * 60 - winStart) / winSize * w; const r = ((i + 1) * 60 - winStart) / winSize * w;
if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56); if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56);
}); });
ctx.fillStyle = "#ffd43b";
highlights.forEach(item => {
const l = (item.start - winStart) / winSize * w;
const r = (item.end - winStart) / winSize * w;
if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56);
});
ctx.fillStyle = "#a87900";
systemHighlights.forEach(item => {
const l = (item.start - winStart) / winSize * w;
const r = (item.end - winStart) / winSize * w;
if (r > 0 && l < w) ctx.fillRect(Math.max(0, l), 0, Math.min(w, r) - Math.max(0, l), 56);
});
// hour lines // hour lines
ctx.strokeStyle = "rgba(255,255,255,.08)"; ctx.strokeStyle = "rgba(255,255,255,.08)";
for (let h = Math.ceil(winStart / 3600); h < winEnd / 3600; h++) { for (let h = Math.ceil(winStart / 3600); h < winEnd / 3600; h++) {
@@ -334,9 +447,20 @@
const winStart = Math.max(0, Math.min(86400 - winSize, focus - winSize / 2)); const winStart = Math.max(0, Math.min(86400 - winSize, focus - winSize / 2));
const sec = winStart + e.offsetX / timeline.clientWidth * winSize; const sec = winStart + e.offsetX / timeline.clientWidth * winSize;
focus = sec; focus = sec;
const systemEvent = systemHighlights.find(item => sec >= item.start && sec < item.end);
if (systemEvent) {
const eventClip = clips.find(clip => clip.start >= systemEvent.start);
if (eventClip) return playClip(eventClip, 0);
}
const savedHighlight = highlights.find(item => sec >= item.start && sec < item.end);
if (savedHighlight) {
const highlightClip = clips.find(clip => savedHighlight.start >= clip.start && savedHighlight.start < clip.start + clip.duration)
|| clips.find(clip => clip.start >= savedHighlight.start);
if (highlightClip) return playClip(highlightClip, Math.max(0, savedHighlight.start - highlightClip.start));
}
const clip = clips.find(c => sec >= c.start && sec < c.start + c.duration) const clip = clips.find(c => sec >= c.start && sec < c.start + c.duration)
|| clips.find(c => c.start >= sec) || clips[clips.length - 1]; || clips.find(c => c.start >= sec) || clips[clips.length - 1];
if (clip) playClip(clip, Math.max(0, Math.min(clip.duration - .1, sec - clip.start))); if (clip) playClip(clip, 0);
}); });
timeline.addEventListener("mousemove", e => { timeline.addEventListener("mousemove", e => {
const winSize = 86400 / zoom; const winSize = 86400 / zoom;
@@ -348,24 +472,23 @@
// 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;
dateSelect.innerHTML = d.dates.map(day => `<option value="${day}">${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6)}</option>`).join(""); dateSelect.innerHTML = d.dates.map(day => `<option value="${day}">${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6)}</option>`).join("");
dateSelect.value = date || d.dates[0]; dateSelect.value = date || d.dates[0];
document.querySelector("#clock").textContent = "--:--:--"; document.querySelector("#time-cur").textContent = "--:--:--";
document.querySelector("#time-cur").textContent = "0:00";
document.querySelector("#time-dur").textContent = "0:00";
scrubFill.style.width = "0%";
drawTimeline(); drawTimeline();
loadHighlights();
loadSystemHighlights();
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;
document.querySelector("#clock").textContent = timeOfDay(clip.start + offset); document.querySelector("#time-cur").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(() => {});
drawTimeline(); drawTimeline();
@@ -373,13 +496,10 @@
video.addEventListener("timeupdate", () => { video.addEventListener("timeupdate", () => {
if (!activeClip) return; if (!activeClip) return;
document.querySelector("#clock").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime); document.querySelector("#time-cur").textContent = timeOfDay(activeClip.start + initialOffset + video.currentTime);
document.querySelector("#time-cur").textContent = fmt(video.currentTime);
updateScrub();
drawTimeline(); drawTimeline();
}); });
video.addEventListener("loadedmetadata", () => { video.addEventListener("loadedmetadata", () => {
document.querySelector("#time-dur").textContent = fmt(video.duration);
if (initialOffset > 0 && video.currentTime < initialOffset) video.currentTime = initialOffset; if (initialOffset > 0 && video.currentTime < initialOffset) video.currentTime = initialOffset;
}); });
video.addEventListener("ended", () => { video.addEventListener("ended", () => {
+100
View File
@@ -4,8 +4,10 @@ import concurrent.futures
import json import json
import re import re
import subprocess import subprocess
import struct
import threading import threading
import os import os
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -13,6 +15,7 @@ from urllib.parse import parse_qs, urlparse
DATE_RE = re.compile(r"^\d{8}$") DATE_RE = re.compile(r"^\d{8}$")
VIDEO_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{2})\.(avi|mp4)$", re.IGNORECASE) VIDEO_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{2})\.(avi|mp4)$", re.IGNORECASE)
TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$")
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
@@ -43,6 +46,43 @@ def scan_recordings(root):
return recordings return recordings
def parse_clock(value):
if isinstance(value, (int, float)):
return float(value)
match = TIME_RE.fullmatch(str(value).strip())
if not match:
raise ValueError
hour, minute, second = int(match.group(1)), int(match.group(2)), int(match.group(3) or 0)
if hour > 23 or minute > 59 or second > 59:
raise ValueError
return hour * 3600 + minute * 60 + second
def normalize_date(value):
date = str(value).replace("-", "")
if not DATE_RE.fullmatch(date):
raise ValueError
return date
def scan_system_highlights(root):
try:
data = (root / "fileindex.dat").read_bytes()
except OSError:
return []
highlights = []
for offset in range(0, len(data) - 15, 16):
timestamp, packed, _, _ = struct.unpack_from("<4I", data, offset)
duration = packed & 0xffff
event = (packed >> 16) & 1
if not event or not duration:
continue
moment = datetime.fromtimestamp(timestamp)
start = moment.hour * 3600 + moment.minute * 60 + moment.second
highlights.append({"date": moment.strftime("%Y%m%d"), "start": start, "end": min(86400, start + duration)})
return highlights
class PlayerHandler(BaseHTTPRequestHandler): class PlayerHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
@@ -60,6 +100,11 @@ 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 == "/api/system-highlights":
date = parse_qs(request.query).get("date", [""])[0]
return self.send_json([item for item in self.server.system_highlights if item["date"] == date])
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)
@@ -69,8 +114,45 @@ 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 = normalize_date(item["date"])
start = parse_clock(item.get("start", item.get("start_time")))
end_value = item.get("end", item.get("end_time"))
if end_value is None and item.get("duration") is not None:
end = start + parse_clock(item["duration"])
else:
end = parse_clock(end_value)
if 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", item.get("title", "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)
@@ -215,6 +297,9 @@ 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.system_highlights = scan_system_highlights(server.source_root)
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")
@@ -265,5 +350,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()