← back to Clipreviewbydave
Add 'Load from server' picker: /api/videos list + byte-range /media streaming, no drag-drop needed
1027cfd8a65cbcb2f0b8fdb2336fc39176f679ef · 2026-07-23 15:03:21 -0700 · Steve
Files touched
M README.mdM public/index.htmlA public/server-load.jsM server.js
Diff
commit 1027cfd8a65cbcb2f0b8fdb2336fc39176f679ef
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 23 15:03:21 2026 -0700
Add 'Load from server' picker: /api/videos list + byte-range /media streaming, no drag-drop needed
---
README.md | 14 +++++-
public/index.html | 11 +++--
public/server-load.js | 58 +++++++++++++++++++++++++
server.js | 115 ++++++++++++++++++++++++++++++++++++++++++++------
4 files changed, 182 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 2128a44..500ca1e 100644
--- a/README.md
+++ b/README.md
@@ -24,8 +24,20 @@ Then open http://127.0.0.1:9736/ — add videos, scrub, stamp, export.
> from notes taken by opening the file directly (`file://`). Pick one and stick
> with it for a given review pass.
+## Load from server (no drag needed)
+The server also exposes your own local videos so you can pick them from the
+**From server** dropdown instead of dragging. It lists video files (mp4/mov/m4v/
+webm/mkv/mpg/mpeg) from `~/Videos`, `~/Downloads`, `~/Movies`, `~/Desktop`
+(override with `MEDIA_DIRS=/a:/b`), newest first, with a filter box (type e.g.
+`rentv`). Files stream with HTTP byte-range so scrubbing/seeking works. Still
+100% local — files are read off disk and streamed to the same-origin browser,
+never uploaded. Endpoints: `GET /api/videos`, `GET /media?p=<abs-path>` (path
+allow-listed to the media dirs). This picker is an **additive layer** on top of
+Dave's original tool (`public/server-load.js` + a small toolbar control); the
+drag-drop path is untouched.
+
## Using it
-1. **Add videos** — button or drag-drop. They never leave the machine.
+1. **Add videos** — **From server** dropdown (pick, no drag), the button, or drag-drop. They never leave the machine.
2. **Frame rate** — set to match your footage (24/25/30/60) for accurate frame-stepping.
3. **Stamp** — **IN**/**OUT** mark a keeper segment (pair them); **★** marks a single great frame. Add notes freely.
4. **Transport** — ▶︎/⏸ · −3s/−1s · ◀︎ fr / fr ▶︎ · ½×/1×/2×.
diff --git a/public/index.html b/public/index.html
index 7dc7496..004cad1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -40,6 +40,10 @@
<div class="toolbar">
<button onclick="document.getElementById('file').click()">+ Add videos</button>
<input id="file" type="file" accept="video/*" multiple style="display:none" onchange="addFiles(this.files)">
+ <label>From server
+ <select id="srv"><option value="">— loading… —</option></select>
+ </label>
+ <input id="srvFilter" placeholder="filter (e.g. rentv)" style="display:none">
<label>Frame rate
<select id="fps" onchange="localStorage.setItem('cr_fps', this.value)">
<option value="30">30 fps</option>
@@ -93,10 +97,9 @@
drop.style.display = loaded.size ? 'none' : '';
}
- function buildCard(file) {
- const name = file.name;
+ function buildCard(file) { buildCardFromUrl(file.name, URL.createObjectURL(file)); }
+ function buildCardFromUrl(name, url) {
const id = idFor(name);
- const url = URL.createObjectURL(file);
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
@@ -187,5 +190,7 @@
}
function copyNotes() { navigator.clipboard.writeText(buildExport()).then(()=>{ document.getElementById('saveState').textContent='copied ✓'; }); }
</script>
+<!-- Additive layer (not part of Dave's original): "Load from server" picker. See README / SKILL.md. -->
+<script src="/server-load.js"></script>
</body>
</html>
diff --git a/public/server-load.js b/public/server-load.js
new file mode 100644
index 0000000..73c5692
--- /dev/null
+++ b/public/server-load.js
@@ -0,0 +1,58 @@
+/* ClipReviewbyDave — "Load from server" picker (additive; not part of Dave's original).
+ Populates the #srv dropdown from GET /api/videos and loads a chosen file by URL,
+ reusing the page's global buildCardFromUrl/loaded/order (declared in index.html's
+ inline <script>, so they're in the shared global scope this file runs after). */
+(function () {
+ const sel = document.getElementById('srv');
+ const filter = document.getElementById('srvFilter');
+ if (!sel) return;
+
+ let all = [];
+
+ // Add a server video by URL, mirroring addFiles()'s bookkeeping so notes,
+ // export order, and the drop-zone hide all behave identically to drag-drop.
+ function addServerVideo(name, url) {
+ if (typeof loaded === 'undefined') return; // page not ready
+ if (loaded.has(name)) { const el = document.getElementById('v_' + idFor(name)); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
+ loaded.add(name);
+ order.push(name);
+ buildCardFromUrl(name, url);
+ drop.style.display = loaded.size ? 'none' : '';
+ }
+ window.addServerVideo = addServerVideo;
+
+ function render(list) {
+ sel.innerHTML = '<option value="">— pick a video —</option>';
+ list.forEach((v) => {
+ const o = document.createElement('option');
+ o.value = v.url;
+ o.dataset.name = v.name;
+ o.textContent = `${v.name} · ${v.folder} · ${v.when}`;
+ sel.appendChild(o);
+ });
+ if (!list.length) sel.innerHTML = '<option value="">— none found —</option>';
+ }
+
+ fetch('/api/videos')
+ .then((r) => r.json())
+ .then((list) => {
+ all = list;
+ render(all);
+ if (filter && all.length) filter.style.display = '';
+ })
+ .catch(() => { sel.innerHTML = '<option value="">— server list unavailable —</option>'; });
+
+ sel.addEventListener('change', () => {
+ const o = sel.selectedOptions[0];
+ if (!o || !o.value) return;
+ addServerVideo(o.dataset.name, o.value);
+ sel.value = '';
+ });
+
+ if (filter) {
+ filter.addEventListener('input', () => {
+ const q = filter.value.trim().toLowerCase();
+ render(q ? all.filter((v) => (v.name + ' ' + v.folder).toLowerCase().includes(q)) : all);
+ });
+ }
+})();
diff --git a/server.js b/server.js
index ac2d817..2c0b857 100644
--- a/server.js
+++ b/server.js
@@ -1,38 +1,129 @@
'use strict';
-// ClipReviewbyDave — zero-dependency static server on a fixed port.
-// Serves public/ (index.html = the fully-local clip-review tool by Dave).
-// Everything the tool does is client-side; this server just hands over the HTML.
+// ClipReviewbyDave — zero-dependency static server + local-media picker on a fixed port.
+// Serves public/ (index.html = the clip-review tool by Dave) AND exposes the user's
+// own video files so they can be loaded by URL instead of dragged. Still 100% local:
+// files are read straight off disk and streamed to the same-origin browser; nothing
+// is uploaded anywhere.
const http = require('http');
const fs = require('fs');
+const os = require('os');
const path = require('path');
const PORT = process.env.PORT || 9736;
const ROOT = path.join(__dirname, 'public');
+const HOME = os.homedir();
+
+// Directories whose video files the picker may list + stream. Override with
+// MEDIA_DIRS=/a:/b. Defaults cover where Steve's renders land.
+const MEDIA_DIRS = (process.env.MEDIA_DIRS
+ ? process.env.MEDIA_DIRS.split(':')
+ : ['Videos', 'Downloads', 'Movies', 'Desktop'].map((d) => path.join(HOME, d))
+).map((d) => { try { return fs.realpathSync(d); } catch { return null; } }).filter(Boolean);
+
+const VIDEO_RE = /\.(mp4|mov|m4v|webm|mkv|mpg|mpeg)$/i;
+const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'tmp']);
+const WALK_CAP = 1500; // safety bound on files inspected per request
+
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
- '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
- '.ico': 'image/x-icon', '.json': 'application/json',
+ '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
+ '.mp4': 'video/mp4', '.m4v': 'video/x-m4v', '.mov': 'video/quicktime',
+ '.webm': 'video/webm', '.mkv': 'video/x-matroska', '.mpg': 'video/mpeg', '.mpeg': 'video/mpeg',
};
-const server = http.createServer((req, res) => {
- // health check for fleet keepalive / uptime probes
- if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
+// --- local media discovery ---------------------------------------------------
+function listVideos() {
+ const out = [];
+ let seen = 0;
+ for (const root of MEDIA_DIRS) {
+ const stack = [root];
+ while (stack.length && seen < WALK_CAP) {
+ const dir = stack.pop();
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
+ for (const e of entries) {
+ if (seen >= WALK_CAP) break;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) { if (!SKIP_DIR.has(e.name) && !e.name.startsWith('.')) stack.push(full); continue; }
+ if (!VIDEO_RE.test(e.name)) continue;
+ seen++;
+ let st; try { st = fs.statSync(full); } catch { continue; }
+ out.push({
+ name: e.name,
+ url: '/media?p=' + encodeURIComponent(full),
+ folder: path.basename(dir),
+ size: st.size,
+ mtime: st.mtimeMs,
+ when: new Date(st.mtimeMs).toLocaleString(undefined,
+ { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }),
+ });
+ }
+ }
+ }
+ out.sort((a, b) => b.mtime - a.mtime); // newest first
+ return out;
+}
+
+// --- byte-range media streaming (required for scrubbing large video) ---------
+function isAllowed(abs) {
+ let real; try { real = fs.realpathSync(abs); } catch { return false; }
+ return MEDIA_DIRS.some((root) => real === root || real.startsWith(root + path.sep));
+}
- // resolve request path, default to index.html, and block path traversal
- let rel = decodeURIComponent(req.url.split('?')[0]);
+function serveMedia(req, res, abs) {
+ if (!isAllowed(abs)) { res.writeHead(403); return res.end('forbidden'); }
+ let st; try { st = fs.statSync(abs); } catch { res.writeHead(404); return res.end('not found'); }
+ const type = TYPES[path.extname(abs).toLowerCase()] || 'application/octet-stream';
+ const range = req.headers.range;
+ if (range) {
+ const m = /bytes=(\d*)-(\d*)/.exec(range) || [];
+ let start = m[1] ? parseInt(m[1], 10) : 0;
+ let end = m[2] ? parseInt(m[2], 10) : st.size - 1;
+ if (isNaN(start) || isNaN(end) || start > end || start >= st.size) {
+ res.writeHead(416, { 'Content-Range': `bytes */${st.size}` }); return res.end();
+ }
+ res.writeHead(206, {
+ 'Content-Type': type, 'Accept-Ranges': 'bytes',
+ 'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Content-Length': end - start + 1,
+ });
+ fs.createReadStream(abs, { start, end }).pipe(res);
+ } else {
+ res.writeHead(200, { 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Content-Length': st.size });
+ fs.createReadStream(abs).pipe(res);
+ }
+}
+
+// --- static public/ ----------------------------------------------------------
+function serveStatic(req, res, rel) {
if (rel === '/' || rel === '') rel = '/index.html';
const filePath = path.normalize(path.join(ROOT, rel));
if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end('forbidden'); }
-
fs.readFile(filePath, (err, buf) => {
if (err) { res.writeHead(404); return res.end('not found'); }
- res.writeHead(200, { 'Content-Type': TYPES[path.extname(filePath)] || 'application/octet-stream' });
+ res.writeHead(200, { 'Content-Type': TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream' });
res.end(buf);
});
+}
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
+ if (u.pathname === '/healthz') { res.writeHead(200); return res.end('ok'); }
+ if (u.pathname === '/api/videos') {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(listVideos()));
+ }
+ if (u.pathname === '/media') {
+ const p = u.searchParams.get('p');
+ if (!p) { res.writeHead(400); return res.end('missing p'); }
+ return serveMedia(req, res, path.normalize(p));
+ }
+ serveStatic(req, res, decodeURIComponent(u.pathname));
});
server.listen(PORT, () => {
console.log(`ClipReviewbyDave serving on http://127.0.0.1:${PORT}/`);
+ console.log(` media dirs: ${MEDIA_DIRS.join(', ') || '(none)'}`);
});
← 3b8a2d6 ClipReviewbyDave: fixed-port static server + vendored local
·
back to Clipreviewbydave
·
(newest)