diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift index d57ef6b9..21dd47c0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift @@ -881,6 +881,9 @@ final class CaptureHostView: NSView { controller?.recognizeText() return case "s": + guard toolbarItem(.scroll)?.enabled == true else { + return + } controller?.startScrollCapture(source: "keyboard_s") return default: diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Export.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Export.swift index f754ad2d..e0b7ce5d 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Export.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Export.swift @@ -7,7 +7,7 @@ import RsnapHostBridge private struct FrozenSelectionImageRenderRequest: @unchecked Sendable { let captureID: UInt64 let selection: CGRect - let scrollExportImage: CGImage? + let scrollExportSnapshot: RGBARegionSnapshot? let frozenDisplayFrame: CGRect? let frozenDisplayImage: CGImage? let frozenBaseImage: CGImage? @@ -25,8 +25,8 @@ private struct FrozenSelectionImageRenderRequest: @unchecked Sendable { FrozenPreparedExportKey( captureID: captureID, selection: selection, - scrollExportWidth: scrollExportImage?.width ?? 0, - scrollExportHeight: scrollExportImage?.height ?? 0, + scrollExportWidth: scrollExportSnapshot?.width ?? 0, + scrollExportHeight: scrollExportSnapshot?.height ?? 0, frozenDisplayFrame: frozenDisplayFrame, frozenBaseWidth: frozenBaseImage?.width ?? 0, frozenBaseHeight: frozenBaseImage?.height ?? 0, @@ -45,7 +45,7 @@ private struct FrozenSelectionImageRenderRequest: @unchecked Sendable { var canPrepareExportInBackground: Bool { // Scroll capture exports change as the stitched document grows; prepare those on demand // until the scroll pipeline exposes a stable export revision. - scrollExportImage == nil + scrollExportSnapshot == nil } } @@ -273,8 +273,8 @@ extension CaptureSessionController { guard let selection = currentFrozenSelection() else { return nil } - let scrollExportImage = - scrollCaptureState == nil ? nil : try activeScrollCaptureExportImage() + let scrollExportSnapshot = + scrollCaptureState == nil ? nil : try activeScrollCaptureExportSnapshot() let settings = settingsStore.settings let selectionCenter = CGPoint(x: selection.midX, y: selection.midY) let screen = screen(containing: selectionCenter) @@ -285,7 +285,7 @@ extension CaptureSessionController { return FrozenSelectionImageRenderRequest( captureID: currentCaptureTelemetryID, selection: selection, - scrollExportImage: scrollExportImage, + scrollExportSnapshot: scrollExportSnapshot, frozenDisplayFrame: chromeState.frozenDisplayFrame, frozenDisplayImage: chromeState.frozenDisplayImage, frozenBaseImage: chromeState.frozenBaseImage, @@ -603,20 +603,14 @@ extension CaptureSessionController { ) } - func activeScrollCaptureExportImage() throws -> CGImage? { + func activeScrollCaptureExportSnapshot() throws -> RGBARegionSnapshot? { guard Self.scrollCaptureEnabled else { return nil } guard let state = scrollCaptureState else { return nil } - guard - let export = try state.stitcher.exportImage(), - let exportImage = NativeHostImageBridge.cgImage(from: export) - else { - return nil - } - return exportImage + return try state.stitcher.exportImage() } func captureFrozenSelectionImage(applyingCaptureFrameEffect: Bool = false) throws @@ -658,7 +652,7 @@ extension CaptureSessionController { prefersPixelSnapshot: Bool = false ) throws -> FrozenSelectionImageRenderResult { let captureStartedAt = ProcessInfo.processInfo.systemUptime - if let scrollExport = request.scrollExportImage { + if let scrollExport = request.scrollExportSnapshot { return renderScrollExportImage( scrollExport, request: request, @@ -676,21 +670,22 @@ extension CaptureSessionController { } nonisolated private static func renderScrollExportImage( - _ scrollExport: CGImage, + _ scrollExport: RGBARegionSnapshot, request: FrozenSelectionImageRenderRequest, captureStartedAt: TimeInterval, applyingCaptureFrameEffect: Bool, prefersPixelSnapshot: Bool ) -> FrozenSelectionImageRenderResult { + let base = FrozenRenderedImage(image: nil, rgbaSnapshot: scrollExport) let result = applyingCaptureFrameEffect ? applyCaptureFrameEffectIfNeeded( - to: scrollExport, + to: base, request: request, hasOverlayEdits: false, prefersPixelSnapshot: prefersPixelSnapshot ) - : FrozenRenderedImage(image: scrollExport, rgbaSnapshot: nil) + : resolvedRenderedImage(base, prefersPixelSnapshot: prefersPixelSnapshot) logFrozenSelectionImageTiming( request: request, captureStartedAt: captureStartedAt, diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+FrozenInteraction.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+FrozenInteraction.swift index 8bae93fd..6e93fdd6 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+FrozenInteraction.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+FrozenInteraction.swift @@ -92,6 +92,11 @@ extension CaptureSessionController { if chromeState.frozenSelectionEditable == false { return "not_dragged_region" } + if let selection = currentFrozenSelection(), + scrollCaptureSelectionHasSufficientHeight(selection) == false + { + return "selection_too_short" + } return "unavailable" } @@ -105,18 +110,24 @@ extension CaptureSessionController { return "Scroll Capture requires a dragged region selection." case "no_selection", "requires_frozen": return "Select a dragged region before starting Scroll Capture." + case "selection_too_short": + return "Select a taller region before starting Scroll Capture." default: return "Scroll Capture is not available for this selection." } } private func scrollCaptureEntryDetail(source: String, reason: String) -> String { - [ + let selection = currentFrozenSelection() + + return [ "source=\(source)", "reason=\(reason)", "scene=\(scene.mode)", "editable=\(chromeState.frozenSelectionEditable)", - "has_selection=\(currentFrozenSelection() != nil)", + "has_selection=\(selection != nil)", + "selection_height_px=\(selection.map { scrollCaptureSelectionHeightPixels($0) } ?? 0)", + "minimum_height_px=\(Self.scrollCaptureMinimumSelectionHeightPixels)", "active=\(scrollCaptureState != nil)", ].joined(separator: " ") } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift index d4c59740..0f6a2b7a 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift @@ -48,11 +48,15 @@ extension CaptureSessionController { private static let scrollCaptureQueuedWheelDeltaLimitMultiplier = 32.0 var scrollCaptureToolbarEnabled: Bool { - Self.scrollCaptureEnabled - && scene.mode == .frozen - && scrollCaptureState == nil - && chromeState.frozenSelectionEditable - && currentFrozenSelection() != nil + guard Self.scrollCaptureEnabled, + scene.mode == .frozen, + scrollCaptureState == nil, + chromeState.frozenSelectionEditable, + let selection = currentFrozenSelection() + else { + return false + } + return scrollCaptureSelectionHasSufficientHeight(selection) } func handleScrollCaptureWheel(_ event: NSEvent, at point: CGPoint) -> Bool { @@ -476,6 +480,11 @@ extension CaptureSessionController { refreshOverlay() return } + guard scrollCaptureSelectionHasSufficientHeight(selection) else { + try setHostStatusMessage("Select a taller region before starting Scroll Capture.") + refreshOverlay() + return + } guard let captureSource = overlayController?.scrollCaptureFallbackSource( @@ -1098,6 +1107,24 @@ extension CaptureSessionController { return scrollCaptureFlippedDesktopPoint(fallbackAppKitPoint) } + func scrollCaptureSelectionHasSufficientHeight(_ selection: CGRect) -> Bool { + scrollCaptureSelectionHeightPixels(selection) + >= Self.scrollCaptureMinimumSelectionHeightPixels + } + + func scrollCaptureSelectionHeightPixels(_ selection: CGRect) -> Int { + if chromeState.frozenSelectionSnapshot == selection, + let frozenBaseImage = chromeState.frozenBaseImage + { + return frozenBaseImage.height + } + let point = CGPoint(x: selection.midX, y: selection.midY) + let scale = + screen(containing: point)?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor + ?? 1 + return Int((selection.height * scale).rounded()) + } } private func scrollCaptureFlippedDesktopPoint(_ point: CGPoint) -> CGPoint { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController.swift index 15fabe50..fe8d6c4e 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController.swift @@ -31,6 +31,7 @@ final class CaptureSessionController: NSObject { static let displayFirstFrameWait: TimeInterval = 0.025 static let coldSelfCaptureRecoveryWait: TimeInterval = 3.5 static let scrollCaptureEnabled = true + static let scrollCaptureMinimumSelectionHeightPixels = 120 static let scrollCaptureForwardingPassthrough: TimeInterval = 0.012 static let scrollCaptureControlledScrollSettleDelay: TimeInterval = 0.18 static let scrollCaptureInputLiveFrameMaxAge: TimeInterval = 0.18 diff --git a/packages/rsnap-host-ffi/src/lib.rs b/packages/rsnap-host-ffi/src/lib.rs index f286705e..baba210d 100644 --- a/packages/rsnap-host-ffi/src/lib.rs +++ b/packages/rsnap-host-ffi/src/lib.rs @@ -1432,10 +1432,13 @@ pub unsafe extern "C" fn rsnap_scroll_session_observe_downward_frame( Ok(outcome) => outcome, Err(_err) => return RsnapStatus::InvalidInput, }; - let export = handle.session.export_image(); + let (export_width, export_height) = handle.session.export_dimensions(); unsafe { - ptr::write(out_result, encode_scroll_observe_result(outcome, &export, &handle.session)); + ptr::write( + out_result, + encode_scroll_observe_result(outcome, export_width, export_height, &handle.session), + ); } RsnapStatus::Ok @@ -1481,10 +1484,13 @@ pub unsafe extern "C" fn rsnap_scroll_session_observe_downward_frame_with_motion Ok(outcome) => outcome, Err(_err) => return RsnapStatus::InvalidInput, }; - let export = handle.session.export_image(); + let (export_width, export_height) = handle.session.export_dimensions(); unsafe { - ptr::write(out_result, encode_scroll_observe_result(outcome, &export, &handle.session)); + ptr::write( + out_result, + encode_scroll_observe_result(outcome, export_width, export_height, &handle.session), + ); } RsnapStatus::Ok @@ -1539,7 +1545,7 @@ pub unsafe extern "C" fn rsnap_scroll_session_undo_last_append( } let did_undo = handle.session.undo_last_append(); - let export = handle.session.export_image(); + let (export_width, export_height) = handle.session.export_dimensions(); let kind = if did_undo { ScrollStitchObserveOutcome::PreviewUpdated } else { @@ -1547,7 +1553,10 @@ pub unsafe extern "C" fn rsnap_scroll_session_undo_last_append( }; unsafe { - ptr::write(out_result, encode_scroll_observe_result(kind, &export, &handle.session)); + ptr::write( + out_result, + encode_scroll_observe_result(kind, export_width, export_height, &handle.session), + ); } RsnapStatus::Ok @@ -3840,7 +3849,8 @@ fn encode_host_request(request: HostRequest) -> RsnapHostRequestValue { fn encode_scroll_observe_result( outcome: ScrollStitchObserveOutcome, - export: &ScrollStitchImage, + export_width: u32, + export_height: u32, session: &ScrollStitchSession, ) -> RsnapScrollObserveResult { let (kind, growth_rows) = match outcome { @@ -3859,8 +3869,8 @@ fn encode_scroll_observe_result( RsnapScrollObserveResult { kind: kind as u32, growth_rows, - export_width: export.width, - export_height: export.height, + export_width, + export_height, current_viewport_top_y: session.current_viewport_top_y(), } } diff --git a/packages/rsnap-overlay/src/frozen_export.rs b/packages/rsnap-overlay/src/frozen_export.rs index 57e98690..fe7715c4 100644 --- a/packages/rsnap-overlay/src/frozen_export.rs +++ b/packages/rsnap-overlay/src/frozen_export.rs @@ -251,12 +251,11 @@ pub fn render_frozen_overlay_export_rgba( selection: DisplayPointRect, elements: &[FrozenOverlayExportElement], ) -> Result { - let mut image = rgba_image_from_bytes(width, height, rgba)?; - let original = image.clone(); let transform = ExportTransform::new(selection, width, height)?; + let mut image = rgba_image_from_bytes(width, height, rgba)?; apply_mosaics(&mut image, transform, elements); - apply_spotlights(&mut image, &original, transform, elements); + apply_spotlights(&mut image, transform, elements); render_pen_annotations(&mut image, transform, elements); render_arrow_annotations(&mut image, transform, elements); render_text_annotations(&mut image, transform, elements); @@ -322,7 +321,6 @@ fn apply_mosaic(image: &mut RgbaImage, transform: ExportTransform, rect: Display fn apply_spotlights( image: &mut RgbaImage, - original: &RgbaImage, transform: ExportTransform, elements: &[FrozenOverlayExportElement], ) { @@ -338,10 +336,12 @@ fn apply_spotlights( return; } + let original = image.clone(); + dim_image_for_spotlight(image); for spotlight in &spotlights { - restore_spotlight_rect(image, original, transform, spotlight.rect); + restore_spotlight_rect(image, &original, transform, spotlight.rect); } for spotlight in spotlights { render_spotlight_border(image, transform, spotlight.rect, spotlight.style); @@ -366,16 +366,18 @@ fn restore_spotlight_rect( let Some(destination) = transform.integral_image_rect(rect) else { return; }; - let source = imageops::crop_imm( - original, - destination.x, - destination.y, - destination.width, - destination.height, - ) - .to_image(); + let row_stride = image.width() as usize * 4; + let left_byte = destination.x as usize * 4; + let copy_len = destination.width as usize * 4; + let original_bytes = original.as_raw(); + let image_bytes = image.as_mut(); - imageops::replace(image, &source, i64::from(destination.x), i64::from(destination.y)); + for row in destination.y..destination.y + destination.height { + let start = row as usize * row_stride + left_byte; + let end = start + copy_len; + + image_bytes[start..end].copy_from_slice(&original_bytes[start..end]); + } } fn render_spotlight_border( @@ -557,17 +559,30 @@ fn draw_segments( } let radius = (line_width * 0.5).max(0.5); - let mut coverage_mask = vec![0_u8; image.width() as usize * image.height() as usize]; + let Some(mask_rect) = segments_pixel_bounds(segments, image.width(), image.height(), radius) + else { + return; + }; + let mut coverage_mask = vec![0_u8; mask_rect.width as usize * mask_rect.height as usize]; for (start, end) in segments { - rasterize_segment(&mut coverage_mask, image.width(), image.height(), *start, *end, radius); + rasterize_segment( + &mut coverage_mask, + mask_rect, + image.width(), + image.height(), + *start, + *end, + radius, + ); } - blend_coverage_mask(image, &coverage_mask, color); + blend_coverage_mask(image, mask_rect, &coverage_mask, color); } fn rasterize_segment( coverage_mask: &mut [u8], + mask_rect: PixelRect, width: u32, height: u32, start: Pos2, @@ -578,27 +593,26 @@ fn rasterize_segment( let delta_len_sq = delta.length_sq(); if delta_len_sq <= f32::EPSILON { - rasterize_circle(coverage_mask, width, height, start, radius); + rasterize_circle(coverage_mask, mask_rect, width, height, start, radius); return; } - let min_x = ((start.x.min(end.x) - radius - 0.5).floor().max(0.0)) as u32; - let min_y = ((start.y.min(end.y) - radius - 0.5).floor().max(0.0)) as u32; - let max_x = - ((start.x.max(end.x) + radius + 0.5).ceil().min(width.saturating_sub(1) as f32)) as u32; - let max_y = - ((start.y.max(end.y) + radius + 0.5).ceil().min(height.saturating_sub(1) as f32)) as u32; + let Some(bounds) = segment_pixel_bounds(start, end, width, height, radius) + .and_then(|bounds| intersect_pixel_rect(bounds, mask_rect)) + else { + return; + }; - for y in min_y..=max_y { - for x in min_x..=max_x { + for y in bounds.y..bounds.y + bounds.height { + for x in bounds.x..bounds.x + bounds.width { let sample = Pos2::new(x as f32 + 0.5, y as f32 + 0.5); let projection = ((sample - start).dot(delta) / delta_len_sq).clamp(0.0, 1.0); let nearest = start + delta * projection; update_coverage_mask( coverage_mask, - width, + mask_rect, x, y, stroke_coverage(sample.distance(nearest), radius), @@ -607,23 +621,27 @@ fn rasterize_segment( } } -fn rasterize_circle(coverage_mask: &mut [u8], width: u32, height: u32, center: Pos2, radius: f32) { - let min_x = ((center.x - radius - 0.5).floor().max(0.0)) as u32; - let min_y = ((center.y - radius - 0.5).floor().max(0.0)) as u32; - let max_x = ((center.x + radius + 0.5).ceil().min(width.saturating_sub(1) as f32)) as u32; - let max_y = ((center.y + radius + 0.5).ceil().min(height.saturating_sub(1) as f32)) as u32; - - if min_x > max_x || min_y > max_y { +fn rasterize_circle( + coverage_mask: &mut [u8], + mask_rect: PixelRect, + width: u32, + height: u32, + center: Pos2, + radius: f32, +) { + let Some(bounds) = circle_pixel_bounds(center, width, height, radius) + .and_then(|bounds| intersect_pixel_rect(bounds, mask_rect)) + else { return; - } + }; - for y in min_y..=max_y { - for x in min_x..=max_x { + for y in bounds.y..bounds.y + bounds.height { + for x in bounds.x..bounds.x + bounds.width { let sample = Pos2::new(x as f32 + 0.5, y as f32 + 0.5); update_coverage_mask( coverage_mask, - width, + mask_rect, x, y, stroke_coverage(sample.distance(center), radius), @@ -632,35 +650,159 @@ fn rasterize_circle(coverage_mask: &mut [u8], width: u32, height: u32, center: P } } +fn segments_pixel_bounds( + segments: &[(Pos2, Pos2)], + width: u32, + height: u32, + radius: f32, +) -> Option { + let mut bounds = None; + + for (start, end) in segments { + let Some(segment_bounds) = segment_pixel_bounds(*start, *end, width, height, radius) else { + continue; + }; + + bounds = Some(match bounds { + Some(bounds) => union_pixel_rect(bounds, segment_bounds), + None => segment_bounds, + }); + } + + bounds +} + +fn segment_pixel_bounds( + start: Pos2, + end: Pos2, + width: u32, + height: u32, + radius: f32, +) -> Option { + pixel_bounds( + start.x.min(end.x) - radius - 0.5, + start.y.min(end.y) - radius - 0.5, + start.x.max(end.x) + radius + 0.5, + start.y.max(end.y) + radius + 0.5, + width, + height, + ) +} + +fn circle_pixel_bounds(center: Pos2, width: u32, height: u32, radius: f32) -> Option { + pixel_bounds( + center.x - radius - 0.5, + center.y - radius - 0.5, + center.x + radius + 0.5, + center.y + radius + 0.5, + width, + height, + ) +} + +fn pixel_bounds( + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, + width: u32, + height: u32, +) -> Option { + if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + return None; + } + + let left = min_x.floor().max(0.0) as u32; + let top = min_y.floor().max(0.0) as u32; + let right = (max_x.ceil() + 1.0).clamp(0.0, width as f32) as u32; + let bottom = (max_y.ceil() + 1.0).clamp(0.0, height as f32) as u32; + + if left >= right || top >= bottom { + return None; + } + + Some(PixelRect { x: left, y: top, width: right - left, height: bottom - top }) +} + +fn intersect_pixel_rect(first: PixelRect, second: PixelRect) -> Option { + let left = first.x.max(second.x); + let top = first.y.max(second.y); + let right = (first.x + first.width).min(second.x + second.width); + let bottom = (first.y + first.height).min(second.y + second.height); + + if left >= right || top >= bottom { + return None; + } + + Some(PixelRect { x: left, y: top, width: right - left, height: bottom - top }) +} + +fn union_pixel_rect(first: PixelRect, second: PixelRect) -> PixelRect { + let left = first.x.min(second.x); + let top = first.y.min(second.y); + let right = (first.x + first.width).max(second.x + second.width); + let bottom = (first.y + first.height).max(second.y + second.height); + + PixelRect { x: left, y: top, width: right - left, height: bottom - top } +} + fn stroke_coverage(distance: f32, radius: f32) -> u8 { ((radius + 0.5 - distance).clamp(0.0, 1.0) * 255.0).round() as u8 } -fn update_coverage_mask(coverage_mask: &mut [u8], width: u32, x: u32, y: u32, coverage: u8) { +fn update_coverage_mask( + coverage_mask: &mut [u8], + mask_rect: PixelRect, + x: u32, + y: u32, + coverage: u8, +) { if coverage == 0 { return; } - let index = y as usize * width as usize + x as usize; + let index = (y - mask_rect.y) as usize * mask_rect.width as usize + (x - mask_rect.x) as usize; coverage_mask[index] = coverage_mask[index].max(coverage); } -fn blend_coverage_mask(image: &mut RgbaImage, coverage_mask: &[u8], color: Rgba) { +fn blend_coverage_mask( + image: &mut RgbaImage, + mask_rect: PixelRect, + coverage_mask: &[u8], + color: Rgba, +) { let source_alpha = f32::from(color[3]) / 255.0; - - for (index, pixel) in image.pixels_mut().enumerate() { - let mask_alpha = coverage_mask[index]; - - if mask_alpha == 0 { - continue; + let image_width = image.width() as usize; + let image_bytes = image.as_mut(); + let mask_width = mask_rect.width as usize; + let left = mask_rect.x as usize; + let top = mask_rect.y as usize; + + for local_y in 0..mask_rect.height as usize { + let mask_row_start = local_y * mask_width; + let image_row_start = ((top + local_y) * image_width + left) * 4; + + for local_x in 0..mask_width { + let mask_alpha = coverage_mask[mask_row_start + local_x]; + + if mask_alpha == 0 { + continue; + } + + let pixel_start = image_row_start + local_x * 4; + let pixel_end = pixel_start + 4; + + blend_pixel_channels( + &mut image_bytes[pixel_start..pixel_end], + color, + f32::from(mask_alpha) / 255.0 * source_alpha, + ); } - - blend_pixel(pixel, color, f32::from(mask_alpha) / 255.0 * source_alpha); } } -fn blend_pixel(pixel: &mut Rgba, color: Rgba, src_a: f32) { +fn blend_pixel_channels(pixel: &mut [u8], color: Rgba, src_a: f32) { let dst_a = f32::from(pixel[3]) / 255.0; let out_a = src_a + dst_a * (1.0 - src_a); @@ -835,6 +977,33 @@ mod tests { assert_eq!(top_rendered.get_pixel(10, 19), image.get_pixel(10, 19)); } + #[test] + fn export_compositor_skips_offscreen_segments_without_dropping_visible_stroke() { + let image = RgbaImage::from_pixel(20, 20, Rgba([24, 24, 24, 255])); + let elements = vec![FrozenOverlayExportElement::Pen(FrozenOverlayExportPen { + points: vec![ + FrozenOverlayExportPoint::new(-20.0, 10.0), + FrozenOverlayExportPoint::new(-10.0, 10.0), + FrozenOverlayExportPoint::new(10.0, 10.0), + FrozenOverlayExportPoint::new(12.0, 10.0), + ], + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: 2.0, + rgba: [255, 107, 107, 255], + }, + })]; + let rendered = frozen_export::render_frozen_overlay_export_rgba( + image.width(), + image.height(), + image.as_raw(), + DisplayPointRect::new(0.0, 0.0, 20.0, 20.0), + &elements, + ) + .expect("valid export"); + + assert_ne!(rendered.get_pixel(10, 10), image.get_pixel(10, 10)); + } + #[test] fn export_compositor_renders_arrow_and_text() { let image = RgbaImage::from_pixel(64, 40, Rgba([24, 24, 24, 255])); diff --git a/packages/rsnap-overlay/src/lib.rs b/packages/rsnap-overlay/src/lib.rs index 63047217..8a6bcf92 100644 --- a/packages/rsnap-overlay/src/lib.rs +++ b/packages/rsnap-overlay/src/lib.rs @@ -120,6 +120,14 @@ pub mod scroll_stitching { } } + /// Returns the committed stitched export dimensions without cloning pixels. + #[must_use] + pub fn export_dimensions(&self) -> (u32, u32) { + let image = self.inner.export_image(); + + (image.width(), image.height()) + } + /// Returns the current committed viewport top offset in pixels. #[must_use] pub fn current_viewport_top_y(&self) -> i32 {