Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
153 changes: 139 additions & 14 deletions crates/xpaste-core/src/stitch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
//!
//! 新帧在长图最后几屏里找位置,而不只和上一帧比:滚得太快接不上时,往回滚到预览里已有的任何位置都能接着拼。
//!
//! 只按行哈希精确比对。试过对不上时按亮度轮廓近似比对、再逐像素核对(应对小数像素滚动、每帧带噪点的画面),
//! 一行行很像的内容(每行只差几个字)在真实截屏上会错开整行接上、悄悄弄坏长图,所以不做:宁可报没接上(见已知限制)。
//! 先按行哈希精确比对。找不到时退一步,只和上次接上的那帧近似比对(`follow`):背景图不随文字滚动的终端、
//! 半透明窗口、小数像素滚动,滚过之后没有一行完全相同。近似比对要明显胜出才接(见 `follow`),
//! 一行行很像的内容错开整行时各个位置差不多好,分不出胜负,报没接上,不接错。

use std::hash::{DefaultHasher, Hash, Hasher};

Expand All @@ -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 {
Expand Down Expand Up @@ -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 行;这一帧可靠的部分没超出它:没有新内容(往回滚了,或没动)
Expand Down Expand Up @@ -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<usize> {
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::<u64>()
})
.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<usize> = 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。
Expand Down Expand Up @@ -235,6 +309,33 @@ fn rows(px: &[u8], w: usize, cmp_w: usize) -> Vec<Row> {
.collect()
}

/// 每个像素与左边像素的亮度差(每行比对宽度内,第 0 列为 0),行紧密排列
fn edges(px: &[u8], w: usize, cmp_w: usize) -> Vec<i16> {
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 行非空白行对上;取对上的非空白行最多的位置,一样多取靠后的(离当前滚动位置最近)。
Expand Down Expand Up @@ -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<u8> {
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]
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/2026-10-28-截图与录屏-设计.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 完成后只按普通写入写剪切板进历史并发系统通知,不回到遮罩(标注、保存、贴图都不做;遮罩早已撤下,重新上屏代价大,与录屏同理)。有标注时不能开始长截图。

Loading
Loading