Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d194d5241 | ||
|
|
43561d5012 | ||
|
|
48cbd56f63 | ||
|
|
c59fee65fd | ||
|
|
9d4f12e5df | ||
|
|
780544d002 | ||
|
|
afdbf474e6 | ||
|
|
3a858c9a55 | ||
|
|
35be3fc2e7 | ||
|
|
abeb245cdc | ||
|
|
1be5e840af | ||
|
|
7f38d7918c | ||
|
|
a5e689af09 | ||
|
|
2bc0c77d81 | ||
|
|
e7a4d0b9b4 | ||
|
|
7e034d07ca | ||
|
|
9cee32e894 | ||
|
|
cedb672633 | ||
|
|
22fef80a5f | ||
|
|
7ee3835db5 | ||
|
|
6772311641 | ||
|
|
8326fd7c03 | ||
|
|
baa344c8cb | ||
|
|
807447e815 | ||
|
|
f00ae278cd | ||
|
|
b488bce210 | ||
|
|
d2d8f3b657 | ||
|
|
eebbc346e1 | ||
|
|
0bfea476da | ||
|
|
dc6856da9a | ||
|
|
eb35a7dc26 | ||
|
|
5a0e83fb4c | ||
|
|
25bdd34c70 | ||
|
|
6c0d06de77 |
@@ -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,46 +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
|
|
||||||
|
|
||||||
An unprivileged LXC works without Docker. Install Python and FFmpeg inside the
|
|
||||||
container, then bind-mount the host folders into the LXC and run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 server.py --host 0.0.0.0 --source /mnt/c41-media --root /mnt/c41-media-mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
The LXC user needs read permission on the source mount and write permission on
|
|
||||||
the encoded mount. Run the player as a system service or with a process
|
|
||||||
supervisor so it restarts after reboot. If the LXC should be reachable from
|
|
||||||
other machines, bind the port explicitly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 server.py --host 0.0.0.0 --source /mnt/c41-media --root /mnt/c41-media-mp4 --port 8765
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open `http://LXC-IP:8765`. No privileged container features are required.
|
|
||||||
|
|
||||||
The default source archive is:
|
The default source archive is:
|
||||||
|
|
||||||
`/home/human/Videos/Yassine Salon/C41/media`
|
`/home/human/Videos/Yassine Salon/C41/media`
|
||||||
@@ -98,6 +60,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:
|
||||||
|
|||||||
+377
-47
@@ -25,26 +25,19 @@
|
|||||||
.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; }
|
||||||
|
|
||||||
/* 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 +55,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; }
|
||||||
@@ -80,9 +87,47 @@
|
|||||||
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
|
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
|
||||||
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
|
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
|
||||||
|
|
||||||
|
/* upload panel */
|
||||||
|
.upload-toggle svg { fill: #8ab4ff; }
|
||||||
|
.upload-body { border-top: 1px solid #1c1c1c; }
|
||||||
|
.upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||||
|
.upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); }
|
||||||
|
.upload-drop b { color: var(--accent); font-weight: 500; }
|
||||||
|
.upload-drop input { display: none; }
|
||||||
|
.upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; }
|
||||||
|
.upload-file-chip.show { display: flex; }
|
||||||
|
.upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; }
|
||||||
|
.upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; }
|
||||||
|
.upload-file-chip .chip-x:hover { color: var(--red); }
|
||||||
|
.upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; }
|
||||||
|
.upload-queue.show { display: flex; }
|
||||||
|
.q-count { color: var(--dim); font-size: 11px; padding: 2px 0; }
|
||||||
|
.q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; }
|
||||||
|
.q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.q-item .q-size { color: var(--dim); white-space: nowrap; }
|
||||||
|
.q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; }
|
||||||
|
.q-item.ok .q-state { color: #7bd97b; }
|
||||||
|
.q-item.err .q-state { color: var(--red); }
|
||||||
|
.q-item.busy .q-state { color: var(--accent); }
|
||||||
|
.q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; }
|
||||||
|
.q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; }
|
||||||
|
.upload-btn { position: relative; overflow: hidden; }
|
||||||
|
.upload-btn.busy { color: transparent; pointer-events: none; }
|
||||||
|
.upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.upload-status { transition: color .2s; }
|
||||||
|
.upload-status.ok { color: #7bd97b; }
|
||||||
|
.upload-status.err { color: var(--red); }
|
||||||
|
|
||||||
@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>
|
||||||
@@ -91,6 +136,8 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>C41 Archive</h1>
|
<h1>C41 Archive</h1>
|
||||||
|
<span class="spacer" style="flex:1"></span>
|
||||||
|
<button id="rescan-btn" title="Rescan the archive folder" style="background:var(--surface);color:var(--text);border:none;padding:6px 10px;border-radius:3px;font-size:14px;cursor:pointer;margin-right:8px">↻</button>
|
||||||
<select id="date"></select>
|
<select id="date"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,17 +158,13 @@
|
|||||||
<video id="video" playsinline></video>
|
<video id="video" playsinline></video>
|
||||||
<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 +172,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,7 +190,36 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button id="export-csv" title="Download all highlights as CSV">Export CSV</button>
|
||||||
|
<button id="export-json" title="Download all highlights as JSON">Export JSON</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">
|
||||||
|
<button class="encode-toggle upload-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
|
||||||
|
<div class="encode-body upload-body" id="upload-body" style="flex-wrap:wrap;gap:14px">
|
||||||
|
<div class="upload-drop" id="upload-drop" title="Click to browse, or drag MP4/AVI files here">
|
||||||
|
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">multiple files allowed; date & time come from each file's metadata</span>
|
||||||
|
<input type="file" id="upload-file" accept="video/mp4,video/x-msvideo,.avi" multiple>
|
||||||
|
</div>
|
||||||
|
<div class="upload-queue" id="upload-queue"></div>
|
||||||
|
<span class="enc-status" style="min-width:110px">Upload date: <b id="upload-date" style="color:var(--text)">-</b></span>
|
||||||
|
<input id="upload-date-override" type="date" title="Override: force all files into this date instead of file mtime" style="background:var(--surface);color:var(--text);border:1px solid #555;border-radius:3px;padding:6px 8px;font-size:12px">
|
||||||
|
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
|
||||||
|
<span class="enc-status upload-status" id="upload-status" style="min-width:200px;text-align:left">Ready</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="encode-panel">
|
<div class="encode-panel">
|
||||||
@@ -166,14 +237,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 +318,100 @@
|
|||||||
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];
|
||||||
|
if (clip) { focus = clip.start; playClip(clip, 0); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = kind === "system" ? systemHighlights : highlights;
|
||||||
|
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});
|
||||||
});
|
});
|
||||||
scrub.addEventListener("mousemove", e => {
|
const gap = gaps.find(item => item.start > now + 1) || gaps[0];
|
||||||
if (!activeClip || !video.duration) return;
|
if (gap) {
|
||||||
const t = (e.offsetX / scrub.clientWidth) * video.duration;
|
video.pause(); activeClip = null; focus = gap.start;
|
||||||
scrubHover.textContent = fmt(t);
|
document.querySelector("#time-cur").textContent = timeOfDay(gap.start);
|
||||||
scrubHover.style.left = e.offsetX + "px";
|
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("#export-csv").onclick = () => location.href = "/api/highlights/export?format=csv";
|
||||||
|
document.querySelector("#export-json").onclick = () => location.href = "/api/highlights/export?format=json";
|
||||||
|
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 +460,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 +497,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;
|
||||||
@@ -354,18 +528,17 @@
|
|||||||
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 +546,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", () => {
|
||||||
@@ -397,8 +567,168 @@
|
|||||||
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
|
else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// upload
|
||||||
|
const uploadToggle = document.querySelector("#upload-toggle");
|
||||||
|
uploadToggle.addEventListener("click", () => {
|
||||||
|
uploadToggle.classList.toggle("open");
|
||||||
|
document.querySelector("#upload-body").classList.toggle("show");
|
||||||
|
document.querySelector("#upload-date").textContent = dateSelect.value || "-";
|
||||||
|
});
|
||||||
|
const uploadFile = document.querySelector("#upload-file");
|
||||||
|
const uploadBtn = document.querySelector("#upload-btn");
|
||||||
|
const uploadDrop = document.querySelector("#upload-drop");
|
||||||
|
const uploadQueue = document.querySelector("#upload-queue");
|
||||||
|
const uploadStatus = document.querySelector("#upload-status");
|
||||||
|
const overrideDate = document.querySelector("#upload-date-override");
|
||||||
|
overrideDate.addEventListener("change", () => {
|
||||||
|
renderQueue();
|
||||||
|
if (overrideDate.value) setStatus(`Override active: all files go to ${overrideDate.value.replaceAll("-", "")}`, "ok");
|
||||||
|
else setStatus("Ready");
|
||||||
|
});
|
||||||
|
let queue = [];
|
||||||
|
function setStatus(msg, cls) {
|
||||||
|
uploadStatus.textContent = msg;
|
||||||
|
uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : "");
|
||||||
|
}
|
||||||
|
function renderQueue() {
|
||||||
|
uploadQueue.replaceChildren();
|
||||||
|
uploadQueue.classList.toggle("show", queue.length > 0);
|
||||||
|
if (!queue.length) return;
|
||||||
|
const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {});
|
||||||
|
const summary = document.createElement("div");
|
||||||
|
summary.className = "q-count";
|
||||||
|
summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`;
|
||||||
|
uploadQueue.append(summary);
|
||||||
|
queue.forEach(entry => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "q-item" + (entry.state ? " " + entry.state : "");
|
||||||
|
const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name;
|
||||||
|
const stamp = fileStamp(entry);
|
||||||
|
const slot = document.createElement("span"); slot.className = "q-size";
|
||||||
|
slot.textContent = stamp.date + " " + (stamp.time ? stamp.time : "mtime time");
|
||||||
|
const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB";
|
||||||
|
const bar = document.createElement("span"); bar.className = "q-bar";
|
||||||
|
const fill = document.createElement("i"); bar.append(fill);
|
||||||
|
entry.bar = fill;
|
||||||
|
const state = document.createElement("span"); state.className = "q-state";
|
||||||
|
state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued";
|
||||||
|
row.append(name, slot, size, bar, state);
|
||||||
|
uploadQueue.append(row);
|
||||||
|
});
|
||||||
|
uploadBtn.disabled = !queue.some(e => e.state === "queued");
|
||||||
|
}
|
||||||
|
function addFiles(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (!/\.(mp4|avi)$/i.test(file.name)) continue;
|
||||||
|
queue.push({file, state: "queued"});
|
||||||
|
}
|
||||||
|
renderQueue();
|
||||||
|
if (queue.length) setStatus(`${queue.length} file${queue.length > 1 ? "s" : ""} queued${overrideDate.value ? " for " + overrideDate.value.replaceAll("-", "") : " (mtimes decide)"}`);
|
||||||
|
}
|
||||||
|
function fileStamp(entry) {
|
||||||
|
if (overrideDate.value) {
|
||||||
|
return {date: overrideDate.value.replaceAll("-", ""), time: null};
|
||||||
|
}
|
||||||
|
const d = new Date(entry.file.lastModified);
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
return {
|
||||||
|
date: `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`,
|
||||||
|
time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function clearDone() {
|
||||||
|
queue = queue.filter(e => e.state === "err");
|
||||||
|
renderQueue();
|
||||||
|
}
|
||||||
|
uploadDrop.addEventListener("click", () => uploadFile.click());
|
||||||
|
uploadFile.addEventListener("change", () => addFiles(uploadFile.files));
|
||||||
|
["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); }));
|
||||||
|
["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); }));
|
||||||
|
uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files));
|
||||||
|
function normName(name) {
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
if (dot <= 0) return null;
|
||||||
|
const base = name.slice(0, dot).padStart(8, "0").slice(-8);
|
||||||
|
const ext = name.slice(dot).toLowerCase();
|
||||||
|
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
|
||||||
|
}
|
||||||
|
async function uploadOne(entry) {
|
||||||
|
const stamp = fileStamp(entry);
|
||||||
|
if (!stamp.date) {
|
||||||
|
entry.state = "err"; entry.msg = "no date";
|
||||||
|
renderQueue();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.state = "busy"; renderQueue();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
let url = `/api/upload?date=${stamp.date}&filename=${encodeURIComponent(entry.file.name)}`;
|
||||||
|
if (stamp.time) url += `&time=${encodeURIComponent(stamp.time)}`;
|
||||||
|
xhr.open("POST", url);
|
||||||
|
xhr.upload.addEventListener("progress", e => {
|
||||||
|
if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`;
|
||||||
|
});
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
let d = {};
|
||||||
|
try { d = JSON.parse(xhr.responseText); } catch {}
|
||||||
|
entry.state = xhr.status < 300 ? "ok" : "err";
|
||||||
|
entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40);
|
||||||
|
if (entry.bar) entry.bar.style.transform = "scaleX(1)";
|
||||||
|
renderQueue();
|
||||||
|
resolve(xhr.status < 300);
|
||||||
|
});
|
||||||
|
xhr.addEventListener("error", () => {
|
||||||
|
entry.state = "err"; entry.msg = "network";
|
||||||
|
renderQueue();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
xhr.send(entry.file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
uploadBtn.addEventListener("click", async () => {
|
||||||
|
const pending = queue.filter(e => e.state === "queued");
|
||||||
|
if (!pending.length) return;
|
||||||
|
if (!fileStamp(pending[0]).date) {
|
||||||
|
setStatus("No upload date: set the override date or file mtimes.", "err");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
let okCount = 0, done = 0;
|
||||||
|
const workers = Array.from({length: Math.min(3, pending.length)}, async () => {
|
||||||
|
while (pending.length) {
|
||||||
|
const entry = pending.shift();
|
||||||
|
if (await uploadOne(entry)) okCount++;
|
||||||
|
done++;
|
||||||
|
setStatus(`${done}/${pending.length + done} sent...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
const errs = queue.filter(e => e.state === "err").length;
|
||||||
|
setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok");
|
||||||
|
const touched = new Set(queue.filter(e => e.state === "ok").map(e => fileStamp(e).date));
|
||||||
|
if (touched.size) {
|
||||||
|
loadDate(dateSelect.value || [...touched][0]);
|
||||||
|
}
|
||||||
|
if (!errs) clearDone();
|
||||||
|
});
|
||||||
|
|
||||||
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
||||||
addEventListener("resize", drawTimeline);
|
addEventListener("resize", drawTimeline);
|
||||||
|
|
||||||
|
// rescan: button + silent auto-poll for new dates
|
||||||
|
const rescanBtn = document.querySelector("#rescan-btn");
|
||||||
|
async function rescan(notify) {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/rescan", {method: "POST"});
|
||||||
|
const d = await r.json();
|
||||||
|
if (notify) setStatus(`${d.days} day${d.days !== 1 ? "s" : ""}, ${d.clips} clip${d.clips !== 1 ? "s" : ""} indexed`);
|
||||||
|
loadDate(dateSelect.value || "");
|
||||||
|
return d;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
rescanBtn.addEventListener("click", () => rescan(true));
|
||||||
|
setInterval(() => { if (!queue.some(e => e.state === "busy")) rescan(false); }, 30000);
|
||||||
|
|
||||||
loadDate("").catch(() => {});
|
loadDate("").catch(() => {});
|
||||||
updateEncodeStatus();
|
updateEncodeStatus();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import struct
|
||||||
import threading
|
import threading
|
||||||
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,17 +100,174 @@ 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/highlights/export":
|
||||||
|
return self.export_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)
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if urlparse(self.path).path == "/api/encode":
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/rescan":
|
||||||
|
self.server.recordings = scan_recordings(self.server.output_root)
|
||||||
|
self.server.system_highlights = scan_system_highlights(self.server.source_root)
|
||||||
|
count = sum(map(len, self.server.recordings.values()))
|
||||||
|
return self.send_json({"ok": True, "days": len(self.server.recordings), "clips": count})
|
||||||
|
if path == "/api/upload":
|
||||||
|
return self.handle_upload()
|
||||||
|
if path == "/api/encode":
|
||||||
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 export_highlights(self):
|
||||||
|
fmt = parse_qs(urlparse(self.path).query).get("format", ["csv"])[0].lower()
|
||||||
|
items = sorted(self.server.highlights, key=lambda h: (h["date"], h["start"], h["id"]))
|
||||||
|
seen = {}
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
# same label may repeat across days; within one day duplicates get -2, -3...
|
||||||
|
key = (item["date"], item["label"])
|
||||||
|
seen[key] = seen.get(key, 0) + 1
|
||||||
|
name = item["label"] if seen[key] == 1 else f"{item['label']} ({seen[key]})"
|
||||||
|
def clock(sec):
|
||||||
|
return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}"
|
||||||
|
rows.append({
|
||||||
|
"date": f"{item['date'][:4]}-{item['date'][4:6]}-{item['date'][6:]}",
|
||||||
|
"name": name,
|
||||||
|
"start": clock(item["start"]),
|
||||||
|
"end": clock(item["end"]),
|
||||||
|
"duration_sec": item["end"] - item["start"],
|
||||||
|
"clip": f"{item['date']}/{item['start'] // 3600:02d}.{item['start'] % 3600 // 60:02d}.{item['start'] % 60:02d}",
|
||||||
|
})
|
||||||
|
if fmt == "json":
|
||||||
|
data = json.dumps(rows, indent=2).encode()
|
||||||
|
ctype = "application/json"
|
||||||
|
else:
|
||||||
|
import io
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = __import__("csv").writer(buf)
|
||||||
|
writer.writerow(["date", "name", "start", "end", "duration_sec", "clip"])
|
||||||
|
writer.writerows((r["date"], r["name"], r["start"], r["end"], r["duration_sec"], r["clip"]) for r in rows)
|
||||||
|
data = buf.getvalue().encode()
|
||||||
|
ctype = "text/csv"
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
|
||||||
|
self.send_header("Content-Disposition", f'attachment; filename="highlights-{stamp}.{fmt}"')
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def handle_upload(self):
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
return self.send_error(400, "Invalid content length")
|
||||||
|
max_bytes = 2 * 1024 * 1024 * 1024
|
||||||
|
if not 0 < length <= max_bytes:
|
||||||
|
return self.send_error(413, "Missing or oversized body (limit 2 GiB)")
|
||||||
|
params = parse_qs(urlparse(self.path).query)
|
||||||
|
filename = params.get("filename", [""])[0]
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9._ -]{1,120}\.(mp4|avi)", filename, re.IGNORECASE):
|
||||||
|
return self.send_error(400, "filename must be a simple MP4/AVI name")
|
||||||
|
suffix = filename.rsplit(".", 1)[1].lower()
|
||||||
|
try:
|
||||||
|
day = normalize_date(params.get("date", [""])[0])
|
||||||
|
except ValueError:
|
||||||
|
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
|
||||||
|
try:
|
||||||
|
start = parse_clock(params.get("time", [""])[0])
|
||||||
|
if not (0 <= start < 86400):
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
return self.send_error(400, "time must be HH:MM[:SS] within one day")
|
||||||
|
|
||||||
|
target_dir = self.server.output_root / day
|
||||||
|
if suffix == "avi":
|
||||||
|
# AVI sources mirror the real recorder: stored in the source tree for the Encoding menu
|
||||||
|
target_dir = self.server.source_root / day
|
||||||
|
# Archive-canonical name from the actual start time; collisions with a
|
||||||
|
# different original name get a numeric suffix.
|
||||||
|
base = f"{start // 3600:02d}.{start % 3600 // 60:02d}.{start % 60:02d}"
|
||||||
|
target = target_dir / f"{base}.{suffix}"
|
||||||
|
serial = 1
|
||||||
|
while target.exists() or temporary_exists(target_dir, target.name):
|
||||||
|
base_alt = f"{base}-{serial:02d}"
|
||||||
|
target = target_dir / f"{base_alt}.{suffix}"
|
||||||
|
serial += 1
|
||||||
|
temporary = target.with_suffix(f".uploading.{suffix}")
|
||||||
|
try:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
received = 0
|
||||||
|
with temporary.open("wb") as sink:
|
||||||
|
while received < length:
|
||||||
|
chunk = self.rfile.read(min(256 * 1024, length - received))
|
||||||
|
if not chunk:
|
||||||
|
raise OSError("client disconnected mid-upload")
|
||||||
|
sink.write(chunk)
|
||||||
|
received += len(chunk)
|
||||||
|
if target.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
return self.send_error(409, "A recording already exists at that time; nothing was overwritten")
|
||||||
|
temporary.replace(target)
|
||||||
|
except OSError:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return self.send_error(500, "Failed to store upload")
|
||||||
|
self.server.recordings.setdefault(day, [])
|
||||||
|
if suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]):
|
||||||
|
self.server.recordings[day].append({
|
||||||
|
"name": target.name,
|
||||||
|
"start": start,
|
||||||
|
"duration": 120,
|
||||||
|
"path": target,
|
||||||
|
})
|
||||||
|
self.server.recordings[day].sort(key=lambda clip: clip["start"])
|
||||||
|
return self.send_json({"ok": True, "file": f"{day}/{target.name}"})
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -191,6 +388,11 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
print(f"{self.client_address[0]} - {message % args}")
|
print(f"{self.client_address[0]} - {message % args}")
|
||||||
|
|
||||||
|
|
||||||
|
def temporary_exists(directory, final_name):
|
||||||
|
stem = final_name.rsplit(".", 1)[0]
|
||||||
|
return any(path.name.startswith(stem + ".uploading.") for path in directory.glob("*.uploading.*")) if directory.is_dir() else False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Play dated recorder AVI files in a browser")
|
parser = argparse.ArgumentParser(description="Play dated recorder AVI files in a browser")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -215,6 +417,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 +470,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