use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, watch};
use tokio::task::JoinHandle;
use crate::browser::tab::{ImageFormat, Tab, TabCore};
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScreencastMode {
#[default]
Imgs,
FrugalImgs,
Video,
FrugalVideo,
}
impl ScreencastMode {
fn is_video(self) -> bool {
matches!(self, ScreencastMode::Video | ScreencastMode::FrugalVideo)
}
fn is_frugal(self) -> bool {
matches!(
self,
ScreencastMode::FrugalImgs | ScreencastMode::FrugalVideo
)
}
}
#[derive(Debug, Clone)]
struct Cfg {
save_path: Option<PathBuf>,
mode: ScreencastMode,
interval: Duration,
}
impl Default for Cfg {
fn default() -> Self {
Self {
save_path: None,
mode: ScreencastMode::Imgs,
interval: Duration::from_millis(100), }
}
}
pub(crate) struct ScreencastShared {
running: AtomicBool,
cfg: std::sync::Mutex<Cfg>,
task: Mutex<Option<JoinHandle<Result<PathBuf>>>>,
stop_tx: Mutex<Option<watch::Sender<bool>>>,
}
impl ScreencastShared {
pub(crate) fn new() -> Self {
Self {
running: AtomicBool::new(false),
cfg: std::sync::Mutex::new(Cfg::default()),
task: Mutex::new(None),
stop_tx: Mutex::new(None),
}
}
}
pub struct Screencast {
tab: Tab,
}
impl Screencast {
pub(crate) fn new(tab: Tab) -> Self {
Self { tab }
}
fn shared(&self) -> &Arc<ScreencastShared> {
&self.tab.core.screencast
}
pub fn set_save_path(&self, path: impl AsRef<Path>) -> &Self {
if let Ok(mut cfg) = self.shared().cfg.lock() {
cfg.save_path = Some(path.as_ref().to_path_buf());
}
self
}
pub fn set_mode(&self, mode: ScreencastMode) -> &Self {
if let Ok(mut cfg) = self.shared().cfg.lock() {
cfg.mode = mode;
}
self
}
pub fn set_fps(&self, fps: f64) -> &Self {
let fps = fps.clamp(1.0, 60.0);
if let Ok(mut cfg) = self.shared().cfg.lock() {
cfg.interval = Duration::from_secs_f64(1.0 / fps);
}
self
}
pub fn is_recording(&self) -> bool {
self.shared().running.load(Ordering::SeqCst)
}
pub async fn start(&self, save_path: Option<impl AsRef<Path>>) -> Result<()> {
let sh = self.shared().clone();
if sh.running.swap(true, Ordering::SeqCst) {
return Err(Error::Other("录屏已在进行中".into()));
}
let cfg = {
match sh.cfg.lock() {
Ok(mut c) => {
if let Some(p) = save_path {
c.save_path = Some(p.as_ref().to_path_buf());
}
c.clone()
}
Err(_) => {
sh.running.store(false, Ordering::SeqCst);
return Err(Error::Other("读取录屏配置失败".into()));
}
}
};
let Some(save_path) = cfg.save_path.clone() else {
sh.running.store(false, Ordering::SeqCst);
return Err(Error::Other(
"请先 set_save_path(或 start(Some(path))) 设置保存路径".into(),
));
};
let (tx, rx) = watch::channel(false);
*sh.stop_tx.lock().await = Some(tx);
let core = self.tab.core.clone();
let handle = tokio::spawn(record_loop(core, cfg, save_path, rx));
*sh.task.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<PathBuf> {
let sh = self.shared().clone();
if !sh.running.swap(false, Ordering::SeqCst) {
return Err(Error::Other("当前未在录屏".into()));
}
if let Some(tx) = sh.stop_tx.lock().await.take() {
let _ = tx.send(true);
}
let handle = sh.task.lock().await.take();
match handle {
Some(h) => h
.await
.map_err(|e| Error::Other(format!("录屏任务异常退出: {e}")))?,
None => Err(Error::Other("录屏任务句柄缺失".into())),
}
}
}
async fn record_loop(
core: Arc<TabCore>,
cfg: Cfg,
save_path: PathBuf,
mut stop: watch::Receiver<bool>,
) -> Result<PathBuf> {
let frames_dir = if cfg.mode.is_video() {
save_path.join(".frames")
} else {
save_path.clone()
};
tokio::fs::create_dir_all(&frames_dir).await?;
let mut n: usize = 0;
let mut prev: Option<Vec<u8>> = None;
loop {
if let Ok(clip) = core.page_clip(false).await
&& let Ok(bytes) = core.capture(clip, ImageFormat::Png, None).await
{
let changed = prev.as_deref() != Some(bytes.as_slice());
if !cfg.mode.is_frugal() || changed {
let path = frames_dir.join(format!("frame_{n:06}.png"));
let _ = tokio::fs::write(&path, &bytes).await;
n += 1;
prev = Some(bytes);
}
}
tokio::select! {
_ = tokio::time::sleep(cfg.interval) => {}
res = stop.changed() => {
if res.is_err() || *stop.borrow() {
break;
}
}
}
}
if cfg.mode.is_video() {
let fps = (1.0 / cfg.interval.as_secs_f64()).round().max(1.0) as u32;
let out = save_path.join(format!("screencast_{}.mp4", timestamp()));
encode_video(&frames_dir, fps, &out).await?;
let _ = tokio::fs::remove_dir_all(&frames_dir).await;
Ok(out)
} else {
Ok(frames_dir)
}
}
async fn encode_video(frames_dir: &Path, fps: u32, out: &Path) -> Result<()> {
let pattern = frames_dir.join("frame_%06d.png");
let status = tokio::process::Command::new("ffmpeg")
.args(["-y", "-framerate", &fps.to_string(), "-i"])
.arg(&pattern)
.args([
"-vf",
"pad=ceil(iw/2)*2:ceil(ih/2)*2",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
])
.arg(out)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(Error::Other(format!(
"ffmpeg 合成失败(退出码 {:?});video 模式需要 ffmpeg,或改用 ScreencastMode::Imgs",
s.code()
))),
Err(e) => Err(Error::Other(format!(
"无法运行 ffmpeg({e});video 模式需安装 ffmpeg,或改用 ScreencastMode::Imgs 保存帧序列"
))),
}
}
fn timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}