mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge remote-tracking branch 'origin/main' into test/backfill-regression-tests
This commit is contained in:
@@ -18,7 +18,47 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -105,6 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
|
||||
|
||||
@@ -16,6 +16,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
||||
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
|
||||
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
|
||||
- **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
|
||||
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
|
||||
|
||||
Generated
+50
-36
@@ -803,6 +803,15 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1163,7 +1172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
@@ -1188,7 +1197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1200,14 +1209,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1223,7 +1232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1231,8 +1240,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1248,8 +1258,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1282,7 +1293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1312,7 +1323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1329,7 +1340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1337,7 +1348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1359,14 +1370,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1391,7 +1402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1410,7 +1421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1422,7 +1433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1440,7 +1451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-model",
|
||||
@@ -1469,7 +1480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1481,14 +1492,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1500,7 +1511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1516,7 +1527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1528,7 +1539,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1541,14 +1552,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1557,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1579,7 +1590,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1596,7 +1607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
@@ -1605,7 +1616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1617,7 +1628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1629,7 +1640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1637,7 +1648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1649,8 +1660,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
@@ -1681,11 +1693,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -6625,9 +6637,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -52,6 +52,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
# std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there).
|
||||
web-time = "1"
|
||||
@@ -137,9 +139,12 @@ type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Panics and backtraces keep file and line; full debug info only made the many test binaries large.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Pixel hot loops: keep debug builds interactive.
|
||||
[profile.dev.package.effectcraft-raster]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod cli;
|
||||
mod qa_template;
|
||||
@@ -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;
|
||||
})(),
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0,
|
||||
no `unsafe`, builds for `wasm32-unknown-unknown`).
|
||||
|
||||
Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in
|
||||
`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
|
||||
`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own
|
||||
`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
|
||||
`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own
|
||||
reconstruction, so prediction never drifts.
|
||||
|
||||
## What is implemented
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default):
|
||||
//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`.
|
||||
//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
@@ -2,7 +2,7 @@
|
||||
//! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1`
|
||||
//! decoder only drives hardware decoders, so it is not used.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod bench;
|
||||
mod common;
|
||||
mod ffmpeg_oracle;
|
||||
mod roundtrip;
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the
|
||||
//! encoder's own reconstruction bit for bit.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
use effectcraft_av1enc::{EncoderConfig, RateControl};
|
||||
@@ -21,4 +21,5 @@ rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -288,20 +288,39 @@ fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook {
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
|
||||
|
||||
/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday).
|
||||
/// `num_days_from_ce` of 2000-01-01: the day count since 2000-01-01 (Numbers' date epoch, a
|
||||
/// Saturday) rebased so that chrono's `num_days_from_ce` (0001-01-01 is day 1) can convert it.
|
||||
const CE_FROM_2000: i64 = 730_120;
|
||||
|
||||
/// Days in one 400-year Gregorian cycle; the proleptic calendar repeats after it.
|
||||
const DAYS_PER_400_YEARS: i64 = 146_097;
|
||||
|
||||
/// Proleptic Gregorian date (year, month, day, Saturday-indexed weekday) from a day count since
|
||||
/// 2000-01-01 (a Saturday), for any `i64` count.
|
||||
///
|
||||
/// chrono's `NaiveDate` stops near ±262143, but Numbers' `value` slider is a plain scalar with
|
||||
/// no range guard, so a keyframed count can run past that. The calendar repeats every 400 years,
|
||||
/// so chrono converts the day within one cycle and the whole cycles are added back as years (via
|
||||
/// i128 so `i64::MIN`/`MAX` can't overflow); the result matches the pre-chrono count for every
|
||||
/// year. The weekday comes from the same resolved date, so it can never disagree with it.
|
||||
fn date_from_days(days: i64) -> (i64, u32, u32, usize) {
|
||||
// Days since 0000-03-01 based civil calendar conversion.
|
||||
let z = days + 730_425; // 2000-01-01 relative to 0000-03-01
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d, days.rem_euclid(7) as usize)
|
||||
use chrono::Datelike;
|
||||
let from_ce = i128::from(days) + i128::from(CE_FROM_2000);
|
||||
let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS)));
|
||||
// `rest` is 0..146_097, so it is always a valid `num_days_from_ce` day inside chrono's range.
|
||||
let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
// Unreachable fallback: every `rest` in 0..146_097 converts. 2000-01-01 keeps the epoch's date.
|
||||
let (y, m, d, wd) = match date {
|
||||
Some(date) => {
|
||||
let year = i128::from(date.year()) + cycles * 400;
|
||||
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
|
||||
// DAYS is Saturday-indexed; chrono counts from Sunday.
|
||||
let wd = (date.weekday().num_days_from_sunday() as usize + 1) % 7;
|
||||
(year, date.month(), date.day(), wd)
|
||||
}
|
||||
None => (2000, 1, 1, 0),
|
||||
};
|
||||
(y, m, d, wd)
|
||||
}
|
||||
|
||||
fn timecode(frames: i64, fps: f64) -> String {
|
||||
@@ -1123,6 +1142,10 @@ mod tests {
|
||||
assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF");
|
||||
assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00");
|
||||
assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000");
|
||||
// A keyframed date count past chrono's ±262143 range still renders its real date and a
|
||||
// matching weekday (275790-09-13 is a Monday), not an epoch-derived stand-in.
|
||||
assert_eq!(txt(&[("value", num(100_000_000.0)), ("type", Value::Enum(8))], 0.0), "Monday, September 13, 275790");
|
||||
assert_eq!(txt(&[("value", num(-100_000_000.0)), ("type", Value::Enum(7))], 0.0), "Apr 20, -271791");
|
||||
assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0");
|
||||
assert_ne!(
|
||||
txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0),
|
||||
@@ -1130,6 +1153,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_from_days_past_chrono_range() {
|
||||
// chrono's NaiveDate stops near ±262143, but Numbers' `value` has no range guard, so a
|
||||
// keyframed day count can run past it. The 400-year cycle keeps the real date (and a
|
||||
// matching weekday) for every count; values from the pre-chrono formula, both directions,
|
||||
// both sides of the 2000-01-01 epoch.
|
||||
for (days, date, day) in [
|
||||
(0_i64, (2000, 1, 1), "Saturday"),
|
||||
(60, (2000, 3, 1), "Wednesday"),
|
||||
(95_015_644, (262143, 12, 31), "Tuesday"), // chrono's last representable date
|
||||
(100_000_000, (275790, 9, 13), "Monday"), // past it: used to read as the epoch
|
||||
(-96_476_616, (-262145, 12, 31), "Monday"),
|
||||
(-100_000_000, (-271791, 4, 20), "Thursday"),
|
||||
] {
|
||||
let (y, m, d, wd) = super::date_from_days(days);
|
||||
assert_eq!((y, m, d), date, "days={days}");
|
||||
assert_eq!(super::DAYS[wd], day, "days={days}");
|
||||
}
|
||||
// Extremes don't overflow (i128 intermediates).
|
||||
let _ = (super::date_from_days(i64::MAX), super::date_from_days(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_draws_glyph_at_position() {
|
||||
let img = Image::new(80, 60);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod gen_golden;
|
||||
mod particle_golden;
|
||||
mod sim_golden;
|
||||
mod timewarp_golden;
|
||||
@@ -34,6 +34,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
@@ -33,14 +33,16 @@ pub struct Stamp {
|
||||
pub dated: bool,
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as
|
||||
/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over;
|
||||
/// `None` when the year/month is out of chrono's range. `m` must be 1..=12.
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
|
||||
let year = i32::try_from(y).ok()?;
|
||||
let month = u32::try_from(m).ok()?;
|
||||
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?;
|
||||
// Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01.
|
||||
use chrono::Datelike;
|
||||
i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?)
|
||||
}
|
||||
|
||||
/// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds.
|
||||
@@ -71,7 +73,7 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
|
||||
// ISO 8601: date [T| ] time [zone].
|
||||
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
|
||||
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
|
||||
let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0;
|
||||
let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
|
||||
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
|
||||
if !rest.is_empty() {
|
||||
// Zone: Z or ±hh:mm / ±hhmm.
|
||||
|
||||
@@ -46,13 +46,24 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result<PathBuf, NotFollow
|
||||
let p = Path::new(p);
|
||||
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
});
|
||||
let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// project does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NoProjectFolder);
|
||||
}
|
||||
Ok(path)
|
||||
craft_docpath::resolve_absolute(stored, project.as_deref()).map_err(|why| match why {
|
||||
// Resolved, but still relative: it would be taken from the working directory, which the
|
||||
// project does not choose. A Windows drive or share path (`C:\x`) stays invalid outside
|
||||
// Windows even with a project.
|
||||
Refused::Invalid
|
||||
if project.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_rooted(&p)) =>
|
||||
{
|
||||
NotFollowed::NoProjectFolder
|
||||
}
|
||||
why => NotFollowed::Refused(why),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows.
|
||||
fn is_windows_rooted(p: &Path) -> bool {
|
||||
let s = p.to_string_lossy();
|
||||
let b = s.as_bytes();
|
||||
(b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//")
|
||||
}
|
||||
|
||||
/// The paths of `f` (its file and every frame of an image sequence) resolved against the document
|
||||
|
||||
@@ -149,7 +149,23 @@ fn stored_paths_resolve_against_the_project() {
|
||||
let doc = doc.to_str().unwrap();
|
||||
assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov")));
|
||||
assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
// A drive path is not a local file on Unix, with or without a project: it is not a missing project.
|
||||
#[cfg(unix)]
|
||||
for stored in [r"C:\x.mov", "C:x.mov"] {
|
||||
assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}");
|
||||
}
|
||||
assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network)));
|
||||
assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url)));
|
||||
// Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character.
|
||||
if cfg!(windows) {
|
||||
assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
|
||||
} else {
|
||||
assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov")));
|
||||
}
|
||||
// A Windows drive path names no file on other platforms.
|
||||
#[cfg(unix)]
|
||||
assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
|
||||
#[cfg(windows)]
|
||||
assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok());
|
||||
}
|
||||
|
||||
@@ -93,28 +93,34 @@ fn thread_name() -> String {
|
||||
return "main".to_string();
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set
|
||||
/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch.
|
||||
fn timestamp(ms: u64) -> String {
|
||||
let secs = ms / 1000;
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
|
||||
let (secs, millis) = (ms / 1000, (ms % 1000) as u32);
|
||||
let (y, t) = utc_parts(secs, millis * 1_000_000);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
@@ -358,7 +364,12 @@ mod tests {
|
||||
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
|
||||
let _ = timestamp(u64::MAX);
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z");
|
||||
// A clock absurdly far ahead keeps its real year (years far outside chrono's own range are
|
||||
// preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs).
|
||||
assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z");
|
||||
assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z");
|
||||
}
|
||||
|
||||
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
|
||||
|
||||
@@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option<String> {
|
||||
std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Format seconds since the epoch as an ISO 8601 UTC date-time.
|
||||
/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own
|
||||
/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
|
||||
pub fn iso_date(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let rem = secs % 86_400;
|
||||
// Civil-from-days (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
let (y, t) = utc_parts(secs);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ"))
|
||||
}
|
||||
|
||||
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
|
||||
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
|
||||
/// month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days`
|
||||
/// always fits an `i64`.
|
||||
fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// File dates and size: (created, modified, size).
|
||||
@@ -506,5 +517,10 @@ mod tests {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z");
|
||||
assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z");
|
||||
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
|
||||
// them) rather than collapsing to the epoch.
|
||||
assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z");
|
||||
assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z");
|
||||
assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`).
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/it/timeline_interop.rs`).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod key_time_post_snap_bounds;
|
||||
mod key_time_reply_contract;
|
||||
mod parenting_3d_position;
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod audio_sample_positions;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
mod opus_roundtrip;
|
||||
mod render_settings;
|
||||
mod roundtrip;
|
||||
mod uncompressed_mov;
|
||||
mod vp9_roundtrip;
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
//! Tiny native WAV to ProRes PCM MOV sample-position proof.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
//! Native MediaPool decoding and native WAV export preserve selected sample positions.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod background_presets;
|
||||
mod determinism;
|
||||
mod exr_layers;
|
||||
mod footage_reload;
|
||||
mod gif_animates;
|
||||
mod plugins;
|
||||
mod relink;
|
||||
mod tga_import;
|
||||
mod timeline_interop;
|
||||
mod vector_import;
|
||||
mod webm_metadata;
|
||||
@@ -121,8 +121,8 @@ fn psd_16bit_cmyk_composition_imports() {
|
||||
assert!(d < 2.0 / 255.0, "mean diff {d}");
|
||||
}
|
||||
|
||||
const SVG: &[u8] = include_bytes!("../../svg/tests/fixtures/basic-shapes.svg");
|
||||
const SVG2: &[u8] = include_bytes!("../../svg/tests/fixtures/paths-gradients.svg");
|
||||
const SVG: &[u8] = include_bytes!("../../../svg/tests/fixtures/basic-shapes.svg");
|
||||
const SVG2: &[u8] = include_bytes!("../../../svg/tests/fixtures/paths-gradients.svg");
|
||||
|
||||
#[test]
|
||||
fn svg_footage_and_shapes_from_vector_layer() {
|
||||
@@ -96,9 +96,11 @@ fn external_image(stored: &str, base_dir: &str) -> Result<String, String> {
|
||||
let doc = std::path::Path::new(base_dir).join("lottie.json");
|
||||
std::path::absolute(&doc).unwrap_or(doc)
|
||||
});
|
||||
match craft_docpath::resolve(stored, doc.as_deref()) {
|
||||
Ok(path) if path.is_absolute() => Ok(path.to_string_lossy().into_owned()),
|
||||
Ok(_) => Err(format!("{stored} is relative and the Lottie file's folder is not known")),
|
||||
match craft_docpath::resolve_absolute(stored, doc.as_deref()) {
|
||||
Ok(path) => Ok(path.to_string_lossy().into_owned()),
|
||||
Err(craft_docpath::Refused::Invalid) if doc.is_none() && craft_docpath::resolve(stored, None).is_ok() => {
|
||||
Err(format!("{stored} is relative and the Lottie file's folder is not known"))
|
||||
}
|
||||
Err(why) => Err(format!("{stored}: {why}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +559,7 @@ impl Inner {
|
||||
let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| {
|
||||
// Files the model names are read only when local or on the model's own network share.
|
||||
let resolve = |uri: &str| -> Option<Vec<u8>> {
|
||||
let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?;
|
||||
let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?;
|
||||
self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec())
|
||||
};
|
||||
effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string())
|
||||
|
||||
@@ -27,12 +27,21 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok());
|
||||
return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path));
|
||||
}
|
||||
probe_bytes(&path.to_string_lossy(), bytes.into())
|
||||
}
|
||||
|
||||
/// Reads the files a model file names (buffers, textures, MTL files). Like the pool, it follows only
|
||||
/// local files and files on the model's own network share: a URI is a path stored in the model.
|
||||
fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option<Vec<u8>> + use<> {
|
||||
let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf());
|
||||
move |uri| {
|
||||
let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?;
|
||||
std::fs::read(p).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its
|
||||
/// longest animation. `resolve` reads sibling resources.
|
||||
pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Footage> {
|
||||
@@ -316,6 +325,20 @@ mod tests {
|
||||
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
|
||||
}
|
||||
|
||||
/// A model's side files are read from beside it; a URI to another computer is not followed.
|
||||
#[test]
|
||||
fn model_side_files_are_not_read_from_other_computers() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-probe-side-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("buf.bin"), b"abc").unwrap();
|
||||
let read = side_file_reader(&dir.join("model.gltf"));
|
||||
assert_eq!(read("buf.bin"), Some(b"abc".to_vec()));
|
||||
assert_eq!(read(r"\\192.0.2.1\share\buf.bin"), None);
|
||||
assert_eq!(read("file://192.0.2.1/share/buf.bin"), None);
|
||||
assert_eq!(read(r"C:\buf.bin"), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millisecond_rounded_frame_times_average_to_their_rate() {
|
||||
assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]);
|
||||
|
||||
@@ -837,7 +837,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() {
|
||||
}
|
||||
// ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries
|
||||
// channels 0 and 1 at full level and the others at half, so a multichannel file sounds as
|
||||
// its stereo downmix (tests/downmix.rs checks that matrix channel by channel).
|
||||
// its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel).
|
||||
let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false);
|
||||
let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::<f64>();
|
||||
let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt();
|
||||
@@ -0,0 +1,7 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod downmix;
|
||||
mod exif_orientation;
|
||||
mod footage;
|
||||
mod open_in_place;
|
||||
mod tga;
|
||||
@@ -130,7 +130,7 @@ weights) and the decoder-side normative arithmetic the encoder must reproduce bi
|
||||
allocation; SILK NLSF dequantisation and stabilisation, NLSF → LPC conversion, `log2lin`, the
|
||||
fixed-point division/inverse helpers and the LPC analysis filter) are mirrored from FilmCraft's
|
||||
first-party, spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0), which is also the
|
||||
round-trip oracle in `tests/` and `crates/export/tests/opus_roundtrip.rs` (ffmpeg is a second,
|
||||
round-trip oracle in `tests/` and `crates/export/tests/it/opus_roundtrip.rs` (ffmpeg is a second,
|
||||
external oracle when installed). All encoder-side analysis and search (pitch, LPC, NLSF and
|
||||
LTP search, noise feedback, rate control, stereo prediction, the decimator) is our own design.
|
||||
No third-party encoder or decoder source (libopus or otherwise) was consulted.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! without errors with both ffmpeg's native `opus` decoder and `libopus`, honour the pre-skip
|
||||
//! and end granule, and reproduce the input. Skipped when ffmpeg is not installed.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod common;
|
||||
mod ffmpeg;
|
||||
mod modes;
|
||||
@@ -2,7 +2,7 @@
|
||||
//! mode/bandwidth decodes, waveform quality on speech-like and music-like signals, bitrate
|
||||
//! tracking, stereo, the application tables and delay alignment.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
use effectcraft_opusenc::{Application, Bandwidth, Mode, OpusEncoder};
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
|
||||
+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
@@ -597,7 +597,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 };
|
||||
@@ -702,18 +710,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) {
|
||||
@@ -824,7 +840,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();
|
||||
}
|
||||
@@ -845,7 +864,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 {
|
||||
@@ -862,18 +881,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));
|
||||
}
|
||||
@@ -900,7 +926,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) = (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() {
|
||||
@@ -945,10 +973,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();
|
||||
@@ -975,11 +1010,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() {
|
||||
@@ -1060,6 +1113,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);
|
||||
@@ -1415,6 +1471,15 @@ mod tests {
|
||||
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod script_fallback;
|
||||
mod system_font_menu;
|
||||
mod vertical_punctuation;
|
||||
@@ -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]
|
||||
|
||||
@@ -26,6 +26,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -67,21 +67,32 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec<RecentEnt
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds.
|
||||
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
|
||||
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
|
||||
/// month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// `i64` exact instead of collapsing out-of-range years to the epoch.
|
||||
fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds → `YYYY-MM-DD HH:MM` (UTC). Years far outside chrono's own range are preserved
|
||||
/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
|
||||
pub fn format_utc(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60)
|
||||
let (y, t) = utc_parts(secs);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%d %H:%M"))
|
||||
}
|
||||
|
||||
/// How long ago `then` was, as the Home screen's Opened column shows it ("3 hours ago", then the
|
||||
@@ -95,7 +106,8 @@ pub fn format_ago(now: i64, then: i64) -> String {
|
||||
3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"),
|
||||
86_400..172_800 => tr("Yesterday").to_string(),
|
||||
172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"),
|
||||
_ => format_utc(then).get(..10).unwrap_or_default().to_string(),
|
||||
// The date part: up to the space, not a fixed 10 bytes, so a year past 9999 stays whole.
|
||||
_ => format_utc(then).split(' ').next().unwrap_or_default().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,6 +522,12 @@ mod tests {
|
||||
assert_eq!(format_utc(0), "1970-01-01 00:00");
|
||||
assert_eq!(format_utc(1_790_000_000), "2026-09-21 14:13");
|
||||
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00");
|
||||
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
|
||||
// them), including negatives and the i64 extremes, rather than collapsing to the epoch.
|
||||
assert_eq!(format_utc(10_000_000_000_000), "318857-05-20 17:46");
|
||||
assert_eq!(format_utc(-10_000_000_000_000), "-314918-08-13 06:13");
|
||||
assert_eq!(format_utc(i64::MAX), "292277026596-12-04 15:30");
|
||||
assert_eq!(format_utc(i64::MIN), "-292277022657-01-27 08:29");
|
||||
let img = egui::ColorImage::new([200, 100], vec![Color32::from_rgb(10, 200, 30); 200 * 100]);
|
||||
let text = encode_thumb(&img);
|
||||
let back = decode_thumb(&text).unwrap();
|
||||
@@ -533,6 +551,8 @@ mod tests {
|
||||
assert_eq!(format_ago(now, now + 100), "Just now");
|
||||
assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
|
||||
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
|
||||
// After a week it is the date, whole even for a five-digit year.
|
||||
assert_eq!(format_ago(i64::MAX, 253_402_300_800), "10000-01-01");
|
||||
}
|
||||
|
||||
/// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind
|
||||
|
||||
@@ -35,21 +35,33 @@ pub fn hms(secs: f64) -> String {
|
||||
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
|
||||
}
|
||||
|
||||
/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC).
|
||||
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
|
||||
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
|
||||
/// month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// input exact instead of collapsing out-of-range years to the epoch. `unix` is `u64`, so `days`
|
||||
/// always fits an `i64`.
|
||||
fn utc_parts(unix: u64) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((unix / 86_400) as i64, (unix % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). Years far outside chrono's own range are preserved
|
||||
/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
|
||||
pub fn utc_stamp(unix: u64) -> String {
|
||||
let days = (unix / 86_400) as i64;
|
||||
let rem = unix % 86_400;
|
||||
// Civil-from-days (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60)
|
||||
let (y, t) = utc_parts(unix);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%d %H:%M:%S"))
|
||||
}
|
||||
|
||||
fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
|
||||
@@ -807,6 +819,10 @@ mod tests {
|
||||
fn stamps() {
|
||||
assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00");
|
||||
assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20");
|
||||
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
|
||||
// them) rather than collapsing to the epoch.
|
||||
assert_eq!(super::utc_stamp(10_000_000_000_000), "318857-05-20 17:46:40");
|
||||
assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15");
|
||||
assert_eq!(super::hms(3725.4), "1:02:05");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod render_queue_snapshot;
|
||||
mod shared_docking_ui;
|
||||
mod ui_3d;
|
||||
mod ui_agent_audit;
|
||||
mod ui_angles;
|
||||
mod ui_audio_scrub;
|
||||
mod ui_azerty_shortcuts;
|
||||
mod ui_blank_context_menus;
|
||||
mod ui_camera_tracker;
|
||||
mod ui_character_eyedropper;
|
||||
mod ui_color_hex;
|
||||
mod ui_comp_presets;
|
||||
mod ui_control_modifiers;
|
||||
mod ui_dock_resize;
|
||||
mod ui_drag_drop;
|
||||
mod ui_effect_controls;
|
||||
mod ui_effect_drop_highlight;
|
||||
mod ui_effect_names;
|
||||
mod ui_font_search;
|
||||
mod ui_form_fit;
|
||||
mod ui_frame_step;
|
||||
mod ui_fx_editors;
|
||||
mod ui_gpu;
|
||||
mod ui_gradient_editor;
|
||||
mod ui_gradient_handles;
|
||||
mod ui_gradient_value;
|
||||
mod ui_graph_fit;
|
||||
mod ui_group_rename;
|
||||
mod ui_guide_layers;
|
||||
mod ui_inline_edit;
|
||||
mod ui_keyframes;
|
||||
mod ui_large_project;
|
||||
mod ui_layer_panel_zoom;
|
||||
mod ui_live_preview;
|
||||
mod ui_locked_color;
|
||||
mod ui_media_browser_cache;
|
||||
mod ui_media_browser_copy_path;
|
||||
mod ui_menus;
|
||||
mod ui_object_matte;
|
||||
mod ui_panel_focus;
|
||||
mod ui_panel_windows;
|
||||
mod ui_panels;
|
||||
mod ui_parameter_spacing;
|
||||
mod ui_presets_folder;
|
||||
mod ui_preview_align;
|
||||
mod ui_project_delete;
|
||||
mod ui_project_keyboard;
|
||||
mod ui_project_new_folder;
|
||||
mod ui_project_rows;
|
||||
mod ui_quick_offset;
|
||||
mod ui_ram_preview;
|
||||
mod ui_render_queue;
|
||||
mod ui_script_console;
|
||||
mod ui_scriptui;
|
||||
mod ui_scrolling;
|
||||
mod ui_settings_info;
|
||||
mod ui_tabs;
|
||||
mod ui_tail;
|
||||
mod ui_text_edit;
|
||||
mod ui_text_range_handles;
|
||||
mod ui_theme_favorites;
|
||||
mod ui_time_zoom_controls;
|
||||
mod ui_timeline_effect_label;
|
||||
mod ui_timeline_shapes;
|
||||
mod ui_timeline_switches;
|
||||
mod ui_timeline_trim;
|
||||
mod ui_timeline_zoom;
|
||||
mod ui_toolbar_gradient;
|
||||
mod ui_tracker;
|
||||
mod ui_unreadable_footage;
|
||||
mod ui_video_preview_off;
|
||||
mod ui_viewer;
|
||||
mod ui_viewers;
|
||||
mod ui_visual_review;
|
||||
mod ui_web_gpu_storage;
|
||||
mod ui_web_job_gpu;
|
||||
mod ui_web_offload;
|
||||
mod ui_workflow_selection;
|
||||
mod ui_workspace_focus;
|
||||
mod viewer_geometry;
|
||||
mod viewer_passive_display;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU
|
||||
//! adapter); run with
|
||||
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`.
|
||||
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it render_queue_snapshot:: -- --ignored`.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::render_queue::RenderStatus;
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Headless UI checks for the 3D viewer (egui_kittest). The `snapshot` test renders the window
|
||||
//! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui
|
||||
//! --test ui_3d -- --ignored`); the other test only runs the UI logic.
|
||||
//! --test it ui_3d:: -- --ignored`); the other test only runs the UI logic.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::render::three_d::View3D;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user