Merge upstream/main into docs-roadmap-g14; re-measure G14 on merged tree

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-20 01:05:48 +02:00
128 changed files with 7479 additions and 374 deletions
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+6 -29
View File
@@ -27,32 +27,9 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + package)
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# The same packages as release.yml's freebsd job: winit/wgpu (X11, Wayland, Vulkan,
# Mesa), the file dialogs (GTK 3), fonts, and audio (cpal over ALSA: alsa-lib).
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "*"
rustc --version
export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
# 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/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
skip-tests: true
run-package-check: true
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
Generated
+60 -54
View File
@@ -1011,6 +1011,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1674,7 +1679,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"arboard",
"chrono",
@@ -1707,7 +1712,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1717,7 +1722,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1725,7 +1730,7 @@ dependencies = [
[[package]]
name = "filmcraft-apv"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1735,14 +1740,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1758,7 +1763,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1769,21 +1774,21 @@ dependencies = [
[[package]]
name = "filmcraft-avi"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-bitstream"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1796,11 +1801,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.7.2"
version = "0.9.0"
[[package]]
name = "filmcraft-cli"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1822,7 +1827,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1860,7 +1865,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -1868,7 +1873,7 @@ dependencies = [
[[package]]
name = "filmcraft-dng"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-dng",
"rayon",
@@ -1877,7 +1882,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1887,7 +1892,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
@@ -1900,8 +1905,9 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"craft-docpath",
"filmcraft-audio-dsp",
"filmcraft-captions",
"filmcraft-codecs",
@@ -1934,7 +1940,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
@@ -1965,7 +1971,7 @@ dependencies = [
[[package]]
name = "filmcraft-flac"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1974,7 +1980,7 @@ dependencies = [
[[package]]
name = "filmcraft-format"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1987,7 +1993,7 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1997,7 +2003,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -2005,7 +2011,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2022,7 +2028,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"filmcraft-export",
@@ -2041,7 +2047,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2051,7 +2057,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2061,7 +2067,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2071,7 +2077,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2088,7 +2094,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2099,7 +2105,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
@@ -2107,7 +2113,7 @@ dependencies = [
[[package]]
name = "filmcraft-media"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"filmcraft-dng",
@@ -2123,7 +2129,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpa"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2132,7 +2138,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2141,7 +2147,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2149,28 +2155,28 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs",
@@ -2196,7 +2202,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2210,7 +2216,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2220,7 +2226,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2240,7 +2246,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"serde",
@@ -2249,7 +2255,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2267,14 +2273,14 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-color",
"harfrust",
@@ -2286,7 +2292,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -2294,7 +2300,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2308,7 +2314,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts-text"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"serde",
"thiserror 2.0.21",
@@ -2316,7 +2322,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"cpu-time",
"craft-ui",
@@ -2355,14 +2361,14 @@ dependencies = [
[[package]]
name = "filmcraft-vp8"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-vp9"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2371,7 +2377,7 @@ dependencies = [
[[package]]
name = "filmcraft-web"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -7396,7 +7402,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.2"
version = "0.9.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.2"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -70,6 +70,7 @@ log = "0.4"
rayon = "1"
proptest = "1"
roxmltree = "0.21"
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
+3 -2
View File
@@ -5,7 +5,7 @@
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (language table: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
@@ -87,7 +87,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Italian | partial (whole interface; native review welcome) | ~100% |
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | menus only (native-reviewed) | 10.6% |
| German | partial (whole interface; menus native-reviewed, the rest welcome) | ~100% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
@@ -166,6 +166,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) |
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
+17
View File
@@ -2,6 +2,9 @@
use serde_json::{Map, Value};
// Keep nested CLI params safely within the depth also accepted by JSON serialization.
const MAX_PARAM_NESTING: usize = 64;
/// Options that take a value (`--project p`); every other `--x` is a flag.
const VALUED: &[&str] = &[
"--project",
@@ -84,6 +87,9 @@ impl Args {
let mut m = Map::new();
for kv in rest {
let (k, v) = kv.split_once('=').ok_or_else(|| format!("expected key=value, got `{kv}`"))?;
if k.bytes().filter(|&b| b == b'.').count() >= MAX_PARAM_NESTING {
return Err(format!("parameter key exceeds {MAX_PARAM_NESTING} nesting levels"));
}
insert_dotted(&mut m, k, parse_value(v));
}
Ok(Value::Object(m))
@@ -141,6 +147,17 @@ mod tests {
assert_eq!(args("exec x").params_from(2).unwrap(), json!({}));
}
#[test]
fn dotted_key_depth_is_bounded() {
let allowed = format!("{}leaf=1", "a.".repeat(MAX_PARAM_NESTING - 1));
let a = Args::parse(["exec".into(), "x".into(), allowed]);
assert!(a.params_from(2).is_ok());
let too_deep = format!("{}leaf=1", "a.".repeat(MAX_PARAM_NESTING));
let b = Args::parse(["exec".into(), "x".into(), too_deep]);
assert!(b.params_from(2).unwrap_err().contains("nesting levels"));
}
#[test]
fn json_params() {
let a = Args::parse(["exec".into(), "x".into(), r#"{"a": {"b": [1]}}"#.into()]);
+12 -2
View File
@@ -465,8 +465,8 @@ async fn cli() {
usage("render is headless; with --bridge use the MCP `render_frame` tool or `exec` ui commands");
}
let Backend::Local(mut s) = Backend::open(&a) else { usage("render is headless") };
let secs: f64 = a.opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(0.0);
let scale: f32 = a.opt("--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
let secs: f64 = finite_opt(&a, "--seconds").unwrap_or(0.0);
let scale: f32 = finite_opt(&a, "--scale").unwrap_or(1.0);
let out = a.opt("--out").unwrap_or("frame.png");
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
let t0 = std::time::Instant::now();
@@ -551,6 +551,16 @@ fn check_offline(a: &Args, offline: &[Value], what: &str) {
}
}
/// A numeric option, or a usage error when it is not a finite number (so a typo never silently
/// falls back to a default).
fn finite_opt<T: std::str::FromStr + Copy + Into<f64>>(a: &Args, name: &str) -> Option<T> {
let v = a.opt(name)?;
match v.parse::<T>() {
Ok(n) if n.into().is_finite() => Some(n),
_ => usage(format!("{name}: expected a finite number, got `{v}`")),
}
}
/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be
/// rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec<filmcraft_engine::media_pool::OfflineMedia>), String> {
+21
View File
@@ -295,6 +295,27 @@ fn closed_stdout_still_runs_the_script_and_saves() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `render` with a non-numeric or non-finite `--seconds` / `--scale` is a usage error (exit 2) and
/// writes nothing, instead of silently rendering frame 0 at full scale.
#[test]
fn render_rejects_bad_numbers() {
let dir = std::env::temp_dir().join(format!("fc-cli-render-nan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let still = dir.join("s.png").to_str().unwrap().to_string();
for (opt, bad) in
[("--seconds", "1s"), ("--seconds", "1,5"), ("--seconds", "abc"), ("--seconds", "NaN"), ("--seconds", "inf"), ("--scale", "xyz"), ("--scale", "NaN")]
{
let (secs, scale) = if opt == "--seconds" { (bad, "0.1") } else { ("1", bad) };
let o = cli(&["--demo", "render", "--seconds", secs, "--scale", scale, "--out", &still]);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{opt} {bad}: {err}");
assert!(err.contains("filmcraft-cli:") && err.contains(opt), "{opt} {bad}: {err}");
assert!(!std::path::Path::new(&still).exists(), "{opt} {bad} wrote a file");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the
/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the
/// placeholder frames; then it still warns.
+83 -1
View File
@@ -278,9 +278,22 @@ impl FilmcraftMcp {
}
/// The project tree plus the active sequence (null when there is none).
///
/// `project.inspect` and `sequence.inspect` are two separate calls (two lock scopes in
/// headless mode, two round trips to the app in bridge mode), so the active sequence can
/// change between them if another client, or the app's own UI, switches sequence tabs in
/// that window (#1477). Pinning `sequence.inspect` to the `activeSequence` id this
/// `project.inspect` just reported keeps the pair consistent: the returned `sequence`, when
/// not null, always describes the sequence the returned `project.activeSequence` names.
async fn document(&self) -> Result<Value, AutomationError> {
let project = self.run("project.inspect", json!({})).await?;
let sequence = self.run("sequence.inspect", json!({})).await.ok();
// `ItemId` is a u64 (`item_p` parses the `item` param with `u64_p`): read it as one too,
// not `as_i64`, so an id above `i64::MAX` is still looked up instead of silently going null.
let active = project.get("activeSequence").and_then(Value::as_u64);
let sequence = match active {
Some(id) => self.run("sequence.inspect", json!({"item": id})).await.ok(),
None => None,
};
Ok(json!({"project": project, "sequence": sequence}))
}
@@ -998,4 +1011,73 @@ mod tests {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
/// `doc_inspect` composes `project.inspect` and `sequence.inspect` as two separate round
/// trips to the app. If the active sequence changes between them (another client, or the
/// app's own UI, switching tabs in that window), an unscoped `sequence.inspect` answers for
/// whichever sequence is active *now*, not the one `project.inspect` just reported, so the
/// pair comes back inconsistent (#1477, reproduced with a real bridge client and a flipping
/// `sequence.open` in `race_test.py`: ~300/300 calls mismatched). Pinning the second call to
/// the id the first one reported keeps `sequence`, when not null, always describing the
/// sequence `project.activeSequence` names.
#[tokio::test]
async fn doc_inspect_pins_the_sequence_to_the_reported_active_id() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = tokio::io::BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let result = match req["params"]["command"].as_str().unwrap_or_default() {
"project.inspect" => json!({"activeSequence": 21}),
// The app's own active sequence has since moved on to 54; only an explicit
// `item` still gets 21's data (an unscoped call would wrongly answer for 54).
"sequence.inspect" => json!({"id": req["params"]["params"]["item"].as_i64().unwrap_or(54)}),
_ => Value::Null,
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
let m = FilmcraftMcp::bridge(&addr).unwrap();
let v = m.document().await.unwrap();
assert_eq!(v["project"]["activeSequence"], json!(21), "{v}");
assert_eq!(v["sequence"]["id"], json!(21), "sequence must describe the reported active id, not whatever is active now: {v}");
}
/// `activeSequence` is a `u64` (`ItemId`'s representation; `item_p` parses the `item` param
/// with `u64_p`), so an id above `i64::MAX` must still round-trip through `document()`:
/// reading it with `as_i64` would silently return `None` and make `sequence` go null instead
/// of inspecting the (valid) id project.inspect reported.
#[tokio::test]
async fn doc_inspect_handles_an_active_sequence_id_above_i64_max() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
const HUGE: u64 = i64::MAX as u64 + 1;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = tokio::io::BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let result = match req["params"]["command"].as_str().unwrap_or_default() {
"project.inspect" => json!({"activeSequence": HUGE}),
// Echoes back whatever `item` it was actually asked for, so the test can
// check that `document()` asked for `HUGE` rather than nothing at all.
"sequence.inspect" => json!({"id": req["params"]["params"]["item"]}),
_ => Value::Null,
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
let m = FilmcraftMcp::bridge(&addr).unwrap();
let v = m.document().await.unwrap();
assert_eq!(v["project"]["activeSequence"].as_u64(), Some(HUGE), "{v}");
assert_eq!(v["sequence"]["id"].as_u64(), Some(HUGE), "an id above i64::MAX must still be inspected, not dropped to a null sequence: {v}");
}
}
+8
View File
@@ -23,6 +23,14 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
frame grid (the engine does this on import) and removes overlaps.
### TTML tick timing
When `ttp:tickRate` is omitted and `ttp:frameRate` is present, tick offsets use the effective
frame rate (`frameRate` times `frameRateMultiplier`) times `subFrameRate`. The rate stays
rational, so a 30-tick offset at 30 times 1000/1001 fps is exactly 1.001 seconds. An explicit
`tickRate` takes precedence; without `frameRate`, the default is one tick per second.
This follows [W3C TTML1, third edition (2018), §6.2.10](https://www.w3.org/TR/2018/REC-ttml1-20181108/#parameter-attribute-tickRate).
### TTML speaker metadata
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
+23
View File
@@ -286,6 +286,29 @@ mod tests {
assert!(o.x < 120, "{}", o.x);
}
#[test]
fn margin_is_a_fraction_of_frame_height() {
// #1542: on a portrait sequence, a bottom-anchored caption with margin 0.31 sits with
// its bottom edge 0.31 of the frame height above the bottom; the top anchor mirrors it.
let st = CaptionStyle { anchor: CaptionAnchor::Bottom, margin: 0.31, size: 78.0, ..Default::default() };
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
// the overlay pads the content by the outline grow (the default outline is 0, so by 1 px)
let bottom = (o.y as usize + o.h) as f32;
let expected = 1920.0 - 0.31 * 1920.0;
assert!((bottom - expected).abs() <= 2.0, "bottom edge {bottom}, expected {expected}");
let st = CaptionStyle { anchor: CaptionAnchor::Top, margin: 0.31, size: 78.0, ..Default::default() };
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
assert!((o.y as f32 - 0.31 * 1920.0).abs() <= 2.0, "top edge {}, expected {}", o.y, 0.31 * 1920.0);
// margin 0 and the 0.45 clamp stay inside the frame
for margin in [0.0, 0.31, 0.45, 9.0] {
let st = CaptionStyle { anchor: CaptionAnchor::Bottom, margin, size: 78.0, ..Default::default() };
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
// the outline-grow padding plus ceil rounding may reach 2 px past the edge; the
// composite clips it
assert!(o.y >= 0 && o.y as usize + o.h <= 1922, "margin {margin}: y {} h {}", o.y, o.h);
}
}
#[test]
fn wraps_and_composites() {
let long = "word ".repeat(60);
+38 -10
View File
@@ -209,7 +209,8 @@ struct Timing {
/// Nominal frames per second for `HH:MM:SS:FF`.
base: i64,
sub_frame_rate: i64,
tick_rate: i64,
/// Ticks per second, rational when derived from the effective frame rate.
tick_rate: FrameRate,
}
fn positive_integer(s: &str) -> Option<i64> {
@@ -296,9 +297,10 @@ fn timing(root: roxmltree::Node) -> Result<Timing> {
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
let sub = integer("subFrameRate", 1)?;
let tick_rate = match attr(root, "tickRate") {
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
None => 1,
Some(v) => FrameRate::new(positive_integer(v).ok_or_else(|| invalid("tickRate"))?, 1),
// TTML1 §6.2.10: absent tickRate uses the effective frame rate times subFrameRate.
None if attr(root, "frameRate").is_some() => FrameRate::new(rate.num.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?, rate.den),
None => FrameRate::new(1, 1),
};
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
}
@@ -306,13 +308,13 @@ fn timing(root: roxmltree::Node) -> Result<Timing> {
/// Seconds as an exact decimal string → ticks.
fn decimal_ticks(int: &str, frac: &str, unit_ticks: i128) -> Option<i128> {
let i: i128 = if int.is_empty() { 0 } else { int.parse().ok()? };
let mut t = i * unit_ticks;
let mut t = i.checked_mul(unit_ticks)?;
if !frac.is_empty() {
if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let f: i128 = frac.parse().ok()?;
t += f * unit_ticks / 10i128.pow(frac.len() as u32);
t = t.checked_add(f.checked_mul(unit_ticks)? / 10i128.pow(frac.len() as u32))?;
}
Some(t)
}
@@ -372,7 +374,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
}
whole.0 as i128 + decimal_ticks("0", vf, fd)?
}
"t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128,
"t" => decimal_ticks(vi, vf, tps * i128::from(tm.tick_rate.den))? / i128::from(tm.tick_rate.num),
_ => return None,
};
i64::try_from(t).ok().map(Tick)
@@ -625,7 +627,7 @@ mod tests {
use super::*;
fn tm(rate: FrameRate) -> Timing {
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: FrameRate::new(1, 1) }
}
#[test]
@@ -651,6 +653,8 @@ mod tests {
fn missing_timing_parameters_keep_defaults() {
for (params, begin, end) in [
("", "1s", "2s"),
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50t", "100t"),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:subFrameRate="1001""#, "30000t", "60000t"),
(r#"ttp:frameRate="25""#, "25f", "50f"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
@@ -666,6 +670,30 @@ mod tests {
}
}
#[test]
fn default_tick_rate_preserves_fractional_frame_rate() {
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
for (params, begin, end, start, finish) in [
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001""#, "30t", "60t", 1001000, 2002000),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001""#, "1.5t", "3t", 50050, 100100),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:subFrameRate="2""#, "30t", "60t", 500500, 1001000),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:tickRate="10""#, "10t", "20t", 1000000, 2000000),
(r#"ttp:frameRateMultiplier="2 1" ttp:subFrameRate="2""#, "1t", "2t", 1000000, 2000000),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
);
let doc = crate::parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1, "{params}");
assert_eq!(doc.cues[0].start, Tick(start * (TICKS_PER_SECOND / 1_000_000)), "{params}");
assert_eq!(doc.cues[0].end, Tick(finish * (TICKS_PER_SECOND / 1_000_000)), "{params}");
}
}
let timing = Timing { tick_rate: FrameRate::new(1, i64::MAX), ..tm(FrameRate::FPS_25) };
assert_eq!(parse_time("999999999999999t", timing), None);
assert_eq!(parse_time("0.999999999999t", timing), None);
}
#[test]
fn time_expressions() {
let t = tm(FrameRate::FPS_25);
@@ -679,7 +707,7 @@ mod tests {
assert_eq!(parse_time("50f", t), Some(s(2.0)));
let ntsc = tm(FrameRate::FPS_29_97);
assert_eq!(parse_time("30f", ntsc), Some(FrameRate::FPS_29_97.tick_of(30)));
let ticks = Timing { tick_rate: 10_000_000, ..t };
let ticks = Timing { tick_rate: FrameRate::new(10_000_000, 1), ..t };
assert_eq!(parse_time("15000000t", ticks), Some(s(1.5)));
assert_eq!(parse_time("bogus", t), None);
assert_eq!(parse_time("1:2", t), None);
@@ -723,7 +751,7 @@ mod tests {
assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4));
assert!(doc.warnings.is_empty());
}
let timing = Timing { tick_rate: 3, ..tm(FrameRate::FPS_25) };
let timing = Timing { tick_rate: FrameRate::new(3, 1), ..tm(FrameRate::FPS_25) };
assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6)));
for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] {
assert_eq!(parse_time(expr, timing), None, "{expr}");
+3
View File
@@ -50,3 +50,6 @@ chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB]
bytes) and multi-sector streams and up to 40 siblings; red-black invariants of every sibling tree;
a 7.5 MB stream that needs DIFAT sectors; Unicode and case-insensitive names; truncation at every
29 bytes and 1500 random mutations never panic and only return complete streams.
The writer rejects a duplicate sibling name with an O(1) hash lookup on the upper-cased name (a scan over the
siblings made n children cost O(n^2): 20 000 AAF markers took a minute); `adding_many_children_is_not_quadratic`
and `duplicate_names_are_still_rejected` (`tests/writer_scale.rs`) cover it.
+28 -4
View File
@@ -5,6 +5,8 @@
//! when the FAT needs more than the 109 header slots. Every chain is contiguous. Sibling trees are
//! balanced binary search trees coloured as valid red-black trees (§2.6.4).
use std::collections::HashSet;
use crate::{DIFSECT, ENDOFCHAIN, FATSECT, FREESECT, MINI_SECTOR_SIZE, MINI_STREAM_CUTOFF, NOSTREAM, Result, SIGNATURE, compare_names, validate_name};
/// File format version.
@@ -44,6 +46,15 @@ struct Node {
clsid: [u8; 16],
data: Vec<u8>,
children: Vec<usize>,
/// The children's names, upper-cased as sibling order compares them: an O(1) duplicate check.
/// (Scanning every sibling with `compare_names`, which allocates, made adding n children O(n^2):
/// 20 000 AAF markers took a minute.)
child_keys: HashSet<Vec<u16>>,
}
/// A name as sibling comparison sees it: its UTF-16 code units, upper-cased.
fn name_key(name: &str) -> Vec<u16> {
name.encode_utf16().map(crate::upper).collect()
}
/// Builds a compound file in memory.
@@ -64,7 +75,17 @@ impl Writer {
pub const ROOT: NodeId = NodeId(0);
pub fn new(version: Version) -> Self {
Writer { version, nodes: vec![Node { name: "Root Entry".into(), kind: Kind::Root, clsid: [0; 16], data: Vec::new(), children: Vec::new() }] }
Writer {
version,
nodes: vec![Node {
name: "Root Entry".into(),
kind: Kind::Root,
clsid: [0; 16],
data: Vec::new(),
children: Vec::new(),
child_keys: HashSet::new(),
}],
}
}
fn add(&mut self, parent: NodeId, name: &str, kind: Kind, clsid: [u8; 16], data: Vec<u8>) -> Result<NodeId> {
@@ -73,12 +94,15 @@ impl Writer {
if p.kind == Kind::Stream {
return Err(crate::Error::Invalid(format!("{:?} is a stream, not a storage", p.name)));
}
if p.children.iter().any(|&c| compare_names(&self.nodes[c].name, name).is_eq()) {
let key = name_key(name);
if p.child_keys.contains(&key) {
return Err(crate::Error::BadName(format!("duplicate name {name}")));
}
let id = self.nodes.len();
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new() });
self.nodes[parent.0].children.push(id);
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new(), child_keys: HashSet::new() });
let p = &mut self.nodes[parent.0];
p.children.push(id);
p.child_keys.insert(key);
Ok(NodeId(id))
}
+33
View File
@@ -0,0 +1,33 @@
//! The writer scales: adding many children to one storage, and the duplicate-name rules that check keeps.
use filmcraft_cfb::{Version, Writer};
/// Adding a child compared its name with every sibling (allocating twice per comparison), so n children
/// cost O(n^2): 20 000 AAF markers in one storage took about a minute. The duplicate check is a hash
/// lookup now. 40 000 plain names take milliseconds now and about 18 s before (a minute for real marker
/// objects), so the limit separates them by orders of magnitude on any machine.
#[test]
fn adding_many_children_is_not_quadratic() {
let mut w = Writer::new(Version::V3);
let start = std::time::Instant::now();
for i in 0..40_000 {
w.stream(Writer::ROOT, &format!("{i}"), vec![1]).expect("unique name");
}
assert!(start.elapsed() < std::time::Duration::from_secs(3), "{:?}", start.elapsed());
}
/// The faster duplicate check still means what sibling comparison means: case-insensitive, also beyond ASCII,
/// per storage (the same name in another storage is fine), and a name that differs only in length is distinct.
#[test]
fn duplicate_names_are_still_rejected() {
let mut w = Writer::new(Version::V3);
let a = w.storage(Writer::ROOT, "Alpha", [0; 16]).unwrap();
assert!(w.stream(Writer::ROOT, "ALPHA", vec![]).is_err(), "case-insensitive");
assert!(w.stream(Writer::ROOT, "alpha", vec![]).is_err());
assert!(w.stream(Writer::ROOT, "Alph", vec![]).is_ok());
assert!(w.stream(Writer::ROOT, "Alpha2", vec![]).is_ok());
assert!(w.stream(a, "Alpha", vec![]).is_ok(), "same name in another storage");
assert!(w.stream(a, "Alpha", vec![]).is_err());
w.stream(Writer::ROOT, "Ünï", vec![]).unwrap();
assert!(w.stream(Writer::ROOT, "ÜNÏ", vec![]).is_err(), "non-ASCII case");
}
+102 -6
View File
@@ -620,10 +620,16 @@ fn yuv_label(chroma_format_idc: u32, bits: u32) -> String {
format!("YUV {sub} {bits}-bit")
}
/// `chroma_format_idc` and luma bit depth of an H.264 track: from the `avcC` High-profile extension
/// (ISO/IEC 14496-15 §5.3.3), else from its first SPS. The profile alone can't say (High 4:2:2 is
/// 8 or 10-bit, High 10 can be 8-bit).
/// `chroma_format_idc` and luma bit depth of an H.264 track: from its first SPS, which is what the
/// decoder decodes, else from the `avcC` High-profile extension (ISO/IEC 14496-15 §5.3.3). The
/// extension is not trusted over the SPS: muxers have been seen writing `chroma_format` 0 (4:0:0)
/// into it for 4:2:0 files, which labelled them "4:0:0" (#1959). The profile alone can't say (High
/// 4:2:2 is 8 or 10-bit, High 10 can be 8-bit).
fn avc_format(a: &filmcraft_isobmff::AvcConfig) -> Option<(u32, u32)> {
let from_sps = a.sps.first().and_then(|n| n.get(1..)).and_then(|rbsp| filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(rbsp)).ok());
if let Some(sps) = from_sps {
return Some((sps.chroma_format_idc, sps.bit_depth_luma));
}
// reserved bits set: '111111' chroma_format, '11111' bit_depth_luma_minus8 (else not an extension)
if !matches!(a.profile, 66 | 77 | 88)
&& let &[chroma, depth, ..] = a.ext.as_slice()
@@ -632,14 +638,26 @@ fn avc_format(a: &filmcraft_isobmff::AvcConfig) -> Option<(u32, u32)> {
{
return Some(((chroma & 3) as u32, (depth & 7) as u32 + 8));
}
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(a.sps.first()?.get(1..)?)).ok()?;
Some((sps.chroma_format_idc, sps.bit_depth_luma))
None
}
/// `chroma_format_idc` and luma bit depth of an HEVC track: from its first SPS, which is what the
/// decoder decodes, else the `hvcC` record's own fields (which a muxer may leave at 0, i.e. "4:0:0").
fn hevc_format(h: &filmcraft_isobmff::HevcConfig) -> (u32, u32) {
let from_sps = h.sps().first().and_then(|n| n.get(2..)).and_then(|rbsp| filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(rbsp)).ok());
match from_sps {
Some(sps) => (sps.chroma_format_idc, sps.bit_depth_luma),
None => (h.chroma_format_idc as u32, h.bit_depth_luma as u32),
}
}
fn pixfmt_label(c: &CodecConfig) -> String {
match c {
CodecConfig::Avc(a) => avc_format(a).map(|(chroma, bits)| yuv_label(chroma, bits)).unwrap_or_default(),
CodecConfig::Hevc(h) => yuv_label(h.chroma_format_idc as u32, h.bit_depth_luma as u32),
CodecConfig::Hevc(h) => {
let (chroma, bits) = hevc_format(h);
yuv_label(chroma, bits)
}
CodecConfig::Vp9(c) => {
let sub = match c.chroma_subsampling {
2 => "4:2:2",
@@ -1061,6 +1079,84 @@ mod tests {
assert_eq!(pixfmt_label(&avc(244, high_sps(244, 3, 10), Vec::new())), "YUV 4:4:4 10-bit");
// an unreadable SPS and no extension: unknown, not a guess
assert_eq!(pixfmt_label(&avc(122, vec![0x67, 122], Vec::new())), "");
// the SPS outranks the extension: an avcC claiming 4:0:0 for a 4:2:0 stream labels 4:2:0 (#1959)
assert_eq!(pixfmt_label(&avc(100, high_sps(100, 1, 8), vec![0xFC, 0xF8, 0xF8, 0])), "YUV 4:2:0 8-bit");
// only without a readable SPS does the extension speak for itself
assert_eq!(pixfmt_label(&avc(100, vec![0x67, 100], vec![0xFC, 0xF8, 0xF8, 0])), "YUV 4:0:0 8-bit");
}
/// An HEVC Main SPS NAL unit (ITU-T H.265 §7.3.2.2) with the given chroma format and bit depth, 64×64.
fn hevc_sps(chroma: u32, bits: u32) -> Vec<u8> {
let mut w = filmcraft_bitstream::BitWriter::new();
w.write_bits(0, 4); // vps id
w.write_bits(0, 3); // max_sub_layers_minus1
w.write_bits(1, 1); // temporal_id_nesting
// profile_tier_level: Main, level 3.1
w.write_bits(0, 2);
w.write_bits(0, 1);
w.write_bits(1, 5);
w.write_bits(0x6000_0000, 32);
w.write_bits(0b1001, 4);
w.write_bits(0, 32);
w.write_bits(0, 11);
w.write_bits(0, 1);
w.write_bits(93, 8);
w.write_ue(0); // sps id
w.write_ue(chroma);
if chroma == 3 {
w.write_bit(false); // separate_colour_plane
}
w.write_ue(64);
w.write_ue(64);
w.write_bits(0, 1); // conformance window
w.write_ue(bits - 8); // luma
w.write_ue(bits - 8); // chroma
w.write_ue(4); // log2_max_poc_lsb_minus4
w.write_bits(1, 1); // sub_layer_ordering_info_present
w.write_ue(4);
w.write_ue(0);
w.write_ue(0);
w.write_ue(0); // min CB 8
w.write_ue(1); // CTB 16
w.write_ue(0); // min TB 4
w.write_ue(2); // max TB 16
w.write_ue(0);
w.write_ue(0);
w.write_bits(0, 1); // scaling lists
w.write_bits(0, 1); // amp
w.write_bits(0, 1); // sao
w.write_bits(0, 1); // pcm
w.write_ue(0); // num_short_term_ref_pic_sets
w.write_bits(0, 1); // long_term_ref_pics_present
w.write_bits(0, 1); // temporal mvp
w.write_bits(0, 1); // strong intra smoothing
w.write_bits(0, 1); // vui
w.write_bits(0, 1); // extensions
w.rbsp_trailing();
let mut nal = vec![33 << 1, 1];
nal.extend(filmcraft_bitstream::escape_rbsp(&w.finish()));
nal
}
/// The hvcC's own chroma/bit-depth fields can be left at 0 by a muxer: the SPS it carries says.
#[test]
fn hevc_pixel_format_prefers_the_sps_over_the_hvcc_fields() {
let with_sps = |chroma: u32, bits: u32, field_chroma: u8, field_bits: u8| {
let arrays = vec![filmcraft_isobmff::HevcNalArray { completeness: true, nal_type: 33, nalus: vec![hevc_sps(chroma, bits)] }];
CodecConfig::Hevc(filmcraft_isobmff::HevcConfig {
general_profile_idc: 1,
chroma_format_idc: field_chroma,
bit_depth_luma: field_bits,
arrays,
..Default::default()
})
};
assert_eq!(pixfmt_label(&with_sps(1, 8, 0, 0)), "YUV 4:2:0 8-bit", "hvcC fields at 0, SPS 4:2:0");
assert_eq!(pixfmt_label(&with_sps(2, 10, 1, 8)), "YUV 4:2:2 10-bit", "the SPS outranks the fields");
assert_eq!(pixfmt_label(&with_sps(3, 12, 3, 12)), "YUV 4:4:4 12-bit");
// no SPS in the record: its fields
let bare = CodecConfig::Hevc(filmcraft_isobmff::HevcConfig { general_profile_idc: 1, chroma_format_idc: 1, bit_depth_luma: 10, ..Default::default() });
assert_eq!(pixfmt_label(&bare), "YUV 4:2:0 10-bit");
}
/// A one-frame 32×16 ProRes MOV with the given profile, chroma format and fourcc.
+29 -1
View File
@@ -308,7 +308,11 @@ impl Lut {
if let Some(sh) = &self.shaper {
let _ = writeln!(s, "LUT_1D_SIZE {}", sh.size());
if both {
if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] {
let uniform = sh.domain_min.iter().all(|v| *v == sh.domain_min[0]) && sh.domain_max.iter().all(|v| *v == sh.domain_max[0]);
if !uniform {
// with both tables, DOMAIN_MIN/DOMAIN_MAX belong to the shaper
write_domain(&mut s, sh.domain_min, sh.domain_max);
} else if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] {
let _ = writeln!(s, "LUT_1D_INPUT_RANGE {} {}", sh.domain_min[0], sh.domain_max[0]);
}
} else {
@@ -368,6 +372,9 @@ impl Lut {
}
let mesh = mesh.ok_or("empty .3dl")?;
let n = mesh.len();
if !(2..=256).contains(&n) {
return Err(format!("input mesh must have 2–256 values, found {n}"));
}
if rows.len() != n * n * n {
return Err(format!("expected {}³ = {} rows, found {}", n, n * n * n, rows.len()));
}
@@ -638,4 +645,25 @@ mod tests {
assert!(Lut::parse(&text, None).is_ok());
assert_eq!(LutFormat::from_path("a/b/Look.CUBE"), Some(LutFormat::Cube));
}
#[test]
fn huge_3dl_mesh_is_rejected() {
let text = (0..1u32 << 22).map(|i| i.to_string()).collect::<Vec<_>>().join(" ") + "\n";
let r = std::panic::catch_unwind(|| Lut::parse_3dl(&text).map(|l| l.apply([0.5; 3])));
assert!(matches!(r, Ok(Err(_))), "huge mesh must be rejected without panicking");
let mesh257 = (0..257).map(|i| i.to_string()).collect::<Vec<_>>().join(" ") + "\n";
assert!(Lut::parse_3dl(&mesh257).is_err());
}
#[test]
fn cube_shaper_per_channel_domain_round_trips() {
let mut text = String::from("LUT_1D_SIZE 2\nLUT_3D_SIZE 2\nDOMAIN_MAX 1 2 3\n");
for _ in 0..10 {
text.push_str("0 0 0\n");
}
let a = Lut::parse_cube(&text).unwrap();
assert_eq!(a.shaper.as_ref().unwrap().domain_max, [1.0, 2.0, 3.0]);
let b = Lut::parse_cube(&a.to_cube()).unwrap();
assert_eq!(b.shaper.unwrap().domain_max, [1.0, 2.0, 3.0]);
}
}
+1
View File
@@ -42,6 +42,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
craft-docpath = { workspace = true }
web-time = "1"
[dev-dependencies]
+19 -10
View File
@@ -50,11 +50,23 @@ pub struct AutoSavePrefs {
pub recovery_journal: bool,
/// Longest time (s) unsaved changes may stay out of the journal while edits keep coming.
pub recovery_interval_seconds: u32,
/// Folders chosen in Project Settings ▸ Scratch Disks on this computer. A project file may
/// name these, or folders inside its own folder; see
/// [`crate::project_tools::trusted_scratch`].
pub scratch_folders: Vec<String>,
}
impl Default for AutoSavePrefs {
fn default() -> Self {
Self { enabled: true, interval_minutes: 5, max_versions: 20, save_current_project: false, recovery_journal: true, recovery_interval_seconds: 5 }
Self {
enabled: true,
interval_minutes: 5,
max_versions: 20,
save_current_project: false,
recovery_journal: true,
recovery_interval_seconds: 5,
scratch_folders: Vec::new(),
}
}
}
@@ -1000,21 +1012,18 @@ impl Worker {
fn auto_save(&mut self) -> Result<PathBuf, String> {
let Some(s) = &self.latest else { return Err("nothing to save".into()) };
let scratch = s.project.settings.scratch.auto_save.clone().filter(|d| !d.is_empty());
let (dir, name) = match &s.path {
let scratch = crate::project_tools::trusted_scratch(s.project.settings.scratch.auto_save.as_deref(), s.path.as_deref(), &self.prefs.scratch_folders);
let (dir, name) = match (&s.path, scratch) {
// Project Settings ▸ Scratch Disks ▸ Project Auto Save
Some(p) if scratch.is_some() => {
(Some(p), Some(scratch)) => {
let p = Path::new(p);
(
PathBuf::from(scratch.clone().unwrap_or_default()),
p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()),
)
(scratch, p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))
}
Some(p) => {
(Some(p), None) => {
let p = Path::new(p);
(names::auto_save_dir(p), p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))
}
None => (self.data_dir.join(names::AUTO_SAVE_DIR), s.project.name.clone()),
(None, _) => (self.data_dir.join(names::AUTO_SAVE_DIR), s.project.name.clone()),
};
let t0 = Instant::now();
let bytes = filmcraft_format::encode(&s.project, false);
+96
View File
@@ -229,3 +229,99 @@ fn journal_costs_the_ui_thread_only_a_handoff() {
s.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
/// Scratch Disks folders named in a project file are used only when they are inside the project's
/// folder or were chosen on this computer: otherwise auto-saves and previews go to the default
/// location, nothing is written to the named folder, and the user is told.
#[test]
fn project_scratch_folders_outside_the_project_are_not_used() {
let d = temp_dir("scratch-trust");
let outside = d.join("elsewhere");
let show = d.join("show");
std::fs::create_dir_all(&show).unwrap();
let proj = show.join("cut.fcproj").to_string_lossy().to_string();
let mut a = demo();
let named = outside.to_string_lossy().to_string();
a.edit("scratch", |p, _| {
p.settings.scratch.auto_save = Some(named.clone());
p.settings.scratch.video_previews = Some(named.clone());
Ok(())
})
.unwrap();
a.execute("file.saveAs", json!({"path": proj})).unwrap();
let _ = std::fs::remove_dir_all(&outside);
let mut s = Session::default();
start(&mut s, &d.join("data"));
s.execute("file.open", json!({"path": proj})).unwrap();
assert!(
s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message.contains(&named))),
"the user is told the folder is not used"
);
assert_eq!(crate::project_tools::previews_dir(&s), Some(show.join("FilmCraft Previews").join("cut")));
s.execute("sequence.addEdit", json!({"seconds": 1.0})).unwrap();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
let written = std::path::PathBuf::from(r["path"].as_str().unwrap());
assert!(written.starts_with(show.join("Auto-Save")), "{}", written.display());
assert!(!outside.exists(), "nothing written to the folder the project names");
// A folder inside the project's folder is used as is.
let inner = show.join("Saves").to_string_lossy().to_string();
s.edit("scratch", |p, _| {
p.settings.scratch.auto_save = Some(inner.clone());
Ok(())
})
.unwrap();
s.sync_persistence();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
assert!(std::path::Path::new(r["path"].as_str().unwrap()).starts_with(&inner));
// Choosing the outside folder in Scratch Disks makes it usable; network shares never are.
s.execute("file.projectSettings.scratchDisks", json!({"autoSave": named})).unwrap();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
assert!(std::path::Path::new(r["path"].as_str().unwrap()).starts_with(&outside));
assert!(s.execute("file.projectSettings.scratchDisks", json!({"autoSave": r"\\server\share\saves"})).is_err());
assert!(crate::project_tools::trusted_scratch(Some(r"\\server\share"), Some(&proj), &[r"\\server\share".into()]).is_none());
assert!(crate::project_tools::trusted_scratch(Some(&format!("{}/../x", show.display())), Some(&proj), &[]).is_none());
s.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
/// The Captured Audio and Video and Audio Previews folders a project names follow the same rule:
/// voice-over takes, pasted images and proxies go next to the project unless the folder is
/// inside the project's folder; folders on another computer are never used.
#[test]
fn project_captured_folder_outside_the_project_is_not_used() {
let d = temp_dir("scratch-captured");
let show = d.join("show");
std::fs::create_dir_all(&show).unwrap();
let proj = show.join("cut.fcproj").to_string_lossy().to_string();
let mut s = demo();
s.execute("file.saveAs", json!({"path": proj})).unwrap();
let mut named = vec![d.join("elsewhere").to_string_lossy().to_string(), r"\\192.0.2.1\share\cap".to_string(), "file://192.0.2.1/s/cap".to_string()];
if cfg!(unix) {
named.push(format!("/{}", show.join("Captured").display()));
}
let beside = show.to_string_lossy().to_string();
for dir in &named {
s.edit("scratch", |p, _| {
p.settings.scratch.captured = Some(dir.clone());
p.settings.scratch.audio_previews = Some(dir.clone());
Ok(())
})
.unwrap();
assert_eq!(crate::voiceover::media_dir(&s, &serde_json::Value::Null, "Captured"), beside, "{dir}");
let paths = crate::project_tools::scratch_paths(&s);
assert_eq!(paths["captured"]["path"], json!(beside), "{dir}");
assert_eq!(paths["audioPreviews"]["path"], json!(beside), "{dir}");
}
let inner = show.join("Captured").to_string_lossy().to_string();
s.edit("scratch", |p, _| {
p.settings.scratch.captured = Some(inner.clone());
Ok(())
})
.unwrap();
assert_eq!(crate::voiceover::media_dir(&s, &serde_json::Value::Null, "Captured"), inner);
assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": "file://192.0.2.1/s/cap"})).is_err());
let _ = std::fs::remove_dir_all(&d);
}
+9 -1
View File
@@ -299,7 +299,15 @@ pub fn commands() -> Vec<CommandSpec> {
r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
has_caption_track,
|s, p| {
let tid = track_param(s, p).ok_or_else(|| bad("captions.setStyle", "no such caption track"))?;
let tid = track_param(s, p).ok_or_else(|| {
bad(
"captions.setStyle",
format!(
"no such caption track ({}) — pass the track id from captions.list, or \"C1\"…",
p.get("track").map(|v| v.to_string()).unwrap_or_else(|| "no `track` given".into())
),
)
})?;
let p = p.clone();
s.edit("Caption Style", |pr, st| {
let seq = st.active_sequence.ok_or(EngineError::NoSequence)?;
+23
View File
@@ -1059,3 +1059,26 @@ fn linked_rate_stretch_keeps_one_speed_when_a_neighbour_blocks_the_sound() {
assert!((pic.speed - sound.speed).abs() < 1e-12);
assert!((r["speed"].as_f64().unwrap() - 60.0 / 70.0).abs() < 1e-9);
}
/// #2358: Toggle Animation on a parameter an older effect instance lacks (it was added to the
/// effect later) creates it from its default and animates it, instead of "no such param".
#[test]
fn toggle_animation_creates_a_parameter_missing_from_an_older_instance() {
let mut s = crate::Session::default();
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
let seq = s.state.active_sequence.unwrap();
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
// an older project: Motion without its Rotation entry
{
let p = std::sync::Arc::make_mut(&mut s.project);
let q = p.sequence_mut(seq).unwrap();
let (_, it) = q.find_item_mut(clip).unwrap();
let m = it.effects.iter_mut().find(|e| e.effect == "motion").unwrap();
m.params.remove("rotation");
}
// `"mask": null` is no mask, as in effects.setParam
s.execute("effects.toggleAnimation", serde_json::json!({"clip": clip.0, "effect": "motion", "param": "rotation", "mask": null})).unwrap();
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
let rot = it.effect("motion").unwrap().param("rotation").unwrap();
assert!(rot.is_animated(), "the stopwatch is on");
}
+34 -8
View File
@@ -700,6 +700,7 @@ fn import_source(
v.frame_rate = crate::settings::timebase_rate(&s.prefs.media.indeterminate_timebase);
}
let path_s = path.to_string();
s.media.allow(path);
let id = s.edit(&format!("Import {name}"), |p, _| {
Ok(p.add_item(
&name,
@@ -1646,7 +1647,8 @@ fn build() -> Vec<CommandSpec> {
let caps = s.state.caption_selection.clone();
return crate::captions::delete(s, &caps, false);
}
let sel = with_links(s, &clips_p(s, p));
// An Alt selection is already exact; explicit targets keep the command's linked behavior.
let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() };
s.edit_sequence("Clear", |q, _, st| {
edit::delete_items(q, &sel);
st.selection.clear();
@@ -1664,7 +1666,7 @@ fn build() -> Vec<CommandSpec> {
let caps = s.state.caption_selection.clone();
return crate::captions::delete(s, &caps, true);
}
let sel = with_links(s, &clips_p(s, p));
let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() };
s.edit_sequence("Ripple Delete", |q, _, st| {
let spans = edit::ripple_delete_items(q, &sel)?;
if st.ripple_sequence_markers {
@@ -2586,13 +2588,13 @@ fn build() -> Vec<CommandSpec> {
None => place_one(s, p),
}
),
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| {
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool,"linked":bool?}"#, has_seq, |s, p| {
if let Some(ids) = p.get("transitions") {
return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false));
}
let clips: Vec<ClipId> =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
let clips = with_links(s, &clips);
let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { clips };
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
s.state.edit_points.clear();
s.state.trim_shift = Default::default();
@@ -2858,10 +2860,17 @@ fn build() -> Vec<CommandSpec> {
"Track Settings",
[],
None,
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#,
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?,"label":"Violet|Iris|…"|""|null?}"#,
has_seq,
|s, p| {
let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
// colour label: a label name, or "" / "None" / null for no colour
let label = match p.get("label") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(n)) if n.is_empty() || n.eq_ignore_ascii_case("none") => Some(None),
Some(v) => Some(Some(v.as_str().and_then(Label::from_name).ok_or_else(|| bad("timeline.setTrack", format!("unknown label {v}")))?)),
};
let p = p.clone();
s.edit_sequence("Track Settings", |q, _, _| {
let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?;
@@ -2889,6 +2898,9 @@ fn build() -> Vec<CommandSpec> {
if let Some(v) = f64_p(&p, "pan") {
t.pan = v.clamp(-100.0, 100.0);
}
if let Some(l) = label {
t.label = l;
}
Ok(())
})?;
Ok(Value::Null)
@@ -3109,6 +3121,14 @@ fn build() -> Vec<CommandSpec> {
_ => None,
}
.ok_or_else(|| bad("effects.toggleAnimation", "no such effect"))?;
// a parameter an older instance lacks (added to the effect later) starts from its
// default, as the UI shows it, without touching an existing one
if p.get("mask").is_none_or(Value::is_null)
&& !e.params.contains_key(&pid)
&& let Some(d) = e.def().and_then(|d| d.param(&pid))
{
e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone()));
}
crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt);
Ok(())
})?;
@@ -4257,6 +4277,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
let tr = |t: &filmcraft_project::Track| {
json!({
"id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo,
"label": t.label.map(|l| l.name()),
"items": t.items.iter().map(|i| json!({
"clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0,
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse,
@@ -4355,6 +4376,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
s.media.set_use_proxies(proxies);
s.offline = Default::default();
let first = proj.sequences().next().map(|i| i.id);
s.media.add_stored(&proj, path.as_deref());
s.project = std::sync::Arc::new(proj);
s.history = Default::default();
s.history.limit = 200;
@@ -4364,7 +4386,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
if let Some(p) = &path
&& !cfg!(target_arch = "wasm32")
{
s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p)));
s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p, &s.prefs.auto_save.scratch_folders)));
}
s.path = path;
s.revision += 1;
@@ -4372,6 +4394,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
s.saved_revision = if clean { s.revision } else { 0 };
s.loaded_schema = filmcraft_format::SCHEMA_VERSION;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
crate::project_tools::warn_untrusted_scratch(s);
}
fn open_project(s: &mut Session, path: &str) -> Result<Value> {
@@ -4423,8 +4446,11 @@ fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
/// Where auto-saves of the current project go.
fn auto_save_dir(s: &Session) -> std::path::PathBuf {
if let Some(d) = s.project.settings.scratch.auto_save.as_deref().filter(|d| !d.is_empty() && s.path.is_some()) {
return std::path::PathBuf::from(d);
if s.path.is_some()
&& let Some(d) =
crate::project_tools::trusted_scratch(s.project.settings.scratch.auto_save.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)
{
return d;
}
match (&s.path, &s.persistence) {
(Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)),
+64
View File
@@ -0,0 +1,64 @@
//! Media paths stored in a document (a project, a project template, an interchange file) reach
//! the file system only through [`resolve`]: a relative path is taken from the document's folder,
//! and only local files, or files on the network share the document itself is on, are opened (see
//! `craft_docpath`). A refused path is handled like a missing file: the item renders the offline
//! slate and File ▸ Link Media… can reconnect it.
//!
//! Paths the user picks in this session (import, Link Media, file dialogs, the control channel)
//! are not stored-document paths and are followed as given. The media pool tells them apart
//! ([`crate::MediaPool::follow`]): it records the paths of each document it loads, with that
//! document, and forgets a path once the user picks it again.
use std::fmt;
use std::path::{Path, PathBuf};
pub use craft_docpath::Refused;
/// Why a stored path is not followed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotFollowed {
/// The path is not a local file, or is not on the document's own share.
Refused(Refused),
/// The path is relative and the document has no file to take it from.
NoDocumentFolder,
}
impl fmt::Display for NotFollowed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Refused(r) => r.fmt(f),
Self::NoDocumentFolder => f.write_str("the path is relative and the project has not been saved"),
}
}
}
impl std::error::Error for NotFollowed {}
/// The file to open for `stored`, a path or `file:` URL stored in the document at `document`
/// (None: a document without a file). A relative `document` is taken from the working directory,
/// where it was opened from.
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<String, NotFollowed> {
let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
let path: PathBuf = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
// A path that is still relative would be taken from the working directory, which the
// document does not choose.
if path.is_relative() {
return Err(NotFollowed::NoDocumentFolder);
}
path.into_os_string().into_string().map_err(|_| NotFollowed::Refused(Refused::Invalid))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_paths_are_taken_from_the_document_folder() {
let dir = std::env::temp_dir().join("films");
let doc = dir.join("cut.fcproj");
assert_eq!(resolve("clip.mp4", Some(&doc)), Ok(dir.join("clip.mp4").to_string_lossy().into_owned()));
assert_eq!(resolve("clip.mp4", None), Err(NotFollowed::NoDocumentFolder));
assert_eq!(resolve(r"\\192.0.2.1\share\x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
assert_eq!(resolve("file://192.0.2.1/s/x.mp4", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
}
}
+32
View File
@@ -23,6 +23,38 @@ fn hostile_export_ranges_are_rejected_without_panicking() {
}
}
/// #1544: an export started without `wait` says where it can be followed: `jobs.list` (not
/// `export.queue.list`, which lists the Export Queue only) and `jobs.cancel` to stop it.
#[test]
fn export_without_wait_says_where_to_poll() {
let mut s = demo();
let dir = Scratch::new("poll");
let r = s
.execute("file.exportMedia", json!({"path": dir.path("Demo.wav"), "format": "wav", "range": "custom", "startSeconds": 0, "endSeconds": 0.25}))
.unwrap();
assert_eq!((r["poll"].as_str(), r["cancel"].as_str()), (Some("jobs.list"), Some("jobs.cancel")), "{r}");
let job = r["job"].as_u64().unwrap();
// the job is there to poll, and the Export Queue is not where it is
let listed = |s: &mut Session| s.execute("jobs.list", json!({})).unwrap().as_array().unwrap().iter().find(|j| j["id"] == job).cloned();
assert!(listed(&mut s).is_some(), "the job is in jobs.list");
assert_eq!(s.execute("export.queue.list", json!({})).unwrap()["items"], json!([]));
for _ in 0..1200 {
if listed(&mut s).is_some_and(|j| j["finished"] == true) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert_eq!(listed(&mut s).map(|j| j["finished"].clone()), Some(json!(true)), "the export finished");
// a waited export answers its result instead
let r = s
.execute(
"file.exportMedia",
json!({"path": dir.path("Demo2.wav"), "format": "wav", "range": "custom", "startSeconds": 0, "endSeconds": 0.25, "wait": true}),
)
.unwrap();
assert!(r.get("poll").is_none() && r["result"].is_object(), "{r}");
}
/// A scratch directory under the workspace `target/`, removed when dropped.
struct Scratch(PathBuf);
+6
View File
@@ -549,6 +549,12 @@ pub fn export_media(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(pr) = preset {
out["preset"] = json!(pr.name);
}
if !wait {
// where the job can be followed (#1544: it is not in `export.queue.list`, which only lists
// the Export Queue, so agents looked there and found nothing)
out["poll"] = json!("jobs.list");
out["cancel"] = json!("jobs.cancel");
}
if wait && let Some(j) = s.jobs.iter().find(|j| j.id == id) {
let v = j.to_json();
if let Some(e) = v["result"]["error"].as_str() {
+7 -2
View File
@@ -775,7 +775,12 @@ fn set_char_style(s: &mut Session, p: &Value) -> Result<Value> {
};
let tl = s.playhead();
let (start, end) = (u64_p(p, "start").map(|v| v as usize), u64_p(p, "end").map(|v| v as usize));
let runs = s.edit_sequence("Character Style", move |q, _, _| {
// A pointer gesture shares one undo step only within the same clip, layer and range.
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("graphics.setCharStyle:{}:{ei}:{start:?}:{end:?}", clip.0));
if bool_p(p, "begin").unwrap_or(false) {
s.history.merge_key = None;
}
let runs = s.edit_sequence_as("Character Style", merge.as_deref(), move |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
let e = &mut it.effects[ei];
@@ -1217,7 +1222,7 @@ pub fn commands() -> Vec<CommandSpec> {
"graphics.setCharStyle",
"Character Style",
&[],
r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range)}"##,
r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range),"merge":bool?,"begin":bool? (start a new undo gesture)}"##,
has_graphic,
set_char_style,
),
@@ -380,3 +380,52 @@ fn copy_paste_duplicates_a_graphic_clip() {
assert!((pasted[0].start.seconds() - 620.0).abs() < 0.05, "at the playhead: {:?}", pasted[0].start);
assert_eq!(texts(&s, pasted[0].id), vec!["Lyric".to_string()]);
}
#[test]
fn character_style_drag_merges_only_its_target_and_range() {
let mut s = demo("gt-char-drag");
let c = clip_of(&s.execute("graphics.newText", json!({"text":"Alpha Bravo"})).unwrap());
let before = s.project.clone();
let n = s.history.undo.len();
for i in 0..8 {
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":105+i*5},"merge":true,"begin":i==0})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 1, "one drag, one undo step");
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":150},"merge":true,"begin":true})).unwrap();
assert_eq!(s.history.undo.len(), n + 2, "a new gesture on the same range starts a new step");
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":6,"end":11,"style":{"size":160},"merge":true})).unwrap();
assert_eq!(s.history.undo.len(), n + 3, "another character range does not merge");
let before = s.project.clone();
assert!(s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":3,"end":3,"style":{"size":170},"merge":true})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.history.undo.len(), n + 3, "an invalid edit adds no undo step");
}
#[test]
fn character_style_merge_preserves_default_calls_and_separate_clips() {
let mut s = demo("gt-char-merge-targets");
let a = clip_of(&s.execute("graphics.newText", json!({"text":"First", "newClip":true})).unwrap());
let b = clip_of(&s.execute("graphics.newText", json!({"text":"Other", "newClip":true})).unwrap());
assert_ne!(a, b);
let n = s.history.undo.len();
for c in [a, b] {
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":120},"merge":true})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 2, "distinct clips do not merge");
for size in [130, 140] {
s.execute("graphics.setCharStyle", json!({"clip":b.0,"style":{"size":size}})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 4, "default calls retain separate undo steps");
let added = s.execute("graphics.newText", json!({"clip":b.0,"text":"Layer"})).unwrap();
assert_eq!(clip_of(&added), b);
let n = s.history.undo.len();
for layer in [0, 1] {
s.execute("graphics.setCharStyle", json!({"clip":b.0,"layer":layer,"start":0,"end":5,"style":{"size":150},"merge":true})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 2, "distinct layers do not merge");
}
+11 -2
View File
@@ -74,11 +74,20 @@ fn item_of(r: &Value) -> ItemId {
#[test]
fn import_as_image_sequence_with_missing_frames() {
let dir = tmp_dir("imgseq-import");
check_image_sequence_with_missing_frames("shot_");
}
#[test]
fn import_unicode_image_sequence_with_missing_frames() {
check_image_sequence_with_missing_frames("é");
}
fn check_image_sequence_with_missing_frames(prefix: &str) {
let dir = tmp_dir(&format!("imgseq-import-{prefix}"));
let mut s = demo();
s.execute("prefs.set", json!({"key": "media.indeterminateTimebase", "value": "25"})).unwrap();
// frames 1..=6 with 4 missing, plus an unrelated file
let paths = write_frames(&dir, "shot_", &[1, 2, 3, 5, 6], "png");
let paths = write_frames(&dir, prefix, &[1, 2, 3, 5, 6], "png");
write_frames(&dir, "other_", &[1], "png");
let r = s.execute("file.import", json!({"paths": [paths[0]], "imageSequence": true})).unwrap();
let id = item_of(&r);
@@ -0,0 +1,127 @@
//! Individual linked-clip selection and deletion (#680, #1163).
use crate::Session;
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
fn paired_sequence() -> (Session, [(ClipId, ClipId); 2]) {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Individual selection", "video": 1, "audio": 1})).unwrap();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).unwrap().id;
for frame in [0, 48] {
s.execute("timeline.place", json!({"item": item.0, "frame": frame, "duration": s.sequence_rate().tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
let pairs = [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id));
(s, pairs)
}
#[test]
fn linked_false_is_local_to_selection_and_respects_add_toggle() {
for audio in [false, true] {
let (mut s, [(video, sound), _]) = paired_sequence();
let (selected, partner) = if audio { (sound, video) } else { (video, sound) };
s.execute("timeline.select", json!({"clips": [selected.0]})).unwrap();
assert_eq!(s.state.selection.len(), 2, "ordinary selection includes the partner");
s.execute("timeline.select", json!({"clips": [selected.0], "linked": false})).unwrap();
assert_eq!(s.state.selection, vec![selected]);
assert!(s.state.linked_selection, "Alt selection does not change the global toggle");
s.execute("timeline.select", json!({"clips": [partner.0], "add": true, "linked": false})).unwrap();
assert_eq!(s.state.selection, vec![selected, partner]);
s.execute("timeline.select", json!({"clips": [selected.0], "toggle": true, "linked": false})).unwrap();
assert_eq!(s.state.selection, vec![partner]);
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
for params in [json!({"clips": [selected.0]}), json!({"clips": [selected.0], "linked": true})] {
s.execute("timeline.select", params).unwrap();
assert_eq!(s.state.selection, vec![selected], "the global off toggle still applies");
}
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
s.execute("timeline.select", json!({"clips": [selected.0]})).unwrap();
assert_eq!(s.state.selection.len(), 2);
}
}
#[test]
fn implicit_clear_keeps_an_individually_selected_partner_and_undo() {
for audio in [false, true] {
let (mut s, [(video, sound), _]) = paired_sequence();
let (selected, partner) = if audio { (sound, video) } else { (video, sound) };
// Alt-click and the marquee preview can already hold a precise transient selection.
// Test deletion independently of the timeline.select parameter handling.
s.state.selection = vec![selected];
let before = s.project.clone();
s.execute("edit.clear", json!({})).unwrap();
let q = s.active_sequence().unwrap();
assert!(q.find_item(selected).is_none());
assert!(q.find_item(partner).is_some(), "the unselected linked partner stays");
assert!(s.state.linked_selection);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before, "one undo restores the pair");
}
}
#[test]
fn implicit_ripple_delete_keeps_the_partner_on_an_unsynced_track() {
for audio in [false, true] {
let (mut s, [(video, sound), (next_video, next_sound)]) = paired_sequence();
let (selected, partner, next, partner_next, partner_track) =
if audio { (sound, video, next_sound, next_video, "V1") } else { (video, sound, next_video, next_sound, "A1") };
s.execute("timeline.setTrack", json!({"track": partner_track, "syncLock": false})).unwrap();
s.state.selection = vec![selected];
let before = s.project.clone();
s.execute("edit.rippleDelete", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!(q.find_item(selected).is_none());
assert_eq!(q.find_item(partner).unwrap().1.start, Tick::ZERO);
assert_eq!(q.find_item(next).unwrap().1.start, Tick::ZERO, "the selected track closes its gap");
assert_eq!(q.find_item(partner_next).unwrap().1.start, s.sequence_rate().tick_of(48), "the unsynced partner track stays put");
assert!(s.state.linked_selection);
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
}
#[test]
fn individual_ripple_sync_lock_conflict_is_atomic() {
for audio in [false, true] {
let (mut s, [(video, sound), _]) = paired_sequence();
let selected = if audio { sound } else { video };
s.state.selection = vec![selected];
let before = s.project.clone();
let history = (s.history.undo.len(), s.history.redo.len());
let error = s.execute("edit.rippleDelete", json!({})).unwrap_err();
assert!(error.to_string().to_lowercase().contains("sync"), "{error}");
assert_eq!(*s.project, *before, "a conflict must not delete either clip");
assert_eq!(s.state.selection, vec![selected]);
assert_eq!((s.history.undo.len(), s.history.redo.len()), history);
assert!(s.state.linked_selection);
}
}
#[test]
fn explicit_clear_and_ripple_targets_keep_the_linked_selection_setting() {
for command in ["edit.clear", "edit.rippleDelete"] {
for key in ["clip", "clips"] {
for linked in [false, true] {
let (mut s, [(video, sound), (next, _)]) = paired_sequence();
for track in ["V1", "A1"] {
s.execute("timeline.setTrack", json!({"track": track, "syncLock": false})).unwrap();
}
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
s.state.selection = vec![next];
let params = if key == "clip" { json!({"clip": video.0}) } else { json!({"clips": [video.0]}) };
s.execute(command, params).unwrap();
let q = s.active_sequence().unwrap();
assert!(q.find_item(video).is_none(), "{command} {key}");
assert_eq!(q.find_item(sound).is_none(), linked, "{command} {key}: explicit targets retain link expansion");
assert!(q.find_item(next).is_some(), "explicit targets do not delete the current selection");
}
}
}
}
+12 -2
View File
@@ -74,13 +74,23 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
_ => None,
})
.collect();
// The document's media paths are opened as stored paths of the document.
s.media.add_stored_paths(new_media.iter().map(|(_, p)| p.clone()), Some(path));
let mut linked = 0;
let mut offline = Vec::new();
for (id, mpath) in new_media {
match s.media.open_file(&mpath, &*s.services).ok() {
let file = match s.media.follow(&mpath) {
Ok(file) => file,
Err(why) => {
log::warn!("{mpath} is not opened: {why}");
offline.push(mpath);
continue;
}
};
match s.media.open_file(&file, &*s.services).ok() {
Some(src) => {
let info = src.info().clone();
let identity = crate::relink::identity_of(&*s.services, &mpath).ok();
let identity = crate::relink::identity_of(&*s.services, &file).ok();
let rebase = (format == Format::Edl).then_some(info.start_timecode).flatten();
s.edit("Link Media", |proj, _| {
if let Some(item) = proj.items.get_mut(&id)
+5
View File
@@ -17,6 +17,7 @@ pub mod clip_ops;
pub mod color;
pub mod commands;
pub mod demo;
pub mod doc_path;
pub mod essential_sound;
pub mod export_tools;
pub mod graphic_templates;
@@ -1236,6 +1237,8 @@ mod gap_selection_tests;
#[cfg(test)]
mod image_sequence_tests;
#[cfg(test)]
mod individual_selection_tests;
#[cfg(test)]
mod interchange_auto_points_tests;
#[cfg(test)]
mod keyboard_tests;
@@ -1306,6 +1309,8 @@ mod shortcuts_tests;
#[cfg(test)]
mod split_edit_ripple_trim_tests;
#[cfg(test)]
mod stored_paths_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod timeline_move_tests;
+64 -7
View File
@@ -11,8 +11,11 @@
//! from it. Proxy frames are smaller; the source still reports the full-resolution size, and the
//! compositor derives its pixel scale from the frame it gets, so effects and Motion render the
//! same picture at lower resolution. Export asks for [`MediaPool::full_res_provider`].
//! - Paths that came from a document (the project file, a template, an interchange file) are
//! opened through [`crate::doc_path`] ([`MediaPool::follow`]); a refused one renders as missing.
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
@@ -53,6 +56,9 @@ pub struct MediaPool {
failures: Arc<Mutex<HashMap<ItemId, u32>>>,
/// Openers tried before the built-in ones (MP4/MOV + codecs register here).
pub openers: RwLock<Vec<Opener>>,
/// Media and proxy paths that came from a document, with that document's path (see
/// [`Self::follow`]).
stored: RwLock<HashMap<String, Option<PathBuf>>>,
}
impl Default for MediaPool {
@@ -65,6 +71,7 @@ impl Default for MediaPool {
use_proxies: AtomicBool::new(false),
failures: Arc::default(),
openers: RwLock::new(filmcraft_codecs::openers()),
stored: RwLock::default(),
}
}
}
@@ -173,6 +180,54 @@ impl MediaPool {
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
}
/// Record the media and proxy paths of `p`, loaded from the document at `document` (None: one
/// without a file), as stored paths: they are opened only as [`crate::doc_path::resolve`]
/// allows. A path already recorded keeps the document it came from first.
pub fn add_stored(&self, p: &Project, document: Option<&str>) {
let paths = p.items.values().filter_map(|i| i.as_media()).flat_map(|m| [Some(&m.media), m.proxy.as_ref()]).filter_map(|r| match r {
Some(MediaRef::File { path }) => Some(path.clone()),
_ => None,
});
self.add_stored_paths(paths, document);
}
/// [`Self::add_stored`] for single paths.
pub fn add_stored_paths(&self, paths: impl IntoIterator<Item = String>, document: Option<&str>) {
let document = document.filter(|d| !d.is_empty()).map(PathBuf::from);
let mut stored = self.stored.write().unwrap_or_else(|e| e.into_inner());
for p in paths {
stored.entry(p).or_insert_with(|| document.clone());
}
}
/// Forget the stored paths (a new project).
pub fn clear_stored(&self) {
self.stored.write().unwrap_or_else(|e| e.into_inner()).clear();
}
/// The user picked `path` in this session (import, Link Media): open it as given.
pub fn allow(&self, path: &str) {
self.stored.write().unwrap_or_else(|e| e.into_inner()).remove(path);
}
/// The file to open for `path`, a media or proxy path of the project: a stored path as
/// [`crate::doc_path::resolve`] resolves it, any other path as given.
pub fn follow(&self, path: &str) -> Result<String, crate::doc_path::NotFollowed> {
// The web host reads only the files the user gave it, under its own virtual paths.
if cfg!(target_arch = "wasm32") {
return Ok(path.to_string());
}
match self.stored.read().unwrap_or_else(|e| e.into_inner()).get(path) {
Some(document) => crate::doc_path::resolve(path, document.as_deref()),
None => Ok(path.to_string()),
}
}
/// [`Self::follow`] as a media error (a refused path is a missing file).
fn follow_media(&self, path: &str) -> Result<String, MediaError> {
self.follow(path).map_err(|why| MediaError::Offline(format!("{path} is not opened: {why}")))
}
/// Forget every opened source (files are re-read on next use).
pub fn clear(&self) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).clear();
@@ -279,11 +334,13 @@ impl MediaPool {
self.set_offline(item, OfflineReason::MadeOffline, path, "made offline");
Arc::new(SlateSource::new(m.info.clone(), &file_name(path), OfflineReason::MadeOffline))
}
MediaRef::File { path } => match if m.info.kind == filmcraft_media::MediaKind::ImageSequence {
open_image_sequence(path, services, m.info.frame_rate(), &it.name)
} else {
self.open_file(path, services)
} {
MediaRef::File { path } => match self.follow_media(path).and_then(|file| {
if m.info.kind == filmcraft_media::MediaKind::ImageSequence {
open_image_sequence(&file, services, m.info.frame_rate(), &it.name)
} else {
self.open_file(&file, services)
}
}) {
Ok(s) => {
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.unsupported_as_slate(item, path, s)
@@ -315,7 +372,7 @@ impl MediaPool {
{
return Some(s.clone());
}
match self.open_file(path, services) {
match self.follow_media(path).and_then(|file| self.open_file(&file, services)) {
Ok(s) => {
// a proxy with fewer audio streams than the original (say one track of an OBS
// recording's seven) still stands in for the picture; the audio comes from the
@@ -323,7 +380,7 @@ impl MediaPool {
let audio_from = if s.info().audio_streams.len() < m.info.audio_streams.len() {
log::warn!("proxy has fewer audio streams than the original: {path}; audio from full-resolution media");
match &m.media {
MediaRef::File { path: original } if !m.offline => self.open_file(original, services).ok(),
MediaRef::File { path: original } if !m.offline => self.follow_media(original).and_then(|file| self.open_file(&file, services)).ok(),
_ => None,
}
} else {
+12 -10
View File
@@ -18,13 +18,13 @@
//!
//! [`MediaInfo`]: filmcraft_media::MediaInfo
use std::collections::{BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex, PoisonError};
use filmcraft_project::{ItemId, ItemKind, MediaRef, Project};
use serde_json::{Value, json};
use crate::{Result, Session};
use crate::{MediaPool, Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
@@ -44,13 +44,14 @@ fn stamp(_: &str) -> Option<Stamp> {
None
}
/// The files of `p`'s file media items (made-offline ones aside).
pub fn media_files(p: &Project) -> BTreeSet<String> {
/// The files of `p`'s file media items (made-offline ones aside): each item's path → the file the
/// media pool opens for it. Paths the pool does not follow ([`MediaPool::follow`]) are left out.
pub fn media_files(p: &Project, pool: &MediaPool) -> BTreeMap<String, String> {
p.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Media(m) if !m.offline => match &m.media {
MediaRef::File { path } => Some(path.clone()),
MediaRef::File { path } => Some((path.clone(), pool.follow(path).ok()?)),
_ => None,
},
_ => None,
@@ -60,7 +61,7 @@ pub fn media_files(p: &Project) -> BTreeSet<String> {
/// `media.refreshChanged`: stamp the project's media files now and read the changed ones again.
pub(crate) fn refresh_changed(s: &mut Session, _: &Value) -> Result<Value> {
let now = media_files(&s.project).into_iter().map(|p| (p.clone(), stamp(&p))).collect();
let now = media_files(&s.project, &s.media).into_iter().map(|(p, file)| (p, stamp(&file))).collect();
s.apply_media_stamps(now)
}
@@ -71,14 +72,14 @@ impl Session {
if self.media_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let files = media_files(&self.project);
let files = media_files(&self.project, &self.media);
if files.is_empty() {
return;
}
let slot: MediaScan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("media-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|p| (p.clone(), stamp(&p))).collect();
let stamps = files.into_iter().map(|(p, file)| (p, stamp(&file))).collect();
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps);
});
if spawned.is_ok() {
@@ -125,8 +126,9 @@ impl Session {
if !changed.contains(path) || failed.contains(path) {
continue;
}
let Ok(file) = self.media.follow(path) else { continue };
// Opened the way import opens it, so the info is what a new import would get.
match self.media.open_file(path, &*self.services) {
match self.media.open_file(&file, &*self.services) {
Ok(src) => {
let mut info = src.info().clone();
if info.kind == filmcraft_media::MediaKind::Still
@@ -134,7 +136,7 @@ impl Session {
{
v.frame_rate = crate::settings::timebase_rate(&self.prefs.media.indeterminate_timebase);
}
let identity = crate::relink::identity_of(&*self.services, path).ok();
let identity = crate::relink::identity_of(&*self.services, &file).ok();
fresh.push((*id, path.clone(), info, identity, src));
}
Err(_) => {
+47 -7
View File
@@ -163,8 +163,8 @@ fn base_commands() -> Vec<CommandSpec> {
create_multicam,
true,
),
spec("clip.multicamEnable", "Enable", &["Clip", "Multi-Camera"], r#"{"clips":[id]?,"enabled":bool?}"#, has_selection, enable, true),
spec("clip.multicamFlatten", "Flatten", &["Clip", "Multi-Camera"], r#"{"clips":[id]?}"#, has_selection, flatten, true),
spec("clip.multicamEnable", "Enable", &["Clip", "Multi-Camera"], r#"{"clips":[id]?,"enabled":bool?}"#, has_nested_selection, enable, true),
spec("clip.multicamFlatten", "Flatten", &["Clip", "Multi-Camera"], r#"{"clips":[id]?}"#, has_multicam_selection, flatten, true),
spec(
"multicam.switchAngle",
"Switch Multi-Camera Angle",
@@ -243,17 +243,19 @@ fn can_synchronize(s: &Session) -> std::result::Result<(), String> {
if sel.len() < 2 { Err("select at least two clips to synchronize".into()) } else { Ok(()) }
}
fn has_items(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) {
if selected_items(s).iter().any(|i| sync::project_clip(&s.project, *i).is_some()) {
Ok(())
} else {
Err("select clips in the Project panel".into())
Err("select clips in the Project panel or the Timeline".into())
}
}
/// Two clips or more: Merge Clips then opens its dialog, which says what is wrong with a
/// selection it can't merge (two video clips, no audio clip) instead of a greyed-out menu item.
fn has_two_items(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 {
if selected_items(s).iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 {
Ok(())
} else {
Err("select a video clip and audio clips in the Project panel".into())
Err("select a video clip and audio clips in the Project panel or the Timeline".into())
}
}
@@ -262,10 +264,30 @@ fn has_two_items(s: &Session) -> std::result::Result<(), String> {
fn items_p(s: &Session, p: &Value) -> Vec<ItemId> {
match p.get("items").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
None => s.state.project_selection.clone(),
None => selected_items(s),
}
}
/// The items Merge Clips and Create Multi-Camera Source Sequence work on without `items`: the
/// clips selected in the Project panel, or, with none there, the media of the clips selected in
/// the Timeline (with their linked partners), once each in selection order (#1362).
pub fn selected_items(s: &Session) -> Vec<ItemId> {
if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) {
return s.state.project_selection.clone();
}
let Some(q) = s.active_sequence() else { return Vec::new() };
let mut out = Vec::new();
for c in with_links(s, &s.state.selection) {
if let Some((_, it)) = q.find_item(c)
&& sync::project_clip(&s.project, it.item).is_some()
&& !out.contains(&it.item)
{
out.push(it.item);
}
}
out
}
fn method_p(p: &Value, cmd: &str) -> Result<Method> {
Method::from_params(p).map_err(|e| bad(cmd, e))
}
@@ -745,6 +767,24 @@ fn switch_angle(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"angle": angle, "changed": n}))
}
/// Clip ▸ Multi-Camera ▸ Enable: the selection (with its linked clips) holds a nested sequence
/// clip, as [`enable`] needs; the menu item is greyed out otherwise instead of failing when chosen.
fn has_nested_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let q = s.active_sequence().ok_or("no sequence")?;
let nested = with_links(s, &s.state.selection).iter().any(|c| q.find_item(*c).is_some_and(|(_, i)| s.project.sequence(i.item).is_some()));
if nested { Ok(()) } else { Err("select nested sequence or multi-camera clips".into()) }
}
/// Clip ▸ Multi-Camera ▸ Flatten: the selection (with its linked clips) holds an enabled
/// multi-camera clip, as [`flatten`] needs.
fn has_multicam_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let q = s.active_sequence().ok_or("no sequence")?;
let multicam = with_links(s, &s.state.selection).iter().any(|c| q.find_item(*c).is_some_and(|(_, i)| i.multicam.is_some_and(|m| m.enabled)));
if multicam { Ok(()) } else { Err("select multi-camera clips to flatten".into()) }
}
fn enable(s: &mut Session, p: &Value) -> Result<Value> {
let q = s.active_sequence().ok_or(EngineError::NoSequence)?.clone();
let clips = with_links(s, &clips_p(s, p));
+80
View File
@@ -328,6 +328,59 @@ const H: u32 = 90;
/// Three 4-second cameras (different scenes, own audio), a multi-camera source sequence by In
/// points, and a sequence holding one multi-camera clip on V1/A1. Returns (session, camera items,
/// source sequence, edit sequence).
/// #1362: with nothing selected in the Project panel, Create Multi-Camera Source Sequence and Merge
/// Clips take the media of the clips selected in the Timeline.
#[test]
fn multicam_from_clips_selected_in_the_timeline() {
let dir = tmp_dir("mc-timeline");
let files: Vec<PathBuf> = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect();
for (f, sc) in files.iter().zip([DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora]) {
make_movie(f, sc, W, H, 48);
}
let mut s = Session::default();
let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect();
let items = import(&mut s, &refs);
let mut p = (*s.project).clone();
let edit =
p.new_sequence("Edit", filmcraft_project::SequenceSettings { width: W, height: H, frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 1, None);
s.project = std::sync::Arc::new(p);
s.state.active_sequence = Some(edit);
for (k, item) in items.iter().enumerate() {
s.set_playhead(FrameRate::FPS_24.tick_of(k as i64 * 48));
s.execute("source.open", json!({"item": item.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
}
s.execute("project.select", json!({"items": []})).unwrap();
s.execute("timeline.select", json!({"clips": []})).unwrap();
assert!(!s.is_enabled("clip.createMulticam"), "nothing selected anywhere");
let clips: Vec<u64> = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect();
assert_eq!(clips.len(), 3);
s.execute("timeline.select", json!({"clips": clips})).unwrap();
assert!(s.is_enabled("clip.createMulticam") && s.is_enabled("clip.mergeClips"));
// Merge Clips opens on them, and says why it can't merge three video clips
assert!(s.execute("clip.mergeClips", json!({"method": "in"})).unwrap_err().to_string().contains("one video clip"));
let r = s.execute("clip.createMulticam", json!({"method": "in"})).unwrap();
let src = ItemId(r["sequence"].as_u64().unwrap());
let cameras: Vec<ItemId> = s.project.sequence(src).unwrap().video_tracks.iter().filter_map(|t| t.items.first().map(|c| c.item)).collect();
assert_eq!(cameras, items, "one camera per selected clip's media, in selection order");
// one video clip and an audio-only one, selected in the Timeline: Merge Clips takes them
s.state.active_sequence = Some(edit);
let wav = dir.join("room.wav");
std::fs::write(&wav, crate::voiceover::write_wav_f32(&vec![0.0; 48_000 * 2], 48_000)).unwrap();
let audio = import(&mut s, &[wav.as_path()])[0];
s.set_playhead(FrameRate::FPS_24.tick_of(200));
s.execute("source.open", json!({"item": audio.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let a = q.audio_tracks.iter().flat_map(|t| &t.items).find(|c| c.item == audio).unwrap().id.0;
s.execute("project.select", json!({"items": []})).unwrap();
s.execute("timeline.select", json!({"clips": [clips[0], a], "linked": false})).unwrap();
assert!(s.is_enabled("clip.mergeClips"));
let r = s.execute("clip.mergeClips", json!({"method": "in"})).unwrap();
assert!(r["item"].as_u64().is_some(), "{r}");
}
fn mc_session(audio: &str) -> (Session, Vec<ItemId>, ItemId, ItemId) {
let dir = tmp_dir("mc-edit");
let files: Vec<PathBuf> = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect();
@@ -841,3 +894,30 @@ fn perf_grid_of_four_1080p_angles() {
"Multi-Camera grid (4 × 1080p H.264, ¼-scale cells): {grid_ms:.1} ms/frame wall, {grid_cpu:.1} ms/frame CPU; one angle at ½ scale: {one_ms:.1} ms/frame wall (24 fps budget 41.7 ms)"
);
}
/// Clip ▸ Multi-Camera ▸ Enable / Flatten were enabled for any selection and then failed when
/// chosen with plain clips selected. They are enabled when the selection holds what they work on:
/// a nested sequence clip (Enable), an enabled multi-camera clip (Flatten). Naming the clips still
/// works with nothing selected.
#[test]
fn multicam_menu_items_are_enabled_only_for_clips_they_work_on() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let first = s.active_sequence().unwrap().video_tracks[0].items[0].id;
s.execute("timeline.select", json!({"clips": [first.0]})).unwrap();
assert!(!s.is_enabled("clip.multicamEnable"), "a plain clip is not a nest");
assert!(!s.is_enabled("clip.multicamFlatten"));
s.execute("clip.nest", json!({"name": "Nest"})).unwrap();
let nest = s.active_sequence().unwrap().all_tracks().flat_map(|t| &t.items).find(|i| s.project.sequence(i.item).is_some()).map(|i| i.id).unwrap();
s.execute("timeline.select", json!({"clips": [nest.0]})).unwrap();
assert!(s.is_enabled("clip.multicamEnable"), "a nested sequence clip can become multi-camera");
assert!(!s.is_enabled("clip.multicamFlatten"), "not multi-camera yet");
s.execute("clip.multicamEnable", json!({})).unwrap();
assert!(s.is_enabled("clip.multicamFlatten"));
// agents naming the clips do not depend on the selection
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(!s.is_enabled("clip.multicamFlatten"));
s.execute("clip.multicamFlatten", json!({"clips": [nest.0]})).unwrap();
let q = s.active_sequence().unwrap();
assert!(q.find_item(nest).is_none(), "flattened into the camera clips");
}
+16 -5
View File
@@ -535,8 +535,7 @@ impl PreviewStore {
}
}
let path = self.dir()?.join(name);
let bytes = std::fs::read(&path).ok()?;
let src = pool.open_bytes(name, bytes.into()).ok()?;
let src = open_preview_file(pool, name, &path)?;
let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner());
g.push((name.to_string(), src.clone()));
if g.len() > 6 {
@@ -546,6 +545,21 @@ impl PreviewStore {
}
}
/// Open a rendered preview file. Native builds read it in place (the container's index now,
/// frames on demand): previews are full-resolution ProRes, gigabytes a minute, and reading each
/// one whole filled memory during render and playback (#2185).
#[cfg(any(unix, windows))]
pub(crate) fn open_preview_file(_pool: &MediaPool, name: &str, path: &std::path::Path) -> Option<SharedSource> {
let reader = filmcraft_media::reader::FileReader::open(path).ok()?;
filmcraft_media::reader::open_reader_within(name, std::sync::Arc::new(reader), &filmcraft_codecs::reader_openers(), &[], 0).ok()
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn open_preview_file(pool: &MediaPool, name: &str, path: &std::path::Path) -> Option<SharedSource> {
let bytes = std::fs::read(path).ok()?;
pool.open_bytes(name, bytes.into()).ok()
}
fn need_state(seg: &Segment) -> BarState {
match seg.need {
Need::None => BarState::None,
@@ -639,7 +653,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
let (prog, res) = (job.progress.clone(), job.result.clone());
let nseg = todo.len();
let pool = s.media.clone();
let run = move || {
let t0 = web_time::Instant::now();
let mut bytes = 0u64;
@@ -679,8 +692,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
break;
}
store.add_video(&name);
// open it now so the first playback doesn't wait for the file read
let _ = store.open(&pool, &name);
}
Err(e) => {
let _ = std::fs::remove_file(&part);
+26
View File
@@ -282,3 +282,29 @@ fn float_wav_round_trip() {
fn bar_state_serializes_lowercase() {
assert_eq!(serde_json::to_value(BarState::Green).unwrap(), json!("green"));
}
/// #2185: preview files are read in place, never whole. Rendered previews still play from their
/// files, and a file only a whole-file reader could open is refused rather than loaded.
#[test]
fn preview_files_are_streamed_not_read_whole() {
let (mut s, _) = session("stream");
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
job_ok(&s, &v);
let seq = s.state.active_sequence.unwrap();
assert!(s.previews.frame(&s.media, &s.project, seq, 5, 1.0).is_some(), "a rendered frame plays from its file");
// a still image opens only from its whole bytes: as a preview file it is not opened
let dir = s.previews.dir().unwrap();
let png = dir.join("still.png");
// a 1×1 PNG
const PNG: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84,
120, 218, 99, 100, 96, 248, 95, 15, 0, 2, 135, 1, 128, 235, 71, 186, 146, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
std::fs::write(&png, PNG).unwrap();
assert!(s.media.open_bytes("still.png", PNG.into()).is_ok(), "the image opens from its bytes");
// (the web, without a file reader, still reads preview files whole)
if cfg!(any(unix, windows)) {
assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none());
}
let _ = std::fs::remove_dir_all(dir);
}
+128 -12
View File
@@ -13,7 +13,7 @@
//! | `file.mediaPropertiesFile` / `file.mediaProperties` | File ▸ Get Media File Properties for ▸ File… / Selection… (⇧⌘H) |
//! | `file.projectSettings.general` / `.scratchDisks` | File ▸ Project Settings ▸ General… / Scratch Disks… |
//! | `edit.find` / `edit.findNext` | Edit ▸ Find… (⌘F) / Find Next |
//! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files |
//! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files (regular files with a media extension only) |
//! | `file.reveal` | Project panel ▸ Reveal in Finder / Show in Explorer: the frontend reveals the media file |
//! | `clip.editOffline` | Clip ▸ Edit Offline… |
//! | `clip.sourceSettings` | Clip ▸ Source Settings… |
@@ -687,6 +687,8 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
s.previews.reset_temp();
}
s.media.clear();
s.media.clear_stored();
s.media.add_stored(&s.project, Some(path.as_str()));
s.revision += 1;
s.saved_revision = s.revision;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
@@ -953,33 +955,120 @@ pub fn scratch_paths(s: &Session) -> Value {
let sc = &s.project.settings.scratch;
let project_dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_string_lossy().to_string());
let same = |o: &Option<String>| o.clone().or(project_dir.clone()).unwrap_or_else(|| "(data directory until the project is saved)".into());
let auto_save = match s.path.as_deref() {
Some(p) => trusted_scratch(sc.auto_save.as_deref(), Some(p), &s.prefs.auto_save.scratch_folders)
.unwrap_or_else(|| filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)))
.to_string_lossy()
.to_string(),
None => same(&None),
};
let scratch = |o: &Option<String>| {
trusted_scratch(o.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_else(|| same(&None))
};
json!({
"captured": {"setting": sc.captured, "path": same(&sc.captured)},
"captured": {"setting": sc.captured, "path": scratch(&sc.captured)},
"videoPreviews": {"setting": sc.video_previews, "path": previews_dir(s).map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| same(&sc.video_previews))},
"audioPreviews": {"setting": sc.audio_previews, "path": same(&sc.audio_previews)},
"autoSave": {"setting": sc.auto_save, "path": sc.auto_save.clone().or_else(|| s.path.as_deref().map(|p| filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)).to_string_lossy().to_string())).unwrap_or_else(|| same(&None))},
"audioPreviews": {"setting": sc.audio_previews, "path": scratch(&sc.audio_previews)},
"autoSave": {"setting": sc.auto_save, "path": auto_save},
})
}
/// Render previews folder of the open project: Scratch Disks ▸ Video Previews / `<project
/// name>`, else `FilmCraft Previews/<project name>` next to the project (None while unsaved).
pub fn previews_dir(s: &Session) -> Option<std::path::PathBuf> {
s.path.as_deref().map(|p| previews_dir_for(&s.project, p))
s.path.as_deref().map(|p| previews_dir_for(&s.project, p, &s.prefs.auto_save.scratch_folders))
}
/// [`previews_dir`] for `project` saved at `path`.
pub fn previews_dir_for(project: &Project, path: &str) -> std::path::PathBuf {
match &project.settings.scratch.video_previews {
Some(d) if !d.is_empty() => {
/// [`previews_dir`] for `project` saved at `path`; `chosen` as in [`trusted_scratch`].
pub fn previews_dir_for(project: &Project, path: &str, chosen: &[String]) -> std::path::PathBuf {
match trusted_scratch(project.settings.scratch.video_previews.as_deref(), Some(path), chosen) {
Some(d) => {
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
std::path::Path::new(d).join(stem)
d.join(stem)
}
_ => crate::previews::dir_for_project(path),
None => crate::previews::dir_for_project(path),
}
}
/// The Scratch Disks folder `dir` from the settings of the project saved at `project_path`, if
/// FilmCraft may write there: a folder chosen in Project Settings ▸ Scratch Disks on this computer
/// (`chosen`, kept in the preferences), or a folder inside the project's own folder. Other folders
/// named in a project file, and paths [`crate::doc_path::resolve`] refuses (other computers,
/// devices), give None: the default location.
pub fn trusted_scratch(dir: Option<&str>, project_path: Option<&str>, chosen: &[String]) -> Option<std::path::PathBuf> {
let dir = dir.filter(|d| !d.is_empty())?;
let resolved = std::path::PathBuf::from(crate::doc_path::resolve(dir, project_path.map(std::path::Path::new)).ok()?);
if chosen.iter().any(|c| c == dir) {
return Some(resolved);
}
let project = std::path::absolute(project_path?).ok()?;
let root = project.parent().filter(|r| !r.as_os_str().is_empty())?;
inside(&resolved, root).then_some(resolved)
}
/// Whether `dir` lies inside `root`, also after following the links in the part that exists.
fn inside(dir: &std::path::Path, root: &std::path::Path) -> bool {
use std::path::Component;
if !dir.starts_with(root) || dir.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) {
return false;
}
let mut existing = dir;
while existing.symlink_metadata().is_err() {
let Some(up) = existing.parent() else { return false };
existing = up;
}
match (std::fs::canonicalize(existing), std::fs::canonicalize(root)) {
(Ok(real), Ok(real_root)) => real.starts_with(real_root),
_ => false,
}
}
/// Remember the Scratch Disks folders chosen in this session, so projects naming them may use
/// them later (see [`trusted_scratch`]).
fn remember_scratch_folders(s: &mut Session, dirs: &[String]) {
const KEEP: usize = 64;
let mut prefs = s.prefs.clone();
let list = &mut prefs.auto_save.scratch_folders;
for d in dirs {
list.retain(|x| x != d);
list.push(d.clone());
}
let excess = list.len().saturating_sub(KEEP);
list.drain(..excess);
if prefs != s.prefs
&& let Err(e) = s.set_prefs(prefs)
{
log::warn!("saving preferences: {e}");
}
}
/// Log and tell the user once when the project just opened names Scratch Disks folders that
/// FilmCraft will not write to (see [`trusted_scratch`]).
pub(crate) fn warn_untrusted_scratch(s: &mut Session) {
let sc = &s.project.settings.scratch;
let ignored: Vec<String> = [&sc.auto_save, &sc.video_previews, &sc.captured, &sc.audio_previews]
.into_iter()
.filter_map(|d| d.as_deref().filter(|d| !d.is_empty()))
.filter(|d| trusted_scratch(Some(d), s.path.as_deref(), &s.prefs.auto_save.scratch_folders).is_none())
.map(str::to_string)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if ignored.is_empty() {
return;
}
let list = ignored.join(", ");
log::warn!("project scratch disks outside the project folder not used: {list}");
s.toast(format!(
"Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it."
));
}
fn project_settings_scratch(s: &mut Session, p: &Value) -> Result<Value> {
let mut sc = s.project.settings.scratch.clone();
let mut chosen = Vec::new();
for (k, slot) in
[("captured", &mut sc.captured), ("videoPreviews", &mut sc.video_previews), ("audioPreviews", &mut sc.audio_previews), ("autoSave", &mut sc.auto_save)]
{
@@ -987,10 +1076,19 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result<Value> {
None => {}
Some(Value::Null) => *slot = None,
Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None,
Some(Value::String(x)) => *slot = Some(x.clone()),
Some(Value::String(x))
if matches!(crate::doc_path::resolve(x, s.path.as_deref().map(std::path::Path::new)), Err(crate::doc_path::NotFollowed::Refused(_))) =>
{
return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer, not a network share or device")));
}
Some(Value::String(x)) => {
chosen.push(x.clone());
*slot = Some(x.clone());
}
Some(_) => return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` is a folder path or null"))),
}
}
remember_scratch_folders(s, &chosen);
if sc != s.project.settings.scratch {
s.edit("Scratch Disks", |pr, _| {
pr.settings.scratch = sc;
@@ -1005,6 +1103,21 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result<Value> {
// Edit Original, clip items
// ---------------------------------------------------------------------------------------------
/// Whether Edit Original may hand `path` to the system's default app: a regular file (after
/// following links) whose name, and the name it links to, have an extension the importer takes.
/// Anything else (a folder or app bundle, a script, a shortcut, a launcher) is refused.
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned());
let real = std::fs::canonicalize(path).map_err(|e| format!("can't open “{name}”: {e}"))?;
if !real.is_file() {
return Err(format!("“{name}” is not a file"));
}
if !filmcraft_media::is_importable(std::path::Path::new(path)) || !filmcraft_media::is_importable(&real) {
return Err(format!("Edit Original opens media files only; “{name}” is not one"));
}
Ok(())
}
fn edit_original(s: &mut Session, p: &Value) -> Result<Value> {
let mut paths = Vec::new();
for id in targets(s, p) {
@@ -1017,6 +1130,9 @@ fn edit_original(s: &mut Session, p: &Value) -> Result<Value> {
if paths.is_empty() {
return Err(EngineError::Other("select a clip with a media file".into()));
}
for path in &paths {
openable_original(path).map_err(|e| bad("edit.editOriginal", e))?;
}
Ok(json!({"paths": paths}))
}
+40
View File
@@ -322,6 +322,46 @@ fn media_properties_settings_and_scratch_disks() {
assert!(s.execute("file.projectSettings.scratchDisks", json!({"captured": 5})).is_err());
}
/// Edit Original hands only regular media files to the system opener.
#[test]
fn edit_original_opens_media_files_only() {
let d = tmp_dir("orig-kinds");
for f in ["clip.mov", "Still.PNG", "run.bat", "app.desktop", "clip.mov.lnk"] {
std::fs::write(d.join(f), b"").unwrap();
}
for f in ["Tool.app", "folder.mov"] {
std::fs::create_dir_all(d.join(f)).unwrap();
}
let ok = |f: &str| crate::project_tools::openable_original(&d.join(f).to_string_lossy()).is_ok();
assert!(ok("clip.mov") && ok("Still.PNG"));
for f in ["run.bat", "app.desktop", "clip.mov.lnk", "Tool.app", "folder.mov", "missing.mov", ""] {
assert!(!ok(f), "{f}");
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("app.desktop"), d.join("link.mov")).unwrap();
assert!(!ok("link.mov"), "a media name linking to another kind of file");
std::os::unix::fs::symlink(d.join("clip.mov"), d.join("link.desktop")).unwrap();
assert!(!ok("link.desktop"), "another kind of name linking to a media file");
}
// The command refuses a clip whose file is not media, and returns no paths to open.
let path = d.join("take.mp4");
make_movie(&path, DemoScene::Aurora, 64, 36, 8);
let (mut s, items, _) = session_with(&[&path]);
let id = items[0];
let bat = d.join("run.bat").to_string_lossy().into_owned();
s.edit("relink", |pr, _| {
if let Some(ItemKind::Media(m)) = pr.item_mut(id).map(|it| &mut it.kind) {
m.media = filmcraft_project::MediaRef::File { path: bat };
}
Ok(())
})
.unwrap();
s.execute("project.select", json!({"items": [id.0]})).unwrap();
let e = s.execute("edit.editOriginal", json!({})).unwrap_err().to_string();
assert!(e.contains("run.bat"), "{e}");
}
#[test]
fn edit_original_update_metadata_and_clip_items() {
let dir = tmp_dir("orig");
+11 -3
View File
@@ -347,9 +347,10 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let pr = preset(str_p(p, "preset").unwrap_or(DEFAULT_PROXY_PRESET)).ok_or_else(|| bad("media.createProxies", "unknown preset (see media.proxyPresets)"))?;
let items = items_param(s, p);
// default: Project Settings ▸ Scratch Disks ▸ Captured and Generated, else next to the media
let dest = str_p(p, "destination")
.map(str::to_string)
.or_else(|| s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()).map(|d| format!("{d}/Proxies")));
let dest = str_p(p, "destination").map(str::to_string).or_else(|| {
crate::project_tools::trusted_scratch(s.project.settings.scratch.captured.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)
.map(|d| d.join("Proxies").to_string_lossy().into_owned())
});
let mut work = Vec::new();
let mut taken = Vec::new();
let mut skipped = Vec::new();
@@ -362,6 +363,13 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
skipped.push(json!({"item": i.0, "reason": format!("{name} has no video to proxy")}));
continue;
}
let path = match s.media.follow(&path) {
Ok(file) => file,
Err(why) => {
skipped.push(json!({"item": i.0, "reason": format!("{path} is not opened: {why}")}));
continue;
}
};
let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name);
let out = unique_path(&proxy_dir(&path, dest.as_deref()), &stem, "_Proxy", pr.extension, &taken).to_string_lossy().into_owned();
taken.push(out.clone());
+20 -9
View File
@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, bool_p, str_p, u64_p};
use crate::{EngineError, Result, Services, Session};
use crate::{EngineError, MediaPool, Result, Services, Session};
/// Bytes hashed at each end of the file.
pub const FINGERPRINT_SPAN: usize = 1 << 20;
@@ -100,18 +100,20 @@ fn split_ext(name: &str) -> (&str, &str) {
}
}
/// Every media item whose file is missing (or that was made offline), in project order.
pub fn scan(p: &Project, services: &dyn Services) -> Vec<Missing> {
/// Every media item whose file is missing (or that was made offline), in project order. A
/// stored path the media pool does not follow ([`MediaPool::follow`]) counts as missing.
pub fn scan(p: &Project, pool: &MediaPool, services: &dyn Services) -> Vec<Missing> {
let exists = |path: &str| pool.follow(path).is_ok_and(|file| services.file_size(&file).is_ok());
let mut out = Vec::new();
for it in p.items.values() {
let ItemKind::Media(m) = &it.kind else { continue };
let MediaRef::File { path } = &m.media else { continue };
let status = if m.offline {
"offline"
} else if services.file_size(path).is_err() {
} else if !exists(path) {
"missing"
} else if let Some(MediaRef::File { path: pp }) = &m.proxy
&& services.file_size(pp).is_err()
&& !exists(pp)
{
"proxyMissing"
} else {
@@ -132,7 +134,7 @@ pub fn scan(p: &Project, services: &dyn Services) -> Vec<Missing> {
/// Rescan and remember the result. Returns the list.
pub fn refresh(s: &mut Session) -> Vec<Missing> {
let list = scan(&s.project, &*s.services);
let list = scan(&s.project, &s.media, &*s.services);
s.offline.missing = list.iter().filter(|m| m.status != "proxyMissing").map(|m| ItemId(m.item)).collect();
list
}
@@ -352,6 +354,9 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) -
}
Ok(())
})?;
for p in &plans {
s.media.allow(&p.cand.path);
}
for (item, path, src) in sources {
s.media.insert_file(item, &path, src);
}
@@ -368,7 +373,7 @@ fn others(s: &Session, skip: ItemId, old: &str, new: &str, o: &MatchOptions) ->
let remap = derive_remap(old, new);
let new_dir = Path::new(new).parent().map(Path::to_path_buf);
let mut out = Vec::new();
for m in scan(&s.project, &*s.services) {
for m in scan(&s.project, &s.media, &*s.services) {
let id = ItemId(m.item);
if id == skip || m.status != "missing" {
continue;
@@ -431,7 +436,7 @@ pub fn relink(s: &mut Session, p: &Value) -> Result<Value> {
/// `media.autoRelink`: `{from, to}` remaps a folder prefix; `{folder}` searches a folder tree.
pub fn auto_relink(s: &mut Session, p: &Value) -> Result<Value> {
let o = MatchOptions::from_params(p);
let missing: Vec<Missing> = scan(&s.project, &*s.services).into_iter().filter(|m| m.status == "missing").collect();
let missing: Vec<Missing> = scan(&s.project, &s.media, &*s.services).into_iter().filter(|m| m.status == "missing").collect();
let mut plans = Vec::new();
let mut failed = Vec::new();
if let (Some(from), Some(to)) = (str_p(p, "from"), str_p(p, "to")) {
@@ -505,7 +510,7 @@ pub fn search(s: &Session, p: &Value) -> Result<Value> {
let o = MatchOptions::from_params(p);
let items: Vec<ItemId> = match u64_p(p, "item") {
Some(i) => vec![ItemId(i)],
None => scan(&s.project, &*s.services).into_iter().filter(|m| m.status == "missing").map(|m| ItemId(m.item)).collect(),
None => scan(&s.project, &s.media, &*s.services).into_iter().filter(|m| m.status == "missing").map(|m| ItemId(m.item)).collect(),
};
let index = index_folder(Path::new(folder), 20_000);
let mut out = Vec::new();
@@ -734,6 +739,12 @@ pub fn commands() -> Vec<CommandSpec> {
/// After a project opens: find missing files and ask for them.
pub fn on_open(s: &mut Session) -> usize {
let list = refresh(s);
let refused: Vec<String> =
list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect();
if !refused.is_empty() {
log::warn!("media paths stored in the project not opened: {}", refused.join(", "));
s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len()));
}
s.offline.prompt = !s.offline.missing.is_empty();
if !s.offline.missing.is_empty() {
s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len()));
+5
View File
@@ -109,6 +109,9 @@ pub struct AppearancePrefs {
/// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed
/// percentage of [`UI_SCALES`] (`100` = one point per physical pixel).
pub ui_scale: String,
/// "Animate zoom, scrolling and menus" (#2325): off, the Timeline zooms and scrolls in one
/// step and menus, collapsing sections and tooltips appear without a fade.
pub animations: bool,
}
impl Default for AppearancePrefs {
@@ -122,6 +125,7 @@ impl Default for AppearancePrefs {
accessible_contrast: false,
highlight_color: DEFAULT_HIGHLIGHT.into(),
ui_scale: "auto".into(),
animations: true,
}
}
}
@@ -1053,6 +1057,7 @@ static CATEGORIES: &[Category] = &[
b("appearance.accessibleContrast", "Accessible color contrast", true),
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
b("appearance.animations", "Animate zoom, scrolling and menus", true),
],
},
Category {
+195
View File
@@ -0,0 +1,195 @@
//! Media paths stored in a project or an interchange file are opened only when they name a file
//! on this computer or on the document's own share (`doc_path`): the open-time scan, rendering
//! and the growing-files look never reach another computer, while a path beside the document and
//! a file the user links in this session still open.
use std::path::Path;
use std::sync::{Arc, Mutex, PoisonError};
use filmcraft_media::DemoScene;
use filmcraft_project::{ItemId, MediaRef};
use filmcraft_render::offline::OfflineReason;
use serde_json::json;
use crate::media_test_util::{make_movie, session_with, tmp_dir};
use crate::{FsServices, Services, Session};
const W: u32 = 160;
const H: u32 = 90;
/// The file system, remembering every path it is asked about.
#[derive(Default)]
struct Recording(Arc<Mutex<Vec<String>>>);
impl Recording {
fn note(&self, path: &str) {
self.0.lock().unwrap_or_else(PoisonError::into_inner).push(path.to_string());
}
}
impl Services for Recording {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.note(path);
FsServices.read_file(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
self.note(path);
FsServices.write_file(path, data)
}
fn file_size(&self, path: &str) -> std::io::Result<u64> {
self.note(path);
FsServices.file_size(path)
}
fn read_range(&self, path: &str, offset: u64, len: usize) -> std::io::Result<Vec<u8>> {
self.note(path);
FsServices.read_range(path, offset, len)
}
fn reader(&self, path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
self.note(path);
FsServices.reader(path)
}
fn list_dir(&self, dir: &str) -> Option<std::io::Result<Vec<String>>> {
self.note(dir);
FsServices.list_dir(dir)
}
}
/// A session reading files through [`Recording`], and the paths it was asked about.
fn recording_session() -> (Session, Arc<Mutex<Vec<String>>>) {
let seen = Arc::<Mutex<Vec<String>>>::default();
let s = Session { services: Arc::new(Recording(seen.clone())), ..Session::default() };
(s, seen)
}
/// Paths that name another computer, or `//<local path>` (on Unix the system reads it as the
/// local file; the resolver reads it as a share and refuses it).
fn foreign(paths: &[String]) -> Vec<&String> {
paths.iter().filter(|p| p.contains("192.0.2.1") || p.starts_with("//") || p.starts_with(r"\\") || p.starts_with("file:")).collect()
}
/// `//<path>` for an absolute Unix path (None elsewhere).
fn slashed(path: &Path) -> Option<String> {
cfg!(unix).then(|| format!("/{}", path.display()))
}
#[test]
fn stored_media_paths_open_only_on_this_computer_or_beside_the_project() {
let root = tmp_dir("stored-paths");
let media = root.join("Media");
let project = root.join("Project");
std::fs::create_dir_all(&media).unwrap();
std::fs::create_dir_all(&project).unwrap();
let a = media.join("a.mov");
let beside = project.join("b.mov");
make_movie(&a, DemoScene::Aurora, W, H, 12);
make_movie(&beside, DemoScene::CityNight, W, H, 12);
let mut stored = vec![r"\\192.0.2.1\share\a.mov".to_string(), "file://192.0.2.1/s/a.mov".to_string()];
stored.extend(slashed(&a));
let refused = stored.len();
stored.push("b.mov".to_string());
let files: Vec<&Path> = (0..refused).map(|_| a.as_path()).chain([beside.as_path()]).collect();
let (mut s, items, _) = session_with(&files);
let p = Arc::make_mut(&mut s.project);
for (id, path) in items.iter().zip(&stored) {
p.item_mut(*id).unwrap().as_media_mut().unwrap().media = MediaRef::File { path: path.clone() };
}
let doc = project.join("p.fcproj").to_string_lossy().into_owned();
s.execute("file.save", json!({"path": doc})).unwrap();
let (mut t, seen) = recording_session();
let r = t.execute("file.open", json!({"path": doc})).unwrap();
assert_eq!(r["missingMedia"], json!(refused), "{r}");
for &id in &items {
assert!(t.media.full_res_source(&t.project, id, &*t.services).is_some());
}
t.execute("media.refreshChanged", json!({})).unwrap();
let asked = seen.lock().unwrap().clone();
assert!(foreign(&asked).is_empty(), "stored paths on other computers were opened: {:?}", foreign(&asked));
for (id, path) in items.iter().zip(&stored).take(refused) {
let st = t.media.offline_status(*id).unwrap_or_else(|| panic!("{path} must render offline"));
assert_eq!(st.reason, OfflineReason::Missing);
assert!(st.error.contains("is not opened"), "{st:?}");
assert!(!t.media_stamps.contains_key(path), "{path} was stamped");
}
let rel = items[refused];
assert_eq!(t.media.offline_status(rel), None, "a path beside the project opens");
assert!(t.media_stamps.contains_key("b.mov"));
// linking a file in this session opens it, even at the path the project stored
if let Some(path) = slashed(&a) {
let id = items[refused - 1];
t.execute("media.relink", json!({"item": id.0, "path": path, "force": true})).unwrap();
t.media.remove(id);
assert!(t.media.full_res_source(&t.project, id, &*t.services).is_some());
assert_eq!(t.media.offline_status(id), None, "a file linked in this session opens");
}
}
/// A one-clip FCP7 XML sequence using the file at `pathurl`.
fn xml(pathurl: &str) -> String {
let rate = "<rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>";
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4">
<sequence id="sequence-1">
<name>Cut</name>
<duration>12</duration>
{rate}
<media>
<video>
<format><samplecharacteristics>{rate}<width>{W}</width><height>{H}</height></samplecharacteristics></format>
<track>
<clipitem id="clipitem-1">
<name>shot</name>
{rate}
<start>0</start><end>12</end><in>0</in><out>12</out>
<file id="file-1">
<name>shot.mov</name>
<pathurl>{pathurl}</pathurl>
{rate}
<duration>12</duration>
</file>
</clipitem>
</track>
</video>
</media>
</sequence>
</xmeml>
"#
)
}
#[test]
fn interchange_media_paths_open_only_on_this_computer_or_beside_the_document() {
let root = tmp_dir("stored-paths-xml");
std::fs::create_dir_all(root.join("media")).unwrap();
let shot = root.join("media").join("shot.mov");
make_movie(&shot, DemoScene::Aurora, W, H, 12);
let mut urls = vec!["file://192.0.2.1/s/shot.mov".to_string()];
urls.extend(slashed(&shot).map(|p| format!("file://{p}")));
for (k, url) in urls.iter().enumerate() {
let doc = root.join(format!("refused-{k}.xml"));
std::fs::write(&doc, xml(url)).unwrap();
let (mut s, seen) = recording_session();
let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap();
assert_eq!(r["documents"][0]["linkedMedia"], json!(0), "{r}");
let asked = seen.lock().unwrap().clone();
assert!(foreign(&asked).is_empty(), "{url} was opened: {:?}", foreign(&asked));
let ids: Vec<ItemId> = s.project.items.keys().copied().collect();
for id in ids {
s.media.full_res_source(&s.project, id, &*s.services);
}
let asked = seen.lock().unwrap().clone();
assert!(foreign(&asked).is_empty(), "{url} was rendered: {:?}", foreign(&asked));
}
let doc = root.join("beside.xml");
std::fs::write(&doc, xml("media/shot.mov")).unwrap();
let (mut s, _) = recording_session();
let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap();
assert_eq!(r["documents"][0]["linkedMedia"], json!(1), "a path beside the document links: {r}");
}
+35
View File
@@ -114,3 +114,38 @@ fn place_on_an_audio_track_places_the_sound_there_and_nothing_else() {
s.execute("timeline.place", json!({"item": with_picture.0, "seconds": 600.0})).unwrap();
assert_eq!(counts(&s), [2, 1, 2, 2]);
}
/// Track colour labels (#1186): set, shown by `sequence.inspect`, saved with the project,
/// cleared with "" / null, one undo step each; unknown names are refused.
#[test]
fn a_track_carries_a_colour_label() {
let mut s = demo();
let label = |s: &mut Session, kind: &str| s.execute("sequence.inspect", json!({})).unwrap()[kind][0]["label"].clone();
assert_eq!(label(&mut s, "video"), json!(null));
s.execute("timeline.setTrack", json!({"track": "V1", "label": "Forest"})).unwrap();
s.execute("timeline.setTrack", json!({"track": "A1", "label": "Mango"})).unwrap();
assert_eq!(label(&mut s, "video"), json!("Forest"));
assert_eq!(label(&mut s, "audio"), json!("Mango"));
// saved with the project, and a project from before the field still loads
let text = serde_json::to_string(&*s.project).unwrap();
let back: filmcraft_project::Project = serde_json::from_str(&text).unwrap();
let q = back.sequence(s.state.active_sequence.unwrap()).unwrap();
assert_eq!(q.video_tracks[0].label, Some(filmcraft_project::Label::Forest));
let mut old = serde_json::to_value(&q.video_tracks[0]).unwrap();
old.as_object_mut().unwrap().remove("label");
let old: filmcraft_project::Track = serde_json::from_value(old).unwrap();
assert_eq!(old.label, None);
// a plain track does not write the key at all
assert!(serde_json::to_value(&q.video_tracks[1]).unwrap().get("label").is_none());
// clear, undo, refuse
s.execute("timeline.setTrack", json!({"track": "V1", "label": ""})).unwrap();
assert_eq!(label(&mut s, "video"), json!(null));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(label(&mut s, "video"), json!("Forest"));
s.execute("timeline.setTrack", json!({"track": "V1", "label": null})).unwrap();
assert_eq!(label(&mut s, "video"), json!(null));
assert!(matches!(s.execute("timeline.setTrack", json!({"track": "V1", "label": "Chartreuse"})), Err(EngineError::BadParams { .. })));
// other settings leave the label alone
s.execute("timeline.setTrack", json!({"track": "A1", "muted": true})).unwrap();
assert_eq!(label(&mut s, "audio"), json!("Mango"));
}
+4 -2
View File
@@ -396,8 +396,10 @@ pub(crate) fn media_dir(s: &Session, p: &Value, fallback: &str) -> String {
if let Some(d) = str_p(p, "dir") {
return d.to_string();
}
if let Some(d) = s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()) {
return d;
if let Some(d) =
crate::project_tools::trusted_scratch(s.project.settings.scratch.captured.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)
{
return d.to_string_lossy().into_owned();
}
if let Some(d) = s.path.as_deref().and_then(|x| std::path::Path::new(x).parent()).filter(|d| !d.as_os_str().is_empty()) {
return d.to_string_lossy().into_owned();
+14
View File
@@ -199,3 +199,17 @@ fn the_software_h264_sei_is_the_static_metadata_laid_out_as_messages() {
want.push(0x80);
assert_eq!(sei, want);
}
/// #2142: SDR gets its `colr` box as well (nclc in MOV, nclx in MP4), and no HDR metadata.
#[test]
fn sdr_gets_a_colr_box_and_no_hdr_metadata() {
let mut e = filmcraft_isobmff::SampleEntry::prores(filmcraft_isobmff::FourCc(*b"apch"), 64, 64);
ColorSignal::default().apply_to(&mut e, true);
let v = e.video.as_ref().unwrap();
assert_eq!(v.color, Some(filmcraft_isobmff::ColorInfo::Nclc { primaries: 1, transfer: 1, matrix: 1 }));
assert!(v.mastering_display.is_none() && v.content_light.is_none());
let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64);
ColorSignal::default().apply_to(&mut e, false);
let v = e.video.as_ref().unwrap();
assert_eq!(v.color, Some(filmcraft_isobmff::ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }));
}
+5 -3
View File
@@ -297,7 +297,8 @@ impl Exporter {
(self.f1 - self.f0).max(0) as u64
}
/// Frames per [`Self::step`] (default: one per core); 1 keeps web UI frames short.
/// Requested frames per [`Self::step`] (default: one per core), capped by GPU renderer
/// capacity and the overlap memory budget. 1 keeps web UI frames short.
pub fn set_batch(&mut self, n: i64) {
self.batch = n.max(1);
}
@@ -333,9 +334,10 @@ impl Exporter {
self.ahead.as_ref().map_or(0, |a| a.frames.len())
}
/// Frames in the next batch: [`Self::set_batch`], lowered to the memory budget when overlapping.
/// Frames in the next batch, bounded before Rayon can steal nested frame tasks.
fn batch_len(&self) -> i64 {
if self.overlapping() { overlap_batch(self.batch, frame_bytes(self.pipe.w, self.pipe.h, self.pipe.hdr_out), self.budget) } else { self.batch }
let batch = self.pipe.render_batch_limit(self.batch);
if self.overlapping() { overlap_batch(batch, frame_bytes(self.pipe.w, self.pipe.h, self.pipe.hdr_out), self.budget) } else { batch }
}
fn sample_at_frame(&self, f: i64, sr: u32) -> i64 {
+4 -4
View File
@@ -520,11 +520,10 @@ impl ColorSignal {
if self.matrix == 9 { (0.2627, 0.0593) } else { (0.2126, 0.0722) }
}
/// Sample-entry boxes: `colr` (nclx for MP4, nclc for MOV) plus `mdcv`/`clli` for PQ
/// (mastering display BT.2020/D65, 1000/0.0001 cd/m²; MaxCLL/MaxFALL 0 = unknown).
/// (mastering display BT.2020/D65, 1000/0.0001 cd/m²; MaxCLL/MaxFALL 0 = unknown). SDR gets
/// its `colr` too: Apple's media stack (QuickTime Player, Final Cut Pro) reads the colour of
/// ProRes, DNxHR and APV only from this box, not from their frame headers (#2142).
pub fn apply_to(&self, e: &mut SampleEntry, mov: bool) {
if !self.is_hdr() {
return;
}
if let Some(v) = e.video.as_mut() {
let (p, t, m) = (self.primaries as u16, self.transfer as u16, self.matrix as u16);
v.color = Some(if mov {
@@ -1924,6 +1923,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
f if f.is_image_sequence() || f == Format::Gif => {
let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?;
let batch = pipe.render_batch_limit(batch);
let (f0, f1) = frame_span(pipe.rate, range);
let count = (f1 - f0) as u64;
let (w, h) = (pipe.w, pipe.h);
+17 -1
View File
@@ -52,6 +52,8 @@ impl Geometry {
/// GPU renderers shared by the export workers: a worker takes a free one for a frame and puts it
/// back (a single renderer behind a mutex made the workers queue, GPU export ~20 % slower than CPU).
struct RendererPool {
/// Initial capacity, also the maximum number of frame tasks admitted at once.
capacity: usize,
renderers: std::sync::Mutex<Vec<Box<dyn FrameRenderer>>>,
available: std::sync::Condvar,
/// A renderer panicked: the pool is retired and the rest of the export renders on the CPU.
@@ -62,13 +64,17 @@ impl RendererPool {
/// `None` for an empty list (a pool nobody could take from).
fn new(renderers: Vec<Box<dyn FrameRenderer>>) -> Option<Self> {
(!renderers.is_empty()).then(|| Self {
capacity: renderers.len(),
renderers: std::sync::Mutex::new(renderers),
available: std::sync::Condvar::new(),
failed: std::sync::atomic::AtomicBool::new(false),
})
}
/// Run `f` with a free renderer, waiting for one if all are busy. `None` once a renderer has
/// Run `f` with a free renderer. Frame batches must respect `Pipeline::render_batch_limit`:
/// a renderer can run nested Rayon work, which may steal another frame on the same thread.
/// Admitting more frame tasks than renderers could then deadlock this condition variable
/// against the suspended outer frame's renderer. `None` once a renderer has
/// panicked: the panicking renderer is dropped (its state can't be trusted) and so are the
/// others as they come back, so the caller falls back to the CPU for the rest of the export.
fn with<R>(&self, f: impl FnOnce(&mut dyn FrameRenderer) -> R) -> Option<R> {
@@ -146,6 +152,16 @@ impl Drop for Pipeline {
}
impl Pipeline {
/// Bound outer frame tasks before spawning them, not inside a Rayon job. A renderer can
/// invoke nested parallel decoding/conversion; those joins may steal another frame task.
/// With at most one active batch and no more tasks than the initial renderer capacity,
/// every stolen frame can acquire a renderer without waiting for an ancestor to return it.
/// Keep the immutable capacity even after a failed renderer retires the pool.
pub(crate) fn render_batch_limit(&self, requested: i64) -> i64 {
let requested = requested.max(1);
self.renderer.as_ref().map_or(requested, |pool| requested.min(i64::try_from(pool.capacity).unwrap_or(i64::MAX)))
}
pub fn new(project: Arc<Project>, seq: ItemId, settings: &ExportSettings, hdr_out: bool) -> Result<Self> {
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
q.settings.validate().map_err(ExportError::Unsupported)?;
+25
View File
@@ -126,6 +126,31 @@ fn prores_export_roundtrip() {
assert!(f[0] > 240 && f[1] < 15 && f[2] < 15, "{:?}", &f[..4]);
}
/// #2142: SDR ProRes, DNxHR and APV (MOV) and H.264 (MP4) exports carry a `colr` box with the
/// export's BT.709 colour: Apple's media stack reads the colour of the MOV codecs only from there.
#[test]
fn sdr_exports_carry_a_colr_box() {
use filmcraft_isobmff::ColorInfo;
let (p, seq, m) = project();
let nclc = ColorInfo::Nclc { primaries: 1, transfer: 1, matrix: 1 };
let nclx = ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false };
for (format, name, want) in [
(Format::ProRes, "colr-prores.mov", nclc.clone()),
(Format::DnxHr, "colr-dnxhr.mov", nclc.clone()),
(Format::Apv, "colr-apv.mov", nclc.clone()),
(Format::H264, "colr-h264.mp4", nclx),
] {
let path = tmp(name);
export(&p, seq, &ExportSettings { format, path: path.clone(), include_audio: false, ..Default::default() }, &m, &Progress::default()).unwrap();
let bytes = std::fs::read(&path).unwrap();
let file = filmcraft_isobmff::open(bytes).unwrap();
let t = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
let v = file.tracks[t].entries[0].video.as_ref().unwrap();
assert_eq!(v.color.as_ref(), Some(&want), "{name}");
assert!(v.mastering_display.is_none() && v.content_light.is_none(), "{name}: no HDR metadata on SDR");
}
}
/// ProRes 4444 / 4444 XQ export (#342): 4:4:4 frames under the `ap4h` / `ap4x` codes, and with
/// Include Alpha Channel the picture's alpha survives instead of being flattened over black.
#[test]
+153
View File
@@ -0,0 +1,153 @@
//! A renderer may enter Rayon while holding its pool slot (upload/readback conversion does).
//! Yielding can then run another frame on the same thread. Exercise the actual export entry
//! points with a one-slot factory, including hosts that request oversized stepped batches.
//! Each case runs in a bounded child so a regression fails instead of hanging the test runner.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use filmcraft_export::{ExportSettings, Exporter, Format, FrameRenderer, GpuRendering, Progress, Step, export, gpu_render_stats, register_frame_renderer};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{Generator, MediaSource};
use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
use filmcraft_render::{Image, RenderOptions, SourceMap, SourceProvider};
use filmcraft_time::{FrameRate, Tick, TimeRange};
use rayon::prelude::*;
const CHILD: &str = "FILMCRAFT_RENDERER_REENTRANCY_CASE";
const FRAMES: i64 = 6;
static CREATED: AtomicUsize = AtomicUsize::new(0);
static RENDERED: AtomicUsize = AtomicUsize::new(0);
struct YieldingRenderer;
impl FrameRenderer for YieldingRenderer {
fn render(&mut self, project: &Project, seq: ItemId, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider) -> Option<Image> {
// Deterministically offer this thread to the queued sibling frame before returning the
// renderer. With one Rayon worker and one renderer, an uncapped batch deadlocks here.
rayon::yield_now();
let checksum: usize = (0..4096usize).into_par_iter().map(|x| x ^ 37).sum();
assert!(checksum > 0);
RENDERED.fetch_add(1, Ordering::Relaxed);
Some(filmcraft_render::render_sequence(project, seq, t, opts, sources))
}
}
fn factory() -> Option<Box<dyn FrameRenderer>> {
// A factory may stop before the requested renderer count (e.g. allocation pressure).
// Batch safety must use the successfully created capacity, not the requested capacity.
(CREATED.fetch_add(1, Ordering::Relaxed) == 0).then(|| Box::new(YieldingRenderer) as Box<dyn FrameRenderer>)
}
fn project() -> (Arc<Project>, ItemId, SourceMap) {
let mut project = Project::new("renderer reentrancy");
let rate = FrameRate::FPS_24;
let duration = rate.tick_of(FRAMES);
let seq = project.new_sequence("test", SequenceSettings { width: 32, height: 32, frame_rate: rate, ..Default::default() }, 1, 1, None);
let mut sources = SourceMap::default();
for (generator, kind) in
[(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }, TrackKind::Video), (Generator::Tone { hz: 440.0, db: -6.0 }, TrackKind::Audio)]
{
let source = GeneratorSource::new(generator, 32, 32, rate, duration);
let info = source.info().clone();
let id = project.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(source.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let item = project.make_track_item(id, kind, Tick::ZERO, TimeRange::new(Tick::ZERO, duration), rate).unwrap();
let sequence = project.sequence_mut(seq).unwrap();
match kind {
TrackKind::Video => sequence.video_tracks[0].items.push(item),
TrackKind::Audio => sequence.audio_tracks[0].items.push(item),
}
sources.0.insert(id, Arc::new(source));
}
(Arc::new(project), seq, sources)
}
fn run_case(case: &str) {
let (threads, format, overlap, audio) = match case {
"png" => (1, Format::PngSequence, false, false),
"gif" => (1, Format::Gif, false, false),
"stepped" => (1, Format::ProRes, false, false),
"overlap-audio" => (8, Format::ProRes, true, true),
_ => panic!("unknown regression case: {case}"),
};
register_frame_renderer(factory);
let (project, seq, sources) = project();
let dir = std::env::temp_dir().join(format!("fc-renderer-reentrancy-{}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let settings = ExportSettings {
format,
path: dir.join("output").to_string_lossy().into_owned(),
gpu_rendering: GpuRendering::Auto,
include_audio: audio,
..Default::default()
};
let progress = Progress::default();
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
if format == Format::ProRes {
let mut exporter = Exporter::new(project.clone(), seq, &settings, &progress).unwrap();
// Host overrides must not bypass the resource-capacity bound.
exporter.set_batch(64);
exporter.set_overlap(overlap);
loop {
if let Step::Done(report) = exporter.step(&sources, &progress).unwrap() {
assert_eq!(report.frames, FRAMES as u64);
break;
}
}
if overlap {
assert!(exporter.overlapped_steps() > 0);
}
} else {
assert_eq!(export(&project, seq, &settings, &sources, &progress).unwrap().frames, FRAMES as u64);
}
});
assert_eq!(RENDERED.load(Ordering::Relaxed), FRAMES as usize);
assert_eq!(gpu_render_stats().frames, FRAMES as u64, "every frame uses the registered renderer");
assert_eq!(gpu_render_stats().fallbacks, 0, "contention must not silently switch frames to CPU");
assert_eq!(progress.done.load(Ordering::Relaxed), FRAMES as u64);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn nested_rayon_renderers_finish_every_export_path() {
if let Ok(case) = std::env::var(CHILD) {
let (done, finished) = std::sync::mpsc::channel();
let diagnostic = case.clone();
let watchdog = std::thread::spawn(move || {
if finished.recv_timeout(Duration::from_secs(30)).is_err() {
eprintln!("{diagnostic}: renderer-pool reentrancy deadlocked (30-second deadline)");
// Exit only this private synthetic test child, without signaling any process.
std::process::exit(124);
}
});
run_case(&case);
done.send(()).unwrap();
watchdog.join().unwrap();
return;
}
for case in ["png", "gif", "stepped", "overlap-audio"] {
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "nested_rayon_renderers_finish_every_export_path", "--nocapture"])
.env(CHILD, case)
.spawn()
.unwrap();
let status = child.wait().unwrap();
assert!(status.success(), "{case}: child failed with {status}");
}
}
+25 -11
View File
@@ -738,16 +738,33 @@ fn scenes_are_distinct() {
// ---- GPU parity ----
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
/// A device for the GPU parity tests, or `None` (with a `SKIPPED` note) when the machine has no
/// usable adapter. `require_float_target` additionally demands a renderable `Rgba32Float`, which
/// only the effect-chain plan needs; main falls back to CPU effects on adapters without it.
fn device(context: &str, require_float_target: bool) -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
let Some(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok() else {
eprintln!("SKIPPED ({context}): no GPU adapter");
return None;
};
let name = adapter.get_info().name;
let renderable = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba32Float).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT);
if require_float_target && !renderable {
eprintln!("SKIPPED ({context}): adapter {name} cannot render to Rgba32Float");
return None;
}
match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
Ok(device) => Some(device),
Err(_) => {
eprintln!("SKIPPED ({context}): adapter {name} refused a device");
None
}
}
}
#[test]
fn gpu_matches_cpu_on_golden_scenes() {
let Some((dev, q)) = device() else {
eprintln!("SKIPPED (gpu parity): no GPU adapter");
let Some((dev, q)) = device("gpu parity", false) else {
return;
};
let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q);
@@ -807,8 +824,7 @@ fn interior_diff(plan: &filmcraft_render::plan::FramePlan, a: &Rgba8, b: &Rgba8)
/// printed too.
#[test]
fn gpu_blend_modes_match_cpu_render() {
let Some((dev, q)) = device() else {
eprintln!("SKIPPED (gpu blend parity): no GPU adapter");
let Some((dev, q)) = device("gpu blend parity", false) else {
return;
};
let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q);
@@ -855,8 +871,7 @@ fn gpu_blend_modes_match_cpu_render() {
/// blend modes: the plan does the same (Screen clips cross-dissolving on V2 over V1).
#[test]
fn gpu_transition_of_blended_clips_matches_cpu_render() {
let Some((dev, q)) = device() else {
eprintln!("SKIPPED (gpu blend parity): no GPU adapter");
let Some((dev, q)) = device("gpu blend parity", false) else {
return;
};
let mut b = Builder::new(2);
@@ -931,8 +946,7 @@ fn gpu_effect_chains_match_cpu_render() {
assert!(!fx.ops.is_empty());
}
assert!(layers[4].fx.is_none(), "Emboss is rendered on the CPU");
let Some((dev, q)) = device() else {
eprintln!("SKIPPED (gpu effect parity): no GPU adapter");
let Some((dev, q)) = device("gpu effect parity", true) else {
return;
};
let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q);
+4
View File
@@ -24,6 +24,10 @@ read off files it exports, never from the application itself.
The other formats' editions are listed in the module docs (`edl`, `fcp7`, `fcpxml`, `otio`, `ale`).
**Invariant — Unicode media URLs:** file URL prefix probes must not panic when their byte boundary
falls inside a UTF-8 character. Timeline importers use the shared converter; `tests/file_urls.rs`
checks expected Unicode paths through the public converter, resolver and EDL importer.
## AAF and OMF
`comp` holds a format-neutral mob-style model (compositions of slots whose sequences of fillers,
+1 -1
View File
@@ -540,7 +540,7 @@ impl R<'_> {
channels: if channel.is_some() { 1 } else { file_channels },
file_channels,
width: d.u32(pid::STORED_WIDTH).unwrap_or(0),
height: if matches!(layout, 1 | 3) { stored_h * 2 } else { stored_h },
height: if matches!(layout, 1 | 3) { stored_h.saturating_mul(2) } else { stored_h },
frame_rate: if seg_kind == CKind::Picture { fr } else { FrameRate::FPS_25 },
sample_rate: d.rational(pid::AUDIO_SAMPLING_RATE).filter(|r| r.1 > 0 && r.0 > 0).map(|r| (r.0 / r.1) as u32).unwrap_or(file_rate.0 as u32),
bits: d.u32(pid::QUANTIZATION_BITS).unwrap_or(16).clamp(8, 32) as u16,
+1 -1
View File
@@ -65,7 +65,7 @@ pub fn file_url_to_path(url: &str) -> String {
return url.to_string();
}
let rest = &url[7..];
let rest = if rest.len() >= 9 && rest[..9].eq_ignore_ascii_case("localhost") { &rest[9..] } else { rest };
let rest = if rest.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest };
let decoded = percent_decode(rest);
// file:///C:/x -> C:/x
if decoded.len() >= 3 && decoded.starts_with('/') && is_windows_abs(&decoded[1..]) {
+45
View File
@@ -0,0 +1,45 @@
//! Media URL handling at the public interchange boundary.
use filmcraft_interchange::{Format, file_url_to_path, import, resolve_path};
use filmcraft_project::MediaRef;
#[test]
fn unicode_file_urls_do_not_panic_or_lose_the_path() {
for (url, path) in [("file://srv/日本語.mov", "//srv/日本語.mov"), ("file://localhost/media/漢字.mov", "/media/漢字.mov")] {
let parsed = std::panic::catch_unwind(|| file_url_to_path(url));
assert!(parsed.is_ok(), "file URL panicked: {url}");
assert_eq!(parsed.unwrap(), path);
assert_eq!(resolve_path(url, Some("/project")), path);
}
for path in ["/media/漢字.mov", "/日本語.mov", "//srv/日本語.mov"] {
let url = format!("file://{path}");
let parsed = std::panic::catch_unwind(|| file_url_to_path(&url));
assert!(parsed.is_ok(), "file URL panicked: {url}");
assert_eq!(parsed.unwrap(), path);
assert_eq!(resolve_path(&url, Some("/project")), path);
}
}
#[test]
fn edl_import_keeps_unicode_file_url_media() {
let doc = "TITLE: Unicode media
FCM: NON-DROP FRAME
001 AX V C 00:00:00:00 00:00:01:00 00:00:00:00 00:00:01:00
* SOURCE FILE: file:///日本語.mov
";
let parsed = std::panic::catch_unwind(|| import(doc.as_bytes(), Format::Edl, None));
assert!(parsed.is_ok(), "importing a Unicode file URL panicked");
let (imported, report) = parsed.unwrap().unwrap();
let paths: Vec<_> = imported
.project
.items
.values()
.filter_map(|item| item.as_media())
.filter_map(|media| match &media.media {
MediaRef::File { path } => Some(path.as_str()),
_ => None,
})
.collect();
assert_eq!(paths, ["/日本語.mov"]);
assert!(!report.has_warnings(), "{report}");
}
+39 -6
View File
@@ -6,6 +6,9 @@ use crate::ebml::{Children, float, int, string, uint};
use crate::ids::*;
use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd};
// A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains.
const MAX_META_DEPTH: usize = 64;
/// `Info` element.
#[derive(Clone, Debug, PartialEq)]
pub struct SegmentInfo {
@@ -234,7 +237,7 @@ pub(crate) fn parse_cues(d: &[u8], out: &mut Vec<CuePoint>) {
}
}
fn parse_chapter(d: &[u8]) -> Chapter {
fn parse_chapter(d: &[u8], depth: usize) -> Chapter {
let mut c = Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() };
for e in Children::new(d) {
match e.id {
@@ -260,7 +263,7 @@ fn parse_chapter(d: &[u8]) -> Chapter {
}
c.displays.push(disp);
}
CHAPTER_ATOM => c.children.push(parse_chapter(e.data)),
CHAPTER_ATOM if depth < MAX_META_DEPTH => c.children.push(parse_chapter(e.data, depth + 1)),
_ => {}
}
}
@@ -276,7 +279,7 @@ pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec<Edition>) {
EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0,
EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0,
EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0,
CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data)),
CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data, 0)),
_ => {}
}
}
@@ -284,7 +287,7 @@ pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec<Edition>) {
}
}
fn parse_simple_tag(d: &[u8]) -> SimpleTag {
fn parse_simple_tag(d: &[u8], depth: usize) -> SimpleTag {
let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() };
for e in Children::new(d) {
match e.id {
@@ -293,7 +296,7 @@ fn parse_simple_tag(d: &[u8]) -> SimpleTag {
TAG_DEFAULT => t.default = uint(e.data) != 0,
TAG_STRING => t.string = Some(string(e.data)),
TAG_BINARY => t.binary = Some(e.data.to_vec()),
SIMPLE_TAG => t.children.push(parse_simple_tag(e.data)),
SIMPLE_TAG if depth < MAX_META_DEPTH => t.children.push(parse_simple_tag(e.data, depth + 1)),
_ => {}
}
}
@@ -318,7 +321,7 @@ pub(crate) fn parse_tags(d: &[u8], out: &mut Vec<Tag>) {
}
}
}
SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data)),
SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data, 0)),
_ => {}
}
}
@@ -537,3 +540,33 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec<Track> {
}
out
}
#[cfg(test)]
mod metadata_depth_tests {
use super::*;
#[test]
fn deeply_nested_chapters_stop_at_depth_limit() {
// Unknown-size elements make every pair the sole child of the previous ChapterAtom.
let data = [0xB6, 0xFF].repeat(100_000);
let chapter = parse_chapter(&data, 0);
let mut current = &chapter;
for _ in 0..MAX_META_DEPTH {
assert_eq!(current.children.len(), 1);
current = &current.children[0];
}
assert!(current.children.is_empty());
}
#[test]
fn deeply_nested_simple_tags_stop_at_depth_limit() {
let data = [0x67, 0xC8, 0xFF].repeat(100_000);
let tag = parse_simple_tag(&data, 0);
let mut current = &tag;
for _ in 0..MAX_META_DEPTH {
assert_eq!(current.children.len(), 1);
current = &current.children[0];
}
assert!(current.children.is_empty());
}
}
+26 -1
View File
@@ -126,7 +126,8 @@ impl VideoInfo {
/// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced.
pub fn pixel_aspect(&self) -> (u32, u32) {
let (pw, ph) = (self.pixel_width.saturating_sub(self.crop.2 + self.crop.3) as u64, self.pixel_height.saturating_sub(self.crop.0 + self.crop.1) as u64);
let pw = self.pixel_width.saturating_sub(self.crop.2.saturating_add(self.crop.3)) as u64;
let ph = self.pixel_height.saturating_sub(self.crop.0.saturating_add(self.crop.1)) as u64;
let (Some(dw), Some(dh)) = (self.display_width, self.display_height) else { return (1, 1) };
if pw == 0 || ph == 0 || dw == 0 || dh == 0 || !matches!(self.display_unit, 0 | 3) {
return (1, 1);
@@ -349,3 +350,27 @@ mod rotation_tests {
assert_eq!(with_roll(0, 0.0, 0.0, f64::NAN).display_rotation(), None, "malformed angle");
}
}
#[cfg(test)]
mod aspect_overflow_tests {
use super::VideoInfo;
#[test]
fn hostile_crop_sums_do_not_overflow() {
let video = VideoInfo {
pixel_width: 640,
pixel_height: 480,
display_width: Some(640),
display_height: Some(480),
crop: (u32::MAX, 1, u32::MAX, 1),
..Default::default()
};
assert_eq!(video.pixel_aspect(), (1, 1));
}
#[test]
fn ordinary_pixel_aspect_is_unchanged() {
let video = VideoInfo { pixel_width: 1920, pixel_height: 1080, display_width: Some(1440), display_height: Some(1080), ..Default::default() };
assert_eq!(video.pixel_aspect(), (3, 4));
}
}
+14
View File
@@ -0,0 +1,14 @@
# filmcraft-media
Media sources, still-image and image-sequence import, generated media, audio streams, and decoded
frame caching for FilmCraft.
## Image-sequence filenames
`sequence::Numbered` identifies the last run of 1–18 ASCII frame-number digits in a filename's
stem when the extension is a recognized still-image format. `/` and `\` separate directory
components. A prefix may contain other UTF-8 characters, including accented letters, CJK and emoji:
`é0001.png`, `图0001.png` and `🎞️0001.png`. Parsing preserves the prefix, number padding and suffix
when generating sibling-frame paths. Sibling recognition compares the prefix's UTF-8 bytes exactly.
Stems without ASCII digits or with a final frame-number run longer than 18 characters are not numbered
image-sequence filenames.
+19 -1
View File
@@ -46,7 +46,7 @@ impl Numbered {
}
let stem = &name[..ext_at];
let end = stem.rfind(|c: char| c.is_ascii_digit())? + 1;
let start = stem[..end].rfind(|c: char| !c.is_ascii_digit()).map_or(0, |i| i + 1);
let start = stem[..end].trim_end_matches(|c: char| c.is_ascii_digit()).len();
let digits = &stem[start..end];
if digits.len() > 18 {
return None;
@@ -271,6 +271,24 @@ mod tests {
assert_eq!((w.dir.as_str(), w.prefix.as_str(), w.number), ("C:\\renders\\", "out.", 10));
}
#[test]
fn numbered_unicode_prefix() {
for prefix in ["é", "图", "🎞️", "é1a_", "1é", "e\u{301}"] {
let path = format!("/shots/{prefix}0001.png");
let numbered = Numbered::parse(&path).unwrap();
assert_eq!(numbered.prefix, prefix);
assert_eq!(numbered.number, 1);
assert_eq!(numbered.digits, 4);
assert_eq!(numbered.path(2), format!("/shots/{prefix}0002.png"));
assert_eq!(numbered.number_of(&format!("{prefix}0002.png")), Some(2));
}
let numbered = Numbered::parse("/shots/é0001.png").unwrap();
assert_eq!(numbered.number_of("e\u{301}0002.png"), None, "prefixes must match exactly");
for name in ["é١٠٠١.png", "图0001.png"] {
assert_eq!(Numbered::parse(name), None, "frame numbers require ASCII digits");
}
}
#[test]
fn frames_with_gaps() {
let n = Numbered::parse("/d/s_003.png").unwrap();
+3 -2
View File
@@ -135,10 +135,11 @@ fn ffmpeg_ycbcr_tiffs_match_ffmpeg_decode() {
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("YCbCr TIFF oracle") else { return };
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/tiff_ycbcr");
for pix_fmt in ["yuv420p", "yuv422p", "yuv444p"] {
let tiff = dir.join(format!("gradient_{pix_fmt}.tiff"));
let tiff = dir.join(format!("gradient_fixed_{pix_fmt}.tiff"));
let made = filmcraft_testkit::fixtures::generate(&tiff, |tmp| {
std::process::Command::new(&ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "gradients=size=95x63:seed=7", "-frames:v", "1", "-pix_fmt", pix_fmt])
// Default gradient colors stay random even with a fixed seed.
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "gradients=size=95x63:seed=7:c0=0x204060:c1=0xd0a080", "-frames:v", "1", "-pix_fmt", pix_fmt])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
+9 -1
View File
@@ -151,7 +151,7 @@ impl PictureInfo {
/// Frame height: field-based layouts code the height of one field.
pub fn frame_height(&self) -> u32 {
let h = if self.display_height > 0 { self.display_height } else { self.stored_height };
if matches!(self.frame_layout, 1 | 3) { h * 2 } else { h }
if matches!(self.frame_layout, 1 | 3) { h.saturating_mul(2) } else { h }
}
pub fn frame_width(&self) -> u32 {
if self.display_width > 0 { self.display_width } else { self.stored_width }
@@ -268,6 +268,14 @@ mod tests {
Ul(b)
}
#[test]
fn interlaced_picture_height_does_not_overflow() {
let picture = PictureInfo { stored_height: u32::MAX, frame_layout: 1, ..Default::default() };
assert_eq!(picture.frame_height(), u32::MAX);
let picture = PictureInfo { display_height: 240, stored_height: u32::MAX, frame_layout: 3, ..Default::default() };
assert_eq!(picture.frame_height(), 480);
}
#[test]
fn identifies_picture_codings() {
assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 1, 0x31, 0x40, 1])), None, false), Codec::Avc { intra: false });
+4
View File
@@ -760,6 +760,9 @@ pub struct Track {
/// Audio Track Mixer state: automation mode and lanes, sends, output, record arm.
#[serde(default)]
pub mixer: MixerStrip,
/// Colour label tinting the track's header and lane background (`None` = no colour).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<Label>,
}
impl Track {
@@ -780,6 +783,7 @@ impl Track {
pan: 0.0,
effects: Vec::new(),
mixer: MixerStrip::default(),
label: None,
}
}
pub fn end(&self) -> Tick {
+41
View File
@@ -322,3 +322,44 @@ fn a_transition_with_an_adjustment_layer_is_not_planned_as_layers() {
// halfway (frame 24): half blue, half the inverted red (cyan)
assert!(near(at(&s, 24), [0.0, 0.5, 1.0], 1e-3), "{:?}", at(&s, 24));
}
/// The picture's bounds (in sequence pixels) and the share of the frame it covers.
fn coverage(img: &Image, scale: f32) -> ([f32; 4], f32) {
let (mut b, mut sum) = ([f32::MAX, f32::MAX, f32::MIN, f32::MIN], 0.0);
for y in 0..img.h {
for x in 0..img.w {
let a = img.get(x, y)[3];
sum += a;
if a > 0.5 {
let (sx, sy) = (x as f32 / scale, y as f32 / scale);
b = [b[0].min(sx), b[1].min(sy), b[2].max(sx), b[3].max(sy)];
}
}
}
(b, sum / (img.w * img.h) as f32)
}
/// Basic 3D's Distance to Image is in sequence pixels: zooming the Program monitor out (a lower
/// render scale) shrinks the picture, it does not push the plane further away (#1920).
#[test]
fn basic_3d_keeps_its_perspective_at_every_render_scale() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "basic_3d");
let b3 = clip(&mut s, adj).effects.iter_mut().find(|e| e.effect == "basic_3d").unwrap();
for (id, v) in [("swivel", -12.0), ("tilt", -15.0), ("distance", 15.0)] {
b3.params.get_mut(id).unwrap().value = ParamValue::Float(v);
}
let at = |scale: f32| coverage(&render_sequence(&s.p, s.seq, R.tick_of(12), RenderOptions { scale, ..Default::default() }, &s.map), scale);
let (full, full_cov) = at(1.0);
assert!(full_cov < 0.9, "the plane is turned and moved away: {full_cov}");
for scale in [0.5, 0.25] {
let (b, cov) = at(scale);
assert!((cov - full_cov).abs() < 0.02, "at {scale}: covers {cov}, {full_cov} at full resolution");
for k in 0..4 {
assert!((b[k] - full[k]).abs() < 6.0, "at {scale}: bounds {b:?}, {full:?} at full resolution");
}
}
}
+2 -1
View File
@@ -277,7 +277,8 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
"basic_3d" => {
let sw = (f(e, "swivel", cx) as f64).to_radians();
let tl = (f(e, "tilt", cx) as f64).to_radians();
let dist = f(e, "distance", cx) as f64;
// authored in full-resolution pixels; the focal length follows the working image (#1920)
let dist = f(e, "distance", cx) as f64 * cx.px_scale as f64;
let (w, h) = (img.w as f64, img.h as f64);
let focal = w.max(h) * 1.2;
// inverse perspective mapping: ray through (x,y) intersected with rotated plane
+29 -2
View File
@@ -364,7 +364,17 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option
if let Some(p) = &shape {
fill_into(p, &mut cover, -x0 as f32, -y0 as f32);
}
let strokes: Vec<(Mask, [f32; 4])> = ap.strokes.iter().map(|(c, sw, k)| (mask::stroke(&cover, sw * s, stroke_kind(*k)), *c)).collect();
let mut strokes: Vec<(Mask, [f32; 4])> = ap.strokes.iter().map(|(c, sw, k)| (mask::stroke(&cover, sw * s, stroke_kind(*k)), *c)).collect();
let outer = |k: u32| k != 1 && k != 2;
// an outer stroke mask includes the shape so the fill sits on it without a seam; with the
// fill off only the ring outside the edge shows (as in Premiere)
if ap.fill.is_none() {
for ((sm, _), (_, _, k)) in strokes.iter_mut().zip(&ap.strokes) {
if outer(*k) {
sm.a.iter_mut().zip(&cover.a).for_each(|(v, c)| *v = v.min(1.0 - c));
}
}
}
let bg = ap.background.map(|(c, pad, r)| {
let p = filmcraft_text::Path::round_rect(lb[0] - pad, lb[1] - pad, lb[2] + pad, lb[3] + pad, r).transformed(m);
let mut bm = Mask::new(w, h);
@@ -391,7 +401,6 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option
over_mask(&mut px, bm, premul_linear(*c));
}
// outer strokes sit under the fill; centre and inner strokes are drawn over it
let outer = |k: u32| k != 1 && k != 2;
for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() {
if outer(*k) {
over_mask(&mut px, sm, premul_linear(*c));
@@ -914,6 +923,24 @@ mod tests {
}
}
#[test]
fn outer_stroke_without_fill_leaves_the_inside_empty() {
let mut e = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(100.0, 100.0), vec![]);
set(&mut e, "fill", ParamValue::Bool(false));
set(&mut e, "stroke", ParamValue::Bool(true));
set(&mut e, "stroke_width", ParamValue::Float(6.0));
set(&mut e, "stroke_type", ParamValue::Choice(0));
set(&mut e, "stroke_color", ParamValue::Color([0.0, 1.0, 0.0, 1.0]));
let mut img = Image::new(200, 200);
render_graphic_layers(&[e], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
// rectangle spans 50..150: the ring is 150..156, everything left of the edge stays clear
let p = img.get(153, 100);
assert!(p[1] > 0.9 && p[0] < 0.1 && p[3] > 0.9, "ring is stroke-coloured: {p:?}");
for x in [100, 140, 148] {
assert_eq!(img.get(x, 100)[3], 0.0, "inside at x = {x}");
}
}
#[test]
fn rotation_scale_and_opacity() {
let mut e = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(100.0, 10.0), vec![]);
+8 -3
View File
@@ -5,7 +5,10 @@ use std::f32::consts::PI;
use super::dissolve::perceptual_luma;
use super::wipe::angle_dir;
use super::{Tx, add_light, bell, blurred, disc_blur, ease_in, ease_out, hash, line_blur, luma, mid_mix, mix, paint, smoothstep, spin_blur, taps, zoom_blur};
use super::{
Tx, add_light, bell, blurred, disc_blur_with, disc_offsets, ease_in, ease_out, hash, line_blur, luma, mid_mix, mix, paint, smoothstep, spin_blur, taps,
zoom_blur,
};
use crate::image::Image;
/// Soft round light blob.
@@ -142,9 +145,11 @@ pub(super) fn apply(id: &str, t: &Tx) -> Option<Image> {
let ra = r * smoothstep(0.0, 0.55, p);
let rb = r * (1.0 - smoothstep(0.45, 1.0, p));
let n = ((r * 0.8) as usize).clamp(8, 48);
// the disc's sample offsets once per frame, not per pixel (#1871)
let (oa, ob) = (disc_offsets(ra, n), disc_offsets(rb, n));
paint(t.w, t.h, |x, y| {
let a = if k < 1.0 { disc_blur(t.a, x, y, ra, n) } else { [0.0; 4] };
let b = if k > 0.0 { disc_blur(t.b, x, y, rb, n) } else { [0.0; 4] };
let a = if k < 1.0 { disc_blur_with(t.a, x, y, &oa) } else { [0.0; 4] };
let b = if k > 0.0 { disc_blur_with(t.b, x, y, &ob) } else { [0.0; 4] };
mix(a, b, k)
})
}
+23 -7
View File
@@ -337,21 +337,37 @@ pub(crate) fn spin_blur(img: &Image, x: f32, y: f32, cx: f32, cy: f32, ang: f32,
}
scale4(acc, 1.0 / n as f32)
}
/// Disc (bokeh-like) blur of radius `r` with `n` golden-angle samples.
pub(crate) fn disc_blur(img: &Image, x: f32, y: f32, r: f32, n: usize) -> [f32; 4] {
/// The sample offsets of a disc (bokeh-like) blur of radius `r` with `n` golden-angle samples
/// (empty: no blur), for [`disc_blur_with`]. They depend
/// only on `r` and `n`, so a frame works them out once instead of for every pixel (#1871: the
/// square root, sine and cosine of every sample of every pixel were most of a Lens Blur frame).
pub(crate) fn disc_offsets(r: f32, n: usize) -> Vec<(f32, f32)> {
if r < 0.5 || n <= 1 {
return Vec::new();
}
(0..n)
.map(|i| {
let rr = r * ((i as f32 + 0.5) / n as f32).sqrt();
let a = i as f32 * 2.399_963;
(rr * a.cos(), rr * a.sin())
})
.collect()
}
/// Disc (bokeh-like) blur at `(x, y)` with `n` golden-angle samples, their offsets from
/// [`disc_offsets`].
pub(crate) fn disc_blur_with(img: &Image, x: f32, y: f32, offsets: &[(f32, f32)]) -> [f32; 4] {
if offsets.is_empty() {
return img.sample_bilinear_clamped(x, y);
}
let mut acc = [0.0; 4];
for i in 0..n {
let rr = r * ((i as f32 + 0.5) / n as f32).sqrt();
let a = i as f32 * 2.399_963;
let p = img.sample_bilinear_clamped(x + rr * a.cos(), y + rr * a.sin());
for &(dx, dy) in offsets {
let p = img.sample_bilinear_clamped(x + dx, y + dy);
for k in 0..4 {
acc[k] += p[k];
}
}
scale4(acc, 1.0 / n as f32)
scale4(acc, 1.0 / offsets.len() as f32)
}
/// Sample count for a blur of extent `len` pixels.
#[inline]
+59
View File
@@ -420,3 +420,62 @@ fn contact_sheets() {
sheet.save(std::path::Path::new(&dir).join(format!("{f}.png"))).unwrap();
}
}
/// The disc blur as it was before #1871: offsets worked out for every pixel.
fn disc_blur_per_pixel(img: &Image, x: f32, y: f32, r: f32, n: usize) -> [f32; 4] {
if r < 0.5 || n <= 1 {
return img.sample_bilinear_clamped(x, y);
}
let mut acc = [0.0; 4];
for i in 0..n {
let rr = r * ((i as f32 + 0.5) / n as f32).sqrt();
let a = i as f32 * 2.399_963;
let p = img.sample_bilinear_clamped(x + rr * a.cos(), y + rr * a.sin());
for k in 0..4 {
acc[k] += p[k];
}
}
scale4(acc, 1.0 / n as f32)
}
/// #1871: offsets worked out once per frame give exactly the pixels the per-pixel version gave.
#[test]
fn disc_blur_with_shared_offsets_is_bit_identical() {
let img = frame_b();
for (r, n) in [(0.0, 24), (0.4, 24), (3.0, 8), (30.0, 24), (75.5, 48), (200.0, 48), (12.0, 1)] {
let offsets = disc_offsets(r, n);
for y in (0..H).step_by(7) {
for x in (0..W).step_by(5) {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let a = disc_blur_per_pixel(&img, fx, fy, r, n);
let b = disc_blur_with(&img, fx, fy, &offsets);
assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "r {r}, n {n}, at ({x}, {y})");
}
}
}
}
/// `cargo test --release -p filmcraft-render --lib lens_blur_frame_time -- --ignored --nocapture`
#[test]
#[ignore]
fn lens_blur_frame_time() {
let (w, h) = (1920, 1080);
let img = paint(w, h, |x, y| [x / w as f32, y / h as f32, 0.5, 1.0]);
let (r, n) = (30.0, 24);
let time = |f: &dyn Fn() -> Image| {
let t = std::time::Instant::now();
let mut out = f();
for _ in 0..4 {
out = f();
}
(t.elapsed().as_secs_f64() * 1000.0 / 5.0, out)
};
let (before, a) = time(&|| paint(w, h, |x, y| disc_blur_per_pixel(&img, x, y, r, n)));
let offsets = disc_offsets(r, n);
let (after, b) = time(&|| paint(w, h, |x, y| disc_blur_with(&img, x, y, &offsets)));
assert_eq!(a.px, b.px);
eprintln!(
"1080p disc blur of one picture, radius {r}, {n} samples: {before:.1} ms per pixel offsets, {after:.1} ms shared offsets ({:.2}x)",
before / after
);
}
+49 -1
View File
@@ -4,6 +4,11 @@
use std::io::{Read, Seek, SeekFrom};
/// The tables skrifa reads glyph outlines from: TrueType, CFF, CFF2 and VARC. The renderer draws
/// no glyph of a face that has none of them, such as a face with bitmaps only or with its outlines
/// in Apple's `hvgl` table.
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
/// Names and style attributes of one face in a font file.
#[derive(Clone, Debug, PartialEq)]
pub struct FaceNames {
@@ -32,7 +37,8 @@ fn read_at<R: Read + Seek>(r: &mut R, off: u64, len: usize) -> Option<Vec<u8>> {
Some(v)
}
/// Read the names of every face in a font file (a single font or a collection).
/// Read the names of every face in a font file (a single font or a collection). Faces with none
/// of the `OUTLINE_TABLES` are left out.
pub fn read_faces<R: Read + Seek>(r: &mut R) -> Vec<FaceNames> {
let Some(head) = read_at(r, 0, 12) else { return Vec::new() };
let offsets: Vec<u32> = if &head[0..4] == b"ttcf" {
@@ -58,6 +64,9 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
}
let n = be16(&h, 4)? as usize;
let dir = read_at(r, off + 12, n * 16)?;
if !dir.as_chunks::<16>().0.iter().any(|rec| OUTLINE_TABLES.iter().any(|t| rec.starts_with(*t))) {
return None;
}
let mut name = None;
let mut os2 = None;
for i in 0..n {
@@ -137,4 +146,43 @@ mod tests {
assert_eq!(n[0].family, "Noto Serif");
assert!(read_faces_bytes(b"not a font").is_empty());
}
#[test]
fn faces_without_outline_tables_are_left_out() {
use skrifa::MetadataProvider;
// Renaming the `glyf` record of a copy of Inter Regular gives a face with no outline
// tables, like a face whose glyphs are bitmaps only or are kept in Apple's `hvgl` table.
// The new tag keeps the table directory sorted.
let font = crate::fonts::INTER_REGULAR;
let n = usize::from(be16(font, 4).unwrap());
let glyf = (0..n).map(|i| 12 + 16 * i).find(|&r| &font[r..r + 4] == b"glyf").unwrap();
let mut renamed = font.to_vec();
renamed[glyf..glyf + 4].copy_from_slice(b"gxyz");
// skrifa maps 'A' to a glyph of the copy and reads no outline for it.
let copy = skrifa::FontRef::new(&renamed).unwrap();
assert!(copy.outline_glyphs().get(copy.charmap().map('A').unwrap()).is_none());
assert!(read_faces_bytes(&renamed).is_empty());
assert_eq!(read_faces_bytes(font).len(), 1);
// A collection holds both faces, which share one copy of the tables. Table offsets count
// from the start of the collection. Only the face with outlines is listed, with its index 1.
let dir = 12 + 16 * n;
let tables_at = 20 + 2 * dir;
let shifted = |face: &[u8]| {
let mut d = face[..dir].to_vec();
for r in (12..dir).step_by(16) {
let o = be32(&d, r + 8).unwrap() + tables_at as u32;
d[r + 8..r + 12].copy_from_slice(&o.to_be_bytes());
}
d
};
// The header holds the tag, version 1.0, the number of faces and where each face's table
// directory starts.
let mut ttc = [*b"ttcf", [0, 1, 0, 0], 2u32.to_be_bytes(), 20u32.to_be_bytes(), (20 + dir as u32).to_be_bytes()].concat();
ttc.extend(shifted(&renamed));
ttc.extend(shifted(font));
ttc.extend_from_slice(font);
let faces = read_faces_bytes(&ttc);
assert_eq!(faces.len(), 1);
assert_eq!((faces[0].index, faces[0].family.as_str(), faces[0].style.as_str()), (1, "Inter", "Regular"));
}
}
+29 -4
View File
@@ -416,11 +416,18 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
}
TimeDisplay::AudioSamples => {
let secs = t.0.div_euclid(TICKS_PER_SECOND);
let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
let neg = t.0 < 0;
let a = t.0.unsigned_abs();
let tps = TICKS_PER_SECOND.unsigned_abs();
let secs = a / tps;
let rem = Tick((a % tps) as i64).to_units_floor(sample_rate);
format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60)
}
TimeDisplay::Seconds => {
let s = format!("{:.3}", t.seconds());
// A tiny negative time rounds to zero but keeps its sign: drop it.
if s == "-0.000" { "0.000".to_string() } else { s }
}
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
}
}
@@ -507,6 +514,24 @@ mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn seconds_display_has_no_negative_zero() {
let f = |t: i64| format_time(Tick(t), FrameRate::default(), false, TimeDisplay::Seconds, 48000);
assert_eq!(f(-1), "0.000");
assert_eq!(f(-2), "0.000");
assert_eq!(f(-(TICKS_PER_SECOND / 2500)), "0.000");
assert_eq!(f(-TICKS_PER_SECOND), "-1.000");
assert_eq!(f(-TICKS_PER_SECOND / 1000), "-0.001");
}
#[test]
fn audio_samples_negative_time_has_leading_minus() {
let r = FrameRate::new(30000, 1001);
let f = |t| format_time(Tick(t), r, false, TimeDisplay::AudioSamples, 48000);
assert_eq!(f(-TICKS_PER_SECOND), "-00:00:01:00000");
assert_eq!(f(-(TICKS_PER_SECOND + TICKS_PER_SECOND / 2)), "-00:00:01:24000");
}
/// Damaged files and projects can carry zero / negative rates and timebases, and users or
/// agents can type huge timecodes: these used to panic (division by zero, overflow,
/// crossed `clamp` bounds).
+13 -2
View File
@@ -348,9 +348,10 @@ fn parse_amount(rest: &str, _lang: Lang) -> Option<(u64, usize, usize, u32, bool
let major = int_digits.parse::<u64>().ok()?;
let had_minor = after_decimal && !frac_digits.is_empty();
// One fractional digit denotes tenths of a major unit (e.g. $1.5 = $1.50),
// not five minor units. Preserve the existing two-digit and longer readings.
// not five minor units. Digits beyond the second are sub-cent precision and are
// dropped, so the minor part never exceeds 99 (`$3.459` reads as 45 cents).
let minor = if had_minor {
let value = frac_digits.parse::<u32>().ok()?;
let value = frac_digits.get(..2).unwrap_or(&frac_digits).parse::<u32>().ok()?;
if frac_digits.len() == 1 { value * 10 } else { value }
} else {
0
@@ -1236,6 +1237,16 @@ mod tests {
assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents");
}
#[test]
fn money_fraction_beyond_two_digits_is_not_hundreds_of_cents() {
for lang in [Lang::EnUs, Lang::EnGb] {
assert_eq!(words("gas is $3.459 a gallon", lang), "gas is three dollars and forty-five cents a gallon");
assert_eq!(words("$1.999", lang), "one dollar and ninety-nine cents");
assert_eq!(words("$1.250", lang), "one dollar and twenty-five cents");
assert_eq!(words("$1.005", lang), "one dollar");
}
}
#[test]
fn first_digit_group_keeps_leading_zeros() {
for lang in [Lang::EnUs, Lang::EnGb] {
+4 -4
View File
@@ -18,7 +18,8 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation
Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese, German, Russian, French, Indonesian, Korean and Italian (which cover the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian, French, Indonesian, Korean and Italian (all but Ukrainian and Russian cover
the whole interface; those two cover the core menus). The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
preferred languages that the interface has (the host supplies them through
@@ -32,11 +33,10 @@ fills translated templates, and `i18n::t` translates names from registries. Plac
(including user filenames containing braces) are inserted literally. Catalog translations are
original work using ordinary language, without proprietary localisation resources.
Spanish, Japanese, Simplified Chinese, French and Brazilian Portuguese cover menus, panels, dialogs, settings and registry labels. Searches accept both the
Spanish, Japanese, Simplified Chinese, French, Brazilian Portuguese and German cover menus, panels, dialogs, settings and registry labels. Searches accept both the
translated label and its English source, including Unicode capitals. Project content, command ids
and preference values retain their original values. Engine errors, CLI and MCP messages remain
English. Ukrainian and Russian currently cover the core menus, German every menu; they fall back
to English elsewhere.
English. Ukrainian and Russian currently cover the core menus; they fall back to English elsewhere.
Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders,
literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual
+27 -14
View File
@@ -15,25 +15,27 @@ pub fn eval(text: &str, current: f64) -> Option<f64> {
if raw.len() > MAX_LEN {
return None;
}
// letters between two digits (`1e3`, `10x20`) are not a unit: refuse rather than guess
let mut i = 0;
while let Some(c) = raw.get(i) {
if c.is_alphabetic() {
let end = raw.iter().skip(i).position(|c| !c.is_alphabetic()).map_or(raw.len(), |n| i + n);
let before = i.checked_sub(1).and_then(|b| raw.get(b)).is_some_and(|c| c.is_ascii_digit());
let after = raw.get(end).is_some_and(|c| c.is_ascii_digit() || *c == '.');
if before && after {
return None;
}
i = end;
} else {
i += 1;
// spaces or a unit between two numbers (`1e3`, `10x20`, `10 x 20`, `1920 1080`) would fuse them
// into one number once dropped: refuse rather than guess
let skipped = |c: &char| c.is_alphabetic() || c.is_whitespace() || matches!(c, '%' | '°');
let mut after_number = false;
let mut gap = false;
for c in &raw {
if skipped(c) {
gap = true;
continue;
}
let number_char = c.is_ascii_digit() || *c == '.';
if number_char && after_number && gap {
return None;
}
after_number = number_char;
gap = false;
}
let chars: Vec<char> = raw
.iter()
.copied()
.filter(|c| !(c.is_alphabetic() || c.is_whitespace() || matches!(c, '%' | '°')))
.filter(|c| !skipped(c))
.map(|c| match c {
'×' => '*',
'÷' => '/',
@@ -181,4 +183,15 @@ mod tests {
assert_eq!(eval("1e3", 0.0), None);
assert_eq!(eval("10x20", 0.0), None);
}
#[test]
fn numbers_separated_by_spaces_or_a_unit_are_not_merged() {
for bad in ["10 x 20", "1 e 3", "1920 1080", "10px 20", "50% 2", "1 .5"] {
assert_eq!(eval(bad, 0.0), None, "{bad:?}");
}
assert_eq!(eval("100 * 2", 0.0), Some(200.0));
assert_eq!(eval("10px", 0.0), Some(10.0));
assert_eq!(eval("10 px * 2", 0.0), Some(20.0));
assert_eq!(eval("*2", 5.0), Some(10.0));
}
}
+11
View File
@@ -97,6 +97,11 @@ impl FilmcraftApp {
Action::Activate { panel } => Some(*panel),
_ => None,
};
// a panel closed while floating remembers where; closed while docked, it forgets (#1985)
let closing = match &action {
Action::Close { panel } => Some((*panel, self.ui.floating.iter().find(|g| g.panels.contains(panel)).map(|g| g.rect))),
_ => None,
};
self.commit_dock(|shared| {
crate::dock_shared::check_last_docked(shared, &action)?;
shared.apply(action).map_err(|error| error.to_string())
@@ -104,6 +109,12 @@ impl FilmcraftApp {
if let Some(panel) = reveal {
self.pending_panel_raise = Some(panel);
}
if let Some((panel, rect)) = closing {
self.ui.closed_floating.retain(|(p, _)| *p != panel);
if let Some(rect) = rect {
self.ui.closed_floating.push((panel, rect));
}
}
if relocating
|| self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel)))
{
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1266,6 +1266,7 @@
59.94 fps Non Drop-Frame 59,94 fps sin eliminación de fotogramas
ASC CDL ASC CDL
Accessible color contrast Contraste de color accesible
Animate zoom, scrolling and menus Animar el zoom, el desplazamiento y los menús
Action-Safe Margin Margen seguro de acción
Active Monitor Monitor activo
Add Añadir
@@ -3610,3 +3611,9 @@
Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft.
Couldn't open your web browser No se pudo abrir el navegador web
Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Restablecer zoom de la interfaz
Track Color Color de pista
Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos
No text graphics in this sequence. Add text with the Type tool. Esta secuencia no tiene gráficos con texto. Añade texto con la herramienta Texto.
No matching text. Ningún texto coincide.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+7
View File
@@ -1266,6 +1266,7 @@
59.94 fps Non Drop-Frame 59,94 fps non-drop frame
ASC CDL ASC CDL
Accessible color contrast Contraste des couleurs accessible
Animate zoom, scrolling and menus Animer le zoom, le défilement et les menus
Action-Safe Margin Marge de sécurité de l'action
Active Monitor Moniteur actif
Add Ajouter
@@ -3577,3 +3578,9 @@
Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini…
Show the presets folder Afficher le dossier des paramètres prédéfinis
Delete this preset Supprimer ce paramètre prédéfini
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Réinitialiser le zoom de l'interface
Track Color Couleur de la piste
Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques
No text graphics in this sequence. Add text with the Type tool. Aucun graphique avec du texte dans cette séquence. Ajoutez du texte avec l'outil Texte.
No matching text. Aucun texte ne correspond.
Can't render this file because it contains an unexpected character in line 5 and column 83.
+7
View File
@@ -1271,6 +1271,7 @@
59.94 fps Non Drop-Frame 59,94 fps Non Drop-Frame
ASC CDL ASC CDL
Accessible color contrast Kontras warna yang aksesibel
Animate zoom, scrolling and menus Animasikan zoom, gulir, dan menu
Action-Safe Margin Margin Aman Aksi
Active Monitor Monitor Aktif
Add Tambah
@@ -3575,3 +3576,9 @@
Appearance Settings… Pengaturan Tampilan…
Add to favorites Tambahkan ke favorit
Remove from favorites Hapus dari favorit
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Atur Ulang Zoom Antarmuka
Track Color Warna Trek
Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya
No text graphics in this sequence. Add text with the Type tool. Tidak ada grafis teks di sekuens ini. Tambahkan teks dengan alat Teks.
No matching text. Tidak ada teks yang cocok.
Can't render this file because it contains an unexpected character in line 6 and column 14.
+7
View File
@@ -1263,6 +1263,7 @@
59.94 fps Non Drop-Frame 59,94 fps non drop-frame
ASC CDL ASC CDL
Accessible color contrast Contrasto dei colori accessibile
Animate zoom, scrolling and menus Anima zoom, scorrimento e menu
Action-Safe Margin Margine di sicurezza azione
Active Monitor Monitor attivo
Add Aggiungi
@@ -3601,3 +3602,9 @@
Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft.
Couldn't open your web browser Impossibile aprire il browser web
Open this address in your browser yourself: Apri tu questo indirizzo nel browser:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Reimposta zoom dell'interfaccia
Track Color Colore traccia
Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche
No text graphics in this sequence. Add text with the Type tool. Nessuna grafica con testo in questa sequenza. Aggiungi testo con lo strumento Testo.
No matching text. Nessun testo corrispondente.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+7
View File
@@ -1260,6 +1260,7 @@
59.94 fps Non Drop-Frame 59.94 fps ノンドロップフレーム
ASC CDL ASC CDL
Accessible color contrast アクセシビリティに配慮したカラーコントラスト
Animate zoom, scrolling and menus ズーム、スクロール、メニューをアニメーション表示
Action-Safe Margin アクションセーフの余白
Active Monitor アクティブなモニター
Add 加算
@@ -3609,3 +3610,9 @@
Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。
Couldn't open your web browser ウェブブラウザーを開けませんでした
Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom インターフェイスのズームをリセット
Track Color トラックカラー
Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます
No text graphics in this sequence. Add text with the Type tool. このシーケンスにはテキストのグラフィックスがありません。文字ツールでテキストを追加してください。
No matching text. 一致するテキストはありません。
Can't render this file because it contains an unexpected character in line 11 and column 59.
+7
View File
@@ -76,6 +76,7 @@
About FilmCraft FilmCraft 정보
Accent Color 강조 색상
Accessible color contrast 접근성 색상 대비
Animate zoom, scrolling and menus 확대/축소, 스크롤 및 메뉴 애니메이션
Action and Title Safe Areas 액션 및 제목 안전 영역
Action Safe Area 액션 안전 영역
Action-Safe Margin 액션 세이프 여백
@@ -3596,3 +3597,9 @@
Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다.
Couldn't open your web browser 웹 브라우저를 열 수 없습니다
Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom 인터페이스 확대/축소 재설정
Track Color 트랙 색상
Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요
No text graphics in this sequence. Add text with the Type tool. 이 시퀀스에는 텍스트 그래픽이 없습니다. 문자 도구로 텍스트를 추가하세요.
No matching text. 일치하는 텍스트가 없습니다.
Can't render this file because it contains an unexpected character in line 2815 and column 7.
+35 -9
View File
@@ -1103,16 +1103,17 @@ mod tests {
}
}
/// German covers at least the core menus (`core_menu_labels`, the top-level menus and the native
/// macOS app menu); every entry must still be a menu label.
/// German covers the whole interface (every entry of the Spanish catalog, the native macOS app
/// menu included), but, like French, the tests require only the core menus and the native app
/// menu, so strings added later show in English until they are translated. Every entry must
/// still have a Spanish counterpart: a renamed or removed source would otherwise leave a dead
/// German entry.
#[test]
fn german_entries_cover_the_original_menu_catalog() {
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
let items = crate::menus::menu_items(&app);
let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text));
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
for (_, en, _) in entries {
assert!(known(&en) || crate::menus::NATIVE_APP_MENU.contains(&en.as_str()), "not a menu label: {en}");
fn german_covers_the_menus_and_has_no_dead_entries() {
let (entries, errors) = catalog::parse_entries(include_str!("de.tsv"));
assert!(errors.is_empty(), "{errors:?}");
for (ctx, en, _) in &entries {
assert!(ctx.is_empty() && Language::Es.has(en), "German entry without a Spanish one: {ctx:?} {en:?}");
}
for en in core_menu_labels() {
assert!(Language::De.has(&en), "missing German menu label: {en}");
@@ -1123,6 +1124,21 @@ mod tests {
}
assert_eq!(Language::De.tr("Settings"), "Voreinstellungen");
assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden");
// beyond the menus: panels, effects and their parameters, settings, status messages
for (en, de) in [
("Gaussian Blur", "Gaußscher Weichzeichner"),
("Audio Track Mixer", "Audiospur-Mischpult"),
("Sequence", "Sequenz"),
("Playhead", "Abspielposition"),
("Keyboard Shortcuts", "Tastenkürzel"),
("{n} min ago", "vor {n} Min."),
] {
assert_eq!(Language::De.tr(en), de);
}
set_current(Language::De);
assert!(matches_query("Gaussian Blur", "weichzeichner"));
assert!(matches_query("Gaussian Blur", "gaussian"));
set_current(Language::En);
}
/// French covers the whole interface (the Spanish catalog's entries and the native macOS
@@ -1318,6 +1334,16 @@ mod tests {
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters));
}
#[test]
fn german_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
// every German letter and quotation mark, even ones no entry uses yet, and every letter,
// dash and ellipsis the entries use (symbols such as ✔ come from the English sources)
let alphabet = "ÄÖÜäöüß„“‚‘";
let letters = entries.iter().flat_map(|(_, _, de)| de.chars()).filter(|ch| ch.is_alphabetic() || "„“‚‘–—…".contains(*ch));
assert_bundled_fonts_cover(alphabet.chars().chain(Language::De.name().chars()).chain(letters));
}
#[test]
fn indonesian_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("id.tsv"));
+7
View File
@@ -1268,6 +1268,7 @@
59.94 fps Non Drop-Frame 59,94 fps sem drop-frame
ASC CDL ASC CDL
Accessible color contrast Contraste de cores acessível
Animate zoom, scrolling and menus Animar zoom, rolagem e menus
Action-Safe Margin Margem de segurança de ação
Active Monitor Monitor ativo
Add Adicionar
@@ -3578,3 +3579,9 @@
# The native macOS application menu (Settings…).
Settings Preferências
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Redefinir zoom da interface
Track Color Cor da trilha
Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela
No text graphics in this sequence. Add text with the Type tool. Esta sequência não tem gráficos com texto. Adicione texto com a ferramenta Texto.
No matching text. Nenhum texto corresponde.
Can't render this file because it contains an unexpected character in line 6 and column 4.
+7
View File
@@ -1254,6 +1254,7 @@
59.94 fps Non Drop-Frame 59.94 fps 非掉帧
ASC CDL ASC CDL
Accessible color contrast 无障碍颜色对比度
Animate zoom, scrolling and menus 缩放、滚动和菜单使用动画
Action-Safe Margin 动作安全边距
Active Monitor 活动监视器
Add 添加
@@ -3585,3 +3586,9 @@
Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。
Couldn't open your web browser 无法打开网页浏览器
Open this address in your browser yourself: 请自行在浏览器中打开此地址:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom 重置界面缩放
Track Color 轨道颜色
Open a sequence to search its graphics 打开序列以搜索其中的图形
No text graphics in this sequence. Add text with the Type tool. 此序列中没有文本图形。请用文字工具添加文本。
No matching text. 没有匹配的文本。
Can't render this file because it contains an unexpected character in line 3 and column 95.
+69 -2
View File
@@ -39,6 +39,9 @@ pub mod credits;
pub mod dock;
mod dock_shared;
mod dock_ui;
/// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide).
const MAX_THUMB_TEXTURES: usize = 512;
pub mod frames;
pub mod graphics_notice;
pub mod header;
@@ -289,6 +292,9 @@ pub struct FilmcraftApp {
pub auto: automation::Registry,
/// Named textures (monitors, thumbnails) with the key they show.
textures: HashMap<String, (FrameKey, TextureHandle)>,
/// The UI pass each thumbnail texture was last used in: thumbnails beyond
/// [`MAX_THUMB_TEXTURES`] are freed, least recently used first (#1682).
thumb_used: HashMap<String, u64>,
control_rx: Option<Receiver<ControlRequest>>,
/// One explicit panel reveal; consumed by the custom renderer, never repeated per frame.
pub(crate) pending_panel_raise: Option<PanelKind>,
@@ -553,6 +559,7 @@ impl FilmcraftApp {
native_dialog: None,
auto: Default::default(),
textures: HashMap::new(),
thumb_used: HashMap::new(),
control_rx: None,
pending_panel_raise: None,
control_input_only: false,
@@ -633,6 +640,7 @@ impl FilmcraftApp {
FrameServer::default_workers(),
));
self.textures.clear();
self.thumb_used.clear();
self.tl.reset_media_caches();
// the new frame server needs the Memory settings
self.applied_prefs = None;
@@ -685,6 +693,16 @@ impl FilmcraftApp {
}
}
/// View ▸ Reset Interface Zoom: the scale Settings ▸ Appearance ▸ UI Scale asks for (the
/// system's with Auto), undoing any session zoom (#2253).
/// Returns the zoom factor set (egui reports it from the next pass on).
pub(crate) fn reset_ui_zoom(&mut self, ctx: &egui::Context) -> f32 {
let native = ctx.native_pixels_per_point().filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
let zoom = self.session.prefs.appearance.ui_scale_factor().map_or(1.0, |scale| scale / native);
ctx.set_zoom_factor(zoom);
zoom
}
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
/// play after rendering, audio device).
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
@@ -697,6 +715,11 @@ impl FilmcraftApp {
self.set_theme(ctx, k);
}
}
// checked every frame: a theme or font change installs the style again
let animation = if self.session.prefs.appearance.animations { theme::ANIMATION_TIME } else { 0.0 };
if ctx.global_style().animation_time != animation {
ctx.global_style_mut(|s| s.animation_time = animation);
}
if self.applied_prefs.as_ref() == Some(&self.session.prefs) {
return;
}
@@ -849,6 +872,13 @@ impl FilmcraftApp {
}
pub fn show_panel(&mut self, p: PanelKind) {
// closed while floating: it floats again where it was (#1985); taken before the Timeline's
// own restore below docks it
let open = self.ui.dock.contains(p) || self.ui.floating.iter().any(|group| group.panels.contains(&p));
let refloat = match self.ui.closed_floating.iter().position(|(k, _)| *k == p) {
Some(i) if !open => Some(self.ui.closed_floating.remove(i).1),
_ => None,
};
if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.ui.dock.restore_timeline();
}
@@ -873,6 +903,15 @@ impl FilmcraftApp {
};
self.ui.dock.open_near(p, near);
}
if let Some(rect) = refloat.filter(|r| r.iter().all(|v| v.is_finite())) {
let rect = [rect[0], rect[1], rect[2].max(160.0), rect[3].max(120.0)];
if let Err(e) = self.apply_dock(craft_ui::docking::Action::Float { panel: p, rect }) {
// refused (it is the only docked panel left): it stays docked, and its floating
// place is kept rather than lost
log::warn!("could not float {} again: {e}", p.id());
self.ui.closed_floating.push((p, rect));
}
}
let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p });
self.ui.focused = p;
}
@@ -1251,12 +1290,40 @@ impl FilmcraftApp {
let name = format!("thumb-{}-{}-{}", item.0, frame, width);
if let Some(img) = self.frames.get(&key) {
let id = self.texture_for(ctx, &name, key, &img);
self.thumb_touched(ctx, &name);
return Some((id, egui::vec2(img.w as f32, img.h as f32)));
}
let scale = width as f32 / src_w.max(1) as f32;
let project = self.session.project.clone();
self.frames.request(key, rate.tick_of(frame), scale, &project, 50);
self.texture_existing(&name)
let shown = self.texture_existing(&name);
if shown.is_some() {
self.thumb_touched(ctx, &name);
}
shown
}
/// Note that thumbnail texture `name` is used in this pass, and free the least recently used
/// thumbnails once there are more than [`MAX_THUMB_TEXTURES`]: every frame shown in a timeline
/// tile or a hover scrub got a texture of its own that was never freed (#1682).
fn thumb_touched(&mut self, ctx: &egui::Context, name: &str) {
let pass = ctx.cumulative_pass_nr();
if self.thumb_used.insert(name.to_string(), pass).is_some() || self.thumb_used.len() <= MAX_THUMB_TEXTURES {
return;
}
// down to three quarters, never a thumbnail already drawn in this pass
let mut old: Vec<(u64, String)> = self.thumb_used.iter().filter(|(_, p)| **p < pass).map(|(n, p)| (*p, n.clone())).collect();
old.sort_unstable();
let excess = self.thumb_used.len().saturating_sub(MAX_THUMB_TEXTURES * 3 / 4);
for (_, n) in old.into_iter().take(excess) {
self.thumb_used.remove(&n);
self.textures.remove(&n);
}
}
/// The number of thumbnail textures held (tests, diagnostics).
pub fn thumbnail_textures(&self) -> usize {
self.textures.keys().filter(|n| n.starts_with("thumb-")).count()
}
// ---------------------------------------------------------------- files
@@ -1701,7 +1768,7 @@ impl FilmcraftApp {
let focused = ctx.input(|i| i.focused);
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
if !on || filmcraft_engine::media_watch::media_files(&self.session.project).is_empty() {
if !on || filmcraft_engine::media_watch::media_files(&self.session.project, &self.session.media).is_empty() {
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
+17
View File
@@ -53,6 +53,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.zoomIn", "Zoom In", ["View"], Some("=")),
uic!("view.zoomOut", "Zoom Out", ["View"], Some("-")),
uic!("view.zoomToSequence", "Zoom to Sequence", ["View"], Some("\\")),
uic!("view.resetInterfaceZoom", "Reset Interface Zoom", ["View"], None),
uic!("view.playbackRes.full", "Full", ["View", "Playback Resolution"], None),
uic!("view.playbackRes.half", "1/2", ["View", "Playback Resolution"], None),
uic!("view.playbackRes.quarter", "1/4", ["View", "Playback Resolution"], None),
@@ -417,6 +418,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
app.ui.timeline.fit_pending = true;
return Ok(Value::Null);
}
// the whole interface back to Settings ▸ Appearance ▸ UI Scale, after Ctrl+Plus or a mouse
// mapped to page zoom scaled it (#2253)
"view.resetInterfaceZoom" => {
let zoom = app.reset_ui_zoom(ctx);
return Ok(json!({"zoom": zoom}));
}
"mode.import" => {
app.ui.mode = Mode::Import;
return Ok(Value::Null);
@@ -842,6 +849,16 @@ pub fn bindings(app: &FilmcraftApp) -> Vec<KeyBinding> {
let shifted: Vec<KeyBinding> =
v.iter().filter(|b| b.0.shift).filter_map(|(m, k, id, p)| shifted_key(*k).map(|k2| (*m, k2, id.clone(), p.clone()))).collect();
v.extend(shifted);
// `+` is the `=` key with Shift on US layouts and its own key on others and the keypad: a chord
// on `=` takes it too, unless something is bound to `+` itself. Otherwise Ctrl+Plus fell
// through to egui's interface zoom while Ctrl+- and Ctrl+0 were taken by FilmCraft
// shortcuts, so the interface grew and could not shrink back (#2253).
let plus: Vec<KeyBinding> = v
.iter()
.filter(|(m, k, _, p)| *k == egui::Key::Equals && !v.iter().any(|(m2, k2, _, p2)| *k2 == egui::Key::Plus && m2 == m && p2 == p))
.map(|(m, _, id, p)| (*m, egui::Key::Plus, id.clone(), p.clone()))
.collect();
v.extend(plus);
v.sort_by_key(|(m, ..)| std::cmp::Reverse(specificity(*m)));
v
}
@@ -197,9 +197,18 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
let mut order: Vec<usize> = (0..it.effects.len()).collect();
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
let mut after_fixed = false;
for idx in order {
let e = &it.effects[idx];
let Some(def) = e.def() else { continue };
// the first effect added from the library after the fixed ones gets a line on top
// (#2328); drawn on its edge, so no row moves
let first_added = if def.intrinsic {
after_fixed = true;
false
} else {
std::mem::take(&mut after_fixed)
};
let key = format!("{}:{}", clip.0, idx);
let open = !app.ui.collapsed_fx.contains(&key);
let selected = app.session.state.selected_effect.as_ref().is_some_and(|s| s.clip == clip && s.index(&it.effects) == Some(idx));
@@ -210,6 +219,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
bui.painter().rect_filled(crate::panels::effect_controls::row_fill(r), 0.0, t.hover);
}
row_line(bui, r, &lane, &t);
if first_added {
let line = Rect::from_min_max(pos2(r.min.x, r.min.y - 1.0), pos2(r.max.x, r.min.y + 1.0));
bui.painter().rect_filled(line, 0.0, t.accent.gamma_multiply(0.8));
let which = if *kind == TrackKind::Video { "video" } else { "audio" };
app.auto.add(&format!("effectControls.addedEffects.{which}"), line, "Added effects");
}
// only the arrow twirls the effect open or shut; the rest of the header selects it
let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0));
icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
@@ -1112,6 +1127,16 @@ pub(crate) fn graph_rows(
lo = lo.max(min);
hi = hi.min(max);
}
// While the graph stays open its range only widens: refitted to the keyframes, a keyframe
// dragged off the top or bottom edge was drawn back on that edge, the curve unchanged (#1918)
let range_id = egui::Id::new(("kfg-range", clip.0, idx, mask, pd.id));
let frame = ui.ctx().cumulative_frame_nr();
if let Some((a, b, seen)) = ui.data(|d| d.get_temp::<(f64, f64, u64)>(range_id))
&& frame.saturating_sub(seen) <= 1
{
(lo, hi) = (lo.min(a), hi.max(b));
}
ui.data_mut(|d| d.insert_temp(range_id, (lo, hi, frame)));
let area = Rect::from_min_max(pos2(lane.min.x, vr.min.y + 4.0), pos2(lane.max.x, vr.max.y - 4.0));
let y_of = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height();
let v_of = |y: f32| lo + ((area.max.y - y) / area.height()) as f64 * (hi - lo);
@@ -1536,6 +1561,31 @@ mod graph_drag_tests {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// #1918, #1921: a keyframe dragged to a new value stays where it was dropped. The graph
/// used to fit itself to the new values, so with two keyframes the one at the bottom (or
/// the top) went straight back there and the curve looked unchanged.
#[test]
fn a_dropped_graph_keyframe_stays_where_it_was_dropped() {
for (key, offset) in [(0, vec2(0.0, -20.0)), (1, vec2(0.0, 20.0))] {
let mut d = Driver::new();
let before = d.param();
d.frame(vec![]);
let id = format!("effectControls.scale.graph.keyframe.{}", before.keyframes[key].time.0);
let [x, y, w, h] = d.app.auto.find(&id).expect("the keyframe is drawn").rect;
let start = pos2(x + w / 2.0, y + h / 2.0);
d.frame(vec![egui::Event::PointerMoved(start), button(start, true)]);
d.frame(vec![egui::Event::PointerMoved(start + offset)]);
d.frame(vec![button(start + offset, false)]);
d.frame(vec![]);
d.frame(vec![]);
let changed = d.param();
assert_ne!(changed.keyframes[key].value, before.keyframes[key].value, "keyframe {key} takes the dropped value");
let [x, y, w, h] = d.app.auto.find(&id).expect("the keyframe is drawn").rect;
let now = pos2(x + w / 2.0, y + h / 2.0);
assert!(now.distance(start + offset) < 0.5, "keyframe {key} stays where it was dropped: dropped at {:?}, drawn at {now:?}", start + offset);
}
}
#[test]
fn scalar_graph_press_starts_at_the_pointer_origin_and_release_is_one_undo_step() {
for influence in [false, true] {
+13 -4
View File
@@ -87,8 +87,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 46.0;
let icon_r = Rect::from_center_size(pos2(x - 10.0, r.center().y), vec2(13.0, 13.0));
// inside the top folder, or inside its sub-folder
let x = r.min.x + tree_x(if s.is_empty() { 1 } else { 2 }) + 30.0;
let icon_r = Rect::from_center_size(pos2(x - 12.0, r.center().y), vec2(13.0, 13.0));
let is_tr = matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition);
ui.painter().rect_stroke(icon_r, 2.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
if is_tr {
@@ -139,6 +140,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Where a tree row at `depth` starts (its chevron): folders at 0 (top) and 1 (sub-folders), and
/// what a folder holds one level deeper than the folder (#2329), so an effect is never level with,
/// or left of, the folder it is in. A row's icon is centred 18 points in, its label 30.
pub(crate) fn tree_x(depth: usize) -> f32 {
8.0 + depth as f32 * 16.0
}
fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize, filter: &str) -> bool {
let t = app.tokens;
let open = !filter.is_empty() || app.ui.expanded_fx.iter().any(|k| k == key);
@@ -146,7 +154,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 8.0 + depth as f32 * 16.0;
let x = r.min.x + tree_x(depth);
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x + 4.0, r.center().y), vec2(10.0, 10.0)),
@@ -240,7 +248,8 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti
ui.painter().rect_filled(r, 0.0, t.hover);
}
let tex = preset_texture(app, ui.ctx(), p, 64);
let tr = Rect::from_min_size(pos2(r.min.x + 40.0, r.min.y + 3.0), vec2(32.0, 18.0));
// inside a Lumetri Presets sub-folder: one level deeper than it
let tr = Rect::from_min_size(pos2(r.min.x + tree_x(2) + 12.0, r.min.y + 3.0), vec2(32.0, 18.0));
ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text);
app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name);
+1 -1
View File
@@ -28,7 +28,7 @@ fn button(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, label: &str, primary: b
.corner_radius(CornerRadius::same(15))
.fill(if primary { t.accent } else { Color32::TRANSPARENT })
.stroke(if primary { Stroke::NONE } else { Stroke::new(1.5, t.text_faint) });
let r = ui.add(b);
let r = ui.add(b).on_hover_cursor(egui::CursorIcon::PointingHand);
app.auto.add(id, r.rect, label);
r.clicked()
}
+65 -14
View File
@@ -190,6 +190,13 @@ pub(crate) fn handle_points(quad: &[Pos2; 4]) -> [Pos2; 8] {
[quad[0], quad[1], quad[2], quad[3], mid(0), mid(1), mid(2), mid(3)]
}
/// Resize cursor for a selected graphics-box handle, following the handle's screen-space axis.
fn resize_handle_cursor(v: &LayerView, at: Pos2) -> Option<egui::CursorIcon> {
let quad = v.quad();
let centre = quad[0] + (quad[2] - quad[0]) * 0.5;
handle_points(&quad).into_iter().find(|point| (*point - at).length() <= 7.0).map(|point| super::transform_handles::resize_cursor(point - centre))
}
/// Dragging handle `n` of `quad` from `start` to `cur`: the box grows towards the pointer while
/// the opposite corner / edge stays put. A corner scales each axis by the pointer's travel along
/// it (both alike with `keep_ratio`, Shift); an edge scales only its own.
@@ -594,15 +601,18 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
Tool::Type | Tool::VerticalType => ui.ctx().set_cursor_icon(egui::CursorIcon::Text),
Tool::Rectangle | Tool::Ellipse | Tool::Pen => ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair),
Tool::Selection => {
let corner = views
.iter()
.filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer))
.find_map(|v| corner_handles(v).and_then(|(_, pts)| pts.iter().position(|c| (*c - p).length() <= 6.0)));
let selected = || views.iter().filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer));
let handle = selected().find_map(|v| resize_handle_cursor(v, p));
let radius_handle = selected().find_map(|v| {
let centre = v.quad()[0] + (v.quad()[2] - v.quad()[0]) * 0.5;
corner_handles(v)?.1.iter().find(|c| (**c - p).length() <= 6.0).map(|c| super::transform_handles::resize_cursor(*c - centre))
});
if editing.as_ref().is_some_and(|e| views.iter().any(|v| v.clip.0 == e.clip && v.layer == e.layer && v.hit(p))) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
} else if let Some(n) = corner {
// along the corner's diagonal, the way the drag works
ui.ctx().set_cursor_icon(if n % 2 == 0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
} else if let Some(icon) = handle {
ui.ctx().set_cursor_icon(icon);
} else if let Some(icon) = radius_handle {
ui.ctx().set_cursor_icon(icon);
} else if views.iter().any(|v| v.hit(p)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
}
@@ -821,7 +831,20 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
let p = resp.interact_pointer_pos().unwrap_or(pic.center());
let in_edit = edit_view.is_some_and(|v| v.hit(p));
match tool {
_ if in_edit => {}
// a click in the text being edited puts the caret there (Shift extends the selection),
// as a press that becomes a drag already does (#2430)
_ if in_edit => {
if let (Some(v), Some(mut ed)) = (edit_view, editing.clone())
&& let Some(tp) = text_of(&v.spec)
&& let Some((x, y)) = v.to_local(p)
{
ed.caret = text_layout(tp).hit(x, y);
if !ui.input(|i| i.modifiers.shift) {
ed.anchor = ed.caret;
}
app.ui.gfx_edit = Some(ed);
}
}
Tool::Type | Tool::VerticalType => {
if let Some(v) = views.iter().find(|v| v.is_text() && v.hit(p)) {
start_edit_at(app, ui, v, p, &mut actions);
@@ -900,8 +923,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE));
}
let (x, base, li) = l.caret(caret);
let line = &l.lines[li.min(l.lines.len() - 1)];
let (a, b) = (sp(&v.to_screen, x, base - line.ascent), sp(&v.to_screen, x, base + line.descent));
let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent));
let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent));
painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE));
painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK));
let er = Rect::from_points(&v.quad()).union(Rect::from_two_pos(a, b));
@@ -1188,6 +1211,8 @@ fn color32(c: [f32; 4]) -> Color32 {
struct Ctx<'a> {
clip: ClipId,
layer: usize,
/// The layer's index in the clip's effect list (what `effects.toggleAnimation` addresses).
eidx: usize,
e: &'a EffectInstance,
mt: Tick,
t: Tokens,
@@ -1235,12 +1260,37 @@ impl Ctx<'_> {
);
(r, v)
}
/// The stopwatch in front of an animatable property's label (#2358): turns keyframing on or
/// off at the playhead, like the one in Effect Controls. While a property animates, changing
/// it here adds or updates a keyframe at the playhead instead of changing it everywhere. A
/// Type-tool selection edits the selected characters, which have no keyframes: no stopwatch.
fn stopwatch(&mut self, ui: &egui::Ui, row: Rect, id: &str) {
let animatable = self.e.def().and_then(|d| d.param(id)).is_some_and(|d| d.animatable);
if !animatable || (self.range.is_some() && char_style_key(id).is_some()) {
return;
}
let animated = self.e.params.get(id).is_some_and(|p| p.is_animated());
let sw = Rect::from_center_size(pos2(row.min.x + 8.0, row.center().y), vec2(12.0, 12.0));
let resp = ui.interact(sw.expand(2.0), egui::Id::new(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation"));
let tint = if animated {
self.t.accent
} else if resp.hovered() {
self.t.text
} else {
self.t.text_faint
};
icons::paint(ui.painter(), sw, Icon::Stopwatch, tint);
self.autos.push((format!("graphics.prop.{id}.stopwatch"), sw, "Toggle animation".to_string()));
if resp.clicked() {
self.actions.push(("effects.toggleAnimation".into(), json!({"clip": self.clip.0, "effect": self.eidx, "param": id})));
}
}
fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) {
let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0);
let (r, mut vui) = self.row(ui, label);
let (resp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t);
self.auto(id, resp.rect, label);
let _ = r;
self.stopwatch(ui, r, id);
if let Some(nv) = nv {
self.set(id, json!(nv));
}
@@ -1255,7 +1305,8 @@ impl Ctx<'_> {
}
fn point(&mut self, ui: &mut egui::Ui, label: &str, id: &str) {
let v = pv(self.e, id, self.mt).as_vec2().unwrap_or_default();
let (_, mut vui) = self.row(ui, label);
let (r, mut vui) = self.row(ui, label);
self.stopwatch(ui, r, id);
let (rx, nx) = crate::widgets::hot_number(
&mut vui,
egui::Id::new(("gfxp", self.clip.0, self.layer, id, "x")),
@@ -1555,7 +1606,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ch = |b: usize| text.get(..b.min(text.len())).map_or(0, |s| s.chars().count());
Some((ch(ed.caret.min(ed.anchor)), ch(ed.caret.max(ed.anchor))))
});
let mut cx = Ctx { clip, layer: l, e, mt, t, actions: Vec::new(), autos: Vec::new(), range };
let mut cx = Ctx { clip, layer: l, eidx: idx[l], e, mt, t, actions: Vec::new(), autos: Vec::new(), range };
if section(ui, app, "Responsive Design - Position", &t) {
crate::panels::graphics_templates::responsive_position(app, ui, clip, &it, l, &mut cx.autos, &mut cx.actions);
}
@@ -1800,7 +1851,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (cmd, mut p) in actions {
// a drag of a property is one undo step: the first change of each press begins a new one,
// the rest fold into it (like Effect Controls, #201 / #887)
if cmd == "graphics.set" && down {
if matches!(cmd.as_str(), "graphics.set" | "graphics.setCharStyle") && down {
p["merge"] = json!(true);
p["begin"] = json!(begun != press);
wrote = true;
+55 -31
View File
@@ -10,7 +10,8 @@
//! Automation ids: `mixer.<A1|S1|Mix>.<fader|value|pan|panValue|mode|mute|solo|record|output|input|
//! fx.<slot>|send.<slot>|meter|name>`, popup entries below them (`mixer.A1.mode.Touch`…), the
//! effects disclosure `mixer.showEffects`, and the footer transport `mixer.transport.<command>`.
//! The Clip Mixer uses `clipMixer.A1.<fader|value|pan|mute|solo|keyframe|mode>`.
//! The Clip Mixer uses `clipMixer.A1.<fader|value|pan|mute|solo|keyframe|mode>`, and `clipMixer.scroll`
//! when its strips, narrowed to fit the panel, still do not fit.
//!
//! Strips feeding a 5.1 submix or Mix show the 5.1 panner instead of the pan knob: a puck in the
//! listening square (`mixer.A1.pan51`, drag; double-click: front centre) with Center % and LFE
@@ -41,6 +42,8 @@ const TAPER: [(f64, f32); 10] =
/// Labels on the fader scale.
const SCALE: [f64; 10] = [15.0, 6.0, 0.0, -6.0, -12.0, -20.0, -30.0, -40.0, -60.0, -96.0];
const STRIP_W: f32 = 98.0;
/// Narrowest Clip Mixer strip: the M / S / keyframe buttons side by side.
const CLIP_STRIP_MIN_W: f32 = 66.0;
const SLOT_H: f32 = 15.0;
const RED: Color32 = Color32::from_rgb(0xd8, 0x50, 0x3f);
const MUTE_ON: Color32 = Color32::from_rgb(0x2d, 0x9d, 0x78);
@@ -287,15 +290,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x0 = body.min.x + 18.0;
let total = strips.len() as f32 * (STRIP_W + 2.0);
let avail = body.max.x - x0 - 2.0;
let max_off = (total - avail).max(0.0);
let off_id = egui::Id::new("mixer-hscroll");
let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0);
if ui.rect_contains_pointer(body) {
let sd = ui.input(|i| i.smooth_scroll_delta);
off -= sd.x + if ui.input(|i| i.modifiers.shift) { sd.y } else { 0.0 };
}
off = off.clamp(0.0, max_off);
let sb_h = if max_off > 0.0 { 12.0 } else { 0.0 };
let (off, sb_h) = strip_scroll(app, ui, body, x0, total, avail, "mixer");
// strips never get shorter than their controls plus a usable fader: scroll vertically instead
let need = 4.0 + if app.ui.mixer_fx_open { 10.0 * SLOT_H + 10.0 } else { 0.0 } + 112.0 + 26.0 + 24.0 + 6.0 + 170.0 + 42.0;
let clip = Rect::from_min_max(pos2(x0, body.min.y), pos2(body.max.x - 8.0, body.max.y - sb_h));
@@ -320,20 +315,6 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("mixer.vscroll", knob, "Scroll strips vertically");
}
ui.data_mut(|d| d.insert_temp(voff_id, voff));
if max_off > 0.0 {
let bar = Rect::from_min_max(pos2(x0, body.max.y - 10.0), pos2(body.max.x - 4.0, body.max.y - 2.0));
ui.painter().rect_filled(bar, 4.0, t.field_bg);
let kw = (bar.width() * avail / total).max(20.0);
let kx = bar.min.x + (bar.width() - kw) * off / max_off;
let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height()));
let kr = ui.interact(knob, egui::Id::new("mixer-hscroll-knob"), Sense::drag());
ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob });
if kr.dragged() {
off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off);
}
app.auto.add("mixer.scroll", knob, "Scroll strips");
}
ui.data_mut(|d| d.insert_temp(off_id, off));
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(clip));
cui.set_clip_rect(clip.intersect(ui.clip_rect()));
for (i, id) in strips.iter().enumerate() {
@@ -356,6 +337,36 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
run_actions(app, &ui.ctx().clone(), acts);
}
/// Horizontal scrolling of a row of strips `total` wide shown in `avail` from `x0`: the wheel (and
/// Shift + wheel) over `body`, and a knob along its bottom edge (`<key>.scroll`). Returns the offset
/// and the height the scrollbar takes (0 when everything fits).
fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, total: f32, avail: f32, key: &str) -> (f32, f32) {
let t = app.tokens;
let max_off = (total - avail).max(0.0);
let off_id = egui::Id::new(format!("{key}-hscroll"));
let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0);
if ui.rect_contains_pointer(body) {
let sd = ui.input(|i| i.smooth_scroll_delta);
off -= sd.x + if ui.input(|i| i.modifiers.shift) { sd.y } else { 0.0 };
}
off = off.clamp(0.0, max_off);
if max_off > 0.0 {
let bar = Rect::from_min_max(pos2(x0, body.max.y - 10.0), pos2(body.max.x - 4.0, body.max.y - 2.0));
ui.painter().rect_filled(bar, 4.0, t.field_bg);
let kw = (bar.width() * avail / total).max(20.0);
let kx = bar.min.x + (bar.width() - kw) * off / max_off;
let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height()));
let kr = ui.interact(knob, egui::Id::new(format!("{key}-hscroll-knob")), Sense::drag());
ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob });
if kr.dragged() {
off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off);
}
app.auto.add(&format!("{key}.scroll"), knob, "Scroll strips");
}
ui.data_mut(|d| d.insert_temp(off_id, off));
(off, if max_off > 0.0 { 12.0 } else { 0.0 })
}
fn run_actions(app: &mut FilmcraftApp, ctx: &egui::Context, acts: Vec<(String, Value)>) {
for (id, p) in acts {
let r = if filmcraft_engine::find_command(&id).is_some() {
@@ -903,12 +914,23 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut acts: Vec<(String, Value)> = Vec::new();
let kf_id = egui::Id::new("clip-mixer-keyframes");
let mut write_kf: HashMap<u64, bool> = ui.data(|d| d.get_temp(kf_id)).unwrap_or_default();
// Like Premiere, the strips narrow so that every track fits the panel; below the narrowest
// usable strip the row scrolls instead, so no track is out of reach (#1922).
let x0 = rect.min.x + 6.0;
let avail = rect.max.x - x0 - 2.0;
let n = seq.audio_tracks.len();
let sw = (avail / n.max(1) as f32 - 2.0).clamp(CLIP_STRIP_MIN_W, STRIP_W);
let (off, sb_h) = strip_scroll(app, ui, rect, x0, n as f32 * (sw + 2.0), avail, "clipMixer");
let view = Rect::from_min_max(pos2(x0, rect.min.y), pos2(rect.max.x - 2.0, rect.max.y - sb_h));
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(view));
cui.set_clip_rect(view.intersect(ui.clip_rect()));
let ui = &mut cui;
for (i, tr) in seq.audio_tracks.iter().enumerate() {
let x = rect.min.x + 6.0 + i as f32 * (STRIP_W + 2.0);
if x + STRIP_W > rect.max.x {
break;
let x = x0 + i as f32 * (sw + 2.0) - off;
if x + sw < view.min.x || x > view.max.x {
continue;
}
let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0));
let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + sw, view.max.y - 4.0));
let label = format!("A{}", i + 1);
let ap = format!("clipMixer.{label}");
ui.painter().rect_filled(sr, 3.0, t.control_surface);
@@ -974,7 +996,8 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
});
// M S ◇
let y = sr.min.y + 72.0;
let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - 33.0 + k * 24.0, y), vec2(18.0, 16.0));
let step = ((sr.width() - 6.0 - 18.0) / 2.0).min(24.0);
let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - step - 9.0 + k * step, y), vec2(18.0, 16.0));
let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::new((&ap, "M")));
app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track");
if mr.clicked() {
@@ -1038,8 +1061,9 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if vresp.drag_stopped() {
release(&mut acts, "volume");
}
small_text(ui, pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, 12.0, t.text);
small_text(ui, pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, 12.0, t.text);
let name = ui.painter().with_clip_rect(sr.intersect(ui.clip_rect()));
name.text(pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, Tokens::ui(12.0), t.text);
name.text(pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, Tokens::ui(12.0), t.text);
}
ui.data_mut(|d| d.insert_temp(kf_id, write_kf));
run_actions(app, &ui.ctx().clone(), acts);
+1
View File
@@ -86,6 +86,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
PanelKind::AudioMeters => meters::show(app, ui, rect),
PanelKind::LumetriColor => lumetri::show(app, ui, rect),
PanelKind::Properties if graphics::graphic_selected(app) => graphics::properties(app, ui, rect),
PanelKind::Properties if text::caption_selected(app) => text::caption_properties(app, ui, rect),
PanelKind::Properties => effect_controls::properties_panel(app, ui, rect),
PanelKind::EssentialGraphics => graphics_templates::essential_graphics(app, ui, rect),
PanelKind::History => misc::history(app, ui, rect),
+2 -1
View File
@@ -130,7 +130,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
(video_area, None)
};
if let (Some(wa), Some(item)) = (wave_area, source_item) {
monitor_view::waveform(app, ui, wa, item, time, duration, mark_in, mark_out);
let shown = ruler_shown(mv.time_zoom, origin, duration);
monitor_view::waveform(app, ui, wa, item, time, shown, mark_in, mark_out);
}
let show_picture = has_video && !wave_only && !grid_only;
// Rulers along the top and left of the picture area.
+35 -10
View File
@@ -599,7 +599,8 @@ pub fn guides(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, p
}
/// The Source Monitor's audio waveform (Audio Waveform display mode, audio-only clips and the
/// split view): click to move the source playhead.
/// split view) over `shown`, the part of the clip the time ruler shows: click to move the source
/// playhead.
#[allow(clippy::too_many_arguments)]
pub fn waveform(
app: &mut FilmcraftApp,
@@ -607,17 +608,17 @@ pub fn waveform(
area: Rect,
item: ItemId,
time: Tick,
duration: Tick,
shown: (Tick, Tick),
mark_in: Option<Tick>,
mark_out: Option<Tick>,
) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(area);
p.rect_filled(area, 0.0, t.monitor_bg);
let dur = duration.0.max(1) as f64;
let xof = |tk: Tick| area.min.x + ((tk.0 as f64 / dur) as f32).clamp(0.0, 1.0) * area.width();
let dur = (shown.1 - shown.0).0.max(1) as f64;
let xof = |tk: Tick| area.min.x + (((tk - shown.0).0 as f64 / dur) as f32).clamp(0.0, 1.0) * area.width();
if mark_in.is_some() || mark_out.is_some() {
let (a, b) = (xof(mark_in.unwrap_or(Tick::ZERO)), xof(mark_out.unwrap_or(duration)));
let (a, b) = (xof(mark_in.unwrap_or(shown.0)), xof(mark_out.unwrap_or(shown.1)));
p.rect_filled(Rect::from_min_max(pos2(a, area.min.y), pos2(b, area.max.y)), 0.0, Color32::from_white_alpha(14));
}
let mid = area.center().y;
@@ -625,15 +626,12 @@ pub fn waveform(
match crate::panels::timeline::request_peaks(app, item, 0) {
Some(peaks) if !peaks.is_empty() => {
let half = area.height() * 0.45;
let spp = 256.0 * TICKS_PER_SAMPLE_48K;
let mut mesh = egui::Mesh::default();
let col = Color32::from_rgb(0x4f, 0xc3, 0x7a);
for x in (area.min.x.floor() as i32)..(area.max.x.ceil() as i32) {
let f0 = (x as f32 - area.min.x) / area.width();
let f1 = (x as f32 + 1.0 - area.min.x) / area.width();
let s0 = (dur * f0 as f64 / spp) as usize;
let s1 = ((dur * f1 as f64 / spp) as usize).max(s0 + 1);
let (lo, hi) = peaks.iter().skip(s0).take(s1 - s0).fold((0f32, 0f32), |(l, h), (a, b)| (l.min(*a), h.max(*b)));
let (lo, hi) = column_peak(&peaks, shown, f0 as f64, f1 as f64);
if hi - lo > 0.002 {
mesh.add_colored_rect(
Rect::from_min_max(pos2(x as f32, mid - hi.min(1.0) * half), pos2(x as f32 + 1.0, mid - lo.max(-1.0) * half + 1.0)),
@@ -665,10 +663,20 @@ pub fn waveform(
&& let Some(pos) = resp.interact_pointer_pos()
{
let f = ((pos.x - area.min.x) / area.width()).clamp(0.0, 1.0) as f64;
let _ = app.session.execute("source.setPlayhead", json!({"time": (f * dur) as i64}));
let _ = app.session.execute("source.setPlayhead", json!({"time": shown.0.0.saturating_add((f * dur) as i64)}));
}
}
/// The lowest and highest sample of `peaks` under the waveform column from `f0` to `f1` (fractions
/// of the shown range `shown`): the narrower the range, the fewer peak buckets a column covers.
fn column_peak(peaks: &[(f32, f32)], shown: (Tick, Tick), f0: f64, f1: f64) -> (f32, f32) {
let spp = 256.0 * TICKS_PER_SAMPLE_48K;
let (start, len) = (shown.0.0.max(0) as f64, (shown.1 - shown.0).0.max(1) as f64);
let s0 = ((start + len * f0) / spp) as usize;
let s1 = (((start + len * f1) / spp) as usize).max(s0 + 1);
peaks.iter().skip(s0).take(s1 - s0).fold((0f32, 0f32), |(l, h), (a, b)| (l.min(*a), h.max(*b)))
}
/// Ticks per 48 kHz sample (the waveform peaks are 256-sample min/max pairs at 48 kHz).
const TICKS_PER_SAMPLE_48K: f64 = filmcraft_time::TICKS_PER_SECOND as f64 / 48_000.0;
@@ -960,4 +968,21 @@ mod tests {
assert_eq!(v.effective_res(true), PlaybackRes::Half);
assert_eq!(v.effective_res(false), PlaybackRes::Quarter);
}
#[test]
fn zooming_in_resolves_the_waveform_in_finer_columns() {
// a square wave: four loud peak buckets, then four silent ones, 800 buckets in all
let peaks: Vec<(f32, f32)> = (0..800).map(|i| if (i / 4) % 2 == 0 { (-1.0, 1.0) } else { (0.0, 0.0) }).collect();
let bucket = 256.0 * TICKS_PER_SAMPLE_48K;
let at = |b: f64| Tick((b * bucket) as i64);
let silent = |shown: (Tick, Tick)| (0..100).filter(|x| column_peak(&peaks, shown, *x as f64 / 100.0, (*x + 1) as f64 / 100.0).1 == 0.0).count();
// the whole clip in 100 columns: 8 buckets each, every one with a loud part
assert_eq!(silent((at(0.0), at(800.0))), 0);
// an eighth of it: about one bucket a column (a column edge rounding down takes in its
// neighbour), and the silent half shows
let zoomed = silent((at(0.0), at(100.0)));
assert!((45..=50).contains(&zoomed), "{zoomed} silent columns");
// the zoomed part starts where the ruler does: a silent stretch is silent throughout
assert_eq!(silent((at(4.0), at(8.0))), 100);
}
}
+4 -2
View File
@@ -70,8 +70,10 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
if let Err(e) = filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)) {
return Some(Err(e));
}
let items: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
let first = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default();
// the Project panel's selection, or the media of the clips selected in the Timeline (#1362)
let selected = filmcraft_engine::multicam::selected_items(&app.session);
let items: Vec<u64> = selected.iter().map(|i| i.0).collect();
let first = selected.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default();
let name = match kind {
"merge" => tlf!("{first} - Merged", first),
"multicam" => tlf!("{first} Multicam", first),
+2 -1
View File
@@ -26,7 +26,8 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 30.0;
// inside the Presets folder: icon and label one level deeper than it (#2329)
let x = r.min.x + crate::panels::effects::tree_x(1) + 18.0;
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x, r.center().y), vec2(13.0, 13.0)),
+1 -1
View File
@@ -376,7 +376,7 @@ fn button(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, label: &str, primary: b
.corner_radius(CornerRadius::same(15))
.fill(if primary { t.accent } else { Color32::TRANSPARENT })
.stroke(if primary { Stroke::NONE } else { Stroke::new(1.5, t.text_faint) });
let r = ui.add(b);
let r = ui.add(b).on_hover_cursor(egui::CursorIcon::PointingHand);
app.auto.add(id, r.rect, label);
r.clicked()
}

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