cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

feat: configurable codec + backend, Windows support, multi-codec NAL parsing

dami etoile committed 695a1df
Backend:
- EncoderConfig struct: codec (h264/h265/av1), backend (auto/nvenc/vaapi/qsv/
  amf/videotoolbox/software), bitrate, framerate, gop
- resolve_encoder(): auto-detects best hardware encoder per platform
- Windows gdigrab capture path added
- Codec-aware frame delimiter parsing:
  H.264: AUD NAL type 9
  H.265: AUD NAL type 35, keyframe = IDR(19,20)/VPS(32)/SPS(33)
  AV1: temporal delimiter OBU
- Codec-specific BSFs: h264_metadata, hevc_metadata, av1_metadata
- stderr captured on failure for diagnostics
- New commands: get_encoder_config, set_encoder_config, detect_available_encoders

Frontend:
- Encoder config section in info panel: codec/backend/bitrate/framerate/gop
- Available encoders detected and displayed
- Performance panel shows resolved encoder name (e.g. h264_nvenc)
commit
695a1df6221af2c62fa8c2070ac69785c9efa3d7
parent
9ab7d9c5a820b0001f3702d285c96c62e41ed2b1
author
dami etoile <dami.etoile@gmail.com>
3 files changed, +386 -87
Msrc-tauri/src/commands.rs368++++++++++++++++++++++++------
Msrc-tauri/src/main.rs3+
Msrc/ui/index.html102+++++++-
M src-tauri/src/commands.rs +294 -74
@@ -32,7 +32,7 @@ use std::io::Cursor;
3232 use std::sync::atomic::{AtomicU32, Ordering};3333 use std::sync::{Arc, Mutex};3434 use std::time::{Duration, Instant};35-use tauri::{AppHandle, Emitter, State};35+use tauri::{AppHandle, Emitter, Manager, State};3636 use tokio::sync::Mutex as TokioMutex;3737 3838 const FRAME_ALPN: &[u8] = b"keyhome/frame-stream/0";
@@ -299,6 +299,27 @@ fn key_from_str(s: &str) -> Option<Key> {
299299 300300 // ─── AppState ────────────────────────────────────────────────────────────────301301 302+#[derive(Clone, Debug, Serialize, Deserialize)]303+pub struct EncoderConfig {304+ pub codec: String, // "h264", "h265", "av1"305+ pub backend: String, // "auto", "nvenc", "vaapi", "videotoolbox", "software"306+ pub bitrate: String, // e.g. "8M"307+ pub framerate: u32, // e.g. 30308+ pub gop: u32, // keyframe interval309+}310+311+impl Default for EncoderConfig {312+ fn default() -> Self {313+ Self {314+ codec: "h264".to_string(),315+ backend: "auto".to_string(),316+ bitrate: "8M".to_string(),317+ framerate: 30,318+ gop: 30,319+ }320+ }321+}322+302323 pub struct AppState {303324 pub host: Mutex<Option<HostState>>,304325 pub host_connections: Arc<AtomicU32>,
@@ -307,6 +328,7 @@ pub struct AppState {
307328 pub client_endpoint: TokioMutex<Option<Endpoint>>,308329 pub webcodecs: std::sync::atomic::AtomicBool,309330 pub daemon: bool,331+ pub encoder_config: Mutex<EncoderConfig>,310332 }311333 312334 pub struct HostState {
@@ -324,6 +346,7 @@ impl Default for AppState {
324346 client_endpoint: TokioMutex::new(None),325347 webcodecs: std::sync::atomic::AtomicBool::new(false),326348 daemon,349+ encoder_config: Mutex::new(EncoderConfig::default()),327350 }328351 }329352 }
@@ -485,6 +508,40 @@ pub fn is_webcodecs_available(state: State<'_, AppState>) -> bool {
485508 state.webcodecs.load(Ordering::SeqCst)486509 }487510 511+#[tauri::command]512+pub fn get_encoder_config(state: State<'_, AppState>) -> EncoderConfig {513+ state.encoder_config.lock().unwrap().clone()514+}515+516+#[tauri::command]517+pub fn set_encoder_config(state: State<'_, AppState>, config: EncoderConfig) {518+ *state.encoder_config.lock().unwrap() = config;519+}520+521+#[tauri::command]522+pub fn detect_available_encoders() -> Vec<String> {523+ let encoders = std::process::Command::new("ffmpeg")524+ .arg("-encoders")525+ .output()526+ .ok()527+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())528+ .unwrap_or_default();529+530+ let all = [531+ // H.264532+ "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox", "libx264",533+ // H.265534+ "hevc_nvenc", "hevc_vaapi", "hevc_qsv", "hevc_amf", "hevc_videotoolbox", "libx265",535+ // AV1536+ "av1_nvenc", "av1_vaapi", "av1_qsv", "av1_amf", "av1_videotoolbox", "libsvtav1", "libaom-av1",537+ ];538+539+ all.iter()540+ .filter(|name| encoders.contains(*name))541+ .map(|s| s.to_string())542+ .collect()543+}544+488545 // ─── Host Registration (keyring) ─────────────────────────────────────────────489546 490547 #[derive(Serialize)]
@@ -691,8 +748,15 @@ async fn stream_frames(conn: Connection, app: &AppHandle) -> anyhow::Result<()>
691748 692749 // Try ffmpeg subprocess first693750 if ffmpeg_available() {694- eprintln!("[host] using ffmpeg subprocess for capture+encode");695- match stream_frames_ffmpeg(&mut send, &mut recv, app, w, h).await {751+ let config = {752+ let app_state = app.state::<AppState>();753+ app_state.encoder_config.lock().unwrap().clone()754+ };755+ eprintln!(756+ "[host] using ffmpeg: codec={} backend={}",757+ config.codec, config.backend758+ );759+ match stream_frames_ffmpeg(&mut send, &mut recv, app, w, h, &config).await {696760 Ok(()) => return Ok(()),697761 Err(e) => {698762 eprintln!("[host] ffmpeg failed: {}, falling back to xcap+openh264", e);
@@ -716,8 +780,8 @@ fn ffmpeg_available() -> bool {
716780 .is_ok()717781 }718782 719-fn detect_ffmpeg_encoder() -> String {720- // Check which hardware encoders are available783+/// Resolve the ffmpeg encoder name based on codec + backend preference.784+fn resolve_encoder(codec: &str, backend: &str) -> String {721785 let encoders = std::process::Command::new("ffmpeg")722786 .arg("-encoders")723787 .output()
@@ -725,35 +789,102 @@ fn detect_ffmpeg_encoder() -> String {
725789 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())726790 .unwrap_or_default();727791 728- if cfg!(target_os = "macos") {729- if encoders.contains("h264_videotoolbox") {730- return "h264_videotoolbox".to_string();731- }792+ let has = |name: &str| encoders.contains(name);793+794+ // Backend preference order by platform795+ let hw_backends: &[&str] = if cfg!(target_os = "macos") {796+ &["videotoolbox"]797+ } else if cfg!(target_os = "windows") {798+ &["nvenc", "qsv", "amf"]732799 } else {733- if encoders.contains("h264_nvenc") {734- return "h264_nvenc".to_string();800+ &["nvenc", "vaapi"]801+ };802+803+ let codec_prefix = match codec {804+ "h265" => "hevc",805+ "av1" => "av1",806+ _ => "h264",807+ };808+809+ // If specific backend requested, try it810+ if backend != "auto" {811+ let name = if backend == "software" {812+ match codec {813+ "h265" => "libx265",814+ "av1" => "libsvtav1",815+ _ => "libx264",816+ }817+ } else {818+ &format!("{}_{}", codec_prefix, backend)819+ };820+ if has(name) {821+ return name.to_string();735822 }736- if encoders.contains("h264_vaapi") {737- return "h264_vaapi".to_string();823+ }824+825+ // Auto: try hardware backends in order826+ for hw in hw_backends {827+ let name = format!("{}_{}", codec_prefix, hw);828+ if has(&name) {829+ return name;738830 }739831 }740- "libx264".to_string()832+833+ // Software fallback834+ match codec {835+ "h265" => "libx265".to_string(),836+ "av1" => {837+ if has("libsvtav1") {838+ "libsvtav1".to_string()839+ } else {840+ "libaom-av1".to_string()841+ }842+ }843+ _ => "libx264".to_string(),844+ }845+}846+847+/// Get the ffmpeg output format and BSF for a given codec.848+fn codec_format(codec: &str) -> (&'static str, &'static str) {849+ match codec {850+ "h265" => ("hevc", "hevc_metadata=aud=insert"),851+ "av1" => ("av1", "av1_metadata=td=insert"),852+ _ => ("h264", "h264_metadata=aud=insert"),853+ }741854 }742855 743-/// Find the next AUD (NAL type 9) start code after `from`.744-fn find_next_aud(data: &[u8], from: usize) -> Option<usize> {856+/// Find the next frame delimiter in the NAL stream.857+/// H.264/H.265: AUD start code (00 00 00 01 + NAL type 9 for H.264, type 35 for H.265)858+/// AV1: temporal delimiter OBU859+fn find_next_frame_delim(data: &[u8], from: usize, codec: &str) -> Option<usize> {860+ let aud_nal_type = match codec {861+ "h265" => 35, // HEVC AUD862+ _ => 9, // H.264 AUD863+ };864+745865 let mut i = from;746866 while i + 4 < data.len() {747867 if data[i] == 0 && data[i + 1] == 0 {748868 if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {749- if i + 4 < data.len() && (data[i + 4] & 0x1f) == 9 {869+ // 4-byte start code870+ if codec == "av1" {871+ // AV1 temporal delimiter: OBU type 2 (0x12 or 0x22 with extension flag)872+ if i + 4 < data.len() && (data[i + 4] & 0x38) == 0x10 {873+ return Some(i);874+ }875+ } else if i + 4 < data.len() && (data[i + 4] & 0x7f) == aud_nal_type {750876 return Some(i);751877 }752878 i += 4;753879 continue;754880 }755881 if data[i + 2] == 1 {756- if i + 3 < data.len() && (data[i + 3] & 0x1f) == 9 {882+ // 3-byte start code883+ if codec == "av1" {884+ if i + 3 < data.len() && (data[i + 3] & 0x38) == 0x10 {885+ return Some(i);886+ }887+ } else if i + 3 < data.len() && (data[i + 3] & 0x7f) == aud_nal_type {757888 return Some(i);758889 }759890 i += 3;
@@ -765,31 +896,68 @@ fn find_next_aud(data: &[u8], from: usize) -> Option<usize> {
765896 None766897 }767898 768-/// Check if a frame's NAL data contains an IDR (type 5) or SPS (type 7).769-fn frame_is_keyframe(data: &[u8]) -> bool {770- let mut i = 0;771- while i + 3 < data.len() {772- if data[i] == 0 && data[i + 1] == 0 {773- let (sc_len, nal_off) = if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {774- (4, 4)775- } else if data[i + 2] == 1 {776- (3, 3)777- } else {778- i += 1;779- continue;780- };781- if i + nal_off < data.len() {782- let nal_type = data[i + nal_off] & 0x1f;783- if nal_type == 5 || nal_type == 7 {784- return true;899+/// Check if a frame's NAL data contains a keyframe.900+fn frame_is_keyframe(data: &[u8], codec: &str) -> bool {901+ match codec {902+ "h265" => {903+ // HEVC: IDR (type 19, 20) or VPS (32) or SPS (33)904+ let mut i = 0;905+ while i + 3 < data.len() {906+ if data[i] == 0 && data[i + 1] == 0 {907+ let (sc_len, nal_off) = if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {908+ (4, 4)909+ } else if data[i + 2] == 1 {910+ (3, 3)911+ } else {912+ i += 1;913+ continue;914+ };915+ if i + nal_off < data.len() {916+ let nal_type = (data[i + nal_off] >> 1) & 0x3f;917+ if nal_type == 19 || nal_type == 20 || nal_type == 32 || nal_type == 33 {918+ return true;919+ }920+ }921+ i += sc_len;922+ } else {923+ i += 1;785924 }786925 }787- i += sc_len;788- } else {789- i += 1;926+ false927+ }928+ "av1" => {929+ // AV1: key frame has show_existing_frame=0 and frame_type=0 (KEY_FRAME)930+ // Simplified: check for any OBU_FRAME with frame_type=0931+ // For now, treat first frame as keyframe (AV1 temporal unit delimiters make this reliable)932+ true933+ }934+ _ => {935+ // H.264: IDR (type 5) or SPS (type 7)936+ let mut i = 0;937+ while i + 3 < data.len() {938+ if data[i] == 0 && data[i + 1] == 0 {939+ let (sc_len, nal_off) = if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {940+ (4, 4)941+ } else if data[i + 2] == 1 {942+ (3, 3)943+ } else {944+ i += 1;945+ continue;946+ };947+ if i + nal_off < data.len() {948+ let nal_type = data[i + nal_off] & 0x1f;949+ if nal_type == 5 || nal_type == 7 {950+ return true;951+ }952+ }953+ i += sc_len;954+ } else {955+ i += 1;956+ }957+ }958+ false790959 }791960 }792- false793961 }794962 795963 async fn stream_frames_ffmpeg(
@@ -798,76 +966,120 @@ async fn stream_frames_ffmpeg(
798966 app: &AppHandle,799967 width: usize,800968 height: usize,969+ config: &EncoderConfig,801970 ) -> anyhow::Result<()> {802971 use std::process::Stdio;803972 use tokio::io::AsyncReadExt;804973 use tokio::process::Command as TokioCommand;805974 806- let encoder = detect_ffmpeg_encoder();807- eprintln!("[host] ffmpeg encoder: {}", encoder);975+ let encoder = resolve_encoder(&config.codec, &config.backend);976+ let (fmt, bsf) = codec_format(&config.codec);977+ eprintln!(978+ "[host] ffmpeg: encoder={} format={} bsf={} bitrate={} fps={} gop={}",979+ encoder, fmt, bsf, config.bitrate, config.framerate, config.gop980+ );808981 809982 let mut cmd = TokioCommand::new("ffmpeg");810983 cmd.stdin(Stdio::null())811984 .stdout(Stdio::piped())812- .stderr(Stdio::null())985+ .stderr(Stdio::piped())813986 .kill_on_drop(true);814987 815988 // Input: screen capture816989 if cfg!(target_os = "macos") {817990 cmd.arg("-f").arg("avfoundation")818- .arg("-framerate").arg("30")991+ .arg("-framerate").arg(config.framerate.to_string())819992 .arg("-capture_cursor").arg("1")820993 .arg("-i").arg("1:");994+ } else if cfg!(target_os = "windows") {995+ cmd.arg("-f").arg("gdigrab")996+ .arg("-framerate").arg(config.framerate.to_string())997+ .arg("-i").arg("desktop");821998 } else {822999 let display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());8231000 cmd.arg("-f").arg("x11grab")824- .arg("-framerate").arg("30")1001+ .arg("-framerate").arg(config.framerate.to_string())8251002 .arg("-video_size").arg(format!("{}x{}", width, height))8261003 .arg("-i").arg(&display);8271004 }8281005 8291006 // Encoder-specific settings8301007 match encoder.as_str() {831- "h264_nvenc" => {832- cmd.arg("-c:v").arg("h264_nvenc")1008+ e if e.ends_with("_nvenc") => {1009+ cmd.arg("-c:v").arg(&encoder)8331010 .arg("-preset").arg("p1")8341011 .arg("-tune").arg("ll")8351012 .arg("-rc").arg("cbr")836- .arg("-b:v").arg("8M");1013+ .arg("-b:v").arg(&config.bitrate);8371014 }838- "h264_vaapi" => {839- cmd.arg("-c:v").arg("h264_vaapi")1015+ e if e.ends_with("_vaapi") => {1016+ cmd.arg("-c:v").arg(&encoder)8401017 .arg("-rc_mode").arg("CBR")841- .arg("-b:v").arg("8M");1018+ .arg("-b:v").arg(&config.bitrate);8421019 }843- "h264_videotoolbox" => {844- cmd.arg("-c:v").arg("h264_videotoolbox")1020+ e if e.ends_with("_videotoolbox") => {1021+ cmd.arg("-c:v").arg(&encoder)8451022 .arg("-realtime").arg("1")846- .arg("-b:v").arg("8M");1023+ .arg("-b:v").arg(&config.bitrate);8471024 }848- _ => {849- // libx264 software fallback1025+ e if e.ends_with("_qsv") => {1026+ cmd.arg("-c:v").arg(&encoder)1027+ .arg("-preset").arg("veryfast")1028+ .arg("-b:v").arg(&config.bitrate);1029+ }1030+ e if e.ends_with("_amf") => {1031+ cmd.arg("-c:v").arg(&encoder)1032+ .arg("-usage").arg("ultralowlatency")1033+ .arg("-b:v").arg(&config.bitrate);1034+ }1035+ "libx264" => {8501036 cmd.arg("-c:v").arg("libx264")8511037 .arg("-preset").arg("ultrafast")8521038 .arg("-tune").arg("zerolatency")853- .arg("-b:v").arg("8M");1039+ .arg("-b:v").arg(&config.bitrate);1040+ }1041+ "libx265" => {1042+ cmd.arg("-c:v").arg("libx265")1043+ .arg("-preset").arg("ultrafast")1044+ .arg("-tune").arg("zerolatency")1045+ .arg("-x265-params").arg("keyint=30:min-keyint=30")1046+ .arg("-b:v").arg(&config.bitrate);1047+ }1048+ "libsvtav1" => {1049+ cmd.arg("-c:v").arg("libsvtav1")1050+ .arg("-preset").arg("8")1051+ .arg("-crf").arg("35")1052+ .arg("-g").arg(config.gop.to_string());1053+ }1054+ "libaom-av1" => {1055+ cmd.arg("-c:v").arg("libaom-av1")1056+ .arg("-cpu-used").arg("8")1057+ .arg("-crf").arg("35")1058+ .arg("-b:v").arg(&config.bitrate);1059+ }1060+ _ => {1061+ return Err(anyhow::anyhow!("unsupported encoder: {}", encoder));8541062 }8551063 }8561064 857- // Common output settings858- cmd.arg("-g").arg("30")859- .arg("-bf").arg("0")1065+ // Common output settings (skip gop for libx265/libsvtav1 which set it above)1066+ if encoder != "libx265" && encoder != "libsvtav1" {1067+ cmd.arg("-g").arg(config.gop.to_string());1068+ }1069+ cmd.arg("-bf").arg("0")8601070 .arg("-pix_fmt").arg("yuv420p")861- .arg("-bsf:v").arg("h264_metadata=aud=insert")862- .arg("-f").arg("h264")1071+ .arg("-bsf:v").arg(bsf)1072+ .arg("-f").arg(fmt)8631073 .arg("-");8641074 8651075 let mut child = cmd.spawn().context("failed to spawn ffmpeg")?;8661076 let mut stdout = child.stdout.take().context("no stdout from ffmpeg")?;1077+ let mut stderr = child.stderr.take();1078+ let mut stderr_buf = String::new();8671079 8681080 let mut buf: Vec<u8> = Vec::with_capacity(65536);8691081 let mut frame_start: usize = 0;870- let mut first_aud_seen = false;1082+ let mut first_delim_seen = false;8711083 let mut frame_count = 0u32;8721084 let start = Instant::now();8731085 let mut last_frame_time = Instant::now();
@@ -876,20 +1088,27 @@ async fn stream_frames_ffmpeg(
8761088 let mut tmp = [0u8; 16384];8771089 let n = stdout.read(&mut tmp).await?;8781090 if n == 0 {1091+ // Capture stderr for diagnostics1092+ if let Some(ref mut stderr) = stderr {1093+ use tokio::io::AsyncReadExt;1094+ let _ = stderr.read_to_string(&mut stderr_buf).await;1095+ }8791096 eprintln!("[host] ffmpeg stdout closed");1097+ if !stderr_buf.is_empty() {1098+ eprintln!("[host] ffmpeg stderr: {}", stderr_buf);1099+ }8801100 break;8811101 }8821102 buf.extend_from_slice(&tmp[..n]);8831103 884- // Extract complete frames (delimited by AUD NAL units)1104+ // Extract complete frames (delimited by AUD/TD NAL units)8851105 loop {886- let search_from = if first_aud_seen { frame_start + 6 } else { 0 };887- match find_next_aud(&buf, search_from) {888- Some(aud_pos) => {889- if first_aud_seen {890- // Frame data: buf[frame_start..aud_pos]891- let frame_data = &buf[frame_start..aud_pos];892- let is_keyframe = frame_is_keyframe(frame_data);1106+ let search_from = if first_delim_seen { frame_start + 6 } else { 0 };1107+ match find_next_frame_delim(&buf, search_from, &config.codec) {1108+ Some(delim_pos) => {1109+ if first_delim_seen {1110+ let frame_data = &buf[frame_start..delim_pos];1111+ let is_keyframe = frame_is_keyframe(frame_data, &config.codec);8931112 let frame_size = frame_data.len();8941113 8951114 let now = Instant::now();
@@ -922,16 +1141,17 @@ async fn stream_frames_ffmpeg(
9221141 "size_bytes": frame_size,9231142 "fps": (fps * 10.0).round() / 10.0,9241143 "keyframe": is_keyframe,1144+ "encoder": encoder.clone(),9251145 }),9261146 );9271147 9281148 eprintln!(929- "[host] frame={} {}x{} h264={}B kf={} fps={:.1} ftime={:.1}ms",930- frame_count, width, height, frame_size, is_keyframe, fps, frame_ms1149+ "[host] frame={} {}x{} {}={}B kf={} fps={:.1} ftime={:.1}ms",1150+ frame_count, width, height, config.codec, frame_size, is_keyframe, fps, frame_ms9311151 );9321152 }933- frame_start = aud_pos;934- first_aud_seen = true;1153+ frame_start = delim_pos;1154+ first_delim_seen = true;9351155 }9361156 None => break,9371157 }
M src-tauri/src/main.rs +3 -0
M src/ui/index.html +89 -13
@@ -386,6 +386,28 @@
386386 font-size: 11px;387387 color: var(--foreground);388388 }389+ .config-select {390+ font-family: var(--mono);391+ font-size: 11px;392+ color: var(--foreground);393+ background: var(--background);394+ border: 1px solid var(--border);395+ padding: 2px 4px;396+ cursor: pointer;397+ }398+ .config-input {399+ font-family: var(--mono);400+ font-size: 11px;401+ color: var(--foreground);402+ background: var(--background);403+ border: 1px solid var(--border);404+ padding: 2px 4px;405+ width: 80px;406+ }407+ .config-input:focus {408+ outline: none;409+ border-color: var(--foreground);410+ }389411 </style>390412 </head>391413 <body>
@@ -497,9 +519,41 @@
497519 <button class="link-btn" style="margin-top: 8px;" onclick="scanFido()">rescan</button>498520 </div>499521 <div class="panel-section">500- <h3># codec</h3>501- <div class="info-row"><span class="info-label">webcodecs</span><span class="info-value" id="codec-webcodecs">checking...</span></div>502- <div class="info-row"><span class="info-label">decode path</span><span class="info-value" id="codec-path">—</span></div>522+ <h3># encoder</h3>523+ <div class="info-row">524+ <span class="info-label">codec</span>525+ <select id="enc-codec" class="config-select">526+ <option value="h264">h.264</option>527+ <option value="h265">h.265</option>528+ <option value="av1">av1</option>529+ </select>530+ </div>531+ <div class="info-row">532+ <span class="info-label">backend</span>533+ <select id="enc-backend" class="config-select">534+ <option value="auto">auto</option>535+ <option value="nvenc">nvenc</option>536+ <option value="vaapi">vaapi</option>537+ <option value="qsv">qsv</option>538+ <option value="amf">amf</option>539+ <option value="videotoolbox">videotoolbox</option>540+ <option value="software">software</option>541+ </select>542+ </div>543+ <div class="info-row">544+ <span class="info-label">bitrate</span>545+ <input id="enc-bitrate" class="config-input" type="text" value="8M" />546+ </div>547+ <div class="info-row">548+ <span class="info-label">framerate</span>549+ <input id="enc-framerate" class="config-input" type="number" value="30" min="1" max="60" />550+ </div>551+ <div class="info-row">552+ <span class="info-label">gop</span>553+ <input id="enc-gop" class="config-input" type="number" value="30" min="1" max="120" />554+ </div>555+ <div class="info-row"><span class="info-label">available</span><span class="info-value" id="enc-available">—</span></div>556+ <button class="link-btn" style="margin-top: 8px;" onclick="saveEncoderConfig()">apply</button>503557 </div>504558 <div class="panel-section">505559 <h3># performance</h3>
@@ -871,9 +925,9 @@
871925 }872926 873927 listen('host-encode-stats', (event) => {874- const { encode_ms, capture_ms, size_bytes, fps } = event.payload;928+ const { encode_ms, capture_ms, size_bytes, fps, encoder } = event.payload;875929 document.getElementById('perf-encode').textContent = encode_ms.toFixed(1) + ' ms';876- document.getElementById('perf-capture').textContent = capture_ms > 0 ? capture_ms.toFixed(1) + ' ms' : 'ffmpeg';930+ document.getElementById('perf-capture').textContent = encoder || (capture_ms > 0 ? capture_ms.toFixed(1) + ' ms' : 'ffmpeg');877931 document.getElementById('perf-size').textContent = (size_bytes / 1024).toFixed(1) + ' KB';878932 document.getElementById('perf-fps').textContent = fps.toFixed(1);879933
@@ -888,21 +942,43 @@
888942 let decoderConfigured = false;889943 890944 (async function detectWebCodecs() {891- const el = document.getElementById('codec-webcodecs');892- const pathEl = document.getElementById('codec-path');893945 if (hasWebCodecs) {894- el.textContent = 'available';895- el.style.color = 'var(--green)';896- pathEl.textContent = 'webcodecs (hw)';897946 await invoke('set_webcodecs_available', { available: true });898947 } else {899- el.textContent = 'unavailable';900- el.style.color = 'var(--destructive)';901- pathEl.textContent = 'openh264 → jpeg';902948 await invoke('set_webcodecs_available', { available: false });903949 }904950 })();905951 952+ // ─── Encoder config ────────────────────────────────────────────────953+ (async function loadEncoderConfig() {954+ try {955+ const config = await invoke('get_encoder_config');956+ document.getElementById('enc-codec').value = config.codec;957+ document.getElementById('enc-backend').value = config.backend;958+ document.getElementById('enc-bitrate').value = config.bitrate;959+ document.getElementById('enc-framerate').value = config.framerate;960+ document.getElementById('enc-gop').value = config.gop;961+ } catch (e) { console.error('encoder config load failed:', e); }962+963+ try {964+ const available = await invoke('detect_available_encoders');965+ document.getElementById('enc-available').textContent = available.join(', ') || 'none';966+ } catch (e) {967+ document.getElementById('enc-available').textContent = 'ffmpeg not found';968+ }969+ })();970+971+ async function saveEncoderConfig() {972+ const config = {973+ codec: document.getElementById('enc-codec').value,974+ backend: document.getElementById('enc-backend').value,975+ bitrate: document.getElementById('enc-bitrate').value,976+ framerate: parseInt(document.getElementById('enc-framerate').value) || 30,977+ gop: parseInt(document.getElementById('enc-gop').value) || 30,978+ };979+ await invoke('set_encoder_config', { config });980+ }981+906982 // ─── Annex B → AVCC conversion ─────────────────────────────────────907983 // openh264 outputs Annex B (00 00 00 01 start codes).908984 // WebCodecs expects AVCC (4-byte length-prefixed NALs) + description.