image and video draw their bitmap with canvas.draw_image_rect(img, None, dst, &Paint::default()), which samples with Skia's default SamplingOptions: nearest neighbour, no mipmaps. A photo drawn smaller than its source picks one source pixel out of N and drops the rest, so it aliases (jagged hair, stair-stepped glasses, moiré on fabric). A photo drawn larger, for instance under a camera zoom, is enlarged pixel by pixel into visible blocks.
crates/rustmotion-components/src/image.rs:
let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
let paint = Paint::default();
// ...
canvas.draw_image_rect(img, None, dst, &paint);
video.rs (draw_fitted) has the same call. The camera is applied as a canvas matrix (apply_camera_transform scales the canvas), so the scale the image is finally drawn at is the layout scale times the camera zoom, and both go through that nearest-neighbour sampling.
Reproduction
A 640x640 checkerboard of 1px black/white cells (checker.png), drawn at 64px and 120px on a #808080 background. Correct downsampling averages it to the background grey.
{
"version": "1.0",
"video": { "width": 320, "height": 160, "fps": 30, "background": "#808080" },
"scenes": [{
"duration": 1.0,
"layout": { "direction": "row", "align_items": "center", "justify_content": "center", "gap": 40 },
"children": [
{ "type": "image", "src": "checker.png", "fit": "cover", "style": { "width": 64, "height": 64 } },
{ "type": "image", "src": "checker.png", "fit": "cover", "style": { "width": 120, "height": 120 } }
]
}]
}
Luma inside each image (4px inset), render --frame 0:
| drawn size |
mean |
min |
max |
near-black or near-white pixels |
| 64px (current) |
255 |
255 |
255 |
100% |
| 120px (current) |
124 |
0 |
255 |
100% |
| 64px (mipmapped) |
127 |
127 |
127 |
0% |
| 120px (mipmapped) |
127 |
127 |
127 |
0% |
At 64px the scale is exactly 1/10, so every sampled pixel lands on the same parity and the image renders solid white. At 120px it is black/white noise.
Seen in production on a video using portraits: 640px faces drawn as 52px avatars in a table had visibly jagged hair and glasses, and a wall of 400px portraits under a zoom: 3.4 camera move showed pixel blocks.
Proposed fix
Choose the sampling from the on-screen size, camera included:
- drawn smaller than the source:
SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear);
- drawn at or above source size:
SamplingOptions::from(CubicResampler::mitchell()) (cubic does not use mipmaps, so it is only right when enlarging).
The on-screen width is dst.width() times the scale of canvas.local_to_device_as_3x3(), which already includes the camera zoom. A shared helper in rustmotion-core::engine::renderer (draw_photo(canvas, image, dst, paint)) can replace the draw_image_rect call in image.rs and video.rs. A local patch along those lines gives the "mipmapped" rows above. icon.rs already does something similar with CubicResampler::mitchell().
The other places that draw a bitmap with Paint::default() are not covered by that patch and likely have the same problem: avatar.rs, avatar_group.rs, badge.rs, list.rs, stat.rs, mockup.rs, gif.rs, svg.rs (raster fallbacks), lottie.rs, engine/transition.rs and the sheet command.
imageandvideodraw their bitmap withcanvas.draw_image_rect(img, None, dst, &Paint::default()), which samples with Skia's defaultSamplingOptions: nearest neighbour, no mipmaps. A photo drawn smaller than its source picks one source pixel out of N and drops the rest, so it aliases (jagged hair, stair-stepped glasses, moiré on fabric). A photo drawn larger, for instance under a camerazoom, is enlarged pixel by pixel into visible blocks.crates/rustmotion-components/src/image.rs:video.rs(draw_fitted) has the same call. The camera is applied as a canvas matrix (apply_camera_transformscales the canvas), so the scale the image is finally drawn at is the layout scale times the camera zoom, and both go through that nearest-neighbour sampling.Reproduction
A 640x640 checkerboard of 1px black/white cells (
checker.png), drawn at 64px and 120px on a#808080background. Correct downsampling averages it to the background grey.{ "version": "1.0", "video": { "width": 320, "height": 160, "fps": 30, "background": "#808080" }, "scenes": [{ "duration": 1.0, "layout": { "direction": "row", "align_items": "center", "justify_content": "center", "gap": 40 }, "children": [ { "type": "image", "src": "checker.png", "fit": "cover", "style": { "width": 64, "height": 64 } }, { "type": "image", "src": "checker.png", "fit": "cover", "style": { "width": 120, "height": 120 } } ] }] }Luma inside each image (4px inset),
render --frame 0:At 64px the scale is exactly 1/10, so every sampled pixel lands on the same parity and the image renders solid white. At 120px it is black/white noise.
Seen in production on a video using portraits: 640px faces drawn as 52px avatars in a table had visibly jagged hair and glasses, and a wall of 400px portraits under a
zoom: 3.4camera move showed pixel blocks.Proposed fix
Choose the sampling from the on-screen size, camera included:
SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear);SamplingOptions::from(CubicResampler::mitchell())(cubic does not use mipmaps, so it is only right when enlarging).The on-screen width is
dst.width()times the scale ofcanvas.local_to_device_as_3x3(), which already includes the camera zoom. A shared helper inrustmotion-core::engine::renderer(draw_photo(canvas, image, dst, paint)) can replace thedraw_image_rectcall inimage.rsandvideo.rs. A local patch along those lines gives the "mipmapped" rows above.icon.rsalready does something similar withCubicResampler::mitchell().The other places that draw a bitmap with
Paint::default()are not covered by that patch and likely have the same problem:avatar.rs,avatar_group.rs,badge.rs,list.rs,stat.rs,mockup.rs,gif.rs,svg.rs(raster fallbacks),lottie.rs,engine/transition.rsand thesheetcommand.