← back to Clipreviewbydave
ClipReviewbyDave: fixed-port static server + vendored local clip-review tool
3b8a2d6644cfab1048b894849b350757a52a91cc · 2026-07-23 14:45:54 -0700 · Steve
Files touched
A .gitignoreA README.mdA package.jsonA public/index.htmlA server.js
Diff
commit 3b8a2d6644cfab1048b894849b350757a52a91cc
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 23 14:45:54 2026 -0700
ClipReviewbyDave: fixed-port static server + vendored local clip-review tool
---
.gitignore | 8 +++
README.md | 54 +++++++++++++++
package.json | 11 ++++
public/index.html | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 38 +++++++++++
5 files changed, 302 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2128a44
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+# ClipReviewbyDave
+
+A fully-local, browser-based video clip-review tool (authored by Dave). Load your
+own video files, scrub each one frame-by-frame, stamp **IN / OUT / ★** points and
+plain-language notes, then export a markdown notes file. **Nothing uploads** —
+clips stay on your computer (blob URLs + `localStorage`).
+
+Sourced from `http://lebron.tailb77ba2.ts.net:3333/review-docs/clip-review/` and
+vendored here so it works offline and forever.
+
+## Run
+
+```sh
+cd ~/Projects/clipreviewbydave
+npm start # serves http://127.0.0.1:9736/ (override with PORT=xxxx)
+```
+
+Then open http://127.0.0.1:9736/ — add videos, scrub, stamp, export.
+
+`GET /healthz` returns `200 ok` for keepalive / uptime probes.
+
+> **Origin note:** notes autosave to `localStorage`, which is **per-origin**.
+> Notes taken under this `http://127.0.0.1:9736` server are a separate bucket
+> from notes taken by opening the file directly (`file://`). Pick one and stick
+> with it for a given review pass.
+
+## Using it
+1. **Add videos** — 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×.
+5. **Keyboard** (after clicking a video's buttons): `space` play/pause · `←`/`→` ±1s (`shift`=±3s) · `,`/`.` frame · `i`/`o`/`s` = IN/OUT/★.
+6. **Export notes** / **Copy notes** when done.
+
+## Notes format
+The exported `clip-notes.md` groups per file with machine-parseable stamps:
+
+```
+## take_03.mp4
+[IN 4.20s] [OUT 9.85s] use this take, tighten the head
+[★ 12.40s] thumbnail frame
+```
+
+Each `[IN t]…[OUT t]` pair is a cut range; each `[★ t]` is a still to grab, e.g.:
+
+```sh
+ffmpeg -i take_03.mp4 -ss 4.20 -to 9.85 -c copy clip_01.mp4
+```
+
+## Related
+Also wired as the Claude Code skill `clipreviewbydave` (say "ClipReviewbyDave"
+or "review these clips"), which can launch this and read the notes back into a
+cut plan. This project is the canonical copy; the skill carries a portable
+`file://` fallback of the same HTML.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0032f98
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "clipreviewbydave",
+ "version": "1.0.0",
+ "private": true,
+ "description": "ClipReviewbyDave — fully-local browser video clip-review tool (by Dave), served on a fixed port.",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "author": "steve@designerwallcoverings.com"
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..7dc7496
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,191 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Clip Review — mark in/out points & notes</title>
+<style>
+ :root { --bg:#0d1017; --card:#171b26; --accent:#F59E0B; --deep:#B45309; --text:#F3F4F6; --dim:#9CA3AF; --line:#262c3a; }
+ * { box-sizing: border-box; }
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, Helvetica, sans-serif; margin: 0; padding: 24px; }
+ h1 { font-size: 22px; margin: 0 0 4px; }
+ .sub { color: var(--dim); margin-bottom: 18px; font-size: 14px; line-height: 1.5; }
+ .toolbar { position: sticky; top: 0; background: var(--bg); padding: 12px 0; z-index: 10; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
+ .toolbar label { color: var(--dim); font-size: 12px; display: inline-flex; align-items: center; gap: 6px; }
+ select { background: var(--card); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; font-size: 13px; }
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px; }
+ .card { background: var(--card); border-radius: 14px; padding: 14px; }
+ .card h3 { margin: 0 0 8px; font-size: 15px; display: flex; justify-content: space-between; gap: 10px; word-break: break-all; }
+ .card h3 .dur { color: var(--dim); font-weight: normal; white-space: nowrap; }
+ video { width: 100%; border-radius: 10px; background: #000; }
+ .row { display: flex; gap: 6px; margin: 8px 0; flex-wrap: wrap; align-items: center; }
+ button { background: var(--accent); color: #0d1017; border: 0; border-radius: 8px; padding: 7px 12px; font-size: 13px; font-weight: 700; cursor: pointer; }
+ button:hover { filter: brightness(1.12); }
+ button.alt { background: var(--line); color: var(--text); }
+ button.gold { background: var(--deep); color: #fff; }
+ .time { font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 700; min-width: 64px; }
+ textarea { width: 100%; min-height: 84px; background: #0a0d14; color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px; font-size: 13px; font-family: Menlo, Consolas, monospace; resize: vertical; }
+ .hint { color: var(--dim); font-size: 12px; margin-top: 18px; line-height: 1.6; }
+ .hint b { color: var(--text); }
+ #drop { border: 2px dashed var(--line); border-radius: 14px; padding: 44px 20px; text-align: center; color: var(--dim); margin-bottom: 20px; transition: border-color .15s, background .15s; }
+ #drop.hot { border-color: var(--accent); background: rgba(245,158,11,0.06); color: var(--text); }
+ #drop strong { color: var(--text); }
+ #saveState { color: var(--dim); font-size: 12px; }
+</style>
+</head>
+<body>
+<h1>🎬 Clip Review</h1>
+<div class="sub">Load your videos, scrub each one, and stamp the moments worth keeping. Everything stays on your computer — nothing is uploaded anywhere. Export your notes when you're done.</div>
+
+<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>Frame rate
+ <select id="fps" onchange="localStorage.setItem('cr_fps', this.value)">
+ <option value="30">30 fps</option>
+ <option value="24">24 fps</option>
+ <option value="25">25 fps</option>
+ <option value="60">60 fps</option>
+ </select>
+ </label>
+ <button class="alt" onclick="exportNotes()">⬇ Export notes</button>
+ <button class="alt" onclick="copyNotes()">Copy notes</button>
+ <span id="saveState">notes autosave as you type</span>
+</div>
+
+<div id="drop">
+ <strong>Drag video files here</strong>, or use “+ Add videos”.<br>
+ Your clips never leave this computer.
+</div>
+
+<div class="grid" id="grid"></div>
+
+<div class="hint">
+ <b>IN</b> / <b>OUT</b> — stamp the current time as the start/end of a clip you want to keep (pair them up). <b>★</b> — mark a great single frame (a screenshot moment). Add plain-language notes too (“skip this bit”, “speed up”, “use this take”).<br>
+ Transport: <b>▶︎/⏸</b> play-pause · <b>−3s / −1s</b> jump back · <b>◀︎ fr / fr ▶︎</b> step one frame (set the frame rate above to match your footage) · <b>½× / 1× / 2×</b> speed.<br>
+ Keyboard (after clicking any video's buttons): <b>space</b> play/pause · <b>←/→</b> ±1s (<b>shift</b> = ±3s) · <b>,</b> / <b>.</b> frame back/forward · <b>i</b>=IN · <b>o</b>=OUT · <b>s</b>=★.<br>
+ Notes are saved in this browser and keyed to each file name — reload the page or re-add the same files and your notes come back.
+</div>
+
+<script>
+ const grid = document.getElementById('grid');
+ const drop = document.getElementById('drop');
+ let activeId = null;
+ const loaded = new Set(); // filenames already on screen
+ const order = []; // preserve add order for export
+
+ // restore saved frame rate
+ const savedFps = localStorage.getItem('cr_fps');
+ if (savedFps) document.getElementById('fps').value = savedFps;
+ function fps() { return parseFloat(document.getElementById('fps').value) || 30; }
+
+ function keyFor(name) { return 'crnotes_' + name; }
+ function idFor(name) { return 'c_' + name.replace(/[^a-z0-9]/gi, '_'); }
+
+ function addFiles(fileList) {
+ [...fileList].forEach(f => {
+ if (!f.type.startsWith('video/') && !/\.(mp4|mov|m4v|webm|mkv)$/i.test(f.name)) return;
+ if (loaded.has(f.name)) return;
+ loaded.add(f.name);
+ order.push(f.name);
+ buildCard(f);
+ });
+ drop.style.display = loaded.size ? 'none' : '';
+ }
+
+ function buildCard(file) {
+ const name = file.name;
+ const id = idFor(name);
+ const url = URL.createObjectURL(file);
+ const card = document.createElement('div');
+ card.className = 'card';
+ card.innerHTML = `
+ <h3><span>${name}</span> <span class="dur" id="dur_${id}"></span></h3>
+ <video id="v_${id}" src="${url}" controls preload="metadata" playsinline></video>
+ <div class="row">
+ <span class="time" id="t_${id}">0.00s</span>
+ <button onclick="stamp('${id}','IN')">IN</button>
+ <button onclick="stamp('${id}','OUT')">OUT</button>
+ <button class="gold" onclick="stamp('${id}','★')">★</button>
+ </div>
+ <div class="row">
+ <button class="alt" id="pp_${id}" onclick="playPause('${id}')">▶︎</button>
+ <button class="alt" onclick="nudge('${id}',-3)">−3s</button>
+ <button class="alt" onclick="nudge('${id}',-1)">−1s</button>
+ <button class="alt" onclick="frameStep('${id}',-1)">◀︎ fr</button>
+ <button class="alt" onclick="frameStep('${id}',1)">fr ▶︎</button>
+ <button class="alt" onclick="rate('${id}',0.5)">½×</button>
+ <button class="alt" onclick="rate('${id}',1)">1×</button>
+ <button class="alt" onclick="rate('${id}',2)">2×</button>
+ </div>
+ <textarea id="n_${id}" data-name="${name}" placeholder="notes for this clip…"></textarea>`;
+ grid.appendChild(card);
+
+ const v = document.getElementById('v_'+id);
+ v.addEventListener('timeupdate', () => document.getElementById('t_'+id).textContent = v.currentTime.toFixed(2)+'s');
+ v.addEventListener('seeked', () => document.getElementById('t_'+id).textContent = v.currentTime.toFixed(2)+'s');
+ v.addEventListener('play', () => { activeId = id; document.getElementById('pp_'+id).textContent = '⏸'; });
+ v.addEventListener('pause', () => document.getElementById('pp_'+id).textContent = '▶︎');
+ v.addEventListener('loadedmetadata', () => document.getElementById('dur_'+id).textContent = fmt(v.duration));
+
+ const n = document.getElementById('n_'+id);
+ n.value = localStorage.getItem(keyFor(name)) || '';
+ n.addEventListener('input', () => localStorage.setItem(keyFor(name), n.value));
+ n.addEventListener('focus', () => { activeId = id; });
+ }
+
+ function fmt(s) { s = Math.round(s); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
+ const vid = id => document.getElementById('v_'+id);
+
+ function stamp(id, tag) {
+ activeId = id;
+ const v = vid(id), n = document.getElementById('n_'+id);
+ n.value += (n.value && !n.value.endsWith('\n') ? '\n' : '') + `[${tag} ${v.currentTime.toFixed(2)}s] `;
+ n.focus();
+ localStorage.setItem(keyFor(n.dataset.name), n.value);
+ }
+ function rate(id, r) { activeId = id; vid(id).playbackRate = r; }
+ function nudge(id, secs) { activeId = id; const v = vid(id); v.currentTime = Math.max(0, Math.min(v.duration || 1e9, v.currentTime + secs)); }
+ function playPause(id) { activeId = id; const v = vid(id); if (v.paused) v.play(); else v.pause(); }
+ function frameStep(id, dir) { activeId = id; const v = vid(id); v.pause(); v.currentTime = Math.max(0, Math.min(v.duration || 1e9, v.currentTime + dir*(1/fps()))); }
+
+ document.addEventListener('keydown', (e) => {
+ if (!activeId) return;
+ if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
+ const map = {
+ ' ': () => playPause(activeId),
+ 'ArrowLeft': () => nudge(activeId, e.shiftKey ? -3 : -1),
+ 'ArrowRight': () => nudge(activeId, e.shiftKey ? 3 : 1),
+ ',': () => frameStep(activeId, -1),
+ '.': () => frameStep(activeId, 1),
+ 'i': () => stamp(activeId, 'IN'),
+ 'o': () => stamp(activeId, 'OUT'),
+ 's': () => stamp(activeId, '★'),
+ };
+ if (map[e.key]) { e.preventDefault(); map[e.key](); }
+ });
+
+ // drag & drop anywhere
+ ['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('hot'); }));
+ ['dragleave','drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); if (ev !== 'drop') drop.classList.remove('hot'); }));
+ document.addEventListener('drop', e => { drop.classList.remove('hot'); if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); });
+
+ function buildExport() {
+ let out = '# Clip notes — ' + new Date().toLocaleString() + '\n\n';
+ order.forEach(name => {
+ const txt = (localStorage.getItem(keyFor(name)) || '').trim();
+ if (txt) out += `## ${name}\n${txt}\n\n`;
+ });
+ return out;
+ }
+ function exportNotes() {
+ const blob = new Blob([buildExport()], {type:'text/markdown'});
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = 'clip-notes.md';
+ a.click();
+ }
+ function copyNotes() { navigator.clipboard.writeText(buildExport()).then(()=>{ document.getElementById('saveState').textContent='copied ✓'; }); }
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..ac2d817
--- /dev/null
+++ b/server.js
@@ -0,0 +1,38 @@
+'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.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 9736;
+const ROOT = path.join(__dirname, 'public');
+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',
+};
+
+const server = http.createServer((req, res) => {
+ // health check for fleet keepalive / uptime probes
+ if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
+
+ // resolve request path, default to index.html, and block path traversal
+ let rel = decodeURIComponent(req.url.split('?')[0]);
+ 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.end(buf);
+ });
+});
+
+server.listen(PORT, () => {
+ console.log(`ClipReviewbyDave serving on http://127.0.0.1:${PORT}/`);
+});
(oldest)
·
back to Clipreviewbydave
·
Add 'Load from server' picker: /api/videos list + byte-range 1027cfd →