mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 23:02:14 +00:00
Import: VectorCraft documents import through the PDF they carry (#2901)
* Import: VectorCraft documents import through the PDF they carry File > Import reads a .vectorcraft file through the PDF that VectorCraft saves in it with Create PDF-Compatible File, one page per artboard, as footage or as a composition with one layer per PDF layer. The PDF is read with craft-doc from craft-libs (plain or compressed files, the PDF inline or after the JSON). A PDF after the JSON may be as large as a PDF file EffectCraft imports (512 MiB). A document saved without that option gives an import error that names it. craft-doc, craft-vectorcraft-format (tests only) and craft-ecproj-format are pinned to craft-libs fa1a6574; craft-ecproj-format is unchanged between ca1b5e2e and that commit. * Import: show file errors returned by the import dialog * Keep dropped-file import errors alongside local drop errors * Import: preserve every file error across background jobs and UI notices * Import: queue pending errors with bounded notification details * Import: a damaged VectorCraft preview no longer stops the carried PDF The importer read every rendition with craft_doc::renditions and then picked the PDF, so a preview whose base64 was invalid or whose data lay past the end of the file made the whole import fail. It now reads only the PDF with craft_doc::rendition, from storytold/craft-libs#546, and never decodes the preview. A document without a PDF still reports NoCarriedPdf. * Import: dismissing a picked import's error status acknowledges its details A picked or menu import queued its error details with the status report_import_errors set ("first (and 1 more)"), and show_result_errors then replaced that status with "first; second". The two never matched, so clicking the status left the details queued and they showed again. Import commands now move the queued details to the status that replaces it, also when After Effects project errors lead it. Other commands' statuses never take over an import's details.
This commit is contained in:
1 parent
dc2380b070
commit
7cbefaf70f
26 files changed
+911
-35
No files matched your search
Generated
+40
@@ -1103,6 +1103,20 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-doc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
|
||||
dependencies = [
|
||||
"craft-ecproj-format",
|
||||
"craft-pcraft-format",
|
||||
"craft-vectorcraft-format",
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
@@ -1206,6 +1220,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1297,6 +1321,17 @@ dependencies = [
|
||||
"zip 9.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-video"
|
||||
version = "0.1.0"
|
||||
@@ -1770,6 +1805,7 @@ dependencies = [
|
||||
name = "effectcraft-host"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-vectorcraft-format",
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
"effectcraft-expr",
|
||||
@@ -1915,14 +1951,18 @@ dependencies = [
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-doc",
|
||||
"craft-eps",
|
||||
"craft-pdf",
|
||||
"craft-vectorcraft-format",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo",
|
||||
"miniz_oxide 0.9.1",
|
||||
"serde_json",
|
||||
"skrifa",
|
||||
"wasm-bindgen-test",
|
||||
"zune-jpeg",
|
||||
|
||||
@@ -25,6 +25,8 @@ craft-webp = { version = "0.1.0", git = "https://github.com/storytold/craft-libs
|
||||
craft-lottie = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
|
||||
craft-mesh = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", default-features = false, features = ["stl", "obj", "gltf", "fbx"] }
|
||||
craft-aep = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
|
||||
craft-doc = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
|
||||
craft-vectorcraft-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["egui"] }
|
||||
# internal
|
||||
|
||||
@@ -187,7 +187,8 @@ impl Session {
|
||||
"cancelled" => format!("{} cancelled", t.label),
|
||||
_ => format!("{} failed: {message}", t.label),
|
||||
};
|
||||
self.events.push(crate::Event::Toast { message: toast, error: status == "failed" });
|
||||
let has_errors = rec.result["errors"].as_array().is_some_and(|errors| !errors.is_empty());
|
||||
self.events.push(crate::Event::Toast { message: toast, error: status == "failed" || has_errors });
|
||||
self.job_log.push(rec.clone());
|
||||
if self.job_log.len() > 50 {
|
||||
self.job_log.remove(0);
|
||||
|
||||
@@ -143,7 +143,8 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => format!("Importing {total} files"),
|
||||
};
|
||||
let p = p.clone();
|
||||
return s.spawn_task("import", label, false, move |ctl| {
|
||||
let immediate_errors = pending.errors.clone();
|
||||
let mut result = s.spawn_task("import", label, false, move |ctl| {
|
||||
let mut probed = Vec::with_capacity(total);
|
||||
for (k, src) in sources.iter().enumerate() {
|
||||
ctl.message(format!("{} ({} of {total})", src.name(), k + 1));
|
||||
@@ -162,13 +163,19 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match errors.as_slice() {
|
||||
[] => {}
|
||||
[one] => toast = format!("{toast}. {one}"),
|
||||
[first, rest @ ..] => toast = format!("{toast}. {first} (and {} more)", rest.len()),
|
||||
[first, rest @ ..] => toast = format!("{toast}. {first} (and {} more)\n{}", rest.len(), errors.join("\n")),
|
||||
}
|
||||
r["toast"] = json!(toast);
|
||||
Ok(r)
|
||||
});
|
||||
Ok(apply)
|
||||
});
|
||||
})?;
|
||||
// Preparation can reject layered files before the remaining footage needs probing.
|
||||
// Report those failures now as well as in the eventual job result.
|
||||
if result["started"] == true {
|
||||
result["errors"] = json!(immediate_errors);
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
|
||||
let mut r = add_footage(s, pending, probed)?;
|
||||
@@ -346,8 +353,9 @@ impl Prober {
|
||||
None => ask_alpha = true,
|
||||
}
|
||||
}
|
||||
// Page: another page of a PDF / Illustrator file.
|
||||
if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
|
||||
// Page: another page of a PDF / Illustrator file, or another artboard of a VectorCraft
|
||||
// document's PDF.
|
||||
if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI" | "VectorCraft") {
|
||||
let doc = self
|
||||
.services
|
||||
.read_file(path)
|
||||
@@ -563,7 +571,8 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result:
|
||||
/// definition, OpenEXR premultiplied); TGA, TIFF and movies with alpha don't.
|
||||
fn alpha_is_labeled(path: &str, codec: &str) -> bool {
|
||||
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD")
|
||||
matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps" | "vectorcraft")
|
||||
|| codec.eq_ignore_ascii_case("PSD")
|
||||
}
|
||||
|
||||
/// A Photoshop layer by index or name (pixel layers only).
|
||||
|
||||
@@ -197,7 +197,7 @@ fn sort_entries(entries: &mut [Entry]) {
|
||||
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
||||
}
|
||||
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr", "vectorcraft"];
|
||||
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
|
||||
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
|
||||
|
||||
@@ -99,7 +99,7 @@ fn extension(path: &str) -> String {
|
||||
|
||||
/// Stills that can form a sequence (not layered or vector documents, which import as comps).
|
||||
fn sequenceable(path: &str) -> bool {
|
||||
crate::media_browser::kind_of(path) == Some("image") && !matches!(extension(path).as_str(), "svg" | "psd" | "psb")
|
||||
crate::media_browser::kind_of(path) == Some("image") && !matches!(extension(path).as_str(), "svg" | "psd" | "psb" | "vectorcraft")
|
||||
}
|
||||
|
||||
/// The run a still belongs to: (folder, name prefix, extension); alphabetical runs ignore the
|
||||
@@ -343,6 +343,17 @@ mod tests {
|
||||
assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1);
|
||||
}
|
||||
|
||||
/// VectorCraft documents list as media in the Media Browser and import from a folder, each on
|
||||
/// its own, as SVG files do: numbered ones don't form a sequence.
|
||||
#[test]
|
||||
fn vectorcraft_documents_are_media_but_not_sequences() {
|
||||
assert_eq!(crate::media_browser::kind_of("/art/Poster.VECTORCRAFT"), Some("image"));
|
||||
let t = Table(vec!["/art/frame_0001.vectorcraft", "/art/frame_0002.vectorcraft"]);
|
||||
let on = SequenceOptions::default();
|
||||
assert_eq!(group(&t, &strs(&["/art/frame_0001.vectorcraft"]), on), [Source::File("/art/frame_0001.vectorcraft".into())]);
|
||||
assert_eq!(group(&t, &strs(&["/art"]), on), [Source::File("/art/frame_0001.vectorcraft".into()), Source::File("/art/frame_0002.vectorcraft".into())]);
|
||||
}
|
||||
|
||||
/// Thousands of files picked one by one from one folder: the folder is listed once, not
|
||||
/// once per file, and each finds its run by key.
|
||||
#[test]
|
||||
|
||||
@@ -173,6 +173,7 @@ fn background_imports_report_progress_and_select_what_arrived() {
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["c.png"]);
|
||||
assert_eq!(s.job_log.last().unwrap().message, "Imported 1 item. /drop/d.xyz: unsupported file");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { message, error: true } if message.contains("/drop/d.xyz"))));
|
||||
// Agents and scripts import at once by default.
|
||||
let r = s.execute("file.import", json!({"paths": ["/drop/e.png"]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
|
||||
|
||||
@@ -32,6 +32,7 @@ tempfile = { workspace = true }
|
||||
effectcraft-psd = { workspace = true }
|
||||
effectcraft-svg = { workspace = true }
|
||||
effectcraft-pdf = { workspace = true }
|
||||
craft-vectorcraft-format = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-interchange = { workspace = true }
|
||||
|
||||
@@ -951,6 +951,17 @@ fn ai_composition_retains_layer_sizes_and_positions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.vectorcraft` file (written with craft-vectorcraft-format's envelope) holding an empty
|
||||
/// document and `pdf` as the PDF it carries (Create PDF-Compatible File).
|
||||
fn vectorcraft_file(pdf: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut file = craft_vectorcraft_format::File::new(json!({"artboards": [], "layers": []}));
|
||||
let mut tail = file.tail();
|
||||
file.pdf = pdf.map(|b| tail.image("application/pdf", b, false));
|
||||
let mut out = serde_json::to_vec(&file).unwrap();
|
||||
tail.write_to(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An A4 page (595.28 × 841.89 pt) whose artwork sits inside a page clip and ten nested
|
||||
/// clipping groups, as PDFs exported from layout and office applications nest them: a black bar
|
||||
/// whose left edge is at x = 30 pt, from 41.89 to 191.89 pt down the page.
|
||||
@@ -1025,3 +1036,64 @@ fn a4_pdf_scaled_200_percent_continuously_rasterizes_sharp() {
|
||||
// Outside the clips (x = 10 pt) nothing is drawn.
|
||||
assert!(on.get(20, 240)[3] < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectorcraft_documents_import_through_the_pdf_they_carry() {
|
||||
let bytes = layered_pdf();
|
||||
let doc = effectcraft_pdf::parse(&bytes).unwrap();
|
||||
let direct = effectcraft_svg::rasterize(&doc, 120, 80, 1.0);
|
||||
let path = tmp("art.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_file(Some(&bytes))).unwrap();
|
||||
// As footage: drawn as the PDF, and continuously rasterized.
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.codec.as_str(), f.width, f.height), ("VectorCraft", 120, 80));
|
||||
s.execute("comp.new", json!({"name": "V", "width": 120, "height": 80, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
assert!(mean_diff(&s.render(cid, t, RenderOpts::default()), &direct) < 1.0 / 255.0, "footage = the carried PDF");
|
||||
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] {
|
||||
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
|
||||
}
|
||||
let want = effectcraft_svg::rasterize(&doc, 120, 80, 3.0);
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
|
||||
let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
assert!(sharp < 0.004, "continuously rasterized {sharp}");
|
||||
// As a composition: one layer per PDF layer, top first.
|
||||
for import_as in ["composition", "compositionLayerSizes"] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), vec!["Art", "Sky"]);
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
let d = mean_diff(&s.render(cid, t, RenderOpts::default()), &direct);
|
||||
assert!(d < 1.0 / 255.0, "{import_as}: comp vs page {d}");
|
||||
}
|
||||
// Another artboard: the carried PDF's page 2.
|
||||
let pages = tmp("pages.vectorcraft");
|
||||
std::fs::write(&pages, vectorcraft_file(Some(&two_page_pdf()))).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [pages], "page": 2})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(r["items"][0].as_u64().unwrap())).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height, f.page), (100, 60, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vectorcraft_document_without_a_pdf_reports_the_option_to_turn_on() {
|
||||
let path = tmp("no-pdf.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_file(None)).unwrap();
|
||||
for import_as in ["footage", "composition"] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
let e = r["errors"][0].as_str().unwrap_or_default();
|
||||
assert!(e.contains("no-pdf.vectorcraft") && e.contains("Create PDF-Compatible File"), "{import_as}: {r}");
|
||||
assert_eq!(r["items"], json!([]), "{import_as}: {r}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one
|
||||
//! layer for footage imported as a composition) and vector files rasterised at any scale: SVG
|
||||
//! (`effectcraft-svg`), PDF / Illustrator / EPS (`effectcraft-pdf`; one layer of the file for
|
||||
//! footage imported as a composition).
|
||||
//! (`effectcraft-svg`), PDF / Illustrator / EPS and the PDF a VectorCraft document carries
|
||||
//! (`effectcraft-pdf`; one layer of the file for footage imported as a composition).
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
@@ -65,7 +65,7 @@ impl From<filmcraft_media::MediaError> for MediaError {
|
||||
|
||||
/// File extensions recognised as stills.
|
||||
pub const STILL_EXTENSIONS: &[&str] =
|
||||
&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"];
|
||||
&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps", "vectorcraft"];
|
||||
/// File extensions recognised as audio-only files.
|
||||
pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
|
||||
pub use effectcraft_project::VIDEO_EXTENSIONS;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "effectcraft-pdf"
|
||||
description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, shadings, tiling patterns, clipping, text with embedded fonts, images, blend modes, soft masks, optional-content layers)"
|
||||
description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages, EPS (a PostScript subset) and the PDF a VectorCraft (.vectorcraft) document carries, parsed into the SVG render tree (paths, fills, strokes, shadings, tiling patterns, clipping, text with embedded fonts, images, blend modes, soft masks, optional-content layers)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
[dependencies]
|
||||
craft-eps = { workspace = true }
|
||||
craft-pdf.workspace = true
|
||||
# The PDF a VectorCraft document carries (Create PDF-Compatible File).
|
||||
craft-doc = { workspace = true }
|
||||
effectcraft-svg = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
effectcraft-text = { workspace = true }
|
||||
@@ -25,6 +27,10 @@ zune-jpeg = "0.5"
|
||||
# Encodes JPEG fixtures for the DCT image test.
|
||||
image = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
# Writes the VectorCraft documents the tests read.
|
||||
craft-vectorcraft-format = { workspace = true }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
@@ -114,6 +114,10 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
|
||||
stitching and PostScript calculator (type 4) functions.
|
||||
- **Pages:** any page ([`parse_page`], [`page_count`]); File ▸ Import takes `page` (from 1) and
|
||||
the Import dialog asks for it; footage remembers its page (`Footage::page`).
|
||||
- **VectorCraft documents** (`.vectorcraft`, plain or compressed): the PDF the document carries
|
||||
when VectorCraft saved it with Create PDF-Compatible File, one page per artboard, read through
|
||||
`craft-doc` from craft-libs. The footage codec is `VectorCraft`. A document saved without that
|
||||
option has no PDF, and importing it reports an error that names the option.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
||||
+45
-4
@@ -34,6 +34,8 @@ pub enum Error {
|
||||
NoPages,
|
||||
PageOutOfRange(usize),
|
||||
NoBoundingBox,
|
||||
/// A VectorCraft document saved without Create PDF-Compatible File.
|
||||
NoCarriedPdf,
|
||||
Read(String),
|
||||
}
|
||||
|
||||
@@ -45,6 +47,9 @@ impl std::fmt::Display for Error {
|
||||
Error::NoPages => write!(f, "the PDF has no pages"),
|
||||
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n.saturating_add(1)),
|
||||
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
|
||||
Error::NoCarriedPdf => {
|
||||
write!(f, "the VectorCraft document holds no PDF; save it in VectorCraft with Create PDF-Compatible File turned on")
|
||||
}
|
||||
Error::Read(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
@@ -53,17 +58,24 @@ impl std::fmt::Display for Error {
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// File extensions read as vector footage by this crate.
|
||||
pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi"];
|
||||
pub const EXTENSIONS: &[&str] = &["pdf", "ai", "eps", "epsf", "epsi", "vectorcraft"];
|
||||
|
||||
/// The format of a file, by its first bytes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Pdf,
|
||||
Eps,
|
||||
/// A VectorCraft document (`.vectorcraft`), read through the PDF it carries when it was
|
||||
/// saved with Create PDF-Compatible File: one page per artboard.
|
||||
VectorCraft,
|
||||
}
|
||||
|
||||
/// Sniff a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files are) or an EPS file.
|
||||
/// Sniff a VectorCraft document, a PDF (`%PDF-` near the start, as PDF-compatible `.ai` files
|
||||
/// are) or an EPS file. The first 64 KiB suffice.
|
||||
pub fn sniff(bytes: &[u8]) -> Option<Format> {
|
||||
if craft_doc::sniff(bytes) == Some(craft_doc::Kind::VectorCraft) {
|
||||
return Some(Format::VectorCraft);
|
||||
}
|
||||
let head = &bytes[..bytes.len().min(1024)];
|
||||
if head.windows(5).any(|w| w == b"%PDF-") {
|
||||
return Some(Format::Pdf);
|
||||
@@ -74,11 +86,12 @@ pub fn sniff(bytes: &[u8]) -> Option<Format> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A footage codec name for a sniffed file ("PDF", "AI", "EPS").
|
||||
/// A footage codec name for a sniffed file ("PDF", "AI", "EPS", "VectorCraft").
|
||||
pub fn codec(path: &str, bytes: &[u8]) -> Option<&'static str> {
|
||||
let f = sniff(bytes)?;
|
||||
let ai = path.to_ascii_lowercase().ends_with(".ai");
|
||||
Some(match (f, ai) {
|
||||
(Format::VectorCraft, _) => "VectorCraft",
|
||||
(_, true) => "AI",
|
||||
(Format::Pdf, false) => "PDF",
|
||||
(Format::Eps, false) => "EPS",
|
||||
@@ -112,19 +125,47 @@ pub fn parse_page_framed(bytes: &[u8], index: usize, framing: Framing) -> Result
|
||||
match sniff(bytes) {
|
||||
Some(Format::Pdf) => parse_pdf(bytes, index, framing),
|
||||
Some(Format::Eps) => parse_eps(bytes),
|
||||
Some(Format::VectorCraft) => parse_pdf(&carried_pdf(bytes)?, index, framing),
|
||||
None => Err(Error::NotVector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of pages (1 for EPS).
|
||||
pub fn page_count(bytes: &[u8]) -> usize {
|
||||
let pdf_pages = |bytes: &[u8]| object::File::parse(bytes).map(|file| page::pages(&file).len()).unwrap_or(0);
|
||||
match sniff(bytes) {
|
||||
Some(Format::Pdf) => object::File::parse(bytes).map(|file| page::pages(&file).len()).unwrap_or(0),
|
||||
Some(Format::Pdf) => pdf_pages(bytes),
|
||||
Some(Format::Eps) => 1,
|
||||
Some(Format::VectorCraft) => carried_pdf(bytes).map(|pdf| pdf_pages(&pdf)).unwrap_or(0),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The PDF a VectorCraft document carries: [`Error::NoCarriedPdf`] when it was saved without
|
||||
/// Create PDF-Compatible File. The preview is not read, so a damaged preview does not stop the
|
||||
/// import.
|
||||
fn carried_pdf(bytes: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
let pdf = craft_doc::rendition(bytes, craft_doc::RenditionKind::Pdf, &carried_limits())
|
||||
.map_err(|e| Error::Read(format!("VectorCraft document: {e}")))?
|
||||
.ok_or(Error::NoCarriedPdf)?;
|
||||
if sniff(&pdf.bytes) != Some(Format::Pdf) {
|
||||
return Err(Error::Read("VectorCraft document: its PDF member is not a PDF".into()));
|
||||
}
|
||||
Ok(pdf.bytes)
|
||||
}
|
||||
|
||||
/// craft-doc's limits for [`carried_pdf`]: a PDF after the JSON (version 4) may be as large as a
|
||||
/// PDF file this crate opens (`craft_pdf::MAX_INPUT_BYTES`); one inline in the JSON as base64
|
||||
/// (version 3) stays within `max_json`, about 192 MiB of PDF. `max_total` allows `max_json` (craft-doc's default) for a
|
||||
/// compressed document's JSON, and twice `max_entry` for its data up to the PDF's end and the PDF
|
||||
/// copied out, which caps what a hostile file can make the import allocate.
|
||||
fn carried_limits() -> craft_doc::Limits {
|
||||
let mut limits = craft_doc::Limits::default();
|
||||
limits.max_entry = u64::try_from(craft_pdf::MAX_INPUT_BYTES).unwrap_or(u64::MAX);
|
||||
limits.max_total = limits.max_json.saturating_add(limits.max_entry.saturating_mul(2));
|
||||
limits
|
||||
}
|
||||
|
||||
/// Build the document: `root` children become layer groups (see the crate docs).
|
||||
fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64, height: f64, to_doc: Affine) -> Doc {
|
||||
let mut layers: Vec<Node> = vec![];
|
||||
|
||||
@@ -8,6 +8,7 @@ mod groups;
|
||||
mod import;
|
||||
mod reader;
|
||||
mod shading;
|
||||
mod vectorcraft;
|
||||
|
||||
const CONTENT: &str = "/OC /MC0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n\
|
||||
/OC /MC1 BDC\nq 10 10 80 80 re W n\n1 0 0 rg 0 0 50 100 re f\nQ\n\
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! VectorCraft documents read through the PDF they carry (Create PDF-Compatible File), written
|
||||
//! with craft-vectorcraft-format's envelope types: plain and compressed, the PDF inline as base64
|
||||
//! (version 3) and after the JSON (version 4).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use craft_vectorcraft_format::{BLOB_MAGIC, File, INLINE_MAX, Image};
|
||||
|
||||
use crate::write::pdf;
|
||||
use crate::*;
|
||||
|
||||
/// Two artboards: a 200×100 page with the layers "Background" (green) and "Art" (a red left
|
||||
/// half), and a 100×50 blue page. `pad` adds an unused object so the PDF is long enough to go
|
||||
/// after the JSON.
|
||||
fn artboards_pdf(pad: bool) -> Vec<u8> {
|
||||
let first = "/OC /L0 BDC\n0 1 0 rg 0 0 200 100 re f\nEMC\n/OC /L1 BDC\n1 0 0 rg 0 0 100 100 re f\nEMC\n";
|
||||
let mut objs = vec![
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>".to_string(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".to_string(), None),
|
||||
(4, "<< >>".to_string(), Some(first.as_bytes().to_vec())),
|
||||
(5, "<< /Type /OCG /Name (Background) >>".to_string(), None),
|
||||
(6, "<< /Type /OCG /Name (Art) >>".to_string(), None),
|
||||
(7, "<< >>".to_string(), Some(b"0 0 1 rg 0 0 100 50 re f\n".to_vec())),
|
||||
(8, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 7 0 R >>".to_string(), None),
|
||||
];
|
||||
if pad {
|
||||
// Written as given (the writer compresses streams without a `/Filter`); never read.
|
||||
objs.push((9, "<< /Filter /ASCIIHexDecode >>".to_string(), Some(vec![b'0'; INLINE_MAX + 1024])));
|
||||
}
|
||||
pdf(&objs, 1)
|
||||
}
|
||||
|
||||
/// A `.vectorcraft` file of `version` holding an empty document and `carried` as its `pdf`
|
||||
/// member, gzip-compressed when `gzip` is set (Use Compression).
|
||||
fn vectorcraft(carried: Option<&[u8]>, version: u32, gzip: bool) -> Vec<u8> {
|
||||
vectorcraft_with_preview(carried, None, version, gzip)
|
||||
}
|
||||
|
||||
/// [`vectorcraft`] with `preview` as the file's `preview` member.
|
||||
fn vectorcraft_with_preview(carried: Option<&[u8]>, preview: Option<Image>, version: u32, gzip: bool) -> Vec<u8> {
|
||||
let mut file = File::new(serde_json::json!({"artboards": [], "layers": []}));
|
||||
file.version = version;
|
||||
file.generator = Some("VectorCraft 0.1.0".into());
|
||||
file.preview = preview;
|
||||
let mut tail = file.tail();
|
||||
file.pdf = carried.map(|b| tail.image("application/pdf", b, false));
|
||||
let mut out = serde_json::to_vec(&file).unwrap();
|
||||
tail.write_to(&mut out).unwrap();
|
||||
if !gzip {
|
||||
return out;
|
||||
}
|
||||
let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::default());
|
||||
gz.write_all(&out).unwrap();
|
||||
gz.finish().unwrap()
|
||||
}
|
||||
|
||||
fn pixels(doc: &Doc) -> Vec<[f32; 4]> {
|
||||
let (w, h) = doc.pixel_size();
|
||||
effectcraft_svg::rasterize(doc, w, h, 1.0).data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vectorcraft_document_reads_as_the_pdf_it_carries() {
|
||||
for (version, gzip, pad) in [(3, false, false), (3, true, false), (4, false, true), (4, true, true)] {
|
||||
let carried = artboards_pdf(pad);
|
||||
let file = vectorcraft(Some(&carried), version, gzip);
|
||||
let case = format!("version {version}, gzip {gzip}");
|
||||
if !gzip {
|
||||
let after_json = file.windows(BLOB_MAGIC.len()).any(|w| w == BLOB_MAGIC);
|
||||
assert_eq!(after_json, version == 4, "{case}: the PDF goes after the JSON in version 4");
|
||||
}
|
||||
assert_eq!(sniff(&file), Some(Format::VectorCraft), "{case}");
|
||||
assert_eq!(codec("art.vectorcraft", &file), Some("VectorCraft"), "{case}");
|
||||
assert_eq!(page_count(&file), 2, "{case}");
|
||||
let doc = parse(&file).unwrap();
|
||||
assert_eq!((doc.width, doc.height), (200.0, 100.0), "{case}");
|
||||
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "{case}");
|
||||
assert_eq!(pixels(&doc), pixels(&parse(&carried).unwrap()), "{case}: drawn as the PDF itself");
|
||||
let second = parse_page(&file, 1).unwrap();
|
||||
assert_eq!((second.width, second.height), (100.0, 50.0), "{case}");
|
||||
assert_eq!(parse_page(&file, 2).err(), Some(Error::PageOutOfRange(2)), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vectorcraft_document_without_a_pdf_names_create_pdf_compatible_file() {
|
||||
for gzip in [false, true] {
|
||||
let file = vectorcraft(None, 4, gzip);
|
||||
assert_eq!(sniff(&file), Some(Format::VectorCraft));
|
||||
assert_eq!(page_count(&file), 0);
|
||||
let e = parse(&file).err();
|
||||
assert_eq!(e, Some(Error::NoCarriedPdf));
|
||||
assert!(e.map(|e| e.to_string()).unwrap_or_default().contains("Create PDF-Compatible File"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_preview_does_not_stop_the_carried_pdf_from_reading() {
|
||||
let mut not_base64 = Image::default();
|
||||
not_base64.mime = "image/png".into();
|
||||
not_base64.data = "not base64!".into();
|
||||
let mut past_the_end = Image::default();
|
||||
past_the_end.mime = "image/png".into();
|
||||
past_the_end.blob = Some([u64::MAX / 2, 16]);
|
||||
for (preview, what) in [(not_base64, "not base64"), (past_the_end, "past the end")] {
|
||||
for (version, gzip, pad) in [(3, false, false), (4, false, true), (4, true, true)] {
|
||||
let carried = artboards_pdf(pad);
|
||||
let file = vectorcraft_with_preview(Some(&carried), Some(preview.clone()), version, gzip);
|
||||
let case = format!("{what}, version {version}, gzip {gzip}");
|
||||
assert_eq!(page_count(&file), 2, "{case}");
|
||||
let doc = parse(&file).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_carried_pdf_may_be_as_large_as_a_pdf_file() {
|
||||
let limits = carried_limits();
|
||||
assert_eq!(limits.max_entry, craft_pdf::MAX_INPUT_BYTES as u64);
|
||||
assert_eq!(limits.max_json, craft_doc::Limits::default().max_json);
|
||||
assert_eq!(limits.max_total, limits.max_json + 2 * limits.max_entry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vectorcraft_document_read_wrongly_is_an_error() {
|
||||
// A newer format version.
|
||||
let newer = vectorcraft(Some(&artboards_pdf(false)), craft_vectorcraft_format::VERSION + 1, false);
|
||||
let e = parse(&newer).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("newer"), "{e}");
|
||||
// A `pdf` member that holds something else.
|
||||
let not_pdf = vectorcraft(Some(b"not a PDF"), 4, false);
|
||||
assert!(matches!(parse(¬_pdf), Err(Error::Read(_))));
|
||||
// A cut-off file.
|
||||
let file = vectorcraft(Some(&artboards_pdf(true)), 4, true);
|
||||
assert!(parse(&file[..file.len() / 2]).is_err());
|
||||
// Other JSON is not a VectorCraft document.
|
||||
assert_eq!(sniff(br#"{"format":"other","version":1}"#), None);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ impl FootageSource for CancellableFootage<'_> {
|
||||
/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a
|
||||
/// placed page of an After Effects project (its codec is not stored there).
|
||||
pub fn is_vector_footage(f: &Footage) -> bool {
|
||||
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") || f.placed_page
|
||||
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS" | "VectorCraft") || f.placed_page
|
||||
}
|
||||
|
||||
/// How vector footage `f` is drawn at `scale` × its size: the image size, and the x and y scale
|
||||
|
||||
@@ -74,6 +74,7 @@ impl EffectcraftApp {
|
||||
last_activity: (0.0, 0),
|
||||
last_edit: (0.0, 0),
|
||||
toast: None,
|
||||
error_toasts: Default::default(),
|
||||
viewer_tex: None,
|
||||
passive_tex: std::collections::HashMap::new(),
|
||||
viewer_native: None,
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
use super::{Dialog, EffectcraftApp};
|
||||
|
||||
impl EffectcraftApp {
|
||||
pub(crate) fn push_error_toast(&mut self, toast: crate::ErrorToast) {
|
||||
if self.error_toasts.len() >= crate::ErrorToast::MAX_PENDING {
|
||||
// Preserve the visible notification and summarize the pending backlog.
|
||||
let reports = self.error_toasts.drain(1..).fold(0usize, |sum, toast| sum.saturating_add(toast.reports));
|
||||
self.error_toasts.push_back(crate::ErrorToast::omitted(reports));
|
||||
}
|
||||
self.error_toasts.push_back(toast);
|
||||
}
|
||||
|
||||
/// Imports can succeed as commands while individual files fail before a job starts.
|
||||
pub(crate) fn report_import_errors(&mut self, command: &str, result: &serde_json::Value) {
|
||||
if !matches!(command, "file.import" | "file.importMultiple") {
|
||||
return;
|
||||
}
|
||||
let errors: Vec<&str> = result["errors"].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
match errors.as_slice() {
|
||||
[] => {}
|
||||
[one] => self.ui.status = (*one).into(),
|
||||
[first, rest @ ..] => self.ui.status = format!("{first} (and {} more)", rest.len()),
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
self.ui.status = crate::ErrorToast::bounded_text([self.ui.status.as_str()]);
|
||||
self.push_error_toast(crate::ErrorToast::new(errors.iter().copied(), Some(&self.ui.status)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
|
||||
pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) {
|
||||
if r.autosave.is_some() {
|
||||
|
||||
@@ -142,7 +142,13 @@ impl EffectcraftApp {
|
||||
panels::viewers::on_open_comp(self, c);
|
||||
self.ui.timeline.pps = None;
|
||||
}
|
||||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, self.now)),
|
||||
effectcraft_engine::Event::Toast { message, error } => {
|
||||
if error {
|
||||
self.push_error_toast(crate::ErrorToast::new([message.as_str()], None));
|
||||
} else {
|
||||
self.toast = Some((message, self.now));
|
||||
}
|
||||
}
|
||||
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
|
||||
effectcraft_engine::Event::ProjectChanged { .. } => {}
|
||||
@@ -567,21 +573,44 @@ impl EffectcraftApp {
|
||||
if self.ui.status.is_empty() {
|
||||
ui.data_mut(|d| d.remove::<(f64, String)>(egui::Id::unique("status-since")));
|
||||
}
|
||||
let msg = if !self.ui.status.is_empty() {
|
||||
Some(self.ui.status.clone())
|
||||
while self.error_toasts.front().is_some_and(|toast| toast.shown_at.is_some_and(|at| now - at >= 4.0)) {
|
||||
self.error_toasts.pop_front();
|
||||
}
|
||||
let error = self.error_toasts.front_mut().and_then(|toast| toast.message_at(!self.ui.status.is_empty(), now)).map(str::to_owned);
|
||||
let notice = self.toast.as_ref().filter(|(_, at)| now - at < 4.0).map(|(message, _)| crate::ErrorToast::bounded_text([message.as_str()]));
|
||||
let mut msg = if !self.ui.status.is_empty() {
|
||||
// A background result must get its display time after the foreground status.
|
||||
// Keep its details available on hover while the status takes precedence.
|
||||
let status = crate::ErrorToast::bounded_text([self.ui.status.as_str()]);
|
||||
if let Some(details) = error { Some(format!("{status}\n{details}")) } else { Some(status) }
|
||||
} else {
|
||||
self.toast.as_ref().filter(|(_, at)| now - at < 4.0).map(|(m, _)| m.clone())
|
||||
error.or_else(|| notice.clone())
|
||||
};
|
||||
if let Some(m) = &mut msg
|
||||
&& let Some(notice) = ¬ice
|
||||
&& m != notice
|
||||
{
|
||||
m.push('\n');
|
||||
m.push_str(notice);
|
||||
}
|
||||
if let Some(m) = msg {
|
||||
let t = &self.tokens;
|
||||
let galley = ui.painter().layout_no_wrap(m.clone(), Tokens::ui(12.0), t.text);
|
||||
let summary = m.lines().next().unwrap_or_default().to_owned();
|
||||
let galley = ui.painter().layout_no_wrap(summary, Tokens::ui(12.0), t.text);
|
||||
let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0 - lift), galley.size() + egui::vec2(24.0, 14.0));
|
||||
ui.painter().rect_filled(r, 6.0, t.panel_bg);
|
||||
ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text);
|
||||
self.auto.add("toast", r, &m);
|
||||
let resp = ui.interact(r, egui::Id::unique("toast"), egui::Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique("toast"), egui::Sense::click()).on_hover_text(&m);
|
||||
if resp.clicked() {
|
||||
if self.ui.status.is_empty() {
|
||||
self.error_toasts.pop_front();
|
||||
} else if let Some(index) = self.error_toasts.iter().rposition(|toast| toast.reported_by(&self.ui.status)) {
|
||||
// The current status belongs to the latest matching import. Earlier
|
||||
// imports can share its summary while carrying different details.
|
||||
self.error_toasts.remove(index);
|
||||
}
|
||||
self.ui.status.clear();
|
||||
self.toast = None;
|
||||
}
|
||||
@@ -711,3 +740,250 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dismissing_a_status_acknowledges_its_queued_error_behind_another() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.events.push(effectcraft_engine::Event::Toast { message: "Earlier background error".into(), error: true });
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
app.report_import_errors("file.import", &json!({"errors":["Later import error"]}));
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
let r = app.auto.elements.iter().find(|e| e.id == "toast").unwrap().rect;
|
||||
let pos = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||||
for pressed in [true, false] {
|
||||
app.auto.elements.clear();
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
];
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
assert!(app.ui.status.is_empty());
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.label.contains("Earlier background error")));
|
||||
app.now = 4.1;
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(!app.auto.elements.iter().any(|e| e.id == "toast"), "the acknowledged later error must not reappear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_import_summaries_only_acknowledge_the_latest_details() {
|
||||
for first in ["Shared error".to_owned(), "💥".repeat(20_000)] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.report_import_errors("file.import", &json!({"errors":[first, "Earlier details"]}));
|
||||
app.report_import_errors("file.import", &json!({"errors":[first, "Later details"]}));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
let r = app.auto.elements.iter().find(|e| e.id == "toast").unwrap().rect;
|
||||
let pos = egui::pos2(r[0] + 4.0, r[1] + 4.0);
|
||||
for pressed in [true, false] {
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
];
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
assert!(app.ui.status.is_empty());
|
||||
assert_eq!(app.error_toasts.len(), 1, "only the latest matching import is acknowledged, including long statuses");
|
||||
if first.len() < 100 {
|
||||
assert!(app.error_toasts.front().unwrap().message.contains("Earlier details"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_error_notifications_are_bounded_and_report_omissions() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.events.extend((0..40).map(|i| effectcraft_engine::Event::Toast { message: format!("Error {i}"), error: true }));
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
assert!(app.error_toasts.len() <= 32);
|
||||
assert_eq!(app.error_toasts.front().unwrap().message, "Error 0");
|
||||
assert_eq!(app.error_toasts.back().unwrap().message, "Error 39");
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
app.now += 4.1;
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.label.contains("notifications omitted")));
|
||||
let long = format!("a{}", "💥".repeat(20_000));
|
||||
let toast = crate::ErrorToast::new([long.as_str()], None);
|
||||
assert!(toast.message.starts_with("a💥"));
|
||||
assert!(toast.message.len() < 20 << 10);
|
||||
assert!(toast.message.capacity() < 64 << 10, "the large input allocation must not remain retained");
|
||||
assert!(toast.message.contains("truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_background_errors_are_each_shown_before_expiring_or_dismissal() {
|
||||
for dismiss in [false, true] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.events.extend([
|
||||
effectcraft_engine::Event::Toast { message: "First.vectorcraft failed".into(), error: true },
|
||||
effectcraft_engine::Event::Toast { message: "A routine notice".into(), error: false },
|
||||
effectcraft_engine::Event::Toast { message: "Second.vectorcraft failed".into(), error: true },
|
||||
]);
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("First.vectorcraft")));
|
||||
if dismiss {
|
||||
let r = app.auto.elements.iter().find(|e| e.id == "toast").unwrap().rect;
|
||||
let pos = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||||
for pressed in [true, false] {
|
||||
app.auto.elements.clear();
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
];
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
app.now = 0.1;
|
||||
} else {
|
||||
app.now = 4.1;
|
||||
}
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("Second.vectorcraft")));
|
||||
assert!(!app.auto.elements.iter().any(|e| e.label.contains("First.vectorcraft")));
|
||||
app.now += 4.1;
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(!app.auto.elements.iter().any(|e| e.id == "toast"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unshown_error_survives_a_replacement_foreground_status() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.ui.status = "First foreground status".into();
|
||||
app.session.events.push(effectcraft_engine::Event::Toast { message: "An unseen import error".into(), error: true });
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
app.ui.status = "Another foreground status".into();
|
||||
for now in [1.0, 3.0, 6.0, 6.1] {
|
||||
app.now = now;
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("unseen import error")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissing_a_status_keeps_an_unshown_background_error() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.ui.status = "A local drop failed".into();
|
||||
app.session.events.push(effectcraft_engine::Event::Toast { message: "A later background import failed".into(), error: true });
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
let click = |app: &mut EffectcraftApp| {
|
||||
let r = app.auto.elements.iter().find(|e| e.id == "toast").unwrap().rect;
|
||||
let pos = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||||
for pressed in [true, false] {
|
||||
app.auto.elements.clear();
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
];
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
};
|
||||
click(&mut app);
|
||||
assert!(app.ui.status.is_empty());
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("later background import failed")));
|
||||
click(&mut app);
|
||||
assert!(app.error_toasts.is_empty(), "the displayed error can be dismissed too");
|
||||
app.report_import_errors("file.import", &json!({"errors": ["An immediate import error", "Another import error"]}));
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
click(&mut app);
|
||||
assert!(app.ui.status.is_empty());
|
||||
assert!(app.error_toasts.is_empty(), "dismissing an error status acknowledges its own details");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_status_does_not_extend_an_error_already_shown() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.events.push(effectcraft_engine::Event::Toast { message: "An earlier error".into(), error: true });
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
app.ui.status = "An unrelated later status".into();
|
||||
for now in [1.0, 4.0, 7.0, 7.1] {
|
||||
app.now = now;
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
assert!(!app.auto.elements.iter().any(|e| e.id == "toast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_error_details_survive_a_warning_event() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.report_import_errors("file.import", &json!({"errors": ["first failed file", "second failed file"]}));
|
||||
app.session.events.push(effectcraft_engine::Event::Toast { message: "Sequence has missing frames".into(), error: false });
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("second failed file")));
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("missing frames")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreground_status_does_not_extend_a_confirmation_toast() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.ui.status = "A foreground status".into();
|
||||
app.toast = Some(("Saved".into(), 0.0));
|
||||
let ctx = egui::Context::default();
|
||||
for now in [0.0, 3.0, 6.0, 6.1] {
|
||||
app.now = now;
|
||||
app.auto.elements.clear();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
assert!(!app.auto.elements.iter().any(|e| e.id == "toast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_status_does_not_revive_an_expired_toast() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.ui.status = "A new status".into();
|
||||
app.toast = Some(("An old toast".into(), 0.0));
|
||||
app.now = 8.0;
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().all(|e| !e.label.contains("old toast")));
|
||||
app.ui.status.clear();
|
||||
app.auto.elements.clear();
|
||||
app.now = 8.1;
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(!app.auto.elements.iter().any(|e| e.id == "toast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_message_does_not_expire_a_background_import_error() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.ui.status = "A local drop failed".into();
|
||||
app.session.events.push(effectcraft_engine::Event::Toast { message: "Imported 1 item. Failed first file\nFailed second file".into(), error: true });
|
||||
let ctx = egui::Context::default();
|
||||
app.handle_events(&ctx);
|
||||
for now in [0.0, 3.0, 6.0] {
|
||||
app.now = now;
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
}
|
||||
assert!(app.ui.status.is_empty());
|
||||
app.auto.elements.clear();
|
||||
app.now = 6.1;
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.draw_toast(ui, ui.max_rect(), 0.0)).textures_delta.clear();
|
||||
assert!(app.auto.elements.iter().any(|e| e.id == "toast" && e.label.contains("Failed second file")), "{:?}", app.auto.elements);
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,77 @@ impl Playback {
|
||||
}
|
||||
}
|
||||
|
||||
/// An error awaiting its first standalone display.
|
||||
pub(crate) struct ErrorToast {
|
||||
message: String,
|
||||
shown_at: Option<f64>,
|
||||
/// A status already reports this error; dismissing that same status acknowledges both.
|
||||
reported_status: Option<String>,
|
||||
reports: usize,
|
||||
}
|
||||
|
||||
impl ErrorToast {
|
||||
const MAX_PENDING: usize = 32;
|
||||
const MAX_DETAILS: usize = 16 << 10;
|
||||
|
||||
fn bounded_text<'a>(messages: impl IntoIterator<Item = &'a str>) -> String {
|
||||
let mut text = String::new();
|
||||
let mut truncated = false;
|
||||
for (index, part) in messages.into_iter().enumerate() {
|
||||
let separator = usize::from(!text.is_empty());
|
||||
let remaining = Self::MAX_DETAILS.saturating_sub(text.len() + separator);
|
||||
if remaining == 0 || index >= Self::MAX_DETAILS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if separator != 0 {
|
||||
text.push('\n');
|
||||
}
|
||||
let end = part.floor_char_boundary(remaining.min(part.len()));
|
||||
text.push_str(part.get(..end).unwrap_or_default());
|
||||
if end < part.len() {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
let marker = crate::i18n::ui::tr("Further error details were truncated.");
|
||||
let marker = marker.get(..marker.floor_char_boundary(Self::MAX_DETAILS - 1)).unwrap_or_default();
|
||||
text.truncate(text.floor_char_boundary(Self::MAX_DETAILS.saturating_sub(marker.len() + 1)));
|
||||
text.push('\n');
|
||||
text.push_str(marker);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn new<'a>(messages: impl IntoIterator<Item = &'a str>, reported_status: Option<&str>) -> Self {
|
||||
Self { message: Self::bounded_text(messages), shown_at: None, reported_status: reported_status.map(|status| Self::bounded_text([status])), reports: 1 }
|
||||
}
|
||||
|
||||
fn omitted(reports: usize) -> Self {
|
||||
let message = crate::i18n::ui::tr_args("Additional error notifications omitted: {}.", &[&reports.to_string()]);
|
||||
Self { reports, ..Self::new([message.as_str()], None) }
|
||||
}
|
||||
|
||||
fn reported_by(&self, status: &str) -> bool {
|
||||
self.reported_status.as_deref().is_some_and(|reported| status == reported || status.strip_prefix(reported).is_some_and(|rest| rest.starts_with("; ")))
|
||||
}
|
||||
|
||||
/// Pair the details with `status`, a later status line that reports the same errors.
|
||||
fn restate(&mut self, status: &str) {
|
||||
self.reported_status = Some(Self::bounded_text([status]));
|
||||
}
|
||||
|
||||
fn message_at(&mut self, foreground_status: bool, now: f64) -> Option<&str> {
|
||||
if self.shown_at.is_none() && !foreground_status {
|
||||
// Only the first standalone display starts the timer. Later statuses cannot
|
||||
// extend it, and an unseen error cannot expire behind a foreground status.
|
||||
self.shown_at = Some(now);
|
||||
}
|
||||
self.shown_at.is_none_or(|at| now - at < 4.0).then_some(self.message.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EffectcraftApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
@@ -327,6 +398,8 @@ pub struct EffectcraftApp {
|
||||
/// When the project last changed, and its revision then (see [`Self::editing`]).
|
||||
last_edit: (f64, u64),
|
||||
pub(crate) toast: Option<(String, f64)>,
|
||||
/// Error details survive routine notifications and wait for a foreground status to clear.
|
||||
pub(crate) error_toasts: std::collections::VecDeque<ErrorToast>,
|
||||
/// Texture of the last CPU frame shown in the viewer, the key it came from and the factor it
|
||||
/// was averaged down by for the magnification (`panels::viewer::minify_factor`).
|
||||
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey, usize)>,
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params)) {
|
||||
match invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params }) {
|
||||
Ok(result) => super::frontend::show_result_errors(app, &result),
|
||||
Ok(result) => super::frontend::show_command_result_errors(app, &id, &result),
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str
|
||||
} else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() {
|
||||
ctx.copy_text(clipboard_text(&app.session));
|
||||
}
|
||||
if let Ok(result) = &r {
|
||||
app.report_import_errors(id, result);
|
||||
}
|
||||
// Frontend events below may show the Home screen again (Help ▸ In-App Tutorials).
|
||||
leave_home_if_opened(app, before, r.is_ok());
|
||||
let events = app.session.drain_events();
|
||||
@@ -683,8 +686,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
"png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3", "flac", "ogg", "opus", "svg", "pdf", "ai",
|
||||
"eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
|
||||
];
|
||||
// VectorCraft documents, read through the PDF they carry.
|
||||
let media = [MEDIA, &["vectorcraft"]].concat();
|
||||
let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::<Vec<_>>();
|
||||
let importable = |more: &[&'static str]| footage(MEDIA).into_iter().chain(more.iter().copied()).collect::<Vec<_>>();
|
||||
let importable = |more: &[&'static str]| footage(&media).into_iter().chain(more.iter().copied()).collect::<Vec<_>>();
|
||||
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
|
||||
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
|
||||
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
|
||||
@@ -763,7 +768,7 @@ pub(crate) fn dialog_answered(app: &mut EffectcraftApp, answer: crate::DialogAns
|
||||
};
|
||||
let Some(v) = picked_value(answer.key, paths) else { return };
|
||||
match run_with_picked(app, &answer.command, answer.key, &answer.params, v) {
|
||||
Ok(result) => show_result_errors(app, &result),
|
||||
Ok(result) => show_command_result_errors(app, &answer.command, &result),
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
@@ -791,6 +796,27 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) {
|
||||
app.ui.status = msg;
|
||||
}
|
||||
|
||||
/// [`show_result_errors`] for the result of command `id`. An import has already queued its
|
||||
/// details with the shorter status `report_import_errors` set; this status reports the same
|
||||
/// errors in its place, so the details move to it and dismissing it still acknowledges them.
|
||||
pub(crate) fn show_command_result_errors(app: &mut EffectcraftApp, id: &str, result: &Value) {
|
||||
show_result_errors_after_import(app, matches!(id, "file.import" | "file.importMultiple"), result);
|
||||
}
|
||||
|
||||
/// [`show_result_errors`]; when `reported` is set, `report_import_errors` has just queued the
|
||||
/// details of the import whose result this is, last, with the status shown before this call.
|
||||
fn show_result_errors_after_import(app: &mut EffectcraftApp, reported: bool, result: &Value) {
|
||||
let previous = app.ui.status.clone();
|
||||
show_result_errors(app, result);
|
||||
if reported
|
||||
&& app.ui.status != previous
|
||||
&& let Some(toast) = app.error_toasts.back_mut().filter(|toast| toast.reported_by(&previous))
|
||||
{
|
||||
app.ui.status = crate::ErrorToast::bounded_text([app.ui.status.as_str()]);
|
||||
toast.restate(&app.ui.status);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run command `id` with the path picked for it in parameter `key`.
|
||||
fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result<Value, String> {
|
||||
if matches!(id, "file.import" | "file.importMultiple")
|
||||
@@ -834,6 +860,9 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
if let Ok(result) = &r {
|
||||
app.report_import_errors(id, result);
|
||||
}
|
||||
leave_home_if_opened(app, before, r.is_ok());
|
||||
r
|
||||
}
|
||||
@@ -882,8 +911,9 @@ fn import_picked_with_aep(app: &mut EffectcraftApp, id: &str, key: &str, params:
|
||||
result = json!({"result": result});
|
||||
}
|
||||
let own = result.get("errors").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let reported = !own.is_empty();
|
||||
result["errors"] = Value::Array(errors.into_iter().map(Value::from).chain(own).collect());
|
||||
show_result_errors(app, &result);
|
||||
show_result_errors_after_import(app, reported, &result);
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -528,6 +528,80 @@ fn tga_is_available_in_import_replace_and_proxy_dialogs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dropped_files_keep_import_errors_alongside_local_drop_errors() {
|
||||
#[derive(Debug)]
|
||||
struct Drop(std::path::PathBuf);
|
||||
impl egui::DroppedFile for Drop {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
std::fs::read(&self.0).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
let folder = std::env::temp_dir().join(format!("effectcraft-mixed-drop-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = folder.join("no-pdf.vectorcraft");
|
||||
std::fs::write(&path, br#"{"format":"vectorcraft","version":4,"document":{"artboards":[],"layers":[]}}"#).unwrap();
|
||||
let second = folder.join("also-no-pdf.vectorcraft");
|
||||
std::fs::copy(&path, &second).unwrap();
|
||||
let footage = folder.join("pending.png");
|
||||
std::fs::write(&footage, b"not a PNG").unwrap();
|
||||
for (bad_import, background) in [(false, false), (true, false), (true, true)] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_host::session());
|
||||
app.session.prefs.import.drag_import_as = "comp".into();
|
||||
let ctx = egui::Context::default();
|
||||
let mut dropped_files: Vec<egui::DroppedFileHandle> = vec![std::sync::Arc::new(Drop("no-local-path".into()))];
|
||||
if bad_import {
|
||||
dropped_files.push(std::sync::Arc::new(Drop(path.clone())));
|
||||
dropped_files.push(std::sync::Arc::new(Drop(second.clone())));
|
||||
}
|
||||
if background {
|
||||
dropped_files.push(std::sync::Arc::new(Drop(footage.clone())));
|
||||
}
|
||||
ctx.run_ui(egui::RawInput { dropped_files, ..Default::default() }, |ui| {
|
||||
crate::prefs_live::dropped_files(&mut app, ui, ui.max_rect());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(app.ui.status.contains("without a local path"), "{}", app.ui.status);
|
||||
if bad_import {
|
||||
assert!(app.ui.status.contains("no-pdf.vectorcraft") && app.ui.status.contains("Create PDF-Compatible File"), "{}", app.ui.status);
|
||||
let details = &app.error_toasts.back().expect("all import errors remain available").message;
|
||||
assert!(details.contains("also-no-pdf.vectorcraft"), "{details}");
|
||||
if background {
|
||||
app.session.execute("jobs.wait", json!({})).unwrap();
|
||||
app.handle_events(&ctx);
|
||||
let details = &app.error_toasts.back().unwrap().message;
|
||||
for name in ["no-pdf.vectorcraft", "also-no-pdf.vectorcraft", "pending.png"] {
|
||||
assert!(details.contains(name), "{details}");
|
||||
}
|
||||
assert!(app.ui.status.contains("without a local path"));
|
||||
}
|
||||
} else {
|
||||
assert!(!app.ui.status.contains(";"), "{}", app.ui.status);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_without_a_form_shows_immediate_file_errors() {
|
||||
let folder = std::env::temp_dir().join(format!("effectcraft-drop-import-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = folder.join("no-pdf.vectorcraft");
|
||||
std::fs::write(&path, br#"{"format":"vectorcraft","version":4,"document":{"artboards":[],"layers":[]}}"#).unwrap();
|
||||
let mut app = EffectcraftApp::new(effectcraft_host::session());
|
||||
let ctx = egui::Context::default();
|
||||
let r = invoke(&mut app, &ctx, "file.import", json!({"paths": [path], "importAs": "composition", "background": true, "drag": true})).unwrap();
|
||||
assert!(app.dialog.is_none());
|
||||
assert!(r["errors"][0].as_str().unwrap().contains("Create PDF-Compatible File"));
|
||||
assert!(app.ui.status.contains("no-pdf.vectorcraft") && app.ui.status.contains("Create PDF-Compatible File"), "{}", app.ui.status);
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
/// Cmd+W with the Render Queue focused closes the queue; the composition next to it stays
|
||||
/// open (it used to be closed, and the queue stayed).
|
||||
#[test]
|
||||
@@ -769,6 +843,35 @@ fn bundle_result_errors_show_compactly_in_the_status_line() {
|
||||
assert_eq!(app.ui.status, "a.wasm: already installed; b.wasm: does not load; c.wasm: cannot read it; +1 more");
|
||||
}
|
||||
|
||||
/// A picked or menu import reports its errors twice: `report_import_errors` queues the details
|
||||
/// with a short status, and the command's result then writes the longer status. Dismissing that
|
||||
/// status still acknowledges the queued details, also when After Effects project errors lead it.
|
||||
/// Another command's errors do not take over an import's details, even with the same text.
|
||||
#[test]
|
||||
fn an_import_status_rewritten_by_its_result_still_acknowledges_its_details() {
|
||||
let long = format!("first failed file {}", "x".repeat(20_000));
|
||||
for first in ["first failed file", "first failed file\nwith a second line", long.as_str()] {
|
||||
let import = json!({"errors": [first, "second failed file"]});
|
||||
let with_aep = json!({"errors": ["project.aep: cannot read it", first, "second failed file"]});
|
||||
for result in [&import, &with_aep] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.report_import_errors("file.import", &import);
|
||||
super::frontend::show_command_result_errors(&mut app, "file.import", result);
|
||||
assert!(app.ui.status.contains("first failed file"), "{}", app.ui.status);
|
||||
assert_eq!(app.error_toasts.len(), 1);
|
||||
assert!(app.error_toasts.back().unwrap().reported_by(&app.ui.status), "{first:.40}: {:.80}", app.ui.status);
|
||||
}
|
||||
}
|
||||
let import = json!({"errors": ["first failed file", "second failed file"]});
|
||||
for other in [json!({"errors": [{"file": "a.wasm", "error": "does not load"}]}), import.clone()] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.report_import_errors("file.import", &import);
|
||||
super::frontend::show_command_result_errors(&mut app, "effect.installBundle", &other);
|
||||
assert!(!app.ui.status.contains("(and"), "{}", app.ui.status);
|
||||
assert!(!app.error_toasts.back().unwrap().reported_by(&app.ui.status), "{}", app.ui.status);
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect ▸ Install Effect Plug-in… picks its dialog by params: a bundle asks for a folder,
|
||||
/// a plain install asks for a `.wasm`, and with both given inline nothing asks at all.
|
||||
#[test]
|
||||
|
||||
@@ -146,9 +146,10 @@ fn is_layered(p: &Value) -> bool {
|
||||
imports_ext(p, &["psd", "psb"])
|
||||
}
|
||||
|
||||
/// The import includes a PDF or Illustrator file (which may have several pages).
|
||||
/// The import includes a PDF or Illustrator file, or a VectorCraft document read through its
|
||||
/// PDF (which may have several pages).
|
||||
fn is_pdf(p: &Value) -> bool {
|
||||
imports_ext(p, &["pdf", "ai"])
|
||||
imports_ext(p, &["pdf", "ai", "vectorcraft"])
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After
|
||||
@@ -991,8 +992,9 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
if ok || enter {
|
||||
app.dialog = None;
|
||||
let params = f.params();
|
||||
if let Err(e) = app.session.execute(&f.command, params) {
|
||||
app.ui.status = e.to_string();
|
||||
match app.session.execute(&f.command, params) {
|
||||
Ok(r) => app.report_import_errors(&f.command, &r),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1079,6 +1081,37 @@ mod tests {
|
||||
assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_form_shows_errors_returned_in_a_successful_result() {
|
||||
let folder = std::env::temp_dir().join(format!("effectcraft-form-import-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = folder.join("no-pdf.vectorcraft");
|
||||
std::fs::write(&path, br#"{"format":"vectorcraft","version":4,"document":{"artboards":[],"layers":[]}}"#).unwrap();
|
||||
for (import_as, background) in [("footage", false), ("composition", true), ("compositionLayerSizes", true)] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_host::session());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
|
||||
app.dialog_state.form = Form {
|
||||
title: "Import".into(),
|
||||
command: "file.import".into(),
|
||||
base: json!({"paths": [path, path], "importAs": import_as, "background": background}),
|
||||
fields: vec![],
|
||||
};
|
||||
app.dialog = Some(Dialog::Form);
|
||||
let raw = egui::RawInput {
|
||||
events: vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
let tokens = app.tokens;
|
||||
ctx.run_ui(raw, |ui| show_form(&mut app, ui.ctx(), &tokens)).textures_delta.clear();
|
||||
assert!(app.dialog.is_none());
|
||||
assert!(app.ui.status.contains("no-pdf.vectorcraft") && app.ui.status.contains("Create PDF-Compatible File"), "{import_as}: {}", app.ui.status);
|
||||
assert!(app.ui.status.ends_with("(and 1 more)"), "{}", app.ui.status);
|
||||
assert!(!app.ui.status.contains('\n'));
|
||||
}
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
/// A numeric form (Layer ▸ Transform ▸ Position…) opens with its first number being edited
|
||||
/// and selected: typing replaces it, and one Enter confirms.
|
||||
#[test]
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut import_reported_errors = false;
|
||||
if skipped != 0 {
|
||||
errors.push(crate::i18n::tr_args("Skipped {} dropped file(s) without a local path. Use File ▸ Import ▸ File…", &[&skipped]));
|
||||
}
|
||||
@@ -162,13 +163,17 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
params["sequence"] = json!(false);
|
||||
}
|
||||
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) {
|
||||
errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e]));
|
||||
match crate::menus::invoke(app, &ctx, "file.import", params) {
|
||||
Ok(result) => {
|
||||
import_reported_errors = result["errors"].as_array().is_some_and(|errors| errors.iter().any(Value::is_string));
|
||||
}
|
||||
Err(e) => errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e])),
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
app.ui.status = errors.join("; ");
|
||||
let local = errors.join("; ");
|
||||
app.ui.status = if import_reported_errors { format!("{}; {local}", app.ui.status) } else { local };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user