diff --git a/.config/nextest.toml b/.config/nextest.toml
index 2a4cd2cb9..38f939c31 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -1,3 +1,14 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+
+[test-groups]
+gpu = { max-threads = 1 }
+
+# Coverage runs each test in a separate process, so the GPU suite's in-process lock
+# cannot protect software adapters from competing devices and readback timeouts.
+# UI harnesses can create a device lazily when rendering a screenshot, so include
+# the whole UI package instead of an incomplete list of GPU-named tests.
+[[profile.default.overrides]]
+filter = 'package(effectcraft-gpu) | package(effectcraft-ui-egui) | package(effectcraft-sam2) | (package(effectcraft-cli) & test(/^cli::gpu_adapter_name_/))'
+test-group = 'gpu'
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index b903f82ea..ccf74f43a 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -28,6 +28,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
+ - name: Harden runner
+ uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
+ with:
+ egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4e08c7c42..ffbe49afb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,6 +1,10 @@
name: Release
on:
+ # A nightly dry run on main: every release build except macOS, nothing signed or released
+ # (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
+ schedule:
+ - cron: "32 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -15,16 +19,25 @@ concurrency:
cancel-in-progress: false
jobs:
+ # Regenerates contributors/contributors.json for the release commit; every build bundles it.
+ credits:
+ permissions:
+ contents: read
+ pull-requests: read
+ uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
+ needs: credits
permissions:
contents: write
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+ dry-run: ${{ github.event_name == 'schedule' }}
app-name: effectcraft
app-title: EffectCraft
version: ${{ inputs.version }}
+ contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 93b34b9bf..3c1706cc3 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE).
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
-One small After Effects test project of `crates/aep` is in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
+Three small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar), `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/text-synthetic.aep` (original work, made with After Effects 2026 by script from own data, working space None, no colour profile embedded, nothing changed after the save; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
## Screenshots
@@ -99,7 +99,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
(sidecars `.attribution`).
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`).
-- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
+- Example effect plug-in: `examples/plugins/posterize-bands` (WebAssembly ABI 1).
## Trained models (optional downloads, not bundled)
@@ -129,6 +129,14 @@ configuration from these Apache-2.0 projects:
- `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything
architectures and their Apache-2.0 reference code.
+## Frozen matte regression fixture
+
+| Asset | Author | Source | Licence |
+|---|---|---|---|
+| `crates/engine/src/tests/fixtures/analysed-0.24.ecproj` | arnav-hiwarkar | Original work, synthetic project keyed by EffectCraft 0.24.1 | MIT OR Apache-2.0 |
+| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 |
+| `crates/engine/src/tests/fixtures/tracked-0.22.ecproj` | arnav-hiwarkar | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 |
+
## First-party brand marks
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 29521ad73..8ce4b85aa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,7 +26,7 @@ that variable set.
```sh
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
-cargo xtask ci # what every commit must pass
+cargo xtask ci # what every commit must pass (dev-profile checks and tests)
```
The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
diff --git a/Cargo.lock b/Cargo.lock
index 5b426c8f8..e6450c078 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1089,30 +1089,70 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-aep"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+
+[[package]]
+name = "craft-audio"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+
+[[package]]
+name = "craft-dpx"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "thiserror 2.0.21",
+]
[[package]]
name = "craft-ecproj-format"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=ca1b5e2edb8568335c6c14f390307bd5beae8238#ca1b5e2edb8568335c6c14f390307bd5beae8238"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
+[[package]]
+name = "craft-eps"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+
+[[package]]
+name = "craft-exr"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "serde_json",
+]
+
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+
+[[package]]
+name = "craft-gif"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
[[package]]
name = "craft-i18n"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1132,6 +1172,23 @@ dependencies = [
"writeable",
]
+[[package]]
+name = "craft-lottie"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "craft-media"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-media"
version = "0.1.0"
@@ -1140,19 +1197,39 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "craft-mesh"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "miniz_oxide 0.9.1",
+ "serde_json",
+]
+
[[package]]
name = "craft-pdf"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"hayro-syntax",
"thiserror 2.0.21",
]
+[[package]]
+name = "craft-psd"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "encoding_rs",
+ "flate2",
+ "rayon",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-quit"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"dispatch2",
"egui",
@@ -1160,10 +1237,22 @@ dependencies = [
"objc2-app-kit 0.3.2",
]
+[[package]]
+name = "craft-svg"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "craft-xml",
+ "flate2",
+ "kurbo",
+ "roxmltree",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1184,7 +1273,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"chrono",
"dunce",
@@ -1194,6 +1283,7 @@ dependencies = [
"image",
"log",
"plist",
+ "rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1207,6 +1297,33 @@ dependencies = [
"zip 9.1.3",
]
+[[package]]
+name = "craft-video"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)",
+ "rayon",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "craft-webp"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "craft-xml"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
+dependencies = [
+ "quick-xml 0.41.0",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1420,8 +1537,9 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "chrono",
"cpal",
"craft-update",
"effectcraft-engine",
@@ -1439,6 +1557,8 @@ dependencies = [
"rfd",
"serde",
"serde_json",
+ "sysinfo",
+ "tempfile",
"winit",
"winresource",
"zbus",
@@ -1446,13 +1566,15 @@ dependencies = [
[[package]]
name = "effectcraft-aep"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-aep",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-time",
+ "log",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -1460,7 +1582,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1468,19 +1590,13 @@ dependencies = [
"effectcraft-script",
"image",
"serde_json",
+ "tempfile",
"thiserror 2.0.21",
]
-[[package]]
-name = "effectcraft-av1enc"
-version = "0.21.0"
-dependencies = [
- "filmcraft-av1",
-]
-
[[package]]
name = "effectcraft-cli"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1499,7 +1615,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"proptest",
"serde",
@@ -1507,7 +1623,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"chrono",
"effectcraft-color",
@@ -1525,7 +1641,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -1564,12 +1680,13 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
- "effectcraft-av1enc",
+ "craft-audio",
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)",
+ "craft-video",
"effectcraft-color",
"effectcraft-effects",
- "effectcraft-hevcenc",
"effectcraft-keyframe",
"effectcraft-media",
"effectcraft-opusenc",
@@ -1577,25 +1694,22 @@ dependencies = [
"effectcraft-raster",
"effectcraft-render",
"effectcraft-time",
- "effectcraft-vp9enc",
"exr",
- "filmcraft-aac",
- "filmcraft-h264enc",
- "filmcraft-isobmff",
- "filmcraft-opus",
- "filmcraft-prores",
+ "filmcraft-av1",
+ "filmcraft-hevc",
"filmcraft-vp9",
"gif",
"image",
"log",
"rayon",
+ "tempfile",
"thiserror 2.0.21",
"web-time",
]
[[package]]
name = "effectcraft-expr"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1612,7 +1726,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"proptest",
"serde",
@@ -1620,7 +1734,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1643,7 +1757,7 @@ dependencies = [
[[package]]
name = "effectcraft-helper-host"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"bytemuck",
"effectcraft-effects",
@@ -1652,16 +1766,9 @@ dependencies = [
"serde_json",
]
-[[package]]
-name = "effectcraft-hevcenc"
-version = "0.21.0"
-dependencies = [
- "filmcraft-hevc",
-]
-
[[package]]
name = "effectcraft-host"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1688,7 +1795,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1707,7 +1814,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1719,9 +1826,10 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"craft-docpath",
+ "craft-lottie",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1737,10 +1845,17 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-audio",
"craft-docpath",
+ "craft-dpx",
+ "craft-exr",
+ "craft-gif",
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9)",
+ "craft-webp",
"effectcraft-color",
+ "effectcraft-effects",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
@@ -1750,12 +1865,9 @@ dependencies = [
"effectcraft-svg",
"effectcraft-time",
"exr",
- "filmcraft-aac",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
- "filmcraft-isobmff",
- "filmcraft-matroska",
"filmcraft-media",
"filmcraft-mpegts",
"filmcraft-time",
@@ -1764,32 +1876,32 @@ dependencies = [
"log",
"rayon",
"serde_json",
+ "tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-model"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-mesh",
"effectcraft-geom",
"image",
"log",
- "miniz_oxide 0.9.1",
"serde",
- "serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-opusenc"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
- "filmcraft-opus",
+ "craft-audio",
]
[[package]]
name = "effectcraft-path"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1801,8 +1913,9 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-eps",
"craft-pdf",
"effectcraft-raster",
"effectcraft-svg",
@@ -1817,7 +1930,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1829,7 +1942,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"craft-ecproj-format",
"effectcraft-color",
@@ -1843,15 +1956,17 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-psd",
"miniz_oxide 0.9.1",
]
[[package]]
name = "effectcraft-raster"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-exr",
"effectcraft-color",
"effectcraft-geom",
"rayon",
@@ -1860,7 +1975,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1877,12 +1992,13 @@ dependencies = [
"lz4_flex",
"rayon",
"serde",
+ "tempfile",
"web-time",
]
[[package]]
name = "effectcraft-sam2"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"bytemuck",
"candle-core",
@@ -1897,7 +2013,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1915,7 +2031,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"miniz_oxide 0.9.1",
"ndarray",
@@ -1924,19 +2040,21 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
+ "craft-svg",
"effectcraft-geom",
"effectcraft-path",
"effectcraft-raster",
"kurbo",
+ "log",
"rayon",
"roxmltree",
]
[[package]]
name = "effectcraft-text"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1948,7 +2066,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"proptest",
"serde",
@@ -1956,7 +2074,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1968,7 +2086,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"bytemuck",
"chrono",
@@ -2007,13 +2125,9 @@ dependencies = [
"web-time",
]
-[[package]]
-name = "effectcraft-vp9enc"
-version = "0.21.0"
-
[[package]]
name = "effectcraft-web"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -2478,7 +2592,7 @@ name = "filmcraft-bitstream"
version = "0.12.0"
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
dependencies = [
- "craft-media",
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4)",
]
[[package]]
@@ -2579,16 +2693,6 @@ dependencies = [
"thiserror 2.0.21",
]
-[[package]]
-name = "filmcraft-h264enc"
-version = "0.12.0"
-source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
-dependencies = [
- "filmcraft-bitstream",
- "rayon",
- "thiserror 2.0.21",
-]
-
[[package]]
name = "filmcraft-hevc"
version = "0.12.0"
@@ -2621,7 +2725,7 @@ name = "filmcraft-isobmff"
version = "0.12.0"
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
dependencies = [
- "craft-media",
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4)",
]
[[package]]
@@ -2629,7 +2733,7 @@ name = "filmcraft-matroska"
version = "0.12.0"
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
dependencies = [
- "craft-media",
+ "craft-media 0.1.0 (git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4)",
]
[[package]]
@@ -3065,7 +3169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -3318,7 +3422,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"hayro-ccitt",
]
@@ -3332,7 +3436,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=47107f660191de2f0b425569506f4daa4faadac9#47107f660191de2f0b425569506f4daa4faadac9"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
@@ -3665,7 +3769,7 @@ dependencies = [
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -3811,7 +3915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -4117,6 +4221,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -4431,6 +4544,16 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -4632,7 +4755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.45.0",
]
[[package]]
@@ -4661,7 +4784,7 @@ dependencies = [
"libc",
"redox_syscall 0.5.18",
"smallvec",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -6068,6 +6191,20 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "sysinfo"
+version = "0.37.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows 0.61.3",
+]
+
[[package]]
name = "tag_ptr"
version = "0.1.0"
@@ -7221,16 +7358,38 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections 0.2.0",
+ "windows-core 0.61.2",
+ "windows-future 0.2.1",
+ "windows-link 0.1.3",
+ "windows-numerics 0.2.0",
+]
+
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
- "windows-collections",
+ "windows-collections 0.3.2",
"windows-core 0.62.2",
- "windows-future",
- "windows-numerics",
+ "windows-future 0.3.2",
+ "windows-numerics 0.3.1",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
]
[[package]]
@@ -7252,6 +7411,19 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -7260,9 +7432,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
- "windows-link",
+ "windows-link 0.2.1",
"windows-result 0.4.1",
- "windows-strings",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading 0.1.0",
]
[[package]]
@@ -7272,8 +7455,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
- "windows-link",
- "windows-threading",
+ "windows-link 0.2.1",
+ "windows-threading 0.2.1",
]
[[package]]
@@ -7298,12 +7481,28 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -7311,7 +7510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7320,9 +7519,9 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
"windows-result 0.4.1",
- "windows-strings",
+ "windows-strings 0.5.1",
]
[[package]]
@@ -7334,13 +7533,31 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
]
[[package]]
@@ -7349,7 +7566,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7385,7 +7602,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7419,13 +7636,22 @@ dependencies = [
"windows_x86_64_msvc 0.52.6",
]
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7708,7 +7934,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.21.0"
+version = "0.24.1"
dependencies = [
"chrono",
"pkg-config",
@@ -7956,7 +8182,6 @@ dependencies = [
"nix",
"typed-path 0.13.0",
"windows-sys 0.61.2",
- "zopfli",
]
[[package]]
@@ -7971,18 +8196,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-[[package]]
-name = "zopfli"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
-dependencies = [
- "bumpalo",
- "crc32fast",
- "log",
- "simd-adler32",
-]
-
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -8047,18 +8260,3 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
-
-[[patch.unused]]
-name = "egui_demo_lib"
-version = "0.37.0"
-source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
-
-[[patch.unused]]
-name = "egui_inspection"
-version = "0.37.0"
-source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
-
-[[patch.unused]]
-name = "egui_system_font_provider"
-version = "0.37.0"
-source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index abb2256c2..1b80048f2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,19 +4,29 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.21.0"
+version = "0.24.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
-craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
-craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
-craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
+craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", version = "=0.1.0", default-features = false, features = ["read"] }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-dpx = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-eps = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-exr = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-gif = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-psd = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-svg = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+craft-webp = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9" }
+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-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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["egui"] }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
@@ -41,7 +51,6 @@ effectcraft-interchange = { path = "crates/interchange" }
effectcraft-pdf = { path = "crates/pdf" }
effectcraft-psd = { path = "crates/psd" }
effectcraft-aep = { path = "crates/aep" }
-effectcraft-vp9enc = { path = "crates/vp9enc" }
effectcraft-svg = { path = "crates/svg" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-host = { path = "crates/host" }
@@ -49,8 +58,6 @@ effectcraft-ui-egui = { path = "crates/ui-egui" }
effectcraft-automation = { path = "crates/automation" }
effectcraft-opusenc = { path = "crates/opusenc" }
effectcraft-script = { path = "crates/script" }
-effectcraft-hevcenc = { path = "crates/hevcenc" }
-effectcraft-av1enc = { path = "crates/av1enc" }
effectcraft-plugin = { path = "crates/plugin" }
effectcraft-helper-host = { path = "crates/helper-host" }
@@ -82,25 +89,23 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
same-file = "1.0"
tempfile = "3.27"
-# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
+# codecs + containers shared by the Craft apps (crates/media, crates/export); keep all three on one rev.
+craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", default-features = false, features = ["isobmff", "matroska"] }
+craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", default-features = false, features = ["aac-decode", "aac-encode", "opus-decode"] }
+craft-video = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["h264-encode", "prores", "hevc-encode", "vp9-encode", "av1", "threads"] }
+
+# footage decoding: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these.
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-# encoders + muxer (crates/export)
-filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-# container headers read for AAC channel layouts (crates/media)
-filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
+# MPEG TS / PS headers read for AAC channel layouts (crates/media); craft-media has no MPEG TS reader
filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
-filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
+# decoders used as the round-trip oracles of craft-video's VP9, HEVC and AV1 encoders (crates/export
+# dev-dependencies only); craft-video has no decoder for them
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
-# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only)
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only)
@@ -117,7 +122,7 @@ half = "2.7"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
# Shared UI and the complete egui family select reviewed immutable commits below.
-craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
+craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", features = ["serde"] }
# `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()`
# and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate.
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
@@ -137,11 +142,6 @@ sys-locale = "0.3"
# filmcraft-frame = { path = "../filmcraft/crates/frame" }
# filmcraft-color = { path = "../filmcraft/crates/color" }
# filmcraft-time = { path = "../filmcraft/crates/time" }
-# filmcraft-h264enc = { path = "../filmcraft/crates/h264enc" }
-# filmcraft-prores = { path = "../filmcraft/crates/prores" }
-# filmcraft-aac = { path = "../filmcraft/crates/aac" }
-# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
-# filmcraft-matroska = { path = "../filmcraft/crates/matroska" }
# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" }
[workspace.lints.rust]
@@ -200,50 +200,39 @@ opt-level = 3
[profile.dev.package.effectcraft-export]
opt-level = 3
-[profile.dev.package.effectcraft-vp9enc]
+# craft-libs codec hot loops.
+[profile.dev.package.craft-video]
opt-level = 3
-[profile.dev.package.effectcraft-hevcenc]
+[profile.dev.package.craft-audio]
opt-level = 3
-[profile.dev.package.effectcraft-av1enc]
+[profile.dev.package.craft-media]
opt-level = 3
# FilmCraft codec hot loops (same settings as FilmCraft's own workspace).
[profile.dev.package.filmcraft-h264]
opt-level = 3
-[profile.dev.package.filmcraft-h264enc]
-opt-level = 3
-
[profile.dev.package.filmcraft-hevc]
opt-level = 3
[profile.dev.package.filmcraft-vp9]
opt-level = 3
-[profile.dev.package.filmcraft-prores]
-opt-level = 3
-
[profile.dev.package.filmcraft-dnx]
opt-level = 3
[profile.dev.package.filmcraft-av1]
opt-level = 3
-[profile.dev.package.filmcraft-aac]
-opt-level = 3
-
-[profile.dev.package.filmcraft-opus]
-opt-level = 3
-
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
-hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" }
-hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" }
+hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", version = "=0.7.2" }
+hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "47107f660191de2f0b425569506f4daa4faadac9", version = "=0.3.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
@@ -254,8 +243,7 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
-egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+# Patch a crate only together with the dependency that needs it: an unused patch makes the Nix
+# build, which uses vendored sources, fail (`cargo xtask lock-patches`).
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
diff --git a/README.md b/README.md
index 81b5a0ba5..57eddebdc 100644
--- a/README.md
+++ b/README.md
@@ -129,18 +129,14 @@ or into the Timeline, between layers and at the time you point to.
308 built-in effects across its categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
-color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
+color-grading panel…), distort (Warp, Bulge, Turbulent Displace, Perspective Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
-**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
-stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
+**simulation** (Particle Emitter 3D, Rainfall, Shatter, Card Dance, Caustics, Wave World…),
+stylize (Glow, Glass Relief…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
**audio** (Reverb, Parametric EQ, Delay, Stereo Mixer…), text, transitions, utility and expression
controls. All nine **Layer Styles** (Drop Shadow, Inner/Outer Glow, Bevel and Emboss, Satin,
overlays, Stroke) with Global Light. Preview plays audio in sync, with meters and waveforms.
-
-
-
-
## 3D
Classic 3D compositing: 3D layers with orientation and material options,
@@ -160,7 +156,7 @@ MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG,
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
overflow and render logs. The same queue runs from the command line. Every encoder is pure
-Rust (FilmCraft's H.264, ProRes and AAC; EffectCraft's own VP9, AV1, HEVC and Opus); there is no
+Rust (craft-libs' H.264, ProRes, HEVC, VP9, AV1 and AAC; EffectCraft's own Opus); there is no
FFmpeg inside.
**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape,
diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs
index 2981519e9..f52401a00 100644
--- a/apps/effectcraft-cli/src/main.rs
+++ b/apps/effectcraft-cli/src/main.rs
@@ -31,12 +31,13 @@
//! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes
//! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
+//! effectcraft-cli replay SCRIPT.jsonl [--keep-going] replay a repro script (docs/agents.md)
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
//! Saving: --save (back to --project) | --save-as F.ecproj
-//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists;
-//! the default is the CPU (Mercury Software Only)
+//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists;
+//! the default is the CPU (Software Rendering)
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
//! ```
@@ -97,7 +98,7 @@ fn stdout_broken() -> bool {
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
}
-const USAGE: &str = "usage: effectcraft-cli [args] [--json]
+const USAGE: &str = "usage: effectcraft-cli [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec [--params JSON] run one engine command (exec --list: list them, as `commands`)
@@ -138,6 +139,9 @@ const USAGE: &str = "usage: effectcraft-cli Result {
}
/// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's
-/// renderer setting, Mercury GPU Acceleration by default).
+/// renderer setting, GPU Acceleration by default).
fn session(args: &Args) -> Result {
let mut s = effectcraft_host::session();
if args.flag("--autosave") {
@@ -552,6 +557,59 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
Ok(())
}
+/// `replay SCRIPT.jsonl`: run a repro script against a headless session (its start line picks
+/// the project) and report every step. Exits 1 when the replay fails.
+fn replay_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
+ use effectcraft_engine::replay;
+ let Some(file) = args.pos.first() else { return usage_err("replay needs a .jsonl script") };
+ if args.opt("--bridge").is_some() || args.project.is_some() || args.flag("--demo") || args.flag("--empty") {
+ return usage_err("replay runs headless from the script's start line ({\"start\": \"new\" | \"demo\" | {\"project\": path}})");
+ }
+ use std::io::Read;
+ let path = std::path::Path::new(file);
+ let input = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?;
+ // Bound the read itself: metadata can be stale or describe a pipe rather than its contents.
+ let mut script = String::new();
+ input.take(replay::MAX_SCRIPT_BYTES as u64 + 1).read_to_string(&mut script).map_err(|e| Failure::Error(format!("cannot read {file}: {e}")))?;
+ if script.len() > replay::MAX_SCRIPT_BYTES {
+ return Err(Failure::Error(format!("{file} is larger than {} MB", replay::MAX_SCRIPT_BYTES >> 20)));
+ }
+ let mut s = session(args)?;
+ let opts = replay::Options { keep_going: args.flag("--keep-going"), base_dir: path.parent().map(std::path::Path::to_path_buf) };
+ let report = replay::run(&mut s, &script, &opts);
+ if json_out {
+ emit(&serde_json::to_value(&report).unwrap_or_else(|e| json!({"error": e.to_string()})), true);
+ } else {
+ for st in &report.steps {
+ let what = st.command.as_deref().unwrap_or(st.kind);
+ match (&st.error, st.ok) {
+ (None, _) => say!("line {:>4} ok {what}", st.line),
+ (Some(e), true) => say!("line {:>4} ok {what} (tolerated error: {e})", st.line),
+ (Some(e), false) => say!("line {:>4} FAIL {what}: {e}", st.line),
+ }
+ }
+ }
+ let Some(first) = &report.first_failure else {
+ if !json_out {
+ say!("replay ok: {} steps", report.steps.len());
+ }
+ return Ok(());
+ };
+ let msg = format!(
+ "failed at line {} ({}){}: {}{}",
+ first.line,
+ first.command.as_deref().unwrap_or(first.kind),
+ if report.panicked { ", a command panicked" } else { "" },
+ first.error.as_deref().unwrap_or(""),
+ if report.skipped > 0 { format!(" ({} later steps not run)", report.skipped) } else { String::new() }
+ );
+ if json_out {
+ note!("effectcraft-cli replay: {msg}");
+ std::process::exit(1);
+ }
+ Err(Failure::Error(msg))
+}
+
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
@@ -614,6 +672,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
"commands" => list_commands_cmd(args, json_out)?,
"exec" if args.flag("--list") => list_commands_cmd(args, json_out)?,
"script" => script_cmd(args, json_out)?,
+ "replay" => replay_cmd(args, json_out)?,
"exec" => {
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
@@ -1443,7 +1502,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option- Result<(), Failure> {
use effectcraft_render::Backend;
diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs
index 363eb8e52..4510f16c9 100644
--- a/apps/effectcraft-cli/tests/it/cli.rs
+++ b/apps/effectcraft-cli/tests/it/cli.rs
@@ -1223,3 +1223,39 @@ fn render_exr_compression_and_depth() {
assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}");
let _ = std::fs::remove_dir_all(&root);
}
+
+#[test]
+fn replay_reports_steps_and_the_first_failure() {
+ let repro = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/repros/comp-zero-frame-rate.jsonl");
+ let v = ok_json(&["replay", repro.to_str().unwrap()]);
+ assert_eq!(v["ok"], true, "{v}");
+ assert!(v["steps"].as_array().unwrap().len() > 5);
+ let bad = tmp("bad-repro.jsonl");
+ std::fs::write(
+ &bad,
+ "{\"start\": \"new\"}\n{\"command\": \"comp.new\"}\n{\"expect\": {\"query\": \"comp.info\", \"pointer\": \"/width\", \"equals\": 1}}\nnot json\n",
+ )
+ .unwrap();
+ let (code, v) = run_json(&["replay", bad.to_str().unwrap()]);
+ assert_eq!(code, 1);
+ assert_eq!(v["ok"], false);
+ assert_eq!(v["first_failure"]["line"], 3, "{v}");
+ assert_eq!(v["skipped"], 1);
+ let out = bin().args(["replay", bad.to_str().unwrap(), "--keep-going"]).output().unwrap();
+ assert_eq!(out.status.code(), Some(1));
+ let text = String::from_utf8_lossy(&out.stdout);
+ assert!(text.contains("line 2 ok comp.new") && text.contains("line 4 FAIL invalid: not JSON"), "{text}");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("failed at line 3 (comp.info)"));
+ // The start line picks the project.
+ assert_eq!(bin().args(["replay", bad.to_str().unwrap(), "--demo"]).output().unwrap().status.code(), Some(2));
+}
+
+/// Text editors can prefix an exported JSON-lines repro with a UTF-8 BOM.
+#[test]
+fn replay_accepts_a_utf8_bom() {
+ let path = tmp("bom-repro.jsonl");
+ std::fs::write(&path, "\u{feff}{\"command\":\"comp.new\"}\n").unwrap();
+ let (code, report) = run_json(&["replay", path.to_str().unwrap()]);
+ assert_eq!(code, 0, "{report}");
+ assert_eq!(report["ok"], true);
+}
diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs
index 2882b16cf..99e71117f 100644
--- a/apps/effectcraft-web/src/app.rs
+++ b/apps/effectcraft-web/src/app.rs
@@ -329,7 +329,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
if n > 0 {
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
}
- app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
+ app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path)));
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
diff --git a/apps/effectcraft-web/src/diskcache.rs b/apps/effectcraft-web/src/diskcache.rs
index ef9d46f8c..72e9a9e11 100644
--- a/apps/effectcraft-web/src/diskcache.rs
+++ b/apps/effectcraft-web/src/diskcache.rs
@@ -4,23 +4,28 @@
//!
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
-//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
-//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
-//! `FrameReply::Stored`.
+//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4,
+//! checksum) with a synchronous access handle into a temporary file moved into place
+//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't
+//! all be named is not stored.
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
//! `Stored` replies and by reads; eviction deletes the least recently used files.
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
-//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
+//! of rendering it (`crate::frames`), if the files its entry lists still have the size and
+//! content hash it was made of (the file table's identities); a damaged or missing entry, or
+//! one whose files changed, is a miss and is deleted.
//!
//! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a
//! synchronous render, where the browser's file system can't be read, so the frame workers
//! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache
//! misses; before the next request the page names the recent misses that are on disk
//! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries
-//! into memory before rendering, so the lookups find them. Slow buffers are written by the
-//! worker after its frame and indexed here like frames (one least-recently-used order for
-//! both kinds).
+//! into memory before rendering, so the lookups find them; an entry is used only while the
+//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose
+//! files can't all be named is not written (#3184). Slow buffers are written by the worker
+//! after its frame and indexed here like frames (one least-recently-used order for both
+//! kinds).
use std::cell::RefCell;
@@ -187,11 +192,12 @@ pub fn note_miss() {
}
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
-/// damaged.
+/// damaged, or the source files it was made of changed since.
pub async fn read(key: u128) -> Option
{
let name = disk_cache::entry_name(key);
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::().ok()).map(|b| b.to_vec());
- let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
+ let identity = |path: &str| crate::files::STORE.file_identity(path);
+ let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity)));
ST.with(|s| {
let mut s = s.borrow_mut();
if frame.is_some() {
diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs
index 69089e308..0159fe23b 100644
--- a/apps/effectcraft-web/src/frames.rs
+++ b/apps/effectcraft-web/src/frames.rs
@@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) {
g.settled().await;
replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default());
}
+ // The source files the frame read, kept with its entry (checked by the page before use); a
+ // frame whose files can't all be named is not stored (#3184).
+ let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default());
for (r, px) in replies {
post_reply(&r, px.as_deref());
- if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) {
- let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px);
+ if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) {
+ let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue };
let name = format!("{key}.ecc");
match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await {
Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None),
diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs
index 0cc877948..a4947600b 100644
--- a/apps/effectcraft-web/src/store.rs
+++ b/apps/effectcraft-web/src/store.rs
@@ -20,7 +20,6 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_engine::config::{ConfigStore, FileOps};
@@ -187,13 +186,12 @@ pub struct Store {
pub on_change: OnceLock,
/// Clock in milliseconds since the Unix epoch (tests pin it).
pub clock: OnceLock f64>,
- /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
- media_epoch: Arc,
}
impl Default for Store {
+ /// An empty store with no mirror contents, change callback or clock.
fn default() -> Self {
- Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
+ Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() }
}
}
@@ -218,22 +216,12 @@ impl Store {
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
let now = self.now();
let key = file_key(path);
- let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
self.lock().put(&key, data, now, persist);
- if replaced {
- self.media_epoch.fetch_add(1, Ordering::Relaxed);
- }
if persist {
self.changed();
}
}
- /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
- /// a different blob, and it is not a content hash.
- pub fn media_epoch(&self) -> Arc {
- self.media_epoch.clone()
- }
-
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
/// later key does not walk the bytes again.
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
@@ -541,11 +529,9 @@ mod tests {
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
- let epoch = s.media_epoch().load(Ordering::Relaxed);
let id = s.file_identity("/collision.png").expect("identity");
assert_eq!(id, (4, content_hash(b"blue")));
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
- assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
}
/// A write that failed is flushed again with the key's latest state (#215).
diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml
index 3030ee96d..2c8224f65 100644
--- a/apps/effectcraft/Cargo.toml
+++ b/apps/effectcraft/Cargo.toml
@@ -25,6 +25,9 @@ rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
+# MIT; safe process handles and target-only memory observation for the crash helper.
+sysinfo = { version = "=0.37.2", default-features = false, features = ["system"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Native GNOME/Wayland controls without a bundled title font.
@@ -38,6 +41,9 @@ zbus = "5.19"
# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+[dev-dependencies]
+tempfile = { workspace = true }
+
[target.'cfg(target_os = "macos")'.dependencies]
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
diff --git a/apps/effectcraft/src/crash_observation.rs b/apps/effectcraft/src/crash_observation.rs
new file mode 100644
index 000000000..b67dc07fe
--- /dev/null
+++ b/apps/effectcraft/src/crash_observation.rs
@@ -0,0 +1,264 @@
+//! Bounded observations made outside the failed GUI. Measurements are evidence, not a cause.
+use std::io::{self, Write};
+use std::sync::mpsc::{self, Receiver};
+use std::time::{Duration, Instant, SystemTime};
+
+use sysinfo::{MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, System};
+
+pub fn utc() -> String {
+ chrono::DateTime::::from(SystemTime::now()).to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
+}
+
+#[derive(Default)]
+struct Memory {
+ samples: u64,
+ resident_peak: u64,
+ virtual_peak: u64,
+ available_min: Option,
+ total: u64,
+ last_at: Option,
+ last_uptime_secs: Option,
+}
+
+impl Memory {
+ fn sample(&mut self, resident: u64, virtual_bytes: u64, available: u64, total: u64) {
+ self.samples = self.samples.saturating_add(1);
+ self.resident_peak = self.resident_peak.max(resident);
+ self.virtual_peak = self.virtual_peak.max(virtual_bytes);
+ // Zero total means the platform could not supply physical-memory information.
+ if total != 0 {
+ self.available_min = Some(self.available_min.map_or(available, |old| old.min(available)));
+ self.total = total;
+ }
+ self.last_at = Some(utc());
+ }
+}
+
+pub struct Observation {
+ pid: Pid,
+ system: System,
+ birth: Option,
+ started: Instant,
+ started_at: String,
+ detected_at: Option,
+ last_sample: Instant,
+ memory: Memory,
+ // Only Windows can retrieve an unrelated process's ExitStatus through a retained handle.
+ exit: Receiver>,
+ status: Option,
+ alive: Option,
+}
+
+fn target_system(pid: Pid) -> System {
+ let mut system = System::new();
+ system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), false, ProcessRefreshKind::nothing().with_memory());
+ system
+}
+
+impl Observation {
+ /// Acquire the Windows handle before acknowledging reporter readiness, so even an immediate
+ /// GUI abort leaves an observable process object. Never enumerate commands or environments.
+ pub fn start(pid: u32) -> Self {
+ let pid = Pid::from_u32(pid);
+ let system = target_system(pid);
+ let birth = system.process(pid).map(|p| p.start_time());
+ let (send, exit) = mpsc::sync_channel(1);
+ #[cfg(windows)]
+ {
+ let waiting = target_system(pid);
+ let _ = std::thread::Builder::new().name("ec-exit-status".into()).spawn(move || {
+ let status = waiting.process(pid).and_then(|p| p.wait());
+ let _ = send.send(status);
+ });
+ }
+ #[cfg(not(windows))]
+ drop(send);
+ let now = Instant::now();
+ let mut result = Self {
+ pid,
+ system,
+ birth,
+ started: now,
+ started_at: utc(),
+ detected_at: None,
+ last_sample: now,
+ memory: Memory::default(),
+ exit,
+ status: None,
+ alive: None,
+ };
+ result.sample();
+ result
+ }
+
+ fn sample(&mut self) {
+ self.system.refresh_processes_specifics(ProcessesToUpdate::Some(&[self.pid]), false, ProcessRefreshKind::nothing().with_memory());
+ self.system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
+ let process = self.system.process(self.pid).filter(|p| Some(p.start_time()) == self.birth && p.exists());
+ self.alive = self.birth.map(|_| process.is_some());
+ if let Some(process) = process {
+ self.memory.last_uptime_secs = Some(process.run_time());
+ self.memory.sample(process.memory(), process.virtual_memory(), self.system.available_memory(), self.system.total_memory());
+ }
+ if let Ok(status) = self.exit.try_recv() {
+ self.status = status;
+ }
+ self.last_sample = Instant::now();
+ }
+
+ pub fn tick(&mut self) {
+ if self.last_sample.elapsed() >= Duration::from_secs(1) {
+ self.sample();
+ }
+ }
+
+ /// Pipe closure can precede the OS signalling process termination. Bound that grace period;
+ /// a broken pipe or early reporter drop must never be called a confirmed process exit.
+ pub fn finish(&mut self) {
+ self.detected_at = Some(utc());
+ if self.status.is_none()
+ && let Ok(status) = self.exit.recv_timeout(Duration::from_millis(750))
+ {
+ self.status = status;
+ }
+ self.sample();
+ }
+
+ pub fn confirmed_exit(&self) -> bool {
+ self.status.is_some()
+ }
+
+ pub fn write(&self, report: &mut impl Write) -> io::Result<()> {
+ writeln!(
+ report,
+ "Detection UTC: {}\nObserver started UTC: {}\nObserved duration: {} ms",
+ self.detected_at.as_deref().unwrap_or("not captured"),
+ self.started_at,
+ self.started.elapsed().as_millis()
+ )?;
+ writeln!(
+ report,
+ "Process observation: {}",
+ if self.confirmed_exit() {
+ "process termination observed"
+ } else if self.alive == Some(true) {
+ "GUI still present when the control channel ended"
+ } else {
+ "target absent from refresh or process identity/status unavailable (not proof of termination)"
+ }
+ )?;
+ match &self.status {
+ Some(status) => {
+ writeln!(report, "OS exit status: {status}")?;
+ #[cfg(windows)]
+ if let Some(code) = status.code() {
+ write_windows_status(report, code as u32)?;
+ }
+ }
+ None => writeln!(report, "OS exit status: unavailable (not inferred from the control pipe)")?,
+ }
+ let memory = &self.memory;
+ writeln!(report, "\nMemory evidence (1 s sampling; sampled maxima, not OS peaks):\nTarget-present sampling attempts: {}", memory.samples)?;
+ if let Some(uptime) = memory.last_uptime_secs {
+ writeln!(report, "GUI uptime at last target-present observation: {uptime} s")?;
+ }
+ #[cfg(windows)]
+ let virtual_label = "Peak sampled private committed bytes";
+ #[cfg(not(windows))]
+ let virtual_label = "Peak sampled virtual bytes";
+ if memory.samples != 0 {
+ writeln!(
+ report,
+ "Last target sampling attempt UTC: {}\nPeak sampled resident bytes: {}\n{virtual_label}: {}",
+ memory.last_at.as_deref().unwrap_or("unavailable"),
+ memory.resident_peak,
+ memory.virtual_peak
+ )?;
+ }
+ if let Some(available) = memory.available_min {
+ writeln!(report, "Minimum sampled system available RAM bytes: {available}\nSystem total RAM bytes at last sample: {}", memory.total)?;
+ }
+ writeln!(
+ report,
+ "Samples can miss short spikes or retain stale/zero readings when an OS query fails; per-field success is not exposed by sysinfo. Windows private commit is not GPU VRAM; on other platforms virtual memory is not physical RAM. System commit limit/usage and GPU memory budgets were not collected. No memory-pressure threshold establishes the cause of this incident.\nThe memory observer does not collect process command lines, environments, documents, native dumps or system event logs. The existing session-log tail below may contain paths or other logged details; review it before sharing."
+ )
+ }
+}
+
+#[cfg(any(windows, test))]
+fn write_windows_status(report: &mut impl Write, code: u32) -> io::Result<()> {
+ let meaning = match code {
+ 0xc0000005 => "STATUS_ACCESS_VIOLATION",
+ 0xc0000017 => "STATUS_NO_MEMORY",
+ 0xc000012d => "STATUS_COMMITMENT_LIMIT",
+ 0xc0000374 => "STATUS_HEAP_CORRUPTION",
+ 0xc0000409 => "STATUS_STACK_BUFFER_OVERRUN / fast fail",
+ 0x40000015 => "STATUS_FATAL_APP_EXIT",
+ _ => "unclassified",
+ };
+ writeln!(
+ report,
+ "Windows status bits: 0x{code:08X} ({meaning})\nThese are process exit bits, not a verified exception record. Software can set an exit code explicitly; an access violation does not identify the faulting module or prove a driver crash."
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn missing_target_without_exit_status_does_not_confirm_termination() {
+ let (send, exit) = mpsc::sync_channel(1);
+ drop(send);
+ let now = Instant::now();
+ let observation = Observation {
+ pid: Pid::from_u32(1),
+ system: System::new(),
+ birth: Some(1),
+ started: now,
+ started_at: utc(),
+ detected_at: Some(utc()),
+ last_sample: now,
+ memory: Memory::default(),
+ exit,
+ status: None,
+ alive: Some(false),
+ };
+ assert!(!observation.confirmed_exit());
+ let mut report = Vec::new();
+ observation.write(&mut report).unwrap();
+ let report = String::from_utf8(report).unwrap();
+ assert!(report.contains("not proof of termination"));
+ assert!(!report.contains("process termination observed"));
+ }
+
+ #[test]
+ fn memory_high_water_marks_are_bounded_and_not_a_causal_diagnosis() {
+ let mut memory = Memory::default();
+ memory.sample(40, 80, 30, 100);
+ memory.sample(20, 90, 50, 100);
+ assert_eq!((memory.samples, memory.resident_peak, memory.virtual_peak, memory.available_min), (2, 40, 90, Some(30)));
+ memory.sample(0, 0, 0, 0);
+ assert_eq!(memory.available_min, Some(30));
+ }
+
+ #[test]
+ fn windows_status_bits_preserve_unsigned_codes_without_claiming_a_driver_crash() {
+ for (code, name) in
+ [(0xc0000005, "STATUS_ACCESS_VIOLATION"), (0xc0000017, "STATUS_NO_MEMORY"), (0xc000012d, "STATUS_COMMITMENT_LIMIT"), (42, "unclassified")]
+ {
+ let mut text = Vec::new();
+ write_windows_status(&mut text, code).unwrap();
+ let text = String::from_utf8(text).unwrap();
+ assert!(text.contains(name));
+ assert!(text.contains(&format!("0x{code:08X}")));
+ assert!(text.contains("not a verified exception record"));
+ }
+ }
+
+ #[test]
+ fn utc_timestamps_are_parseable_and_zero_offset() {
+ let time = chrono::DateTime::parse_from_rfc3339(&utc()).unwrap();
+ assert_eq!(time.offset().local_minus_utc(), 0);
+ }
+}
diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs
new file mode 100644
index 000000000..3080c12bb
--- /dev/null
+++ b/apps/effectcraft/src/crash_report.rs
@@ -0,0 +1,803 @@
+//! A small, separate process reports an unexpected desktop exit (#3357).
+//!
+//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a
+//! helper which never loads a project, font database or GPU. Only an explicit marker after
+//! normal teardown suppresses the report. EOF alone identifies a lost control channel, not
+//! a process exit or its cause. System-wide exhaustion can still prevent the helper or its native dialog working.
+
+use std::collections::VecDeque;
+use std::ffi::{OsStr, OsString};
+use std::fs::{File, OpenOptions};
+use std::io::{self, Read, Seek, SeekFrom, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Child, ChildStdin, Command, Stdio};
+use std::sync::mpsc::{self, RecvTimeoutError};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+#[path = "crash_observation.rs"]
+mod observation;
+
+const FLAG: &str = "--internal-crash-reporter";
+const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n";
+const LOG_BYTES: usize = 64 * 1024;
+const HEADER_BYTES: usize = 128;
+const READY: &[u8] = b"READY\n";
+const EVENT_BYTES: usize = 128;
+const EVENT_COUNT: usize = 32;
+
+/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes
+/// the pipe and reports the incident; there is intentionally no clean-exit Drop hook.
+pub struct Reporter {
+ pipe: ChildStdin,
+ child: Child,
+}
+
+impl Reporter {
+ /// Call only after application teardown, including handled startup/device-loss errors.
+ pub fn finish(mut self) -> io::Result<()> {
+ let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush());
+ drop(self.pipe);
+ // Do not let a suspended/broken helper hang shutdown. Reap it before the GUI
+ // exits where possible, so Windows updates do not race an old executable.
+ let cleanup = finish_child(&mut self.child, Duration::from_secs(2));
+ match (sent, cleanup) {
+ (Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))),
+ (Err(error), _) | (_, Err(error)) => Err(error),
+ (Ok(()), Ok(())) => Ok(()),
+ }
+ }
+}
+
+fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> {
+ let start = Instant::now();
+ while start.elapsed() < grace {
+ if let Some(status) = child.try_wait()? {
+ return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) };
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ child.kill()?;
+ // Even after termination, do not introduce an unbounded wait on a stuck OS process.
+ let start = Instant::now();
+ while start.elapsed() < Duration::from_secs(1) {
+ if child.try_wait()?.is_some() {
+ return Err(io::Error::other("crash reporter did not finish promptly and was terminated"));
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ Err(io::Error::other("crash reporter termination could not be confirmed"))
+}
+
+/// Shared ownership lets macOS finish in on_exit, while main retains the startup-error path.
+#[derive(Clone, Default)]
+pub struct Shutdown(std::sync::Arc>>);
+
+impl Shutdown {
+ /// Install the one reporter created for this desktop launch.
+ pub fn install(&self, reporter: Reporter) -> io::Result<()> {
+ let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?;
+ *slot = Some(reporter);
+ Ok(())
+ }
+
+ /// Keep only fixed-vocabulary, bounded lifecycle facts outside the GUI. Callers emit each
+ /// category once; their combined payload fits well below the smallest OS pipe buffer.
+ pub fn record(&self, event: &'static str) -> io::Result<()> {
+ if event.len() > 100 || event.contains(['\n', '\r']) {
+ return Err(io::Error::other("invalid diagnostic observation"));
+ }
+ let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?;
+ if let Some(reporter) = slot.as_mut() {
+ writeln!(reporter.pipe, "EVENT {event}")?;
+ reporter.pipe.flush()?;
+ }
+ Ok(())
+ }
+
+ /// Consume the reporter exactly once, after the applicable platform teardown boundary.
+ pub fn finish(&self) -> io::Result<()> {
+ let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take();
+ reporter.map_or(Ok(()), Reporter::finish)
+ }
+}
+
+/// A panic during teardown must leave the reporter armed. macOS terminates after this hook
+/// without returning to main; other platforms pass None and finish after run_native returns.
+pub fn after_teardown(reporter: Option<&Shutdown>, teardown: impl FnOnce()) -> io::Result<()> {
+ teardown();
+ reporter.map_or(Ok(()), Shutdown::finish)
+}
+
+/// Each process/run has its own report, including simultaneous launches.
+pub fn run_id() -> String {
+ let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
+ format!("{}-{nanos}", std::process::id())
+}
+
+pub fn session_marker(id: &str) -> String {
+ format!("crash-report session {id}")
+}
+
+/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app.
+pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result {
+ let exe = std::env::current_exe()?;
+ let args = [
+ OsString::from(FLAG),
+ OsString::from(id),
+ log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()),
+ reports.as_os_str().to_owned(),
+ OsString::from(if interactive { "1" } else { "0" }),
+ OsString::from(std::process::id().to_string()),
+ ];
+ spawn(&exe, &args)
+}
+
+fn spawn(exe: &Path, args: &[OsString]) -> io::Result {
+ let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?;
+ if let Err(error) = ready(&mut child) {
+ let _ = child.kill();
+ let _ = finish_child(&mut child, Duration::from_millis(100));
+ return Err(error);
+ }
+ match child.stdin.take() {
+ Some(pipe) => Ok(Reporter { pipe, child }),
+ None => {
+ let _ = child.kill();
+ let _ = child.wait();
+ Err(io::Error::other("crash reporter has no control pipe"))
+ }
+ }
+}
+
+/// A fixed acknowledgement means the helper holds the process observation before heavy GUI
+/// work begins. A suspended helper cannot hold startup indefinitely.
+fn ready(child: &mut Child) -> io::Result<()> {
+ let mut output = child.stdout.take().ok_or_else(|| io::Error::other("crash reporter has no readiness pipe"))?;
+ let (send, receive) = mpsc::sync_channel(1);
+ std::thread::Builder::new().name("ec-reporter-ready".into()).spawn(move || {
+ let mut bytes = [0; READY.len()];
+ #[cfg(not(test))]
+ let result =
+ output.read_exact(&mut bytes).and_then(|()| if bytes == READY { Ok(()) } else { Err(io::Error::other("invalid reporter acknowledgement")) });
+ // libtest prints its bounded banner before invoking the helper role.
+ #[cfg(test)]
+ let result = (|| {
+ for _ in 0..1024 {
+ bytes.rotate_left(1);
+ output.read_exact(&mut bytes[READY.len() - 1..])?;
+ if bytes == READY {
+ return Ok(());
+ }
+ }
+ Err(io::Error::other("reporter acknowledgement missing"))
+ })();
+ let _ = send.send(result);
+ // Keep the pipe open for libtest's trailing result (and future helper output), without
+ // storing it. Dropping it at READY would turn a clean helper exit into BrokenPipe.
+ let _ = io::copy(&mut output, &mut io::sink());
+ })?;
+ receive.recv_timeout(Duration::from_secs(3)).map_err(|_| io::Error::other("crash reporter readiness timed out"))?
+}
+
+/// Handle the internal mode before logging, argument parsing, updates or GUI initialization.
+/// The callback is a native dialog on this helper's main thread, not on the failed GUI's.
+pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool {
+ if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) {
+ return false;
+ }
+ let parsed = match args {
+ [_, id, log, reports, interactive, pid] => id
+ .to_str()
+ .filter(|id| valid_id(id))
+ .zip(match interactive.to_str() {
+ Some("0") => Some(false),
+ Some("1") => Some(true),
+ _ => None,
+ })
+ .zip(pid.to_str().and_then(|p| p.parse::().ok()).filter(|pid| *pid != 0))
+ .map(|((id, interactive), pid)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive, pid)),
+ _ => None,
+ };
+ let Some((id, log, reports, interactive, pid)) = parsed else {
+ eprintln!("EffectCraft crash reporter: invalid internal arguments");
+ return true;
+ };
+ // Keep the exact file open while the GUI is alive: a later rotation must not substitute
+ // another instance's log. If rotation won the startup race, the session marker rejects it.
+ let mut log = SessionLog::open(log, id);
+ let mut observation = observation::Observation::start(pid);
+ // A GUI that dies during helper startup can already have closed this read end. Keep
+ // watching its control pipe and save the report rather than losing that early failure.
+ let _ = io::stdout().write_all(READY).and_then(|()| io::stdout().flush());
+ // The reader has one bounded queue; the monitor never waits on a live pipe, so it can
+ // sample the target even when no log or lifecycle message is emitted.
+ let (send, receive) = mpsc::sync_channel(1);
+ let reader = std::thread::Builder::new().name("ec-reporter-pipe".into()).spawn(move || {
+ let mut input = io::stdin().lock();
+ loop {
+ let message = read_message(&mut input);
+ let terminal = !matches!(message, Ok(Message::Event(_)));
+ if send.send(message).is_err() || terminal {
+ break;
+ }
+ }
+ });
+ let mut events = VecDeque::with_capacity(EVENT_COUNT);
+ let failure = if reader.is_err() {
+ Some("control reader could not start".to_owned())
+ } else {
+ loop {
+ observation.tick();
+ match receive.recv_timeout(Duration::from_millis(250)) {
+ Ok(Ok(Message::Clean)) => return true,
+ Ok(Ok(Message::Event(event))) => {
+ if events.len() == EVENT_COUNT {
+ events.pop_front();
+ }
+ events.push_back(format!("{} {event}", observation::utc()));
+ }
+ Ok(Ok(Message::Ended)) => break None,
+ Ok(Err(error)) => break Some(error.to_string()),
+ Err(RecvTimeoutError::Timeout) => continue,
+ Err(RecvTimeoutError::Disconnected) => break Some("control reader disconnected".to_owned()),
+ }
+ }
+ };
+ observation.finish();
+ let saved = write_report(reports, id, &mut log, failure.as_deref(), &observation, &events);
+ let description = description(&saved, observation.confirmed_exit());
+ eprintln!("{description}");
+ if interactive {
+ show(&description);
+ }
+ true
+}
+
+fn valid_id(id: &str) -> bool {
+ !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-')
+}
+
+enum Message {
+ Clean,
+ Event(String),
+ Ended,
+}
+
+/// Every line and the retained event ring are bounded, including malformed control input.
+fn read_message(input: &mut impl Read) -> io::Result {
+ let mut line = [0u8; EVENT_BYTES];
+ for i in 0..line.len() {
+ let byte = line.get_mut(i).ok_or_else(|| io::Error::other("invalid control offset"))?;
+ loop {
+ match input.read(std::slice::from_mut(byte)) {
+ Ok(0) => return Ok(Message::Ended),
+ Ok(_) => break,
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ if *byte == b'\n' {
+ let text = line.get(..=i).ok_or_else(|| io::Error::other("invalid control line"))?;
+ if text == CLEAN {
+ return Ok(Message::Clean);
+ }
+ if let Some(event) = text.strip_prefix(b"EVENT ") {
+ let event = std::str::from_utf8(event).map_err(|_| io::Error::other("non-UTF8 diagnostic observation"))?;
+ return Ok(Message::Event(event.trim_end().to_owned()));
+ }
+ return Ok(Message::Ended);
+ }
+ }
+ Err(io::Error::other("control line exceeded its fixed limit"))
+}
+
+#[cfg(test)]
+fn clean_exit(input: &mut impl Read) -> io::Result {
+ Ok(matches!(read_message(input)?, Message::Clean))
+}
+
+enum SessionLog {
+ Captured { file: File, marker: String },
+ Unavailable(String),
+}
+
+impl SessionLog {
+ fn open(path: Option<&Path>, id: &str) -> Self {
+ let opened = (|| {
+ let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?;
+ let mut file = File::open(path)?;
+ let mut header = [0u8; HEADER_BYTES];
+ let marker = format!("{}\n", session_marker(id));
+ let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?;
+ file.read_exact(prefix)?;
+ if prefix != marker.as_bytes() {
+ return Err(io::Error::other("matching session marker unavailable; the log may have rotated"));
+ }
+ Ok(file)
+ })();
+ match opened {
+ Ok(file) => Self::Captured { file, marker: format!("{}\n", session_marker(id)) },
+ Err(e) => Self::Unavailable(e.to_string()),
+ }
+ }
+
+ fn write_tail(&mut self, report: &mut File) -> io::Result<()> {
+ match self {
+ Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"),
+ Self::Captured { file, marker } => {
+ // An older concurrent launcher may have truncated the captured file.
+ // Recheck its identity before copying any content into this report.
+ file.seek(SeekFrom::Start(0))?;
+ let mut header = [0u8; HEADER_BYTES];
+ let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?;
+ if file.read_exact(prefix).is_err() || prefix != marker.as_bytes() {
+ return writeln!(report, "Recent log unavailable: session identity changed");
+ }
+ let size = file.metadata()?.len();
+ file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?;
+ writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?;
+ io::copy(&mut file.take(LOG_BYTES as u64), report)?;
+ Ok(())
+ }
+ }
+ }
+}
+
+fn write_report(
+ reports: &Path,
+ id: &str,
+ log: &mut SessionLog,
+ pipe_error: Option<&str>,
+ observation: &observation::Observation,
+ events: &VecDeque,
+) -> io::Result {
+ if !valid_id(id) {
+ return Err(io::Error::other("invalid report identity"));
+ }
+ std::fs::create_dir_all(reports)?;
+ let path = reports.join(format!("crash-{id}.txt"));
+ let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?;
+ writeln!(
+ report,
+ "EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}",
+ env!("CARGO_PKG_VERSION"),
+ std::env::consts::OS,
+ std::env::consts::ARCH
+ )?;
+ writeln!(
+ report,
+ "The GUI control channel ended without its clean-exit marker.\nThe exact cause is not established. GPU fallback, memory pressure and process exit are separate observations, not a proven causal chain.\nThis report was saved locally; it was not sent anywhere."
+ )?;
+ observation.write(&mut report)?;
+ writeln!(report, "\nRetained lifecycle observations (last {EVENT_COUNT}; UTC is helper receipt time):")?;
+ for event in events {
+ writeln!(report, "{event}")?;
+ }
+ if let Some(error) = pipe_error {
+ writeln!(report, "Control pipe error: {error}")?;
+ }
+ if let Err(error) = log.write_tail(&mut report) {
+ writeln!(report, "\nCould not read the recent session log: {error}")?;
+ }
+ report.flush()?;
+ Ok(path)
+}
+
+fn description(saved: &io::Result, confirmed_exit: bool) -> String {
+ let diagnostic = match saved {
+ Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()),
+ Err(error) => format!("A diagnostic report could not be saved: {error}"),
+ };
+ let incident =
+ if confirmed_exit { "EffectCraft stopped unexpectedly." } else { "EffectCraft lost its diagnostic control channel; process exit is not confirmed." };
+ format!(
+ "{incident}\n\n{diagnostic}\n\nThe exact cause is unknown. If EffectCraft has closed, reopen it to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere."
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::thread;
+ use std::time::{Duration, Instant};
+
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
+ const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE";
+
+ fn test_report(dir: &Path, id: &str, log: &mut SessionLog) -> io::Result {
+ write_report(dir, id, log, None, &observation::Observation::start(std::process::id()), &VecDeque::new())
+ }
+
+ fn folder() -> PathBuf {
+ let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed)));
+ std::fs::create_dir_all(&p).unwrap();
+ p
+ }
+
+ fn test_name(name: &str) -> String {
+ // Also usable from an isolated harness with the reporter dependencies.
+ let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest);
+ format!("{prefix}::{name}")
+ }
+
+ fn helper(dir: &Path, id: &str) -> Reporter {
+ helper_mode(dir, id, false)
+ }
+
+ fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let mut child = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"])
+ .env(ROLE, "reporter")
+ .env("EC_REPORT_DIR", dir)
+ .env("EC_REPORT_ID", id)
+ .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" })
+ .env("EC_REPORT_PID", std::process::id().to_string())
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .unwrap();
+ ready(&mut child).unwrap();
+ Reporter { pipe: child.stdin.take().unwrap(), child }
+ }
+
+ fn wait_file(path: &Path) {
+ let until = Instant::now() + Duration::from_secs(10);
+ while !path.is_file() && Instant::now() < until {
+ thread::sleep(Duration::from_millis(20));
+ }
+ assert!(path.is_file(), "report did not arrive: {}", path.display());
+ }
+
+ #[test]
+ fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() {
+ let mut clean = CLEAN;
+ assert!(clean_exit(&mut clean).unwrap());
+ for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] {
+ assert!(!clean_exit(&mut input).unwrap());
+ }
+ assert!(clean_exit(&mut &vec![b'x'; 100_000][..]).is_err());
+ }
+
+ #[test]
+ fn report_is_bounded_and_keeps_its_session_after_log_rotation() {
+ let dir = folder();
+ let original = dir.join("effectcraft.log");
+ let id = "123-456";
+ std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap();
+ let mut log = SessionLog::open(Some(&original), id);
+ std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap();
+ std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap();
+ let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap();
+ old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap();
+ old.write_all(b"\nGPU context retired\n").unwrap();
+ let report = test_report(&dir, id, &mut log).unwrap();
+ let text = std::fs::read_to_string(report).unwrap();
+ assert!(text.len() < LOG_BYTES + 4096);
+ assert!(text.contains("GPU context retired"));
+ assert!(!text.contains("OTHER SESSION"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() {
+ let dir = folder();
+ let path = dir.join("log");
+ std::fs::write(&path, "private other session").unwrap();
+ for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] {
+ let report = test_report(&dir, id, &mut SessionLog::open(source, id)).unwrap();
+ let text = std::fs::read_to_string(report).unwrap();
+ assert!(text.contains("Recent log unavailable"));
+ assert!(!text.contains("private other session"));
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn captured_log_rejects_a_session_overwritten_after_open() {
+ let dir = folder();
+ let path = dir.join("log");
+ std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap();
+ let mut log = SessionLog::open(Some(&path), "1-2");
+ std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap();
+ let report = test_report(&dir, "1-2", &mut log).unwrap();
+ let text = std::fs::read_to_string(report).unwrap();
+ assert!(text.contains("Recent log unavailable"));
+ assert!(!text.contains("private other session"));
+ assert!(!text.contains("session 3-4"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn session_identity_requires_the_exact_first_line() {
+ let dir = folder();
+ let path = dir.join("log");
+ for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] {
+ std::fs::write(&path, header).unwrap();
+ assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_)));
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn report_write_failure_is_described_and_existing_report_is_not_overwritten() {
+ let dir = folder();
+ let mut log = SessionLog::Unavailable("no log".into());
+ let first = test_report(&dir, "2-1", &mut log).unwrap();
+ let before = std::fs::read(&first).unwrap();
+ let result = test_report(&dir, "2-1", &mut log);
+ assert!(result.is_err());
+ assert!(description(&result, false).contains("could not be saved"));
+ assert_eq!(std::fs::read(first).unwrap(), before);
+ assert!(test_report(&dir, "../escape", &mut log).is_err());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn normal_and_handled_shutdown_send_clean_marker_without_a_report() {
+ for id in ["3-1", "3-2"] {
+ let dir = folder();
+ let r = helper(&dir, id);
+ r.finish().unwrap();
+ // A completion file proves the helper consumed the marker, not merely that it
+ // hadn't yet produced a report.
+ wait_file(&dir.join(format!("ready-{id}")));
+ assert!(!dir.join(format!("crash-{id}.txt")).exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+ }
+
+ #[test]
+ fn accepted_native_close_finishes_before_process_termination() {
+ let dir = folder();
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let status = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
+ .env(ROLE, "native-close")
+ .env("EC_REPORT_DIR", &dir)
+ .stdout(Stdio::null())
+ .status()
+ .unwrap();
+ assert!(status.success());
+ wait_file(&dir.join("ready-8-1"));
+ assert!(!dir.join("crash-8-1.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn a_teardown_panic_does_not_mark_the_reporter_clean() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "8-2")).unwrap();
+ let result = std::panic::catch_unwind(|| {
+ let _ = after_teardown(Some(&reporter), || panic!("simulated teardown failure"));
+ });
+ assert!(result.is_err());
+ drop(reporter);
+ wait_file(&dir.join("ready-8-2"));
+ assert!(dir.join("crash-8-2.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn platforms_returning_to_main_keep_the_reporter_armed_until_then() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "8-3")).unwrap();
+ after_teardown(None, || {}).unwrap();
+ assert!(reporter.0.lock().unwrap().is_some());
+ reporter.clone().finish().unwrap();
+ reporter.finish().unwrap();
+ wait_file(&dir.join("ready-8-3"));
+ assert!(!dir.join("crash-8-3.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn an_aborting_parent_leaves_one_diagnostic_report() {
+ let dir = folder();
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let status = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
+ .env(ROLE, "abort")
+ .env("EC_REPORT_DIR", &dir)
+ .stdout(Stdio::null())
+ .status()
+ .unwrap();
+ assert!(!status.success());
+ wait_file(&dir.join("ready-4-1"));
+ let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap();
+ assert!(text.contains("exact cause is not established"));
+ assert!(text.contains("Detection UTC:"));
+ #[cfg(windows)]
+ assert!(text.contains("Windows status bits:"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn an_unrelated_child_does_not_keep_the_control_pipe_open() {
+ let dir = folder();
+ let r = helper(&dir, "5-1");
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let mut unrelated = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"])
+ .env(ROLE, "linger")
+ .stdout(Stdio::null())
+ .spawn()
+ .unwrap();
+ drop(r);
+ wait_file(&dir.join("ready-5-1"));
+ assert!(unrelated.try_wait().unwrap().is_none());
+ unrelated.kill().unwrap();
+ unrelated.wait().unwrap();
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn an_unresponsive_helper_cannot_hang_clean_shutdown() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let mut child = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"])
+ .env(ROLE, "linger")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .spawn()
+ .unwrap();
+ let pipe = child.stdin.take().unwrap();
+ let started = Instant::now();
+ let result = Reporter { pipe, child }.finish();
+ assert!(result.unwrap_err().to_string().contains("was terminated"));
+ assert!(started.elapsed() < Duration::from_secs(5));
+ }
+
+ #[test]
+ fn simultaneous_sessions_write_separate_reports() {
+ let dir = folder();
+ let a = helper(&dir, "6-1");
+ let b = helper(&dir, "6-2");
+ drop(a);
+ drop(b);
+ wait_file(&dir.join("ready-6-1"));
+ wait_file(&dir.join("ready-6-2"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn interactive_failure_calls_dialog_once_after_saving_the_report() {
+ let dir = folder();
+ drop(helper_mode(&dir, "7-1", true));
+ wait_file(&dir.join("ready-7-1"));
+ let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap();
+ assert!(text.contains("crash-7-1.txt"));
+ assert!(text.contains("No report has been sent"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn lost_pipe_keeps_bounded_lifecycle_evidence_without_claiming_process_exit() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "9-1")).unwrap();
+ for _ in 0..40 {
+ reporter.record("GPU observation: wgpu validation error").unwrap();
+ }
+ drop(reporter);
+ wait_file(&dir.join("ready-9-1"));
+ let text = std::fs::read_to_string(dir.join("crash-9-1.txt")).unwrap();
+ assert!(text.contains("GUI still present"));
+ assert!(!text.contains("process termination observed"));
+ assert_eq!(text.matches("GPU observation: wgpu validation error").count(), EVENT_COUNT);
+ assert!(text.contains("Target-present sampling attempts:"));
+ assert!(text.len() < 8192);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn clean_exit_after_lifecycle_events_does_not_write_a_report() {
+ let dir = folder();
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "9-2")).unwrap();
+ reporter.record("Backend selection source: default").unwrap();
+ reporter.finish().unwrap();
+ wait_file(&dir.join("ready-9-2"));
+ assert!(!dir.join("crash-9-2.txt").exists());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn windows_report_preserves_real_unsigned_exit_bits() {
+ let dir = folder();
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let status = Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
+ .env(ROLE, "windows-status")
+ .env("EC_REPORT_DIR", &dir)
+ .stdout(Stdio::null())
+ .status()
+ .unwrap();
+ assert_eq!(status.code().map(|code| code as u32), Some(0xc0000017));
+ wait_file(&dir.join("ready-9-3"));
+ let text = std::fs::read_to_string(dir.join("crash-9-3.txt")).unwrap();
+ assert!(text.contains("Windows status bits: 0xC0000017 (STATUS_NO_MEMORY)"));
+ assert!(text.contains("not a verified exception record"));
+ assert!(text.contains("process termination observed"));
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn internal_mode_rejects_invalid_arguments_without_launching_the_app() {
+ assert!(!maybe_run(&[], |_| panic!("no dialog")));
+ assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog")));
+ assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog")));
+ let args = [FLAG, "../escape", "", "", "1", "1"].map(OsString::from);
+ assert!(maybe_run(&args, |_| panic!("no dialog")));
+ let args = [FLAG, "1-1", "", "", "invalid", "1"].map(OsString::from);
+ assert!(maybe_run(&args, |_| panic!("no dialog")));
+ for pid in ["0", "not-a-pid", "4294967296"] {
+ let args = [FLAG, "1-1", "", "", "0", pid].map(OsString::from);
+ assert!(maybe_run(&args, |_| panic!("no dialog")));
+ }
+ }
+
+ #[test]
+ fn missing_helper_is_an_error_not_a_panic() {
+ assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err());
+ }
+
+ // Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise
+ // real EOF/abort behavior without allocating a huge image or killing the test runner.
+ #[test]
+ #[ignore]
+ fn reporter_process() {
+ assert_eq!(std::env::var(ROLE).unwrap(), "reporter");
+ let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
+ let id = std::env::var("EC_REPORT_ID").unwrap();
+ let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap();
+ let args = [
+ OsString::from(FLAG),
+ OsString::from(&id),
+ OsString::new(),
+ dir.as_os_str().to_owned(),
+ OsString::from(&interactive),
+ std::env::var_os("EC_REPORT_PID").unwrap(),
+ ];
+ assert!(maybe_run(&args, |description| {
+ assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog");
+ assert!(dir.join(format!("crash-{id}.txt")).exists());
+ let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap();
+ shown.write_all(description.as_bytes()).unwrap();
+ }));
+ std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap();
+ }
+
+ #[test]
+ #[ignore]
+ fn aborting_parent() {
+ let role = std::env::var(ROLE).unwrap();
+ let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
+ if role == "native-close" {
+ let reporter = Shutdown::default();
+ reporter.install(helper(&dir, "8-1")).unwrap();
+ after_teardown(Some(&reporter.clone()), || {}).unwrap();
+ std::process::exit(0); // Models macOS not returning from its native event loop.
+ }
+ if role == "windows-status" {
+ let _reporter = helper(&dir, "9-3");
+ // The real OS status is intentionally set, not actual memory exhaustion. The
+ // report must preserve this distinction rather than claiming a verified exception.
+ std::process::exit(0xc0000017u32 as i32);
+ }
+ assert_eq!(role, "abort");
+ let _reporter = helper(&dir, "4-1");
+ std::process::abort();
+ }
+
+ #[test]
+ #[ignore]
+ fn lingering_child() {
+ assert_eq!(std::env::var(ROLE).unwrap(), "linger");
+ thread::sleep(Duration::from_secs(30));
+ }
+}
diff --git a/apps/effectcraft/src/diagnostic_state.rs b/apps/effectcraft/src/diagnostic_state.rs
new file mode 100644
index 000000000..042e6e88c
--- /dev/null
+++ b/apps/effectcraft/src/diagnostic_state.rs
@@ -0,0 +1,147 @@
+//! Fixed-vocabulary observations retained by the crash helper independently of the log tail.
+//! Driver messages and environment text can contain private data; never forward either here.
+
+use std::ffi::OsStr;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU8, Ordering};
+
+use crate::crash_report::Shutdown;
+
+pub fn record(reporter: &Shutdown, event: &'static str) {
+ log::info!("diagnostic observation: {event}");
+ if let Err(error) = reporter.record(event) {
+ log::warn!("could not retain diagnostic observation: {error}");
+ }
+}
+
+/// Mirror only wgpu's documented names/aliases, without calling its parser: that parser
+/// logs unknown tokens verbatim. Unknown, non-Unicode and oversized values remain private.
+pub fn backend_override_events(value: &OsStr) -> Vec<&'static str> {
+ let Some(value) = value.to_str().filter(|value| value.len() <= 4096) else {
+ return vec!["WGPU_BACKEND: non-Unicode or oversized value omitted"];
+ };
+ let mut selected = [false; 6];
+ let mut unknown = false;
+ for token in value.split(',').map(str::trim) {
+ let aliases: &[&[&str]] = &[&["vulkan", "vk"], &["dx12", "d3d12"], &["metal", "mtl"], &["opengl", "gles", "gl"], &["webgpu"], &["noop"]];
+ if let Some(index) = aliases.iter().position(|names| names.iter().any(|name| token.eq_ignore_ascii_case(name))) {
+ if let Some(flag) = selected.get_mut(index) {
+ *flag = true;
+ }
+ } else {
+ unknown = true;
+ }
+ }
+ let events = [
+ "WGPU_BACKEND requests: vulkan",
+ "WGPU_BACKEND requests: dx12",
+ "WGPU_BACKEND requests: metal",
+ "WGPU_BACKEND requests: gl",
+ "WGPU_BACKEND requests: webgpu",
+ "WGPU_BACKEND requests: noop",
+ ];
+ let mut result: Vec<_> = selected.into_iter().zip(events).filter_map(|(present, event)| present.then_some(event)).collect();
+ if unknown {
+ result.push("WGPU_BACKEND: unrecognized or empty tokens omitted");
+ }
+ result
+}
+
+/// At most eight events per device, even when a driver floods its callbacks. A wgpu OOM
+/// observation is not proof of system RAM exhaustion; device loss is not a driver-crash verdict.
+#[derive(Clone)]
+pub struct GpuObservations {
+ seen: Arc,
+ reporter: Shutdown,
+}
+
+impl GpuObservations {
+ pub fn new(reporter: &Shutdown) -> Self {
+ Self { seen: Arc::new(AtomicU8::new(0)), reporter: reporter.clone() }
+ }
+
+ pub fn uncaptured(&self, error: &eframe::wgpu::Error) {
+ let (bit, event) = match error {
+ eframe::wgpu::Error::OutOfMemory { .. } => (1, "GPU observation: wgpu out-of-memory error (not proof of system RAM exhaustion)"),
+ eframe::wgpu::Error::Validation { .. } => (2, "GPU observation: wgpu validation error"),
+ eframe::wgpu::Error::Internal { .. } => (4, "GPU observation: wgpu internal error"),
+ };
+ self.once(bit, event);
+ }
+
+ pub fn scoped(&self, error: effectcraft_ui_egui::gpu_failure::ScopedError) {
+ use effectcraft_ui_egui::gpu_failure::ScopedError;
+ let (bit, event) = match error {
+ ScopedError::OutOfMemory => (32, "GPU scoped observation: out-of-memory (not proof of system RAM exhaustion)"),
+ ScopedError::Internal => (64, "GPU scoped observation: internal error (not confirmed out-of-memory)"),
+ ScopedError::Validation => (128, "GPU scoped observation: validation error"),
+ };
+ self.once(bit, event);
+ }
+
+ pub fn device_lost(&self, reason: eframe::wgpu::DeviceLostReason) {
+ match reason {
+ eframe::wgpu::DeviceLostReason::Unknown => self.once(8, "GPU observation: device lost, reason unknown (not proof of a driver crash)"),
+ eframe::wgpu::DeviceLostReason::Destroyed => self.once(16, "GPU observation: device-lost callback, explicit device destruction"),
+ };
+ }
+
+ fn once(&self, bit: u8, event: &'static str) -> bool {
+ if self.seen.fetch_or(bit, Ordering::Relaxed) & bit != 0 {
+ return false;
+ }
+ record(&self.reporter, event);
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn override_names_are_normalized_deduplicated_and_private() {
+ let events = backend_override_events(OsStr::new(" Vk, VULKAN, d3d12, MTL, gles, webgpu, noop, private-token"));
+ assert_eq!(events.len(), 7);
+ assert_eq!(events.first(), Some(&"WGPU_BACKEND requests: vulkan"));
+ assert!(!events.join(" ").contains("private-token"));
+ assert!(events.iter().all(|event| event.len() <= 100));
+ assert_eq!(backend_override_events(OsStr::new("")).len(), 1);
+ assert_eq!(backend_override_events(OsStr::new(&"x".repeat(4097))), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]);
+ }
+
+ #[test]
+ fn gpu_categories_are_latched_independently() {
+ let observations = GpuObservations::new(&Shutdown::default());
+ observations.uncaptured(&eframe::wgpu::Error::OutOfMemory { source: Box::new(std::io::Error::other("private driver detail")) });
+ observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown);
+ observations.device_lost(eframe::wgpu::DeviceLostReason::Unknown);
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 9);
+ observations.device_lost(eframe::wgpu::DeviceLostReason::Destroyed);
+ observations.uncaptured(&eframe::wgpu::Error::Validation {
+ source: Box::new(std::io::Error::other("private driver detail")),
+ description: "private validation detail".into(),
+ });
+ observations.uncaptured(&eframe::wgpu::Error::Internal {
+ source: Box::new(std::io::Error::other("private driver detail")),
+ description: "private internal detail".into(),
+ });
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 31);
+ use effectcraft_ui_egui::gpu_failure::ScopedError;
+ observations.scoped(ScopedError::Internal);
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 95, "internal scope must not set scoped OOM bit");
+ observations.scoped(ScopedError::OutOfMemory);
+ observations.scoped(ScopedError::Validation);
+ observations.scoped(ScopedError::OutOfMemory);
+ assert_eq!(observations.seen.load(Ordering::Relaxed), 255);
+ assert!(!observations.once(1, "GPU observation: duplicate"));
+ assert!(!observations.once(8, "GPU observation: duplicate"));
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn non_unicode_override_is_not_copied() {
+ use std::os::unix::ffi::OsStrExt;
+ assert_eq!(backend_override_events(OsStr::from_bytes(b"private\xff")), ["WGPU_BACKEND: non-Unicode or oversized value omitted"]);
+ }
+}
diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs
index fac91c05b..85d1fdd57 100644
--- a/apps/effectcraft/src/launch_guard.rs
+++ b/apps/effectcraft/src/launch_guard.rs
@@ -8,9 +8,9 @@
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A
//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those
//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still
-//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile
-//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to
-//! Automatic ([`opengl_failed`]) rather than failing every launch.
+//! wins over both. A separate guard file is locked while its launch runs, so a second copy opened
+//! meanwhile isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics
+//! back to Automatic ([`opengl_failed`]) rather than failing every launch.
use std::fs::File;
use std::path::{Path, PathBuf};
@@ -21,6 +21,46 @@ use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window has settled.
const MARKER: &str = "launch-pending";
+const LOCK: &str = "launch.lock";
+
+/// Keep this file in place: unlinking a locked file lets a second inode acquire a new lock.
+fn acquire_guard(dir: &Path) -> Option {
+ let file = match std::fs::create_dir_all(dir)
+ .and_then(|()| std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)))
+ {
+ Ok(file) => file,
+ Err(e) => {
+ log::warn!("launch guard unavailable: {e}");
+ return None;
+ }
+ };
+ match file.try_lock() {
+ Ok(()) => {}
+ Err(std::fs::TryLockError::WouldBlock) => return None,
+ Err(e) => {
+ log::warn!("launch guard lock unavailable: {e}");
+ return None;
+ }
+ }
+ // Older versions lock the marker itself. Probe only the lock: Windows also prevents
+ // reads through another handle while it is held. An unreadable marker is not a stale one.
+ match File::open(dir.join(MARKER)) {
+ Ok(marker) => match marker.try_lock() {
+ Ok(()) => {}
+ Err(std::fs::TryLockError::WouldBlock) => return None,
+ Err(e) => {
+ log::warn!("launch marker lock unavailable: {e}");
+ return None;
+ }
+ },
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => {
+ log::warn!("launch marker unavailable: {e}");
+ return None;
+ }
+ }
+ Some(file)
+}
/// How long the window keeps drawing after its first frame before the launch counts as made.
/// A driver that fails as the window is shown and its surface re-created does so within the
@@ -28,16 +68,39 @@ const MARKER: &str = "launch-pending";
/// switch back in Settings.
pub const SETTLE: Duration = Duration::from_secs(3);
+/// Why this launch requested its window graphics APIs. An interrupted startup is an
+/// observation, not proof that a driver caused the previous exit.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub enum BackendSource {
+ #[default]
+ Default,
+ SavedPreference,
+ InterruptedStartup,
+ EnvironmentOverride,
+}
+
+impl BackendSource {
+ pub fn event(self) -> &'static str {
+ match self {
+ Self::Default => "backend source: default",
+ Self::SavedPreference => "backend source: saved OpenGL preference",
+ Self::InterruptedStartup => "backend source: previous interrupted startup; OpenGL fallback",
+ Self::EnvironmentOverride => "backend source: WGPU_BACKEND present",
+ }
+ }
+}
+
/// This launch's window graphics.
#[derive(Debug, Default)]
pub struct Launch {
/// The graphics APIs the window may use (`None`: eframe's default).
pub backends: Option,
+ pub source: BackendSource,
/// Switched to OpenGL because the previous launch stopped as its window opened: the message
/// to show.
pub notice: Option,
marker: Option,
- /// Holds the marker's lock until the launch settles (or the process ends).
+ /// Holds the separate guard file's lock until the launch settles (or the process ends).
lock: Option,
/// When the window drew its first frame, until the launch settles.
first_frame: Option,
@@ -48,14 +111,22 @@ impl Launch {
/// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this
/// launch's marker. `env_override`: `WGPU_BACKEND` is set.
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
- let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
+ if env_override {
+ return Launch { source: BackendSource::EnvironmentOverride, ..Launch::default() };
+ }
+ let Some(dir) = dir else { return Launch::default() };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let marker = dir.join(MARKER);
- // Locked by another launch that is still opening its window: not a crash.
- let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
+ let Some(lock) = acquire_guard(dir) else {
+ let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
+ let source = if backends.is_some() { BackendSource::SavedPreference } else { BackendSource::Default };
+ return Launch { backends, source, ..Launch::default() };
+ };
+ // Read again while owning the guard: another launch may have just changed preferences.
+ prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let mut notice = None;
- if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
+ if marker.exists() && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
prefs.startup.window_graphics = "gl".into();
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"),
@@ -69,15 +140,21 @@ impl Launch {
);
}
let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
- let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) {
- // Where locks aren't available the guard works as before.
- Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)),
+ let source = if notice.is_some() {
+ BackendSource::InterruptedStartup
+ } else if backends.is_some() {
+ BackendSource::SavedPreference
+ } else {
+ BackendSource::Default
+ };
+ let marker = match File::create(&marker) {
+ Ok(_) => Some(marker),
Err(e) => {
log::warn!("launch marker: {e}");
- (None, None)
+ None
}
};
- Launch { backends, notice, marker, lock, first_frame: None }
+ Launch { backends, source, notice, marker, lock: Some(lock), first_frame: None }
}
/// The window drew a frame. Until the launch has settled, returns how long that still takes
@@ -104,18 +181,19 @@ impl Launch {
/// The launch made it: release and remove its marker.
fn settled(&mut self) {
- self.lock = None;
if let Some(m) = self.marker.take()
&& let Err(e) = std::fs::remove_file(&m)
{
log::warn!("launch marker: {e}");
}
+ self.lock = None;
}
}
/// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker.
pub fn opengl_failed(dir: Option<&Path>) {
let Some(dir) = dir else { return };
+ let Some(_lock) = acquire_guard(dir) else { return };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
prefs.startup.window_graphics = "auto".into();
@@ -136,12 +214,13 @@ mod tests {
/// A launch that never drew switches the next one to OpenGL, which then stays (#243).
#[test]
fn a_launch_that_never_drew_switches_the_next_to_opengl() {
- let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("never-drew");
// A first launch that draws: nothing changes. Its first frames don't settle it (a driver
// may still crash as the window is shown); frames for `SETTLE` do.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
+ assert_eq!(l.source, BackendSource::Default);
assert!(dir.join(MARKER).exists());
let t0 = Instant::now();
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
@@ -159,14 +238,18 @@ mod tests {
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
+ assert_eq!(l.source, BackendSource::InterruptedStartup);
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
l.exited();
let l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
+ assert_eq!(l.source, BackendSource::SavedPreference);
}
// `WGPU_BACKEND` wins, and leaves no marker.
let _ = std::fs::remove_file(dir.join(MARKER));
- assert_eq!(Launch::begin(Some(&dir), true).backends, None);
+ let overridden = Launch::begin(Some(&dir), true);
+ assert_eq!(overridden.backends, None);
+ assert_eq!(overridden.source, BackendSource::EnvironmentOverride);
assert!(!dir.join(MARKER).exists());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -176,8 +259,8 @@ mod tests {
/// normal quit in that time doesn't.
#[test]
fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() {
- let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("first-frame");
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
drop(l);
@@ -203,8 +286,8 @@ mod tests {
/// open a window either goes back to Automatic instead of failing every launch.
#[test]
fn a_second_copy_or_failed_opengl_doesnt_strand_window_graphics() {
- let dir = std::env::temp_dir().join(format!("ec-launch-guard-2-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("second-copy");
let _first = Launch::begin(Some(&dir), false);
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
assert_eq!(window_graphics(&dir), "auto");
@@ -217,4 +300,240 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A fresh settings folder for one test.
+ fn fresh_dir(case: &str) -> PathBuf {
+ tempfile::Builder::new().prefix(&format!("ec-launch-guard-{case}-")).tempdir().unwrap().keep()
+ }
+
+ /// Window Graphics saved as OpenGL.
+ fn save_opengl(dir: &Path) {
+ let mut prefs = Prefs::default();
+ prefs.startup.window_graphics = "gl".into();
+ std::fs::write(dir.join(PREFS_FILE), prefs.to_json()).unwrap();
+ }
+
+ #[test]
+ fn a_second_copy_reports_the_saved_backend_without_owning_the_guard() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("second-source");
+ save_opengl(&dir);
+ let mut first = Launch::begin(Some(&dir), false);
+ let mut second = Launch::begin(Some(&dir), false);
+ assert!(second.lock.is_none());
+ assert_eq!((second.backends, second.source), (Some(Backends::GL), BackendSource::SavedPreference));
+ second.exited();
+ first.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A second copy that quits normally while the first is still opening doesn't remove the first
+ /// one's marker (#477).
+ #[test]
+ fn a_second_copy_quitting_leaves_the_running_launchs_marker() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("second-quits");
+ let mut first = Launch::begin(Some(&dir), false);
+ let mut second = Launch::begin(Some(&dir), false);
+ second.exited();
+ let kept = dir.join(MARKER).exists();
+ first.exited();
+ let _ = std::fs::remove_dir_all(&dir);
+ assert!(kept, "the second copy removed the running launch's marker");
+ }
+
+ /// A second copy whose OpenGL window fails doesn't remove a running launch's marker.
+ #[test]
+ fn failed_opengl_in_a_second_copy_leaves_the_running_launchs_marker() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("second-gl-fails");
+ let mut first = Launch::begin(Some(&dir), false);
+ let second = Launch::begin(Some(&dir), false);
+ opengl_failed(Some(&dir));
+ let kept = dir.join(MARKER).exists();
+ drop(second);
+ first.exited();
+ let _ = std::fs::remove_dir_all(&dir);
+ assert!(kept, "OpenGL cleanup removed another running launch's marker");
+ }
+
+ /// With `WGPU_BACKEND` set, a stale marker and a saved OpenGL choice are left as they are.
+ #[test]
+ fn wgpu_backend_leaves_a_stale_marker_and_saved_opengl_alone() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("env-override");
+ save_opengl(&dir);
+ std::fs::write(dir.join(MARKER), b"stale-marker").unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut l = Launch::begin(Some(&dir), true);
+ assert_eq!(l.backends, None);
+ assert!(l.notice.is_none());
+ l.exited();
+ assert!(dir.join(MARKER).exists());
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// After OpenGL fails to open a window, the next launch is back on Automatic, without a notice.
+ #[test]
+ fn failed_opengl_puts_the_next_launch_back_on_automatic() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("gl-recovers");
+ save_opengl(&dir);
+ let l = Launch::begin(Some(&dir), false);
+ assert_eq!(l.backends, Some(Backends::GL));
+ drop(l);
+ opengl_failed(Some(&dir));
+ assert!(!dir.join(MARKER).exists());
+ assert_eq!(Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics, "auto");
+ let mut l = Launch::begin(Some(&dir), false);
+ assert_eq!(l.backends, None);
+ assert!(l.notice.is_none());
+ l.exited();
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The guard file is never removed or replaced: unlinking a locked file would let the next
+ /// launch lock a new one while the old lock is still held.
+ #[cfg(unix)]
+ #[test]
+ fn the_guard_file_stays_the_same_file_across_launches() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ use std::os::unix::fs::MetadataExt;
+ let dir = fresh_dir("guard-identity");
+ let mut a = Launch::begin(Some(&dir), false);
+ let before = std::fs::metadata(dir.join(LOCK)).unwrap();
+ let t0 = Instant::now();
+ assert_eq!(a.frame_drawn_at(t0), Some(SETTLE));
+ assert_eq!(a.frame_drawn_at(t0 + SETTLE), None);
+ assert!(!dir.join(MARKER).exists());
+ let after = std::fs::metadata(dir.join(LOCK)).unwrap();
+ assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
+ let mut b = Launch::begin(Some(&dir), false);
+ assert!(b.lock.is_some());
+ let reused = std::fs::metadata(dir.join(LOCK)).unwrap();
+ assert_eq!((before.dev(), before.ino()), (reused.dev(), reused.ino()));
+ b.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A second copy's frames and quit neither settle nor unlock the running launch.
+ #[test]
+ fn a_second_copys_frames_dont_settle_the_running_launch() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("second-frames");
+ let mut a = Launch::begin(Some(&dir), false);
+ std::fs::write(dir.join(MARKER), b"owner-marker").unwrap();
+ let mut b = Launch::begin(Some(&dir), false);
+ assert!(b.lock.is_none());
+ assert!(b.marker.is_none());
+ let t0 = Instant::now();
+ assert_eq!(b.frame_drawn_at(t0), None);
+ assert_eq!(b.frame_drawn_at(t0 + SETTLE), None);
+ b.exited();
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"owner-marker");
+ let probe = File::open(dir.join(LOCK)).unwrap();
+ assert!(matches!(probe.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
+ a.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// Older versions lock the marker itself: while one runs, a new launch changes nothing, and
+ /// once it has stopped without settling the next launch still switches to OpenGL.
+ #[test]
+ fn a_running_older_version_holding_the_marker_is_left_alone() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("older-version");
+ std::fs::write(dir.join(MARKER), b"old-owner").unwrap();
+ std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
+ let old = File::open(dir.join(MARKER)).unwrap();
+ old.try_lock().unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut b = Launch::begin(Some(&dir), false);
+ assert!(b.lock.is_none());
+ assert!(b.marker.is_none());
+ assert!(b.backends.is_none());
+ assert!(b.notice.is_none());
+ b.exited();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ // Windows locks forbid reading through another handle. Inspect the contents only after
+ // releasing the old owner's lock, but before recovery can truncate the marker.
+ drop(old);
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner");
+ let mut recovered = Launch::begin(Some(&dir), false);
+ if cfg!(not(target_os = "macos")) {
+ assert_eq!(recovered.backends, Some(Backends::GL));
+ }
+ recovered.exited();
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A Windows sharing violation is not evidence that a marker is stale or absent.
+ #[cfg(windows)]
+ #[test]
+ fn an_unopenable_marker_leaves_settings_and_marker_alone() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ use std::os::windows::fs::OpenOptionsExt;
+
+ let dir = fresh_dir("marker-unopenable");
+ std::fs::write(dir.join(MARKER), b"old-owner").unwrap();
+ std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
+ let old = std::fs::OpenOptions::new().read(true).write(true).share_mode(0).open(dir.join(MARKER)).unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut launch = Launch::begin(Some(&dir), false);
+ assert!(launch.lock.is_none());
+ assert!(launch.marker.is_none());
+ assert!(launch.backends.is_none());
+ assert!(launch.notice.is_none());
+ launch.exited();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ drop(old);
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"old-owner");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// A guard file that can't be opened leaves Window Graphics and the marker as they are.
+ #[test]
+ fn an_unusable_guard_file_leaves_settings_and_marker_alone() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("guard-unusable");
+ std::fs::create_dir(dir.join(LOCK)).unwrap();
+ std::fs::write(dir.join(MARKER), b"stale-marker").unwrap();
+ std::fs::write(dir.join(PREFS_FILE), Prefs::default().to_json()).unwrap();
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ let mut l = Launch::begin(Some(&dir), false);
+ assert!(l.backends.is_none());
+ assert!(l.notice.is_none());
+ assert!(l.lock.is_none());
+ assert!(l.marker.is_none());
+ l.exited();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ assert_eq!(std::fs::read(dir.join(MARKER)).unwrap(), b"stale-marker");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ /// OpenGL cleanup from elsewhere waits until the running launch has let go; then it resets
+ /// Window Graphics to Automatic and removes the marker.
+ #[test]
+ fn failed_opengl_cleanup_waits_for_the_running_launch() {
+ let _spawn_lock = crate::test_child_spawn_lock();
+ let dir = fresh_dir("gl-cleanup-waits");
+ save_opengl(&dir);
+ let launch = Launch::begin(Some(&dir), false);
+ assert_eq!(launch.backends, Some(Backends::GL));
+ assert!(launch.lock.is_some());
+ let before = std::fs::read(dir.join(PREFS_FILE)).unwrap();
+ opengl_failed(Some(&dir));
+ assert_eq!(std::fs::read(dir.join(PREFS_FILE)).unwrap(), before);
+ assert!(dir.join(MARKER).exists(), "cleanup elsewhere can't remove a running launch's marker");
+ drop(launch);
+ opengl_failed(Some(&dir));
+ let after = Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap()).startup.window_graphics;
+ assert_eq!(after, "auto", "once the launch has let go, failed OpenGL resets Window Graphics");
+ assert!(!dir.join(MARKER).exists());
+ let _ = std::fs::remove_dir_all(dir);
+ }
}
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
index 07d327a21..b07e3ce43 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -22,6 +22,8 @@ mod appimage;
mod apple_events;
mod audio_out;
mod control_server;
+mod crash_report;
+mod diagnostic_state;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -33,6 +35,17 @@ mod window;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
+// On Unix, a subprocess briefly inherits every non-CLOFORK descriptor between fork and exec.
+// The app's guard-lock tests hold an advisory lock while crash-reporter tests spawn child
+// processes; coordinate just those intervals so an unrelated test child cannot extend a lock.
+#[cfg(test)]
+static TEST_CHILD_SPAWN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+#[cfg(test)]
+fn test_child_spawn_lock() -> std::sync::MutexGuard<'static, ()> {
+ TEST_CHILD_SPAWN_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
@@ -180,6 +193,23 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) {
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
/// reports startup failure through `startup_failed`. Returns the event loop's result.
fn main() -> eframe::Result {
+ let args: Vec<_> = std::env::args_os().skip(1).collect();
+ if crash_report::maybe_run(&args, |description| {
+ rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft diagnostic report").set_description(description).show();
+ }) {
+ return Ok(());
+ }
+ let reporter = crash_report::Shutdown::default();
+ let result = run_desktop(&reporter);
+ // run_desktop has dropped the app and handled any startup error with its existing dialog.
+ // An unwinding panic or abort never reaches this marker.
+ if let Err(error) = reporter.finish() {
+ log::warn!("could not mark crash reporter clean: {error}");
+ }
+ result
+}
+
+fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) {
@@ -205,20 +235,38 @@ fn main() -> eframe::Result {
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
+ let run_id = crash_report::run_id();
if let Some(dir) = effectcraft_host::config_dir() {
- match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
+ let header = format!("{}\n", crash_report::session_marker(&run_id));
+ match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
+ let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref());
+ match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) {
+ Ok(started) => {
+ if let Err(error) = reporter.install(started) {
+ log::warn!("crash reporter could not be retained: {error}");
+ }
+ }
+ Err(error) => log::warn!("crash reporter could not start: {error}"),
+ }
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
- // stopped as its window opened (a crashing graphics driver, #243).
- let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
+ // stopped as its window opened (the marker alone does not establish a cause).
+ let backend_override = std::env::var_os("WGPU_BACKEND");
+ let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), backend_override.is_some());
+ diagnostic_state::record(reporter, launch.source.event());
+ if let Some(value) = backend_override {
+ for event in diagnostic_state::backend_override_events(&value) {
+ diagnostic_state::record(reporter, event);
+ }
+ }
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
let options = eframe::NativeOptions {
@@ -245,22 +293,40 @@ fn main() -> eframe::Result {
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
+ let desktop_reporter = reporter.clone();
let result = eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
match &cc.wgpu_render_state {
- Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
- None => log::info!("graphics: no wgpu device"),
+ Some(rs) => {
+ let info = rs.adapter.get_info();
+ log::info!("graphics: {info:?}");
+ let event = match info.backend {
+ eframe::wgpu::Backend::Noop => "actual GPU backend: noop",
+ eframe::wgpu::Backend::Vulkan => "actual GPU backend: vulkan",
+ eframe::wgpu::Backend::Metal => "actual GPU backend: metal",
+ eframe::wgpu::Backend::Dx12 => "actual GPU backend: dx12",
+ eframe::wgpu::Backend::Gl => "actual GPU backend: gl",
+ eframe::wgpu::Backend::BrowserWebGpu => "actual GPU backend: webgpu",
+ };
+ diagnostic_state::record(&desktop_reporter, event);
+ }
+ None => diagnostic_state::record(&desktop_reporter, "actual GPU backend: no wgpu device"),
}
// This executable owns the shared device's handlers. Install them before
// EffectCraft's compositor pipelines are built, without changing handlers from
// inside Gpu::new. eframe has already constructed its presentation renderer here.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
+ let observations = diagnostic_state::GpuObservations::new(&desktop_reporter);
+ let scoped_observed = observations.clone();
+ gpu_failures.set_scoped_error_observer(move |error| scoped_observed.scoped(error));
if let Some(rs) = &cc.wgpu_render_state {
+ let errors_observed = observations.clone();
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
+ errors_observed.uncaptured(&error);
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
@@ -268,6 +334,7 @@ fn main() -> eframe::Result {
}));
let lost = gpu_failures.clone();
rs.device.set_device_lost_callback(move |reason, message| {
+ observations.device_lost(reason);
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
});
}
@@ -276,9 +343,6 @@ fn main() -> eframe::Result {
// written on background threads (M13.14).
session.check_footage_on_open = true;
session.autosave.background = true;
- // The disk cache's footage stamps too: an imported or opened project with thousands of
- // sequence frames on a network share took half a minute to stamp.
- session.disk_salt_background = true;
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = effectcraft_host::config_dir() {
@@ -401,6 +465,7 @@ fn main() -> eframe::Result {
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
+ reporter: desktop_reporter,
}))
}),
);
@@ -494,6 +559,7 @@ struct Desktop {
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
+ reporter: crash_report::Shutdown,
}
impl eframe::App for Desktop {
@@ -528,15 +594,20 @@ impl eframe::App for Desktop {
}
fn on_exit(&mut self) {
- // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
- // before `eframe::run_native` returns (#341).
- log::info!("the window is closing");
- self.launch.exited();
- self.window.save(self.app.session.config.as_deref());
- self.app.on_exit();
- effectcraft_host::stop_helpers();
- // Scripts in Scripts/Shutdown run with the project closed.
- let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
+ // macOS terminates after this hook without returning from run_native (#341).
+ // Other platforms keep the reporter armed until main sees complete event-loop teardown.
+ let reporter = cfg!(target_os = "macos").then(|| self.reporter.clone());
+ if let Err(error) = crash_report::after_teardown(reporter.as_ref(), || {
+ log::info!("the window is closing");
+ self.launch.exited();
+ self.window.save(self.app.session.config.as_deref());
+ self.app.on_exit();
+ effectcraft_host::stop_helpers();
+ // Scripts in Scripts/Shutdown run with the project closed.
+ let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
+ }) {
+ log::warn!("could not mark crash reporter clean after teardown: {error}");
+ }
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
diff --git a/contributors/people.toml b/contributors/people.toml
index e9144f9ea..beb91a413 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -135,3 +135,6 @@ display_name = "Jonathan Cardoso"
[people.nazar-gavrylyk]
sync_github_name = true
+
+[people.andreaaazo]
+sync_github_name = true
diff --git a/crates/aep/Cargo.toml b/crates/aep/Cargo.toml
index b1bf3f9fe..0523edb9b 100644
--- a/crates/aep/Cargo.toml
+++ b/crates/aep/Cargo.toml
@@ -10,18 +10,15 @@ rust-version.workspace = true
workspace = true
[dependencies]
+craft-aep = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-effects = { workspace = true }
+log = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
-
-# The tool's own tests run with the crate's (examples are only built by default).
-[[example]]
-name = "stubicc"
-test = true
diff --git a/crates/aep/README.md b/crates/aep/README.md
index 965bea317..0e65d86f8 100644
--- a/crates/aep/README.md
+++ b/crates/aep/README.md
@@ -3,7 +3,9 @@
After Effects projects (`.aep`, `.aepx`) in one crate, the way `effectcraft-lottie` handles Lottie:
- `container`: reads and writes the container as a lossless chunk tree, with a typed, read-only
- view (below; its API is re-exported at the crate root);
+ view (below). This is `craft-aep` from [storytold/craft-libs](https://github.com/storytold/craft-libs),
+ re-exported at the crate root; its container tests and the `aepdump`/`stubicc` tools are there.
+ Only `effect_map` and `style_map` (After Effects effects and layer styles to EffectCraft's) are here;
- `import`: maps the typed view into an EffectCraft project and reports everything it does not
import ([`docs/aep-import.md`](../../docs/aep-import.md));
- `export`: writes an EffectCraft project as an `.aep` with every chunk built in code, and reports everything it does not export
@@ -21,7 +23,9 @@ tree; pad bytes, odd bodies and the bytes after the container, an XMP packet, ar
The XML form of a project (`.aepx`) is read into the same tree by `read_aepx` (a small bounded
XML tokenizer, no dependency); `parse_any` picks the reader by content, so the typed view and the
-importer take either form. The mapping is in `docs/aep-format.md`, section 1.
+importer take either form. The mapping is in `docs/aep-format.md`, section 1. The importer, which
+only reads, does not build the owned tree: it reads either form through the flat index
+`TreeIndex`, an `.aep` in place and an `.aepx` decoded into one buffer first.
Bodies that look like lists but are not chunk sequences (for example `LIST btdk`, raw text) stay
opaque rather than failing the parse. Input is untrusted: sizes are checked against the remaining
@@ -69,25 +73,30 @@ tests reach them only through `tests/it/common/mod.rs`, which hands a test its c
of its own cut from the combined file; a test whose fixture is missing (or with the variable not
set) prints `SKIPPED: fixture missing` and passes.
-`tests/fixtures/synthetic.aep` (115 KB) is the one project in the repository: made in After Effects 2026
+`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026
with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a
solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds
the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk;
the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was
changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file.
+`tests/fixtures/text-synthetic.aep` is the other: made in After Effects 2026 the same way (working
+space None, nothing changed after the save), one composition with two text layers that hold a
+text animator's Stroke Opacity (static and keyed) and Source Text expressions (enabled and
+disabled); `tests/it/import/text_synthetic.rs` reads it (#3170).
+
+`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs
+named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a
+Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and
+`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks
+the import against After Effects' read-back, and the host's `vector_import` tests render it.
## Tools
- `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After
Effects; its README says how).
-- `examples/aepdump.rs`: prints the chunk tree of a file (ids, sizes, short data, match names):
- `cargo run -p effectcraft-aep --example aepdump -- file.aep`.
-- `examples/stubicc.rs`: replaces every embedded ICC profile of a saved project by a stub that keeps
- only the profile's description: the JSON `colorProfileData` of the working space (any white space
- around the colon, `\/` escapes, quotes as `"`) in an `.aep` chunk or the `.aepx` text, and a raw
- profile of an imported image (an `.aep` chunk, or the `bdata` hex of an `.aepx`):
- `cargo run -p effectcraft-aep --example stubicc -- in.aep out.aep`. A profile it cannot replace
- stops it before anything is written. The importer reads the working space's name from the
- description only, so the project imports the same; no vendor colour data stays in the test
- project. Check the result independently, not by the tool's count (the corpus repository has
- `tools/aep/inspect-embedded.py`, which scans the raw bytes, base64 and hex).
+- `aepdump` (prints the chunk tree of a file) and `stubicc` (replaces every embedded ICC profile of a
+ saved project by a stub) are examples of `craft-aep` in
+ [storytold/craft-libs](https://github.com/storytold/craft-libs):
+ `cargo run -p craft-aep --example stubicc -- in.aep out.aep` there. Check the result independently,
+ not by the tool's count (the corpus repository has `tools/aep/inspect-embedded.py`, which scans the
+ raw bytes, base64 and hex).
diff --git a/crates/aep/examples/aepdump.rs b/crates/aep/examples/aepdump.rs
deleted file mode 100644
index 2fe492696..000000000
--- a/crates/aep/examples/aepdump.rs
+++ /dev/null
@@ -1,75 +0,0 @@
-//! Developer tool: prints the chunk tree of an .aep (ids, sizes, short data), match names inline.
-#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
-use effectcraft_aep::{Body, Chunk};
-
-/// Ends this developer tool with a message instead of a panic.
-trait OrExit {
- fn or_exit(self, what: &str) -> T;
-}
-
-impl OrExit for Option {
- fn or_exit(self, what: &str) -> T {
- match self {
- Some(v) => v,
- None => exit(what),
- }
- }
-}
-
-impl OrExit for Result {
- fn or_exit(self, what: &str) -> T {
- match self {
- Ok(v) => v,
- Err(e) => exit(&format!("{what}: {e:?}")),
- }
- }
-}
-
-fn exit(msg: &str) -> ! {
- eprintln!("{msg}");
- std::process::exit(1)
-}
-
-fn show(c: &Chunk<'_>, depth: usize, max: usize, hex: usize) {
- let ind = " ".repeat(depth);
- let id = String::from_utf8_lossy(&c.id).to_string();
- match &c.body {
- Body::Data(d) if &c.id == b"tdmn" => {
- let n: String = d.iter().take_while(|b| **b != 0).map(|b| *b as char).collect();
- println!("{ind}tdmn {n}");
- }
- Body::Data(d) => {
- let h: String = d.iter().take(hex).map(|b| format!("{b:02x}")).collect();
- let a: String = d.iter().take(hex).map(|b| if (32..127).contains(b) { *b as char } else { '.' }).collect();
- println!("{ind}{id} [{}] {h} |{a}|", d.len());
- }
- Body::Chunks(k) => {
- println!("{ind}{id} (nested)");
- if depth < max {
- for x in k {
- show(x, depth + 1, max, hex);
- }
- }
- }
- Body::List { list_type, children } => {
- println!("{ind}LIST {} ({} children)", String::from_utf8_lossy(list_type), children.len());
- if depth < max {
- for x in children {
- show(x, depth + 1, max, hex);
- }
- }
- }
- Body::OpaqueList { list_type, data } => println!("{ind}LIST {} opaque [{}]", String::from_utf8_lossy(list_type), data.len()),
- }
-}
-fn main() {
- let a: Vec = std::env::args().collect();
- let bytes = std::fs::read(a.get(1).or_exit("usage: see the comment at the top of this file")).or_exit("read");
- let max: usize = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(4);
- let hex: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(32);
- let f = effectcraft_aep::parse(&bytes).or_exit("parse");
- for c in &f.chunks {
- show(c, 0, max, hex);
- }
- println!("trailer {} bytes", f.trailer.len());
-}
diff --git a/crates/aep/examples/stubicc.rs b/crates/aep/examples/stubicc.rs
deleted file mode 100644
index 9438da06d..000000000
--- a/crates/aep/examples/stubicc.rs
+++ /dev/null
@@ -1,359 +0,0 @@
-//! Developer tool: replaces every embedded ICC profile of an .aep / .aepx by a stub that keeps only the
-//! profile's description. Fixture hygiene: a saved project carries the vendor's working-space profile
-//! (a few KB of colour data); the importer reads only the profile's name, the `desc` tag, so the stub
-//! (a 128-byte header, one `desc` tag, no colour data, made here) imports identically.
-//!
-//! `cargo run -p effectcraft-aep --example stubicc -- in.aep out.aep`
-//!
-//! It handles:
-//! - the JSON value `"colorProfileData": ""` in any chunk of an .aep and anywhere in the text
-//! of an .aepx (white space around the colon, `\/` escapes and quotes written as `"` allowed);
-//! - a raw ICC profile stored as a chunk of its own (the profile of an imported image), in an .aep
-//! as the chunk's bytes and in an .aepx as its `bdata=""` attribute.
-//!
-//! A profile it cannot replace (no readable description, data that does not decode) stops the tool
-//! before anything is written. Check the result independently (the corpus has
-//! `tools/aep/inspect-embedded.py`), not by this tool's own count.
-#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
-use effectcraft_aep::{Body, Chunk};
-
-const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
-
-/// Ends this developer tool with a message instead of a panic.
-trait OrExit {
- fn or_exit(self, what: &str) -> T;
-}
-
-impl OrExit for Option {
- fn or_exit(self, what: &str) -> T {
- match self {
- Some(v) => v,
- None => exit(what),
- }
- }
-}
-
-impl OrExit for Result {
- fn or_exit(self, what: &str) -> T {
- match self {
- Ok(v) => v,
- Err(e) => exit(&format!("{what}: {e:?}")),
- }
- }
-}
-
-fn exit(msg: &str) -> ! {
- eprintln!("{msg}");
- std::process::exit(1)
-}
-
-fn b64_decode(text: &[u8]) -> Option> {
- let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32);
- for &c in text.iter().filter(|c| **c != b'=') {
- let v = u32::try_from(B64.iter().position(|b| *b == c)?).ok()?;
- acc = (acc << 6 | v) & 0x00ff_ffff;
- bits += 6;
- if bits >= 8 {
- bits -= 8;
- out.push(((acc >> bits) & 0xff) as u8);
- }
- }
- Some(out)
-}
-
-fn b64_encode(data: &[u8]) -> Vec {
- let mut out = Vec::new();
- for ch in data.chunks(3) {
- let at = |i: usize| u32::from(ch.get(i).copied().unwrap_or(0));
- let n = (at(0) << 16) | (at(1) << 8) | at(2);
- for i in 0..4usize {
- if i <= ch.len() {
- out.push(B64.get(((n >> (18 - 6 * i)) & 63) as usize).copied().unwrap_or(b'A'));
- } else {
- out.push(b'=');
- }
- }
- }
- out
-}
-
-fn be32(d: &[u8], at: usize) -> Option {
- let b: [u8; 4] = d.get(at..at.checked_add(4)?)?.try_into().ok()?;
- usize::try_from(u32::from_be_bytes(b)).ok()
-}
-
-/// The profile description (ICC.1 `desc`: v2 ASCII or v4 `mluc`, first record).
-fn description(icc: &[u8]) -> Option {
- let count = be32(icc, 128)?;
- let tag = (0..count.min(1024)).find_map(|i| {
- let at = 132usize.checked_add(i.checked_mul(12)?)?;
- (icc.get(at..at.checked_add(4)?)? == b"desc").then_some(at)
- })?;
- let off = be32(icc, tag.checked_add(4)?)?;
- let body = icc.get(off..off.checked_add(be32(icc, tag.checked_add(8)?)?)?)?;
- let text = match body.get(..4)? {
- b"desc" => {
- let n = be32(body, 8)?;
- String::from_utf8_lossy(body.get(12..12usize.checked_add(n)?)?.split(|b| *b == 0).next()?).into_owned()
- }
- b"mluc" => {
- let (n, at) = (be32(body, 20)?, be32(body, 24)?);
- let units: Vec = body.get(at..at.checked_add(n)?)?.chunks(2).filter_map(|p| Some(u16::from_be_bytes([*p.first()?, *p.get(1)?]))).collect();
- String::from_utf16_lossy(&units)
- }
- _ => return None,
- };
- Some(text.trim_end_matches('\0').trim().to_owned())
-}
-
-/// A profile with a header, one `desc` tag and no colour data.
-fn stub(desc: &str) -> Option> {
- let mut body = b"desc\0\0\0\0".to_vec();
- body.extend_from_slice(&u32::try_from(desc.len().checked_add(1)?).ok()?.to_be_bytes());
- body.extend_from_slice(desc.as_bytes());
- body.push(0);
- body.extend_from_slice(&[0; 4 + 4 + 2 + 1 + 67]);
- let mut p = vec![0u8; 128];
- for (at, v) in [(8, &0x0240_0000u32.to_be_bytes()[..]), (12, b"mntr"), (16, b"RGB "), (20, b"XYZ "), (36, b"acsp")] {
- p.get_mut(at..at + v.len())?.copy_from_slice(v);
- }
- p.extend_from_slice(&1u32.to_be_bytes());
- p.extend_from_slice(b"desc");
- p.extend_from_slice(&144u32.to_be_bytes());
- p.extend_from_slice(&u32::try_from(body.len()).ok()?.to_be_bytes());
- p.extend_from_slice(&body);
- let size = u32::try_from(p.len()).ok()?.to_be_bytes();
- p.get_mut(..4)?.copy_from_slice(&size);
- Some(p)
-}
-
-/// The JSON key, without its quotes.
-const KEY: &[u8] = b"colorProfileData";
-/// How a JSON quote is written: as is (a `Utf8` chunk, the `` text of an .aepx) and as an
-/// XML entity (JSON inside an attribute of an .aepx).
-const QUOTES: [&[u8]; 2] = [b"\"", b"""];
-
-/// What the tool did, counted.
-#[derive(Default)]
-struct Tally {
- stubbed: usize,
- /// Values too short to be a profile (no ICC header), kept as they are.
- kept_short: usize,
- /// Profiles that could not be replaced (no readable description, undecodable data): the tool
- /// fails, so no profile is ever left behind silently.
- failed: Vec,
-}
-
-fn is_profile(d: &[u8]) -> bool {
- d.len() > 132 && d.get(36..40) == Some(b"acsp")
-}
-
-/// The stub for the bytes `d` of a value, or `None` (counted) when it holds no profile or the
-/// profile has no readable description.
-fn stub_of(d: &[u8], at: usize, tally: &mut Tally) -> Option> {
- if !is_profile(d) {
- tally.kept_short += 1;
- return None;
- }
- let s = description(d).and_then(|t| stub(&t));
- if s.is_none() {
- tally.failed.push(format!("profile at byte {at}: no readable description"));
- }
- s
-}
-
-/// At the start of `t`: the key in quotes, optional white space, a colon, optional white space and
-/// the opening quote of the value. Returns the length up to and including that quote, and the quote.
-fn value_start(t: &[u8]) -> Option<(usize, &'static [u8])> {
- let ws = |mut i: usize| {
- while t.get(i).is_some_and(u8::is_ascii_whitespace) {
- i += 1;
- }
- i
- };
- QUOTES.into_iter().find_map(|q| {
- let key_end = q.len().checked_mul(2)?.checked_add(KEY.len())?;
- let quoted = t.get(..key_end)?;
- if !(quoted.starts_with(q) && quoted.get(q.len()..q.len() + KEY.len())? == KEY && quoted.ends_with(q)) {
- return None;
- }
- let colon = ws(key_end);
- if t.get(colon) != Some(&b':') {
- return None;
- }
- let open = ws(colon + 1);
- t.get(open..)?.starts_with(q).then_some((open + q.len(), q))
- })
-}
-
-fn hex_decode(h: &[u8]) -> Option> {
- if !h.len().is_multiple_of(2) {
- return None;
- }
- h.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).ok()?, 16).ok()).collect()
-}
-
-fn hex_encode(d: &[u8], upper: bool) -> Vec {
- d.iter().flat_map(|b| if upper { format!("{b:02X}") } else { format!("{b:02x}") }.into_bytes()).collect()
-}
-
-/// Replaces, in `text`, every `colorProfileData` JSON value (base64; `\/` escapes, white space
-/// around the colon and quotes written as `"` allowed) and every .aepx `bdata=""`
-/// attribute that holds a raw profile.
-fn stub_text(text: &[u8], tally: &mut Tally) -> Vec {
- const BDATA: &[u8] = b"bdata=\"";
- let (mut out, mut i) = (Vec::with_capacity(text.len()), 0);
- while let Some(&b) = text.get(i) {
- let rest = text.get(i..).unwrap_or_default();
- if let Some((open, q)) = value_start(rest)
- && let Some(len) = rest.get(open..).and_then(|v| v.windows(q.len()).position(|w| w == q))
- {
- let raw = rest.get(open..open + len).unwrap_or_default();
- let unescaped: Vec = raw.iter().copied().filter(|c| *c != b'\\').collect();
- let replaced = match b64_decode(&unescaped) {
- Some(d) => stub_of(&d, i, tally).map(|s| b64_encode(&s)),
- None => {
- tally.failed.push(format!("colorProfileData at byte {i}: not base64"));
- None
- }
- };
- out.extend_from_slice(rest.get(..open).unwrap_or_default());
- match replaced {
- Some(enc) => {
- out.extend_from_slice(&enc);
- tally.stubbed += 1;
- }
- None => out.extend_from_slice(raw),
- }
- i += open + len;
- continue;
- }
- if rest.starts_with(BDATA)
- && let Some(len) = rest.get(BDATA.len()..).and_then(|v| v.iter().position(|c| *c == b'"'))
- {
- let hex = rest.get(BDATA.len()..BDATA.len() + len).unwrap_or_default();
- let replaced = hex_decode(hex).filter(|d| is_profile(d)).and_then(|d| stub_of(&d, i, tally));
- out.extend_from_slice(BDATA);
- match replaced {
- Some(s) => {
- out.extend_from_slice(&hex_encode(&s, hex.iter().any(u8::is_ascii_uppercase)));
- tally.stubbed += 1;
- }
- None => out.extend_from_slice(hex),
- }
- i += BDATA.len() + len;
- continue;
- }
- out.push(b);
- i += 1;
- }
- out
-}
-
-fn walk(chunks: &mut [Chunk<'_>], tally: &mut Tally) {
- for c in chunks.iter_mut() {
- // Only a data chunk is rewritten as one: an opaque `LIST` also has bytes, but replacing it by
- // a data chunk would drop its list type.
- let data = if let Body::Data(d) = &c.body { Some(d.to_vec()) } else { None };
- match data {
- Some(d) if d.windows(KEY.len()).any(|w| w == KEY) => {
- let new = stub_text(&d, tally);
- *c = Chunk::data(c.id, new);
- }
- Some(d) if is_profile(&d) => {
- if let Some(s) = stub_of(&d, 0, tally) {
- *c = Chunk::data(c.id, s);
- tally.stubbed += 1;
- }
- }
- _ => {
- if let Body::List { children, .. } | Body::Chunks(children) = &mut c.body {
- walk(children, tally);
- }
- }
- }
- }
-}
-
-fn main() {
- let args: Vec = std::env::args().collect();
- let (Some(input), Some(output)) = (args.get(1), args.get(2)) else { exit("usage: stubicc ") };
- let bytes = std::fs::read(input).or_exit("read the input");
- let mut tally = Tally::default();
- let out = if input.ends_with(".aepx") {
- stub_text(&bytes, &mut tally)
- } else {
- let mut file = effectcraft_aep::parse(&bytes).or_exit("parse the input");
- walk(&mut file.chunks, &mut tally);
- effectcraft_aep::write(&file).or_exit("write the output")
- };
- if !tally.failed.is_empty() {
- exit(&format!("nothing written: {} profile(s) could not be replaced:\n{}", tally.failed.len(), tally.failed.join("\n")));
- }
- std::fs::write(output, out).or_exit("write the output");
- println!("stubbed {} profile(s); kept {} short value(s) that hold no profile", tally.stubbed, tally.kept_short);
-}
-
-#[cfg(test)]
-#[allow(clippy::unwrap_used)]
-mod tests {
- use super::*;
-
- fn profile(desc: &str) -> Vec {
- // A profile with colour data after the description (like a real one): the stub of `desc`
- // plus 3000 bytes, its size field updated.
- let mut p = stub(desc).unwrap();
- p.extend(std::iter::repeat_n(0x5a, 3000));
- let size = u32::try_from(p.len()).unwrap().to_be_bytes();
- p[..4].copy_from_slice(&size);
- p
- }
-
- fn stubbed(text: &[u8]) -> (Vec, Tally) {
- let mut tally = Tally::default();
- (stub_text(text, &mut tally), tally)
- }
-
- #[test]
- fn json_values_in_every_spelling_are_replaced() {
- let b64 = String::from_utf8(b64_encode(&profile("sRGB IEC61966-2.1"))).unwrap();
- let want = String::from_utf8(b64_encode(&stub("sRGB IEC61966-2.1").unwrap())).unwrap();
- for (before, after) in [
- (format!("{{\"colorProfileData\":\"{b64}\"}}"), format!("{{\"colorProfileData\":\"{want}\"}}")),
- (format!("{{\"colorProfileData\" : \n\t\"{b64}\"}}"), format!("{{\"colorProfileData\" : \n\t\"{want}\"}}")),
- (format!("{{\"colorProfileData\":\"{}\"}}", b64.replace('/', "\\/")), format!("{{\"colorProfileData\":\"{want}\"}}")),
- (format!("{{"colorProfileData": "{b64}"}}"), format!("{{"colorProfileData": "{want}"}}")),
- ] {
- let (out, tally) = stubbed(before.as_bytes());
- assert_eq!(String::from_utf8(out).unwrap(), after);
- assert_eq!((tally.stubbed, tally.failed.len()), (1, 0));
- }
- }
-
- #[test]
- fn a_raw_profile_in_aepx_hex_is_replaced_and_other_hex_kept() {
- let hex = String::from_utf8(hex_encode(&profile("Rec.709 Gamma 2.4"), false)).unwrap();
- let want = String::from_utf8(hex_encode(&stub("Rec.709 Gamma 2.4").unwrap(), false)).unwrap();
- let text = format!("", "ab".repeat(200));
- let (out, tally) = stubbed(text.as_bytes());
- assert_eq!(String::from_utf8(out).unwrap(), format!("", "ab".repeat(200)));
- assert_eq!((tally.stubbed, tally.failed.len()), (1, 0));
- // Upper-case hex stays upper case.
- let (out, _) = stubbed(format!("bdata=\"{}\"", hex.to_uppercase()).as_bytes());
- assert_eq!(String::from_utf8(out).unwrap(), format!("bdata=\"{}\"", want.to_uppercase()));
- }
-
- #[test]
- fn a_profile_that_cannot_be_replaced_is_a_failure_not_a_silent_copy() {
- // An ICC header whose tag table has no `desc`.
- let mut p = profile("x");
- p[132..136].copy_from_slice(b"wtpt");
- let (_, tally) = stubbed(format!("\"colorProfileData\":\"{}\"", String::from_utf8(b64_encode(&p)).unwrap()).as_bytes());
- assert_eq!((tally.stubbed, tally.failed.len()), (0, 1));
- let (_, tally) = stubbed(b"\"colorProfileData\":\"not*base64\"");
- assert_eq!(tally.failed.len(), 1);
- // A short value holds no profile: kept and counted, not a failure.
- let (out, tally) = stubbed(b"\"colorProfileData\":\"AAAA\"");
- assert_eq!((out.as_slice(), tally.kept_short, tally.failed.len()), (&b"\"colorProfileData\":\"AAAA\""[..], 1, 0));
- }
-}
diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs
index b6c3b4490..d9dbcfb14 100644
--- a/crates/aep/src/container.rs
+++ b/crates/aep/src/container.rs
@@ -1,59 +1,7 @@
-//! After Effects project (`.aep`) container reader and writer.
-//!
-//! An `.aep` is a `RIFX` container (big-endian RIFF) with form type `Egg!`. This crate reads it
-//! into a lossless chunk tree ([`AepFile`]) and writes it back byte for byte. The XML form of a
-//! project (`.aepx`) is read into the same tree by [`read_aepx`]; [`parse_any`] picks the reader
-//! by content. See
-//! `docs/aep-format.md` for the observed format and how it was established (clean room: only
-//! files we generated ourselves, one change per file).
-//!
-//! [`Project::new`] opens a typed, read-only view over the tree: items, compositions
-//! ([`CompHeader`]), layers ([`LayerHeader`], [`Transform`], effects, masks, text documents,
-//! markers), properties, keyframes and footage. Only fields whose layout was verified against the
-//! values the test files were made with are decoded; every accessor returns `None` (or an empty
-//! list) on damaged data instead of failing.
+//! The `.aep` / `.aepx` container and its typed view come from `craft-aep`; only the tables that map
+//! After Effects effects and layer styles onto EffectCraft's are kept here.
-mod aepx;
-mod arbitrary;
-mod bytes;
-mod camera;
-mod chunk;
pub mod effect_map;
-mod effects;
-mod error;
-mod footage;
-mod header;
-mod keys;
-mod masks;
-mod matte;
-mod read;
-mod settings;
-mod shapes;
pub mod style_map;
-mod textdoc;
-mod typed;
-mod values;
-mod version;
-mod write;
-pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
-pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut};
-pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
-pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
-pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
-pub use error::AepError;
-pub use footage::{Footage, SequenceInfo};
-pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer};
-pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey};
-pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath};
-pub use matte::{MatteMode, TrackMatte};
-pub use read::{Limits, parse, parse_with_limits};
-pub use settings::{ProjectSettings, TimeDisplay, WorkingSpace};
-pub use shapes::{GradientType, PolystarType, ShapeDetail, ShapeGroup, ShapeItem, Stroke, StrokeCap, StrokeJoin};
-pub use textdoc::{
- AutoKern, Caps, CueType, FontBaseline, Justification, KernRun, Marker, ParagraphRun, StyleRun, TextBox, TextDocument, TextValue, parse_text_dict,
-};
-pub use typed::{Comp, Item, ItemKind, KeyLayout, KeyList, Keyframe, Layer, ListHeader, Project, PropEntry, PropGroup, PropNode, Property, TimeBase};
-pub use values::{Orientation, OrientationKey, TimeRemap, TimeRemapKey, Tracked, Transform, ValueSource};
-pub use version::AeVersion;
-pub use write::{write, write_to};
+pub use craft_aep::*;
diff --git a/crates/aep/src/container/aepx.rs b/crates/aep/src/container/aepx.rs
deleted file mode 100644
index a45cbae1d..000000000
--- a/crates/aep/src/container/aepx.rs
+++ /dev/null
@@ -1,640 +0,0 @@
-//! Reading the XML form of a project (`.aepx`) into the same chunk tree as an `.aep`.
-//!
-//! An `.aepx` is the `RIFX` chunk tree written as XML: one element per chunk, named by the chunk
-//! id (or the list type of a `LIST`), with the body as hex in a `bdata` attribute. A few chunks
-//! are written as text instead (`Utf8` as ``, the numbers `numS` and `ppSn`, the footage
-//! alias `alas` as ``), and the XMP packet that follows the container of an `.aep`
-//! is the element ``. The mapping, with the cases it was found on, is in
-//! `docs/aep-format.md` (section 1.1).
-//!
-//! The reader is a small XML tokenizer for exactly that subset: elements, attributes, text, the
-//! five predefined entities and character references, CDATA, comments and processing
-//! instructions. A document type declaration is refused (no entity expansion). It runs without
-//! recursion; the nesting depth and the element count are bounded by [`Limits`], and everything
-//! it allocates is bounded by the size of the input.
-
-use std::borrow::Cow;
-
-use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
-use crate::container::error::AepError;
-use crate::container::read::{Limits, parse};
-
-/// The root element of an `.aepx`.
-const ROOT: &[u8] = b"AfterEffectsProject";
-/// The element that holds the XMP packet (the trailer of an `.aep`).
-const XMP: &[u8] = b"ProjectXMPMetadata";
-/// The element of a `Utf8` chunk.
-const STRING: &[u8] = b"string";
-/// The element of an `alas` chunk (footage alias), written as attributes instead of JSON.
-const FILE_REFERENCE: &[u8] = b"fileReference";
-
-/// List types whose body is not a chunk sequence: the `.aepx` writes them like data chunks (with
-/// `bdata`), the `.aep` as an opaque `LIST`.
-const OPAQUE_LIST_TYPES: [[u8; 4]; 1] = [*b"btdk"];
-
-/// True when `data` looks like an `.aepx`: optional UTF-8 byte order mark and white space, then
-/// an XML declaration or the `AfterEffectsProject` root element.
-pub fn is_aepx(data: &[u8]) -> bool {
- let rest = data.strip_prefix(&[0xef, 0xbb, 0xbf][..]).unwrap_or(data);
- let start = rest.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rest.len());
- let rest = rest.get(start..).unwrap_or(&[]);
- rest.starts_with(b" Result, AepError> {
- if is_aepx(data) { read_aepx(data) } else { parse(data) }
-}
-
-/// Reads an `.aepx` with the default [`Limits`] into the chunk tree an `.aep` of the same project
-/// gives.
-///
-/// # Errors
-/// [`AepError::Xml`] for input that is not well-formed XML of the `.aepx` subset (bad UTF-8, an
-/// unknown entity, an unclosed or mismatched tag, invalid or odd-length hex, a number that does
-/// not parse, a document type declaration, a root element other than `AfterEffectsProject`);
-/// [`AepError::TooDeep`] / [`AepError::TooManyChunks`] beyond the limits.
-pub fn read_aepx(data: &[u8]) -> Result, AepError> {
- read_aepx_with_limits(data, Limits::default())
-}
-
-/// Like [`read_aepx`] with explicit [`Limits`]: `max_depth` bounds the element nesting (the root
-/// element's children are depth 1), `max_chunks` the number of elements.
-///
-/// # Errors
-/// See [`read_aepx`].
-pub fn read_aepx_with_limits(data: &[u8], limits: Limits) -> Result, AepError> {
- let text = match std::str::from_utf8(data) {
- Ok(t) => t.as_bytes(),
- Err(e) => return Err(xml(e.valid_up_to(), "the file is not valid UTF-8")),
- };
- Reader { data: text, pos: 0, limits, count: 0, stack: Vec::new(), top: None, trailer: None }.run()
-}
-
-fn xml(offset: usize, what: &'static str) -> AepError {
- AepError::Xml { offset, what }
-}
-
-/// What an open element becomes when it closes.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Kind {
- /// The root element: its children are the top-level chunks.
- Root,
- /// A chunk with this id (or a `LIST` with this list type).
- Chunk([u8; 4]),
- /// A `Utf8` chunk.
- Utf8,
- /// An `alas` chunk built from the attributes.
- Alias,
- /// The XMP packet (only directly below the root).
- Xmp,
- /// An element without a chunk counterpart (a name that is no chunk id); skipped with its content.
- Skip,
-}
-
-struct Frame {
- kind: Kind,
- /// Offset of the start tag (for errors).
- at: usize,
- /// The name as written, to match the end tag.
- name: (usize, usize),
- /// The decoded `bdata` attribute.
- bdata: Option>,
- /// The attributes of a `fileReference`, in document order.
- attrs: Vec<(String, String)>,
- /// Collected text (only for `string` and the XMP packet).
- text: Vec,
- children: Vec>,
-}
-
-struct Reader<'d> {
- data: &'d [u8],
- pos: usize,
- limits: Limits,
- count: usize,
- stack: Vec ,
- /// The top-level chunks once the root element has closed.
- top: Option>>,
- trailer: Option>,
-}
-
-impl Reader<'_> {
- fn run(mut self) -> Result, AepError> {
- if self.data.starts_with(&[0xef, 0xbb, 0xbf]) {
- self.pos = 3;
- }
- while self.pos < self.data.len() {
- if self.rest().starts_with(b"<") {
- self.markup()?;
- } else {
- self.text()?;
- }
- }
- if let Some(open) = self.stack.last() {
- return Err(xml(open.at, "an element is not closed before the end of the file"));
- }
- let chunks = self.top.ok_or_else(|| xml(self.data.len(), "no AfterEffectsProject root element"))?;
- Ok(AepFile { chunks, trailer: Cow::Owned(self.trailer.unwrap_or_default()) })
- }
-
- fn rest(&self) -> &[u8] {
- self.data.get(self.pos..).unwrap_or(&[])
- }
-
- /// The offset of `needle` at or after `from`.
- fn find(&self, from: usize, needle: &[u8]) -> Option {
- let hay = self.data.get(from..)?;
- if needle.is_empty() || hay.len() < needle.len() {
- return None;
- }
- let first = *needle.first()?;
- let mut i = 0;
- while let Some(p) = hay.get(i..)?.iter().position(|b| *b == first) {
- let at = i.checked_add(p)?;
- if hay.get(at..)?.starts_with(needle) {
- return from.checked_add(at);
- }
- i = at.checked_add(1)?;
- }
- None
- }
-
- /// Skips to just after `close`, which must follow.
- fn skip_past(&mut self, close: &[u8], what: &'static str) -> Result {
- let at = self.find(self.pos, close).ok_or(xml(self.pos, what))?;
- self.pos = at.saturating_add(close.len());
- Ok(at)
- }
-
- fn markup(&mut self) -> Result<(), AepError> {
- let rest = self.rest();
- if rest.starts_with(b"") {
- self.skip_past(b"?>", "a processing instruction is not closed")?;
- } else if rest.starts_with(b"", "a comment is not closed")?;
- } else if rest.starts_with(b"", "a CDATA section is not closed")?;
- let content = self.data.get(start..end).unwrap_or(&[]);
- let at = start;
- match self.stack.last_mut() {
- Some(frame) => {
- if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) {
- frame.text.extend_from_slice(content);
- }
- }
- None => return Err(xml(at, "CDATA outside the root element")),
- }
- } else if rest.starts_with(b" bool {
- !(b.is_ascii_whitespace() || matches!(b, b'<' | b'>' | b'/' | b'=' | b'"' | b'\'' | b'&'))
- }
-
- fn name(&mut self) -> Result<(usize, usize), AepError> {
- let start = self.pos;
- let len = self.rest().iter().position(|b| !Self::is_name_byte(*b)).unwrap_or(self.rest().len());
- if len == 0 {
- return Err(xml(start, "a tag or attribute has no name"));
- }
- self.pos = start.saturating_add(len);
- Ok((start, self.pos))
- }
-
- fn skip_ws(&mut self) {
- let n = self.rest().iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(self.rest().len());
- self.pos = self.pos.saturating_add(n);
- }
-
- fn slice(&self, (a, b): (usize, usize)) -> &[u8] {
- self.data.get(a..b).unwrap_or(&[])
- }
-
- fn start_tag(&mut self) -> Result<(), AepError> {
- let at = self.pos;
- self.pos = self.pos.saturating_add(1);
- let name = self.name()?;
- let mut attrs: Vec<((usize, usize), Vec)> = Vec::new();
- let self_closing = loop {
- self.skip_ws();
- let rest = self.rest();
- if rest.starts_with(b"/>") {
- self.pos = self.pos.saturating_add(2);
- break true;
- }
- if rest.starts_with(b">") {
- self.pos = self.pos.saturating_add(1);
- break false;
- }
- if rest.is_empty() {
- return Err(xml(at, "a start tag is not closed"));
- }
- let key = self.name()?;
- self.skip_ws();
- if !self.rest().starts_with(b"=") {
- return Err(xml(self.pos, "an attribute has no value"));
- }
- self.pos = self.pos.saturating_add(1);
- self.skip_ws();
- let quote = *self.rest().first().ok_or(xml(self.pos, "a start tag is not closed"))?;
- if quote != b'"' && quote != b'\'' {
- return Err(xml(self.pos, "an attribute value is not quoted"));
- }
- let vstart = self.pos.saturating_add(1);
- let vend = self.find(vstart, &[quote]).ok_or(xml(vstart, "an attribute value is not closed"))?;
- let raw = self.data.get(vstart..vend).unwrap_or(&[]);
- if raw.contains(&b'<') {
- return Err(xml(vstart, "`<` inside an attribute value"));
- }
- let mut value = Vec::new();
- decode_entities(raw, vstart, &mut value)?;
- self.pos = vend.saturating_add(1);
- attrs.push((key, value));
- };
- let kind = self.kind_of(name, at)?;
- if kind != Kind::Root {
- self.count = self.count.saturating_add(1);
- if self.count > self.limits.max_chunks {
- return Err(AepError::TooManyChunks);
- }
- // The root's children are depth 1, like the top-level chunks of an `.aep`.
- if self.stack.len() > self.limits.max_depth {
- return Err(AepError::TooDeep);
- }
- }
- let mut frame = Frame { kind, at, name, bdata: None, attrs: Vec::new(), text: Vec::new(), children: Vec::new() };
- if matches!(kind, Kind::Chunk(_)) {
- if let Some((key, value)) = attrs.iter().find(|(k, _)| self.slice(*k) == b"bdata") {
- frame.bdata = Some(decode_hex(value, key.1)?);
- }
- } else if kind == Kind::Alias {
- frame.attrs = attrs.iter().map(|(k, v)| (String::from_utf8_lossy(self.slice(*k)).into_owned(), String::from_utf8_lossy(v).into_owned())).collect();
- }
- self.stack.push(frame);
- if self_closing {
- self.close()?;
- }
- Ok(())
- }
-
- /// Classifies an element by its name and where it is.
- fn kind_of(&self, name: (usize, usize), at: usize) -> Result {
- let n = self.slice(name);
- let parent = match self.stack.last() {
- None => {
- if self.top.is_some() {
- return Err(xml(at, "a second root element"));
- }
- if n != ROOT {
- return Err(xml(at, "the root element is not AfterEffectsProject"));
- }
- return Ok(Kind::Root);
- }
- Some(p) => p.kind,
- };
- Ok(match parent {
- Kind::Skip | Kind::Utf8 | Kind::Xmp | Kind::Alias => Kind::Skip,
- Kind::Root | Kind::Chunk(_) => {
- if n == STRING {
- Kind::Utf8
- } else if n == FILE_REFERENCE {
- Kind::Alias
- } else if n == XMP && parent == Kind::Root {
- Kind::Xmp
- } else if let Some(id) = chunk_id(n) {
- Kind::Chunk(id)
- } else {
- Kind::Skip
- }
- }
- })
- }
-
- fn end_tag(&mut self) -> Result<(), AepError> {
- let at = self.pos;
- self.pos = self.pos.saturating_add(2);
- let name = self.name()?;
- self.skip_ws();
- if !self.rest().starts_with(b">") {
- return Err(xml(at, "an end tag is not closed"));
- }
- self.pos = self.pos.saturating_add(1);
- let open = self.stack.last().ok_or(xml(at, "an end tag without a start tag"))?;
- if self.slice(open.name) != self.slice(name) {
- return Err(xml(at, "an end tag does not match its start tag"));
- }
- self.close()
- }
-
- /// Closes the innermost element and hands its chunk to the parent.
- fn close(&mut self) -> Result<(), AepError> {
- let frame = self.stack.pop().ok_or(xml(self.pos, "an end tag without a start tag"))?;
- let chunk = match frame.kind {
- Kind::Root => {
- self.top = Some(frame.children);
- return Ok(());
- }
- Kind::Skip => return Ok(()),
- Kind::Xmp => {
- if self.trailer.is_none() {
- self.trailer = Some(frame.text);
- }
- return Ok(());
- }
- Kind::Utf8 => Chunk::data(*b"Utf8", frame.text),
- Kind::Alias => Chunk::data(*b"alas", alias_json(&frame.attrs).into_bytes()),
- Kind::Chunk(id) => chunk_of(id, frame)?,
- };
- if let Some(parent) = self.stack.last_mut() {
- parent.children.push(chunk);
- }
- Ok(())
- }
-
- fn text(&mut self) -> Result<(), AepError> {
- let start = self.pos;
- let len = self.rest().iter().position(|b| *b == b'<').unwrap_or(self.rest().len());
- let end = start.saturating_add(len);
- self.pos = end;
- let raw = self.data.get(start..end).unwrap_or(&[]);
- match self.stack.last_mut() {
- Some(frame) if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) => decode_entities(raw, start, &mut frame.text),
- Some(_) => Ok(()),
- None if raw.iter().all(u8::is_ascii_whitespace) => Ok(()),
- None => Err(xml(start, "text outside the root element")),
- }
- }
-}
-
-/// The chunk id of an element name: 1 to 4 printable ASCII bytes, padded with spaces (`Pin` is
-/// the list type `Pin `).
-fn chunk_id(name: &[u8]) -> Option<[u8; 4]> {
- if name.is_empty() || name.len() > 4 || !name.iter().all(|b| b.is_ascii_graphic()) {
- return None;
- }
- let mut id = [b' '; 4];
- for (d, s) in id.iter_mut().zip(name) {
- *d = *s;
- }
- Some(id)
-}
-
-/// The chunk of a closed element named by a chunk id.
-fn chunk_of(id: [u8; 4], frame: Frame) -> Result, AepError> {
- if let Some(bytes) = frame.bdata {
- if OPAQUE_LIST_TYPES.contains(&id) {
- let pad = if bytes.len() % 2 == 1 { Some(0) } else { None };
- return Ok(Chunk { id: LIST, body: Body::OpaqueList { list_type: id, data: Cow::Owned(bytes) }, pad });
- }
- return Ok(Chunk::data(id, bytes));
- }
- // Numbers written as text: `0 ` and `150.000000000 `.
- if &id == b"numS" || &id == b"ppSn" {
- let text = match frame.children.as_slice() {
- [only] if only.is(b"Utf8") => only.bytes().unwrap_or(&[]),
- _ => return Err(xml(frame.at, "a numS or ppSn element without its number")),
- };
- let text = std::str::from_utf8(text).map_err(|_| xml(frame.at, "a number is not text"))?.trim();
- let body = if &id == b"numS" {
- // Only `0` is sampled, so whether the field is signed is open: accept both readings of 4 bytes.
- let n = text.parse::().ok().filter(|n| (i64::from(i32::MIN)..=i64::from(u32::MAX)).contains(n));
- let n = n.ok_or(xml(frame.at, "numS is not a 32-bit integer"))?;
- (n as u32).to_be_bytes().to_vec()
- } else {
- text.parse::().map_err(|_| xml(frame.at, "ppSn is not a number"))?.to_be_bytes().to_vec()
- };
- return Ok(Chunk::data(id, body));
- }
- if NESTED_IDS.contains(&id) {
- if frame.children.is_empty() {
- return Ok(Chunk::data(id, Vec::new()));
- }
- return Ok(Chunk { id, body: Body::Chunks(frame.children), pad: None });
- }
- Ok(Chunk::list(id, frame.children))
-}
-
-/// Appends `raw` with the predefined entities and character references decoded.
-fn decode_entities(raw: &[u8], offset: usize, out: &mut Vec) -> Result<(), AepError> {
- let mut i = 0;
- while let Some(rest) = raw.get(i..) {
- let Some(amp) = rest.iter().position(|b| *b == b'&') else {
- out.extend_from_slice(rest);
- break;
- };
- out.extend_from_slice(rest.get(..amp).unwrap_or(&[]));
- let at = offset.saturating_add(i).saturating_add(amp);
- let after = rest.get(amp.saturating_add(1)..).unwrap_or(&[]);
- let semi = after.iter().take(12).position(|b| *b == b';').ok_or(xml(at, "an entity is not terminated"))?;
- let name = after.get(..semi).unwrap_or(&[]);
- let ch = match name {
- b"amp" => '&',
- b"lt" => '<',
- b"gt" => '>',
- b"quot" => '"',
- b"apos" => '\'',
- _ => {
- let code = if let Some(hex) = name.strip_prefix(b"#x").or_else(|| name.strip_prefix(b"#X")) {
- std::str::from_utf8(hex).ok().and_then(|h| u32::from_str_radix(h, 16).ok())
- } else if let Some(dec) = name.strip_prefix(b"#") {
- std::str::from_utf8(dec).ok().and_then(|d| d.parse::().ok())
- } else {
- return Err(xml(at, "an unknown entity"));
- };
- code.filter(|c| *c != 0).and_then(char::from_u32).ok_or(xml(at, "an invalid character reference"))?
- }
- };
- let mut buf = [0u8; 4];
- out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
- i = i.saturating_add(amp).saturating_add(semi).saturating_add(2);
- }
- Ok(())
-}
-
-/// Decodes a `bdata` value (hex digits, two per byte).
-fn decode_hex(text: &[u8], offset: usize) -> Result, AepError> {
- if text.len() % 2 == 1 {
- return Err(xml(offset, "bdata has an odd number of hex digits"));
- }
- let digit = |b: u8| match b {
- b'0'..=b'9' => Some(b - b'0'),
- b'a'..=b'f' => Some(b - b'a' + 10),
- b'A'..=b'F' => Some(b - b'A' + 10),
- _ => None,
- };
- let (pairs, _) = text.as_chunks::<2>();
- pairs
- .iter()
- .map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l))
- .collect::>>()
- .ok_or(xml(offset, "bdata holds a character that is not a hex digit"))
-}
-
-/// The JSON body of an `alas` chunk from the attributes of a `fileReference`, keys in document
-/// order (alphabetical in every sample, as in the `.aep`): the two `ascendcount_*` values as
-/// numbers, `platform` `Win` as `1`, `target_is_folder` `0` / `1` as `false` / `true`, everything
-/// else as a string.
-fn alias_json(attrs: &[(String, String)]) -> String {
- let mut out = String::from("{");
- for (i, (key, value)) in attrs.iter().enumerate() {
- if i > 0 {
- out.push(',');
- }
- json_str(&mut out, key);
- out.push(':');
- let number = value.parse::().ok();
- match (key.as_str(), value.as_str()) {
- ("ascendcount_base" | "ascendcount_target", _) if number.is_some() => out.push_str(value),
- ("platform", "Win") => out.push('1'),
- ("platform", _) if number.is_some() => out.push_str(value),
- ("target_is_folder", "0" | "false") => out.push_str("false"),
- ("target_is_folder", "1" | "true") => out.push_str("true"),
- _ => json_str(&mut out, value),
- }
- }
- out.push('}');
- out
-}
-
-fn json_str(out: &mut String, s: &str) {
- out.push('"');
- for c in s.chars() {
- match c {
- '"' => out.push_str("\\\""),
- '\\' => out.push_str("\\\\"),
- '\n' => out.push_str("\\n"),
- '\r' => out.push_str("\\r"),
- '\t' => out.push_str("\\t"),
- c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))),
- c => out.push(c),
- }
- }
- out.push('"');
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn doc(body: &str) -> String {
- format!("\n{body} \n")
- }
-
- #[test]
- fn maps_elements_to_chunks() {
- let x = doc(concat!(
- "\n ",
- "A & BA -3 ",
- "150.000000000 x ",
- "]]> "
- ));
- let f = read_aepx(x.as_bytes()).unwrap();
- assert_eq!(f.trailer.as_ref(), b"");
- let ids: Vec<_> = f.chunks.iter().map(|c| (c.id, c.list_type())).collect();
- assert_eq!(
- ids,
- vec![
- (*b"head", None),
- (LIST, Some(*b"Pin ")),
- (LIST, Some(*b"Pefl")),
- (*b"tdsn", None),
- (*b"numS", None),
- (*b"ppSn", None),
- (LIST, Some(*b"btdk"))
- ]
- );
- assert_eq!(f.chunks[1].children()[0].bytes(), Some(&[1u8][..]));
- assert_eq!(f.chunks[1].children()[0].pad, Some(0));
- assert_eq!(f.chunks[3].children()[0].bytes(), Some(&b"A & BA"[..]));
- assert_eq!(f.chunks[4].bytes(), Some(&(-3i32).to_be_bytes()[..]));
- assert_eq!(f.chunks[5].bytes(), Some(&150f64.to_be_bytes()[..]));
- assert_eq!(f.chunks[6].bytes(), Some(&b"ABC"[..]));
- // The tree writes as a valid `.aep` that parses back to itself.
- let bin = crate::container::write(&f).unwrap();
- assert_eq!(parse(&bin).unwrap(), f);
- }
-
- #[test]
- fn file_reference_becomes_alias_json() {
- let x = doc(concat!(
- " "
- ));
- let f = read_aepx(x.as_bytes()).unwrap();
- let alas = f.chunks[0].children()[0].bytes().unwrap();
- assert_eq!(
- std::str::from_utf8(alas).unwrap(),
- r#"{"ascendcount_base":1,"ascendcount_target":1,"fullpath":"C:\\a\\\"b.png","platform":1,"server_name":"N","server_volume_name":"V","target_is_folder":false}"#
- );
- }
-
- #[test]
- fn nums_takes_signed_and_unsigned_32_bit_values() {
- for (text, bytes) in [("-2147483648", [0x80, 0, 0, 0]), ("4294967295", [0xff; 4]), (" 7 ", [0, 0, 0, 7])] {
- let f = read_aepx(doc(&format!("{text} ")).as_bytes()).unwrap();
- assert_eq!(f.chunks[0].bytes(), Some(&bytes[..]), "{text}");
- }
- for text in ["-2147483649", "4294967296", "1.5"] {
- assert!(read_aepx(doc(&format!("{text} ")).as_bytes()).is_err(), "{text}");
- }
- }
-
- #[test]
- fn detection() {
- assert!(is_aepx(b"\xef\xbb\xbf "));
- assert!(is_aepx(b""));
- assert!(!is_aepx(b"RIFX\0\0\0\x04Egg!"));
- assert!(parse_any(b"RIFX\0\0\0\x04Egg!").is_ok());
- assert!(parse_any(doc("").as_bytes()).is_ok());
- }
-
- #[test]
- fn bad_input_is_an_error() {
- let bad = [
- doc(""),
- doc(" "),
- doc(" "),
- doc(" "),
- doc("& "),
- doc(" "),
- doc(" "),
- doc("x "),
- doc(" "),
- doc(" "),
- doc(" "),
- "]> ".into(),
- " ".into(),
- "".into(),
- " ".into(),
- " junk".into(),
- " ]]> "), 2);
assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3);
}
+
+/// A hostile project: footage in a folder whose parents loop back to it. Export walked the
+/// folder's parents recursively and never reached the cache insert, so the stack ran out.
+#[test]
+fn export_survives_a_folder_parent_cycle() {
+ let (mut p, cid) = ae_project();
+ let a = p.add_item("A", effectcraft_color::Label::None, None, ItemKind::Folder);
+ let b = p.add_item("B", effectcraft_color::Label::None, Some(a), ItemKind::Folder);
+ p.items.get_mut(&a).unwrap().parent = Some(b);
+ let footage: Vec = p.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.id).collect();
+ for f in footage {
+ p.items.get_mut(&f).unwrap().parent = Some(a);
+ }
+ let out = export(&p, cid, &ExportOptions::default(), &HashMap::new()).unwrap();
+ assert!(out.warnings.iter().any(|w| w.contains("contains itself")), "{:?}", out.warnings);
+}
+
+/// Precomps that use each other, and a chain of comps that each use the next one twice: the
+/// audio check recursed forever on the first and 2^depth times on the second.
+#[test]
+fn export_audio_check_ends_on_precomp_cycles_and_shared_chains() {
+ let mut p = Project::default();
+ let a = footage_item(&mut p, "/media/a.mov");
+ let comp = || Comp::new(W, H, FrameRate::new(25, 1), t(2.0));
+ // A chain of 60 comps, each using the next twice; the last one is silent footage.
+ let mut next = {
+ let mut c = comp();
+ let l = effectcraft_project::build::layer(&mut p, &c, "a", LayerSource::Footage { item: a }, (W, H), None);
+ c.layers.push(l);
+ comp_item(&mut p, "Leaf", c)
+ };
+ for i in 0..60 {
+ let mut c = comp();
+ for n in 0..2 {
+ let l = effectcraft_project::build::layer(&mut p, &c, &format!("use {n}"), LayerSource::Comp { item: next }, (W, H), None);
+ c.layers.push(l);
+ }
+ next = comp_item(&mut p, &format!("Chain {i}"), c);
+ }
+ let started = std::time::Instant::now();
+ export(&p, next, &ExportOptions::default(), &HashMap::new()).unwrap();
+ assert!(started.elapsed() < std::time::Duration::from_secs(30), "{:?}", started.elapsed());
+ // Two comps nesting each other.
+ let x = comp_item(&mut p, "X", comp());
+ let mut y = comp();
+ let l = effectcraft_project::build::layer(&mut p, &y, "x", LayerSource::Comp { item: x }, (W, H), None);
+ y.layers.push(l);
+ let y = comp_item(&mut p, "Y", y);
+ let mut xc = comp();
+ let l = effectcraft_project::build::layer(&mut p, &xc, "y", LayerSource::Comp { item: y }, (W, H), None);
+ xc.layers.push(l);
+ if let Some(ItemKind::Comp(c)) = p.items.get_mut(&x).map(|i| &mut i.kind) {
+ *c = Arc::new(xc);
+ }
+ export(&p, x, &ExportOptions::default(), &HashMap::new()).unwrap();
+}
+
+/// A flat edit with many clips and many transitions: each clip finds its own transitions (it
+/// scanned every transition of the track per clip), and they still extend and fade it.
+#[test]
+fn import_finds_each_clips_transitions_without_scanning_the_track() {
+ let mut p = fp::Project::new("Flat");
+ let a = media(&mut p, "a.png", "/media/a.mov", filmcraft_media::MediaKind::Movie, true, false, None);
+ let settings = fp::SequenceSettings { width: W, height: H, frame_rate: rate(), ..Default::default() };
+ let main = p.new_sequence("Main", settings, 1, 0, None);
+ const N: usize = 500;
+ let mut items = Vec::with_capacity(N);
+ let mut transitions = Vec::with_capacity(N);
+ for i in 0..N {
+ let ti = item(&mut p, a, fp::TrackKind::Video, i as f64 * 0.4, 0.0, 0.4);
+ if let Some(prev) = items.last().map(|x: &fp::TrackItem| x.id) {
+ transitions.push(fp::Transition {
+ id: fp::TransitionId(p.alloc_id()),
+ effect: fp::find_effect("cross_dissolve").unwrap().instance(),
+ start: secs(i as f64 * 0.4 - 0.08),
+ duration: secs(0.16),
+ from: Some(prev),
+ to: Some(ti.id),
+ align: fp::TransitionAlign::CenterAtCut,
+ reverse: false,
+ });
+ }
+ items.push(ti);
+ }
+ let seq = p.sequence_mut(main).unwrap();
+ seq.video_tracks[0].items = items;
+ seq.video_tracks[0].transitions = transitions;
+ let (bytes, _) = fc::export(&p, main, fc::Format::Otio, &Default::default()).unwrap();
+ let (q, r) = import_doc(&bytes, "Flat.otio", &mut probe);
+ let m = q.comp(r.comps[0]).unwrap();
+ assert_eq!(m.layers.len(), N);
+ // A middle clip reaches over both of its transitions' handles.
+ let mid = m.layers.iter().find(|l| near(l.in_point, t(100.0 - 0.08))).expect("clip 250 extended into its incoming transition");
+ assert!(near(mid.out_point, t(100.4 + 0.08)), "{}", mid.out_point.seconds());
+}
diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs
index 88f9f3249..68823b76b 100644
--- a/crates/keyframe/src/tests.rs
+++ b/crates/keyframe/src/tests.rs
@@ -165,6 +165,27 @@ fn gradient_tracks_sample_separately() {
assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0));
}
+/// A gradient's stop count multiplies every painted pixel's sampling: plain JSON with more than
+/// `MAX_GRADIENT_STOPS` is refused, and a longer stored list is sampled through its first ones.
+#[test]
+fn gradient_stop_count_is_bounded() {
+ use crate::value::MAX_GRADIENT_STOPS;
+ let g = Value::Gradient(Gradient::default());
+ let many: Vec = (0..=MAX_GRADIENT_STOPS).map(|_| serde_json::json!("#ff0000")).collect();
+ assert_eq!(g.coerce_json(&serde_json::Value::Array(many.clone())), None);
+ assert!(g.coerce_json(&serde_json::Value::Array(many[..MAX_GRADIENT_STOPS].to_vec())).is_some());
+ let ops: Vec = (0..=MAX_GRADIENT_STOPS).map(|i| serde_json::json!([i as f64 / MAX_GRADIENT_STOPS as f64, 1.0])).collect();
+ assert_eq!(g.coerce_json(&serde_json::json!({"colors": ["#000000", "#ffffff"], "opacities": ops})), None);
+ // The tagged (typed) form is bounded the same way.
+ let tagged = Value::Gradient(Gradient { colors: (0..=MAX_GRADIENT_STOPS).map(|i| (i as f64, [0.0; 4])).collect(), opacities: vec![(0.0, 1.0)] });
+ assert_eq!(g.coerce_json(&tagged.to_json()), None);
+ // A stored gradient with a huge tail: stops past the limit don't take part in sampling.
+ let mut colors: Vec<(f64, [f32; 4])> = (0..MAX_GRADIENT_STOPS).map(|i| (i as f64 / MAX_GRADIENT_STOPS as f64 * 0.5, [0.0, 0.0, 0.0, 1.0])).collect();
+ colors.extend((0..100_000).map(|_| (1.0, [1.0, 1.0, 1.0, 1.0])));
+ let big = Gradient { colors, opacities: vec![(0.0, 1.0)] };
+ assert_eq!(big.sample(1.0), [0.0, 0.0, 0.0, 1.0]);
+}
+
/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser
/// rejects) and each stop's RGBA come back exactly.
#[test]
diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs
index 96a8b6be3..e8fcb52be 100644
--- a/crates/keyframe/src/text_doc.rs
+++ b/crates/keyframe/src/text_doc.rs
@@ -85,13 +85,13 @@ impl Justify {
}
pub fn parse(s: &str) -> Option {
Some(match s.to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() {
- "left" | "leftjustify" => Justify::Left,
- "center" | "centre" | "centerjustify" => Justify::Center,
- "right" | "rightjustify" => Justify::Right,
+ "left" | "leftjustify" | "alignleft" => Justify::Left,
+ "center" | "centre" | "centerjustify" | "aligncenter" => Justify::Center,
+ "right" | "rightjustify" | "alignright" => Justify::Right,
"justify" | "justifyleft" | "justifylastleft" | "fulljustifylastlineleft" => Justify::JustifyLastLeft,
"justifycenter" | "justifylastcenter" | "fulljustifylastlinecenter" => Justify::JustifyLastCenter,
"justifyright" | "justifylastright" | "fulljustifylastlineright" => Justify::JustifyLastRight,
- "justifyall" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll,
+ "justifyall" | "justifyfull" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll,
_ => return None,
})
}
@@ -977,7 +977,19 @@ impl TextDoc {
if r.start >= r.end && n > 0 {
return Ok(true);
}
- self.apply_style(r.start..r.end.max(r.start + usize::from(n == 0)), |s| {
+ // Empty text: apply_style ignores the bounds, so a fixed range avoids `start + 1` overflow.
+ let range = if n == 0 { 0..1 } else { r.start..r.end };
+ // Check every affected run first, so a value one run refuses changes none of them.
+ let mut err = None;
+ self.apply_style(range.clone(), |s| {
+ if let Err(e) = apply_char_attr(&mut s.clone(), key, v) {
+ err.get_or_insert(e);
+ }
+ });
+ if let Some(e) = err {
+ return Err(e);
+ }
+ self.apply_style(range, |s| {
let _ = apply_char_attr(s, key, v);
});
return Ok(true);
@@ -1057,6 +1069,11 @@ pub const CHAR_ATTRS: &[&str] = &[
pub const PARA_ATTRS: &[&str] =
&["justify", "indentLeft", "indentRight", "indentFirst", "spaceBefore", "spaceAfter", "direction", "composer", "hangingPunctuation"];
+/// Most variable font axes one style can set. Real fonts have a handful (OpenType registers
+/// five; custom axes rarely bring the total past twenty); a `variations` object with more is
+/// rejected before any of it is applied.
+pub const MAX_VARIATION_AXES: usize = 64;
+
/// Set one character attribute; Ok(false) for keys that aren't character attributes.
pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result {
match key {
@@ -1173,20 +1190,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result match v {
J::Null => s.variations.clear(),
J::Object(m) => {
- if m.is_empty() {
- s.variations.clear();
+ if m.len() > MAX_VARIATION_AXES {
+ return Err(format!("variations: at most {MAX_VARIATION_AXES} axes"));
}
+ // One keyed merge (the result sorted by tag), applied only when every entry is valid.
+ let mut axes: std::collections::BTreeMap = if m.is_empty() { Default::default() } else { s.variations.iter().cloned().collect() };
for (tag, val) in m {
if tag.is_empty() || tag.len() > 4 {
return Err(format!("variations: `{tag}` is not an axis tag"));
}
- s.variations.retain(|(t, _)| t != tag);
match val {
- J::Null => {}
- _ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)),
+ J::Null => {
+ axes.remove(tag);
+ }
+ _ => {
+ axes.insert(tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32);
+ }
}
}
- s.variations.sort_by(|a, b| a.0.cmp(&b.0));
+ if axes.len() > MAX_VARIATION_AXES {
+ return Err(format!("variations: at most {MAX_VARIATION_AXES} axes"));
+ }
+ s.variations = axes.into_iter().collect();
}
_ => return Err("variations: expected {tag: number, …}".into()),
},
@@ -1321,6 +1346,44 @@ pub fn word_at(text: &str, ci: usize) -> Range {
mod tests {
use super::*;
+ #[test]
+ fn variations_merge_by_tag_and_reject_too_many_axes() {
+ let mut doc = TextDoc::plain("ab");
+ assert!(doc.set_attr("variations", &serde_json::json!({"wght": 650, "wdth": 90}), None).unwrap());
+ assert!(doc.set_attr("variations", &serde_json::json!({"wght": 700, "wdth": null, "opsz": 12}), None).unwrap());
+ assert_eq!(doc.style_at(0).variations, vec![("opsz".to_string(), 12.0), ("wght".to_string(), 700.0)]);
+ // More axes than any font has: refused up front, the style untouched.
+ let many: serde_json::Map = (0..MAX_VARIATION_AXES + 1).map(|i| (format!("a{i:03}"), J::from(1.0))).collect();
+ assert!(doc.set_attr("variations", &J::Object(many), None).is_err());
+ assert_eq!(doc.style_at(0).variations.len(), 2);
+ // A merge that would grow past the limit is refused too.
+ let fill: serde_json::Map = (0..MAX_VARIATION_AXES - 2).map(|i| (format!("b{i:03}"), J::from(1.0))).collect();
+ assert!(doc.set_attr("variations", &J::Object(fill), None).unwrap());
+ assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES);
+ // (The probe on an empty style passes; the styles already at the limit keep their axes.)
+ let _ = doc.set_attr("variations", &serde_json::json!({"zzzz": 1}), None);
+ assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES);
+ assert!(!doc.style_at(0).variations.iter().any(|(t, _)| t == "zzzz"));
+ }
+
+ #[test]
+ fn refused_variation_leaves_every_run_unchanged() {
+ // "ab" is at the axis limit; "cd" has none. Three new axes over both runs must be refused
+ // for both, not applied to "cd" only.
+ let mut doc = TextDoc::plain("abcd");
+ let fill: serde_json::Map = (0..MAX_VARIATION_AXES).map(|i| (format!("b{i:03}"), J::from(1.0))).collect();
+ assert!(doc.set_attr("variations", &J::Object(fill), Some(0..2)).unwrap());
+ assert!(doc.set_attr("variations", &serde_json::json!({"x1": 1, "x2": 1, "x3": 1}), None).is_err());
+ assert_eq!(doc.style_at(3).variations.len(), 0);
+ assert_eq!(doc.style_at(0).variations.len(), MAX_VARIATION_AXES);
+ }
+
+ #[test]
+ fn empty_document_accepts_an_extreme_range_without_overflow() {
+ let mut doc = TextDoc::plain("");
+ assert!(doc.set_attr("size", &J::from(20), Some(usize::MAX..usize::MAX)).unwrap());
+ }
+
#[test]
fn mixed_line_breaks_keep_original_byte_and_character_ranges() {
let text = "é\r\nβ\rx\n\n";
diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs
index 6609aed68..6e87f8933 100644
--- a/crates/keyframe/src/value.rs
+++ b/crates/keyframe/src/value.rs
@@ -210,7 +210,13 @@ impl Gradient {
}
}
+/// Most stops (colour or opacity) a gradient is sampled through. Gradients are sampled per
+/// pixel, so the stop count multiplies every painted pixel's work: stops past this are ignored
+/// when sampling, and a gradient written as JSON with more is refused.
+pub const MAX_GRADIENT_STOPS: usize = 256;
+
fn sample_stops(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option {
+ let stops = &stops[..stops.len().min(MAX_GRADIENT_STOPS)];
let first = stops.first()?;
if t <= first.0 {
return Some(first.1);
@@ -243,7 +249,7 @@ fn gradient_json(j: &serde_json::Value) -> Option {
}
fn stops(a: &[J]) -> Option> {
let n = a.len();
- if n < 2 {
+ if !(2..=MAX_GRADIENT_STOPS).contains(&n) {
return None;
}
a.iter()
@@ -259,6 +265,7 @@ fn gradient_json(j: &serde_json::Value) -> Option {
J::Object(o) => {
let colors = stops(o.get("colors")?.as_array()?)?;
let op = match o.get("opacities") {
+ Some(J::Array(a)) if a.len() > MAX_GRADIENT_STOPS => return None,
Some(J::Array(a)) => Some(
a.iter()
.map(|s| {
@@ -469,9 +476,10 @@ impl Value {
}
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
Value::Path(_) => serde_json::from_value::(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::(j.clone()).ok()),
- Value::Gradient(_) => {
- serde_json::from_value::(j.clone()).ok().filter(|v| matches!(v, Value::Gradient(_))).or_else(|| gradient_json(j).map(Value::Gradient))
- }
+ Value::Gradient(_) => serde_json::from_value::(j.clone())
+ .ok()
+ .filter(|v| matches!(v, Value::Gradient(g) if g.colors.len() <= MAX_GRADIENT_STOPS && g.opacities.len() <= MAX_GRADIENT_STOPS))
+ .or_else(|| gradient_json(j).map(Value::Gradient)),
}
}
/// JSON for display/automation (numbers/arrays for numeric kinds).
diff --git a/crates/lottie/Cargo.toml b/crates/lottie/Cargo.toml
index a9f64b3ba..9a95dca13 100644
--- a/crates/lottie/Cargo.toml
+++ b/crates/lottie/Cargo.toml
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
+craft-lottie = { workspace = true }
craft-docpath = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-color = { workspace = true }
diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs
index 5b6dabf50..62550f86f 100644
--- a/crates/lottie/src/anim.rs
+++ b/crates/lottie/src/anim.rs
@@ -1,26 +1,20 @@
-//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes.
-//!
-//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic
-//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x`
-//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease
-//! is speed (units/s) and influence (fraction of the segment): for a segment of duration `d` and
-//! value change `Δv`,
-//!
-//! ```text
-//! o.x = influence_out o.y = speed_out · influence_out · d / Δv
-//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv
-//! ```
-//!
-//! which is exactly the Bezier the keyframe evaluator uses. Spatial properties ease along the
-//! motion path (Δv = arc length, one dimension) and carry `to`/`ti` spatial tangents. Hold keys
-//! set `h: 1`.
+//! Animated properties: EffectCraft keyframes ↔ Lottie keyframes, through `craft-lottie`'s
+//! property encoding ([`craft_lottie::read_prop`], [`craft_lottie::write_prop`]) and its
+//! conversion between a segment's normalised Bezier ease and After Effects' speed and influence
+//! ([`Tangents`]). Spatial properties ease along the motion path (Δv = arc length, one
+//! dimension) and carry `to`/`ti` spatial tangents.
+pub(crate) use craft_lottie::nums;
+use craft_lottie::{Animated, Json, KeyOut, PropValue, Tangents, read_prop, round6};
use effectcraft_keyframe::{self as kf, Ease, Interp, Keyframe, Value};
use effectcraft_project::{Expression, Property};
-use serde_json::{Value as Json, json};
+use serde_json::json;
use crate::Timebase;
+/// Most distinct warnings an import or export reports.
+const MAX_WARNINGS: usize = 200;
+
/// Export state shared by the whole document.
pub(crate) struct Ex {
pub tb: Timebase,
@@ -31,7 +25,8 @@ pub(crate) struct Ex {
impl Ex {
pub fn warn(&mut self, w: impl Into) {
let w = w.into();
- if !self.warnings.contains(&w) {
+ // Each warning is looked for among the earlier ones: past a screenful, more add nothing.
+ if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
@@ -46,7 +41,8 @@ pub(crate) struct Im {
impl Im {
pub fn warn(&mut self, w: impl Into) {
let w = w.into();
- if !self.warnings.contains(&w) {
+ // Each warning is looked for among the earlier ones: past a screenful, more add nothing.
+ if self.warnings.len() < MAX_WARNINGS && !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
@@ -57,15 +53,11 @@ fn secs(t: effectcraft_time::Tick) -> f64 {
}
fn r6(v: f64) -> f64 {
- if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }
+ round6(v)
}
-/// A key value as Lottie's `s` (numbers wrapped in an array, objects in a one-element array).
-fn wrap(j: Json) -> Json {
- match j {
- Json::Array(_) => j,
- other => json!([other]),
- }
+fn lottie_ease(e: Ease) -> craft_lottie::Ease {
+ craft_lottie::Ease { speed: e.speed, influence: e.influence }
}
fn is_spatial(p: &Property) -> bool {
@@ -75,7 +67,7 @@ fn is_spatial(p: &Property) -> bool {
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and
/// the remaining keys carry the animation the renderer shows.
-pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
+pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Animated {
let muted = p.keys.iter().any(|k| k.muted);
if muted {
ex.warn(format!("{label}: muted keyframes are not exported"));
@@ -87,146 +79,102 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js
} else {
&p.keys
};
- let mut out =
- if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) };
+ let mut expression = None;
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
if ex.include_expressions {
- out["x"] = json!(e.text);
+ expression = Some(e.text.as_str());
} else {
ex.warn(format!("{label}: expression not exported (enable includeExpressions to export it as `x`)"));
}
}
- out
+ if keys.is_empty() {
+ return Animated(craft_lottie::write_prop(conv(&p.value), &[], expression));
+ }
+ Animated::keyed(&export_keys(ex, keys, is_spatial(p), p.hold_only, conv), expression)
}
-fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec {
+fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec {
let mut out = Vec::with_capacity(keys.len());
for (i, k) in keys.iter().enumerate() {
- let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
- if i + 1 == keys.len() {
- if k.out_interp == Interp::Hold || hold_only {
- o["h"] = json!(1);
+ let mut o = KeyOut { t: ex.tb.frame(k.time), s: conv(&k.value), hold: false, ease: vec![], spatial: None };
+ let last = i + 1 == keys.len();
+ if k.out_interp == Interp::Hold || hold_only || (!last && !k.value.interpolates()) {
+ o.hold = true;
+ } else if !last {
+ o.ease = segment_ease(keys, i, spatial);
+ if spatial {
+ let (_, to) = kf::spatial_tangents(keys, i);
+ let (ti, _) = kf::spatial_tangents(keys, i + 1);
+ let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) };
+ o.spatial = Some((to.get(..n).unwrap_or_default().to_vec(), ti.get(..n).unwrap_or_default().to_vec()));
}
- out.push(o);
- continue;
- }
- if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() {
- o["h"] = json!(1);
- out.push(o);
- continue;
- }
- let (ox, oy, ix, iy) = segment_ease(keys, i, spatial);
- o["o"] = json!({"x": ox, "y": oy});
- o["i"] = json!({"x": ix, "y": iy});
- if spatial {
- let (_, to) = kf::spatial_tangents(keys, i);
- let (ti, _) = kf::spatial_tangents(keys, i + 1);
- let n = if matches!(k.value, Value::Vec2(_)) { 2 } else { conv(&k.value).as_array().map(Vec::len).unwrap_or(3).min(3) };
- o["to"] = json!(to[..n].iter().map(|v| r6(*v)).collect::>());
- o["ti"] = json!(ti[..n].iter().map(|v| r6(*v)).collect::>());
}
out.push(o);
}
out
}
-/// Normalised ease of segment `i → i+1`: (o.x, o.y, i.x, i.y) per dimension.
-fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec, Vec, Vec, Vec) {
- let (a, b) = (&keys[i], &keys[i + 1]);
+/// Normalised ease of segment `i → i+1`, one entry per dimension.
+fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> Vec {
+ let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return vec![] };
let dur = (secs(b.time) - secs(a.time)).max(1e-12);
let ca = a.value.components();
let cb = b.value.components();
let numeric = !ca.is_empty() && ca.len() == cb.len();
let dims = if spatial || !numeric { 1 } else { ca.len() };
- let (mut ox, mut oy, mut ix, mut iy) = (vec![], vec![], vec![], vec![]);
- for d in 0..dims {
- let (dv, eo, ei) = if spatial {
- let len = kf::spatial_segment_length(keys, i);
- (len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false))
- } else if numeric {
- (cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false))
- } else {
- // Paths, gradients: overall progress 0 → 1.
- let side = |k: &Keyframe, out: bool| {
- let interp = if out { k.out_interp } else { k.in_interp };
- let list = if out { &k.out_ease } else { &k.in_ease };
- if interp == Interp::Bezier {
- list.first().copied().unwrap_or_default()
- } else {
- Ease { speed: 1.0 / dur, influence: kf::LINEAR_SIDE_INFLUENCE }
- }
+ (0..dims)
+ .map(|d| {
+ let (dv, eo, ei) = if spatial {
+ let len = kf::spatial_segment_length(keys, i);
+ (len, kf::side_ease(keys, i, 0, true, true), kf::side_ease(keys, i + 1, 0, true, false))
+ } else if numeric {
+ (cb[d] - ca[d], kf::side_ease(keys, i, d, false, true), kf::side_ease(keys, i + 1, d, false, false))
+ } else {
+ // Paths, gradients: overall progress 0 → 1.
+ let side = |k: &Keyframe, out: bool| {
+ let interp = if out { k.out_interp } else { k.in_interp };
+ let list = if out { &k.out_ease } else { &k.in_ease };
+ if interp == Interp::Bezier {
+ list.first().copied().unwrap_or_default()
+ } else {
+ Ease { speed: 1.0 / dur, influence: kf::LINEAR_SIDE_INFLUENCE }
+ }
+ };
+ (1.0, Some(side(a, true)), Some(side(b, false)))
};
- (1.0, Some(side(a, true)), Some(side(b, false)))
- };
- let lin = Ease { speed: dv / dur, influence: kf::LINEAR_SIDE_INFLUENCE };
- let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
- // Crossing time handles must retain the independently specified influences.
- let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
- let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) };
- ox.push(r6(i0));
- oy.push(r6(y0));
- ix.push(r6(1.0 - i1));
- iy.push(r6(y1));
- }
- (ox, oy, ix, iy)
+ let lin = Ease { speed: dv / dur, influence: kf::LINEAR_SIDE_INFLUENCE };
+ // Crossing time handles keep the independently specified influences.
+ Tangents::from_speed(lottie_ease(eo.unwrap_or(lin)), lottie_ease(ei.unwrap_or(lin)), dv, dur)
+ })
+ .collect()
}
// ---------------------------------------------------------------- import
-/// Whether a Lottie property value is keyframed.
-pub(crate) fn is_animated(j: &Json) -> bool {
- if j.get("a").and_then(Json::as_i64) == Some(1) {
- return true;
- }
- j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some())
-}
-
-/// Component `d` of an ease tangent field (`x` / `y` may be a number or an array).
-fn comp(j: &Json, field: &str, d: usize) -> Option {
- match j.get(field)? {
- Json::Number(n) => n.as_f64(),
- Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64),
- _ => None,
- }
-}
-
-fn vec3_of(j: Option<&Json>) -> [f64; 3] {
- let a = j.and_then(Json::as_array);
- let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0);
- [g(0), g(1), g(2)]
-}
-
/// Import a Lottie animatable property into `p` (value, keys, expression). `conv` turns a Lottie
/// value (as found in `k` or a key's `s`, possibly wrapped in a one-element array) into a value of
/// the property's type.
pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Json) -> Option) {
- if !j.is_object() {
- if let Some(v) = conv(j) {
- p.value = v;
- }
- return;
- }
- if let Some(x) = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()) {
+ let prop = read_prop(j);
+ if let Some(x) = prop.expression {
p.expr = Some(Expression { text: x.to_string(), enabled: true });
}
- let Some(k) = j.get("k") else { return };
- if !is_animated(j) {
- if let Some(v) = conv(k) {
- p.value = v;
+ let list = match prop.value {
+ PropValue::None => return,
+ PropValue::Static(v) => {
+ if let Some(v) = conv(v) {
+ p.value = v;
+ }
+ return;
}
- return;
- }
- let Some(list) = k.as_array() else { return };
+ PropValue::Keys(list) => list,
+ };
let spatial = is_spatial(p);
let mut keys: Vec = Vec::with_capacity(list.len());
- let mut kept: Vec<&Json> = Vec::with_capacity(list.len());
- let mut prev_end: Option<&Json> = None;
+ let mut kept = Vec::with_capacity(list.len());
for kj in list {
- let t = kj.get("t").and_then(Json::as_f64).unwrap_or(0.0);
- let sv = kj.get("s").or(prev_end);
- prev_end = kj.get("e");
- let Some(v) = sv.and_then(conv) else { continue };
- let mut key = Keyframe::new(im.tb.tick(t), v);
+ let Some(v) = kj.s.and_then(conv) else { continue };
+ let mut key = Keyframe::new(im.tb.tick(kj.t), v);
key.spatial_auto = false;
if p.hold_only || !key.value.interpolates() {
key = key.hold();
@@ -240,16 +188,14 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js
let n = keys.len();
for i in 0..n.saturating_sub(1) {
let Some(kj) = kept.get(i) else { break };
- if kj.get("h").and_then(Json::as_i64) == Some(1) || p.hold_only {
+ if kj.hold || p.hold_only {
keys[i].out_interp = Interp::Hold;
keys[i + 1].in_interp = Interp::Hold;
continue;
}
if spatial {
- let to = vec3_of(kj.get("to"));
- let ti = vec3_of(kj.get("ti"));
- keys[i].spatial_out = to;
- keys[i + 1].spatial_in = ti;
+ keys[i].spatial_out = kj.to;
+ keys[i + 1].spatial_in = kj.ti;
}
}
// Eases need the spatial tangents (arc length), so they come second.
@@ -258,7 +204,7 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js
if keys[i].out_interp == Interp::Hold {
continue;
}
- let (Some(o), Some(ii)) = (kj.get("o"), kj.get("i")) else {
+ let (Some(o), Some(ii)) = (kj.o, kj.i) else {
keys[i].out_interp = Interp::Linear;
keys[i + 1].in_interp = Interp::Linear;
continue;
@@ -272,13 +218,8 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js
let mut outs = vec![];
let mut ins = vec![];
for d in 0..dims {
- let ox = comp(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0);
- let oy = comp(o, "y", d).unwrap_or(ox);
- let ix = comp(ii, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0);
- let iy = comp(ii, "y", d).unwrap_or(ix);
- if (ox - oy).abs() > 1e-4 || (ix - iy).abs() > 1e-4 {
- linear = false;
- }
+ let t = Tangents::read(o, ii, d);
+ linear &= t.is_linear();
let dv = if spatial {
kf::spatial_segment_length(&keys, i)
} else if numeric {
@@ -286,10 +227,9 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js
} else {
1.0
};
- let inf_o = ox.max(0.001);
- let inf_i = (1.0 - ix).max(0.001);
- outs.push(Ease { speed: oy * dv / (inf_o * dur), influence: inf_o });
- ins.push(Ease { speed: (1.0 - iy) * dv / (inf_i * dur), influence: inf_i });
+ let (out, into) = t.to_speed(dv, dur);
+ outs.push(Ease { speed: out.speed, influence: out.influence });
+ ins.push(Ease { speed: into.speed, influence: into.influence });
}
if linear {
keys[i].out_interp = Interp::Linear;
@@ -305,6 +245,13 @@ pub(crate) fn import_prop(im: &Im, j: &Json, p: &mut Property, conv: &dyn Fn(&Js
p.keys = keys;
}
+/// [`import_prop`] for a property of the model ([`Animated`]).
+pub(crate) fn import_animated(im: &Im, a: Option<&Animated>, p: &mut Property, conv: &dyn Fn(&Json) -> Option) {
+ if let Some(a) = a {
+ import_prop(im, &a.0, p, conv);
+ }
+}
+
// ---------------------------------------------------------------- value converters
pub(crate) fn num(v: &Value) -> Json {
@@ -330,23 +277,6 @@ pub(crate) fn arr3(v: &Value) -> Json {
json!([r6(*c.first().unwrap_or(&0.0)), r6(*c.get(1).unwrap_or(&0.0)), r6(*c.get(2).unwrap_or(&0.0))])
}
-/// Numbers of a Lottie value (`5`, `[5]`, `[1, 2, 3]`).
-pub(crate) fn nums(j: &Json) -> Option> {
- match j {
- Json::Number(n) => Some(vec![n.as_f64()?]),
- Json::Array(a) => {
- // `[[x, y]]`-style wrapping.
- if a.len() == 1
- && let Some(inner @ Json::Array(_)) = a.first()
- {
- return nums(inner);
- }
- a.iter().map(Json::as_f64).collect()
- }
- _ => None,
- }
-}
-
pub(crate) fn to_scalar(j: &Json) -> Option {
nums(j).and_then(|v| v.first().copied()).map(Value::Scalar)
}
@@ -363,15 +293,8 @@ pub(crate) fn to_vec3(j: &Json, z: f64) -> Option {
}
pub(crate) fn to_color(j: &Json) -> Option {
- let v = nums(j)?;
- let mut c = [*v.first()?, *v.get(1).unwrap_or(&0.0), *v.get(2).unwrap_or(&0.0), *v.get(3).unwrap_or(&1.0)];
- // Some files use 0–255.
- if c[..3].iter().any(|x| *x > 1.0) {
- for x in &mut c[..3] {
- *x /= 255.0;
- }
- }
- Some(Value::Color(c))
+ // Some files use 0–255: `craft_lottie::color` divides those.
+ craft_lottie::color(j).map(Value::Color)
}
pub(crate) fn to_bool(j: &Json) -> Option {
diff --git a/crates/lottie/src/base64.rs b/crates/lottie/src/base64.rs
deleted file mode 100644
index b45f86d4a..000000000
--- a/crates/lottie/src/base64.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-//! Standard base64 (RFC 4648 §4) for embedded image assets.
-
-const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
-
-pub fn encode(data: &[u8]) -> String {
- let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
- for chunk in data.chunks(3) {
- let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
- let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
- for i in 0..4 {
- if i <= chunk.len() {
- out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
- } else {
- out.push('=');
- }
- }
- }
- out
-}
-
-pub fn decode(s: &str) -> Option> {
- let val = |c: u8| -> Option {
- Some(match c {
- b'A'..=b'Z' => c - b'A',
- b'a'..=b'z' => c - b'a' + 26,
- b'0'..=b'9' => c - b'0' + 52,
- b'+' | b'-' => 62,
- b'/' | b'_' => 63,
- _ => return None,
- } as u32)
- };
- let clean: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
- let mut out = Vec::with_capacity(clean.len() * 3 / 4);
- for chunk in clean.chunks(4) {
- let mut n = 0u32;
- for (i, &c) in chunk.iter().enumerate() {
- n |= val(c)? << (18 - 6 * i);
- }
- let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
- match chunk.len() {
- 4 => out.extend_from_slice(&bytes),
- 3 => out.extend_from_slice(&bytes[..2]),
- 2 => out.push(bytes[0]),
- _ => return None,
- }
- }
- Some(out)
-}
-
-/// Split a `data:;base64,` URI into (mime, bytes).
-pub fn data_uri(s: &str) -> Option<(String, Vec)> {
- let rest = s.strip_prefix("data:")?;
- let (head, payload) = rest.split_once(',')?;
- let mime = head.strip_suffix(";base64")?;
- Some((mime.to_string(), decode(payload)?))
-}
-
-#[cfg(test)]
-mod tests {
- #[test]
- fn roundtrip() {
- for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] {
- let e = super::encode(s.as_bytes());
- assert_eq!(super::decode(&e).unwrap(), s.as_bytes());
- }
- assert_eq!(super::encode(b"foobar"), "Zm9vYmFy");
- assert_eq!(super::encode(b"fo"), "Zm8=");
- }
-}
diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs
index eeb6e2737..99ca28e7e 100644
--- a/crates/lottie/src/export.rs
+++ b/crates/lottie/src/export.rs
@@ -2,11 +2,14 @@
use std::collections::BTreeSet;
+use craft_lottie::model::{Asset, Effect, Font, Fonts, Layer as LottieLayer, LayerContent, Marker, Mask, PrecompLayer, SolidLayer, Transform};
+use craft_lottie::{Animated, Animation, Json};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, MatteKind, Project, PropGroup};
-use serde_json::{Map, Value as Json, json};
+use serde_json::json;
use crate::anim::{self, Ex, export_prop};
+use crate::shapes::fixed;
use crate::{LottieError, Timebase, shapes, text};
/// Export settings.
@@ -134,28 +137,15 @@ struct Doc<'a> {
opts: &'a ExportOptions,
read: &'a dyn Fn(&str) -> Option>,
ex: Ex,
- assets: Vec,
+ assets: Vec,
done: BTreeSet,
- fonts: Vec,
+ fonts: Vec,
any_3d: bool,
}
-fn hex(c: [f32; 3]) -> String {
- let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
- format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
-}
-
-fn mime_of(path: &str) -> &'static str {
- let lower = path.to_ascii_lowercase();
- if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
- "image/jpeg"
- } else if lower.ends_with(".webp") {
- "image/webp"
- } else if lower.ends_with(".gif") {
- "image/gif"
- } else {
- "image/png"
- }
+/// The image type `bytes` hold, by their signature; `None` for anything else.
+pub(crate) fn image_mime(bytes: &[u8]) -> Option<&'static str> {
+ craft_lottie::image_mime(bytes)
}
impl Doc<'_> {
@@ -163,84 +153,68 @@ impl Doc<'_> {
self.ex.tb.frame(t)
}
- fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option {
+ fn prop(&mut self, g: &PropGroup, m: &str, conv: &dyn Fn(&Value) -> Json, label: &str) -> Option {
let p = g.get(m)?;
Some(export_prop(&mut self.ex, p, conv, &format!("{label} ▸ {}", p.name)))
}
- fn transform(&mut self, l: &Layer) -> Json {
- let mut ks = Map::new();
- let Some(tr) = l.transform() else { return json!({}) };
+ fn transform(&mut self, l: &Layer) -> Transform {
+ let mut ks = Transform::default();
+ let Some(tr) = l.transform() else { return ks };
let label = format!("{} ▸ Transform", l.name);
let three = l.is_3d();
- if let Some(a) = self.prop(tr, "anchor", &anim::arr3, &label) {
- ks.insert("a".into(), a);
- }
+ ks.anchor = self.prop(tr, "anchor", &anim::arr3, &label);
if tr.get("positionX").is_some() {
let mut p = json!({"s": true});
for (k, m) in [("x", "positionX"), ("y", "positionY"), ("z", "positionZ")] {
if (k != "z" || three)
&& let Some(v) = self.prop(tr, m, &anim::num, &label)
{
- p[k] = v;
+ p[k] = v.0;
}
}
- ks.insert("p".into(), p);
- } else if let Some(p) = self.prop(tr, "position", &anim::arr3, &label) {
- ks.insert("p".into(), p);
- }
- if let Some(s) = self.prop(tr, "scale", &anim::arr3, &label) {
- ks.insert("s".into(), s);
+ ks.position = Some(Animated(p));
+ } else {
+ ks.position = self.prop(tr, "position", &anim::arr3, &label);
}
+ ks.scale = self.prop(tr, "scale", &anim::arr3, &label);
if three {
- for (k, m) in [("rx", "rotationX"), ("ry", "rotationY"), ("rz", "rotation")] {
- if let Some(v) = self.prop(tr, m, &anim::num, &label) {
- ks.insert(k.into(), v);
- }
- }
- if let Some(v) = self.prop(tr, "orientation", &anim::arr3, &label) {
- ks.insert("or".into(), v);
- }
- } else if let Some(r) = self.prop(tr, "rotation", &anim::num, &label) {
- ks.insert("r".into(), r);
+ ks.rotation_x = self.prop(tr, "rotationX", &anim::num, &label);
+ ks.rotation_y = self.prop(tr, "rotationY", &anim::num, &label);
+ ks.rotation_z = self.prop(tr, "rotation", &anim::num, &label);
+ ks.orientation = self.prop(tr, "orientation", &anim::arr3, &label);
+ } else {
+ ks.rotation = self.prop(tr, "rotation", &anim::num, &label);
}
- if let Some(o) = self.prop(tr, "opacity", &anim::num, &label) {
- ks.insert("o".into(), o);
- }
- Json::Object(ks)
+ ks.opacity = self.prop(tr, "opacity", &anim::num, &label);
+ ks
}
- fn masks(&mut self, l: &Layer) -> Vec {
+ fn masks(&mut self, l: &Layer) -> Vec {
let Some(masks) = l.masks() else { return vec![] };
let mut out = vec![];
for m in masks.groups() {
let GroupKind::Mask { mode, inverted, .. } = m.kind else { continue };
let label = format!("{} ▸ {}", l.name, m.name);
- let mut o = json!({"nm": m.name, "mode": mask_mode(mode), "inv": inverted});
- if let Some(v) = self.prop(m, "path", &shapes::path_value, &label) {
- o["pt"] = v;
- }
- if let Some(v) = self.prop(m, "opacity", &anim::num, &label) {
- o["o"] = v;
- }
- if let Some(v) = self.prop(m, "expansion", &anim::num, &label) {
- o["x"] = v;
- }
+ let mut o = Mask { name: Some(m.name.clone()), mode: Some(mask_mode(mode).into()), inverted: Some(inverted), ..Default::default() };
+ o.path = self.prop(m, "path", &shapes::path_value, &label);
+ o.opacity = self.prop(m, "opacity", &anim::num, &label);
+ o.expansion = self.prop(m, "expansion", &anim::num, &label);
if let Some(f) = m.get("feather") {
if f.is_animated() || f.value.components().iter().any(|v| *v != 0.0) {
self.ex.warn(format!("{label}: mask feather is exported as `f` (not every Lottie player renders it)"));
}
- o["f"] = export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather"));
+ o.extra.insert("f".into(), export_prop(&mut self.ex, f, &anim::arr2, &format!("{label} ▸ Mask Feather")).0);
}
if !m.enabled {
- o["mode"] = json!("n");
+ o.mode = Some("n".into());
}
out.push(o);
}
out
}
- fn effects(&mut self, l: &Layer) -> Vec {
+ fn effects(&mut self, l: &Layer) -> Vec {
let Some(fx) = l.effects() else { return vec![] };
let mut out = vec![];
for (i, g) in fx.groups().enumerate() {
@@ -266,11 +240,27 @@ impl Doc<'_> {
};
(p.name.clone(), export_prop(&mut self.ex, p, &conv, &format!("{label} ▸ {}", p.name)))
}
- None => (format!("Param {}", pi + 1), json!({"a": 0, "k": konst})),
+ None => (format!("Param {}", pi + 1), fixed(json!(konst))),
};
- params.push(json!({"ty": ty, "nm": nm, "mn": m, "ix": pi + 1, "v": v}));
+ params.push(Effect {
+ kind: Some(i64::from(*ty)),
+ name: Some(nm),
+ match_name: Some((*m).into()),
+ index: Some(pi as i64 + 1),
+ value: Some(v),
+ ..Default::default()
+ });
}
- out.push(json!({"ty": map.ty, "nm": g.name, "mn": effect, "ix": i + 1, "en": g.enabled as u8, "np": params.len(), "ef": params}));
+ out.push(Effect {
+ kind: Some(i64::from(map.ty)),
+ name: Some(g.name.clone()),
+ match_name: Some(effect.clone()),
+ index: Some(i as i64 + 1),
+ enabled: Some(i64::from(g.enabled)),
+ count: Some(params.len() as i64),
+ values: Some(params),
+ ..Default::default()
+ });
}
out
}
@@ -286,8 +276,22 @@ impl Doc<'_> {
self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path));
return None;
};
- let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes));
- self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1}));
+ // What a project calls a still is embedded only when it is one: the path can name any
+ // file (an imported animation's asset), and the export would carry its bytes verbatim.
+ let Some(mime) = image_mime(&bytes) else {
+ self.ex.warn(format!("{}: `{}` is not a PNG, JPEG, GIF or WebP image (layer exported as a null)", l.name, f.path));
+ return None;
+ };
+ self.assets.push(Asset {
+ id: Some(id.clone()),
+ name: Some(it.name.clone()),
+ width: Some(f64::from(f.width)),
+ height: Some(f64::from(f.height)),
+ dir: Some(String::new()),
+ path: Some(craft_lottie::base64::to_data_uri(mime, &bytes)),
+ embedded: Some(1),
+ ..Default::default()
+ });
self.done.insert(id.clone());
Some(id)
}
@@ -301,11 +305,19 @@ impl Doc<'_> {
let c = it.as_comp()?;
self.done.insert(id.clone());
let layers = self.layers(c);
- self.assets.push(json!({"id": id, "nm": it.name, "fr": c.frame_rate.as_f64(), "w": c.width, "h": c.height, "layers": layers}));
+ self.assets.push(Asset {
+ id: Some(id.clone()),
+ name: Some(it.name.clone()),
+ frame_rate: Some(c.frame_rate.as_f64()),
+ width: Some(f64::from(c.width)),
+ height: Some(f64::from(c.height)),
+ layers: Some(layers),
+ ..Default::default()
+ });
Some(id)
}
- fn layers(&mut self, comp: &Comp) -> Vec {
+ fn layers(&mut self, comp: &Comp) -> Vec {
let mattes: BTreeSet<_> = comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
let mut out = vec![];
for (idx, l) in comp.layers.iter().enumerate() {
@@ -314,11 +326,9 @@ impl Doc<'_> {
out
}
- fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> Json {
+ fn layer(&mut self, comp: &Comp, idx: usize, l: &Layer, is_matte: bool) -> LottieLayer {
let name = l.name.clone();
- let mut o = Map::new();
- o.insert("ind".into(), json!(idx + 1));
- o.insert("nm".into(), json!(name));
+ let mut o = LottieLayer { index: Some(idx as i64 + 1), name: Some(name.clone()), ..Default::default() };
let three = l.is_3d();
if three {
self.any_3d = true;
@@ -326,34 +336,30 @@ impl Doc<'_> {
self.ex.warn(format!("{name}: 3D layer exported with ddd: 1 (most Lottie players render 2D only)"));
}
}
- o.insert("ddd".into(), json!(three as u8));
+ o.three_d = Some(i64::from(three));
let mut hidden = !l.switches.video || l.switches.guide;
- let mut ty = 3;
+ o.content = LayerContent::Null;
match &l.source {
LayerSource::Shape => {
- ty = 4;
let items = l.props.sub("contents").map(|c| shapes::export_items(&mut self.ex, c, &name)).unwrap_or_default();
- o.insert("shapes".into(), json!(items));
+ o.content = LayerContent::Shape(items);
}
LayerSource::Text => {
if self.opts.text_as_shapes {
- ty = 4;
- let items = text::glyph_shapes(&mut self.ex, l);
- o.insert("shapes".into(), json!(items));
+ o.content = LayerContent::Shape(text::glyph_shapes(&mut self.ex, l));
} else {
- ty = 5;
- let t = text::export_text(&mut self.ex, l, &mut self.fonts);
- o.insert("t".into(), t);
+ o.content = LayerContent::Text(Box::new(text::export_text(&mut self.ex, l, &mut self.fonts)));
}
}
LayerSource::Solid { item } => {
let sol = self.project.item(*item).and_then(|i| if let ItemKind::Solid(s) = &i.kind { Some(s.clone()) } else { None });
match sol {
Some(s) if !l.switches.adjustment => {
- ty = 1;
- o.insert("sc".into(), json!(hex(s.color)));
- o.insert("sw".into(), json!(s.width));
- o.insert("sh".into(), json!(s.height));
+ o.content = LayerContent::Solid(SolidLayer {
+ color: Some(craft_lottie::to_hex(s.color)),
+ width: Some(f64::from(s.width)),
+ height: Some(f64::from(s.height)),
+ });
}
_ => {
self.ex.warn(format!("{name}: adjustment layers are not part of Lottie (exported as a null)"));
@@ -363,15 +369,9 @@ impl Doc<'_> {
}
LayerSource::Comp { item } => {
if let Some(id) = self.precomp_asset(*item) {
- ty = 0;
let (w, h) = self.project.comp(*item).map(|c| (c.width, c.height)).unwrap_or((comp.width, comp.height));
- o.insert("refId".into(), json!(id));
- o.insert("w".into(), json!(w));
- o.insert("h".into(), json!(h));
- if let Some(tm) = l.props.get("timeRemap") {
- let v = export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap"));
- o.insert("tm".into(), v);
- }
+ let time_remap = l.props.get("timeRemap").map(|tm| export_prop(&mut self.ex, tm, &anim::num, &format!("{name} ▸ Time Remap")));
+ o.content = LayerContent::Precomp(PrecompLayer { ref_id: Some(id), width: Some(f64::from(w)), height: Some(f64::from(h)), time_remap });
}
}
LayerSource::Footage { item } => {
@@ -379,8 +379,7 @@ impl Doc<'_> {
match kind {
Some((FootageKind::Still, true)) => {
if let Some(id) = self.image_asset(*item, l) {
- ty = 2;
- o.insert("refId".into(), json!(id));
+ o.content = LayerContent::Image(Some(id));
} else {
hidden = true;
}
@@ -405,20 +404,16 @@ impl Doc<'_> {
hidden = true;
}
}
- o.insert("ty".into(), json!(ty));
- if let Some(p) = l.parent.and_then(|p| comp.index_of(p)) {
- o.insert("parent".into(), json!(p));
- }
- o.insert("sr".into(), json!(l.stretch / 100.0));
+ o.parent = l.parent.and_then(|p| comp.index_of(p)).map(|p| p as i64);
+ o.stretch = Some(l.stretch / 100.0);
if l.stretch < 0.0 {
self.ex.warn(format!("{name}: reversed time stretch is not supported by most Lottie players"));
}
- let ks = self.transform(l);
- o.insert("ks".into(), ks);
- o.insert("ao".into(), json!((l.auto_orient == effectcraft_project::AutoOrient::AlongPath) as u8));
- o.insert("ip".into(), json!(self.frame(l.in_point)));
- o.insert("op".into(), json!(self.frame(l.out_point)));
- o.insert("st".into(), json!(self.frame(l.start_time)));
+ o.transform = Some(self.transform(l));
+ o.auto_orient = Some(i64::from(l.auto_orient == effectcraft_project::AutoOrient::AlongPath));
+ o.in_point = Some(self.frame(l.in_point));
+ o.out_point = Some(self.frame(l.out_point));
+ o.start_time = Some(self.frame(l.start_time));
let bm = match crate::blend_to_lottie(l.blend_mode) {
Some(b) => b,
None => {
@@ -426,32 +421,32 @@ impl Doc<'_> {
0
}
};
- o.insert("bm".into(), json!(bm));
+ o.blend_mode = Some(i64::from(bm));
let masks = self.masks(l);
if !masks.is_empty() {
- o.insert("hasMask".into(), json!(true));
- o.insert("masksProperties".into(), json!(masks));
+ o.has_mask = Some(true);
+ o.masks = Some(masks);
}
let ef = self.effects(l);
if !ef.is_empty() {
- o.insert("ef".into(), json!(ef));
+ o.effects = Some(ef);
}
if let Some(tm) = l.track_matte
&& let Some(mi) = comp.index_of(tm.layer)
{
- o.insert("tt".into(), json!(matte_code(tm.kind)));
- o.insert("tp".into(), json!(mi));
+ o.matte_mode = Some(i64::from(matte_code(tm.kind)));
+ o.matte_parent = Some(mi as i64);
if mi != idx {
self.ex.warn(format!("{name}: track matte layer is not directly above (players without `tp` support use the layer above)"));
}
}
if is_matte {
- o.insert("td".into(), json!(1));
+ o.is_matte = Some(1);
if l.switches.video {
self.ex.warn(format!("{name}: visible track matte layer (Lottie always hides matte layers)"));
}
} else if hidden {
- o.insert("hd".into(), json!(true));
+ o.hidden = Some(true);
}
if !l.markers.is_empty() {
self.ex.warn(format!("{name}: layer markers are not part of Lottie (composition markers are exported)"));
@@ -463,7 +458,7 @@ impl Doc<'_> {
if l.preserve_transparency {
self.ex.warn(format!("{name}: Preserve Underlying Transparency is not part of Lottie"));
}
- Json::Object(o)
+ o
}
}
@@ -485,24 +480,27 @@ pub fn export_comp(project: &Project, comp: ItemId, opts: &ExportOptions, read:
};
doc.done.insert(format!("comp_{}", comp.0));
let layers = doc.layers(c);
- let markers: Vec = c.markers.iter().map(|m| json!({"tm": doc.frame(m.time), "cm": m.comment, "dr": doc.frame(m.duration)})).collect();
- let mut out = json!({
- "v": "5.12.0",
- "fr": c.frame_rate.as_f64(),
- "ip": 0,
- "op": doc.frame(c.duration),
- "w": c.width,
- "h": c.height,
- "nm": item.name,
- "ddd": doc.any_3d as u8,
- "assets": doc.assets,
- "layers": layers,
- "markers": markers,
- });
- if !doc.fonts.is_empty() {
- out["fonts"] = json!({"list": doc.fonts});
- }
- Ok(ExportResult { json: out, warnings: doc.ex.warnings })
+ let markers: Vec = c
+ .markers
+ .iter()
+ .map(|m| Marker { time: Some(doc.frame(m.time)), comment: Some(m.comment.clone()), duration: Some(doc.frame(m.duration)), ..Default::default() })
+ .collect();
+ let animation = Animation {
+ version: Some("5.12.0".into()),
+ frame_rate: Some(c.frame_rate.as_f64()),
+ in_point: Some(0.0),
+ out_point: Some(doc.frame(c.duration)),
+ width: Some(f64::from(c.width)),
+ height: Some(f64::from(c.height)),
+ name: Some(item.name.clone()),
+ three_d: Some(i64::from(doc.any_3d)),
+ assets: Some(doc.assets),
+ layers: Some(layers),
+ markers: Some(markers),
+ fonts: (!doc.fonts.is_empty()).then(|| Fonts { list: Some(doc.fonts), ..Default::default() }),
+ ..Default::default()
+ };
+ Ok(ExportResult { json: animation.to_json(), warnings: doc.ex.warnings })
}
/// Package an export as a dotLottie archive (`manifest.json` + `animations/.json`).
diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs
index 53b1ba71c..6534727dc 100644
--- a/crates/lottie/src/import.rs
+++ b/crates/lottie/src/import.rs
@@ -1,8 +1,10 @@
//! Lottie document → EffectCraft compositions (the inverse of [`crate::export`]).
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
+use craft_lottie::model::{Asset, Effect, Layer as LottieLayer, LayerContent, Mask, Transform};
+use craft_lottie::{Animated, Animation, Json};
use effectcraft_color::Label;
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
@@ -11,12 +13,14 @@ use effectcraft_project::{
PropGroup, Property, Solid, TrackMatte,
};
use effectcraft_time::{FrameRate, Tick};
-use serde_json::Value as Json;
-use crate::anim::{self, Im, import_prop};
+use crate::anim::{self, Im, import_animated, import_prop};
use crate::export::{fx_by_id, fx_by_type, fx_params, mask_mode_from};
use crate::{LottieError, Timebase, shapes, text};
+/// Longest font family or style name kept (each text key using the font holds a copy).
+const MAX_FONT_NAME_CHARS: usize = 256;
+
/// An embedded image asset handed to the caller to store (the returned path becomes the
/// footage item's file).
#[derive(Clone, Debug)]
@@ -37,24 +41,15 @@ pub struct ImportResult {
pub warnings: Vec,
}
-fn f(j: &Json, k: &str) -> Option {
- j.get(k).and_then(Json::as_f64)
-}
-
-fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> {
- j.get(k).and_then(Json::as_str)
-}
-
-fn hex_color(sc: &str) -> [f32; 3] {
- let h = sc.trim_start_matches('#');
- let c = |i: usize| {
- h.get(i..i + 2).filter(|x| x.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0
+/// The Lottie document inside raw bytes (plain JSON, or a stored dotLottie archive), read into
+/// `craft-lottie`'s model within its default limits.
+fn document(bytes: &[u8]) -> Result {
+ let read = |b: &[u8]| {
+ Animation::read(b).map_err(|e| match e {
+ craft_lottie::LottieError::Parse(m) => LottieError::Parse(m),
+ other => LottieError::Invalid(other.to_string()),
+ })
};
- [c(0), c(2), c(4)]
-}
-
-/// The Lottie JSON inside raw bytes (plain JSON, or a stored dotLottie archive).
-fn document(bytes: &[u8]) -> Result {
if bytes.starts_with(b"PK") {
let entries = crate::zip::read_stored(bytes);
let anim = entries
@@ -62,9 +57,9 @@ fn document(bytes: &[u8]) -> Result {
.find(|(n, _)| n.starts_with("animations/") && n.ends_with(".json"))
.or_else(|| entries.iter().find(|(n, _)| n.ends_with(".json") && n != "manifest.json"))
.ok_or_else(|| LottieError::Parse("dotLottie archive without an uncompressed animation".into()))?;
- return serde_json::from_slice(&anim.1).map_err(|e| LottieError::Parse(e.to_string()));
+ return read(&anim.1);
}
- serde_json::from_slice(bytes).map_err(|e| LottieError::Parse(e.to_string()))
+ read(bytes)
}
struct Ctx<'a> {
@@ -118,27 +113,28 @@ pub fn import(
store: &mut dyn FnMut(&ImportedImage) -> Option,
) -> Result {
let doc = document(bytes)?;
- let (Some(w), Some(h)) = (f(&doc, "w"), f(&doc, "h")) else { return Err(LottieError::Parse("missing `w`/`h`".into())) };
- let fr = f(&doc, "fr").filter(|v| *v > 0.0).unwrap_or(30.0);
+ let (Some(w), Some(h)) = (doc.width, doc.height) else { return Err(LottieError::Parse("missing `w`/`h`".into())) };
+ let fr = doc.frame_rate.filter(|v| *v > 0.0).unwrap_or(30.0);
let rate = FrameRate::from_f64(fr);
let tb = Timebase { rate };
- let name = s(&doc, "nm").filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string();
- let ip = f(&doc, "ip").unwrap_or(0.0);
- let op = f(&doc, "op").unwrap_or(ip + fr).max(ip + 1.0);
+ let name = doc.name.as_deref().filter(|n| !n.is_empty()).unwrap_or(fallback_name).to_string();
+ let ip = doc.in_point.unwrap_or(0.0);
+ let op = doc.out_point.unwrap_or(ip + fr).max(ip + 1.0);
let mut cx =
Ctx { im: Im { tb, warnings: vec![] }, assets: BTreeMap::new(), fonts: vec![], store, base_dir, items: vec![], folder: None, name: name.clone() };
- if let Some(list) = doc.get("fonts").and_then(|f| f.get("list")).and_then(Json::as_array) {
- for fo in list {
- let n = s(fo, "fName").unwrap_or("").to_string();
- let fam = s(fo, "fFamily").unwrap_or(&n).to_string();
- let st = s(fo, "fStyle").unwrap_or("Regular").to_string();
- cx.fonts.push((n, fam, st));
- }
+ for fo in doc.fonts.as_ref().and_then(|f| f.list.as_deref()).unwrap_or_default() {
+ // Every text key referring to the font copies its family and style: keep them to
+ // a font name's length.
+ let short = |v: &str| v.chars().take(MAX_FONT_NAME_CHARS).collect::();
+ let n = fo.name.clone().unwrap_or_default();
+ let fam = short(fo.family.as_deref().unwrap_or(&n));
+ let st = short(fo.style.as_deref().unwrap_or("Regular"));
+ cx.fonts.push((n, fam, st));
}
// Glyph outlines embedded for players without the fonts. Text is set in installed fonts.
- if let Some(chars) = doc.get("chars").and_then(Json::as_array).filter(|c| !c.is_empty()) {
+ if let Some(chars) = doc.chars.as_deref().filter(|c| !c.is_empty()) {
let mut fams: Vec<&str> = vec![];
- for fam in chars.iter().filter_map(|c| s(c, "fFamily")).filter(|f| !f.trim().is_empty()) {
+ for fam in chars.iter().filter_map(|c| c.family.as_deref()).filter(|f| !f.trim().is_empty()) {
if !fams.contains(&fam) {
fams.push(fam);
}
@@ -149,24 +145,27 @@ pub fn import(
let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32);
let duration = tb.tick(op);
// Pass 1: asset items (precomps empty for now, images stored).
- let assets = doc.get("assets").and_then(Json::as_array).cloned().unwrap_or_default();
- for a in &assets {
- let Some(id) = s(a, "id") else { continue };
- if a.get("layers").is_some() {
+ let assets: &[Asset] = doc.assets.as_deref().unwrap_or_default();
+ let top: &[LottieLayer] = doc.layers.as_deref().unwrap_or_default();
+ let layer_sizes = precomp_layer_sizes(top, assets);
+ for a in assets {
+ let Some(id) = a.id.as_deref() else { continue };
+ let size = a.width.zip(a.height).map(|(x, y)| (x as u32, y as u32));
+ if a.layers.is_some() {
let folder = cx.folder(project);
- let layer = precomp_layer_size(&doc, &assets, id);
- let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w);
- let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h);
- let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate);
+ let layer = layer_sizes.get(id).copied();
+ let aw = a.width.map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w);
+ let ah = a.height.map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h);
+ let arate = a.frame_rate.map(FrameRate::from_f64).unwrap_or(rate);
let c = Comp::new(aw, ah, arate, duration);
- let nm = s(a, "nm").unwrap_or(id).to_string();
+ let nm = a.name.as_deref().unwrap_or(id).to_string();
let item = project.add_item(&nm, Label::Sandstone, Some(folder), ItemKind::Comp(Arc::new(c)));
cx.items.push(item);
- cx.assets.insert(id.to_string(), (item, f(a, "w").zip(f(a, "h")).map(|(x, y)| (x as u32, y as u32))));
- } else if let Some(p) = s(a, "p") {
- let (aw, ah) = (f(a, "w").unwrap_or(0.0) as u32, f(a, "h").unwrap_or(0.0) as u32);
- let nm = s(a, "nm").unwrap_or(id).to_string();
- let path = if let Some((mime, data)) = crate::base64::data_uri(p) {
+ cx.assets.insert(id.to_string(), (item, size));
+ } else if let Some(p) = a.path.as_deref() {
+ let (aw, ah) = (a.width.unwrap_or(0.0) as u32, a.height.unwrap_or(0.0) as u32);
+ let nm = a.name.as_deref().unwrap_or(id).to_string();
+ let path = if let Some((mime, data)) = craft_lottie::base64::data_uri(p) {
let ext = match mime.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
@@ -176,7 +175,7 @@ pub fn import(
};
(cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() })
} else {
- let u = s(a, "u").unwrap_or("");
+ let u = a.dir.as_deref().unwrap_or("");
match external_image(&format!("{u}{p}"), cx.base_dir) {
Ok(path) => Some(path),
Err(why) => {
@@ -218,11 +217,11 @@ pub fn import(
}
}
// Pass 2: precomp contents.
- for a in &assets {
- let (Some(id), Some(layers)) = (s(a, "id"), a.get("layers").and_then(Json::as_array)) else { continue };
+ for a in assets {
+ let (Some(id), Some(layers)) = (a.id.as_deref(), a.layers.as_deref()) else { continue };
let Some((item, _)) = cx.assets.get(id).copied() else { continue };
let Some(mut c) = project.comp(item).cloned() else { continue };
- let end = layers.iter().filter_map(|l| f(l, "op")).fold(0.0f64, f64::max);
+ let end = layers.iter().filter_map(|l| l.out_point).fold(0.0f64, f64::max);
if end > 0.0 {
c.duration = tb.tick(end);
c.work_area = (Tick::ZERO, c.duration);
@@ -237,62 +236,64 @@ pub fn import(
if ip > 0.0 {
comp.work_area = (tb.tick(ip), duration);
}
- if let Some(ms) = doc.get("markers").and_then(Json::as_array) {
- for m in ms {
- comp.markers.push(Marker {
- time: tb.tick(f(m, "tm").unwrap_or(0.0)),
- duration: tb.tick(f(m, "dr").unwrap_or(0.0)),
- comment: s(m, "cm").unwrap_or("").to_string(),
- ..Default::default()
- });
- }
+ for m in doc.markers.as_deref().unwrap_or_default() {
+ comp.markers.push(Marker {
+ time: tb.tick(m.time.unwrap_or(0.0)),
+ duration: tb.tick(m.duration.unwrap_or(0.0)),
+ comment: m.comment.clone().unwrap_or_default(),
+ ..Default::default()
+ });
}
- let layers = doc.get("layers").and_then(Json::as_array).cloned().unwrap_or_default();
- build_layers(&mut cx, project, &mut comp, &layers);
+ build_layers(&mut cx, project, &mut comp, top);
let id = project.add_item(&name, Label::Sandstone, None, ItemKind::Comp(Arc::new(comp)));
cx.items.push(id);
project.fix_next_id();
Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings })
}
-/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`.
-/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect.
-fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> {
- let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers")));
- lists.filter_map(Json::as_array).flatten().find_map(|l| {
- if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) {
- return None;
- }
- let (lw, lh) = f(l, "w").zip(f(l, "h"))?;
- Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32))
- })
+/// By asset id, the `w`/`h` of the first precomp layer, in the main comp or in any asset, that
+/// shows it. lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its
+/// clipping rect. One pass over all layers, not one per asset.
+fn precomp_layer_sizes<'a>(top: &'a [LottieLayer], assets: &'a [Asset]) -> HashMap<&'a str, (u32, u32)> {
+ let mut out = HashMap::new();
+ let lists = std::iter::once(top).chain(assets.iter().filter_map(|a| a.layers.as_deref()));
+ for l in lists.flatten() {
+ let LayerContent::Precomp(p) = &l.content else { continue };
+ let (Some(id), Some((lw, lh))) = (p.ref_id.as_deref(), p.width.zip(p.height)) else { continue };
+ out.entry(id).or_insert((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32));
+ }
+ out
}
-fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
- let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option| {
- if let (Some(j), Some(p)) = (j, tr.get_mut(m)) {
- import_prop(im, j, p, conv);
+fn transform(im: &Im, ks: &Transform, tr: &mut PropGroup, next: &mut u64) {
+ let set = |tr: &mut PropGroup, m: &str, j: Option<&Animated>, conv: &dyn Fn(&Json) -> Option| {
+ if let Some(p) = tr.get_mut(m) {
+ import_animated(im, j, p, conv);
}
};
- set(tr, "anchor", ks.get("a"), &|v| anim::to_vec3(v, 0.0));
- set(tr, "scale", ks.get("s"), &|v| anim::to_vec3(v, 100.0));
- set(tr, "rotation", ks.get("r").or(ks.get("rz")), &anim::to_scalar);
- set(tr, "rotationX", ks.get("rx"), &anim::to_scalar);
- set(tr, "rotationY", ks.get("ry"), &anim::to_scalar);
- set(tr, "orientation", ks.get("or"), &|v| anim::to_vec3(v, 0.0));
- set(tr, "opacity", ks.get("o"), &anim::to_scalar);
- let Some(p) = ks.get("p") else { return };
- if p.get("s").and_then(Json::as_bool) == Some(true) {
+ set(tr, "anchor", ks.anchor.as_ref(), &|v| anim::to_vec3(v, 0.0));
+ set(tr, "scale", ks.scale.as_ref(), &|v| anim::to_vec3(v, 100.0));
+ set(tr, "rotation", ks.rotation.as_ref().or(ks.rotation_z.as_ref()), &anim::to_scalar);
+ set(tr, "rotationX", ks.rotation_x.as_ref(), &anim::to_scalar);
+ set(tr, "rotationY", ks.rotation_y.as_ref(), &anim::to_scalar);
+ set(tr, "orientation", ks.orientation.as_ref(), &|v| anim::to_vec3(v, 0.0));
+ set(tr, "opacity", ks.opacity.as_ref(), &anim::to_scalar);
+ let Some(p) = &ks.position else { return };
+ if let Some(dims) = p.split() {
// Separated dimensions: X/Y/Z Position after Position.
let at = tr.children.iter().position(|c| c.match_id() == "position").map(|i| i + 1).unwrap_or(tr.children.len());
let base = tr.get("position").map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
- for (d, (key, m, name)) in
- [("x", "positionX", "X Position"), ("y", "positionY", "Y Position"), ("z", "positionZ", "Z Position")].iter().enumerate().rev()
+ for (d, (j, m, name)) in dims
+ .iter()
+ .zip([("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")])
+ .map(|(j, (m, n))| (j, m, n))
+ .enumerate()
+ .rev()
{
let mut pr = Property::new(*next, m, name, Value::Scalar(base[d])).with_ui(ParamUi::Number);
*next += 1;
pr.three_d_only = d == 2;
- if let Some(j) = p.get(*key) {
+ if let Some(j) = j {
import_prop(im, j, &mut pr, &anim::to_scalar);
}
tr.children.insert(at, Node::Prop(pr));
@@ -302,50 +303,50 @@ fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
}
}
-fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) {
+fn masks(im: &Im, ids: &mut Ids, list: &[Mask], g: &mut PropGroup) {
for (i, m) in list.iter().enumerate() {
- let mode = mask_mode_from(s(m, "mode").unwrap_or("a"));
- let name = s(m, "nm").map(str::to_string).unwrap_or(format!("Mask {}", i + 1));
+ let mode = mask_mode_from(m.mode.as_deref().unwrap_or("a"));
+ let name = m.name.clone().unwrap_or(format!("Mask {}", i + 1));
let color = build::MASK_COLORS[i % build::MASK_COLORS.len()];
let mut mg = build::mask(ids, &name, ShapePath::default(), mode, color);
if let GroupKind::Mask { inverted, .. } = &mut mg.kind {
- *inverted = m.get("inv").and_then(Json::as_bool).unwrap_or(false);
+ *inverted = m.inverted.unwrap_or(false);
}
- let mut set = |k: &str, mid: &str, conv: &dyn Fn(&Json) -> Option| {
- if let (Some(j), Some(p)) = (m.get(k), mg.get_mut(mid)) {
+ let mut set = |j: Option<&Json>, mid: &str, conv: &dyn Fn(&Json) -> Option| {
+ if let (Some(j), Some(p)) = (j, mg.get_mut(mid)) {
import_prop(im, j, p, conv);
}
};
- let closed = m.get("cl").and_then(Json::as_bool).unwrap_or(false);
- set("pt", "path", &|v| shapes::to_path(v, closed));
- set("o", "opacity", &anim::to_scalar);
- set("x", "expansion", &anim::to_scalar);
- set("f", "feather", &anim::to_vec2);
+ let closed = m.closed.unwrap_or(false);
+ set(m.path.as_ref().map(|a| &a.0), "path", &|v| shapes::to_path(v, closed));
+ set(m.opacity.as_ref().map(|a| &a.0), "opacity", &anim::to_scalar);
+ set(m.expansion.as_ref().map(|a| &a.0), "expansion", &anim::to_scalar);
+ set(m.extra.get("f"), "feather", &anim::to_vec2);
g.children.push(Node::Group(mg));
}
}
-fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [f64; 2]) {
+fn effects(im: &mut Im, ids: &mut Ids, list: &[Effect], g: &mut PropGroup, size: [f64; 2]) {
for e in list {
- let ty = e.get("ty").and_then(Json::as_u64).unwrap_or(0);
- let params = e.get("ef").and_then(Json::as_array).cloned().unwrap_or_default();
- let inner_ty = params.first().and_then(|p| p.get("ty")).and_then(Json::as_u64);
- let map = s(e, "mn").and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty));
- let nm = s(e, "nm").unwrap_or("Effect");
+ let ty = e.kind.and_then(|t| u64::try_from(t).ok()).unwrap_or(0);
+ let params: &[Effect] = e.values.as_deref().unwrap_or_default();
+ let inner_ty = params.first().and_then(|p| p.kind).and_then(|t| u64::try_from(t).ok());
+ let map = e.match_name.as_deref().and_then(fx_by_id).or_else(|| fx_by_type(ty, inner_ty));
+ let nm = e.name.as_deref().unwrap_or("Effect");
let Some(map) = map else {
im.warn(format!("effect `{nm}` (type {ty}) is not supported (skipped)"));
continue;
};
let Some(spec) = effectcraft_effects::find(map.id) else { continue };
let mut fg = effectcraft_effects::instantiate(spec, ids, nm, size);
- if e.get("en").and_then(Json::as_u64) == Some(0) {
+ if e.enabled == Some(0) {
fg.enabled = false;
}
for (i, (m, pty, scale, _)) in fx_params(map).iter().enumerate() {
if m.is_empty() {
continue;
}
- let Some(pj) = params.get(i).and_then(|p| p.get("v")) else { continue };
+ let Some(pj) = params.get(i).and_then(|p| p.value.as_ref()) else { continue };
let Some(p) = fg.get_mut(m) else { continue };
let (pty, scale) = (*pty, *scale);
let conv = move |v: &Json| -> Option {
@@ -357,155 +358,162 @@ fn effects(im: &mut Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup, size: [
_ => anim::to_scalar(v).map(|x| Value::Scalar(x.as_f64() / scale)),
}
};
- import_prop(im, pj, p, &conv);
+ import_prop(im, &pj.0, p, &conv);
}
g.children.push(Node::Group(fg));
}
}
-fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &Json) -> Option<(LayerSource, (u32, u32))> {
- let ty = j.get("ty").and_then(Json::as_u64).unwrap_or(99);
+fn layer_source(cx: &mut Ctx, project: &mut Project, comp: &Comp, j: &LottieLayer) -> Option<(LayerSource, (u32, u32))> {
let full = (comp.width, comp.height);
- let asset = |cx: &Ctx| s(j, "refId").and_then(|r| cx.assets.get(r).copied());
- Some(match ty {
- 0 => {
- let Some((item, _)) = asset(cx) else {
- cx.im.warn(format!("{}: missing precomp asset", s(j, "nm").unwrap_or("layer")));
+ let nm = j.name.as_deref();
+ let asset = |cx: &Ctx, r: Option<&str>| r.and_then(|r| cx.assets.get(r).copied());
+ Some(match &j.content {
+ LayerContent::Precomp(p) => {
+ let Some((item, _)) = asset(cx, p.ref_id.as_deref()) else {
+ cx.im.warn(format!("{}: missing precomp asset", nm.unwrap_or("layer")));
return None;
};
let size = project.comp(item).map(|c| (c.width, c.height)).unwrap_or(full);
(LayerSource::Comp { item }, size)
}
- 1 => {
- let sw = f(j, "sw").unwrap_or(comp.width as f64).round().max(1.0) as u32;
- let sh = f(j, "sh").unwrap_or(comp.height as f64).round().max(1.0) as u32;
- let color = hex_color(s(j, "sc").unwrap_or("#000000"));
- let name = s(j, "nm").unwrap_or("Solid");
+ LayerContent::Solid(sl) => {
+ let sw = sl.width.unwrap_or(comp.width as f64).round().max(1.0) as u32;
+ let sh = sl.height.unwrap_or(comp.height as f64).round().max(1.0) as u32;
+ let color = craft_lottie::hex_color(sl.color.as_deref().unwrap_or("#000000"));
+ let name = nm.unwrap_or("Solid");
let folder = cx.folder(project);
let item = project.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: sw, height: sh, pixel_aspect: 1.0 }));
cx.items.push(item);
(LayerSource::Solid { item }, (sw, sh))
}
- 2 => {
- let Some((item, size)) = asset(cx) else {
- cx.im.warn(format!("{}: missing image asset", s(j, "nm").unwrap_or("layer")));
+ LayerContent::Image(r) => {
+ let Some((item, size)) = asset(cx, r.as_deref()) else {
+ cx.im.warn(format!("{}: missing image asset", nm.unwrap_or("layer")));
return None;
};
(LayerSource::Footage { item }, size.unwrap_or(full))
}
- 3 => (LayerSource::Null, (100, 100)),
- 4 => (LayerSource::Shape, full),
- 5 => (LayerSource::Text, full),
- 13 => {
- cx.im.warn(format!("{}: camera layers are not imported", s(j, "nm").unwrap_or("camera")));
- return None;
- }
- other => {
- cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", s(j, "nm").unwrap_or("layer")));
- return None;
- }
+ LayerContent::Null => (LayerSource::Null, (100, 100)),
+ LayerContent::Shape(_) => (LayerSource::Shape, full),
+ LayerContent::Text(_) => (LayerSource::Text, full),
+ LayerContent::Other(ty) => match ty.as_u64().unwrap_or(99) {
+ // A shape or text layer whose content is missing or unreadable is an empty one.
+ 4 => (LayerSource::Shape, full),
+ 5 => (LayerSource::Text, full),
+ 13 => {
+ cx.im.warn(format!("{}: camera layers are not imported", nm.unwrap_or("camera")));
+ return None;
+ }
+ other => {
+ cx.im.warn(format!("{}: layer type {other} is not supported (skipped)", nm.unwrap_or("layer")));
+ return None;
+ }
+ },
})
}
/// Build the layers of a comp (Lottie order: first = top = layer #1).
-fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[Json]) {
+fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[LottieLayer]) {
let tb = cx.im.tb;
let mut by_ind: BTreeMap = BTreeMap::new();
- // (layer id, parent ind, matte: (kind, explicit tp ind), json position)
- let mut pending: Vec<(LayerId, Option, Option<(MatteKind, Option)>, usize)> = vec![];
+ // (layer id, its index in `comp.layers`, parent ind, matte: (kind, explicit tp ind), json position)
+ let mut pending: Vec<(LayerId, usize, Option, Option<(MatteKind, Option)>, usize)> = vec![];
let mut pos_ind: Vec> = vec![];
for (ji, j) in layers.iter().enumerate() {
- let ind = j.get("ind").and_then(Json::as_i64);
+ let ind = j.index;
pos_ind.push(ind);
let Some((source, size)) = layer_source(cx, project, comp, j) else { continue };
- let name = s(j, "nm").unwrap_or(source.type_name()).to_string();
+ let name = j.name.clone().unwrap_or_else(|| source.type_name().to_string());
let mut l: Layer = build::layer(project, comp, &name, source.clone(), size, None);
if let Some(i) = ind {
by_ind.insert(i, l.id);
}
- let ip = f(j, "ip").unwrap_or(0.0);
- let op = f(j, "op").unwrap_or(tb.frame(comp.duration));
+ let ip = j.in_point.unwrap_or(0.0);
+ let op = j.out_point.unwrap_or(tb.frame(comp.duration));
l.in_point = tb.tick(ip);
l.out_point = tb.tick(op.max(ip));
- l.start_time = tb.tick(f(j, "st").unwrap_or(0.0));
+ l.start_time = tb.tick(j.start_time.unwrap_or(0.0));
// Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too.
- let sr = f(j, "sr").unwrap_or(1.0);
+ let sr = j.stretch.unwrap_or(1.0);
l.stretch = if sr.is_finite() && sr.abs() >= 0.01 {
- sr * 100.0
+ // Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept.
+ let pct = effectcraft_project::limits::stretch(sr * 100.0);
+ if pct.to_bits() != (sr * 100.0).to_bits() {
+ cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0));
+ }
+ pct
} else {
cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0));
100.0
};
- if j.get("ddd").and_then(Json::as_u64) == Some(1) {
+ if j.three_d == Some(1) {
l.switches.three_d = true;
}
- if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
+ if let Some(bm) = j.blend_mode.and_then(|b| u64::try_from(b).ok()) {
l.blend_mode = crate::blend_from_lottie(bm);
}
- if j.get("ao").and_then(Json::as_u64) == Some(1) {
+ if j.auto_orient == Some(1) {
l.auto_orient = AutoOrient::AlongPath;
}
- if j.get("hd").and_then(Json::as_bool) == Some(true) {
+ if j.hidden == Some(true) {
l.switches.video = false;
}
- if j.get("td").and_then(Json::as_u64) == Some(1) {
+ if j.is_matte == Some(1) {
l.switches.video = false;
}
let mut next = project.next_id;
- if let (Some(ks), Some(tr)) = (j.get("ks"), l.props.sub_mut("transform")) {
+ if let (Some(ks), Some(tr)) = (&j.transform, l.props.sub_mut("transform")) {
transform(&cx.im, ks, tr, &mut next);
}
let mut ids = Ids(&mut next);
- match &source {
- LayerSource::Shape => {
- let items = j.get("shapes").and_then(Json::as_array).cloned().unwrap_or_default();
- let groups = shapes::import_items(&mut cx.im, &mut ids, &items);
+ match (&source, &j.content) {
+ (LayerSource::Shape, LayerContent::Shape(items)) => {
+ let groups = shapes::import_items(&mut cx.im, &mut ids, &mut items.iter());
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(groups.into_iter().map(Node::Group));
}
}
- LayerSource::Text => {
- if let (Some(t), Some(tg)) = (j.get("t"), l.props.sub_mut("text")) {
+ (LayerSource::Text, LayerContent::Text(t)) => {
+ if let Some(tg) = l.props.sub_mut("text") {
text::import_text(&mut cx.im, &mut ids, t, tg, &cx.fonts);
}
}
- LayerSource::Comp { .. } => {
- if let Some(tm) = j.get("tm") {
+ (LayerSource::Comp { .. }, LayerContent::Precomp(p)) => {
+ if let Some(tm) = &p.time_remap {
let mut pr = ids.prop("timeRemap", "Time Remap", Value::Scalar(0.0)).with_ui(ParamUi::Number);
- import_prop(&cx.im, tm, &mut pr, &anim::to_scalar);
+ import_prop(&cx.im, &tm.0, &mut pr, &anim::to_scalar);
l.props.children.insert(0, Node::Prop(pr));
}
}
_ => {}
}
- if let Some(list) = j.get("masksProperties").and_then(Json::as_array)
+ if let Some(list) = &j.masks
&& let Some(g) = l.props.sub_mut("masks")
{
masks(&cx.im, &mut ids, list, g);
}
- if let Some(list) = j.get("ef").and_then(Json::as_array)
+ if let Some(list) = &j.effects
&& let Some(g) = l.props.sub_mut("effects")
{
effects(&mut cx.im, &mut ids, list, g, [size.0 as f64, size.1 as f64]);
}
- if j.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) {
+ if j.extra.get("sy").and_then(Json::as_array).is_some_and(|a| !a.is_empty()) {
cx.im.warn(format!("{name}: layer styles are not imported"));
}
project.next_id = next;
- let parent = j.get("parent").and_then(Json::as_i64);
- let tt = j.get("tt").and_then(Json::as_u64).unwrap_or(0);
- let matte = match tt {
+ let matte = match j.matte_mode.unwrap_or(0) {
1 => Some(MatteKind::Alpha),
2 => Some(MatteKind::AlphaInverted),
3 => Some(MatteKind::Luma),
4 => Some(MatteKind::LumaInverted),
_ => None,
}
- .map(|k| (k, j.get("tp").and_then(Json::as_i64)));
- pending.push((l.id, parent, matte, ji));
+ .map(|k| (k, j.matte_parent));
+ pending.push((l.id, comp.layers.len(), j.parent, matte, ji));
comp.layers.push(l);
}
- for (id, parent, matte, ji) in pending {
+ for (id, at, parent, matte, ji) in pending {
let parent_id = parent.and_then(|p| by_ind.get(&p).copied()).filter(|p| *p != id);
let matte_id = matte.and_then(|(k, tp)| {
let target = match tp {
@@ -515,21 +523,9 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
};
target.filter(|t| *t != id).map(|t| TrackMatte { layer: t, kind: k })
});
- if let Some(l) = comp.layer_mut(id) {
+ if let Some(l) = comp.layers.get_mut(at) {
l.parent = parent_id;
l.track_matte = matte_id;
}
}
}
-
-#[cfg(test)]
-mod hex_color_tests {
- use super::hex_color;
-
- #[test]
- fn hex_color_rejects_plus_sign() {
- assert_eq!(hex_color("#+f0000"), [0.0, 0.0, 0.0]);
- assert_eq!(hex_color("#zz0000"), [0.0, 0.0, 0.0]);
- assert_eq!(hex_color("#ff0000"), [1.0, 0.0, 0.0]);
- }
-}
diff --git a/crates/lottie/src/lib.rs b/crates/lottie/src/lib.rs
index 1b12cc8be..0a1f4bc96 100644
--- a/crates/lottie/src/lib.rs
+++ b/crates/lottie/src/lib.rs
@@ -1,7 +1,7 @@
//! Lottie JSON import and export.
//!
-//! Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the
-//! LottieFiles format documentation); no player or exporter source was used.
+//! The format itself (the document model, property and keyframe encoding, paths, gradients,
+//! embedded images) is `craft-lottie`; this crate maps it onto EffectCraft's project.
//!
//! **Export** ([`export_comp`]) turns a composition into a Lottie document: nested compositions
//! become precomp assets, still footage becomes embedded base64 image assets, and shape, solid,
@@ -21,7 +21,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod anim;
-mod base64;
mod export;
mod import;
mod shapes;
diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs
index fab8d3646..f28e73a04 100644
--- a/crates/lottie/src/shapes.rs
+++ b/crates/lottie/src/shapes.rs
@@ -1,23 +1,27 @@
//! Shape layer contents ↔ Lottie shape items (`gr`, `rc`, `el`, `sr`, `sh`, `fl`, `st`, `gf`,
-//! `gs`, `tm`, `rp`, `rd`, `op`, `pb`, `tw`, `zz`, `mm`, `tr`).
+//! `gs`, `tm`, `rp`, `rd`, `op`, `pb`, `tw`, `zz`, `mm`, `tr`), on `craft-lottie`'s model.
+use craft_lottie::model::{
+ Dash, Ellipse, Fill, GradientColors, GradientFill, GradientStroke, Group, Map, Merge, OffsetPath, Path, PolyStar, PuckerBloat, Rect, Repeater,
+ RepeaterTransform, RoundCorners, Shape, ShapeKind, Stroke, Transform, Trim, Twist, ZigZag,
+};
+use craft_lottie::{Animated, Bezier, Json};
use effectcraft_color::BlendMode;
use effectcraft_keyframe::{Gradient, ShapePath, Value};
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{Node, ParamUi, PropGroup};
-use serde_json::{Map, Value as Json, json};
+use serde_json::json;
use crate::anim::{self, Ex, Im, export_prop, import_prop};
// ---------------------------------------------------------------- shared value converters
-pub(crate) fn shape_json(p: &ShapePath) -> Json {
- let pts = |v: &[[f64; 2]]| v.iter().map(|q| json!([r(q[0]), r(q[1])])).collect::>();
- json!({"c": p.closed, "v": pts(&p.vertices), "i": pts(&p.in_tangents), "o": pts(&p.out_tangents)})
+pub(crate) fn bezier(p: &ShapePath) -> Bezier {
+ Bezier { closed: p.closed, vertices: p.vertices.clone(), in_tangents: p.in_tangents.clone(), out_tangents: p.out_tangents.clone() }
}
-fn r(v: f64) -> f64 {
- (v * 1e6).round() / 1e6
+pub(crate) fn shape_json(p: &ShapePath) -> Json {
+ bezier(p).to_json()
}
pub(crate) fn path_value(v: &Value) -> Json {
@@ -30,29 +34,8 @@ pub(crate) fn path_value(v: &Value) -> Json {
/// A Lottie path. `closed` is used when the path has no `c`: Bodymovin 3.x and early 4.x keep the
/// flag outside the path, as `cl` on a mask and `closed` on an `sh` item.
pub(crate) fn to_path(j: &Json, closed: bool) -> Option {
- let o = match j {
- Json::Array(a) => a.first()?,
- o => o,
- };
- let pts = |k: &str| -> Vec<[f64; 2]> {
- o.get(k)
- .and_then(Json::as_array)
- .map(|a| a.iter().map(|p| anim::nums(p).map(|v| [*v.first().unwrap_or(&0.0), *v.get(1).unwrap_or(&0.0)]).unwrap_or([0.0; 2])).collect())
- .unwrap_or_default()
- };
- let vertices = pts("v");
- let n = vertices.len();
- let fit = |mut v: Vec<[f64; 2]>| {
- v.resize(n, [0.0; 2]);
- v
- };
- Some(Value::Path(ShapePath {
- in_tangents: fit(pts("i")),
- out_tangents: fit(pts("o")),
- vertices,
- closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed),
- feather: Vec::new(),
- }))
+ let b = Bezier::from_json(j, closed)?;
+ Some(Value::Path(ShapePath { vertices: b.vertices, in_tangents: b.in_tangents, out_tangents: b.out_tangents, closed: b.closed, feather: Vec::new() }))
}
/// Gradient as Lottie's flat stop list: `[pos, r, g, b]…` then `[pos, alpha]…`.
@@ -61,29 +44,19 @@ pub(crate) fn gradient_flat(v: &Value) -> Json {
Value::Gradient(g) => g.clone(),
_ => Gradient::default(),
};
- let mut out = vec![];
- for (p, c) in &g.colors {
- out.extend([r(*p), r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)]);
+ craft_lottie::Gradient {
+ colors: g.colors.iter().map(|(p, c)| (*p, [f64::from(c[0]), f64::from(c[1]), f64::from(c[2])])).collect(),
+ opacities: g.opacities.iter().map(|(p, a)| (*p, f64::from(*a))).collect(),
}
- let plain = g.opacities.iter().all(|(_, a)| (*a - 1.0).abs() < 1e-6);
- if !plain {
- for (p, a) in &g.opacities {
- out.extend([r(*p), r(*a as f64)]);
- }
- }
- json!(out)
+ .to_flat()
}
pub(crate) fn to_gradient(j: &Json, ncolors: usize) -> Option {
- let v = anim::nums(j)?;
- let n = ncolors.min(v.len() / 4);
- let colors: Vec<(f64, [f32; 4])> = (0..n).map(|i| (v[i * 4], [v[i * 4 + 1] as f32, v[i * 4 + 2] as f32, v[i * 4 + 3] as f32, 1.0])).collect();
- let rest = &v[n * 4..];
- let mut opacities: Vec<(f64, f32)> = rest.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1] as f32)).collect();
- if opacities.is_empty() {
- opacities = vec![(0.0, 1.0), (1.0, 1.0)];
- }
- Some(Value::Gradient(Gradient { colors, opacities }))
+ let g = craft_lottie::Gradient::from_flat(j, ncolors)?;
+ Some(Value::Gradient(Gradient {
+ colors: g.colors.iter().map(|(p, [r, gr, b])| (*p, [*r as f32, *gr as f32, *b as f32, 1.0])).collect(),
+ opacities: g.opacities.iter().map(|(p, a)| (*p, *a as f32)).collect(),
+ }))
}
fn blend_index(m: BlendMode) -> u32 {
@@ -92,22 +65,18 @@ fn blend_index(m: BlendMode) -> u32 {
// ---------------------------------------------------------------- export
+/// A group's properties, exported with the warnings they need.
struct Out<'a, 'b> {
ex: &'a mut Ex,
g: &'b PropGroup,
- o: Map,
label: String,
}
impl Out<'_, '_> {
- fn put(&mut self, key: &str, m: &str, conv: &dyn Fn(&Value) -> Json) {
- if let Some(p) = self.g.get(m) {
- let v = export_prop(self.ex, p, conv, &format!("{} ▸ {}", self.label, p.name));
- self.o.insert(key.into(), v);
- }
- }
- fn raw(&mut self, key: &str, v: Json) {
- self.o.insert(key.into(), v);
+ /// The property `m` of the group, when it has one.
+ fn put(&mut self, m: &str, conv: &dyn Fn(&Value) -> Json) -> Option {
+ let p = self.g.get(m)?;
+ Some(export_prop(self.ex, p, conv, &format!("{} ▸ {}", self.label, p.name)))
}
fn e(&self, m: &str) -> u32 {
self.g.get(m).map(|p| p.value.as_enum()).unwrap_or(0)
@@ -121,34 +90,46 @@ impl Out<'_, '_> {
fn animated(&self, m: &str) -> bool {
self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression())
}
- fn blend(&mut self) {
+ fn blend(&mut self) -> i64 {
let i = self.e("blend");
let m = BlendMode::ALL.get(i as usize).copied().unwrap_or_default();
match crate::blend_to_lottie(m) {
- Some(bm) => self.raw("bm", json!(bm)),
+ Some(bm) => i64::from(bm),
None => {
self.ex.warn(format!("{}: blend mode {m:?} has no Lottie equivalent (Normal used)", self.label));
- self.raw("bm", json!(0));
+ 0
}
}
}
- fn done(self) -> Json {
- Json::Object(self.o)
+ fn direction(&self) -> i64 {
+ if self.e("direction") == 1 { 3 } else { 1 }
}
}
-/// Stroke fields shared by `st` and `gs`.
-fn stroke_fields(o: &mut Out) {
- o.put("w", "width", &anim::num);
- o.raw("lc", json!(o.e("cap") + 1));
- o.raw("lj", json!(o.e("join") + 1));
- o.raw("ml", json!(o.f("miter")));
+/// A static property (`{a: 0, k}`).
+pub(crate) fn fixed(k: Json) -> Animated {
+ Animated(json!({"a": 0, "k": k}))
+}
+
+/// Stroke fields shared by `st` and `gs`: width, cap, join, miter limit, dashes.
+struct StrokeOut {
+ width: Option,
+ cap: i64,
+ join: i64,
+ miter: f64,
+ dashes: Option>,
+}
+
+fn stroke_fields(o: &mut Out) -> StrokeOut {
+ let width = o.put("width", &anim::num);
+ let (cap, join, miter) = (i64::from(o.e("cap")) + 1, i64::from(o.e("join")) + 1, o.f("miter"));
let used = |g: Option<&PropGroup>, m: &str| g.and_then(|g| g.get(m)).is_some_and(|p| p.is_animated() || p.value.as_f64().abs() > 1e-9);
let (taper, wave) = (o.g.sub("taper"), o.g.sub("wave"));
if used(taper, "startLength") || used(taper, "endLength") || used(wave, "amount") {
let msg = format!("{}: stroke Taper and Wave have no Lottie equivalent (uniform width exported)", o.label);
o.ex.warn(msg);
}
+ let mut dashes = None;
if let Some(d) = o.g.sub("dashes") {
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
if dash > 0.0 || d.any_animated() {
@@ -156,202 +137,257 @@ fn stroke_fields(o: &mut Out) {
for (n, m) in [("d", "dash"), ("g", "gap"), ("d", "dash2"), ("g", "gap2"), ("d", "dash3"), ("g", "gap3"), ("o", "offset")] {
if let Some(p) = d.get(m) {
let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name));
- list.push(json!({"n": n, "nm": p.name, "v": v}));
+ list.push(Dash { kind: Some(n.into()), name: Some(p.name.clone()), length: Some(v), ..Default::default() });
}
}
- o.raw("d", json!(list));
+ dashes = Some(list);
}
}
+ StrokeOut { width, cap, join, miter, dashes }
}
-fn shape_transform(ex: &mut Ex, tr: &PropGroup, label: &str) -> Json {
- let mut o = Out { ex, g: tr, o: Map::new(), label: format!("{label} ▸ Transform") };
- o.raw("ty", json!("tr"));
- o.put("a", "anchor", &anim::arr2);
- o.put("p", "position", &anim::arr2);
- o.put("s", "scale", &anim::arr2);
- o.put("r", "rotation", &anim::num);
- o.put("o", "opacity", &anim::num);
- o.put("sk", "skew", &anim::num);
- o.put("sa", "skewAxis", &anim::num);
- o.put("so", "startOpacity", &anim::num);
- o.put("eo", "endOpacity", &anim::num);
- o.done()
+/// The `tr` of a group or a repeater: anchor, position, scale, rotation, opacity, skew, and the
+/// repeater's start and end opacity, by their Lottie keys.
+fn shape_transform(ex: &mut Ex, tr: &PropGroup, label: &str) -> Vec<(&'static str, Animated)> {
+ let mut o = Out { ex, g: tr, label: format!("{label} ▸ Transform") };
+ let mut out = vec![];
+ for (k, m, conv) in [
+ ("a", "anchor", anim::arr2 as fn(&Value) -> Json),
+ ("p", "position", anim::arr2),
+ ("s", "scale", anim::arr2),
+ ("r", "rotation", anim::num),
+ ("o", "opacity", anim::num),
+ ("sk", "skew", anim::num),
+ ("sa", "skewAxis", anim::num),
+ ("so", "startOpacity", anim::num),
+ ("eo", "endOpacity", anim::num),
+ ] {
+ if let Some(v) = o.put(m, &conv) {
+ out.push((k, v));
+ }
+ }
+ out
+}
+
+/// The `tr` item that ends a group's items.
+fn group_transform(keys: Vec<(&'static str, Animated)>) -> Shape {
+ let (mut t, mut extra) = (Transform::default(), Map::new());
+ for (k, v) in keys {
+ match k {
+ "a" => t.anchor = Some(v),
+ "p" => t.position = Some(v),
+ "s" => t.scale = Some(v),
+ "r" => t.rotation = Some(v),
+ "o" => t.opacity = Some(v),
+ "sk" => t.skew = Some(v),
+ "sa" => t.skew_axis = Some(v),
+ _ => {
+ extra.insert(k.into(), v.0);
+ }
+ }
+ }
+ Shape { kind: ShapeKind::Transform(Box::new(t)), extra, ..Default::default() }
+}
+
+/// A repeater's `tr`, which players read with `ty: "tr"` like a group's.
+fn repeater_transform(keys: Vec<(&'static str, Animated)>) -> RepeaterTransform {
+ let mut t = RepeaterTransform::default();
+ t.extra.insert("ty".into(), json!("tr"));
+ for (k, v) in keys {
+ match k {
+ "a" => t.anchor = Some(v),
+ "p" => t.position = Some(v),
+ "s" => t.scale = Some(v),
+ "r" => t.rotation = Some(v),
+ "so" => t.start_opacity = Some(v),
+ "eo" => t.end_opacity = Some(v),
+ "sk" => t.skew = Some(v),
+ "sa" => t.skew_axis = Some(v),
+ _ => {
+ t.extra.insert(k.into(), v.0);
+ }
+ }
+ }
+ t
}
/// Export a contents group's items (first item = top, as in the timeline).
-pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Vec {
+pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Vec {
let mut out = vec![];
for node in &contents.children {
let Node::Group(g) = node else { continue };
let lbl = format!("{label} ▸ {}", g.name);
- let ty = match g.match_id.as_str() {
- "group" => "gr",
- "rect" => "rc",
- "ellipse" => "el",
- "star" => "sr",
- "path" => "sh",
- "fill" => "fl",
- "stroke" => "st",
- "gfill" => "gf",
- "gstroke" => "gs",
- "trim" => "tm",
- "repeater" => "rp",
- "round" => "rd",
- "offset" => "op",
- "pucker" => "pb",
- "twist" => "tw",
- "zigzag" => "zz",
- "merge" => "mm",
- other => {
- ex.warn(format!("{lbl}: shape item `{other}` has no Lottie equivalent (skipped)"));
- continue;
+ let mut o = Out { ex, g, label: lbl.clone() };
+ let mut blend = None;
+ let kind = match g.match_id.as_str() {
+ "group" => {
+ let mut items = g.sub("contents").map(|c| export_items(o.ex, c, &lbl)).unwrap_or_default();
+ items.push(match g.sub("transform") {
+ Some(t) => group_transform(shape_transform(o.ex, t, &lbl)),
+ None => group_transform(vec![
+ ("a", fixed(json!([0, 0]))),
+ ("p", fixed(json!([0, 0]))),
+ ("s", fixed(json!([100, 100]))),
+ ("r", fixed(json!(0))),
+ ("o", fixed(json!(100))),
+ ]),
+ });
+ blend = Some(o.blend());
+ ShapeKind::Group(Group { count: Some(items.len() as f64), items: Some(items) })
}
- };
- let mut o = Out { ex, g, o: Map::new(), label: lbl.clone() };
- o.raw("ty", json!(ty));
- o.raw("nm", json!(g.name));
- if !g.enabled {
- o.raw("hd", json!(true));
- }
- let dir = |o: &mut Out| {
- let d = if o.e("direction") == 1 { 3 } else { 1 };
- o.raw("d", json!(d));
- };
- match ty {
- "gr" => {
- let items = g.sub("contents").map(|c| export_items(o.ex, c, &lbl)).unwrap_or_default();
- let mut items = items;
- let tr = match g.sub("transform") {
- Some(t) => shape_transform(o.ex, t, &lbl),
- None => {
- json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}})
- }
- };
- items.push(tr);
- o.raw("np", json!(items.len()));
- o.raw("it", json!(items));
- o.blend();
- }
- "rc" => {
- dir(&mut o);
- o.put("s", "size", &anim::arr2);
- o.put("p", "position", &anim::arr2);
- if o.flag("perCorner") {
+ "rect" => {
+ let direction = Some(o.direction());
+ let size = o.put("size", &anim::arr2);
+ let position = o.put("position", &anim::arr2);
+ let roundness = if o.flag("perCorner") {
let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"];
let radii = names.map(|m| o.f(m));
// Lottie `rc` has one `r`: four equal static radii export as that value.
// `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number.
if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 {
- o.raw("r", json!({"a": 0, "k": r(radii[0])}));
+ Some(fixed(json!(craft_lottie::round6(radii[0]))))
} else {
o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label));
- o.raw("r", json!({"a": 0, "k": 0}));
+ Some(fixed(json!(0)))
}
} else {
- o.put("r", "roundness", &anim::num);
- }
+ o.put("roundness", &anim::num)
+ };
+ ShapeKind::Rect(Rect { direction, position, size, roundness })
}
- "el" => {
- dir(&mut o);
- o.put("s", "size", &anim::arr2);
- o.put("p", "position", &anim::arr2);
+ "ellipse" => {
+ let direction = Some(o.direction());
+ let size = o.put("size", &anim::arr2);
+ ShapeKind::Ellipse(Ellipse { direction, position: o.put("position", &anim::arr2), size })
}
- "sr" => {
- dir(&mut o);
- o.raw("sy", json!(o.e("type") + 1));
- o.put("pt", "points", &anim::num);
- o.put("p", "position", &anim::arr2);
- o.put("r", "rotation", &anim::num);
- o.put("ir", "innerRadius", &anim::num);
- o.put("or", "outerRadius", &anim::num);
- o.put("is", "innerRoundness", &anim::num);
- o.put("os", "outerRoundness", &anim::num);
+ "star" => ShapeKind::PolyStar(PolyStar {
+ direction: Some(o.direction()),
+ star_type: Some(i64::from(o.e("type")) + 1),
+ points: o.put("points", &anim::num),
+ position: o.put("position", &anim::arr2),
+ rotation: o.put("rotation", &anim::num),
+ inner_radius: o.put("innerRadius", &anim::num),
+ outer_radius: o.put("outerRadius", &anim::num),
+ inner_roundness: o.put("innerRoundness", &anim::num),
+ outer_roundness: o.put("outerRoundness", &anim::num),
+ }),
+ "path" => ShapeKind::Path(Path { direction: Some(o.direction()), path: o.put("path", &path_value), closed: None }),
+ "fill" => {
+ let f = Fill { color: o.put("color", &anim::arr), opacity: o.put("opacity", &anim::num), rule: Some(i64::from(o.e("rule")) + 1) };
+ blend = Some(o.blend());
+ ShapeKind::Fill(f)
}
- "sh" => {
- dir(&mut o);
- o.put("ks", "path", &path_value);
+ "stroke" => {
+ let (color, opacity) = (o.put("color", &anim::arr), o.put("opacity", &anim::num));
+ let st = stroke_fields(&mut o);
+ blend = Some(o.blend());
+ ShapeKind::Stroke(Stroke {
+ color,
+ opacity,
+ width: st.width,
+ cap: Some(st.cap),
+ join: Some(st.join),
+ miter_limit: Some(st.miter),
+ miter_limit_animated: None,
+ dashes: st.dashes,
+ })
}
- "fl" => {
- o.put("c", "color", &anim::arr);
- o.put("o", "opacity", &anim::num);
- o.raw("r", json!(o.e("rule") + 1));
- o.blend();
- }
- "st" => {
- o.put("c", "color", &anim::arr);
- o.put("o", "opacity", &anim::num);
- stroke_fields(&mut o);
- o.blend();
- }
- "gf" | "gs" => {
- o.put("o", "opacity", &anim::num);
- o.put("s", "start", &anim::arr2);
- o.put("e", "end", &anim::arr2);
- o.raw("t", json!(o.e("type") + 1));
- o.put("h", "highlightLength", &anim::num);
- o.put("a", "highlightAngle", &anim::num);
+ m @ ("gfill" | "gstroke") => {
+ let opacity = o.put("opacity", &anim::num);
+ let (start, end) = (o.put("start", &anim::arr2), o.put("end", &anim::arr2));
+ let gradient_type = Some(i64::from(o.e("type")) + 1);
+ let (highlight_length, highlight_angle) = (o.put("highlightLength", &anim::num), o.put("highlightAngle", &anim::num));
let ncolors = match g.get("colors").map(|p| &p.value) {
Some(Value::Gradient(gr)) => gr.colors.len(),
_ => 2,
};
- if let Some(p) = g.get("colors") {
- let k = export_prop(o.ex, p, &gradient_flat, &format!("{lbl} ▸ Colors"));
- o.raw("g", json!({"p": ncolors, "k": k}));
- }
- if ty == "gf" {
- o.raw("r", json!(o.e("rule") + 1));
+ let colors = g.get("colors").map(|p| GradientColors {
+ count: Some(ncolors as i64),
+ stops: Some(export_prop(o.ex, p, &gradient_flat, &format!("{lbl} ▸ Colors"))),
+ ..Default::default()
+ });
+ let kind = if m == "gfill" {
+ ShapeKind::GradientFill(GradientFill {
+ colors,
+ start,
+ end,
+ gradient_type,
+ highlight_length,
+ highlight_angle,
+ opacity,
+ rule: Some(i64::from(o.e("rule")) + 1),
+ })
} else {
- stroke_fields(&mut o);
- }
- o.blend();
+ let st = stroke_fields(&mut o);
+ ShapeKind::GradientStroke(GradientStroke {
+ colors,
+ start,
+ end,
+ gradient_type,
+ highlight_length,
+ highlight_angle,
+ opacity,
+ width: st.width,
+ cap: Some(st.cap),
+ join: Some(st.join),
+ miter_limit: Some(st.miter),
+ miter_limit_animated: None,
+ dashes: st.dashes,
+ })
+ };
+ blend = Some(o.blend());
+ kind
}
- "tm" => {
- o.put("s", "start", &anim::num);
- o.put("e", "end", &anim::num);
- o.put("o", "offset", &anim::num);
- o.raw("m", json!(o.e("mode") + 1));
- }
- "rp" => {
- o.put("c", "copies", &anim::num);
- o.put("o", "offset", &anim::num);
+ "trim" => ShapeKind::Trim(Trim {
+ start: o.put("start", &anim::num),
+ end: o.put("end", &anim::num),
+ offset: o.put("offset", &anim::num),
+ multiple: Some(i64::from(o.e("mode")) + 1),
+ }),
+ "repeater" => {
+ let (copies, offset) = (o.put("copies", &anim::num), o.put("offset", &anim::num));
// Lottie: 1 = Above, 2 = Below; EffectCraft: 0 = Below, 1 = Above.
- o.raw("m", json!(if o.e("composite") == 1 { 1 } else { 2 }));
- if let Some(t) = g.sub("transform") {
- let tr = shape_transform(o.ex, t, &lbl);
- o.raw("tr", tr);
- }
+ let composite = Some(if o.e("composite") == 1 { 1 } else { 2 });
+ let transform = g.sub("transform").map(|t| repeater_transform(shape_transform(o.ex, t, &lbl)));
+ ShapeKind::Repeater(Repeater { copies, offset, composite, transform })
}
- "rd" => o.put("r", "radius", &anim::num),
- "op" => {
- o.put("a", "amount", &anim::num);
- o.raw("lj", json!(o.e("join") + 1));
- o.put("ml", "miter", &anim::num);
+ "round" => ShapeKind::RoundCorners(RoundCorners { radius: o.put("radius", &anim::num) }),
+ "offset" => {
+ let amount = o.put("amount", &anim::num);
+ let join = Some(i64::from(o.e("join")) + 1);
+ let miter_limit = o.put("miter", &anim::num);
if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 {
o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)"));
}
+ ShapeKind::OffsetPath(OffsetPath { amount, join, miter_limit })
}
- "pb" => o.put("a", "amount", &anim::num),
- "tw" => {
- o.put("a", "angle", &anim::num);
- o.put("c", "center", &anim::arr2);
+ "pucker" => ShapeKind::PuckerBloat(PuckerBloat { amount: o.put("amount", &anim::num) }),
+ "twist" => ShapeKind::Twist(Twist { angle: o.put("angle", &anim::num), center: o.put("center", &anim::arr2) }),
+ "zigzag" => ShapeKind::ZigZag(ZigZag {
+ frequency: o.put("size", &anim::num),
+ amount: o.put("ridges", &anim::num),
+ point_type: Some(fixed(json!(o.e("points") + 1))),
+ }),
+ "merge" => ShapeKind::Merge(Merge { mode: Some(i64::from(o.e("mode")) + 1) }),
+ other => {
+ o.ex.warn(format!("{lbl}: shape item `{other}` has no Lottie equivalent (skipped)"));
+ continue;
}
- "zz" => {
- o.put("s", "size", &anim::num);
- o.put("r", "ridges", &anim::num);
- o.raw("pt", json!({"a": 0, "k": o.e("points") + 1}));
- }
- "mm" => o.raw("mm", json!(o.e("mode") + 1)),
- _ => {}
- }
- out.push(o.done());
+ };
+ out.push(Shape { name: Some(g.name.clone()), hidden: (!g.enabled).then_some(true), blend_mode: blend, kind, ..Default::default() });
}
out
}
// ---------------------------------------------------------------- import
-fn set(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option) {
+fn set(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Animated>, conv: &dyn Fn(&Json) -> Option) {
+ if let (Some(j), Some(p)) = (j, g.get_mut(m)) {
+ import_prop(im, &j.0, p, conv);
+ }
+}
+
+fn set_json(im: &Im, g: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option) {
if let (Some(j), Some(p)) = (j, g.get_mut(m)) {
import_prop(im, j, p, conv);
}
@@ -363,43 +399,60 @@ fn set_enum(g: &mut PropGroup, m: &str, v: u32) {
}
}
-fn int(j: &Json, k: &str) -> Option {
- let v = j.get(k)?;
+/// A whole number however a file writes it: `2`, `2.0`, `2.4` (rounded) or a property `{k: 2}`.
+fn int_json(v: Option<&Json>) -> Option {
+ let v = v?;
v.as_i64().or_else(|| v.as_f64().map(|f| f.round() as i64)).or_else(|| v.get("k").and_then(anim::nums).and_then(|n| n.first().map(|f| f.round() as i64)))
}
-fn import_blend(j: &Json, g: &mut PropGroup) {
- if let Some(bm) = j.get("bm").and_then(Json::as_u64) {
+/// An integer key of the model, or the value the model kept in `extra` when it was written
+/// another way.
+fn int(field: Option, extra: &Map, key: &str) -> Option {
+ field.or_else(|| int_json(extra.get(key)))
+}
+
+fn import_blend(s: &Shape, g: &mut PropGroup) {
+ if let Some(bm) = int(s.blend_mode, &s.extra, "bm").and_then(|b| u64::try_from(b).ok()) {
set_enum(g, "blend", blend_index(crate::blend_from_lottie(bm)));
}
}
-fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) {
- set(im, g, "width", j.get("w"), &anim::to_scalar);
- if let Some(lc) = int(j, "lc") {
+/// The stroke keys of `st` and `gs`.
+struct StrokeIn<'a> {
+ width: Option<&'a Animated>,
+ cap: Option,
+ join: Option,
+ miter: Option,
+ miter_animated: Option<&'a Animated>,
+ dashes: Option<&'a [Dash]>,
+}
+
+fn import_stroke(im: &Im, ids: &mut Ids, st: StrokeIn<'_>, g: &mut PropGroup) {
+ set(im, g, "width", st.width, &anim::to_scalar);
+ if let Some(lc) = st.cap {
set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32);
}
- if let Some(lj) = int(j, "lj") {
+ if let Some(lj) = st.join {
set_enum(g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
}
if let Some(p) = g.get_mut("miter") {
- if let Some(ml) = j.get("ml").and_then(Json::as_f64) {
+ if let Some(ml) = st.miter {
p.value = Value::Scalar(ml);
- } else if let Some(ml2) = j.get("ml2") {
- import_prop(im, ml2, p, &anim::to_scalar);
+ } else if let Some(ml2) = st.miter_animated {
+ import_prop(im, &ml2.0, p, &anim::to_scalar);
}
}
- if let Some(list) = j.get("d").and_then(Json::as_array)
+ if let Some(list) = st.dashes
&& let Some(d) = g.sub_mut("dashes")
{
// The n-th `d` and `g` are the n-th Dash/Gap pair; the extra pairs are created as the Dashes "+" does.
let (mut dashes, mut gaps) = (0, 0);
for e in list {
- let (n, pair) = match e.get("n").and_then(Json::as_str) {
+ let (n, pair) = match e.kind.as_deref() {
Some("d") => (&mut dashes, 0),
Some("g") => (&mut gaps, 1),
Some("o") => {
- set(im, d, "offset", e.get("v"), &anim::to_scalar);
+ set(im, d, "offset", e.length.as_ref(), &anim::to_scalar);
continue;
}
_ => continue,
@@ -418,205 +471,299 @@ fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) {
[dm, gm][pair]
}
};
- set(im, d, m, e.get("v"), &anim::to_scalar);
+ set(im, d, m, e.length.as_ref(), &anim::to_scalar);
}
}
}
-fn import_shape_transform(im: &Im, j: &Json, tr: &mut PropGroup) {
- set(im, tr, "anchor", j.get("a"), &anim::to_vec2);
- set(im, tr, "position", j.get("p"), &anim::to_vec2);
- set(im, tr, "scale", j.get("s"), &anim::to_vec2);
- set(im, tr, "rotation", j.get("r"), &anim::to_scalar);
- set(im, tr, "opacity", j.get("o"), &anim::to_scalar);
- set(im, tr, "skew", j.get("sk"), &anim::to_scalar);
- set(im, tr, "skewAxis", j.get("sa"), &anim::to_scalar);
- set(im, tr, "startOpacity", j.get("so"), &anim::to_scalar);
- set(im, tr, "endOpacity", j.get("eo"), &anim::to_scalar);
+/// A `tr` (of a group or a repeater), looked up by its Lottie keys.
+fn import_shape_transform(im: &Im, get: &dyn Fn(&str) -> Option, tr: &mut PropGroup) {
+ for (k, m, conv) in [
+ ("a", "anchor", anim::to_vec2 as fn(&Json) -> Option),
+ ("p", "position", anim::to_vec2),
+ ("s", "scale", anim::to_vec2),
+ ("r", "rotation", anim::to_scalar),
+ ("o", "opacity", anim::to_scalar),
+ ("sk", "skew", anim::to_scalar),
+ ("sa", "skewAxis", anim::to_scalar),
+ ("so", "startOpacity", anim::to_scalar),
+ ("eo", "endOpacity", anim::to_scalar),
+ ] {
+ set_json(im, tr, m, get(k).as_ref(), &conv);
+ }
+}
+
+/// A named key of the model, or the same key kept in `extra`.
+fn key(a: Option<&Animated>, extra: &Map, k: &str) -> Option {
+ a.map(|a| a.0.clone()).or_else(|| extra.get(k).cloned())
+}
+
+fn group_tr_get<'a>(t: &'a Transform, item: &'a Map) -> impl Fn(&str) -> Option + 'a {
+ move |k| {
+ let named = match k {
+ "a" => t.anchor.as_ref(),
+ "p" => t.position.as_ref(),
+ "s" => t.scale.as_ref(),
+ "r" => t.rotation.as_ref(),
+ "o" => t.opacity.as_ref(),
+ "sk" => t.skew.as_ref(),
+ "sa" => t.skew_axis.as_ref(),
+ _ => None,
+ };
+ key(named, &t.extra, k).or_else(|| item.get(k).cloned())
+ }
+}
+
+fn repeater_tr_get(t: &RepeaterTransform) -> impl Fn(&str) -> Option + '_ {
+ move |k| {
+ let named = match k {
+ "a" => t.anchor.as_ref(),
+ "p" => t.position.as_ref(),
+ "s" => t.scale.as_ref(),
+ "r" => t.rotation.as_ref(),
+ "so" => t.start_opacity.as_ref(),
+ "eo" => t.end_opacity.as_ref(),
+ "sk" => t.skew.as_ref(),
+ "sa" => t.skew_axis.as_ref(),
+ _ => None,
+ };
+ key(named, &t.extra, k)
+ }
}
/// Import Lottie shape items into shape contents groups.
-pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec {
+pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &mut dyn Iterator- ) -> Vec
{
let mut out = vec![];
- for j in items {
- let ty = j.get("ty").and_then(Json::as_str).unwrap_or("");
- let dir = |g: &mut PropGroup| {
- if int(j, "d") == Some(3) {
+ for s in items {
+ let x = &s.extra;
+ let dir = |g: &mut PropGroup, d: Option| {
+ if int(d, x, "d") == Some(3) {
set_enum(g, "direction", 1);
}
};
- let mut g = match ty {
- "gr" => {
- let list = j.get("it").and_then(Json::as_array).cloned().unwrap_or_default();
- let inner: Vec = list.iter().filter(|i| i.get("ty").and_then(Json::as_str) != Some("tr")).cloned().collect();
- let children = import_items(im, ids, &inner);
+ let mut g = match &s.kind {
+ ShapeKind::Group(gr) => {
+ let list = gr.items.as_deref().unwrap_or_default();
+ // Borrowed, not cloned: each nesting level copying its subtree costs memory
+ // quadratic in the depth.
+ let children = import_items(im, ids, &mut list.iter().filter(|i| !matches!(i.kind, ShapeKind::Transform(_))));
let mut g = build::shape_group(ids, "Group 1", children);
- if let Some(tr) = list.iter().find(|i| i.get("ty").and_then(Json::as_str) == Some("tr"))
- && let Some(t) = g.sub_mut("transform")
+ if let Some(tr) = list.iter().find(|i| matches!(i.kind, ShapeKind::Transform(_)))
+ && let ShapeKind::Transform(t) = &tr.kind
+ && let Some(tg) = g.sub_mut("transform")
{
- import_shape_transform(im, tr, t);
+ import_shape_transform(im, &group_tr_get(t, &tr.extra), tg);
}
- import_blend(j, &mut g);
+ import_blend(s, &mut g);
g
}
- "rc" => {
+ ShapeKind::Rect(r) => {
let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], 0.0);
- dir(&mut g);
- set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
- set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
- set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar);
+ dir(&mut g, r.direction);
+ set(im, &mut g, "size", r.size.as_ref(), &anim::to_vec2);
+ set(im, &mut g, "position", r.position.as_ref(), &anim::to_vec2);
+ set(im, &mut g, "roundness", r.roundness.as_ref(), &anim::to_scalar);
// Lottie has one roundness: keep the per-corner values in step so ticking the box
// on an imported rectangle keeps the shape.
for m in ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"] {
- set(im, &mut g, m, j.get("r"), &anim::to_scalar);
+ set(im, &mut g, m, r.roundness.as_ref(), &anim::to_scalar);
}
g
}
- "el" => {
+ ShapeKind::Ellipse(e) => {
let mut g = build::shape_ellipse(ids, [100.0, 100.0], [0.0, 0.0]);
- dir(&mut g);
- set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
- set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
+ dir(&mut g, e.direction);
+ set(im, &mut g, "size", e.size.as_ref(), &anim::to_vec2);
+ set(im, &mut g, "position", e.position.as_ref(), &anim::to_vec2);
g
}
- "sr" => {
- let star = int(j, "sy").unwrap_or(1) != 2;
+ ShapeKind::PolyStar(p) => {
+ let star = int(p.star_type, x, "sy").unwrap_or(1) != 2;
let mut g = build::shape_star(ids, star, 5.0, [0.0, 0.0], 100.0, 50.0);
- dir(&mut g);
- set(im, &mut g, "points", j.get("pt"), &anim::to_scalar);
- set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
- set(im, &mut g, "rotation", j.get("r"), &anim::to_scalar);
- set(im, &mut g, "innerRadius", j.get("ir"), &anim::to_scalar);
- set(im, &mut g, "outerRadius", j.get("or"), &anim::to_scalar);
- set(im, &mut g, "innerRoundness", j.get("is"), &anim::to_scalar);
- set(im, &mut g, "outerRoundness", j.get("os"), &anim::to_scalar);
+ dir(&mut g, p.direction);
+ set(im, &mut g, "points", p.points.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "position", p.position.as_ref(), &anim::to_vec2);
+ set(im, &mut g, "rotation", p.rotation.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "innerRadius", p.inner_radius.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "outerRadius", p.outer_radius.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "innerRoundness", p.inner_roundness.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "outerRoundness", p.outer_roundness.as_ref(), &anim::to_scalar);
g
}
- "sh" => {
+ ShapeKind::Path(p) => {
let mut g = build::shape_path(ids, ShapePath::default());
- dir(&mut g);
- let closed = j.get("closed").and_then(Json::as_bool).unwrap_or(false);
- set(im, &mut g, "path", j.get("ks"), &|v| to_path(v, closed));
+ dir(&mut g, p.direction);
+ let closed = p.closed.unwrap_or(false);
+ set(im, &mut g, "path", p.path.as_ref(), &|v| to_path(v, closed));
g
}
- "fl" => {
+ ShapeKind::Fill(f) => {
let mut g = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
- set(im, &mut g, "color", j.get("c"), &anim::to_color);
- set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
- if int(j, "r") == Some(2) {
+ set(im, &mut g, "color", f.color.as_ref(), &anim::to_color);
+ set(im, &mut g, "opacity", f.opacity.as_ref(), &anim::to_scalar);
+ if int(f.rule, x, "r") == Some(2) {
set_enum(&mut g, "rule", 1);
}
- import_blend(j, &mut g);
+ import_blend(s, &mut g);
g
}
- "st" => {
+ ShapeKind::Stroke(st) => {
let mut g = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);
- set(im, &mut g, "color", j.get("c"), &anim::to_color);
- set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
- import_stroke(im, ids, j, &mut g);
- import_blend(j, &mut g);
- g
- }
- "gf" | "gs" => {
- let radial = int(j, "t") == Some(2);
- let mut g = if ty == "gf" {
- build::shape_gradient_fill(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default())
- } else {
- build::shape_gradient_stroke(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default(), 2.0)
+ set(im, &mut g, "color", st.color.as_ref(), &anim::to_color);
+ set(im, &mut g, "opacity", st.opacity.as_ref(), &anim::to_scalar);
+ let fields = StrokeIn {
+ width: st.width.as_ref(),
+ cap: int(st.cap, x, "lc"),
+ join: int(st.join, x, "lj"),
+ miter: st.miter_limit,
+ miter_animated: st.miter_limit_animated.as_ref(),
+ dashes: st.dashes.as_deref(),
};
- set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
- set(im, &mut g, "start", j.get("s"), &anim::to_vec2);
- set(im, &mut g, "end", j.get("e"), &anim::to_vec2);
- set(im, &mut g, "highlightLength", j.get("h"), &anim::to_scalar);
- set(im, &mut g, "highlightAngle", j.get("a"), &anim::to_scalar);
- if let Some(gj) = j.get("g") {
- let n = gj.get("p").and_then(Json::as_u64).unwrap_or(2) as usize;
- if let Some(k) = gj.get("k") {
- set(im, &mut g, "colors", Some(k), &move |v| to_gradient(v, n));
- }
- }
- if ty == "gf" {
- if int(j, "r") == Some(2) {
- set_enum(&mut g, "rule", 1);
- }
- } else {
- import_stroke(im, ids, j, &mut g);
- }
- import_blend(j, &mut g);
+ import_stroke(im, ids, fields, &mut g);
+ import_blend(s, &mut g);
g
}
- "tm" => {
+ ShapeKind::GradientFill(gf) => {
+ let radial = int(gf.gradient_type, x, "t") == Some(2);
+ let mut g = build::shape_gradient_fill(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default());
+ gradient_common(
+ im,
+ &mut g,
+ gf.opacity.as_ref(),
+ gf.start.as_ref(),
+ gf.end.as_ref(),
+ gf.highlight_length.as_ref(),
+ gf.highlight_angle.as_ref(),
+ gf.colors.as_ref(),
+ );
+ if int(gf.rule, x, "r") == Some(2) {
+ set_enum(&mut g, "rule", 1);
+ }
+ import_blend(s, &mut g);
+ g
+ }
+ ShapeKind::GradientStroke(gs) => {
+ let radial = int(gs.gradient_type, x, "t") == Some(2);
+ let mut g = build::shape_gradient_stroke(ids, radial, [0.0, 0.0], [100.0, 0.0], Gradient::default(), 2.0);
+ gradient_common(
+ im,
+ &mut g,
+ gs.opacity.as_ref(),
+ gs.start.as_ref(),
+ gs.end.as_ref(),
+ gs.highlight_length.as_ref(),
+ gs.highlight_angle.as_ref(),
+ gs.colors.as_ref(),
+ );
+ let fields = StrokeIn {
+ width: gs.width.as_ref(),
+ cap: int(gs.cap, x, "lc"),
+ join: int(gs.join, x, "lj"),
+ miter: gs.miter_limit,
+ miter_animated: gs.miter_limit_animated.as_ref(),
+ dashes: gs.dashes.as_deref(),
+ };
+ import_stroke(im, ids, fields, &mut g);
+ import_blend(s, &mut g);
+ g
+ }
+ ShapeKind::Trim(t) => {
let mut g = build::shape_trim(ids, 0.0, 100.0, 0.0);
- set(im, &mut g, "start", j.get("s"), &anim::to_scalar);
- set(im, &mut g, "end", j.get("e"), &anim::to_scalar);
- set(im, &mut g, "offset", j.get("o"), &anim::to_scalar);
- if int(j, "m") == Some(2) {
+ set(im, &mut g, "start", t.start.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "end", t.end.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "offset", t.offset.as_ref(), &anim::to_scalar);
+ if int(t.multiple, x, "m") == Some(2) {
set_enum(&mut g, "mode", 1);
}
g
}
- "rp" => {
+ ShapeKind::Repeater(r) => {
let mut g = build::shape_repeater(ids, 3.0, [100.0, 0.0]);
- set(im, &mut g, "copies", j.get("c"), &anim::to_scalar);
- set(im, &mut g, "offset", j.get("o"), &anim::to_scalar);
- set_enum(&mut g, "composite", if int(j, "m") == Some(1) { 1 } else { 0 });
- if let (Some(tr), Some(t)) = (j.get("tr"), g.sub_mut("transform")) {
- import_shape_transform(im, tr, t);
+ set(im, &mut g, "copies", r.copies.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "offset", r.offset.as_ref(), &anim::to_scalar);
+ set_enum(&mut g, "composite", if int(r.composite, x, "m") == Some(1) { 1 } else { 0 });
+ if let (Some(tr), Some(t)) = (r.transform.as_ref(), g.sub_mut("transform")) {
+ import_shape_transform(im, &repeater_tr_get(tr), t);
}
g
}
- "rd" | "op" | "pb" | "tw" | "zz" | "mm" => {
- let kind = match ty {
- "rd" => "round",
- "op" => "offset",
- "pb" => "pucker",
- "tw" => "twist",
- "zz" => "zigzag",
- _ => "merge",
- };
- let Some(mut g) = build::shape_simple_op(ids, kind) else { continue };
- match ty {
- "rd" => set(im, &mut g, "radius", j.get("r"), &anim::to_scalar),
- "op" => {
- set(im, &mut g, "amount", j.get("a"), &anim::to_scalar);
- if let Some(lj) = int(j, "lj") {
- set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
- }
- set(im, &mut g, "miter", j.get("ml"), &anim::to_scalar);
- }
- "pb" => set(im, &mut g, "amount", j.get("a"), &anim::to_scalar),
- "tw" => {
- set(im, &mut g, "angle", j.get("a"), &anim::to_scalar);
- set(im, &mut g, "center", j.get("c"), &anim::to_vec2);
- }
- "zz" => {
- set(im, &mut g, "size", j.get("s"), &anim::to_scalar);
- set(im, &mut g, "ridges", j.get("r"), &anim::to_scalar);
- if int(j, "pt") == Some(2) {
- set_enum(&mut g, "points", 1);
- }
- }
- _ => {
- if let Some(m) = int(j, "mm") {
- set_enum(&mut g, "mode", m.saturating_sub(1).clamp(0, 4) as u32);
- }
- }
+ ShapeKind::RoundCorners(r) => {
+ let Some(mut g) = build::shape_simple_op(ids, "round") else { continue };
+ set(im, &mut g, "radius", r.radius.as_ref(), &anim::to_scalar);
+ g
+ }
+ ShapeKind::OffsetPath(op) => {
+ let Some(mut g) = build::shape_simple_op(ids, "offset") else { continue };
+ set(im, &mut g, "amount", op.amount.as_ref(), &anim::to_scalar);
+ if let Some(lj) = int(op.join, x, "lj") {
+ set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
+ }
+ set(im, &mut g, "miter", op.miter_limit.as_ref(), &anim::to_scalar);
+ g
+ }
+ ShapeKind::PuckerBloat(pb) => {
+ let Some(mut g) = build::shape_simple_op(ids, "pucker") else { continue };
+ set(im, &mut g, "amount", pb.amount.as_ref(), &anim::to_scalar);
+ g
+ }
+ ShapeKind::Twist(tw) => {
+ let Some(mut g) = build::shape_simple_op(ids, "twist") else { continue };
+ set(im, &mut g, "angle", tw.angle.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "center", tw.center.as_ref(), &anim::to_vec2);
+ g
+ }
+ ShapeKind::ZigZag(zz) => {
+ let Some(mut g) = build::shape_simple_op(ids, "zigzag") else { continue };
+ set(im, &mut g, "size", zz.frequency.as_ref(), &anim::to_scalar);
+ set(im, &mut g, "ridges", zz.amount.as_ref(), &anim::to_scalar);
+ if int_json(zz.point_type.as_ref().map(|a| &a.0)) == Some(2) {
+ set_enum(&mut g, "points", 1);
}
g
}
- "tr" => continue,
- other => {
- im.warn(format!("shape item `{other}` is not supported (skipped)"));
+ ShapeKind::Merge(mm) => {
+ let Some(mut g) = build::shape_simple_op(ids, "merge") else { continue };
+ if let Some(m) = int(mm.mode, x, "mm") {
+ set_enum(&mut g, "mode", m.saturating_sub(1).clamp(0, 4) as u32);
+ }
+ g
+ }
+ ShapeKind::Transform(_) => continue,
+ ShapeKind::Other(ty) => {
+ im.warn(format!("shape item `{}` is not supported (skipped)", ty.as_str().unwrap_or("")));
continue;
}
};
- if let Some(nm) = j.get("nm").and_then(Json::as_str) {
- g.name = nm.to_string();
+ if let Some(nm) = &s.name {
+ g.name = nm.clone();
}
- if j.get("hd").and_then(Json::as_bool) == Some(true) {
+ if s.hidden == Some(true) {
g.enabled = false;
}
out.push(g);
}
out
}
+
+/// The keys `gf` and `gs` share.
+#[allow(clippy::too_many_arguments)]
+fn gradient_common(
+ im: &Im,
+ g: &mut PropGroup,
+ opacity: Option<&Animated>,
+ start: Option<&Animated>,
+ end: Option<&Animated>,
+ highlight_length: Option<&Animated>,
+ highlight_angle: Option<&Animated>,
+ colors: Option<&GradientColors>,
+) {
+ set(im, g, "opacity", opacity, &anim::to_scalar);
+ set(im, g, "start", start, &anim::to_vec2);
+ set(im, g, "end", end, &anim::to_vec2);
+ set(im, g, "highlightLength", highlight_length, &anim::to_scalar);
+ set(im, g, "highlightAngle", highlight_angle, &anim::to_scalar);
+ if let Some(c) = colors {
+ let n = c.count.and_then(|n| usize::try_from(n).ok()).unwrap_or(2);
+ set(im, g, "colors", c.stops.as_ref(), &move |v| to_gradient(v, n));
+ }
+}
diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs
index d26b3642d..35c52026d 100644
--- a/crates/lottie/src/tests.rs
+++ b/crates/lottie/src/tests.rs
@@ -381,7 +381,7 @@ fn offset_paths_miter_uses_animatable_lottie_value() {
assert_eq!(shapes[0]["ml"]["k"][1]["s"], json!([9.0]));
assert_eq!(shapes[1]["ml"], json!({"a": 0, "k": 6.0}));
assert_eq!(shapes[2]["ty"], "st");
- assert_eq!(shapes[2]["ml"], json!(7.0));
+ assert_eq!(shapes[2]["ml"].as_f64(), Some(7.0));
assert!(compare_comps(&p, cid, &q, nc) > 10);
let restored = &q.comp(nc).unwrap().layers[0];
assert_eq!(restored.props.group("contents/offset").unwrap().get("miter").unwrap().keys.len(), 2);
@@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() {
assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings);
}
+#[test]
+fn a_time_stretch_past_9900_percent_is_held_to_it() {
+ // `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept.
+ for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] {
+ let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [],
+ "layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]});
+ let mut q = Project::default();
+ let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap();
+ let l = &q.comp(imp.comp).unwrap().layers[0];
+ assert_eq!(l.stretch, want, "sr {sr}");
+ let warned = imp.warnings.iter().any(|w| w.contains("time stretch"));
+ assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings);
+ }
+}
+
#[test]
fn embedded_glyphs_are_reported_when_dropped() {
let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}});
@@ -710,7 +725,7 @@ fn effects_expressions_and_warnings() {
#[test]
fn images_embed_as_base64_and_come_back() {
let (mut p, cid) = setup();
- let png: Vec = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4, 5];
+ let png: Vec = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5];
let fid = p.add_item(
"pic.png",
Label::Lavender,
@@ -760,6 +775,17 @@ fn images_embed_as_base64_and_come_back() {
assert!(f.path.starts_with("/tmp/image_") && f.path.ends_with(".png"), "{}", f.path);
}
+#[test]
+fn image_signatures_are_matched_in_full() {
+ use crate::export::image_mime;
+ assert_eq!(image_mime(b"\x89PNG\r\n\x1a\n\0"), Some("image/png"));
+ assert_eq!(image_mime(b"GIF89a\0"), Some("image/gif"));
+ assert_eq!(image_mime(b"GIF87a\0"), Some("image/gif"));
+ // Near matches (a prefix of the signature, or text that starts with it) are not images.
+ assert_eq!(image_mime(b"\x89PNG\x01\x02\x03\x04"), None);
+ assert_eq!(image_mime(b"GIF8 is a text file"), None);
+}
+
#[test]
fn dotlottie_archive_roundtrip() {
let (mut p, cid) = setup();
@@ -970,3 +996,69 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() {
assert_eq!(rc(&json)["r"]["k"], json!(5.0));
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
}
+
+#[test]
+fn many_layers_and_precomps_import_with_their_parents_and_sizes() {
+ // 5 000 null layers each parented to the previous, and 2 000 empty precomp assets: the
+ // relationship and size passes index the layers instead of searching them per layer. (Each
+ // has its own name: `Comp::unique_layer_name` numbering one repeated name is a separate cost.)
+ let n = 5_000;
+ let layers: Vec = (1..=n).map(|i| json!({"ty": 3, "nm": format!("N{i}"), "ind": i, "parent": i - 1, "ip": 0, "op": 30, "st": 0, "ks": {}})).collect();
+ let mut assets: Vec = (0..2_000).map(|i| json!({"id": format!("c{i}"), "layers": []})).collect();
+ assets.push(json!({"id": "last", "layers": []}));
+ let mut layers = layers;
+ layers.push(json!({"ty": 0, "ind": n + 1, "refId": "last", "w": 50, "h": 40, "ip": 0, "op": 30, "st": 0, "ks": {}}));
+ let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": layers});
+ let mut q = Project::default();
+ let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
+ let c = q.comp(nc).unwrap();
+ assert_eq!(c.layers.len(), n as usize + 1);
+ assert_eq!(c.layers.get(0).unwrap().parent, None);
+ assert_eq!(c.layers.get(1).unwrap().parent, Some(c.layers.get(0).unwrap().id));
+ let LayerSource::Comp { item } = c.layers.get(n as usize).unwrap().source else { panic!("not a precomp") };
+ let pc = q.comp(item).unwrap();
+ assert_eq!((pc.width, pc.height), (50, 40));
+}
+
+#[test]
+fn deeply_nested_shape_groups_and_long_font_names_import() {
+ let mut g = json!({"ty": "rc", "s": {"a": 0, "k": [10, 10]}, "p": {"a": 0, "k": [0, 0]}, "r": {"a": 0, "k": 0}});
+ // 50 levels: each is two levels of JSON, within serde_json's recursion limit of 128.
+ for _ in 0..50 {
+ g = json!({"ty": "gr", "it": [g, {"ty": "tr", "o": {"a": 0, "k": 100}}]});
+ }
+ let family = "F".repeat(100_000);
+ let keys: Vec = (0..50).map(|i| json!({"t": i, "s": {"t": "x", "f": "A", "s": 10}})).collect();
+ let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 60,
+ "fonts": {"list": [{"fName": "A", "fFamily": family, "fStyle": "Regular"}]},
+ "layers": [
+ {"ty": 4, "ind": 1, "ip": 0, "op": 60, "st": 0, "ks": {}, "shapes": [g]},
+ {"ty": 5, "ind": 2, "ip": 0, "op": 60, "st": 0, "ks": {}, "t": {"d": {"k": keys}}}
+ ]});
+ let mut q = Project::default();
+ let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
+ let c = q.comp(nc).unwrap();
+ let st = c.layers.get(1).unwrap().props.prop("text/sourceText").unwrap();
+ assert_eq!(st.keys.len(), 50);
+ let Value::Text(d) = &st.keys[0].value else { panic!("not text") };
+ assert_eq!(d.font.len(), 256);
+}
+
+/// A footage item called a still whose file is not an image is not embedded.
+#[test]
+fn only_image_bytes_are_embedded() {
+ let (mut p, cid) = setup();
+ let fid = p.add_item(
+ "secret.png",
+ Label::Lavender,
+ None,
+ ItemKind::Footage(Footage { path: "/virtual/secret.png".into(), kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() }),
+ );
+ let c = comp(&p, cid);
+ let l = build::layer(&mut p, &c, "secret.png", LayerSource::Footage { item: fid }, (8, 8), None);
+ push(&mut p, cid, l);
+ let read = |path: &str| (path == "/virtual/secret.png").then(|| b"root:x:0:0:root:/root:/bin/sh\n".to_vec());
+ let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap();
+ assert!(res.json["assets"].as_array().is_none_or(|a| a.is_empty()), "{}", res.json["assets"]);
+ assert!(res.warnings.iter().any(|w| w.contains("not a PNG")), "{:?}", res.warnings);
+}
diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs
index 125835c2b..39199cfb3 100644
--- a/crates/lottie/src/text.rs
+++ b/crates/lottie/src/text.rs
@@ -1,14 +1,18 @@
//! Text layers ↔ Lottie text data (`t`: document keys `d`, animators `a`, more options `m`),
//! and text converted to glyph shapes.
+use craft_lottie::model::{
+ Font, Map, Path, Shape, ShapeKind, TextAlign, TextData, TextDocKey, TextDocs, TextDocument, TextFollowPath, TextRange, TextSelector, TextStyle,
+};
+use craft_lottie::{Animated, Json};
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_time::Tick;
-use serde_json::{Map, Value as Json, json};
+use serde_json::json;
use crate::anim::{self, Ex, Im, export_prop, import_prop};
-use crate::shapes;
+use crate::shapes::{self, fixed};
const JUSTIFY: [Justify; 7] =
[Justify::Left, Justify::Right, Justify::Center, Justify::JustifyLastLeft, Justify::JustifyLastRight, Justify::JustifyLastCenter, Justify::JustifyAll];
@@ -18,47 +22,49 @@ pub(crate) fn font_name(family: &str, style: &str) -> String {
format!("{}-{}", family.replace(' ', ""), style.replace(' ', ""))
}
-fn rgb(c: [f32; 4]) -> Json {
- json!([c[0] as f64, c[1] as f64, c[2] as f64])
+fn rgb(c: [f32; 4]) -> Vec {
+ vec![f64::from(c[0]), f64::from(c[1]), f64::from(c[2])]
}
-fn doc_json(d: &TextDoc) -> Json {
- let mut o = json!({
- "t": d.text.replace("\r\n", "\r").replace('\n', "\r"),
- "f": font_name(&d.font, &d.style),
- "s": d.size,
- "j": JUSTIFY.iter().position(|j| *j == d.justify).unwrap_or(0),
- "tr": d.tracking,
- "lh": d.leading.unwrap_or(d.size * 1.2),
- "ls": d.baseline_shift,
- "of": d.stroke_over_fill,
- });
- if d.apply_fill {
- o["fc"] = rgb(d.fill);
+fn doc_json(d: &TextDoc) -> TextDocument {
+ let stroke = d.apply_stroke && d.stroke_width > 0.0;
+ TextDocument {
+ text: Some(d.text.replace("\r\n", "\r").replace('\n', "\r")),
+ font: Some(font_name(&d.font, &d.style)),
+ size: Some(d.size),
+ justify: Some(JUSTIFY.iter().position(|j| *j == d.justify).unwrap_or(0) as i64),
+ tracking: Some(d.tracking),
+ line_height: Some(d.leading.unwrap_or(d.size * 1.2)),
+ baseline_shift: Some(d.baseline_shift),
+ stroke_over_fill: Some(d.stroke_over_fill),
+ fill: d.apply_fill.then(|| rgb(d.fill)),
+ stroke: stroke.then(|| rgb(d.stroke)),
+ stroke_width: stroke.then_some(d.stroke_width),
+ caps: if d.all_caps {
+ Some(1)
+ } else if d.small_caps {
+ Some(2)
+ } else {
+ None
+ },
+ box_size: d.box_size.map(|b| b.to_vec()),
+ box_position: d.box_size.map(|_| d.box_pos.to_vec()),
+ ..Default::default()
}
- if d.apply_stroke && d.stroke_width > 0.0 {
- o["sc"] = rgb(d.stroke);
- o["sw"] = json!(d.stroke_width);
- }
- if d.all_caps {
- o["ca"] = json!(1);
- } else if d.small_caps {
- o["ca"] = json!(2);
- }
- if let Some(b) = d.box_size {
- o["sz"] = json!(b);
- o["ps"] = json!(d.box_pos);
- }
- o
}
-fn doc_from(j: &Json, fonts: &[(String, String, String)]) -> TextDoc {
+/// A colour of a text document (`fc`, `sc`), 0..=1 or 0..=255.
+fn doc_color(c: Option<&Vec>, extra: &Map, key: &str) -> Option<[f32; 4]> {
+ let j = c.filter(|c| !c.is_empty()).map(|c| json!(c)).or_else(|| extra.get(key).cloned())?;
+ anim::to_color(&j).map(|v| v.as_color())
+}
+
+fn doc_from(j: &TextDocument, fonts: &[(String, String, String)]) -> TextDoc {
let mut d = TextDoc::default();
- let f = |k: &str| j.get(k).and_then(Json::as_f64);
- if let Some(t) = j.get("t").and_then(Json::as_str) {
+ if let Some(t) = &j.text {
d.text = t.replace(['\r', '\u{3}'], "\n");
}
- if let Some(name) = j.get("f").and_then(Json::as_str) {
+ if let Some(name) = &j.font {
match fonts.iter().find(|(n, _, _)| n == name) {
Some((_, fam, st)) => {
d.font = fam.clone();
@@ -71,40 +77,39 @@ fn doc_from(j: &Json, fonts: &[(String, String, String)]) -> TextDoc {
}
}
}
- if let Some(s) = f("s") {
+ if let Some(s) = j.size {
d.size = s;
}
- let col = |k: &str| j.get(k).and_then(anim::to_color).map(|v| v.as_color());
- match col("fc") {
+ match doc_color(j.fill.as_ref(), &j.extra, "fc") {
Some(c) => d.fill = c,
None => d.apply_fill = false,
}
- if let Some(c) = col("sc") {
+ if let Some(c) = doc_color(j.stroke.as_ref(), &j.extra, "sc") {
d.stroke = c;
d.apply_stroke = true;
- d.stroke_width = f("sw").unwrap_or(1.0);
+ d.stroke_width = j.stroke_width.unwrap_or(1.0);
}
- if let Some(of) = j.get("of").and_then(Json::as_bool) {
+ if let Some(of) = j.stroke_over_fill {
d.stroke_over_fill = of;
}
- if let Some(i) = j.get("j").and_then(Json::as_u64) {
- d.justify = JUSTIFY.get(i as usize).copied().unwrap_or_default();
+ if let Some(i) = j.justify.and_then(|i| usize::try_from(i).ok()) {
+ d.justify = JUSTIFY.get(i).copied().unwrap_or_default();
}
- d.tracking = f("tr").unwrap_or(0.0);
- if let Some(lh) = f("lh")
+ d.tracking = j.tracking.unwrap_or(0.0);
+ if let Some(lh) = j.line_height
&& (lh - d.size * 1.2).abs() > 1e-6
{
d.leading = Some(lh);
}
- d.baseline_shift = f("ls").unwrap_or(0.0);
- match j.get("ca").and_then(Json::as_u64) {
+ d.baseline_shift = j.baseline_shift.unwrap_or(0.0);
+ match j.caps {
Some(1) => d.all_caps = true,
Some(2) => d.small_caps = true,
_ => {}
}
- if let Some(sz) = j.get("sz").and_then(anim::nums).filter(|v| v.len() >= 2) {
+ if let Some(sz) = j.box_size.as_deref().filter(|v| v.len() >= 2) {
d.box_size = Some([sz[0], sz[1]]);
- if let Some(ps) = j.get("ps").and_then(anim::nums).filter(|v| v.len() >= 2) {
+ if let Some(ps) = j.box_position.as_deref().filter(|v| v.len() >= 2) {
d.box_pos = [ps[0], ps[1]];
}
}
@@ -136,28 +141,86 @@ fn conv_for(v: &Value) -> fn(&Value) -> Json {
}
}
+/// The field of a range animator's style for a Lottie key.
+fn style_field<'a>(st: &'a mut TextStyle, key: &str) -> Option<&'a mut Option> {
+ Some(match key {
+ "p" => &mut st.position,
+ "a" => &mut st.anchor,
+ "s" => &mut st.scale,
+ "r" => &mut st.rotation,
+ "rx" => &mut st.rotation_x,
+ "ry" => &mut st.rotation_y,
+ "sk" => &mut st.skew,
+ "sa" => &mut st.skew_axis,
+ "o" => &mut st.opacity,
+ "fc" => &mut st.fill,
+ "fh" => &mut st.fill_hue,
+ "fs" => &mut st.fill_saturation,
+ "fb" => &mut st.fill_brightness,
+ "fo" => &mut st.fill_opacity,
+ "sc" => &mut st.stroke,
+ "sw" => &mut st.stroke_width,
+ "so" => &mut st.stroke_opacity,
+ "t" => &mut st.tracking,
+ "ls" => &mut st.line_spacing,
+ "bl" => &mut st.blur,
+ _ => return None,
+ })
+}
+
+fn style_set(st: &mut TextStyle, key: &str, v: Animated) {
+ match style_field(st, key) {
+ Some(f) => *f = Some(v),
+ None => {
+ st.extra.insert(key.into(), v.0);
+ }
+ }
+}
+
+fn style_get(st: &TextStyle, key: &str) -> Option {
+ let named = match key {
+ "p" => &st.position,
+ "a" => &st.anchor,
+ "s" => &st.scale,
+ "r" => &st.rotation,
+ "sk" => &st.skew,
+ "sa" => &st.skew_axis,
+ "o" => &st.opacity,
+ "fc" => &st.fill,
+ "fh" => &st.fill_hue,
+ "fs" => &st.fill_saturation,
+ "fb" => &st.fill_brightness,
+ "sc" => &st.stroke,
+ "sw" => &st.stroke_width,
+ "t" => &st.tracking,
+ _ => &None,
+ };
+ named.as_ref().map(|a| a.0.clone()).or_else(|| st.extra.get(key).cloned())
+}
+
/// Export a text layer's `t` object. Fonts used are added to `fonts`.
-pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> Json {
- let Some(tg) = layer.props.sub("text") else { return json!({}) };
+pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> TextData {
+ let Some(tg) = layer.props.sub("text") else { return TextData::default() };
let label = &layer.name;
let mut docs = vec![];
let mut add_font = |d: &TextDoc| {
let n = font_name(&d.font, &d.style);
- if !fonts.iter().any(|f| f["fName"] == json!(n)) {
- fonts.push(json!({"fName": n, "fFamily": d.font, "fStyle": d.style, "ascent": 75}));
+ if !fonts.iter().any(|f| f.name.as_deref() == Some(n.as_str())) {
+ fonts.push(Font { name: Some(n), family: Some(d.font.clone()), style: Some(d.style.clone()), ascent: Some(75.0), ..Default::default() });
}
};
+ let key = |t: f64, d: &TextDoc| TextDocKey { time: Some(t), document: Some(doc_json(d)), ..Default::default() };
if let Some(st) = tg.get("sourceText") {
if st.keys.is_empty() {
if let Value::Text(d) = &st.value {
add_font(d);
- docs.push(json!({"t": 0, "s": doc_json(d)}));
+ docs.push(key(0.0, d));
}
} else {
for k in &st.keys {
if let Value::Text(d) = &k.value {
add_font(d);
- docs.push(json!({"t": ex.tb.frame(k.time), "s": doc_json(d)}));
+ docs.push(key(ex.tb.frame(k.time), d));
}
}
}
@@ -184,22 +247,28 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) ->
animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name)));
}
}
- let mut m = json!({"g": 1, "a": {"a": 0, "k": [0, 0]}});
+ let mut m = TextAlign { grouping: Some(1), anchor: Some(fixed(json!([0, 0]))), ..Default::default() };
if let Some(more) = tg.sub("moreOptions") {
- m["g"] = json!(more.get("anchorGrouping").map(|p| p.value.as_enum()).unwrap_or(0) + 1);
+ m.grouping = Some(i64::from(more.get("anchorGrouping").map(|p| p.value.as_enum()).unwrap_or(0)) + 1);
if let Some(p) = more.get("groupingAlignment") {
- m["a"] = export_prop(ex, p, &anim::arr2, &format!("{label} ▸ Grouping Alignment"));
+ m.anchor = Some(export_prop(ex, p, &anim::arr2, &format!("{label} ▸ Grouping Alignment")));
}
}
if tg.sub("pathOptions").and_then(|p| p.get("path")).is_some_and(|p| p.value.as_enum() > 0) {
ex.warn(format!("{label}: text on a path is not exported"));
}
- json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}})
+ TextData {
+ documents: Some(TextDocs { keys: Some(docs), ..Default::default() }),
+ ranges: Some(animators),
+ alignment: Some(m),
+ follow_path: Some(TextFollowPath::default()),
+ ..Default::default()
+ }
}
/// Text animators reproducing the style runs of `d` (fill, stroke, stroke width, size,
/// tracking, baseline shift) over their character ranges.
-fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec {
+fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec {
let base = d.base_style();
let mut out = vec![];
let mut start = 0usize;
@@ -211,63 +280,71 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec {
if *s == base || run.len == 0 {
continue;
}
- let k = |v: Json| json!({"a": 0, "k": v});
- let mut props = Map::new();
+ let k = fixed;
+ let mut props = TextStyle::default();
if s.apply_fill && s.fill != base.fill {
- props.insert("fc".into(), k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0])));
+ props.fill = Some(k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0])));
}
if s.apply_stroke && s.stroke != base.stroke {
- props.insert("sc".into(), k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0])));
+ props.stroke = Some(k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0])));
}
if (s.stroke_width - base.stroke_width).abs() > 1e-9 {
- props.insert("sw".into(), k(json!(s.stroke_width - base.stroke_width)));
+ props.stroke_width = Some(k(json!(s.stroke_width - base.stroke_width)));
}
if (s.size - base.size).abs() > 1e-9 && base.size > 0.0 {
let p = s.size / base.size * 100.0;
- props.insert("s".into(), k(json!([p, p, 100.0])));
+ props.scale = Some(k(json!([p, p, 100.0])));
}
if (s.tracking - base.tracking).abs() > 1e-9 {
// Lottie tracking is in 1/1000 em like the Character panel's.
- props.insert("t".into(), k(json!(s.tracking - base.tracking)));
+ props.tracking = Some(k(json!(s.tracking - base.tracking)));
}
if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 {
- props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0])));
+ props.position = Some(k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0])));
}
let lost = s.font != base.font || s.style != base.style || s.faux_bold != base.faux_bold || s.faux_italic != base.faux_italic;
if lost && !warned {
ex.warn(format!("{label}: per-character fonts and faux styles are not part of Lottie text (base font used)"));
warned = true;
}
- if props.is_empty() {
+ if props == TextStyle::default() {
continue;
}
- out.push(json!({
- "nm": format!("Style {a}–{b}"),
- "s": {
- "t": 0, "xe": k(json!(0)), "ne": k(json!(0)), "a": k(json!(100)), "b": 1, "rn": 0, "sh": 1,
- "r": 2, "m": k(json!(1)), "s": k(json!(a)), "e": k(json!(b)), "o": k(json!(0)),
- },
- "a": Json::Object(props),
- }));
+ let mut sel = TextSelector {
+ kind: Some(0),
+ ease_high: Some(k(json!(0))),
+ ease_low: Some(k(json!(0))),
+ amount: Some(k(json!(100))),
+ based_on: Some(1),
+ randomize: Some(0),
+ shape: Some(1),
+ units: Some(2),
+ start: Some(k(json!(a))),
+ end: Some(k(json!(b))),
+ offset: Some(k(json!(0))),
+ ..Default::default()
+ };
+ sel.extra.insert("m".into(), k(json!(1)).0);
+ out.push(TextRange { name: Some(format!("Style {a}–{b}")), selector: Some(sel), style: Some(props), ..Default::default() });
}
out
}
-fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json {
- let mut props = Map::new();
+fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> TextRange {
+ let mut props = TextStyle::default();
if let Some(pg) = a.sub("properties") {
for p in pg.props() {
match ANIM_PROPS.iter().find(|(_, _, m)| *m == p.match_id) {
Some((key, _, _)) => {
let v = export_prop(ex, p, &conv_for(&p.value), &format!("{label} ▸ {}", p.name));
- props.insert((*key).into(), v);
+ style_set(&mut props, key, v);
}
None if matches!(p.match_id.as_str(), "trackingType") => {}
None => ex.warn(format!("{label} ▸ {}: text animator property not supported by Lottie", p.name)),
}
}
}
- let mut sel = json!({"t": 0});
+ let mut sel = TextSelector { kind: Some(0), ..Default::default() };
let sels: Vec<&PropGroup> = a.sub("selectors").map(|s| s.groups().collect()).unwrap_or_default();
if sels.len() > 1 {
ex.warn(format!("{label}: only the first selector is exported (Lottie animators have one)"));
@@ -276,61 +353,56 @@ fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json {
if s.match_id != "rangeSelector" {
ex.warn(format!("{label}: {} is not supported by Lottie", s.name));
} else {
- for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] {
- if let Some(p) = s.get(m) {
- sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name));
- }
- }
+ let mut prop = |m: &str| s.get(m).map(|p| export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name)));
+ (sel.start, sel.end, sel.offset) = (prop("start"), prop("end"), prop("offset"));
if let Some(adv) = s.sub("advanced") {
- let e = |m: &str| adv.get(m).map(|p| p.value.as_enum()).unwrap_or(0) + 1;
- sel["r"] = json!(e("units"));
- sel["b"] = json!(e("basedOn"));
- sel["m"] = json!({"a": 0, "k": e("mode")});
- sel["sh"] = json!(e("shape"));
- sel["rn"] = json!(adv.get("randomize").is_some_and(|p| p.value.as_bool()) as u8);
- for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] {
- if let Some(p) = adv.get(m) {
- sel[k] = export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name));
- }
- }
+ let e = |m: &str| i64::from(adv.get(m).map(|p| p.value.as_enum()).unwrap_or(0)) + 1;
+ sel.units = Some(e("units"));
+ sel.based_on = Some(e("basedOn"));
+ sel.extra.insert("m".into(), json!({"a": 0, "k": e("mode")}));
+ sel.shape = Some(e("shape"));
+ sel.randomize = Some(i64::from(adv.get("randomize").is_some_and(|p| p.value.as_bool())));
+ let mut prop = |m: &str| adv.get(m).map(|p| export_prop(ex, p, &anim::num, &format!("{label} ▸ {}", p.name)));
+ (sel.amount, sel.smoothness, sel.ease_high, sel.ease_low) = (prop("amount"), prop("smoothness"), prop("easeHigh"), prop("easeLow"));
}
}
}
- json!({"nm": a.name, "s": sel, "a": Json::Object(props)})
+ TextRange { name: Some(a.name.clone()), selector: Some(sel), style: Some(props), ..Default::default() }
}
/// Import Lottie text data into a text layer's `text` group.
-pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGroup, fonts: &[(String, String, String)]) {
- if let Some(list) = t.get("d").and_then(|d| d.get("k")).and_then(Json::as_array)
+pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &TextData, tg: &mut PropGroup, fonts: &[(String, String, String)]) {
+ if let Some(list) = t.documents.as_ref().and_then(|d| d.keys.as_deref())
&& let Some(st) = tg.get_mut("sourceText")
{
- let docs: Vec<(f64, TextDoc)> =
- list.iter().filter_map(|k| Some((k.get("t").and_then(Json::as_f64).unwrap_or(0.0), doc_from(k.get("s")?, fonts)))).collect();
+ let docs: Vec<(f64, TextDoc)> = list.iter().filter_map(|k| Some((k.time.unwrap_or(0.0), doc_from(k.document.as_ref()?, fonts)))).collect();
if let Some((_, first)) = docs.first() {
st.value = Value::Text(Box::new(first.clone()));
}
if docs.len() > 1 {
- st.keys = docs.iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(*f), Value::Text(Box::new(d.clone()))).hold()).collect();
+ // Moved into the keys, not cloned: the documents are not kept twice.
+ st.keys = docs.into_iter().map(|(f, d)| effectcraft_keyframe::Keyframe::new(im.tb.tick(f), Value::Text(Box::new(d))).hold()).collect();
}
}
- if let Some(m) = t.get("m")
+ if let Some(m) = &t.alignment
&& let Some(more) = tg.sub_mut("moreOptions")
{
- if let (Some(g), Some(p)) = (m.get("g").and_then(Json::as_u64), more.get_mut("anchorGrouping")) {
+ let grouping = m.grouping.or_else(|| m.extra.get("g").and_then(Json::as_i64)).and_then(|g| u64::try_from(g).ok());
+ if let (Some(g), Some(p)) = (grouping, more.get_mut("anchorGrouping")) {
p.value = Value::Enum((g.max(1) - 1).min(3) as u32);
}
- if let (Some(a), Some(p)) = (m.get("a"), more.get_mut("groupingAlignment")) {
- import_prop(im, a, p, &anim::to_vec2);
+ if let (Some(a), Some(p)) = (&m.anchor, more.get_mut("groupingAlignment")) {
+ import_prop(im, &a.0, p, &anim::to_vec2);
}
}
- let Some(list) = t.get("a").and_then(Json::as_array) else { return };
+ let Some(list) = &t.ranges else { return };
let mut animators = vec![];
for (i, a) in list.iter().enumerate() {
- let name = a.get("nm").and_then(Json::as_str).map(str::to_string).unwrap_or(format!("Animator {}", i + 1));
- let props_j = a.get("a").cloned().unwrap_or(json!({}));
+ let name = a.name.clone().unwrap_or(format!("Animator {}", i + 1));
+ let style = a.style.clone().unwrap_or_default();
let mut kinds: Vec<&str> = vec![];
for (key, kind, _) in ANIM_PROPS {
- if props_j.get(*key).is_some() && !kinds.contains(kind) {
+ if style_get(&style, key).is_some() && !kinds.contains(kind) {
kinds.push(kind);
}
}
@@ -338,7 +410,7 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro
let mut g = build::text_animator(ids, &name, props);
if let Some(pg) = g.sub_mut("properties") {
for (key, _, m) in ANIM_PROPS {
- let (Some(j), Some(p)) = (props_j.get(*key), pg.get_mut(m)) else { continue };
+ let (Some(j), Some(p)) = (style_get(&style, key), pg.get_mut(m)) else { continue };
let conv: Box Option> = match &p.value {
Value::Vec3(d) => {
let z = d[2];
@@ -348,34 +420,38 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro
Value::Color(_) => Box::new(anim::to_color),
_ => Box::new(anim::to_scalar),
};
- import_prop(im, j, p, &*conv);
+ import_prop(im, &j, p, &*conv);
}
}
- if let Some(s) = a.get("s")
+ if let Some(s) = &a.selector
&& let Some(sel) = g.sub_mut("selectors").and_then(|s| s.groups_mut_first())
{
- for (k, m) in [("s", "start"), ("e", "end"), ("o", "offset")] {
- if let (Some(j), Some(p)) = (s.get(k), sel.get_mut(m)) {
- import_prop(im, j, p, &anim::to_scalar);
+ for (m, j) in [("start", &s.start), ("end", &s.end), ("offset", &s.offset)] {
+ if let (Some(j), Some(p)) = (j, sel.get_mut(m)) {
+ import_prop(im, &j.0, p, &anim::to_scalar);
}
}
if let Some(adv) = sel.sub_mut("advanced") {
- let mut en = |m: &str, key: &str, max: u32| {
- let v = s.get(key).and_then(|v| if v.is_object() { v.get("k") } else { Some(v) }).and_then(anim::nums).and_then(|n| n.first().copied());
+ let mut en = |m: &str, field: Option, key: &str, max: u32| {
+ let v = field.map(|f| f as f64).or_else(|| {
+ let v = s.extra.get(key)?;
+ let v = if v.is_object() { v.get("k")? } else { v };
+ anim::nums(v).and_then(|n| n.first().copied())
+ });
if let (Some(v), Some(p)) = (v, adv.get_mut(m)) {
p.value = Value::Enum((((v.round() as i64).saturating_sub(1)).max(0) as u32).min(max));
}
};
- en("units", "r", 1);
- en("basedOn", "b", 3);
- en("mode", "m", 5);
- en("shape", "sh", 5);
- if let (Some(rn), Some(p)) = (s.get("rn").and_then(Json::as_u64), adv.get_mut("randomize")) {
+ en("units", s.units, "r", 1);
+ en("basedOn", s.based_on, "b", 3);
+ en("mode", None, "m", 5);
+ en("shape", s.shape, "sh", 5);
+ if let (Some(rn), Some(p)) = (s.randomize.and_then(|r| u64::try_from(r).ok()), adv.get_mut("randomize")) {
p.value = Value::Bool(rn != 0);
}
- for (k, m) in [("a", "amount"), ("sm", "smoothness"), ("xe", "easeHigh"), ("ne", "easeLow")] {
- if let (Some(j), Some(p)) = (s.get(k), adv.get_mut(m)) {
- import_prop(im, j, p, &anim::to_scalar);
+ for (m, j) in [("amount", &s.amount), ("smoothness", &s.smoothness), ("easeHigh", &s.ease_high), ("easeLow", &s.ease_low)] {
+ if let (Some(j), Some(p)) = (j, adv.get_mut(m)) {
+ import_prop(im, &j.0, p, &anim::to_scalar);
}
}
}
@@ -399,7 +475,7 @@ impl FirstGroup for PropGroup {
/// Text converted to glyph shapes: one group with every glyph outline (at layer time 0's
/// Source Text), a fill and a stroke, as Lottie shape items.
-pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec {
+pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec {
let Some(tg) = layer.props.sub("text") else { return vec![] };
let Some(st) = tg.get("sourceText") else { return vec![] };
let Value::Text(doc) = st.value_at(Tick::ZERO) else { return vec![] };
@@ -411,14 +487,29 @@ pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec {
for g in &lay.glyphs {
let p = kurbo::Affine::translate((g.origin.x, g.origin.y)) * g.path.clone();
for sp in effectcraft_path::from_kurbo(&p) {
- items.push(json!({"ty": "sh", "nm": g.ch.to_string(), "d": 1, "ks": {"a": 0, "k": shapes::shape_json(&sp)}}));
+ let path = Path { direction: Some(1), path: Some(fixed(shapes::shape_json(&sp))), closed: None };
+ items.push(Shape { name: Some(g.ch.to_string()), kind: ShapeKind::Path(path), ..Default::default() });
}
}
- let col = |c: [f32; 4]| json!({"a": 0, "k": [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]});
- let stroke = (doc.apply_stroke && doc.stroke_width > 0.0).then(
- || json!({"ty": "st", "nm": "Stroke", "c": col(doc.stroke), "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": doc.stroke_width}, "lc": 2, "lj": 2, "ml": 4}),
- );
- let fill = doc.apply_fill.then(|| json!({"ty": "fl", "nm": "Fill", "c": col(doc.fill), "o": {"a": 0, "k": 100}, "r": 1}));
+ let col = |c: [f32; 4]| fixed(json!([c[0] as f64, c[1] as f64, c[2] as f64, 1.0]));
+ let stroke = (doc.apply_stroke && doc.stroke_width > 0.0).then(|| Shape {
+ name: Some("Stroke".into()),
+ kind: ShapeKind::Stroke(craft_lottie::model::Stroke {
+ color: Some(col(doc.stroke)),
+ opacity: Some(fixed(json!(100))),
+ width: Some(fixed(json!(doc.stroke_width))),
+ cap: Some(2),
+ join: Some(2),
+ miter_limit: Some(4.0),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+ let fill = doc.apply_fill.then(|| Shape {
+ name: Some("Fill".into()),
+ kind: ShapeKind::Fill(craft_lottie::model::Fill { color: Some(col(doc.fill)), opacity: Some(fixed(json!(100))), rule: Some(1) }),
+ ..Default::default()
+ });
// Items above draw on top: stroke over fill puts the stroke first.
if doc.stroke_over_fill {
items.extend(stroke);
@@ -428,6 +519,15 @@ pub(crate) fn glyph_shapes(ex: &mut Ex, layer: &Layer) -> Vec {
items.extend(stroke);
}
let n = items.len() + 1;
- items.push(json!({"ty": "tr", "a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}));
- vec![json!({"ty": "gr", "nm": layer.name, "np": n, "it": items})]
+ let tr = craft_lottie::model::Transform {
+ anchor: Some(fixed(json!([0, 0]))),
+ position: Some(fixed(json!([0, 0]))),
+ scale: Some(fixed(json!([100, 100]))),
+ rotation: Some(fixed(json!(0))),
+ opacity: Some(fixed(json!(100))),
+ ..Default::default()
+ };
+ items.push(Shape { kind: ShapeKind::Transform(Box::new(tr)), ..Default::default() });
+ let group = craft_lottie::model::Group { count: Some(n as f64), items: Some(items) };
+ vec![Shape { name: Some(layer.name.clone()), kind: ShapeKind::Group(group), ..Default::default() }]
}
diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml
index a4070297b..f744c684c 100644
--- a/crates/media/Cargo.toml
+++ b/crates/media/Cargo.toml
@@ -22,17 +22,26 @@ effectcraft-pdf = { workspace = true }
effectcraft-model = { workspace = true }
filmcraft-codecs = { workspace = true }
filmcraft-media = { workspace = true }
-filmcraft-aac = { workspace = true }
-filmcraft-isobmff = { workspace = true }
-filmcraft-matroska = { workspace = true }
+craft-dpx = { workspace = true }
+craft-exr = { workspace = true }
+craft-gif = { workspace = true }
+craft-webp = { workspace = true }
+craft-audio = { workspace = true }
+craft-media = { workspace = true }
filmcraft-mpegts = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-time = { workspace = true }
exr = { workspace = true }
-gif = { workspace = true }
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] }
rayon = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+
+[dev-dependencies]
+# Writes the animated GIFs of the tests.
+gif = { workspace = true }
+tempfile = { workspace = true }
+# (tests render comps that read layers through effects; already a dependency of effectcraft-render)
+effectcraft-effects = { workspace = true }
diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs
index 625c37b43..a1793b7af 100644
--- a/crates/media/src/aac.rs
+++ b/crates/media/src/aac.rs
@@ -6,7 +6,7 @@
use std::io;
-use filmcraft_aac::AudioSpecificConfig;
+use craft_audio::aac::AudioSpecificConfig;
use filmcraft_media::ByteReader;
/// How far into an MPEG TS / PS file the audio streams are looked for: the stream is indexed as
@@ -34,15 +34,16 @@ pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Op
/// are in its fragments, not the `moov`).
fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> {
let mp4 = match file {
- Some(f) => filmcraft_isobmff::open(Head(f, f.len())),
- None => filmcraft_isobmff::open(header),
+ Some(f) => craft_media::isobmff::open(Head(f, f.len())),
+ None => craft_media::isobmff::open(header),
};
let mp4 = mp4.ok()?;
- let playable =
- |t: &&filmcraft_isobmff::Track| t.kind == filmcraft_isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0;
+ let playable = |t: &&craft_media::isobmff::Track| {
+ t.kind == craft_media::isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0
+ };
let track = mp4.tracks.iter().find(playable)?;
match &track.entries.first()?.codec {
- filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()),
+ craft_media::isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()),
_ => None,
}
}
@@ -50,9 +51,9 @@ fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> {
/// The track FilmCraft's Matroska source plays: the first audio track with frames, found by
/// indexing the clusters as that source does.
fn matroska(file: &dyn ByteReader) -> Option> {
- let mkv = filmcraft_matroska::open(Head(file, file.len())).ok()?;
- match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio && !t.samples.is_empty())?.codec {
- filmcraft_matroska::Codec::Aac { asc } => Some(asc.clone()),
+ let mkv = craft_media::matroska::open(Head(file, file.len())).ok()?;
+ match &mkv.tracks.iter().find(|t| t.kind == craft_media::matroska::TrackKind::Audio && !t.samples.is_empty())?.codec {
+ craft_media::matroska::Codec::Aac { asc } => Some(asc.clone()),
_ => None,
}
}
@@ -88,16 +89,7 @@ impl Head<'_> {
}
}
-impl filmcraft_isobmff::ByteSource for Head<'_> {
- fn len(&self) -> u64 {
- self.1
- }
- fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
- self.read(offset, buf)
- }
-}
-
-impl filmcraft_matroska::ByteSource for Head<'_> {
+impl craft_media::ByteSource for Head<'_> {
fn len(&self) -> u64 {
self.1
}
diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs
index a2b24b3ae..86fb088f9 100644
--- a/crates/media/src/convert.rs
+++ b/crates/media/src/convert.rs
@@ -7,8 +7,9 @@
//! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation.
use effectcraft_project::{AlphaMode, Footage};
-use effectcraft_raster::{Image, Px};
+use effectcraft_raster::{Image, Px, quantize_px_u8};
use effectcraft_render::FloatCurve;
+use effectcraft_render::disk_cache::Frame8;
use filmcraft_color::{Matrix, Range};
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use rayon::prelude::*;
@@ -80,14 +81,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) -
return img;
}
match &f.data {
- PixelData::Rgba8(d) => {
- img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
- let src = &d[y * w * 4..(y + 1) * w * 4];
- for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
- *o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]);
- }
- });
- }
+ PixelData::Rgba8(d) => convert_rgba8(&mut img.data, d, w, op, |p| p),
PixelData::RgbaF32(d) => {
// linear premultiplied → encoded premultiplied
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
@@ -104,7 +98,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) -
let yuv = Yuv::new(f.color.matrix, f.color.range, 8);
let amax = 255.0;
convert_yuv(
- &mut img,
+ &mut img.data,
YuvSource {
size: (w, h),
chroma: *chroma,
@@ -114,13 +108,14 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) -
},
&yuv,
op,
+ |p| p,
);
}
PixelData::Yuv16 { planes, chroma, bits, alpha } => {
let yuv = Yuv::new(f.color.matrix, f.color.range, *bits);
let amax = ((1u32 << *bits) - 1) as f32;
convert_yuv(
- &mut img,
+ &mut img.data,
YuvSource {
size: (w, h),
chroma: *chroma,
@@ -130,12 +125,52 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) -
},
&yuv,
op,
+ |p| p,
);
}
}
img
}
+/// An 8-bit frame as [`frame_to_image_in`] converts it and an 8 bpc render then quantises it
+/// ([`effectcraft_raster::quantize_px`]), kept as the 8-bit levels: expanded with
+/// [`effectcraft_raster::expand_u8`] it is that quantised image to the bit, in a quarter of
+/// the memory. `None` for frames with more than 8 bits (10/12-bit Y'CbCr, float).
+pub(crate) fn frame_to_rgba8(f: &VideoFrame, op: AlphaOp) -> Option {
+ let (w, h) = (f.width as usize, f.height as usize);
+ let mut px = vec![[0u8; 4]; w * h];
+ if w > 0 && h > 0 {
+ match &f.data {
+ PixelData::Rgba8(d) => convert_rgba8(&mut px, d, w, op, quantize_px_u8),
+ PixelData::Yuv8 { planes, chroma, alpha } => convert_yuv(
+ &mut px,
+ YuvSource {
+ size: (w, h),
+ chroma: *chroma,
+ planes: [&planes[0][..], &planes[1][..], &planes[2][..]],
+ alpha: alpha.as_deref().map(|a| &a[..]),
+ amax: 255.0,
+ },
+ &Yuv::new(f.color.matrix, f.color.range, 8),
+ op,
+ quantize_px_u8,
+ ),
+ PixelData::RgbaF32(_) | PixelData::Yuv16 { .. } => return None,
+ }
+ }
+ Some(Frame8 { width: f.width, height: f.height, rgba: px.into_flattened() })
+}
+
+/// Straight or premultiplied 8-bit RGBA into `out` (one `O` per pixel, made by `store`).
+fn convert_rgba8(out: &mut [O], d: &[u8], w: usize, op: AlphaOp, store: impl Fn(Px) -> O + Sync) {
+ out.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
+ let Some(src) = d.get(y * w * 4..(y + 1) * w * 4) else { return };
+ for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
+ *o = store(op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]));
+ }
+ });
+}
+
/// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]:
/// clockwise quarter turns; anything else leaves it as decoded).
pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image {
@@ -311,13 +346,14 @@ struct YuvSource<'a, T> {
amax: f32,
}
-fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp) {
+/// Y'CbCr into `out` (one `O` per pixel, made by `store` from the converted pixel).
+fn convert_yuv