From e9ec0898bd4b68dc1679b6037ee0b7e82e65bfee Mon Sep 17 00:00:00 2001 From: houvven Date: Mon, 12 Jan 2026 01:52:56 +0800 Subject: [PATCH 1/9] fix(capture): prevent tooltip from occluding buttons in long capture toolbar --- qml/components/ToolbarButton.qml | 3 ++- qml/features/capture/LongCaptureToolbarWindow.qml | 5 +++-- qml/features/capture/components/SelectionToolbar.qml | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/qml/components/ToolbarButton.qml b/qml/components/ToolbarButton.qml index 5c23ba4..1ed511d 100644 --- a/qml/components/ToolbarButton.qml +++ b/qml/components/ToolbarButton.qml @@ -12,6 +12,7 @@ Basic.Button { property color hoveredIconColor: defaultIconColor property bool isActive: false property string tooltipText: "" + property bool showTooltip: true Layout.preferredHeight: AppTheme.buttonHeight Layout.preferredWidth: AppTheme.buttonHeight @@ -34,7 +35,7 @@ Basic.Button { Tooltip { parent: root text: root.tooltipText - visible: root.hovered && root.tooltipText !== "" + visible: root.showTooltip && root.hovered && root.tooltipText !== "" position: Qt.AlignTop } } diff --git a/qml/features/capture/LongCaptureToolbarWindow.qml b/qml/features/capture/LongCaptureToolbarWindow.qml index f5f355b..f0d3ff7 100644 --- a/qml/features/capture/LongCaptureToolbarWindow.qml +++ b/qml/features/capture/LongCaptureToolbarWindow.qml @@ -43,12 +43,13 @@ Window { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter + showTooltips: false buttons: [[ { "icon": "qrc:/resources/icons/save.svg", "text": qsTr("Save"), "action": "save", - "hoverColor": AppTheme.success + "hoverColor": AppTheme.primary }, { "icon": "qrc:/resources/icons/keep.svg", @@ -82,4 +83,4 @@ Window { running: isBusy text: root.busyText } -} \ No newline at end of file +} diff --git a/qml/features/capture/components/SelectionToolbar.qml b/qml/features/capture/components/SelectionToolbar.qml index bad86ab..3ae0a15 100644 --- a/qml/features/capture/components/SelectionToolbar.qml +++ b/qml/features/capture/components/SelectionToolbar.qml @@ -8,6 +8,7 @@ Rectangle { id: root property string activeTool: "" + property bool showTooltips: true property var buttons: [[ { "icon": "qrc:/resources/icons/square.svg", @@ -144,6 +145,7 @@ Rectangle { icon.source: modelData.icon icon.width: 24 isActive: modelData.action === root.activeTool + showTooltip: root.showTooltips tooltipText: modelData.text onClicked: { From 121ec118389dee3a37c0fdc8abc0c3e3e5b97fe3 Mon Sep 17 00:00:00 2001 From: houvven Date: Mon, 12 Jan 2026 01:52:59 +0800 Subject: [PATCH 2/9] style(capture): remove border from long capture preview window --- qml/features/capture/LongCapturePreviewWindow.qml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/qml/features/capture/LongCapturePreviewWindow.qml b/qml/features/capture/LongCapturePreviewWindow.qml index dcdb504..2e9edf9 100644 --- a/qml/features/capture/LongCapturePreviewWindow.qml +++ b/qml/features/capture/LongCapturePreviewWindow.qml @@ -15,7 +15,7 @@ Window { if (imgPhysicalW <= 0) return 150; - let viewW = width - 2; // minus border + let viewW = width; let ratio = viewW / imgPhysicalW; let h = currentHeight * ratio; return Math.max(100, h); @@ -37,7 +37,7 @@ Window { color: "transparent" flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool - height: Math.min(contentHeight + 2, 600) // Cap at 600px, +2 for border + height: Math.min(contentHeight, 600) width: 300 @@ -63,8 +63,6 @@ Window { id: mainRect anchors.fill: parent - border.color: AppTheme.border - border.width: 1 clip: true color: AppTheme.surface radius: AppTheme.radiusLarge @@ -83,7 +81,6 @@ Window { id: previewImg anchors.fill: parent - anchors.margins: 1 cache: false // Default to cropping (showing latest content) for better feedback during scroll fillMode: root.showFull ? Image.PreserveAspectFit : Image.PreserveAspectCrop From 92fb4dd52af046244eb00f9e27a5467c85e7f38b Mon Sep 17 00:00:00 2001 From: houvven Date: Mon, 12 Jan 2026 01:53:35 +0800 Subject: [PATCH 3/9] opt(stitcher): improve overlap detection, motion masking, and seam finding efficiency --- src/core/capture/stitcher.rs | 419 +++++++++++++++++++---------------- 1 file changed, 222 insertions(+), 197 deletions(-) diff --git a/src/core/capture/stitcher.rs b/src/core/capture/stitcher.rs index 097c773..2b507a2 100644 --- a/src/core/capture/stitcher.rs +++ b/src/core/capture/stitcher.rs @@ -7,18 +7,19 @@ pub enum StitchResult { Failure, } +#[derive(Clone, Copy)] pub struct StitchConfig { pub min_overlap: u32, - pub pixel_tolerance: u8, + pub max_overlap_check: u32, pub min_scroll_threshold: u32, } impl Default for StitchConfig { fn default() -> Self { Self { - min_overlap: 50, - pixel_tolerance: 10, - min_scroll_threshold: 15, + min_overlap: 20, + max_overlap_check: 95, + min_scroll_threshold: 5, } } } @@ -58,32 +59,30 @@ impl ScrollStitcher { } } - #[must_use] pub fn current_image(&self) -> Option<(&RgbaImage, u32)> { self.canvas.as_ref().map(|img| (img, self.valid_height)) } - #[must_use] pub fn get_final_image(&self) -> Option { let (canvas, h) = self.current_image()?; + if h == 0 || canvas.width() == 0 { + return None; + } let w = canvas.width(); let mut final_img = RgbaImage::new(w, h); Self::copy_region(canvas, 0, &mut final_img, 0, h); Some(final_img) } - #[must_use] pub fn make_thumbnail(&self, target_width: u32) -> Option { let (canvas, valid_h) = self.current_image()?; let w = canvas.width(); - if valid_h == 0 || w == 0 { return None; } let scale = target_width as f32 / w as f32; let target_height = (valid_h as f32 * scale) as u32; - if target_height == 0 { return None; } @@ -101,9 +100,7 @@ impl ScrollStitcher { if self.canvas.is_none() { let w = new_image.width(); let h = new_image.height(); - let capacity = h * 2; - let mut canvas = RgbaImage::new(w, capacity); - + let mut canvas = RgbaImage::new(w, h * 3); Self::copy_region(&new_image, 0, &mut canvas, 0, h); self.canvas = Some(canvas); @@ -114,67 +111,251 @@ impl ScrollStitcher { } let last_frame = self.last_frame.as_ref().unwrap(); - if last_frame.width() != new_image.width() { return StitchResult::Failure; } - let (fixed_top, fixed_bottom) = self.detect_fixed_regions(last_frame, &new_image); - - let prev_h = last_frame.height(); - let next_h = new_image.height(); + let h_prev = last_frame.height(); + let h_next = new_image.height(); - if prev_h <= fixed_top + fixed_bottom || next_h <= fixed_top + fixed_bottom { - return StitchResult::Failure; + let active_mask = self.compute_motion_mask(last_frame, &new_image); + if active_mask.is_empty() { + self.last_frame = Some(new_image); + return StitchResult::Stationary; } - let valid_prev_h = prev_h - fixed_top - fixed_bottom; - let valid_next_h = next_h - fixed_top - fixed_bottom; + let sig_prev = self.compute_masked_signatures(last_frame, &active_mask); + let sig_next = self.compute_masked_signatures(&new_image, &active_mask); - let prev_sig = self.compute_row_signatures(last_frame, fixed_top, valid_prev_h); - let next_sig = self.compute_row_signatures(&new_image, fixed_top, valid_next_h); + let (fixed_top, fixed_bottom) = self.detect_fixed_regions_sig(&sig_prev, &sig_next); - let best_overlap = self.find_best_overlap(&prev_sig, &next_sig); + let valid_prev = h_prev.saturating_sub(fixed_top + fixed_bottom); + let valid_next = h_next.saturating_sub(fixed_top + fixed_bottom); - if best_overlap == 0 { + if valid_prev < 20 || valid_next < 20 { return StitchResult::Failure; } - let check_offset = best_overlap / 2; - let verify_y_prev = prev_h - fixed_bottom - best_overlap + check_offset; - let verify_y_next = fixed_top + check_offset; - let stride = (last_frame.width() * 4) as usize; + let (best_overlap, is_reverse) = self.find_optimal_overlap(&sig_prev, &sig_next, fixed_top, fixed_bottom, valid_prev, valid_next); - if !self.rows_match_sampled(last_frame.as_raw(), new_image.as_raw(), stride, verify_y_prev, verify_y_next) { + if best_overlap == 0 { return StitchResult::Failure; } - let scroll_delta = valid_prev_h.saturating_sub(best_overlap); + if !self.verify_pixels(last_frame, &new_image, best_overlap, is_reverse, fixed_top, fixed_bottom, &active_mask) { + return StitchResult::Failure; + } + let scroll_delta = valid_prev.saturating_sub(best_overlap); if scroll_delta < self.config.min_scroll_threshold { self.last_frame = Some(new_image); return StitchResult::Stationary; } - let prev_seam_start_y = prev_h - fixed_bottom - best_overlap; - let next_seam_start_y = fixed_top; + if is_reverse { + self.valid_height = self.valid_height.saturating_sub(scroll_delta); + self.last_frame = Some(new_image); + self.last_footer_height = fixed_bottom; + return StitchResult::Success; + } - let best_seam_k = self.find_best_seam(last_frame, &new_image, prev_seam_start_y, next_seam_start_y, best_overlap); + let cut_y = self.find_smart_seam(last_frame, &new_image, best_overlap, fixed_top, &active_mask); - let trim_amount = best_overlap.saturating_sub(best_seam_k); - let next_content_start_y = next_seam_start_y + best_seam_k; + let trim_amount = best_overlap.saturating_sub(cut_y); + let next_start = fixed_top + cut_y; - if self.execute_stitch(new_image, trim_amount, next_content_start_y, fixed_bottom) { + if self.execute_stitch(new_image, trim_amount, next_start, fixed_bottom) { StitchResult::Success } else { StitchResult::Failure } } + fn compute_motion_mask(&self, prev: &RgbaImage, next: &RgbaImage) -> Vec { + let w = prev.width(); + let h = prev.height(); + let raw_prev = prev.as_raw(); + let raw_next = next.as_raw(); + let stride = (w * 4) as usize; + let step = 8; + + (0..w) + .step_by(step) + .map(|x| x as usize) + .filter(|&x| { + let mut diff_sum: u64 = 0; + for y in (0..h).step_by(step) { + let idx = (y as usize) * stride + (x * 4); + let d = (raw_prev[idx] as i32 - raw_next[idx] as i32).abs() + + (raw_prev[idx + 1] as i32 - raw_next[idx + 1] as i32).abs() + + (raw_prev[idx + 2] as i32 - raw_next[idx + 2] as i32).abs(); + diff_sum += d as u64; + } + diff_sum > (h as u64 / 2) + }) + .collect() + } + + fn compute_masked_signatures(&self, img: &RgbaImage, cols: &[usize]) -> Vec { + let w = img.width(); + let h = img.height(); + let raw = img.as_raw(); + let stride = (w * 4) as usize; + + (0..h) + .map(|y| { + let row_start = (y as usize) * stride; + cols.iter().fold(0u64, |sum, &x| { + let idx = row_start + (x * 4); + sum + (raw[idx] as u64) + (raw[idx + 1] as u64) + (raw[idx + 2] as u64) + }) + }) + .collect() + } + + fn measure_fixed_len(&self, s1: &[u64], s2: &[u64], indices: impl Iterator) -> u32 { + let mut len = 0; + for i in indices { + let diff = s1[i].abs_diff(s2[i]); + let max_val = s1[i].max(s2[i]); + if max_val > 0 && (diff * 100 / max_val) > 5 { + break; + } + len += 1; + } + len + } + + fn detect_fixed_regions_sig(&self, s_prev: &[u64], s_next: &[u64]) -> (u32, u32) { + let h = s_prev.len(); + let max_check = h / 3; + + let top = self.measure_fixed_len(s_prev, s_next, 0..max_check); + let bottom = self.measure_fixed_len(s_prev, s_next, (0..max_check).map(|i| h - 1 - i)); + + (top, bottom) + } + + fn scan_overlaps(&self, range: impl Iterator, calc_offsets: F, s1: &[u64], s2: &[u64]) -> (u32, u64) + where + F: Fn(u32) -> (usize, usize), + { + let mut best_ov = 0; + let mut best_score = u64::MAX; + + for overlap in range { + let (st1, st2) = calc_offsets(overlap); + let score = Self::score_overlap(s1, s2, st1, st2, overlap as usize); + let avg = score / (overlap as u64); + + if avg < 500 { + return (overlap, avg); + } + if avg < best_score { + best_score = avg; + best_ov = overlap; + } + } + (best_ov, best_score) + } + + fn find_optimal_overlap(&self, s_prev: &[u64], s_next: &[u64], f_top: u32, f_btm: u32, v_prev: u32, v_next: u32) -> (u32, bool) { + let max_overlap = v_prev.min(v_next); + if max_overlap < self.config.min_overlap { + return (0, false); + } + + let range = (self.config.min_overlap..=max_overlap).rev(); + + let (f_ov, f_score) = self.scan_overlaps( + range.clone(), + |ov| { + let prev_start = (s_prev.len() as u32 - f_btm - ov) as usize; + let next_start = f_top as usize; + (prev_start, next_start) + }, + s_prev, + s_next, + ); + + let (r_ov, r_score) = self.scan_overlaps( + range, + |ov| { + let prev_start = f_top as usize; + let next_start = (s_next.len() as u32 - f_btm - ov) as usize; + (prev_start, next_start) + }, + s_prev, + s_next, + ); + + if r_score < f_score / 2 { (r_ov, true) } else { (f_ov, false) } + } + + #[inline] + fn score_overlap(s1: &[u64], s2: &[u64], start1: usize, start2: usize, len: usize) -> u64 { + (0..len).map(|i| s1[start1 + i].abs_diff(s2[start2 + i])).sum() + } + + fn verify_pixels(&self, prev: &RgbaImage, next: &RgbaImage, overlap: u32, reverse: bool, f_top: u32, f_btm: u32, cols: &[usize]) -> bool { + let raw_prev = prev.as_raw(); + let raw_next = next.as_raw(); + let stride = (prev.width() * 4) as usize; + + let (y1_base, y2_base) = if reverse { + (f_top, next.height() - f_btm - overlap) + } else { + (prev.height() - f_btm - overlap, f_top) + }; + + [0, overlap / 2, overlap - 1].iter().all(|&r| { + let y1 = y1_base + r; + let y2 = y2_base + r; + let step = (cols.len() / 20).max(1); + + let (hits, checks) = cols.iter().step_by(step).fold((0, 0), |(h, c), &x| { + let idx1 = (y1 as usize) * stride + (x * 4); + let idx2 = (y2 as usize) * stride + (x * 4); + let d = (raw_prev[idx1] as i32 - raw_next[idx2] as i32).abs() + (raw_prev[idx1 + 1] as i32 - raw_next[idx2 + 1] as i32).abs(); + (if d < 80 { h + 1 } else { h }, c + 1) + }); + + checks == 0 || hits >= (checks / 2) + }) + } + + fn find_smart_seam(&self, prev: &RgbaImage, next: &RgbaImage, overlap: u32, f_top: u32, cols: &[usize]) -> u32 { + let w = prev.width(); + let stride = (w * 4) as usize; + let raw = next.as_raw(); + let start_y = f_top; + + let search_start = if overlap > 20 { overlap / 4 } else { 0 }; + let search_end = if overlap > 20 { overlap * 3 / 4 } else { overlap }; + let step = (cols.len() / 20).max(1); + + (search_start..search_end) + .min_by_key(|&k| { + let y = start_y + k; + let idx = (y as usize) * stride; + cols.iter() + .step_by(step) + .map(|&x| { + let p = idx + (x * 4); + if p >= stride { + let prev_p = p - stride; + (raw[p] as i32 - raw[prev_p] as i32).abs() as u64 + } else { + 0 + } + }) + .sum::() + }) + .unwrap_or(overlap / 2) + } + fn execute_stitch(&mut self, new_image: RgbaImage, trim_amount: u32, new_content_start_y: u32, fixed_bottom: u32) -> bool { let canvas = self.canvas.as_mut().unwrap(); - let width = canvas.width(); - let content_end_y = self.valid_height.saturating_sub(self.last_footer_height); if trim_amount > content_end_y { @@ -186,22 +367,19 @@ impl ScrollStitcher { let new_total_h = keep_h + new_content_h; if new_total_h > canvas.height() { - let new_cap = (canvas.height() * 2).max(new_total_h); + let new_cap = (canvas.height() * 2).max(new_total_h + 2000); + let width = canvas.width(); let mut new_canvas = RgbaImage::new(width, new_cap); - Self::copy_region(canvas, 0, &mut new_canvas, 0, keep_h); - self.canvas = Some(new_canvas); } let canvas = self.canvas.as_mut().unwrap(); - Self::copy_region(&new_image, new_content_start_y, canvas, keep_h, new_content_h); self.valid_height = new_total_h; self.last_frame = Some(new_image); self.last_footer_height = fixed_bottom; - true } @@ -209,13 +387,10 @@ impl ScrollStitcher { if height == 0 { return; } - let width_bytes = (src.width() * 4) as usize; let copy_bytes = (height as usize) * width_bytes; - let src_offset = (src_y as usize) * width_bytes; let dest_offset = (dest_y as usize) * width_bytes; - let src_raw = src.as_raw(); let dest_raw = dest.as_mut(); @@ -223,154 +398,4 @@ impl ScrollStitcher { dest_raw[dest_offset..dest_offset + copy_bytes].copy_from_slice(&src_raw[src_offset..src_offset + copy_bytes]); } } - - fn detect_fixed_regions(&self, prev: &RgbaImage, next: &RgbaImage) -> (u32, u32) { - let w = prev.width(); - let h = prev.height(); - let max_check = h / 3; - - let raw_prev = prev.as_raw(); - let raw_next = next.as_raw(); - let stride = (w * 4) as usize; - - let mut fixed_top = 0; - for y in 0..max_check { - if !self.rows_match_sampled(raw_prev, raw_next, stride, y, y) { - break; - } - fixed_top += 1; - } - - let mut fixed_bottom = 0; - for y in 0..max_check { - let y_idx = h - 1 - y; - if !self.rows_match_sampled(raw_prev, raw_next, stride, y_idx, y_idx) { - break; - } - fixed_bottom += 1; - } - - (fixed_top, fixed_bottom) - } - - #[inline] - fn rows_match_sampled(&self, raw1: &[u8], raw2: &[u8], stride: usize, y1: u32, y2: u32) -> bool { - let start1 = (y1 as usize) * stride; - let start2 = (y2 as usize) * stride; - - if start1 + stride > raw1.len() || start2 + stride > raw2.len() { - return false; - } - - let r1 = &raw1[start1..start1 + stride]; - let r2 = &raw2[start2..start2 + stride]; - - let mut total_diff: u64 = 0; - let mut count = 0; - - // Sample every 4th pixel (16 bytes) - for (c1, c2) in r1.chunks_exact(16).zip(r2.chunks_exact(16)) { - total_diff += (c1[0].abs_diff(c2[0]) as u64) + (c1[1].abs_diff(c2[1]) as u64) + (c1[2].abs_diff(c2[2]) as u64); - count += 1; - } - - if count == 0 { - return true; - } - (total_diff / count) <= (self.config.pixel_tolerance as u64) - } - - fn compute_row_signatures(&self, img: &RgbaImage, start_y: u32, height: u32) -> Vec { - let w = img.width(); - let stride = (w * 4) as usize; - let raw = img.as_raw(); - let mut sigs = Vec::with_capacity(height as usize); - - for i in 0..height { - let y = start_y + i; - let row_start = (y as usize) * stride; - - if row_start + stride > raw.len() { - sigs.push(0); - continue; - } - - let row = &raw[row_start..row_start + stride]; - let mut sum = 0u64; - - // Sample every 4th pixel for performance (consistent with detection) - // This speeds up signature calculation by 4x without losing much coarse signal - for pixel in row.chunks_exact(16) { - sum += (pixel[0] as u64) + (pixel[1] as u64) + (pixel[2] as u64); - } - sigs.push(sum); - } - sigs - } - - fn find_best_overlap(&self, prev_sig: &[u64], next_sig: &[u64]) -> u32 { - let len_prev = prev_sig.len(); - let len_next = next_sig.len(); - let max_overlap = len_prev.min(len_next); - let min_overlap = self.config.min_overlap as usize; - - if max_overlap < min_overlap { - return 0; - } - - let mut best_overlap = 0; - let mut min_avg_diff = u64::MAX; - - // Slide check: BOTTOM of prev matches TOP of next - for overlap in min_overlap..=max_overlap { - let s1_part = &prev_sig[len_prev - overlap..]; - let s2_part = &next_sig[..overlap]; - - let diff_sum: u64 = s1_part.iter().zip(s2_part.iter()).map(|(a, b)| a.abs_diff(*b)).sum(); - - let avg_diff = diff_sum / (overlap as u64); - - if avg_diff < min_avg_diff { - min_avg_diff = avg_diff; - best_overlap = overlap; - } - } - - best_overlap as u32 - } - - fn find_best_seam(&self, prev: &RgbaImage, next: &RgbaImage, prev_y_start: u32, next_y_start: u32, height: u32) -> u32 { - let w = prev.width(); - let stride = (w * 4) as usize; - let raw_prev = prev.as_raw(); - let raw_next = next.as_raw(); - - let mut best_k = 0; - let mut min_row_diff = u64::MAX; - - for k in 0..height { - let idx_prev = ((prev_y_start + k) as usize) * stride; - let idx_next = ((next_y_start + k) as usize) * stride; - - if idx_prev + stride > raw_prev.len() || idx_next + stride > raw_next.len() { - continue; - } - - let r1 = &raw_prev[idx_prev..idx_prev + stride]; - let r2 = &raw_next[idx_next..idx_next + stride]; - - let mut diff: u64 = 0; - // Full pixel difference for precision in seam finding - for (p1, p2) in r1.chunks_exact(4).zip(r2.chunks_exact(4)) { - diff += (p1[0].abs_diff(p2[0]) as u64) + (p1[1].abs_diff(p2[1]) as u64) + (p1[2].abs_diff(p2[2]) as u64); - } - - if diff < min_row_diff { - min_row_diff = diff; - best_k = k; - } - } - - best_k - } } From 548333f8e9a8066218f921e1bf0b384528414888 Mon Sep 17 00:00:00 2001 From: Lortunate Date: Mon, 12 Jan 2026 02:02:23 +0800 Subject: [PATCH 4/9] Update src/core/capture/stitcher.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/core/capture/stitcher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/capture/stitcher.rs b/src/core/capture/stitcher.rs index 2b507a2..023866a 100644 --- a/src/core/capture/stitcher.rs +++ b/src/core/capture/stitcher.rs @@ -344,7 +344,7 @@ impl ScrollStitcher { let p = idx + (x * 4); if p >= stride { let prev_p = p - stride; - (raw[p] as i32 - raw[prev_p] as i32).abs() as u64 + ((raw[p] as i32 - raw[prev_p] as i32).abs() + (raw[p + 1] as i32 - raw[prev_p + 1] as i32).abs() + (raw[p + 2] as i32 - raw[prev_p + 2] as i32).abs()) as u64 } else { 0 } From 40023976ac263389118ee9188ea17b5a2b5899bc Mon Sep 17 00:00:00 2001 From: Lortunate Date: Mon, 12 Jan 2026 02:03:01 +0800 Subject: [PATCH 5/9] Update src/core/capture/stitcher.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/core/capture/stitcher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/capture/stitcher.rs b/src/core/capture/stitcher.rs index 023866a..1bd8cbf 100644 --- a/src/core/capture/stitcher.rs +++ b/src/core/capture/stitcher.rs @@ -316,7 +316,7 @@ impl ScrollStitcher { let (hits, checks) = cols.iter().step_by(step).fold((0, 0), |(h, c), &x| { let idx1 = (y1 as usize) * stride + (x * 4); let idx2 = (y2 as usize) * stride + (x * 4); - let d = (raw_prev[idx1] as i32 - raw_next[idx2] as i32).abs() + (raw_prev[idx1 + 1] as i32 - raw_next[idx2 + 1] as i32).abs(); + let d = (raw_prev[idx1] as i32 - raw_next[idx2] as i32).abs() + (raw_prev[idx1 + 1] as i32 - raw_next[idx2 + 1] as i32).abs() + (raw_prev[idx1 + 2] as i32 - raw_next[idx2 + 2] as i32).abs(); (if d < 80 { h + 1 } else { h }, c + 1) }); From dd5b21281ee631f893cd70ca391e0c4392ceca3c Mon Sep 17 00:00:00 2001 From: houvven Date: Mon, 12 Jan 2026 21:54:41 +0800 Subject: [PATCH 6/9] style(ui): refine spinners and adjust dimensions for consistent theming --- qml/components/BusyStatus.qml | 40 ++++++++----------- .../capture/LongCapturePreviewWindow.qml | 37 +++++++---------- 2 files changed, 31 insertions(+), 46 deletions(-) diff --git a/qml/components/BusyStatus.qml b/qml/components/BusyStatus.qml index 07a2495..af76adb 100644 --- a/qml/components/BusyStatus.qml +++ b/qml/components/BusyStatus.qml @@ -10,31 +10,23 @@ Rectangle { border.color: AppTheme.border border.width: 1 color: AppTheme.surface - height: 44 - radius: 22 + height: 32 + radius: 16 visible: running - width: Math.max(140, label.contentWidth + 50) + width: Math.max(110, contentRow.width + 24) - // Shadow effect - Rectangle { - anchors.fill: parent - anchors.leftMargin: 2 - anchors.topMargin: 2 - color: AppTheme.shadowColor - radius: 22 - z: -1 - } Row { + id: contentRow + anchors.centerIn: parent - spacing: 12 + spacing: 8 - // Spinner Item { - height: 20 - width: 20 + height: 14 + width: 14 RotationAnimator on rotation { - duration: 1000 + duration: 800 from: 0 loops: Animation.Infinite running: root.running @@ -43,25 +35,27 @@ Rectangle { Canvas { anchors.fill: parent + antialiasing: true + renderTarget: Canvas.Image onPaint: { var ctx = getContext("2d"); ctx.reset(); ctx.beginPath(); - ctx.arc(10, 10, 8, 0, Math.PI * 1.5); - ctx.lineWidth = 2; + ctx.arc(7, 7, 5.5, 0, Math.PI * 1.5); + ctx.lineWidth = 1.5; + ctx.lineCap = "round"; ctx.strokeStyle = AppTheme.primary; ctx.stroke(); } } } - Text { - id: label + Text { color: AppTheme.text font.family: AppTheme.fontFamily - font.pixelSize: AppTheme.fontSizeBody - font.weight: Font.DemiBold + font.pixelSize: AppTheme.fontSizeSmall + font.weight: Font.Medium text: root.text verticalAlignment: Text.AlignVCenter } diff --git a/qml/features/capture/LongCapturePreviewWindow.qml b/qml/features/capture/LongCapturePreviewWindow.qml index 2e9edf9..2dbc9b1 100644 --- a/qml/features/capture/LongCapturePreviewWindow.qml +++ b/qml/features/capture/LongCapturePreviewWindow.qml @@ -52,7 +52,7 @@ Window { // Shadow for depth Rectangle { - color: AppTheme.shadowHeavy + color: AppTheme.shadowMedium height: parent.height radius: AppTheme.radiusLarge width: parent.width @@ -90,41 +90,31 @@ Window { sourceSize.width: parent.width * Screen.devicePixelRatio verticalAlignment: Image.AlignBottom - // Loading / Active State Item { anchors.centerIn: parent - height: 48 + height: 24 visible: root.currentHeight === 0 - width: 48 + width: 24 RotationAnimator on rotation { - duration: 1500 + duration: 800 from: 0 loops: Animation.Infinite to: 360 } - // Simple spinner using Rectangles - Rectangle { - border.color: AppTheme.subText - border.width: AppTheme.spacingTiny - color: "transparent" - height: 48 - opacity: 0.1 - radius: AppTheme.radiusLarge - width: 48 - } - - // Arc segment (simulated with canvas for clean look) Canvas { anchors.fill: parent + antialiasing: true + renderTarget: Canvas.Image onPaint: { var ctx = getContext("2d"); ctx.reset(); ctx.beginPath(); - ctx.arc(width / 2, height / 2, width / 2 - 2, 0, Math.PI / 1.5); - ctx.lineWidth = AppTheme.spacingTiny; + ctx.arc(12, 12, 9, 0, Math.PI * 1.5); + ctx.lineWidth = 2; + ctx.lineCap = "round"; ctx.strokeStyle = AppTheme.primary; ctx.stroke(); } @@ -132,7 +122,7 @@ Window { } Text { anchors.centerIn: parent - anchors.verticalCenterOffset: 40 + anchors.verticalCenterOffset: 24 color: AppTheme.subText font.bold: true font.pixelSize: 12 @@ -144,14 +134,15 @@ Window { // Minimal Floating Badge Rectangle { anchors.bottom: parent.bottom - anchors.margins: 6 + anchors.margins: 8 anchors.right: parent.right color: AppTheme.primary - height: 20 + height: 24 + opacity: 0.9 // Add shadow to badge too layer.enabled: true - radius: AppTheme.radiusMedium + radius: 12 visible: root.currentHeight > 0 width: badgeTxt.contentWidth + AppTheme.spacingMedium From 456c7035da46f9d5fe20042f206bfc9fe99293e2 Mon Sep 17 00:00:00 2001 From: houvven Date: Tue, 13 Jan 2026 14:48:29 +0800 Subject: [PATCH 7/9] opt(capture): increase sleep duration for scroll capture initialization --- src/core/capture/scroll_worker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/capture/scroll_worker.rs b/src/core/capture/scroll_worker.rs index 155597f..e491792 100644 --- a/src/core/capture/scroll_worker.rs +++ b/src/core/capture/scroll_worker.rs @@ -2,8 +2,8 @@ use crate::core::capture::stitcher::{ScrollStitcher, StitchResult}; use crate::core::capture::{get_primary_monitor, get_primary_monitor_scale, perform_crop}; use image::RgbaImage; use log::{error, info}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::Duration; @@ -21,7 +21,7 @@ pub fn start_scroll_capture_thread(x: i32, y: i32, width: i32, height: i32, acti .spawn(move || { info!("Scroll capture thread started"); // Small delay to let UI hide if needed - thread::sleep(Duration::from_millis(150)); + thread::sleep(Duration::from_millis(250)); let Some(monitor) = get_primary_monitor() else { error!("No primary monitor found for scroll capture"); From 3adbcf7e65e042ecae29bef62cc643dbf8615c46 Mon Sep 17 00:00:00 2001 From: houvven Date: Wed, 14 Jan 2026 12:18:42 +0800 Subject: [PATCH 8/9] opt(stitcher): enhance configuration flexibility and refactor overlap detection logic --- src/core/capture/stitcher.rs | 111 +++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 43 deletions(-) diff --git a/src/core/capture/stitcher.rs b/src/core/capture/stitcher.rs index 1bd8cbf..f051b35 100644 --- a/src/core/capture/stitcher.rs +++ b/src/core/capture/stitcher.rs @@ -7,19 +7,31 @@ pub enum StitchResult { Failure, } -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct StitchConfig { pub min_overlap: u32, - pub max_overlap_check: u32, pub min_scroll_threshold: u32, + pub overlap_avg_threshold: u64, + pub motion_scan_step: usize, + pub motion_threshold_divisor: u64, + pub fixed_diff_percent: u64, + pub verify_pixel_diff: u32, + pub verify_step_divisor: usize, + pub seam_margin_divisor: u32, } impl Default for StitchConfig { fn default() -> Self { Self { min_overlap: 20, - max_overlap_check: 95, min_scroll_threshold: 5, + overlap_avg_threshold: 500, + motion_scan_step: 8, + motion_threshold_divisor: 2, + fixed_diff_percent: 5, + verify_pixel_diff: 80, + verify_step_divisor: 20, + seam_margin_divisor: 4, } } } @@ -40,13 +52,7 @@ impl Default for ScrollStitcher { impl ScrollStitcher { pub fn new() -> Self { - Self { - canvas: None, - valid_height: 0, - last_frame: None, - last_footer_height: 0, - config: StitchConfig::default(), - } + Self::with_config(StitchConfig::default()) } pub fn with_config(config: StitchConfig) -> Self { @@ -68,8 +74,7 @@ impl ScrollStitcher { if h == 0 || canvas.width() == 0 { return None; } - let w = canvas.width(); - let mut final_img = RgbaImage::new(w, h); + let mut final_img = RgbaImage::new(canvas.width(), h); Self::copy_region(canvas, 0, &mut final_img, 0, h); Some(final_img) } @@ -87,9 +92,11 @@ impl ScrollStitcher { return None; } - let view = image::imageops::crop_imm(canvas, 0, 0, w, valid_h).to_image(); + let mut cropped = RgbaImage::new(w, valid_h); + Self::copy_region(canvas, 0, &mut cropped, 0, valid_h); + Some(image::imageops::resize( - &view, + &cropped, target_width, target_height, image::imageops::FilterType::Triangle, @@ -98,15 +105,7 @@ impl ScrollStitcher { pub fn process_frame(&mut self, new_image: RgbaImage) -> StitchResult { if self.canvas.is_none() { - let w = new_image.width(); - let h = new_image.height(); - let mut canvas = RgbaImage::new(w, h * 3); - Self::copy_region(&new_image, 0, &mut canvas, 0, h); - - self.canvas = Some(canvas); - self.valid_height = h; - self.last_frame = Some(new_image); - self.last_footer_height = 0; + self.initialize_canvas(new_image); return StitchResult::Success; } @@ -132,7 +131,7 @@ impl ScrollStitcher { let valid_prev = h_prev.saturating_sub(fixed_top + fixed_bottom); let valid_next = h_next.saturating_sub(fixed_top + fixed_bottom); - if valid_prev < 20 || valid_next < 20 { + if valid_prev < self.config.min_overlap || valid_next < self.config.min_overlap { return StitchResult::Failure; } @@ -171,13 +170,25 @@ impl ScrollStitcher { } } + fn initialize_canvas(&mut self, first_image: RgbaImage) { + let w = first_image.width(); + let h = first_image.height(); + let mut canvas = RgbaImage::new(w, h * 3); + Self::copy_region(&first_image, 0, &mut canvas, 0, h); + + self.canvas = Some(canvas); + self.valid_height = h; + self.last_frame = Some(first_image); + self.last_footer_height = 0; + } + fn compute_motion_mask(&self, prev: &RgbaImage, next: &RgbaImage) -> Vec { let w = prev.width(); let h = prev.height(); let raw_prev = prev.as_raw(); let raw_next = next.as_raw(); let stride = (w * 4) as usize; - let step = 8; + let step = self.config.motion_scan_step; (0..w) .step_by(step) @@ -186,12 +197,9 @@ impl ScrollStitcher { let mut diff_sum: u64 = 0; for y in (0..h).step_by(step) { let idx = (y as usize) * stride + (x * 4); - let d = (raw_prev[idx] as i32 - raw_next[idx] as i32).abs() - + (raw_prev[idx + 1] as i32 - raw_next[idx + 1] as i32).abs() - + (raw_prev[idx + 2] as i32 - raw_next[idx + 2] as i32).abs(); - diff_sum += d as u64; + diff_sum += Self::pixel_diff(raw_prev, idx, raw_next, idx) as u64; } - diff_sum > (h as u64 / 2) + diff_sum > (h as u64 / self.config.motion_threshold_divisor) }) .collect() } @@ -207,18 +215,18 @@ impl ScrollStitcher { let row_start = (y as usize) * stride; cols.iter().fold(0u64, |sum, &x| { let idx = row_start + (x * 4); - sum + (raw[idx] as u64) + (raw[idx + 1] as u64) + (raw[idx + 2] as u64) + sum + Self::pixel_sum(raw, idx) }) }) .collect() } - fn measure_fixed_len(&self, s1: &[u64], s2: &[u64], indices: impl Iterator) -> u32 { + fn measure_fixed_len(&self, s1: &[u64], s2: &[u64], indices: impl Iterator) -> u32 { let mut len = 0; for i in indices { let diff = s1[i].abs_diff(s2[i]); let max_val = s1[i].max(s2[i]); - if max_val > 0 && (diff * 100 / max_val) > 5 { + if max_val > 0 && (diff * 100 / max_val) > self.config.fixed_diff_percent { break; } len += 1; @@ -236,7 +244,7 @@ impl ScrollStitcher { (top, bottom) } - fn scan_overlaps(&self, range: impl Iterator, calc_offsets: F, s1: &[u64], s2: &[u64]) -> (u32, u64) + fn scan_overlaps(&self, range: impl Iterator, calc_offsets: F, s1: &[u64], s2: &[u64]) -> (u32, u64) where F: Fn(u32) -> (usize, usize), { @@ -248,7 +256,7 @@ impl ScrollStitcher { let score = Self::score_overlap(s1, s2, st1, st2, overlap as usize); let avg = score / (overlap as u64); - if avg < 500 { + if avg < self.config.overlap_avg_threshold { return (overlap, avg); } if avg < best_score { @@ -311,13 +319,13 @@ impl ScrollStitcher { [0, overlap / 2, overlap - 1].iter().all(|&r| { let y1 = y1_base + r; let y2 = y2_base + r; - let step = (cols.len() / 20).max(1); + let step = (cols.len() / self.config.verify_step_divisor).max(1); let (hits, checks) = cols.iter().step_by(step).fold((0, 0), |(h, c), &x| { let idx1 = (y1 as usize) * stride + (x * 4); let idx2 = (y2 as usize) * stride + (x * 4); - let d = (raw_prev[idx1] as i32 - raw_next[idx2] as i32).abs() + (raw_prev[idx1 + 1] as i32 - raw_next[idx2 + 1] as i32).abs() + (raw_prev[idx1 + 2] as i32 - raw_next[idx2 + 2] as i32).abs(); - (if d < 80 { h + 1 } else { h }, c + 1) + let d = Self::pixel_diff(raw_prev, idx1, raw_next, idx2); + (if d < self.config.verify_pixel_diff { h + 1 } else { h }, c + 1) }); checks == 0 || hits >= (checks / 2) @@ -330,9 +338,17 @@ impl ScrollStitcher { let raw = next.as_raw(); let start_y = f_top; - let search_start = if overlap > 20 { overlap / 4 } else { 0 }; - let search_end = if overlap > 20 { overlap * 3 / 4 } else { overlap }; - let step = (cols.len() / 20).max(1); + let search_start = if overlap > self.config.min_overlap { + overlap / self.config.seam_margin_divisor + } else { + 0 + }; + let search_end = if overlap > self.config.min_overlap { + overlap * (self.config.seam_margin_divisor - 1) / self.config.seam_margin_divisor + } else { + overlap + }; + let step = (cols.len() / self.config.verify_step_divisor).max(1); (search_start..search_end) .min_by_key(|&k| { @@ -343,8 +359,7 @@ impl ScrollStitcher { .map(|&x| { let p = idx + (x * 4); if p >= stride { - let prev_p = p - stride; - ((raw[p] as i32 - raw[prev_p] as i32).abs() + (raw[p + 1] as i32 - raw[prev_p + 1] as i32).abs() + (raw[p + 2] as i32 - raw[prev_p + 2] as i32).abs()) as u64 + Self::pixel_diff(raw, p, raw, p - stride) as u64 } else { 0 } @@ -398,4 +413,14 @@ impl ScrollStitcher { dest_raw[dest_offset..dest_offset + copy_bytes].copy_from_slice(&src_raw[src_offset..src_offset + copy_bytes]); } } + + #[inline(always)] + fn pixel_diff(raw1: &[u8], idx1: usize, raw2: &[u8], idx2: usize) -> u32 { + (raw1[idx1].abs_diff(raw2[idx2]) as u32) + (raw1[idx1 + 1].abs_diff(raw2[idx2 + 1]) as u32) + (raw1[idx1 + 2].abs_diff(raw2[idx2 + 2]) as u32) + } + + #[inline(always)] + fn pixel_sum(raw: &[u8], idx: usize) -> u64 { + (raw[idx] as u64) + (raw[idx + 1] as u64) + (raw[idx + 2] as u64) + } } From 56abc0b1761b7d3d8610016f13f67bd2f4f2f942 Mon Sep 17 00:00:00 2001 From: houvven Date: Wed, 14 Jan 2026 12:52:07 +0800 Subject: [PATCH 9/9] opt(stitcher): use constant for canvas resize headroom --- src/core/capture/stitcher.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/capture/stitcher.rs b/src/core/capture/stitcher.rs index f051b35..33b4c0b 100644 --- a/src/core/capture/stitcher.rs +++ b/src/core/capture/stitcher.rs @@ -1,5 +1,7 @@ use image::RgbaImage; +const CANVAS_RESIZE_HEADROOM: u32 = 2000; + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum StitchResult { Success, @@ -382,7 +384,7 @@ impl ScrollStitcher { let new_total_h = keep_h + new_content_h; if new_total_h > canvas.height() { - let new_cap = (canvas.height() * 2).max(new_total_h + 2000); + let new_cap = (canvas.height() * 2).max(new_total_h + CANVAS_RESIZE_HEADROOM); let width = canvas.width(); let mut new_canvas = RgbaImage::new(width, new_cap); Self::copy_region(canvas, 0, &mut new_canvas, 0, keep_h);