lv 2 недель назад
Родитель
Сommit
ed7d9417aa

+ 120 - 0
public/preview.html

@@ -0,0 +1,120 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>预览</title>
+    <style>
+      html,
+      body {
+        margin: 0;
+        padding: 0;
+        height: 100%;
+        background: #1f1f1f;
+        color: #fff;
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
+          "Microsoft YaHei", sans-serif;
+        overflow: hidden;
+        user-select: none;
+      }
+      .center {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 100vh;
+        width: 100vw;
+      }
+      img,
+      video {
+        max-width: 100%;
+        max-height: 100%;
+        display: block;
+        background: #000;
+      }
+      .msg {
+        padding: 24px;
+        text-align: center;
+        color: #ccc;
+        font-size: 14px;
+      }
+      .err {
+        color: #ff7875;
+      }
+      .hint {
+        position: fixed;
+        bottom: 8px;
+        left: 0;
+        right: 0;
+        text-align: center;
+        font-size: 12px;
+        color: #888;
+        pointer-events: none;
+      }
+    </style>
+  </head>
+  <body>
+    <!-- 入口容器:根据 URL ?type=image|video & path=<abs path> 渲染对应元素 -->
+    <div class="center" id="root">
+      <div class="msg">加载中…</div>
+    </div>
+    <div class="hint" id="path-hint"></div>
+
+    <script>
+      (function () {
+        var params = new URLSearchParams(location.search);
+        var type = params.get("type");
+        var path = params.get("path");
+        var root = document.getElementById("root");
+        var hint = document.getElementById("path-hint");
+
+        if (!path || (type !== "image" && type !== "video")) {
+          root.innerHTML =
+            '<div class="msg err">参数错误:缺少 type 或 path</div>';
+          return;
+        }
+
+        hint.textContent = path;
+
+        // Tauri v2 在 window 注入 __TAURI__ 全局对象(依赖
+        // tauri.conf.json#app.withGlobalTauri = true)。convertFileSrc
+        // 会按 OS 把 fs 路径转成 asset://localhost/... 或
+        // https://asset.localhost/...,再由 webview 通过 asset 协议加载文件
+        var convertFileSrc =
+          window.__TAURI__ &&
+          window.__TAURI__.core &&
+          window.__TAURI__.core.convertFileSrc;
+
+        if (!convertFileSrc) {
+          root.innerHTML =
+            '<div class="msg err">未找到 __TAURI__.core.convertFileSrc,' +
+            "请检查 tauri.conf.json 是否启用 withGlobalTauri</div>";
+          return;
+        }
+
+        var src = convertFileSrc(path);
+
+        root.innerHTML = "";
+        if (type === "image") {
+          var img = document.createElement("img");
+          img.src = src;
+          img.alt = path;
+          img.onerror = function () {
+            root.innerHTML =
+              '<div class="msg err">图片加载失败<br/>' + path + "</div>";
+          };
+          root.appendChild(img);
+        } else {
+          var video = document.createElement("video");
+          video.src = src;
+          video.controls = true;
+          video.autoplay = true;
+          video.onerror = function () {
+            root.innerHTML =
+              '<div class="msg err">视频加载失败<br/>' + path + "</div>";
+          };
+          root.appendChild(video);
+        }
+      })();
+    </script>
+  </body>
+</html>

+ 67 - 0
src-tauri/Cargo.lock

@@ -217,6 +217,7 @@ dependencies = [
  "serde_json",
  "tauri",
  "tauri-build",
+ "tauri-plugin-global-shortcut",
  "tauri-plugin-opener",
  "tokio",
  "url",
@@ -1236,6 +1237,16 @@ dependencies = [
  "version_check",
 ]
 
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.2.17"
@@ -1357,6 +1368,24 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
 
+[[package]]
+name = "global-hotkey"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
+dependencies = [
+ "crossbeam-channel",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.59.0",
+ "x11rb",
+ "xkeysym",
+]
+
 [[package]]
 name = "gobject-sys"
 version = "0.18.0"
@@ -3459,6 +3488,21 @@ dependencies = [
  "walkdir",
 ]
 
+[[package]]
+name = "tauri-plugin-global-shortcut"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
+dependencies = [
+ "global-hotkey",
+ "log",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "tauri-plugin-opener"
 version = "2.5.4"
@@ -5001,6 +5045,29 @@ dependencies = [
  "pkg-config",
 ]
 
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
 [[package]]
 name = "yoke"
 version = "0.8.2"

+ 2 - 0
src-tauri/Cargo.toml

@@ -22,6 +22,8 @@ tauri-build = { version = "2", features = [] }
 # Manager::get_window/webviews 等),仍是 Tauri 2 稳定版 crate,只是这些 API 标记为不稳定
 tauri = { version = "2", features = ["protocol-asset", "unstable"] }
 tauri-plugin-opener = "2"
+# 全局快捷键:F9/F10/F11 控制录制开始/暂停/停止
+tauri-plugin-global-shortcut = "2"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 # 用于解析前端传来的 url 字符串后再交给 webview.navigate

+ 17 - 9
src-tauri/src/lib.rs

@@ -1,11 +1,15 @@
 mod capture;
 mod paths;
+mod preview;
 mod recording;
+mod shortcuts;
 
+use serde::Serialize;
 use std::collections::HashMap;
 use std::sync::Mutex;
-use serde::Serialize;
-use tauri::{webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl};
+use tauri::{
+    webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl,
+};
 
 use recording::RecordingSession;
 
@@ -37,10 +41,7 @@ pub struct AppState {
 impl AppState {
     /// 读当前 task id(克隆出来,避免持锁跨 await)
     pub fn current_task(&self) -> Option<String> {
-        self.current_task_id
-            .lock()
-            .ok()
-            .and_then(|g| g.clone())
+        self.current_task_id.lock().ok().and_then(|g| g.clone())
     }
 }
 
@@ -158,8 +159,15 @@ fn set_window_size(
 pub fn run() {
     tauri::Builder::default()
         .plugin(tauri_plugin_opener::init())
+        .plugin(shortcuts::record_shortcut_plugin())
         .manage(AppState::default())
         .setup(|app| {
+            // 注册 F9/F10/F11 全局快捷键。注册失败仅打日志,不阻塞应用启动
+            // (比如 macOS 上 F9-F11 被 Mission Control 占用时)
+            if let Err(e) = shortcuts::register_record_shortcuts(app.handle()) {
+                eprintln!("[shortcuts] {e}");
+            }
+
             // 在主窗口(也是 React UI 所在的 webview)所属的 Window 上挂一个 child webview
             // 注意:`add_child` 定义在 `Window` 上,不在 `WebviewWindow` 上,所以这里取 Window
             let window = app.get_window("main").ok_or("主窗口未找到")?;
@@ -207,10 +215,10 @@ pub fn run() {
             set_window_size,
             query_task_assets,
             capture::capture_page,
-            recording::prepare_recording,
-            recording::save_recording_raw,
-            recording::finalize_recording,
+            recording::start_recording,
+            recording::stop_recording,
             recording::cancel_recording,
+            preview::open_preview_window,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 1 - 26
src-tauri/src/paths.rs

@@ -5,10 +5,7 @@
 //!   这样自动截图和手动截图共享同一个文件,符合「手动覆盖自动」的语义。
 //! - 录制成片:同样每任务一份固定文件名 `task-<id>.mp4`,重录覆盖。
 //!   预览按钮按 task id 直接拼路径即可,前端无需维护"哪份是最新"。
-//! - 录制原始片:临时 webm,按 session id (uuid) 命名 `raw-<sid>.webm`,
-//!   位于独立 cache 目录;finalize 转码完成后立即删除。
-//! - 落点:截图与最终视频在 `app_data_dir/{screenshots,recordings}/` 下;
-//!   原始 webm 在 `app_cache_dir/recordings_raw/` 下(属临时数据)。
+//! - 落点:截图与最终视频均在 `app_data_dir/{screenshots,recordings}/` 下。
 //!   通过 Tauri 的 PathResolver 拿,跨平台一致。
 
 use std::path::PathBuf;
@@ -16,7 +13,6 @@ use tauri::{AppHandle, Manager};
 
 const SCREENSHOTS_SUBDIR: &str = "screenshots";
 const RECORDINGS_SUBDIR: &str = "recordings";
-const RECORDINGS_RAW_SUBDIR: &str = "recordings_raw";
 
 /// 确保目录存在(递归创建),返回原路径以便链式使用。
 fn ensure_dir(p: PathBuf) -> Result<PathBuf, String> {
@@ -34,13 +30,6 @@ fn app_data_root(app: &AppHandle) -> Result<PathBuf, String> {
         .map_err(|e| format!("解析 app_data_dir 失败: {e}"))
 }
 
-/// `<app_cache_dir>` 根目录
-fn app_cache_root(app: &AppHandle) -> Result<PathBuf, String> {
-    app.path()
-        .app_cache_dir()
-        .map_err(|e| format!("解析 app_cache_dir 失败: {e}"))
-}
-
 // =================== 截图 ===================
 
 /// 截图存放目录:`<app_data_dir>/screenshots/`
@@ -61,26 +50,12 @@ pub fn recordings_dir(app: &AppHandle) -> Result<PathBuf, String> {
     ensure_dir(app_data_root(app)?.join(RECORDINGS_SUBDIR))
 }
 
-/// 录制原始片目录(临时 webm 落点):`<app_cache_dir>/recordings_raw/`
-pub fn recordings_raw_dir(app: &AppHandle) -> Result<PathBuf, String> {
-    ensure_dir(app_cache_root(app)?.join(RECORDINGS_RAW_SUBDIR))
-}
-
 /// 指定任务的最终 mp4 绝对路径(覆盖语义)。
 pub fn recording_final_path_for_task(app: &AppHandle, task_id: &str) -> Result<PathBuf, String> {
     let safe_id = sanitize_task_id(task_id);
     Ok(recordings_dir(app)?.join(format!("task-{safe_id}.mp4")))
 }
 
-/// 指定 session 的临时 webm 绝对路径。
-pub fn recording_raw_path_for_session(
-    app: &AppHandle,
-    session_id: &str,
-) -> Result<PathBuf, String> {
-    let safe_sid = sanitize_task_id(session_id); // 同样的消毒规则即可
-    Ok(recordings_raw_dir(app)?.join(format!("raw-{safe_sid}.webm")))
-}
-
 // =================== 工具 ===================
 
 /// 简单消毒:剔除文件系统不友好的字符。

+ 60 - 0
src-tauri/src/preview.rs

@@ -0,0 +1,60 @@
+//! 应用内预览窗口
+//!
+//! 用法:前端 invoke('open_preview_window', { path, kind }) → 新开一个
+//! 独立 WebviewWindow,加载 public/preview.html,让 preview.html 用
+//! window.__TAURI__.core.convertFileSrc(path) 把绝对路径转成 asset:// URL,
+//! 再渲染为 <img> 或 <video>。
+//!
+//! 走 asset:// 协议而不是直接读文件,是因为 webview 不能直接打开 file:// 资源;
+//! asset:// 协议需要在 tauri.conf.json#security.assetProtocol 里把目标目录
+//! 放进 scope。本项目的 mp4 / png 都在 $APPDATA/com.ewaga.autorecord/* 下,
+//! 已在默认 scope `$APPDATA/**` 内,无需额外配置。
+
+use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder};
+use url::form_urlencoded;
+use uuid::Uuid;
+
+/// 打开预览窗口。`kind` 取值:
+/// - "image" → 1024×800,<img>
+/// - "video" → 1280×800,<video controls autoplay>
+#[tauri::command]
+pub async fn open_preview_window(
+    app: AppHandle,
+    path: String,
+    kind: String,
+) -> Result<(), String> {
+    let kind_norm = match kind.as_str() {
+        "image" => "image",
+        "video" => "video",
+        other => return Err(format!("非法 kind: {other}")),
+    };
+
+    // 文件存在性校验,避免开了个空窗口
+    if !std::path::Path::new(&path).exists() {
+        return Err(format!("文件不存在: {path}"));
+    }
+
+    // 拼 preview.html 的 query string;path 原样传,由 preview.html 内部
+    // 用 convertFileSrc 转成 asset URL
+    let query: String = form_urlencoded::Serializer::new(String::new())
+        .append_pair("type", kind_norm)
+        .append_pair("path", &path)
+        .finish();
+    let url_path = format!("preview.html?{query}");
+
+    // 每次新开窗口都用唯一 label(uuid simple,无连字符)
+    let label = format!("preview-{}", Uuid::new_v4().simple());
+
+    let (w, h, title) = match kind_norm {
+        "image" => (1024.0_f64, 800.0_f64, "图片预览"),
+        _ => (1280.0_f64, 800.0_f64, "视频预览"),
+    };
+
+    WebviewWindowBuilder::new(&app, label, WebviewUrl::App(url_path.into()))
+        .title(title)
+        .inner_size(w, h)
+        .resizable(true)
+        .build()
+        .map_err(|e| format!("创建预览窗口失败: {e}"))?;
+    Ok(())
+}

+ 302 - 203
src-tauri/src/recording.rs

@@ -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;
 
 const EVT_RECORDING_FINISHED: &str = "recording-finished";
 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)]
@@ -73,71 +74,70 @@ struct RecordingFailed {
     error: String,
 }
 
-/// 开始录制前调一次:分配 session_id,登记到 AppState。
+/// 开始录制:查询 content webview 的屏幕矩形 → spawn ffmpeg → 登记 session
 ///
-/// 前端拿到 session_id 后用它驱动后续的 save / finalize / cancel。
+/// 返回 session_id,前端传回给 stop / cancel。
 #[tauri::command]
-pub fn prepare_recording(
+pub async fn start_recording(
+    app: AppHandle,
     state: State<'_, AppState>,
     task_id: 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 session = RecordingSession {
-        task_id,
-    };
     state
         .recording_sessions
         .lock()
         .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]
-pub async fn finalize_recording(
+pub async fn stop_recording(
     app: AppHandle,
     state: State<'_, AppState>,
     session_id: String,
-    crop: CropRect,
-    window_client: WindowClient,
-    frame: FrameSize,
-    output: OutputSize,
 ) -> Result<String, String> {
-    // 取 session 元数据,并从 map 中移除(finalize 是终点)
     let session = {
         let mut guard = state
             .recording_sessions
@@ -147,60 +147,16 @@ pub async fn finalize_recording(
             .remove(&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(()) => {
-            let path_str = out_path.to_string_lossy().to_string();
+            let path_str = output_path.to_string_lossy().to_string();
             let _ = app.emit(
                 EVT_RECORDING_FINISHED,
                 RecordingFinished {
@@ -212,79 +168,222 @@ pub async fn finalize_recording(
             Ok(path_str)
         }
         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]
-pub fn cancel_recording(
-    app: AppHandle,
+pub async fn cancel_recording(
     state: State<'_, AppState>,
     session_id: 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(())
 }
 
-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
 }

+ 60 - 0
src-tauri/src/shortcuts.rs

@@ -0,0 +1,60 @@
+//! 全局快捷键注册
+//!
+//! 按键映射(应用启动时全局注册一次):
+//!   - F9  → emit "record-shortcut" { action: "start" }   开始录制
+//!   - F11 → emit "record-shortcut" { action: "stop"  }   停止录制
+//!
+//! 录制状态机维护在前端 lib/recorder.ts,因此这里只负责把"用户按了哪个键"
+//! 透传给前端,由前端结合当前 RecordState 决定具体行为。
+//!
+//! 注意:F9 / F11 在 macOS 上默认绑定 Mission Control / 显示桌面,若与系统冲突
+//! 会注册失败,本函数会把错误打到 stderr,但不会让应用启动失败。
+
+use serde::Serialize;
+use tauri::{plugin::TauriPlugin, AppHandle, Emitter, Runtime};
+use tauri_plugin_global_shortcut::{Builder, Code, GlobalShortcutExt, Shortcut, ShortcutState};
+
+pub const EVT_RECORD_SHORTCUT: &str = "record-shortcut";
+
+#[derive(Serialize, Clone)]
+struct RecordShortcutPayload {
+    action: &'static str,
+}
+
+/// 返回带统一 handler 的 global-shortcut plugin,挂到 tauri::Builder 上。
+///
+/// handler 内部按按键 Code 分派 action 名,再以应用级事件 emit 出去。
+pub fn record_shortcut_plugin<R: Runtime>() -> TauriPlugin<R> {
+    Builder::<R>::new()
+        .with_handler(|app, sc, ev| {
+            // 仅响应按下,避免一次按键触发两次(按下+抬起)
+            if ev.state != ShortcutState::Pressed {
+                return;
+            }
+            let action = match sc.key {
+                Code::F9 => "start",
+                Code::F11 => "stop",
+                _ => return,
+            };
+            let _ = app.emit(
+                EVT_RECORD_SHORTCUT,
+                RecordShortcutPayload { action },
+            );
+        })
+        .build()
+}
+
+/// 在 setup 阶段调用:注册 F9 / F11 两枚快捷键。
+///
+/// 任一注册失败时返回 Err(带具体键名);调用方决定是直接 fail 还是只 log。
+pub fn register_record_shortcuts<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
+    let f9 = Shortcut::new(None, Code::F9);
+    let f11 = Shortcut::new(None, Code::F11);
+
+    let gs = app.global_shortcut();
+    gs.register(f9)
+        .map_err(|e| format!("注册 F9 失败: {e}"))?;
+    gs.register(f11)
+        .map_err(|e| format!("注册 F11 失败: {e}"))?;
+    Ok(())
+}

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

@@ -17,6 +17,7 @@
         "height": 768
       }
     ],
+    "withGlobalTauri": true,
     "security": {
       "csp": null,
       "assetProtocol": {

+ 348 - 8
src/App.tsx

@@ -1,18 +1,41 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { invoke } from "@tauri-apps/api/core";
 import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import { openUrl } from "@tauri-apps/plugin-opener";
+import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
 import { Button, Divider, InputNumber, notification, Tooltip } from "antd";
 import { mockTasks, type Task } from "./mocks/tasks";
 import {
+  EVT_RECORDING_FAILED,
+  EVT_RECORDING_FINISHED,
+  EVT_RECORD_SHORTCUT,
   EVT_SCREENSHOT_FAILED,
   EVT_SCREENSHOT_FINISHED,
+  type RecordingFailedPayload,
+  type RecordingFinishedPayload,
+  type RecordShortcutPayload,
+  type RecordState,
   type ScreenshotFailedPayload,
   type ScreenshotFinishedPayload,
+  type TaskAssets,
 } from "./types/ipc";
 import { capturePage } from "./lib/capture";
+import {
+  cancelRecording,
+  getRecordState,
+  startRecording,
+  stopRecording,
+  subscribeRecordState,
+} from "./lib/recorder";
 import "./App.css";
-import { PauseCircleFilled, PictureOutlined, PlayCircleFilled } from "@ant-design/icons";
+import {
+  FileImageOutlined,
+  FolderOpenOutlined,
+  LoadingOutlined,
+  PictureOutlined,
+  PlayCircleFilled,
+  StopOutlined,
+  VideoCameraOutlined,
+} from "@ant-design/icons";
 
 /**
  * 与 Rust 端常量保持一致(src-tauri/src/lib.rs):
@@ -57,6 +80,19 @@ function App() {
   const [contentSize, setContentSize] = useState<{ w: number; h: number }>({ w: 1280, h: 720 });
   const [customMode, setCustomMode] = useState(false);
 
+  // 录制状态机(由 lib/recorder.ts 内部单例维护,这里订阅以驱动按钮)
+  const [recordState, setRecordState] = useState<RecordState>(getRecordState());
+
+  // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled
+  const [assets, setAssets] = useState<TaskAssets | null>(null);
+
+  // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
+  // 导致 listen 频繁重订)
+  const activeIdRef = useRef<string | null>(activeId);
+  useEffect(() => {
+    activeIdRef.current = activeId;
+  }, [activeId]);
+
   // antd 6 notification 必须用 hook + contextHolder 才能正确取到主题
   const [notifyApi, notifyContext] = notification.useNotification();
 
@@ -65,6 +101,26 @@ function App() {
     void loadInWebview(t.id, t.url);
   }
 
+  /** 重新查询指定任务的产物文件信息(截图 + mp4) */
+  const refreshAssets = useCallback(async (taskId: string | null) => {
+    if (!taskId) {
+      setAssets(null);
+      return;
+    }
+    try {
+      const a = await invoke<TaskAssets>("query_task_assets", { taskId });
+      setAssets(a);
+    } catch (e) {
+      console.error("query_task_assets 失败:", e);
+      setAssets(null);
+    }
+  }, []);
+
+  // activeId 变化时刷新 assets(切换任务后右侧按钮要按新任务的文件存在性变 enabled/disabled)
+  useEffect(() => {
+    void refreshAssets(activeId);
+  }, [activeId, refreshAssets]);
+
   /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */
   const handleManualCapture = useCallback(async () => {
     if (!activeId) return;
@@ -90,6 +146,10 @@ function App() {
           description: `任务 ${taskId} → ${path}`,
           duration: 4,
         });
+        // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
+        if (activeIdRef.current === taskId) {
+          void refreshAssets(taskId);
+        }
       });
       const u2 = await listen<ScreenshotFailedPayload>(EVT_SCREENSHOT_FAILED, (e) => {
         const { taskId, auto, error } = e.payload;
@@ -114,7 +174,214 @@ function App() {
       unlistenFinished?.();
       unlistenFailed?.();
     };
-  }, [notifyApi]);
+  }, [notifyApi, refreshAssets]);
+
+  // 订阅录制状态机变化,驱动按钮 UI 切换
+  useEffect(() => {
+    return subscribeRecordState(setRecordState);
+  }, []);
+
+  // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一 notification 提示。
+  // 与截图事件订阅采用同一套 disposed 模式,避免 StrictMode 双调用的清理竞态。
+  useEffect(() => {
+    let unlistenFinished: UnlistenFn | null = null;
+    let unlistenFailed: UnlistenFn | null = null;
+    let disposed = false;
+
+    (async () => {
+      const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, (e) => {
+        const { taskId, path } = e.payload;
+        notifyApi.success({
+          message: "录制完成",
+          description: `任务 ${taskId} → ${path}`,
+          duration: 5,
+        });
+        if (activeIdRef.current === taskId) {
+          void refreshAssets(taskId);
+        }
+      });
+      const u2 = await listen<RecordingFailedPayload>(EVT_RECORDING_FAILED, (e) => {
+        const { taskId, error } = e.payload;
+        notifyApi.error({
+          message: "录制失败",
+          description: `任务 ${taskId}:${error}`,
+          duration: 6,
+        });
+      });
+
+      if (disposed) {
+        u1();
+        u2();
+      } else {
+        unlistenFinished = u1;
+        unlistenFailed = u2;
+      }
+    })();
+
+    return () => {
+      disposed = true;
+      unlistenFinished?.();
+      unlistenFailed?.();
+    };
+  }, [notifyApi, refreshAssets]);
+
+  // 组件卸载兜底:若正在录制 / 暂停 / 转码中,主动取消,避免悬挂 stream
+  useEffect(() => {
+    return () => {
+      void cancelRecording();
+    };
+  }, []);
+
+  /** 开始按钮:仅在 idle + 已选任务 时可点;Rust 端 spawn ffmpeg 子进程录屏 */
+  const handleStartRecord = useCallback(async () => {
+    if (recordState !== "idle" || !activeId) return;
+    try {
+      await startRecording(activeId);
+    } catch (e) {
+      notifyApi.error({
+        message: "开始录制失败",
+        description: String(e),
+        duration: 6,
+      });
+    }
+  }, [recordState, activeId, notifyApi]);
+
+  /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */
+  const handleStopRecord = useCallback(async () => {
+    if (recordState !== "recording") return;
+    try {
+      await stopRecording();
+      // 成功时由 EVT_RECORDING_FINISHED 事件统一提示;这里不重复弹
+    } catch (e) {
+      // 本地异常(如内部状态不一致)兜底提示;ffmpeg 失败由 EVT_RECORDING_FAILED 处理
+      notifyApi.error({
+        message: "停止录制失败",
+        description: String(e),
+        duration: 6,
+      });
+    }
+  }, [recordState, notifyApi]);
+
+  /** 全局快捷键派发:F9 = 开始,F11 = 停止 */
+  const handleShortcut = useCallback(
+    async (action: "start" | "stop") => {
+      try {
+        if (action === "start") {
+          if (recordState !== "idle") return;
+          if (!activeId) {
+            notifyApi.warning({
+              message: "无法开始录制",
+              description: "请先在左栏选择一个任务",
+              duration: 4,
+            });
+            return;
+          }
+          await startRecording(activeId);
+        } else if (action === "stop") {
+          if (recordState === "recording") {
+            await stopRecording();
+          }
+        }
+      } catch (e) {
+        notifyApi.error({
+          message: "快捷键操作失败",
+          description: String(e),
+          duration: 6,
+        });
+      }
+    },
+    [recordState, activeId, notifyApi],
+  );
+
+  // 订阅 Rust 端的全局快捷键事件 (F9 / F11)
+  useEffect(() => {
+    let unlisten: UnlistenFn | null = null;
+    let disposed = false;
+    (async () => {
+      const u = await listen<RecordShortcutPayload>(EVT_RECORD_SHORTCUT, (e) => {
+        void handleShortcut(e.payload.action);
+      });
+      if (disposed) u();
+      else unlisten = u;
+    })();
+    return () => {
+      disposed = true;
+      unlisten?.();
+    };
+  }, [handleShortcut]);
+
+  /** 打开当前任务产物所在的文件夹:优先 reveal mp4 → png → 兜底打开 screenshots 目录 */
+  const handleOpenFolder = useCallback(async () => {
+    if (!assets) return;
+    const target = assets.recording_exists
+      ? assets.recording_path
+      : assets.screenshot_exists
+        ? assets.screenshot_path
+        : assets.screenshots_dir;
+    try {
+      await revealItemInDir(target);
+    } catch (e) {
+      notifyApi.error({
+        message: "打开文件夹失败",
+        description: String(e),
+        duration: 6,
+      });
+    }
+  }, [assets, notifyApi]);
+
+  /** 在新 WebviewWindow 中预览截图 */
+  const handlePreviewImage = useCallback(async () => {
+    if (!assets?.screenshot_exists) return;
+    try {
+      await invoke("open_preview_window", {
+        path: assets.screenshot_path,
+        kind: "image",
+      });
+    } catch (e) {
+      notifyApi.error({
+        message: "图片预览失败",
+        description: String(e),
+        duration: 6,
+      });
+    }
+  }, [assets, notifyApi]);
+
+  /** 在新 WebviewWindow 中预览录制 mp4 */
+  const handlePreviewVideo = useCallback(async () => {
+    if (!assets?.recording_exists) return;
+    try {
+      await invoke("open_preview_window", {
+        path: assets.recording_path,
+        kind: "video",
+      });
+    } catch (e) {
+      notifyApi.error({
+        message: "视频预览失败",
+        description: String(e),
+        duration: 6,
+      });
+    }
+  }, [assets, notifyApi]);
+
+  // 开始按钮的图标 / tooltip
+  const startConfig = (() => {
+    if (recordState === "stopping") {
+      return { icon: <LoadingOutlined />, tip: "正在停止…" };
+    }
+    if (recordState === "recording") {
+      // 视觉上保持 Play 图标,但 disabled
+      return { icon: <PlayCircleFilled />, tip: "录制中…(F11 停止)" };
+    }
+    return {
+      icon: <PlayCircleFilled />,
+      tip: activeId ? "开始录制(F9)" : "请先选择任务",
+    };
+  })();
+
+  // 开始按钮仅在 idle 且已选任务时可点;其它状态 disabled
+  const startDisabled = recordState !== "idle" || !activeId;
+  // 停止按钮仅在 recording 时可点
+  const stopDisabled = recordState !== "recording";
 
   const applyWorkAreaSize = useCallback(async (w: number, h: number) => {
     try {
@@ -211,11 +478,84 @@ function App() {
               />
             </Tooltip>
             <Divider type="vertical" />
-            {/* 录制按钮:下一阶段接入 recorder.ts,本轮暂保持 disabled 视觉占位 */}
-            <Tooltip title="开始录制(即将上线)" placement="top">
-              <Button size="small" disabled icon={<PlayCircleFilled />} />
+            {/* 开始按钮:仅 idle + 已选任务时可点 */}
+            <Tooltip title={startConfig.tip} placement="top">
+              <Button
+                size="small"
+                icon={startConfig.icon}
+                disabled={startDisabled}
+                onClick={() => void handleStartRecord()}
+              />
+            </Tooltip>
+            {/* 停止按钮:仅 recording 时可用 */}
+            <Tooltip
+              title={
+                stopDisabled
+                  ? "无进行中的录制"
+                  : "停止录制并落盘 mp4(F11)"
+              }
+              placement="top"
+            >
+              <Button
+                size="small"
+                icon={<StopOutlined />}
+                disabled={stopDisabled}
+                onClick={() => void handleStopRecord()}
+              />
+            </Tooltip>
+            <Divider type="vertical" />
+            {/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */}
+            <Tooltip
+              title={
+                !activeId
+                  ? "请先选择任务"
+                  : assets?.recording_exists
+                    ? "在文件管理器中显示录制视频"
+                    : assets?.screenshot_exists
+                      ? "在文件管理器中显示截图"
+                      : "打开截图目录"
+              }
+              placement="top"
+            >
+              <Button
+                size="small"
+                icon={<FolderOpenOutlined />}
+                disabled={!assets}
+                onClick={() => void handleOpenFolder()}
+              />
+            </Tooltip>
+            {/* 预览截图:仅当 png 存在 */}
+            <Tooltip
+              title={
+                !assets?.screenshot_exists
+                  ? "暂无截图可预览"
+                  : "在新窗口预览截图"
+              }
+              placement="top"
+            >
+              <Button
+                size="small"
+                icon={<FileImageOutlined />}
+                disabled={!assets?.screenshot_exists}
+                onClick={() => void handlePreviewImage()}
+              />
+            </Tooltip>
+            {/* 预览视频:仅当 mp4 存在 */}
+            <Tooltip
+              title={
+                !assets?.recording_exists
+                  ? "暂无录制视频可预览"
+                  : "在新窗口预览录制视频"
+              }
+              placement="top"
+            >
+              <Button
+                size="small"
+                icon={<VideoCameraOutlined />}
+                disabled={!assets?.recording_exists}
+                onClick={() => void handlePreviewVideo()}
+              />
             </Tooltip>
-            <Button size="small" disabled icon={<PauseCircleFilled />} />
           </div>
 
           {/* 右侧:尺寸预设 / 自定义 */}

+ 30 - 183
src/lib/recorder.ts

@@ -1,43 +1,25 @@
-// 子 webview 视频录制器(前端侧)
+// 录制控制器(前端侧)
 //
-// 流程:
-//   1. invoke('prepare_recording', taskId) → sessionId(Rust 登记会话)
-//   2. getDisplayMedia 让用户选 AutoRecord 应用窗口
-//   3. MediaRecorder 录制 webm,chunks 累积到内存
-//   4. stop 时把 webm 二进制 → invoke('save_recording_raw') 落到 cache
-//   5. invoke('finalize_recording', crop/windowClient/frame/output) → Rust 调 ffmpeg
-//      crop 到子 webview 区域 + scale 到 contentSize + 转 mp4
-//   6. mp4 落 app_data/recordings/task-<id>.mp4,最终结果通过 'recording-finished'
-//      事件回推前端
+// 录制由 Rust 端 spawn 的 ffmpeg 子进程完成(直接按 OS 桌面抓屏后端
+// 抓取 content webview 那块屏幕矩形 → 直接输出 mp4)。前端只负责:
+//   1. invoke('start_recording', taskId) → sessionId  (Rust 启动 ffmpeg)
+//   2. invoke('stop_recording', sessionId) → mp4 绝对路径  (Rust 优雅退出 + 落盘)
+//   3. invoke('cancel_recording', sessionId)  (Rust 强杀 + 删半成品)
 //
-// 为什么 crop 在后端做:getDisplayMedia 录到的是整个 AutoRecord 窗口的 webm,
-// 包含左栏 + 工具栏。我们只需要子 webview 那一块。客户端裁剪需要把 webm
-// 解码为帧再处理,太重;ffmpeg 后处理简洁可靠。
+// 不再使用 getDisplayMedia / MediaRecorder:① 浏览器规范强制弹"选择共享源"
+// 对话框,无法跳过;② 只能录整个窗口,仍需后端裁剪。换成 ffmpeg 后这两点
+// 同时解决。
+//
+// 状态机:idle → recording → stopping → idle
+//   - 不支持 pause / resume(ffmpeg 子进程无原生暂停)
 
 import { invoke } from "@tauri-apps/api/core";
 import type { RecordState } from "../types/ipc";
 
-/** 开始录制时由调用方传入的上下文 */
-export interface StartOptions {
-  /** 当前任务 id;决定最终 mp4 文件名(task-<id>.mp4,重录覆盖) */
-  taskId: string;
-  /** 用户设置的 webview 视口尺寸(CSS 逻辑像素),即最终 mp4 的输出尺寸 */
-  contentSize: { w: number; h: number };
-  /**
-   * 子 webview 在主 UI window 内的左上角逻辑坐标(CSS 像素)。
-   * 在当前布局下 = (LEFT_PANEL_WIDTH, TOOLBAR_HEIGHT) = (180, 48)。
-   */
-  origin: { x: number; y: number };
-}
-
 // =================== 内部单例状态 ===================
 
 let currentState: RecordState = "idle";
 let currentSessionId: string | null = null;
-let currentStartOpts: StartOptions | null = null;
-let mediaRecorder: MediaRecorder | null = null;
-let mediaStream: MediaStream | null = null;
-let recordedChunks: Blob[] = [];
 
 const stateListeners = new Set<(s: RecordState) => void>();
 
@@ -59,189 +41,54 @@ export function subscribeRecordState(listener: (s: RecordState) => void): () =>
   };
 }
 
-// =================== 工具函数 ===================
-
-function pickMimeType(): string | null {
-  if (typeof MediaRecorder === "undefined") return null;
-  const candidates = [
-    "video/webm;codecs=vp9",
-    "video/webm;codecs=vp8",
-    "video/webm",
-  ];
-  for (const t of candidates) {
-    if (MediaRecorder.isTypeSupported(t)) return t;
-  }
-  return null;
-}
-
-function teardownStream() {
-  if (mediaStream) {
-    for (const tr of mediaStream.getTracks()) tr.stop();
-    mediaStream = null;
-  }
-  mediaRecorder = null;
-  recordedChunks = [];
-}
-
-function resetSession() {
-  currentSessionId = null;
-  currentStartOpts = null;
-}
-
 // =================== 对外 API ===================
 
 /**
- * 开始录制。
- *
- * 注意:会弹出系统级"选择共享窗口"对话框,让用户选 AutoRecord 应用窗口。
- * 用户取消选择 / 拒绝权限时,会抛错并自动清理 Rust 侧 session。
+ * 开始录制:让 Rust spawn ffmpeg,从此刻起到 stopRecording 之间的 content
+ * webview 屏幕区域都会被录到 task-<taskId>.mp4(覆盖旧文件)。
  */
-export async function startRecording(opts: StartOptions): Promise<void> {
+export async function startRecording(taskId: string): Promise<void> {
   if (currentState !== "idle") {
     throw new Error(`当前录制状态为 ${currentState},无法开始新录制`);
   }
-
-  // 1) 先在 Rust 侧分配 session id
-  const sessionId = await invoke<string>("prepare_recording", { taskId: opts.taskId });
-  currentSessionId = sessionId;
-  currentStartOpts = opts;
-
-  // 2) 请求屏幕共享 —— 失败时清理 session
-  let stream: MediaStream;
-  try {
-    stream = await navigator.mediaDevices.getDisplayMedia({
-      // displaySurface 'window' 是 Chromium 扩展项,TS 标准类型还没收录
-      video: {
-        frameRate: 30,
-        // @ts-expect-error displaySurface 是 Screen Capture API spec 扩展
-        displaySurface: "window",
-      } as MediaTrackConstraints,
-      audio: false,
-    });
-  } catch (e) {
-    await invoke("cancel_recording", { sessionId }).catch(() => {});
-    resetSession();
-    throw new Error(`getDisplayMedia 失败或被用户取消: ${String(e)}`);
-  }
-
-  mediaStream = stream;
-
-  // 用户在 OS 共享条上点"停止共享"时,video track 触发 ended,自动收尾
-  const videoTrack = stream.getVideoTracks()[0];
-  if (videoTrack) {
-    videoTrack.addEventListener("ended", () => {
-      if (currentState === "recording" || currentState === "paused") {
-        void stopRecording().catch((err) => console.error("自动停止录制失败:", err));
-      }
-    });
-  }
-
-  // 3) 启动 MediaRecorder
-  const mimeType = pickMimeType();
-  const recorder = mimeType
-    ? new MediaRecorder(stream, { mimeType })
-    : new MediaRecorder(stream);
-  recorder.ondataavailable = (ev) => {
-    if (ev.data && ev.data.size > 0) recordedChunks.push(ev.data);
-  };
-  mediaRecorder = recorder;
-  recorder.start(1000); // 1 秒一个 chunk,便于增量收集 & 异常时丢失最少
-  setState("recording");
-}
-
-export function pauseRecording(): void {
-  if (currentState !== "recording" || !mediaRecorder) {
-    throw new Error(`当前状态 ${currentState},无法暂停`);
-  }
-  mediaRecorder.pause();
-  setState("paused");
-}
-
-export function resumeRecording(): void {
-  if (currentState !== "paused" || !mediaRecorder) {
-    throw new Error(`当前状态 ${currentState},无法恢复`);
-  }
-  mediaRecorder.resume();
+  const sid = await invoke<string>("start_recording", { taskId });
+  currentSessionId = sid;
   setState("recording");
 }
 
 /**
- * 停止录制 → 转码 → 拿到 mp4 路径。
- *
- * 转码期间状态为 'processing',结束后回到 'idle'。
- * 整个流程的进度也会通过 Tauri 的 'recording-finished' / 'recording-failed' 事件
- * 回推前端,App.tsx 可统一在事件回调里给 toast。
+ * 停止录制:发送停止指令给 ffmpeg,等其 flush moov atom 后退出。
+ * 返回最终 mp4 绝对路径。整个过程也会通过 'recording-finished' / 'recording-failed'
+ * Tauri 事件回推前端,App.tsx 在事件回调里统一弹 toast。
  */
 export async function stopRecording(): Promise<string> {
-  if (currentState !== "recording" && currentState !== "paused") {
+  if (currentState !== "recording") {
     throw new Error(`当前状态 ${currentState},无法停止`);
   }
-  if (!mediaRecorder || !mediaStream || !currentSessionId || !currentStartOpts) {
-    throw new Error("录制器内部状态不一致");
+  if (!currentSessionId) {
+    throw new Error("录制器内部状态不一致(无 sessionId)");
   }
-
-  const recorder = mediaRecorder;
-  const stream = mediaStream;
-  const sessionId = currentSessionId;
-  const opts = currentStartOpts;
-
-  setState("processing");
-
-  // 1) 在 stop 之前先抓 frame 实际尺寸(stop 后 track 状态会变)
-  const videoTrack = stream.getVideoTracks()[0];
-  const settings = videoTrack?.getSettings?.() ?? {};
-  const frameW = Math.round(settings.width ?? window.innerWidth * window.devicePixelRatio);
-  const frameH = Math.round(settings.height ?? window.innerHeight * window.devicePixelRatio);
-
-  // 2) stop recorder 并等 onstop
-  const stopped = new Promise<void>((resolve) => {
-    recorder.addEventListener("stop", () => resolve(), { once: true });
-  });
-  recorder.stop();
-  await stopped;
-
-  // 3) 拼合 Blob
-  const blob = new Blob(recordedChunks, {
-    type: recordedChunks[0]?.type || "video/webm",
-  });
-  const arrayBuffer = await blob.arrayBuffer();
-  const bytes = new Uint8Array(arrayBuffer);
-
-  // 4) 关 stream(释放系统共享指示)
-  teardownStream();
-  resetSession();
-
-  // 5) 把 webm 送到 Rust → ffmpeg 转码
+  const sid = currentSessionId;
+  setState("stopping");
   try {
-    await invoke("save_recording_raw", { sessionId, bytes });
-    const path = await invoke<string>("finalize_recording", {
-      sessionId,
-      crop: {
-        x: opts.origin.x,
-        y: opts.origin.y,
-        w: opts.contentSize.w,
-        h: opts.contentSize.h,
-      },
-      windowClient: { w: window.innerWidth, h: window.innerHeight },
-      frame: { w: frameW, h: frameH },
-      output: { w: opts.contentSize.w, h: opts.contentSize.h },
-    });
+    const path = await invoke<string>("stop_recording", { sessionId: sid });
+    currentSessionId = null;
     setState("idle");
     return path;
   } catch (e) {
+    currentSessionId = null;
     setState("idle");
     throw e;
   }
 }
 
 /**
- * 取消录制:不调 finalize、清掉所有现场(stream、session、raw 文件)
+ * 取消录制:强杀 ffmpeg 并删除可能半成品;不 emit finished/failed。
  */
 export async function cancelRecording(): Promise<void> {
   if (currentState === "idle") return;
   const sid = currentSessionId;
-  teardownStream();
-  resetSession();
+  currentSessionId = null;
   setState("idle");
   if (sid) {
     await invoke("cancel_recording", { sessionId: sid }).catch(() => {});

+ 12 - 5
src/types/ipc.ts

@@ -4,12 +4,11 @@
 
 /**
  * 录制状态机
- * - idle:       未在录制(初始 / 已完成 / 已取消)
- * - recording:  正在录制
- * - paused:     已暂停(可恢复 / 停止)
- * - processing: 已停止,正在后端 ffmpeg 转码
+ * - idle:      未在录制(初始 / 已完成 / 已取消)
+ * - recording: 正在录制(ffmpeg 子进程运行中)
+ * - stopping:  已发送停止指令,等 ffmpeg 落盘退出
  */
-export type RecordState = "idle" | "recording" | "paused" | "processing";
+export type RecordState = "idle" | "recording" | "stopping";
 
 /** 截图完成事件 payload(对应 Rust emit 的 screenshot-finished) */
 export interface ScreenshotFinishedPayload {
@@ -35,6 +34,14 @@ export const EVT_SCREENSHOT_FINISHED = "screenshot-finished";
 export const EVT_SCREENSHOT_FAILED = "screenshot-failed";
 export const EVT_RECORDING_FINISHED = "recording-finished";
 export const EVT_RECORDING_FAILED = "recording-failed";
+/** Rust 端注册的全局快捷键 (F9/F10/F11) 按下时发出的事件名 */
+export const EVT_RECORD_SHORTCUT = "record-shortcut";
+
+/** 全局快捷键事件 payload(对应 Rust emit 的 record-shortcut) */
+export interface RecordShortcutPayload {
+  /** "start" | "stop" —— 对应 F9 / F11 */
+  action: "start" | "stop";
+}
 
 /** 录制完成事件 payload(对应 Rust emit 的 recording-finished) */
 export interface RecordingFinishedPayload {