Files
Thing/src-tauri/src/screenshot/scroll_session.rs
T

648 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 滚动截图会话(仅 Windows):后台线程捕捉拼接 + 独立滚轮线程平滑滚动。
//!
//! 与 [super::scroll_capture::scroll_capture](同步一次调用)不同,本模块把捕获循环
//! 放进后台线程,通过事件把**实时进度**推给前端。
//!
//! ## 自动模式架构
//! - **run_loop(会话线程)**:按节奏捕获客户区帧,与上一帧做带内偏移匹配,拼接新行;
//! - **ticker(滚轮线程)**:以 ~16ms 间隔发送小步长 `WM_MOUSEWHEEL`,由目标程序自身的
//! 平滑滚动动画呈现连续滚动(替代旧版"每 70ms 一整格"的跳变式滚动);
//! - **目标策略升级**:带中心最深子窗口(小步长 Send)→ 子窗口整格 → 顶层整格 Send →
//! 顶层整格 Post。解决"一开始就不滚动"(顶层不转发滚轮 / 程序忽略非整格消息);
//! - **自适应速度**:按每周期实测位移调滚轮步长 / 捕获周期,逼近"带高 1/4"的理想重叠;
//! - **停止补帧(settle)**:用户停止后等目标窗口滚动动画静止再补拼最后一帧,
//! 保证最终图片结尾 = 用户停止时窗口实际停留的位置;
//! - **精准回滚**:先按累计像素粗估上滚,再用帧匹配对初始帧校准回到起始位置。
//!
//! 事件(广播到所有窗口):
//! - `SCROLL_PROGRESS``{ width, height, auto }` 当前已拼接高度
//! - `SCROLL_COMPLETE``{ width, height }` 完成导出(PNG 原始字节已存入编辑器图片槽,
//! 由前端常驻编辑器窗口通过 raw IPC 取出,事件本身不携带图片数据)
//! - `SCROLL_CANCELLED``{}` 取消(丢弃画布)
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, Ordering};
use std::sync::Mutex;
use std::thread::sleep;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use windows_sys::Win32::UI::WindowsAndMessaging::WHEEL_DELTA;
use crate::constants::events as evts;
use crate::logger::{log_error, log_info};
use super::capture::{bgra_to_png, crop_bgra};
use super::scroll_capture::{
capture_client_bgra, client_pt_to_screen, deep_child_at_point, detect_vscroll_offset,
post_wheel, resolve_band, send_wheel,
};
use super::ScrollRegion;
// ===== 可调参数 =====
/// 滚轮线程发送间隔(毫秒)。小步长 + 高频 → 目标程序的平滑滚动动画连贯不断帧。
const TICK_MS: u64 = 16;
/// 捕获周期基准(毫秒)。与滚轮解耦:滚动不因捕获/匹配而停顿。
const CYCLE_MS: u64 = 130;
/// 捕获周期上限(整格模式下按实测位移自适应放大)。
const CYCLE_MS_MAX: u64 = 360;
/// 每周期理想位移 = clamp(band_h / 4, TARGET_D_MIN, TARGET_D_MAX)。
/// 太小 → 慢;太大 → 带内重叠不足、匹配易失败。
const TARGET_D_MIN: usize = 32;
const TARGET_D_MAX: usize = 180;
/// 小步长模式:起始 / 上下限步长(WHEEL_DELTA=120 为一整格)。
const DELTA_START: i32 = 20;
const DELTA_MIN: i32 = 4;
const DELTA_MAX: i32 = 72;
/// 整格模式步长(部分程序忽略非整格滚轮消息,累不进小步长)。
const DELTA_NOTCH: i32 = 120;
/// 策略未锁定时:连续多少周期无位移 → 升级投递策略。
const ESCALATE_CYCLES: u32 = 3;
/// 策略已锁定时:连续多少周期无位移 → 判定到底。
const NO_CHANGE_STOP: u32 = 3;
/// 上限:拼接后总高(像素),超出即自动结束。
const MAX_TOTAL_H: u32 = 30000;
/// 进度事件节流。
const PROGRESS_THROTTLE_MS: u64 = 120;
/// 停止后等待滚动动画静止的超时(毫秒)。
const SETTLE_TIMEOUT_MS: u64 = 900;
/// 回滚粗估:每整格对应的滚动像素。
const ROLLBACK_PX_PER_NOTCH: f64 = 55.0;
// ===== 会话状态 =====
/// 活动会话(会话线程独占读写;命令只改 STOP/CANCEL 原子标志)
struct Session {
hwnd: isize,
/// 客户区尺寸与客户区在窗口位图内偏移
cw: i32,
ch: i32,
ox: i32,
oy: i32,
band_x: i32,
band_y: i32,
band_w: i32,
band_h: i32,
/// 已拼接像素(带宽 × total_h)
canvas: Vec<u8>,
total_h: u32,
/// 累计检测到的滚动像素
ttl_px: u32,
/// 上一帧(全客户区,匹配基准)
prev: Vec<u8>,
/// 初始帧(回滚校准用)
first: Vec<u8>,
auto_scroll: bool,
active: bool,
}
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
/// 请求线程结束(完成/取消共用)
static STOP: AtomicBool = AtomicBool::new(false);
/// 结束方式:true = 取消(不导出),false = 完成(导出)
static CANCEL: AtomicBool = AtomicBool::new(false);
// ===== 滚轮线程共享控制 =====
static TICKER_STOP: AtomicBool = AtomicBool::new(false);
static WHEEL_TARGET: AtomicIsize = AtomicIsize::new(0);
/// 滚轮步长(有符号 delta,正值;发送时取负 = 向下滚)
static WHEEL_STEP: AtomicI32 = AtomicI32::new(0);
static WHEEL_POST: AtomicBool = AtomicBool::new(false);
static WHEEL_SX: AtomicI32 = AtomicI32::new(0);
static WHEEL_SY: AtomicI32 = AtomicI32::new(0);
/// 会话线程每轮用的只读参数(启动时一次性读出)。
struct Params {
hwnd: isize,
cw: i32,
ch: i32,
ox: i32,
oy: i32,
band_x: i32,
band_y: i32,
band_w: i32,
band_h: i32,
auto_scroll: bool,
}
fn read_params() -> Option<Params> {
let g = SESSION.lock().ok()?;
let s = g.as_ref().filter(|s| s.active)?;
Some(Params {
hwnd: s.hwnd,
cw: s.cw,
ch: s.ch,
ox: s.ox,
oy: s.oy,
band_x: s.band_x,
band_y: s.band_y,
band_w: s.band_w,
band_h: s.band_h,
auto_scroll: s.auto_scroll,
})
}
/// 会话是否仍活动(run_loop 每周期自检,异常路径提前退出)
fn is_active() -> bool {
SESSION
.lock()
.map(|g| g.as_ref().map(|s| s.active).unwrap_or(false))
.unwrap_or(false)
}
/// 启动滚动截图会话。`auto = true` 时线程自动下滚拼到底部;否则等用户手动滚动。
pub fn start(app: AppHandle, hwnd: isize, region: Option<ScrollRegion>, auto: bool) -> Result<(), String> {
{
let g = SESSION.lock().map_err(|e| e.to_string())?;
if g.as_ref().map(|s| s.active).unwrap_or(false) {
return Err("已有滚动截图进行中".into());
}
}
let (ctx, bx, by, bw, bh) = resolve_band(hwnd, region)?;
let first = capture_client_bgra(hwnd, ctx.ox, ctx.oy, ctx.cw, ctx.ch)?;
let canvas = crop_bgra(&first, ctx.cw as usize, bx, by, bw, bh)?;
{
let mut g = SESSION.lock().map_err(|e| e.to_string())?;
*g = Some(Session {
hwnd,
cw: ctx.cw,
ch: ctx.ch,
ox: ctx.ox,
oy: ctx.oy,
band_x: bx,
band_y: by,
band_w: bw,
band_h: bh,
canvas,
total_h: bh as u32,
ttl_px: 0,
first: first.clone(),
prev: first,
auto_scroll: auto,
active: true,
});
}
STOP.store(false, Ordering::Release);
CANCEL.store(false, Ordering::Release);
log_info("scroll-session", &format!(
"启动滚动截图 hwnd={} band={}x{} auto={}",
hwnd, bw, bh, auto
));
std::thread::spawn(move || run_loop(app));
Ok(())
}
/// 请求结束会话并导出(完成)。立即返回,线程在下一轮感知并完成。
pub fn finish() -> Result<(), String> {
if !running() {
return Err("没有进行中的滚动截图".into());
}
STOP.store(true, Ordering::Release);
CANCEL.store(false, Ordering::Release);
Ok(())
}
/// 请求取消会话(不导出)。立即返回,线程在下一轮感知并回滚终止。
pub fn cancel_now() -> Result<(), String> {
if !running() {
return Err("没有进行中的滚动截图".into());
}
STOP.store(true, Ordering::Release);
CANCEL.store(true, Ordering::Release);
Ok(())
}
fn running() -> bool {
SESSION.lock().map(|g| g.as_ref().map(|s| s.active).unwrap_or(false)).unwrap_or(false)
}
/// 滚轮线程:高频小步长发送,滚动手感交给目标程序的平滑滚动动画。
fn ticker_loop() {
loop {
if TICKER_STOP.load(Ordering::Acquire) {
break;
}
let target = WHEEL_TARGET.load(Ordering::Acquire);
let delta = WHEEL_STEP.load(Ordering::Acquire);
let sx = WHEEL_SX.load(Ordering::Acquire);
let sy = WHEEL_SY.load(Ordering::Acquire);
if target != 0 && delta != 0 {
if WHEEL_POST.load(Ordering::Acquire) {
post_wheel(target, sx, sy, -delta);
} else {
send_wheel(target, sx, sy, -delta);
}
}
sleep(Duration::from_millis(TICK_MS));
}
}
/// 应用滚轮投递策略(未检测到位移时逐级升级,直到找到能滚动的通道)。
fn apply_strategy(strategy: u8, p: &Params, bcx: i32, bcy: i32) {
match strategy {
0 => {
// 子窗口 + 小步长(Chromium/WinUI 等会累加小步长并平滑滚动)
WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release);
WHEEL_STEP.store(DELTA_START, Ordering::Release);
WHEEL_POST.store(false, Ordering::Release);
}
1 => {
// 子窗口 + 整格(经典 Win32 控件只认整格消息)
WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release);
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
WHEEL_POST.store(false, Ordering::Release);
}
2 => {
// 顶层 + 整格 Send(部分程序由顶层统一处理滚轮)
WHEEL_TARGET.store(p.hwnd, Ordering::Release);
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
WHEEL_POST.store(false, Ordering::Release);
}
_ => {
// 顶层 + 整格 Post(只处理消息泵排队事件的程序)
WHEEL_TARGET.store(p.hwnd, Ordering::Release);
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
WHEEL_POST.store(true, Ordering::Release);
}
}
log_info("scroll-session", &format!("滚轮策略升级为 {}", strategy));
}
/// 单周期匹配结果
enum CycleResult {
/// 两帧一致(未滚动)
NoChange,
/// 向下滚了 d 像素(已拼接)
Moved(usize),
/// 匹配失败(动画 / 位移超上限)
MatchFail,
}
/// 一轮捕获-匹配-拼接。返回 (band_w, total_h, CycleResult)。
fn stitch_cycle(p: &Params, cur: &[u8]) -> (i32, u32, CycleResult) {
let mut g = match SESSION.lock() {
Ok(g) => g,
Err(e) => {
log_error("scroll-session", &format!("会话锁异常: {}", e));
return (p.band_w, 0, CycleResult::MatchFail);
}
};
let s = match g.as_mut() {
Some(s) => s,
None => return (p.band_w, 0, CycleResult::MatchFail),
};
let prev = std::mem::take(&mut s.prev);
let (bx, by, bw, bh) = (s.band_x, s.band_y, s.band_w, s.band_h);
let off = detect_vscroll_offset(
&prev,
cur,
p.cw as usize,
p.ch as usize,
bx as usize,
by as usize,
bw as usize,
bh as usize,
);
match off {
Some(0) => {
s.prev = cur.to_vec();
(bw, s.total_h, CycleResult::NoChange)
}
Some(d) => {
// 一次最多拼接带的整高:单步滚动量超过带高时无法恢复中间内容,
// 以整带兜底(自适应速度会把位移压回安全区间)
let new_rows = (d as i32).min(bh) as usize;
let new_y = bh - new_rows as i32; // 带在客户区内的底对齐
match crop_bgra(cur, p.cw as usize, bx, s.band_y + new_y, bw, new_rows as i32) {
Ok(rows) => {
s.canvas.extend_from_slice(&rows);
s.total_h += new_rows as u32;
s.ttl_px += d as u32;
s.prev = cur.to_vec();
(bw, s.total_h, CycleResult::Moved(d))
}
Err(_) => {
s.prev = cur.to_vec();
(bw, s.total_h, CycleResult::MatchFail)
}
}
}
None => {
// 匹配失败:保留旧基准帧(下一帧与更早的稳定帧比对,累积位移仍能对上)
s.prev = prev;
(bw, s.total_h, CycleResult::MatchFail)
}
}
}
fn run_loop(app: AppHandle) {
let p = match read_params() {
Some(p) => p,
None => return,
};
// 滚轮目标初始参数:带中心(客户区坐标)→ 屏幕坐标
let bcx = p.band_x + p.band_w / 2;
let bcy = p.band_y + p.band_h / 2;
let (bsx, bsy) = client_pt_to_screen(p.hwnd, bcx, bcy).unwrap_or((0, 0));
WHEEL_SX.store(bsx, Ordering::Release);
WHEEL_SY.store(bsy, Ordering::Release);
// 自动模式:启动滚轮线程(策略 0 起步)
let mut strategy: u8 = 0;
let mut ticker = None;
if p.auto_scroll {
apply_strategy(0, &p, bcx, bcy);
TICKER_STOP.store(false, Ordering::Release);
ticker = Some(std::thread::spawn(ticker_loop));
}
let mut no_change: u32 = 0; // 锁定后:连续无位移(到底判定)
let mut no_move: u32 = 0; // 未锁定:连续无位移(策略升级判定)
let mut locked = false; // 是否已确认当前策略能滚动
let mut gave_up = false; // 所有策略都滚不动
let mut cycle_ms = CYCLE_MS;
let mut capture_errors: u32 = 0;
let mut last_emit = Instant::now();
loop {
if STOP.load(Ordering::Acquire) || !is_active() {
break;
}
// 周期节拍:捕获+匹配耗时计入周期
let t0 = Instant::now();
let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) {
Ok(f) => {
capture_errors = 0;
f
}
Err(e) => {
// 瞬时失败先重试,连续多次失败才视为窗口关闭/失去客户区
capture_errors += 1;
log_error("scroll-session", &format!("捕获失败({}/3): {}", capture_errors, e));
if capture_errors >= 3 {
break;
}
sleep(Duration::from_millis(100));
continue;
}
};
let (band_w, total_h, result) = stitch_cycle(&p, &cur);
// 目标位移:带高 1/4,夹在安全区间
let target_d = (p.band_h as usize / 4).clamp(TARGET_D_MIN, TARGET_D_MAX);
match result {
CycleResult::Moved(d) => {
if !locked {
locked = true;
log_info("scroll-session", &format!("策略 {} 生效,锁定", strategy));
}
no_change = 0;
no_move = 0;
if p.auto_scroll {
if strategy == 0 {
// 小步长模式:按实测位移调步长(sqrt 阻尼防过冲)
let delta = WHEEL_STEP.load(Ordering::Acquire) as f64;
let factor = target_d as f64 / d.max(6) as f64;
let nd = (delta * factor.sqrt()).clamp(DELTA_MIN as f64, DELTA_MAX as f64);
WHEEL_STEP.store(nd as i32, Ordering::Release);
} else {
// 整格模式:步长固定,按实测位移调捕获周期
if d as f64 > p.band_h as f64 * 0.55 {
cycle_ms = ((cycle_ms as f64) * 1.25).min(CYCLE_MS_MAX as f64) as u64;
} else if d < target_d / 2 && cycle_ms > CYCLE_MS {
cycle_ms = ((cycle_ms as f64) * 0.8).max(CYCLE_MS as f64) as u64;
}
}
}
}
CycleResult::NoChange => {
if locked {
no_change += 1;
if no_change >= NO_CHANGE_STOP && p.auto_scroll {
break; // 已到底部
}
// 手动模式由用户控制,不自动结束
} else if p.auto_scroll {
no_move += 1;
if no_move >= ESCALATE_CYCLES {
no_move = 0;
strategy += 1;
if strategy > 3 {
gave_up = true; // 所有通道都滚不动 → 结束(前端提示)
break;
}
apply_strategy(strategy, &p, bcx, bcy);
}
}
}
CycleResult::MatchFail => {
// 内容大幅变化/动画/位移超上限:若持续失败则降速,
// 避免越滚越快导致匹配一直失败(表现即"滚动中断")
if p.auto_scroll {
if locked && strategy == 0 {
let delta = WHEEL_STEP.load(Ordering::Acquire) as f64;
let nd = (delta * 0.75).max(DELTA_MIN as f64);
WHEEL_STEP.store(nd as i32, Ordering::Release);
} else if !locked {
// 未锁定:匹配失败也算"无位移"参与策略升级,
// 防止动画页面导致升级判定永不触发
no_move += 1;
if no_move >= ESCALATE_CYCLES {
no_move = 0;
strategy += 1;
if strategy > 3 {
gave_up = true;
break;
}
apply_strategy(strategy, &p, bcx, bcy);
}
}
}
}
}
// 进度事件(节流)
if matches!(result, CycleResult::Moved(_))
&& last_emit.elapsed().as_millis() as u64 >= PROGRESS_THROTTLE_MS
{
last_emit = Instant::now();
emit_progress(&app, band_w, total_h, p.auto_scroll);
}
if total_h >= MAX_TOTAL_H {
break;
}
// 补足周期剩余时间
let el = t0.elapsed().as_millis() as u64;
if el < cycle_ms {
sleep(Duration::from_millis(cycle_ms - el));
}
}
// 停滚轮线程(join 等它退出,最多再发一拍)
if let Some(t) = ticker.take() {
TICKER_STOP.store(true, Ordering::Release);
let _ = t.join();
}
let cancel = CANCEL.load(Ordering::Acquire);
// 完成(非取消、非放弃)时:等滚动动画静止后补拼最后一帧,
// 保证图片结尾 = 用户停止时窗口实际停留的位置
if !cancel && !gave_up {
settle_and_stitch(&p);
}
// 取出会话数据
let mut s = match SESSION.lock().ok().and_then(|mut g| g.take()) {
Some(s) => s,
None => {
log_info("scroll-session", "会话已不存在(未导出)");
let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({}));
return;
}
};
// 编码与回滚并行:PNG 编码只依赖画布(已定格),回滚不依赖画布,
// 两者无数据依赖,并行可显著缩短「停止 → 打开编辑器」的等待
let (band_w, total_h) = (s.band_w, s.total_h);
let encode = if cancel {
None
} else {
let canvas = std::mem::take(&mut s.canvas);
Some(std::thread::spawn(move || {
bgra_to_png(&canvas, band_w, total_h as i32)
}))
};
// 回滚到起始位置(与编码并行):先按累计像素粗估上滚,再帧匹配校准
if s.ttl_px > 0 {
rollback(&s);
}
STOP.store(false, Ordering::Release);
CANCEL.store(false, Ordering::Release);
// 等编码完成:PNG 原始字节直接存入编辑器图片槽(不经 base64 / JSON 事件传输)
let png = encode.and_then(|h| h.join().ok()).and_then(|r| r.ok());
match png {
Some(png) => {
log_info("scroll-session", &format!(
"完成导出 width={} height={}",
band_w, total_h
));
super::set_editor_image_raw(png);
let _ = app.emit(
evts::SCROLL_COMPLETE,
serde_json::json!({ "width": band_w, "height": total_h }),
);
}
None => {
log_info("scroll-session", "会话已取消(未导出)");
let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({}));
}
}
}
/// 停止后等待目标窗口滚动动画静止,并把静止帧补拼进画布(结尾对齐停止位置)。
fn settle_and_stitch(p: &Params) {
let deadline = Instant::now() + Duration::from_millis(SETTLE_TIMEOUT_MS);
loop {
sleep(Duration::from_millis(70));
if Instant::now() >= deadline || !is_active() {
return;
}
let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) {
Ok(f) => f,
Err(_) => return,
};
match stitch_cycle(p, &cur).2 {
CycleResult::NoChange => return, // 已静止且无新内容
CycleResult::Moved(_) | CycleResult::MatchFail => {
// 动画仍在进行(Moved 已拼接;MatchFail 继续等)
if Instant::now() >= deadline {
return;
}
}
}
}
}
/// 回滚:粗估整格数上滚 → 与初始帧逐次比对校准,直到回到起始位置。
/// 帧匹配校准消除"每格滚动像素因程序而异"带来的累计误差。
fn rollback(s: &Session) {
let target = {
let t = WHEEL_TARGET.load(Ordering::Acquire);
if t != 0 { t } else { s.hwnd }
};
let mut sx = WHEEL_SX.load(Ordering::Acquire);
let mut sy = WHEEL_SY.load(Ordering::Acquire);
if sx == 0 && sy == 0 {
if let Some((x, y)) = client_pt_to_screen(
s.hwnd,
s.band_x + s.band_w / 2,
s.band_y + s.band_h / 2,
) {
sx = x;
sy = y;
}
}
// 粗估上滚(宁可略少,剩余交给校准补齐)。
// 上限按累计像素推算:长图捕获(累计数万 px)也要能回滚到位,
// 固定小上限会导致超长捕获结束后窗口停在半途。
let notches = (((s.ttl_px as f64) / ROLLBACK_PX_PER_NOTCH).floor() as i32).clamp(1, 800);
for _ in 0..notches {
send_wheel(target, sx, sy, WHEEL_DELTA as i32);
sleep(Duration::from_millis(3));
}
sleep(Duration::from_millis(80));
// 帧匹配校准:d = 当前仍相对起始位置向下滚动的像素
let mut last_d = usize::MAX;
for _ in 0..12 {
let cur = match capture_client_bgra(s.hwnd, s.ox, s.oy, s.cw, s.ch) {
Ok(f) => f,
Err(_) => return,
};
let d = detect_vscroll_offset(
&s.first,
&cur,
s.cw as usize,
s.ch as usize,
s.band_x as usize,
s.band_y as usize,
s.band_w as usize,
s.band_h as usize,
);
match d {
Some(0) | None => return, // 已回到起始位置 / 无法匹配(视为完成)
Some(d) if d < 10 => return, // 误差 10px 内视为到位
Some(d) => {
if d >= last_d {
return; // 不再下降:窗口可能不支持向上滚(虚拟化列表),放弃
}
last_d = d;
let more = (((d as f64) / ROLLBACK_PX_PER_NOTCH).ceil() as i32).clamp(1, 60);
for _ in 0..more {
send_wheel(target, sx, sy, WHEEL_DELTA as i32);
sleep(Duration::from_millis(3));
}
sleep(Duration::from_millis(60));
}
}
}
}
fn emit_progress(app: &AppHandle, width: i32, height: u32, auto: bool) {
let _ = app.emit(
evts::SCROLL_PROGRESS,
serde_json::json!({ "width": width, "height": height, "auto": auto }),
);
}