From e5483f26131e1e672ffc93bca1f02c751c7b0544 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 8 Jul 2026 21:40:51 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20videoeditor-record=20=E2=80=94=20re?= =?UTF-8?q?cord=20narration=20takes=20in=20a=20local=20web=20recorder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate videoeditor-record and a `record` subcommand: `videoeditor record ` serves a one-page teleprompter + mic recorder on 127.0.0.1 and writes kept takes straight into the episode, ready for the normal render/assemble pipeline. - Capture in the browser (getUserMedia/MediaRecorder): device picker, permission UX, and live level metering are already solved there — cross-platform native capture is not. localhost is a secure context, so the mic works without TLS. Raw voice: echoCancellation / noiseSuppression / autoGainControl all disabled. - UI: clip sidebar with per-take fit status, big teleprompter text, 3-2-1 countdown, elapsed-vs-window timer that flags overruns live, level meter (WebAudio), review player, keyboard-driven flow (space/enter/r/arrows). - Server (tiny_http, sync — no async runtime added): uploads (webm/opus or mp4/aac) transcode via ffmpeg to the exact mp3 format the TTS path produces; every kept take lands in audio/takes// and the replaced clip is archived, so no take is ever lost; audio/clips.json is rebuilt from disk (self-healing) and each save returns the episode fit-check. - Clip ids off the URL are allowlist-sanitized (no path traversal). - guide.md pipeline/director-loop + CLAUDE.md layout updated. Live-tested end to end against a scratch episode: webm upload → transcode → archive (take_001/take_002/replaced_003) → manifest → fit-check response; traversal attempts rejected. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 + Cargo.lock | 43 +++ Cargo.toml | 2 + crates/videoeditor-record/Cargo.toml | 19 ++ crates/videoeditor-record/src/index.html | 318 +++++++++++++++++++++++ crates/videoeditor-record/src/lib.rs | 314 ++++++++++++++++++++++ crates/videoeditor/Cargo.toml | 1 + crates/videoeditor/guide.md | 7 +- crates/videoeditor/src/main.rs | 20 ++ 9 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 crates/videoeditor-record/Cargo.toml create mode 100644 crates/videoeditor-record/src/index.html create mode 100644 crates/videoeditor-record/src/lib.rs diff --git a/CLAUDE.md b/CLAUDE.md index db67a3d..1c0a080 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,9 @@ vertical video out via headless Chrome + ffmpeg + ElevenLabs). - `crates/videoeditor-genai` — typed image-generation clients: xAI Grok Imagine (`XAI_API_KEY`, reference images) + Google Imagen (`AI_STUDIO`); Veo/Grok video is the planned next tenant. +- `crates/videoeditor-record` — `record` subcommand: local web recorder + (teleprompter + MediaRecorder mic capture, tiny_http server); kept takes + transcode via ffmpeg into `audio/clips/` in the TTS format. - `examples/hello-bench` — smallest end-to-end episode; keep it rendering. ## Commands diff --git a/Cargo.lock b/Cargo.lock index 82626ad..2e813af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,12 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "base64" version = "0.22.1" @@ -107,6 +113,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" version = "4.6.1" @@ -276,6 +288,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "icu_collections" version = "2.1.1" @@ -708,6 +726,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -821,6 +851,7 @@ dependencies = [ "videoeditor-chrome", "videoeditor-genai", "videoeditor-media", + "videoeditor-record", "videoeditor-timeline", "videoeditor-voice", ] @@ -856,6 +887,18 @@ dependencies = [ "videoeditor-timeline", ] +[[package]] +name = "videoeditor-record" +version = "0.1.1" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tiny_http", + "videoeditor-media", + "videoeditor-timeline", +] + [[package]] name = "videoeditor-timeline" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index c199862..9ee0336 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,14 @@ clap = { version = "4", features = ["derive"] } include_dir = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1" +tiny_http = "0.12" tungstenite = "0.24" ureq = { version = "2", features = ["json"] } videoeditor-chrome = { version = "0.1.1", path = "crates/videoeditor-chrome" } videoeditor-genai = { version = "0.1.1", path = "crates/videoeditor-genai" } videoeditor-media = { version = "0.1.1", path = "crates/videoeditor-media" } +videoeditor-record = { version = "0.1.1", path = "crates/videoeditor-record" } videoeditor-timeline = { version = "0.1.1", path = "crates/videoeditor-timeline" } videoeditor-voice = { version = "0.1.1", path = "crates/videoeditor-voice" } diff --git a/crates/videoeditor-record/Cargo.toml b/crates/videoeditor-record/Cargo.toml new file mode 100644 index 0000000..fd446a9 --- /dev/null +++ b/crates/videoeditor-record/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "videoeditor-record" +description = "Local web recorder for videoeditor narration: teleprompter + mic capture in the browser, mp3 takes written straight into the episode" +keywords = ["audio", "recording", "narration", "voiceover", "video"] +categories = ["multimedia::audio", "web-programming::http-server"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tiny_http.workspace = true +videoeditor-media.workspace = true +videoeditor-timeline.workspace = true diff --git a/crates/videoeditor-record/src/index.html b/crates/videoeditor-record/src/index.html new file mode 100644 index 0000000..a169233 --- /dev/null +++ b/crates/videoeditor-record/src/index.html @@ -0,0 +1,318 @@ + + + + +videoeditor · recorder + + + + +
+
+
+
+ +
+
+
loading…
+
3
+
+
+
+
+ +
0.00 / —
+
pick a clip, hit space, read after the countdown
+
+ + + +
+
+
+ space record / stop · enter keep · r retake · + switch clip · raw mic (no browser processing) — watch the meter, stay out of the red +
+
+
+ + + diff --git a/crates/videoeditor-record/src/lib.rs b/crates/videoeditor-record/src/lib.rs new file mode 100644 index 0000000..ee383ea --- /dev/null +++ b/crates/videoeditor-record/src/lib.rs @@ -0,0 +1,314 @@ +//! Local web recorder: `videoeditor record ` serves a one-page +//! teleprompter + mic-capture UI on localhost, and every kept take lands +//! directly in the episode (`audio/clips/__.mp3` + manifest), +//! ready for the normal render/assemble pipeline. +//! +//! Capture happens in the browser (getUserMedia/MediaRecorder) because +//! cross-platform native audio capture is a swamp — device pickers, +//! permission prompts, and live metering are already solved there. +//! localhost counts as a secure context, so the mic works without TLS. +//! The browser uploads webm/opus (Chrome) or mp4/aac (Safari); ffmpeg +//! transcodes to the same mp3 44.1 kHz mono the TTS path produces. +//! +//! Every kept take is archived under `audio/takes//` before the +//! current clip is replaced, so no take is ever lost to a retake. + +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use std::fs; +use std::path::Path; +use videoeditor_timeline::{ClipInfo, Episode}; + +const INDEX_HTML: &str = include_str!("index.html"); + +#[derive(Serialize)] +struct ClipView { + id: String, + scene: String, + clip: String, + text: String, + at: f64, + tempo: f64, + scene_duration: f64, + /// Seconds of playback the scene has room for (scene duration − clip at). + window: f64, + /// Measured duration of the current take, if one exists. + take_duration: Option, +} + +#[derive(Serialize)] +struct EpisodeView { + title: String, + clips: Vec, +} + +#[derive(Serialize)] +struct TakeResponse { + duration: f64, + window: f64, + fits: bool, + warnings: Vec, +} + +/// Serve the recorder for this episode until Ctrl+C. +pub fn run(ep: &Episode, port: u16, open_browser: bool) -> Result<()> { + let addr = format!("127.0.0.1:{port}"); + let server = + tiny_http::Server::http(&addr).map_err(|e| anyhow::anyhow!("binding {addr}: {e}"))?; + let url = format!("http://{addr}"); + println!("record: teleprompter at {url} (Ctrl+C to stop)"); + println!( + "record: kept takes replace audio/clips/.mp3; previous audio is archived in audio/takes/" + ); + if open_browser { + let _ = open_url(&url); + } + + for mut request in server.incoming_requests() { + let method = request.method().clone(); + let url = request.url().to_string(); + let resp = route(ep, &method, &url, &mut request); + let _ = match resp { + Ok(r) => request.respond(r), + Err(e) => request.respond( + tiny_http::Response::from_string(format!("error: {e:#}")).with_status_code(500), + ), + }; + } + Ok(()) +} + +fn route( + ep: &Episode, + method: &tiny_http::Method, + url: &str, + request: &mut tiny_http::Request, +) -> Result>>> { + use tiny_http::Method::{Get, Post}; + match (method, url) { + (Get, "/") => Ok(html(INDEX_HTML)), + (Get, "/api/episode") => Ok(json(&episode_view(ep)?)), + (Get, path) if path.starts_with("/audio/") => { + let id = sanitize_id(&path["/audio/".len()..])?; + let file = ep.root.join(format!("audio/clips/{id}.mp3")); + let bytes = fs::read(&file).with_context(|| format!("no take for {id}"))?; + Ok(tiny_http::Response::from_data(bytes) + .with_header(header("content-type", "audio/mpeg"))) + } + (Post, path) if path.starts_with("/api/take/") => { + let id = sanitize_id(&path["/api/take/".len()..])?; + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body)?; + let mime = request + .headers() + .iter() + .find(|h| h.field.equiv("content-type")) + .map(|h| h.value.as_str().to_string()) + .unwrap_or_default(); + let resp = save_take(ep, &id, &body, &mime)?; + println!( + "record: {id} take kept ({:.2}s / window {:.2}s){}", + resp.duration, + resp.window, + if resp.fits { "" } else { " ⚠ too long" } + ); + Ok(json(&resp)) + } + _ => Ok(tiny_http::Response::from_string("not found").with_status_code(404)), + } +} + +fn episode_view(ep: &Episode) -> Result { + let mut clips = Vec::new(); + for scene in &ep.scenes { + for clip in &scene.clips { + let id = format!("{}__{}", scene.name, clip.name); + let file = ep.root.join(format!("audio/clips/{id}.mp3")); + let take_duration = file + .exists() + .then(|| videoeditor_media::ffprobe_duration(&file)) + .transpose()?; + let at = clip.at.unwrap_or(0.0); + clips.push(ClipView { + id, + scene: scene.name.clone(), + clip: clip.name.clone(), + text: clip.text.clone(), + at, + tempo: clip.tempo, + scene_duration: scene.duration, + window: scene.duration - at, + take_duration, + }); + } + } + Ok(EpisodeView { + title: ep.meta.title.clone(), + clips, + }) +} + +/// Transcode an uploaded take to the pipeline's mp3 format, archive what it +/// replaces, refresh the manifest, and fit-check the result. +fn save_take(ep: &Episode, id: &str, body: &[u8], mime: &str) -> Result { + let (scene, clip) = ep + .scenes + .iter() + .flat_map(|s| s.clips.iter().map(move |c| (s, c))) + .find(|(s, c)| format!("{}__{}", s.name, c.name) == id) + .with_context(|| format!("unknown clip id {id}"))?; + + let clips_dir = ep.root.join("audio/clips"); + let takes_dir = ep.root.join("audio/takes").join(id); + fs::create_dir_all(&clips_dir)?; + fs::create_dir_all(&takes_dir)?; + + // raw upload → temp file (extension helps ffmpeg pick a demuxer) + let ext = if mime.contains("mp4") { "mp4" } else { "webm" }; + let raw = takes_dir.join(format!("upload.{ext}")); + fs::write(&raw, body)?; + + // transcode to the exact format the TTS path produces + let take = takes_dir.join(format!("take_{:03}.mp3", next_take_number(&takes_dir))); + videoeditor_media::ffmpeg(&[ + "-y", + "-i", + raw.to_str().context("path")?, + "-ac", + "1", + "-ar", + "44100", + "-b:a", + "128k", + take.to_str().context("path")?, + ])?; + fs::remove_file(&raw).ok(); + + // archive whatever the kept take replaces, then promote the new one + let current = clips_dir.join(format!("{id}.mp3")); + if current.exists() { + let n = next_take_number(&takes_dir); + fs::rename(¤t, takes_dir.join(format!("replaced_{n:03}.mp3")))?; + } + fs::copy(&take, ¤t)?; + + let manifest = rebuild_manifest(ep)?; + fs::write( + ep.root.join("audio/clips.json"), + serde_json::to_string_pretty(&manifest)?, + )?; + + let duration = videoeditor_media::ffprobe_duration(¤t)?; + let window = scene.duration - clip.at.unwrap_or(0.0); + Ok(TakeResponse { + duration, + window, + fits: duration / clip.tempo <= window, + warnings: ep.fit_check(&manifest), + }) +} + +/// Re-scan every clip file on disk so the manifest self-heals even if a +/// previous run (or a hand copy) left it stale. +fn rebuild_manifest(ep: &Episode) -> Result> { + let mut manifest = Vec::new(); + for scene in &ep.scenes { + for clip in &scene.clips { + let id = format!("{}__{}", scene.name, clip.name); + let path = ep.root.join(format!("audio/clips/{id}.mp3")); + if path.exists() { + manifest.push(ClipInfo { + scene: scene.name.clone(), + clip: clip.name.clone(), + file: format!("audio/clips/{id}.mp3"), + duration: videoeditor_media::ffprobe_duration(&path)?, + }); + } + } + } + Ok(manifest) +} + +fn next_take_number(dir: &Path) -> u32 { + fs::read_dir(dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + // take_007.mp3 / replaced_012.mp3 → 7 / 12 + let name = e.file_name().to_string_lossy().to_string(); + let stem = name.strip_suffix(".mp3")?; + stem.rsplit('_').next()?.parse::().ok() + }) + .max() + .map(|n| n + 1) + .unwrap_or(1) + }) + .unwrap_or(1) +} + +/// Clip ids come straight off the URL — allow only manifest-shaped names so +/// they can never traverse out of the episode dir. +fn sanitize_id(raw: &str) -> Result { + if raw.is_empty() + || !raw + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + bail!("invalid clip id {raw:?}"); + } + Ok(raw.to_string()) +} + +fn open_url(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let (cmd, args) = ("open", vec![url]); + #[cfg(target_os = "windows")] + let (cmd, args) = ("cmd", vec!["/c", "start", url]); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let (cmd, args) = ("xdg-open", vec![url]); + std::process::Command::new(cmd).args(args).spawn()?; + Ok(()) +} + +fn html(body: &str) -> tiny_http::Response>> { + tiny_http::Response::from_string(body) + .with_header(header("content-type", "text/html; charset=utf-8")) +} + +fn json(value: &T) -> tiny_http::Response>> { + tiny_http::Response::from_string(serde_json::to_string(value).unwrap_or_default()) + .with_header(header("content-type", "application/json")) +} + +fn header(field: &str, value: &str) -> tiny_http::Header { + tiny_http::Header::from_bytes(field.as_bytes(), value.as_bytes()).expect("static header") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_accepts_manifest_ids_only() { + assert_eq!(sanitize_id("title__hook").unwrap(), "title__hook"); + assert_eq!(sanitize_id("pass2__pass2").unwrap(), "pass2__pass2"); + assert!(sanitize_id("").is_err()); + assert!(sanitize_id("../../etc/passwd").is_err()); + assert!(sanitize_id("a/b").is_err()); + assert!(sanitize_id("a b").is_err()); + } + + #[test] + fn take_numbers_start_at_one_and_increment() { + let dir = std::env::temp_dir().join(format!("ve-record-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + assert_eq!(next_take_number(&dir), 1); + fs::write(dir.join("take_001.mp3"), b"x").unwrap(); + assert_eq!(next_take_number(&dir), 2); + fs::write(dir.join("replaced_007.mp3"), b"x").unwrap(); + assert_eq!(next_take_number(&dir), 8); + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/videoeditor/Cargo.toml b/crates/videoeditor/Cargo.toml index 0a986a9..cea6947 100644 --- a/crates/videoeditor/Cargo.toml +++ b/crates/videoeditor/Cargo.toml @@ -21,5 +21,6 @@ serde_json.workspace = true videoeditor-chrome.workspace = true videoeditor-genai.workspace = true videoeditor-media.workspace = true +videoeditor-record.workspace = true videoeditor-timeline.workspace = true videoeditor-voice.workspace = true diff --git a/crates/videoeditor/guide.md b/crates/videoeditor/guide.md index 3d48051..84364c6 100644 --- a/crates/videoeditor/guide.md +++ b/crates/videoeditor/guide.md @@ -11,6 +11,7 @@ READMEs) points here; if another doc disagrees with this one, this one wins. videoeditor new [--format meme-benchmark|blank] scaffold (renders as-is) videoeditor parse resolved plan as JSON (scene starts, clips) videoeditor tts narration via ElevenLabs (needs ELEVENLABS_API_KEY) +videoeditor record record narration YOURSELF: local web teleprompter + mic videoeditor render [--scene name] headless-Chrome frames → scene mp4s videoeditor assemble concat + narration@offsets + music → build/final.mp4 videoeditor build tts + render + assemble @@ -51,7 +52,11 @@ episode-relative. `` and unknown `[MARKERS:]` are ignored. ## The director loop (in order, every time) 1. **Script** per the craft rules below. -2. **`tts`** — then READ the ⚠ fit-check warnings. Narration overlap is a +2. **`tts`** — or **`record`** to perform the narration yourself: it + serves a teleprompter at localhost with a live level meter, writes kept + takes to `audio/clips/__.mp3` (previous audio is archived + in `audio/takes/`), and fit-checks each take against its scene window. + Then READ the ⚠ fit-check warnings. Narration overlap is a real defect (two voices at once). Recompute: scene duration = clip `at` + measured clip length ÷ tempo + hold; re-place downstream `at`s. Re-run until clean. Recompute after EVERY voice or text change. diff --git a/crates/videoeditor/src/main.rs b/crates/videoeditor/src/main.rs index 5c0871f..652b25e 100644 --- a/crates/videoeditor/src/main.rs +++ b/crates/videoeditor/src/main.rs @@ -90,6 +90,18 @@ enum Cmd { /// Print the embedded director's guide: production workflow, script.md /// grammar, template authoring — written for AI agents and humans alike Guide, + /// Record narration takes with your own voice: spawns a local + /// web recorder (teleprompter, level meter, per-clip takes) and + /// writes kept takes straight into audio/clips/ + Record { + episode: PathBuf, + /// Port for the local recorder UI + #[arg(long, default_value_t = 4747)] + port: u16, + /// Don't auto-open the browser + #[arg(long)] + no_open: bool, + }, /// Generate a still image with a generative model — xAI Grok Imagine /// (XAI_API_KEY; accepts reference images) or Google Imagen /// (AI_STUDIO/GEMINI_API_KEY; safety-filtered, no references) @@ -232,6 +244,14 @@ fn main() -> Result<()> { &out, )?; } + Cmd::Record { + episode, + port, + no_open, + } => { + let ep = load(&episode)?; + videoeditor_record::run(&ep, port, !no_open)?; + } Cmd::Image { prompt, out, From a67f42955b0630218d366c19966c05557f644012 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 8 Jul 2026 21:55:06 -0700 Subject: [PATCH 2/9] =?UTF-8?q?feat(record):=20take=20coaching=20=E2=80=94?= =?UTF-8?q?=20level=20checks=20+=20ElevenLabs=20Scribe=20review=20before?= =?UTF-8?q?=20you=20keep=20a=20take?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/review/ analyzes a pending take without keeping it, and the UI shows a COACH panel while you listen back: - always (no API needed): duration vs scene window, mean/max dBFS via ffmpeg volumedetect, clipping and too-quiet flags - with ELEVENLABS_API_KEY: Scribe transcription → script-vs-spoken word diff (LCS; dropped words + ad-libs), words/sec pacing, dead-air gaps from word timestamps, tagged background audio events - coaching lines synthesize it ("dropped from the script: boomer", "dead air at 3.0s", "clipping — back off the mic", or "clean take — ship it"); degrades gracefully to local-only metrics without a key Tests: LCS diff (missing/added words), clean-take praise, multi-fault coaching (overrun + clipping + drops + racing + dead air + cough). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/videoeditor-record/Cargo.toml | 1 + crates/videoeditor-record/src/index.html | 45 ++- crates/videoeditor-record/src/lib.rs | 363 ++++++++++++++++++++++- crates/videoeditor/guide.md | 6 +- 5 files changed, 408 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e813af..c03f123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -897,6 +897,7 @@ dependencies = [ "tiny_http", "videoeditor-media", "videoeditor-timeline", + "videoeditor-voice", ] [[package]] diff --git a/crates/videoeditor-record/Cargo.toml b/crates/videoeditor-record/Cargo.toml index fd446a9..d7f29f9 100644 --- a/crates/videoeditor-record/Cargo.toml +++ b/crates/videoeditor-record/Cargo.toml @@ -17,3 +17,4 @@ serde_json.workspace = true tiny_http.workspace = true videoeditor-media.workspace = true videoeditor-timeline.workspace = true +videoeditor-voice.workspace = true diff --git a/crates/videoeditor-record/src/index.html b/crates/videoeditor-record/src/index.html index a169233..3e2035d 100644 --- a/crates/videoeditor-record/src/index.html +++ b/crates/videoeditor-record/src/index.html @@ -80,6 +80,16 @@ #keepBtn { background: #238636; color: #fff; } #retakeBtn { background: #21262d; color: #e6edf3; } audio { height: 44px; } + #coach { + display: none; margin-top: 34px; max-width: 1000px; + background: #10151d; border: 1px solid #21262d; border-radius: 14px; + padding: 18px 22px; font-size: 16px; line-height: 1.7; + } + #coach.visible { display: block; } + #coach .hdr { font-size: 13px; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 8px; } + #coach .hdr b { color: #7ee787; } + #coach ul { list-style: none; } + #coach .transcript { margin-top: 10px; font-size: 13.5px; color: #8b949e; font-style: italic; } kbd { background: #161b22; border: 1px solid #30363d; border-radius: 5px; padding: 1px 7px; font-size: 12px; color: #8b949e; @@ -102,7 +112,10 @@

videoeditor · recorder

-
loading…
+
+
loading…
+
+
3