231 lines
11 KiB
HTML
231 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Lahr Clip Browser</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { background: #0a0a0b; color: #eee; font-family: Inter, sans-serif; padding: 24px; }
|
|
h1 { font-size: 20px; color: #e8291b; margin-bottom: 6px; }
|
|
p.sub { color: #888; font-size: 13px; margin-bottom: 24px; }
|
|
.layout { display: grid; grid-template-columns: 1fr 340px; gap: 24px; }
|
|
#clip-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.clip-item {
|
|
display: flex; align-items: center; gap: 12px;
|
|
background: #161618; border: 1px solid #2a2a2e; border-radius: 8px;
|
|
padding: 10px 12px; cursor: grab; user-select: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.clip-item:hover { border-color: #e8291b; }
|
|
.clip-item.dragging { opacity: 0.4; }
|
|
.clip-item.drag-over { border-color: #e8291b; background: #1f1012; }
|
|
.drag-handle { color: #555; font-size: 18px; flex-shrink: 0; cursor: grab; }
|
|
.clip-thumb { width: 120px; height: 68px; object-fit: cover; border-radius: 4px; flex-shrink: 0; background: #222; }
|
|
.clip-info { flex: 1; min-width: 0; }
|
|
.clip-name { font-size: 13px; font-weight: 600; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.clip-size { font-size: 11px; color: #666; margin-top: 2px; }
|
|
.clip-actions { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
|
|
.btn-preview { background: #1e1e22; border: 1px solid #333; color: #ccc; padding: 5px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; }
|
|
.btn-preview:hover { border-color: #e8291b; color: #e8291b; }
|
|
.btn-remove { background: transparent; border: 1px solid #333; color: #555; padding: 5px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; }
|
|
.btn-remove:hover { border-color: #e8291b; color: #e8291b; }
|
|
|
|
.sidebar { position: sticky; top: 24px; }
|
|
.preview-box { background: #161618; border: 1px solid #2a2a2e; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
|
.preview-box h3 { font-size: 13px; color: #888; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
#preview-video { width: 100%; border-radius: 6px; background: #000; }
|
|
#preview-name { font-size: 12px; color: #666; margin-top: 8px; text-align: center; }
|
|
|
|
.reel-box { background: #161618; border: 1px solid #2a2a2e; border-radius: 8px; padding: 16px; }
|
|
.reel-box h3 { font-size: 13px; color: #888; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
#reel-order { font-size: 11px; color: #666; line-height: 1.8; margin-bottom: 14px; max-height: 220px; overflow-y: auto; }
|
|
.btn-build { width: 100%; background: #e8291b; color: #fff; border: none; padding: 12px; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
|
.btn-build:hover { background: #c72216; }
|
|
#build-output { margin-top: 12px; font-size: 11px; color: #888; background: #0d0d0f; border-radius: 4px; padding: 10px; display: none; white-space: pre-wrap; }
|
|
|
|
.unused-section { margin-top: 32px; }
|
|
.unused-section h2 { font-size: 14px; color: #555; margin-bottom: 12px; }
|
|
#unused-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.unused-item { display: flex; align-items: center; gap: 12px; background: #111; border: 1px solid #1e1e22; border-radius: 8px; padding: 8px 12px; }
|
|
.unused-item .clip-name { font-size: 12px; color: #666; }
|
|
.btn-add { background: #1e1e22; border: 1px solid #333; color: #888; padding: 5px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; margin-left: auto; }
|
|
.btn-add:hover { border-color: #4a9; color: #4a9; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>Lahr Clip Browser</h1>
|
|
<p class="sub">Drag clips to reorder. Click Preview to watch. Remove clips from reel. Build Reel when ready.</p>
|
|
|
|
<div class="layout">
|
|
<div>
|
|
<div id="clip-list"></div>
|
|
<div class="unused-section">
|
|
<h2>Available (not in reel)</h2>
|
|
<div id="unused-list"></div>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar">
|
|
<div class="preview-box">
|
|
<h3>Preview</h3>
|
|
<video id="preview-video" controls></video>
|
|
<div id="preview-name">Click Preview on any clip</div>
|
|
</div>
|
|
<div class="reel-box">
|
|
<h3>Current Reel Order</h3>
|
|
<div id="reel-order"></div>
|
|
<button class="btn-build" onclick="buildReel()">Build Reel</button>
|
|
<div id="build-output"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const BASE = '/assets/videos/hero/clips/';
|
|
|
|
const ALL_CLIPS = [
|
|
{ name: 'v3-shot-01', label: 'v3 · Family enters door, pans to carpet', size: '5.2MB' },
|
|
{ name: 'v3-shot-02', label: 'v3 · Wine spill on sofa close-up', size: '6.0MB' },
|
|
{ name: 'v3-shot-03', label: 'v3 · Dirty stained carpet close-up', size: '3.3MB' },
|
|
{ name: 'v3-shot-04', label: 'v3 · Clean bright sofa pullback', size: '5.6MB' },
|
|
{ name: 'v3-shot-05', label: 'v3 · Office lobby carpet pan', size: '4.7MB' },
|
|
{ name: 'v3-shot-06', label: 'v3 · Living room clean carpet pan', size: '4.9MB' },
|
|
{ name: 'v3-shot-07', label: 'v3 · Restaurant carpet glide', size: '3.1MB' },
|
|
{ name: 'v2-shot-01-door-entry', label: 'v2 · Door entry muddy boots', size: '2.2MB' },
|
|
{ name: 'v2-shot-02-mud-on-carpet', label: 'v2 · Mud boots on carpet floor level', size: '4.1MB' },
|
|
{ name: 'v2-shot-03-stain-on-chair', label: 'v2 · Stain on chair close-up', size: '3.3MB' },
|
|
{ name: 'v2-shot-04-extraction-carpet', label: 'v2 · Extraction machine on carpet', size: '4.2MB' },
|
|
{ name: 'v2-shot-05-clean-stairs', label: 'v2 · Clean bright staircase', size: '5.6MB' },
|
|
{ name: 'v2-shot-06-office', label: 'v2 · Bright office carpet', size: '4.7MB' },
|
|
{ name: 'v2-shot-07-restaurant', label: 'v2 · Restaurant carpet', size: '4.7MB' },
|
|
{ name: 'shot-01-door-opens-trimmed', label: 'v1 · Door opens (trimmed 2.5s)', size: '525KB' },
|
|
{ name: 'shot-01-wide-room', label: 'v1 · Wide room establishing', size: '2.6MB' },
|
|
{ name: 'shot-02-pan-to-stains', label: 'v1 · Pan to stains / muddy shoes', size: '2.8MB' },
|
|
{ name: 'shot-02-staircase', label: 'v1 · Staircase', size: '1.4MB' },
|
|
{ name: 'shot-03-stain-closeup', label: 'v1 · Stain close-up', size: '4.7MB' },
|
|
{ name: 'shot-03-technician', label: 'v1 · Technician', size: '1.6MB' },
|
|
{ name: 'shot-04-extraction-carpet', label: 'v1 · Extraction carpet (clean reveal)', size: '6.2MB' },
|
|
{ name: 'shot-04-extraction-closeup', label: 'v1 · Extraction close-up', size: '2.0MB' },
|
|
{ name: 'shot-05-clean-reveal', label: 'v1 · Clean reveal', size: '2.2MB' },
|
|
{ name: 'shot-05-extraction-couch', label: 'v1 · Extraction couch', size: '3.5MB' },
|
|
{ name: 'shot-06-extraction-stairs', label: 'v1 · Extraction stairs', size: '5.4MB' },
|
|
{ name: 'shot-07-office-entryway', label: 'v1 · Office entryway', size: '5.9MB' },
|
|
{ name: 'shot-08-showroom', label: 'v1 · Showroom', size: '4.1MB' },
|
|
{ name: 'shot-09-technician-unloading', label: 'v1 · Technician unloading van', size: '3.7MB' },
|
|
{ name: 'shot-01-door-opens', label: 'v1 · Door opens (full 6s)', size: '1.5MB' },
|
|
];
|
|
|
|
// Default reel = current v3 set
|
|
let reelClips = ALL_CLIPS.slice(0, 7).map(c => c.name);
|
|
|
|
function getClip(name) { return ALL_CLIPS.find(c => c.name === name); }
|
|
|
|
function render() {
|
|
const list = document.getElementById('clip-list');
|
|
list.innerHTML = '';
|
|
reelClips.forEach((name, idx) => {
|
|
const clip = getClip(name);
|
|
if (!clip) return;
|
|
const div = document.createElement('div');
|
|
div.className = 'clip-item';
|
|
div.draggable = true;
|
|
div.dataset.name = name;
|
|
div.innerHTML = `
|
|
<span class="drag-handle">⠿</span>
|
|
<video class="clip-thumb" src="${BASE}${name}.mp4" muted preload="metadata"></video>
|
|
<div class="clip-info">
|
|
<div class="clip-name">${idx+1}. ${clip.label}</div>
|
|
<div class="clip-size">${clip.size}</div>
|
|
</div>
|
|
<div class="clip-actions">
|
|
<button class="btn-preview" onclick="preview('${name}', '${clip.label}')">Preview</button>
|
|
<button class="btn-remove" onclick="remove('${name}')">Remove</button>
|
|
</div>`;
|
|
div.addEventListener('dragstart', dragStart);
|
|
div.addEventListener('dragover', dragOver);
|
|
div.addEventListener('drop', drop);
|
|
div.addEventListener('dragend', dragEnd);
|
|
list.appendChild(div);
|
|
});
|
|
|
|
const unused = ALL_CLIPS.filter(c => !reelClips.includes(c.name));
|
|
const ulist = document.getElementById('unused-list');
|
|
ulist.innerHTML = '';
|
|
unused.forEach(clip => {
|
|
const div = document.createElement('div');
|
|
div.className = 'unused-item';
|
|
div.innerHTML = `
|
|
<div class="clip-name">${clip.label} <span style="color:#444">(${clip.size})</span></div>
|
|
<button class="btn-add" onclick="addToReel('${clip.name}')">+ Add</button>`;
|
|
ulist.appendChild(div);
|
|
});
|
|
|
|
const orderEl = document.getElementById('reel-order');
|
|
orderEl.innerHTML = reelClips.map((n,i) => {
|
|
const c = getClip(n);
|
|
return `<div>${i+1}. ${c ? c.label : n}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function preview(name, label) {
|
|
const v = document.getElementById('preview-video');
|
|
v.src = BASE + name + '.mp4';
|
|
v.play();
|
|
document.getElementById('preview-name').textContent = label;
|
|
}
|
|
|
|
function remove(name) {
|
|
reelClips = reelClips.filter(n => n !== name);
|
|
render();
|
|
}
|
|
|
|
function addToReel(name) {
|
|
reelClips.push(name);
|
|
render();
|
|
}
|
|
|
|
let dragSrc = null;
|
|
function dragStart(e) { dragSrc = this; this.classList.add('dragging'); }
|
|
function dragOver(e) { e.preventDefault(); document.querySelectorAll('.clip-item').forEach(el => el.classList.remove('drag-over')); this.classList.add('drag-over'); }
|
|
function drop(e) {
|
|
e.preventDefault();
|
|
if (dragSrc === this) return;
|
|
const fromName = dragSrc.dataset.name;
|
|
const toName = this.dataset.name;
|
|
const fi = reelClips.indexOf(fromName);
|
|
const ti = reelClips.indexOf(toName);
|
|
reelClips.splice(fi, 1);
|
|
reelClips.splice(ti, 0, fromName);
|
|
render();
|
|
}
|
|
function dragEnd() { document.querySelectorAll('.clip-item').forEach(el => el.classList.remove('dragging','drag-over')); }
|
|
|
|
async function buildReel() {
|
|
const out = document.getElementById('build-output');
|
|
out.style.display = 'block';
|
|
out.textContent = 'Sending to server...';
|
|
|
|
const resp = await fetch('/tools/build-reel-api.py', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ clips: reelClips })
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
// Fallback: show the ffmpeg command to copy
|
|
const lines = reelClips.map(n => `file 'assets/videos/hero/clips/${n}.mp4'`).join('\n');
|
|
out.textContent = 'Server not available. Run this manually:\n\n' +
|
|
'# 1. Save as concat.txt:\n' + lines + '\n\n' +
|
|
'# 2. Run ffmpeg:\n' +
|
|
'ffmpeg -y -f concat -safe 0 -i concat.txt -c:v libx264 -crf 22 -preset fast -movflags +faststart assets/videos/hero/hero-reel.mp4';
|
|
} else {
|
|
const data = await resp.json();
|
|
out.textContent = data.message || 'Done.';
|
|
}
|
|
}
|
|
|
|
render();
|
|
</script>
|
|
</body>
|
|
</html>
|