Merge main into rfx-text

Conflict in crates/text/src/layout.rs (line metrics): main's partition_point
slice from #2193 plus this branch's first-baseline maximum. rustfmt applied to
main's paragraph() signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-23 18:05:14 +02:00
co-authored by Claude Opus 5.5
41 changed files with 1122 additions and 237 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
Generated
+36 -36
View File
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1188,7 +1188,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1200,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1223,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1231,7 +1231,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1282,7 +1282,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1312,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1329,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1337,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1359,14 +1359,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1391,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1422,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,7 +1469,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1481,14 +1481,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1516,7 +1516,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1541,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1557,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1579,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1605,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1629,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1637,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,7 +1649,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1681,11 +1681,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.12.0"
version = "0.16.0"
[[package]]
name = "effectcraft-web"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.16.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.12.0"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+37 -30
View File
@@ -8,14 +8,18 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png" width="128" height="128" alt="EffectCraft icon: an engraved unicorn on a pink tile">
</p>
<h1 align="center">EffectCraft</h1>
<p align="center">
<b>Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust.</b>
<b>Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.</b>
</p>
<p align="center">
A free, open-source compositor in the spirit of After Effects: compositions, layers,
A free, open-source compositor: compositions, layers,
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
Windows and Linux, and in the browser. Young, moving fast, and already usable.
</p>
@@ -24,6 +28,9 @@
<img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-e0368f?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/rust-1.95%2B-b0206c?style=flat-square&logo=rust&logoColor=white">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_effectcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/effectcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -64,13 +71,14 @@
## What EffectCraft is
EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing
people reach for After Effects to do. You build a composition out of layers (solids, shapes,
motion designers and VFX artists need. You build a composition out of layers (solids, shapes,
text, footage, other compositions), animate their properties with keyframes, stack effects on
them and render the result.
The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project,
Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and
then to go further in a few places where it matters to us:
The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline,
Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph,
Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going further in
a few places where it matters to us:
- **Lottie import and export built in**, so animations can go straight to the web and apps
without a plugin.
@@ -84,7 +92,7 @@ then to go further in a few places where it matters to us:
## Animate
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
The panels, menus and shortcuts follow standard motion design conventions:
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
@@ -102,7 +110,7 @@ or into the Timeline, between layers and at the time you point to.
feather, expansion and vertex editing in the viewer.
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
Character and Paragraph panels, and text animators with range selectors.
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
- **Expressions:** JavaScript with the standard motion expressions model (`wiggle`, `loopOut`,
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S,
@@ -118,9 +126,9 @@ or into the Timeline, between layers and at the time you point to.
## Effects
306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri
Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
306 built-in effects across its categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
@@ -134,7 +142,7 @@ overlays, Stroke) with Global Light. Preview plays audio in sync, with meters an
## 3D
Classic 3D the way After Effects does it: 3D layers with orientation and material options,
Classic 3D compositing: 3D layers with orientation and material options,
one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient)
with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera
tools, and Front, Top, Left and Custom views.
@@ -145,7 +153,7 @@ tools, and Front, Top, Left and Custom views.
## Export
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
@@ -219,25 +227,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
## Where it stands
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
exists, including all of its effects, but existing is not the same as behaving exactly like After
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
exists, including 306 effects, but breadth is not the same as full maturity across every edge
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
for production client work. In particular:
- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
After Effects automatically, so please report differences.
- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary
project formats and After Effects SDK plug-ins are not supported.
- **Fidelity is still being refined.** Automated rendering tests currently focus on small
compositions, so please report visual discrepancies or unexpected behaviour.
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
bugs, which we're fixing first.
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning.
EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
EffectCraft is in **alpha**: about 91% of the features tracked in
[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the
way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan,
[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md)
every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
send us right now.
@@ -245,8 +254,8 @@ send us right now.
- **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on
top and nothing underneath depends on it.
- **Clean room.** We work from After Effects' public documentation and how it behaves, never
from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules
- **Clean room.** We work from public documentation and observable behavior, never
from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules
are in [AGENTS.md](AGENTS.md).
- **Private by default.** No telemetry, and no network access unless you ask for it.
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
@@ -368,8 +377,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+6 -2
View File
@@ -494,8 +494,12 @@ export async function cacheRead(name) {
/// resolves with its size.
export async function cacheWrite(fullName, bytes) {
const [dir, name] = await entryDir(fullName);
const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype;
const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name;
const canMove =
typeof FileSystemHandle !== "undefined" &&
"move" in FileSystemHandle.prototype &&
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function";
const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name;
const fh = await dir.getFileHandle(tmp, { create: true });
if (fh.createSyncAccessHandle) {
const h = await fh.createSyncAccessHandle();
+8 -6
View File
@@ -32,13 +32,15 @@ self.addEventListener("fetch", (event) => {
if (req.method !== "GET" || url.origin !== self.location.origin) return;
event.respondWith(
(async () => {
const cache = await caches.open(CACHE);
// The page itself regardless of its query (?empty, ?demo…).
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
if (hit) return hit;
const cache = await caches.open(CACHE).catch(() => null);
if (cache) {
// The page itself regardless of its query (?empty, ?demo…).
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null);
if (hit) return hit;
}
const res = await fetch(req);
if (res.ok && res.type === "basic") cache.put(req, res.clone());
if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
return res;
})(),
);
+11 -1
View File
@@ -141,7 +141,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
}
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
let h6 = h.rem_euclid(1.0) * 6.0;
let mut h6 = h.rem_euclid(1.0) * 6.0;
// In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0.
if h6 >= 6.0 {
h6 = 0.0;
}
let i = h6.floor();
let f = h6 - i;
let p = v * (1.0 - s);
@@ -252,6 +256,12 @@ impl Label {
mod tests {
use super::*;
#[test]
fn hsv_to_rgb_tiny_negative_hue_is_red() {
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
}
#[test]
fn srgb_roundtrip() {
for i in 0..=100 {
+6 -2
View File
@@ -284,9 +284,13 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
return None;
}
let fr = fc.frames.get(&f)?;
let a = rle::decode(&fr.a)?;
let [fw, fh] = fc.size;
if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? {
let expected = fw.checked_mul(fh)?;
if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
return None;
}
let a = rle::decode_bounded(&fr.a, expected)?;
if a.len() != expected {
return None;
}
let af: Vec<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
+7 -3
View File
@@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc<Image>, scale: f64) -> Option<(V
&& fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps())
&& let Some(fr) = fc.frames.get(&f)
{
let a = rle::decode(&fr.a)?;
let bnd = rle::decode(&fr.b)?;
let [fw, fh] = fc.size;
if a.len() == fw * fh && bnd.len() == fw * fh {
let expected = fw.checked_mul(fh)?;
if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
return None;
}
let a = rle::decode_bounded(&fr.a, expected)?;
let bnd = rle::decode_bounded(&fr.b, expected)?;
if a.len() == expected && bnd.len() == expected {
let af: Vec<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
let (alpha, band) = if fw == w && fh == h {
(af, bnd)
+3 -1
View File
@@ -34,9 +34,11 @@ impl Services for Recording {
}
}
/// Probes every file as a still.
/// Probes every file as a still. Only the Unix part of the test below imports files.
#[cfg(unix)]
struct Stills;
#[cfg(unix)]
impl crate::Importer for Stills {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !std::path::Path::new(path).is_file() {
+35
View File
@@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() {
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
}
#[test]
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
// contains C walked the A ⇄ B cycle until the stack ran out (#1237).
let mut s = Session::default();
let mut ids = vec![];
for name in ["A", "B", "C"] {
s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
ids.push(s.active_comp_id().unwrap());
}
let (a, b, c) = (ids[0], ids[1], ids[2]);
{
let proj = std::sync::Arc::make_mut(&mut s.project);
for (outer, inner) in [(a, b), (b, a)] {
let comp = proj.comp(outer).unwrap().clone();
let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
std::sync::Arc::make_mut(cc).layers.push(l);
}
}
let path = tmp("cycle.ecproj");
std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
let mut t = Session::default();
t.execute("file.open", json!({"path": path})).unwrap();
t.open_comp(c);
// Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
assert!(r["layer"].as_u64().is_some(), "{r}");
// Adding C into A would nest C in itself through A: refused, as before.
assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
// C renders: inside A the renderer skips B's copy of A instead of looping.
let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (32, 32));
}
+1 -1
View File
@@ -461,7 +461,7 @@ Comp.prototype = {
var l = this.layer(i);
if (l.__info[10] === 'Camera' && l.active) return l;
}
__err('Composition "' + this.name + '" has no active camera');
return __err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
+78 -1
View File
@@ -528,8 +528,9 @@ fn rewrite_seq(items: &[Item]) -> String {
let end = assignment_end(items, j + 1);
let rhs = rewrite_seq(&items[j + 1..end]);
let h = helper(&op.text[..1]).unwrap_or("__add");
let target = compound_assignment_target(items, i, j);
out.push_str(&p.ws);
out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body));
out.push_str(&format!("{target} ={}{h}({target}, ({rhs}))", op.ws));
i = end;
continue;
}
@@ -543,6 +544,34 @@ fn rewrite_seq(items: &[Item]) -> String {
out
}
fn raw_ref(item: &Item) -> String {
match item {
Item::Tok(t) => format!("{}{}", t.ws, t.text),
Item::Group { open, inner, close } => {
let mut out = format!("{}{}", open.ws, open.text);
if has_assign(inner) {
// shortcut: nested assignments stay verbatim (rewriting them would double per level), so arithmetic beside them is not array-aware
for item in inner {
out.push_str(&raw_ref(item));
}
} else {
out.push_str(&rewrite_seq(inner));
}
if let Some(close) = close {
out.push_str(&close.text);
}
out
}
}
}
fn has_assign(items: &[Item]) -> bool {
items.iter().any(|it| match it {
Item::Tok(t) => ASSIGN.contains(&t.text.as_str()),
Item::Group { inner, .. } => has_assign(inner),
})
}
fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.ws);
out.push_str(&p.body);
@@ -554,6 +583,18 @@ fn push_piece(out: &mut String, p: Piece) {
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
/// nest without brackets count as part of a level ([`too_nested`]).
pub const MAX_NESTING: usize = 32;
/// Hard limit on bytes emitted by the array-math source rewrite.
pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024;
fn over_rewrite_limit(text: &str) -> bool {
text.len() > MAX_REWRITTEN_BYTES
}
fn compound_assignment_target(items: &[Item], start: usize, end: usize) -> String {
// Rewriting the whole left side duplicates nested computed assignment targets. Keep the
// original target syntax for both reads/writes; source length is bounded above.
items[start..end].iter().map(raw_ref).collect()
}
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
/// prefix operator, which cost the parser about as much or are never chained in real code).
@@ -746,12 +787,18 @@ fn too_nested_from(toks: &[Tok], base: usize) -> bool {
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
pub fn check_nesting(src: &str) -> Result<(), String> {
if over_rewrite_limit(src) {
return Err(too_large());
}
let (toks, tail) = tokenize(src);
if too_nested(&toks) {
return Err(too_deep());
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if over_rewrite_limit(&out) {
return Err(too_large());
}
if too_nested(&tokenize(&out).0) {
return Err(too_deep());
}
@@ -765,12 +812,18 @@ fn too_deep() -> String {
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
pub fn rewrite(src: &str) -> String {
if over_rewrite_limit(src) {
return throw_too_large();
}
let (toks, tail) = tokenize(src);
if too_nested(&toks) {
return throw_too_deep();
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if over_rewrite_limit(&out) {
return throw_too_large();
}
if too_nested(&tokenize(&out).0) {
return throw_too_deep();
}
@@ -781,6 +834,14 @@ fn throw_too_deep() -> String {
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
}
fn too_large() -> String {
format!("Error: expression exceeds the {MAX_REWRITTEN_BYTES}-byte rewrite limit")
}
fn throw_too_large() -> String {
format!("throw new RangeError({:?})", too_large().trim_start_matches("Error: "))
}
#[cfg(test)]
mod tests {
use super::{MAX_NESTING, check_nesting, rewrite};
@@ -821,6 +882,8 @@ mod tests {
#[test]
fn compound_assignment_and_functions() {
assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));"));
assert_eq!(n(&rewrite("obj[key] += 1")), n("obj[key] = __add(obj[key], (1))"));
assert_eq!(n(&rewrite("o[a + b] += 1")), n("o[__add(a, b)] = __add(o[__add(a, b)], (1))"));
assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }"));
assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)"));
assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')"));
@@ -828,6 +891,20 @@ mod tests {
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
}
#[test]
fn source_expansion_is_bounded_before_rewriting() {
let src = "x += 1;".repeat(super::MAX_REWRITTEN_BYTES / 6 + 1);
assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
assert!(rewrite(&src).starts_with("throw new RangeError("));
}
#[test]
fn output_expansion_is_bounded() {
let src = format!("{} += 1", "a".repeat(super::MAX_REWRITTEN_BYTES / 2));
assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
assert!(rewrite(&src).starts_with("throw new RangeError("));
}
#[test]
fn deep_nesting_is_an_error_not_a_stack_overflow() {
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
+2 -2
View File
@@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2],
};
let mut acc = [0.0f64; 4];
let mut n = 0.0;
for y in y0..=y1.min(y0 + 4096) {
for x in x0..=x1.min(x0 + 4096) {
for y in y0..=y1.min(y0.saturating_add(4096)) {
for x in x0..=x1.min(x0.saturating_add(4096)) {
let p = b.img.get(x, y);
for k in 0..4 {
acc[k] += p[k] as f64;
+7
View File
@@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() {
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
}
/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
#[test]
fn sample_image_huge_point_is_transparent() {
let f = fx();
assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0);
}
fn data_item(p: &mut Project, name: &str, text: &str) {
let f = effectcraft_project::Footage {
path: format!("/data/{name}"),
+12 -1
View File
@@ -82,7 +82,18 @@ impl ShapePath {
if i == 0 && f.segment == 0 && !self.closed {
return false;
}
if i > 0 && f.segment == i - 1 {
if i == 0 && self.closed && segs > 0 {
// The old closing segment and segment 0 merge into the new closing one.
if f.segment == segs {
f.segment = segs - 1;
f.t *= 0.5;
} else if f.segment == 0 {
f.segment = segs - 1;
f.t = 0.5 + f.t * 0.5;
} else {
f.segment -= 1;
}
} else if i > 0 && f.segment == i - 1 {
f.t *= 0.5;
} else if f.segment == i && i > 0 {
f.segment = i - 1;
+14
View File
@@ -0,0 +1,14 @@
use effectcraft_keyframe::{FeatherPoint, ShapePath};
#[test]
fn removing_first_vertex_of_closed_path_merges_closing_segment() {
let mut p = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
let fp = |segment| FeatherPoint { segment, t: 0.5, radius: 1.0, tension: 0.0 };
p.feather = vec![fp(3), fp(0)];
p.vertices.remove(0);
p.in_tangents.remove(0);
p.out_tangents.remove(0);
p.feather_after_remove(0);
let got: Vec<(usize, f64)> = p.feather.iter().map(|f| (f.segment, f.t)).collect();
assert_eq!(got, vec![(2, 0.25), (2, 0.75)]);
}
+5 -1
View File
@@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
assert!(st.success(), "ffmpeg failed for {name}");
std::fs::rename(&tmp, &out).expect("rename");
if std::fs::rename(&tmp, &out).is_err() {
// Another test made it first; on Windows the rename fails while that test has it open.
let _ = std::fs::remove_file(&tmp);
assert!(out.exists(), "rename {} to {name} failed", tmp.display());
}
Some(out)
}
+8 -1
View File
@@ -133,7 +133,9 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right).
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs()));
let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0);
let max_rad = size[0].abs().min(size[1].abs()) / 2.0;
// `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) };
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
}
@@ -354,6 +356,11 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
mod tests {
use super::*;
#[test]
fn rect_with_nan_size_does_not_panic() {
let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
}
#[test]
fn roundtrip_vertex_model() {
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
+73 -25
View File
@@ -16,6 +16,7 @@ pub(crate) struct Decoded {
/// The longest side of a decoded image (larger images are refused rather than exhausting
/// memory).
const MAX_SIDE: usize = 16384;
const MAX_PIXELS: usize = 64 << 20;
fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> {
file.get(d, long).or_else(|| file.get(d, short))
@@ -114,6 +115,11 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec<u8>, usize), String> {
use zune_jpeg::zune_core::options::DecoderOptions;
let mut probe = JpegDecoder::new(std::io::Cursor::new(data));
probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?;
// Bound the header-declared size before decode() allocates the output.
let (hw, hh) = probe.dimensions().ok_or_else(|| "image (JPEG)".to_string())?;
if hw.checked_mul(hh).is_none_or(|p| p > MAX_PIXELS) {
return Err("image (JPEG)".to_string());
}
let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3);
let out = match n {
1 => ColorSpace::Luma,
@@ -134,8 +140,17 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec<u8>, usize), String> {
/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers.
fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec<u16> {
let row_bytes = (w * n * bpc).div_ceil(8);
let mut out = Vec::with_capacity(w * h * n);
let Some(samples) = w.checked_mul(h).and_then(|p| p.checked_mul(n)).filter(|count| *count <= MAX_PIXELS.saturating_mul(4)) else { return Vec::new() };
let Some(row_bits) = w.checked_mul(n).and_then(|v| v.checked_mul(bpc)) else { return Vec::new() };
let row_bytes = row_bits.div_ceil(8);
let Some(row_len) = row_bytes.checked_mul(h) else { return Vec::new() };
if row_len > data.len() {
return Vec::new();
}
let mut out = Vec::new();
if out.try_reserve_exact(samples).is_err() {
return Vec::new();
}
for y in 0..h {
let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]);
for i in 0..w * n {
@@ -157,10 +172,22 @@ fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec<u16> {
/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`.
fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec<u8> {
let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { return Vec::new() };
if mw.checked_mul(mh).is_none_or(|n| n > m.len()) {
return Vec::new();
}
if mw == w && mh == h {
return m.to_vec();
let mut out = Vec::new();
if out.try_reserve_exact(pixels).is_err() {
return Vec::new();
}
out.extend_from_slice(&m[..pixels]);
return out;
}
let mut out = Vec::new();
if out.try_reserve_exact(pixels).is_err() {
return Vec::new();
}
let mut out = Vec::with_capacity(w * h);
for y in 0..h {
let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1));
for x in 0..w {
@@ -176,12 +203,16 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize
let Obj::Stream(d, raw) = file.resolve(o) else { return None };
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w.checked_mul(h).is_none_or(|p| p > MAX_PIXELS) {
return None;
}
let (data, jpeg_n) = samples(file, d, raw).ok()?;
let pixels = w.checked_mul(h)?;
if jpeg_n.is_some() {
return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
let mut out = Vec::new();
out.try_reserve_exact(pixels).ok()?;
out.extend(data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(pixels));
return Some((out, w, h));
}
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
// Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably
@@ -192,19 +223,21 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize
let max = ((1u32 << bpc) - 1).max(1) as f64;
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
let v = unpack(&data, w, h, 1, bpc);
let out = v
.into_iter()
.map(|s| {
let t = s as f64 / max;
let t = match &dec {
Some(dd) => dd[0] + t * (dd[1] - dd[0]),
None => t,
};
// Explicit (stencil) masks: sample 1 masks out with the default Decode.
let a = if soft { t } else { 1.0 - t };
(a.clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect();
if v.len() != pixels {
return None;
}
let mut out = Vec::new();
out.try_reserve_exact(pixels).ok()?;
out.extend(v.into_iter().map(|s| {
let t = s as f64 / max;
let t = match &dec {
Some(dd) => dd[0] + t * (dd[1] - dd[0]),
None => t,
};
// Explicit (stencil) masks: sample 1 masks out with the default Decode.
let a = if soft { t } else { 1.0 - t };
(a.clamp(0.0, 1.0) * 255.0).round() as u8
}));
Some((out, w, h))
}
@@ -212,15 +245,24 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize
pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result<Decoded, String> {
let w = file.get_num(d, "Width").unwrap_or(0.0) as usize;
let h = file.get_num(d, "Height").unwrap_or(0.0) as usize;
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 {
let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else {
return Err("image (size)".into());
};
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return Err("image (size)".into());
}
let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let (data, jpeg_n) = samples(file, d, raw)?;
let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default();
let mut rgba = vec![0u8; w * h * 4];
let rgba_len = pixels.checked_mul(4).ok_or_else(|| "image (size)".to_string())?;
let mut rgba = Vec::new();
rgba.try_reserve_exact(rgba_len).map_err(|_| "image (allocation)".to_string())?;
rgba.resize(rgba_len, 0);
if stencil {
let v = unpack(&data, w, h, 1, 1);
if v.len() != pixels {
return Err("image (sample allocation)".into());
}
let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 };
let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
for (i, s) in v.into_iter().enumerate() {
@@ -243,8 +285,14 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(format!("image ({bpc} bits per component)"));
}
let Some(sample_count) = pixels.checked_mul(n).filter(|n| *n <= MAX_PIXELS.saturating_mul(4)) else {
return Err("image (size)".into());
};
let max = ((1u32 << bpc) - 1) as f64;
let raw_samples = unpack(&data, w, h, n, bpc);
if raw_samples.len() != sample_count {
return Err("image (sample allocation)".into());
}
// Decode ranges per component.
let indexed = matches!(cs, Cs::Indexed { .. });
let range = |k: usize| -> (f64, f64) {
@@ -262,7 +310,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()),
_ => None,
};
for i in 0..w * h {
for i in 0..pixels {
if let Some(pal) = &palette {
let s = raw_samples[i] as f64 / max;
let (a, b) = ranges[0];
@@ -288,7 +336,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
Some(Obj::Array(a)) => {
let keys: Vec<f64> = a.iter().filter_map(|x| file.resolve(x).num()).collect();
if keys.len() >= 2 * n {
for i in 0..w * h {
for i in 0..pixels {
if (0..n).all(|k| {
let v = raw_samples[i * n + k] as f64;
v >= keys[2 * k] && v <= keys[2 * k + 1]
@@ -300,7 +348,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
}
Some(m @ Obj::Stream(..)) => {
if let Some((m, mw, mh)) = mask_image(file, m, false) {
for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() {
rgba[i * 4 + 3] = a;
}
}
@@ -310,7 +358,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
if let Some(sm) = file.get(d, "SMask")
&& let Some((m, mw, mh)) = mask_image(file, sm, true)
{
for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() {
rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8;
}
}
+23 -2
View File
@@ -1478,11 +1478,32 @@ impl Project {
}
/// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles).
pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool {
// A walk with a stack and a visited set rather than recursion (#1237): a damaged project
// may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long
// chain must not use up the call stack, and comps shared by many paths are visited once
// (recursion re-walked them for every path).
if outer == inner {
return true;
}
let Some(c) = self.comp(outer) else { return false };
c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner)))
let mut seen = std::collections::HashSet::new();
let mut stack = vec![outer];
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(c) = self.comp(id) else { continue };
for l in &c.layers {
if let LayerSource::Comp { item } = l.source {
if item == inner {
return true;
}
if !seen.contains(&item) {
stack.push(item);
}
}
}
}
false
}
/// Make sure `next_id` exceeds every id in the project (after loading or merging).
pub fn fix_next_id(&mut self) {
+55
View File
@@ -212,3 +212,58 @@ fn item_users_count_layers_in_every_comp() {
assert!(p.item_users(unused).is_empty());
assert!(p.item_users(main).is_empty());
}
/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a`
/// (indexes into the returned ids).
fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec<crate::ItemId>) {
let mut p = Project::default();
let ids: Vec<crate::ItemId> = (0..n)
.map(|i| {
p.add_item(
&format!("C{i}"),
effectcraft_color::Label::None,
None,
ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()),
)
})
.collect();
for &(a, b) in links {
let outer = p.comp(ids[a]).unwrap().clone();
let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None);
let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") };
std::sync::Arc::make_mut(c).layers.push(l);
}
(p, ids)
}
#[test]
fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
// A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237).
let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]);
assert!(!p.comp_contains(c[0], c[2]));
assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0]));
// A comp nesting itself.
let (p, c) = comp_graph(2, &[(0, 0)]);
assert!(!p.comp_contains(c[0], c[1]));
assert!(p.comp_contains(c[0], c[0]));
// A target reached through a cycle.
let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]);
assert!(p.comp_contains(c[0], c[2]));
assert!(!p.comp_contains(c[2], c[0]));
// 40 levels of diamonds: 2^40 paths, each comp visited once.
let mut links = vec![];
for i in 0..40 {
let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3);
links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]);
}
let (p, c) = comp_graph(121, &links);
let t0 = std::time::Instant::now();
assert!(p.comp_contains(c[0], c[120]));
assert!(!p.comp_contains(c[120], c[0]));
assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed());
// A chain 20 000 comps deep (a deep recursion's stack frames).
let n = 20_000;
let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::<Vec<_>>());
assert!(p.comp_contains(c[0], c[n - 1]));
assert!(!p.comp_contains(c[n - 1], c[0]));
}
+4 -1
View File
@@ -869,8 +869,11 @@ Property.prototype.property = function () { return null; };
}
if (info.dims > 1) {
if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers");
// After Effects rejects an array of the wrong length instead of truncating or broadcasting it
// (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0.
if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension");
var o = [];
for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1])));
for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0);
return o;
}
if (v instanceof Array) throw __err("value must be a number, not an array");
+1 -1
View File
@@ -840,7 +840,7 @@ function __uiJson(c, inherited) {
o.value = Number(c.value) || 0;
o.min = Number(c.minvalue) || 0;
o.max = Number(c.maxvalue);
if (!(o.max === o.max)) o.max = 100;
if (Number.isNaN(o.max)) o.max = 100;
}
if (c.items) {
o.items = c.items.map(function (it) { return String(it.text); });
+38
View File
@@ -1585,3 +1585,41 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() {
let (_, c) = comp_named(&s, "x");
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
/// #1936: an array whose length doesn't match the property's dimension is an error in After
/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
#[test]
fn set_value_rejects_an_array_of_the_wrong_dimension() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30);
var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1);
var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001");
function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } }
var before = "" + ramp.value;
var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); });
var tooFew = attempt(function () { ramp.setValue([1]); });
var untouched = "" + ramp.value;
ramp.setValue([7, 8]);
var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); });
// A 3-D property still takes two values, as it does in After Effects: z stays 0.
l.property("Position").setValue([120, 34]);
[tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value]
"#,
);
assert_eq!(
o.result,
json!([
"Array is not of the correct dimension",
"Array is not of the correct dimension",
true,
"7,8",
"Array is not of the correct dimension",
"120,34,0"
])
);
let (_, c) = comp_named(&s, "Dims");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
}
+123 -16
View File
@@ -6,6 +6,15 @@
use std::collections::HashMap;
const MAX_ARCHIVE_BYTES: usize = 1 << 30;
const MAX_PICKLE_OPS: usize = 4_000_000;
const MAX_TENSORS: usize = 1_000_000;
const MAX_TENSOR_ELEMENTS: usize = 1 << 28;
const MAX_ENTRY_BYTES: usize = 1 << 30;
const MAX_PICKLE_NODES: usize = 4_000_000;
const MAX_TOTAL_ELEMENTS: usize = 1 << 29;
const MAX_TENSOR_DIMS: usize = 64;
/// A tensor read from a checkpoint: its shape and its values as f32 (row-major, contiguous).
#[derive(Clone, Debug, PartialEq)]
pub struct Tensor {
@@ -38,6 +47,9 @@ struct Entry {
/// The entries of a zip archive by name.
fn zip_entries(b: &[u8]) -> Result<HashMap<String, Entry>> {
if b.len() > MAX_ARCHIVE_BYTES {
return Err("checkpoint exceeds the 1024 MiB archive limit".into());
}
// End of central directory: the last `PK\x05\x06` (a comment may follow it).
let eocd =
(0..b.len().saturating_sub(21)).rev().find(|&i| b.get(i..i + 4) == Some(b"PK\x05\x06")).ok_or("not a zip archive (no end of central directory)")?;
@@ -96,6 +108,9 @@ fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result<std::borrow::Cow<'a, [u8]>>
match e.method {
0 => Ok(std::borrow::Cow::Borrowed(raw)),
8 => {
if e.size > MAX_ENTRY_BYTES {
return Err("zip entry exceeds the size limit".into());
}
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}"))
}
m => Err(format!("unsupported zip compression {m}")),
@@ -142,7 +157,58 @@ fn int_list(o: &Obj) -> Option<Vec<usize>> {
}
}
/// Nodes in `o`, stopping once the count passes `budget`.
fn nodes(o: &Obj, budget: usize) -> usize {
let kids: Vec<&Obj> = match o {
Obj::Tuple(v) | Obj::List(v) => v.iter().collect(),
Obj::Dict(v) => v.iter().flat_map(|(k, x)| [k, x]).collect(),
_ => return 1,
};
let mut n = 1;
for k in kids {
if n > budget {
break;
}
n += nodes(k, budget - n);
}
n
}
fn tensor_element_count(name: &str, offset: usize, size: &[usize], stride: &[usize], values_len: usize) -> Result<usize> {
if size.len() != stride.len() || size.len() > MAX_TENSOR_DIMS {
return Err(format!("{name}: invalid tensor dimensionality"));
}
let n = size
.iter()
.try_fold(1usize, |n, &dim| n.checked_mul(dim))
.filter(|n| *n <= MAX_TENSOR_ELEMENTS)
.ok_or_else(|| format!("{name}: tensor exceeds the element limit"))?;
if n == 0 {
return Ok(0);
}
let mut max_at = offset;
for (&dim, &step) in size.iter().zip(stride) {
max_at = max_at
.checked_add((dim - 1).checked_mul(step).ok_or_else(|| format!("{name}: tensor stride overflow"))?)
.ok_or_else(|| format!("{name}: tensor offset overflow"))?;
}
if max_at >= values_len {
return Err(format!("{name}: data out of range"));
}
Ok(n)
}
/// Run the pickle program and return its result.
/// A memoized object, charged against the clone budget (memo reuse can otherwise grow trees exponentially).
fn memo_get(memo: &HashMap<u64, Obj>, k: u64, cloned: &mut usize) -> Result<Obj> {
let o = memo.get(&k).ok_or("pickle: bad memo")?;
*cloned = cloned.saturating_add(nodes(o, MAX_PICKLE_NODES));
if *cloned > MAX_PICKLE_NODES {
return Err("pickle exceeds the object limit".into());
}
Ok(o.clone())
}
fn unpickle(p: &[u8]) -> Result<Obj> {
let mut stack: Vec<Obj> = Vec::new();
let mut memo: HashMap<u64, Obj> = HashMap::new();
@@ -166,7 +232,10 @@ fn unpickle(p: &[u8]) -> Result<Obj> {
*i += n + 1;
Ok(s)
};
let mut op_count = 0usize;
let mut cloned = 0usize;
loop {
op_count = op_count.checked_add(1).filter(|n| *n <= MAX_PICKLE_OPS).ok_or("pickle exceeds the opcode limit")?;
let op = *p.get(i).ok_or("truncated pickle")?;
i += 1;
match op {
@@ -253,11 +322,11 @@ fn unpickle(p: &[u8]) -> Result<Obj> {
}
b'h' => {
let k = take(&mut i, 1)?[0] as u64;
stack.push(memo.get(&k).cloned().ok_or("pickle: bad memo")?);
stack.push(memo_get(&memo, k, &mut cloned)?);
}
b'j' => {
let s = take(&mut i, 4)?;
stack.push(memo.get(&(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64)).cloned().ok_or("pickle: bad memo")?);
stack.push(memo_get(&memo, u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, &mut cloned)?);
}
b't' => {
let items = pop_mark(&mut stack)?;
@@ -290,9 +359,10 @@ fn unpickle(p: &[u8]) -> Result<Obj> {
stack.push(match f {
Obj::Global(m, n) if m == "torch._utils" && n == "_rebuild_tensor_v2" => {
match (args.first(), args.get(1), args.get(2).and_then(int_list), args.get(3).and_then(int_list)) {
(Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => {
Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset: usize::try_from(*off).unwrap_or(0), size, stride }
}
(Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => match usize::try_from(*off) {
Ok(offset) => Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset, size, stride },
Err(_) => Obj::Other,
},
_ => Obj::Other,
}
}
@@ -337,16 +407,24 @@ fn unpickle(p: &[u8]) -> Result<Obj> {
}
}
/// Element bytes and conversion of a storage dtype.
fn to_f32(dtype: &str, bytes: &[u8]) -> Option<Vec<f32>> {
/// Bytes per element of a float storage dtype.
fn elem_size(dtype: &str) -> Option<usize> {
match dtype {
"FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()),
"HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()),
"BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()),
"FloatStorage" => Some(4),
"HalfStorage" | "BFloat16Storage" => Some(2),
_ => None,
}
}
/// Element `i` of a storage (`i` must be below `bytes.len() / elem_size`).
fn elem_f32(dtype: &str, bytes: &[u8], i: usize) -> f32 {
match dtype {
"FloatStorage" => f32::from_le_bytes([bytes[4 * i], bytes[4 * i + 1], bytes[4 * i + 2], bytes[4 * i + 3]]),
"HalfStorage" => half_to_f32(u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]])),
_ => f32::from_bits((u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]) as u32) << 16),
}
}
pub(crate) fn half_to_f32(h: u16) -> f32 {
let (s, e, m) = ((h >> 15) as u32, ((h >> 10) & 0x1f) as u32, (h & 0x3ff) as u32);
let bits = match (e, m) {
@@ -382,22 +460,42 @@ pub fn read(bytes: &[u8]) -> Result<Weights> {
Some((_, Obj::Dict(inner))) => inner.clone(),
_ => dict,
};
if dict.len() > MAX_TENSORS {
return Err("checkpoint exceeds the tensor count limit".into());
}
let mut storages: HashMap<String, std::borrow::Cow<[u8]>> = HashMap::new();
let mut out = Weights::new();
let mut total = 0usize;
let mut inflated = 0usize;
for (k, v) in dict {
let (Obj::Str(name), Obj::Tensor { dtype, key, offset, size, stride }) = (k, v) else { continue };
if !storages.contains_key(&key) {
let e = entries.get(&format!("{prefix}data/{key}")).ok_or_else(|| format!("missing storage {key}"))?;
if e.method == 8 {
inflated = inflated.saturating_add(e.size);
if inflated > MAX_ENTRY_BYTES {
return Err("checkpoint exceeds the decompressed storage limit".into());
}
}
storages.insert(key.clone(), read_entry(bytes, e)?);
}
let Some(values) = storages.get(&key).and_then(|b| to_f32(&dtype, b)) else { continue };
let n: usize = size.iter().product();
// Gather (any strides) into a contiguous row-major tensor.
let mut data = Vec::with_capacity(n);
let (Some(esz), Some(values)) = (elem_size(&dtype), storages.get(&key)) else { continue };
// Check hostile stride math before allocating or indexing the backing storage.
let n = tensor_element_count(&name, offset, &size, &stride, values.len() / esz)?;
total = total.saturating_add(n);
if total > MAX_TOTAL_ELEMENTS {
return Err("checkpoint exceeds the total element limit".into());
}
let mut data = Vec::new();
data.try_reserve_exact(n).map_err(|_| format!("{name}: tensor allocation failed"))?;
let mut idx = vec![0usize; size.len()];
for _ in 0..n {
let at = offset + idx.iter().zip(&stride).map(|(i, s)| i * s).sum::<usize>();
data.push(*values.get(at).ok_or_else(|| format!("{name}: data out of range"))?);
let at = idx
.iter()
.zip(&stride)
.try_fold(offset, |at, (i, s)| i.checked_mul(*s).and_then(|v| at.checked_add(v)))
.ok_or_else(|| format!("{name}: tensor offset overflow"))?;
data.push(elem_f32(&dtype, values, at));
for d in (0..idx.len()).rev() {
idx[d] += 1;
if idx[d] < size[d] {
@@ -490,6 +588,15 @@ mod tests {
assert!(!w.contains_key("n"), "integer tensors are skipped");
}
#[test]
fn rejects_tensor_shape_overflow_and_invalid_strides() {
assert!(tensor_element_count("bad", 0, &[usize::MAX, 2], &[1, 1], 16).is_err());
assert!(tensor_element_count("bad", 0, &[2, 2], &[usize::MAX, 1], 16).is_err());
assert!(tensor_element_count("bad", 0, &[2, 2], &[1], 16).is_err());
assert!(tensor_element_count("bad", usize::MAX, &[2], &[1], 16).is_err());
assert_eq!(tensor_element_count("empty", 0, &[0, usize::MAX], &[0, 0], 0), Ok(0));
}
#[test]
fn damaged_files_are_errors() {
assert!(read(b"not a zip").is_err());
+112 -29
View File
@@ -20,12 +20,47 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
let mut img = Image::new(w, h);
draw_group(&doc.root, m, 1.0, &mut img);
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES };
let Some(mut img) = budget.image(w, h, None) else { return Image::default() };
draw_group(&doc.root, m, 1.0, &mut img, &mut budget);
img
}
fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
/// Bound the simultaneously live offscreen image surfaces created by nested groups.
const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024;
struct SurfaceBudget {
live_bytes: usize,
limit_bytes: usize,
}
impl SurfaceBudget {
fn image(&mut self, width: u32, height: u32, copy: Option<&Image>) -> Option<Image> {
let pixels = (width as usize).checked_mul(height as usize)?;
let bytes = pixels.checked_mul(std::mem::size_of::<[f32; 4]>())?;
let new_live = self.live_bytes.checked_add(bytes)?;
if new_live > self.limit_bytes {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(pixels).ok()?;
data.resize(pixels, [0.0; 4]);
if let Some(source) = copy {
if source.width != width || source.height != height || source.data.len() != pixels {
return None;
}
data.copy_from_slice(&source.data);
}
self.live_bytes = new_live;
Some(Image { width, height, data })
}
fn release(&mut self, image: &Image) {
self.live_bytes = self.live_bytes.saturating_sub(image.data.len().saturating_mul(std::mem::size_of::<[f32; 4]>()));
}
}
fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
let m = m * g.transform;
let o = opacity * g.opacity;
if o <= 0.0 {
@@ -38,7 +73,7 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
|| g.knockout;
if !offscreen {
for c in &g.children {
draw_node(c, m, o, dst);
draw_node(c, m, o, dst, budget);
}
return;
}
@@ -46,12 +81,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
// Draw the children on their own (isolated) or over a copy of the backdrop, keep what
// every clip path covers and the soft mask lets through, then composite with the group's
// opacity and blend mode.
let backdrop = if g.isolated { None } else { Some(dst.clone()) };
let backdrop = if g.isolated { None } else { budget.image(dst.width, dst.height, Some(dst)) };
if !g.isolated && backdrop.is_none() {
return;
}
let mut tmp = match &backdrop {
Some(b) => b.clone(),
None => Image::new(dst.width, dst.height),
Some(b) => match budget.image(dst.width, dst.height, Some(b)) {
Some(image) => image,
None => {
budget.release(b);
return;
}
},
None => match budget.image(dst.width, dst.height, None) {
Some(image) => image,
None => return,
},
};
draw_children(g, m, &mut tmp, backdrop.as_ref());
draw_children(g, m, &mut tmp, backdrop.as_ref(), budget);
if let Some(b) = &backdrop {
if only_clip {
// A non-isolated clipping group: the backdrop where the clip excludes the children.
@@ -61,12 +108,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
d[i] = d[i] * (1.0 - c) + t[i] * c;
}
});
budget.release(&tmp);
drop(tmp);
if let Some(b) = &backdrop {
budget.release(b);
}
return;
}
// Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own
// alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0).
let mut solo = Image::new(dst.width, dst.height);
draw_children(g, m, &mut solo, None);
let Some(mut solo) = budget.image(dst.width, dst.height, None) else {
budget.release(&tmp);
drop(tmp);
if let Some(b) = &backdrop {
budget.release(b);
}
return;
};
draw_children(g, m, &mut solo, None, budget);
tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| {
let ag = s[3];
if ag <= 0.0 {
@@ -83,6 +142,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
out[3] = ag;
*t = out;
});
budget.release(&solo);
drop(solo);
}
if !g.clip.is_empty() {
let cov = clip_coverage(g, m, dst.width, dst.height);
@@ -93,8 +154,15 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
});
}
if let Some(sm) = &g.mask {
let mut mk = Image::new(dst.width, dst.height);
draw_group(&sm.content, m, 1.0, &mut mk);
let Some(mut mk) = budget.image(dst.width, dst.height, None) else {
budget.release(&tmp);
drop(tmp);
if let Some(b) = &backdrop {
budget.release(b);
}
return;
};
draw_group(&sm.content, m, 1.0, &mut mk, budget);
let bc = sm.backdrop.map(|v| v as f32);
let lum = sm.luminosity;
tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| {
@@ -110,6 +178,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
*v *= k;
}
});
budget.release(&mk);
drop(mk);
}
if g.blend == BlendMode::Normal {
over(dst, &tmp, o as f32);
@@ -123,6 +193,11 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
}
});
}
budget.release(&tmp);
drop(tmp);
if let Some(b) = &backdrop {
budget.release(b);
}
}
/// The product of a group's clip-path coverages.
@@ -142,19 +217,22 @@ fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec<f32> {
/// Draw a group's children into `dst`. Knockout groups composite each child with the
/// group's initial backdrop (`backdrop`, or transparent) instead of with the children below:
/// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`.
fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) {
fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>, budget: &mut SurfaceBudget) {
if !g.knockout {
for c in &g.children {
draw_node(c, m, 1.0, dst);
draw_node(c, m, 1.0, dst, budget);
}
return;
}
for c in &g.children {
let mut ci = Image::new(dst.width, dst.height);
draw_node(c, m, 1.0, &mut ci);
let Some(mut ci) = budget.image(dst.width, dst.height, None) else { return };
draw_node(c, m, 1.0, &mut ci, budget);
// Shape: the child drawn fully opaque (constant opacities to 1).
let mut si = Image::new(dst.width, dst.height);
draw_node(&opaque(c), m, 1.0, &mut si);
let Some(mut si) = budget.image(dst.width, dst.height, None) else {
budget.release(&ci);
return;
};
draw_node(&opaque(c), m, 1.0, &mut si, budget);
let zero = [0.0f32; 4];
dst.data.par_iter_mut().enumerate().for_each(|(i, d)| {
let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3]));
@@ -166,6 +244,10 @@ fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>
d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]);
}
});
budget.release(&ci);
budget.release(&si);
drop(ci);
drop(si);
}
}
@@ -299,14 +381,15 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
c.map(|v| (v - mn) * s / (mx - mn))
}
fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) {
fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
match n {
Node::Group(g) => draw_group(g, m, opacity, dst),
Node::Group(g) => draw_group(g, m, opacity, dst, budget),
Node::Shape(s) => {
if s.opacity < 1.0 && s.fill.is_some() && s.stroke.is_some() {
let mut tmp = Image::new(dst.width, dst.height);
let Some(mut tmp) = budget.image(dst.width, dst.height, None) else { return };
draw_shape(s, m, 1.0, &mut tmp);
over(dst, &tmp, (opacity * s.opacity) as f32);
budget.release(&tmp);
} else {
draw_shape(s, m, opacity * s.opacity, dst);
}
@@ -514,12 +597,12 @@ pub(crate) fn eval_stops(stops: &[(f64, [f64; 4])], t: f64) -> [f32; 4] {
if t <= first.0 {
return first.1.map(|v| v as f32);
}
for w in stops.windows(2) {
let (a, b) = (w[0], w[1]);
if t <= b.0 {
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
return [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32);
}
}
stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32))
// Find the first upper stop (not the last duplicate): equal-offset segments retain the
// reference algorithm's exact-boundary behavior, while nearby values are interpolated from
// the preceding stop as before.
let upper = stops.partition_point(|stop| stop.0 < t);
let Some(b) = stops.get(upper) else { return stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32)) };
let Some(a) = stops.get(upper.saturating_sub(1)) else { return first.1.map(|v| v as f32) };
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
[0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32)
}
+19
View File
@@ -11,6 +11,25 @@ fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() <= tol)
}
#[test]
fn gradient_stop_lookup_handles_duplicate_offsets() {
use crate::raster::eval_stops;
let stops = [(0.0, [0.0, 0.0, 0.0, 1.0]), (0.5, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])];
assert_eq!(eval_stops(&stops, 0.5), [1.0, 0.0, 0.0, 1.0]);
assert!(near(eval_stops(&stops, 0.5001), [0.0, 1.0, 0.0, 1.0], 3e-4));
assert_eq!(eval_stops(&stops, 0.75), [0.0, 0.5, 0.5, 1.0]);
}
#[test]
fn raster_surface_budget_rejects_oversized_destination_before_allocation() {
let doc = parse(br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#).unwrap();
// 16,384² RGBA-f32 pixels exceed the bounded destination-surface budget; no giant
// allocation should be attempted and the caller receives an empty image.
let img = rasterize(&doc, 16_384, 16_384, 1.0);
assert!(img.data.is_empty());
assert_eq!((img.width, img.height), (0, 0));
}
#[test]
fn basic_shapes_structure() {
let d = parse(BASIC).unwrap();
+86 -21
View File
@@ -628,7 +628,15 @@ fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
fn paragraph(
text: &str,
base: usize,
runs: &[(Range<usize>, TextStyle)],
primaries: &[Resolved],
variation_ids: &[u32],
para: &ParagraphStyle,
width: Option<f32>,
) -> Vec<ParaLine> {
let rtl_para = para.rtl == Some(true);
let margins_for = |first: bool| -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
@@ -742,18 +750,26 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
}
}
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let mut end = r.end;
let mut w = 0.0;
for (b, c) in text[r.clone()].char_indices().rev() {
if !c.is_whitespace() {
break;
}
end = r.start + b;
w += char_adv[r.start + b];
// Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1).
// Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes
// or rescanning its trailing whitespace would otherwise add another factor of text length.
let mut prefix_adv = vec![0.0f64; text.len() + 1];
for i in 0..text.len() {
prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]);
}
let mut previous_non_ws = vec![0usize; text.len() + 1];
let mut non_ws_end = 0usize;
for (b, c) in text.char_indices() {
previous_non_ws[b] = non_ws_end;
if !c.is_whitespace() {
non_ws_end = b + c.len_utf8();
}
(end, w)
previous_non_ws[b + c.len_utf8()] = non_ws_end;
}
let range_w = |r: Range<usize>| -> f32 { (prefix_adv[r.end] - prefix_adv[r.start]) as f32 };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let end = previous_non_ws[r.end].max(r.start).min(r.end);
(end, (prefix_adv[r.end] - prefix_adv[end]) as f32)
};
// Hanging punctuation widths (left, right) of a line's content.
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
@@ -867,7 +883,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
let rtl = levels[run.start].is_rtl();
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
// Items are ordered and disjoint: binary-search both ends of the overlapping slice.
let run_limit = items.partition_point(|item| item.range.start < run.end);
let run_first = items[..run_limit].partition_point(|item| item.range.end <= run.start);
let mut idx: Vec<usize> = (run_first..run_limit).collect();
if rtl {
idx.reverse();
}
@@ -888,7 +907,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: crate::variable::intern(&st.variations),
variations: variation_ids[g.run],
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
@@ -905,18 +924,25 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
}
}
}
// caret stops for each char boundary in the line
// Caret stops for each char boundary in the line.
let mut carets = Vec::new();
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
let mut char_index = 0usize;
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
let members: Vec<usize> = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect();
let n = members.len().max(1) as f32;
for (mi, b) in members.iter().enumerate() {
let f = mi as f32 / n;
while char_index < char_bytes.len() && char_bytes[char_index] < cs {
char_index += 1;
}
let begin = char_index;
while char_index < char_bytes.len() && char_bytes[char_index] < next {
char_index += 1;
}
let count = (char_index - begin).max(1) as f32;
for (mi, b) in char_bytes[begin..char_index].iter().enumerate() {
let f = mi as f32 / count;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((base + b, x));
}
@@ -943,7 +969,9 @@ fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primar
carets.dedup_by_key(|c| c.0);
// Line metrics: the tallest face and the largest leading on the line.
let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
let mut seen: Vec<usize> = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
let char_start = chars.partition_point(|c| c.0 < r.start);
let char_end = chars.partition_point(|c| c.0 < r.end);
let mut seen: Vec<usize> = chars[char_start..char_end].iter().map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
@@ -990,10 +1018,17 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
/// Maximum input size processed by one layout request. Above this, return an empty layout
/// retaining the source length, so untrusted text cannot fan out into unbounded shaped data.
const MAX_LAYOUT_BYTES: usize = 1 << 20;
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
/// one) and paragraph settings (one per CR/LF/CRLF-separated paragraph; the last repeats). The wrap
/// width is the first paragraph's.
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
return Layout { text_len: text.len(), ..Default::default() };
}
let fallback = [(0..text.len(), TextStyle::default())];
let runs = if runs.is_empty() { &fallback[..] } else { runs };
let default_para = ParagraphStyle::default();
@@ -1020,11 +1055,29 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
} else {
runs
};
// A location that cannot be interned would outline at the default; shape there too.
let cleared: Vec<(Range<usize>, TextStyle)>;
let runs = if runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0) {
cleared = runs
.iter()
.map(|(r, s)| {
let mut s = s.clone();
if crate::variable::intern(&s.variations) == 0 {
s.variations.clear();
}
(r.clone(), s)
})
.collect();
&cleared[..]
} else {
runs
};
let variation_ids: Vec<u32> = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect();
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
for (pi, span) in paragraph_ranges(text).enumerate() {
let p = text.get(span.bytes.clone()).unwrap_or_default();
plines.extend(paragraph(p, span.bytes.start, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l)));
plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l)));
}
let mut baseline = 0.0f32;
for (i, (pi, pl)) in plines.into_iter().enumerate() {
@@ -1107,6 +1160,9 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layou
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
return Arc::new(layout_rich_uncached(text, runs, paras));
}
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
runs.hash(&mut h);
@@ -1557,6 +1613,15 @@ mod tests {
}
}
#[test]
fn oversized_layout_requests_are_bounded_without_caching() {
let text = "a".repeat(MAX_LAYOUT_BYTES + 1);
let layout = layout(&text, &TextStyle::default(), &ParagraphStyle::default());
assert_eq!(layout.text_len, text.len());
assert!(layout.lines.is_empty() && layout.glyphs.is_empty());
assert!(layout_rich_uncached("ok", &[], &[]).lines.len() == 1);
}
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
+62 -19
View File
@@ -201,13 +201,21 @@ fn native_name(b: &[u8], id: u16) -> Option<String> {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
for i in 0..count {
let r = 6 + i * 12;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
let r = 6usize.checked_add(i.checked_mul(12)?)?;
let (plat, enc, lang, nid, len, off) = (
be16(b, r)?,
be16(b, r.checked_add(2)?)?,
be16(b, r.checked_add(4)?)?,
be16(b, r.checked_add(6)?)?,
be16(b, r.checked_add(8)?)? as usize,
be16(b, r.checked_add(10)?)? as usize,
);
if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 {
continue;
}
let raw = b.get(storage + off..storage + off + len)?;
let start = storage.checked_add(off)?;
let end = start.checked_add(len)?;
let raw = b.get(start..end)?;
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let s = String::from_utf16_lossy(&u);
if !s.trim().is_empty() {
@@ -221,32 +229,45 @@ fn native_name(b: &[u8], id: u16) -> Option<String> {
fn name_string(b: &[u8], id: u16) -> Option<String> {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
let mut best: Option<(u8, String)> = None;
let mut cands: Vec<(u8, u16, u16, &[u8])> = Vec::new();
for i in 0..count {
let r = 6 + i * 12;
let r = 6usize.checked_add(i.checked_mul(12)?)?;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
if nid != id {
continue;
}
let raw = b.get(storage + off..storage + off + len)?;
let (rank, s) = match plat {
let start = storage.checked_add(off)?;
let end = start.checked_add(len)?;
let raw = b.get(start..end)?;
let rank = match plat {
3 if enc == 1 || enc == 10 => {
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u))
if lang == 0x409 {
0
} else {
1
}
}
0 => {
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(2, String::from_utf16_lossy(&u))
}
1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()),
0 => 2,
1 if enc == 0 => 3,
_ => continue,
};
if best.as_ref().is_none_or(|(r0, _)| rank < *r0) {
best = Some((rank, s));
}
cands.push((rank, plat, enc, raw));
}
best.map(|(_, s)| s).filter(|s| !s.trim().is_empty())
// Best rank first; a blank record falls through to the next. Decoding is lazy, so aliased
// records are not decoded once a usable one is found.
cands.sort_by_key(|c| c.0);
cands.into_iter().find_map(|(_, plat, enc, raw)| {
let s = match (plat, enc) {
(3, 1 | 10) | (0, _) => {
let u: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
String::from_utf16_lossy(&u)
}
(1, 0) => raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect(),
_ => return None,
};
(!s.trim().is_empty()).then_some(s)
})
}
/// Synthetic font files for tests: just `name` and `fvar`, enough for the scan and the resolver.
@@ -367,6 +388,28 @@ mod tests {
/// The scan marks a face with an `fvar` table that declares axes as variable, reading only
/// the table directory and the fvar header; a static font, an fvar without axes and a
/// truncated fvar are not.
#[test]
fn name_table_offsets_are_checked_and_duplicate_records_do_not_repeat_decode() {
let valid = names_font("Name Bounds", None);
let mut invalid = valid.clone();
// name record storage offset near u16::MAX must be rejected rather than overflowing
// when combined with its length (the name table starts at byte 28 in this fixture).
invalid[28 + 6 + 10] = 0xFF;
invalid[28 + 6 + 11] = 0xFF;
assert!(read_faces_bytes(&invalid).is_empty());
// Repeated aliases for one id retain the same selected name and do not change the face.
let mut duplicated = valid;
let count = u16::from_be_bytes([duplicated[28 + 2], duplicated[28 + 3]]);
let first = duplicated[28 + 6..28 + 18].to_vec();
for i in 1..count as usize {
duplicated[28 + 6 + i * 12..28 + 18 + i * 12].copy_from_slice(&first);
}
let faces = read_faces_bytes(&duplicated);
assert_eq!(faces.len(), 1);
assert_eq!((faces[0].family.as_str(), faces[0].style.as_str()), ("Name Bounds", "Regular"));
}
#[test]
fn detects_variable_faces_from_fvar() {
let v = read_faces_bytes(&names_font("Var Test", Some(Some(1))));
+36 -8
View File
@@ -4,6 +4,19 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// Keep process-wide variation storage bounded; id 0 is the safe default location when full.
const MAX_INTERNED_LOCATIONS: usize = 8192;
const MAX_AXES_PER_LOCATION: usize = 32;
const MAX_AXIS_TAG_BYTES: usize = 16;
type CoordKey = Vec<(String, u32)>;
#[derive(Default)]
struct Table {
values: Vec<Arc<Vec<(String, f32)>>>,
by_coords: HashMap<CoordKey, u32>,
}
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::DrawSettings;
@@ -14,15 +27,27 @@ use crate::{CharGlyph, Pen};
/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them.
pub fn intern(coords: &[(String, f32)]) -> u32 {
if coords.is_empty() {
if coords.is_empty() || coords.len() > MAX_AXES_PER_LOCATION || coords.iter().any(|(tag, v)| tag.len() > MAX_AXIS_TAG_BYTES || !v.is_finite()) {
return 0;
}
// Axis order is not significant. Canonicalize it once so equivalent locations share one key.
let mut key: CoordKey = coords.iter().map(|(tag, v)| (tag.clone(), if *v == 0.0 { 0 } else { v.to_bits() })).collect();
key.sort_unstable_by(|a, b| a.0.cmp(&b.0));
if key.windows(2).any(|w| w[0].0 == w[1].0) {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
if let Some(i) = t.iter().position(|c| c.as_slice() == coords) {
return i as u32 + 1;
if let Some(id) = t.by_coords.get(&key) {
return *id;
}
t.push(Arc::new(coords.to_vec()));
t.len() as u32
if t.values.len() >= MAX_INTERNED_LOCATIONS {
return 0;
}
let Some(id) = u32::try_from(t.values.len()).ok().and_then(|n| n.checked_add(1)) else { return 0 };
let value = Arc::new(key.iter().map(|(tag, bits)| (tag.clone(), f32::from_bits(*bits))).collect());
t.values.push(value);
t.by_coords.insert(key, id);
id
}
/// The axis values of an interned id (empty for 0).
@@ -30,11 +55,11 @@ pub fn coords(id: u32) -> Arc<Vec<(String, f32)>> {
if id == 0 {
return Arc::new(Vec::new());
}
table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default()
table().lock().unwrap_or_else(|e| e.into_inner()).values.get(id as usize - 1).cloned().unwrap_or_default()
}
fn table() -> &'static Mutex<Vec<Arc<Vec<(String, f32)>>>> {
static T: OnceLock<Mutex<Vec<Arc<Vec<(String, f32)>>>>> = OnceLock::new();
fn table() -> &'static Mutex<Table> {
static T: OnceLock<Mutex<Table>> = OnceLock::new();
T.get_or_init(Default::default)
}
@@ -197,6 +222,9 @@ mod tests {
let id = intern(&[("wght".into(), 700.0)]);
assert_eq!(intern(&[("wght".into(), 700.0)]), id);
assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]);
assert_eq!(intern(&[("wdth".into(), 20.0), ("wght".into(), 700.0)]), intern(&[("wght".into(), 700.0), ("wdth".into(), 20.0)]));
assert_eq!(intern(&[("wght".into(), f32::NAN)]), 0);
assert_eq!(intern(&vec![("wght".into(), 1.0); MAX_AXES_PER_LOCATION + 1]), 0);
let Some((face, axes)) = find_variable_face() else {
eprintln!("no variable font installed: skipped");
return;
+44 -8
View File
@@ -224,13 +224,19 @@ pub struct FrameSeg {
impl FrameSeg {
pub fn empty(w: usize, h: usize) -> FrameSeg {
FrameSeg { w, h, matte: vec![0; w * h], refine: vec![0; w * h], ..Default::default() }
let Some(n) = w.checked_mul(h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else {
return FrameSeg::default();
};
FrameSeg { w, h, matte: vec![0; n], refine: vec![0; n], ..Default::default() }
}
pub fn area(&self) -> usize {
self.matte.iter().filter(|v| **v != 0).count()
}
/// Centroid of the matte (pixels), if any.
pub fn centroid(&self) -> Option<[f64; 2]> {
if self.w == 0 || self.w.checked_mul(self.h)? > self.matte.len() {
return None;
}
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
for (i, v) in self.matte.iter().enumerate() {
if *v != 0 {
@@ -246,8 +252,18 @@ impl FrameSeg {
/// Worker to the page): `ECSG`, width, height (u32 LE), the colour models as JSON (length
/// prefixed), then the matte and the Refine Edge band run-length encoded (u16 LE run, byte).
pub fn to_bytes(&self) -> Vec<u8> {
let Some(pixels) = self.w.checked_mul(self.h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else {
return Vec::new();
};
if self.matte.len() != pixels || self.refine.len() != pixels || self.w > u32::MAX as usize || self.h > u32::MAX as usize {
return Vec::new();
}
let models = serde_json::to_vec(&(&self.fg, &self.bg)).unwrap_or_default();
let mut out = Vec::with_capacity(16 + models.len() + 64);
let Some(header_len) = 16usize.checked_add(models.len()) else { return Vec::new() };
let mut out = Vec::new();
if out.try_reserve(header_len.saturating_add(64)).is_err() {
return Vec::new();
}
out.extend_from_slice(b"ECSG");
out.extend_from_slice(&(self.w as u32).to_le_bytes());
out.extend_from_slice(&(self.h as u32).to_le_bytes());
@@ -261,6 +277,9 @@ impl FrameSeg {
while i + n < plane.len() && plane[i + n] == v && n < u16::MAX as usize {
n += 1;
}
if out.try_reserve(3).is_err() {
return Vec::new();
}
out.extend_from_slice(&(n as u16).to_le_bytes());
out.push(v);
i += n;
@@ -277,23 +296,40 @@ impl FrameSeg {
let u = |i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;
let (w, h, ml) = (u(4), u(8), u(12));
let n = w.checked_mul(h)?;
let models = b.get(16..16 + ml)?;
if n > rle::MAX_PLANE_BYTES || w == 0 || h == 0 {
return None;
}
let models_end = 16usize.checked_add(ml)?;
let models = b.get(16..models_end)?;
let (fg, bg): (Gmm, Gmm) = serde_json::from_slice(models).ok()?;
let mut rest = &b[16 + ml..];
let mut rest = b.get(models_end..)?;
// Each run covers at most u16::MAX pixels; refuse before reserving a plane the input can't fill.
if rest.len() < n.div_ceil(usize::from(u16::MAX)).saturating_mul(6) {
return None;
}
let mut plane = || -> Option<Vec<u8>> {
let mut v = Vec::with_capacity(n);
let mut v = Vec::new();
v.try_reserve_exact(n).ok()?;
while v.len() < n {
if rest.len() < 3 {
return None;
}
let run = u16::from_le_bytes([rest[0], rest[1]]) as usize;
v.resize(v.len() + run, rest[2]);
rest = &rest[3..];
let run = u16::from_le_bytes([*rest.first()?, *rest.get(1)?]) as usize;
let next = v.len().checked_add(run)?;
if run == 0 || next > n {
return None;
}
let value = *rest.get(2)?;
v.resize(next, value);
rest = rest.get(3..)?;
}
(v.len() == n).then_some(v)
};
let matte = plane()?;
let refine = plane()?;
if !rest.is_empty() {
return None;
}
Some(FrameSeg { w, h, matte, refine, fg, bg })
}
}
+60 -14
View File
@@ -17,6 +17,10 @@ pub fn base64_encode(data: &[u8]) -> String {
}
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
base64_decode_bounded(s, usize::MAX)
}
fn base64_decode_bounded(s: &str, max_bytes: usize) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
Some(match c {
b'A'..=b'Z' => (c - b'A') as u32,
@@ -27,13 +31,25 @@ pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
_ => return None,
})
};
let bytes: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace()).collect();
if !bytes.len().is_multiple_of(4) {
let significant = s.bytes().filter(|c| !c.is_ascii_whitespace()).count();
let max_encoded = max_bytes.checked_add(2).and_then(|n| n.checked_div(3)).and_then(|n| n.checked_mul(4)).unwrap_or(usize::MAX);
if !significant.is_multiple_of(4) || significant > max_encoded {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let mut bytes = Vec::new();
bytes.try_reserve_exact(significant).ok()?;
bytes.extend(s.bytes().filter(|c| !c.is_ascii_whitespace()));
let decoded_len = bytes.len().checked_div(4)?.checked_mul(3)?;
if decoded_len > max_bytes.saturating_add(2) {
return None;
}
let mut out = Vec::new();
out.try_reserve_exact(decoded_len.min(max_bytes)).ok()?;
for c in bytes.chunks(4) {
let pad = c.iter().rev().take_while(|b| **b == b'=').count();
if pad > 2 || c[..4 - pad].contains(&b'=') {
return None;
}
let mut n = 0u32;
for (i, b) in c.iter().enumerate() {
n |= if *b == b'=' { 0 } else { val(*b)? } << (18 - 6 * i);
@@ -96,36 +112,66 @@ pub fn encode(values: &[u8]) -> String {
base64_encode(&out)
}
/// Decode [`encode`]'s output (None when malformed).
pub fn decode(s: &str) -> Option<Vec<u8>> {
let data = base64_decode(s)?;
let mut pos = 0;
let n = get(&data, &mut pos)? as usize;
if n > 1 << 30 {
/// Decode [`encode`]'s output (None when malformed or larger than the caller's validated plane).
pub fn decode_bounded(s: &str, max_len: usize) -> Option<Vec<u8>> {
let encoded_limit = max_len.saturating_mul(16).saturating_add(128);
if s.len() > encoded_limit {
return None;
}
let mut out = Vec::with_capacity(n);
// An alternating byte plane takes at most one value byte and a ten-byte varint per
// pixel, plus the declared-length varint.
let decoded_limit = max_len.saturating_mul(11).saturating_add(10);
let data = base64_decode_bounded(s, decoded_limit)?;
let mut pos = 0;
let n = usize::try_from(get(&data, &mut pos)?).ok()?;
if n > max_len {
return None;
}
let mut out = Vec::new();
out.try_reserve_exact(n).ok()?;
while out.len() < n {
let v = *data.get(pos)?;
pos += 1;
let run = get(&data, &mut pos)? as usize;
if out.len() + run > n {
let run = usize::try_from(get(&data, &mut pos)?).ok()?;
let next = out.len().checked_add(run)?;
if run == 0 || next > n {
return None;
}
out.resize(out.len() + run, v);
out.resize(next, v);
}
Some(out)
}
/// Decode [`encode`]'s output, capped at the standard matte-plane limit.
pub fn decode(s: &str) -> Option<Vec<u8>> {
decode_bounded(s, MAX_PLANE_BYTES)
}
/// Maximum frozen matte plane size (256 MiB at one byte per pixel).
pub const MAX_PLANE_BYTES: usize = 256 * 1024 * 1024;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuses_oversized_and_zero_runs_before_expansion() {
let mut declared = Vec::new();
put(&mut declared, 1024);
declared.extend_from_slice(&[255, 0x80, 0x08]); // 1024-byte run
assert!(decode_bounded(&base64_encode(&declared), 16).is_none());
let mut zero_run = Vec::new();
put(&mut zero_run, 1);
zero_run.extend_from_slice(&[0, 0]);
assert!(decode_bounded(&base64_encode(&zero_run), 16).is_none());
assert!(decode_bounded(&"A".repeat(100_000), 16).is_none());
}
#[test]
fn round_trips() {
for n in [0usize, 1, 2, 3, 4, 5, 100, 1000] {
let v: Vec<u8> = (0..n).map(|i| ((i / 7) % 3) as u8 * 100).collect();
assert_eq!(decode(&encode(&v)).unwrap(), v);
assert_eq!(decode(&encode(&v)).unwrap(), v, "RLE n={n}");
let raw: Vec<u8> = (0..n).map(|i| (i * 31) as u8).collect();
assert_eq!(base64_decode(&base64_encode(&raw)).unwrap(), raw);
}
+15
View File
@@ -95,6 +95,21 @@ fn strokes_segment_the_base_frame() {
assert!(bytes.len() < seg.w * seg.h / 4, "{} bytes", bytes.len());
assert!(FrameSeg::from_bytes(&bytes[..bytes.len() - 1]).is_none());
assert!(FrameSeg::from_bytes(b"nope").is_none());
// Untrusted plane dimensions and run lengths are rejected before allocation/resize.
let mut hostile = b"ECSG".to_vec();
hostile.extend_from_slice(&u32::MAX.to_le_bytes());
hostile.extend_from_slice(&u32::MAX.to_le_bytes());
hostile.extend_from_slice(&0u32.to_le_bytes());
assert!(FrameSeg::from_bytes(&hostile).is_none());
let mut overrun = bytes.clone();
let plane_at = 16 + u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
overrun[plane_at..plane_at + 2].copy_from_slice(&u16::MAX.to_le_bytes());
assert!(FrameSeg::from_bytes(&overrun).is_none());
let mut trailing = bytes.clone();
trailing.push(0);
assert!(FrameSeg::from_bytes(&trailing).is_none());
assert_eq!(FrameSeg::empty(usize::MAX, 2), FrameSeg::default());
}
#[test]
+29
View File
@@ -321,6 +321,26 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
Err(e) => return err(e),
};
let m = modifiers(p);
// Validate the complete gesture before mutating the queue; the loopback control
// channel is externally reachable and must not amplify tiny requests into huge work.
const MAX_PENDING_EVENTS: usize = 4096;
const MAX_CLICK_COUNT: u64 = 512;
let pending = app.synthetic.len();
let gesture_events = if req.method == "ui.click" {
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
if count == 0 || count > MAX_CLICK_COUNT {
return err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}"));
}
let Some(events) = usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)) else {
return err("click event count overflow");
};
events
} else {
3
};
if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
return err("synthetic input queue is full");
}
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
// and the synthetic events arrive one per frame: hold them for the whole gesture.
app.synthetic.push(egui::Event::ModifiersChanged(m));
@@ -348,6 +368,15 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
(Err(e), _) | (_, Err(e)) => return err(e),
};
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
const MAX_PENDING_EVENTS: usize = 4096;
const MAX_DRAG_STEPS: u64 = 2048;
if steps > MAX_DRAG_STEPS {
return err(format!("`steps` must be at most {MAX_DRAG_STEPS}"));
}
let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") };
if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
return err("synthetic input queue is full");
}
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(a));
@@ -37,6 +37,24 @@ fn send(app: &mut EffectcraftApp, method: &str, params: Value) {
let _ = control::handle(app, &ctx, &req);
}
#[test]
fn control_gestures_reject_excessive_counts_without_queue_mutation() {
let mut app = EffectcraftApp::new(Session::default());
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": u64::MAX}));
assert!(app.take_synthetic_input().is_empty(), "an oversized click must not partially queue input");
send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": u64::MAX}));
assert!(app.take_synthetic_input().is_empty(), "an oversized drag must not partially queue input");
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
assert_eq!(app.take_synthetic_input().len(), 2054, "two maximum accepted clicks fit under the aggregate cap");
for _ in 0..4 {
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
}
assert_eq!(app.take_synthetic_input().len(), 3081, "the pending event limit is enforced before requests can exceed it");
}
#[test]
fn shift_click_holds_shift_from_press_to_release() {
let mut app = EffectcraftApp::new(Session::default());
+10 -3
View File
@@ -5,11 +5,18 @@ import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const cli = process.argv[2];
if (!cli || !fs.existsSync(cli)) throw new Error('Usage: node examples/check-ae-ease.mjs <aesync/server/dist/cli.js>');
// The argument is the operator's own CLI build to compare against; it is resolved and must be an
// existing regular file before it is used.
const cli = path.resolve(process.argv[2] ?? ''); // NOSONAR
// Reject anything that is not an existing regular file before the checker uses it.
if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) { // NOSONAR
throw new Error('Usage: node examples/check-ae-ease.mjs <aesync/server/dist/cli.js>');
}
const tolerance = 1e-7;
function run(command, args) {
const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
// A developer-run comparison script; spawnSync never uses a shell and every command is either
// a fixed binary (`cargo`, `process.execPath`) or the CLI script path validated above.
const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); // NOSONAR
if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Process failed');
return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim());
}
+8
View File
@@ -46,6 +46,14 @@ echo "==> EffectCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
# The aarch64 release VM is emulated (no rustup toolchain for this tier-3 target, so it builds
# natively under QEMU). With one codegen unit and thin LTO the largest crates each optimize on a
# single emulated core and the job runs past its 5-hour limit, so trade a little runtime speed for
# parallel codegen there. x86_64 keeps the workspace's release profile.
if [ "$ARCH" = aarch64 ]; then
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}"
export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-off}"
fi
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli)
fi
+1 -1
View File
@@ -115,7 +115,7 @@ if has appimage; then
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=storytold_effectcraft
sonar.organization=storytold