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:
.CooCoo authored and GitHub committed 2026-11-15 03:45:56 -05:00
1 parent dc2380b070
commit 7cbefaf70f
26 files changed
+911 -35

No files matched your search

Generated
+40
View File
@@ -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",
+2
View File
@@ -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
+2 -1
View File
@@ -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);
+15 -6
View File
@@ -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).
+1 -1
View File
@@ -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"];
+12 -1
View File
@@ -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]
+1
View File
@@ -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));
+1
View File
@@ -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 }
+72
View File
@@ -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}");
}
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+7 -1
View File
@@ -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"
+4
View File
@@ -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
View File
@@ -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![];
+1
View File
@@ -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\
+140
View File
@@ -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(&not_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);
}
+1 -1
View File
@@ -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
+1
View File
@@ -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,
+26
View File
@@ -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() {
+282 -6
View File
@@ -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) = &notice
&& 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);
}
}
+73
View File
@@ -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)>,
+1 -1
View File
@@ -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,
}
}
+33 -3
View File
@@ -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) => {
+103
View File
@@ -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]
+37 -4
View File
@@ -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]
+8 -3
View File
@@ -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 };
}
}