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>
277 lines
12 KiB
HTML
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://<machine>.<tailnet>.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 && 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>
|