diff --git a/Cargo.lock b/Cargo.lock index 8579c1d..2d0ec41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5792,7 +5792,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xpaste" -version = "0.5.0" +version = "0.5.1" dependencies = [ "embed-resource", "image", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "xpaste-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "chrono", "log", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "xpaste-platform" -version = "0.5.0" +version = "0.5.1" dependencies = [ "block2", "dispatch2", diff --git a/Cargo.toml b/Cargo.toml index d004910..ba9ada8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["src-tauri", "crates/xpaste-core", "crates/xpaste-platform"] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version = "1.90" diff --git a/crates/xpaste-core/src/stitch.rs b/crates/xpaste-core/src/stitch.rs index 62016a9..9d74e03 100644 --- a/crates/xpaste-core/src/stitch.rs +++ b/crates/xpaste-core/src/stitch.rs @@ -5,8 +5,9 @@ //! //! 新帧在长图最后几屏里找位置,而不只和上一帧比:滚得太快接不上时,往回滚到预览里已有的任何位置都能接着拼。 //! -//! 只按行哈希精确比对。试过对不上时按亮度轮廓近似比对、再逐像素核对(应对小数像素滚动、每帧带噪点的画面), -//! 一行行很像的内容(每行只差几个字)在真实截屏上会错开整行接上、悄悄弄坏长图,所以不做:宁可报没接上(见已知限制)。 +//! 先按行哈希精确比对。找不到时退一步,只和上次接上的那帧近似比对(`follow`):背景图不随文字滚动的终端、 +//! 半透明窗口、小数像素滚动,滚过之后没有一行完全相同。近似比对要明显胜出才接(见 `follow`), +//! 一行行很像的内容错开整行时各个位置差不多好,分不出胜负,报没接上,不接错。 use std::hash::{DefaultHasher, Hash, Hasher}; @@ -15,6 +16,10 @@ pub const MAX_PIXELS: usize = 48_000_000; pub const MAX_ROWS: usize = 32_000; /// 新帧在长图最后这么多屏里找位置 const SEARCH_SCREENS: usize = 4; +/// 近似比对时每行按亮度分成这么多段求和,粗筛位置 +const SEGS: usize = 8; +/// 粗筛后逐像素核对的位置数 +const PICKS: usize = 6; #[derive(Debug, Clone, Copy)] struct Row { @@ -143,8 +148,16 @@ impl Stitch { }; // 这一帧第 lo 行落在长图第 p 行 let window = SEARCH_SCREENS * h; - let Some(p) = locate(len, at, &b[lo..hi], window) else { - return Step::Lost; + let p = match locate(len, at, &b[lo..hi], window) { + Some(p) => p, + // 这一帧第 lo 行是上次接上那帧的第 lo + d 行,那一行在长图第 done - end + lo + d 行 + None => match self.follow(&cur, lo, hi) { + Some(d) => match (done + lo + d).checked_sub(self.end) { + Some(p) => p, + None => return Step::Lost, + }, + None => return Step::Lost, + }, }; let to = hi.saturating_sub(self.guard).max(lo); // 长图的末尾是这一帧的第 lo + len - p 行;这一帧可靠的部分没超出它:没有新内容(往回滚了,或没动) @@ -172,6 +185,67 @@ impl Stitch { Step::Added(self.height().saturating_sub(before)) } + /// 精确比对找不到时:这一帧(不动的顶部、底部之间的 `lo..hi` 行)相对上次接上的那帧往下滚了几行。 + /// 比的是横向边缘(每个像素与左边像素的亮度差),不是颜色:文字是锐利的边,背景图平滑,不随文字滚动的背景 + /// 几乎不影响结果。先按每行分段的边缘强度粗筛出几个彼此相隔 3 行以上的位置,再逐像素(每 4 列取 1 列)算平均差; + /// 最好的位置的差要不到次好的一半,次好的又不能好过毫不相干的位置的一半,才算数:只有一个像样的位置。 + /// 背景不动、文字滚动时,对的位置比错开的位置小得多(真实终端截屏约 0.07–0.23 倍);内容甩出了上次那帧时 + /// 各个位置差不多(约 0.98 倍);一行行很像时错开整行的几个位置都不错。这些都报没接上。 + /// 只往下找,往回滚时报没接上,滚回来接着拼。 + fn follow(&self, cur: &[u8], lo: usize, hi: usize) -> Option { + let m = hi - lo; + let min = min_overlap(m); + let cw = self.cmp_w; + let (a, b) = (edges(&self.last, self.w, cw), edges(cur, self.w, cw)); + let (pa, pb) = (profile(&a, cw), profile(&b, cw)); + let mut rough: Vec<(u64, usize)> = (0..=m - min) + .map(|d| { + let n = m - d; + let s: u64 = (lo..lo + n) + .map(|r| { + pa[r + d] + .iter() + .zip(&pb[r]) + .map(|(x, y)| u64::from(x.abs_diff(*y))) + .sum::() + }) + .sum(); + (s / n as u64, d) + }) + .collect(); + rough.sort_unstable(); + // 两帧第 r 行对第 r + d 行的平均边缘差,每 4 列取 1 列 + let diff = |d: usize| { + let (mut s, mut k) = (0u64, 0u64); + for r in lo..hi - d { + let (x, y) = (&a[(r + d) * cw..][..cw], &b[r * cw..][..cw]); + for (p, q) in x.iter().zip(y).step_by(4) { + s += u64::from(p.abs_diff(*q)); + k += 1; + } + } + s as f64 / k.max(1) as f64 + }; + // 粗筛排在正中的位置:当作“错开、毫不相干”的典型差别 + let typical = diff(rough[rough.len() / 2].1); + let mut picks: Vec = Vec::with_capacity(PICKS); + for &(_, d) in &rough { + if picks.iter().all(|q| q.abs_diff(d) > 3) { + picks.push(d); + if picks.len() == PICKS { + break; + } + } + } + let mut fine: Vec<(f64, usize)> = picks.into_iter().map(|d| (diff(d), d)).collect(); + fine.sort_by(|x, y| x.0.total_cmp(&y.0)); + let (best, d) = *fine.first()?; + let second = fine.get(1)?.0; + // 次好的也几乎没差别:整片空白,分不出位置。次好的明显好过毫不相干的位置:有不止一个像样的位置 + // (一行行很像的内容错开整行),分不清 + (second >= 1.0 && best * 2.0 < second && second * 2.0 >= typical).then_some(d) + } + /// 长截图期间旁边的预览(M23 规则 13):把拼好的图缩到宽 `pw`,只取最下面最多 `max_ph` 行(最新接上的部分, /// 用户据此看接缝对不对)。每个输出像素取源区域里 2 × 2 个采样点的平均,只碰输出大小的像素,几毫秒。 /// 返回宽、高、RGBA。 @@ -235,6 +309,33 @@ fn rows(px: &[u8], w: usize, cmp_w: usize) -> Vec { .collect() } +/// 每个像素与左边像素的亮度差(每行比对宽度内,第 0 列为 0),行紧密排列 +fn edges(px: &[u8], w: usize, cmp_w: usize) -> Vec { + let mut e = Vec::with_capacity(px.len() / 4 / w * cmp_w); + for r in px.chunks_exact(w * 4) { + let mut left = None; + for c in r[..cmp_w * 4].as_chunks::<4>().0 { + let l = i16::from(c[0]) + 2 * i16::from(c[1]) + i16::from(c[2]); + e.push(left.map_or(0, |x| l - x)); + left = Some(l); + } + } + e +} + +/// 每行分成 `SEGS` 段,各段的边缘强度和 +fn profile(e: &[i16], cmp_w: usize) -> Vec<[u32; SEGS]> { + e.chunks_exact(cmp_w) + .map(|r| { + let mut p = [0u32; SEGS]; + for (x, v) in r.iter().enumerate() { + p[x * SEGS / cmp_w] += u32::from(v.unsigned_abs()); + } + p + }) + .collect() +} + /// 在长图(共 `len` 行,`at(i)` 取第 i 行)最后 `window` 行里找 `band` 的位置:`band[0]` 对应长图第几行。 /// 重叠至少 1/5(不少于 8 行),对不上的行不超过重叠的 1/4(帧边上的圆角、渐隐、悬停高亮都会对不上), /// 至少 4 行非空白行对上;取对上的非空白行最多的位置,一样多取靠后的(离当前滚动位置最近)。 @@ -475,21 +576,45 @@ mod tests { } } - /// 已知限制:小数像素滚动、每帧带噪点时精确比对接不上,但一定报没接上,不会接错 + /// 小数像素滚动、每帧带噪点时精确比对接不上,近似比对按最近的整行接上 #[test] - fn fractional_or_noisy_frames_are_lost_not_misjoined() { + fn fractional_or_noisy_frames_join_at_the_nearest_row() { let d = Dev::new(600, 7); let mut s = Stitch::new(64, 60, &d.shot(0.0, 60, 0, 1), 0, 4); - for y in [13.5, 37.25, 61.75] { - assert_eq!(s.push(d.shot(y, 60, 0, 2)), Step::Lost, "y = {y}"); + for (i, y) in [13.5, 37.25, 61.75].into_iter().enumerate() { + assert!( + matches!(s.push(d.shot(y, 60, 0, 2)), Step::Added(_)), + "y = {y}" + ); + // 每次只能接到相邻的整行上(差不到 1 行),相对上次那帧,会累积 + assert!( + (s.height() as f32 - 60.0 - y).abs() <= (i + 1) as f32, + "y = {y}" + ); } - // 整数像素、没有噪点时照常接上 - assert_eq!(s.push(d.shot(20.0, 60, 0, 3)), Step::Added(20)); let mut s = Stitch::new(64, 60, &d.shot(0.0, 60, 2, 1), 0, 4); - for (i, y) in [10.0, 30.0].into_iter().enumerate() { - assert_eq!(s.push(d.shot(y, 60, 2, 10 + i as u32)), Step::Lost); - } - assert_eq!(s.height(), 60); + assert_eq!(s.push(d.shot(10.0, 60, 2, 10)), Step::Added(10)); + assert_eq!(s.push(d.shot(30.0, 60, 2, 11)), Step::Added(20)); + assert_eq!(s.height(), 90); + } + + /// 背景图不随文字滚动(终端的背景图、半透明窗口):没有一行完全相同,近似比对接上;甩出上次那帧时报没接上 + #[test] + fn text_over_a_fixed_background() { + let (text, bg) = (Dev::new(600, 5), Dev::new(70, 6).shot(0.0, 60, 0, 1)); + let shot = |y: f32| -> Vec { + let t = text.shot(y, 60, 0, 1); + t.iter() + .zip(&bg) + .map(|(a, b)| ((u16::from(*a) * 3 + u16::from(*b)) / 4) as u8) + .collect() + }; + let mut s = Stitch::new(64, 60, &shot(0.0), 0, 4); + assert_eq!(s.push(shot(15.0)), Step::Added(15)); + assert_eq!(s.push(shot(33.0)), Step::Added(18)); + assert_eq!(s.push(shot(200.0)), Step::Lost); + assert_eq!(s.push(shot(50.0)), Step::Added(17)); + assert_eq!(s.height(), 110); } #[test] diff --git "a/docs/specs/2026-10-28-\346\210\252\345\233\276\344\270\216\345\275\225\345\261\217-\350\256\276\350\256\241.md" "b/docs/specs/2026-10-28-\346\210\252\345\233\276\344\270\216\345\275\225\345\261\217-\350\256\276\350\256\241.md" index 4868c7d..2427b85 100644 --- "a/docs/specs/2026-10-28-\346\210\252\345\233\276\344\270\216\345\275\225\345\261\217-\350\256\276\350\256\241.md" +++ "b/docs/specs/2026-10-28-\346\210\252\345\233\276\344\270\216\345\275\225\345\261\217-\350\256\276\350\256\241.md" @@ -318,7 +318,7 @@ pub fn request_screen_capture(); // CGRequestScreenCaptureAccess + 打 1. 用户自己滚,不由 xPaste 模拟滚动(iShot 用 `CGEventPost` 自动滚,附录 A):模拟滚动要辅助功能权限,不同应用对合成滚轮事件的步长、惯性各不相同,网页懒加载还会让“滚到底”判断不准。用户滚动在任何应用里都一样,速度也由用户掌握。 2. 复用录屏边框窗口 `rec-frame`(鼠标穿透、不激活)画四角与提示,不另建窗口;期间临时把回车、Esc 注册成全局快捷键(键盘已还给原应用),截图快捷键也能完成。没有可点的“完成”按钮:边框窗口必须鼠标穿透,选区里的滚轮才能落到下面的应用。 3. 截屏:macOS 用 `CGWindowListCreateImage` 只截边框窗口之下的内容(边框与提示自然不进图),与冻结屏幕同为已废弃接口,800 × 600 点约 70–100 ms;Windows 用 `BitBlt` 截区域,不带 `CAPTUREBLT`(每秒截 10 次会让光标闪),边框窗口设 `WDA_EXCLUDEFROMCAPTURE`。录屏边框在 Windows 上也随之改为不激活地显示(`show()` 会抢键盘)。 -4. 拼接放在核心层 `xpaste_core::stitch`(纯函数,单元测试):逐行哈希,去掉与上一帧相同的顶部、底部后,把中间部分放到已拼好的长图最后 4 屏里逐个位置比对(最初只和上一次接上的那帧比,真机上一甩就接不上,而且要精确滚回那一帧附近才能恢复,见验收记录),对不上的行不超过重叠的 1/4、至少 4 行非空白行对上,取对上最多的位置,一样多取靠后的;重叠至少 1/5,且对上的行至少是错开 3 行以外的次好位置的 2 倍。为了别的设备(小数像素滚动、颜色抖动)试过近似比对:先按每行 32 段亮度轮廓找候选、再逐像素核对,模拟数据上能接,但在真实截屏的“一行行只差几个字”的文字上会错开整行接上(见验收记录),接错比接不上糟得多,所以撤掉,只留精确比对,连续 3 秒接不上时提示用户回车保留已拼好的部分。比对忽略右侧 24 逻辑像素(滚动条);每帧底部 40 逻辑像素先不接,滚上来在下一帧里再接,结束时补上最后一帧的底部。阈值来自真实窗口的实验(选中窗口时底部 48 行带圆角,1/20 的容差全部对不上)。 +4. 拼接放在核心层 `xpaste_core::stitch`(纯函数,单元测试):逐行哈希,去掉与上一帧相同的顶部、底部后,把中间部分放到已拼好的长图最后 4 屏里逐个位置比对(最初只和上一次接上的那帧比,真机上一甩就接不上,而且要精确滚回那一帧附近才能恢复,见验收记录),对不上的行不超过重叠的 1/4、至少 4 行非空白行对上,取对上最多的位置,一样多取靠后的;重叠至少 1/5,且对上的行至少是错开 3 行以外的次好位置的 2 倍。为了别的设备(小数像素滚动、颜色抖动)试过近似比对:先按每行 32 段亮度轮廓找候选、再逐像素核对,模拟数据上能接,但在真实截屏的“一行行只差几个字”的文字上会错开整行接上(见验收记录),接错比接不上糟得多,所以撤掉。后来真机上终端带不随文字滚动的背景图,滚过之后没有一行完全相同,精确比对一帧都接不上,又加回一个更严的近似比对(`Stitch::follow`):只在精确比对找不到时用,只和上一次接上的那帧比,比的是横向边缘(每个像素与左边像素的亮度差)而不是颜色:文字是锐利的边,背景图平滑。按颜色比时,背景较亮、文字稀疏的帧里“没滚动”(背景对齐、文字错开)和对的位置分不开(比值 0.53);按边缘比降到 0.12–0.23。每行 8 段边缘强度粗筛出 6 个彼此相隔 3 行以上的位置,逐像素(每 4 列取 1 列)算平均边缘差;最好的不到次好的一半,且次好的不好过粗筛排在正中的位置(当作毫不相干)的一半才接。后一条挡住“一行行很像”:错开整行的几个位置都像样,报没接上。真实终端帧里对的位置约 1.3–3.5,次好约 14–21,毫不相干约 21–31;没有重叠的两帧比值 0.98–1.00。连续 3 秒接不上时提示用户回车保留已拼好的部分。比对忽略右侧 24 逻辑像素(滚动条);每帧底部 40 逻辑像素先不接,滚上来在下一帧里再接,结束时补上最后一帧的底部。阈值来自真实窗口的实验(选中窗口时底部 48 行带圆角,1/20 的容差全部对不上)。 5. 预览(用户在实现后要求):放在同一个边框窗口里,选区旁 240 逻辑像素宽、高同选区,只显示长图最下面最新的部分。核心层 `Stitch::preview` 直接从拼接结果按输出尺寸 2 × 2 采样缩小(只碰输出大小的像素),边框页收到进度(高度或状态变了才发,另外每秒补发一次)后用 `long_preview` 拉原始 RGBA(同 `shot_frame` 的格式),不经过 PNG 编码。 6. 完成后只按普通写入写剪切板进历史并发系统通知,不回到遮罩(标注、保存、贴图都不做;遮罩早已撤下,重新上屏代价大,与录屏同理)。有标注时不能开始长截图。 diff --git "a/docs/xPaste-\345\212\237\350\203\275\350\247\204\346\240\274\350\257\264\346\230\216.md" "b/docs/xPaste-\345\212\237\350\203\275\350\247\204\346\240\274\350\257\264\346\230\216.md" index 7743f38..a221b1b 100644 --- "a/docs/xPaste-\345\212\237\350\203\275\350\247\204\346\240\274\350\257\264\346\230\216.md" +++ "b/docs/xPaste-\345\212\237\350\203\275\350\247\204\346\240\274\350\257\264\346\230\216.md" @@ -2,7 +2,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | v0.1.39(初稿,待评审) | +| 文档版本 | v0.1.41(初稿,待评审) | | 日期 | 2026-11-01 | | 适用平台 | macOS、Windows 桌面端 | | 依据 | 交互原型 `xpaste-prototype.html`(v0.3) | @@ -943,11 +943,11 @@ xPaste 是一款常驻后台的剪切板历史工具。它自动记录用户复 - 第一次截图时后台预加载识别模型(规则 7),降低线程优先级,不占上屏时间;记 `shot 预加载识别模型 Tms`(macOS 约 8 秒)。 13. 长截图(L,或工具栏“长截图”):选区后按 L,遮罩撤下,键盘还给原来的应用,选区四角显示图片类型色的直角,旁边(下方放不下时在上方,都放不下时在选区底部)显示“长截图 · 已拼接 N px · 滚动选区里的内容 · ↩ 完成 · Esc 取消”。 - - 用户自己滚动选区里的内容(鼠标滚轮、触控板、拖滚动条、按键都行,xPaste 不模拟滚动)。后台截完一帧就截下一帧(间隔不少于 40 ms,一帧约 50–80 ms),按行比对找出这一帧落在已拼好的长图的什么位置,把新露出的行接到长图下面;边框与提示不进截图。 + - 用户自己滚动选区里的内容(鼠标滚轮、触控板、拖滚动条、按键都行,xPaste 不模拟滚动)。遮罩淡出要一会儿,开始时等画面稳定(连续两帧完全相同,最多 0.6 秒)再截长图的第一帧,否则第一帧截到淡出中的遮罩、颜色差一点,之后每一帧都接不上。随后后台截完一帧就截下一帧(间隔不少于 40 ms,一帧约 20–80 ms),按行比对找出这一帧落在已拼好的长图的什么位置,把新露出的行接到长图下面;边框与提示不进截图。 - 选区旁(右侧,放不下时左侧,都放不下时选区里的右上角)有“预览”框,宽 240 逻辑像素、高同选区:显示拼好的长图缩小后最下面最新接上的部分,每接上一段就更新,用户据此看接缝是否连续;这一帧没接上时预览框描边变红。预览框同样不进截图。 - 不随滚动变化的顶部、底部(吸顶的标题栏、底部工具栏,比 40 逻辑像素高也一样)只保留一份。比对时忽略区域右侧 24 逻辑像素(滚动条);每帧底部 40 逻辑像素先不接,滚上来后再接(窗口圆角、列表渐隐),最后补上最后一帧的底部。 - 每一帧都在长图最后 4 屏里找位置,不只和上一帧比:往回滚到已拼好的部分不算出错,再往下滚接着拼。两次截屏之间滚过一整屏(甩得太快)、超出了已拼好的部分,或画面整体变了,这一帧不接,提示变为“滚得太快没接上,往回滚到预览里的位置”、预览框描边变红;往回滚到和预览里任何内容重叠的位置就能继续,不必回到上次接上的那一帧。只往下拼接,不往上接。 - - 宁可接不上,也不接错:只按像素完全相同的行比对;重叠至少 1/5,且对上的行要明显多于错开 3 行以外的其他位置(一行行很像的内容,如代码、表格、每行只差几个字的列表,错开整行也能对上不少行)。连续 3 秒都没接上(画面一直在变:动画、视频;或这个应用滚动时落在小数像素上),提示改为“这里一直接不上(画面在变?),↩ 保留已拼好的部分”。 + - 宁可接不上,也不接错:先按像素完全相同的行比对;重叠至少 1/5,且对上的行要明显多于错开 3 行以外的其他位置(一行行很像的内容,如代码、表格、每行只差几个字的列表,错开整行也能对上不少行)。找不到时只和上一次接上的那帧近似比对(背景图不随文字滚动的终端、半透明窗口、小数像素滚动,滚过之后没有一行完全相同),比的是文字的边缘而不是颜色,不随文字滚动的背景图几乎不影响:最好的位置的平均差不到次好的一半,且次好的不好过毫不相干的位置的一半(只有一个像样的位置),才接上;一行行很像的内容错开整行时有好几个像样的位置,报没接上。近似比对只往下找,往回滚时报没接上,滚回来接着拼。连续 3 秒都没接上(画面一直在变:动画、视频),提示改为“这里一直接不上(画面在变?),↩ 保留已拼好的部分”。 - 长度上限:总像素 4800 万或 32000 行,取较小的;到上限后提示一直是“已到长度上限”,不再增加。 - 回车或再按一次截图快捷键(录屏快捷键也一样)完成,完成前再截一帧,最后滚进来的内容不漏:拼好的 PNG 按普通写入写进剪切板,同规则 7 的复制进入历史,屏幕左下角的提示条显示“长截图已复制,已加入历史 · 宽 × 高”(在边框窗口里,停留 2.4 秒,不依赖系统通知;失败时另发系统通知)(PNG 超过 50 MB、不会入库时为“长截图已复制,超过 50 MB,没有加入历史 · 宽 × 高”);Esc 取消,什么也不写。长截图期间回车、Esc 临时注册为全局快捷键,结束后注销。 - 有标注时“长截图”不可用(悬停提示“长截图不带标注,先撤销标注”):长截图截的是滚动中的实时画面,标注对不上。 @@ -1649,6 +1649,8 @@ xPaste 是一款常驻后台的剪切板历史工具。它自动记录用户复 | v0.1.10(阶段 9) | 2026-10-20 | 阶段 9 产品闭环回写:删除颜色类型——1.1、第 2 章类型为六种,M02 判定表删颜色行并重新编号(`rgb()` / `hsl()` 扩展移到第 8 章),AC-M02-01 预期改为文本,M06 分类 8 项(AC-M06-02 改为 8 个),M07 删色块缩略图与颜色标题,M08 删颜色行与补充规则 2、3(AC-M08-01 改为 6 种,AC-M08-02、03 作废),M16 类型颜色表删颜色行(品红保留为强调色),附录删“已复制 rgb(…)”。M04:标题栏删最小化 / 关闭按钮(规则 3、6),设置或确认对话框打开时失焦与快捷键不隐藏面板(失焦时按快捷键回到面板),布局注明频谱可收起。M08、M13:删预览区“更多”按钮;右键菜单加按类型的动作(在浏览器中打开 / 在访达或资源管理器中显示)。M09、M07、M13、M19:动作文字按能力显示“粘贴 / 复制”“快速粘贴 / 快速复制”。M12:其他提示不影响撤销,5 秒内 `Mod + Z` 始终可用,状态栏显示 `Mod + Z 撤销`;清空历史也可从设置进入。M14:规则 9 收起 / 展开并记住,AC-M14-06、07;键盘切换时箭头旋转 140 ms 作为例外列入 M20 动效清单。M15:外观点选即生效并保存(删交互规则 2 的例外,AC-M15-05 不含外观),新增“清空历史…”一行(行内说明显示结果或错误文字,设置开着时托盘清空的结果也写在这里),录制框聚焦时按全局快捷键只写入录制框,“前往授权”同时隐藏面板,开关改名“跳过密码管理器与敏感内容”(M01 同步)。M16 规则 5:删圆形扩散(移到第 8 章)。M01 规则 8、M17:暂停状态跨重启;托盘菜单加版本项。M19:状态栏提示随状态变化。附录增加对应文案 | | v0.1.11(阶段 9.1) | 2026-10-20 | 阶段 9.1 验收修正回写:M05 规则 2 匹配范围加记录标题(截图自动标题此前搜不到,库迁移 3 回填);M12 规则 1 与附录快捷键表——删除键改为 ⌦(Delete)或 Mod + ⌫(macOS ⌘ ⌫,Windows Ctrl + Backspace),单独按 ⌫ 只作用于搜索框;AC-M12-01 改为按 ⌦ 或 Mod + ⌫,新增 AC-M12-06(搜索框为空时按 ⌫ 没有任何变化)。M16、M15:默认外观由“跟随系统”改为暗色(已保存的设置不变)。M06、M07、M08、M09、M11、M15 与名词表:“常用”的使用次数阈值可在设置中配置(2–99,默认 3),M15 设置表新增“进入‘常用’的使用次数”一行与 AC-M15-08,AC-M06-03 改为按设置阈值;附录首次进入常用的提示改为 N 次,新增三条文案。M08:预览头部与底部信息栏收紧,信息栏排成一行,放不下时截断较长的值,悬停显示完整值(`title`)。视觉修正(不涉及规格条文):搜索框聚焦下划线与面板顶边光谱线铺满宽度,两端随圆角收束 | | v0.1.17 | 2026-10-20 | M15 唤起快捷键:macOS 的 ⌃(Control)成为独立修饰键,与 ⌘ 分开录制与显示(修复 ⌃ Z 被记成 ⌘ Z 的缺陷);修饰键顺序 Mod → Control → Alt → Shift;提示文案按平台 | +| v0.1.41 | 2026-11-01 | 真机反馈“在其他应用里长截图还是接不上”:终端带不随文字滚动的背景图,滚过之后没有一行完全相同。M23 规则 13 加近似比对:精确比对找不到时只和上次接上的那帧比,最好的位置要明显胜过次好的、次好的又不能像样(防止一行行很像的内容错开整行接上) | +| v0.1.40 | 2026-11-01 | 真机反馈“滚得很慢也一直提示没接上”:长截图开始时第一帧截到了淡出中的截图遮罩(冻结画面,每个像素颜色差一点),之后每帧都对不上。M23 规则 13 改为等画面稳定(连续两帧完全相同,最多 0.6 秒)再截第一帧 | | v0.1.39 | 2026-11-01 | 真机反馈“截图后抖一下”:M23 规则 7 复制、保存、贴图确认即淡出,去掉选区光扫与 300 ms 停顿,提示条改为淡入上移、不回弹(AC-M23-13)。新增 M23 规则 13 长截图(L,用户滚动、后台逐帧比对拼接、回车或截图快捷键完成进历史、Esc 取消)与 AC-M23-14…16;附录加长截图的提示与通知文案;第 8 章“长截图”改为只列不做的部分。用户要求加长截图预览(规则 13、AC-M23-17);设备支持梳理(新增[设备支持](设备支持.md)):录屏与长截图的结果、录制中按截图快捷键的提示改为显示在边框窗口左下角的提示条,失败与中断另发系统通知(M23 规则 13、M24 规则 9、10);打开设置时启动时被占用的快捷键标红(附录);Windows 安装程序在 Windows 10 on ARM 上拦下、低于 21H2 时询问(1.4、附录);1.4 注明 macOS 13.0 的样式缺失;真机验收反馈“很容易没接上、然后无法完成”:每帧改为在长图最后 4 屏里找位置(往回滚到预览里任何重叠的位置就能接上),截屏间隔 100 → 40 ms,提示文案改为“滚得太快没接上,往回滚到预览里的位置”(规则 13、AC-M23-16)。面向其他设备的加固:只认像素完全相同的行(近似比对在真实截屏上会错开整行接错,试过后不做),重叠至少 1/5 且要明显胜过错开的位置,连续 3 秒接不上时提示可回车保留已拼好的部分(规则 13)。代码审查修正:底栏高于暂不接的行时也能拼接、到上限后一直报上限、回车完成前再截一帧、滚回后撤掉“没接上”、超过 50 MB 时通知不说已加入历史,多块屏时其他屏的蒙层与动作屏同时淡出 | | v0.1.38 | 2026-10-31 | M26 规则 13:连接阶段超时(10 秒内连不上)提示“连不上翻译接口…”,“翻译超时”只用于连上之后的读超时(原来连接超时也提示成“翻译超时”) | | v0.1.37 | 2026-10-31 | M04 规则 3:输入法选字时按 Esc、回车归输入法,搜索框、设置、确认框、片段对话框都不因它清空或关闭(WebKit 结束选字时先结束组字再发按键,之前会被当成普通 Esc) | diff --git "a/docs/\345\267\262\347\237\245\351\231\220\345\210\266.md" "b/docs/\345\267\262\347\237\245\351\231\220\345\210\266.md" index 0a4a7ac..b9700f3 100644 --- "a/docs/\345\267\262\347\237\245\351\231\220\345\210\266.md" +++ "b/docs/\345\267\262\347\237\245\351\231\220\345\210\266.md" @@ -61,7 +61,7 @@ **长截图**(规格说明 M23 规则 13) - 要自己滚动,xPaste 不模拟滚动;只往下拼接。截一帧约 50–80 ms(2 倍屏),触控板用力一甩会在两帧之间滚过一整屏,这时接不上,要往回滚到与预览重叠的位置;往回超过 4 屏也接不上。 -- 按行精确比对,宁可接不上也不接错。以下情况可能一直接不上(连续 3 秒接不上时提示可按 ↩ 保留已拼好的部分):选区里有一直在变的大块内容(视频、动画);滚动时内容落在小数像素上的应用(Windows 125% / 150% 缩放下个别应用);每帧颜色有细微抖动的画面。试过按亮度轮廓近似比对再逐像素核对,在一行行很像的文字上会错开整行接上,悄悄弄坏长图,所以没有做。一行行很像的内容(代码、表格、列表)在滚得很快、两帧只重叠一点时也会报接不上,慢一点滚即可。 +- 先按行精确比对,找不到时只和上次接上的那帧近似比对,宁可接不上也不接错。以下情况可能一直接不上(连续 3 秒接不上时提示可按 ↩ 保留已拼好的部分):选区里有一直在变的大块内容(视频、动画)。背景图不随文字滚动(终端背景图、半透明窗口)时能接上,但长图里的背景是一段段拼起来的。近似比对只往下找:这类画面往回滚时报没接上,滚回来接着拼;小数像素滚动时每次接上可能差不到 1 行。一行行很像的内容(代码、表格、列表)在滚得很快、两帧只重叠一点时会报接不上,慢一点滚即可。 - 闪烁的光标、悬停高亮等零星变化的几行可能接不齐;整页渐变背景、视差滚动、滚动时位置会变的吸顶栏可能对不上或重复。区域右侧 24 逻辑像素(滚动条)不参与比对,长图右边那一列来自不同的帧,滚动条会断断续续。 - 长截图期间回车、Esc 被注册成全局快捷键,在其他应用里按也只会完成或取消长截图;期间在设置里改快捷键(重新注册全部快捷键)后回车、Esc 失效,只能再按截图快捷键完成。 - Windows 上逐帧截屏不带 `CAPTUREBLT`(带上它每秒截 10 次光标会闪),分层窗口(WPF 透明窗口、部分 Electron / Qt 透明窗口、贴图)截不进去,这类窗口里的内容不能长截图。 diff --git "a/docs/\350\256\276\345\244\207\346\224\257\346\214\201.md" "b/docs/\350\256\276\345\244\207\346\224\257\346\214\201.md" index 21ec41e..8eea8f1 100644 --- "a/docs/\350\256\276\345\244\207\346\224\257\346\214\201.md" +++ "b/docs/\350\256\276\345\244\207\346\224\257\346\214\201.md" @@ -42,7 +42,7 @@ xPaste 在不同系统、芯片、显示器和权限设置下哪些功能可用 | --- | --- | --- | | Retina / 高分屏、普通屏 | 支持;截图按各屏物理像素导出 | 已验证 Retina | | 多块屏、缩放不同 | 支持:每块屏一个遮罩,选区只在一块屏内,吸附框按各屏缩放换算 | 混合缩放待实机验收(AC-M23-10) | -| Windows 125% / 150% 等小数缩放 | 截图、贴图支持。长截图:个别应用滚动时内容落在小数像素上,精确比对接不上 | 连续接不上时有提示(见上表);不会接错。待 Windows 实机确认 | +| Windows 125% / 150% 等小数缩放 | 截图、贴图支持。长截图:个别应用滚动时内容落在小数像素上,精确比对接不上,由近似比对接上(每次可能差不到 1 行) | 接不上时有提示(见上表)。待 Windows 实机确认 | | 广色域(P3) | 冻结画面转换到 sRGB,前端按 sRGB 画,颜色不偏 | 实现见设计 §8 第 1 条,没有单独验收 | | HDR(macOS EDR、Windows 开着 HDR) | 截图按 SDR 截取;Windows 开着 HDR 时 GDI 截图可能发灰或过曝 | 没有提示;见第 5 节优化方案 | | 带刘海的 MacBook | 遮罩覆盖整块屏,包括菜单栏与刘海区域 | 按设计实现(设计 §4.3),没有单独验收 | @@ -65,11 +65,11 @@ xPaste 在不同系统、芯片、显示器和权限设置下哪些功能可用 1. 录屏、长截图的结果显示在屏幕上(边框窗口左下角的提示条,停留 2.4 秒),失败或录制被中断时另发系统通知。不再完全依赖系统通知。 2. 打开设置时,启动时被占用、没注册上的快捷键在录制框下标红,所在标签出红点。 3. Windows 安装程序:Windows 10 on ARM 直接拦下并说明原因;低于 21H2 时提示风险,由用户决定。 -4. 长截图:在已拼好的长图最后 4 屏里找位置,截屏间隔 40 ms;只认像素完全相同的行,重叠至少 1/5、要明显胜过错开的位置,宁可接不上也不接错;连续 3 秒接不上时提示可以保留已拼好的部分。 +4. 长截图:在已拼好的长图最后 4 屏里找位置,截屏间隔 40 ms;先认像素完全相同的行,重叠至少 1/5、要明显胜过错开的位置;找不到时和上次接上的那帧近似比对(背景图不动的终端、半透明窗口、小数像素滚动),只有一个像样的位置才接,宁可接不上也不接错;连续 3 秒接不上时提示可以保留已拼好的部分。第一帧等画面稳定再截(遮罩淡出中截到的第一帧颜色差一点,之后每帧都接不上)。 **试过但不做** -- 长截图的近似比对(亮度轮廓找候选、再逐像素核对),用来支持小数像素滚动、颜色抖动的设备:模拟数据能接上,但在真实截屏上“一行行只差几个字”的内容会错开整行接上,悄悄弄坏长图。详见[验收记录](验收记录.md)“截图收尾去抖与长截图”。 +- 长截图在已拼好的长图最后 4 屏里近似比对(亮度轮廓找候选、再逐像素核对):“一行行只差几个字”的内容甩出已拼好的部分时会错开整行接上。现在的近似比对只和上次接上的那帧比,并要求只有一个像样的位置,见[验收记录](验收记录.md)“截图收尾去抖与长截图”“长截图在终端里接不上”。 **可以做的优化(没做,按价值排序)** diff --git "a/docs/\351\252\214\346\224\266\350\256\260\345\275\225.md" "b/docs/\351\252\214\346\224\266\350\256\260\345\275\225.md" index 22db274..814648f 100644 --- "a/docs/\351\252\214\346\224\266\350\256\260\345\275\225.md" +++ "b/docs/\351\252\214\346\224\266\350\256\260\345\275\225.md" @@ -965,3 +965,34 @@ macOS 27.2(arm64),2026-11-01,分支 `fix/shot-shake-long-shot`,`make c | 设备-安装程序 | Windows | | 待用户 | | 在 Windows 10 21H2 以下(如 20H2 虚拟机)运行安装程序:出现“当前系统低于 Windows 10 21H2…仍要安装吗?”,选“否”退出、选“是”照常安装;Windows 10 on ARM 上直接提示需要 Windows 11 并退出;21H2 及以上没有任何多出的提示 | | 设备-macOS 13 | macOS 13 | | 待用户 | | macOS 13.0(或 Safari 16.1)上打开面板与截图:确认只是部分描边、高亮缺失,功能都能用;13.1 起正常 | +## 长截图一开始就接不上(无阶段编号) + +macOS 27.2(arm64),2026-11-01,分支 `fix/long-shot-lost`。规格 v0.1.40(M23 规则 13)。 + +**现象**:用户在 Chrome 里慢慢滚也一直提示“滚得太快没接上”,完成不了;已安装的 0.5.0 当天 4 次长截图都在 10–20 秒后取消。 + +**排查**:开发构建临时加了逐帧存 PNG,用户复现一次(区域 871 × 162 点,271 帧,没接上 89 帧),离线把存下的帧逐帧送进 `xpaste_core::stitch` 重放: +- 第 2 帧起相邻两帧都能精确对上(如第 34、35 帧错开 22 行,302 行完全相同),拼接逻辑本身没问题。 +- 第 0 帧(长图的开头)和之后任何一帧都没有一行完全相同;画面没动时也是每个像素差一点(平均差 0.5)。第 0、1 帧截到的是还在淡出的截图遮罩:遮罩里是冻结画面,内容一样、颜色差一点。原来只等 50 ms 就截第一帧,遮罩撤下与窗口服务器的淡出是异步的,赶不赶得上看运气,所以之前的验收有时正常。 +- 去掉第 0、1 帧重放:没接上 0 帧,长图 324 → 1240 px。 + +**修正**:第一帧等到连续两帧完全相同再用,最多 0.6 秒(`longshot::settled`,单元测试 `first_frame_waits_until_the_overlay_is_gone`)。用户在修好的开发版上同一页面复现:435 帧,没接上 0 帧,长图 1756 × 3006;存下的帧离线重放同样 0 帧没接上。排查用的逐帧存图没有留在代码里。 + +## 长截图在终端里接不上(无阶段编号) + +macOS 27.2(arm64),2026-11-01,分支 `fix/long-shot-lost`。规格 v0.1.41(M23 规则 13)。 + +**现象**:修好第一帧之后,用户在其他应用(终端,窗口带一张不随文字滚动的背景图)里长截图仍然接不上。 + +**排查**:同样逐帧存图(区域 1359 × 246 点,215 帧,没接上 109 帧)后离线重放:一滚动就再也没有一行完全相同(文字动了,背景没动),精确比对按设计一帧都接不上。逐像素平均差:相邻两帧在对的位移上 1.3–7,错开 3 行以外的次好位置 28–38(比值 0.04–0.19);拿相隔很远、没有重叠的两帧比,比值 0.97–0.99。 + +**修正**:精确比对找不到时只和上次接上的那帧近似比对(`Stitch::follow`)。第一版只要求“最好的不到次好的一半”,单元测试 `repetitive_lines_never_misjoin` 当场接错(甩出已拼好的部分后接到了错开整行的位置);加上“次好的不能好过毫不相干的位置的一半”后拒绝(次好约 4,毫不相干约 98)。重放结果: +- 终端 216 帧:没接上 0 帧,长图 2718 × 1848,文字行号连续(944–949、1649–1655、965–972 与屏幕一致),背景图一段段拼起来。最慢一帧 15 ms。 +- Chrome(上一节的帧,第 2 帧起):没接上 0 帧,长图 1240 px,与只用精确比对时相同。 + +**第二个终端**:用户在修好的开发版上又截了一个终端(日志输出,每行开头都是相同格式的时间戳,背景图在选区里更亮),249 帧没接上 25 帧,只拼出 722 px。按颜色比时,滚得多的帧里“没滚动”(背景对齐、文字错开)是次好的位置,比值 0.53,刚好过不了一半的门槛。改为比横向边缘(每个像素与左边像素的亮度差):文字是锐利的边、背景图平滑,比值降到 0.12–0.23;第一个终端 0.07–0.14;没有重叠的两帧 0.98–1.00。重放三组帧: +- 日志终端 250 帧:没接上 0 帧,长图 2246 × 2176,各段启动日志的时间与编译耗时依次递增、没有重复。 +- 第一个终端 216 帧:没接上 0 帧,长图 1848 px,同上。 +- Chrome:没接上 0 帧,1240 px,同上。 + +单元测试:`text_over_a_fixed_background`(文字 3/4、静止背景 1/4 混合,滚动接上、甩出报没接上、滚回来接着拼);原 `fractional_or_noisy_frames_are_lost_not_misjoined` 改为 `fractional_or_noisy_frames_join_at_the_nearest_row`(小数像素与噪点帧现在接得上,每次接上差不到 1 行)。 diff --git a/package.json b/package.json index 42eb78b..f68ebdc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xpaste", "private": true, - "version": "0.5.0", + "version": "0.5.1", "type": "module", "packageManager": "pnpm@10.28.0", "scripts": { diff --git a/src-tauri/src/longshot.rs b/src-tauri/src/longshot.rs index 6721547..084789f 100644 --- a/src-tauri/src/longshot.rs +++ b/src-tauri/src/longshot.rs @@ -29,6 +29,8 @@ const TICK: Duration = Duration::from_millis(40); const SCROLLBAR: f64 = 24.0; /// 每帧底部这么高(逻辑像素)先不接,滚上来再接:选中整个窗口时底部有圆角,列表底部常有渐隐 const GUARD: f64 = 40.0; +/// 第一帧最多等这么久画面稳定(见 `settled`) +const SETTLE: Duration = Duration::from_millis(600); /// 连续这么久都没接上,提示改为“这里接不上”,告诉用户回车可以保留已拼好的部分 const STUCK: Duration = Duration::from_secs(3); const ENTER: &str = "Enter"; @@ -172,9 +174,7 @@ fn run( scale: f64, ) { let grab = || xpaste_platform::capture_rect(r, below); - // 遮罩刚撤下:等窗口服务器真正撤掉它,第一帧里不能有蒙层(同截图的 settle) - std::thread::sleep(Duration::from_millis(50)); - let (w, h, first) = match grab() { + let (w, h, first) = match settled(grab) { Ok(f) => f, Err(e) => { log::warn!("long 截屏失败:{e}"); @@ -284,6 +284,24 @@ fn run( rec::frame_result(app, ¬ice, ok); } +/// 长图的第一帧。遮罩刚撤下,窗口服务器还在把它淡出:这时截到的是遮罩里的冻结画面,看着一样、每个像素的颜色 +/// 都差一点,之后每一帧都和它对不上,怎么滚都“没接上”。所以等到连续两帧完全相同(画面稳定)再用; +/// 用户一开始就在滚、一直不稳定时,最多等 `SETTLE`,用最后一帧。 +fn settled(grab: impl Fn() -> Result<(u32, u32, Vec), E>) -> Result<(u32, u32, Vec), E> { + let t = Instant::now(); + std::thread::sleep(Duration::from_millis(50)); + let mut f = grab()?; + while t.elapsed() < SETTLE { + std::thread::sleep(TICK); + let next = grab()?; + if next == f { + break; + } + f = next; + } + Ok(f) +} + fn encode(w: usize, h: usize, rgba: Vec) -> Result, AppError> { let img = image::RgbaImage::from_raw(w as u32, h as u32, rgba) .ok_or_else(|| AppError::Invalid("长截图尺寸无效".into()))?; @@ -304,3 +322,31 @@ fn end(app: &AppHandle) { } *running() = None; } + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn first_frame_waits_until_the_overlay_is_gone() { + // 前两帧还带着淡出中的遮罩,之后画面稳定 + let frames = [1u8, 2, 3, 3, 3]; + let i = Cell::new(0); + let grab = || { + let k = frames[i.get().min(frames.len() - 1)]; + i.set(i.get() + 1); + Ok::<_, ()>((1, 1, vec![k; 4])) + }; + assert_eq!(settled(grab), Ok((1, 1, vec![3; 4]))); + // 一直在变:最多等 SETTLE,用最后一帧 + let n = Cell::new(0u8); + let moving = || { + n.set(n.get().wrapping_add(1)); + Ok::<_, ()>((1, 1, vec![n.get(); 4])) + }; + let t = Instant::now(); + assert!(settled(moving).is_ok()); + assert!(t.elapsed() < SETTLE + Duration::from_millis(200)); + } +}