diff --git a/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift b/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift index f2b5b36c..011a8f51 100644 --- a/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift +++ b/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift @@ -2470,6 +2470,91 @@ public final class RsnapLiveSampler: @unchecked Sendable { ) } + public func nextRegionFrame( + monitor: MonitorSnapshot, + pixelRect: CGRect, + afterFrameSequence: UInt64, + waitForFresh: Bool + ) throws -> RGBARegionFrameSnapshot? { + stateLock.lock() + defer { stateLock.unlock() } + + let encodedPixelRect = try Self.encode( + pixelRect: pixelRect, + context: "encoding live RGBA pixel region") + let encodedMonitor = RsnapMonitorRect( + id: monitor.id, + origin: RsnapPoint( + x: Int32(monitor.frame.origin.x.rounded()), + y: Int32(monitor.frame.origin.y.rounded()) + ), + width: UInt32(max(monitor.frame.width.rounded(), 0)), + height: UInt32(max(monitor.frame.height.rounded(), 0)), + scale_factor_x1000: monitor.scaleFactorX1000 + ) + var frameSequence: UInt64 = 0 + var frameAgeMicroseconds: UInt64 = 0 + var ownedRegion = RsnapOwnedRgbaRegion() + let status = rsnap_live_sampler_take_next_region_rgba_pixels_after_seq( + handle, + encodedMonitor, + encodedPixelRect, + afterFrameSequence, + UInt8(waitForFresh ? 1 : 0), + &frameSequence, + &frameAgeMicroseconds, + &ownedRegion + ) + let code = rsnap_status_code(status) + if code == 3 { + return nil + } + if code != 0 { + throw HostBridgeError.ffiStatus( + context: "taking next live RGBA pixel region frame", + code: code + ) + } + guard let region = rsnapOwnedRgbaSnapshot(from: ownedRegion) else { + return nil + } + return RGBARegionFrameSnapshot( + frameSequence: frameSequence, + frameAgeMicroseconds: frameAgeMicroseconds, + region: region + ) + } + + private static func encode(pixelRect: CGRect, context: String) throws -> RsnapPixelRect { + let x = pixelRect.origin.x.rounded() + let y = pixelRect.origin.y.rounded() + let width = pixelRect.width.rounded() + let height = pixelRect.height.rounded() + let maxValue = CGFloat(UInt32.max) + + guard + x >= 0, + y >= 0, + width > 0, + height > 0, + x <= maxValue, + y <= maxValue, + width <= maxValue, + height <= maxValue + else { + throw HostBridgeError.ffiStatus( + context: context, + code: RSNAP_STATUS_INVALID_INPUT.rawValue) + } + + return RsnapPixelRect( + x: UInt32(x), + y: UInt32(y), + width: UInt32(width), + height: UInt32(height) + ) + } + /// Returns the live sampler's cache-only full-monitor snapshot. /// /// This API does not expose the original frame capture time or stream sequence. Do not use it diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift index d519d40b..2f1665c5 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift @@ -393,6 +393,20 @@ final class CaptureOverlayController { ) } + func nextRegionFrame( + in rect: CGRect, + pixelRect: CGRect, + afterFrameSequence: UInt64, + waitForFresh: Bool + ) -> RGBARegionFrameSnapshot? { + liveFrameStream.nextRegionFrame( + in: rect, + pixelRect: pixelRect, + afterFrameSequence: afterFrameSequence, + waitForFresh: waitForFresh + ) + } + func updateLivePreviewDemand( point: CGPoint?, settings: NativeHostSettings, diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift index 0f6a2b7a..4d61bef6 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift @@ -39,6 +39,13 @@ private func scrollCaptureViewportPointCandidates( private let nativeScrollCaptureMinimumNonzeroWheelMotionHintRows = 12.0 +private struct NativeScrollCaptureGeometry { + let baseImage: CGImage + let baseSnapshot: RGBARegionSnapshot + let pixelRect: CGRect + let samplingRect: CGRect +} + extension CaptureSessionController { private static let scrollCaptureForwardedEventMarker: Int64 = 0x5253_4E41_5053_4352 private static let scrollCapturePreciseWheelDeltaLimit = 72.0 @@ -496,18 +503,14 @@ extension CaptureSessionController { return } - ensureFrozenBaseImageFromDisplayIfNeeded(for: selection) - let frozenBaseImage = - chromeState.frozenBaseImage ?? frozenBaseImageFromDisplay(for: selection) - guard let frozenBaseImage, - let frozenBaseSnapshot = NativeHostImageBridge.rgbaSnapshot(from: frozenBaseImage) + guard let geometry = nativeScrollCaptureGeometry(for: selection) else { try setHostStatusMessage("Scroll capture could not read the selected region.") refreshOverlay() return } - let baseImage = frozenBaseImage - let baseSnapshot = frozenBaseSnapshot + let baseImage = geometry.baseImage + let baseSnapshot = geometry.baseSnapshot let baseSource = "frozen_display_region" debugDumpNativeScrollCaptureSnapshot(baseSnapshot, name: "base-\(baseSource)") let stitcher = try RsnapScrollCaptureSession( @@ -517,8 +520,11 @@ extension CaptureSessionController { var initialState = NativeScrollCaptureState( stitcher: stitcher, viewportRect: selection, + viewportPixelRect: geometry.pixelRect, + viewportSamplingRect: geometry.samplingRect, captureSource: captureSource, - viewportPixelsPerPointY: Double(baseSnapshot.height) / max(Double(selection.height), 1) + viewportPixelsPerPointY: Double(geometry.pixelRect.height) + / max(Double(selection.height), 1) ) initialState.sampleUntilUptime = ProcessInfo.processInfo.systemUptime + Self.scrollCaptureInitialSampleWindow @@ -535,7 +541,7 @@ extension CaptureSessionController { "capture.scroll_capture_started", captureID: currentCaptureTelemetryID, detail: - "width=\(baseSnapshot.width),height=\(baseSnapshot.height),x=\(Int(selection.minX.rounded())),y=\(Int(selection.minY.rounded())),mode=manual_universal,baseSource=\(baseSource),liveBaseMatched=false" + "width=\(baseSnapshot.width),height=\(baseSnapshot.height),x=\(Int(selection.minX.rounded())),y=\(Int(selection.minY.rounded())),pixelX=\(Int(geometry.pixelRect.minX.rounded())),pixelY=\(Int(geometry.pixelRect.minY.rounded())),pixelWidth=\(Int(geometry.pixelRect.width.rounded())),pixelHeight=\(Int(geometry.pixelRect.height.rounded())),samplingX=\(Int(geometry.samplingRect.minX.rounded())),samplingY=\(Int(geometry.samplingRect.minY.rounded())),samplingWidth=\(Int(geometry.samplingRect.width.rounded())),samplingHeight=\(Int(geometry.samplingRect.height.rounded())),mode=manual_universal,baseSource=\(baseSource),liveBaseMatched=false" ) NativeHostTelemetry.captureEvent( "capture.scroll_capture_mode", @@ -561,6 +567,57 @@ extension CaptureSessionController { scheduleNativeScrollCaptureToolbarBackdropRefresh() } + private func nativeScrollCaptureGeometry( + for selection: CGRect + ) -> NativeScrollCaptureGeometry? { + guard + let displayFrame = chromeState.frozenDisplayFrame, + let displayImage = chromeState.frozenDisplayImage, + let pixelRect = try? RsnapExportEncoder.frozenDisplayCropRect( + imageWidth: displayImage.width, + imageHeight: displayImage.height, + displayFrame: displayFrame, + selection: selection + ), + let baseImage = displayImage.cropping(to: pixelRect), + let baseSnapshot = NativeHostImageBridge.rgbaSnapshot(from: baseImage) + else { + return nil + } + let samplingRect = Self.nativeScrollCaptureSamplingRect( + pixelRect: pixelRect, + displayFrame: displayFrame, + displayImageSize: CGSize( + width: CGFloat(displayImage.width), + height: CGFloat(displayImage.height) + ) + ) + + return NativeScrollCaptureGeometry( + baseImage: baseImage, + baseSnapshot: baseSnapshot, + pixelRect: pixelRect, + samplingRect: samplingRect + ) + } + + private static func nativeScrollCaptureSamplingRect( + pixelRect: CGRect, + displayFrame: CGRect, + displayImageSize: CGSize + ) -> CGRect { + let pointsPerPixelX = displayFrame.width / max(displayImageSize.width, 1) + let pointsPerPixelY = displayFrame.height / max(displayImageSize.height, 1) + let height = pixelRect.height * pointsPerPixelY + let maxY = displayFrame.maxY - pixelRect.minY * pointsPerPixelY + return CGRect( + x: displayFrame.minX + pixelRect.minX * pointsPerPixelX, + y: maxY - height, + width: pixelRect.width * pointsPerPixelX, + height: height + ) + } + private func configureNativeScrollCaptureChrome( baseImage: CGImage, baseSnapshot: RGBARegionSnapshot, @@ -625,6 +682,7 @@ extension CaptureSessionController { let sampledFrames = nativeScrollCaptureSampleFrames( in: state.viewportRect, + pixelRect: state.viewportPixelRect, afterFrameSequence: state.lastStreamFrameSequence, maximumFrameAgeMicroseconds: nativeScrollCaptureMaximumStreamFrameAge(state: state) ) @@ -633,7 +691,8 @@ extension CaptureSessionController { && nativeScrollCaptureFallbackReadyForInput(state: state) && nativeScrollCaptureFallbackAllowed(at: sampleUptime) ? NativeScrollCaptureFallbackRequest( - rect: state.viewportRect, + rect: state.viewportSamplingRect, + pixelRect: state.viewportPixelRect, source: state.captureSource, frameSequence: state.lastStreamFrameSequence &+ 1 ) : nil @@ -687,7 +746,11 @@ extension CaptureSessionController { in: fallbackRequest.rect, source: fallbackRequest.source ), - let snapshot = NativeHostImageBridge.rgbaSnapshot(from: image) + let snapshot = NativeHostImageBridge.rgbaSnapshot(from: image), + Self.nativeScrollCaptureFrameMatchesPixelRect( + snapshot, + pixelRect: fallbackRequest.pixelRect + ) { writeNativeScrollCaptureDebugDump( snapshot, @@ -884,6 +947,7 @@ extension CaptureSessionController { private func nativeScrollCaptureSampleFrames( in rect: CGRect, + pixelRect: CGRect, afterFrameSequence: UInt64, maximumFrameAgeMicroseconds: UInt64? ) -> [NativeScrollCaptureSampleFrame] { @@ -894,6 +958,7 @@ extension CaptureSessionController { guard let frame = overlayController?.nextRegionFrame( in: rect, + pixelRect: pixelRect, afterFrameSequence: nextAfterFrameSequence, waitForFresh: false ) @@ -906,6 +971,18 @@ extension CaptureSessionController { nextAfterFrameSequence = frame.frameSequence continue } + guard Self.nativeScrollCaptureFrameMatchesPixelRect(frame.region, pixelRect: pixelRect) + else { + NativeHostTelemetry.captureWarning( + "capture.scroll_observe_failed", + captureID: currentCaptureTelemetryID, + stage: "sample_frame", + error: + "live stream returned \(frame.region.width)x\(frame.region.height), expected \(Int(pixelRect.width.rounded()))x\(Int(pixelRect.height.rounded()))" + ) + nextAfterFrameSequence = frame.frameSequence + continue + } frames.append( NativeScrollCaptureSampleFrame( region: frame.region, @@ -918,6 +995,14 @@ extension CaptureSessionController { return frames } + nonisolated private static func nativeScrollCaptureFrameMatchesPixelRect( + _ snapshot: RGBARegionSnapshot, + pixelRect: CGRect + ) -> Bool { + snapshot.width == Int(pixelRect.width.rounded()) + && snapshot.height == Int(pixelRect.height.rounded()) + } + private func nativeScrollCaptureMaximumStreamFrameAge( state: NativeScrollCaptureState ) -> UInt64? { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/FrozenCaptureModels.swift b/native/macos-host/Sources/RsnapNativeHostKit/FrozenCaptureModels.swift index fd193388..745e8ac3 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/FrozenCaptureModels.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/FrozenCaptureModels.swift @@ -549,6 +549,8 @@ struct CaptureChromeState { struct NativeScrollCaptureState { let stitcher: RsnapScrollCaptureSession let viewportRect: CGRect + let viewportPixelRect: CGRect + let viewportSamplingRect: CGRect let captureSource: CaptureSessionController.FrozenCaptureJobSource let viewportPixelsPerPointY: Double var sampleLoopScheduled = false diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift index a4a6f1ce..4942b434 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift @@ -273,6 +273,30 @@ final class LiveFrameStreamBroker { ) } + func nextRegionFrame( + in rect: CGRect, + pixelRect: CGRect, + afterFrameSequence: UInt64, + waitForFresh: Bool + ) -> RGBARegionFrameSnapshot? { + guard let monitor = monitor(containing: CGPoint(x: rect.midX, y: rect.midY)) else { + return nil + } + stateLock.lock() + let sampler = self.sampler + let encodedMonitor = samplerMonitorSnapshot(for: monitor) + stateLock.unlock() + guard let sampler else { + return nil + } + return try? sampler.nextRegionFrame( + monitor: encodedMonitor, + pixelRect: pixelRect, + afterFrameSequence: afterFrameSequence, + waitForFresh: waitForFresh + ) + } + func prime(at point: CGPoint?) { guard let point, let monitor = monitor(containing: point) else { return diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeScrollCaptureObservationPipeline.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeScrollCaptureObservationPipeline.swift index ca0c3c10..cfbdb8a0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeScrollCaptureObservationPipeline.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeScrollCaptureObservationPipeline.swift @@ -12,6 +12,7 @@ struct NativeScrollCaptureSampleFrame: Sendable { struct NativeScrollCaptureFallbackRequest: Sendable { let rect: CGRect + let pixelRect: CGRect let source: CaptureSessionController.FrozenCaptureJobSource let frameSequence: UInt64 } diff --git a/packages/rsnap-host-ffi/include/rsnap_host_ffi.h b/packages/rsnap-host-ffi/include/rsnap_host_ffi.h index fdcb3ec1..924370ce 100644 --- a/packages/rsnap-host-ffi/include/rsnap_host_ffi.h +++ b/packages/rsnap-host-ffi/include/rsnap_host_ffi.h @@ -8,7 +8,7 @@ extern "C" { #endif -#define RSNAP_HOST_FFI_ABI_VERSION 34u +#define RSNAP_HOST_FFI_ABI_VERSION 35u #define RSNAP_TOOLBAR_ITEM_CAPACITY 16u #define RSNAP_STATUS_MESSAGE_CAPACITY 256u #define RSNAP_LIVE_SAMPLE_PATCH_CAPACITY 4096u @@ -724,6 +724,16 @@ enum RsnapStatus rsnap_live_sampler_take_next_region_rgba_after_seq( uint64_t *out_frame_age_micros, struct RsnapOwnedRgbaRegion *out_region ); +enum RsnapStatus rsnap_live_sampler_take_next_region_rgba_pixels_after_seq( + RsnapLiveSamplerHandle *handle, + struct RsnapMonitorRect monitor, + struct RsnapPixelRect rect, + uint64_t after_frame_seq, + uint8_t wait_for_fresh, + uint64_t *out_frame_seq, + uint64_t *out_frame_age_micros, + struct RsnapOwnedRgbaRegion *out_region +); enum RsnapStatus rsnap_live_sampler_peek_latest_monitor_rgba( RsnapLiveSamplerHandle *handle, struct RsnapMonitorRect monitor, diff --git a/packages/rsnap-host-ffi/src/lib.rs b/packages/rsnap-host-ffi/src/lib.rs index baba210d..04802c21 100644 --- a/packages/rsnap-host-ffi/src/lib.rs +++ b/packages/rsnap-host-ffi/src/lib.rs @@ -44,7 +44,7 @@ use rsnap_overlay::scroll_stitching::{ }; /// ABI version exported by the thin C host bridge. -pub const RSNAP_HOST_FFI_ABI_VERSION: u32 = 34; +pub const RSNAP_HOST_FFI_ABI_VERSION: u32 = 35; const RSNAP_TOOLBAR_ITEM_CAPACITY: usize = 16; const RSNAP_STATUS_MESSAGE_CAPACITY: usize = 256; @@ -2742,6 +2742,69 @@ pub unsafe extern "C" fn rsnap_live_sampler_take_next_region_rgba_after_seq( RsnapStatus::Ok } +/// Transfers ownership of the oldest queued RGBA region newer than `after_frame_seq` +/// using a monitor-local pixel rectangle, preserving live-stream frame order. +/// +/// # Safety +/// +/// `handle` must be a valid pointer returned by `rsnap_live_sampler_create`, +/// `out_frame_seq` and `out_frame_age_micros` must be valid writable pointers, and +/// `out_region` must be a valid writable pointer. The caller must later release the +/// returned region buffer with `rsnap_owned_rgba_region_release`. +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_live_sampler_take_next_region_rgba_pixels_after_seq( + handle: *mut RsnapLiveSamplerHandle, + monitor: RsnapMonitorRect, + rect: RsnapPixelRect, + after_frame_seq: u64, + wait_for_fresh: u8, + out_frame_seq: *mut u64, + out_frame_age_micros: *mut u64, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + let Some(handle) = (unsafe { live_sampler_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + if out_frame_seq.is_null() || out_frame_age_micros.is_null() || out_region.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(frame) = handle.sampler.next_region_rgba_after_seq_pixels( + decode_overlay_monitor(monitor), + decode_pixel_rect(rect), + after_frame_seq, + wait_for_fresh != 0, + ) else { + unsafe { + ptr::write(out_frame_seq, after_frame_seq); + ptr::write(out_frame_age_micros, 0); + ptr::write(out_region, RsnapOwnedRgbaRegion::default()); + } + + return RsnapStatus::Empty; + }; + let mut rgba = frame.region.rgba; + let out = RsnapOwnedRgbaRegion { + width: frame.region.width, + height: frame.region.height, + len: rgba.len(), + capacity: rgba.capacity(), + rgba: rgba.as_mut_ptr(), + }; + + mem::forget(rgba); + + unsafe { + ptr::write(out_frame_seq, frame.frame_seq); + ptr::write(out_frame_age_micros, frame.frame_age_micros); + ptr::write(out_region, out); + } + + RsnapStatus::Ok +} + /// Peeks the latest cached full-monitor RGBA snapshot from the live sampler without waiting /// for a new capture. /// diff --git a/packages/rsnap-overlay/src/host_live_sampling_macos.rs b/packages/rsnap-overlay/src/host_live_sampling_macos.rs index 5e679f33..f032d6b7 100644 --- a/packages/rsnap-overlay/src/host_live_sampling_macos.rs +++ b/packages/rsnap-overlay/src/host_live_sampling_macos.rs @@ -168,6 +168,43 @@ impl HostMacLiveSampler { }) } + #[must_use] + /// Returns the oldest queued RGBA region after `after_frame_seq` using an + /// already-authoritative monitor-local pixel rectangle. + pub fn next_region_rgba_after_seq_pixels( + &mut self, + monitor: MonitorRect, + rect_px: RectPoints, + after_frame_seq: u64, + wait_for_fresh: bool, + ) -> Option { + if rect_px.is_empty() { + return None; + } + + let frames = if wait_for_fresh { + self.stream.ordered_rgba_regions_after_seq(monitor, rect_px, after_frame_seq) + } else { + self.stream.ordered_rgba_regions_after_seq_nonblocking( + monitor, + rect_px, + after_frame_seq, + ) + }?; + let frame = frames.into_iter().next()?; + + Some(HostRgbaRegionFrame { + frame_seq: frame.frame_seq, + frame_age_micros: frame.captured_at.elapsed().as_micros().min(u128::from(u64::MAX)) + as u64, + region: HostRgbaRegion { + width: frame.image.width(), + height: frame.image.height(), + rgba: frame.image.into_raw(), + }, + }) + } + #[must_use] /// Returns the latest cached full-monitor RGBA snapshot when one is already warm. ///