Merge remote-tracking branch 'upstream/main' into docking-frame-trial

This commit is contained in:
aiongg
2026-10-26 02:08:32 +00:00
55 changed files with 1205 additions and 564 deletions
Generated
+370 -180
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -84,6 +84,7 @@ craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs",
egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "37c907d23e608aaa098d7a48fa483670e90c6f33", features = ["egui"] }
rfd = "0.17"
[workspace.lints.rust]
+1 -1
View File
@@ -264,7 +264,7 @@ pub fn install_hooks(app: &mut FilmcraftApp) {
// Saving writes the file in memory and offers it as a download (`fs::WebServices`).
app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}"))));
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| {
let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy());
let file = std::path::Path::new(name).file_name().map_or(name.into(), std::ffi::OsStr::to_string_lossy);
Some(format!("/exports/{file}"))
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the
+7 -1
View File
@@ -44,7 +44,7 @@ open = "5"
[dev-dependencies]
glob = "0.3"
# graphics.rs: enumerate the real adapters in a test.
pollster = "0.4"
pollster = "1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
@@ -54,6 +54,9 @@ objc2 = "0.6"
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
# portal file dialogs (rfd) and accesskit already use.
@@ -62,3 +65,6 @@ zbus = "5.19"
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+17 -1
View File
@@ -78,6 +78,11 @@ fn tell(text: &str, error: bool) {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FILMCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up record are logged (`logging`).
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
@@ -249,6 +254,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
}
}
let mut app = FilmcraftApp::new(session);
app.set_startup_update_check(true);
app.graphics_notice = notice;
if recover == Some(false) {
app.dialog = None;
@@ -290,7 +296,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
let path = std::path::Path::new(name);
let mut dialog = rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy()));
.set_file_name(path.file_name().map_or(name.into(), std::ffi::OsStr::to_string_lossy));
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
dialog = dialog.set_directory(dir);
}
@@ -436,6 +442,16 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
b.with_activate_ignoring_other_apps(false);
}));
}
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; FILMCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("FILMCRAFT_WAYLAND").is_none() {
return Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
let _ = agent;
None
}
+1 -1
View File
@@ -17,7 +17,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] }
base64 = "0.22"
base64 = "0.23"
schemars = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
tempfile = "3"
+3 -2
View File
@@ -4,6 +4,7 @@
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
//! short polls, so other requests are answered while the export runs.
use std::ffi::OsStr;
use std::path::Path;
use std::time::{Duration, SystemTime};
@@ -118,10 +119,10 @@ impl FilmcraftMcp {
/// never touched, even when they were saved during the export.
pub fn remove_partial(path: &str, since: SystemTime) {
let p = Path::new(path);
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(OsStr::to_str), p.file_name().and_then(OsStr::to_str)) else {
return;
};
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
let ext = p.extension().and_then(OsStr::to_str).unwrap_or_default();
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let Ok(entries) = std::fs::read_dir(dir) else { return };
// file times are coarser than the clock
+13 -7
View File
@@ -337,10 +337,12 @@ impl AviSource {
let mut audio_streams = Vec::new();
for &s in &astreams {
let Some(st) = file.streams.get(s) else { continue };
let w = st.audio.clone().unwrap_or_default();
let mut w = st.audio.clone().unwrap_or_default();
let Ok((sample_rate, channels)) = filmcraft_media::bounded_audio_dimensions(w.sample_rate, u32::from(w.channels)) else { continue };
w.sample_rate = sample_rate;
w.channels = u16::try_from(channels).unwrap_or(u16::MAX);
let (codec, label) = audio_codec(&w);
let mut sample_rate = w.sample_rate.max(1);
let mut channels = u32::from(w.channels.max(1));
let (mut sample_rate, mut channels) = (sample_rate, channels);
let (pieces, first, starts) = match &codec {
ACodec::Pcm(cfg) => {
let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize);
@@ -537,14 +539,18 @@ impl MediaSource for AviSource {
return Err(MediaError::Unsupported(format!("{name} audio")));
}
let ch = ainfo.channels.max(1) as usize;
let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1));
let source_frames = filmcraft_media::resample_source_frames(frames, ainfo.sample_rate, sample_rate)?;
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate);
let s0 = (start as f64 * ratio).floor() as i64;
let need = (frames as f64 * ratio).ceil() as i64 + 2;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1);
while let Some(&pk_start) = a.starts.get(i) {
if pk_start >= s0 + need {
if pk_start >= s0.saturating_add(need_i64) {
break;
}
let pk = self.audio_packet(a, &mut st, ainfo, i)?;
+4 -4
View File
@@ -463,7 +463,7 @@ const CONTINUE_THROUGH: usize = 48;
fn decode_pool() -> Option<&'static rayon::ThreadPool> {
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
POOL.get_or_init(|| {
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let n = std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1);
rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok()
})
.as_ref()
@@ -494,7 +494,7 @@ fn isolated<T: Send>(f: impl FnOnce() -> crate::Result<T> + Send) -> crate::Resu
let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take();
job.map(|job| pool.install(job))
});
spawned.map(|h| h.join())
spawned.map(std::thread::ScopedJoinHandle::join)
});
return match waited {
Ok(Ok(Some(r))) => r,
@@ -672,7 +672,7 @@ impl GopCache {
// after the guard on every return path: dropping a decoder can block (a hardware session
// waits for its frames in flight) and must not hold this cache's lock.
let mut released: Vec<Box<dyn VideoDecoder>> = Vec::new();
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let mut st = self.shared.state.lock().unwrap_or_else(PoisonError::into_inner);
let _decoding = DecodingGuard::enter(me);
st.last_used = Instant::now();
st.pin = want_pts;
@@ -875,7 +875,7 @@ impl GopCache {
count_frames(&out);
out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::<Vec<_>>()
});
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let mut st = self.shared.state.lock().unwrap_or_else(PoisonError::into_inner);
if st.spare.len() < 16 {
st.spare.push(dec);
}
+25 -18
View File
@@ -142,6 +142,20 @@ fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 {
i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX)
}
/// The stream description of an audio track, `None` when its advertised sample rate or channel
/// count is out of range (see [`filmcraft_media::bounded_audio_dimensions`]).
fn audio_info(t: &filmcraft_matroska::Track) -> Option<AudioStreamInfo> {
let a = t.audio.clone().unwrap_or_default();
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
let mut channels = (a.channels as u32).max(1);
if let Some(h) = opus_head(&t.codec) {
rate = crate::audio::OPUS_RATE;
channels = h.channels as u32;
}
let (rate, channels) = filmcraft_media::bounded_audio_dimensions(rate, channels).ok()?;
Some(AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) })
}
/// The track timestamp that media time zero stands for (#715): the earliest start among the
/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps
/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture
@@ -339,7 +353,9 @@ impl MkvSource {
.tracks
.iter()
.enumerate()
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty())
// A track whose advertised dimensions are refused is no stream at all, so `atracks[k]` and
// `info.audio_streams[k]` stay the same track.
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty() && audio_info(t).is_some())
.map(|(i, _)| i)
.take(filmcraft_media::MAX_AUDIO_STREAMS)
.collect();
@@ -407,20 +423,7 @@ impl MkvSource {
hdr: v.color.as_ref().and_then(hdr_metadata),
}
});
let audio_streams: Vec<AudioStreamInfo> = atracks
.iter()
.filter_map(|&i| file.tracks.get(i))
.map(|t| {
let a = t.audio.clone().unwrap_or_default();
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
let mut channels = (a.channels as u32).max(1);
if let Some(h) = opus_head(&t.codec) {
rate = crate::audio::OPUS_RATE;
channels = h.channels as u32;
}
AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }
})
.collect();
let audio_streams: Vec<AudioStreamInfo> = atracks.iter().filter_map(|&i| file.tracks.get(i)).filter_map(audio_info).collect();
let info = MediaInfo {
name: name.to_string(),
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
@@ -628,15 +631,19 @@ impl MediaSource for MkvSource {
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?;
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
let ratio = src_rate as f64 / sample_rate as f64;
let s0 = (start as f64 * ratio).floor() as i64;
let need = (frames as f64 * ratio).ceil() as i64 + 2;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1);
while i < n_packets {
let Some(&pk_start) = a.starts.get(i) else { break };
if pk_start >= s0 + need {
if pk_start >= s0.saturating_add(need_i64) {
break;
}
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
+9 -4
View File
@@ -484,20 +484,24 @@ impl MediaSource for Mp4Source {
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?;
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
let ratio = src_rate as f64 / sample_rate as f64;
let p0 = (start as f64 * ratio).floor() as i64;
// (in i128: a hostile edit list can make the offset large enough to overflow i64)
let offset = i128::from(a.offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX });
let need = (frames as f64 * ratio).ceil() as i64 + 2;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
let mut i = first;
while let Some(sample) = track.samples.get(i) {
let Some(&pk_start) = a.starts.get(i) else { break };
if pk_start >= s0.saturating_add(need) {
if pk_start >= s0.saturating_add(need_i64) {
break;
}
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
@@ -589,7 +593,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option
}
c => codec_label(c),
};
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits })
let (rate, ch) = filmcraft_media::bounded_audio_dimensions(rate, ch).ok()?;
Some(AudioStreamInfo { sample_rate: rate, channels: ch, codec, bits_per_sample: bits })
}
fn codec_label(c: &CodecConfig) -> String {
+19 -7
View File
@@ -330,7 +330,7 @@ impl MxfSource {
if let Some(&a0) = atracks.first() {
let si0 = file.tracks[a0].sound.clone().unwrap_or_default();
let rate = si0.sample_rate;
let mut channels = 0;
let mut channels: u32 = 0;
for &ai in &atracks {
let t = &file.tracks[ai];
let Some(si) = t.sound.as_ref() else { continue };
@@ -342,11 +342,18 @@ impl MxfSource {
let stored = t.stored_sample_frames().saturating_sub(first);
let presented = t.duration.map_or(stored, |du| ((du as i128 * n as i128 / d.max(1) as i128) as u64).min(stored));
aframes = aframes.min(presented);
channels += si.channels.max(1);
let next =
channels.checked_add(si.channels.max(1)).ok_or_else(|| CodecError::Unsupported("MXF: combined audio channel count overflows".into()))?;
if next > filmcraft_media::MAX_AUDIO_CHANNELS {
return Err(CodecError::Unsupported(format!("MXF: combined audio exceeds {} channels", filmcraft_media::MAX_AUDIO_CHANNELS)));
}
channels = next;
combined.push((ai, first));
}
let sr = if rate.is_valid() { (rate.num as f64 / rate.den as f64).round() as u32 } else { 48_000 };
audio = Some(AudioStreamInfo { sample_rate: sr.max(1), channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) });
if let Ok((sr, channels)) = filmcraft_media::bounded_audio_dimensions(sr, channels) {
audio = Some(AudioStreamInfo { sample_rate: sr, channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) });
}
}
if aframes == u64::MAX {
aframes = 0;
@@ -461,15 +468,20 @@ impl MediaSource for MxfSource {
let ainfo = self.info.audio().ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
let exact = src_rate == sample_rate;
let source_frames = if exact { frames } else { filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)? };
let need =
if exact { source_frames } else { source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))? };
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
let ratio = src_rate as f64 / sample_rate as f64;
let exact = src_rate == sample_rate;
let s0 = if exact { start } else { (start as f64 * ratio).floor() as i64 };
let need = if exact { frames as i64 } else { (frames as f64 * ratio).ceil() as i64 + 2 };
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
// clip the request to the presented range [0, aframes)
let a = s0.max(0);
let b = (s0 + need).min(self.aframes as i64);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let b = s0.saturating_add(need_i64).min(self.aframes as i64);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
if b > a {
let mut c0 = 0;
for &(ti, first) in &self.atracks {
+1 -1
View File
@@ -311,7 +311,7 @@ pub fn decode(data: &[u8], max_samples: usize) -> Result<Decoded> {
}
let mut hufs = Vec::with_capacity(comps.len());
for c in &comps {
hufs.push(tables.get(c.table).and_then(|t| t.as_ref()).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?);
hufs.push(tables.get(c.table).and_then(Option::as_ref).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?);
}
let samples = scan(data, pos + len, &hufs, width, height, precision, predictor, pt, restart_interval)?;
return Ok(Decoded { width, height, components: comps.len(), precision, predictor, samples });
+51 -39
View File
@@ -72,48 +72,60 @@ fn sweep() {
for n in depth.truncations(seed.len()) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
// The header (and the start of the scan-line index): every byte to extreme values, every bit.
for p in 0..seed.len().min(0x300) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
header_flips(&depth, seed, si);
big_fields(seed, si);
random_rounds(&depth, seed, si);
}
}
/// The header (and the start of the scan-line index): every byte to extreme values, every bit.
fn header_flips(depth: &Depth, seed: &[u8], si: usize) {
for p in 0..seed.len().min(0x300) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
let mut d = seed.to_vec();
d[p] = v;
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
}
}
}
/// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
fn big_fields(seed: &[u8], si: usize) {
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
let mut d = seed.to_vec();
if let Some(s) = d.get_mut(off..off + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
}
}
fn random_rounds(depth: &Depth, seed: &[u8], si: usize) {
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(off..off + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run(&d, &format!("seed {si} random {i}"));
}
if depth.round(i) {
run(&d, &format!("seed {si} random {i}"));
}
}
}
+33 -33
View File
@@ -17,7 +17,7 @@
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_media::generators::GeneratorSource;
@@ -147,24 +147,24 @@ fn file_name(path: &str) -> String {
impl MediaPool {
pub fn register_opener(&self, o: Opener) {
self.openers.write().unwrap_or_else(|e| e.into_inner()).push(o);
self.openers.write().unwrap_or_else(PoisonError::into_inner).push(o);
}
/// Cache a source for an item, valid whatever the item's reference (generators).
pub fn insert(&self, item: ItemId, src: SharedSource) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (String::new(), src));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (String::new(), src));
}
/// Cache a source for an item under its [`media_key`].
pub fn insert_keyed(&self, item: ItemId, key: String, src: SharedSource) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src));
}
/// Cache a source opened from `path` for an item.
pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) {
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
let src = self.unsupported_as_slate(item, path, src);
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (format!("file:{path}"), src));
}
/// `src` (opened from `path` for `item`) showing the unreadable slate for the frames it
@@ -175,9 +175,9 @@ impl MediaPool {
}
pub fn remove(&self, item: ItemId) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.proxies.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.sources.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
self.proxies.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
}
/// Record the media and proxy paths of `p`, loaded from the document at `document` (None: one
@@ -194,7 +194,7 @@ impl MediaPool {
/// [`Self::add_stored`] for single paths.
pub fn add_stored_paths(&self, paths: impl IntoIterator<Item = String>, document: Option<&str>) {
let document = document.filter(|d| !d.is_empty()).map(PathBuf::from);
let mut stored = self.stored.write().unwrap_or_else(|e| e.into_inner());
let mut stored = self.stored.write().unwrap_or_else(PoisonError::into_inner);
for p in paths {
stored.entry(p).or_insert_with(|| document.clone());
}
@@ -202,12 +202,12 @@ impl MediaPool {
/// Forget the stored paths (a new project).
pub fn clear_stored(&self) {
self.stored.write().unwrap_or_else(|e| e.into_inner()).clear();
self.stored.write().unwrap_or_else(PoisonError::into_inner).clear();
}
/// The user picked `path` in this session (import, Link Media): open it as given.
pub fn allow(&self, path: &str) {
self.stored.write().unwrap_or_else(|e| e.into_inner()).remove(path);
self.stored.write().unwrap_or_else(PoisonError::into_inner).remove(path);
}
/// The file to open for `path`, a media or proxy path of the project: a stored path as
@@ -217,7 +217,7 @@ impl MediaPool {
if cfg!(target_arch = "wasm32") {
return Ok(path.to_string());
}
match self.stored.read().unwrap_or_else(|e| e.into_inner()).get(path) {
match self.stored.read().unwrap_or_else(PoisonError::into_inner).get(path) {
Some(document) => crate::doc_path::resolve(path, document.as_deref()),
None => Ok(path.to_string()),
}
@@ -230,18 +230,18 @@ impl MediaPool {
/// Forget every opened source (files are re-read on next use).
pub fn clear(&self) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).clear();
self.proxies.write().unwrap_or_else(|e| e.into_inner()).clear();
self.offline.write().unwrap_or_else(|e| e.into_inner()).clear();
self.sources.write().unwrap_or_else(PoisonError::into_inner).clear();
self.proxies.write().unwrap_or_else(PoisonError::into_inner).clear();
self.offline.write().unwrap_or_else(PoisonError::into_inner).clear();
}
pub fn cached(&self, item: ItemId) -> Option<SharedSource> {
self.sources.read().unwrap_or_else(|e| e.into_inner()).get(&item).map(|(_, s)| s.clone())
self.sources.read().unwrap_or_else(PoisonError::into_inner).get(&item).map(|(_, s)| s.clone())
}
/// Sources opened so far (originals and proxies; `perf.stats`).
pub fn open_sources(&self) -> usize {
self.sources.read().unwrap_or_else(|e| e.into_inner()).len() + self.proxies.read().unwrap_or_else(|e| e.into_inner()).len()
self.sources.read().unwrap_or_else(PoisonError::into_inner).len() + self.proxies.read().unwrap_or_else(PoisonError::into_inner).len()
}
pub fn use_proxies(&self) -> bool {
@@ -267,12 +267,12 @@ impl MediaPool {
/// Why an item last rendered offline (None = online or not opened yet).
pub fn offline_status(&self, item: ItemId) -> Option<OfflineStatus> {
self.offline.read().unwrap_or_else(|e| e.into_inner()).get(&item).cloned()
self.offline.read().unwrap_or_else(PoisonError::into_inner).get(&item).cloned()
}
/// Open a file through the registered openers.
pub fn open_bytes(&self, name: &str, bytes: Arc<[u8]>) -> Result<SharedSource, MediaError> {
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone();
filmcraft_media::open_bytes(name, bytes, &openers)
}
@@ -281,7 +281,7 @@ impl MediaPool {
pub fn open_file(&self, path: &str, services: &dyn Services) -> Result<SharedSource, MediaError> {
let io = |e: std::io::Error| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) };
if let Some(r) = services.reader(path) {
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone();
return filmcraft_media::reader::open_reader(&file_name(path), r.map_err(io)?, &filmcraft_codecs::reader_openers(), &openers);
}
let bytes = services.read_file(path).map_err(io)?;
@@ -294,7 +294,7 @@ impl MediaPool {
pub fn probe_file(&self, path: &str, services: &dyn Services) -> Result<SharedSource, MediaError> {
let Some(r) = services.reader(path) else { return self.open_file(path, services) };
let r = r.map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) })?;
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone();
filmcraft_media::reader::open_reader_within(&file_name(path), r, &filmcraft_codecs::reader_openers(), &openers, PROBE_WHOLE_FILE_MAX)
}
@@ -319,7 +319,7 @@ impl MediaPool {
return Some(s);
}
let key = media_key(m);
if let Some((k, s)) = self.sources.read().unwrap_or_else(|e| e.into_inner()).get(&item)
if let Some((k, s)) = self.sources.read().unwrap_or_else(PoisonError::into_inner).get(&item)
&& (k.is_empty() || *k == key)
{
return Some(s.clone());
@@ -342,7 +342,7 @@ impl MediaPool {
}
}) {
Ok(s) => {
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
self.unsupported_as_slate(item, path, s)
}
Err(_) if filmcraft_media::pending::is_set() => {
@@ -357,17 +357,17 @@ impl MediaPool {
}
},
};
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone()));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src.clone()));
Some(src)
}
fn set_offline(&self, item: ItemId, reason: OfflineReason, path: &str, error: &str) {
self.offline.write().unwrap_or_else(|e| e.into_inner()).insert(item, OfflineStatus { reason, path: path.to_string(), error: error.to_string() });
self.offline.write().unwrap_or_else(PoisonError::into_inner).insert(item, OfflineStatus { reason, path: path.to_string(), error: error.to_string() });
}
fn proxy_source(&self, item: ItemId, path: &str, m: &MediaClip, services: &dyn Services) -> Option<SharedSource> {
let key = format!("proxy:{path}");
if let Some((k, s)) = self.proxies.read().unwrap_or_else(|e| e.into_inner()).get(&item)
if let Some((k, s)) = self.proxies.read().unwrap_or_else(PoisonError::into_inner).get(&item)
&& *k == key
{
return Some(s.clone());
@@ -387,7 +387,7 @@ impl MediaPool {
None
};
let src: SharedSource = Arc::new(ProxySource { proxy: s, info: m.info.clone(), audio_from });
self.proxies.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone()));
self.proxies.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src.clone()));
Some(src)
}
Err(e) => {
@@ -432,7 +432,7 @@ impl PoolProvider {
/// item order (subclips count as their media). Empty when not recording.
pub fn offline_used(&self) -> Vec<OfflineMedia> {
let Some(used) = &self.used else { return Vec::new() };
let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone();
let used = used.lock().unwrap_or_else(PoisonError::into_inner).clone();
let media: BTreeSet<ItemId> = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect();
media
.into_iter()
@@ -453,7 +453,7 @@ impl PoolProvider {
impl filmcraft_render::SourceProvider for PoolProvider {
fn source(&self, item: ItemId) -> Option<SharedSource> {
if let Some(used) = &self.used {
used.lock().unwrap_or_else(|e| e.into_inner()).insert(item);
used.lock().unwrap_or_else(PoisonError::into_inner).insert(item);
}
if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) }
}
@@ -485,7 +485,7 @@ impl MediaSource for SlateSource {
let v = self.info.video.as_ref().ok_or(MediaError::NoStream("video"))?;
let s = req.scale.clamp(1.0 / 64.0, 1.0);
let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2));
let mut last = self.last.lock().unwrap_or_else(|e| e.into_inner());
let mut last = self.last.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(f) = last.as_ref()
&& f.width == w
&& f.height == h
@@ -521,7 +521,7 @@ impl MediaSource for UnsupportedAsSlate {
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
match self.inner.video_frame(req) {
Err(MediaError::Unsupported(why)) => {
self.offline.write().unwrap_or_else(|e| e.into_inner()).entry(self.item).or_insert_with(|| {
self.offline.write().unwrap_or_else(PoisonError::into_inner).entry(self.item).or_insert_with(|| {
log::warn!("media unreadable: {}: {why}", self.path);
OfflineStatus { reason: OfflineReason::Unreadable, path: self.path.clone(), error: why }
});
@@ -560,7 +560,7 @@ impl MediaSource for DamagedAsSlate {
}
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
let r = self.inner.video_frame(req);
let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner());
let mut failures = self.failures.lock().unwrap_or_else(PoisonError::into_inner);
match r {
Ok(f) => {
failures.remove(&self.item);
+10 -4
View File
@@ -97,7 +97,13 @@ pub fn transcode(
preset: &Preset,
progress: &Progress,
) -> std::result::Result<Report, String> {
transcode_with_file(clip, name, src, range, out, None, preset, progress)
transcode_with_file(clip, name, src, range, Dest { path: out, file: None }, preset, progress)
}
/// Output path, plus the already-open file to write into when the caller holds one.
struct Dest<'a> {
path: &'a str,
file: Option<Arc<std::fs::File>>,
}
fn transcode_with_file(
@@ -105,11 +111,11 @@ fn transcode_with_file(
name: &str,
src: SharedSource,
range: Option<TimeRange>,
out: &str,
output_file: Option<Arc<std::fs::File>>,
dest: Dest<'_>,
preset: &Preset,
progress: &Progress,
) -> std::result::Result<Report, String> {
let Dest { path: out, file: output_file } = dest;
let info = &clip.info;
let rate = clip.frame_rate();
let mut st = SequenceSettings::default();
@@ -280,7 +286,7 @@ fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput,
}
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("{name} ({} of {n})", k + 1);
let out = output.path().to_string_lossy().into_owned();
match transcode_with_file(&clip, &name, src, None, &out, output.file(), pr, &prog) {
match transcode_with_file(&clip, &name, src, None, Dest { path: &out, file: output.file() }, pr, &prog) {
Ok(r) => {
bytes += r.bytes;
output.preserve();
+55 -45
View File
@@ -35,7 +35,7 @@ pub use pipeline::limit_rgba8;
pub use presets::{ExportPreset, builtin_presets};
pub use settings::*;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use filmcraft_isobmff::SampleEntry;
use filmcraft_project::{ItemId, Project};
@@ -623,6 +623,42 @@ fn default_crf() -> f32 {
}
impl ExportSettings {
fn validate_audio_settings(&self) -> Result<()> {
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
Ok(())
}
fn validate_bitrate_mode(&self) -> Result<()> {
if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass {
return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
}
if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass {
return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
}
if self.bitrate_mode == BitrateMode::Crf {
if self.format == Format::Hevc {
return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if self.format == Format::Av1 {
return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
}
}
Ok(())
}
/// Reject settings the encoders cannot honour.
pub fn validate(&self) -> Result<()> {
if let Some(range) = self.range {
@@ -644,40 +680,14 @@ impl ExportSettings {
{
return Err(ExportError::Unsupported("output frame rate must be positive and at most 1000 fps".into()));
}
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
self.validate_audio_settings()?;
if self.field_order != FieldOrder::Progressive && self.has_video() {
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
}
if self.effects.image_overlay.enabled && self.effects.image_overlay.path.trim().is_empty() {
return Err(ExportError::Unsupported("image overlay: no image file chosen".into()));
}
if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass {
return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
}
if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass {
return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
}
if self.bitrate_mode == BitrateMode::Crf {
if self.format == Format::Hevc {
return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if self.format == Format::Av1 {
return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
}
}
self.validate_bitrate_mode()?;
Ok(())
}
}
@@ -713,7 +723,7 @@ impl Progress {
/// [`Progress::eta`] at a given time (tests, and hosts with a clock of their own).
pub fn eta_at(&self, now: web_time::Instant) -> Option<std::time::Duration> {
let (done, total) = (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed));
let mut pace = self.pace.lock().unwrap_or_else(|e| e.into_inner());
let mut pace = self.pace.lock().unwrap_or_else(PoisonError::into_inner);
pace.observe(now, done);
if self.finished.load(Ordering::Relaxed) || done >= total {
return None;
@@ -721,7 +731,7 @@ impl Progress {
pace.eta(total - done)
}
fn set_status(&self, s: impl Into<String>) {
*self.status.lock().unwrap_or_else(|e| e.into_inner()) = s.into();
*self.status.lock().unwrap_or_else(PoisonError::into_inner) = s.into();
}
}
@@ -918,7 +928,7 @@ pub fn timed<T>(stage: Stage, f: impl FnOnce() -> T) -> T {
/// Register a video encoder factory (tried before the built-in ones and those registered earlier).
/// Registering the same factory twice is harmless.
pub fn register_encoder(f: EncoderFactory) {
let mut g = video_factories().write().unwrap_or_else(|e| e.into_inner());
let mut g = video_factories().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) {
g.insert(0, f);
}
@@ -926,7 +936,7 @@ pub fn register_encoder(f: EncoderFactory) {
/// Whether `f` is among the registered video encoder factories (startup diagnostics, tests).
pub fn encoder_registered(f: EncoderFactory) -> bool {
video_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
video_factories().read().unwrap_or_else(PoisonError::into_inner).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
}
/// Renders one export frame on something other than the CPU reference renderer — today the GPU
@@ -956,7 +966,7 @@ fn frame_renderer_factories() -> &'static RwLock<Vec<FrameRendererFactory>> {
/// Register a frame-renderer factory (tried before those registered earlier). Registering the
/// same factory twice is harmless.
pub fn register_frame_renderer(f: FrameRendererFactory) {
let mut g = frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner());
let mut g = frame_renderer_factories().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) {
g.insert(0, f);
}
@@ -964,21 +974,21 @@ pub fn register_frame_renderer(f: FrameRendererFactory) {
/// Whether `f` is among the registered frame-renderer factories (startup diagnostics, tests).
pub fn frame_renderer_registered(f: FrameRendererFactory) -> bool {
frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
frame_renderer_factories().read().unwrap_or_else(PoisonError::into_inner).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
}
/// Clear the registered frame-renderer factories. Test-only: registration is a process-wide
/// startup step and tests that register their own must not see each other's.
#[cfg(test)]
pub(crate) fn reset_frame_renderers_for_tests() {
frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner()).clear();
frame_renderer_factories().write().unwrap_or_else(PoisonError::into_inner).clear();
GPU_FRAMES.store(0, Ordering::Relaxed);
GPU_FALLBACKS.store(0, Ordering::Relaxed);
}
/// Pick the first registered factory that can build a renderer for this host.
pub(crate) fn build_frame_renderer() -> Option<Box<dyn FrameRenderer>> {
let g = frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner());
let g = frame_renderer_factories().read().unwrap_or_else(PoisonError::into_inner);
g.iter().filter_map(|f| f()).next()
}
@@ -1022,7 +1032,7 @@ pub fn gpu_render_stats() -> GpuRenderStats {
GpuRenderStats { frames: GPU_FRAMES.load(Ordering::Relaxed), fallbacks: GPU_FALLBACKS.load(Ordering::Relaxed) }
}
pub fn register_audio_encoder(f: AudioEncoderFactory) {
audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
audio_factories().write().unwrap_or_else(PoisonError::into_inner).insert(0, f);
}
type FormatProbe = (Format, fn() -> bool);
@@ -1036,7 +1046,7 @@ fn format_probes() -> &'static RwLock<Vec<FormatProbe>> {
/// works on this machine: `probe` runs when [`available`] asks (it should cache its answer).
/// Registering the same probe twice is harmless.
pub fn register_format_probe(format: Format, probe: fn() -> bool) {
let mut g = format_probes().write().unwrap_or_else(|e| e.into_inner());
let mut g = format_probes().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
g.push((format, probe));
}
@@ -1052,7 +1062,7 @@ fn hdr_probes() -> &'static RwLock<Vec<FormatProbe>> {
/// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence
/// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless.
pub fn register_hdr_probe(format: Format, probe: fn() -> bool) {
let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner());
let mut g = hdr_probes().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
g.push((format, probe));
}
@@ -1061,7 +1071,7 @@ pub fn register_hdr_probe(format: Format, probe: fn() -> bool) {
/// Whether a registered probe says the encoder of `format` writes HDR here (false without one).
pub fn hdr_available(format: Format) -> bool {
// the probes run outside the lock: one may take a while
let probes: Vec<fn() -> bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect();
let probes: Vec<fn() -> bool> = hdr_probes().read().unwrap_or_else(PoisonError::into_inner).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect();
probes.into_iter().any(|p| p())
}
@@ -1071,7 +1081,7 @@ pub fn available(format: Format) -> bool {
if !Format::ALL.contains(&format) {
return false;
}
format.has_builtin_encoder() || format_probes().read().unwrap_or_else(|e| e.into_inner()).iter().any(|(f, probe)| *f == format && probe())
format.has_builtin_encoder() || format_probes().read().unwrap_or_else(PoisonError::into_inner).iter().any(|(f, probe)| *f == format && probe())
}
struct MjpegEncoder {
@@ -1797,7 +1807,7 @@ struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(buf);
self.0.lock().unwrap_or_else(PoisonError::into_inner).extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
@@ -1927,7 +1937,7 @@ fn export_here(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, s
progress.set_status(format!("Exporting audio ({})", settings.format.label()));
}
a.measure(settings, sources, &cancelled)?;
*progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness;
*progress.loudness.lock().unwrap_or_else(PoisonError::into_inner) = a.loudness;
let mut out = Out::create(settings)?;
let io = |e: std::io::Error| ExportError::Io(e.to_string());
out.write_all(&head).map_err(io)?;
@@ -2025,7 +2035,7 @@ fn export_here(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, s
if let Some(enc) = gif {
let buf = gif_buf.clone();
drop(enc);
let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(|e| e.into_inner()));
let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(PoisonError::into_inner));
total = write_output(settings, &settings.path, data)?;
}
(total, count)
+12 -12
View File
@@ -26,18 +26,6 @@ fn bounded_overlay_dimensions(w: usize, h: usize) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod overlay_budget_tests {
use super::*;
#[test]
fn overlay_pixel_budget_is_checked_without_overflow() {
assert!(bounded_overlay_dimensions(4096, 4096).is_ok());
assert!(bounded_overlay_dimensions(4097, 4096).is_err());
assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err());
}
}
/// Where the rendered picture lands in the output frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Geometry {
@@ -534,3 +522,15 @@ fn limit_px(c: &mut [f32; 3], lo: f32, hi: f32) {
}
c.iter_mut().for_each(|v| *v = v.clamp(0.0, hi.max(lo)));
}
#[cfg(test)]
mod overlay_budget_tests {
use super::*;
#[test]
fn overlay_pixel_budget_is_checked_without_overflow() {
assert!(bounded_overlay_dimensions(4096, 4096).is_ok());
assert!(bounded_overlay_dimensions(4097, 4096).is_err());
assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err());
}
}
+1 -1
View File
@@ -24,5 +24,5 @@ filmcraft-media = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-gpu = { workspace = true }
pollster = "0.4"
pollster = "1"
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
+2 -2
View File
@@ -18,11 +18,11 @@ filmcraft-render = { workspace = true }
filmcraft-time = { workspace = true }
wgpu = { version = "30", default-features = false, features = ["wgsl"] }
log = { workspace = true }
pollster = "0.4"
pollster = "1"
rayon = { workspace = true }
[dev-dependencies]
pollster = "0.4"
pollster = "1"
filmcraft-export = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png"] }
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
+2 -1
View File
@@ -151,7 +151,8 @@ fn gpu_blend_modes_match_cpu() {
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
)
.unwrap();
let demo = Arc::new(VideoFrame::rgba8(320, 180, pixels));
let base = ramp_layer(200, 180, 3);
let top = ramp_layer(160, 120, 0);
+4 -2
View File
@@ -116,7 +116,8 @@ fn gpu_matches_cpu_plan() {
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
)
.unwrap();
let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels));
let (w, h) = (320usize, 180usize);
let plan = FramePlan::Layers {
@@ -464,7 +465,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
)
.unwrap();
let background = Arc::new(VideoFrame::rgba8(320, 180, pixels));
for bits in [8, 10, 12] {
let mut c = GpuCompositor::new(&dev, &q);
+3 -3
View File
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
let mut w = [0u64; N];
for (i, w) in w.iter_mut().enumerate() {
let o = (y + i) * stride + x - 4;
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
let Some(&b) = pix.get(o..).and_then(<[u8]>::first_chunk::<8>) else {
return;
};
*w = u64::from_le_bytes(b);
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
let base = (y - 4) * stride + x;
let mut s = [[0i16; N]; 8];
for (k, row) in s.iter_mut().enumerate() {
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
let Some(src) = pix.get(base + k * stride..).and_then(<[u8]>::first_chunk::<N>) else {
return;
};
for i in 0..N {
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
filter_lanes(&mut s, ep, chroma);
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[u8]>::first_chunk_mut::<N>) else {
return;
};
for i in 0..N {
+3 -3
View File
@@ -888,7 +888,7 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (raster & 3) * 4;
let py = self.mb_y * 16 + (raster >> 2) * 4;
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[i32]>::first_chunk_mut::<16>) else {
return;
};
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
@@ -902,7 +902,7 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (b8 & 1) * 8;
let py = self.mb_y * 16 + (b8 >> 1) * 8;
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[i32]>::first_chunk_mut::<64>) else {
return;
};
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
@@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> {
}
let px = self.mb_x * 8 + (b & 1) * 4;
let py = self.mb_y * 8 + (b >> 1) * 4;
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[i32]>::first_chunk_mut::<16>) else {
continue;
};
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
+39 -31
View File
@@ -103,40 +103,48 @@ fn sweep() {
for n in depth.truncations(seed.len()) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
header_flips(&depth, &seed, name);
random_rounds(&depth, &seed, name);
}
}
/// SPS/PPS/first slice header region: every byte to a few values, every bit.
fn header_flips(depth: &Depth, seed: &[u8], name: &str) {
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
let mut d = seed.to_vec();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
}
fn random_rounds(depth: &Depth, seed: &[u8], name: &str) {
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
}
}
+2 -2
View File
@@ -56,7 +56,7 @@ fn filt_w<'a, P: Px + 'a, const T: usize, const W: usize>(tap: impl Fn(usize) ->
let mut acc = [0i32; W];
for k in 0..T {
let c = f[k] as i32;
let Some(p) = tap(k).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = tap(k).and_then(<[P]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
@@ -80,7 +80,7 @@ fn filt16_w<'a, P: Px + 'a, const T: usize, const W: usize>(tap: impl Fn(usize)
let mut acc = [0i16; W];
for k in 0..T {
let c = f[k] as i16;
let Some(p) = tap(k).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = tap(k).and_then(<[P]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
+39 -31
View File
@@ -104,40 +104,48 @@ fn sweep() {
for n in depth.truncations(seed.len()) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
header_flips(&depth, &seed, name);
random_rounds(&depth, &seed, name);
}
}
/// SPS/PPS/first slice header region: every byte to a few values, every bit.
fn header_flips(depth: &Depth, seed: &[u8], name: &str) {
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
let mut d = seed.to_vec();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
}
fn random_rounds(depth: &Depth, seed: &[u8], name: &str) {
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ fn main() {
let rows = gens.len().div_ceil(cols);
let mut sheet = image::RgbaImage::new(w * cols as u32, h * rows as u32);
for (i, g) in gens.iter().enumerate() {
let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976);
let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976).unwrap();
let img = image::RgbaImage::from_raw(w, h, px).unwrap();
image::imageops::overlay(&mut sheet, &img, ((i % cols) as u32 * w) as i64, ((i / cols) as u32 * h) as i64);
}
+26 -8
View File
@@ -19,6 +19,10 @@ use serde::{Deserialize, Serialize};
use crate::digits::{draw_text, text_width};
use crate::{AudioStreamInfo, FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo};
/// Generator frames are RGBA8 and cached. Keep one project-requested frame within the media
/// working-set budget before allocating it.
const MAX_GENERATOR_PIXELS: u64 = 1 << 25;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DemoScene {
Aurora,
@@ -153,7 +157,7 @@ impl MediaSource for GeneratorSource {
let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2));
let t = rate.tick_of(frame).seconds() as f32;
self.cache.get_or_insert_with((frame, bucket), || {
let px = render(&self.generator, w, h, t, frame, rate);
let px = render(&self.generator, w, h, t, frame, rate)?;
Ok(Arc::new(VideoFrame::rgba8(w, h, px).with_pts(rate.tick_of(frame))))
})
}
@@ -291,9 +295,16 @@ fn to8(c: [f32; 3]) -> [u8; 4] {
}
/// Render a generator frame as straight-alpha sRGB RGBA8.
pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Vec<u8> {
pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Result<Vec<u8>> {
let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| MediaError::Decode("generator frame dimensions overflow".into()))?;
if pixels > MAX_GENERATOR_PIXELS {
return Err(MediaError::Decode(format!("generator frame has {pixels} pixels; limit is {MAX_GENERATOR_PIXELS}")));
}
let (wu, hu) = (w as usize, h as usize);
let mut px = vec![0u8; wu * hu * 4];
let len = wu.checked_mul(hu).and_then(|n| n.checked_mul(4)).ok_or_else(|| MediaError::Decode("generator frame byte count overflows".into()))?;
let mut px = Vec::new();
px.try_reserve_exact(len).map_err(|_| MediaError::Decode("cannot allocate generator frame".into()))?;
px.resize(len, 0);
match g {
Generator::BarsAndTone => bars(&mut px, wu, hu),
Generator::ColorMatte { color } => {
@@ -315,7 +326,7 @@ pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate
});
}
}
px
Ok(px)
}
fn bars(px: &mut [u8], w: usize, h: usize) {
@@ -558,13 +569,14 @@ mod tests {
let g = GeneratorSource::demo(DemoScene::OceanSunset);
let a = g.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND), scale: 0.25 }).unwrap();
assert_eq!((a.width, a.height), (480, 270));
let b = render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976);
let b =
render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976).unwrap();
assert_eq!(a.to_rgba8(), b);
}
#[test]
fn bars_values() {
let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25);
let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25).unwrap();
// first bar after the side panel is 75% white
let i = (10 * 800 + 150) * 4;
assert_eq!(&px[i..i + 4], &[191, 191, 191, 255]);
@@ -685,10 +697,16 @@ mod tests {
#[test]
fn all_scenes_render() {
for s in DemoScene::ALL {
let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24);
let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap();
assert!(px.as_chunks::<4>().0.iter().any(|p| p[0] > 20 || p[1] > 20 || p[2] > 20));
}
let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24);
let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap();
assert_eq!(px.len(), 64 * 36 * 4);
}
#[test]
fn oversized_generator_frames_are_rejected_before_allocation() {
let err = render(&Generator::BlackVideo, 32_768, 32_768, 0.0, 0, FrameRate::FPS_24).unwrap_err();
assert!(err.to_string().contains("limit"), "{err}");
}
}
+64
View File
@@ -53,6 +53,59 @@ pub enum MediaError {
pub type Result<T> = std::result::Result<T, MediaError>;
/// Up to 384 kHz audio (surpassing professional 192 kHz formats).
pub const MAX_AUDIO_SAMPLE_RATE: u32 = 384_000;
/// Up to 64 discrete channels (exceeding standard 32-channel immersive layouts).
pub const MAX_AUDIO_CHANNELS: u32 = 64;
/// Bounded planar audio buffer: 64 channels * 192 kHz * 10 seconds of f32 is ~492 MiB.
pub const MAX_AUDIO_BUFFER_BYTES: usize = 512 * 1024 * 1024;
/// Bound container-advertised audio stream dimensions before they are trusted by the app or
/// allocator.
///
/// Out-of-range values are rejected rather than clamped: publishing a lower sample rate without
/// resampling the samples would make the stream play at the wrong speed, and a clamped channel
/// count would misdescribe the interleaving.
pub fn bounded_audio_dimensions(sample_rate: u32, channels: u32) -> Result<(u32, u32)> {
if sample_rate == 0 || sample_rate > MAX_AUDIO_SAMPLE_RATE {
return Err(MediaError::Decode(format!("unsupported audio sample rate {sample_rate}")));
}
if channels == 0 || channels > MAX_AUDIO_CHANNELS {
return Err(MediaError::Decode(format!("unsupported audio channel count {channels}")));
}
Ok((sample_rate, channels))
}
/// Check an adapter-owned planar `f32` audio allocation before constructing it.
pub fn validate_audio_buffer_shape(channels: usize, frames: usize) -> Result<()> {
if channels == 0 || channels > MAX_AUDIO_CHANNELS as usize {
return Err(MediaError::Decode(format!("unsupported audio channel count {channels}")));
}
let bytes = channels
.checked_mul(frames)
.and_then(|samples| samples.checked_mul(std::mem::size_of::<f32>()))
.ok_or_else(|| MediaError::Decode("audio buffer dimensions overflow".into()))?;
if bytes > MAX_AUDIO_BUFFER_BYTES {
return Err(MediaError::Decode(format!("audio buffer of {bytes} bytes exceeds the {} byte limit", MAX_AUDIO_BUFFER_BYTES)));
}
Ok(())
}
/// Number of source frames needed to resample `frames` output frames. Arithmetic is checked so
/// corrupted rates cannot wrap into a small allocation.
pub fn resample_source_frames(frames: usize, source_rate: u32, output_rate: u32) -> Result<usize> {
if output_rate == 0 {
return Err(MediaError::Decode("zero requested audio sample rate".into()));
}
let source = usize::try_from(source_rate).map_err(|_| MediaError::Decode("invalid source sample rate".into()))?;
let output = usize::try_from(output_rate).map_err(|_| MediaError::Decode("invalid output sample rate".into()))?;
frames
.checked_mul(source)
.and_then(|n| n.checked_add(output.saturating_sub(1)))
.map(|n| n / output)
.ok_or_else(|| MediaError::Decode("resampled audio window overflows".into()))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum MediaKind {
/// Video, possibly with audio.
@@ -257,4 +310,15 @@ mod importable_tests {
}
assert!(!is_importable(Path::new("notes.txt")));
}
#[test]
fn hostile_audio_dimensions_are_bounded_before_adapters_allocate() {
assert!(bounded_audio_dimensions(0, 0).is_err());
assert!(bounded_audio_dimensions(u32::MAX, u32::MAX).is_err());
assert!(bounded_audio_dimensions(MAX_AUDIO_SAMPLE_RATE + 1, 2).is_err());
assert_eq!(bounded_audio_dimensions(48_000, 2).ok(), Some((48_000, 2)));
assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize).is_ok());
assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize + 1).is_err());
assert!(resample_source_frames(usize::MAX, MAX_AUDIO_SAMPLE_RATE, 1).is_err());
}
}
+19 -1
View File
@@ -115,7 +115,13 @@ impl WavSource {
let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?;
let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?;
let float = tag == 3;
if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) {
if !(tag == 1 || float)
|| ch == 0
|| rate == 0
|| u32::from(ch) > crate::MAX_AUDIO_CHANNELS
|| rate > crate::MAX_AUDIO_SAMPLE_RATE
|| !matches!(bits, 8 | 16 | 24 | 32 | 64)
{
return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits")));
}
let frame_bytes = u64::from(ch) * u64::from(bits / 8);
@@ -191,6 +197,7 @@ impl MediaSource for WavSource {
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer> {
let total = self.frames;
crate::validate_audio_buffer_shape(self.channels, frames)?;
let mut out = AudioBuffer::silence(sample_rate, self.channels, frames);
if total == 0 || sample_rate == 0 {
return Ok(out);
@@ -343,4 +350,15 @@ mod tests {
let src = WavSource::parse("short.wav", with_chunk(&wav, b"bext", &[0u8; 100]).into()).unwrap();
assert_eq!(src.time_reference(), None);
}
#[test]
fn hostile_header_dimensions_are_rejected_before_publishing_or_reading() {
let mut wav = write_wav16(&[0.0; 2], 1, 48_000);
// `fmt ` starts at byte 12: channels at +22, rate at +24.
wav[22..24].copy_from_slice(&u16::MAX.to_le_bytes());
assert!(WavSource::parse("wide.wav", wav.into()).is_err());
let mut wav = write_wav16(&[0.0; 2], 1, 48_000);
wav[24..28].copy_from_slice(&(crate::MAX_AUDIO_SAMPLE_RATE + 1).to_le_bytes());
assert!(WavSource::parse("fast.wav", wav.into()).is_err());
}
}
+50 -5
View File
@@ -11,14 +11,38 @@ pub struct Image {
}
impl Image {
pub const MAX_PIXELS: usize = 268_435_456;
pub fn new(w: usize, h: usize) -> Self {
Self { w, h, px: vec![0.0; w * h * 4] }
let len = w.checked_mul(h).filter(|&px| px <= Self::MAX_PIXELS).and_then(|px| px.checked_mul(4)).unwrap_or(0);
let (w, h, px) = if len == 0 && (w > 0 || h > 0) {
(0, 0, Vec::new())
} else {
let mut px = Vec::new();
if px.try_reserve_exact(len).is_ok() {
px.resize(len, 0.0);
(w, h, px)
} else {
(0, 0, Vec::new())
}
};
Self { w, h, px }
}
pub fn filled(w: usize, h: usize, c: [f32; 4]) -> Self {
let mut px = Vec::with_capacity(w * h * 4);
for _ in 0..w * h {
px.extend_from_slice(&c);
}
let len = w.checked_mul(h).filter(|&px| px <= Self::MAX_PIXELS).and_then(|px| px.checked_mul(4)).unwrap_or(0);
let (w, h, px) = if len == 0 && (w > 0 || h > 0) {
(0, 0, Vec::new())
} else {
let mut px = Vec::new();
if px.try_reserve_exact(len).is_ok() {
for _ in 0..w * h {
px.extend_from_slice(&c);
}
(w, h, px)
} else {
(0, 0, Vec::new())
}
};
Self { w, h, px }
}
#[inline]
@@ -83,6 +107,9 @@ impl Image {
/// Resample `self` into a `w`×`h` image through `m` (maps source pixels → destination pixels).
/// Uses bilinear filtering, with a box pre-filter (mip level) when minifying by more than 2×.
pub fn transformed(&self, w: usize, h: usize, m: &Affine) -> Image {
if self.w == 0 || self.h == 0 || w == 0 || h == 0 {
return Image::new(w, h);
}
let Some(inv) = m.inverse() else { return Image::new(w, h) };
// minification factor
let sx = (m.a * m.a + m.b * m.b).sqrt();
@@ -93,6 +120,9 @@ impl Image {
let m2 = m.then_apply(&Affine::scale(2.0, 2.0));
return half.transformed(w, h, &m2);
}
if w == 0 || h == 0 {
return Image::new(0, 0);
}
let mut out = Image::new(w, h);
// Destination bounds of the source rect, to skip empty rows/cols.
let b = m.bounds(&filmcraft_geom::Rect::new(0.0, 0.0, self.w as f64, self.h as f64));
@@ -119,6 +149,9 @@ impl Image {
/// Half-size box downsample.
pub fn downsample2(&self) -> Image {
if self.w == 0 || self.h == 0 {
return Image::new(0, 0);
}
let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1));
let mut out = Image::new(w, h);
out.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
@@ -161,6 +194,9 @@ impl Image {
/// Apply a per-pixel function on straight-alpha colour (alpha preserved).
pub fn map_rgb(&mut self, f: impl Fn([f32; 3], usize, usize) -> [f32; 3] + Sync) {
if self.w == 0 || self.h == 0 {
return;
}
let w = self.w;
self.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
@@ -179,6 +215,9 @@ impl Image {
/// Convert to straight sRGB RGBA8.
pub fn to_rgba8(&self) -> Vec<u8> {
if self.w == 0 || self.h == 0 {
return Vec::new();
}
let mut out = vec![0u8; self.w * self.h * 4];
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| filmcraft_frame::linear_premul_to_srgb8(s, o));
out
@@ -186,6 +225,9 @@ impl Image {
/// Opaque version over black (for monitors).
pub fn over_black_rgba8(&self) -> Vec<u8> {
if self.w == 0 || self.h == 0 {
return Vec::new();
}
let mut out = vec![0u8; self.w * self.h * 4];
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| {
for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) {
@@ -202,6 +244,9 @@ impl Image {
/// the same (the sRGB of the premultiplied linear color), so the picture still shows over
/// black, and the alpha says where it is transparent.
pub fn over_black_with_alpha_rgba8(&self) -> Vec<u8> {
if self.w == 0 || self.h == 0 {
return Vec::new();
}
let mut out = vec![0u8; self.w * self.h * 4];
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| {
for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) {
+4
View File
@@ -84,7 +84,11 @@ impl Default for RenderOptions {
}
/// Output size for a sequence at a scale.
///
/// A non-finite or non-positive scale is invalid and falls back to 1.0 rather than being cast to a
/// zero pixel count (which would silently collapse the frame to 1x1).
pub fn output_size(seq: &Sequence, scale: f32) -> (usize, usize) {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
(((seq.settings.width as f32 * scale).round() as usize).max(1), ((seq.settings.height as f32 * scale).round() as usize).max(1))
}
+15 -15
View File
@@ -125,21 +125,6 @@ impl Tokenizer {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sparse_huge_vocab_id_is_rejected_before_allocation() {
let json = format!(r#"{{"model":{{"vocab":{{"x":{}}}}},"added_tokens":[]}}"#, MAX_VOCAB_ID + 1);
let error = match Tokenizer::from_json(&json) {
Err(error) => error,
Ok(_) => panic!("huge sparse vocabulary was accepted"),
};
assert!(error.to_string().contains("vocab id is too large"));
}
}
/// Group text tokens into words: a token whose text starts with a space starts a new word;
/// punctuation-only tokens join the word before them (opening quotes/brackets the word after).
/// Returns (word text, token index range).
@@ -186,3 +171,18 @@ pub fn group_words(tok: &Tokenizer, ids: &[u32]) -> Vec<(String, std::ops::Range
}
groups.into_iter().map(|(b, r)| (String::from_utf8_lossy(&b).trim().to_string(), r)).filter(|(t, _)| !t.is_empty()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sparse_huge_vocab_id_is_rejected_before_allocation() {
let json = format!(r#"{{"model":{{"vocab":{{"x":{}}}}},"added_tokens":[]}}"#, MAX_VOCAB_ID + 1);
let error = match Tokenizer::from_json(&json) {
Err(error) => error,
Ok(_) => panic!("huge sparse vocabulary was accepted"),
};
assert!(error.to_string().contains("vocab id is too large"));
}
}
+10 -9
View File
@@ -6,8 +6,9 @@
//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic
//! bold/italic needed when the family lacks that style. Unknown families fall back to Inter.
use std::ffi::OsStr;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
use skrifa::instance::{LocationRef, Size};
use skrifa::{FontRef, MetadataProvider};
@@ -123,7 +124,7 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
if depth < 4 {
walk(&p, depth + 1, out);
}
} else if is_file && p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
} else if is_file && p.extension().and_then(OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
out.push(p);
}
}
@@ -342,7 +343,7 @@ fn push(db: &mut Db, info: FaceInfo) -> FaceId {
/// added.
pub fn add_source(src: &dyn FontSource) -> usize {
let faces = src.faces();
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut d = db().write().unwrap_or_else(PoisonError::into_inner);
let mut n = 0;
for f in faces {
let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style));
@@ -358,7 +359,7 @@ pub fn add_source(src: &dyn FontSource) -> usize {
pub fn add_font_data(data: Vec<u8>) -> Vec<FaceId> {
let data = Arc::new(data);
let names = read_faces_bytes(&data);
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut d = db().write().unwrap_or_else(PoisonError::into_inner);
names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect()
}
@@ -368,17 +369,17 @@ pub fn scan_system() -> usize {
// Held for the whole scan: a concurrent caller waits for the complete list instead of
// returning early and resolving against a half-filled database.
static SCAN: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
let _scan = SCAN.lock().unwrap_or_else(PoisonError::into_inner);
if system_scanned() {
return 0;
}
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
db().write().unwrap_or_else(PoisonError::into_inner).scanned = true;
n
}
pub fn system_scanned() -> bool {
db().read().unwrap_or_else(|e| e.into_inner()).scanned
db().read().unwrap_or_else(PoisonError::into_inner).scanned
}
/// A face by id (unknown ids give the default face).
@@ -386,13 +387,13 @@ pub fn system_scanned() -> bool {
// created, so `faces` is never empty.
#[allow(clippy::expect_used)]
pub fn face(id: FaceId) -> Arc<Face> {
let d = db().read().unwrap_or_else(|e| e.into_inner());
let d = db().read().unwrap_or_else(PoisonError::into_inner);
d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present")
}
/// All faces (bundled first).
pub fn all_faces() -> Vec<Arc<Face>> {
db().read().unwrap_or_else(|e| e.into_inner()).faces.clone()
db().read().unwrap_or_else(PoisonError::into_inner).faces.clone()
}
/// Families with their style names, sorted by family; styles in weight order.
+5 -5
View File
@@ -11,7 +11,7 @@
//! Synthetic bold grows outlines by 2.5 % of the size per side; synthetic italic slants by 12°.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
@@ -59,7 +59,7 @@ impl OutlinePen for Rec {
fn outline(face: FaceId, gid: u32) -> Option<Arc<Vec<Cmd>>> {
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<Vec<Cmd>>>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
if let Some(v) = c.lock().unwrap_or_else(PoisonError::into_inner).get(&(face, gid)) {
return v.clone();
}
let f = fonts::face(face);
@@ -69,7 +69,7 @@ fn outline(face: FaceId, gid: u32) -> Option<Arc<Vec<Cmd>>> {
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut rec).ok()?;
Some(Arc::new(rec.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(PoisonError::into_inner);
if m.len() > 20_000 {
m.clear();
}
@@ -164,7 +164,7 @@ pub fn glyph_mask(g: &Glyph, px: f32, frac: f32) -> Option<Arc<GlyphMask>> {
let size_q = (px * 8.0).round().max(1.0) as u32;
let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4;
let key = (g.face, g.id, size_q, frac_q, g.synth_bold, g.synth_italic);
if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).entries.get(&key) {
if let Some(v) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).entries.get(&key) {
return v.clone();
}
let px = size_q as f32 / 8.0;
@@ -178,7 +178,7 @@ pub fn glyph_mask(g: &Glyph, px: f32, frac: f32) -> Option<Arc<GlyphMask>> {
};
let v = Some(Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top }));
let bytes = w * h * std::mem::size_of::<f32>();
let mut m = glyph_cache().lock().unwrap_or_else(|e| e.into_inner());
let mut m = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner);
if m.entries.len() > 16_384 || m.bytes.checked_add(bytes).is_none_or(|n| n > MAX_GLYPH_CACHE_BYTES) {
m.entries.clear();
m.bytes = 0;
+7 -7
View File
@@ -292,7 +292,7 @@ fn parse_money(rest: &str, lang: Lang) -> Option<(Vec<String>, usize)> {
let (value, digits, used, minor, had_minor) = parse_amount(rest.get(skip..)?, lang)?;
if digits > MAX_CARDINAL_DIGITS || has_leading_zero(&value, digits) {
// Oversized amounts: every digit, unit-free, no overflow.
let all = rest.get(skip..skip + used)?.chars().filter(|c| c.is_ascii_digit()).collect::<String>();
let all = rest.get(skip..skip + used)?.chars().filter(char::is_ascii_digit).collect::<String>();
let (words, _) = digit_run_as_words(&all);
return Some((words, used + skip));
}
@@ -385,7 +385,7 @@ fn parse_amount(rest: &str, _lang: Lang) -> Option<(u64, usize, usize, u32, bool
/// A grouping mark is valid only when exactly three digits follow it.
fn grouping_followed_by_3(rest: &str, sep_index: usize) -> bool {
let after = rest.get(sep_index + 1..).unwrap_or("");
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
let digits: String = after.chars().take_while(char::is_ascii_digit).collect();
let boundary = after.get(digits.len()..).and_then(|r| r.chars().next()).is_none_or(|c| !c.is_ascii_digit());
digits.len() == 3 && boundary
}
@@ -659,7 +659,7 @@ fn parse_continuation(rest: &str, used: usize, value: u64, digits: &str) -> Opti
match peek(after, 0) {
Some('s' | 'S' | 't' | 'T' | 'n' | 'N' | 'd' | 'D' | 'r' | 'R') => {
let suffix = after.get(..2)?;
if is_ordinal_suffix(suffix) && !peek(after, 2).is_some_and(|c| c.is_alphabetic()) {
if is_ordinal_suffix(suffix) && !peek(after, 2).is_some_and(char::is_alphabetic) {
return Some((vec![Token::Word(english_ordinal(value))], used + 2));
}
None
@@ -780,7 +780,7 @@ fn is_ordinal_suffix(suffix: &str) -> bool {
/// own parser before this check, so a leftover `.`/`,`/`:`/`/` — or a sentence
/// terminator or closing bracket — is fine.
fn year_is_standalone(rest: &str, used: usize) -> bool {
!peek(rest, used).is_some_and(|c| c.is_alphanumeric())
!peek(rest, used).is_some_and(char::is_alphanumeric)
}
/// A digit run read digit by digit.
@@ -1018,10 +1018,10 @@ fn dotted_abbreviation(lower: &str, rest: &str, used: usize) -> Option<(Vec<Toke
"mt" => vec![Token::Word("mount".into())],
"st" => {
let mut i = used + 1;
while peek(rest, i).is_some_and(|c| c.is_whitespace()) {
while peek(rest, i).is_some_and(char::is_whitespace) {
i += 1;
}
let capitalised = peek(rest, i).is_some_and(|c| c.is_uppercase());
let capitalised = peek(rest, i).is_some_and(char::is_uppercase);
vec![Token::Word(if capitalised { "saint".into() } else { "street".into() })]
}
"vs" => vec![Token::Word("versus".into())],
@@ -1054,7 +1054,7 @@ fn dotted_abbreviation(lower: &str, rest: &str, used: usize) -> Option<(Vec<Toke
fn is_spellable_acronym(word: &str) -> bool {
const KEEP_AS_WORDS: [&str; 8] = ["NASA", "UNESCO", "NATO", "UNICEF", "OPEC", "FIFA", "RADAR", "SONAR"];
let letters: Vec<char> = word.chars().collect();
if letters.len() < 2 || letters.len() > 5 || !letters.iter().all(|c| c.is_ascii_uppercase()) {
if letters.len() < 2 || letters.len() > 5 || !letters.iter().all(char::is_ascii_uppercase) {
return false;
}
if KEEP_AS_WORDS.contains(&word) {
+2 -2
View File
@@ -627,7 +627,7 @@ fn morphology(word: &str, lex: &Lexicon) -> Option<String> {
}
}
// -ing (stem may need its silent e back: making → make).
let ing_stems: [Option<String>; 2] = [strip_suffix(word, "ing").map(|s| s.to_string()), strip_suffix(word, "ing").map(|s| format!("{s}e"))];
let ing_stems: [Option<String>; 2] = [strip_suffix(word, "ing").map(ToString::to_string), strip_suffix(word, "ing").map(|s| format!("{s}e"))];
for stem in ing_stems.into_iter().flatten() {
let stem = stem.as_str();
if let Some(pron) = lex.pronunciations(&stem.to_uppercase())
@@ -720,7 +720,7 @@ fn past_ed(last: &Phone) -> &'static str {
/// doubled consonants, soft c/g, `y` as vowel; primary stress on the first
/// syllable (on the vowel before -tion/-sion). Always pronounceable.
fn letter_to_sound(word: &str) -> Option<String> {
let letters: Vec<char> = word.chars().filter(|c| c.is_ascii_alphabetic()).collect();
let letters: Vec<char> = word.chars().filter(char::is_ascii_alphabetic).collect();
if letters.is_empty() || letters.len() > MAX_LETTER_TO_SOUND_CHARS {
return None;
}
+1 -1
View File
@@ -17,7 +17,7 @@
use candle_core::{D, Device, Tensor};
use super::layers::*;
use super::layers::{AdaLayerNorm, AdaResBlock, BiLstm, Conv, ConvT, LayerNorm, Linear, R, SnakeResBlock, leaky};
use super::weights::{Weights, err};
const HEADS: usize = 12;
+4 -1
View File
@@ -46,7 +46,7 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
cpu-time = "1"
[dev-dependencies]
pollster = "0.4"
pollster = "1"
filmcraft-codecs = { workspace = true }
# the bench registers OS hardware decoders as the desktop app does (dev-dependency only: ui-egui
# itself stays OS-independent and web-portable)
@@ -62,3 +62,6 @@ tempfile = "3"
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
+42 -42
View File
@@ -10,7 +10,7 @@
use std::cell::Cell;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use std::time::Duration;
use web_time::Instant;
@@ -449,7 +449,7 @@ pub fn playback_plan(render_cost: f64, fps: f64, workers: usize) -> (i64, i64) {
impl FrameServer {
/// Settings ▸ Memory ▸ frame cache budget (bytes); evicts down to it right away.
pub fn set_cache_budget(&self, bytes: usize) {
let mut c = self.shared.done.lock().unwrap_or_else(|e| e.into_inner());
let mut c = self.shared.done.lock().unwrap_or_else(PoisonError::into_inner);
c.budget = bytes.max(16 << 20);
if c.bytes > c.budget
&& let Some((k, v)) = c.map.iter().next().map(|(k, (v, _))| (*k, v.clone()))
@@ -461,7 +461,7 @@ impl FrameServer {
/// (bytes cached, budget).
pub fn cache_usage(&self) -> (usize, usize) {
let c = self.shared.done.lock().unwrap_or_else(|e| e.into_inner());
let c = self.shared.done.lock().unwrap_or_else(PoisonError::into_inner);
(c.bytes, c.budget)
}
@@ -500,7 +500,7 @@ impl FrameServer {
}
pub fn set_context(&self, ctx: &egui::Context) {
let mut g = self.repaint.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.repaint.lock().unwrap_or_else(PoisonError::into_inner);
if g.is_none() {
let ctx = ctx.clone();
*g = Some(Box::new(move || ctx.request_repaint()));
@@ -513,12 +513,12 @@ impl FrameServer {
}
pub fn take_records(&self) -> Vec<JobRecord> {
std::mem::take(&mut *self.shared.records.lock().unwrap_or_else(|e| e.into_inner()))
std::mem::take(&mut *self.shared.records.lock().unwrap_or_else(PoisonError::into_inner))
}
/// Cumulative job statistics (`perf.stats`).
pub fn stats(&self) -> FrameStats {
self.shared.stats.lock().unwrap_or_else(|e| e.into_inner()).clone()
self.shared.stats.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Worker threads.
@@ -529,15 +529,15 @@ impl FrameServer {
/// (cached images, their bytes, cached GPU plans, their bytes).
pub fn cache_entries(&self) -> (usize, usize, usize, usize) {
let (n, b) = {
let c = self.shared.done.lock().unwrap_or_else(|e| e.into_inner());
let c = self.shared.done.lock().unwrap_or_else(PoisonError::into_inner);
(c.map.len(), c.bytes)
};
let p = self.shared.plans.lock().unwrap_or_else(|e| e.into_inner());
let p = self.shared.plans.lock().unwrap_or_else(PoisonError::into_inner);
(n, b, p.map.len(), p.bytes)
}
pub fn get(&self, k: &FrameKey) -> Option<Arc<Rgba>> {
let mut c = self.shared.done.lock().unwrap_or_else(|e| e.into_inner());
let mut c = self.shared.done.lock().unwrap_or_else(PoisonError::into_inner);
c.clock += 1;
let clock = c.clock;
c.map.get_mut(k).map(|(v, s)| {
@@ -547,20 +547,20 @@ impl FrameServer {
}
pub fn get_plan(&self, k: &FrameKey) -> Option<Arc<GpuPlan>> {
self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).get(k)
self.shared.plans.lock().unwrap_or_else(PoisonError::into_inner).get(k)
}
/// Whether the exact frame (image or plan) is ready.
pub fn is_ready(&self, k: &FrameKey) -> bool {
match k.target {
Target::SequencePlan(_) => self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).map.contains_key(k),
_ => self.shared.done.lock().unwrap_or_else(|e| e.into_inner()).map.contains_key(k),
Target::SequencePlan(_) => self.shared.plans.lock().unwrap_or_else(PoisonError::into_inner).map.contains_key(k),
_ => self.shared.done.lock().unwrap_or_else(PoisonError::into_inner).map.contains_key(k),
}
}
/// Nearest cached plan at or before `frame`.
pub fn nearest_plan(&self, key: FrameKey, max_back: i64) -> Option<(FrameKey, Arc<GpuPlan>)> {
let mut g = self.shared.plans.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.shared.plans.lock().unwrap_or_else(PoisonError::into_inner);
(0..=max_back).find_map(|d| {
let k = FrameKey { frame: key.frame - d, ..key };
g.get(&k).map(|p| (k, p))
@@ -578,18 +578,18 @@ impl FrameServer {
}
fn request_job(&self, key: FrameKey, time: Tick, scale: f32, project: &Arc<Project>, prio: u32, prefetch: bool, catch_up: Option<Tick>) {
let hit = || self.shared.stats.lock().unwrap_or_else(|e| e.into_inner()).request_hits += 1;
if self.shared.done.lock().unwrap_or_else(|e| e.into_inner()).map.contains_key(&key)
|| self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).map.contains_key(&key)
let hit = || self.shared.stats.lock().unwrap_or_else(PoisonError::into_inner).request_hits += 1;
if self.shared.done.lock().unwrap_or_else(PoisonError::into_inner).map.contains_key(&key)
|| self.shared.plans.lock().unwrap_or_else(PoisonError::into_inner).map.contains_key(&key)
{
hit();
return;
}
if self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).iter().any(|(k, c, _)| *k == key && !c.load(Ordering::Relaxed)) {
if self.shared.in_flight.lock().unwrap_or_else(PoisonError::into_inner).iter().any(|(k, c, _)| *k == key && !c.load(Ordering::Relaxed)) {
hit();
return;
}
let mut q = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut q = self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(j) = q.iter_mut().find(|j| j.key == key) {
j.prio = j.prio.min(prio);
j.prefetch &= prefetch;
@@ -602,7 +602,7 @@ impl FrameServer {
hit();
return;
}
self.shared.stats.lock().unwrap_or_else(|e| e.into_inner()).request_misses += 1;
self.shared.stats.lock().unwrap_or_else(PoisonError::into_inner).request_misses += 1;
if prio == 0 {
// A new frame on screen for this view replaces the one asked for before: scrubbing
// asks for one per refresh, and without this the oldest position would decode
@@ -614,7 +614,7 @@ impl FrameServer {
// refresh, cancelling each one for the next would show nothing until the mouse rests.
let same_view = |k: &FrameKey| k.target == key.target && k.size == key.size;
q.retain(|j| !(j.prio == 0 && !j.prefetch && same_view(&j.key)));
for (k, c, prefetch) in self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).iter() {
for (k, c, prefetch) in self.shared.in_flight.lock().unwrap_or_else(PoisonError::into_inner).iter() {
if !prefetch && same_view(k) && k.frame != key.frame {
c.store(true, Ordering::Relaxed);
}
@@ -666,15 +666,15 @@ impl FrameServer {
/// The current per-frame render cost estimate (s), see [`playback_plan`].
pub fn render_cost(&self) -> f64 {
*self.shared.render_cost.lock().unwrap_or_else(|e| e.into_inner())
*self.shared.render_cost.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Drop queued jobs that fail `keep` (e.g. stale revisions or frames far from the playhead).
/// Jobs already running for such frames are cancelled: a worker blocked behind a decoder
/// would otherwise go on to decode a frame playback has passed, possibly from its keyframe.
pub fn retain_queue(&self, keep: impl Fn(&FrameKey) -> bool) {
self.shared.queue.lock().unwrap_or_else(|e| e.into_inner()).retain(|j| keep(&j.key));
for (k, c, _) in self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).iter() {
self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner).retain(|j| keep(&j.key));
for (k, c, _) in self.shared.in_flight.lock().unwrap_or_else(PoisonError::into_inner).iter() {
if !keep(k) {
c.store(true, Ordering::Relaxed);
}
@@ -684,8 +684,8 @@ impl FrameServer {
/// Playback stopped: drop its queued prefetch jobs and cancel the running ones (left alone,
/// they would keep the workers busy, e.g. rendering effects for frames nobody will see).
pub fn stop_prefetch(&self) {
self.shared.queue.lock().unwrap_or_else(|e| e.into_inner()).retain(|j| !j.prefetch);
for (_, c, prefetch) in self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).iter() {
self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner).retain(|j| !j.prefetch);
for (_, c, prefetch) in self.shared.in_flight.lock().unwrap_or_else(PoisonError::into_inner).iter() {
if *prefetch {
c.store(true, Ordering::Relaxed);
}
@@ -693,7 +693,7 @@ impl FrameServer {
}
pub fn queue_len(&self) -> usize {
self.shared.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner).len()
}
/// The most recent cached frame for `target` at or before `frame` within `max_back` frames.
@@ -718,7 +718,7 @@ impl FrameServer {
let mut retry = Vec::new();
loop {
let job = {
let mut q = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut q = self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner);
if q.is_empty() {
break;
}
@@ -738,7 +738,7 @@ impl FrameServer {
}
}
if !retry.is_empty() {
let mut q = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut q = self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner);
for j in retry.into_iter().rev() {
if !q.iter().any(|x| x.key == j.key) {
q.push_front(j);
@@ -753,11 +753,11 @@ impl Drop for FrameServer {
/// Stop the workers: queued jobs are dropped, running ones are cancelled, and each worker
/// returns once it has no job, releasing the frame cache, the media pool and the services.
fn drop(&mut self) {
let mut q = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut q = self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner);
self.shared.stop.store(true, Ordering::Relaxed);
q.clear();
drop(q);
for (_, c, _) in self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).iter() {
for (_, c, _) in self.shared.in_flight.lock().unwrap_or_else(PoisonError::into_inner).iter() {
c.store(true, Ordering::Relaxed);
}
self.shared.cv.notify_all();
@@ -844,7 +844,7 @@ fn worker(
) {
loop {
let job = {
let mut q = sh.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut q = sh.queue.lock().unwrap_or_else(PoisonError::into_inner);
loop {
if sh.stop.load(Ordering::Relaxed) {
return;
@@ -856,7 +856,7 @@ fn worker(
break job;
}
}
q = sh.cv.wait(q).unwrap_or_else(|e| e.into_inner());
q = sh.cv.wait(q).unwrap_or_else(PoisonError::into_inner);
}
};
// A panic while decoding or rendering one frame (bad media, a decoder bug) must not take
@@ -864,11 +864,11 @@ fn worker(
// monitors blank. Log it, forget the job and keep serving.
let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_job(&sh, &job, &pool, &services, &previews)));
if ran.is_err() {
sh.in_flight.lock().unwrap_or_else(|e| e.into_inner()).retain(|(k, c, _)| !(*k == job.key && Arc::ptr_eq(c, &job.cancel)));
sh.stats.lock().unwrap_or_else(|e| e.into_inner()).failed += 1;
sh.in_flight.lock().unwrap_or_else(PoisonError::into_inner).retain(|(k, c, _)| !(*k == job.key && Arc::ptr_eq(c, &job.cancel)));
sh.stats.lock().unwrap_or_else(PoisonError::into_inner).failed += 1;
log::error!("frame job {:?} panicked; the worker continues", job.key);
}
if let Some(f) = repaint.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(f) = repaint.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
f();
}
}
@@ -886,7 +886,7 @@ static INJECT_PANICS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU3
fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Services>, previews: &PreviewStore) -> bool {
{
let mut in_flight = sh.in_flight.lock().unwrap_or_else(|e| e.into_inner());
let mut in_flight = sh.in_flight.lock().unwrap_or_else(PoisonError::into_inner);
// A job taken from the queue just before the server was dropped is not in the list
// that `Drop` cancels.
if sh.stop.load(Ordering::Relaxed) {
@@ -916,14 +916,14 @@ fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Ser
if !job.cancel.load(Ordering::Relaxed) && !loading {
// Convert texels for upload here, not on the UI thread when the frame is shown.
let prepared = filmcraft_gpu::prepare(&plan);
sh.plans.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, GpuPlan { plan, prepared });
sh.plans.lock().unwrap_or_else(PoisonError::into_inner).insert(job.key, GpuPlan { plan, prepared });
}
pv
} else {
let (img, pv) = render_job(job, pool, services, previews);
loading = filmcraft_media::pending::take();
if !job.cancel.load(Ordering::Relaxed) && !loading {
sh.done.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, Arc::new(img));
sh.done.lock().unwrap_or_else(PoisonError::into_inner).insert(job.key, Arc::new(img));
}
pv
}
@@ -932,13 +932,13 @@ fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Ser
})
});
let cancelled = job.cancel.load(Ordering::Relaxed);
sh.in_flight.lock().unwrap_or_else(|e| e.into_inner()).retain(|(k, c, _)| !(*k == job.key && Arc::ptr_eq(c, &job.cancel)));
sh.in_flight.lock().unwrap_or_else(PoisonError::into_inner).retain(|(k, c, _)| !(*k == job.key && Arc::ptr_eq(c, &job.cancel)));
if job.prefetch {
// Work beyond fetching source frames (decoding is sequential per source and is
// not saved by skipping frames, so it is left out of the estimate). A cancelled job
// only tells that the work takes at least this long.
let cost = started.elapsed().saturating_sub(SOURCE_TIME.with(|s| s.get()).0).as_secs_f64();
let mut c = sh.render_cost.lock().unwrap_or_else(|e| e.into_inner());
let mut c = sh.render_cost.lock().unwrap_or_else(PoisonError::into_inner);
if !cancelled || cost > *c {
*c = if *c == 0.0 { cost } else { *c * 0.8 + cost * 0.2 };
}
@@ -947,7 +947,7 @@ fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Ser
let job_wall = started.elapsed();
let source = SOURCE_TIME.with(|s| s.get()).0;
let (src_ms, render_ms) = (source.as_secs_f64() * 1e3, job_wall.saturating_sub(source).as_secs_f64() * 1e3);
let mut st = sh.stats.lock().unwrap_or_else(|e| e.into_inner());
let mut st = sh.stats.lock().unwrap_or_else(PoisonError::into_inner);
st.jobs += 1;
st.cancelled += cancelled as u64;
st.preview += preview as u64;
@@ -974,7 +974,7 @@ fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Ser
preview,
cancelled,
};
sh.records.lock().unwrap_or_else(|e| e.into_inner()).push(rec);
sh.records.lock().unwrap_or_else(PoisonError::into_inner).push(rec);
}
!loading
}
+25 -17
View File
@@ -63,6 +63,7 @@ pub mod source_playback;
pub mod state;
pub mod theme;
mod theme_favorites;
pub mod updates_ui;
pub mod widgets;
use std::collections::HashMap;
@@ -302,6 +303,7 @@ pub struct FilmcraftApp {
pub scrub: scrub::ScrubAudio,
pub hooks: HostHooks,
pub dialog: Option<Dialog>,
pub(crate) updates_rt: updates_ui::Runtime,
pub file_dialogs: panels::file_dialogs::FileDialogState,
/// The native file dialog the host is showing ([`Self::pick`]).
native_dialog: Option<native_dialogs::PendingDialog>,
@@ -638,6 +640,7 @@ impl FilmcraftApp {
hooks: HostHooks::default(),
// Unsaved changes left by a session that died are offered first thing.
dialog: recovery.then_some(Dialog::Recovery),
updates_rt: Default::default(),
file_dialogs: Default::default(),
native_dialog: None,
auto: Default::default(),
@@ -2199,6 +2202,7 @@ impl FilmcraftApp {
// the Timeline has had its chance to place a file drop this frame; anything left stays a bin import
self.pending_timeline_drop = None;
panels::dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
if !self.ui.show_status_bar {
return;
}
@@ -2523,6 +2527,7 @@ impl FilmcraftApp {
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
self.sync_pool();
updates_ui::tick(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
self.tick_media_refresh(ctx, now);
}
@@ -3172,15 +3177,26 @@ mod audio_recovery_tests {
Some(4_800)
}
}
let amplitude = |samples: &[f32], ch: usize, hz: f64| {
let (mut re, mut im) = (0.0, 0.0);
for (i, frame) in samples.as_chunks::<2>().0.iter().enumerate() {
let phase = std::f64::consts::TAU * hz * i as f64 / 48_000.0;
re += f64::from(frame[ch]) * phase.cos();
im += f64::from(frame[ch]) * phase.sin();
fn assert_shuttle_tones(buf: &[f32], maintain: bool, speed: f64) {
let amplitude = |samples: &[f32], ch: usize, hz: f64| {
let (mut re, mut im) = (0.0, 0.0);
for (i, frame) in samples.as_chunks::<2>().0.iter().enumerate() {
let phase = std::f64::consts::TAU * hz * i as f64 / 48_000.0;
re += f64::from(frame[ch]) * phase.cos();
im += f64::from(frame[ch]) * phase.sin();
}
2.0 * re.hypot(im) / (samples.len() / 2) as f64
};
for (ch, tone) in [440.0, 660.0].into_iter().enumerate() {
let hz = if maintain { tone } else { tone * speed };
let level = amplitude(buf, ch, hz);
assert!(level > 0.08, "maintain={maintain}, speed={speed}, channel={ch}: {hz} Hz level={level}");
if speed != 1.0 {
let wrong = if maintain { tone * speed } else { tone };
assert!(amplitude(buf, ch, wrong) < level * 0.3, "wrong pitch {wrong} Hz at {speed}x, maintain={maintain}");
}
}
2.0 * re.hypot(im) / (samples.len() / 2) as f64
};
}
let mut session = Session::default();
session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap();
let inter: Vec<f32> =
@@ -3201,15 +3217,7 @@ mod audio_recovery_tests {
app.end_preroll(0.0);
assert!(app.playback.audio_clock);
let buf = samples.lock().unwrap();
for (ch, tone) in [440.0, 660.0].into_iter().enumerate() {
let hz = if maintain { tone } else { tone * speed };
let level = amplitude(&buf, ch, hz);
assert!(level > 0.08, "maintain={maintain}, speed={speed}, channel={ch}: {hz} Hz level={level}");
if speed != 1.0 {
let wrong = if maintain { tone * speed } else { tone };
assert!(amplitude(&buf, ch, wrong) < level * 0.3, "wrong pitch {wrong} Hz at {speed}x, maintain={maintain}");
}
}
assert_shuttle_tones(&buf, maintain, speed);
if speed == 1.0 {
if let Some(previous) = &normal {
assert_eq!(&*buf, previous, "1x must bypass pitch processing");
+1
View File
@@ -50,6 +50,7 @@ pub fn menu_hint(command: &str) -> Option<String> {
"help.revealLogFiles" => tl!("Saves this session's log and shows it in your file manager."),
"help.systemCompatibilityReport" => tl!("Shows a report on this computer."),
"app.about" => tl!("Shows the version, community links and credits in a FilmCraft window."),
"app.checkForUpdates" => tl!("Looks for a newer FilmCraft release and offers to update."),
_ => return None,
}
.to_string(),
+5
View File
@@ -146,6 +146,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("help.reportIssue", "Report an Issue…", ["Help"], None),
uic!("help.revealLogFiles", "Reveal Log Files…", ["Help"], None),
uic!("help.systemCompatibilityReport", "System Compatibility Report…", ["Help"], None),
uic!("app.checkForUpdates", "Check for Updates…", ["Help"], None),
uic!("app.about", "About FilmCraft", ["Help"], None),
uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")),
uic!("app.settings.general", "General…", ["Edit", "Preferences"], Some("Cmd+,")),
@@ -444,6 +445,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
app.ui.mode = Mode::Export;
return Ok(Value::Null);
}
"app.checkForUpdates" => {
let _ = crate::updates_ui::check(app);
return Ok(serde_json::Value::Null);
}
"app.about" => {
app.dialog = Some(crate::Dialog::About);
return Ok(Value::Null);
+2 -2
View File
@@ -1434,8 +1434,8 @@ fn quick_file(app: &FilmcraftApp, path: &str, seq_name: &str, ext: &str) -> Opti
let p = std::path::Path::new(path);
let folder = path.ends_with(['/', '\\']);
let dir = if folder { Some(p) } else { p.parent() }.filter(|d| !d.as_os_str().is_empty());
let name = if folder { None } else { p.file_name().and_then(|n| n.to_str()) }.filter(|n| !n.trim().is_empty());
let cur = name.and_then(|n| std::path::Path::new(n).extension()).and_then(|e| e.to_str()).unwrap_or("");
let name = if folder { None } else { p.file_name().and_then(std::ffi::OsStr::to_str) }.filter(|n| !n.trim().is_empty());
let cur = name.and_then(|n| std::path::Path::new(n).extension()).and_then(std::ffi::OsStr::to_str).unwrap_or("");
if dir.is_some() && name.is_some() && cur.eq_ignore_ascii_case(ext) {
return None;
}
+2 -2
View File
@@ -84,7 +84,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS.to_vec();
let hint = RelinkHint { command: id.to_string(), params: json!({"item": item.0}) };
let id = id.to_string();
Some(app.pick(FileDialog::Relink { exts: exts.iter().map(|e| e.to_string()).collect(), hint }, move |app, paths| {
Some(app.pick(FileDialog::Relink { exts: exts.iter().map(ToString::to_string).collect(), hint }, move |app, paths| {
let Some(picked) = paths.into_iter().next() else { return Ok(Value::Null) };
app.session.execute(&id, json!({"item": item.0, "path": picked})).map_err(|e| e.to_string())
}))
@@ -342,7 +342,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
params["item"] = json!(item);
let hint = RelinkHint { command: "media.relink".into(), params };
let item = json!(item);
app.pick_ui(FileDialog::Relink { exts: exts.iter().map(|e| e.to_string()).collect(), hint }, move |app, paths| {
app.pick_ui(FileDialog::Relink { exts: exts.iter().map(ToString::to_string).collect(), hint }, move |app, paths| {
let (Some(path), Some(mut d)) = (paths.into_iter().next(), app.ui.link_media.clone()) else { return };
relink_to(app, &mut d, &item, path);
app.ui.link_media = Some(d);
+170
View File
@@ -0,0 +1,170 @@
//! Software updates, background check at startup, and startup prompt modal.
use serde_json::Value;
use crate::FilmcraftApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
#[cfg(not(target_arch = "wasm32"))]
repaint: Option<egui::Context>,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::FILMCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
manual_window_open: false,
status_message: None,
}
}
}
impl FilmcraftApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
pub(crate) fn check(app: &mut FilmcraftApp) -> Result<Value, String> {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
Ok(Value::Null)
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
Ok(Value::Null)
}
}
pub(crate) fn tick(app: &mut FilmcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
rt.repaint = Some(ctx.clone());
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
let clean = !app.session.is_dirty();
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => crate::links::open(app, ctx, &url),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, Some(tl!("Checking for updates…").to_string())),
Status::UpToDate => {
(true, Some(tl!("FilmCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION"))))
}
Status::Failed(e) => (true, Some(tl!("Could not check for updates:\n{error}").replace("{error}", e))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
egui::Window::new(crate::i18n::t("FilmCraft Updates"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
}
ui.add_space(8.0);
if ui.button(crate::i18n::t("OK")).clicked() {
app.updates_rt.manual_window_open = false;
}
});
if !open {
app.updates_rt.manual_window_open = false;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_runtime_is_idle() {
let rt = Runtime::default();
assert!(!rt.manual_window_open);
#[cfg(not(target_arch = "wasm32"))]
{
assert!(!rt.startup_check);
assert!(!rt.updater.prompt_open());
assert_eq!(rt.updater.status(), &Status::Idle);
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn startup_prompt_shows_and_closes() {
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
let ctx = egui::Context::default();
let release = craft_update::Release {
tag: "v99.0.0".into(),
version: craft_update::parse_version("99.0.0"),
name: "FilmCraft 99.0.0".into(),
html_url: "https://github.com/storytold/filmcraft/releases/tag/v99.0.0".into(),
..Default::default()
};
app.updates_rt.updater.offer(release);
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui.ctx());
});
output.textures_delta.clear();
assert!(app.updates_rt.updater.prompt_open());
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ fn main() {
Ok(n) => decoded += n,
Err(e) => {
panics += 1;
let msg = e.downcast_ref::<String>().cloned().or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_default();
let msg = e.downcast_ref::<String>().cloned().or_else(|| e.downcast_ref::<&str>().map(ToString::to_string)).unwrap_or_default();
eprintln!("PANIC {path} iteration {it} mode {mode}: {msg}");
}
}
+10 -3
View File
@@ -9,8 +9,15 @@ use crate::error::{Error, Result, ensure};
use crate::header::{EntropyContext, FrameHeader, FrameTag, LfDeltas, Segmentation, read_header};
use crate::loopfilter::{self, Kind, Params};
use crate::predict::{self, RefPlane};
use crate::spec_tables::*;
use crate::tables::*;
use crate::spec_tables::{
AC_QLOOKUP, BILINEAR_FILTERS, BMODE_PROBS, COEFF_BANDS, DC_QLOOKUP, KF_BMODE_PROBS, KF_UV_MODE_PROBS, KF_YMODE_PROBS, MODE_CONTEXTS, MVPARTITION_PROBS,
SIXTAP_FILTERS, SUB_MV_REF_PROBS,
};
use crate::tables::{
ABOVE_4X4, B_DC_PRED, B_HE_PRED, B_PRED, B_TM_PRED, B_VE_PRED, BMODE_TREE, CAT_BASE, COEFF_TREE, DC_PRED, DCT_0, DCT_4, DCT_CAT1, DCT_EOB, H_PRED,
KF_YMODE_TREE, LEFT_4X4, MV_NEAR, MV_NEAREST, MV_NEW, MV_REF_TREE, MV_SPLIT, MV_ZERO, MVP_BITS, MVP_IS_SHORT, MVP_SHORT, MVP_SIGN, MVPARTITION_TREE, PCAT,
SEGMENT_TREE, SMALL_MV_TREE, SPLIT_COUNTS, SPLIT_MAPS, SUB_MV_REF_TREE, TM_PRED, UV_MODE_TREE, V_PRED, YMODE_TREE, ZERO_4X4, ZIGZAG,
};
use crate::transform;
/// Largest frame dimension in macroblocks (14-bit sizes).
@@ -380,7 +387,7 @@ impl Decoder {
predict::predict_block(&mut frame.u, cs, px / 2, py / 2, 8, uv_mode);
predict::predict_block(&mut frame.v, cs, px / 2, py / 2, 8, uv_mode);
} else {
let Some(r) = refs.get(usize::from(info.ref_frame) - 1).and_then(|r| r.as_deref()) else {
let Some(r) = refs.get(usize::from(info.ref_frame) - 1).and_then(Option::as_deref) else {
return Err(Error::MissingReference(format!("reference frame {}", info.ref_frame)));
};
predict_inter_mb(r, &mut frame, mb_x, mb_y, &info, filters, full_pixel);
+1 -1
View File
@@ -89,7 +89,7 @@ pub struct EntropyContext {
impl Default for EntropyContext {
fn default() -> Self {
use crate::spec_tables::*;
use crate::spec_tables::{DEFAULT_COEFF_PROBS, DEFAULT_MV_PROBS, UV_MODE_PROBS, YMODE_PROBS};
let mut coeff = [[[[0u8; 11]; 3]; 8]; 4];
for (i, p) in coeff.iter_mut().flatten().flatten().flatten().enumerate() {
*p = DEFAULT_COEFF_PROBS[i];
+1 -1
View File
@@ -6,7 +6,7 @@
//! row and 129 below it). Inter prediction reads a reference plane extended infinitely by
//! repeating its edge pixels.
use crate::tables::*;
use crate::tables::{B_HD_PRED, B_HE_PRED, B_HU_PRED, B_LD_PRED, B_RD_PRED, B_TM_PRED, B_VE_PRED, B_VL_PRED, B_VR_PRED, H_PRED, TM_PRED, V_PRED};
/// A pixel of the current frame for intra prediction, with the out-of-frame values of section 12.
#[inline]
+7 -1
View File
@@ -19,6 +19,7 @@ workspace_version() {
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
return 0
}
VERSION="${FILMCRAFT_VERSION:-$(workspace_version)}"
@@ -42,6 +43,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
return 0
}
# Copy licence and readme files that exist into a package directory, plus the licences of the
@@ -52,6 +54,7 @@ copy_docs() {
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
return 0
}
# Copy each craft-fonts licence ($CRAFT_FONTS_DIR/fonts/<family>/OFL.txt) to <dest>/OFL-<family>.txt.
@@ -64,9 +67,12 @@ copy_font_licences() {
family="$(basename "$(dirname "$lic")")"
cp "$lic" "$dest/OFL-$family.txt"
done
return 0
}
# Portable SHA-256 of a file (prints just the hash).
sha256() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
local file="$1"
if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
return 0
}
+1 -1
View File
@@ -23,7 +23,7 @@ MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.14 -62.14 636.27 636.27"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.14' "$MAC" || { echo "error: unexpected viewBox in $SVG" >&2; exit 1; }
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render() { local input="$1" size="$2" output="$3"; resvg -w "$size" -h "$size" "$input" "$output" </dev/null; }
render "$SVG" 1024 "$DIR/filmcraft-1024.png"
# Runtime window/Dock icon on macOS (apps/filmcraft/src/main.rs embeds it).
+1 -1
View File
@@ -24,7 +24,7 @@ prefix="$(realpath -m -- "$prefix")"
[[ "$prefix" != *'='* && "$prefix" != *'%'* && ! "$prefix" =~ [[:cntrl:]] ]] || fail 'prefix cannot contain =, % or control characters'
[[ "$prefix" != / ]] || fail 'use /usr/local or a user directory, not /'
source_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)"
case "$prefix/" in "$source_dir/"*) fail 'choose an installation prefix outside the extracted tarball' ;; esac
case "$prefix/" in "$source_dir/"*) fail 'choose an installation prefix outside the extracted tarball' ;; *) ;; esac
app_id=ai.storyteller.filmcraft
for binary in filmcraft filmcraft-cli; do
[[ -f "$source_dir/bin/$binary" ]] || fail "missing bin/$binary; extract the complete tarball first"
+1 -1
View File
@@ -79,7 +79,7 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
has() { local fmt="$1"; case " $FORMATS " in *" $fmt "*) return 0 ;; *) return 1 ;; esac; }
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then