滚动截图,细节调整
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
//! 滚动截图会话(仅 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 }),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user