diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7cc4785..782b4fd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,9 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22 - with: - components: rustfmt + # The version is `rust-toolchain.toml` and nothing else. `rustup toolchain + # install` with no argument reads that file, so there is one place to bump + # and no way for CI to test a version the working tree does not. Installing + # the floating `stable` channel instead is what reddened a green main on + # 2026-10-01: rustc 1.99.0 sharpened a clippy lint and no commit had changed. + - name: Install the pinned toolchain + run: rustup toolchain install - name: Check formatting run: cargo fmt --all --check @@ -30,9 +34,8 @@ jobs: # fonts-dejavu-core: font *files*, not just the libraries. A bare runner has none, # and usvg silently drops an 's when no face resolves. run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libxkbcommon-x11-dev libwayland-dev libxcb-cursor-dev libasound2-dev fonts-dejavu-core - - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22 - with: - components: clippy + - name: Install the pinned toolchain + run: rustup toolchain install - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Clippy # `--features rustmotion/studio` n'est pas décoratif : le studio est @@ -53,7 +56,8 @@ jobs: # fonts-dejavu-core: font *files*, not just the libraries. A bare runner has none, # and usvg silently drops an 's when no face resolves. run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libxkbcommon-x11-dev libwayland-dev libxcb-cursor-dev libasound2-dev fonts-dejavu-core - - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22 + - name: Install the pinned toolchain + run: rustup toolchain install - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Run tests # Même raison qu'au job clippy : sans le feature, les tests du studio @@ -64,7 +68,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22 + - name: Install the pinned toolchain + run: rustup toolchain install - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install cargo-audit run: cargo install cargo-audit --locked diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 96c7da2..ff82174 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -23,7 +23,8 @@ jobs: # asound: required by cpal, which rodio pulls in for preview audio run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libxkbcommon-x11-dev libwayland-dev libxcb-cursor-dev libasound2-dev - - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22 + - name: Install the pinned toolchain + run: rustup toolchain install # La version se lit via `cargo metadata`, pas en grepant un manifeste : elle # est déclarée dans `[workspace.package]` et héritée, donc un `grep diff --git a/crates/rustmotion-components/src/avatar.rs b/crates/rustmotion-components/src/avatar.rs index ed49179..a15a2d4 100644 --- a/crates/rustmotion-components/src/avatar.rs +++ b/crates/rustmotion-components/src/avatar.rs @@ -6,7 +6,7 @@ use skia_safe::{Canvas, Paint, PaintStyle, RRect, Rect}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{asset_cache, paint_from_hex}; +use rustmotion_core::engine::renderer::{asset_cache, draw_photo, paint_from_hex}; use rustmotion_core::error::RustmotionError; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -112,7 +112,7 @@ impl Avatar { let offset_y = (h - draw_h) / 2.0; let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); canvas.restore(); diff --git a/crates/rustmotion-components/src/avatar_group.rs b/crates/rustmotion-components/src/avatar_group.rs index 1851f99..6df4f5d 100644 --- a/crates/rustmotion-components/src/avatar_group.rs +++ b/crates/rustmotion-components/src/avatar_group.rs @@ -6,6 +6,7 @@ use skia_safe::{Canvas, Paint, PaintStyle, RRect, Rect}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, subpixel_font, typeface_with_fallback, @@ -141,7 +142,7 @@ impl AvatarGroup { let offset_y = cy - inner_r + (d - draw_h) / 2.0; let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); canvas.restore(); } diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs index f0c8823..9d015a6 100644 --- a/crates/rustmotion-components/src/badge.rs +++ b/crates/rustmotion-components/src/badge.rs @@ -6,6 +6,7 @@ use rustmotion_core::css::style::AlignSelf; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, measure_text_with_fallback, paint_from_hex, subpixel_font, typeface_with_fallback, @@ -221,7 +222,7 @@ impl Badge { let icon_y = (h - icon_size) / 2.0; let dst = Rect::from_xywh(x_offset, icon_y, icon_size, icon_size); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); let ratio = self.resolved_font_size(ctx) / self.badge_size.params().0; x_offset += icon_size + 6.0 * ratio; diff --git a/crates/rustmotion-components/src/gif.rs b/crates/rustmotion-components/src/gif.rs index b226726..b36a364 100644 --- a/crates/rustmotion-components/src/gif.rs +++ b/crates/rustmotion-components/src/gif.rs @@ -7,6 +7,7 @@ use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect}; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::gif_cache; use rustmotion_core::schema::{ImageFit, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -228,7 +229,7 @@ impl Painter for Gif { if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) { let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); } } } diff --git a/crates/rustmotion-components/src/icon.rs b/crates/rustmotion-components/src/icon.rs index 7ed5490..6e0c1d8 100644 --- a/crates/rustmotion-components/src/icon.rs +++ b/crates/rustmotion-components/src/icon.rs @@ -1,11 +1,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect, SamplingOptions}; +use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect}; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{asset_cache, fetch_icon_svg, icon_cache_key}; +use rustmotion_core::engine::renderer::{asset_cache, draw_photo, fetch_icon_svg, icon_cache_key}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -91,13 +91,7 @@ impl Painter for Icon { let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); let paint = Paint::default(); - canvas.draw_image_rect_with_sampling_options( - img, - None, - dst, - SamplingOptions::from(skia_safe::CubicResampler::mitchell()), - &paint, - ); + draw_photo(canvas, img, dst, &paint); } } diff --git a/crates/rustmotion-components/src/image.rs b/crates/rustmotion-components/src/image.rs index e1adb95..f2e505d 100644 --- a/crates/rustmotion-components/src/image.rs +++ b/crates/rustmotion-components/src/image.rs @@ -5,7 +5,7 @@ use skia_safe::{Canvas, Paint, Rect}; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::asset_cache; +use rustmotion_core::engine::renderer::{asset_cache, draw_photo}; use rustmotion_core::schema::{ImageFit, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -84,10 +84,10 @@ impl Painter for Image { skia_safe::ClipOp::Intersect, true, ); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); canvas.restore(); } else { - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); } } } diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index c6a1ecf..593ec8a 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -6,6 +6,7 @@ use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, Rect}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex, subpixel_font, typeface_with_fallback, @@ -154,7 +155,7 @@ impl List { }; let dst = Rect::from_xywh(x, y, self.icon_size, self.icon_size); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); Ok(()) } } diff --git a/crates/rustmotion-components/src/lottie.rs b/crates/rustmotion-components/src/lottie.rs index cdfed9f..cbd2daa 100644 --- a/crates/rustmotion-components/src/lottie.rs +++ b/crates/rustmotion-components/src/lottie.rs @@ -1,7 +1,7 @@ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::asset_cache; +use rustmotion_core::engine::renderer::{asset_cache, draw_photo}; use rustmotion_core::error::{Result, RustmotionError}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -128,6 +128,7 @@ mod native { use std::sync::{Arc, OnceLock}; use dashmap::DashMap; + use rustmotion_core::engine::renderer::draw_photo; const CACHE_MAX_ENTRIES: usize = 128; @@ -363,7 +364,7 @@ mod native { let dst = skia_safe::Rect::from_xywh(0.0, 0.0, layout.width, layout.height); let paint = skia_safe::Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); } #[cfg(test)] @@ -417,7 +418,7 @@ impl Painter for Lottie { let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); return; } diff --git a/crates/rustmotion-components/src/mockup.rs b/crates/rustmotion-components/src/mockup.rs index e4556b1..cfdba03 100644 --- a/crates/rustmotion-components/src/mockup.rs +++ b/crates/rustmotion-components/src/mockup.rs @@ -6,6 +6,7 @@ use skia_safe::{Canvas, Paint, PaintStyle, PathBuilder, RRect, Rect}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{asset_cache, paint_from_hex}; use rustmotion_core::error::RustmotionError; use rustmotion_core::schema::TimelineStep; @@ -151,7 +152,7 @@ impl Mockup { canvas.save(); canvas.clip_rect(screen_rect, skia_safe::ClipOp::Intersect, true); let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); canvas.restore(); Ok(()) diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs index f9d6344..4599f40 100644 --- a/crates/rustmotion-components/src/stat.rs +++ b/crates/rustmotion-components/src/stat.rs @@ -8,6 +8,7 @@ use skia_safe::{ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{ asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, measure_text_with_fallback, paint_from_hex, parse_hex_color, subpixel_font, @@ -276,7 +277,7 @@ impl Stat { if let Some(img) = icon_img { let icon_y = ty - trend_fs * 0.8; let dst = Rect::from_xywh(tx, icon_y, icon_sz as f32, icon_sz as f32); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); tx += icon_sz as f32 + 4.0; } } diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index 31a4167..4183f8c 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -11,6 +11,7 @@ use skia_safe::{ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::{ease, AnimatedProperties}; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{asset_cache, interpolate_path_data}; use rustmotion_core::schema::{SvgPathMorph, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -372,7 +373,7 @@ fn paint_fill_reveal( canvas.clip_rect(sweep, None, true); } - canvas.draw_image_rect(full_image, None, image_dst, &paint); + draw_photo(canvas, full_image, image_dst, &paint); canvas.restore(); } } @@ -721,7 +722,7 @@ impl Svg { return; }; let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); - canvas.draw_image_rect(img, None, dst, &Paint::default()); + draw_photo(canvas, img, dst, &Paint::default()); return; } @@ -773,7 +774,7 @@ impl Svg { let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); } fn cached_full_image(&self, layout: &BoxLayout) -> Option { @@ -896,7 +897,7 @@ impl Svg { let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); let _ = svg_data; } diff --git a/crates/rustmotion-components/src/video.rs b/crates/rustmotion-components/src/video.rs index e773df7..aa9e23a 100644 --- a/crates/rustmotion-components/src/video.rs +++ b/crates/rustmotion-components/src/video.rs @@ -5,6 +5,7 @@ use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect}; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::draw_photo; use rustmotion_core::engine::renderer::{ extract_video_frame, find_closest_frame, probe_video_metadata, video_frame_cache, }; @@ -80,10 +81,10 @@ fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: & skia_safe::ClipOp::Intersect, true, ); - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); canvas.restore(); } else { - canvas.draw_image_rect(img, None, dst, &paint); + draw_photo(canvas, img, dst, &paint); } } diff --git a/crates/rustmotion-core/src/engine/renderer/mod.rs b/crates/rustmotion-core/src/engine/renderer/mod.rs index f6d036b..39c538e 100644 --- a/crates/rustmotion-core/src/engine/renderer/mod.rs +++ b/crates/rustmotion-core/src/engine/renderer/mod.rs @@ -4,6 +4,7 @@ mod colors; mod fonts; pub mod google_fonts; pub use google_fonts::{remote_font_policy, set_remote_font_policy, RemoteFontPolicy}; +mod photo; mod shapes; mod text; mod yuv; @@ -12,6 +13,7 @@ pub use assets::*; pub use audio_analysis::*; pub use colors::*; pub use fonts::*; +pub use photo::*; pub use shapes::*; pub use text::*; pub use yuv::*; diff --git a/crates/rustmotion-core/src/engine/renderer/photo.rs b/crates/rustmotion-core/src/engine/renderer/photo.rs new file mode 100644 index 0000000..5e09cdd --- /dev/null +++ b/crates/rustmotion-core/src/engine/renderer/photo.rs @@ -0,0 +1,191 @@ +use skia_safe::{ + Canvas, CubicResampler, FilterMode, Image, Matrix, MipmapMode, Paint, Rect, SamplingOptions, +}; + +pub fn device_axis_scales(matrix: &Matrix) -> (f32, f32) { + ( + matrix.scale_x().hypot(matrix.skew_y()), + matrix.scale_y().hypot(matrix.skew_x()), + ) +} + +pub fn photo_sampling(source: (f32, f32), on_screen: (f32, f32)) -> SamplingOptions { + let minifying = on_screen.0 < source.0 || on_screen.1 < source.1; + if minifying { + SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear) + } else { + SamplingOptions::from(CubicResampler::mitchell()) + } +} + +pub fn draw_photo(canvas: &Canvas, image: impl AsRef, dst: Rect, paint: &Paint) { + let image = image.as_ref(); + let (scale_x, scale_y) = device_axis_scales(&canvas.local_to_device_as_3x3()); + let sampling = photo_sampling( + (image.width() as f32, image.height() as f32), + (dst.width() * scale_x, dst.height() * scale_y), + ); + canvas.draw_image_rect_with_sampling_options(image, None, dst, sampling, paint); +} + +#[cfg(test)] +mod tests { + use super::*; + use skia_safe::{images, surfaces, AlphaType, ColorType, Data, ImageInfo}; + + const MIPMAPPED: SamplingOptions = SamplingOptions { + max_aniso: 0, + use_cubic: false, + cubic: CubicResampler { b: 0.0, c: 0.0 }, + filter: FilterMode::Linear, + mipmap: MipmapMode::Linear, + }; + + fn checkerboard(size: i32) -> Image { + let mut pixels = Vec::with_capacity((size * size * 4) as usize); + for y in 0..size { + for x in 0..size { + let v = if (x + y) % 2 == 0 { 255 } else { 0 }; + pixels.extend_from_slice(&[v, v, v, 255]); + } + } + let info = ImageInfo::new((size, size), ColorType::RGBA8888, AlphaType::Premul, None); + images::raster_from_data(&info, Data::new_copy(&pixels), (size * 4) as usize) + .expect("raster image from checkerboard pixels") + } + + struct Luma { + mean: f64, + min: u8, + max: u8, + } + + fn luma_inside(rgba: &[u8], side: u32, inset: u32) -> Luma { + let mut values = Vec::new(); + for y in inset..side - inset { + for x in inset..side - inset { + let i = ((y * side + x) * 4) as usize; + let luma = 0.299 * rgba[i] as f64 + + 0.587 * rgba[i + 1] as f64 + + 0.114 * rgba[i + 2] as f64; + values.push(luma.round() as u8); + } + } + Luma { + mean: values.iter().map(|v| *v as f64).sum::() / values.len() as f64, + min: *values.iter().min().expect("non-empty region"), + max: *values.iter().max().expect("non-empty region"), + } + } + + fn draw_checkerboard(side: u32, canvas_scale: f32, through_helper: bool) -> Vec { + let info = ImageInfo::new( + (side as i32, side as i32), + ColorType::RGBA8888, + AlphaType::Premul, + None, + ); + let mut surface = surfaces::raster(&info, None, None).expect("raster surface"); + let source = checkerboard(640); + let local_side = side as f32 / canvas_scale; + let dst = Rect::from_xywh(0.0, 0.0, local_side, local_side); + { + let canvas = surface.canvas(); + canvas.scale((canvas_scale, canvas_scale)); + if through_helper { + draw_photo(canvas, &source, dst, &Paint::default()); + } else { + canvas.draw_image_rect(&source, None, dst, &Paint::default()); + } + } + let mut rgba = vec![0u8; (side * side * 4) as usize]; + assert!(surface.read_pixels(&info, &mut rgba, (side * 4) as usize, (0, 0))); + rgba + } + + #[test] + fn minifying_either_axis_asks_for_mipmaps() { + assert_eq!(photo_sampling((640.0, 640.0), (64.0, 64.0)), MIPMAPPED); + assert_eq!(photo_sampling((640.0, 640.0), (64.0, 900.0)), MIPMAPPED); + assert_eq!(photo_sampling((640.0, 640.0), (900.0, 64.0)), MIPMAPPED); + } + + #[test] + fn drawing_at_or_above_source_size_asks_for_cubic() { + let at_source = photo_sampling((640.0, 640.0), (640.0, 640.0)); + let enlarged = photo_sampling((640.0, 640.0), (1280.0, 1280.0)); + assert!(at_source.use_cubic, "1:1 must not sample with mipmaps"); + assert!(enlarged.use_cubic, "an enlargement must not sample mipmaps"); + } + + #[test] + fn axis_scales_ignore_rotation() { + let (sx, sy) = device_axis_scales(&Matrix::scale((2.0, 3.0))); + assert!((sx - 2.0).abs() < 1e-5 && (sy - 3.0).abs() < 1e-5); + + let mut rotated = Matrix::new_identity(); + rotated.set_rotate(90.0, None); + let (rx, ry) = device_axis_scales(&rotated); + assert!( + (rx - 1.0).abs() < 1e-5 && (ry - 1.0).abs() < 1e-5, + "a pure rotation is not a shrink, got ({rx}, {ry})" + ); + } + + #[test] + fn a_checkerboard_shrunk_to_a_tenth_averages_to_grey() { + let before = luma_inside(&draw_checkerboard(64, 1.0, false), 64, 4); + let after = luma_inside(&draw_checkerboard(64, 1.0, true), 64, 4); + + assert_eq!( + (before.min, before.max), + (255, 255), + "the nearest-neighbour path is expected to read solid white at scale 1/10, \ + got min {} max {}", + before.min, + before.max + ); + assert!( + (after.mean - 127.0).abs() < 2.0 && after.min > 120 && after.max < 135, + "a 1px checkerboard at scale 1/10 must average to grey, got mean {:.1} \ + min {} max {}", + after.mean, + after.min, + after.max + ); + } + + #[test] + fn a_checkerboard_at_an_awkward_scale_averages_to_grey() { + let before = luma_inside(&draw_checkerboard(120, 1.0, false), 120, 4); + let after = luma_inside(&draw_checkerboard(120, 1.0, true), 120, 4); + + assert!( + before.min == 0 && before.max == 255, + "the nearest-neighbour path is expected to read black-and-white noise, \ + got min {} max {}", + before.min, + before.max + ); + assert!( + (after.mean - 127.0).abs() < 2.0 && after.min > 120 && after.max < 135, + "a 1px checkerboard at scale 120/640 must average to grey, got mean {:.1} \ + min {} max {}", + after.mean, + after.min, + after.max + ); + } + + #[test] + fn a_camera_zoom_out_is_read_as_a_shrink() { + let full_size_rect = luma_inside(&draw_checkerboard(64, 0.1, true), 64, 4); + assert!( + (full_size_rect.mean - 127.0).abs() < 2.0 && full_size_rect.max < 135, + "a 640px rect on a canvas scaled to 0.1 is drawn at 64px and must sample as a \ + shrink, got mean {:.1} max {}", + full_size_rect.mean, + full_size_rect.max + ); + } +} diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 9876962..4b895d1 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,5 +1,5 @@ use crate::engine::animator::ease; -use crate::engine::renderer::{color4f_from_hex, paint_from_hex}; +use crate::engine::renderer::{color4f_from_hex, draw_photo, paint_from_hex}; use crate::schema::{ EasingType, IrisRing, IrisShape, MaskShape, PanBackground, PixelDissolveOrder, Transition, TransitionCorner, TransitionDirection, TransitionType, ZoomBlurOrigin, @@ -1461,9 +1461,7 @@ pub fn camera_pan_transition( fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) -> Option> { let mut surface = create_skia_surface(width, height)?; - surface - .canvas() - .draw_image_rect(img, None, dest, &Paint::default()); + draw_photo(surface.canvas(), img, dest, &Paint::default()); Some(surface_to_pixels(surface, width, height)) } diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 49a1e11..a22c705 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -1,3 +1,5 @@ +#![allow(clippy::redundant_field_names)] + use thiserror::Error; pub type Result = std::result::Result; diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 46c404f..2af5c83 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -218,6 +218,17 @@ pub trait Painter { `PaintCtx` contient : `time`, `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. +> A raster bitmap scaled into a destination rect is drawn with +> `renderer::draw_photo`, not `canvas.draw_image_rect`. Skia's default +> `SamplingOptions` is nearest neighbour with no mipmaps, so a photo drawn smaller +> than its source aliased and one drawn larger broke into blocks; a 640px source at +> 64px is scale 1/10 exactly and rendered solid white. `draw_photo` reads the size +> the bitmap is finally drawn at off the canvas matrix, camera included, and asks for +> mipmapped linear when either axis minifies and Mitchell cubic otherwise — cubic +> consults no mipmaps, so it is only right when enlarging. The point form of +> `draw_image` places a bitmap at 1:1 and never consults the sampling, which is why +> the full-frame composites in `transition` are left on it. + > Every `skia_safe::Font` comes from `renderer::subpixel_font`, never from > `Font::from_typeface`. Skia defaults to whole-pixel glyph placement (subpixel off, > baseline snapping on), so a text node under a slow translation stepped by 1px while diff --git a/crates/rustmotion/src/cli/commands/sheet.rs b/crates/rustmotion/src/cli/commands/sheet.rs index 4179ac4..1693cce 100644 --- a/crates/rustmotion/src/cli/commands/sheet.rs +++ b/crates/rustmotion/src/cli/commands/sheet.rs @@ -1,7 +1,7 @@ use crate::cli::OutputFormat; use rustmotion::encode; use rustmotion::engine; -use rustmotion::engine::renderer::subpixel_font; +use rustmotion::engine::renderer::{draw_photo, subpixel_font}; use rustmotion::error::{Result, RustmotionError}; use rustmotion::schema::ResolvedScenario; use skia_safe::{ @@ -191,7 +191,7 @@ fn compose_grid(cells: &[(f64, image::RgbaImage)], layout: &GridLayout) -> Resul .ok_or(RustmotionError::PixelImage)?; let dst = Rect::from_xywh(x, y, layout.cell_width as f32, layout.cell_height as f32); - canvas.draw_image_rect(&sk_img, None, dst, &Paint::default()); + draw_photo(canvas, &sk_img, dst, &Paint::default()); canvas.draw_rect(dst, &border_paint); draw_timestamp_stamp(canvas, &font, *t, x, y, layout.cell_height as f32); diff --git a/crates/rustmotion/tests/image_sampling.rs b/crates/rustmotion/tests/image_sampling.rs new file mode 100644 index 0000000..980aebc --- /dev/null +++ b/crates/rustmotion/tests/image_sampling.rs @@ -0,0 +1,139 @@ +use rustmotion::encode::{build_frame_tasks, render_frame_task_scaled}; +use rustmotion::schema::ResolvedScenario; + +const WIDTH: u32 = 320; +const HEIGHT: u32 = 160; +const SOURCE_SIDE: u32 = 640; +const BACKGROUND: [u8; 3] = [255, 0, 0]; + +fn write_checkerboard() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "rm_image_sampling_checker_{}.png", + std::process::id() + )); + let mut buf = image::ImageBuffer::, Vec>::new(SOURCE_SIDE, SOURCE_SIDE); + for (x, y, pixel) in buf.enumerate_pixels_mut() { + let v = if (x + y) % 2 == 0 { 255 } else { 0 }; + *pixel = image::Rgb([v, v, v]); + } + buf.save(&path).expect("write the checkerboard source"); + path +} + +fn scenario(src: &std::path::Path) -> ResolvedScenario { + let src = src.display(); + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {WIDTH}, "height": {HEIGHT}, "fps": 30, "background": "#FF0000" }}, + "scenes": [{{ + "duration": 1.0, + "layout": {{ "direction": "row", "align_items": "center", "justify_content": "center", "gap": 40 }}, + "children": [ + {{ "type": "image", "src": "{src}", "fit": "cover", "style": {{ "width": 64, "height": 64 }} }}, + {{ "type": "image", "src": "{src}", "fit": "cover", "style": {{ "width": 120, "height": 120 }} }} + ] + }}] + }}"## + ); + rustmotion::loader::load_scenario_from_source(None, Some(&json)).expect("scenario loads") +} + +fn first_frame(src: &std::path::Path) -> Vec { + let scenario = scenario(src); + rustmotion::engine::preload::preload_scenario_assets(&scenario).expect("preload"); + let tasks = build_frame_tasks(&scenario); + render_frame_task_scaled(&scenario.video, &scenario, &tasks[0], 1.0).expect("render") +} + +fn is_background(rgba: &[u8], x: u32, y: u32) -> bool { + let i = ((y * WIDTH + x) * 4) as usize; + rgba[i] == BACKGROUND[0] && rgba[i + 1] == BACKGROUND[1] && rgba[i + 2] == BACKGROUND[2] +} + +fn image_columns(rgba: &[u8]) -> Vec<(u32, u32)> { + let mut spans = Vec::new(); + let mut start = None; + for x in 0..WIDTH { + let painted = (0..HEIGHT).any(|y| !is_background(rgba, x, y)); + match (start, painted) { + (None, true) => start = Some(x), + (Some(s), false) => { + spans.push((s, x)); + start = None; + } + _ => {} + } + } + if let Some(s) = start { + spans.push((s, WIDTH)); + } + spans +} + +fn rows_of(rgba: &[u8], (from_x, to_x): (u32, u32)) -> (u32, u32) { + let painted: Vec = (0..HEIGHT) + .filter(|y| (from_x..to_x).any(|x| !is_background(rgba, x, *y))) + .collect(); + ( + *painted.first().expect("a painted row"), + painted.last().expect("a painted row") + 1, + ) +} + +struct Luma { + mean: f64, + min: u8, + max: u8, +} + +fn luma(rgba: &[u8], (x0, x1): (u32, u32), (y0, y1): (u32, u32), inset: u32) -> Luma { + let mut values = Vec::new(); + for y in y0 + inset..y1 - inset { + for x in x0 + inset..x1 - inset { + let i = ((y * WIDTH + x) * 4) as usize; + let v = + 0.299 * rgba[i] as f64 + 0.587 * rgba[i + 1] as f64 + 0.114 * rgba[i + 2] as f64; + values.push(v.round() as u8); + } + } + Luma { + mean: values.iter().map(|v| *v as f64).sum::() / values.len() as f64, + min: *values.iter().min().expect("a non-empty region"), + max: *values.iter().max().expect("a non-empty region"), + } +} + +#[test] +fn a_photo_shrunk_by_the_image_component_is_averaged_not_point_sampled() { + let src = write_checkerboard(); + let rgba = first_frame(&src); + let _ = std::fs::remove_file(&src); + + let spans = image_columns(&rgba); + assert_eq!( + spans.len(), + 2, + "expected the 64px and the 120px image, got {spans:?}" + ); + + for (span, drawn) in spans.iter().zip([64u32, 120]) { + let rows = rows_of(&rgba, *span); + assert_eq!( + (span.1 - span.0, rows.1 - rows.0), + (drawn, drawn), + "the {drawn}px image is not laid out at its declared size" + ); + + let measured = luma(&rgba, *span, rows, 4); + assert!( + (measured.mean - 127.0).abs() < 2.0 && measured.min > 118 && measured.max < 137, + "a 1px checkerboard drawn at {drawn}px must average to grey, got mean {:.1} \ + min {} max {}; nearest-neighbour sampling reads 255/255/255 at 64px and \ + mean 124 with min 0 max 255 at 120px", + measured.mean, + measured.min, + measured.max + ); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..0fd1b3a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.99.0" +components = ["clippy", "rustfmt"]