mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge remote-tracking branch 'upstream/main' into docking-frame-trial
This commit is contained in:
Generated
+370
-180
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user