lv 2 tygodni temu
rodzic
commit
7255749876
6 zmienionych plików z 129 dodań i 68 usunięć
  1. 0 26
      pnpm-lock.yaml
  2. 1 1
      src-tauri/.gitignore
  3. 106 35
      src-tauri/src/recording.rs
  4. 3 0
      src-tauri/tauri.conf.json
  5. 4 4
      src/App.tsx
  6. 15 2
      src/lib/recorder.ts

+ 0 - 26
pnpm-lock.yaml

@@ -671,79 +671,66 @@ packages:
     resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-arm-musleabihf@4.60.3':
     resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
     cpu: [arm]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-arm64-gnu@4.60.3':
     resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-arm64-musl@4.60.3':
     resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-loong64-gnu@4.60.3':
     resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
     cpu: [loong64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-loong64-musl@4.60.3':
     resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
     cpu: [loong64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-ppc64-gnu@4.60.3':
     resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-ppc64-musl@4.60.3':
     resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
     cpu: [ppc64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-riscv64-gnu@4.60.3':
     resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-musl@4.60.3':
     resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
     cpu: [riscv64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-linux-s390x-gnu@4.60.3':
     resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-x64-gnu@4.60.3':
     resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@rollup/rollup-linux-x64-musl@4.60.3':
     resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@rollup/rollup-openbsd-x64@4.60.3':
     resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -813,28 +800,24 @@ packages:
     engines: {node: '>= 20'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@tailwindcss/oxide-linux-arm64-musl@4.3.0':
     resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
     engines: {node: '>= 20'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@tailwindcss/oxide-linux-x64-gnu@4.3.0':
     resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
     engines: {node: '>= 20'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@tailwindcss/oxide-linux-x64-musl@4.3.0':
     resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
     engines: {node: '>= 20'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@tailwindcss/oxide-wasm32-wasi@4.3.0':
     resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@@ -895,35 +878,30 @@ packages:
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@tauri-apps/cli-linux-arm64-musl@2.11.1':
     resolution: {integrity: sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@tauri-apps/cli-linux-riscv64-gnu@2.11.1':
     resolution: {integrity: sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==}
     engines: {node: '>= 10'}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@tauri-apps/cli-linux-x64-gnu@2.11.1':
     resolution: {integrity: sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@tauri-apps/cli-linux-x64-musl@2.11.1':
     resolution: {integrity: sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@tauri-apps/cli-win32-arm64-msvc@2.11.1':
     resolution: {integrity: sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==}
@@ -1126,28 +1104,24 @@ packages:
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   lightningcss-linux-arm64-musl@1.32.0:
     resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   lightningcss-linux-x64-gnu@1.32.0:
     resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   lightningcss-linux-x64-musl@1.32.0:
     resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   lightningcss-win32-arm64-msvc@1.32.0:
     resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}

+ 1 - 1
src-tauri/.gitignore

@@ -9,4 +9,4 @@
 /*.exe
 /*.zip
 /*.7z
-/*.rar
+/*.rar

+ 106 - 35
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,41 @@ 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("bin")
+        .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,39 +290,58 @@ 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 一致。
-        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");
-    }
-    #[cfg(not(target_os = "windows"))]
-    {
-        let _ = (x, y, w, h, output); // 抑制未使用警告
-        return Err("录制功能目前仅支持 Windows".to_string());
+        // 输入端抓 24 fps,避免多抓的帧被 ffmpeg 内部 drop。
+        cmd.arg("-f")
+            .arg("gdigrab")
+            .arg("-framerate")
+            .arg(OUTPUT_FPS.to_string())
+            .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");
     }
 
-    cmd.arg("-c:v").arg("libx264")
-        .arg("-preset").arg("veryfast")
-        .arg("-crf").arg("22")
-        .arg("-pix_fmt").arg("yuv420p")
-        .arg("-movflags").arg("+faststart")
+    // 输出参数:先缩放到 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")
+        .arg("-crf")
+        .arg("22")
+        .arg("-pix_fmt")
+        .arg("yuv420p")
+        .arg("-movflags")
+        .arg("+faststart")
         .arg(output);
 
     cmd.stdin(Stdio::piped())
@@ -275,13 +351,10 @@ fn spawn_ffmpeg(
         .kill_on_drop(true);
 
     // 把要执行的命令打到日志,方便用户手工复现调试
-    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}")
+        format!("启动 ffmpeg 失败(请确认 src-tauri/resources/ffmpeg/ 下已放置 ffmpeg.exe): {e}")
     })?;
 
     // 接管 stderr:丢给一个 tokio 任务,按行实时 eprintln,同时往共享缓冲里写
@@ -352,7 +425,9 @@ async fn graceful_stop(child: &mut Child, stderr_log: &SharedStderrLog) -> Resul
                 Ok(())
             } else {
                 let tail = drain_stderr_tail(stderr_log, 12);
-                Err(format!("ffmpeg 退出码非 0({status})。stderr 末尾:\n{tail}"))
+                Err(format!(
+                    "ffmpeg 退出码非 0({status})。stderr 末尾:\n{tail}"
+                ))
             }
         }
         Ok(Err(e)) => Err(format!("等待 ffmpeg 退出失败: {e}")),
@@ -371,11 +446,7 @@ fn drain_stderr_tail(log: &SharedStderrLog, n: usize) -> String {
     };
     let total = g.len();
     let skip = total.saturating_sub(n);
-    g.iter()
-        .skip(skip)
-        .cloned()
-        .collect::<Vec<_>>()
-        .join("\n")
+    g.iter().skip(skip).cloned().collect::<Vec<_>>().join("\n")
 }
 
 /// 把 Command 的参数序列化成可读 string(仅用于日志,不严格 shell-escape)

+ 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

@@ -377,7 +377,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: "开始录制失败",
@@ -385,7 +385,7 @@ function App() {
         duration: 6,
       });
     }
-  }, [recordState, activeId, notifyApi]);
+  }, [recordState, activeId, contentSize, notifyApi]);
 
   /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */
   const handleStopRecord = useCallback(async () => {
@@ -417,7 +417,7 @@ function App() {
             });
             return;
           }
-          await startRecording(activeId);
+          await startRecording(activeId, contentSize.w, contentSize.h);
         } else if (action === "stop") {
           if (recordState === "recording") {
             await stopRecording();
@@ -431,7 +431,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");
 }