From 6005440fedfb35bcaba9868b0f71d60ffae619b2 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 1 Oct 2026 17:07:31 +0200 Subject: [PATCH 1/4] fix(error): silence the lint clippy 1.99 raises on thiserror's generated From impl `redundant_field_names` now fires on the two `#[from]` variants whose field is named `source`: the derive generates `Self::JsonParse { source: source }`, and 1.99 attributes that to our field span. rustc 1.98.1 did not report it. The allow sits on the module, not on the variants, because the generated `impl From` is a sibling item of the enum rather than part of it: an attribute on the variant leaves the lint exactly where it was, which is what the first attempt measured. Renaming the field is not an option either way, since `source` is what thiserror reads to implement `Error::source` and what the `#[error("...{source}")]` strings interpolate. --- crates/rustmotion-core/src/error.rs | 2 ++ 1 file changed, 2 insertions(+) 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; From 60e30c12bd8cdea92f71fc15fc4a4eeaef87db5c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 1 Oct 2026 17:08:09 +0200 Subject: [PATCH 2/4] ci: pin the toolchain so a rust release cannot redden a green main CI installed the floating `stable` channel, so the version it tested was whatever had shipped by the time the job ran. On 2026-10-01 that turned main red with no commit in between: rustc moved 1.98.1 -> 1.99.0, clippy gained a lint on code the repository had not touched, and the schema PR that was green on its own branch failed once merged. `rust-toolchain.toml` is now the single place the version is written. `rustup toolchain install` with no argument reads that file, including its `components`, so there is no second copy in the workflow to drift out of step and no way for CI to check a version the working tree does not. It replaces `dtolnay/rust-toolchain` in all four CI jobs and in publish: that action's `toolchain` input is required and defaults to `stable`, and it does not read the manifest, so keeping it would have meant writing the version five more times. Runners ship rustup, and dropping the action removes a third-party dependency from every job. Relying on rustup's implicit auto-install was the alternative and is rejected: it works today, but rustup prints a deprecation for it and says it may stop working, which is the same class of delayed breakage this commit exists to remove. The `audit` job stays as it is. It is designed to fail on a new advisory, and the ten `--ignore` entries are reviewed on a date written next to them. Making that job unconditionally green would mean not auditing. --- .github/workflows/ci.yaml | 21 +++++++++++++-------- .github/workflows/publish.yaml | 3 ++- rust-toolchain.toml | 3 +++ 3 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 rust-toolchain.toml 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/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"] From a49f02c5dd5cdd2393315b55016c095c97098cdd Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 1 Oct 2026 17:11:01 +0200 Subject: [PATCH 3/4] fix(paint): sample photos from their on-screen size instead of nearest neighbour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `draw_image_rect` with a default `Paint` samples with Skia's defaults, which are nearest neighbour and no mipmaps. A photo drawn smaller than its source kept one source pixel out of N and dropped the rest, so it aliased; drawn larger it was enlarged pixel by pixel into blocks. On the issue's 640px checkerboard the 64px case is scale 1/10 exactly, every sample lands on the same parity, and the image renders solid white. `renderer::photo` decides from the size the bitmap is finally drawn at, camera included: mipmapped linear when either axis minifies, Mitchell cubic otherwise, since cubic does not consult mipmaps and is only right when enlarging. The scale comes off `canvas.local_to_device_as_3x3()`, so a layout size of 640 under a camera at 0.1 is correctly read as a 64px draw. The axis scales are `hypot(scale_x, skew_y)` rather than `scale_x` alone: taking the raw component would report a pure rotation as a shrink. Twenty call sites now go through `draw_photo`, which is every place a raster bitmap is scaled into a destination rect — the eleven components the issue listed plus `transition::render_layer` and the `sheet` command. `icon` joins them: it already asked for Mitchell, but unconditionally, so a shrunk icon still had no mipmaps. The sites left alone all use the point form of `draw_image`, which places the bitmap at 1:1 and never consults the sampling: the full-frame composites in `transition`, the layer snapshots in `paint_pass` and `render/scene`, and the cosmic-text bridge that is not wired into rendering. Cost, best of three over 300 frames at 1920x1080: twelve 640px photos drawn at 52px go from 1.01s to 1.13s, and one 640px source filling the frame goes from 1.79s to 2.59s. The enlargement is where cubic is paid for, at 8.6ms a frame. Mipmaps are built once per image and cached on the `SkImage`, which is why the shrinking case barely moves. Closes #465 --- crates/rustmotion-components/src/avatar.rs | 4 +- .../rustmotion-components/src/avatar_group.rs | 3 +- crates/rustmotion-components/src/badge.rs | 3 +- crates/rustmotion-components/src/gif.rs | 3 +- crates/rustmotion-components/src/icon.rs | 12 +- crates/rustmotion-components/src/image.rs | 6 +- crates/rustmotion-components/src/list.rs | 3 +- crates/rustmotion-components/src/lottie.rs | 7 +- crates/rustmotion-components/src/mockup.rs | 3 +- crates/rustmotion-components/src/stat.rs | 3 +- crates/rustmotion-components/src/svg.rs | 9 +- crates/rustmotion-components/src/video.rs | 5 +- .../src/engine/renderer/mod.rs | 2 + .../src/engine/renderer/photo.rs | 191 ++++++++++++++++++ .../rustmotion-core/src/engine/transition.rs | 6 +- crates/rustmotion/src/cli/commands/sheet.rs | 4 +- crates/rustmotion/tests/image_sampling.rs | 139 +++++++++++++ 17 files changed, 368 insertions(+), 35 deletions(-) create mode 100644 crates/rustmotion-core/src/engine/renderer/photo.rs create mode 100644 crates/rustmotion/tests/image_sampling.rs 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/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 + ); + } +} From 2330d023e19f0a4772ee150439abff1a9a558a5e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 1 Oct 2026 17:39:09 +0200 Subject: [PATCH 4/4] docs(claude): record that bitmaps are drawn through draw_photo Placed above the subpixel_font rule rather than below it so this and the font-weight note on the other branch merge without touching the same lines. --- crates/rustmotion/CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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