← back to Clipreviewbydave
public/index.html
197 lines
<!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>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>
<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) { buildCardFromUrl(file.name, URL.createObjectURL(file)); }
function buildCardFromUrl(name, url) {
const id = idFor(name);
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>
<!-- Additive layer (not part of Dave's original): "Load from server" picker. See README / SKILL.md. -->
<script src="/server-load.js"></script>
</body>
</html>