1use alpha2_media::{DecodedVideoFrame, PixelFormat, Plane, Rect};
6
7const SCORE_GRID: (u32, u32) = (128, 72);
9
10pub const THUMBNAIL_LUMA_RANGE: (f64, f64) = (0.08, 0.92);
13
14#[derive(Copy, Clone, Debug, PartialEq)]
16pub struct FrameScore {
17 pub mean_luma: f64,
19 pub sharpness: f64,
22}
23
24#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
30pub fn score_frame(frame: &DecodedVideoFrame) -> Result<FrameScore, String> {
31 let (columns, rows) = SCORE_GRID;
32 let crop = frame.visible_rect;
33 if crop.width == 0 || crop.height == 0 {
34 return Err("frame has no visible pixels".into());
35 }
36 let mut luma = Vec::with_capacity((columns * rows) as usize);
37 for row in 0..rows {
38 for column in 0..columns {
39 let x =
40 crop.x + (u64::from(column) * u64::from(crop.width) / u64::from(columns)) as u32;
41 let y = crop.y + (u64::from(row) * u64::from(crop.height) / u64::from(rows)) as u32;
42 luma.push(luma_at(frame, x, y)?);
43 }
44 }
45 let mean = luma.iter().sum::<f64>() / luma.len() as f64;
46 let at = |column: u32, row: u32| luma[(row * columns + column) as usize];
47 let mut laplacians = Vec::with_capacity(luma.len());
48 for row in 1..rows - 1 {
49 for column in 1..columns - 1 {
50 laplacians.push(
51 4.0 * at(column, row)
52 - at(column - 1, row)
53 - at(column + 1, row)
54 - at(column, row - 1)
55 - at(column, row + 1),
56 );
57 }
58 }
59 let average = laplacians.iter().sum::<f64>() / laplacians.len() as f64;
60 let sharpness = laplacians
61 .iter()
62 .map(|value| (value - average).powi(2))
63 .sum::<f64>()
64 / laplacians.len() as f64;
65 Ok(FrameScore {
66 mean_luma: mean,
67 sharpness,
68 })
69}
70
71#[must_use]
75pub fn choose_thumbnail(scores: &[FrameScore]) -> Option<usize> {
76 let (dark, bright) = THUMBNAIL_LUMA_RANGE;
77 let sharpest = |acceptable: &dyn Fn(&FrameScore) -> bool| {
78 scores
79 .iter()
80 .enumerate()
81 .filter(|(_, score)| acceptable(score))
82 .max_by(|(_, a), (_, b)| a.sharpness.total_cmp(&b.sharpness))
83 .map(|(index, _)| index)
84 };
85 sharpest(&|score| (dark..=bright).contains(&score.mean_luma)).or_else(|| sharpest(&|_| true))
86}
87
88fn luma_at(frame: &DecodedVideoFrame, x: u32, y: u32) -> Result<f64, String> {
91 let plane = frame.planes.first().ok_or("frame has no planes")?;
92 match frame.pixel_format {
93 PixelFormat::Yuv(format) => {
94 let wide = format.bit_depth > 8;
95 let value = f64::from(sample(plane, x, y, if wide { 2 } else { 1 }, 1, 0)?);
96 let full_scale = if format.msb_aligned {
98 65_535.0
99 } else {
100 f64::from((1_u32 << format.bit_depth) - 1)
101 };
102 Ok(value / full_scale)
103 }
104 PixelFormat::Rgba8 => {
105 let channel = |index| sample(plane, x, y, 1, 4, index).map(f64::from);
106 Ok((0.2126 * channel(0)? + 0.7152 * channel(1)? + 0.0722 * channel(2)?) / 255.0)
107 }
108 other => Err(format!("cannot read luma from {other:?}")),
109 }
110}
111
112fn sample(
116 plane: &Plane,
117 x: u32,
118 y: u32,
119 bytes: usize,
120 components: usize,
121 component: usize,
122) -> Result<u16, String> {
123 let offset = y as usize * plane.stride as usize + (x as usize * components + component) * bytes;
124 let slice = plane
125 .bytes
126 .get(offset..offset + bytes)
127 .ok_or("plane is shorter than its layout")?;
128 Ok(if bytes == 2 {
129 u16::from_le_bytes([slice[0], slice[1]])
130 } else {
131 u16::from(slice[0])
132 })
133}
134
135pub fn downscale_frame(
143 frame: &DecodedVideoFrame,
144 factor: u32,
145) -> Result<DecodedVideoFrame, String> {
146 if factor < 2 {
147 return Err("a proxy factor is 2 or more".into());
148 }
149 let crop = frame.visible_rect;
150 let (width, height) = ((crop.width / factor) & !1, (crop.height / factor) & !1);
151 if width == 0 || height == 0 {
152 return Err(format!(
153 "a {}x{} frame is too small to shrink by {factor}",
154 crop.width, crop.height
155 ));
156 }
157 let planes = match frame.pixel_format {
158 PixelFormat::Rgba8 => vec![shrink_plane(
159 plane(frame, 0)?,
160 (crop.x, crop.y),
161 (width, height),
162 factor,
163 1,
164 4,
165 )?],
166 PixelFormat::Yuv(format) => {
167 let bytes = if format.bit_depth > 8 { 2 } else { 1 };
168 let (shift_x, shift_y) = (
169 u32::from(format.chroma_shift_x),
170 u32::from(format.chroma_shift_y),
171 );
172 let chroma_size = (width.div_ceil(1 << shift_x), height.div_ceil(1 << shift_y));
173 let chroma_origin = (crop.x >> shift_x, crop.y >> shift_y);
174 let mut planes = vec![shrink_plane(
175 plane(frame, 0)?,
176 (crop.x, crop.y),
177 (width, height),
178 factor,
179 bytes,
180 1,
181 )?];
182 if format.interleaved_chroma {
183 planes.push(shrink_plane(
184 plane(frame, 1)?,
185 chroma_origin,
186 chroma_size,
187 factor,
188 bytes,
189 2,
190 )?);
191 } else {
192 for index in 1..frame.planes.len() {
193 planes.push(shrink_plane(
194 plane(frame, index)?,
195 chroma_origin,
196 chroma_size,
197 factor,
198 bytes,
199 1,
200 )?);
201 }
202 }
203 planes
204 }
205 other => return Err(format!("cannot shrink {other:?} frames")),
206 };
207 Ok(DecodedVideoFrame {
208 visible_rect: Rect {
209 x: 0,
210 y: 0,
211 width,
212 height,
213 },
214 planes,
215 ..frame.clone()
216 })
217}
218
219pub fn resize_rgba(
231 pixels: &[u8],
232 (width, height): (u32, u32),
233 (new_width, new_height): (u32, u32),
234) -> Result<Vec<u8>, String> {
235 let (columns, rows) = (width as usize, height as usize);
236 if width == 0 || height == 0 || new_width == 0 || new_height == 0 {
237 return Err("cannot resize an empty picture".to_owned());
238 }
239 if columns
240 .checked_mul(rows)
241 .and_then(|count| count.checked_mul(4))
242 != Some(pixels.len())
243 {
244 return Err("RGBA buffer does not match its size".to_owned());
245 }
246 let mut picture = pixels
248 .as_chunks::<4>()
249 .0
250 .iter()
251 .flat_map(|&[r, g, b, a]| {
252 let alpha = f32::from(a) / 255.0;
253 [
254 f32::from(r) / 255.0 * alpha,
255 f32::from(g) / 255.0 * alpha,
256 f32::from(b) / 255.0 * alpha,
257 alpha,
258 ]
259 })
260 .collect::<Vec<_>>();
261 let (mut current_columns, mut current_rows) = (columns, rows);
262 if new_width != width {
263 let taps = tent_taps(columns, new_width as usize);
264 let mut resized = vec![0.0_f32; new_width as usize * rows * 4];
265 for y in 0..rows {
266 for (x, row_taps) in taps.iter().enumerate() {
267 let out = &mut resized[(y * new_width as usize + x) * 4..][..4];
268 for &(source, weight) in row_taps {
269 for (channel, value) in out.iter_mut().enumerate() {
270 *value += weight * picture[(y * columns + source) * 4 + channel];
271 }
272 }
273 }
274 }
275 picture = resized;
276 current_columns = new_width as usize;
277 }
278 if new_height != height {
279 let taps = tent_taps(rows, new_height as usize);
280 let mut resized = vec![0.0_f32; current_columns * new_height as usize * 4];
281 for (y, column_taps) in taps.iter().enumerate() {
282 for x in 0..current_columns {
283 let out = &mut resized[(y * current_columns + x) * 4..][..4];
284 for &(source, weight) in column_taps {
285 for (channel, value) in out.iter_mut().enumerate() {
286 *value += weight * picture[(source * current_columns + x) * 4 + channel];
287 }
288 }
289 }
290 }
291 picture = resized;
292 current_rows = new_height as usize;
293 }
294 debug_assert_eq!(picture.len(), current_columns * current_rows * 4);
295 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
296 let code = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
297 Ok(picture
298 .as_chunks::<4>()
299 .0
300 .iter()
301 .flat_map(|&[r, g, b, a]| {
302 let colour = |value: f32| if a > 0.0 { code(value / a) } else { 0 };
303 [colour(r), colour(g), colour(b), code(a)]
304 })
305 .collect())
306}
307
308#[allow(
313 clippy::cast_precision_loss,
314 clippy::cast_possible_truncation,
315 clippy::cast_possible_wrap,
316 clippy::cast_sign_loss
317)]
318fn tent_taps(inputs: usize, outputs: usize) -> Vec<Vec<(usize, f32)>> {
319 let scale = inputs as f64 / outputs as f64;
320 let radius = scale.max(1.0);
321 (0..outputs)
322 .map(|output| {
323 let centre = (output as f64 + 0.5) * scale - 0.5;
324 let first = (centre - radius).floor() as i64;
325 let last = (centre + radius).ceil() as i64;
326 let mut taps = Vec::new();
327 for input in first..=last {
328 let weight = 1.0 - (input as f64 - centre).abs() / radius;
329 if weight <= 0.0 {
330 continue;
331 }
332 let clamped = input.clamp(0, inputs as i64 - 1) as usize;
333 match taps.iter_mut().find(|(index, _)| *index == clamped) {
334 Some((_, total)) => *total += weight,
335 None => taps.push((clamped, weight)),
336 }
337 }
338 let sum: f64 = taps.iter().map(|(_, weight)| weight).sum();
339 taps.into_iter()
340 .map(|(index, weight)| (index, (weight / sum) as f32))
341 .collect()
342 })
343 .collect()
344}
345
346fn plane(frame: &DecodedVideoFrame, index: usize) -> Result<&Plane, String> {
347 frame
348 .planes
349 .get(index)
350 .ok_or_else(|| format!("frame has no plane {index}"))
351}
352
353fn shrink_plane(
359 source: &Plane,
360 origin: (u32, u32),
361 size: (u32, u32),
362 factor: u32,
363 bytes: usize,
364 components: usize,
365) -> Result<Plane, String> {
366 let (width, height) = size;
367 let stride = width as usize * components * bytes;
368 let (source_width, source_height) = (source.width as usize, source.height as usize);
369 let source_stride = source.stride as usize;
370 let row_bytes = source_width * components * bytes;
371 if source_width == 0
372 || source_height == 0
373 || source_stride < row_bytes
374 || source.bytes.len() < (source_height - 1) * source_stride + row_bytes
375 {
376 return Err("plane is shorter than its layout".to_owned());
377 }
378 let columns = (0..width as usize)
380 .flat_map(|x| {
381 (0..factor as usize)
382 .map(move |dx| (origin.0 as usize + x * factor as usize + dx).min(source_width - 1))
383 })
384 .collect::<Vec<_>>();
385 let count = u64::from(factor * factor);
386 let mut sums = vec![0_u64; width as usize * components];
387 let mut output = Vec::with_capacity(stride * height as usize);
388 for y in 0..height as usize {
389 sums.fill(0);
390 for dy in 0..factor as usize {
391 let sy = (origin.1 as usize + y * factor as usize + dy).min(source_height - 1);
392 let row = &source.bytes[sy * source_stride..sy * source_stride + row_bytes];
393 if bytes == 2 {
394 accumulate(&mut sums, &columns, factor as usize, components, |at| {
395 u64::from(u16::from_le_bytes([row[2 * at], row[2 * at + 1]]))
396 });
397 } else {
398 accumulate(&mut sums, &columns, factor as usize, components, |at| {
399 u64::from(row[at])
400 });
401 }
402 }
403 for sum in &sums {
404 let mean = (sum + count / 2) / count;
405 if bytes == 2 {
406 output.extend_from_slice(&u16::try_from(mean).unwrap_or(u16::MAX).to_le_bytes());
407 } else {
408 output.push(u8::try_from(mean).unwrap_or(u8::MAX));
409 }
410 }
411 }
412 Ok(Plane {
413 bytes: output.into(),
414 stride: u32::try_from(stride).map_err(|_| "plane is too wide")?,
415 width,
416 height,
417 })
418}
419
420fn accumulate(
424 sums: &mut [u64],
425 columns: &[usize],
426 factor: usize,
427 components: usize,
428 read: impl Fn(usize) -> u64,
429) {
430 for (x, sources) in columns.chunks_exact(factor).enumerate() {
431 for component in 0..components {
432 sums[x * components + component] += sources
433 .iter()
434 .map(|column| read(column * components + component))
435 .sum::<u64>();
436 }
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use alpha2_media::{ChromaLocation, Orientation, SampleAspectRatio, YuvFormat};
444 use alpha2_time::MediaTime;
445
446 fn frame(width: u32, height: u32, luma: impl Fn(u32, u32) -> u8) -> DecodedVideoFrame {
448 let y_plane = Plane {
449 bytes: (0..height)
450 .flat_map(|y| (0..width).map(move |x| (x, y)))
451 .map(|(x, y)| luma(x, y))
452 .collect::<Vec<_>>()
453 .into(),
454 stride: width,
455 width,
456 height,
457 };
458 let chroma = |value| Plane {
459 bytes: vec![value; (width.div_ceil(2) * height.div_ceil(2)) as usize].into(),
460 stride: width.div_ceil(2),
461 width: width.div_ceil(2),
462 height: height.div_ceil(2),
463 };
464 DecodedVideoFrame {
465 pts: MediaTime::ZERO,
466 dts: None,
467 duration: None,
468 visible_rect: Rect {
469 x: 0,
470 y: 0,
471 width,
472 height,
473 },
474 sample_aspect_ratio: SampleAspectRatio::default(),
475 orientation: Orientation::UPRIGHT,
476 pixel_format: PixelFormat::Yuv(YuvFormat::I420),
477 chroma_location: ChromaLocation::default(),
478 color: None,
479 planes: vec![y_plane, chroma(128), chroma(128)],
480 }
481 }
482
483 #[test]
484 fn the_thumbnail_is_the_sharpest_frame_neither_black_nor_white() {
485 let black = frame(128, 72, |_, _| 5);
486 let white = frame(
488 128,
489 72,
490 |x, y| {
491 if x % 4 == 0 && y % 4 == 0 { 0 } else { 255 }
492 },
493 );
494 let soft = frame(128, 72, |x, _| {
495 u8::try_from(40 + x * 150 / 128).expect("small")
496 });
497 let detailed = frame(
498 128,
499 72,
500 |x, y| {
501 if (x / 4 + y / 4) % 2 == 0 { 40 } else { 200 }
502 },
503 );
504 let scores =
505 [&black, &white, &soft, &detailed].map(|frame| score_frame(frame).expect("score"));
506 assert!(scores[0].mean_luma < THUMBNAIL_LUMA_RANGE.0);
507 assert!(scores[1].mean_luma > THUMBNAIL_LUMA_RANGE.1);
508 assert!(scores[3].sharpness > scores[2].sharpness);
509 assert!(scores[1].sharpness > scores[3].sharpness);
510 assert_eq!(choose_thumbnail(&scores), Some(3));
511 assert_eq!(choose_thumbnail(&scores[..2]), Some(1));
513 assert_eq!(choose_thumbnail(&[]), None);
514 }
515
516 #[test]
517 fn a_proxy_averages_blocks_and_keeps_an_even_size() {
518 let source = frame(10, 6, |x, _| u8::try_from(x * 20).expect("small"));
521 let half = downscale_frame(&source, 2).expect("proxy");
522 assert_eq!((half.visible_rect.width, half.visible_rect.height), (4, 2));
523 assert_eq!(&half.planes[0].bytes[..4], &[10, 50, 90, 130]);
524 assert_eq!((half.planes[1].width, half.planes[1].height), (2, 1));
525 assert!(half.planes[1].bytes.iter().all(|value| *value == 128));
526 assert!(downscale_frame(&source, 1).is_err());
527 assert!(downscale_frame(&frame(3, 3, |_, _| 0), 4).is_err());
528 }
529
530 #[test]
531 fn a_resize_keeps_flat_colour_weighs_alpha_and_leaves_a_kept_axis_alone() {
532 let rgba = |pixels: &[[u8; 4]]| pixels.iter().flatten().copied().collect::<Vec<_>>();
533 let flat = rgba(&[[200, 100, 50, 255]; 60 * 4]);
534 for size in [(64, 4), (32, 4), (60, 7), (17, 3)] {
535 let resized = resize_rgba(&flat, (60, 4), size).expect("resize");
536 assert!(
537 resized.chunks(4).all(|pixel| pixel == [200, 100, 50, 255]),
538 "{size:?}"
539 );
540 }
541 let half = resize_rgba(&rgba(&[[255, 0, 0, 255], [0, 0, 255, 0]]), (2, 1), (1, 1))
544 .expect("resize");
545 assert_eq!(half, [255, 0, 0, 128]);
546 let ramp = rgba(&[[0, 0, 0, 255], [200, 200, 200, 255]]);
548 let stretched = resize_rgba(&ramp, (2, 1), (4, 1)).expect("resize");
549 assert_eq!(
550 stretched
551 .chunks(4)
552 .map(|pixel| pixel[0])
553 .collect::<Vec<_>>(),
554 [0, 50, 150, 200]
555 );
556 assert!(resize_rgba(&ramp, (2, 1), (0, 1)).is_err());
557 assert!(resize_rgba(&ramp, (3, 1), (4, 1)).is_err());
558 }
559
560 fn reference_shrink_plane(
565 source: &Plane,
566 origin: (u32, u32),
567 size: (u32, u32),
568 factor: u32,
569 bytes: usize,
570 components: usize,
571 ) -> Result<Plane, String> {
572 let (width, height) = size;
573 let stride = width as usize * components * bytes;
574 let mut output = Vec::with_capacity(stride * height as usize);
575 for y in 0..height {
576 for x in 0..width {
577 for component in 0..components {
578 let mut sum = 0_u64;
579 let mut count = 0_u64;
580 for dy in 0..factor {
581 for dx in 0..factor {
582 let sx =
583 (origin.0 + x * factor + dx).min(source.width.saturating_sub(1));
584 let sy =
585 (origin.1 + y * factor + dy).min(source.height.saturating_sub(1));
586 sum += u64::from(sample(source, sx, sy, bytes, components, component)?);
587 count += 1;
588 }
589 }
590 let mean = (sum + count / 2) / count;
591 if bytes == 2 {
592 output.extend_from_slice(
593 &u16::try_from(mean).unwrap_or(u16::MAX).to_le_bytes(),
594 );
595 } else {
596 output.push(u8::try_from(mean).unwrap_or(u8::MAX));
597 }
598 }
599 }
600 }
601 Ok(Plane {
602 bytes: output.into(),
603 stride: u32::try_from(stride).map_err(|_| "plane is too wide")?,
604 width,
605 height,
606 })
607 }
608
609 #[test]
612 fn shrinking_by_rows_gives_the_samples_sample_by_sample_did() {
613 let plane = |width: u32, height: u32, bytes: usize, components: usize| {
614 let stride = width as usize * components * bytes + 3;
615 Plane {
616 bytes: (0..stride * height as usize)
617 .map(|index| u8::try_from((index * 7919 + index / 13) % 251).expect("byte"))
618 .collect::<Vec<_>>()
619 .into(),
620 stride: u32::try_from(stride).expect("stride"),
621 width,
622 height,
623 }
624 };
625 for (width, height) in [(17, 9), (64, 48), (1, 1)] {
626 for (bytes, components) in [(1, 1), (2, 1), (1, 2), (2, 2)] {
627 let source = plane(width, height, bytes, components);
628 for factor in [2, 4] {
629 for origin in [(0, 0), (3, 1)] {
630 let size = ((width / factor).max(1), (height / factor).max(1));
631 let expected = reference_shrink_plane(
632 &source, origin, size, factor, bytes, components,
633 )
634 .expect("reference");
635 let found = shrink_plane(&source, origin, size, factor, bytes, components)
636 .expect("rows");
637 assert_eq!(
638 found.bytes, expected.bytes,
639 "{width}x{height} {bytes}B x{components} /{factor} at {origin:?}"
640 );
641 }
642 }
643 }
644 }
645 }
646}