festifun/backend/festival4d/static/capture.html
m3ultra 36bacf67f0 Live capture: USB webcams, laptop cams, and phones -> data/raw
Records rather than streams: each device records a clip WITH AUDIO and uploads
it; the normal offline pipeline takes over. Fits the design almost for free —
MediaRecorder's webm/mp4 are already ingestible, and audio-based sync means
devices need no clock sync and needn't start together.

- capture.py: POST /api/capture/upload (streamed, sanitized name, size cap,
  partial cleanup), heartbeat/devices registry, GET /capture page.
- static/capture.html: standalone page (no build step) — device picker, live
  preview, record->upload, 1fps snapshot heartbeat, 'who's shooting what'
  monitor, and secure-context detection that tells you to use
  (getUserMedia needs HTTPS — the #1 gotcha for phones).
- OPT-IN via FESTIVAL4D_CAPTURE=1; manifest reports has_capture and the viewer
  only then shows a Capture link. Public deploy leaves it off (open upload
  endpoint would be unsafe) — noted in DEPLOY.md.
- Adds python-multipart dep. Suite 103 -> 119.

Also fixes test_manifest_shape, which was already RED on main: f12b6e0 added
has_splat to the manifest without updating the exact-set lock. Updated to the
true set (now also has_capture); the lock is what caught both drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:43:51 +10:00

277 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Festival 4D — Capture</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0d12; --panel: #141824; --panel-2: #1c2233; --border: #232838;
--text: #e6e8ee; --dim: #8a92a6; --ok: #59d499; --bad: #ff6b6b; --rec: #ff3b6b;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); min-height: 100vh;
font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: env(safe-area-inset-top) 0.8rem calc(env(safe-area-inset-bottom) + 0.8rem);
}
h1 { font-size: 1.1rem; margin: 0.8rem 0 0.2rem; }
.sub { color: var(--dim); font-size: 0.8rem; margin-bottom: 0.8rem; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 0.7rem; margin-bottom: 0.7rem;
}
label { display: block; color: var(--dim); font-size: 0.76rem; margin-bottom: 0.2rem; }
select, input[type="text"] {
width: 100%; background: var(--panel-2); color: var(--text); font-size: 1rem;
border: 1px solid var(--border); border-radius: 7px; padding: 0.5rem;
margin-bottom: 0.55rem;
}
#preview {
width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px;
object-fit: contain; display: block;
}
.row { display: flex; gap: 0.5rem; align-items: center; }
button {
flex: 1 1 auto; font-size: 1rem; font-weight: 600; padding: 0.75rem 0.6rem;
border-radius: 9px; border: 1px solid var(--border); background: var(--panel-2);
color: var(--text); cursor: pointer;
}
button:disabled { opacity: 0.45; cursor: default; }
#rec.recording { background: var(--rec); border-color: var(--rec); color: #fff; }
.pill {
display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.75rem;
color: var(--dim);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); }
.dot.on { background: var(--rec); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.25; } }
.msg { font-size: 0.82rem; margin-top: 0.5rem; line-height: 1.4; }
.err { color: var(--bad); }
.ok { color: var(--ok); }
code {
background: var(--panel-2); padding: 0.1rem 0.3rem; border-radius: 4px;
font-size: 0.85em; word-break: break-all;
}
/* monitor */
#mon { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0.5rem; }
.mcell { background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
.mcell img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; }
.mcell .cap { padding: 0.25rem 0.35rem; font-size: 0.68rem; display: flex; align-items: center; gap: 0.25rem; }
.mcell .nm { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mcell.rec { border-color: var(--rec); }
#gate { display: none; }
</style>
</head>
<body>
<h1>Festival 4D — Capture</h1>
<div class="sub">Record this camera. Clips upload to the project and sync by audio.</div>
<!-- Shown when getUserMedia is unavailable (insecure origin) -->
<div class="card" id="gate">
<div class="msg err"><b>Camera blocked — this page isn't a secure context.</b></div>
<div class="msg">
Browsers only grant camera access over <b>HTTPS</b> (or localhost). On the machine running
the backend:
<div style="margin:0.4rem 0"><code>tailscale serve --bg 8000</code></div>
then open the printed <code>https://&lt;machine&gt;.&lt;tailnet&gt;.ts.net/capture</code>
URL on this device (join the tailnet first).
</div>
</div>
<div id="app">
<div class="card">
<label for="name">Device name (used in the filename)</label>
<input type="text" id="name" placeholder="e.g. phone-left / logitech-c920" />
<label for="cam">Camera</label>
<select id="cam"></select>
<label for="mic">Microphone <span style="color:var(--bad)">— required for sync</span></label>
<select id="mic"></select>
<video id="preview" playsinline muted autoplay></video>
<div class="row" style="margin-top:0.55rem">
<span class="pill"><span class="dot" id="recdot"></span><span id="status">idle</span></span>
</div>
</div>
<div class="row">
<button id="rec">● Record</button>
</div>
<div class="msg" id="msg"></div>
<div class="card" style="margin-top:0.8rem">
<label style="margin-bottom:0.4rem">Who's shooting what — live monitor</label>
<div id="mon"></div>
<div class="msg" id="monempty" style="color:var(--dim)">no other cameras yet</div>
</div>
<div class="msg" style="color:var(--dim)">
All cameras must hear the <b>same sound</b> — that's how clips get aligned. No clock sync
needed; you don't even have to start together. When done, run
<code>python -m festival4d ingest &amp;&amp; python -m festival4d sync</code>.
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const deviceId = (localStorage.f4dDevId ||= "dev-" + Math.random().toString(36).slice(2, 9));
let stream = null, recorder = null, chunks = [], startedAt = 0, timer = null;
const secure = window.isSecureContext && navigator.mediaDevices?.getUserMedia;
if (!secure) {
$("gate").style.display = "block";
$("app").style.display = "none";
}
function msg(html, cls = "") { $("msg").className = "msg " + cls; $("msg").innerHTML = html; }
async function listDevices() {
const devs = await navigator.mediaDevices.enumerateDevices();
const fill = (sel, kind, fallback) => {
const cur = sel.value;
sel.innerHTML = devs
.filter((d) => d.kind === kind)
.map((d, i) => `<option value="${d.deviceId}">${d.label || `${fallback} ${i + 1}`}</option>`)
.join("");
if (cur) sel.value = cur;
};
fill($("cam"), "videoinput", "Camera");
fill($("mic"), "audioinput", "Microphone");
}
async function start(camId, micId) {
stream?.getTracks().forEach((t) => t.stop());
// Audio is NOT optional: the pipeline aligns clips by sound.
stream = await navigator.mediaDevices.getUserMedia({
video: camId ? { deviceId: { exact: camId } } : true,
audio: micId ? { deviceId: { exact: micId } } : true,
});
$("preview").srcObject = stream;
await listDevices(); // labels only populate after permission is granted
}
function pickMime() {
const want = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4", // Safari / iOS
];
return want.find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
}
$("rec").addEventListener("click", () => (recorder ? stop() : record()));
function record() {
if (!stream) return msg("no camera stream yet", "err");
if (stream.getAudioTracks().length === 0)
return msg("no audio track — clips without sound can't be synced.", "err");
const mime = pickMime();
chunks = [];
recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.onstop = upload;
recorder.start(1000);
startedAt = Date.now();
$("rec").textContent = "■ Stop";
$("rec").classList.add("recording");
$("recdot").classList.add("on");
timer = setInterval(tick, 250);
msg("recording — point at the stage and keep some static structure in frame.");
}
function stop() {
clearInterval(timer);
recorder?.stop();
recorder = null;
$("rec").textContent = "● Record";
$("rec").classList.remove("recording");
$("recdot").classList.remove("on");
tick();
}
function elapsed() { return startedAt ? (Date.now() - startedAt) / 1000 : 0; }
function tick() {
$("status").textContent = recorder ? `recording ${elapsed().toFixed(0)}s` : "idle";
}
async function upload() {
const type = chunks[0]?.type || "video/webm";
const blob = new Blob(chunks, { type });
const ext = type.includes("mp4") ? "mp4" : "webm";
const name = ($("name").value || "camera").trim();
const fd = new FormData();
fd.append("device_name", name);
fd.append("file", blob, `clip.${ext}`);
msg(`uploading ${(blob.size / 1e6).toFixed(1)} MB…`);
try {
const r = await fetch("api/capture/upload", { method: "POST", body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`);
msg(`saved <code>${j.filename}</code> (${(j.bytes / 1e6).toFixed(1)} MB). Record again or run the pipeline.`, "ok");
} catch (e) {
msg(`upload failed: ${e.message}`, "err");
}
startedAt = 0;
tick();
}
// --- heartbeat + monitor -------------------------------------------------------------
const snapCanvas = document.createElement("canvas");
function snapshot() {
const v = $("preview");
if (!v.videoWidth) return null;
snapCanvas.width = 160;
snapCanvas.height = Math.round((160 * v.videoHeight) / v.videoWidth);
snapCanvas.getContext("2d").drawImage(v, 0, 0, snapCanvas.width, snapCanvas.height);
return snapCanvas.toDataURL("image/jpeg", 0.4);
}
async function beat() {
try {
await fetch("api/capture/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: deviceId,
name: ($("name").value || "camera").trim(),
status: recorder ? "recording" : "idle",
elapsed_s: elapsed(),
snapshot: snapshot(),
}),
});
const devs = await (await fetch("api/capture/devices")).json();
renderMonitor(devs.filter((d) => d.device_id !== deviceId));
} catch { /* offline: monitor just goes stale */ }
}
function renderMonitor(devs) {
$("monempty").style.display = devs.length ? "none" : "block";
$("mon").innerHTML = devs
.map(
(d) => `<div class="mcell ${d.status === "recording" ? "rec" : ""}">
${d.snapshot ? `<img src="${d.snapshot}" alt="">` : `<img alt="">`}
<div class="cap"><span class="dot ${d.status === "recording" ? "on" : ""}"></span>
<span class="nm">${d.name}</span>
<span>${d.status === "recording" ? d.elapsed_s.toFixed(0) + "s" : ""}</span></div>
</div>`
)
.join("");
}
// --- boot ----------------------------------------------------------------------------
if (secure) {
$("name").value = localStorage.f4dName || "";
$("name").addEventListener("change", () => (localStorage.f4dName = $("name").value));
$("cam").addEventListener("change", () => start($("cam").value, $("mic").value).catch((e) => msg(e.message, "err")));
$("mic").addEventListener("change", () => start($("cam").value, $("mic").value).catch((e) => msg(e.message, "err")));
start().then(() => msg("ready — name this device, then record.")).catch((e) =>
msg(`camera error: ${e.message}`, "err")
);
setInterval(beat, 2000);
navigator.mediaDevices.addEventListener?.("devicechange", listDevices);
}
</script>
</body>
</html>