|
@@ -24,7 +24,7 @@
|
|
|
use crate::{find_content_webview, paths, AppState};
|
|
use crate::{find_content_webview, paths, AppState};
|
|
|
use serde::Serialize;
|
|
use serde::Serialize;
|
|
|
use std::collections::VecDeque;
|
|
use std::collections::VecDeque;
|
|
|
-use std::path::PathBuf;
|
|
|
|
|
|
|
+use std::path::{Path, PathBuf};
|
|
|
use std::process::Stdio;
|
|
use std::process::Stdio;
|
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
|
use std::time::Duration;
|
|
use std::time::Duration;
|
|
@@ -33,6 +33,9 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
|
use tokio::process::{Child, Command};
|
|
use tokio::process::{Child, Command};
|
|
|
use uuid::Uuid;
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
+/// 输出视频固定帧率
|
|
|
|
|
+const OUTPUT_FPS: u32 = 24;
|
|
|
|
|
+
|
|
|
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";
|
|
|
|
|
|
|
@@ -82,20 +85,39 @@ pub async fn start_recording(
|
|
|
app: AppHandle,
|
|
app: AppHandle,
|
|
|
state: State<'_, AppState>,
|
|
state: State<'_, AppState>,
|
|
|
task_id: String,
|
|
task_id: String,
|
|
|
|
|
+ content_width: u32,
|
|
|
|
|
+ content_height: u32,
|
|
|
) -> Result<String, String> {
|
|
) -> Result<String, String> {
|
|
|
- eprintln!("[recording] start_recording 进入:task_id={task_id}");
|
|
|
|
|
|
|
+ eprintln!(
|
|
|
|
|
+ "[recording] start_recording 进入:task_id={task_id} \
|
|
|
|
|
+ content={content_width}x{content_height}"
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- // 1) 计算 content webview 在屏幕上的物理像素矩形
|
|
|
|
|
|
|
+ // 1) 计算 content webview 在屏幕上的物理像素矩形(gdigrab 抓取用)
|
|
|
let (x, y, w, h) = query_content_screen_rect(&app)?;
|
|
let (x, y, w, h) = query_content_screen_rect(&app)?;
|
|
|
eprintln!("[recording] content webview 屏幕矩形:x={x} y={y} w={w} h={h}");
|
|
eprintln!("[recording] content webview 屏幕矩形:x={x} y={y} w={w} h={h}");
|
|
|
|
|
|
|
|
- // 2) 输出路径(recordings_dir 内部会 ensure_dir)
|
|
|
|
|
|
|
+ // 2) 输出尺寸 = 前端 contentSize(逻辑像素),libx264 要求偶数
|
|
|
|
|
+ let out_w = content_width - (content_width % 2);
|
|
|
|
|
+ let out_h = content_height - (content_height % 2);
|
|
|
|
|
+ if out_w == 0 || out_h == 0 {
|
|
|
|
|
+ return Err(format!(
|
|
|
|
|
+ "contentSize 非法:{content_width}x{content_height}"
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 输出路径(recordings_dir 内部会 ensure_dir)
|
|
|
let output_path = paths::recording_final_path_for_task(&app, &task_id)?;
|
|
let output_path = paths::recording_final_path_for_task(&app, &task_id)?;
|
|
|
// 若已存在旧文件,先删;ffmpeg -y 也能覆盖,但提前删能避免 reveal 到旧文件
|
|
// 若已存在旧文件,先删;ffmpeg -y 也能覆盖,但提前删能避免 reveal 到旧文件
|
|
|
let _ = std::fs::remove_file(&output_path);
|
|
let _ = std::fs::remove_file(&output_path);
|
|
|
|
|
|
|
|
- // 3) 启动 ffmpeg;同时开 stderr 抽取任务
|
|
|
|
|
- let (mut child, stderr_log) = spawn_ffmpeg(x, y, w, h, &output_path)?;
|
|
|
|
|
|
|
+ // 4) 解析 ffmpeg 可执行文件路径(打包到 resources/ffmpeg/)
|
|
|
|
|
+ let ffmpeg_path = resolve_ffmpeg_path(&app)?;
|
|
|
|
|
+ eprintln!("[recording] 使用 ffmpeg: {}", ffmpeg_path.display());
|
|
|
|
|
+
|
|
|
|
|
+ // 5) 启动 ffmpeg;同时开 stderr 抽取任务
|
|
|
|
|
+ let (mut child, stderr_log) =
|
|
|
|
|
+ spawn_ffmpeg(&ffmpeg_path, x, y, w, h, out_w, out_h, &output_path)?;
|
|
|
|
|
|
|
|
// 4) 健康检查:等 500ms,看 ffmpeg 是否一启动就 crash(比如参数不被接受、
|
|
// 4) 健康检查:等 500ms,看 ffmpeg 是否一启动就 crash(比如参数不被接受、
|
|
|
// 平台 backend 不可用、libx264 缺失等)。若已退出,把 stderr 末尾返回。
|
|
// 平台 backend 不可用、libx264 缺失等)。若已退出,把 stderr 末尾返回。
|
|
@@ -203,6 +225,37 @@ pub async fn cancel_recording(
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// 解析打包进 app 的 ffmpeg 可执行文件路径。
|
|
|
|
|
+///
|
|
|
|
|
+/// 文件由 `src-tauri/tauri.conf.json` 的 `bundle.resources` 复制到运行时
|
|
|
|
|
+/// 的 `resource_dir`:
|
|
|
|
|
+/// - dev 模式:`<repo>/src-tauri/`
|
|
|
|
|
+/// - Windows 打包:安装目录(exe 同级)
|
|
|
|
|
+/// - macOS 打包:`.app/Contents/Resources/`
|
|
|
|
|
+///
|
|
|
|
|
+/// 因此用 `app.path().resource_dir()? + 'resources/ffmpeg/ffmpeg(.exe)'` 都能命中。
|
|
|
|
|
+fn resolve_ffmpeg_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|
|
|
|
+ let resource_dir = app
|
|
|
|
|
+ .path()
|
|
|
|
|
+ .resource_dir()
|
|
|
|
|
+ .map_err(|e| format!("读取 resource_dir 失败: {e}"))?;
|
|
|
|
|
+
|
|
|
|
|
+ let file_name = if cfg!(target_os = "windows") {
|
|
|
|
|
+ "ffmpeg.exe"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ "ffmpeg"
|
|
|
|
|
+ };
|
|
|
|
|
+ let p = resource_dir.join("resources").join("ffmpeg").join(file_name);
|
|
|
|
|
+
|
|
|
|
|
+ if !p.exists() {
|
|
|
|
|
+ return Err(format!(
|
|
|
|
|
+ "未找到 ffmpeg 可执行文件:{}(请确认已放到 src-tauri/resources/ffmpeg/ 下)",
|
|
|
|
|
+ p.display()
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(p)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// 返回 content webview 在屏幕上的物理像素矩形 (x, y, w, h)。
|
|
/// 返回 content webview 在屏幕上的物理像素矩形 (x, y, w, h)。
|
|
|
/// 宽高对齐为偶数(libx264 要求)。
|
|
/// 宽高对齐为偶数(libx264 要求)。
|
|
|
fn query_content_screen_rect(app: &AppHandle) -> Result<(i32, i32, u32, u32), String> {
|
|
fn query_content_screen_rect(app: &AppHandle) -> Result<(i32, i32, u32, u32), String> {
|
|
@@ -233,25 +286,33 @@ fn query_content_screen_rect(app: &AppHandle) -> Result<(i32, i32, u32, u32), St
|
|
|
|
|
|
|
|
/// Spawn ffmpeg + Windows gdigrab,返回 child + stderr 共享缓冲。
|
|
/// Spawn ffmpeg + Windows gdigrab,返回 child + stderr 共享缓冲。
|
|
|
///
|
|
///
|
|
|
-/// 非 Windows 平台直接返回错误(macOS / Linux 暂不支持原生录制)。
|
|
|
|
|
|
|
+/// 输入端按物理像素 (x,y,w,h) 抓取桌面;输出端通过 `-vf scale` 缩放到
|
|
|
|
|
+/// `out_w × out_h`(前端传入的 contentSize,逻辑像素),并固定 24 fps。
|
|
|
|
|
+///
|
|
|
|
|
+/// 非 Windows 平台目前未启用 gdigrab 分支,仍会构造命令但抓取源缺失,
|
|
|
|
|
+/// 调用方 (recording.rs 顶部) 已注明仅 Windows 支持。
|
|
|
fn spawn_ffmpeg(
|
|
fn spawn_ffmpeg(
|
|
|
|
|
+ ffmpeg_path: &Path,
|
|
|
x: i32,
|
|
x: i32,
|
|
|
y: i32,
|
|
y: i32,
|
|
|
w: u32,
|
|
w: u32,
|
|
|
h: u32,
|
|
h: u32,
|
|
|
|
|
+ out_w: u32,
|
|
|
|
|
+ out_h: u32,
|
|
|
output: &PathBuf,
|
|
output: &PathBuf,
|
|
|
) -> Result<(Child, SharedStderrLog), String> {
|
|
) -> Result<(Child, SharedStderrLog), String> {
|
|
|
- let mut cmd = Command::new("ffmpeg");
|
|
|
|
|
|
|
+ let mut cmd = Command::new(ffmpeg_path);
|
|
|
cmd.arg("-y").arg("-hide_banner");
|
|
cmd.arg("-y").arg("-hide_banner");
|
|
|
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[cfg(target_os = "windows")]
|
|
|
{
|
|
{
|
|
|
// gdigrab:直接按物理像素抓取桌面区域。
|
|
// gdigrab:直接按物理像素抓取桌面区域。
|
|
|
// 注意:HiDPI 缩放下 win_pos / wv_pos 已是物理像素,与 gdigrab 一致。
|
|
// 注意:HiDPI 缩放下 win_pos / wv_pos 已是物理像素,与 gdigrab 一致。
|
|
|
|
|
+ // 输入端抓 24 fps,避免多抓的帧被 ffmpeg 内部 drop。
|
|
|
cmd.arg("-f")
|
|
cmd.arg("-f")
|
|
|
.arg("gdigrab")
|
|
.arg("gdigrab")
|
|
|
.arg("-framerate")
|
|
.arg("-framerate")
|
|
|
- .arg("30")
|
|
|
|
|
|
|
+ .arg(OUTPUT_FPS.to_string())
|
|
|
.arg("-offset_x")
|
|
.arg("-offset_x")
|
|
|
.arg(x.to_string())
|
|
.arg(x.to_string())
|
|
|
.arg("-offset_y")
|
|
.arg("-offset_y")
|
|
@@ -261,13 +322,13 @@ fn spawn_ffmpeg(
|
|
|
.arg("-i")
|
|
.arg("-i")
|
|
|
.arg("desktop");
|
|
.arg("desktop");
|
|
|
}
|
|
}
|
|
|
- // #[cfg(not(target_os = "windows"))]
|
|
|
|
|
- // {
|
|
|
|
|
- // let _ = (x, y, w, h, output); // 抑制未使用警告
|
|
|
|
|
- // return Err("录制功能目前仅支持 Windows".to_string());
|
|
|
|
|
- // }
|
|
|
|
|
|
|
|
|
|
- cmd.arg("-c:v")
|
|
|
|
|
|
|
+ // 输出参数:先缩放到 contentSize,再编 H.264;输出 fps 固定 24
|
|
|
|
|
+ cmd.arg("-vf")
|
|
|
|
|
+ .arg(format!("scale={out_w}:{out_h}"))
|
|
|
|
|
+ .arg("-r")
|
|
|
|
|
+ .arg(OUTPUT_FPS.to_string())
|
|
|
|
|
+ .arg("-c:v")
|
|
|
.arg("libx264")
|
|
.arg("libx264")
|
|
|
.arg("-preset")
|
|
.arg("-preset")
|
|
|
.arg("veryfast")
|
|
.arg("veryfast")
|
|
@@ -288,9 +349,11 @@ fn spawn_ffmpeg(
|
|
|
// 把要执行的命令打到日志,方便用户手工复现调试
|
|
// 把要执行的命令打到日志,方便用户手工复现调试
|
|
|
eprintln!("[recording] spawning: ffmpeg{}", format_args_for_log(&cmd));
|
|
eprintln!("[recording] spawning: ffmpeg{}", format_args_for_log(&cmd));
|
|
|
|
|
|
|
|
- let mut child = cmd
|
|
|
|
|
- .spawn()
|
|
|
|
|
- .map_err(|e| format!("启动 ffmpeg 失败(请确认系统已安装 ffmpeg 并在 PATH 中): {e}"))?;
|
|
|
|
|
|
|
+ let mut child = cmd.spawn().map_err(|e| {
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "启动 ffmpeg 失败(请确认 src-tauri/resources/ffmpeg/ 下已放置 ffmpeg.exe): {e}"
|
|
|
|
|
+ )
|
|
|
|
|
+ })?;
|
|
|
|
|
|
|
|
// 接管 stderr:丢给一个 tokio 任务,按行实时 eprintln,同时往共享缓冲里写
|
|
// 接管 stderr:丢给一个 tokio 任务,按行实时 eprintln,同时往共享缓冲里写
|
|
|
let stderr_log: SharedStderrLog =
|
|
let stderr_log: SharedStderrLog =
|