Compare commits

..
3 changed files with 54 additions and 36 deletions
+18 -6
View File
@@ -12,6 +12,9 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
use crate::icons::{self, Icon};
/// Video shapes that get a decoder at once; more play their sound without a picture.
const MAX_VIDEO_FEEDS: usize = 16;
/// Who started a voice: the editor's control bar stops when you leave the slide, the slide show
/// manages its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -227,12 +230,21 @@ impl MediaHost {
audio: clip.volume > 0.0,
};
let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key);
if clip.video && (source_changed || !self.videos.contains_key(&id)) {
let feed = match VideoFeed::new(bytes.clone()) {
Ok(f) => Some(f),
Err(e) => {
log::info!("video {id}: {e}");
None
// A shape whose feed could not be had (the cap, a decode error) tries again on each play.
let feedless = self.videos.get(&id).is_some_and(|v| v.feed.is_none());
if clip.video && (source_changed || feedless || !self.videos.contains_key(&id)) {
// One decoder and frame queue per video shape: past the cap a shape plays its sound only.
let live = self.videos.iter().filter(|(vid, v)| **vid != id && v.feed.is_some()).count();
let feed = if live >= MAX_VIDEO_FEEDS {
log::info!("video {id}: more than {MAX_VIDEO_FEEDS} videos playing; showing no picture");
None
} else {
match VideoFeed::new(bytes.clone()) {
Ok(f) => Some(f),
Err(e) => {
log::info!("video {id}: {e}");
None
}
}
};
self.videos.insert(id, Video { feed, frame: None, tex: None });
+29 -7
View File
@@ -196,18 +196,40 @@ pub(super) fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Optio
]
}
/// Width of a slide-show grid thumbnail, in pixels.
const THUMB_WIDTH: f64 = 320.0;
/// Tallest grid thumbnail, so an extreme portrait slide cannot ask for a huge raster.
const THUMB_MAX_HEIGHT: f64 = 2048.0;
/// The scale and pixel size of a grid thumbnail of a `width` x `height` slide.
fn thumb_size(width: f64, height: f64) -> (f64, f64, f64) {
let (sw, sh) = (width.max(1.0), height.max(1.0));
let scale = (THUMB_WIDTH / sw).min(THUMB_MAX_HEIGHT / sh);
(scale, (sw * scale).round().max(1.0), (sh * scale).round().max(1.0))
}
pub(super) fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option<TextureHandle> {
if show.thumbs.len() >= GRID_CAP {
return None;
}
let w = 320.0_f64;
let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0);
let img = deckcraft_render::render_slide(
doc,
i,
&RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() },
);
// 320 wide, unless the slide is so tall that this would pass the height limit: fit that box.
let (scale, w, h) = thumb_size(doc.slide_size.width, doc.slide_size.height);
let img = deckcraft_render::render_slide(doc, i, &RenderOpts { scale, size: Some((w as u32, h as u32)), ..Default::default() });
let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false));
show.thumbs.push((i, tex.clone()));
Some(tex)
}
#[cfg(test)]
mod thumb_size_tests {
use super::*;
#[test]
fn ordinary_slides_are_320_wide_and_extreme_ones_stay_in_the_height_limit() {
let (scale, w, h) = thumb_size(960.0, 540.0);
assert_eq!((w, h), (320.0, 180.0));
assert!((scale - 320.0 / 960.0).abs() < 1e-12);
let (_, w, h) = thumb_size(1.0, 1.0e9);
assert!(h <= THUMB_MAX_HEIGHT && w >= 1.0);
}
}
+7 -23
View File
@@ -61,23 +61,16 @@ pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
files.iter().filter(|f| is_asset(f) && !patterns.iter().any(|p| glob(p, f))).cloned().collect()
}
/// `*` matches any run of characters except `/`; everything else literally. Matched with a
/// table over the pattern, so a pattern with many `*` costs pattern length times name length
/// rather than exploring every way to split the name.
/// `*` matches any run of characters except `/`; everything else literally.
fn glob(pat: &str, s: &str) -> bool {
let t: Vec<char> = s.chars().collect();
// `row[k]`: the pattern so far matches the first k characters of the name.
let mut row = vec![false; t.len() + 1];
row[0] = true;
for pc in pat.chars() {
let mut next = vec![false; t.len() + 1];
next[0] = pc == '*' && row[0];
for k in 1..=t.len() {
next[k] = if pc == '*' { row[k] || (next[k - 1] && t[k - 1] != '/') } else { row[k - 1] && t[k - 1] == pc };
match pat.split_once('*') {
None => pat == s,
Some((head, tail)) => {
let Some(rest) = s.strip_prefix(head) else { return false };
let ends = rest.char_indices().map(|(i, _)| i).chain(std::iter::once(rest.len()));
ends.take_while(|&i| !rest[..i].contains('/')).any(|i| glob(tail, &rest[i..]))
}
row = next;
}
row[t.len()]
}
/// Splits `git ls-files -z` output into real file names (no display quoting of Unicode or special characters).
@@ -127,15 +120,6 @@ mod tests {
assert_eq!(missing(&["assets/é.png".into()], "`assets/*.svg`"), vec!["assets/é.png".to_string()]);
}
#[test]
fn many_wildcards_on_a_failing_name_finish_at_once() {
let pat = format!("{}b", "a*".repeat(40));
assert!(!glob(&pat, &"a".repeat(60)));
assert!(glob(&pat, &format!("{}b", "a".repeat(60))));
assert!(!glob("a*b", "a/b"));
assert!(glob("a*b", "ab"));
}
#[test]
fn ls_files_output_is_nul_separated_and_unquoted() {
let names = parse_ls_files("assets/zażółć.png\0docs/images/a b\nc.png\0".as_bytes());