Merge remote-tracking branch 'origin/main' into jp/ja-dialogs

# Conflicts:
#	crates/ui-egui/src/panels/graph.rs
#	crates/ui-egui/src/panels/misc.rs
#	crates/ui-egui/src/panels/project.rs
This commit is contained in:
midasdf
2026-10-26 23:55:54 +09:00
253 changed files with 17018 additions and 5471 deletions
+21 -1
View File
@@ -1,9 +1,28 @@
# Attribution
Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has
Every non-code asset in EffectCraft, with its author, source and licence. Each repository file listed here also has
a `<file>.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe
iconography, images or other Adobe assets; open licences only; every asset attributed.
`cargo xtask assets` checks that this index and the sidecars cover every asset in the repository.
The complete licence texts and copyright notices for the bundled fonts, including the fonts embedded
by the pinned egui dependency, are reproduced in [`NOTICE`](NOTICE), which every package ships; the
`assets/fonts/OFL-*.txt` files are the upstream copies kept beside the fonts.
## Bundled dependency fonts
These fonts are embedded by the pinned egui dependency (epaint_default_fonts 0.37 at
`6d518fc799681571963842f95b55a6b68431b15a`); no font file is added to this repository.
egui-icons is always embedded, `default_fonts` adds Ubuntu Light and Hack, and
`monochrome_emoji_fonts` adds Noto Emoji and emoji-icon-font. The complete copyright notices
and licence texts are in [`NOTICE`](NOTICE).
| Asset | Author | Source | Licence |
|---|---|---|---|
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
| `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Google Inc. (The Noto Project Authors) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/OFL.txt | OFL-1.1 (complete text in `NOTICE`) |
| `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Canonical Ltd. and Dalton Maag | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/UFL.txt | Ubuntu Font Licence 1.0 (complete text in `NOTICE`) |
| `epaint_default_fonts/fonts/Hack-Regular.ttf` | Source Foundry Authors; Bitstream Inc. | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/Hack-Regular.txt; original https://github.com/source-foundry/Hack | MIT AND Bitstream-Vera (complete texts in `NOTICE`) |
## Asset files
@@ -15,6 +34,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/NotoSansSymbols2-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/symbols | OFL-1.1 (`assets/fonts/OFL-NotoSansSymbols2.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
Generated
+768 -348
View File
File diff suppressed because it is too large Load Diff
+29 -9
View File
@@ -4,15 +4,16 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.16.0"
version = "0.17.0"
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 = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", features = ["egui"] }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
@@ -102,14 +103,17 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
# gpu (the same wgpu eframe / egui-wgpu use)
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
bytemuck = "1"
pollster = "0.4"
pollster = "1"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
# 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 = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", 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"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
cpal = "0.16"
# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop).
@@ -216,4 +220,20 @@ lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", 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" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
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" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+272 -3
View File
@@ -12,11 +12,280 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Bundled fonts
EffectCraft embeds Inter, Noto Serif, JetBrains Mono and Noto Sans Symbols 2 from
assets/fonts/. The pinned egui dependency (epaint_default_fonts, revision
6d518fc799681571963842f95b55a6b68431b15a) additionally embeds Ubuntu Light, Hack,
Noto Emoji, egui-icons and emoji-icon-font through EffectCraft's enabled `default_fonts`
and `monochrome_emoji_fonts` features; no font file from that dependency is added to
this repository. The complete copyright notices and licence texts required for all of
these fonts follow.
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
SIL Open Font License 1.1
Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf),
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf), Noto Sans Symbols 2
(assets/fonts/NotoSansSymbols2-Regular.ttf) and Noto Emoji (NotoEmoji-Regular.ttf).
Copyright notices:
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
Copyright 2013 Google Inc. All Rights Reserved.
The fonts in assets/fonts/ also keep their upstream licence files at
assets/fonts/OFL-<family>.txt. The complete licence text follows.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu Font Licence 1.0
Applies to Ubuntu Light (Ubuntu-Light.ttf). Copyright notice:
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Hack: MIT License and Bitstream Vera License
Applies to Hack (Hack-Regular.ttf). Upstream notice and both licence texts:
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
MIT License (egui-icons and emoji-icon-font)
Applies to egui-icons.ttf, a five-glyph subset of emoji-icon-font, and the parent
emoji-icon-font.ttf, both by John Slegers, https://github.com/jslegers/emoji-icon-font.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+4 -1
View File
@@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import
}
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid. Give the
// wrapper the source's exact name, not Duplicate's numbered one, for `[compName]` outputs.
s.edit("Template Duration", None, |project, _| {
let name = project.item(ItemId(source)).ok_or_else(|| EngineError::Other("template composition is missing".into()))?.name.clone();
project.item_mut(ItemId(parent)).ok_or_else(|| EngineError::Other("template render composition is missing".into()))?.name = name;
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
let duration = comp.duration;
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
@@ -767,3 +767,45 @@ fn template_media_control_reports_a_path_that_cannot_be_imported() {
assert!(!dir.join("missing-out.png").exists(), "{reply}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Renders with `--template` name their outputs after the template's composition (#1937), not
/// the numbered name a duplicate would get. The name ends in a number and differs from the
/// archive's file name; an expression that looks the composition up by that name still finds it.
#[test]
fn template_render_outputs_keep_the_template_composition_name() {
let dir = std::env::temp_dir().join(format!("ec-qa-template-comp-name-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let build = BUILD.replace(r#"addComp("Lower Third""#, r#"addComp("Title Card 2""#).replace(
"var fill =",
"bar.property(\"ADBE Transform Group\").property(\"ADBE Position\").expression = '[comp(\"Title Card 2\").numLayers * 80, 65]';\nvar fill =",
);
std::fs::write(dir.join("build.jsx"), build).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Title Card 2","path":"card.ectemplate"}"#, "t.ecproj"]);
let template_before = std::fs::read(dir.join("card.ectemplate")).unwrap();
std::fs::write(dir.join("values.json"), r#"{"Title":"John Smith"}"#).unwrap();
for (case, values) in [("defaults", None), ("values", Some("values.json"))] {
let out = dir.join(case).join("[compName]_[#####].png");
let mut args =
vec!["render", "--template", "card.ectemplate", "--out", out.to_str().unwrap(), "--format", "png", "--start", "1", "--end", "1.0833333333333333"];
if let Some(values) = values {
args.extend(["--values", values]);
}
let rendered = ok(&dir, &args);
let rendered = rendered["rendered"].as_array().unwrap();
assert_eq!(rendered.len(), 1, "{case}: {rendered:?}");
assert_eq!(rendered[0]["comp"], "Title Card 2", "{case}: {rendered:?}");
let frames: Vec<_> = std::fs::read_dir(dir.join(case)).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(frames.len(), 1, "{case}: {frames:?}");
assert!(frames[0].starts_with("Title Card 2_") && frames[0].ends_with(".png"), "{case}: {frames:?}");
// The template's own two layers place the bar; the one-layer wrapper would move it left.
let frame = image::open(dir.join(case).join(&frames[0])).unwrap().to_rgba8();
let bar = frame.get_pixel(300, 75).0;
assert!(bar[0] > 240 && bar[1] < 15, "{case}: comp(\"Title Card 2\") is the template's composition: {bar:?}");
}
assert_eq!(std::fs::read(dir.join("card.ectemplate")).unwrap(), template_before);
let _ = std::fs::remove_dir_all(&dir);
}
+14 -14
View File
@@ -19,7 +19,7 @@ const enc = (k) => encodeURIComponent(k);
const dec = (n) => decodeURIComponent(n);
async function opfs() {
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
if (!navigator.storage?.getDirectory) throw new Error("no OPFS");
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(DIR, { create: true });
// Probe a write: some browsers expose OPFS but only write it from workers.
@@ -116,7 +116,7 @@ export async function storeOpen(prefer) {
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
}
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {}
return backend.name;
}
@@ -128,7 +128,7 @@ export async function storeDelete(key) { return backend.del(key); }
/// Ask the browser to keep the origin's storage under pressure; resolves with whether it is.
export async function storePersist() {
try {
if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist();
if (navigator.storage?.persist) return await navigator.storage.persist();
} catch {}
return false;
}
@@ -227,7 +227,7 @@ export async function audioStart(workletUrl) {
}
if (gen !== generation) return;
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; };
kind = "worklet";
} catch (e) {
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
@@ -318,11 +318,11 @@ function spawn(base) {
const w = { worker, files: new Map(), ready: null, gpu: null };
w.ready = new Promise((res, rej) => {
worker.onmessage = (e) => {
if (e.data && e.data.type === "gpu") {
if (e.data?.type === "gpu") {
w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" };
jobGpu = w.gpu;
}
if (e.data && e.data.type === "ready") res();
if (e.data?.type === "ready") res();
};
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
});
@@ -341,7 +341,7 @@ export function workerRun(id, json, files, base, onMessage) {
} catch (e) {
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
// fails like one whose worker didn't start.
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
setTimeout(() => onMessage("error", String(e?.message || e), null, null));
return;
}
running.set(id, w);
@@ -416,12 +416,12 @@ export function frameWorkerStart(base, onMessage, gpu) {
// slot stays, and the failure is reported like a worker that died, after the caller has
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
frameWorkers.push(null);
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed")));
return index;
}
worker.onmessage = (e) => {
const m = e.data;
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
};
worker.onerror = (e) => {
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
@@ -447,7 +447,7 @@ const cacheDirHandles = {};
async function cacheDir(kind = "frames") {
if (cacheDirHandles[kind]) return cacheDirHandles[kind];
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System");
if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System");
let d = await navigator.storage.getDirectory();
for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true });
cacheDirHandles[kind] = d;
@@ -544,7 +544,7 @@ export async function cacheClear() {
/// Whether this thread has WebGPU (`navigator.gpu`).
export function hasWebGpu() {
return !!(self.navigator && self.navigator.gpu);
return !!self.navigator?.gpu;
}
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
@@ -652,7 +652,7 @@ export async function browseForget(name) {
export async function browseRead(path) {
const rel = path.replace(/^\/Folders\//, "");
const folder = folders.get(rel.split("/")[0]);
const h = folder && folder.files.get(rel);
const h = folder?.files.get(rel);
if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`);
return new Uint8Array(await (await h.getFile()).arrayBuffer());
}
@@ -706,7 +706,7 @@ export function showDeviceLost(message, kept) {
self.effectcraft
.execute("file.save", {})
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
.catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`));
const reload = document.createElement("button");
reload.textContent = "Reload";
reload.onclick = async () => {
@@ -714,7 +714,7 @@ export function showDeviceLost(message, kept) {
try {
await self.effectcraft.flush();
} catch (e) {
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`;
reload.textContent = "Reload Anyway";
reload.disabled = false;
reload.onclick = () => location.reload();
+20 -9
View File
@@ -87,6 +87,17 @@ pub struct WebApp {
impl eframe::App for WebApp {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Files dropped on the page are input: read them here, where the browser's drop event is
// still the current pass's, not from a later (possibly logic-only) tick.
//
// The list is *taken* out of the raw input: a hidden tab runs `logic` and eframe appends
// the same prepared raw input back for the next pass, so leaving the files there would
// import them again on every hidden logic tick.
let dropped_files = std::mem::take(&mut raw_input.dropped_files);
let dropped: Vec<web_sys::File> = dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect();
if !dropped.is_empty() {
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
}
self.app.raw_input_hook(ctx, raw_input);
}
@@ -95,18 +106,14 @@ impl eframe::App for WebApp {
for f in actions {
f(&mut self.app);
}
// Files dropped on the page: read them, then open/import.
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
if !dropped.is_empty() {
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
}
self.app.logic(ctx, frame);
self.app.tick(ctx, frame);
api::poll_replies();
// Render Queue outputs written this frame (here or by a worker) → downloads.
files::flush_downloads();
// What a reload comes back to.
if self.persist {
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
// The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick.
persist::snapshot(&self.app.session, self.app.now(), 1.0);
}
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
api::set_info("frameWorkers", frames::stats());
@@ -114,6 +121,7 @@ impl eframe::App for WebApp {
let d = &self.app.session.prefs.disk;
diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota());
api::set_info("diskCache", diskcache::stats());
self.app.finish_ime_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -195,7 +203,9 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
let home = query_flag("home");
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
// the last session come back.
let t0 = js_sys::Date::now();
// Monotonic (performance.now) rather than the wall clock: a system-time jump must not turn
// the storage-load report negative or huge.
let t0 = web_time::Instant::now();
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
Ok(n) => n,
Err(e) => {
@@ -203,7 +213,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
0
}
};
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0}));
audio::install_unlock();
browse::restore();
diskcache::init();
@@ -214,6 +224,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
canvas,
eframe::WebOptions::default(),
Box::new(move |cc| {
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
// The page owns this shared presentation device; worker devices retain their
// separate owned-device handlers. No browser error-scope future is blocked on.
+1 -1
View File
@@ -52,7 +52,7 @@ struct State {
static STATE: LazyLock<Mutex<State>> = LazyLock::new(Mutex::default);
fn state() -> std::sync::MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The web app's Media Browser.
+4 -4
View File
@@ -48,14 +48,14 @@ pub fn list() -> Vec<(String, usize)> {
/// The next write of `path` goes to browser storage only (no download).
pub fn save_quietly(path: &str) {
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
}
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
/// downloaded at the next [`flush_downloads`].
pub fn add_output(path: &str, data: Arc<[u8]>) {
STORE.put_file(path, data, false);
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
}
/// The table path for a picked/dropped file name.
@@ -120,7 +120,7 @@ impl Services for WebServices {
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
put(path, data.into());
let quiet = {
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
let mut q = QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let n = q.len();
q.retain(|p| p != path);
q.len() != n
@@ -184,7 +184,7 @@ pub fn export_sink() -> Arc<effectcraft_export::Sink> {
/// Download the render outputs written since the last call.
pub fn flush_downloads() {
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()));
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out.dedup_by(|a, b| a.0 == b.0);
+1 -1
View File
@@ -181,7 +181,7 @@ impl Default for Store {
impl Store {
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
self.mirror.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn now(&self) -> f64 {
+8 -8
View File
@@ -162,7 +162,7 @@ try {
const info = await js("effectcraft.info()");
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
if (info.webgpu && info.backend === "BrowserWebGpu") {
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
check(info.gpu?.compositor, "GPU compositor on WebGPU");
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
}
@@ -250,7 +250,7 @@ try {
const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`);
report.steps.storage = st;
check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`);
check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
check(st.diskCache?.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
await js(`effectcraft.execute("prefs.open", {page: "disk"})`);
await sleep(800);
report.steps.storage.screenshot = await shot("01c-storage-manager");
@@ -378,14 +378,14 @@ try {
const jobs = gj.workers.jobs || [];
const rj = jobs.filter((j) => j.kind === "Render").pop();
const wj = jobs.filter((j) => j.kind === "Warp").pop();
check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
check(gj.workers.gpu?.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`);
check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
check(rj?.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`);
check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
check(wj?.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
const fj = jobs.filter((j) => j.kind === "ContentFill").pop();
check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`);
check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
check(fj?.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`);
check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`);
} else {
@@ -418,7 +418,7 @@ try {
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
check(!mbImport.pending && mbImport.items?.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
await js(`effectcraft.execute("time.set", {time: 0})`);
await js(`effectcraft.request("ui.playback", {action: "play"})`);
@@ -579,7 +579,7 @@ try {
report.steps.final2 = { screenshot: await shot("03-offline") };
} catch (e) {
failed = true;
report.error = String(e && e.stack || e);
report.error = String(e?.stack || e);
try { report.errorShot = await shot("error"); } catch {}
}
report.console = logs.slice(-40);
+4 -1
View File
@@ -15,7 +15,7 @@ effectcraft-host = { workspace = true }
effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
# `links`: links (Help menu, Discord, Settings) open in the system browser.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
serde = { workspace = true }
serde_json = { workspace = true }
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
@@ -48,3 +48,6 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+60 -15
View File
@@ -50,7 +50,7 @@ fn app_icon() -> egui::IconData {
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
}
@@ -128,17 +128,32 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
/// answer without pumping events, so the window stops answering the compositor and the desktop
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the
/// main window's handle is at hand.
fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let ctx = ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
let (tx, rx) = std::sync::mpsc::channel::<sheet_dialogs::Asked>();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| {
if tx.send((dialog, done)).is_err() {
log::error!("file dialog: the window is gone");
}
}));
Some(rx)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = (app, ctx);
{
let _ = app;
None
}
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
@@ -203,6 +218,7 @@ fn main() -> eframe::Result {
"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"),
@@ -263,6 +279,7 @@ fn main() -> eframe::Result {
// reported in a toast and forgotten).
let _ = session.execute("window.restoreScriptPanels", json!({}));
let mut app = EffectcraftApp::new(session);
app.set_startup_update_check(true);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
@@ -319,7 +336,7 @@ fn main() -> eframe::Result {
}));
// The commands' file dialogs don't block the frame where the platform's backend
// would (#511, #1275); see [`install_file_dialogs`].
install_file_dialogs(&mut app, &cc.egui_ctx);
let file_dialogs = install_file_dialogs(&mut app);
#[cfg(target_os = "macos")]
{
app.hooks.app_action = Some(Box::new(native_menu::app_action));
@@ -337,6 +354,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
file_dialogs,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
@@ -371,15 +389,22 @@ fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfigu
// Iris Pro: the device is lost and the window never opens.
// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter.limits()),
..Default::default()
});
setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
}
config
}
/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
/// so memory the compositor frees on the shared device goes back to the driver.
fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter),
memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
..Default::default()
}
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
@@ -411,6 +436,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
/// The desktop app: the egui app plus, on macOS, the system menu bar.
struct Desktop {
app: EffectcraftApp,
/// The commands' file dialogs, shown at the end of the frame that asked for them.
file_dialogs: Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>>,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// Documents opened from Finder, the Dock or `open` (macOS).
@@ -427,11 +454,18 @@ impl eframe::App for Desktop {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.menu.update(&mut self.app, ctx);
self.app.logic(ctx, frame);
self.app.tick(ctx, frame);
self.app.finish_ime_logic(ctx);
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ctx);
}
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ui.ctx());
}
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
@@ -539,6 +573,17 @@ mod tests {
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
#[test]
fn the_window_device_favours_memory_usage() {
use eframe::wgpu::Limits;
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
let d = super::device_descriptor(big);
assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
assert_eq!(d.label, Some("EffectCraft device"));
}
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
#[test]
fn launch_classifies_xml_and_json_projects_as_projects() {
@@ -620,10 +665,10 @@ mod tests {
#[test]
fn commands_file_dialogs_do_not_block_the_frame() {
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
super::install_file_dialogs(&mut app, &egui::Context::default());
let asked = super::install_file_dialogs(&mut app);
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
assert!(app.hooks.file_dialog_async.is_some() && asked.is_some(), "the commands' file dialogs must not block the frame");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
assert!(app.hooks.file_dialog_async.is_none() && asked.is_none(), "the blocking dialog pumps its own messages here");
}
}
+8 -7
View File
@@ -2,10 +2,10 @@
//! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree.
//!
//! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window
//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels,
//! enabled and check state into the existing items; a structural change (Open Recent, a new
//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS
//! swaps back to the in-window bar.
//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it
//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a
//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸
//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, channel};
@@ -24,7 +24,7 @@ pub struct NativeBar {
items: HashMap<String, CheckMenuItem>,
spec: NativeMenu,
structure: u64,
state: Option<u64>,
state: Option<(u64, bool)>,
events: Receiver<MenuEvent>,
/// The in-window-bar setting last applied (None: not yet).
in_window: Option<bool>,
@@ -67,12 +67,13 @@ impl NativeBar {
if in_window {
return;
}
let key = native_menu::state_key(app);
// While a text field has the keyboard, the Edit items it takes stay enabled.
let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some());
if self.state == Some(key) && self.menu.is_some() {
return;
}
self.state = Some(key);
let spec = native_menu::build(app);
let spec = native_menu::build_now(app, ctx);
let structure = spec.structure();
if self.menu.is_none() || structure != self.structure {
self.install(&spec);
+32 -11
View File
@@ -17,10 +17,24 @@ use std::pin::Pin;
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
/// closes, then wake the UI.
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
let answer = open(dialog);
/// A command's dialog waiting for [`show_asked`].
pub type Asked = (FileDialog, DialogDone);
/// Show the dialogs the commands asked for in this frame (on the main thread: a sheet opens
/// here) and hand each one's paths to its `done` once it closes, then wake the UI.
///
/// On macOS the sheet goes on the main window (`frame`'s). Without a parent rfd puts it on
/// AppKit's main window, which is a floating panel's OS window after a click in one; that
/// window hides while the app has no focused window, and a sheet whose window goes away ends
/// without answering, with later sheets queued behind it on the same window.
pub fn show_asked(asked: &std::sync::mpsc::Receiver<Asked>, frame: &eframe::Frame, ctx: &egui::Context) {
while let Ok((dialog, done)) = asked.try_recv() {
show(dialog, done, frame, ctx.clone());
}
}
fn show(dialog: FileDialog, done: DialogDone, frame: &eframe::Frame, ctx: egui::Context) {
let answer = open(dialog, frame);
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
done(pollster::block_on(answer));
ctx.request_repaint();
@@ -30,6 +44,13 @@ pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
}
}
/// A dialog on the main window. Linux keeps the portal's dialog unparented: rfd exports the
/// window for the portal from the dialog's worker thread there.
fn parented(frame: &eframe::Frame) -> AsyncFileDialog {
let d = AsyncFileDialog::new();
if cfg!(target_os = "macos") { d.set_parent(frame) } else { d }
}
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
}
@@ -39,10 +60,10 @@ fn path(file: Option<FileHandle>) -> Vec<String> {
}
/// The dialog, as the blocking hooks in `main.rs` show it.
fn open(dialog: FileDialog) -> Answer {
fn open(dialog: FileDialog, frame: &eframe::Frame) -> Answer {
match dialog {
FileDialog::Open { exts, many } => {
let d = AsyncFileDialog::new().add_filter("Media", &exts);
let d = parented(frame).add_filter("Media", &exts);
if many {
let f = d.pick_files();
Box::pin(async move { paths(f.await) })
@@ -52,22 +73,22 @@ fn open(dialog: FileDialog) -> Answer {
}
}
FileDialog::OpenProject => {
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
let f = parented(frame).add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
Box::pin(async move { path(f.await) })
}
FileDialog::Folder => {
let f = AsyncFileDialog::new().pick_folder();
let f = parented(frame).pick_folder();
Box::pin(async move { path(f.await) })
}
FileDialog::Save { default, ext, project } => {
let at = std::path::Path::new(&default);
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut d = if project {
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
parented(frame).add_filter("EffectCraft Project", &["ecproj"])
} else if ext.is_empty() {
AsyncFileDialog::new()
parented(frame)
} else {
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
parented(frame).add_filter(&ext, &[ext.as_str()])
};
// A default with a folder that exists opens the dialog there.
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
Binary file not shown.
@@ -0,0 +1,8 @@
asset: NotoSansSymbols2-Regular.ttf
title: Noto Sans Symbols 2 Regular (chrome symbols such as ✔ and ▣)
author: The Noto Project Authors
source: https://github.com/notofonts/symbols (Google Fonts commit 47d54ca1ebb14fd864695f1d39dc734f39017c33, file ofl/notosanssymbols2/NotoSansSymbols2-Regular.ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-NotoSansSymbols2.txt
added: 2026-10-24 by @skylartaylor
notes: Unmodified font file, sha256 7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21. Embedded with include_bytes! in crates/ui-egui/src/theme.rs as the last UI fallback so every family can draw the chrome symbols (U+2714 ✔, U+25A3 ▣) that Inter, Inter Medium, Inter SemiBold and JetBrains Mono lack.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+3
View File
@@ -95,3 +95,6 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.ki2pixel]
display_name = "ki2pixel"
+25 -3
View File
@@ -39,7 +39,13 @@ impl Rgba {
/// `#rrggbb` or `#rrggbbaa`.
pub fn from_hex(s: &str) -> Option<Rgba> {
let s = s.trim().trim_start_matches('#');
let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
let p = |i: usize| {
let pair = s.get(i..i + 2)?;
if !pair.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u8::from_str_radix(pair, 16).ok()
};
match s.len() {
6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)),
8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }),
@@ -93,7 +99,7 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
} else if max == g {
((b - r) / d + 2.0) / 6.0
} else {
@@ -130,7 +136,7 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let h = if d < 1e-7 {
0.0
} else if max == r {
((g - b) / d).rem_euclid(6.0) / 6.0
(((g - b) / d).rem_euclid(6.0) / 6.0).rem_euclid(1.0)
} else if max == g {
((b - r) / d + 2.0) / 6.0
} else {
@@ -262,6 +268,22 @@ mod tests {
assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
}
#[test]
fn hue_stays_below_one_for_tiny_blue() {
let (h, _, _) = rgb_to_hsv(1.0, 0.0, 1e-9);
assert!((0.0..1.0).contains(&h), "rgb_to_hsv hue {h} not in 0..1");
let (h, _, _) = rgb_to_hsl(1.0, 0.0, 1e-9);
assert!((0.0..1.0).contains(&h), "rgb_to_hsl hue {h} not in 0..1");
}
#[test]
fn rejects_signed_hex_pairs() {
assert!(Rgba::from_hex("#+1+2+3").is_none());
assert!(Rgba::from_hex("#1+2+3+").is_none());
assert!(Rgba::from_hex("#ff00+0ff").is_none());
assert!(Rgba::from_hex("#a1B2c3").is_some());
}
#[test]
fn srgb_roundtrip() {
for i in 0..=100 {
+1 -1
View File
@@ -177,7 +177,7 @@ impl ColorSpace {
/// Parse an id or label (case-insensitive, loose).
pub fn parse(s: &str) -> Option<ColorSpace> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb),
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
+30
View File
@@ -509,11 +509,24 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------------------------------------
// CC Radial Fast Blur
/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center
/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`,
/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other
/// growing effects). Shared with the GPU path so both grow the same way.
pub fn radial_fast_pad(c: (f64, f64), w: u32, h: u32, s: f64) -> u32 {
let (w, h) = (f64::from(w), f64::from(h));
let far = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - c.0).hypot(y - c.1)).fold(0.0, f64::max);
((far * s / (1.0 - s)).ceil() + 1.0).min(4096.0) as u32
}
fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return b;
}
if !ctx.adjustment {
b.pad(radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s));
}
let c = b.to_px(ctx.params.v2("center"));
let mode = ctx.params.e("zoom");
let src = b.img.clone();
@@ -906,6 +919,23 @@ mod tests {
assert_eq!(circle.len(), 21);
}
#[test]
fn radial_fast_blur_streaks_past_the_layer_edge() {
// Center left of an opaque square: the streaks run right, beyond the layer rectangle (#2095).
let img = Image::filled(20, 20, [1.0, 1.0, 1.0, 1.0]);
let out = run("ec.blur.ccradialfast", &img, &[("center", Value::Vec2([-20.0, 10.0])), ("amount", num(80.0))]);
assert!(out.img.width > 20 && out.img.height > 20, "{}x{}", out.img.width, out.img.height);
let (o, d) = (out.offset[0] as i64, out.offset[1] as i64);
assert!(out.img.get(30 + o, 10 + d)[3] > 0.1, "{:?}", out.img.get(30 + o, 10 + d));
// Its streak samples only the square: still opaque, so the content keeps its place.
assert!((out.img.get(19 + o, 10 + d)[3] - 1.0).abs() < 1e-5, "{:?}", out.img.get(19 + o, 10 + d));
// An adjustment layer is comp-sized already; its buffer is not grown.
let s = find("ec.blur.ccradialfast").unwrap();
let params = Params { values: [("center".into(), Value::Vec2([-20.0, 10.0])), ("amount".into(), num(80.0))].into_iter().collect() };
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [20.0, 20.0], seed: 1, adjustment: true, env: Default::default() };
assert_eq!((s.render)(&ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img.width, 20);
}
#[test]
fn reduce_flicker_blurs_vertically_only() {
let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]);
+2 -2
View File
@@ -136,11 +136,11 @@ type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
let key = fnv(json.as_bytes());
let c = c.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(a.clone());
}
let a = Arc::new(parse(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
+24 -3
View File
@@ -49,12 +49,15 @@ fn set_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Shift Channels' Take … From menu. After Effects' ten options come first, in its order, so a
/// project or script that sets 10 ("Full Off" there) turns the channel off here too; "Half On" is
/// an EffectCraft extra and sits last.
const SHIFT_ORDER: [Src; 11] =
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off];
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"];
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half];
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Full Off", "Half On"];
fn shift_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(10)]);
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(SHIFT_ORDER.len() - 1)]);
route(&mut b, s);
b
}
@@ -548,6 +551,24 @@ mod tests {
assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5]));
}
/// #1797: the Take … From menu keeps After Effects' order, where the tenth option (`Enum(9)`
/// here, 10 to a script) is Full Off, not Half On.
#[test]
fn shift_channels_menu_follows_after_effects_order() {
assert_eq!(SHIFT_NAMES[8..], ["Full On", "Full Off", "Half On"]);
// The sample's right-hand pixels are half transparent, so a straight 0.0 and 0.5 are
// stored as 0.0 and 0.25.
let off = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(9))], sample());
assert!(close(off.get(0, 0), [0.2, 0.0, 0.9, 1.0]), "{:?}", off.get(0, 0));
assert!(close(off.get(7, 0), [0.1, 0.0, 0.45, 0.5]), "{:?}", off.get(7, 0));
let half = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(10))], sample());
assert!(close(half.get(0, 0), [0.2, 0.5, 0.9, 1.0]), "{:?}", half.get(0, 0));
assert!(close(half.get(7, 0), [0.1, 0.25, 0.45, 0.5]), "{:?}", half.get(7, 0));
// A value past the last option stays on the last one instead of panicking.
let past = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(99))], sample());
assert_eq!(past.get(0, 0), half.get(0, 0));
}
#[test]
fn channel_combiner_hls_round_trip() {
let img = sample();
+106 -12
View File
@@ -403,13 +403,42 @@ fn crypto_matte(planes: &RankPlanes, sel: &[u32], i: usize) -> f32 {
.clamp(0.0, 1.0)
}
/// [`id_color`] of the last ID seen on each rank plane, per worker. Neighbouring pixels mostly
/// carry the same IDs, so this skips the hash and the HSL conversion for most of them. Keyed by the
/// ID's bits, so it returns exactly what [`id_color`] would.
///
/// Keep the per-pixel loop below as it is: with NaN coverage, which NaN payload ends up in a
/// channel depends on the order the compiler adds the ranks in, and a row-wise loop let it reorder
/// them (`crypto_aux_image_is_bit_exact_on_edge_values` catches that).
#[derive(Default)]
struct IdColorMemo(Vec<(u32, [f32; 3])>);
impl IdColorMemo {
#[inline]
fn get(&mut self, rank: usize, id: f32) -> [f32; 3] {
let bits = id.to_bits();
match self.0.get_mut(rank) {
Some(e) if e.0 == bits => e.1,
Some(e) => {
*e = (bits, id_color(id));
e.1
}
None => {
let c = id_color(id);
self.0.resize(rank + 1, (bits, c));
c
}
}
}
}
/// The coverage-weighted ID colours at aux pixel `i` (the spec's preview).
#[inline]
fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] {
fn crypto_colors(planes: &RankPlanes, i: usize, memo: &mut IdColorMemo) -> [f32; 3] {
let mut c = [0.0f32; 3];
for (id, cov) in planes {
for (rank, (id, cov)) in planes.iter().enumerate() {
let (Some(&id), Some(&cov)) = (id.get(i), cov.get(i)) else { continue };
let k = id_color(id);
let k = memo.get(rank, id);
for j in 0..3 {
c[j] += k[j] * cov;
}
@@ -421,10 +450,37 @@ fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] {
/// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU
/// effect uploads this image and samples it per layer pixel.
pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image {
crypto_aux_image_with(a, layer, sel, CryptoAux::ColorsAndMatte)
}
/// What [`crypto_aux_image_with`] computes per aux pixel.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CryptoAux {
/// The coverage-weighted ID colours (RGB) and the selection's coverage (A).
ColorsAndMatte,
/// Only the selection's coverage (A); the RGB stays 0.
MatteOnly,
}
impl CryptoAux {
/// What the Cryptomatte effect reads for `display` and Matte Only: the ID colours are only
/// read by the Colors display (0) without Matte Only; Matte, Preview and Matte Only need the
/// matte alone.
pub fn for_display(display: u32, matte_only: bool) -> Self {
if display == 0 && !matte_only { Self::ColorsAndMatte } else { Self::MatteOnly }
}
}
/// [`crypto_aux_image`]; with [`CryptoAux::MatteOnly`] the RGB stays 0 and only the matte (A) is
/// computed, for the displays that never read the ID colours.
pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], what: CryptoAux) -> Image {
let planes = a.crypto_rank_planes(layer);
let mut img = Image::new(a.width, a.height);
img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let c = crypto_colors(&planes, i);
img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| {
let c = match what {
CryptoAux::ColorsAndMatte => crypto_colors(&planes, i, memo),
CryptoAux::MatteOnly => [0.0; 3],
};
*px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)];
});
img
@@ -444,9 +500,8 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let display = ctx.params.e("display");
let matte_only = ctx.params.b("matteOnly");
let planes = a.crypto_rank_planes(&layer);
let src = b.img.clone();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k));
b.img.data.par_iter_mut().zip(idx.par_iter()).for_each_init(IdColorMemo::default, |memo, (px, &k)| {
let m = k.map_or(0.0, |k| crypto_matte(&planes, &sel, k));
if matte_only {
*px = [m, m, m, m];
return;
@@ -454,15 +509,15 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
*px = match display {
// Colors: coverage-weighted ID colours (the spec's preview), selection brighter.
0 => {
let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k));
let c = k.map_or([0.0; 3], |k| crypto_colors(&planes, k, memo));
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
[c[0], c[1], c[2], 1.0]
}
// Matte: the layer keyed by the selection.
1 => src.data[i].map(|v| v * m),
// Matte: the layer keyed by the selection (each pixel only reads itself, so no copy).
1 => px.map(|v| v * m),
// Preview: the layer with the selection tinted.
_ => {
let s = src.data[i];
let s = *px;
[s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]]
}
};
@@ -822,6 +877,20 @@ mod tests {
a
}
/// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values
/// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits).
#[test]
fn id_color_memo_matches_id_color() {
let a = edge_aux(true);
let planes = a.crypto_rank_planes("CryptoObject");
let mut memo = IdColorMemo::default();
for i in 0..12 {
for (r, (id, _)) in planes.iter().enumerate() {
assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}");
}
}
}
fn edge_selection() -> Vec<u32> {
vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()]
}
@@ -843,6 +912,31 @@ mod tests {
}
}
/// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values.
#[test]
fn crypto_aux_image_without_colors_keeps_the_matte() {
let a = edge_aux(true);
let sel = edge_selection();
let full = crypto_aux_image(&a, "CryptoObject", &sel);
let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly);
for (f, m) in full.data.iter().zip(&matte.data) {
assert_eq!(f[3].to_bits(), m[3].to_bits());
assert_eq!(m[..3], [0.0; 3]);
}
}
/// Only the Colors display without Matte Only reads the ID colours.
#[test]
fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() {
assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte);
assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly);
for display in [1, 2, 3, u32::MAX] {
for matte_only in [false, true] {
assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}");
}
}
}
/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match
/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index)
/// get an empty matte and no ID colour.
+153 -28
View File
@@ -107,7 +107,14 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
[v[0] / l, v[1] / l, v[2] / l]
}
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
fn norm3f(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
[v[0] / l, v[1] / l, v[2] / l]
}
fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
@@ -115,14 +122,23 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let soft = ctx.params.f("softness").max(0.0) * b.scale;
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32;
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32;
if cut >= 1.0 {
// Endpoint guard: at Cut Away 100 the gradient antialias must not leave a half-covered
// pixel where an opaque source reaches exactly 1.0.
b.img.data.fill([0.0; 4]);
return b;
}
let blob = layer_or_self(ctx, &b, "blobLayer", true, false);
let prop = src_at(ctx.params.e("property"));
let raw = Plane::from_image(&blob, |px| {
let (c, a) = unpremul(px);
pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a }
});
let h = gauss_plane(&raw, soft, soft);
// Oracle (AE 26.2, 16 bpc): a step thresholded at Cut 25/75 with Softness 20 moves the
// 50% contour by 5.55 px. For a Gaussian that is 0.67449 sigma, so sigma = 0.42 Softness.
// The same probe shows the matte stays a ~1 px antialiased threshold, not a broad ramp.
let h = gauss_plane(&raw, soft * 0.42, soft * 0.42);
let intensity = ctx.params.f("lightIntensity") / 100.0;
let lc = ctx.params.color("lightColor");
let point = ctx.params.e("lightType") == 1;
@@ -135,37 +151,69 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rough = ctx.params.f("roughness").clamp(0.001, 1.0);
let metal = (ctx.params.f("metal") / 100.0) as f32;
let shininess = (1.0 / rough).min(500.0);
let depth = (soft * 0.5).max(1.0);
let src = b.img.clone();
let (w, hh) = (b.img.width as usize, b.img.height as usize);
let span = (w.max(hh) as f64).max(1.0);
// The source matte and the RGB refraction are separate in the oracle:
// - the matte edge of a self step moves 5/9/14/18 px at Softness 5/10/20/40. A normal
// with z = 1, a ~30 px depth at 1920 and a slope scale of 1.5 Softness reproduces that;
// - RGB follows a height-field refraction with z = height: on a linear ramp the measured
// horizontal offset is dx = -0.34 / h, independent of Softness.
let matte_slope = soft * 1.5;
let matte_depth = if soft > 0.05 { span / 64.0 } else { 0.0 };
let rgb_depth = span * 0.34 * b.scale;
let light_slope = (soft * 0.65).max(1.0);
let limit = span * 0.5;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (xi, yi) = (x as i64, y as i64);
let hv = h.get(x, y);
let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5;
let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5;
// Refraction-like displacement down the slope, then the blobby matte.
let sx = x as f64 + 0.5 - gx * depth * 2.0;
let sy = y as f64 + 0.5 - gy * depth * 2.0;
let s = src.sample_bilinear(sx, sy);
let (c, a) = unpremul(s);
let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0);
let alpha = a.max(s[3]) * m;
let grad = (gx * gx + gy * gy).sqrt();
// One-pixel antialiased threshold. A flat field is either fully in or fully out.
let aa = (1.25 * grad).max(5e-4);
let m = if grad > 1e-9 {
(0.5 + (hv - cut) as f64 / aa).clamp(0.0, 1.0) as f32
} else if hv > cut {
1.0
} else {
0.0
};
if m <= 0.0 {
*px = [0.0; 4];
continue;
}
// Warp the source matte with the softness-scaled bevel normal (z = 1).
let na = norm3([-gx * matte_slope, -gy * matte_slope, 1.0]);
let sa = src.sample_bilinear(x as f64 + 0.5 + na[0] * matte_depth, y as f64 + 0.5 + na[1] * matte_depth);
let alpha = sa[3].max(0.0) * m;
if alpha <= 0.0 {
*px = [0.0; 4];
continue;
}
let n = norm3([-gx * depth, -gy * depth, 1.0]);
let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir };
let ndl = dot3(n, l).max(0.0);
let hv3 = norm3([l[0], l[1], l[2] + 1.0]);
let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular;
// Refract the colour through the height field (z = height for this pass).
let hz = (hv as f64).max(1e-3);
let rx = (-rgb_depth * gx / hz).clamp(-limit, limit);
let ry = (-rgb_depth * gy / hz).clamp(-limit, limit);
let srgb = src.sample_bilinear(x as f64 + 0.5 + rx, y as f64 + 0.5 + ry);
let (c, _) = unpremul(srgb);
let (gx32, gy32) = (gx as f32, gy as f32);
let n = norm3f([-gx32 * light_slope as f32, -gy32 * light_slope as f32, 1.0]);
let l = if point {
norm3f([lpos.0 as f32 - (x as f32 + 0.5), lpos.1 as f32 - (y as f32 + 0.5), (lheight / 100.0 * span) as f32])
} else {
[ldir[0] as f32, ldir[1] as f32, ldir[2] as f32]
};
let ndl = dot3f(n, l).max(0.0);
let hv3 = norm3f([l[0], l[1], l[2] + 1.0]);
let sp = dot3f(n, hv3).max(0.0).powf(shininess as f32) * specular as f32;
let mut out = [0.0f32; 3];
for i in 0..3 {
let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i];
let lit = (ambient + diffuse * ndl as f64 * intensity) as f32 * lc[i];
let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i];
out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col;
out[i] = c[i] * lit + (sp as f64 * intensity) as f32 * spec_col;
}
*px = premul(out, alpha);
}
@@ -880,19 +928,19 @@ pub fn specs() -> Vec<EffectSpec> {
"CC Blobbylize",
vec![
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])),
p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)),
p("cutAway", "Cut Away", num(0.0), pct()),
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
p("softness", "Softness", num(20.0), slider(0.0, 500.0, 0.0, 50.0, 1)),
p("cutAway", "Cut Away", num(25.0), pct()),
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])),
p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point),
p("lightHeight", "Light Height", num(65.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)),
p("ambient", "Ambient", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("diffuse", "Diffuse", num(25.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("specular", "Specular", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("roughness", "Roughness", num(0.025), slider(0.001, 1.0, 0.001, 1.0, 3)),
p("metal", "Metal", num(100.0), pct()),
],
cc_blobbylize,
@@ -1127,6 +1175,83 @@ mod tests {
}
}
#[test]
fn blobbylize_defaults_match_ae() {
let s = crate::find("ec.distort.ccblobbylize").unwrap();
let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
assert_eq!(default("softness"), num(20.0));
assert_eq!(default("cutAway"), num(25.0));
assert_eq!(default("lightHeight"), num(65.0));
assert_eq!(default("lightPosition"), pt(0.5, 0.5));
assert_eq!(default("ambient"), num(75.0));
assert_eq!(default("diffuse"), num(25.0));
assert_eq!(default("specular"), num(100.0));
assert_eq!(default("roughness"), num(0.025));
assert_eq!(default("metal"), num(100.0));
}
#[test]
fn blobbylize_cut_away_thresholds_a_height_ramp() {
// AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
let im = crate::util::gen_image(1920, 8, |x, _| {
let h = x as f32 / 1920.0;
[h, h, h, 1.0]
});
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
let edge = o.img.get(480, 4)[3];
assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
assert!(partial <= 2, "{partial} antialiased columns instead of one");
}
#[test]
fn blobbylize_cut_away_100_is_empty() {
// Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
let im = crate::util::gen_image(1920, 8, |x, _| {
let h = x as f32 / 1920.0;
[h, h, h, 1.0]
});
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
}
#[test]
fn blobbylize_cut_away_100_clears_an_opaque_edge() {
// An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
// used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
}
#[test]
fn blobbylize_softness_warps_a_step_matte_inward() {
// AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
// edge at about x = 946, not at the source edge. The PR's matte-preserving version
// left it at 960; the effect must warp the source matte.
let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
let o = run(
"ec.distort.ccblobbylize",
&[
("property", Value::Enum(3)),
("softness", num(20.0)),
("cutAway", num(0.0)),
("ambient", num(100.0)),
("diffuse", num(0.0)),
("specular", num(0.0)),
("metal", num(0.0)),
],
im,
&[],
);
assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
}
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
/// (200 × 100) layer bounds, after `id` with `set`.
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
+2 -2
View File
@@ -91,7 +91,7 @@ pub use sim::particle_state;
pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
pub use stylize2::strobe_on;
// CPU helpers the GPU kernels share (effectcraft-gpu).
pub use blur2::{camera_lens_plain, camera_lens_spans};
pub use blur2::{camera_lens_plain, camera_lens_spans, radial_fast_pad};
pub use generate::{four_color_softening, gen_mode};
pub use generate2::pattern_kind as cell_pattern_kind;
pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan};
@@ -111,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, aux_point, crypto_aux_image, id_color, selection_hashes};
pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
pub use sim2::{
+16
View File
@@ -499,6 +499,22 @@ mod tests {
assert_eq!(pr.ui, popup.ui);
}
/// #1797: Shift Channels' menu put Half On before Full Off; a project saved then keeps the
/// option it chose, by label.
#[test]
fn shift_channels_half_on_and_full_off_are_remapped_by_label() {
const OLD: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"];
for (saved, want) in [(9u32, 10u32), (10, 9), (1, 1)] {
let mut next = 1;
let (spec, mut g) = instance("ec.channel.shiftchannels", &mut next);
let pr = g.get_mut("takeGreenFrom").unwrap();
pr.ui = ParamUi::Popup { options: OLD.iter().map(|s| s.to_string()).collect() };
pr.value = Value::Enum(saved);
upgrade_instance(spec, &mut g, &mut Ids(&mut next), SIZE);
assert_eq!(g.get("takeGreenFrom").unwrap().value, Value::Enum(want), "saved {saved}");
}
}
/// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were
/// plain text; old projects get the Choose… file parameter, keeping the path.
#[test]
+63 -5
View File
@@ -35,22 +35,44 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !repeat && !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment);
let (sx, sy) = (s * kx, s * ky);
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) {
effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, repeat || ctx.adjustment)
} else {
gaussian_blur(&b.img, sx, sy, repeat || ctx.adjustment)
};
b
}
fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
let radius = (ctx.params.f("radius") * b.scale).max(0.0);
let r = radius.floor() as usize;
let frac = (radius - r as f64) as f32;
// Fractional radius: blend the two neighbouring integer radii so the result changes continuously (#1939).
let frac = if frac < 1e-4 { 0.0 } else { frac };
if r == 0 && frac == 0.0 {
return b;
}
let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize;
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((r * it) as u32 + 1);
let reach = if frac > 0.0 { r + 1 } else { r };
b.pad((reach * it) as u32 + 1);
}
let edge = repeat || ctx.adjustment;
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
if frac == 0.0 {
b.img = lo;
return b;
}
let hi = effectcraft_raster::box_blur(&b.img, (r + 1) * kx as usize, (r + 1) * ky as usize, it, edge);
for ((l, h), o) in lo.data.iter().zip(hi.data.iter()).zip(b.img.data.iter_mut()) {
for c in 0..4 {
o[c] = l[c] + (h[c] - l[c]) * frac;
}
}
b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment);
b
}
@@ -738,6 +760,42 @@ mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
/// White left half, black right half (one vertical edge at x = 20).
fn edge() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(40, 8, [0.0, 0.0, 0.0, 1.0]);
for y in 0..8 {
for x in 0..20 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
img
}
fn blurred_row(id: &str, param: &str, v: f64) -> Vec<f32> {
let out = run_fx(id, &[(param, num(v)), ("repeatEdge", Value::Bool(true))], edge(), 0.0, EffectEnv::default());
(16..24).map(|x| out.img.get(x, 4)[0]).collect()
}
#[test]
fn small_gaussian_blurriness_is_continuous() {
// #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other.
let row = |v| blurred_row("ec.blur.gaussian", "blurriness", v);
assert_ne!(row(0.0), row(0.9));
assert_ne!(row(0.9), row(1.2));
assert_ne!(row(1.2), row(1.6));
assert_ne!(row(1.6), row(2.0));
assert_ne!(row(2.0), row(2.4));
}
#[test]
fn fractional_box_blur_radius_is_continuous() {
let row = |v| blurred_row("ec.blur.fastbox", "radius", v);
assert_ne!(row(0.0), row(0.5));
assert_ne!(row(0.5), row(0.9));
assert_ne!(row(1.2), row(1.6));
assert_ne!(row(1.6), row(2.4));
}
/// A bright square on mid grey.
fn scene() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
+43 -11
View File
@@ -406,15 +406,20 @@ pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: boo
sub_rotation: fr.sub_rotation != 0.0,
norm,
octaves,
mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) {
BLEND_NONE => None,
m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)),
},
mode: blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE)),
opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0),
blend: ctx.params.f("blend") as f32 / 100.0,
}
}
/// Resolve a Blending Mode index: None → `None`, unknown → Normal.
fn blend_mode(m: u32) -> Option<BlendMode> {
match m {
BLEND_NONE => None,
m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)),
}
}
/// Map a value by an [`OVERFLOWS`] option.
pub fn overflow(v: f32, mode: u32) -> f32 {
match mode {
@@ -431,14 +436,11 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: Option<BlendMode>, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
BLEND_NONE => src,
m => {
let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal);
blend_pixel(bm, orig, src, 0.5)
}
None => src,
Some(bm) => blend_pixel(bm, orig, src, 0.5),
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
@@ -447,7 +449,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
let fr = Fractal::from_params(ctx, &b, turbulent);
let opacity = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let mode = ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE);
let mode = blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE));
let blend = ctx.params.f("blend") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
@@ -679,4 +681,34 @@ mod tests {
}
assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag);
}
/// The base composite, resolving the Blending Mode by name on every call, kept only to prove
/// the hoisted lookup changes nothing.
fn old_composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
BLEND_NONE => src,
m => {
let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal);
blend_pixel(bm, orig, src, 0.5)
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
}
#[test]
fn resolved_blend_matches_per_pixel_lookup() {
for m in (0..BLEND_MODES.len() as u32).chain([24, 999]) {
for orig in [[0.2, 0.4, 0.6, 1.0], [0.9, 0.1, 0.5, 0.5], [0.0, 0.0, 0.0, 0.0]] {
for val in [0.0, 0.3, 0.75, 1.0] {
for (op, bl) in [(1.0, 0.0), (0.6, 0.25)] {
assert_eq!(composite(orig, val, op, blend_mode(m), bl), old_composite(orig, val, op, m, bl), "mode {m}");
}
}
}
}
assert_eq!(blend_mode(BLEND_NONE), None);
assert_eq!(blend_mode(999), Some(BlendMode::Normal));
}
}
+17 -1
View File
@@ -99,7 +99,13 @@ fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment);
let (sx, sy) = (s * kx, s * ky);
// Small sigmas use the sampled kernel, like Gaussian Blur (#1939).
b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) {
effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, ctx.adjustment)
} else {
gaussian_blur(&b.img, sx, sy, ctx.adjustment)
};
b
}
@@ -175,6 +181,16 @@ mod tests {
assert!(out.img.get(c as i64, c as i64)[3] < 0.5);
}
#[test]
fn gaussian_legacy_small_blurriness_blurs() {
// #1939: Blurriness under ~1.2 used to be a no-op.
let mut img = Image::new(15, 15);
img.set(7, 7, [1.0, 1.0, 1.0, 1.0]);
let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(0.9))], img, 0.0, EffectEnv::default());
let c = out.img.width / 2;
assert!(out.img.get(c as i64, c as i64)[3] < 0.9);
}
#[test]
fn moved_effects_are_obsolete() {
for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] {
+1 -1
View File
@@ -617,7 +617,7 @@ pub fn load_file(src: &str) -> Option<Arc<FileXform>> {
let parsed = if inline {
parse_file(src, "")
} else {
let ext = std::path::Path::new(src_t).extension().and_then(|e| e.to_str()).unwrap_or("");
let ext = std::path::Path::new(src_t).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("");
std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext))
}
.map(Arc::new);
+2 -2
View File
@@ -537,7 +537,7 @@ impl<S: Clone> SimCache<S> {
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
use std::sync::Arc;
let start = {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
g.iter_mut().find(|e| e.key == key).and_then(|e| {
e.used = tick;
@@ -563,7 +563,7 @@ impl<S: Clone> SimCache<S> {
}
let out = Arc::new(st);
fresh.push((target, out.clone()));
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
let idx = match g.iter().position(|e| e.key == key) {
Some(ix) => ix,
+7 -7
View File
@@ -191,11 +191,11 @@ fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
static C: OnceLock<AnalysisCache> = OnceLock::new();
let key = fnv(json.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some((key, a.clone()));
}
let a = Arc::new(WarpAnalysis::from_json(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
@@ -294,7 +294,7 @@ pub fn store_summary(key: [u64; 2], summary: PlanSummary) {
}
fn keep_summary(key: (u64, u64), summary: Arc<PlanSummary>) {
let mut m = summaries().lock().unwrap_or_else(|e| e.into_inner());
let mut m = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 32 {
m.clear();
}
@@ -305,7 +305,7 @@ fn keep_summary(key: (u64, u64), summary: Arc<PlanSummary>) {
/// cached (seconds for a long Subspace Warp clip). `None` when not analysed.
pub fn summary(params: &Params) -> Option<([u64; 2], Arc<PlanSummary>)> {
let (key, a, st) = plan_key(params)?;
let cached = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
let cached = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).cloned();
let sum = match cached {
Some(s) => s,
None => {
@@ -333,7 +333,7 @@ pub fn summary_at(params: &Params, time: f64) -> Option<(usize, Arc<PlanSummary>
pub fn cached_summary_at(params: &Params, time: f64) -> Option<(usize, Arc<PlanSummary>)> {
let (key, a, _) = plan_key(params)?;
let k = a.frame_at(time)?;
let sum = summaries().lock().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
let sum = summaries().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).cloned();
let sum = match sum {
Some(s) => s,
None => {
@@ -445,13 +445,13 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
}
}
if !local.is_empty() {
let mut n = need_m.lock().unwrap_or_else(|e| e.into_inner());
let mut n = need_m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
for (x, w) in local {
n[y * ow as usize + x] = w;
}
}
});
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
}
+56 -4
View File
@@ -13,14 +13,23 @@ use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_pr
use crate::{EngineError, Event, Result, Session, cmd, query};
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
/// the project is restored.
/// the project, editor state and undo history are restored.
///
/// Front-end events emitted by a failed `f` are discarded with the state. Pending-analysis
/// queues and the journal are not part of this rollback and keep their existing behaviour.
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
// taken before them), then become one step on the real one.
// taken before them), then become one step on the real one. Keep the complete pre-`f`
// history and editor state for a failure: rollback must not leave a partially retargeted
// editor or a `f`-recorded redo/branch behind.
let saved_history = s.history.clone();
let saved = std::mem::take(&mut s.history.undo);
let before = s.project.clone();
let before_state = s.state.clone();
let event_mark = s.events.len();
let r = f(s);
let changed = !s.history.undo.is_empty();
let interim_revision = s.ime_revision;
s.history.undo = saved;
s.history.merge_key = None;
match r {
@@ -36,9 +45,22 @@ pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi
Ok(v)
}
Err(e) => {
// Snapshot the failing editor before restoring the pre-`f` state: an intermediate
// text.edit / text change / comp switch must invalidate the pre-`f` editor token
// instead of reviving it.
let edited = super::text_edit::active_editor_snapshot(s);
s.project = before;
s.state = before_state;
s.history = saved_history;
s.history.merge_key = None;
// A failed intermediate comp switch must not reach the front end through an
// `OpenComp` event while its state has been rolled back.
s.events.truncate(event_mark);
s.sanitize_after_replacement(edited.as_ref());
// As in the batch rollback, the IME revision is a high-water mark: leave the highest
// intermediate stamp rather than rewinding to before `f`.
s.ime_revision = s.ime_revision.max(interim_revision);
if changed {
s.project = before;
s.sanitize_state();
s.bump();
}
Err(e)
@@ -704,6 +726,36 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use serde_json::json;
#[test]
fn grouped_rollback_restores_the_editor_state_and_history_after_a_transient_text_change() {
let mut s = crate::Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 1})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
let original_comp = s.active_comp_id().unwrap();
let owner = s.state.text_edit.as_ref().unwrap().session;
let other =
s.execute("comp.new", json!({"name": "Other", "width": 160, "height": 90, "duration": 1, "open": false})).unwrap()["comp"].as_u64().unwrap();
let undo = s.history.undo.len();
s.drain_events();
let r = super::grouped(&mut s, "Rollback", |s| -> crate::Result<()> {
// An intermediate document edit and state switch, both undone by the error path.
s.execute("layer.setText", json!({"layer": lid, "text": "ab"}))?;
s.execute("comp.open", json!({"comp": other}))?;
Err(crate::EngineError::Other("stop".into()))
});
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original_comp), "the pre-group comp is restored");
assert_eq!(super::super::text_edit::layer_doc(&s, effectcraft_project::LayerId(lid)).unwrap().text, "abcdef", "the document is restored");
assert_eq!(s.history.undo.len(), undo, "the partial grouped steps are not left in history");
assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a transient replacement does not revive the old IME token");
assert!(
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(_))),
"the failed intermediate comp switch is not published to the front end"
);
}
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
+15 -1
View File
@@ -56,6 +56,9 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let label = str_p(p, "label").unwrap_or("Batch").to_string();
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
// Rolled-back steps may have queued events (an `OpenComp`, a Toast) for the state that is
// about to be dropped: the UI must not act on them.
let event_mark = s.events.len();
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
@@ -75,10 +78,21 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Err(e) => {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
let note = if atomic {
// The snapshot is the intermediate state the failing step left: comparing it
// with the restored editor detects a restart or cross-comp switch inside the
// batch, so the pre-batch token is never revived. Do not restore
// `ime_revision`: a transient text edit inside the batch already advanced
// this process-monotonic high-water mark, and rewinding it would reuse a
// stamp (letting a wide-selection composition look current). An unrelated
// failed batch did not advance it, so its value stays exactly as it was.
let edited = crate::commands::text_edit::active_editor_snapshot(s);
let interim_revision = s.ime_revision;
s.project = project;
s.history = history;
s.state = state;
s.sanitize_state();
s.events.truncate(event_mark);
s.sanitize_after_replacement(edited.as_ref());
s.ime_revision = s.ime_revision.max(interim_revision);
s.bump();
"; the batch was rolled back (nothing changed)".to_string()
} else {
+24 -1
View File
@@ -1,6 +1,7 @@
//! Edit menu.
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -73,6 +74,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
// script, MCP — leaves one final state.
super::text_edit::end_edit(s, &Value::Null)?;
s.state.selected_layers.clear();
s.state.selected_props.clear();
s.state.selected_keys.clear();
@@ -201,12 +206,30 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) {
l.track_matte = None;
}
// A layer-reference property (an Environment light's Source, an effect's Clone Source,
// Back Layer…) must read as None once its layer is gone, in this same history step: a
// dead id renders as nothing while the UI shows "None", which means "fall back to the
// comp's Environment Layer" (#2153). Keys carry the same value kind as the property.
l.props.walk_mut(&mut |p| {
let dead = |v: &KV| matches!(v, KV::Layer(Some(id)) if ids.contains(&LayerId(*id)));
if dead(&p.value) {
p.value = KV::Layer(None);
}
for k in &mut p.keys {
if dead(&k.value) {
k.value = KV::Layer(None);
}
}
});
}
st.selected_layers.clear();
st.selected_props.clear();
st.selected_keys.clear();
Ok(())
})?;
// A removed layer may have been the edited one: end that session before another command
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
s.sanitize_state();
Ok(json!(ids.len()))
}
@@ -827,7 +850,7 @@ fn original_path(s: &Session) -> Option<String> {
/// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused.
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
let media = |p: &std::path::Path| {
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps")
};
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
+1 -1
View File
@@ -560,7 +560,7 @@ pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
+11 -1
View File
@@ -200,7 +200,14 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.char_len();
let id = insert_layer(proj, st, cid, l)?;
if edit {
st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() });
st.text_edit = Some(super::text_edit::TextEdit {
layer: id,
anchor: 0,
caret: n,
created: true,
session: super::text_edit::next_edit_session(),
..Default::default()
});
}
Ok(id)
})?;
@@ -1474,6 +1481,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
}
s.bump();
}
// A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and
// starts a new IME epoch only when the CTI document actually changed, so an identical text
// write stays a no-op and keeps the editor's owner.
s.edit("Edit Text", merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
+1 -1
View File
@@ -708,7 +708,7 @@ const BUNDLE_EXTENSIONS: &[&str] = &[
/// Whether the system file manager would open folder `dir` as something other than a folder: a
/// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension.
fn opens_as_bundle(dir: &std::path::Path) -> bool {
let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
let ext = dir.extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str())
}
+1 -1
View File
@@ -107,7 +107,7 @@ impl Default for PaintOptions {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
}
/// Index of `v` in `names` (by number or case/space-insensitive name).
+1 -1
View File
@@ -103,7 +103,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio)
&& old.layer.is_none() && old.page == 0;
let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = Path::new(&old.path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
let paths = index.get(name).cloned().unwrap_or_default();
let mut candidates = vec![];
let mut accepted = vec![];
+399 -17
View File
@@ -6,15 +6,58 @@
//! Positions are character indices. The selection is `anchor..caret` (either order).
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
use effectcraft_project::{LayerId, LayerSource};
use effectcraft_project::{ItemId, LayerId, LayerSource};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Session tokens for the on-canvas editor, unique across the process (so a recreated session
/// never reuses a live IME context). Exhaustion wraps; it cannot panic.
static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
pub(super) fn next_edit_session() -> u64 {
NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps.
///
/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from
/// one process-global counter means neither a second session nor a rollback can reuse a value.
/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep
/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no
/// receipt rather than claiming a mutation with a reused revision.
static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub(crate) fn next_ime_revision() -> Option<u64> {
allocate_ime_revision(&NEXT_IME_REVISION)
}
fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
use std::sync::atomic::Ordering::Relaxed;
let mut current = counter.load(Relaxed);
loop {
let next = current.checked_add(1)?;
match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) {
Ok(_) => return Some(next),
Err(observed) => current = observed,
}
}
}
#[test]
fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() {
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
let counter = AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX));
assert_eq!(allocate_ime_revision(&counter), None);
assert_eq!(allocate_ime_revision(&counter), None);
assert_eq!(counter.load(Relaxed), u64::MAX);
}
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
use crate::{EditorState, EngineError, Result, Session, cmd};
/// An on-canvas text editing session.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextEdit {
pub layer: LayerId,
pub anchor: usize,
@@ -28,6 +71,23 @@ pub struct TextEdit {
/// Line position kept by repeated up / down moves (layout space).
#[serde(default)]
pub goal_x: Option<f64>,
/// This editing session's identity.
///
/// It changes when the editor is started and when its layer or document is replaced, by
/// undo/redo, a history jump, a rollback, or a time change showing another document. A
/// restore that keeps the same composition, layer and byte/style-equal document (an opacity
/// undo while editing) keeps it, so an active IME composition survives an unrelated change.
/// It is deliberately not serialized: a saved token belongs to a previous process and
/// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old
/// composition is never inherited by a recreated layer or a replaced document.
#[serde(skip, default = "next_edit_session")]
pub session: u64,
}
impl Default for TextEdit {
fn default() -> Self {
Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() }
}
}
impl TextEdit {
@@ -49,6 +109,139 @@ pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
}
}
/// The largest committed-text window an IME observation copies.
///
/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is
/// reported as unavailable rather than silently truncated.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// The editor's committed-text revision for an IME receipt whose surrounding text is
/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]).
///
/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so
/// they cannot make an active composition look stale.
pub fn ime_stamp_revision(s: &Session) -> u64 {
s.ime_revision
}
/// Bounded committed text around an IME selection, with UTF-8 byte offsets.
///
/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the
/// layer's [`TextDoc`], so [`Self::text`] is committed text only.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text.
pub text: String,
/// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`].
pub cursor: usize,
/// The other selection end; larger than `cursor` for a reversed selection.
pub anchor: usize,
}
impl ImeSurrounding {
/// A bounded window of `text` around the selection `cursor`/`anchor`.
///
/// Returns `None` when an offset is past the text or inside a character, or when the
/// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports
/// surrounding text as unavailable instead of sending a truncated selection.
pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option<Self> {
if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) {
return None;
}
let (start, end) = (cursor.min(anchor), cursor.max(anchor));
let selection = end - start;
if selection > MAX_IME_TEXT_BYTES {
return None;
}
// Spend the remaining budget on both sides of the selection, giving unused room on one
// side to the other. Only this bounded window is copied.
let budget = MAX_IME_TEXT_BYTES - selection;
let mut before = (budget / 2).min(start);
let mut after = (budget - before).min(text.len() - end);
before = (budget - after).min(start);
after = (budget - before).min(text.len() - end);
let mut window_start = start - before;
let mut window_end = end + after;
// Byte budgets can land inside a multi-byte character: shrink the window so it never
// splits one and the selection stays inside it.
while window_start < start && !text.is_char_boundary(window_start) {
window_start += 1;
}
while end < window_end && !text.is_char_boundary(window_end) {
window_end -= 1;
}
Some(Self {
text: text.get(window_start..window_end)?.to_owned(),
cursor: cursor.checked_sub(window_start)?,
anchor: anchor.checked_sub(window_start)?,
})
}
}
/// The edited layer's committed text around its selection, or `None` when there is no edit
/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`].
///
/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed
/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`.
pub fn ime_surrounding(s: &Session) -> Option<ImeSurrounding> {
let e = s.state.text_edit.as_ref()?;
let comp = s.active_comp()?;
let layer = comp.layer(e.layer)?;
let prop = layer.props.prop("text/sourceText")?;
// A static Source Text (the normal case) is borrowed in place; only an animated one has to
// be evaluated into a temporary. Either way only the bounded window is copied.
if !prop.is_animated() {
return match &prop.value {
KV::Text(doc) => {
let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor);
ImeSurrounding::from_text(&doc.text, caret, anchor)
}
KV::Str(text) => {
let (caret, anchor) = char_bytes(text, e.caret, e.anchor);
ImeSurrounding::from_text(text, caret, anchor)
}
_ => None,
};
}
match prop.value_at(layer.layer_time(s.time())) {
KV::Text(doc) => {
let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor);
ImeSurrounding::from_text(&doc.text, caret, anchor)
}
KV::Str(text) => {
let (caret, anchor) = char_bytes(&text, e.caret, e.anchor);
ImeSurrounding::from_text(&text, caret, anchor)
}
_ => None,
}
}
/// Byte offsets of two character positions in `text` (clamped to its end), scanning once.
///
/// The second position may be before the first; the returned pair keeps the caller's order.
fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) {
let (lo, hi) = if first <= second { (first, second) } else { (second, first) };
let mut lo_byte = if lo == 0 { 0 } else { text.len() };
let mut hi_byte = if hi == 0 { 0 } else { text.len() };
if hi != 0 {
for (i, (b, _)) in text.char_indices().enumerate() {
if i == lo {
lo_byte = b;
}
if i == hi {
hi_byte = b;
break;
}
}
}
if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) }
}
/// Byte offsets of the editor's caret and anchor in a document, in that order.
fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) {
char_bytes(&doc.text, caret, anchor)
}
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
let cid = comp_id(s, p)?;
@@ -78,7 +271,7 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
Some(e) if e.layer == lid => e.clone(),
_ => {
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() };
s.state.selected_layers = vec![lid];
s.state.text_edit = Some(e.clone());
e
@@ -101,7 +294,8 @@ pub(crate) fn edit_doc(
) -> Result<()> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let t = s.time();
s.edit(label, merge, |proj, st| {
let revision = s.revision;
s.edit_text_doc(label, merge, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
@@ -110,16 +304,28 @@ pub(crate) fn edit_doc(
KV::Str(t) => TextDoc::plain(&t),
_ => TextDoc::default(),
};
let before = first_line_name(&doc.text);
let original = doc.clone();
f(&mut doc, st)?;
let after = first_line_name(&doc.text);
pr.set_value_at(lt, KV::Text(Box::new(doc)));
// Text layers are named after their text until renamed.
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
l.name = after;
// A no-op (an IME deletion with nothing around the selection, a set to the value it
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
// would change the project and record an empty undo step.
if doc != original {
let before = first_line_name(&original.text);
let after = first_line_name(&doc.text);
pr.set_value_at(lt, KV::Text(Box::new(doc)));
// Text layers are named after their text until renamed.
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
l.name = after;
}
}
Ok(())
})
})?;
// The editor's unavailable-text receipt identity follows only its own document: an edit to
// another text layer while this editor is open must not cancel the composition.
if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) {
s.note_text_edit_change();
}
Ok(())
}
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
@@ -153,13 +359,13 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
};
let created = b_p(p, "created").unwrap_or(false);
s.state.selected_layers = vec![lid];
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() });
s.bump();
Ok(state_json(s))
}
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
@@ -314,6 +520,78 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// How many whole characters `before_bytes` / `after_bytes` cover around `r`.
///
/// This is the input method's own byte count, as winit reports it: a count that ends inside a
/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a
/// malformed or platform-specific offset never splits or corrupts the document.
fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range<usize>, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
let r = r.start.min(n)..r.end.min(n);
let (start_byte, end_byte) = char_bytes(text, r.start, r.end);
let from = start_byte.saturating_sub(before_bytes);
let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count());
let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count());
(before.min(r.start), after.min(n.saturating_sub(r.end)))
}
/// Delete the text an input method marks for replacement around the selection
/// (`DeleteSurrounding`, `DeleteSurroundingBytes`).
///
/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its
/// start and after its end are removed in two separate ranges, and the following `Commit`
/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters
/// otherwise); a count that ends inside a character leaves that character.
///
/// The counts are computed from the one document `edit_doc` already evaluated, so an animated
/// Source Text and a large static document are not copied or evaluated again just to decide what
/// the input method asked to replace.
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
let lid = target(s, p, "text.deleteSurrounding")?;
// Only an open edit session has a selection to keep: without one there is nothing an input
// method could be replacing.
let e = match &s.state.text_edit {
Some(e) if e.layer == lid => e.clone(),
_ => return Err(bad("text.deleteSurrounding", "not editing this text layer")),
};
let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
let (before_count, after_count) = (count("before"), count("after"));
let bytes = b_p(p, "bytes").unwrap_or(false);
let merge = str_p(p, "merge").map(str::to_string);
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
let n = doc.char_len();
let r = e.range().start.min(n)..e.range().end.min(n);
let (before, after) = if bytes {
surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count)
} else {
(before_count.min(r.start), after_count.min(n.saturating_sub(r.end)))
};
let start = r.start.saturating_sub(before);
let end = r.end.saturating_add(after).min(n);
if start == r.start && end == r.end {
// Deletion-only batches with an empty count, or a document with nothing around the
// selection: no empty undo step.
return Ok(());
}
// The selection keeps its text: remove after it first, then before it, so the two ranges
// stay valid and the caret/selection shift by the characters before it.
if end > r.end {
doc.replace_range(r.end..end, "", None);
}
if r.start > start {
doc.replace_range(start..r.start, "", None);
}
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
let shift = r.start.saturating_sub(start);
ed.anchor = ed.anchor.saturating_sub(shift);
ed.caret = ed.caret.saturating_sub(shift);
ed.pending = None;
ed.goal_x = None;
}
Ok(())
})?;
Ok(state_json(s))
}
/// Selected text (plain) of the session, and its formatted fragment.
fn selection_doc(s: &Session) -> Option<TextDoc> {
let e = s.state.text_edit.as_ref()?;
@@ -448,16 +726,111 @@ fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
/// Give the current editor a fresh replacement epoch after its document/layer was replaced.
pub(crate) fn invalidate_session(s: &mut Session) {
let replaced = s.state.text_edit.is_some();
if let Some(e) = s.state.text_edit.as_mut() {
e.session = next_edit_session();
}
if replaced {
s.note_text_edit_change();
}
}
/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced.
///
/// This is the one replacement path for document changes outside `edit_doc`:
/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`]
/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and
/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted
/// before a history restore or rollback.
/// Clamping first means a shortened document leaves the caret in range, so the next Backspace
/// deletes instead of being swallowed by an out-of-range selection.
pub(crate) fn document_replaced(s: &mut Session) {
sanitize(s);
invalidate_session(s);
}
/// The identity and document of the active on-canvas editor at one moment.
///
/// The composition and the editor session are part of the identity: a restoration must not keep
/// a composition alive when the snapshot came from another comp, or from an intermediate editor
/// that was restarted during the operation being rolled back.
#[derive(Clone, Debug, PartialEq)]
pub struct EditorSnapshot {
/// The composition the editor was attached to.
pub comp: ItemId,
/// The edited layer.
pub layer: LayerId,
/// The editor session token, unique across the process.
pub session: u64,
/// The Source Text the editor was showing.
pub doc: TextDoc,
}
/// The identity and document the active on-canvas editor is showing.
///
/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it
/// afterwards: only a change that actually replaces this document starts a new epoch, while a
/// write to another property, layer or composition, a move to a time showing the same text, or a
/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a
/// single `Option` check, with no document copy.
pub fn active_editor_snapshot(s: &Session) -> Option<EditorSnapshot> {
let e = s.state.text_edit.as_ref()?;
Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? })
}
/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the
/// selection and start a new IME epoch.
///
/// An edit that itself moves or closes the editor owns the resulting session, so an identity
/// mismatch is not a replacement here.
pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) {
let Some(before) = before else { return };
let Some(e) = s.state.text_edit.as_ref() else { return };
if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session {
return;
}
if layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
document_replaced(s);
}
}
/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor
/// only when the restored state still has the same composition, layer and editor session and
/// shows byte/style-equal text.
///
/// The restored session token is the one saved in the replaced state, so an intermediate
/// restart or cross-comp switch must be detected against the snapshot instead of being assumed
/// to be the editor's own change.
pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) {
let Some(before) = before else {
// No editor was open when the snapshot was taken: a restored editor must not inherit a
// composition from the state the replacement replaced.
if s.state.text_edit.is_some() {
document_replaced(s);
}
return;
};
let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session);
if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
document_replaced(s);
}
}
/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the
/// layer is gone or not in the active comp.
///
/// This is also called after project-item cleanup that leaves the edited document alone, so it
/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as
/// a document replacement and interrupt the active IME composition. Replacement paths call
/// [`document_replaced`] instead.
pub(crate) fn sanitize(s: &mut Session) {
let Some(e) = s.state.text_edit.clone() else { return };
match layer_doc(s, e.layer) {
Some(d) => {
let n = d.char_len();
if (e.anchor > n || e.caret > n)
&& let Some(ed) = s.state.text_edit.as_mut()
{
if let Some(ed) = s.state.text_edit.as_mut() {
ed.anchor = ed.anchor.min(n);
ed.caret = ed.caret.min(n);
}
@@ -594,6 +967,15 @@ pub fn specs() -> Vec<CommandSpec> {
editing_or_text_layer,
delete
),
cmd!(
"text.deleteSurrounding",
"Delete Text Around Selection",
[],
None,
"{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)",
editing_or_text_layer,
delete_surrounding
),
cmd!(
"edit.pasteTextMatchFormatting",
"Paste Text and Match Formatting",
+12 -5
View File
@@ -129,17 +129,22 @@ pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result<usize> {
/// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read
/// (only without `undoable`).
fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet<String>)> {
if s.importer.is_none() {
let Some(importer) = s.importer.clone() else {
return Err(crate::EngineError::Other("media import is not available in this build".into()));
}
};
let mut updates = vec![];
let mut paths = BTreeSet::new();
let mut failed = BTreeSet::new();
for i in ids {
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
// Probed the way it imports by default, so a sequence is found as one. A path that
// is not followed is not read.
let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path));
// A sequence is probed the way it imports by default, so its run is found again; any
// other item is its one file, not the run a numbered still sits in (#2074). A path
// that is not followed is not read.
let probed = s
.stored_paths
.check(f)
.map_err(|(p, why)| crate::doc_path::refusal(&p, why))
.and_then(|_| if f.kind == FootageKind::Sequence { s.probe_footage(&f.path) } else { importer.probe(&f.path) });
let nf = match probed {
Ok(p) => refreshed(s, f, p),
Err(_) if undoable => Footage { missing: true, ..f.clone() },
@@ -193,6 +198,8 @@ fn forget(s: &mut Session, paths: &BTreeSet<String>) {
s.footage.forget(p);
}
s.layer_cache.clear();
// Reloaded files may keep their footage settings but not their size and time.
s.refresh_disk_salt(true);
s.events.push(crate::Event::PurgeCaches);
}
+2 -1
View File
@@ -341,13 +341,14 @@ impl Session {
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
let edited = crate::commands::text_edit::active_editor_snapshot(self);
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.history.undo = undo;
self.history.redo = redo;
self.history.branches = branches;
self.history.merge_key = None;
self.sanitize_state();
self.sanitize_after_replacement(edited.as_ref());
self.bump();
true
}
+128 -6
View File
@@ -394,6 +394,15 @@ pub struct Session {
pub project: Arc<Project>,
pub history: History,
pub revision: u64,
/// Process-unique monotonic revision of the on-canvas editor's committed text and session.
/// Unlike [`Session::revision`], unrelated project edits do not advance it; the IME receipt
/// uses it when surrounding text is unavailable. Read it with
/// [`commands::text_edit::ime_stamp_revision`].
///
/// Stamps come from a process-global checked allocator and are never rewound: a failed
/// atomic batch keeps the highest intermediate stamp. On allocator exhaustion no stamp is
/// issued, so an unavailable receipt cannot be accepted with a reused revision.
pub ime_revision: u64,
pub saved_revision: u64,
/// The project as last opened or saved: the project is unmodified while it is this very
/// snapshot, so undoing back to the saved state clears the modified mark.
@@ -521,6 +530,8 @@ pub struct Session {
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
pub disk_salt: Option<(u64, u64)>,
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
/// is, settings that guard what scripts may do can't change (see
/// [`prefs::Scripting::same_permissions`]).
@@ -560,6 +571,8 @@ impl Default for Session {
project,
history: History::default(),
revision: 0,
// A fresh session gets its own process-monotonic stamp even before its first edit.
ime_revision: commands::text_edit::next_ime_revision().unwrap_or(u64::MAX),
saved_revision: 0,
path: None,
state: EditorState { snapping: true, view_layout: 1, ..Default::default() },
@@ -618,6 +631,7 @@ impl Default for Session {
check_footage_on_open: false,
stored_paths: Default::default(),
footage_stamps: Default::default(),
disk_salt: None,
footage_scan: None,
script_calls: ScriptCalls::default(),
}
@@ -683,7 +697,25 @@ impl Session {
}
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
///
/// Every edit is an external boundary for the on-canvas text editor: if it replaced the
/// document the editor is showing, the selection is clamped and the editor gets a fresh IME
/// epoch. The editor's own typing goes through `edit_text_doc`, which owns that
/// document and must not treat its own keystrokes as a replacement.
pub fn edit<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
let edited = commands::text_edit::active_editor_snapshot(self);
let out = self.edit_inner(label, merge, f)?;
commands::text_edit::after_external_change(self, edited.as_ref());
Ok(out)
}
/// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one
/// the editor owns, so a change here is typing, not a replacement behind the IME's back.
pub(crate) fn edit_text_doc<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
self.edit_inner(label, merge, f)
}
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
let before = self.project.clone();
let mut p = (*self.project).clone();
let mut st = self.state.clone();
@@ -730,18 +762,61 @@ impl Session {
Ok(r)
}
/// Note a change to the on-canvas editor's committed text or session.
///
/// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated
/// project change must not advance it.
pub(crate) fn note_text_edit_change(&mut self) {
// Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion
// keep the previous value; an unavailable before/after pair then compares equal and
// earns no receipt instead of accepting a stale revision.
if let Some(revision) = commands::text_edit::next_ime_revision() {
self.ime_revision = self.ime_revision.max(revision);
}
}
pub fn bump(&mut self) {
self.revision += 1;
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
if Arc::ptr_eq(&self.project, &self.saved_project) {
self.saved_revision = self.revision;
}
if self.disk_cache.is_some() {
self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project));
}
self.refresh_disk_salt(false);
self.events.push(Event::ProjectChanged { revision: self.revision });
}
/// Salt the disk cache's layer keys with the project's footage files
/// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of
/// every file, every frame of an image sequence included, so it is taken again only when the
/// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache
/// attached), not at every edit: with a few large sequences that cost each keystroke in a
/// text field over half a second (#1926).
pub fn refresh_disk_salt(&mut self, force: bool) -> Option<u64> {
use std::hash::{Hash, Hasher};
if self.disk_cache.is_none() {
self.disk_salt = None;
return None;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for (id, it) in &self.project.items {
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
id.hash(&mut h);
format!("{f:?}").hash(&mut h);
}
}
let footage = h.finish();
if let Some((seen, salt)) = self.disk_salt
&& seen == footage
&& !force
{
return Some(salt);
}
let salt = effectcraft_render::disk_cache::footage_salt(&self.project);
self.disk_salt = Some((footage, salt));
self.layer_cache.set_disk_salt(salt);
Some(salt)
}
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
/// valid ([`effectcraft_project::Comp::same_pixels`]).
@@ -772,26 +847,48 @@ impl Session {
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
self.sanitize_after_replacement(edited.as_ref());
self.bump();
true
}
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
self.sanitize_after_replacement(edited.as_ref());
self.bump();
true
}
/// Keep selections valid after the project was replaced by another state (undo, redo, a
/// history jump, a rollback): the same layer id can name a different document, so the
/// on-canvas editor gets a fresh IME epoch only when its own document changed.
///
/// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the
/// state this call is about to replace. Atomic batch rollback is the exception: it has
/// already restored the pre-batch state and passes the snapshot of the failing intermediate
/// state, so a transient restart or component switch inside the batch still rejects reviving
/// the old composition. A restored state that still has the same composition, layer and
/// editor session and shows byte/style-equivalent text (an opacity or other property undo)
/// keeps the editor's owner and selection; a different document, comp, layer or session clamps the
/// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's
/// matching layer id never inherits the old comp's session. `None` means no editor was open
/// when the snapshot was taken; if one is open after the replacement it is treated as an
/// unverified document replacement, so it never inherits an old composition.
pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) {
self.sanitize_state();
commands::text_edit::after_replacement(self, before);
}
/// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id
/// counts only inside the comp that owns it, so the active comp never sees another comp's layers.
pub fn sanitize_state(&mut self) {
@@ -808,6 +905,8 @@ impl Session {
});
if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) {
self.clear_live_selection();
// A matching layer id in the fallback comp is not the document being edited.
self.state.text_edit = None;
self.state.active_comp = self.state.open_comps.first().copied();
if let Some(id) = self.state.active_comp {
self.recall_selection(id);
@@ -867,13 +966,22 @@ impl Session {
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
}
/// Move the active comp's current time.
///
/// Time is an external boundary for the on-canvas text editor too: an animated Source Text
/// can show a different document at another time. The document is snapshotted only while an
/// editor is open, so playback with no text editor does no text copying.
pub fn set_time(&mut self, t: Tick) {
if let Some(c) = self.state.active_comp {
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
let edited = commands::text_edit::active_editor_snapshot(self);
self.state.times.insert(c, t);
if self.prefs.general.sync_time_related_items {
self.sync_related_times(c, t);
// Direct call: this `set_time` owns the editor snapshot for both time changes,
// so a synced move does not take a second snapshot or start a second epoch.
self.sync_related_times_inner(c, t);
}
commands::text_edit::after_external_change(self, edited.as_ref());
}
}
@@ -881,6 +989,14 @@ impl Session {
/// `comp` moves the current time of the comps nested in it (through their precomp layers'
/// timing) and of the comps it is nested in, so every related viewer shows the same moment.
pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) {
// Moving a related comp can move the active one, so the editor's document is checked
// here too; the snapshot is skipped entirely when no text editor is open.
let edited = commands::text_edit::active_editor_snapshot(self);
self.sync_related_times_inner(comp, t);
commands::text_edit::after_external_change(self, edited.as_ref());
}
fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) {
let mut seen = std::collections::BTreeSet::from([comp]);
let mut todo = vec![(comp, t)];
let mut found = vec![];
@@ -924,6 +1040,9 @@ impl Session {
self.state.active_comp = Some(id);
self.recall_selection(id);
self.state.current_track = None;
// A text-edit session belongs to the comp that owned it. Switching comps ends it; even
// a same-numbered layer in the new comp must not inherit the old IME composition.
self.state.text_edit = None;
}
/// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`].
@@ -932,6 +1051,7 @@ impl Session {
self.state.active_comp = None;
self.clear_live_selection();
self.state.current_track = None;
self.state.text_edit = None;
}
fn remember_selection(&mut self) {
@@ -1175,6 +1295,8 @@ impl Session {
}
self.layer_cache.clear();
self.project = Arc::new(p);
// Its files may have changed since they were last stamped.
self.disk_salt = None;
self.history = History::default();
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
self.path = path;
+2 -2
View File
@@ -80,10 +80,10 @@ impl MaskMethod {
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let n: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
MaskMethod::ALL
.into_iter()
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == n)
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase() == n)
}
pub fn model(self) -> Model {
match self {
+2 -2
View File
@@ -289,10 +289,10 @@ pub struct Inbox(Mutex<Vec<WorkerReply>>);
impl Inbox {
pub fn push(&self, r: WorkerReply) {
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(r);
}
pub fn take(&self) -> Vec<WorkerReply> {
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
std::mem::take(&mut *self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner))
}
}
+10 -8
View File
@@ -26,17 +26,19 @@ pub const PREFS_FILE: &str = "prefs.json";
pub const LANGUAGES: &[(&str, &str)] = &[
("Match System", "system"),
("English", "en"),
// Alphabetical by the language's own name after English; Cyrillic, Georgian, then CJK names
// follow the Latin ones.
("Čeština", "cs"),
("Deutsch", "de"),
("Español", "es"),
("Français", "fr"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Українська", "uk"),
("ქართული", "ka"),
("日本語", "ja"),
("简体中文", "zh-hans"),
("繁體中文", "zh-hant"),
("Українська", "uk"),
("Español", "es"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Français", "fr"),
("Deutsch", "de"),
("Čeština", "cs"),
("ქართული", "ka"),
];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
+1 -1
View File
@@ -110,7 +110,7 @@ pub struct JobShared {
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl JobShared {
+4 -2
View File
@@ -102,7 +102,7 @@ impl Session {
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).
pub fn configure_disk_cache(&mut self) {
use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt};
use effectcraft_render::disk_cache::{DiskCache, default_folder};
let d = &self.prefs.disk;
let folder = if d.disk_cache_folder.trim().is_empty() {
(self.config.is_some() && !cfg!(target_arch = "wasm32")).then(default_folder)
@@ -136,7 +136,9 @@ impl Session {
}
},
};
self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project))));
self.disk_cache = Some(dc.clone());
let salt = self.refresh_disk_salt(true).unwrap_or_default();
self.layer_cache.set_disk(Some((dc.clone(), salt)));
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
// disk cache off drops it).
let store: std::sync::Arc<dyn effectcraft_effects::roto::SegStore> = dc.clone();
+8 -2
View File
@@ -486,10 +486,10 @@ pub fn encode_thumb(w: u32, h: u32, rgba: &[u8]) -> String {
/// Decode a thumbnail to RGB bytes (THUMB_W×THUMB_H×3).
pub fn decode_thumb(text: &str) -> Option<Vec<u8>> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
if t.len() != THUMB_W * THUMB_H * 6 || !t.is_ascii() {
return None;
}
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect()
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok()).collect()
}
/// Render a project's main comp at its poster time into a thumbnail.
@@ -943,6 +943,12 @@ mod tests {
use super::*;
use crate::config::{DirConfig, MemoryConfig};
#[test]
fn non_ascii_thumbnail_is_rejected_without_panicking() {
let malformed = format!("aé{}", "0".repeat(THUMB_W * THUMB_H * 6 - 3));
assert!(decode_thumb(&malformed).is_none());
}
#[test]
fn every_builtin_builds_with_controls_and_renders() {
let s = Session::default();
+17
View File
@@ -131,6 +131,23 @@ fn environment_light_is_listed_and_takes_a_source_layer() {
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None);
}
/// Deleting the layer a Source points at leaves no dangling id: "None" means the comp's
/// Environment Layer, and a dead id rendered as nothing at all (#2153).
#[test]
fn deleting_a_source_layer_clears_the_reference() {
let mut s = session();
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
s.execute("edit.clear", json!({"layers": [sky]})).unwrap();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "the reference goes with the layer");
// One history step with the delete, so undo brings both back.
s.undo();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
}
#[test]
fn three_d_switch_undo_redo() {
let mut s = session();
+2 -2
View File
@@ -20,7 +20,7 @@ use crate::render::{FootageSource, RenderOpts};
struct Synthetic;
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
match name {
"red16.png" => [1.0, 0.0, 0.0, 1.0],
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
@@ -43,7 +43,7 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
}
fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
+45 -2
View File
@@ -8,14 +8,16 @@ use serde_json::{Value, json};
use crate::Session;
/// Probes every file as a 64×32 still (the media layer's single-file probe).
/// Probes every file as a still 64 + its byte length wide and 32 high (the media layer's
/// single-file probe).
struct Stills;
impl crate::Importer for Stills {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !std::path::Path::new(path).is_file() {
return Err(format!("{path}: no such file"));
}
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
let width = 64 + std::fs::metadata(path).map_or(0, |m| m.len() as u32);
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
}
}
@@ -196,6 +198,19 @@ fn replace_footage_and_reload_keep_sequences() {
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
}
/// #2074: reloading a numbered still probed it as its run, so it took the first file's size.
#[test]
fn reloading_a_numbered_still_measures_its_own_file() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap()["items"][0].clone();
std::fs::write(at(&dir, "shot_0002.png"), [0; 16]).unwrap();
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.width, f.sequence.len()), ("shot_0002.png", FootageKind::Still, 80, 0));
assert_eq!(f.path, at(&dir, "shot_0002.png"));
}
/// #756: numbered DPX and Cineon scans import as sequences, as in After Effects (`.cin` was not
/// an image to the Media Browser, so its runs came in one file at a time).
#[test]
@@ -207,3 +222,31 @@ fn dpx_and_cineon_runs_import_as_sequences() {
got.sort();
assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}");
}
/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences
/// again for the disk cache's salt (with large sequences each keystroke in a text field took over
/// half a second); Reload Footage does, and so does new footage.
#[test]
fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() {
let dir = folder(&RUN);
let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache);
let mut s = session();
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap();
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap();
let item = r["items"][0].clone();
let salt = s.refresh_disk_salt(false).expect("a disk cache");
// A frame rewritten in place: same footage settings, another size on disk.
std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap();
s.execute("layer.newText", json!({"text": "title"})).unwrap();
s.execute("layer.setText", json!({"text": "title 2"})).unwrap();
assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again");
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
let reloaded = s.refresh_disk_salt(false).unwrap();
assert_ne!(reloaded, salt, "Reload Footage stamps them again");
std::fs::write(at(&dir, "extra.png"), b"").unwrap();
s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap();
assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts");
let _ = std::fs::remove_dir_all(&cache);
}
+863 -37
View File
@@ -6,7 +6,7 @@ use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc};
use serde_json::json;
use crate::Session;
use crate::commands::text_edit::layer_doc;
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
fn session(text: &str) -> (Session, u64) {
let mut s = Session::default();
@@ -24,6 +24,98 @@ fn sel(s: &Session) -> (usize, usize) {
(e.anchor, e.caret)
}
/// Start editing the text layer with everything selected (no caret given).
fn begin_edit(s: &mut Session, t: u64) {
s.execute("text.edit", json!({"layer": t})).unwrap();
}
/// Start editing the text layer with the caret at `caret`.
fn begin_edit_at(s: &mut Session, t: u64, caret: usize) {
s.execute("text.edit", json!({"layer": t, "caret": caret})).unwrap();
}
/// A session with one text layer already open in the editor at `caret`.
fn editing(text: &str, caret: usize) -> (Session, u64) {
let (mut s, t) = session(text);
begin_edit_at(&mut s, t, caret);
(s, t)
}
/// A session with one text layer already open in the editor with everything selected.
fn editing_all(text: &str) -> (Session, u64) {
let (mut s, t) = session(text);
begin_edit(&mut s, t);
(s, t)
}
/// The IME owner token of the open editor.
fn ime_owner(s: &Session) -> u64 {
s.state.text_edit.as_ref().unwrap().session
}
fn undo_steps(s: &Session) -> usize {
s.history.undo.len()
}
/// The editor kept its document (same IME owner).
fn assert_ime_owner(s: &Session, expected: u64, why: &str) {
assert_eq!(ime_owner(s), expected, "{why}");
}
/// The editor's document was replaced (new IME owner/epoch).
fn assert_ime_epoch_changed(s: &Session, previous: u64, why: &str) {
assert_ne!(ime_owner(s), previous, "{why}");
}
fn assert_text(s: &Session, t: u64, expected: &str) {
assert_eq!(doc(s, t).text, expected);
}
/// A new, empty composition called "Other" (the standard second comp).
fn new_other_comp(s: &mut Session) -> u64 {
s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap()
}
/// Stopwatch the layer's Source Text (one key at the CTI).
fn animate_source(s: &mut Session, t: u64) {
s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap();
}
/// Add a Source Text key at `time`.
fn add_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
}
/// Write Source Text as a static value (a replacement when it differs from the shown text).
fn set_source(s: &mut Session, t: u64, value: &str) {
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": value})).unwrap();
}
/// Write the Source Text key at `time`.
fn set_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
}
/// Set the Source Text key at the CTI (`keys.set`).
fn set_source_cti_key(s: &mut Session, t: u64, time: f64, value: &str) {
s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
}
/// Replace the whole Source Text of the layer (`layer.setText`).
fn replace_text(s: &mut Session, t: u64, text: &str) {
s.execute("layer.setText", json!({"layer": t, "text": text})).unwrap();
}
/// Select the character range `start..end` in the open editor.
fn select(s: &mut Session, start: usize, end: usize) {
s.execute("text.setSelection", json!({"start": start, "end": end})).unwrap();
}
/// Set both selection ends, keeping their direction (`anchor` is the other end).
fn select_anchor(s: &mut Session, anchor: usize, caret: usize) {
s.execute("text.setSelection", json!({"anchor": anchor, "caret": caret})).unwrap();
}
#[test]
fn set_text_with_a_range_creates_runs_and_undoes() {
let (mut s, t) = session("Hello World");
@@ -200,7 +292,596 @@ fn edit_state_round_trips_through_serde() {
s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap();
let j = serde_json::to_value(&s.state).unwrap();
let back: crate::EditorState = serde_json::from_value(j).unwrap();
assert_eq!(back.text_edit, s.state.text_edit);
let before = s.state.text_edit.clone().expect("editing");
let after = back.text_edit.clone().expect("editing");
assert_eq!((after.layer, after.anchor, after.caret), (before.layer, before.anchor, before.caret));
assert_ne!(after.session, before.session, "a restored editor gets a process-unique replacement epoch");
assert_ne!(after.session, 0, "the restored epoch is always a real session token");
}
#[test]
fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() {
let (mut s, t) = editing_all("abc");
let first = ime_owner(&s);
// Replacing Source Text is a new document even though the layer id stays.
replace_text(&mut s, t, "replaced");
let replaced = ime_owner(&s);
assert_ne!(replaced, first, "layer.setText starts a new IME epoch");
// Undo restores another document: it must not revive the token from before either edit.
s.execute("edit.undo", json!({})).unwrap();
let undone = ime_owner(&s);
assert_ne!(undone, replaced);
assert_ne!(undone, first);
// Clear + immediate undo in the same command sequence: the layer is gone long enough to
// end the session, and the restored layer cannot inherit a composition.
begin_edit(&mut s, t);
s.execute("edit.clear", json!({"layers": [t]})).unwrap();
assert!(s.state.text_edit.is_none(), "clearing the edited layer ends the session");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "undo does not recreate the old composition session");
begin_edit(&mut s, t);
assert_ne!(ime_owner(&s), first);
}
#[test]
fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() {
use effectcraft_keyframe::Value as KV;
let (mut s, t) = session("abc");
// Legacy/imported text can still be stored as a plain Str rather than a TextDoc.
let cid = s.active_comp_id().unwrap();
let pr = std::sync::Arc::make_mut(&mut s.project)
.comp_mut(cid)
.unwrap()
.layer_mut(effectcraft_project::LayerId(t))
.unwrap()
.props
.prop_mut("text/sourceText")
.unwrap();
pr.value = KV::Str("abc".into());
begin_edit_at(&mut s, t, 0);
let steps = undo_steps(&s);
// A non-zero before count at the document start deletes nothing.
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap();
assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant");
assert!(pr.keys.is_empty());
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
}
#[test]
fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() {
let (mut s, t) = editing("abc", 0);
animate_source(&mut s, t);
let lid = effectcraft_project::LayerId(t);
let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len();
assert_eq!(keys(&s), 1, "one key from the stopwatch");
s.execute("time.set", json!({"time": 1.0})).unwrap();
let steps = undo_steps(&s);
// A non-zero before count at the document start deletes nothing, at a time with no key.
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI");
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
}
#[test]
fn property_writes_replace_the_edited_document_only_when_the_text_changes() {
let (mut s, t) = session("abc");
let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap();
begin_edit_at(&mut s, t, 1);
let owner = ime_owner(&s);
let steps = undo_steps(&s);
// Writing the text the document already has is a no-op: same document, same owner, no step.
set_source(&mut s, t, "abc");
assert_ime_owner(&s, owner, "a no-op write keeps the IME owner");
assert_eq!(undo_steps(&s), steps, "a no-op write records no undo step");
replace_text(&mut s, t, "abc");
assert_ime_owner(&s, owner, "a no-op layer.setText keeps the IME owner");
assert_eq!(undo_steps(&s), steps, "a no-op layer.setText records no undo step");
// Another property of the edited layer leaves its document alone.
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
assert_ime_owner(&s, owner, "an unrelated property keeps the IME owner");
// Source Text of another layer is not the editor's document either.
set_source(&mut s, other, "changed");
assert_ime_owner(&s, owner, "another layer's text keeps the IME owner");
// Replacing the edited layer's Source Text is a document replacement: a new IME epoch.
set_source(&mut s, t, "replaced");
assert_text(&s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "replacing the document changes the IME owner");
}
#[test]
fn undo_and_redo_of_an_unrelated_property_keep_the_ime_owner_and_selection() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
assert_ime_owner(&s, owner, "the opacity write keeps the owner");
assert_eq!(sel(&s), (3, 3), "the opacity write keeps the selection");
// The opacity step does not change Source Text: restoring it keeps the same editor.
s.execute("edit.undo", json!({})).unwrap();
assert_ime_owner(&s, owner, "undoing only opacity keeps the owner");
assert_eq!(sel(&s), (3, 3), "undoing only opacity keeps the selection");
s.execute("edit.redo", json!({})).unwrap();
assert_ime_owner(&s, owner, "redoing only opacity keeps the owner");
assert_eq!(sel(&s), (3, 3), "redoing only opacity keeps the selection");
}
#[test]
fn a_style_only_text_change_and_its_undo_replace_the_editor_epoch() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
// The same characters with another run style are still a different document.
s.execute("layer.setText", json!({"layer": t, "range": [1, 4], "size": 12})).unwrap();
assert_eq!(doc(&s, t).text, "abcdef");
assert_eq!(doc(&s, t).style_at(2).size, 12.0);
assert_ime_epoch_changed(&s, owner, "a style-only change starts a new IME epoch");
assert_eq!(sel(&s), (3, 3), "a same-length style change keeps the caret in range");
let styled = ime_owner(&s);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).style_at(2).size, 60.0);
assert_ime_epoch_changed(&s, styled, "undoing a style change starts a new IME epoch");
}
#[test]
fn history_jumps_keep_the_owner_over_unrelated_steps_and_replace_it_when_the_text_changes() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 25})).unwrap();
let states = s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().unwrap().clone();
let cur = states.iter().find(|n| n["current"] == true).unwrap()["index"].as_u64().unwrap();
let parent = states.iter().find(|n| n["index"].as_u64() == Some(cur)).unwrap()["parent"].as_u64().unwrap();
// Both states show the same Source Text: jumping between them keeps the editor.
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
assert_ime_owner(&s, owner, "jumping to another opacity state keeps the owner");
assert_eq!(sel(&s), (3, 3), "and the selection");
s.execute("edit.history.goto", json!({"index": cur})).unwrap();
assert_ime_owner(&s, owner, "jumping back keeps the owner");
assert_eq!(sel(&s), (3, 3), "and the selection");
// A history state whose Source Text differs is a document replacement.
set_source(&mut s, t, "abc");
let changed = ime_owner(&s);
assert_ne!(changed, owner);
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
assert_text(&s, t, "abcdef");
assert_ime_epoch_changed(&s, changed, "jumping to a different document starts a new epoch");
}
#[test]
fn atomic_batch_rollback_keeps_the_owner_for_unrelated_steps_and_replaces_it_for_text_changes() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 50}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_ime_owner(&s, owner, "rolling back an unrelated step keeps the owner");
assert_eq!(sel(&s), (3, 3), "and the selection");
// A partially applied text replacement was still shown before the rollback restores the
// original document, so the editor must not revive the composition it had before the batch.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "layer.setText", "params": {"layer": t, "text": "ab"}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_text(&s, t, "abcdef");
assert_ime_epoch_changed(&s, owner, "a rolled-back text change does not revive the old owner");
assert_eq!(sel(&s), (3, 3), "the restored document keeps the caret in range");
}
#[test]
fn atomic_batch_rollback_after_a_transient_editor_restart_bumps_the_epoch() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
// `text.edit` restarts the session even on the layer that is already edited. The rollback
// restores the pre-batch TextEdit (with the old token), so the intermediate restart must be
// detected from the snapshot: neither the old composition nor its token may be revived.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(sel(&s), (3, 3), "the restored selection is the pre-batch one");
assert_ime_epoch_changed(&s, owner, "a canceled intermediate editor must not revive the old token");
}
#[test]
fn atomic_batch_rollback_after_a_cross_comp_editor_switch_bumps_the_epoch() {
let (mut s, t) = session("abcdef");
let original = s.active_comp_id().unwrap();
// Give the other comp a layer with the same id and the same Source Text, so only the comp
// and editor-session identity distinguishes the two editors.
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
let other = new_other_comp(&mut s);
let other_id = effectcraft_project::ItemId(other);
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
s.execute("comp.open", json!({"comp": original.0})).unwrap();
begin_edit_at(&mut s, t, 3);
let owner = ime_owner(&s);
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "comp.open", "params": {"comp": other}},
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
assert_eq!(sel(&s), (3, 3), "and the pre-batch selection");
assert_ime_epoch_changed(&s, owner, "an identical layer in another comp must not revive the old token");
}
#[test]
fn an_atomic_batch_drops_the_events_its_failed_steps_queued() {
let (mut s, _) = session("abcdef");
let original = s.active_comp_id().unwrap();
// `comp.new` opens the comp it creates; go back so the batch's switch is a real one.
let other = new_other_comp(&mut s);
s.execute("comp.open", json!({"comp": original.0})).unwrap();
s.drain_events();
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "comp.open", "params": {"comp": other}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
assert!(
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(id) if id.0 == other)),
"the rolled-back step's OpenComp is not queued for the UI"
);
}
#[test]
fn atomic_batch_rollback_after_closing_the_editor_does_not_revive_it() {
let (mut s, t) = session("abcdef");
let original = s.active_comp_id().unwrap();
let other = new_other_comp(&mut s);
s.execute("comp.open", json!({"comp": original.0})).unwrap();
begin_edit_at(&mut s, t, 3);
let owner = ime_owner(&s);
// The failing state has no editor at all: the restoration brings the pre-batch one back and
// must treat it as unverified rather than revive its composition.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "comp.open", "params": {"comp": other}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert!(s.state.text_edit.is_some(), "the batch restored the editor state");
assert_eq!(sel(&s), (3, 3));
assert_ime_epoch_changed(&s, owner, "a canceled editor close must not revive the old token");
}
#[test]
fn restoring_without_an_editor_snapshot_replaces_an_open_editor() {
let (mut s, _) = editing("abcdef", 3);
let owner = ime_owner(&s);
// `None` means no editor was open when the state was replaced; an editor that is open
// afterwards cannot prove it kept its document.
s.sanitize_after_replacement(None);
assert_ime_epoch_changed(&s, owner, "an unverified restored editor starts a new epoch");
assert_eq!(sel(&s), (3, 3));
}
#[test]
fn undo_to_a_shorter_document_clamps_the_caret() {
let (mut s, t) = editing("abc", 3);
replace_text(&mut s, t, "abcdef");
select_anchor(&mut s, 6, 6);
let owner = ime_owner(&s);
assert_eq!(sel(&s), (6, 6));
s.execute("edit.undo", json!({})).unwrap();
assert_text(&s, t, "abc");
assert_eq!(sel(&s), (3, 3), "undoing to a shorter document clamps the caret");
assert_ime_epoch_changed(&s, owner, "and starts a new epoch");
}
#[test]
fn the_ime_stamp_revision_follows_the_editor_not_unrelated_project_edits() {
let (mut s, t) = editing("abcdef", 6);
let revision = ime_stamp_revision(&s);
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
assert_eq!(ime_stamp_revision(&s), revision, "an unrelated property write does not advance it");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ime_stamp_revision(&s), revision, "undoing it does not either");
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(ime_stamp_revision(&s), revision, "a rolled-back unrelated batch restores it");
s.execute("text.insert", json!({"text": "x"})).unwrap();
assert!(ime_stamp_revision(&s) > revision, "typing advances it");
let typed = ime_stamp_revision(&s);
s.execute("layer.setText", json!({"layer": t, "text": "y"})).unwrap();
assert!(ime_stamp_revision(&s) > typed, "a replaced document advances it too");
}
#[test]
fn a_failed_atomic_batch_with_a_transient_edit_keeps_its_high_water_ime_revision() {
let (mut s, t) = editing("abcdef", 6);
let revision = ime_stamp_revision(&s);
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "text.insert", "params": {"text": "x"}},
{"command": "text.delete", "params": {}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_text(&s, t, "abcdef");
// The intermediate document edit is real even though the final document equals the
// pre-batch one: its stamp must remain the high-water mark.
assert!(ime_stamp_revision(&s) > revision, "a transient edit must not rewind the IME revision");
let after_transient = ime_stamp_revision(&s);
// An unrelated failed batch really did not edit the document: it must preserve the revision
// exactly, or every wide-selection composition would be canceled by unrelated work.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(ime_stamp_revision(&s), after_transient, "an unrelated rolled-back batch preserves the revision");
}
#[test]
fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() {
let (mut s, t) = editing("abcdef", 6);
let owner = ime_owner(&s);
set_source(&mut s, t, "a");
assert_text(&s, t, "a");
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
assert_ne!(ime_owner(&s), owner);
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
s.execute("text.delete", json!({})).unwrap();
assert_text(&s, t, "");
assert_eq!(sel(&s), (0, 0));
}
#[test]
fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() {
let (mut s, t) = editing("abc", 1);
// Keying the text the CTI already shows leaves the document equal.
animate_source(&mut s, t);
let owner = ime_owner(&s);
set_source_cti_key(&mut s, t, 0.0, "abc");
assert_ime_owner(&s, owner, "a no-op key write keeps the IME owner");
// `keys.set` changes the key at the CTI: a document replacement.
set_source_cti_key(&mut s, t, 0.0, "replaced");
assert_text(&s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "a changed keyframe changes the IME owner");
}
#[test]
fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() {
let (mut s, t) = editing("abc", 1);
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "later");
let owner = ime_owner(&s);
let lid = effectcraft_project::LayerId(t);
let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap();
let uid = pr.uid;
let times: Vec<_> = pr.keys.iter().map(|k| k.time).collect();
assert_eq!(times.len(), 2);
// Deleting the key at the CTI (0s) while the 1s key remains shows the later text: replacing
// the document starts a new epoch.
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }];
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
assert_text(&s, t, "later");
let replaced = ime_owner(&s);
assert_ne!(replaced, owner, "deleting the CTI key changed the document");
// Removing the last key turns its value into the static CTI value. That value is what the
// CTI already showed, so the document does not change and the new owner stays.
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }];
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
assert_text(&s, t, "later");
assert_ime_owner(&s, replaced, "removing the last key is a document no-op");
}
#[test]
fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() {
let (mut s, t) = session("abcdef");
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "a");
begin_edit_at(&mut s, t, 6);
let owner = ime_owner(&s);
// The same time still shows the same document: no new epoch.
s.execute("time.set", json!({"time": 0.0})).unwrap();
assert_text(&s, t, "abcdef");
assert_ime_owner(&s, owner, "an unchanged CTI document keeps the IME owner");
// The key at 1s holds shorter text: the document changes, the selection is clamped and the
// editor gets a new epoch.
s.execute("time.set", json!({"time": 1.0})).unwrap();
assert_text(&s, t, "a");
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
let replaced = ime_owner(&s);
assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch");
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
s.execute("text.delete", json!({})).unwrap();
assert_text(&s, t, "");
assert_eq!(sel(&s), (0, 0));
assert_ime_owner(&s, replaced, "typing after the replacement keeps its owner");
// Staying at the same time after the edit is a no-op for the document too.
s.execute("time.set", json!({"time": 1.0})).unwrap();
assert_text(&s, t, "");
assert_ime_owner(&s, replaced, "a no-op time move keeps the IME owner");
}
#[test]
fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() {
let (mut s, t) = session("abcdef");
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "a");
let outer = s.active_comp_id().unwrap();
let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap();
let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap());
assert_eq!(s.active_comp_id(), Some(inner));
begin_edit_at(&mut s, t, 6);
let owner = ime_owner(&s);
s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0));
assert_text(&s, t, "a");
assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection");
assert_ime_epoch_changed(&s, owner, "the related time move changed the document");
}
#[test]
fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() {
let (mut s, t) = editing("abc", 1);
let owner = ime_owner(&s);
let preset = json!({
"effectcraftAnimationPreset": 1,
"properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}],
});
s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap();
assert_text(&s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "a preset that replaces source text changes the IME owner");
}
#[test]
fn a_failed_property_write_keeps_the_edited_document_and_its_owner() {
let (mut s, t) = editing("abc", 1);
let owner = ime_owner(&s);
let steps = undo_steps(&s);
// null cannot coerce to a Text value: the edit fails before anything is committed.
assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err());
assert_text(&s, t, "abc");
assert_ime_owner(&s, owner, "a failed write keeps the IME owner");
assert_eq!(undo_steps(&s), steps, "a failed write records no undo step");
}
#[test]
fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() {
let (mut s, t) = editing("abc", 1);
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "later");
let owner = ime_owner(&s);
// Writing a key at 1s leaves the CTI (0s) value as it was.
set_source_key(&mut s, t, 1.0, "changed");
assert_text(&s, t, "abc");
assert_ime_owner(&s, owner, "an off-CTI write keeps the IME owner");
// Writing the CTI value itself replaces the document.
set_source(&mut s, t, "cti");
assert_text(&s, t, "cti");
assert_ime_epoch_changed(&s, owner, "a CTI write changes the IME owner");
}
#[test]
fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() {
let (mut s, t) = editing("abc", 1);
let owner = ime_owner(&s);
// file.removeUnusedFootage calls sanitize_state while the edited document is untouched.
s.execute("file.importSolid", json!({"name": "Unused"})).unwrap();
let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap();
assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed");
assert_ime_owner(&s, owner, "unrelated cleanup keeps the IME owner");
// Replacing the Source Text is a document replacement: a new owner.
replace_text(&mut s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "replacing the document changes the owner");
}
#[test]
fn ime_session_does_not_cross_a_composition_switch() {
let (mut s, _) = editing_all("abc");
let other = new_other_comp(&mut s);
s.execute("comp.open", json!({"comp": other})).unwrap();
assert!(s.state.text_edit.is_none(), "the old comp's editor is not reused in another comp");
}
#[test]
fn removing_the_active_comp_does_not_retarget_its_text_editor() {
for replacement in [false, true] {
let (mut s, t) = session("original");
let original = s.active_comp_id().unwrap();
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
let other = new_other_comp(&mut s);
let other_id = effectcraft_project::ItemId(other);
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
s.execute("comp.open", json!({"comp": other})).unwrap();
s.execute("comp.open", json!({"comp": original.0})).unwrap();
begin_edit_at(&mut s, t, 1);
assert!(s.state.text_edit.is_some());
// Reproduce cleanup after a project replacement, bypassing Session::edit's earlier
// document guard so this specifically exercises sanitize_state's fallback-comp branch.
let edited = crate::commands::text_edit::active_editor_snapshot(&s);
std::sync::Arc::make_mut(&mut s.project).items.remove(&original);
if replacement {
s.sanitize_after_replacement(edited.as_ref());
} else {
s.sanitize_state();
}
assert_eq!(s.active_comp_id(), Some(other_id));
assert!(s.state.text_edit.is_none(), "the same-numbered layer belongs to a different comp");
assert!(s.execute("text.insert", json!({"text": "wrong document"})).is_err());
assert_eq!(doc(&s, t).text, "original", "the fallback comp was not edited");
}
}
#[test]
@@ -260,26 +941,32 @@ fn fonts_lists_bundled_and_installed_families() {
assert_eq!(crate::font_styles("No Such Family").len(), 5);
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
fn pinned_variable_font(family: &str) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
/// A big-endian `u16` array, as sfnt tables store them.
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
/// A `name` table with one Windows/Unicode English record per `(id, text)`.
fn name_table(strings: &[(u16, String)]) -> Vec<u8> {
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
enc.iter().for_each(|(_, st)| name.extend(st));
name
}
/// A single-face font with `tables` in order (zero checksums, offsets after the directory).
fn sfnt(tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
for (tag, data) in tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend(((12 + 16 * tables.len() + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
@@ -287,6 +974,15 @@ fn pinned_variable_font(family: &str) -> Vec<u8> {
out
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
fn pinned_variable_font(family: &str) -> Vec<u8> {
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
let name = name_table(&[(1, family.to_owned()), (2, "Regular".to_owned())]);
sfnt(&[(b"fvar", fvar), (b"name", name)])
}
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
/// lists only families with variation axes, every listing says which families are variable, and
/// the font menu's rows carry the flag the filter uses.
@@ -321,7 +1017,6 @@ fn fonts_filter_variable_families() {
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
/// `<family without spaces>-<style without spaces>`.
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
@@ -334,25 +1029,7 @@ fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
fvar.extend(u16s(&[id + 1]));
}
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
enc.iter().for_each(|(_, st)| name.extend(st));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
sfnt(&[(b"fvar", fvar), (b"name", name_table(&strings))])
}
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
@@ -392,3 +1069,152 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap();
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
}
/// Incoming IME replacement (stock `DeleteSurrounding` / the fork's `DeleteSurroundingBytes`):
/// the characters around the selection go, the selected text stays for the following commit, and
/// a byte count that ends inside a UTF-8 character leaves that character.
#[test]
fn ime_replacement_keeps_the_selection_and_never_splits_a_character() {
let (mut s, t) = editing_all("abcdef");
select(&mut s, 1, 4);
// One character before "bcd" and everything after it: the selection survives for the commit.
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 99, "merge": "typing-0"})).unwrap();
assert_text(&s, t, "bcd");
assert_eq!(sel(&s), (0, 3), "the selection is kept for the commit that follows");
s.execute("text.insert", json!({"text": "X", "merge": "typing-0"})).unwrap();
assert_text(&s, t, "X");
assert_eq!(sel(&s), (1, 1));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).text, "abcdef", "the surrounding delete and its commit are one undo step");
// An empty count is a no-op (deletion-only batches, empty commits), never an undo step.
s.execute("edit.redo", json!({})).unwrap();
select(&mut s, 0, 1);
s.execute("text.deleteSurrounding", json!({"before": 0, "after": 0})).unwrap();
assert_text(&s, t, "X");
// Byte counts end inside a character: it stays whole (never a split UTF-8 sequence).
let (mut s, t) = editing_all("A😀B");
select(&mut s, 3, 3);
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "A😀", "B is whole, the emoji only partly covered, so both are not one range");
select(&mut s, 2, 2);
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "A", "four bytes cover the whole emoji");
// A combining mark is one character of two bytes: one byte of it removes nothing.
let (mut s, t) = editing_all("e\u{301}");
select(&mut s, 2, 2);
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "e\u{301}", "a partial combining mark stays");
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "e", "the whole combining mark is one character");
}
/// The outgoing IME query: logical committed text (the pre-edit never enters the model), UTF-8
/// byte offsets in either selection direction, a bounded window, and an explicit unavailable
/// answer for a selection that cannot be represented.
#[test]
fn ime_surrounding_maps_characters_to_bytes_and_preserves_direction() {
let (mut s, _) = editing_all("café😀");
select_anchor(&mut s, 2, 5);
let w = ime_surrounding(&s).expect("an available document");
assert_eq!(w.text, "café😀");
assert_eq!((w.cursor, w.anchor), (9, 2), "the caret is six characters in, after the emoji; the anchor stays at byte 2");
assert_eq!(&w.text[w.cursor..], "");
assert_eq!(&w.text[..w.anchor], "ca");
// Reversed: the caret stays the primary end and the anchor the other one.
select_anchor(&mut s, 5, 2);
let reversed = ime_surrounding(&s).expect("an available document");
assert_eq!((reversed.cursor, reversed.anchor), (2, 9), "cursor > anchor is preserved after the conversion");
assert_eq!(&reversed.text[reversed.cursor..reversed.anchor], "fé😀");
// An available empty document is not the unavailable state.
let (s, _) = editing_all("");
let empty = ime_surrounding(&s).expect("empty text is available");
assert_eq!((empty.text.as_str(), empty.cursor, empty.anchor), ("", 0, 0));
// No edit session: unavailable, not an empty document.
let (s, _) = session("text");
assert!(ime_surrounding(&s).is_none());
}
#[test]
fn ime_surrounding_is_bounded_and_refuses_a_too_wide_selection() {
// A long line: the query copies only a window around the caret, never the document.
let text = "a".repeat(20_000);
let (mut s, _) = editing(&text, 10_000);
let w = ime_surrounding(&s).expect("a collapsed caret fits");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES, "the payload is bounded");
assert!(w.text.len() > 1000, "the caret keeps a useful amount of context");
assert!(w.text.is_char_boundary(w.cursor) && w.text.is_char_boundary(w.anchor));
assert_eq!(w.cursor, w.anchor);
// A selection wider than the protocol limit is unavailable, not a truncated one.
select(&mut s, 0, 20_000);
assert!(ime_surrounding(&s).is_none(), "a too-wide selection is unavailable");
// Multi-byte text at the window boundary is never split, and offsets stay on boundaries.
let text = "é".repeat(5_000);
let (s, _) = editing(&text, 2_500);
let w = ime_surrounding(&s).expect("a collapsed caret fits");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert!(w.text.is_char_boundary(w.cursor));
assert_eq!(&w.text[w.cursor..w.cursor + "é".len()], "é");
}
/// A reversed selection survives a byte-count deletion: both endpoints shift by the removed
/// characters before the selection, its direction stays reversed, and partial characters on
/// either side stay whole.
#[test]
fn ime_byte_deletion_keeps_a_reversed_selection_direction() {
let (mut s, t) = editing_all("😀ab😀");
select_anchor(&mut s, 3, 1);
let (anchor, caret) = sel(&s);
assert_eq!((anchor, caret), (3, 1), "reversed before the replacement");
// Three bytes cover only part of either four-byte emoji: neither is split or removed.
s.execute("text.deleteSurrounding", json!({"before": 3, "after": 3, "bytes": true})).unwrap();
assert_text(&s, t, "😀ab😀");
// Four bytes cover each whole emoji: the selection text stays, shifted, still reversed.
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 4, "bytes": true})).unwrap();
assert_text(&s, t, "ab");
assert_eq!(sel(&s), (2, 0), "the anchor stays after the caret");
let w = ime_surrounding(&s).expect("available");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("ab", 0, 2), "the outgoing offsets keep the direction");
}
#[test]
fn deselect_all_ends_the_open_edit() {
let (mut s, t) = editing("Hello", 5);
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "Deselect All ends the open text edit on every route");
assert!(s.state.selected_layers.is_empty(), "and leaves nothing selected");
assert_eq!(doc(&s, t).text, "Hello!", "the text stays committed");
// Ending an edit is no undoable step of its own (as with text.endEdit): undo takes
// the typed text, it does not reopen the edit.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).text, "Hello", "undo reverts the typed text, not the deselection");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
}
+1 -1
View File
@@ -157,7 +157,7 @@ pub struct TrackShared {
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running (or finished, not yet polled) analysis.
+2 -2
View File
@@ -663,10 +663,10 @@ impl SimProfile {
}
pub fn parse(s: &str) -> Option<SimProfile> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
SimProfile::ALL
.into_iter()
.find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase() == k)
.or(match k.as_str() {
"off" => Some(SimProfile::None),
"hdtv" | "bt709" => Some(SimProfile::Rec709),
+4 -4
View File
@@ -247,7 +247,7 @@ impl<'a> Cx<'a> {
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
}
img
}
@@ -327,7 +327,7 @@ impl<'a> Cx<'a> {
for dir in &self.job.options.overflow {
let p = std::path::Path::new(dir).join(&name).to_string_lossy().to_string();
if q.has_room(&p, bytes) {
self.overflowed.lock().unwrap_or_else(|e| e.into_inner()).push(p.clone());
self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(p.clone());
return p;
}
}
@@ -351,7 +351,7 @@ impl<'a> Cx<'a> {
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
}
let over = self.overflowed.lock().unwrap_or_else(|e| e.into_inner());
let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !over.is_empty() {
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
}
@@ -366,7 +366,7 @@ impl<'a> Cx<'a> {
}
}
if let (RenderLog::PlusPerFrameInfo, Some(ft), Some(c)) = (level, &self.frame_times, comp) {
let mut v = ft.lock().unwrap_or_else(|e| e.into_inner()).clone();
let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
v.sort_by_key(|(i, _)| *i);
s.push_str("\nFrames:\n");
let f0 = self.job.settings.first_frame(c);
+4 -4
View File
@@ -840,14 +840,14 @@ var __propHandler = {
// A layer-valued property (a Layer Control's Layer) is named as its layer (#809).
if (k === 'name' && t.__info[2] === 'layer') {
var lv = t.value;
if (lv !== null && lv !== undefined && lv.__isLayer) return lv.name;
if (lv?.__isLayer) return lv.name;
}
if (k in t) return t[k];
var v = t.value;
if (v === null || v === undefined) return undefined;
var x = v[k];
// Property groups are callable proxies: hand them over as they are, not bound like methods (#809).
if (x !== null && x !== undefined && x.__isGroup) return x;
if (x?.__isGroup) return x;
return typeof x === 'function' ? x.bind(v) : x;
},
};
@@ -904,8 +904,8 @@ __TextStyle.prototype = {
},
get text() {
var t = this.__d.text;
for (var k = 0; k < this.__ops.length; k++) {
var o = this.__ops[k];
for (const elem of this.__ops) {
var o = elem;
if (o[0] !== 'text') continue;
if (o[2] < 0) t = String(o[1]);
else t = t.slice(0, o[2]) + String(o[1]) + (o[3] < 0 ? '' : t.slice(o[2] + o[3]));
+13
View File
@@ -360,6 +360,9 @@ impl Mat4 {
}
/// General inverse (Gauss-Jordan with partial pivoting).
pub fn inverse(&self) -> Option<Mat4> {
if !self.0.iter().flatten().all(|v| v.is_finite()) {
return None;
}
let mut a = self.0;
let mut inv = Mat4::IDENTITY.0;
for col in 0..4 {
@@ -450,6 +453,16 @@ mod tests {
(a - b).length() < 1e-9
}
#[test]
fn mat4_inverse_rejects_non_finite() {
for (r, c, v) in [(0, 0, f64::NAN), (2, 1, f64::NAN), (1, 3, f64::INFINITY)] {
let mut m = Mat4::IDENTITY;
m.0[r][c] = v;
assert!(m.inverse().is_none(), "{r},{c},{v}");
}
assert!(Mat4::IDENTITY.inverse().is_some());
}
#[test]
fn layer_2d_maps_anchor_to_position() {
let m = Mat3::layer_2d(vec2(50.0, 25.0), vec2(960.0, 540.0), vec2(200.0, 50.0), 30.0);
+7 -4
View File
@@ -462,6 +462,12 @@ pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Opt
buffer
}
/// The descriptor for a device the compositor owns: `MemoryUsage` so freed textures give their
/// allocator blocks back instead of a few long-lived objects pinning large ones.
pub(crate) fn device_descriptor(required_limits: wgpu::Limits) -> wgpu::DeviceDescriptor<'static> {
wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, memory_hints: wgpu::MemoryHints::MemoryUsage, ..Default::default() }
}
impl GpuContext {
/// [`buffer_init`] on this context's device.
pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer {
@@ -870,10 +876,7 @@ impl GpuContext {
max_buffer_size: limits.max_buffer_size,
..wgpu::Limits::default()
};
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
.await
.map_err(|e| match by_name {
let (device, queue) = adapter.request_device(&device_descriptor(required_limits)).await.map_err(|e| match by_name {
true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name),
false => format!("no device: {e}"),
})?;
+10 -1
View File
@@ -261,6 +261,10 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
return Some(b);
}
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
// Small sigmas use the CPU's sampled kernel (#1939).
if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) {
return None;
}
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad(e, (s * 3.0).ceil() as u32)?;
@@ -270,7 +274,12 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
let radius = (ctx.params.f("radius") * b.scale).max(0.0);
// Fractional radii blend two integer radii on the CPU (#1939).
if radius - radius.floor() >= 1e-4 {
return None;
}
let r = radius.floor() as usize;
if r == 0 {
return Some(b);
}
+5 -1
View File
@@ -278,7 +278,11 @@ fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option
let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest);
// Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all
// cores with the rank planes resolved once.
let img = effectcraft_effects::crypto_aux_image(a, &layer, &sel);
// The ID colours are only read by the Colors display; Matte, Preview and Matte Only need the
// matte alone.
// `tests_fx_depth::cryptomatte_every_display_matches_the_cpu` guards this against the shader.
let what = effectcraft_effects::CryptoAux::for_display(pr.e("display"), pr.b("matteOnly"));
let img = effectcraft_effects::crypto_aux_image_with(a, &layer, &sel, what);
let tex = e.g.upload_image(&img)?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let mut p = op(7);
+4
View File
@@ -188,6 +188,10 @@ fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
2 => (0.0, 1.0),
_ => (1.0, 1.0),
};
// Small sigmas use the CPU's sampled kernel (#1939).
if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) {
return None;
}
if !ctx.adjustment {
b.pad(e, (s * 3.0).ceil() as u32)?;
}
+4 -1
View File
@@ -117,11 +117,14 @@ fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
run(e, "gen_radial_blur", &p, b, None, Some(&data))
}
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return Some(b);
}
if !ctx.adjustment {
b.pad(e, effectcraft_effects::radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s))?;
}
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.u[0][0] = ctx.params.e("zoom");
+5 -2
View File
@@ -438,8 +438,10 @@ fn refine(e: &mut Enc, ctx: &EffectCtx, b: GBuf, hard: bool) -> Option<GBuf> {
const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"];
/// Shift Channels' Take … From menu, in the order of `effects`' `SHIFT_ORDER`: the two must agree,
/// or the source depends on whether the effect renders on the GPU.
const SHIFT_ORDER: [Src; 11] =
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off];
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half];
const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off];
@@ -527,7 +529,8 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0];
}
"ec.channel.shiftchannels" => {
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)]));
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"]
.map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(SHIFT_ORDER.len() - 1)]));
return route(e, b, s);
}
"ec.channel.removecolormatting" => {
+4 -2
View File
@@ -586,17 +586,19 @@ fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let soft = pr.f("softness").max(0.0) * b.scale;
let blob = layer_or_self(e, ctx, &b, "blobLayer")?;
let raw = plane(e, &blob, pr.e("property").min(10));
let h = gaussian_blur(e, &raw, soft, soft, true);
let h = gaussian_blur(e, &raw, soft * 0.42, soft * 0.42, true);
let lpos = b.to_px(pr.v2("lightPosition"));
let lheight = pr.f("lightHeight");
let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2);
let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()];
let span = (b.img.width.max(b.img.height) as f64).max(1.0);
let mut p = Params::default();
p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]);
p.f[0] = f4([(pr.f("cutAway") / 100.0).clamp(0.0, 1.0), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0, soft * 1.5]);
p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]);
p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]);
p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]);
p.f[4] = rgb(pr.color("lightColor"), 0.0);
p.f[5] = f4([if soft > 0.05 { span / 64.0 } else { 0.0 }, span * 0.34 * b.scale, (soft * 0.65).max(1.0), 1.25]);
p.f[6] = f4([span * 0.5, 0.0, 0.0, 0.0]);
run(e, "ftr_blob", &p, b, Some(&h), None)
}
+39 -14
View File
@@ -569,9 +569,11 @@ fn ftr_bender(@builtin(global_invocation_id) gid: vec3<u32>) {
}
// ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize)
// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient);
// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height,
// point light); f[3] = distant light direction; f[4] = light colour.
// aux = the Softness-blurred blob plane (x). f[0] = (cut away, intensity, ambient,
// matte slope); f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y,
// point light height, point); f[3] = distant light direction; f[4] = light colour;
// f[5] = (matte depth, RGB depth, light slope, matte antialias width);
// f[6].x = displacement clamp.
@compute @workgroup_size(16, 16)
fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -579,32 +581,55 @@ fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!ftr_inside(p)) {
return;
}
let depth = P.f[0].x;
let cut = P.f[0].y;
let cut = P.f[0].x;
if (cut >= 1.0) {
// Endpoint guard: an opaque source can reach h = 1.0, where the gradient antialias
// would otherwise leave a half-covered pixel at Cut Away 100.
textureStore(out, p, vec4<f32>(0.0));
return;
}
let hv = textureLoad(aux, p, 0).x;
let g = ftr_grad(p);
let grad = length(g);
var m = 0.0;
if (grad > 1e-9) {
let aa = max(P.f[5].w * grad, 5e-4);
m = clamp(0.5 + (hv - cut) / aa, 0.0, 1.0);
} else if (hv > cut) {
m = 1.0;
}
if (m <= 0.0) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let f = ftr_centre(p);
let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0);
let c = ftr_unpremul(s);
let a = max(s.w, 0.0);
let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0);
let alpha = max(a, s.w) * m;
// Softness-scaled bevel warp of the source matte (normal z = 1).
let na = normalize(vec3<f32>(-g.x * P.f[0].w, -g.y * P.f[0].w, 1.0));
let sa = sample_bilinear(src, f.x + na.x * P.f[5].x, f.y + na.y * P.f[5].x);
let alpha = max(sa.w, 0.0) * m;
if (alpha <= 0.0) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let n = normalize(vec3<f32>(-g.x * depth, -g.y * depth, 1.0));
// Height-field refraction of the colour (normal z = height), independent of Softness.
let hz = max(hv, 1e-3);
let d = -g * P.f[5].y / hz;
let lim = P.f[6].x;
let disp = vec2<f32>(clamp(d.x, -lim, lim), clamp(d.y, -lim, lim));
let srgb = sample_bilinear(src, f.x + disp.x, f.y + disp.y);
let c = ftr_unpremul(srgb);
let n = normalize(vec3<f32>(-g.x * P.f[5].z, -g.y * P.f[5].z, 1.0));
var l = P.f[3].xyz;
if (P.f[2].w != 0.0) {
l = normalize(vec3<f32>(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z));
l = normalize(vec3<f32>(P.f[2].x - f.x, P.f[2].y - f.y, P.f[2].z));
}
let intensity = P.f[0].z;
let intensity = P.f[0].y;
let ndl = max(dot(n, l), 0.0);
let h3 = normalize(vec3<f32>(l.x, l.y, l.z + 1.0));
let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y;
let lc = P.f[4].xyz;
let metal = P.f[1].w;
let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc;
let lit = (P.f[0].z + P.f[1].x * ndl * intensity) * lc;
let spec_col = (1.0 - metal) * lc + metal * c * lc;
let o = c * lit + (sp * intensity) * spec_col;
textureStore(out, p, vec4<f32>(o * alpha, alpha));
+33 -4
View File
@@ -475,12 +475,16 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool)
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let expect_empty = id == "ec.distort.ccblobbylize" && vals.iter().any(|(k, v)| *k == "cutAway" && matches!(v, Value::Scalar(x) if *x == 100.0));
for scale in [1.0, 0.5] {
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.25, layer_size: size, seed: 11, adjustment, env: Default::default() };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
if expect_empty {
assert!(out.img.data.iter().all(|p| p[3] == 0.0), "{id} {vals:?} adj {adjustment} scale {scale}: Cut Away 100 produced non-zero alpha");
}
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
let allow = if boundary_flips_allowed() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
@@ -520,12 +524,27 @@ pub(crate) fn c(r: f64, g: f64, b: f64) -> Value {
Value::Color([r, g, b, 1.0])
}
#[test]
fn small_blurs_fall_back_to_the_cpu() {
// #1939: small Gaussian sigmas and fractional box radii render on the CPU kernel.
for (id, vals) in [("ec.blur.gaussian", [("blurriness", n(0.9))]), ("ec.blur.fastbox", [("radius", n(1.5))])] {
let mut s = Scene::new(BitDepth::Bpc32);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
s.effect(&mut l, id, &vals);
s.push(l);
check(&format!("{id} {vals:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
#[test]
fn gpu_effects_match_cpu() {
effect_case("ec.blur.gaussian", &[("blurriness", n(9.0))]);
effect_case("ec.blur.gaussian", &[("blurriness", n(4.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.fastbox", &[("radius", n(5.0)), ("iterations", n(2.0))]);
effect_case("ec.blur.fastbox", &[("radius", n(3.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
// Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939).
effect_case("ec.blur.gaussian", &[("blurriness", n(14.0))]);
effect_case("ec.blur.gaussian", &[("blurriness", n(12.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.fastbox", &[("radius", n(6.0)), ("iterations", n(2.0))]);
effect_case("ec.blur.fastbox", &[("radius", n(4.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.directional", &[("direction", n(30.0)), ("length", n(12.0))]);
effect_case("ec.stylize.glow", &[("threshold", n(40.0)), ("radius", n(14.0)), ("intensity", n(1.5))]);
effect_case("ec.stylize.glow", &[("colors", Value::Enum(1)), ("operation", Value::Enum(1)), ("colorA", c(1.0, 0.8, 0.1))]);
@@ -1146,3 +1165,13 @@ fn upload_cache_drops_textures_of_freed_buffers() {
let kept = g.ctx.upload_entries();
assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})");
}
/// The device the compositor owns asks for `MemoryUsage` so freed textures give
/// their allocator blocks back, while its label and limits stay as they were.
#[test]
fn owned_devices_favour_memory_usage() {
let d = crate::context::device_descriptor(wgpu::Limits::default());
assert!(matches!(d.memory_hints, wgpu::MemoryHints::MemoryUsage));
assert_eq!(d.label, Some("effectcraft gpu"));
assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d);
}
+1 -1
View File
@@ -30,7 +30,7 @@ pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'sta
g
});
let g = crate::tests::require_gpu("deferred gpu tests", g.as_ref())?;
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
Some((g, LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)))
}
/// Footage with GPU effects around a CPU-only one, a solid with Glow, and a precomp whose
+12
View File
@@ -251,6 +251,18 @@ fn ids_and_cryptomatte() {
case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]);
}
/// The GPU skips the ID colours for displays that do not read them
/// ([`effectcraft_effects::CryptoAux::for_display`]): every display × Matte Only must still match
/// the CPU effect, so a shader that starts reading RGB in another mode fails here.
#[test]
fn cryptomatte_every_display_matches_the_cpu() {
for display in 0..3 {
for matte_only in [false, true] {
case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into())), ("display", e(display)), ("matteOnly", Value::Bool(matte_only))]);
}
}
}
#[test]
fn extractor() {
case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]);
+3 -2
View File
@@ -46,6 +46,7 @@ fn bevels_and_legacy_gaussian() {
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]);
// Sigma 3 px and up at half scale too: smaller ones render on the CPU (#1939).
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(14.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(12.0)), ("blurDimensions", e(2))]);
}
+2
View File
@@ -31,6 +31,8 @@ fn cc_radial_fast_blur() {
effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]);
// Center outside the layer: the buffer grows so the streaks pass its edge (#2095).
effect_case("ec.blur.ccradialfast", &[("amount", n(80.0)), ("center", pt(-30.0, 20.0))]);
}
#[test]
+3
View File
@@ -155,6 +155,9 @@ fn refine_mattes() {
fn channel_effects() {
effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]);
effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]);
// #1797: the constant sources at the end of the menu, where the GPU's order could drift from
// the CPU's (Full On, Full Off, Half On).
effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(8)), ("takeGreenFrom", e(9)), ("takeBlueFrom", e(10))]);
effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]);
effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]);
for op in [0, 2, 3, 4, 8, 10, 12] {
+1
View File
@@ -126,6 +126,7 @@ fn distort() {
"ec.distort.ccblobbylize",
&[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))],
);
effect_case("ec.distort.ccblobbylize", &[("cutAway", n(100.0))]);
effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]);
}
+18 -4
View File
@@ -82,7 +82,7 @@ impl Exporter for FileExporter {
/// A new session with media, import, expressions, scripting and export enabled.
pub fn session() -> Session {
Session {
let mut s = Session {
// The desktop app, the CLI and the MCP server share installed Roto Brush models.
models_dir: config_dir().map(|d| d.join("models")),
exporter: Some(Arc::new(FileExporter::default())),
@@ -90,11 +90,11 @@ pub fn session() -> Session {
importer: Some(Arc::new(MediaImporter)),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_script::runner),
script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() },
plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader),
..Default::default()
}
};
effectcraft_script::install(&mut s);
s
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
@@ -109,6 +109,20 @@ pub fn config_dir() -> Option<std::path::PathBuf> {
mod tests {
use serde_json::json;
#[test]
fn host_runs_tasks_scheduled_by_panel_handlers() {
let mut s = super::session();
let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Timer regression'); var b=w.add('button',undefined,'Import'); b.onClick=function(){app.scheduleTask(\"app.project.items.addComp('Scheduled',64,64,1,1,25)\",0,false);};w.show();"})).unwrap();
assert_eq!(result["ok"], true, "{result}");
s.execute("scriptui.click", json!({"window":"Timer regression","widget":"Import"})).unwrap();
assert!(s.project.items.is_empty());
let tick = s.execute("script.runTasks", json!({})).unwrap();
assert_eq!(tick["ran"], 1, "{tick}");
assert_eq!(tick["errors"], json!([]), "{tick}");
assert!(s.project.items.values().any(|item| item.name == "Scheduled"));
s.execute("scriptui.close", json!({"window":"Timer regression"})).unwrap();
}
/// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions
/// evaluate to the camera's focus on the target.
#[test]
+26 -1
View File
@@ -327,7 +327,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
let a = v3(&keys[i.saturating_sub(1)].value);
let c = v3(&keys[(i + 1).min(keys.len() - 1)].value);
let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0];
([-tout[0], -tout[1], -tout[2]], tout)
// A side facing a key at the same position gets no tangent, so the segment between two
// identical keys has zero length and the layer holds still (#1675).
let p = v3(&keys[i].value);
let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] };
let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout };
(tin, tout)
}
/// Effective spatial tangents of key `i` (auto or stored).
@@ -928,6 +933,26 @@ mod tests {
assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3]));
}
#[test]
fn auto_spatial_holds_between_identical_keys() {
let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])];
for eased in [false, true] {
let keys: Vec<Keyframe> = path
.iter()
.map(|&(t, v)| {
let k = Keyframe::new(s(t), Value::Vec2(v));
if eased { k.eased() } else { k }
})
.collect();
assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]);
assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]);
for t in [0.7, 1.0, 2.5, 4.0, 4.3] {
let p = evaluate(&keys, s(t), true).unwrap().as_vec3();
assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}");
}
}
}
#[test]
fn set_key_replaces_same_time() {
let mut keys = vec![];
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn vector_doc(
}
fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
}
+1 -1
View File
@@ -80,7 +80,7 @@ pub fn is_importable(path: &std::path::Path) -> bool {
}
pub(crate) fn ext_of(path: &std::path::Path) -> String {
path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
/// The `image` crate format of a still: from its signature, else from its extension (Targa has
+2 -2
View File
@@ -756,7 +756,7 @@ impl Inner {
drop(c);
return Ok(Arc::new(self.decode(&loc, footage)?));
}
c = self.done.wait(c).unwrap_or_else(|e| e.into_inner());
c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner);
}
self.misses.fetch_add(1, Ordering::Relaxed);
c.inflight.insert(loc.key.clone());
@@ -972,7 +972,7 @@ fn describe_problem(codec: &str, e: &MediaError) -> String {
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
+1 -1
View File
@@ -15,4 +15,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
miniz_oxide = "0.8"
miniz_oxide = "0.9"
+1 -1
View File
@@ -15,4 +15,4 @@ effectcraft-keyframe = { workspace = true }
effectcraft-raster = { workspace = true }
kurbo = { workspace = true }
tiny-skia = { workspace = true }
linesweeper = "0.4"
linesweeper = "0.5"
+1 -1
View File
@@ -14,7 +14,7 @@ craft-pdf.workspace = true
effectcraft-svg = { workspace = true }
kurbo = { workspace = true }
effectcraft-text = { workspace = true }
miniz_oxide = "0.8"
miniz_oxide = "0.9"
# Embedded TrueType / OpenType font programs (MIT OR Apache-2.0).
skrifa = { workspace = true }
# DCT (JPEG) images (MIT OR Apache-2.0 OR Zlib; pure Rust).
+5 -3
View File
@@ -138,15 +138,17 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
## Shared parser setup and bounds
The workspace pins `craft-pdf` to `craft-libs` Git revision
`ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d`, with `default-features = false` and only the explicit `read`
`512b54355cd1418f4e8c56a3fbd7483c3c274bf3`, with `default-features = false` and only the explicit `read`
feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's
font and asset policies. The shared parser does not change the supported drawing operators,
image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter.
This revision removes an unchecked lazy-array read and preserves later values and pages after
sign-only numeric entries; the app exercises those inputs through its object adapter, public
page parser and rasterizer on native and WASM targets.
page parser and rasterizer on native and WASM targets. It also bounds xref, object-stream and
graphics-state work, reads the full `/W[0]` xref type width, and caps JBIG2 segment references
and bitmap allocation.
The root Cargo patch pins Hayro-syntax to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses
The root Cargo patch pins Hayro-syntax and Hayro-jbig2 to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses
an enabled Hayro interpreter, rasterizer or CMap dependency. The shared revision must be
published before a fresh app checkout can resolve it; no sibling checkout is required.
+11 -7
View File
@@ -263,8 +263,8 @@ fn parse_cmap(data: &[u8]) -> CMap {
}
Obj::Array(list) => {
for (k, s) in list.iter().enumerate() {
if let Obj::Str(s) = s {
m.unicode.insert(lo + k as u32, utf16(s));
if let (Obj::Str(s), Some(c)) = (s, lo.checked_add(k as u32)) {
m.unicode.insert(c, utf16(s));
}
}
}
@@ -401,13 +401,15 @@ impl Font {
match w.get(i + 1).map(|x| file.resolve(x)) {
Some(Obj::Array(list)) => {
for (k, v) in list.iter().enumerate() {
f.cid_widths.insert(first + k as u32, file.resolve(v).num().unwrap_or(0.0) / 1000.0);
if let Some(cid) = first.checked_add(k as u32) {
f.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0);
}
}
i += 2;
}
Some(Obj::Num(last)) => {
let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0;
for c in first..=(*last as u32).min(first + 65535) {
for c in first..=(*last as u32).min(first.saturating_add(65535)) {
f.cid_widths.insert(c, wv);
}
i += 3;
@@ -432,13 +434,15 @@ impl Font {
Some(Obj::Array(list)) => {
let v: Vec<f64> = list.iter().filter_map(|x| file.resolve(x).num()).collect();
for (k, t) in v.as_chunks::<3>().0.iter().enumerate() {
f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0));
if let Some(cid) = first.checked_add(k as u32) {
f.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0));
}
}
i += 2;
}
Some(Obj::Num(last)) => {
let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break };
for c in first..=(last.max(0.0) as u32).min(first + 65535) {
for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) {
f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0));
}
i += 5;
@@ -609,7 +613,7 @@ impl Font {
if self.cid_ranges.is_empty() {
return code;
}
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0)
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).and_then(|(lo, _, c)| c.checked_add(code - lo)).unwrap_or(0)
}
/// Vertical metrics of a code in text space (before the font size): the vertical
+132 -5
View File
@@ -28,6 +28,8 @@ enum V {
Name(Rc<str>),
/// A literal name (`/name`).
Lit(Rc<str>),
/// A built-in operator, as `load` returns it for a name no dictionary defines.
Op(Rc<str>),
Str(Rc<RefCell<Vec<u8>>>),
Proc(Rc<Vec<V>>),
Array(Rc<RefCell<Vec<V>>>),
@@ -47,7 +49,7 @@ impl V {
}
fn key(&self) -> String {
match self {
V::Name(n) | V::Lit(n) => n.to_string(),
V::Name(n) | V::Lit(n) | V::Op(n) => n.to_string(),
V::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(),
V::Num(n) => format!("{n}"),
V::Bool(b) => format!("{b}"),
@@ -441,6 +443,12 @@ impl Interp {
break;
}
}
V::Op(op) => {
r = self.spend(1).and_then(|()| self.builtin(op, src));
if r.is_err() {
break;
}
}
// Other items count against the budget as names do.
other => {
r = self.spend(1);
@@ -454,7 +462,21 @@ impl Interp {
self.calls -= 1;
r
}
V::Name(n) => self.exec_name(&n, src),
// An executable name can run another (`exec` of `/exec cvx`), so names count too.
V::Name(n) => {
if self.calls >= MAX_CALL_DEPTH {
self.b.skip("PostScript (recursion limit)");
return Err(Flow::Quit);
}
self.calls += 1;
let r = self.exec_name(&n, src);
self.calls -= 1;
r
}
V::Op(op) => {
self.spend(1)?;
self.builtin(&op, src)
}
other => {
self.push(other);
Ok(())
@@ -467,6 +489,12 @@ impl Interp {
if let Some(v) = self.lookup(n) {
return match v {
V::Proc(_) => self.exec_obj(v, src),
// An operator stored under another name (`/mo /moveto load def`, as Illustrator's
// EPS prolog does for every path operator) runs that operator. It pushed the
// operator's name instead, so such files drew nothing (#955).
V::Op(op) => self.builtin(&op, src),
// An executable name (`/y /x cvx def`) runs what that name is bound to.
V::Name(_) => self.exec_obj(v, src),
other => {
self.push(other);
Ok(())
@@ -963,7 +991,7 @@ impl Interp {
}
"load" => {
let k = self.pop().key();
let v = self.lookup(&k).unwrap_or(V::Name(k.into()));
let v = self.lookup(&k).unwrap_or(V::Op(k.into()));
self.push(v);
}
"undef" => {
@@ -1160,13 +1188,14 @@ impl Interp {
}
"xcheck" => {
let v = self.pop();
self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_))));
self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_) | V::Op(_))));
}
"type" => {
let t = match self.pop() {
V::Num(_) => "realtype",
V::Bool(_) => "booleantype",
V::Name(_) | V::Lit(_) => "nametype",
V::Op(_) => "operatortype",
V::Str(_) => "stringtype",
V::Proc(_) | V::Array(_) => "arraytype",
V::Dict(_) => "dicttype",
@@ -1203,6 +1232,98 @@ impl Interp {
}
}
"currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)),
// Operators that return values must push them, or the rest of the program reads a
// shifted stack: Illustrator's EPS prolog queries the device this way and then drew
// nothing (#955). Values are a plain device's defaults (PostScript Language
// Reference, 3rd ed., chapter 8).
"currentflat" => self.push(V::Num(1.0)),
"currentscreen" => {
self.push(V::Num(60.0));
self.push(V::Num(45.0));
self.push(V::Proc(Rc::new(vec![])));
}
"currentblackgeneration" | "currentundercolorremoval" | "currenttransfer" => self.push(V::Proc(Rc::new(vec![]))),
"currentcolortransfer" => {
for _ in 0..4 {
self.push(V::Proc(Rc::new(vec![])));
}
}
"currentpagedevice" | "currenthalftone" | "currentsystemparams" | "currentuserparams" | "currentdevparams" => {
if n == "currentdevparams" {
self.pop();
}
self.push(V::Dict(new_dict()));
}
"setuserparams" | "setsystemparams" | "setcolortransfer" | "setblackgeneration" | "setundercolorremoval" => {
let k = if n == "setcolortransfer" { 4 } else { 1 };
for _ in 0..k {
self.pop();
}
}
// `file status` → false; `string status` → false (no such file).
"status" => {
self.pop();
self.push(V::Bool(false));
}
"dictstack" => {
self.pop();
let ds: Vec<V> = self.dicts.iter().map(|d| V::Dict(d.clone())).collect();
self.push(array(ds));
}
"maxlength" => {
let k = match self.pop() {
V::Dict(d) => d.borrow().len(),
_ => 0,
};
self.push(V::Num(k.saturating_add(16) as f64));
}
"internaldict" => {
self.pop();
let d = self.dicts[0].clone();
self.push(V::Dict(d));
}
"undefineresource" => {
self.pop();
self.pop();
}
// `[ … /Name pdfmark`: a Distiller annotation; other interpreters discard it.
"pdfmark" => {
while let Some(v) = self.st.pop() {
if matches!(v, V::Mark) {
break;
}
}
}
// `source [params] /Name filter`. A SubFileDecode of the current file with a count of
// 0 reads to its end-of-data string: skip that data (an embedded XMP packet, which
// otherwise ran as PostScript). Other filters read what their source reads.
"filter" => {
let name = self.pop().key();
let mut skipped = false;
let source = if name == "SubFileDecode" && !matches!(self.st.last(), Some(V::Dict(_))) {
let eod = match self.pop() {
V::Str(s) => s.borrow().clone(),
_ => vec![],
};
let count = self.popn();
let source = self.pop();
if matches!(source, V::File) && count == 0.0 && !eod.is_empty() {
src.pos = match find_from(src.d, &eod, src.pos) {
Some(p) => p.saturating_add(eod.len()),
None => src.d.len(),
};
skipped = true;
}
source
} else {
if matches!(self.st.last(), Some(V::Dict(_))) {
self.pop();
}
self.pop()
};
// Data already stepped over leaves nothing for `image` to skip.
self.push(if skipped { V::Null } else { source });
}
"vmstatus" => {
for _ in 0..3 {
self.push(V::Num(0.0));
@@ -1236,9 +1357,15 @@ impl Interp {
let a: Vec<f64> = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect();
self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off));
}
"setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => {
"setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "settransfer" => {
self.pop();
}
// frequency angle proc
"setscreen" => {
for _ in 0..3 {
self.pop();
}
}
"setgray" => {
let g = self.popn().clamp(0.0, 1.0);
self.gs.color = [g; 3];
+44
View File
@@ -1090,3 +1090,47 @@ fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() {
assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page");
assert_eq!(offset, [0.0, 0.0]);
}
#[test]
fn cid_width_entries_at_the_top_of_the_cid_range_do_not_overflow() {
// A first CID of u32::MAX (larger values saturate to it) used to overflow `first + k` and `first + 65535`:
// a panic in debug builds; in release builds the list wrapped round to CID 0 and the range was dropped.
let load = |entry: &str| {
let type0 = "<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H /DescendantFonts [13 0 R] >>";
let desc = format!("<< /Type /Font /Subtype /CIDFontType2 {entry} >>");
let file = object::File::parse(&page_pdf("", "/Font << /F1 10 0 R >>", vec![(10, type0.into(), None), (13, desc, None)])).unwrap();
let d = file.objects.get(&(10, 0)).and_then(object::Obj::dict).unwrap();
font::Font::load(&file, d, &mut vec![])
};
let max = u32::MAX;
// c [w1 w2 ...]
let f = load("/W [4294967295 [500 600 700]]");
assert_eq!((f.width(max), f.width(0)), (0.5, 1.0));
// cfirst clast w
assert_eq!(load("/W [4294967295 4294967295 400]").width(max), 0.4);
// c [w1y vx vy ...]
let f = load("/W2 [4294967295 [1 2 3 4 5 6]]");
assert_eq!((f.vmetrics(max), f.vmetrics(0)), ((0.001, 0.002, 0.003), (-1.0, 0.5, 0.88)));
// cfirst clast w1y vx vy
assert_eq!(load("/W2 [4294967295 4294967295 1 2 3]").vmetrics(max), (0.001, 0.002, 0.003));
}
#[test]
fn cmap_entries_at_the_top_of_the_code_range_do_not_overflow() {
// A bfrange array starting at code 0xFFFFFFFF and a cidrange mapping onto CID u32::MAX used to overflow
// `lo + k` and `c + (code - lo)`: a panic in debug builds, a wrap round to code / CID 0 in release builds.
let to_unicode = "beginbfrange <FFFFFFFF> <FFFFFFFF> [<0041> <0042>] endbfrange";
let encoding = "begincodespacerange <0000> <FFFF> endcodespacerange begincidrange <0000> <FFFF> 4294967295 endcidrange";
let objs: Objs = vec![
(10, "<< /Type /Font /Subtype /Type0 /Encoding 15 0 R /ToUnicode 14 0 R /DescendantFonts [13 0 R] >>".into(), None),
(13, "<< /Type /Font /Subtype /CIDFontType2 /W [0 [700 800] 4294967295 [500]] >>".into(), None),
(14, "<< >>".into(), Some(to_unicode.as_bytes().to_vec())),
(15, "<< /Type /CMap >>".into(), Some(encoding.as_bytes().to_vec())),
];
let file = object::File::parse(&page_pdf("", "/Font << /F1 10 0 R >>", objs)).unwrap();
let d = file.objects.get(&(10, 0)).and_then(object::Obj::dict).unwrap();
let f = font::Font::load(&file, d, &mut vec![]);
assert_eq!((f.unicode(0xFFFF_FFFF), f.unicode(0)), ("A".to_string(), String::new()));
// Code 0 is CID u32::MAX; code 2 would be past it, so it falls back to CID 0 like an unmapped code.
assert_eq!((f.width(0), f.width(2)), (0.5, 0.7));
}
+49
View File
@@ -25,6 +25,55 @@ fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) {
(x0, x1)
}
/// #955: EPS files that name their operators (`/mo /moveto load def`, as Illustrator's do),
/// query the device and embed metadata read through a SubFileDecode filter draw their artwork.
/// They imported as an empty layer.
#[test]
fn eps_with_aliased_operators_device_queries_and_embedded_data_draws() {
let square = "1 0 0 setrgbcolor 50 25 mo 150 25 li 150 75 li 50 75 li cp f\n";
let aliases = "/mo /moveto load def /li /lineto load def /cp /closepath load def /f /fill load def\n";
let doc = eps(format!("{aliases}{square}").as_bytes());
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "aliased operators draw the square");
// Every operator that returns a value pushes it, so the stack stays aligned.
let queries = "mark currentflat currentscreen currentpagedevice currenthalftone currentblackgeneration \
currentundercolorremoval currentcolortransfer currentsystemparams counttomark 13 eq /ok exch def cleartomark\n";
let doc = eps(format!("{aliases}{queries}ok {{ {square} }} if\n").as_bytes());
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "device queries leave 13 values");
// Data up to the end-of-data string isn't run: its unbalanced brace would swallow the rest.
let data = "currentfile 0 (%END-OF-DATA) /SubFileDecode filter pop\n<x> { ( not PostScript\n%END-OF-DATA\n";
let doc = eps(format!("{aliases}{data}{square}").as_bytes());
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after the embedded data");
}
#[test]
fn eps_filter_on_a_string_source_does_not_skip_the_program() {
let square = "1 0 0 setrgbcolor 50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill\n";
let img = "gsave 1 1 8 [1 0 0 1 0 0] (FF) /ASCIIHexDecode filter image grestore\n";
// Images aren't drawn yet, so this parses without `eps`'s nothing-skipped check.
let doc = parse(format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n{img}{square}").as_bytes()).unwrap();
assert_eq!(doc.skipped, ["image"]);
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after an image read from a string");
}
/// A loaded operator runs that operator even after its name is redefined (the prolog idiom
/// `/_m /moveto load def /moveto {… _m} def`), and an executable name bound with `cvx` runs what
/// its name is bound to.
#[test]
fn eps_loaded_operators_and_executable_name_aliases() {
let saved = "/_m /moveto load def /moveto {_m} def 1 0 0 setrgbcolor 50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill\n";
assert!(coverage(&render(&eps(saved.as_bytes())), 50, 25, 150, 75) > 4900.0, "a redefined moveto that calls the saved one");
let alias = "/sq {50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill} def /y /sq cvx def 1 0 0 setrgbcolor y\n";
assert!(coverage(&render(&eps(alias.as_bytes())), 50, 25, 150, 75) > 4900.0, "an executable name bound to a procedure's name");
}
/// Each `exec` of the executable name `exec` runs the next one: the chain is cut off at the call
/// depth instead of overflowing the stack.
#[test]
fn eps_exec_of_executable_names_stops_at_the_call_depth() {
let doc = parse(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n/exec cvx 99999 {dup} repeat exec\n").unwrap();
assert!(doc.skipped.iter().any(|s| s.contains("recursion limit")), "{:?}", doc.skipped);
}
#[test]
fn eps_text_with_bundled_fonts_and_show_variants() {
// Not embedded: drawn with the bundled stand-in, like the same text in a PDF.
+329
View File
@@ -0,0 +1,329 @@
//! A cheap check that a project file will read back, without parsing it.
//!
//! `serde_json` writes a non-finite float as `null` and refuses to parse nesting deeper than 128
//! containers, so a project holding either would be saved to a file that can't be opened. Walking
//! the value once, writing nothing, finds both without building the project again.
use std::fmt;
use serde::ser::{
self, Serialize, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, SerializeTupleStruct, SerializeTupleVariant,
Serializer,
};
/// serde_json refuses to parse the 128th nested array/object (its recursion limit is 128).
const MAX_DEPTH: u32 = 127;
const NOT_FINITE: &str = "a number that isn't finite (NaN or infinity)";
const TOO_DEEP: &str = "nesting deeper than 127 levels";
/// Why a value can't be written to a project file.
#[derive(Debug)]
pub(crate) struct Unstorable(String);
impl fmt::Display for Unstorable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Unstorable {}
impl ser::Error for Unstorable {
fn custom<T: fmt::Display>(msg: T) -> Self {
Unstorable(msg.to_string())
}
}
/// Walk `value` as `serde_json` would write it, producing no text and allocating only on error.
pub(crate) fn check<T: Serialize + ?Sized>(value: &T) -> Result<(), Unstorable> {
let mut walker = Walker { depth: 0 };
value.serialize(&mut walker)
}
/// The current nesting depth of the value being walked.
struct Walker {
depth: u32,
}
impl Walker {
/// Enter `levels` nested containers, refusing anything serde_json couldn't parse back.
fn enter(&mut self, levels: u32) -> Result<(), Unstorable> {
let depth = self.depth.checked_add(levels).ok_or_else(|| Unstorable(TOO_DEEP.into()))?;
if depth > MAX_DEPTH {
return Err(Unstorable(TOO_DEEP.into()));
}
self.depth = depth;
Ok(())
}
fn leave(&mut self, levels: u32) {
self.depth = self.depth.saturating_sub(levels);
}
}
/// One compound value being walked; leaving it when it ends keeps the depth exact.
struct Compound<'a> {
walker: &'a mut Walker,
levels: u32,
}
impl<'a> Compound<'a> {
fn new(walker: &'a mut Walker, levels: u32) -> Result<Self, Unstorable> {
walker.enter(levels)?;
Ok(Self { walker, levels })
}
fn value<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
value.serialize(&mut *self.walker)
}
fn finish(self) -> Result<(), Unstorable> {
self.walker.leave(self.levels);
Ok(())
}
}
impl SerializeSeq for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_element<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeTuple for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_element<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeTupleStruct for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeTupleVariant for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeMap for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_key<T: Serialize + ?Sized>(&mut self, key: &T) -> Result<(), Unstorable> {
self.value(key)
}
fn serialize_value<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeStruct for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeStructVariant for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl<'a> Serializer for &'a mut Walker {
type Ok = ();
type Error = Unstorable;
type SerializeSeq = Compound<'a>;
type SerializeTuple = Compound<'a>;
type SerializeTupleStruct = Compound<'a>;
type SerializeTupleVariant = Compound<'a>;
type SerializeMap = Compound<'a>;
type SerializeStruct = Compound<'a>;
type SerializeStructVariant = Compound<'a>;
fn serialize_bool(self, _v: bool) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i8(self, _v: i8) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i16(self, _v: i16) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i32(self, _v: i32) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i64(self, _v: i64) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i128(self, _v: i128) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u8(self, _v: u8) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u16(self, _v: u16) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u32(self, _v: u32) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u64(self, _v: u64) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u128(self, _v: u128) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_f32(self, v: f32) -> Result<(), Unstorable> {
if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) }
}
fn serialize_f64(self, v: f64) -> Result<(), Unstorable> {
if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) }
}
fn serialize_char(self, _v: char) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_str(self, _v: &str) -> Result<(), Unstorable> {
Ok(())
}
/// serde_json writes bytes as an array of numbers: one level of nesting.
fn serialize_bytes(self, _v: &[u8]) -> Result<(), Unstorable> {
Compound::new(self, 1)?.finish()
}
/// Writes text for a `Display` value; it is a scalar, not a container.
fn collect_str<T: fmt::Display + ?Sized>(self, _v: &T) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_none(self) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_some<T: Serialize + ?Sized>(self, value: &T) -> Result<(), Unstorable> {
value.serialize(&mut *self)
}
fn serialize_unit(self) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_unit_struct(self, _name: &'static str) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_unit_variant(self, _name: &'static str, _index: u32, _variant: &'static str) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_newtype_struct<T: Serialize + ?Sized>(self, _name: &'static str, value: &T) -> Result<(), Unstorable> {
value.serialize(self)
}
fn serialize_newtype_variant<T: Serialize + ?Sized>(self, _name: &'static str, _index: u32, _variant: &'static str, value: &T) -> Result<(), Unstorable> {
let mut compound = Compound::new(self, 1)?;
compound.value(value)?;
compound.finish()
}
fn serialize_seq(self, _len: Option<usize>) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_tuple(self, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_tuple_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 2)
}
fn serialize_map(self, _len: Option<usize>) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_struct(self, _name: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_struct_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 2)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::check;
#[test]
fn refuses_non_finite_numbers_anywhere() {
assert!(check(&1.5f64).is_ok());
assert!(check(&vec![Some(1.0f64), None]).is_ok());
assert!(check(&BTreeMap::from([("a".to_string(), 2.0f32)])).is_ok(), "finite floats in a map");
assert!(check(&f64::NAN).is_err());
assert!(check(&f64::INFINITY).is_err());
assert!(check(&vec![Some(f64::NEG_INFINITY)]).is_err(), "inside an option in a list");
assert!(check(&BTreeMap::from([("a".to_string(), f32::NAN)])).is_err(), "as a map value");
}
#[test]
fn depth_limit_matches_serde_json() {
for n in [127, 128] {
let mut v = serde_json::Value::Null;
for _ in 0..n {
v = serde_json::Value::Array(vec![v]);
}
let text = serde_json::to_string(&v).unwrap();
let reads_back = serde_json::from_str::<serde_json::Value>(&text).is_ok();
assert_eq!(check(&v).is_ok(), reads_back, "depth {n} agrees with serde_json");
assert_eq!(check(&v).is_ok(), n == 127, "depth {n}");
}
}
/// `n` nested lists around bytes, which serde_json writes as one more array.
struct BytesUnder(u32);
impl serde::Serialize for BytesUnder {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let Some(inner) = self.0.checked_sub(1) else {
return s.serialize_bytes(&[1]);
};
let mut seq = s.serialize_seq(Some(1))?;
seq.serialize_element(&BytesUnder(inner))?;
seq.end()
}
}
#[test]
fn bytes_count_as_one_level() {
for n in [126, 127] {
let text = serde_json::to_string(&BytesUnder(n)).unwrap();
let reads_back = serde_json::from_str::<serde_json::Value>(&text).is_ok();
assert_eq!(check(&BytesUnder(n)).is_ok(), reads_back, "depth {n} agrees with serde_json");
assert_eq!(check(&BytesUnder(n)).is_ok(), n == 126, "depth {n}");
}
}
}
+79 -10
View File
@@ -8,6 +8,7 @@
pub mod build;
pub mod essential;
mod file_check;
pub mod limits;
pub mod props;
pub mod render_queue;
@@ -200,7 +201,7 @@ impl TimeDisplayStyle {
}
}
pub fn parse(s: &str) -> Option<TimeDisplayStyle> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"timecode" => Some(TimeDisplayStyle::Timecode),
"frames" => Some(TimeDisplayStyle::Frames),
@@ -851,7 +852,12 @@ impl Comp {
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
/// Shy Layers and the viewer's Resolution.
pub fn same_pixels(&self, o: &Comp) -> bool {
// Every field (no `..`: a new field must be considered here); the layers last.
self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
}
/// [`Self::same_pixels`] but for the layers.
fn same_settings(&self, o: &Comp) -> bool {
// Every field (no `..`: a new field must be considered here).
let Comp {
width,
height,
@@ -861,7 +867,7 @@ impl Comp {
display_start,
background,
work_area,
layers,
layers: _,
markers,
shutter_angle,
shutter_phase,
@@ -903,8 +909,61 @@ impl Comp {
&& *global_light == o.global_light
&& *guides == o.guides
&& *essential == o.essential
&& layers.len() == o.layers.len()
&& layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
}
/// The comp-time span (`in_point..out_point` of every layer changed, added or removed,
/// widened by the shutter) outside which `self` draws the same pixels as `o`, so cached
/// frames there stay valid (#1641). `None` when the edit can reach any frame: a comp setting,
/// a layer reorder, Solo turning on or off, an expression (it reads anything), or a changed
/// layer another layer parents to, mattes with or picks in an effect.
pub fn changed_span(&self, o: &Comp) -> Option<(Tick, Tick)> {
fn reads(g: &PropGroup, changed: &[LayerId]) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => {
p.expr.is_some()
|| std::iter::once(&p.value)
.chain(p.keys.iter().map(|k| &k.value))
.any(|v| matches!(v, Value::Layer(Some(id)) if changed.contains(&LayerId(*id))))
}
Node::Group(g) => reads(g, changed),
})
}
// Solo hides every other layer at every frame.
if !self.same_settings(o) || self.any_solo() != o.any_solo() {
return None;
}
let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::<Vec<_>>();
if common(self, o) != common(o, self) {
return None;
}
let mut changed = Vec::new();
let mut span: Option<(Tick, Tick)> = None;
// Each changed layer comes up from both sides: its old and its new span.
for (l, other) in self.layers.iter().map(|l| (l, o.layer(l.id))).chain(o.layers.iter().map(|l| (l, self.layer(l.id)))) {
if other.is_some_and(|b| l.same_pixels(b)) {
continue;
}
changed.push(l.id);
let (s, e) = (l.in_point.min(l.out_point), l.in_point.max(l.out_point));
span = Some(span.map_or((s, e), |(a, b)| (a.min(s), b.max(e))));
}
let reaches = |l: &Layer| {
l.parent.is_some_and(|p| changed.contains(&p)) || l.track_matte.as_ref().is_some_and(|m| changed.contains(&m.layer)) || reads(&l.props, &changed)
};
if self.layers.iter().chain(&o.layers).any(reaches) {
return None;
}
// A frame at `t` samples cameras, lights and transforms over its shutter,
// `t + (phase + angle * 0..=1) / 360` frames, and draws the layers active at `t` itself.
let (a, p) = (self.shutter_angle / 360.0, self.shutter_phase / 360.0);
let fd = self.frame_duration().seconds();
let (before, after) = (Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd));
span.map(|(s, e)| (s - before, e + after))
}
/// Some layer is soloed: only the soloed layers draw.
pub fn any_solo(&self) -> bool {
self.layers.iter().any(|l| l.switches.solo && l.source.is_av())
}
/// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected
@@ -1233,7 +1292,7 @@ impl Footage {
if self.kind != FootageKind::Data {
return None;
}
let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(&self.path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
Some(match ext.as_str() {
"csv" => "csv",
"tsv" | "tab" => "tsv",
@@ -1540,13 +1599,20 @@ impl Project {
&& self.render_prefs == other.render_prefs
}
/// The project file's text: the project with the version that wrote it (`savedBy`), checked
/// to read back, so a save never writes a file that can't be opened (JSON has no NaN or
/// infinity: such a value would be written as `null`).
/// to read back, so a save never writes a file that can't be opened. Values JSON can't hold
/// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every
/// save; debug builds also read the text back.
pub fn to_file_json(&self) -> Result<String, ProjectError> {
self.to_file_json_with_view(&SavedView::default())
}
/// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]).
pub fn to_file_json_with_view(&self, view: &SavedView) -> Result<String, ProjectError> {
if self.schema > SCHEMA_VERSION {
return Err(ProjectError::Invalid(format!(
"the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))",
self.schema
)));
}
#[derive(Serialize)]
struct File<'a> {
#[serde(rename = "savedBy")]
@@ -1556,8 +1622,11 @@ impl Project {
#[serde(flatten)]
project: &'a Project,
}
let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self })
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
let file = File { saved_by: APP_VERSION, view, project: self };
file_check::check(&file)
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
#[cfg(debug_assertions)]
Project::from_json(&text)
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
Ok(text)
+23
View File
@@ -166,10 +166,33 @@ fn project_files_name_their_version_and_always_read_back() {
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::NAN);
let e = p.to_file_json().unwrap_err().to_string();
assert!(e.contains("can't be saved"), "{e}");
// Infinity too, in either profile: the check doesn't rely on reading the file back.
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::INFINITY);
let e = p.to_file_json().unwrap_err().to_string();
assert!(e.contains("can't be saved"), "{e}");
assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9"));
assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1"));
}
#[test]
fn project_files_refuse_newer_schemas() {
let p = Project { schema: crate::SCHEMA_VERSION + 1, ..Project::default() };
let expected = format!(
"the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({}))",
p.schema,
crate::SCHEMA_VERSION
);
assert_eq!(p.to_file_json().unwrap_err().to_string(), expected);
assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected);
}
#[test]
fn saved_files_reopen_in_pretty_and_compact_form() {
let (p, _cid) = project_with_layer();
assert_eq!(Project::from_json(&p.to_file_json().unwrap()).unwrap(), p, "the pretty project file");
assert_eq!(Project::from_json(&p.to_json_compact()).unwrap(), p, "compact JSON");
}
#[test]
fn project_files_remember_the_open_comps() {
let (p, cid) = project_with_layer();
+1 -1
View File
@@ -10,4 +10,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
miniz_oxide = "0.8"
miniz_oxide = "0.9"
+57 -2
View File
@@ -1,5 +1,5 @@
//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional
//! and radial (spin/zoom) blurs.
//! Blurs: separable box passes (exact running sums) approximating Gaussians, a sampled Gaussian
//! kernel for small sigmas, plus directional and radial (spin/zoom) blurs.
//!
//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over
//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two
@@ -157,6 +157,50 @@ pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> I
box_passes(img, &rx, &ry, repeat)
}
/// Below this sigma (pixels) three integer-radius box passes are too coarse (sigma under ~0.6 is
/// a no-op and the result steps between a few shapes), so [`gaussian_blur_exact`] is used instead.
const EXACT_SIGMA_BELOW: f64 = 3.0;
/// True when a Gaussian of these per-axis sigmas should use [`gaussian_blur_exact`].
pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool {
sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW
}
/// One separable pass of a sampled Gaussian (radius 3 sigma) along x or y.
fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image {
// Below 1e-3 the kernel is the identity in f64; tiny sigmas would also make 2σ² underflow to 0 (NaN taps).
if sigma.is_nan() || sigma <= 1e-3 || img.is_empty() {
return img.clone();
}
let r = (sigma * 3.0).ceil() as i64;
let mut k: Vec<f64> = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect();
let total: f64 = k.iter().sum();
k.iter_mut().for_each(|v| *v /= total);
let w = img.width as usize;
let mut out = Image::new(img.width, img.height);
out.rows_mut().for_each(|(y, row)| {
for x in 0..w {
let mut acc = [0.0f64; 4];
for (i, wt) in k.iter().enumerate() {
let d = i as i64 - r;
let (sx, sy) = if vertical { (x as i64, y as i64 + d) } else { (x as i64 + d, y as i64) };
let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) };
for c in 0..4 {
acc[c] += p[c] as f64 * wt;
}
}
row[x] = acc.map(|v| v as f32);
}
});
out
}
/// Gaussian blur as a true sampled kernel, continuous in sigma (for small sigmas, see
/// [`gaussian_is_exact`]).
pub fn gaussian_blur_exact(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image {
gauss_pass(&gauss_pass(img, sigma_x, false, repeat), sigma_y, true, repeat)
}
/// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from
/// up) over `length` pixels.
pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image {
@@ -228,6 +272,17 @@ mod tests {
assert!(b.get(32, 32)[3] < 0.05);
}
#[test]
fn vanishing_or_nan_sigma_leaves_the_image_unchanged() {
let mut img = Image::new(5, 5);
img.set(2, 2, [1.0, 0.5, 0.25, 1.0]);
for s in [1e-200, f64::MIN_POSITIVE, f64::NAN] {
let b = gaussian_blur_exact(&img, s, s, false);
assert!(b.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "sigma {s}");
assert_eq!(b, img, "sigma {s}");
}
}
#[test]
fn flat_image_stays_flat_with_repeat() {
let img = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
+1 -1
View File
@@ -16,7 +16,7 @@ pub mod warp;
use effectcraft_color::{BlendMode, blend_pixel};
use rayon::prelude::*;
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur};
pub use channels3d::AuxChannels;
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
+1 -1
View File
@@ -19,7 +19,7 @@ effectcraft-raster = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
effectcraft-model = { workspace = true }
kurbo = { workspace = true }
rayon = { workspace = true }
+1 -1
View File
@@ -331,7 +331,7 @@ impl DiskCache {
let Ok(files) = std::fs::read_dir(shard.path()) else { continue };
for f in files.flatten() {
let p = f.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
if name.contains(".tmp") {
// A writer that died mid-write; old temporaries are removed.
if f.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|e| e.as_secs() > 600)) {
+1 -1
View File
@@ -2135,7 +2135,7 @@ impl<'a> Renderer<'a> {
/// applied).
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = comp.any_solo();
comp.layers
.iter()
.rev()
+1 -1
View File
@@ -880,7 +880,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item:
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = nctx.comp.any_solo();
for l in nctx.comp.layers.iter().rev() {
if !l.is_active_at(t)
|| l.is_light()

Some files were not shown because too many files have changed in this diff Show More