diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b40951e..ad3151c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,19 @@ jobs: - name: build run: just build + # recorder UI end-to-end: real binary, real Chromium, fake mic — the whole + # record → coach → keep → approve journey (see e2e/) + record-e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: DeterminateSystems/nix-installer-action@v16 + - uses: DeterminateSystems/magic-nix-cache-action@v9 + - name: playwright e2e + run: nix develop --command just e2e + # the preferred install path must stay green: sandboxed build from flake.lock nix: strategy: diff --git a/.gitignore b/.gitignore index 57bd070..aad9a6f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ analysis/ # videoeditor preview output template-previews/ + +# trunk build output (the committed copy lives in crates/videoeditor-record/ui) +crates/videoeditor-record-ui/dist/ +# playwright artifacts +e2e/test-results/ +e2e/playwright-report/ diff --git a/CLAUDE.md b/CLAUDE.md index db67a3d..b4cede8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,21 @@ 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 + (tiny_http REST server); takes transcode via ffmpeg into `audio/clips/`. + Serves the UI from `ui/` — a COMMITTED trunk build of the next crate. + Never edit `ui/` by hand; `just e2e` regenerates it automatically and + `just check` fails if it's stale vs the UI source (`ui/.source-hash`). + This crate publishes to crates.io WITH the dist — installs need no wasm. +- `crates/videoeditor-record-ui` — the recorder front end: Leptos (CSR) → + wasm via trunk. `publish = false`: not standalone, its build output + ships inside videoeditor-record. `nix develop` provides + trunk/wasm-bindgen-cli (the `wasm-bindgen` crate pin must match the CLI + version — bump together). Regenerate the dist only via the nix + toolchain (`nix develop --command just ui`) so bytes stay stable. +- `e2e/` — Playwright suite for the recorder (`just e2e`, rebuilds the UI + first): drives the real binary + real Chromium with a fake mic through + record → coach → keep → approve. UI changes are not done until it passes. - `examples/hello-bench` — smallest end-to-end episode; keep it rendering. ## Commands diff --git a/Cargo.lock b/Cargo.lock index 82626ad..9d59c9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -58,18 +67,93 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + [[package]] name = "anyhow" 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 = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.10.4" @@ -79,6 +163,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -91,6 +181,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + [[package]] name = "cc" version = "1.2.66" @@ -107,6 +203,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" @@ -147,12 +249,121 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451d0640545a0553814b4c646eb549343561618838e9b42495f466131fe3ad49" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -171,6 +382,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -181,12 +398,37 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -208,6 +450,61 @@ dependencies = [ "syn", ] +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "either_of" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216d23e0ec69759a17f05e1c553f3a6870e5ec73420fbb07807a6f34d5d1d5a4" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -233,6 +530,94 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -254,12 +639,95 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + [[package]] name = "http" version = "1.4.2" @@ -276,6 +744,26 @@ 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 = "hydration_context" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -397,36 +885,252 @@ dependencies = [ "quote", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leptos" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c202a7897aa73c90ac4ce73713512d871f75e79847558d9e88f778659b164dc" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom 0.3.4", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.18", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74eec2103dfa808f4b13c149dfbd3842f13a5948489fda3de31cc565fb28dbec" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b845379c33884f0dead9abb5aa3d258d7bd507789fc2527a5972f82c0757c7" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae732329192df886803f076515d73c883166a4c8cbc5532584d0d1e43539300" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e32ae8783d4b64838167e026ef773dbc53399e9e6658e9c2f65e0ce67a5ccec" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.8.0", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26851048e161998b8f9fb3261a833ec64df2a2669ea95f360c54b7f47a1e07cb" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "memchr" version = "2.8.2" @@ -443,6 +1147,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "oco_ref" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b94982fe39a861561cf67ff17a7849f2cedadbbad960a797634032b7abb998" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -455,12 +1175,85 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "potential_utf" version = "0.1.5" @@ -479,6 +1272,49 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -488,6 +1324,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.46" @@ -497,6 +1346,40 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -524,9 +1407,99 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "reactive_graph" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c79f7561c7c246e2c2feab3b0b63502b213e589d7768032bbfc5ea7b267140b9" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "or_poisoned", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", ] +[[package]] +name = "reactive_stores" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edd67f166cfec4ffc6abf271bada268c13dac0b0318f4c6a76a877490d0617a" +dependencies = [ + "dashmap", + "guardian", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2418b703f385f35481737d8f2ccd9ae3de850dd778bce09817f30338f3cb5ca2" +dependencies = [ + "convert_case 0.8.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -535,12 +1508,42 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.41" @@ -576,6 +1579,42 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + [[package]] name = "serde" version = "1.0.228" @@ -619,6 +1658,84 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "server_fn" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c27fbd25ecc066481e383e2ed62ab2480e708aa3fe46cba36e95f58e61dfd04" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http", + "js-sys", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.18", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -642,6 +1759,21 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -677,6 +1809,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -688,13 +1832,56 @@ dependencies = [ "syn", ] +[[package]] +name = "tachys" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d706b2ab0fa7e14b878d638faee3dafed08a2ab9ff7f116ad2445cce04bfaaa" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -708,6 +1895,38 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "throw_error" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e42a6afdde94f3e656fae18f837cb9bbe500a5ac5de325b09f3ec05b9c28e3" +dependencies = [ + "pin-project-lite", +] + +[[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" @@ -718,6 +1937,37 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tungstenite" version = "0.24.0" @@ -732,10 +1982,30 @@ dependencies = [ "log", "rand", "sha1", - "thiserror", + "thiserror 1.0.69", "utf-8", ] +[[package]] +name = "typed-builder" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.20.1" @@ -748,6 +2018,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -802,6 +2084,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -821,6 +2114,7 @@ dependencies = [ "videoeditor-chrome", "videoeditor-genai", "videoeditor-media", + "videoeditor-record", "videoeditor-timeline", "videoeditor-voice", ] @@ -856,6 +2150,33 @@ dependencies = [ "videoeditor-timeline", ] +[[package]] +name = "videoeditor-record" +version = "0.1.1" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "tiny_http", + "videoeditor-media", + "videoeditor-timeline", + "videoeditor-voice", +] + +[[package]] +name = "videoeditor-record-ui" +version = "0.1.1" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "leptos", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "videoeditor-timeline" version = "0.1.1" @@ -876,12 +2197,109 @@ dependencies = [ "videoeditor-timeline", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -900,6 +2318,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -988,12 +2415,39 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" 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-ui/Cargo.toml b/crates/videoeditor-record-ui/Cargo.toml new file mode 100644 index 0000000..44ea2f5 --- /dev/null +++ b/crates/videoeditor-record-ui/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "videoeditor-record-ui" +description = "Leptos (CSR/WASM) front end for the videoeditor narration recorder — built with trunk, embedded and served by videoeditor-record" +# not a standalone crate: its BUILD OUTPUT ships inside videoeditor-record +# (committed ui/ dist), so consumers get it from there, never from here +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +leptos = { version = "0.8", features = ["csr"] } +serde.workspace = true +serde_json.workspace = true +# pinned to the wasm-bindgen-cli version the nix devshell provides (trunk +# refuses mismatched versions); bump both together +wasm-bindgen = "=0.2.121" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +console_error_panic_hook = "0.1" +web-sys = { version = "0.3", features = [ + "AnalyserNode", + "AudioContext", + "AudioNode", + "Blob", + "BlobEvent", + "BlobPropertyBag", + "ChannelSplitterNode", + "Headers", + "HtmlAudioElement", + "HtmlSelectElement", + "MediaDeviceInfo", + "MediaDeviceKind", + "MediaDevices", + "MediaRecorder", + "MediaRecorderOptions", + "MediaStream", + "MediaStreamAudioSourceNode", + "MediaStreamConstraints", + "MediaStreamTrack", + "MediaTrackConstraints", + "MediaTrackSettings", + "Navigator", + "Performance", + "Request", + "RequestInit", + "Response", + "Url", + "Window", +] } diff --git a/crates/videoeditor-record-ui/Trunk.toml b/crates/videoeditor-record-ui/Trunk.toml new file mode 100644 index 0000000..87215f9 --- /dev/null +++ b/crates/videoeditor-record-ui/Trunk.toml @@ -0,0 +1,12 @@ +# Builds the recorder UI to static assets. Regenerate the committed dist +# that videoeditor-record embeds with: just ui +[build] +target = "index.html" +release = true +# stable filenames — videoeditor-record's include_bytes! table names them +filehash = false + +[tools] +# match the wasm-bindgen crate pin in Cargo.toml AND the nix devshell's +# wasm-bindgen-cli; bump all three together +wasm_bindgen = "0.2.121" diff --git a/crates/videoeditor-record-ui/index.html b/crates/videoeditor-record-ui/index.html new file mode 100644 index 0000000..a396b60 --- /dev/null +++ b/crates/videoeditor-record-ui/index.html @@ -0,0 +1,10 @@ + + + + +videoeditor · recorder + + + + + diff --git a/crates/videoeditor-record-ui/src/api.rs b/crates/videoeditor-record-ui/src/api.rs new file mode 100644 index 0000000..cbe0e5f --- /dev/null +++ b/crates/videoeditor-record-ui/src/api.rs @@ -0,0 +1,68 @@ +//! Thin fetch client over the videoeditor-record REST API. + +use crate::types::{Episode, Review, TakeInfo, TakeResponse}; +use serde::de::DeserializeOwned; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Blob, Headers, Request, RequestInit, Response}; + +fn err(v: JsValue) -> String { + v.as_string().unwrap_or_else(|| format!("{v:?}")) +} + +async fn send(url: &str, method: &str, blob: Option<&Blob>) -> Result { + let init = RequestInit::new(); + init.set_method(method); + if let Some(b) = blob { + let headers = Headers::new().map_err(err)?; + headers.set("content-type", &b.type_()).map_err(err)?; + init.set_headers(&headers); + init.set_body(b); + } + let req = Request::new_with_str_and_init(url, &init).map_err(err)?; + let window = web_sys::window().ok_or("no window")?; + let resp: Response = JsFuture::from(window.fetch_with_request(&req)) + .await + .map_err(err)? + .dyn_into() + .map_err(|_| "not a Response")?; + if !resp.ok() { + let text = JsFuture::from(resp.text().map_err(err)?) + .await + .map_err(err)?; + return Err(text + .as_string() + .unwrap_or_else(|| format!("http {}", resp.status()))); + } + Ok(resp) +} + +async fn json(resp: Response) -> Result { + let text = JsFuture::from(resp.text().map_err(err)?) + .await + .map_err(err)?; + serde_json::from_str(&text.as_string().ok_or("non-text body")?).map_err(|e| e.to_string()) +} + +pub async fn episode() -> Result { + json(send("/api/episode", "GET", None).await?).await +} + +pub async fn takes(id: &str) -> Result, String> { + json(send(&format!("/api/takes/{id}"), "GET", None).await?).await +} + +/// Archive + analyze a fresh take (the server stores it permanently). +pub async fn review(id: &str, blob: &Blob) -> Result { + json(send(&format!("/api/review/{id}"), "POST", Some(blob)).await?).await +} + +/// Promote an archived take to the clip's audio. +pub async fn approve(id: &str, file: &str) -> Result { + json(send(&format!("/api/approve/{id}/{file}"), "POST", None).await?).await +} + +/// Fallback: upload + approve in one shot (review never reached the server). +pub async fn upload_take(id: &str, blob: &Blob) -> Result { + json(send(&format!("/api/take/{id}"), "POST", Some(blob)).await?).await +} diff --git a/crates/videoeditor-record-ui/src/app.rs b/crates/videoeditor-record-ui/src/app.rs new file mode 100644 index 0000000..d30cb3b --- /dev/null +++ b/crates/videoeditor-record-ui/src/app.rs @@ -0,0 +1,790 @@ +//! The recorder app: three-column studio layout (clips | takes | stage) +//! with a state-aware stage. The mode is mirrored onto `` so +//! the stylesheet drives what each mode shows — same contract as the CSS. + +use crate::types::{Episode, Review, TakeInfo}; +use crate::{api, audio}; +use leptos::ev; +use leptos::prelude::*; +use leptos::task::spawn_local; +use std::cell::RefCell; +use wasm_bindgen::JsCast; + +thread_local! { + /// The just-recorded take (JS Blob is !Send, so it can't live in a signal). + static PENDING_BLOB: RefCell> = const { RefCell::new(None) }; + /// Countdown / record-timer interval ids so a new cycle clears the old. + static TICKERS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + Idle, + Countdown, + Recording, + Review, + Uploading, +} + +impl Mode { + fn class(self) -> &'static str { + match self { + Mode::Idle => "idle", + Mode::Countdown => "countdown", + Mode::Recording => "recording", + Mode::Review => "review", + Mode::Uploading => "uploading", + } + } +} + +/// Every signal the components share. Copy — hand it around freely. +#[derive(Clone, Copy)] +pub struct Store { + pub mode: RwSignal, + pub episode: RwSignal>, + pub current: RwSignal, + pub takes: RwSignal>, + pub takes_reload: RwSignal, + pub status: RwSignal, + /// Coach result for the just-recorded take (None while analyzing). + pub review: RwSignal>, + pub review_error: RwSignal>, + /// Archive number the review pass stored the pending take under. + pub pending_take: RwSignal>, + /// Expanded takes-rail row, keyed "/". + pub expanded: RwSignal>, + /// Audition url currently playing in the rail. + pub playing: RwSignal>, + pub countdown: RwSignal, + pub elapsed: RwSignal, + pub meter: RwSignal<(f64, f64)>, + pub devices: RwSignal>, + pub active_device: RwSignal, +} + +impl Store { + fn new() -> Self { + Self { + mode: RwSignal::new(Mode::Idle), + episode: RwSignal::new(None), + current: RwSignal::new(0), + takes: RwSignal::new(Vec::new()), + takes_reload: RwSignal::new(0), + status: RwSignal::new("pick a clip, hit space, read after the countdown".into()), + review: RwSignal::new(None), + review_error: RwSignal::new(None), + pending_take: RwSignal::new(None), + expanded: RwSignal::new(None), + playing: RwSignal::new(None), + countdown: RwSignal::new(3), + elapsed: RwSignal::new(0.0), + meter: RwSignal::new((0.0, 0.0)), + devices: RwSignal::new(Vec::new()), + active_device: RwSignal::new(String::new()), + } + } + + pub fn clip(&self, i: usize) -> Option { + self.episode + .with(|e| e.as_ref().and_then(|e| e.clips.get(i).cloned())) + } + + pub fn current_clip(&self) -> Option { + self.clip(self.current.get()) + } + + pub fn current_clip_untracked(&self) -> Option { + self.episode.with_untracked(|e| { + e.as_ref() + .and_then(|e| e.clips.get(self.current.get_untracked()).cloned()) + }) + } + + /// Switch clips: reset the transient review state, keep archive state. + pub fn select(&self, i: usize) { + self.current.set(i); + self.mode.set(Mode::Idle); + self.review.set(None); + self.review_error.set(None); + self.pending_take.set(None); + PENDING_BLOB.with(|b| *b.borrow_mut() = None); + let c = self.clip(i); + self.elapsed.set(0.0); + if let Some(c) = c { + let _ = c; + } + } +} + +pub fn fmt2(v: f64) -> String { + format!("{v:.2}") +} + +fn clear_tickers() { + TICKERS.with(|t| { + for id in t.borrow_mut().drain(..) { + audio::clear_interval(id); + } + }); +} + +fn begin_countdown(store: Store) { + clear_tickers(); + store.mode.set(Mode::Countdown); + store.review.set(None); + store.review_error.set(None); + store.pending_take.set(None); + store.playing.set(None); + PENDING_BLOB.with(|b| *b.borrow_mut() = None); + store.countdown.set(3); + let id = audio::set_interval(700, move || { + let n = store.countdown.get_untracked(); + if n <= 1 { + clear_tickers(); + start_recording(store); + } else { + store.countdown.set(n - 1); + } + }); + TICKERS.with(|t| t.borrow_mut().push(id)); +} + +fn start_recording(store: Store) { + let started = audio::start_recording(move |blob| on_take_recorded(store, blob)); + if let Err(e) = started { + store.mode.set(Mode::Idle); + store.status.set(format!("recording failed: {e}")); + return; + } + store.mode.set(Mode::Recording); + store.status.set("recording… space to stop".into()); + store.elapsed.set(0.0); + let t0 = audio::now_ms(); + let id = audio::set_interval(50, move || { + store.elapsed.set((audio::now_ms() - t0) / 1000.0); + }); + TICKERS.with(|t| t.borrow_mut().push(id)); +} + +fn stop_recording(store: Store) { + clear_tickers(); + audio::stop_recording(); + let _ = store; +} + +fn toggle_record(store: Store) { + match store.mode.get_untracked() { + Mode::Recording => stop_recording(store), + Mode::Idle | Mode::Review => begin_countdown(store), + _ => {} + } +} + +/// MediaRecorder finished: stash the blob, enter review, ship it to the +/// coach (which also archives it server-side — every take is kept). +fn on_take_recorded(store: Store, blob: web_sys::Blob) { + PENDING_BLOB.with(|b| *b.borrow_mut() = Some(blob.clone())); + store.mode.set(Mode::Review); + store.status.set("listen back — keep it or go again".into()); + if let Some(player) = player_element() { + if let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) { + player.set_src(&url); + } + } + let Some(c) = store.current_clip_untracked() else { + return; + }; + spawn_local(async move { + match api::review(&c.id, &blob).await { + Ok(r) => { + store.takes_reload.update(|n| *n += 1); + if store.mode.get_untracked() != Mode::Review { + return; // user already moved on + } + store.pending_take.set(Some(r.take)); + store.review.set(Some(r)); + } + Err(e) => store.review_error.set(Some(e)), + } + }); +} + +fn keep_take(store: Store) { + if store.mode.get_untracked() != Mode::Review { + return; + } + let Some(blob) = PENDING_BLOB.with(|b| b.borrow().clone()) else { + return; + }; + let Some(c) = store.current_clip_untracked() else { + return; + }; + store.mode.set(Mode::Uploading); + store.status.set("saving…".into()); + let pending = store.pending_take.get_untracked(); + spawn_local(async move { + // review already archived this take → approve it in place; otherwise + // (review still in flight or failed) upload the blob directly + let result = match pending { + Some(n) => api::approve(&c.id, &format!("take_{n:03}.mp3")).await, + None => api::upload_take(&c.id, &blob).await, + }; + match result { + Ok(info) => { + apply_take_response(store, &c.id, &info); + store.status.set(format!( + "saved {}s{}{}", + fmt2(info.duration), + if info.fits { + " ✓ fits".to_string() + } else { + format!( + " ⚠ over the {}s window — retake or stretch the scene", + fmt2(info.window) + ) + }, + if info.warnings.is_empty() { + String::new() + } else { + format!( + " · {} timeline warning(s), re-run tts fit-check", + info.warnings.len() + ) + }, + )); + store.select(store.current.get_untracked()); + store.takes_reload.update(|n| *n += 1); + } + Err(e) => { + store.status.set(format!("save failed: {e}")); + store.mode.set(Mode::Review); + } + } + }); +} + +fn retake(store: Store) { + if store.mode.get_untracked() == Mode::Review { + begin_countdown(store); + } +} + +/// Reflect an approve/keep response into the episode signal so the clip +/// list + header show the new current-take duration. +fn apply_take_response(store: Store, id: &str, info: &crate::types::TakeResponse) { + let id = id.to_string(); + let duration = info.duration; + store.episode.update(|e| { + if let Some(e) = e { + if let Some(c) = e.clips.iter_mut().find(|c| c.id == id) { + c.take_duration = Some(duration); + } + } + }); +} + +fn player_element() -> Option { + web_sys::window()? + .document()? + .get_element_by_id("player")? + .dyn_into() + .ok() +} + +fn audition_element() -> Option { + web_sys::window()? + .document()? + .get_element_by_id("audition")? + .dyn_into() + .ok() +} + +#[component] +pub fn App() -> impl IntoView { + let store = Store::new(); + + // mode → — CSS owns the mode presentation + Effect::new(move |_| { + let m = store.mode.get(); + if let Some(body) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.body()) + { + body.set_class_name(m.class()); + } + }); + + // boot: episode, mic, meters + Effect::new(move |_| { + spawn_local(async move { + match api::episode().await { + Ok(e) => { + store.episode.set(Some(e)); + store.select(0); + } + Err(e) => store.status.set(format!("episode load failed: {e}")), + } + match audio::init_mic(None).await { + Ok((devices, active)) => { + store + .devices + .set(devices.into_iter().map(|d| (d.id, d.label)).collect()); + store.active_device.set(active); + audio::start_meters(move |l, r| store.meter.set((l, r))); + } + Err(e) => store.status.set(format!("mic init failed: {e}")), + } + }); + }); + + // takes rail follows the selected clip (and explicit reloads) + Effect::new(move |_| { + let Some(c) = store.current_clip() else { + return; + }; + let _ = store.takes_reload.get(); + spawn_local(async move { + match api::takes(&c.id).await { + Ok(t) => { + // drop stale responses if the user switched clips mid-fetch + if store.current_clip_untracked().map(|cc| cc.id) == Some(c.id) { + store.takes.set(t); + } + } + Err(e) => store.status.set(format!("takes: {e}")), + } + }); + }); + + // audition player follows the `playing` signal + Effect::new(move |_| { + let url = store.playing.get(); + let Some(el) = audition_element() else { + return; + }; + match url { + Some(u) => { + el.set_src(&u); + let _ = el.play(); + } + None => el.pause().unwrap_or(()), + } + }); + + window_event_listener(ev::keydown, move |e: web_sys::KeyboardEvent| { + let idle = store.mode.get_untracked() == Mode::Idle; + match (e.code().as_str(), e.key().as_str()) { + ("Space", _) => { + e.prevent_default(); + toggle_record(store); + } + (_, "Enter") => keep_take(store), + (_, "r") => retake(store), + (_, "ArrowDown" | "ArrowRight") if idle => { + let max = store + .episode + .with_untracked(|e| e.as_ref().map(|e| e.clips.len()).unwrap_or(1)); + store.select((store.current.get_untracked() + 1).min(max.saturating_sub(1))); + } + (_, "ArrowUp" | "ArrowLeft") if idle => { + store.select(store.current.get_untracked().saturating_sub(1)); + } + _ => {} + } + }); + + view! { + + +
+ + + +
+
{move || store.countdown.get()}
+ } +} + +#[component] +fn ClipsRail(store: Store) -> impl IntoView { + view! { + + } +} + +/// One-line comparison stats from a take's saved coach report. +fn take_meta(r: &Review) -> String { + let mut m = Vec::new(); + if let Some(acc) = r.accuracy_pct { + m.push(format!("{acc:.0}% script")); + } + if let Some(wps) = r.wps { + m.push(format!("{wps:.1} w/s")); + } + m.push(format!("peak {:.0}dB", r.max_db)); + if r.clipped { + m.push("🔴 clipped".into()); + } + if !r.fits { + m.push("⚠ long".into()); + } + if !r.pauses.is_empty() { + m.push(format!( + "💀 {} pause{}", + r.pauses.len(), + if r.pauses.len() > 1 { "s" } else { "" } + )); + } + m.join(" · ") +} + +#[component] +fn TakesRail(store: Store) -> impl IntoView { + view! { + + } +} + +#[component] +fn TakeRow( + store: Store, + take: TakeInfo, + key: String, + url: String, + clip_id: String, +) -> impl IntoView { + let file = take.file.clone(); + let name = file.trim_end_matches(".mp3").to_string(); + let meta = take.review.as_ref().map(take_meta).unwrap_or_default(); + let coaching = take + .review + .as_ref() + .map(|r| r.coaching.clone()) + .unwrap_or_default(); + let transcript = take.review.as_ref().and_then(|r| r.transcript.clone()); + let has_review = take.review.is_some(); + let approved = take.approved; + let key_open = key.clone(); + let key_click = key.clone(); + let url_play = url.clone(); + let file_approve = file.clone(); + + view! { +
+
+ "›" + {name} + {format!("{}s", fmt2(take.duration))} + + {if approved { + view! { "✓" }.into_any() + } else { + view! { + + } + .into_any() + }} +
+ {(!meta.is_empty()).then(|| view! {
{meta}
})} +
+
+ {if has_review { + view! { +
    + {coaching.into_iter().map(|n| view! {
  • {n}
  • }).collect_view()} +
+ {transcript.map(|t| view! {
{format!("heard: “{t}”")}
})} + } + .into_any() + } else { + view! {
"no analysis saved for this take"
}.into_any() + }} +
+
+
+ } +} + +#[component] +fn StageHeader(store: Store) -> impl IntoView { + view! { +
+
+ {move || store.current_clip().map(|c| format!("{} / {}", c.scene, c.clip)).unwrap_or("—".into())} +
+
+ {move || { + store + .current_clip() + .map(|c| { + format!( + "window {}s at tempo {}{}", + fmt2(c.window), + c.tempo, + match c.take_duration { + Some(d) => format!(" · current take {}s", fmt2(d)), + None => " · no take yet".into(), + }, + ) + }) + .unwrap_or_default() + }} +
+ +
+ } +} + +#[component] +fn Stage(store: Store) -> impl IntoView { + view! { +
+
+
+ {move || store.current_clip().map(|c| c.text).unwrap_or("loading…".into())} +
+ +
+
+ } +} + +#[component] +fn ReviewCard(store: Store) -> impl IntoView { + view! { +
+
+ {move || match (store.review.get(), store.review_error.get()) { + (Some(r), _) => { + let acc = r.accuracy_pct.map(|a| format!(" · script {a:.0}%")).unwrap_or_default(); + let pace = r.wps.map(|w| format!(" · {w:.1} w/s")).unwrap_or_default(); + view! { + "COACH · take " {format!("{:03}", r.take)} " · " + {format!("{}s", fmt2(r.duration))} + {format!(" / {}s window{acc}{pace} · peak {:.1} dB", fmt2(r.window), r.max_db)} + } + .into_any() + } + (None, Some(e)) => view! { "COACH · unavailable (" {e} ")" }.into_any(), + (None, None) => view! { "COACH · analyzing take…" }.into_any(), + }} +
+
    + {move || { + store + .review + .get() + .map(|r| r.coaching.into_iter().map(|n| view! {
  • {n}
  • }).collect_view()) + }} +
+
+ {move || { + store + .review + .get() + .and_then(|r| r.transcript) + .map(|t| format!("heard: “{t}”")) + }} +
+
+ + + +
+
+ } +} + +#[component] +fn FooterBar(store: Store) -> impl IntoView { + let limit = move || { + store + .current_clip() + .map(|c| c.window * c.tempo) + .unwrap_or(0.0) + }; + view! { +
+
+
+ "L" +
+
0.85 { "fill hot" } else { "fill" }} + style:width=move || format!("{}%", (store.meter.get().0 * 130.0).min(100.0)) + >
+
+
+
+ "R" +
+
0.85 { "fill hot" } else { "fill" }} + style:width=move || format!("{}%", (store.meter.get().1 * 130.0).min(100.0)) + >
+
+
+
+
+ +
limit() && limit() > 0.0 { "over" } else { "" }}> + {move || fmt2(store.elapsed.get())} + {move || format!(" / {}", fmt2(limit()))} +
+
{move || store.status.get()}
+
+
+ "space" " record / stop · " "enter" " keep · " "r" " retake · " + "↑""↓" " switch clip · raw stereo mic (no browser processing) — watch the meters, stay out of the red · every take is archived in audio/takes/, kept or not" +
+
+ } +} diff --git a/crates/videoeditor-record-ui/src/audio.rs b/crates/videoeditor-record-ui/src/audio.rs new file mode 100644 index 0000000..d3a0708 --- /dev/null +++ b/crates/videoeditor-record-ui/src/audio.rs @@ -0,0 +1,258 @@ +//! Browser audio interop: mic capture, L/R analysers, MediaRecorder, and +//! the timer/raf plumbing. JS handles are !Send, so they live in +//! thread-locals (Leptos CSR is single-threaded). + +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + AnalyserNode, AudioContext, Blob, BlobEvent, BlobPropertyBag, MediaRecorder, + MediaRecorderOptions, MediaStream, MediaStreamConstraints, MediaStreamTrack, +}; + +/// A live MediaRecorder and the event closures keeping its handlers alive — +/// replaced wholesale each recording, which drops the previous set. +type RecorderSlot = ( + MediaRecorder, + Closure, + Closure, +); +type IntervalSlot = (i32, Closure); +type RafClosure = Rc>>>; + +thread_local! { + static STREAM: RefCell> = const { RefCell::new(None) }; + static AUDIO_CTX: RefCell> = const { RefCell::new(None) }; + static ANALYSERS: RefCell> = const { RefCell::new(None) }; + static RECORDER: RefCell> = const { RefCell::new(None) }; + /// Countdown/record-timer interval closures, keyed by interval id. + static INTERVALS: RefCell> = const { RefCell::new(Vec::new()) }; + static METERS_RUNNING: RefCell = const { RefCell::new(false) }; +} + +fn err(v: JsValue) -> String { + v.as_string().unwrap_or_else(|| format!("{v:?}")) +} + +fn window() -> web_sys::Window { + web_sys::window().expect("no window") +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MicDevice { + pub id: String, + pub label: String, +} + +/// (Re)open the mic and wire the stereo analyser pair. Returns the input +/// device list and the id of the active device. +pub async fn init_mic(device_id: Option) -> Result<(Vec, String), String> { + // stop the previous stream's tracks before opening a new one + STREAM.with(|s| { + if let Some(old) = s.borrow_mut().take() { + for t in old.get_tracks().iter() { + if let Ok(track) = t.dyn_into::() { + track.stop(); + } + } + } + }); + + // { audio: { channelCount: 2, echoCancellation: false, ... } } — + // raw stereo voice, no call-style processing + let audio = js_sys::Object::new(); + let set = |k: &str, v: JsValue| { + js_sys::Reflect::set(&audio, &k.into(), &v).expect("object set"); + }; + set("channelCount", 2.into()); + set("echoCancellation", false.into()); + set("noiseSuppression", false.into()); + set("autoGainControl", false.into()); + if let Some(id) = &device_id { + let exact = js_sys::Object::new(); + js_sys::Reflect::set(&exact, &"exact".into(), &id.as_str().into()).expect("object set"); + set("deviceId", exact.into()); + } + let constraints = MediaStreamConstraints::new(); + constraints.set_audio(&audio.into()); + + let devices_api = window().navigator().media_devices().map_err(err)?; + let stream: MediaStream = JsFuture::from( + devices_api + .get_user_media_with_constraints(&constraints) + .map_err(err)?, + ) + .await + .map_err(err)? + .dyn_into() + .map_err(|_| "getUserMedia returned a non-stream")?; + + let ctx = AUDIO_CTX.with(|c| { + let mut c = c.borrow_mut(); + if c.is_none() { + *c = AudioContext::new().ok(); + } + c.clone() + }); + let ctx = ctx.ok_or("no AudioContext")?; + let splitter = ctx + .create_channel_splitter_with_number_of_outputs(2) + .map_err(err)?; + let left = ctx.create_analyser().map_err(err)?; + let right = ctx.create_analyser().map_err(err)?; + left.set_fft_size(1024); + right.set_fft_size(1024); + let source = ctx.create_media_stream_source(&stream).map_err(err)?; + source.connect_with_audio_node(&splitter).map_err(err)?; + splitter + .connect_with_audio_node_and_output(&left, 0) + .map_err(err)?; + splitter + .connect_with_audio_node_and_output(&right, 1) + .map_err(err)?; + + let active = stream + .get_audio_tracks() + .get(0) + .dyn_into::() + .ok() + .map(|t| { + js_sys::Reflect::get(&t.get_settings(), &"deviceId".into()) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + + STREAM.with(|s| *s.borrow_mut() = Some(stream)); + ANALYSERS.with(|a| *a.borrow_mut() = Some((left, right))); + + // enumerate AFTER permission is granted so labels are populated + let list = JsFuture::from(devices_api.enumerate_devices().map_err(err)?) + .await + .map_err(err)?; + let mut devices = Vec::new(); + for d in js_sys::Array::from(&list).iter() { + if let Ok(info) = d.dyn_into::() { + if info.kind() == web_sys::MediaDeviceKind::Audioinput { + let label = info.label(); + devices.push(MicDevice { + id: info.device_id(), + label: if label.is_empty() { + "microphone".into() + } else { + label + }, + }); + } + } + } + Ok((devices, active)) +} + +/// Start the requestAnimationFrame meter loop (idempotent). Calls +/// `on_levels(peak_left, peak_right)` with 0..=1 peaks every frame. +pub fn start_meters(on_levels: impl Fn(f64, f64) + 'static) { + let already = METERS_RUNNING.with(|m| std::mem::replace(&mut *m.borrow_mut(), true)); + if already { + return; + } + let mut buf = [0.0f32; 1024]; + let cb: RafClosure = Rc::new(RefCell::new(None)); + let cb2 = cb.clone(); + *cb.borrow_mut() = Some(Closure::new(move || { + let peaks = ANALYSERS.with(|a| { + a.borrow().as_ref().map(|(l, r)| { + let mut peak = |an: &AnalyserNode| { + an.get_float_time_domain_data(&mut buf); + buf.iter().fold(0.0f32, |m, v| m.max(v.abs())) as f64 + }; + (peak(l), peak(r)) + }) + }); + if let Some((l, r)) = peaks { + on_levels(l, r); + } + raf(cb2.borrow().as_ref().expect("meter closure")); + })); + raf(cb.borrow().as_ref().expect("meter closure")); +} + +fn raf(c: &Closure) { + window() + .request_animation_frame(c.as_ref().unchecked_ref()) + .expect("requestAnimationFrame"); +} + +/// Start recording the current mic stream; `on_stop` receives the full +/// take as a single Blob when recording ends. +pub fn start_recording(on_stop: impl Fn(Blob) + 'static) -> Result<(), String> { + let stream = STREAM + .with(|s| s.borrow().clone()) + .ok_or("mic not initialized")?; + let mime = if MediaRecorder::is_type_supported("audio/webm;codecs=opus") { + "audio/webm;codecs=opus" + } else { + "audio/mp4" + }; + let opts = MediaRecorderOptions::new(); + opts.set_mime_type(mime); + let rec = MediaRecorder::new_with_media_stream_and_media_recorder_options(&stream, &opts) + .map_err(err)?; + + let chunks: Rc>> = Rc::new(RefCell::new(Vec::new())); + let chunks2 = chunks.clone(); + let on_data = Closure::new(move |e: BlobEvent| { + if let Some(b) = e.data() { + chunks2.borrow_mut().push(b.into()); + } + }); + rec.set_ondataavailable(Some(on_data.as_ref().unchecked_ref())); + + let mime_owned = mime.to_string(); + let on_stop_cl = Closure::new(move |_: web_sys::Event| { + let parts = js_sys::Array::new(); + for c in chunks.borrow().iter() { + parts.push(c); + } + let bag = BlobPropertyBag::new(); + bag.set_type(&mime_owned); + if let Ok(blob) = Blob::new_with_blob_sequence_and_options(&parts, &bag) { + on_stop(blob); + } + }); + rec.set_onstop(Some(on_stop_cl.as_ref().unchecked_ref())); + + rec.start().map_err(err)?; + RECORDER.with(|r| *r.borrow_mut() = Some((rec, on_data, on_stop_cl))); + Ok(()) +} + +pub fn stop_recording() { + RECORDER.with(|r| { + if let Some((rec, ..)) = r.borrow().as_ref() { + let _ = rec.stop(); + } + }); +} + +/// setInterval that owns its closure (cleared + dropped via [`clear_interval`]). +pub fn set_interval(ms: i32, f: impl FnMut() + 'static) -> i32 { + let cb: Closure = Closure::new(f); + let id = window() + .set_interval_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), ms) + .expect("setInterval"); + INTERVALS.with(|i| i.borrow_mut().push((id, cb))); + id +} + +pub fn clear_interval(id: i32) { + window().clear_interval_with_handle(id); + INTERVALS.with(|i| i.borrow_mut().retain(|(iid, _)| *iid != id)); +} + +pub fn now_ms() -> f64 { + window().performance().map(|p| p.now()).unwrap_or_default() +} diff --git a/crates/videoeditor-record-ui/src/lib.rs b/crates/videoeditor-record-ui/src/lib.rs new file mode 100644 index 0000000..5f9f516 --- /dev/null +++ b/crates/videoeditor-record-ui/src/lib.rs @@ -0,0 +1,11 @@ +//! Leptos (CSR) front end for `videoeditor record` — the narration +//! recorder's teleprompter, takes rail, and coach UI. Built to static +//! assets with trunk; `videoeditor-record` embeds the committed dist and +//! serves it, so the shipped CLI stays a single self-contained binary. + +pub mod api; +pub mod app; +pub mod audio; +pub mod types; + +pub use app::App; diff --git a/crates/videoeditor-record-ui/src/main.rs b/crates/videoeditor-record-ui/src/main.rs new file mode 100644 index 0000000..7cd81c3 --- /dev/null +++ b/crates/videoeditor-record-ui/src/main.rs @@ -0,0 +1,4 @@ +fn main() { + console_error_panic_hook::set_once(); + leptos::mount::mount_to_body(videoeditor_record_ui::App); +} diff --git a/crates/videoeditor-record-ui/src/types.rs b/crates/videoeditor-record-ui/src/types.rs new file mode 100644 index 0000000..bbebefc --- /dev/null +++ b/crates/videoeditor-record-ui/src/types.rs @@ -0,0 +1,73 @@ +//! Mirrors of the JSON types `videoeditor-record` serves. Kept as a copy — +//! a shared crate would drag the server's native deps (tiny_http, ffmpeg +//! shell-outs) into the wasm build. The e2e suite exercises every field, +//! so drift breaks loudly. + +use serde::Deserialize; + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct Clip { + pub id: String, + pub scene: String, + pub clip: String, + pub text: String, + pub at: f64, + pub tempo: f64, + pub scene_duration: f64, + pub window: f64, + pub take_duration: Option, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct Episode { + pub title: String, + pub clips: Vec, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct Pause { + pub at: f64, + pub len: f64, +} + +/// The coach's report on one take. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct Review { + pub take: u32, + pub duration: f64, + pub window: f64, + pub fits: bool, + pub mean_db: f64, + pub max_db: f64, + pub clipped: bool, + pub transcript: Option, + pub accuracy_pct: Option, + #[serde(default)] + pub missing: Vec, + #[serde(default)] + pub added: Vec, + pub wps: Option, + #[serde(default)] + pub pauses: Vec, + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub coaching: Vec, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct TakeInfo { + pub file: String, + pub duration: f64, + pub approved: bool, + pub review: Option, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct TakeResponse { + pub duration: f64, + pub window: f64, + pub fits: bool, + #[serde(default)] + pub warnings: Vec, +} diff --git a/crates/videoeditor-record-ui/style.css b/crates/videoeditor-record-ui/style.css new file mode 100644 index 0000000..f294c26 --- /dev/null +++ b/crates/videoeditor-record-ui/style.css @@ -0,0 +1,136 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + background: #0a0a10; color: #e6edf3; height: 100vh; display: flex; + font-family: -apple-system, 'Segoe UI', sans-serif; overflow: hidden; +} +button { font: inherit; cursor: pointer; border: none; border-radius: 10px; } + +/* ── left rails: clips, then takes ─────────────────────────────────── */ +aside { + border-right: 1px solid #21262d; display: flex; flex-direction: column; + transition: opacity 0.25s; +} +aside.clips { width: 280px; min-width: 280px; } +aside.rail { width: 320px; min-width: 320px; } +aside header { padding: 18px 20px; border-bottom: 1px solid #21262d; } +aside header h1 { font-size: 13px; color: #7ee787; letter-spacing: 0.06em; text-transform: uppercase; } +aside header .ep { font-size: 12.5px; color: #8b949e; margin-top: 4px; } +#clipList, #takesList { overflow-y: auto; flex: 1; } + +.clip-row { + padding: 13px 20px; border-bottom: 1px solid #161b22; cursor: pointer; + display: flex; align-items: center; gap: 10px; +} +.clip-row:hover { background: #11151c; } +.clip-row.active { background: #16202b; box-shadow: inset 3px 0 0 #7ee787; } +.dot { width: 10px; height: 10px; border-radius: 50%; background: #30363d; flex-shrink: 0; } +.dot.has-take { background: #7ee787; } +.dot.too-long { background: #f0883e; } +.clip-row .name { font-size: 14px; font-weight: 600; flex: 1; } +.clip-row .len { font-size: 12px; color: #8b949e; font-variant-numeric: tabular-nums; } + +.take-row { padding: 10px 16px; border-bottom: 1px solid #161b22; cursor: pointer; } +.take-row:hover { background: #11151c; } +.take-row.approved { background: #10231a; box-shadow: inset 3px 0 0 #7ee787; } +.take-row .top { display: flex; align-items: center; gap: 8px; } +.take-row .chev { + color: #58606a; font-size: 13px; width: 12px; flex-shrink: 0; + transition: transform 0.25s ease; +} +.take-row.open .chev { transform: rotate(90deg); } +.take-row .file { font-size: 13px; font-weight: 600; flex: 1; } +.take-row .dur { font-size: 12px; color: #8b949e; font-variant-numeric: tabular-nums; } +.take-row .meta { margin-top: 5px; font-size: 12px; color: #8b949e; line-height: 1.5; padding-left: 20px; } +.take-row button { padding: 3px 10px; font-size: 12px; border-radius: 6px; } +.take-row .play { background: #21262d; color: #e6edf3; } +.take-row .approve { background: #238636; color: #fff; } +.take-row .badge { color: #7ee787; font-weight: 700; } + +/* disclosure: the review unfolds inside the row (0fr → 1fr animates height) */ +.take-detail { + display: grid; grid-template-rows: 0fr; + transition: grid-template-rows 0.28s ease; +} +.take-detail .detail-inner { overflow: hidden; min-height: 0; padding-left: 20px; } +.take-row.open .take-detail { grid-template-rows: 1fr; } +.detail-inner .coaching { list-style: none; margin-top: 8px; font-size: 13px; line-height: 1.7; color: #c9d1d9; } +.detail-inner .transcript { margin-top: 8px; font-size: 12.5px; color: #8b949e; font-style: italic; } +.detail-inner .no-review { margin-top: 8px; font-size: 12.5px; color: #58606a; } +.rail-empty { padding: 16px 20px; font-size: 13px; color: #58606a; display: none; } + +/* recording / countdown: nothing competes with the script */ +body.recording aside, body.countdown aside { opacity: 0.2; pointer-events: none; } + +/* ── stage: teleprompter, review card ──────────────────────────────── */ +main { flex: 1; display: flex; flex-direction: column; min-width: 0; } +main > header { + display: flex; align-items: center; gap: 16px; + padding: 14px 26px; border-bottom: 1px solid #21262d; +} +main > header .which { font-size: 15px; font-weight: 700; } +main > header .window { font-size: 13px; color: #8b949e; } +select { + margin-left: auto; background: #161b22; color: #e6edf3; + border: 1px solid #30363d; border-radius: 8px; padding: 6px 10px; font-size: 13px; + max-width: 340px; +} + +#stage { flex: 1; display: flex; padding: 40px 6%; overflow-y: auto; } +#stage .inner { margin: auto; width: 100%; max-width: 1000px; } +#promptText { + font-size: 42px; line-height: 1.55; font-weight: 600; + color: #f0f6fc; text-wrap: balance; transition: font-size 0.2s, color 0.2s; +} +/* review: script shrinks + dims but NEVER hides — read along while listening */ +body.review #promptText, body.uploading #promptText { font-size: 21px; color: #8b949e; } + +#reviewCard { + display: none; margin-top: 26px; background: #10151d; border: 1px solid #21262d; + border-radius: 14px; padding: 20px 24px; font-size: 16px; line-height: 1.7; +} +body.review #reviewCard, body.uploading #reviewCard { display: block; } +#reviewCard .hdr { font-size: 13px; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 8px; } +#reviewCard .hdr b { color: #7ee787; } +#coachNotes { list-style: none; } +#transcript { margin-top: 10px; font-size: 13.5px; color: #8b949e; font-style: italic; } +.review-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; } +.review-actions audio { height: 44px; flex: 1; min-width: 0; } +.review-actions button { padding: 14px 26px; font-size: 16px; font-weight: 700; } +#keepBtn { background: #238636; color: #fff; } +#retakeBtn { background: #21262d; color: #e6edf3; } +body.uploading .review-actions { pointer-events: none; opacity: 0.6; } + +#countdown { + position: fixed; inset: 0; display: none; align-items: center; justify-content: center; + background: rgba(10,10,16,0.88); font-size: 220px; font-weight: 800; color: #7ee787; + z-index: 10; +} +body.countdown #countdown { display: flex; } + +/* ── footer: meters, transport ─────────────────────────────────────── */ +footer { border-top: 1px solid #21262d; padding: 18px 26px 22px; } +#meter { display: flex; flex-direction: column; gap: 3px; margin-bottom: 14px; } +.chan { display: flex; align-items: center; gap: 8px; } +.chan .lbl { font-size: 10px; color: #58606a; width: 10px; font-weight: 700; } +.chan .track { flex: 1; height: 8px; background: #161b22; border-radius: 4px; overflow: hidden; } +.chan .fill { height: 100%; width: 0%; background: #7ee787; transition: width 60ms linear; } +.chan .fill.hot { background: #ff5252; } +.controls { display: flex; align-items: center; gap: 18px; } +#recBtn { + width: 84px; height: 84px; border-radius: 50%; background: #da3633; color: #fff; + font-size: 30px; box-shadow: 0 0 0 6px rgba(218,54,51,0.25); +} +body.recording #recBtn { + background: #161b22; color: #ff5252; + box-shadow: 0 0 0 6px rgba(255,82,82,0.5); animation: pulse 1.2s infinite; +} +@keyframes pulse { 50% { box-shadow: 0 0 0 12px rgba(255,82,82,0.15); } } +#timer { font-size: 34px; font-variant-numeric: tabular-nums; font-weight: 700; min-width: 190px; } +#timer .lim { font-size: 18px; color: #8b949e; font-weight: 500; } +#timer.over { color: #ff5252; } +#status { font-size: 14px; color: #8b949e; flex: 1; } +kbd { + background: #161b22; border: 1px solid #30363d; border-radius: 5px; + padding: 1px 7px; font-size: 12px; color: #8b949e; +} +.hints { margin-top: 12px; font-size: 12.5px; color: #58606a; } diff --git a/crates/videoeditor-record/Cargo.toml b/crates/videoeditor-record/Cargo.toml new file mode 100644 index 0000000..d7f29f9 --- /dev/null +++ b/crates/videoeditor-record/Cargo.toml @@ -0,0 +1,20 @@ +[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 +videoeditor-voice.workspace = true diff --git a/crates/videoeditor-record/src/lib.rs b/crates/videoeditor-record/src/lib.rs new file mode 100644 index 0000000..fa8a22c --- /dev/null +++ b/crates/videoeditor-record/src/lib.rs @@ -0,0 +1,842 @@ +//! Local web recorder: `videoeditor record ` serves a +//! teleprompter + mic-capture UI on localhost (a Leptos/WASM app from the +//! `videoeditor-record-ui` crate, embedded as committed static assets), +//! 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 stereo mp3 44.1 kHz — final assembly normalizes every +//! narration input to stereo before mixing, so stereo and mono TTS +//! clips coexist fine. +//! +//! Every take — kept or not — is archived under `audio/takes//` +//! the moment it is recorded, and a keep archives the clip it replaces, +//! so no audio is ever lost to a retake, and each take's coach report is +//! saved beside it (`take_NNN.json`). The UI lists a clip's archived +//! takes with their stats for audition; approving one copies it to +//! `audio/clips/` and records the pick in `audio/takes/approvals.json`. + +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use videoeditor_timeline::{Clip, ClipInfo, Episode, Scene}; + +/// The whole front end, embedded: the committed trunk build of the +/// `videoeditor-record-ui` Leptos (CSR/WASM) crate — the shipped binary +/// stays self-contained and installing from crates.io needs no wasm +/// toolchain. Regenerate after UI changes with `just ui`. +const ASSETS: &[(&str, &[u8], &str)] = &[ + ( + "/", + include_bytes!("../ui/index.html"), + "text/html; charset=utf-8", + ), + ( + "/style.css", + include_bytes!("../ui/style.css"), + "text/css; charset=utf-8", + ), + ( + "/videoeditor-record-ui.js", + include_bytes!("../ui/videoeditor-record-ui.js"), + "text/javascript; charset=utf-8", + ), + ( + "/videoeditor-record-ui_bg.wasm", + include_bytes!("../ui/videoeditor-record-ui_bg.wasm"), + "application/wasm", + ), +]; + +#[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; every take (kept or not) 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, path) if ASSETS.iter().any(|(p, ..)| *p == path) => { + let (_, body, mime) = ASSETS + .iter() + .find(|(p, ..)| *p == path) + .expect("just found"); + Ok(tiny_http::Response::from_data(body.to_vec()) + .with_header(header("content-type", mime))) + } + (Get, "/api/episode") => Ok(json(&episode_view(ep)?)), + (Get, path) if path.starts_with("/api/takes/") => { + let id = sanitize_id(&path["/api/takes/".len()..])?; + Ok(json(&list_takes(ep, &id)?)) + } + // must come before the generic /audio/ arm + (Get, path) if path.starts_with("/audio/takes/") => { + let rest = &path["/audio/takes/".len()..]; + let (raw_id, raw_file) = rest + .split_once('/') + .context("expected /audio/takes//")?; + let id = sanitize_id(raw_id)?; + let file = sanitize_take_file(raw_file)?; + let bytes = fs::read(ep.root.join("audio/takes").join(&id).join(&file)) + .with_context(|| format!("no take {file} for {id}"))?; + Ok(tiny_http::Response::from_data(bytes) + .with_header(header("content-type", "audio/mpeg"))) + } + (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/review/") => { + let id = sanitize_id(&path["/api/review/".len()..])?; + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body)?; + let mime = content_type(request); + Ok(json(&review_take(ep, &id, &body, &mime)?)) + } + // approve an archived take: promote it to the clip + record the pick + (Post, path) if path.starts_with("/api/approve/") => { + let rest = &path["/api/approve/".len()..]; + let (raw_id, raw_file) = rest + .split_once('/') + .context("expected /api/approve//")?; + let id = sanitize_id(raw_id)?; + let file = sanitize_take_file(raw_file)?; + let resp = approve_take(ep, &id, &file)?; + log_approve(&id, &file, &resp); + Ok(json(&resp)) + } + // fallback: upload + approve in one shot (review never reached the server) + (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 = content_type(request); + let n = store_take(ep, &id, &body, &mime)?; + let file = format!("take_{n:03}.mp3"); + let resp = approve_take(ep, &id, &file)?; + log_approve(&id, &file, &resp); + 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, + }) +} + +fn find_clip<'a>(ep: &'a Episode, id: &str) -> Result<(&'a Scene, &'a 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}")) +} + +fn take_path(ep: &Episode, id: &str, n: u32) -> std::path::PathBuf { + ep.root + .join("audio/takes") + .join(id) + .join(format!("take_{n:03}.mp3")) +} + +/// Transcode an upload to the archive format (stereo mp3 44.1 kHz) and +/// store it permanently as the next `take_NNN.mp3`. Returns the take number. +fn store_take(ep: &Episode, id: &str, body: &[u8], mime: &str) -> Result { + find_clip(ep, id)?; // reject ids that aren't in the episode + let takes_dir = ep.root.join("audio/takes").join(id); + 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)?; + + let n = next_take_number(&takes_dir); + videoeditor_media::ffmpeg(&[ + "-y", + "-i", + raw.to_str().context("path")?, + "-ac", + "2", + "-ar", + "44100", + "-b:a", + "192k", + take_path(ep, id, n).to_str().context("path")?, + ])?; + fs::remove_file(&raw).ok(); + Ok(n) +} + +/// Promote an archived take to the episode's current clip audio and record +/// it as the approved take for that clip: archive any pre-recorder audio it +/// replaces, refresh the manifest, and fit-check the result. +fn approve_take(ep: &Episode, id: &str, file: &str) -> Result { + let (scene, clip) = find_clip(ep, id)?; + let takes_dir = ep.root.join("audio/takes").join(id); + let take = takes_dir.join(file); + if !take.exists() { + bail!("no archived take {file} for {id}"); + } + + let clips_dir = ep.root.join("audio/clips"); + fs::create_dir_all(&clips_dir)?; + let current = clips_dir.join(format!("{id}.mp3")); + // Archive the current clip only if it isn't itself a copy of an archived + // take (no approval on record = pre-recorder audio, e.g. TTS). Approved + // takes already live in takes/, so re-archiving them would just pile up + // duplicates every time you A/B switch. + if current.exists() && !read_approvals(ep).contains_key(id) { + let m = next_take_number(&takes_dir); + fs::rename(¤t, takes_dir.join(format!("replaced_{m:03}.mp3")))?; + } + fs::copy(&take, ¤t)?; + write_approval(ep, id, file)?; + + 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), + }) +} + +/// clip id → approved take filename, persisted inside the episode +/// (`audio/takes/approvals.json`) so picks survive restarts and travel +/// with the repo. +fn read_approvals(ep: &Episode) -> BTreeMap { + fs::read_to_string(approvals_path(ep)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn write_approval(ep: &Episode, id: &str, file: &str) -> Result<()> { + let mut approvals = read_approvals(ep); + approvals.insert(id.to_string(), file.to_string()); + fs::write( + approvals_path(ep), + serde_json::to_string_pretty(&approvals)?, + )?; + Ok(()) +} + +fn approvals_path(ep: &Episode) -> std::path::PathBuf { + ep.root.join("audio/takes/approvals.json") +} + +#[derive(Serialize)] +struct TakeInfo { + file: String, + duration: f64, + approved: bool, + /// The coach report saved when this take was recorded (`take_NNN.json`), + /// if one exists — replaced_* archives and pre-analysis takes have none. + review: Option, +} + +/// Every archived mp3 for a clip (take_* and replaced_*), sorted by name, +/// flagged with which one is currently approved, with its saved analysis. +fn list_takes(ep: &Episode, id: &str) -> Result> { + find_clip(ep, id)?; + let dir = ep.root.join("audio/takes").join(id); + let approved = read_approvals(ep).get(id).cloned(); + let mut takes = Vec::new(); + if let Ok(entries) = fs::read_dir(&dir) { + for e in entries.filter_map(|e| e.ok()) { + let file = e.file_name().to_string_lossy().to_string(); + if !file.ends_with(".mp3") { + continue; + } + let review = fs::read_to_string(dir.join(&file).with_extension("json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); + takes.push(TakeInfo { + duration: videoeditor_media::ffprobe_duration(&dir.join(&file))?, + approved: approved.as_deref() == Some(file.as_str()), + review, + file, + }); + } + } + // newest take first; replaced_* (pre-recorder audio) sinks to the bottom + takes.sort_by(|a, b| b.file.cmp(&a.file)); + Ok(takes) +} + +fn log_approve(id: &str, file: &str, resp: &TakeResponse) { + println!( + "record: {id} ← {file} approved ({:.2}s / window {:.2}s){}", + resp.duration, + resp.window, + if resp.fits { "" } else { " ⚠ too long" } + ); +} + +#[derive(Serialize)] +struct Pause { + at: f64, + len: f64, +} + +/// The coach's report on one (not-yet-kept) take. +#[derive(Serialize)] +struct Review { + /// Archive number of this take (`audio/takes//take_NNN.mp3`); + /// approve it via `/api/approve//take_NNN.mp3` — no re-upload. + take: u32, + duration: f64, + window: f64, + fits: bool, + mean_db: f64, + max_db: f64, + clipped: bool, + /// None = no ELEVENLABS_API_KEY; level/timing coaching still runs. + transcript: Option, + accuracy_pct: Option, + missing: Vec, + added: Vec, + wps: Option, + pauses: Vec, + events: Vec, + coaching: Vec, +} + +/// Archive and analyze a pending take WITHOUT keeping it: the take lands +/// in `audio/takes//` permanently (data safety — retakes lose +/// nothing), then local level metrics via ffmpeg always; script-accuracy +/// / pacing / dead-air / background-noise coaching via ElevenLabs Scribe +/// when a key is present. +fn review_take(ep: &Episode, id: &str, body: &[u8], mime: &str) -> Result { + let (scene, clip) = find_clip(ep, id)?; + let n = store_take(ep, id, body, mime)?; + let mp3 = take_path(ep, id, n); + + let duration = videoeditor_media::ffprobe_duration(&mp3)?; + let window = scene.duration - clip.at.unwrap_or(0.0); + let fits = duration / clip.tempo <= window; + let (mean_db, max_db) = audio_levels(&mp3)?; + let clipped = max_db > -0.2; + + // Scribe is optional — no key, no transcript coaching. + let stt = if videoeditor_voice::api_key().is_ok() { + Some(videoeditor_voice::stt(&mp3).context("ElevenLabs STT")?) + } else { + None + }; + + let mut review = Review { + take: n, + duration, + window, + fits, + mean_db, + max_db, + clipped, + transcript: None, + accuracy_pct: None, + missing: vec![], + added: vec![], + wps: None, + pauses: vec![], + events: vec![], + coaching: vec![], + }; + + if let Some(t) = &stt { + review.transcript = t["text"].as_str().map(|s| s.to_string()); + let spoken_words: Vec<(f64, f64, String)> = t["words"] + .as_array() + .map(|ws| { + ws.iter() + .filter(|w| w["type"] == "word") + .map(|w| { + ( + w["start"].as_f64().unwrap_or(0.0), + w["end"].as_f64().unwrap_or(0.0), + w["text"].as_str().unwrap_or("").to_string(), + ) + }) + .collect() + }) + .unwrap_or_default(); + review.events = t["words"] + .as_array() + .map(|ws| { + ws.iter() + .filter(|w| w["type"] == "audio_event") + .filter_map(|w| w["text"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let script = normalize_words(&clip.text); + let spoken: Vec = spoken_words + .iter() + .flat_map(|(_, _, w)| normalize_words(w)) + .collect(); + let (accuracy, missing, added) = script_diff(&script, &spoken); + review.accuracy_pct = Some(accuracy * 100.0); + review.missing = missing; + review.added = added; + if duration > 0.0 { + review.wps = Some(spoken.len() as f64 / duration); + } + for pair in spoken_words.windows(2) { + let gap = pair[1].0 - pair[0].1; + if gap >= 0.9 { + review.pauses.push(Pause { + at: pair[0].1, + len: gap, + }); + } + } + } + + review.coaching = coach(&review); + // persist the analysis next to the take it describes — the archive + // holds the audio AND its report, not just the audio + fs::write( + take_path(ep, id, n).with_extension("json"), + serde_json::to_string_pretty(&review)?, + )?; + Ok(review) +} + +/// Turn measurements into the feedback lines the UI shows. +fn coach(r: &Review) -> Vec { + let mut notes = Vec::new(); + if !r.fits { + notes.push(format!( + "⏱ runs {:.1}s against a {:.1}s window — tighten the read or the scene stretches", + r.duration, r.window + )); + } + if r.clipped { + notes.push("🔴 clipping — back off the mic or lower the gain".into()); + } else if r.max_db < -12.0 { + notes.push("🔉 quiet peak level — get closer to the mic".into()); + } + if r.mean_db < -30.0 { + notes.push("🔉 overall level is low — closer to the mic or raise input gain".into()); + } + if let Some(acc) = r.accuracy_pct { + if acc < 92.0 && !r.missing.is_empty() { + notes.push(format!( + "📜 dropped from the script: {}", + r.missing.join(", ") + )); + } + if r.added.len() > 2 { + notes.push(format!("🗣 ad-libbed: {}", r.added.join(", "))); + } + } + if let Some(wps) = r.wps { + if wps > 3.9 { + notes.push(format!( + "🏃 {wps:.1} words/sec — racing; let the beats breathe" + )); + } else if wps < 2.2 { + notes.push(format!( + "🐢 {wps:.1} words/sec — dragging; bring the energy up" + )); + } + } + for p in &r.pauses { + notes.push(format!( + "💀 dead air at {:.1}s ({:.1}s) — intentional?", + p.at, p.len + )); + } + for e in &r.events { + notes.push(format!("🎧 background sound picked up: {e}")); + } + if notes.is_empty() { + notes.push("✅ clean take — levels good, script covered, pace on target. Ship it.".into()); + } + notes +} + +/// mean/max dBFS via ffmpeg volumedetect (stderr parse). +fn audio_levels(path: &Path) -> Result<(f64, f64)> { + let out = std::process::Command::new("ffmpeg") + .args([ + "-i", + path.to_str().context("path")?, + "-af", + "volumedetect", + "-f", + "null", + "-", + ]) + .output() + .context("running ffmpeg volumedetect")?; + let stderr = String::from_utf8_lossy(&out.stderr); + let grab = |key: &str| -> f64 { + stderr + .lines() + .find(|l| l.contains(key)) + .and_then(|l| l.split(':').nth(1)) + .and_then(|v| v.trim().trim_end_matches(" dB").parse().ok()) + .unwrap_or(0.0) + }; + Ok((grab("mean_volume"), grab("max_volume"))) +} + +/// Lowercase alphanumeric+apostrophe tokens — the comparison currency for +/// script-vs-transcript diffing. +fn normalize_words(text: &str) -> Vec { + text.split_whitespace() + .map(|w| { + w.chars() + .filter(|c| c.is_alphanumeric() || *c == '\'') + .collect::() + .to_lowercase() + }) + .filter(|w| !w.is_empty()) + .collect() +} + +/// LCS word alignment → (accuracy vs script, missing words, added words). +fn script_diff(script: &[String], spoken: &[String]) -> (f64, Vec, Vec) { + let (n, m) = (script.len(), spoken.len()); + if n == 0 { + return (1.0, vec![], spoken.to_vec()); + } + let mut lcs = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if script[i] == spoken[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let (mut missing, mut added) = (Vec::new(), Vec::new()); + while i < n && j < m { + if script[i] == spoken[j] { + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + missing.push(script[i].clone()); + i += 1; + } else { + added.push(spoken[j].clone()); + j += 1; + } + } + missing.extend(script[i..].iter().cloned()); + added.extend(spoken[j..].iter().cloned()); + (lcs[0][0] as f64 / n as f64, missing, added) +} + +fn content_type(request: &tiny_http::Request) -> String { + request + .headers() + .iter() + .find(|h| h.field.equiv("content-type")) + .map(|h| h.value.as_str().to_string()) + .unwrap_or_default() +} + +/// 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) +} + +/// Take filenames come straight off the URL — allow only `.mp3` +/// so they can never traverse out of the clip's takes dir. +fn sanitize_take_file(raw: &str) -> Result { + let stem = raw.strip_suffix(".mp3").unwrap_or(""); + if stem.is_empty() + || !stem + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + bail!("invalid take file {raw:?}"); + } + Ok(raw.to_string()) +} + +/// 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 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 sanitize_take_file_accepts_archive_names_only() { + assert_eq!(sanitize_take_file("take_001.mp3").unwrap(), "take_001.mp3"); + assert_eq!( + sanitize_take_file("replaced_002.mp3").unwrap(), + "replaced_002.mp3" + ); + assert!(sanitize_take_file(".mp3").is_err()); + assert!(sanitize_take_file("take_001.wav").is_err()); + assert!(sanitize_take_file("../take_001.mp3").is_err()); + assert!(sanitize_take_file("a/b.mp3").is_err()); + } + + #[test] + fn script_diff_finds_missing_and_added_words() { + let script = normalize_words("They hired the strictest security guard in software"); + let spoken = normalize_words("they hired the um strictest guard in software"); + let (acc, missing, added) = script_diff(&script, &spoken); + assert!(acc > 0.85 && acc < 1.0); + assert_eq!(missing, vec!["security"]); + assert_eq!(added, vec!["um"]); + } + + #[test] + fn perfect_read_scores_full_accuracy() { + let script = normalize_words("Zig in a trenchcoat."); + let spoken = normalize_words("zig in a trenchcoat"); + let (acc, missing, added) = script_diff(&script, &spoken); + assert_eq!(acc, 1.0); + assert!(missing.is_empty() && added.is_empty()); + } + + #[test] + fn coach_praises_a_clean_take() { + let r = Review { + take: 1, + duration: 7.0, + window: 8.4, + fits: true, + mean_db: -18.0, + max_db: -3.0, + clipped: false, + transcript: Some("x".into()), + accuracy_pct: Some(100.0), + missing: vec![], + added: vec![], + wps: Some(3.0), + pauses: vec![], + events: vec![], + coaching: vec![], + }; + let notes = coach(&r); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("Ship it")); + } + + #[test] + fn coach_flags_clipping_overrun_and_dead_air() { + let r = Review { + take: 2, + duration: 10.0, + window: 8.4, + fits: false, + mean_db: -35.0, + max_db: 0.0, + clipped: true, + transcript: Some("x".into()), + accuracy_pct: Some(80.0), + missing: vec!["boomer".into()], + added: vec![], + wps: Some(4.5), + pauses: vec![Pause { at: 3.0, len: 1.5 }], + events: vec!["(cough)".into()], + coaching: vec![], + }; + let notes = coach(&r).join("\n"); + assert!(notes.contains("against a")); + assert!(notes.contains("clipping")); + assert!(notes.contains("boomer")); + assert!(notes.contains("racing")); + assert!(notes.contains("dead air")); + assert!(notes.contains("cough")); + } + + #[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-record/ui/.source-hash b/crates/videoeditor-record/ui/.source-hash new file mode 100644 index 0000000..f055811 --- /dev/null +++ b/crates/videoeditor-record/ui/.source-hash @@ -0,0 +1 @@ +308c73e825b72e599da88aeda1111fc7460945ee084a0b383c33f5e784181c63 diff --git a/crates/videoeditor-record/ui/index.html b/crates/videoeditor-record/ui/index.html new file mode 100644 index 0000000..088d207 --- /dev/null +++ b/crates/videoeditor-record/ui/index.html @@ -0,0 +1,21 @@ + + + + +videoeditor · recorder + + + + + + diff --git a/crates/videoeditor-record/ui/style.css b/crates/videoeditor-record/ui/style.css new file mode 100644 index 0000000..f294c26 --- /dev/null +++ b/crates/videoeditor-record/ui/style.css @@ -0,0 +1,136 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + background: #0a0a10; color: #e6edf3; height: 100vh; display: flex; + font-family: -apple-system, 'Segoe UI', sans-serif; overflow: hidden; +} +button { font: inherit; cursor: pointer; border: none; border-radius: 10px; } + +/* ── left rails: clips, then takes ─────────────────────────────────── */ +aside { + border-right: 1px solid #21262d; display: flex; flex-direction: column; + transition: opacity 0.25s; +} +aside.clips { width: 280px; min-width: 280px; } +aside.rail { width: 320px; min-width: 320px; } +aside header { padding: 18px 20px; border-bottom: 1px solid #21262d; } +aside header h1 { font-size: 13px; color: #7ee787; letter-spacing: 0.06em; text-transform: uppercase; } +aside header .ep { font-size: 12.5px; color: #8b949e; margin-top: 4px; } +#clipList, #takesList { overflow-y: auto; flex: 1; } + +.clip-row { + padding: 13px 20px; border-bottom: 1px solid #161b22; cursor: pointer; + display: flex; align-items: center; gap: 10px; +} +.clip-row:hover { background: #11151c; } +.clip-row.active { background: #16202b; box-shadow: inset 3px 0 0 #7ee787; } +.dot { width: 10px; height: 10px; border-radius: 50%; background: #30363d; flex-shrink: 0; } +.dot.has-take { background: #7ee787; } +.dot.too-long { background: #f0883e; } +.clip-row .name { font-size: 14px; font-weight: 600; flex: 1; } +.clip-row .len { font-size: 12px; color: #8b949e; font-variant-numeric: tabular-nums; } + +.take-row { padding: 10px 16px; border-bottom: 1px solid #161b22; cursor: pointer; } +.take-row:hover { background: #11151c; } +.take-row.approved { background: #10231a; box-shadow: inset 3px 0 0 #7ee787; } +.take-row .top { display: flex; align-items: center; gap: 8px; } +.take-row .chev { + color: #58606a; font-size: 13px; width: 12px; flex-shrink: 0; + transition: transform 0.25s ease; +} +.take-row.open .chev { transform: rotate(90deg); } +.take-row .file { font-size: 13px; font-weight: 600; flex: 1; } +.take-row .dur { font-size: 12px; color: #8b949e; font-variant-numeric: tabular-nums; } +.take-row .meta { margin-top: 5px; font-size: 12px; color: #8b949e; line-height: 1.5; padding-left: 20px; } +.take-row button { padding: 3px 10px; font-size: 12px; border-radius: 6px; } +.take-row .play { background: #21262d; color: #e6edf3; } +.take-row .approve { background: #238636; color: #fff; } +.take-row .badge { color: #7ee787; font-weight: 700; } + +/* disclosure: the review unfolds inside the row (0fr → 1fr animates height) */ +.take-detail { + display: grid; grid-template-rows: 0fr; + transition: grid-template-rows 0.28s ease; +} +.take-detail .detail-inner { overflow: hidden; min-height: 0; padding-left: 20px; } +.take-row.open .take-detail { grid-template-rows: 1fr; } +.detail-inner .coaching { list-style: none; margin-top: 8px; font-size: 13px; line-height: 1.7; color: #c9d1d9; } +.detail-inner .transcript { margin-top: 8px; font-size: 12.5px; color: #8b949e; font-style: italic; } +.detail-inner .no-review { margin-top: 8px; font-size: 12.5px; color: #58606a; } +.rail-empty { padding: 16px 20px; font-size: 13px; color: #58606a; display: none; } + +/* recording / countdown: nothing competes with the script */ +body.recording aside, body.countdown aside { opacity: 0.2; pointer-events: none; } + +/* ── stage: teleprompter, review card ──────────────────────────────── */ +main { flex: 1; display: flex; flex-direction: column; min-width: 0; } +main > header { + display: flex; align-items: center; gap: 16px; + padding: 14px 26px; border-bottom: 1px solid #21262d; +} +main > header .which { font-size: 15px; font-weight: 700; } +main > header .window { font-size: 13px; color: #8b949e; } +select { + margin-left: auto; background: #161b22; color: #e6edf3; + border: 1px solid #30363d; border-radius: 8px; padding: 6px 10px; font-size: 13px; + max-width: 340px; +} + +#stage { flex: 1; display: flex; padding: 40px 6%; overflow-y: auto; } +#stage .inner { margin: auto; width: 100%; max-width: 1000px; } +#promptText { + font-size: 42px; line-height: 1.55; font-weight: 600; + color: #f0f6fc; text-wrap: balance; transition: font-size 0.2s, color 0.2s; +} +/* review: script shrinks + dims but NEVER hides — read along while listening */ +body.review #promptText, body.uploading #promptText { font-size: 21px; color: #8b949e; } + +#reviewCard { + display: none; margin-top: 26px; background: #10151d; border: 1px solid #21262d; + border-radius: 14px; padding: 20px 24px; font-size: 16px; line-height: 1.7; +} +body.review #reviewCard, body.uploading #reviewCard { display: block; } +#reviewCard .hdr { font-size: 13px; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 8px; } +#reviewCard .hdr b { color: #7ee787; } +#coachNotes { list-style: none; } +#transcript { margin-top: 10px; font-size: 13.5px; color: #8b949e; font-style: italic; } +.review-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; } +.review-actions audio { height: 44px; flex: 1; min-width: 0; } +.review-actions button { padding: 14px 26px; font-size: 16px; font-weight: 700; } +#keepBtn { background: #238636; color: #fff; } +#retakeBtn { background: #21262d; color: #e6edf3; } +body.uploading .review-actions { pointer-events: none; opacity: 0.6; } + +#countdown { + position: fixed; inset: 0; display: none; align-items: center; justify-content: center; + background: rgba(10,10,16,0.88); font-size: 220px; font-weight: 800; color: #7ee787; + z-index: 10; +} +body.countdown #countdown { display: flex; } + +/* ── footer: meters, transport ─────────────────────────────────────── */ +footer { border-top: 1px solid #21262d; padding: 18px 26px 22px; } +#meter { display: flex; flex-direction: column; gap: 3px; margin-bottom: 14px; } +.chan { display: flex; align-items: center; gap: 8px; } +.chan .lbl { font-size: 10px; color: #58606a; width: 10px; font-weight: 700; } +.chan .track { flex: 1; height: 8px; background: #161b22; border-radius: 4px; overflow: hidden; } +.chan .fill { height: 100%; width: 0%; background: #7ee787; transition: width 60ms linear; } +.chan .fill.hot { background: #ff5252; } +.controls { display: flex; align-items: center; gap: 18px; } +#recBtn { + width: 84px; height: 84px; border-radius: 50%; background: #da3633; color: #fff; + font-size: 30px; box-shadow: 0 0 0 6px rgba(218,54,51,0.25); +} +body.recording #recBtn { + background: #161b22; color: #ff5252; + box-shadow: 0 0 0 6px rgba(255,82,82,0.5); animation: pulse 1.2s infinite; +} +@keyframes pulse { 50% { box-shadow: 0 0 0 12px rgba(255,82,82,0.15); } } +#timer { font-size: 34px; font-variant-numeric: tabular-nums; font-weight: 700; min-width: 190px; } +#timer .lim { font-size: 18px; color: #8b949e; font-weight: 500; } +#timer.over { color: #ff5252; } +#status { font-size: 14px; color: #8b949e; flex: 1; } +kbd { + background: #161b22; border: 1px solid #30363d; border-radius: 5px; + padding: 1px 7px; font-size: 12px; color: #8b949e; +} +.hints { margin-top: 12px; font-size: 12.5px; color: #58606a; } diff --git a/crates/videoeditor-record/ui/videoeditor-record-ui.js b/crates/videoeditor-record/ui/videoeditor-record-ui.js new file mode 100644 index 0000000..6f8743c --- /dev/null +++ b/crates/videoeditor-record/ui/videoeditor-record-ui.js @@ -0,0 +1,1173 @@ +export class IntoUnderlyingByteSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, addHeapObject(controller)); + return takeObject(ret); + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, addHeapObject(controller)); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +export class IntoUnderlyingSink { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} reason + * @returns {Promise} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, addHeapObject(reason)); + return takeObject(ret); + } + /** + * @returns {Promise} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return takeObject(ret); + } + /** + * @param {any} chunk + * @returns {Promise} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, addHeapObject(chunk)); + return takeObject(ret); + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +export class IntoUnderlyingSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, addHeapObject(controller)); + return takeObject(ret); + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_debug_string_edece8177ad01481: function(arg0, arg1) { + const ret = debugString(getObject(arg1)); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_falsy_402d3af0d5f09bc6: function(arg0) { + const ret = !getObject(arg0); + return ret; + }, + __wbg___wbindgen_is_function_5cd60d5cf78b4eef: function(arg0) { + const ret = typeof(getObject(arg0)) === 'function'; + return ret; + }, + __wbg___wbindgen_is_null_2042690d351e14f0: function(arg0) { + const ret = getObject(arg0) === null; + return ret; + }, + __wbg___wbindgen_is_undefined_35bb9f4c7fd651d5: function(arg0) { + const ret = getObject(arg0) === undefined; + return ret; + }, + __wbg___wbindgen_string_get_d109740c0d18f4d7: function(arg0, arg1) { + const obj = getObject(arg1); + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_9c31b086c2b26051: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_3fa391f3fcdb55f8: function(arg0) { + getObject(arg0)._wbg_cb_unref(); + }, + __wbg_addEventListener_aedacff123afaebd: function() { return handleError(function (arg0, arg1, arg2, arg3) { + getObject(arg0).addEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3)); + }, arguments); }, + __wbg_add_ceec5fdac76a2ce5: function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).add(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_body_2ac005c657a3d103: function(arg0) { + const ret = getObject(arg0).body; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_buffer_8d6798e32d1afd34: function(arg0) { + const ret = getObject(arg0).buffer; + return addHeapObject(ret); + }, + __wbg_byobRequest_9d8c3b7b2f692560: function(arg0) { + const ret = getObject(arg0).byobRequest; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_byteLength_c0cecdd68fab1693: function(arg0) { + const ret = getObject(arg0).byteLength; + return ret; + }, + __wbg_byteOffset_3791b0030cc3b490: function(arg0) { + const ret = getObject(arg0).byteOffset; + return ret; + }, + __wbg_call_dfde26266607c996: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_cancelBubble_3624910f1719e6e5: function(arg0) { + const ret = getObject(arg0).cancelBubble; + return ret; + }, + __wbg_classList_8e2d38f6fb1576f2: function(arg0) { + const ret = getObject(arg0).classList; + return addHeapObject(ret); + }, + __wbg_clearInterval_d8d28e6a4cc7f7cd: function(arg0, arg1) { + getObject(arg0).clearInterval(arg1); + }, + __wbg_cloneNode_08d7e527bdeafea1: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).cloneNode(arg1 !== 0); + return addHeapObject(ret); + }, arguments); }, + __wbg_cloneNode_2a76700e8b275079: function() { return handleError(function (arg0) { + const ret = getObject(arg0).cloneNode(); + return addHeapObject(ret); + }, arguments); }, + __wbg_close_53179a3d37ed525d: function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments); }, + __wbg_close_807d553ef8405788: function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments); }, + __wbg_code_5b2bac6d40570b70: function(arg0, arg1) { + const ret = getObject(arg1).code; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_composedPath_32408549123498fe: function(arg0) { + const ret = getObject(arg0).composedPath(); + return addHeapObject(ret); + }, + __wbg_connect_78ba3febd22d2eae: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).connect(getObject(arg1), arg2 >>> 0); + return addHeapObject(ret); + }, arguments); }, + __wbg_connect_f5de65560f2d4f1f: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).connect(getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_content_8db59416e5614955: function(arg0) { + const ret = getObject(arg0).content; + return addHeapObject(ret); + }, + __wbg_createAnalyser_4257e4bee9d90611: function() { return handleError(function (arg0) { + const ret = getObject(arg0).createAnalyser(); + return addHeapObject(ret); + }, arguments); }, + __wbg_createChannelSplitter_02375f04617f2fb1: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).createChannelSplitter(arg1 >>> 0); + return addHeapObject(ret); + }, arguments); }, + __wbg_createComment_7dcce125ba894f86: function(arg0, arg1, arg2) { + const ret = getObject(arg0).createComment(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); + }, + __wbg_createElementNS_10d5e4db26ea11c7: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = getObject(arg0).createElementNS(arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + return addHeapObject(ret); + }, arguments); }, + __wbg_createElement_d10771800cfb6e7e: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).createElement(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_createMediaStreamSource_cdd1ffabc8d9271f: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).createMediaStreamSource(getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_createObjectURL_2be3e54ec71c01da: function() { return handleError(function (arg0, arg1) { + const ret = URL.createObjectURL(getObject(arg1)); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_createTextNode_4889277c0228b30c: function(arg0, arg1, arg2) { + const ret = getObject(arg0).createTextNode(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); + }, + __wbg_data_c278b37cfe35bfd7: function(arg0) { + const ret = getObject(arg0).data; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_deleteProperty_598e2c418b0266c8: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.deleteProperty(getObject(arg0), getObject(arg1)); + return ret; + }, arguments); }, + __wbg_deviceId_9feacad30d47a669: function(arg0, arg1) { + const ret = getObject(arg1).deviceId; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_document_3540635616a18455: function(arg0) { + const ret = getObject(arg0).document; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_enqueue_c3ce0a986a355a8c: function() { return handleError(function (arg0, arg1) { + getObject(arg0).enqueue(getObject(arg1)); + }, arguments); }, + __wbg_enumerateDevices_0e3e8493201939c7: function() { return handleError(function (arg0) { + const ret = getObject(arg0).enumerateDevices(); + return addHeapObject(ret); + }, arguments); }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_export4(deferred0_0, deferred0_1, 1); + } + }, + __wbg_fetch_47ebc0e53aa08033: function(arg0, arg1) { + const ret = getObject(arg0).fetch(getObject(arg1)); + return addHeapObject(ret); + }, + __wbg_firstElementChild_d534db2a0a86bd8a: function(arg0) { + const ret = getObject(arg0).firstElementChild; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_from_fa561fa561dc8031: function(arg0) { + const ret = Array.from(getObject(arg0)); + return addHeapObject(ret); + }, + __wbg_getAudioTracks_375f408c58b6dc7e: function(arg0) { + const ret = getObject(arg0).getAudioTracks(); + return addHeapObject(ret); + }, + __wbg_getElementById_78449141d07cd8ef: function(arg0, arg1, arg2) { + const ret = getObject(arg0).getElementById(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_getFloatTimeDomainData_4742b3d22779d463: function(arg0, arg1, arg2) { + getObject(arg0).getFloatTimeDomainData(getArrayF32FromWasm0(arg1, arg2)); + }, + __wbg_getSettings_6a057b6fe3ad5c50: function(arg0) { + const ret = getObject(arg0).getSettings(); + return addHeapObject(ret); + }, + __wbg_getTracks_d1bf828343cf0a52: function(arg0) { + const ret = getObject(arg0).getTracks(); + return addHeapObject(ret); + }, + __wbg_getUserMedia_a7fb7ce9bc372b3e: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).getUserMedia(getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_get_98fdf51d029a75eb: function(arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0]; + return addHeapObject(ret); + }, + __wbg_get_dcf82ab8aad1a593: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_get_unchecked_1dfe6d05ad91d9b7: function(arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0]; + return addHeapObject(ret); + }, + __wbg_host_3f895baf6025c92c: function(arg0) { + const ret = getObject(arg0).host; + return addHeapObject(ret); + }, + __wbg_insertBefore_a5dd8cbe82ae0c9a: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).insertBefore(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_instanceof_Element_244fd1e5d45219a6: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof Element; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_HtmlAudioElement_4a845b1c15369550: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof HTMLAudioElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_MediaDeviceInfo_a0e59d44de7c8ccb: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof MediaDeviceInfo; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_MediaStreamTrack_d408d13641c4cb6f: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof MediaStreamTrack; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_MediaStream_c49946d49630aaa8: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof MediaStream; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Response_ecfc823e8fb354e2: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_ShadowRoot_041f538aef6c98e0: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof ShadowRoot; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Window_faa5cf994f49cca7: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_isTypeSupported_653edc17cbdf4293: function(arg0, arg1) { + const ret = MediaRecorder.isTypeSupported(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_key_daba1c10e3b408ef: function(arg0, arg1) { + const ret = getObject(arg1).key; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_kind_3b31820db2305870: function(arg0) { + const ret = getObject(arg0).kind; + return (__wbindgen_enum_MediaDeviceKind.indexOf(ret) + 1 || 4) - 1; + }, + __wbg_label_f671a68111be830e: function(arg0, arg1) { + const ret = getObject(arg1).label; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_length_2591a0f4f659a55c: function(arg0) { + const ret = getObject(arg0).length; + return ret; + }, + __wbg_length_56fcd3e2b7e0299d: function(arg0) { + const ret = getObject(arg0).length; + return ret; + }, + __wbg_mediaDevices_a715215a62bffe6f: function() { return handleError(function (arg0) { + const ret = getObject(arg0).mediaDevices; + return addHeapObject(ret); + }, arguments); }, + __wbg_navigator_3db7ba343e05d4d1: function(arg0) { + const ret = getObject(arg0).navigator; + return addHeapObject(ret); + }, + __wbg_new_02d162bc6cf02f60: function() { + const ret = new Object(); + return addHeapObject(ret); + }, + __wbg_new_0ce1b02bfa980113: function() { return handleError(function () { + const ret = new lAudioContext(); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_1f236d63ba0c4784: function(arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return addHeapObject(ret); + }, + __wbg_new_310879b66b6e95e1: function() { + const ret = new Array(); + return addHeapObject(ret); + }, + __wbg_new_ee0be486d8f01282: function() { return handleError(function () { + const ret = new Headers(); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_typed_c072c4ce9a2a0cdf: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wasm_bindgen_func_elem_3443(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = 0; + } + }, + __wbg_new_with_blob_sequence_and_options_c11fb3cc97af67f3: function() { return handleError(function (arg0, arg1) { + const ret = new Blob(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_with_byte_offset_and_length_a87e79143162d67f: function(arg0, arg1, arg2) { + const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }, + __wbg_new_with_media_stream_and_media_recorder_options_5c2e3ad0e58e039a: function() { return handleError(function (arg0, arg1) { + const ret = new MediaRecorder(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_with_str_and_init_ffe9977c986ea039: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new Request(getStringFromWasm0(arg0, arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_now_3cd905700d21a70b: function(arg0) { + const ret = getObject(arg0).now(); + return ret; + }, + __wbg_ok_556a55299dd238ba: function(arg0) { + const ret = getObject(arg0).ok; + return ret; + }, + __wbg_parentNode_1bd06e132b3671e5: function(arg0) { + const ret = getObject(arg0).parentNode; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_pause_a6bc55e903df96ae: function() { return handleError(function (arg0) { + getObject(arg0).pause(); + }, arguments); }, + __wbg_performance_ddd4e7eeef6254f3: function(arg0) { + const ret = getObject(arg0).performance; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_play_4188c48dccde42bd: function() { return handleError(function (arg0) { + const ret = getObject(arg0).play(); + return addHeapObject(ret); + }, arguments); }, + __wbg_preventDefault_077a15ca7e97dc5a: function(arg0) { + getObject(arg0).preventDefault(); + }, + __wbg_push_b77c476b01548d0a: function(arg0, arg1) { + const ret = getObject(arg0).push(getObject(arg1)); + return ret; + }, + __wbg_queueMicrotask_78d584b53af520f5: function(arg0) { + const ret = getObject(arg0).queueMicrotask; + return addHeapObject(ret); + }, + __wbg_queueMicrotask_b39ea83c7f01971a: function(arg0) { + queueMicrotask(getObject(arg0)); + }, + __wbg_removeAttribute_69b4f669d167f410: function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).removeAttribute(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_removeEventListener_3d948197bcd2a229: function() { return handleError(function (arg0, arg1, arg2, arg3) { + getObject(arg0).removeEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3)); + }, arguments); }, + __wbg_remove_cb3a518be27356f9: function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).remove(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_remove_dc0df15d456f4b1a: function(arg0) { + getObject(arg0).remove(); + }, + __wbg_remove_e954ec2032f89c87: function(arg0) { + getObject(arg0).remove(); + }, + __wbg_requestAnimationFrame_0ed67cfff9dd8396: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).requestAnimationFrame(getObject(arg1)); + return ret; + }, arguments); }, + __wbg_resolve_d17db9352f5a220e: function(arg0) { + const ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); + }, + __wbg_respond_0196e052b003e1db: function() { return handleError(function (arg0, arg1) { + getObject(arg0).respond(arg1 >>> 0); + }, arguments); }, + __wbg_setAttribute_2e611c7b4151e535: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_setInterval_c33cbb7818f66a32: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).setInterval(getObject(arg1), arg2); + return ret; + }, arguments); }, + __wbg_setProperty_ee784b2651f9ff8d: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).setProperty(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_24d0fa9e104112f9: function(arg0, arg1, arg2) { + getObject(arg0).set(getArrayU8FromWasm0(arg1, arg2)); + }, + __wbg_set_a0e911be3da02782: function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); + return ret; + }, arguments); }, + __wbg_set_audio_0bd842d9b8fb2470: function(arg0, arg1) { + getObject(arg0).audio = getObject(arg1); + }, + __wbg_set_body_7f56457720e81672: function(arg0, arg1) { + getObject(arg0).body = getObject(arg1); + }, + __wbg_set_className_891d1e531a061f66: function(arg0, arg1, arg2) { + getObject(arg0).className = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_d57e5106f0271787: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).set(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_fftSize_2ef843cae19629b5: function(arg0, arg1) { + getObject(arg0).fftSize = arg1 >>> 0; + }, + __wbg_set_headers_97ed66619adb1e3e: function(arg0, arg1) { + getObject(arg0).headers = getObject(arg1); + }, + __wbg_set_innerHTML_c3fb7c39d5f5f244: function(arg0, arg1, arg2) { + getObject(arg0).innerHTML = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_method_4d69a1a7e34c0aca: function(arg0, arg1, arg2) { + getObject(arg0).method = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_mime_type_31cd6317a28af945: function(arg0, arg1, arg2) { + getObject(arg0).mimeType = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_nodeValue_f3d0122e1895fb0d: function(arg0, arg1, arg2) { + getObject(arg0).nodeValue = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }, + __wbg_set_ondataavailable_aa61d6cd31405c6a: function(arg0, arg1) { + getObject(arg0).ondataavailable = getObject(arg1); + }, + __wbg_set_onstop_6870c4b60f2744ee: function(arg0, arg1) { + getObject(arg0).onstop = getObject(arg1); + }, + __wbg_set_src_a07a152020498f7e: function(arg0, arg1, arg2) { + getObject(arg0).src = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_type_ae6cc1dd3447fed5: function(arg0, arg1, arg2) { + getObject(arg0).type = getStringFromWasm0(arg1, arg2); + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = getObject(arg1).stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_start_63f0617fd76eee41: function() { return handleError(function (arg0) { + getObject(arg0).start(); + }, arguments); }, + __wbg_static_accessor_GLOBAL_THIS_02344c9b09eb08a9: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_static_accessor_GLOBAL_ac6d4ac874d5cd54: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_static_accessor_SELF_9b2406c23aeb2023: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_static_accessor_WINDOW_b34d2126934e16ba: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_status_0853c9f5752c7ee2: function(arg0) { + const ret = getObject(arg0).status; + return ret; + }, + __wbg_stopPropagation_fc3493778c4d8ac7: function(arg0) { + getObject(arg0).stopPropagation(); + }, + __wbg_stop_284c60d2f6a91539: function(arg0) { + getObject(arg0).stop(); + }, + __wbg_stop_8b13fcfcf06a35c4: function() { return handleError(function (arg0) { + getObject(arg0).stop(); + }, arguments); }, + __wbg_style_403ebe345c76f9f5: function(arg0) { + const ret = getObject(arg0).style; + return addHeapObject(ret); + }, + __wbg_target_84e05e84ffc12989: function(arg0) { + const ret = getObject(arg0).target; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_text_99930d92d5f1b540: function() { return handleError(function (arg0) { + const ret = getObject(arg0).text(); + return addHeapObject(ret); + }, arguments); }, + __wbg_then_837494e384b37459: function(arg0, arg1) { + const ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); + }, + __wbg_then_bd927500e8905df2: function(arg0, arg1, arg2) { + const ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, + __wbg_type_7fee727610464e22: function(arg0, arg1) { + const ret = getObject(arg1).type; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_value_bfbe8f2b86b7ad71: function(arg0, arg1) { + const ret = getObject(arg1).value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_view_1b637c097280508c: function(arg0) { + const ret = getObject(arg0).view; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 396, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1134); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 461, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_3438); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("BlobEvent")], shim_idx: 274, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_898); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 381, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1026); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000005: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 396, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1134_4); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000006: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 379, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1028); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000007: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000008: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }, + __wbindgen_object_clone_ref: function(arg0) { + const ret = getObject(arg0); + return addHeapObject(ret); + }, + __wbindgen_object_drop_ref: function(arg0) { + takeObject(arg0); + }, + }; + return { + __proto__: null, + "./videoeditor-record-ui_bg.js": import0, + }; +} + +const lAudioContext = (typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : undefined)); +function __wasm_bindgen_func_elem_1028(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_1028(arg0, arg1); +} + +function __wasm_bindgen_func_elem_1134(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1134(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_898(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_898(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_1026(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1026(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_1134_4(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1134_4(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_3438(arg0, arg1, arg2) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.__wasm_bindgen_func_elem_3438(retptr, arg0, arg1, addHeapObject(arg2)); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + if (r1) { + throw takeObject(r0); + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + +function __wasm_bindgen_func_elem_3443(arg0, arg1, arg2, arg3) { + wasm.__wasm_bindgen_func_elem_3443(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +} + + +const __wbindgen_enum_MediaDeviceKind = ["audioinput", "audiooutput", "videoinput"]; + + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr, 1)); +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr, 1)); +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr, 1)); + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => wasm.__wbindgen_export5(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function dropObject(idx) { + if (idx < 1028) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function getArrayF32FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len); +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +let cachedFloat32ArrayMemory0 = null; +function getFloat32ArrayMemory0() { + if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) { + cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer); + } + return cachedFloat32ArrayMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function getObject(idx) { return heap[idx]; } + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_export3(addHeapObject(e)); + } +} + +let heap = new Array(1024).fill(undefined); +heap.push(undefined, null, true, false); + +let heap_next = heap.length; + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_export5(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedFloat32ArrayMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('videoeditor-record-ui_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/crates/videoeditor-record/ui/videoeditor-record-ui_bg.wasm b/crates/videoeditor-record/ui/videoeditor-record-ui_bg.wasm new file mode 100644 index 0000000..595e4b9 Binary files /dev/null and b/crates/videoeditor-record/ui/videoeditor-record-ui_bg.wasm differ 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..2f1dda6 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,15 @@ 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. + After every take a COACH panel reviews it before you keep it: level / + clipping checks always; with `ELEVENLABS_API_KEY` set it also + transcribes the take (Scribe) and flags dropped script words, ad-libs, + pace, dead air, and background noises. 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, diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..657ebdb --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,44 @@ +import { ChildProcess, spawn } from 'node:child_process'; +import { cpSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Spawn the REAL shipped binary against a throwaway copy of the example +// episode. Managed here (not playwright's webServer) so the fixture dir +// provably exists before the server starts. + +let server: ChildProcess; + +export default async function globalSetup() { + const episode = join(tmpdir(), 'videoeditor-record-e2e'); + rmSync(episode, { recursive: true, force: true }); + cpSync(join(__dirname, '..', 'examples', 'hello-bench'), episode, { recursive: true }); + + const env = { ...process.env }; + // hermetic + free: level coaching only, no ElevenLabs STT calls + delete env.ELEVENLABS_API_KEY; + + const bin = join(__dirname, '..', 'target', 'debug', 'videoeditor'); + server = spawn(bin, ['record', episode, '--port', '4901', '--no-open'], { + env, + stdio: 'inherit', + }); + server.on('exit', (code) => { + if (code !== null && code !== 0) console.error(`recorder server exited with ${code}`); + }); + + for (let i = 0; i < 100; i++) { + try { + const res = await fetch('http://127.0.0.1:4901/api/episode'); + if (res.ok) return; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('recorder server never became ready on :4901'); +} + +export async function teardown() { + server?.kill(); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..833d88a --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,3 @@ +import { teardown } from './global-setup'; + +export default teardown; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..916f644 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@playwright/test'; + +// The recorder e2e suite drives the REAL shipped binary (videoeditor record) +// against a throwaway copy of the hello-bench example episode, in a real +// Chromium with a fake mic (a generated tone). Run via `just e2e` — the nix +// devshell provides playwright + pinned browsers (PLAYWRIGHT_BROWSERS_PATH). + +export default defineConfig({ + testDir: './tests', + timeout: 60_000, + // one worker: every test shares the one recorder server + episode dir + workers: 1, + globalSetup: './global-setup', + globalTeardown: './global-teardown', + use: { + baseURL: 'http://127.0.0.1:4901', + // nix's playwright-driver bundle ships the FULL chromium (no separate + // headless shell); 'chromium' channel = new headless mode on that build + channel: 'chromium', + launchOptions: { + args: [ + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + '--autoplay-policy=no-user-gesture-required', + ], + }, + permissions: ['microphone'], + }, +}); diff --git a/e2e/tests/recorder.spec.ts b/e2e/tests/recorder.spec.ts new file mode 100644 index 0000000..0301668 --- /dev/null +++ b/e2e/tests/recorder.spec.ts @@ -0,0 +1,121 @@ +import { expect, Page, test } from '@playwright/test'; + +// One serial journey through the recorder: the tests build on each other's +// state (takes accumulate in the throwaway episode) exactly like a session. +test.describe.configure({ mode: 'serial' }); + +async function recordTake(page: Page, ms = 1500) { + await page.keyboard.press('Space'); + await expect(page.locator('body')).toHaveClass('countdown'); + // 3-2-1 at 700ms per tick + await expect(page.locator('body')).toHaveClass('recording', { timeout: 10_000 }); + await page.waitForTimeout(ms); + await page.keyboard.press('Space'); + await expect(page.locator('body')).toHaveClass('review'); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.clip-row')).toHaveCount(5); +}); + +test('boots: clips, teleprompter, takes rail, mic picker', async ({ page }) => { + await expect(page.locator('body')).toHaveClass('idle'); + await expect(page.locator('#promptText')).toContainText('Rust versus Python'); + await expect(page.locator('.clip-row.active .name')).toHaveText('title / hook'); + // fresh episode: nothing archived yet + await expect(page.locator('.rail-empty')).toBeVisible(); + await expect(page.locator('#reviewCard')).toBeHidden(); + // fake device shows up in the input picker once permission is granted + await expect(page.locator('#micSelect option')).not.toHaveCount(0); +}); + +test('recording protects the script; review shows AI feedback beside it', async ({ page }) => { + // idle: teleprompter at full size + const fontSize = () => page.locator('#promptText').evaluate((el) => getComputedStyle(el).fontSize); + expect(await fontSize()).toBe('42px'); + + await recordTake(page); + + // review: card + coach visible, script SHRUNK BUT NEVER HIDDEN + // (poll: the font-size animates over 0.2s) + await expect(page.locator('#reviewCard')).toBeVisible(); + await expect(page.locator('#promptText')).toBeVisible(); + await expect.poll(fontSize).toBe('21px'); + await expect(page.locator('#coachHdr')).toContainText('COACH · take 001', { timeout: 20_000 }); + await expect(page.locator('#coachHdr')).toContainText('peak'); + await expect(page.locator('#coachNotes li').first()).toBeVisible(); + // the take is already archived — it appears in the rail before any keep + await expect(page.locator('.take-row .file').first()).toHaveText('take_001'); + await expect(page.locator('.take-row .meta').first()).toContainText('peak'); +}); + +test('enter keeps the take: approved badge + clip gains audio', async ({ page }) => { + await recordTake(page); + await expect(page.locator('#coachHdr')).toContainText('take 002', { timeout: 20_000 }); + await page.keyboard.press('Enter'); + await expect(page.locator('body')).toHaveClass('idle'); + await expect(page.locator('#status')).toContainText('saved'); + // newest first: take_002 on top, wearing the approved badge + const first = page.locator('.take-row').first(); + await expect(first.locator('.file')).toHaveText('take_002'); + await expect(first.locator('.badge')).toHaveText('✓'); + await expect(page.locator('.clip-row.active .dot.has-take')).toBeVisible(); +}); + +test('takes stay newest-first and the API agrees', async ({ page, request }) => { + await expect(page.locator('.take-row .file').first()).toHaveText('take_002'); + const files = (await (await request.get('/api/takes/title__hook')).json()).map( + (t: { file: string }) => t.file, + ); + const sorted = [...files].sort().reverse(); + expect(files).toEqual(sorted); + expect(files[0]).toBe('take_002.mp3'); +}); + +test('clicking a take discloses its saved AI review inline', async ({ page }) => { + const row = page.locator('.take-row').first(); + await expect(row.locator('.take-detail .detail-inner')).not.toBeInViewport(); + await row.click(); + await expect(row).toHaveClass(/open/); + await expect(row.locator('.coaching li').first()).toBeVisible(); + // one open at a time: opening another closes the first + const second = page.locator('.take-row').nth(1); + await second.click(); + await expect(second).toHaveClass(/open/); + await expect(row).not.toHaveClass(/open/); +}); + +test('approve an older take from the rail moves the badge', async ({ page }) => { + const older = page.locator('.take-row', { hasText: 'take_001' }); + await older.locator('button.approve').click(); + await expect(page.locator('#status')).toContainText('approved take_001.mp3'); + await expect(older.locator('.badge')).toHaveText('✓'); + await expect(page.locator('.take-row', { hasText: 'take_002' }).locator('button.approve')).toBeVisible(); +}); + +test('audition toggles playback from the rail', async ({ page }) => { + const play = page.locator('.take-row').first().locator('button.play'); + await expect(play).toHaveText('▶'); + await play.click(); + await expect(play).toHaveText('⏸'); + await play.click(); + await expect(play).toHaveText('▶'); +}); + +test('retake (r) goes straight back to the countdown', async ({ page }) => { + await recordTake(page); + await page.keyboard.press('r'); + await expect(page.locator('body')).toHaveClass('countdown'); + await expect(page.locator('body')).toHaveClass('recording', { timeout: 10_000 }); + await page.keyboard.press('Space'); + await expect(page.locator('body')).toHaveClass('review'); +}); + +test('arrow keys switch clips and the teleprompter follows', async ({ page }) => { + await page.keyboard.press('ArrowDown'); + await expect(page.locator('.clip-row.active .name')).toHaveText('good / explain'); + await expect(page.locator('#promptText')).not.toContainText('Rust versus Python'); + await page.keyboard.press('ArrowUp'); + await expect(page.locator('.clip-row.active .name')).toHaveText('title / hook'); +}); diff --git a/flake.lock b/flake.lock index d38172b..74f94c1 100644 --- a/flake.lock +++ b/flake.lock @@ -18,7 +18,28 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1783577481, + "narHash": "sha256-EWMxOWrJ031f8f/lrcEmhTRs4npw1/5N3Hcjin846c8=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "4cdea398dc491b082dccdce0fad1ed0b75fa3394", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index f37472d..d6ba897 100644 --- a/flake.nix +++ b/flake.nix @@ -3,12 +3,19 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + # rust with the wasm32-unknown-unknown target for the Leptos recorder UI + # (nixpkgs' rustc ships host-only std) + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, rust-overlay }: let systems = [ "aarch64-darwin" "x86_64-darwin" "x86_64-linux" "aarch64-linux" ]; - forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: + f (import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; })); in { # The preferred install path: `nix profile install github:security-union/videoeditor` @@ -72,6 +79,11 @@ devShells = forAllSystems (pkgs: let pwBrowsers = pkgs.playwright-driver.browsers-chromium; + # one toolchain for host AND wasm: the recorder UI + # (videoeditor-record-ui) compiles to wasm32-unknown-unknown + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + targets = [ "wasm32-unknown-unknown" ]; + }; # `videoeditor` inside the dev shell = cargo run over THIS checkout, # so the command always matches the source you're editing (a # store-built binary here would silently go stale). First call @@ -85,13 +97,19 @@ { default = pkgs.mkShell { packages = with pkgs; [ - # Rust toolchain — the orchestrator - rustc - cargo - clippy - rustfmt + # Rust toolchain — the orchestrator (host + wasm32 targets) + rustToolchain rust-analyzer + # Recorder UI: Leptos → wasm. wasm-bindgen-cli must match the + # workspace's pinned wasm-bindgen crate — bump them together. + trunk + wasm-bindgen-cli + + # Recorder e2e: playwright runner + pinned browsers + nodejs + playwright-test + # Media pipeline ffmpeg yt-dlp @@ -108,6 +126,11 @@ export VIDEOEDITOR_SRC="$PWD" echo "videoeditor dev shell — rustc $(rustc --version | cut -d' ' -f2), ffmpeg $(ffmpeg -version 2>/dev/null | head -1 | cut -d' ' -f3)" echo "\`videoeditor\` builds+runs this checkout (cargo run); first call compiles" + # Playwright e2e (just e2e): pinned browser bundle + resolvable + # @playwright/test for the config/spec imports. + export PLAYWRIGHT_BROWSERS_PATH=${pwBrowsers} + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + export NODE_PATH=${pkgs.playwright-test}/lib/node_modules # Rendering uses the same pinned browser as the installed # package: chromium from nixpkgs on Linux (found via PATH), # playwright's Chrome for Testing on darwin. CHROME_BIN overrides. diff --git a/justfile b/justfile index abee09f..2aa6bd9 100644 --- a/justfile +++ b/justfile @@ -12,8 +12,8 @@ default: build: cargo build --workspace --release -# lint gate: clippy -D warnings + rustfmt -check: +# lint gate: clippy -D warnings + rustfmt + embedded-UI freshness +check: ui-check cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check @@ -23,6 +23,38 @@ test: # everything CI runs ci: check test build +# rebuild the recorder UI (Leptos → wasm via trunk) and refresh the +# committed dist that videoeditor-record embeds, stamped with the hash of +# the source that produced it (see ui-check) +ui: + cd crates/videoeditor-record-ui && trunk build + rm -rf crates/videoeditor-record/ui + cp -R crates/videoeditor-record-ui/dist crates/videoeditor-record/ui + just _ui-stamp > crates/videoeditor-record/ui/.source-hash + +# drift guard, no wasm toolchain needed: fails when the recorder UI source +# changed but the committed dist wasn't regenerated. Keeps the embedded +# front end trustworthy without any remember-to-run step — CI runs this +# via `just check` on every push. +ui-check: + @test "$(cat crates/videoeditor-record/ui/.source-hash 2>/dev/null)" = "$(just _ui-stamp)" \ + || { echo "error: committed recorder UI (crates/videoeditor-record/ui) is stale."; \ + echo " run: nix develop --command just ui — then commit the refreshed ui/"; exit 1; } + +# hash of everything that determines the UI build (source + toolchain pins) +_ui-stamp: + @find crates/videoeditor-record-ui/src crates/videoeditor-record-ui/index.html \ + crates/videoeditor-record-ui/style.css crates/videoeditor-record-ui/Trunk.toml \ + crates/videoeditor-record-ui/Cargo.toml -type f | LC_ALL=C sort \ + | xargs shasum -a 256 | shasum -a 256 | cut -d' ' -f1 + +# recorder end-to-end suite: real binary + real Chromium (fake mic). +# Depends on `ui` so the embedded front end is always freshly built from +# source — no manual rebuild step, ever. +e2e: ui + cargo build -p videoeditor + cd e2e && playwright test + # generate narration clips (needs ELEVENLABS_API_KEY) tts episode=episode: {{run}} tts {{episode}} diff --git a/release-plz.toml b/release-plz.toml index 823b9d7..ecc23f0 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -7,6 +7,12 @@ pr_branch_prefix = "release-plz-" pr_labels = ["release"] publish_timeout = "10m" +# The recorder UI never releases on its own — its trunk build ships inside +# videoeditor-record's committed ui/ dist. +[[package]] +name = "videoeditor-record-ui" +release = false + # The CLI crate is the user-facing release: it gets the GitHub release + tag. [[package]] name = "videoeditor" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d0ead5e..db31ec1 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,5 @@ [toolchain] channel = "stable" components = ["clippy", "rustfmt"] +# the recorder UI (videoeditor-record-ui) compiles to wasm +targets = ["wasm32-unknown-unknown"]