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
59 changes: 59 additions & 0 deletions native/macos-host/Sources/RsnapHostBridge/HostFFI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,30 @@ public enum RsnapExportEncoder {
return try data(from: outPNG, context: "taking encoded export PNG")
}

public static func pngData(
from image: RGBARegionSnapshot,
screenScaleFactor: CGFloat
) throws -> Data {
let scaleFactorX1000 = encode(screenScaleFactor: screenScaleFactor)
var outPNG = RsnapOwnedBytes()
let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in
guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else {
return RSNAP_STATUS_INVALID_INPUT
}
return rsnap_export_rgba_to_png_with_screen_scale(
UInt32(max(image.width, 0)),
UInt32(max(image.height, 0)),
baseAddress,
image.rgba.count,
scaleFactorX1000,
&outPNG
)
}
try rsnapRequireOk(status, context: "encoding export PNG with screen scale")

return try data(from: outPNG, context: "taking encoded export PNG with screen scale")
}

public static func pngData(from image: RGBARegionSnapshot, crop: CGRect) throws -> Data {
let cropRect = try encode(crop: crop)
var outPNG = RsnapOwnedBytes()
Expand All @@ -1334,6 +1358,34 @@ public enum RsnapExportEncoder {
return try data(from: outPNG, context: "taking encoded cropped export PNG")
}

public static func pngData(
from image: RGBARegionSnapshot,
crop: CGRect,
screenScaleFactor: CGFloat
) throws -> Data {
let cropRect = try encode(crop: crop)
let scaleFactorX1000 = encode(screenScaleFactor: screenScaleFactor)
var outPNG = RsnapOwnedBytes()
let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in
guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else {
return RSNAP_STATUS_INVALID_INPUT
}
return rsnap_export_rgba_crop_to_png_with_screen_scale(
UInt32(max(image.width, 0)),
UInt32(max(image.height, 0)),
baseAddress,
image.rgba.count,
cropRect,
scaleFactorX1000,
&outPNG
)
}
try rsnapRequireOk(status, context: "encoding cropped export PNG with screen scale")

return try data(
from: outPNG, context: "taking encoded cropped export PNG with screen scale")
}

public static func frozenDisplayCropRect(
imageWidth: Int,
imageHeight: Int,
Expand Down Expand Up @@ -1442,6 +1494,13 @@ public enum RsnapExportEncoder {
)
}

private static func encode(screenScaleFactor: CGFloat) -> UInt32 {
let scale = screenScaleFactor.isFinite ? max(screenScaleFactor, 1) : 1
let scaled = min((scale * 1_000).rounded(), CGFloat(UInt32.max))

return UInt32(scaled)
}

private static func data(from outPNG: RsnapOwnedBytes, context: String) throws -> Data {
guard outPNG.len > 0, let bytes = outPNG.bytes else {
throw HostBridgeError.ffiStatus(context: context, code: RSNAP_STATUS_EMPTY.rawValue)
Expand Down
64 changes: 64 additions & 0 deletions native/macos-host/Sources/RsnapHostBridgeProbe/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,24 @@ enum RsnapHostBridgeProbe {
guard let fullPNGDimensions = pngDimensions(png), fullPNGDimensions == (16, 120) else {
fatalError("unexpected PNG export dimensions")
}
guard pngPixelDensity(png) == nil else {
fatalError("unexpected PNG density metadata on default export")
}
let scaledPNG = try RsnapExportEncoder.pngData(
from: scrollExport,
screenScaleFactor: 2
)
let scaledPNGDensity = pngPixelDensity(scaledPNG)
guard
let scaledPNGDimensions = pngDimensions(scaledPNG),
scaledPNGDimensions == (16, 120),
let scaledPNGDensity,
scaledPNGDensity.0 == 5_669,
scaledPNGDensity.1 == 5_669,
scaledPNGDensity.2 == 1
else {
fatalError("unexpected scaled PNG export metadata")
}
let croppedPNG = try RsnapExportEncoder.pngData(
from: scrollExport,
crop: CGRect(x: 1, y: 2, width: 4, height: 8)
Expand Down Expand Up @@ -749,6 +767,52 @@ enum RsnapHostBridgeProbe {
return (width, height)
}

private static func pngPixelDensity(_ data: Data) -> (Int, Int, UInt8)? {
let bytes = [UInt8](data)
guard
bytes.count >= 24,
bytes[0..<8].elementsEqual([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
else {
return nil
}

var offset = 8
while offset + 12 <= bytes.count {
let length =
Int(UInt32(bytes[offset]) << 24)
| Int(UInt32(bytes[offset + 1]) << 16)
| Int(UInt32(bytes[offset + 2]) << 8)
| Int(UInt32(bytes[offset + 3]))
let typeStart = offset + 4
let dataStart = offset + 8
let dataEnd = dataStart + length
let nextOffset = dataEnd + 4
guard nextOffset <= bytes.count else {
return nil
}
if bytes[typeStart..<typeStart + 4].elementsEqual([0x70, 0x48, 0x59, 0x73]) {
guard length == 9 else {
return nil
}
let x =
Int(UInt32(bytes[dataStart]) << 24)
| Int(UInt32(bytes[dataStart + 1]) << 16)
| Int(UInt32(bytes[dataStart + 2]) << 8)
| Int(UInt32(bytes[dataStart + 3]))
let y =
Int(UInt32(bytes[dataStart + 4]) << 24)
| Int(UInt32(bytes[dataStart + 5]) << 16)
| Int(UInt32(bytes[dataStart + 6]) << 8)
| Int(UInt32(bytes[dataStart + 7]))

return (x, y, bytes[dataStart + 8])
}
offset = nextOffset
}

return nil
}

private static func makeScrollFrame(
width: Int,
height: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -738,10 +738,17 @@ extension CaptureSessionController {

let makeImageStartedAt = ProcessInfo.processInfo.systemUptime
let pngData: Data?
let screenScaleFactor = request.captureFrameEnvironment.screenScaleFactor
if let snapshot = renderResult.rgbaSnapshot {
pngData = try? RsnapExportEncoder.pngData(from: snapshot)
pngData = try? RsnapExportEncoder.pngData(
from: snapshot,
screenScaleFactor: screenScaleFactor
)
} else if let cgImage = renderResult.image {
pngData = try? losslessPNGData(from: cgImage)
pngData = try? losslessPNGData(
from: cgImage,
screenScaleFactor: screenScaleFactor
)
} else {
pngData = nil
}
Expand Down Expand Up @@ -841,10 +848,17 @@ extension CaptureSessionController {
}
let makeImageStartedAt = ProcessInfo.processInfo.systemUptime
let pngData: Data?
let screenScaleFactor = request.captureFrameEnvironment.screenScaleFactor
if let snapshot = renderResult.rgbaSnapshot {
pngData = try RsnapExportEncoder.pngData(from: snapshot)
pngData = try RsnapExportEncoder.pngData(
from: snapshot,
screenScaleFactor: screenScaleFactor
)
} else if let cgImage = renderResult.image {
pngData = try losslessPNGData(from: cgImage)
pngData = try losslessPNGData(
from: cgImage,
screenScaleFactor: screenScaleFactor
)
} else {
pngData = nil
}
Expand Down Expand Up @@ -1549,12 +1563,18 @@ extension CaptureSessionController {
return image.cropping(to: cropRect)
}

nonisolated static func losslessPNGData(from image: CGImage) throws -> Data? {
nonisolated static func losslessPNGData(
from image: CGImage,
screenScaleFactor: CGFloat
) throws -> Data? {
guard let snapshot = NativeHostImageBridge.rgbaSnapshot(from: image) else {
return nil
}

return try RsnapExportEncoder.pngData(from: snapshot)
return try RsnapExportEncoder.pngData(
from: snapshot,
screenScaleFactor: screenScaleFactor
)
}
func compositeFrozenOverlay(on image: CGImage, selection: CGRect) throws -> CGImage {
let elements = chromeState.frozenOverlay.exportElements
Expand Down
113 changes: 103 additions & 10 deletions packages/rsnap-capture-core/src/export.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
//! Lossless export-image primitives owned by the Rust product core.

use color_eyre::eyre::{self, Result, WrapErr};
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{ExtendedColorType, ImageEncoder, RgbaImage, imageops};
use image::{RgbaImage, imageops};
use png::{BitDepth, ColorType, Compression, Encoder, Filter, PixelDimensions, Unit};

use crate::RectPoints;

const BASE_SCREEN_DPI: f64 = 72.0;
const INCHES_PER_METER: f64 = 39.370_078_740_157_48;

/// Rectangle in display point space used for export geometry decisions.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DisplayPointRect {
Expand Down Expand Up @@ -111,6 +114,11 @@ impl RgbaExportImage {
pub fn to_png_bytes(&self) -> Result<Vec<u8>> {
encode_png_lossless_fast(&self.image)
}

/// Encodes this export image with display-density metadata for the screen scale.
pub fn to_png_bytes_with_screen_scale(&self, scale_factor_x1000: u32) -> Result<Vec<u8>> {
encode_png_lossless_fast_with_screen_scale(&self.image, scale_factor_x1000)
}
}

/// Returns a pixel-exact crop when the requested rectangle lies inside the image.
Expand Down Expand Up @@ -176,6 +184,27 @@ pub fn frozen_display_crop_rect(
/// the image byte-exact after decoding while avoiding expensive deflate work on
/// the capture hot path.
pub fn encode_png_lossless_fast(image: &RgbaImage) -> Result<Vec<u8>> {
encode_png_lossless_fast_inner(image, None)
}

/// Encodes an RGBA export image with PNG physical-pixel density metadata.
///
/// A 2x Retina capture still stores every physical pixel. The density metadata lets
/// consumers that honor PNG `pHYs` display it as `points @ scale` instead of as a
/// larger 1x image.
pub fn encode_png_lossless_fast_with_screen_scale(
image: &RgbaImage,
scale_factor_x1000: u32,
) -> Result<Vec<u8>> {
let pixel_dims = screen_scale_pixel_dimensions(scale_factor_x1000)?;

encode_png_lossless_fast_inner(image, Some(pixel_dims))
}

fn encode_png_lossless_fast_inner(
image: &RgbaImage,
pixel_dims: Option<PixelDimensions>,
) -> Result<Vec<u8>> {
let raw_len = image.as_raw().len();
let mut bytes = Vec::new();

Expand All @@ -184,19 +213,35 @@ pub fn encode_png_lossless_fast(image: &RgbaImage) -> Result<Vec<u8>> {
let _ = bytes.try_reserve_exact(raw_len.saturating_add(extra));
}

let encoder = PngEncoder::new_with_quality(
&mut bytes,
CompressionType::Uncompressed,
FilterType::NoFilter,
);
let mut encoder = Encoder::new(&mut bytes, image.width(), image.height());

encoder.set_color(ColorType::Rgba);
encoder.set_depth(BitDepth::Eight);
encoder.set_compression(Compression::NoCompression);
encoder.set_filter(Filter::NoFilter);
encoder.set_pixel_dims(pixel_dims);

encoder
.write_image(image.as_raw(), image.width(), image.height(), ExtendedColorType::Rgba8)
.wrap_err("failed to encode screenshot as PNG")?;
let mut writer = encoder.write_header().wrap_err("failed to encode screenshot as PNG")?;

writer.write_image_data(image.as_raw()).wrap_err("failed to encode screenshot as PNG")?;

drop(writer);

Ok(bytes)
}

fn screen_scale_pixel_dimensions(scale_factor_x1000: u32) -> Result<PixelDimensions> {
if scale_factor_x1000 == 0 {
return Err(eyre::eyre!("PNG screen scale factor must be non-zero"));
}

let scale = f64::from(scale_factor_x1000) / 1_000.0;
let pixels_per_meter =
(BASE_SCREEN_DPI * scale * INCHES_PER_METER).round().clamp(1.0, f64::from(u32::MAX)) as u32;

Ok(PixelDimensions { xppu: pixels_per_meter, yppu: pixels_per_meter, unit: Unit::Meter })
}

fn integral_image_intersection(
left: f64,
top: f64,
Expand Down Expand Up @@ -365,6 +410,23 @@ mod tests {
let png = crate::encode_png_lossless_fast(&image).expect("PNG encode should succeed");

assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(png_phys_chunk(&png), None);
}

#[test]
fn encode_png_lossless_fast_with_screen_scale_writes_retina_density() {
let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255]));
let png = crate::encode_png_lossless_fast_with_screen_scale(&image, 2_000)
.expect("PNG encode should succeed");

assert_eq!(png_phys_chunk(&png), Some((5_669, 5_669, 1)));
}

#[test]
fn encode_png_lossless_fast_with_screen_scale_rejects_zero_scale() {
let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255]));

assert!(crate::encode_png_lossless_fast_with_screen_scale(&image, 0).is_err());
}

#[test]
Expand All @@ -379,4 +441,35 @@ mod tests {
assert_eq!(crop.height(), 2);
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
}

fn png_phys_chunk(bytes: &[u8]) -> Option<(u32, u32, u8)> {
if bytes.len() < 8 || !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return None;
}

let mut offset = 8_usize;

while offset.checked_add(12)? <= bytes.len() {
let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().ok()?) as usize;
let chunk_type = &bytes[offset + 4..offset + 8];
let data_start = offset.checked_add(8)?;
let data_end = data_start.checked_add(length)?;
let chunk_end = data_end.checked_add(4)?;

if chunk_end > bytes.len() {
return None;
}
if chunk_type == b"pHYs" && length == 9 {
let xppu = u32::from_be_bytes(bytes[data_start..data_start + 4].try_into().ok()?);
let yppu =
u32::from_be_bytes(bytes[data_start + 4..data_start + 8].try_into().ok()?);

return Some((xppu, yppu, bytes[data_start + 8]));
}

offset = chunk_end;
}

None
}
}
5 changes: 4 additions & 1 deletion packages/rsnap-capture-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ pub use self::{
render_capture_frame_effect,
},
export::{DisplayPointRect, frozen_display_crop_rect},
export::{RgbaExportImage, crop_export_image, crop_rgba_image, encode_png_lossless_fast},
export::{
RgbaExportImage, crop_export_image, crop_rgba_image, encode_png_lossless_fast,
encode_png_lossless_fast_with_screen_scale,
},
geometry::{
GlobalPoint, GlobalRect, MonitorRect, MonitorRectPoints, RectPoints, Rgb, WindowHit,
WindowRect,
Expand Down
Loading