mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
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:
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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..]) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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 = ¤t.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 = ¤t.children[0];
|
||||
}
|
||||
assert!(current.children.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
{
|
||||
|
||||
+3225
-3
File diff suppressed because it is too large
Load Diff
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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"));
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user