Skip to content

Commit c40edc7

Browse files
refactor(record): studio layout + modular front-end
- three-column layout: clips | takes rail | stage — takes and coach move out of the teleprompter area for good - state-aware stage via body class: recording dims the rails so only the script competes for attention; review shrinks (never hides) the script and shows one review card: coach stats, notes, transcript, player, keep/retake - takes rail newest-first (list_takes sorts descending) - uber index.html split into web/: index.html + style.css + 6 plain ES modules (state/mic/record/review/takes/main) talking over an event bus — no framework, no build step, still embedded via include_str! Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bc64979 commit c40edc7

10 files changed

Lines changed: 652 additions & 499 deletions

File tree

crates/videoeditor-record/src/index.html

Lines changed: 0 additions & 491 deletions
This file was deleted.

crates/videoeditor-record/src/lib.rs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,50 @@ use std::fs;
2626
use std::path::Path;
2727
use videoeditor_timeline::{Clip, ClipInfo, Episode, Scene};
2828

29-
const INDEX_HTML: &str = include_str!("index.html");
29+
/// The whole front end, embedded: plain ES modules, no framework, no
30+
/// build step — the binary stays self-contained.
31+
const ASSETS: &[(&str, &str, &str)] = &[
32+
(
33+
"/",
34+
include_str!("web/index.html"),
35+
"text/html; charset=utf-8",
36+
),
37+
(
38+
"/style.css",
39+
include_str!("web/style.css"),
40+
"text/css; charset=utf-8",
41+
),
42+
(
43+
"/js/state.js",
44+
include_str!("web/js/state.js"),
45+
"text/javascript; charset=utf-8",
46+
),
47+
(
48+
"/js/mic.js",
49+
include_str!("web/js/mic.js"),
50+
"text/javascript; charset=utf-8",
51+
),
52+
(
53+
"/js/record.js",
54+
include_str!("web/js/record.js"),
55+
"text/javascript; charset=utf-8",
56+
),
57+
(
58+
"/js/review.js",
59+
include_str!("web/js/review.js"),
60+
"text/javascript; charset=utf-8",
61+
),
62+
(
63+
"/js/takes.js",
64+
include_str!("web/js/takes.js"),
65+
"text/javascript; charset=utf-8",
66+
),
67+
(
68+
"/js/main.js",
69+
include_str!("web/js/main.js"),
70+
"text/javascript; charset=utf-8",
71+
),
72+
];
3073

3174
#[derive(Serialize)]
3275
struct ClipView {
@@ -93,7 +136,10 @@ fn route(
93136
) -> Result<tiny_http::Response<std::io::Cursor<Vec<u8>>>> {
94137
use tiny_http::Method::{Get, Post};
95138
match (method, url) {
96-
(Get, "/") => Ok(html(INDEX_HTML)),
139+
(Get, path) if ASSETS.iter().any(|(p, ..)| *p == path) => {
140+
let (_, body, mime) = ASSETS.iter().find(|(p, ..)| *p == path).expect("just found");
141+
Ok(tiny_http::Response::from_string(*body).with_header(header("content-type", mime)))
142+
}
97143
(Get, "/api/episode") => Ok(json(&episode_view(ep)?)),
98144
(Get, path) if path.starts_with("/api/takes/") => {
99145
let id = sanitize_id(&path["/api/takes/".len()..])?;
@@ -327,7 +373,8 @@ fn list_takes(ep: &Episode, id: &str) -> Result<Vec<TakeInfo>> {
327373
});
328374
}
329375
}
330-
takes.sort_by(|a, b| a.file.cmp(&b.file));
376+
// newest take first; replaced_* (pre-recorder audio) sinks to the bottom
377+
takes.sort_by(|a, b| b.file.cmp(&a.file));
331378
Ok(takes)
332379
}
333380

@@ -685,11 +732,6 @@ fn open_url(url: &str) -> Result<()> {
685732
Ok(())
686733
}
687734

688-
fn html(body: &str) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
689-
tiny_http::Response::from_string(body)
690-
.with_header(header("content-type", "text/html; charset=utf-8"))
691-
}
692-
693735
fn json<T: Serialize>(value: &T) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
694736
tiny_http::Response::from_string(serde_json::to_string(value).unwrap_or_default())
695737
.with_header(header("content-type", "application/json"))
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<title>videoeditor · recorder</title>
6+
<link rel="stylesheet" href="/style.css">
7+
</head>
8+
<body class="idle">
9+
10+
<aside class="clips">
11+
<header>
12+
<h1>videoeditor · recorder</h1>
13+
<div class="ep" id="epTitle"></div>
14+
</header>
15+
<div id="clipList"></div>
16+
</aside>
17+
18+
<aside class="rail">
19+
<header>
20+
<h1>takes · newest first</h1>
21+
<div class="ep">▶ audition · approve → becomes the clip</div>
22+
</header>
23+
<div id="takesList"></div>
24+
<div class="rail-empty" id="takesEmpty">no takes yet — every recording lands here, kept or not</div>
25+
</aside>
26+
27+
<main>
28+
<header>
29+
<div class="which" id="which"></div>
30+
<div class="window" id="windowInfo"></div>
31+
<select id="micSelect" title="input device"></select>
32+
</header>
33+
34+
<div id="stage">
35+
<div class="inner">
36+
<div id="promptText">loading…</div>
37+
<div id="reviewCard">
38+
<div class="hdr" id="coachHdr">COACH</div>
39+
<ul id="coachNotes"></ul>
40+
<div id="transcript"></div>
41+
<div class="review-actions">
42+
<audio id="player" controls></audio>
43+
<button id="keepBtn">keep (enter)</button>
44+
<button id="retakeBtn">retake (r)</button>
45+
</div>
46+
</div>
47+
</div>
48+
</div>
49+
50+
<footer>
51+
<div id="meter">
52+
<div class="chan"><span class="lbl">L</span><div class="track"><div class="fill" id="meterL"></div></div></div>
53+
<div class="chan"><span class="lbl">R</span><div class="track"><div class="fill" id="meterR"></div></div></div>
54+
</div>
55+
<div class="controls">
56+
<button id="recBtn" title="record / stop (space)"></button>
57+
<div id="timer">0.00<span class="lim"> / —</span></div>
58+
<div id="status">pick a clip, hit <b>space</b>, read after the countdown</div>
59+
</div>
60+
<div class="hints">
61+
<kbd>space</kbd> record / stop · <kbd>enter</kbd> keep · <kbd>r</kbd> retake ·
62+
<kbd></kbd><kbd></kbd> switch clip · raw stereo mic (no browser processing) — watch the meters, stay out of the red ·
63+
every take is archived in audio/takes/, kept or not
64+
</div>
65+
</footer>
66+
</main>
67+
68+
<div id="countdown">3</div>
69+
<script type="module" src="/js/main.js"></script>
70+
</body>
71+
</html>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Boot, clip list, selection, keyboard map.
2+
3+
import { $, fmt, bus, getState, setState, getEpisode, setEpisode, getCurrent, setCurrent, status } from './state.js';
4+
import { initMic } from './mic.js';
5+
import { toggleRecord } from './record.js';
6+
import { keepTake, retake } from './review.js';
7+
import './takes.js';
8+
9+
async function boot() {
10+
setEpisode(await (await fetch('/api/episode')).json());
11+
$('epTitle').textContent = getEpisode().title;
12+
select(0);
13+
await initMic();
14+
}
15+
16+
function select(i) {
17+
setCurrent(i);
18+
setState('idle');
19+
const c = getEpisode().clips[i];
20+
$('which').textContent = `${c.scene} / ${c.clip}`;
21+
$('windowInfo').textContent =
22+
`window ${fmt(c.window)}s at tempo ${c.tempo}` +
23+
(c.take_duration != null ? ` · current take ${fmt(c.take_duration)}s` : ' · no take yet');
24+
$('promptText').textContent = c.text;
25+
$('timer').innerHTML = `0.00<span class="lim"> / ${fmt(c.window * c.tempo)}</span>`;
26+
$('timer').classList.remove('over');
27+
renderList();
28+
bus.dispatchEvent(new Event('clip-selected'));
29+
}
30+
31+
function renderList() {
32+
const list = $('clipList');
33+
list.innerHTML = '';
34+
getEpisode().clips.forEach((c, i) => {
35+
const row = document.createElement('div');
36+
row.className = 'clip-row' + (i === getCurrent() ? ' active' : '');
37+
const fits = c.take_duration != null && c.take_duration / c.tempo <= c.window + 0.005;
38+
const dot = c.take_duration == null ? '' : (fits ? 'has-take' : 'too-long');
39+
row.innerHTML = `<div class="dot ${dot}"></div>
40+
<div class="name">${c.scene} / ${c.clip}</div>
41+
<div class="len">${c.take_duration != null ? fmt(c.take_duration) + 's' : '—'}</div>`;
42+
row.onclick = () => {
43+
if (getState() === 'idle' || getState() === 'review') select(i);
44+
};
45+
list.appendChild(row);
46+
});
47+
}
48+
49+
// approve/keep changed which take is the clip — refresh header, list, rail
50+
bus.addEventListener('clip-updated', () => select(getCurrent()));
51+
52+
document.addEventListener('keydown', (e) => {
53+
if (e.code === 'Space') {
54+
e.preventDefault();
55+
toggleRecord();
56+
} else if (e.key === 'Enter') keepTake();
57+
else if (e.key === 'r') retake();
58+
else if ((e.key === 'ArrowDown' || e.key === 'ArrowRight') && getState() === 'idle')
59+
select(Math.min(getCurrent() + 1, getEpisode().clips.length - 1));
60+
else if ((e.key === 'ArrowUp' || e.key === 'ArrowLeft') && getState() === 'idle')
61+
select(Math.max(getCurrent() - 1, 0));
62+
});
63+
64+
boot().catch((e) => status('mic init failed: ' + e.message));
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Mic capture + input picker + L/R level meters.
2+
3+
import { $ } from './state.js';
4+
5+
let mediaStream = null;
6+
let audioCtx = null;
7+
let analyserL = null;
8+
let analyserR = null;
9+
10+
export const stream = () => mediaStream;
11+
12+
export async function initMic(deviceId) {
13+
if (mediaStream) mediaStream.getTracks().forEach((t) => t.stop());
14+
mediaStream = await navigator.mediaDevices.getUserMedia({
15+
audio: {
16+
deviceId: deviceId ? { exact: deviceId } : undefined,
17+
channelCount: 2, // interface sends a stereo mix — take it as-is
18+
echoCancellation: false, // raw voice: no call-style processing
19+
noiseSuppression: false,
20+
autoGainControl: false,
21+
},
22+
});
23+
audioCtx = audioCtx || new AudioContext();
24+
const splitter = audioCtx.createChannelSplitter(2);
25+
analyserL = audioCtx.createAnalyser();
26+
analyserR = audioCtx.createAnalyser();
27+
analyserL.fftSize = analyserR.fftSize = 1024;
28+
audioCtx.createMediaStreamSource(mediaStream).connect(splitter);
29+
splitter.connect(analyserL, 0);
30+
splitter.connect(analyserR, 1);
31+
meterLoop();
32+
await fillDevices();
33+
}
34+
35+
async function fillDevices() {
36+
const devices = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audioinput');
37+
const sel = $('micSelect');
38+
const activeId = mediaStream?.getAudioTracks()[0]?.getSettings().deviceId;
39+
sel.innerHTML = '';
40+
devices.forEach((d) => {
41+
const o = document.createElement('option');
42+
o.value = d.deviceId;
43+
o.textContent = d.label || 'microphone';
44+
if (d.deviceId === activeId) o.selected = true;
45+
sel.appendChild(o);
46+
});
47+
}
48+
49+
let meterRunning = false;
50+
function meterLoop() {
51+
if (meterRunning) return; // one loop; reads whichever analysers are current
52+
meterRunning = true;
53+
const buf = new Float32Array(1024);
54+
(function tick() {
55+
for (const [analyser, fill] of [[analyserL, $('meterL')], [analyserR, $('meterR')]]) {
56+
analyser.getFloatTimeDomainData(buf);
57+
let peak = 0;
58+
for (const v of buf) peak = Math.max(peak, Math.abs(v));
59+
fill.style.width = Math.min(100, peak * 130) + '%';
60+
fill.classList.toggle('hot', peak > 0.85);
61+
}
62+
requestAnimationFrame(tick);
63+
})();
64+
}
65+
66+
$('micSelect').onchange = (e) => initMic(e.target.value);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Record flow: countdown → MediaRecorder → timer → hand the blob to review.
2+
3+
import { $, fmt, bus, getState, setState, currentClip, status } from './state.js';
4+
import { stream } from './mic.js';
5+
6+
let recorder = null;
7+
let chunks = [];
8+
let recStart = 0;
9+
let timerId = null;
10+
11+
export function toggleRecord() {
12+
const s = getState();
13+
if (s === 'recording') stopRecording();
14+
else if (s === 'idle' || s === 'review') beginCountdown();
15+
}
16+
17+
export function beginCountdown() {
18+
setState('countdown');
19+
bus.dispatchEvent(new Event('record-start'));
20+
const cd = $('countdown');
21+
let n = 3;
22+
cd.textContent = n;
23+
const iv = setInterval(() => {
24+
n -= 1;
25+
if (n === 0) {
26+
clearInterval(iv);
27+
startRecording();
28+
} else cd.textContent = n;
29+
}, 700);
30+
}
31+
32+
function startRecording() {
33+
chunks = [];
34+
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/mp4';
35+
recorder = new MediaRecorder(stream(), { mimeType: mime });
36+
recorder.ondataavailable = (e) => chunks.push(e.data);
37+
recorder.onstop = () => {
38+
setState('review');
39+
status('listen back — keep it or go again');
40+
bus.dispatchEvent(new CustomEvent('take-recorded', { detail: new Blob(chunks, { type: recorder.mimeType }) }));
41+
};
42+
recorder.start();
43+
setState('recording');
44+
status('recording… space to stop');
45+
recStart = performance.now();
46+
const c = currentClip();
47+
const limit = c.window * c.tempo;
48+
timerId = setInterval(() => {
49+
const s = (performance.now() - recStart) / 1000;
50+
$('timer').innerHTML = `${fmt(s)}<span class="lim"> / ${fmt(limit)}</span>`;
51+
$('timer').classList.toggle('over', s > limit);
52+
}, 50);
53+
}
54+
55+
function stopRecording() {
56+
recorder.stop();
57+
clearInterval(timerId);
58+
}
59+
60+
$('recBtn').onclick = toggleRecord;

0 commit comments

Comments
 (0)