diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 890a9def1..d0158a286 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -319,15 +319,19 @@ impl Prober { return f.map(|footage| Probed { name, footage, ask_alpha: false, warning: None }).map_err(fail); } let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?; - let mut f = crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?; - let mut warning = None; + // What the file's own import left out (a model's textures over the limits) is a warning too. + let (mut f, mut warning) = match src { + Source::File(_) => importer.probe_noting(path).map_err(fail)?, + _ => (crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?, None), + }; // Settings ▸ Import ▸ Report Missing Frames (numbered sequences). if f.kind == FootageKind::Sequence && !f.alphabetical && self.report_missing_frames && let Some(gaps) = crate::sequence::missing_report(&f.sequence) { - warning = Some(format!("{name}: {gaps} (the previous frame is held)")); + let gaps = format!("{name}: {gaps} (the previous frame is held)"); + warning = Some(warning.map_or(gaps.clone(), |w| format!("{w}; {gaps}"))); } // Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its // own alpha, and audio and data have none). diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index 5658df107..fe95f5300 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -106,6 +106,11 @@ pub trait Importer: Send + Sync { /// Probe one file and return footage metadata. Image sequences are the engine's /// ([`sequence`]): it groups numbered stills and probes their first file here. fn probe(&self, path: &str) -> std::result::Result; + /// [`probe`](Self::probe), and what the import left out of the file (a 3D model's textures + /// over the limits), which the import shows the user. + fn probe_noting(&self, path: &str) -> std::result::Result<(effectcraft_project::Footage, Option), String> { + self.probe(path).map(|f| (f, None)) + } /// A footage file's bytes are now at `path` (extracted from a template): hosts whose media /// layer reads from memory (the browser) register them; file-based ones need nothing. fn register(&self, _path: &str, _data: &[u8]) {} diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b253640e7..020c2daf0 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -96,6 +96,25 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y"); } +/// What an import left out of a file (a 3D model's textures over the limits) comes back in the +/// import's `warnings`. +#[test] +fn what_an_import_left_out_is_a_warning() { + use effectcraft_project::{Footage, FootageKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + Ok(Footage { path: path.into(), kind: FootageKind::Model, ..Default::default() }) + } + fn probe_noting(&self, path: &str) -> Result<(Footage, Option), String> { + Ok((self.probe(path)?, Some(format!("{path}: texture `a.png` left out")))) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let r = s.execute("file.import", json!({"paths": ["/m/scene.obj"]})).unwrap(); + assert_eq!(r["warnings"], json!(["/m/scene.obj: texture `a.png` left out"])); +} + /// #270: files dropped or picked in the Import dialog (`file.import {background}`) are probed in /// a background job that says which file it is reading and how many are left; then they arrive /// in one undo step, all selected, with a count. The same file imported again arrives again. diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 843a06123..7212fd730 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -20,6 +20,9 @@ impl Importer for MediaImporter { fn probe(&self, path: &str) -> Result { effectcraft_media::probe(path).map_err(|e| e.to_string()) } + fn probe_noting(&self, path: &str) -> Result<(Footage, Option), String> { + effectcraft_media::probe_noting(path).map_err(|e| e.to_string()) + } } /// Render Queue export via `effectcraft-export`: to the file system, or to `sink` (the web app diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 0b509c45f..454c3705f 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -33,7 +33,7 @@ mod webp_anim; /// Video decoder counters (time in decoders, samples decoded, seeks, cache hits), for benchmarks. pub use filmcraft_codecs::{GopStats, gop_stats}; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; -pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; +pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model, probe_noting}; /// Errors from probing or decoding footage. #[derive(Debug, thiserror::Error)] diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2c7b2a402..2a734b7d5 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -43,11 +43,25 @@ fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { } } +thread_local! { + /// What the last model probed on this thread left out (see [`probe_noting`]). + static MODEL_WARNING: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// [`probe`], and what the import left out of the file (a 3D model's textures over the limits), +/// for the user. +pub fn probe_noting(path: impl AsRef) -> Result<(Footage, Option)> { + MODEL_WARNING.with(|w| w.borrow_mut().take()); + let f = probe(path)?; + Ok((f, MODEL_WARNING.with(|w| w.borrow_mut().take()))) +} + /// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its /// longest animation. `resolve` reads sibling resources. pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; let s = m.summary(); + MODEL_WARNING.with(|w| *w.borrow_mut() = (!m.warnings.is_empty()).then(|| format!("{path}: {}", m.warnings.join("; ")))); let dur = s.animations.iter().map(|a| a.1).fold(0.0f64, f64::max); let codec = match effectcraft_model::format_of(path) { Some("obj") => "OBJ", diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index c0b736359..79dab1ddc 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -536,7 +536,7 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option String { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 19a0d37f3..2a7374a05 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -68,6 +68,11 @@ impl Texture { pub fn solid(c: [f32; 4]) -> Texture { Texture { width: 1, height: 1, data: vec![c], wrap: [Wrap::Repeat; 2] } } + /// The pixel size an image claims, read from its header without decoding it. + pub fn dimensions(bytes: &[u8]) -> Result<(u32, u32)> { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| perr(format!("image: {e}")))?; + reader.into_dimensions().map_err(|e| perr(format!("image: {e}"))) + } /// Decode a PNG or JPEG. pub fn decode(bytes: &[u8]) -> Result { let img = image::load_from_memory(bytes).map_err(|e| perr(format!("image: {e}")))?.to_rgba32f(); @@ -512,6 +517,8 @@ pub struct Model { pub cameras: Vec, /// Punctual lights embedded in the file (glTF `KHR_lights_punctual`). pub lights: Vec, + /// Parts of the file the import left out (textures over the limits), for the user. + pub warnings: Vec, } /// A camera embedded in a model file.