mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
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:
@@ -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]"
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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))));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
sonar.projectKey=storytold_effectcraft
|
||||
sonar.organization=storytold
|
||||
Reference in New Issue
Block a user