[object Object]

← 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

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)