lv 2 săptămâni în urmă
părinte
comite
6e19573f81

+ 36 - 0
src-tauri/resources/ffmpeg/README.md

@@ -0,0 +1,36 @@
+# ffmpeg 资源目录
+
+把 Windows 用的 `ffmpeg.exe`(推荐 ffmpeg 6.x 静态构建,含 libx264)直接放在这个目录下,最终结构应为:
+
+```
+src-tauri/resources/ffmpeg/
+├── ffmpeg.exe              ← 必需,单文件即可(静态构建已自带 libx264)
+├── <任意运行时依赖.dll>    ← 可选,如果你用的是动态构建,把同目录的 dll 一并放进来
+└── README.md               ← 本说明
+```
+
+## 打包行为
+
+`src-tauri/tauri.conf.json` 中 `bundle.resources` 已经声明:
+
+```jsonc
+"resources": [ "resources/ffmpeg/*" ]
+```
+
+- **开发模式(pnpm tauri dev)**:`app.path().resource_dir()` 返回 `src-tauri/`,
+  Rust 端调用 `resources/ffmpeg/ffmpeg.exe`,直接命中本目录的文件。
+- **打包模式(pnpm tauri build)**:本目录里所有文件会被 bundler 复制到
+  安装目录的 `resources/ffmpeg/`(Windows 安装包是 exe 同级),Rust 端的
+  解析逻辑同样会拼到正确路径,因此不需要把 ffmpeg.exe 放到系统 PATH。
+
+## 推荐下载
+
+- Windows:https://www.gyan.dev/ffmpeg/builds/ 选 `release-full` 的静态构建,
+  解压后取里面的 `bin/ffmpeg.exe` 放到本目录。
+- macOS / Linux 当前不支持原生录制(recording.rs 头部有说明),可以不放。
+
+## 注意
+
+- 不要把整个 ffmpeg 解压包都丢进来,bundler 会把所有文件都打到安装包里,体积会显著变大。
+- 如果未来要支持 macOS / Linux,再在本目录下放对应平台的二进制,并改造
+  `recording.rs::resolve_ffmpeg_path` 按 `cfg!(target_os)` 分支选择文件名。

+ 81 - 18
src-tauri/src/recording.rs

@@ -24,7 +24,7 @@
 use crate::{find_content_webview, paths, AppState};
 use serde::Serialize;
 use std::collections::VecDeque;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 use std::process::Stdio;
 use std::sync::{Arc, Mutex as StdMutex};
 use std::time::Duration;
@@ -33,6 +33,9 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
 use tokio::process::{Child, Command};
 use uuid::Uuid;
 
+/// 输出视频固定帧率
+const OUTPUT_FPS: u32 = 24;
+
 const EVT_RECORDING_FINISHED: &str = "recording-finished";
 const EVT_RECORDING_FAILED: &str = "recording-failed";
 
@@ -82,20 +85,39 @@ pub async fn start_recording(
     app: AppHandle,
     state: State<'_, AppState>,
     task_id: String,
+    content_width: u32,
+    content_height: u32,
 ) -> 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)?;
     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)?;
     // 若已存在旧文件,先删;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) 解析 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(比如参数不被接受、
     //    平台 backend 不可用、libx264 缺失等)。若已退出,把 stderr 末尾返回。
@@ -203,6 +225,37 @@ pub async fn cancel_recording(
     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)。
 /// 宽高对齐为偶数(libx264 要求)。
 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 共享缓冲。
 ///
-/// 非 Windows 平台直接返回错误(macOS / Linux 暂不支持原生录制)。
+/// 输入端按物理像素 (x,y,w,h) 抓取桌面;输出端通过 `-vf scale` 缩放到
+/// `out_w × out_h`(前端传入的 contentSize,逻辑像素),并固定 24 fps。
+///
+/// 非 Windows 平台目前未启用 gdigrab 分支,仍会构造命令但抓取源缺失,
+/// 调用方 (recording.rs 顶部) 已注明仅 Windows 支持。
 fn spawn_ffmpeg(
+    ffmpeg_path: &Path,
     x: i32,
     y: i32,
     w: u32,
     h: u32,
+    out_w: u32,
+    out_h: u32,
     output: &PathBuf,
 ) -> Result<(Child, SharedStderrLog), String> {
-    let mut cmd = Command::new("ffmpeg");
+    let mut cmd = Command::new(ffmpeg_path);
     cmd.arg("-y").arg("-hide_banner");
 
     #[cfg(target_os = "windows")]
     {
         // gdigrab:直接按物理像素抓取桌面区域。
         // 注意:HiDPI 缩放下 win_pos / wv_pos 已是物理像素,与 gdigrab 一致。
+        // 输入端抓 24 fps,避免多抓的帧被 ffmpeg 内部 drop。
         cmd.arg("-f")
             .arg("gdigrab")
             .arg("-framerate")
-            .arg("30")
+            .arg(OUTPUT_FPS.to_string())
             .arg("-offset_x")
             .arg(x.to_string())
             .arg("-offset_y")
@@ -261,13 +322,13 @@ fn spawn_ffmpeg(
             .arg("-i")
             .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("-preset")
         .arg("veryfast")
@@ -288,9 +349,11 @@ fn spawn_ffmpeg(
     // 把要执行的命令打到日志,方便用户手工复现调试
     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,同时往共享缓冲里写
     let stderr_log: SharedStderrLog =

+ 3 - 0
src-tauri/tauri.conf.json

@@ -42,6 +42,9 @@
       "icons/128x128@2x.png",
       "icons/icon.icns",
       "icons/icon.ico"
+    ],
+    "resources": [
+      "resources/ffmpeg/*"
     ]
   }
 }

+ 4 - 4
src/App.tsx

@@ -374,7 +374,7 @@ function App() {
   const handleStartRecord = useCallback(async () => {
     if (recordState !== "idle" || !activeId) return;
     try {
-      await startRecording(activeId);
+      await startRecording(activeId, contentSize.w, contentSize.h);
     } catch (e) {
       notifyApi.error({
         message: "开始录制失败",
@@ -382,7 +382,7 @@ function App() {
         duration: 6,
       });
     }
-  }, [recordState, activeId, notifyApi]);
+  }, [recordState, activeId, contentSize, notifyApi]);
 
   /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */
   const handleStopRecord = useCallback(async () => {
@@ -414,7 +414,7 @@ function App() {
             });
             return;
           }
-          await startRecording(activeId);
+          await startRecording(activeId, contentSize.w, contentSize.h);
         } else if (action === "stop") {
           if (recordState === "recording") {
             await stopRecording();
@@ -428,7 +428,7 @@ function App() {
         });
       }
     },
-    [recordState, activeId, notifyApi],
+    [recordState, activeId, contentSize, notifyApi],
   );
 
   // 订阅 Rust 端的全局快捷键事件 (F9 / F11)

+ 15 - 2
src/lib/recorder.ts

@@ -46,12 +46,25 @@ export function subscribeRecordState(listener: (s: RecordState) => void): () =>
 /**
  * 开始录制:让 Rust spawn ffmpeg,从此刻起到 stopRecording 之间的 content
  * webview 屏幕区域都会被录到 task-<taskId>.mp4(覆盖旧文件)。
+ *
+ * @param taskId 当前任务 id(决定输出文件名)
+ * @param contentWidth  输出视频的目标宽(= 前端 contentSize.w,逻辑像素)
+ * @param contentHeight 输出视频的目标高(= 前端 contentSize.h,逻辑像素)
+ *                      gdigrab 抓取的是物理像素,Rust 端会 -vf scale 到这个尺寸。
  */
-export async function startRecording(taskId: string): Promise<void> {
+export async function startRecording(
+  taskId: string,
+  contentWidth: number,
+  contentHeight: number,
+): Promise<void> {
   if (currentState !== "idle") {
     throw new Error(`当前录制状态为 ${currentState},无法开始新录制`);
   }
-  const sid = await invoke<string>("start_recording", { taskId });
+  const sid = await invoke<string>("start_recording", {
+    taskId,
+    contentWidth,
+    contentHeight,
+  });
   currentSessionId = sid;
   setState("recording");
 }