//! 滚动截图会话(仅 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, total_h: u32, /// 累计检测到的滚动像素 ttl_px: u32, /// 上一帧(全客户区,匹配基准) prev: Vec, /// 初始帧(回滚校准用) first: Vec, auto_scroll: bool, active: bool, } static SESSION: Mutex> = 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 { 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, 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 }), ); }