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