|
@@ -1,58 +1,59 @@
|
|
|
-//! 录制会话管理 + ffmpeg 后处理
|
|
|
|
|
|
|
+//! 录制:直接用 ffmpeg + Windows gdigrab 抓取 content webview 所在屏幕矩形
|
|
|
//!
|
|
//!
|
|
|
-//! 录制实际由前端 MediaRecorder + getDisplayMedia 完成(录到的是整个 AutoRecord
|
|
|
|
|
-//! 窗口的 webm 流),Rust 端只负责:
|
|
|
|
|
-//! 1. prepare_recording 分配 session id
|
|
|
|
|
-//! 2. save_recording_raw 把前端传过来的 webm 字节落到 cache 目录
|
|
|
|
|
-//! 3. finalize_recording 调 ffmpeg 把 raw webm crop 到子 webview 区域 + scale 到目标尺寸 + 转 mp4
|
|
|
|
|
-//! 4. cancel_recording 用户取消时清现场
|
|
|
|
|
|
|
+//! 平台支持:**仅 Windows**。macOS 的 avfoundation 方案在不同机型上检测/启动行为
|
|
|
|
|
+//! 差异太大(设备索引、屏幕录制权限、stdin 继承等),暂不支持;非 Windows 调用
|
|
|
|
|
+//! start_recording 会返回明确的错误。
|
|
|
//!
|
|
//!
|
|
|
-//! ffmpeg 二进制:本轮直接通过 PATH 调系统 ffmpeg。后续可换成 sidecar 打包,
|
|
|
|
|
-//! `run_ffmpeg` 只需要换实现,命令链路本身不动。
|
|
|
|
|
|
|
+//! 设计:
|
|
|
|
|
+//! - 不走浏览器 `getDisplayMedia` + `MediaRecorder`(会弹"选择共享源"对话框,
|
|
|
|
|
+//! 而且只能录整个窗口、还需要后处理裁剪)。
|
|
|
|
|
+//! - 改为 Rust 端直接 spawn ffmpeg,用 Windows gdigrab 按物理像素抓取桌面矩形:
|
|
|
|
|
+//! `ffmpeg -f gdigrab -offset_x X -offset_y Y -video_size WxH -i desktop ...`
|
|
|
|
|
+//! - 输出直接 mp4,省掉中间 webm 与第二轮 transcoding。
|
|
|
|
|
+//! - 用户只能 start / stop / cancel;不支持暂停(ffmpeg 子进程不支持原生 pause)。
|
|
|
|
|
+//!
|
|
|
|
|
+//! 诊断:
|
|
|
|
|
+//! - ffmpeg stderr 全程接出来:实时 eprintln 到终端 + 缓存最后 100 行;
|
|
|
|
|
+//! start_recording 启动 500ms 后做健康检查,若 ffmpeg 已退出,把 stderr
|
|
|
|
|
+//! 尾部一起返回给前端,方便定位"为何启动即退"。
|
|
|
|
|
+//! - stop 时也先 try_wait 检查进程,避免无谓的 Broken Pipe 日志。
|
|
|
|
|
+//!
|
|
|
|
|
+//! 优雅停止:向 ffmpeg stdin 写 "q\n",等它自己 flush moov atom 后退出;
|
|
|
|
|
+//! 超时(15s)强制 kill 并算失败。
|
|
|
|
|
|
|
|
-use crate::{paths, AppState};
|
|
|
|
|
-use serde::{Deserialize, Serialize};
|
|
|
|
|
-use tauri::{AppHandle, Emitter, State};
|
|
|
|
|
|
|
+use crate::{find_content_webview, paths, AppState};
|
|
|
|
|
+use serde::Serialize;
|
|
|
|
|
+use std::collections::VecDeque;
|
|
|
|
|
+use std::path::PathBuf;
|
|
|
|
|
+use std::process::Stdio;
|
|
|
|
|
+use std::sync::{Arc, Mutex as StdMutex};
|
|
|
|
|
+use std::time::Duration;
|
|
|
|
|
+use tauri::{AppHandle, Emitter, Manager, State};
|
|
|
|
|
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
|
|
|
+use tokio::process::{Child, Command};
|
|
|
use uuid::Uuid;
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
const EVT_RECORDING_FINISHED: &str = "recording-finished";
|
|
const EVT_RECORDING_FINISHED: &str = "recording-finished";
|
|
|
const EVT_RECORDING_FAILED: &str = "recording-failed";
|
|
const EVT_RECORDING_FAILED: &str = "recording-failed";
|
|
|
|
|
|
|
|
-/// 录制会话元数据(存在 AppState.recording_sessions 里)
|
|
|
|
|
-#[derive(Clone)]
|
|
|
|
|
-pub struct RecordingSession {
|
|
|
|
|
- pub task_id: String,
|
|
|
|
|
-}
|
|
|
|
|
|
|
+/// 最近 N 行 ffmpeg stderr,stop / 健康检查失败时附进 error
|
|
|
|
|
+const STDERR_LOG_MAX_LINES: usize = 100;
|
|
|
|
|
+/// 启动后用于"刚 spawn 就 crash"的健康检查窗口
|
|
|
|
|
+const STARTUP_HEALTH_CHECK_MS: u64 = 500;
|
|
|
|
|
+/// 优雅停止的最大等待
|
|
|
|
|
+const STOP_TIMEOUT_SEC: u64 = 15;
|
|
|
|
|
|
|
|
-/// 前端传过来的 crop 区域,单位:CSS 逻辑像素(子 webview 在 React UI window 内的坐标)
|
|
|
|
|
-#[derive(Deserialize)]
|
|
|
|
|
-pub struct CropRect {
|
|
|
|
|
- pub x: f64,
|
|
|
|
|
- pub y: f64,
|
|
|
|
|
- pub w: f64,
|
|
|
|
|
- pub h: f64,
|
|
|
|
|
-}
|
|
|
|
|
|
|
+/// 跨 tokio 任务共享的 stderr 行缓冲(仅短暂持锁,无需 async mutex)
|
|
|
|
|
+type SharedStderrLog = Arc<StdMutex<VecDeque<String>>>;
|
|
|
|
|
|
|
|
-/// 前端传过来的 React UI 主 webview 的 innerWidth/innerHeight(CSS 逻辑像素)。
|
|
|
|
|
-/// 用于跟实际录到的 frame 像素算 ratio,弥补 dpr / titlebar 等差异。
|
|
|
|
|
-#[derive(Deserialize)]
|
|
|
|
|
-pub struct WindowClient {
|
|
|
|
|
- pub w: f64,
|
|
|
|
|
- pub h: f64,
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// 前端通过 `stream.getVideoTracks()[0].getSettings()` 拿到的实际 frame 尺寸(像素)
|
|
|
|
|
-#[derive(Deserialize)]
|
|
|
|
|
-pub struct FrameSize {
|
|
|
|
|
- pub w: u32,
|
|
|
|
|
- pub h: u32,
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// 期望的最终 mp4 输出尺寸(CSS 逻辑像素,等于用户设置的 contentSize)
|
|
|
|
|
-#[derive(Deserialize)]
|
|
|
|
|
-pub struct OutputSize {
|
|
|
|
|
- pub w: u32,
|
|
|
|
|
- pub h: u32,
|
|
|
|
|
|
|
+/// 录制会话:登记到 AppState.recording_sessions,session_id → 会话
|
|
|
|
|
+pub struct RecordingSession {
|
|
|
|
|
+ pub task_id: String,
|
|
|
|
|
+ pub output_path: PathBuf,
|
|
|
|
|
+ /// 持有 ffmpeg 子进程;session drop 时 kill_on_drop 兜底
|
|
|
|
|
+ pub child: Child,
|
|
|
|
|
+ /// ffmpeg stderr 末尾缓冲
|
|
|
|
|
+ pub stderr_log: SharedStderrLog,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[derive(Serialize, Clone)]
|
|
#[derive(Serialize, Clone)]
|
|
@@ -73,71 +74,70 @@ struct RecordingFailed {
|
|
|
error: String,
|
|
error: String,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// 开始录制前调一次:分配 session_id,登记到 AppState。
|
|
|
|
|
|
|
+/// 开始录制:查询 content webview 的屏幕矩形 → spawn ffmpeg → 登记 session
|
|
|
///
|
|
///
|
|
|
-/// 前端拿到 session_id 后用它驱动后续的 save / finalize / cancel。
|
|
|
|
|
|
|
+/// 返回 session_id,前端传回给 stop / cancel。
|
|
|
#[tauri::command]
|
|
#[tauri::command]
|
|
|
-pub fn prepare_recording(
|
|
|
|
|
|
|
+pub async fn start_recording(
|
|
|
|
|
+ app: AppHandle,
|
|
|
state: State<'_, AppState>,
|
|
state: State<'_, AppState>,
|
|
|
task_id: String,
|
|
task_id: String,
|
|
|
) -> Result<String, String> {
|
|
) -> Result<String, String> {
|
|
|
|
|
+ eprintln!("[recording] start_recording 进入:task_id={task_id}");
|
|
|
|
|
+
|
|
|
|
|
+ // 1) 计算 content webview 在屏幕上的物理像素矩形
|
|
|
|
|
+ let (x, y, w, h) = query_content_screen_rect(&app)?;
|
|
|
|
|
+ eprintln!("[recording] content webview 屏幕矩形:x={x} y={y} w={w} h={h}");
|
|
|
|
|
+
|
|
|
|
|
+ // 2) 输出路径(recordings_dir 内部会 ensure_dir)
|
|
|
|
|
+ let output_path = paths::recording_final_path_for_task(&app, &task_id)?;
|
|
|
|
|
+ // 若已存在旧文件,先删;ffmpeg -y 也能覆盖,但提前删能避免 reveal 到旧文件
|
|
|
|
|
+ let _ = std::fs::remove_file(&output_path);
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 启动 ffmpeg;同时开 stderr 抽取任务
|
|
|
|
|
+ let (mut child, stderr_log) = spawn_ffmpeg(x, y, w, h, &output_path)?;
|
|
|
|
|
+
|
|
|
|
|
+ // 4) 健康检查:等 500ms,看 ffmpeg 是否一启动就 crash(比如参数不被接受、
|
|
|
|
|
+ // 平台 backend 不可用、libx264 缺失等)。若已退出,把 stderr 末尾返回。
|
|
|
|
|
+ tokio::time::sleep(Duration::from_millis(STARTUP_HEALTH_CHECK_MS)).await;
|
|
|
|
|
+ if let Ok(Some(status)) = child.try_wait() {
|
|
|
|
|
+ // 再短等一下,让 stderr 抽取任务把最后几行写入缓冲
|
|
|
|
|
+ tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
|
+ let tail = drain_stderr_tail(&stderr_log, 12);
|
|
|
|
|
+ let err = format!(
|
|
|
|
|
+ "ffmpeg 启动 {}ms 内即退出({})。stderr 末尾:\n{}",
|
|
|
|
|
+ STARTUP_HEALTH_CHECK_MS, status, tail
|
|
|
|
|
+ );
|
|
|
|
|
+ eprintln!("[recording] {err}");
|
|
|
|
|
+ return Err(err);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5) 注册 session
|
|
|
let sid = Uuid::new_v4().to_string();
|
|
let sid = Uuid::new_v4().to_string();
|
|
|
- let session = RecordingSession {
|
|
|
|
|
- task_id,
|
|
|
|
|
- };
|
|
|
|
|
state
|
|
state
|
|
|
.recording_sessions
|
|
.recording_sessions
|
|
|
.lock()
|
|
.lock()
|
|
|
.map_err(|e| format!("recording_sessions 锁失败: {e}"))?
|
|
.map_err(|e| format!("recording_sessions 锁失败: {e}"))?
|
|
|
- .insert(sid.clone(), session);
|
|
|
|
|
- Ok(sid)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// 把前端 MediaRecorder 收集到的 webm 二进制写到 cache 目录。
|
|
|
|
|
-///
|
|
|
|
|
-/// 注意:bytes 是整个录制时长的全部数据,可能上百 MB。前端调本命令时
|
|
|
|
|
-/// Tauri 会一次性序列化整段 buffer,目前看体感能扛;如果出现卡顿可以
|
|
|
|
|
-/// 改成分段写入。
|
|
|
|
|
-#[tauri::command]
|
|
|
|
|
-pub fn save_recording_raw(
|
|
|
|
|
- app: AppHandle,
|
|
|
|
|
- state: State<'_, AppState>,
|
|
|
|
|
- session_id: String,
|
|
|
|
|
- bytes: Vec<u8>,
|
|
|
|
|
-) -> Result<(), String> {
|
|
|
|
|
- // 校验 session 存在(防御性)
|
|
|
|
|
- {
|
|
|
|
|
- let guard = state
|
|
|
|
|
- .recording_sessions
|
|
|
|
|
- .lock()
|
|
|
|
|
- .map_err(|e| format!("recording_sessions 锁失败: {e}"))?;
|
|
|
|
|
- if !guard.contains_key(&session_id) {
|
|
|
|
|
- return Err(format!("找不到 session: {session_id}"));
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ .insert(
|
|
|
|
|
+ sid.clone(),
|
|
|
|
|
+ RecordingSession {
|
|
|
|
|
+ task_id,
|
|
|
|
|
+ output_path,
|
|
|
|
|
+ child,
|
|
|
|
|
+ stderr_log,
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- let path = paths::recording_raw_path_for_session(&app, &session_id)?;
|
|
|
|
|
- std::fs::write(&path, &bytes)
|
|
|
|
|
- .map_err(|e| format!("写入 raw webm 失败 {}: {}", path.display(), e))?;
|
|
|
|
|
- Ok(())
|
|
|
|
|
|
|
+ Ok(sid)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// 调 ffmpeg 把 raw webm 转为最终 mp4:
|
|
|
|
|
-/// crop 到子 webview 区域 → scale 到用户设置的视口尺寸 → libx264 + faststart
|
|
|
|
|
-///
|
|
|
|
|
-/// 成功时通过 `recording-finished` 事件回推路径;失败时通过 `recording-failed`。
|
|
|
|
|
-/// 同时也把 mp4 路径作为命令返回值返给前端,方便链式处理。
|
|
|
|
|
|
|
+/// 停止录制:向 ffmpeg stdin 写 'q' 优雅退出,落盘后 emit recording-finished
|
|
|
#[tauri::command]
|
|
#[tauri::command]
|
|
|
-pub async fn finalize_recording(
|
|
|
|
|
|
|
+pub async fn stop_recording(
|
|
|
app: AppHandle,
|
|
app: AppHandle,
|
|
|
state: State<'_, AppState>,
|
|
state: State<'_, AppState>,
|
|
|
session_id: String,
|
|
session_id: String,
|
|
|
- crop: CropRect,
|
|
|
|
|
- window_client: WindowClient,
|
|
|
|
|
- frame: FrameSize,
|
|
|
|
|
- output: OutputSize,
|
|
|
|
|
) -> Result<String, String> {
|
|
) -> Result<String, String> {
|
|
|
- // 取 session 元数据,并从 map 中移除(finalize 是终点)
|
|
|
|
|
let session = {
|
|
let session = {
|
|
|
let mut guard = state
|
|
let mut guard = state
|
|
|
.recording_sessions
|
|
.recording_sessions
|
|
@@ -147,60 +147,16 @@ pub async fn finalize_recording(
|
|
|
.remove(&session_id)
|
|
.remove(&session_id)
|
|
|
.ok_or_else(|| format!("找不到 session: {session_id}"))?
|
|
.ok_or_else(|| format!("找不到 session: {session_id}"))?
|
|
|
};
|
|
};
|
|
|
- let task_id = session.task_id.clone();
|
|
|
|
|
-
|
|
|
|
|
- let raw_path = paths::recording_raw_path_for_session(&app, &session_id)?;
|
|
|
|
|
- if !raw_path.exists() {
|
|
|
|
|
- let err = format!("raw webm 不存在: {}", raw_path.display());
|
|
|
|
|
- emit_failed(&app, &task_id, &session_id, &err);
|
|
|
|
|
- return Err(err);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let out_path = paths::recording_final_path_for_task(&app, &task_id)?;
|
|
|
|
|
-
|
|
|
|
|
- // 计算 crop in frame pixels
|
|
|
|
|
- if window_client.w <= 0.0 || window_client.h <= 0.0 {
|
|
|
|
|
- let err = "window_client 尺寸非法".to_string();
|
|
|
|
|
- emit_failed(&app, &task_id, &session_id, &err);
|
|
|
|
|
- return Err(err);
|
|
|
|
|
- }
|
|
|
|
|
- let ratio_x = frame.w as f64 / window_client.w;
|
|
|
|
|
- let ratio_y = frame.h as f64 / window_client.h;
|
|
|
|
|
- let cx = (crop.x * ratio_x).round() as i64;
|
|
|
|
|
- let cy = (crop.y * ratio_y).round() as i64;
|
|
|
|
|
- let cw = (crop.w * ratio_x).round() as i64;
|
|
|
|
|
- let ch = (crop.h * ratio_y).round() as i64;
|
|
|
|
|
-
|
|
|
|
|
- if cw <= 0 || ch <= 0 {
|
|
|
|
|
- let err = format!("无效的 crop 区域: w={cw} h={ch}");
|
|
|
|
|
- emit_failed(&app, &task_id, &session_id, &err);
|
|
|
|
|
- return Err(err);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // libx264 要求宽高为偶数;输出尺寸同样对齐
|
|
|
|
|
- let cw_even = cw - (cw % 2);
|
|
|
|
|
- let ch_even = ch - (ch % 2);
|
|
|
|
|
- let ow_even = (output.w as i64) - (output.w as i64 % 2);
|
|
|
|
|
- let oh_even = (output.h as i64) - (output.h as i64 % 2);
|
|
|
|
|
-
|
|
|
|
|
- let vf = format!(
|
|
|
|
|
- "crop={cw}:{ch}:{cx}:{cy},scale={ow}:{oh}",
|
|
|
|
|
- cw = cw_even,
|
|
|
|
|
- ch = ch_even,
|
|
|
|
|
- cx = cx,
|
|
|
|
|
- cy = cy,
|
|
|
|
|
- ow = ow_even,
|
|
|
|
|
- oh = oh_even,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- let result = run_ffmpeg(&raw_path, &out_path, &vf).await;
|
|
|
|
|
-
|
|
|
|
|
- // 不论成功失败都删 raw,避免 cache 越堆越大
|
|
|
|
|
- let _ = std::fs::remove_file(&raw_path);
|
|
|
|
|
|
|
+ let RecordingSession {
|
|
|
|
|
+ task_id,
|
|
|
|
|
+ output_path,
|
|
|
|
|
+ mut child,
|
|
|
|
|
+ stderr_log,
|
|
|
|
|
+ } = session;
|
|
|
|
|
|
|
|
- match result {
|
|
|
|
|
|
|
+ match graceful_stop(&mut child, &stderr_log).await {
|
|
|
Ok(()) => {
|
|
Ok(()) => {
|
|
|
- let path_str = out_path.to_string_lossy().to_string();
|
|
|
|
|
|
|
+ let path_str = output_path.to_string_lossy().to_string();
|
|
|
let _ = app.emit(
|
|
let _ = app.emit(
|
|
|
EVT_RECORDING_FINISHED,
|
|
EVT_RECORDING_FINISHED,
|
|
|
RecordingFinished {
|
|
RecordingFinished {
|
|
@@ -212,79 +168,222 @@ pub async fn finalize_recording(
|
|
|
Ok(path_str)
|
|
Ok(path_str)
|
|
|
}
|
|
}
|
|
|
Err(e) => {
|
|
Err(e) => {
|
|
|
- let err = format!("ffmpeg 转码失败: {e}");
|
|
|
|
|
- emit_failed(&app, &task_id, &session_id, &err);
|
|
|
|
|
- Err(err)
|
|
|
|
|
|
|
+ // 优雅停止失败时,半成品 mp4 多半不可用(缺 moov atom),直接删
|
|
|
|
|
+ let _ = std::fs::remove_file(&output_path);
|
|
|
|
|
+ let _ = app.emit(
|
|
|
|
|
+ EVT_RECORDING_FAILED,
|
|
|
|
|
+ RecordingFailed {
|
|
|
|
|
+ task_id,
|
|
|
|
|
+ session_id,
|
|
|
|
|
+ error: e.clone(),
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+ Err(e)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// 用户中途取消录制:删除可能存在的 raw 文件,从 session map 移除。
|
|
|
|
|
|
|
+/// 取消录制:kill ffmpeg + 删半成品(不 emit finished/failed)
|
|
|
#[tauri::command]
|
|
#[tauri::command]
|
|
|
-pub fn cancel_recording(
|
|
|
|
|
- app: AppHandle,
|
|
|
|
|
|
|
+pub async fn cancel_recording(
|
|
|
state: State<'_, AppState>,
|
|
state: State<'_, AppState>,
|
|
|
session_id: String,
|
|
session_id: String,
|
|
|
) -> Result<(), String> {
|
|
) -> Result<(), String> {
|
|
|
- if let Ok(p) = paths::recording_raw_path_for_session(&app, &session_id) {
|
|
|
|
|
- let _ = std::fs::remove_file(&p);
|
|
|
|
|
- }
|
|
|
|
|
- if let Ok(mut guard) = state.recording_sessions.lock() {
|
|
|
|
|
- guard.remove(&session_id);
|
|
|
|
|
|
|
+ let session = {
|
|
|
|
|
+ let mut guard = state
|
|
|
|
|
+ .recording_sessions
|
|
|
|
|
+ .lock()
|
|
|
|
|
+ .map_err(|e| format!("recording_sessions 锁失败: {e}"))?;
|
|
|
|
|
+ guard.remove(&session_id)
|
|
|
|
|
+ };
|
|
|
|
|
+ if let Some(mut s) = session {
|
|
|
|
|
+ let _ = s.child.kill().await;
|
|
|
|
|
+ let _ = std::fs::remove_file(&s.output_path);
|
|
|
}
|
|
}
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-fn emit_failed(app: &AppHandle, task_id: &str, session_id: &str, err: &str) {
|
|
|
|
|
- let _ = app.emit(
|
|
|
|
|
- EVT_RECORDING_FAILED,
|
|
|
|
|
- RecordingFailed {
|
|
|
|
|
- task_id: task_id.to_string(),
|
|
|
|
|
- session_id: session_id.to_string(),
|
|
|
|
|
- error: err.to_string(),
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
|
|
+/// 返回 content webview 在屏幕上的物理像素矩形 (x, y, w, h)。
|
|
|
|
|
+/// 宽高对齐为偶数(libx264 要求)。
|
|
|
|
|
+fn query_content_screen_rect(app: &AppHandle) -> Result<(i32, i32, u32, u32), String> {
|
|
|
|
|
+ let window = app
|
|
|
|
|
+ .get_window("main")
|
|
|
|
|
+ .ok_or_else(|| "找不到主窗口".to_string())?;
|
|
|
|
|
+ let webview = find_content_webview(app)?;
|
|
|
|
|
+
|
|
|
|
|
+ let win_pos = window
|
|
|
|
|
+ .inner_position()
|
|
|
|
|
+ .map_err(|e| format!("读取窗口位置失败: {e}"))?;
|
|
|
|
|
+ let wv_pos = webview
|
|
|
|
|
+ .position()
|
|
|
|
|
+ .map_err(|e| format!("读取 webview 位置失败: {e}"))?;
|
|
|
|
|
+ let wv_size = webview
|
|
|
|
|
+ .size()
|
|
|
|
|
+ .map_err(|e| format!("读取 webview 尺寸失败: {e}"))?;
|
|
|
|
|
+
|
|
|
|
|
+ let x = win_pos.x + wv_pos.x;
|
|
|
|
|
+ let y = win_pos.y + wv_pos.y;
|
|
|
|
|
+ let w = wv_size.width - (wv_size.width % 2);
|
|
|
|
|
+ let h = wv_size.height - (wv_size.height % 2);
|
|
|
|
|
+ if w == 0 || h == 0 {
|
|
|
|
|
+ return Err(format!("content webview 尺寸非法: {w}x{h}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok((x, y, w, h))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// 调系统 PATH 中的 ffmpeg 把 webm 按 `-vf` 滤镜转为 mp4。
|
|
|
|
|
|
|
+/// Spawn ffmpeg + Windows gdigrab,返回 child + stderr 共享缓冲。
|
|
|
///
|
|
///
|
|
|
-/// 如果系统没装 ffmpeg,会返回带具体提示的错误。后续可以替换为
|
|
|
|
|
-/// ffmpeg-sidecar 打包,本函数实现替换即可。
|
|
|
|
|
-async fn run_ffmpeg(
|
|
|
|
|
- input: &std::path::Path,
|
|
|
|
|
- output: &std::path::Path,
|
|
|
|
|
- vf: &str,
|
|
|
|
|
-) -> anyhow::Result<()> {
|
|
|
|
|
- use tokio::process::Command;
|
|
|
|
|
-
|
|
|
|
|
- let status = Command::new("ffmpeg")
|
|
|
|
|
- .arg("-y")
|
|
|
|
|
- .arg("-i")
|
|
|
|
|
- .arg(input)
|
|
|
|
|
- .arg("-vf")
|
|
|
|
|
- .arg(vf)
|
|
|
|
|
- .arg("-c:v")
|
|
|
|
|
- .arg("libx264")
|
|
|
|
|
- .arg("-preset")
|
|
|
|
|
- .arg("veryfast")
|
|
|
|
|
- .arg("-crf")
|
|
|
|
|
- .arg("22")
|
|
|
|
|
- .arg("-pix_fmt")
|
|
|
|
|
- .arg("yuv420p")
|
|
|
|
|
- .arg("-movflags")
|
|
|
|
|
- .arg("+faststart")
|
|
|
|
|
- .arg(output)
|
|
|
|
|
- .stdin(std::process::Stdio::null())
|
|
|
|
|
- .stdout(std::process::Stdio::null())
|
|
|
|
|
- .stderr(std::process::Stdio::null())
|
|
|
|
|
- .status()
|
|
|
|
|
- .await
|
|
|
|
|
- .map_err(|e| {
|
|
|
|
|
- anyhow::anyhow!("启动 ffmpeg 失败(请确认系统已安装 ffmpeg 并在 PATH 中): {e}")
|
|
|
|
|
- })?;
|
|
|
|
|
-
|
|
|
|
|
- if !status.success() {
|
|
|
|
|
- anyhow::bail!("ffmpeg 退出码非 0: {status:?}");
|
|
|
|
|
|
|
+/// 非 Windows 平台直接返回错误(macOS / Linux 暂不支持原生录制)。
|
|
|
|
|
+fn spawn_ffmpeg(
|
|
|
|
|
+ x: i32,
|
|
|
|
|
+ y: i32,
|
|
|
|
|
+ w: u32,
|
|
|
|
|
+ h: u32,
|
|
|
|
|
+ output: &PathBuf,
|
|
|
|
|
+) -> Result<(Child, SharedStderrLog), String> {
|
|
|
|
|
+ let mut cmd = Command::new("ffmpeg");
|
|
|
|
|
+ cmd.arg("-y").arg("-hide_banner");
|
|
|
|
|
+
|
|
|
|
|
+ #[cfg(target_os = "windows")]
|
|
|
|
|
+ {
|
|
|
|
|
+ // gdigrab:直接按物理像素抓取桌面区域。
|
|
|
|
|
+ // 注意:HiDPI 缩放下 win_pos / wv_pos 已是物理像素,与 gdigrab 一致。
|
|
|
|
|
+ cmd.arg("-f").arg("gdigrab")
|
|
|
|
|
+ .arg("-framerate").arg("30")
|
|
|
|
|
+ .arg("-offset_x").arg(x.to_string())
|
|
|
|
|
+ .arg("-offset_y").arg(y.to_string())
|
|
|
|
|
+ .arg("-video_size").arg(format!("{w}x{h}"))
|
|
|
|
|
+ .arg("-i").arg("desktop");
|
|
|
}
|
|
}
|
|
|
- Ok(())
|
|
|
|
|
|
|
+ #[cfg(not(target_os = "windows"))]
|
|
|
|
|
+ {
|
|
|
|
|
+ let _ = (x, y, w, h, output); // 抑制未使用警告
|
|
|
|
|
+ return Err("录制功能目前仅支持 Windows".to_string());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cmd.arg("-c:v").arg("libx264")
|
|
|
|
|
+ .arg("-preset").arg("veryfast")
|
|
|
|
|
+ .arg("-crf").arg("22")
|
|
|
|
|
+ .arg("-pix_fmt").arg("yuv420p")
|
|
|
|
|
+ .arg("-movflags").arg("+faststart")
|
|
|
|
|
+ .arg(output);
|
|
|
|
|
+
|
|
|
|
|
+ cmd.stdin(Stdio::piped())
|
|
|
|
|
+ .stdout(Stdio::null())
|
|
|
|
|
+ // 改为 piped,下面 spawn 后转给抽取任务实时打到 terminal
|
|
|
|
|
+ .stderr(Stdio::piped())
|
|
|
|
|
+ .kill_on_drop(true);
|
|
|
|
|
+
|
|
|
|
|
+ // 把要执行的命令打到日志,方便用户手工复现调试
|
|
|
|
|
+ eprintln!(
|
|
|
|
|
+ "[recording] spawning: ffmpeg{}",
|
|
|
|
|
+ format_args_for_log(&cmd)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ let mut child = cmd.spawn().map_err(|e| {
|
|
|
|
|
+ format!("启动 ffmpeg 失败(请确认系统已安装 ffmpeg 并在 PATH 中): {e}")
|
|
|
|
|
+ })?;
|
|
|
|
|
+
|
|
|
|
|
+ // 接管 stderr:丢给一个 tokio 任务,按行实时 eprintln,同时往共享缓冲里写
|
|
|
|
|
+ let stderr_log: SharedStderrLog =
|
|
|
|
|
+ Arc::new(StdMutex::new(VecDeque::with_capacity(STDERR_LOG_MAX_LINES)));
|
|
|
|
|
+ if let Some(stderr) = child.stderr.take() {
|
|
|
|
|
+ let log_clone = stderr_log.clone();
|
|
|
|
|
+ tokio::spawn(async move {
|
|
|
|
|
+ let mut reader = BufReader::new(stderr).lines();
|
|
|
|
|
+ loop {
|
|
|
|
|
+ match reader.next_line().await {
|
|
|
|
|
+ Ok(Some(line)) => {
|
|
|
|
|
+ eprintln!("[ffmpeg] {line}");
|
|
|
|
|
+ if let Ok(mut g) = log_clone.lock() {
|
|
|
|
|
+ if g.len() >= STDERR_LOG_MAX_LINES {
|
|
|
|
|
+ g.pop_front();
|
|
|
|
|
+ }
|
|
|
|
|
+ g.push_back(line);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(None) => break, // ffmpeg 关掉了 stderr
|
|
|
|
|
+ Err(e) => {
|
|
|
|
|
+ eprintln!("[ffmpeg] 读 stderr 失败: {e}");
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Ok((child, stderr_log))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// 向 ffmpeg stdin 写 'q' 让其优雅退出(落盘 moov atom),超时强杀
|
|
|
|
|
+async fn graceful_stop(child: &mut Child, stderr_log: &SharedStderrLog) -> Result<(), String> {
|
|
|
|
|
+ // 0) 先看进程是否已经死了——避免无意义的 Broken Pipe 日志
|
|
|
|
|
+ match child.try_wait() {
|
|
|
|
|
+ Ok(Some(status)) => {
|
|
|
|
|
+ tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
|
+ let tail = drain_stderr_tail(stderr_log, 12);
|
|
|
|
|
+ return Err(format!(
|
|
|
|
|
+ "ffmpeg 已提前退出({status})。stderr 末尾:\n{tail}"
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(None) => {} // 还活着,继续
|
|
|
|
|
+ Err(e) => {
|
|
|
|
|
+ return Err(format!("查询 ffmpeg 状态失败: {e}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 1) 写 'q'。ffmpeg 子进程会轮询 stdin 并在收到 'q' 后正常退出
|
|
|
|
|
+ if let Some(stdin) = child.stdin.as_mut() {
|
|
|
|
|
+ if let Err(e) = stdin.write_all(b"q\n").await {
|
|
|
|
|
+ // 走到这里只可能是写入瞬间 ffmpeg 刚好崩了;附 stderr 末尾给前端
|
|
|
|
|
+ tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
|
+ let tail = drain_stderr_tail(stderr_log, 12);
|
|
|
|
|
+ return Err(format!(
|
|
|
|
|
+ "向 ffmpeg stdin 写 'q' 失败({e})。stderr 末尾:\n{tail}"
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ let _ = stdin.flush().await;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2) 等待退出;超时则 kill
|
|
|
|
|
+ match tokio::time::timeout(Duration::from_secs(STOP_TIMEOUT_SEC), child.wait()).await {
|
|
|
|
|
+ Ok(Ok(status)) => {
|
|
|
|
|
+ if status.success() {
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let tail = drain_stderr_tail(stderr_log, 12);
|
|
|
|
|
+ Err(format!("ffmpeg 退出码非 0({status})。stderr 末尾:\n{tail}"))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(Err(e)) => Err(format!("等待 ffmpeg 退出失败: {e}")),
|
|
|
|
|
+ Err(_) => {
|
|
|
|
|
+ eprintln!("[recording] ffmpeg {STOP_TIMEOUT_SEC}s 内未退出,强制 kill");
|
|
|
|
|
+ let _ = child.kill().await;
|
|
|
|
|
+ Err("ffmpeg 退出超时".to_string())
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// 取共享缓冲最后 n 行,按发生顺序用换行串起来
|
|
|
|
|
+fn drain_stderr_tail(log: &SharedStderrLog, n: usize) -> String {
|
|
|
|
|
+ let Ok(g) = log.lock() else {
|
|
|
|
|
+ return String::new();
|
|
|
|
|
+ };
|
|
|
|
|
+ let total = g.len();
|
|
|
|
|
+ let skip = total.saturating_sub(n);
|
|
|
|
|
+ g.iter()
|
|
|
|
|
+ .skip(skip)
|
|
|
|
|
+ .cloned()
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join("\n")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// 把 Command 的参数序列化成可读 string(仅用于日志,不严格 shell-escape)
|
|
|
|
|
+fn format_args_for_log(cmd: &Command) -> String {
|
|
|
|
|
+ use std::fmt::Write;
|
|
|
|
|
+ let mut s = String::new();
|
|
|
|
|
+ for arg in cmd.as_std().get_args() {
|
|
|
|
|
+ let _ = write!(&mut s, " {:?}", arg);
|
|
|
|
|
+ }
|
|
|
|
|
+ s
|
|
|
}
|
|
}
|