diff --git a/apps/web/app.js b/apps/web/app.js index 9cadbed..b3f100b 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -172,16 +172,11 @@ function mountPlayer(relayUrl, name) { watch.connection.websocket = { enabled: false }; } - // Prefer a video element for native controls/audio routing. - // Start muted to satisfy autoplay policy, then unlock audio on user gesture. - const video = document.createElement("video"); - video.className = "archiveVideo"; - video.controls = true; - video.autoplay = true; - video.muted = true; - video.volume = 1; - video.playsInline = true; - watch.appendChild(video); + const canvas = document.createElement("canvas"); + canvas.className = "canvas"; + canvas.width = 1280; + canvas.height = 720; + watch.appendChild(canvas); mount.appendChild(watch); const cleanup = []; let audioUnlocked = false; @@ -196,30 +191,21 @@ function mountPlayer(relayUrl, name) { } watch.removeAttribute("muted"); watch.muted = false; - video.muted = false; - video.volume = 1; - }; - const keepAudioUnlocked = () => { - if (audioUnlocked) forceAudioOn(); }; const unlockAudio = () => { audioUnlocked = true; forceAudioOn(); watch.backend?.paused?.set?.(true); watch.backend?.paused?.set?.(false); - void video.play().catch(() => {}); setHint(`Live: subscribed to ${name} (audio unlocked)`, "ok"); }; document.addEventListener("pointerdown", unlockAudio, { once: true }); - video.addEventListener("pointerdown", unlockAudio, { once: true }); - video.addEventListener("volumechange", keepAudioUnlocked); + canvas.addEventListener("pointerdown", unlockAudio, { once: true }); cleanup.push(() => { document.removeEventListener("pointerdown", unlockAudio); - video.removeEventListener("pointerdown", unlockAudio); - video.removeEventListener("volumechange", keepAudioUnlocked); + canvas.removeEventListener("pointerdown", unlockAudio); }); - setHint(`Live: subscribed to ${name} (tap video to unmute)`, "warn"); - void video.play().catch(() => {}); + setHint(`Live: subscribed to ${name} (tap player to unmute)`, "warn"); bindPlayerSignals(watch, name, cleanup); } diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs index 4111621..bcace45 100644 --- a/crates/ec-node/src/main.rs +++ b/crates/ec-node/src/main.rs @@ -66,6 +66,8 @@ const DIRECT_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(8); const DIRECT_WIRE_CHUNK_BYTES: usize = 16 * 1024; const WT_ARCHIVE_DEFAULT_TRACKS: &[&str] = &["catalog.json", "init.mp4", "video0.m4s", "audio0.m4s"]; +const WT_PUBLISH_GOP_FRAMES: u32 = 1; +const WT_PUBLISH_VIDEO_FILTER: &str = "fps=6"; const WT_PUBLISH_MOVFLAGS: &str = "empty_moov+frag_keyframe+separate_moof+omit_tfhd_offset"; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -470,6 +472,15 @@ struct WtPublishArgs { /// If set, transcode to H.264/AAC before fragmenting to fMP4. #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] transcode: bool, + /// ffmpeg video filter used by the transcode path. + #[arg(long, default_value = WT_PUBLISH_VIDEO_FILTER)] + video_filter: String, + /// H.264 GOP/keyframe interval in frames for the transcode path. + #[arg(long, default_value_t = WT_PUBLISH_GOP_FRAMES)] + gop_frames: u32, + /// fMP4 movflags used for WebTransport publishing. + #[arg(long, default_value = WT_PUBLISH_MOVFLAGS)] + movflags: String, /// Transmit fMP4 fragments directly (passthrough mode). /// When false, the importer may reframe into CMAF fragments. #[arg(long, default_value_t = false, action = clap::ArgAction::Set)] @@ -531,6 +542,12 @@ struct NbcWtPublishArgs { /// Transmit fMP4 fragments directly (passthrough mode). #[arg(long, default_value_t = false, action = clap::ArgAction::Set)] passthrough: bool, + /// H.264 GOP/keyframe interval in frames. + #[arg(long, default_value_t = WT_PUBLISH_GOP_FRAMES)] + gop_frames: u32, + /// fMP4 movflags used for WebTransport publishing. + #[arg(long, default_value = WT_PUBLISH_MOVFLAGS)] + movflags: String, /// Danger: disable TLS verification for the relay. #[arg(long, default_value_t = false)] tls_disable_verify: bool, @@ -6795,6 +6812,8 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { "0:a:0?", "-c:v", "libx264", + "-vf", + args.video_filter.as_str(), "-preset", "veryfast", "-tune", @@ -6804,9 +6823,9 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { "-profile:v", "main", "-g", - "48", + &args.gop_frames.to_string(), "-keyint_min", - "48", + &args.gop_frames.to_string(), "-sc_threshold", "0", "-threads", @@ -6830,7 +6849,7 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { cmd.args(["-c", "copy"]); } - cmd.args(["-f", "mp4", "-movflags", WT_PUBLISH_MOVFLAGS, "pipe:1"]); + cmd.args(["-f", "mp4", "-movflags", args.movflags.as_str(), "pipe:1"]); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::inherit()); @@ -6921,7 +6940,6 @@ async fn nbc_wt_publish(args: NbcWtPublishArgs) -> Result<()> { .with_context(|| format!("failed to open NBC browser session for {}", args.source_url))?; let fps = nbc_capture_fps().max(1); - let gop = (fps * 4).clamp(12, 48); let mut cmd = TokioCommand::new("ffmpeg"); cmd.arg("-hide_banner") @@ -6954,9 +6972,9 @@ async fn nbc_wt_publish(args: NbcWtPublishArgs) -> Result<()> { "main", "-g", ]) - .arg(gop.to_string()) + .arg(args.gop_frames.to_string()) .arg("-keyint_min") - .arg(gop.to_string()) + .arg(args.gop_frames.to_string()) .args([ "-sc_threshold", "0", @@ -6965,7 +6983,7 @@ async fn nbc_wt_publish(args: NbcWtPublishArgs) -> Result<()> { "-f", "mp4", "-movflags", - WT_PUBLISH_MOVFLAGS, + args.movflags.as_str(), "pipe:1", ]); diff --git a/crates/ec-node/tests/e2e_remote_website_watch_existing.rs b/crates/ec-node/tests/e2e_remote_website_watch_existing.rs index 58b0900..48c9528 100644 --- a/crates/ec-node/tests/e2e_remote_website_watch_existing.rs +++ b/crates/ec-node/tests/e2e_remote_website_watch_existing.rs @@ -16,14 +16,11 @@ fn chrome_path() -> Option { .or_else(|| which("chromium")) } -fn wait_for_blob_video(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { +fn wait_for_canvas_element(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { let js = r#"(function() { - let v = document.querySelector('video'); - if (!v) return false; - if (typeof v.src !== 'string') return false; - return v.src.startsWith('blob:'); + return !!document.querySelector('moq-watch canvas'); })();"#; let v = tab.evaluate(js, false)?; if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { @@ -31,14 +28,14 @@ fn wait_for_blob_video(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow: } std::thread::sleep(Duration::from_millis(200)); } - anyhow::bail!("timed out waiting for video blob src"); + anyhow::bail!("timed out waiting for canvas player"); } -fn wait_for_video_element(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { +fn wait_for_moq_watch_element(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { let js = r#"(function() { - return !!document.querySelector('video'); + return !!document.querySelector('moq-watch'); })();"#; let v = tab.evaluate(js, false)?; if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { @@ -46,23 +43,32 @@ fn wait_for_video_element(tab: &headless_chrome::Tab, timeout: Duration) -> anyh } std::thread::sleep(Duration::from_millis(200)); } - anyhow::bail!("timed out waiting for