Merge remote-tracking branch 'origin/main' into nvdec-linux

tests/setting.rs: keep the check against the backend register() reported
(main's fix expects VA-API on Linux, which NVDEC now sits in front of).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SilentYeti 2026-10-10 02:30:22 -07:00
commit 3df0b9bb36
122 changed files with 9486 additions and 674 deletions

View File

@ -31,10 +31,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- 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
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true

View File

@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@ -64,6 +64,11 @@ jobs:
assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
# Both install the app and the CLI/MCP server (#346).
import re
def programs(m):
return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)}
assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}"
print("Flatpak manifests ok")
EOF
- name: FreeBSD package layout (stub binaries)

View File

@ -43,7 +43,7 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@ -71,17 +71,16 @@ jobs:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@ -101,7 +100,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@ -129,17 +128,16 @@ jobs:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
@ -163,7 +161,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@ -187,9 +185,8 @@ jobs:
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
@ -203,8 +200,8 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@ -228,7 +225,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@ -251,8 +248,8 @@ jobs:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@ -266,7 +263,7 @@ jobs:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/filmcraft-$FILMCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
@ -284,16 +281,15 @@ jobs:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
@ -318,7 +314,7 @@ jobs:
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$'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
@ -334,17 +330,16 @@ jobs:
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install wasm-bindgen CLI
@ -362,7 +357,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@ -381,7 +376,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts

View File

@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@ -51,7 +51,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: filmcraft-windows-arm64
path: dist/arm64/*
@ -64,7 +64,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: filmcraft-windows-arm64
path: dist

View File

@ -83,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
- **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out.
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.

View File

@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
### Interface localization
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese covers the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.

View File

@ -56,7 +56,7 @@ fn stdout_failed(e: std::io::Error) {
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
eprintln!("filmcraft-cli: can't write to stdout: {e}");
diag(format_args!("filmcraft-cli: can't write to stdout: {e}"));
STDOUT_ERROR.store(true, Ordering::Relaxed);
}
@ -115,13 +115,23 @@ EXIT STATUS
0 success · 1 a command failed · 2 usage error
";
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
/// reader is gone, which would turn a usage error, a failure or a finished export into a crash.
fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) {
let _ = writeln!(w, "{msg}");
}
fn diag(msg: std::fmt::Arguments) {
write_diag(&mut std::io::stderr(), msg);
}
fn usage(msg: impl std::fmt::Display) -> ! {
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."));
std::process::exit(2)
}
fn fail(msg: impl std::fmt::Display) -> ! {
eprintln!("filmcraft-cli: {msg}");
diag(format_args!("filmcraft-cli: {msg}"));
std::process::exit(1)
}
@ -373,11 +383,7 @@ async fn cli() {
if let Some(r) = a.opt("--range") {
p["range"] = json!(r);
}
if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) {
p["range"] = json!("custom");
p["startSeconds"] = parse_value(s0);
p["endSeconds"] = parse_value(s1);
}
set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end"));
if let Some(js) = a.opt("--settings") {
p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}")));
}
@ -399,7 +405,7 @@ async fn cli() {
match r {
Ok(v) => {
print(&a, &v);
eprintln!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64());
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
}
Err(e) => fail(format!("export: {e}")),
}
@ -414,11 +420,11 @@ async fn cli() {
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();
let Some(img) = s.render_program(scale) else { fail("no sequence") };
let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
}
"mcp" => {
let server = match a.opt("--bridge") {
@ -454,8 +460,40 @@ fn written_paths(result: &Value, requested: &str) -> Vec<String> {
if paths.is_empty() { vec![requested.to_string()] } else { paths }
}
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
}
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
/// instead of the export silently running over the whole sequence.
fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) {
if start.is_none() && end.is_none() {
return;
}
p["range"] = json!("custom");
if let Some(s0) = start {
p["startSeconds"] = parse_value(s0);
}
if let Some(s1) = end {
p["endSeconds"] = parse_value(s1);
}
}
#[cfg(test)]
mod format_tests {
#[test]
fn render_reports_the_real_reason() {
let mut s = filmcraft_engine::Session::default();
assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence"));
s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap();
for scale in [0.0, -1.0, f32::NAN] {
let e = super::render_at_playhead(&s, scale).unwrap_err();
assert!(e.contains("finite and positive"), "{e}");
}
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
}
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
/// decoders at start-up.
#[test]
@ -467,6 +505,23 @@ mod format_tests {
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
}
#[test]
fn a_failing_diagnostic_stream_does_not_panic() {
struct Closed;
impl std::io::Write for Closed {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
}
super::write_diag(&mut Closed, format_args!("exported {}", "a.wav"));
let mut ok = Vec::new();
super::write_diag(&mut ok, format_args!("hi"));
assert_eq!(ok, b"hi\n");
}
#[test]
fn export_message_names_the_written_files() {
use serde_json::json;
@ -478,6 +533,23 @@ mod format_tests {
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
}
#[test]
fn export_lone_bound_is_forwarded_as_custom_range() {
use serde_json::json;
let mut p = json!({});
super::set_custom_bounds(&mut p, Some("0.25"), None);
assert_eq!(p["range"], "custom");
assert_eq!(p["startSeconds"], 0.25);
assert!(p.get("endSeconds").is_none());
let mut p = json!({});
super::set_custom_bounds(&mut p, None, Some("0.25"));
assert_eq!(p["range"], "custom");
assert_eq!(p["endSeconds"], 0.25);
let mut p = json!({});
super::set_custom_bounds(&mut p, None, None);
assert!(p.get("range").is_none());
}
#[test]
fn export_format_from_extension() {
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));

View File

@ -40,3 +40,10 @@ sync_github_name = true
[people.ItzBloxy]
display_name = "Bloxy"
[people.ReReddo]
display_name = "ReReddo"
sync_github_name = false
[people.liangforstudy]
sync_github_name = true

View File

@ -402,7 +402,7 @@ impl FilmcraftMcp {
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
let (w, h) =
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
let scale = (max as f32 / w.max(h) as f32).min(1.0);
let scale = render_scale(max, w, h);
let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?;
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
})
@ -648,10 +648,26 @@ impl ServerHandler for FilmcraftMcp {
}
}
/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`).
fn render_scale(max_side: u32, w: u32, h: u32) -> f32 {
if max_side == 0 {
return 1.0;
}
(max_side as f32 / w.max(h).max(1) as f32).min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_scale_zero_means_no_limit() {
assert_eq!(render_scale(0, 1920, 1080), 1.0);
assert_eq!(render_scale(960, 1920, 1080), 0.5);
assert_eq!(render_scale(960, 640, 360), 1.0);
assert_eq!(render_scale(960, 0, 0), 1.0);
}
#[tokio::test]
async fn headless_commands_and_render() {
let mut s = Session::default();

View File

@ -7,7 +7,7 @@ use common::*;
#[test]
#[ignore]
fn adhoc_compare() {
let Some(ff) = ffmpeg() else { return };
let Some(ff) = ffmpeg_dav1d() else { return };
let Some(path) = std::env::var_os("AV1_IVF") else { return };
let path = std::path::PathBuf::from(path);
let pics = decode_all(&path).unwrap();

View File

@ -6,8 +6,27 @@ use filmcraft_av1::{Decoder, Picture};
use std::path::{Path, PathBuf};
use std::process::Command;
/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip.
pub fn ffmpeg() -> Option<PathBuf> {
filmcraft_testkit::ffmpeg_or_skip("av1 oracle")
ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")])
}
/// ffmpeg with libdav1d, for checks that decode existing files only.
pub fn ffmpeg_dav1d() -> Option<PathBuf> {
ffmpeg_with(&[("-decoders", "libdav1d")])
}
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
for &(list, lib) in needs {
let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
if !out.contains(lib) {
filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)"));
return None;
}
}
Some(ff)
}
pub fn fixture_dir() -> PathBuf {

View File

@ -9,7 +9,7 @@ const VECTORS: &[&str] =
#[test]
fn conformance_vectors() {
let Some(ff) = ffmpeg() else { return };
let Some(ff) = ffmpeg_dav1d() else { return };
for name in VECTORS {
let Some(path) = test_vector(name) else { return };
check_file(&ff, name, &path);
@ -21,7 +21,7 @@ fn conformance_vectors() {
#[test]
#[ignore]
fn conformance_vectors_extended() {
let Some(ff) = ffmpeg() else { return };
let Some(ff) = ffmpeg_dav1d() else { return };
let list: Vec<String> = match std::env::var("AV1_VECTORS") {
Ok(v) => v.split(',').map(str::to_string).collect(),
Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(),

View File

@ -17,7 +17,8 @@
//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence
//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
//! checksum that makes the packet's byte sum 0 mod 256.
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
//! (low 8 bits of DID + SDID + data count + CDP bytes).
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
@ -161,6 +162,9 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec<u8> {
cdp.push(((256 - sum % 256) % 256) as u8);
let mut out = vec![0x61, 0x01, cdp.len() as u8];
out.extend(cdp);
// SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words.
let anc_sum: u32 = out.iter().map(|&b| b as u32).sum();
out.push((anc_sum & 0xff) as u8);
out
}
@ -661,7 +665,11 @@ mod tests {
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
let b = build_cdp(7, &d);
assert_eq!(&b[..3], &[0x61, 0x01, 73]);
let cdp = &b[3..];
// prefix (3) + CDP (73) + ancillary checksum byte (1)
assert_eq!(b.len(), 77);
let sum: u32 = b[..76].iter().map(|&x| x as u32).sum();
assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum");
let cdp = &b[3..76];
assert_eq!(cdp.len(), 73);
assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]);
assert_eq!(cdp.iter().map(|&x| x as u32).sum::<u32>() % 256, 0, "checksum");

View File

@ -53,7 +53,7 @@ pub struct FrameStreamInfo {
}
/// The OBUs of a low-overhead AV1 sample: `(type, payload)`, stopping at the first malformed one.
fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
let mut out = Vec::new();
let mut pos = 0usize;
while let Some(&h) = data.get(pos) {

View File

@ -28,6 +28,7 @@ pub mod mp4;
pub mod mpeg;
pub mod mxf;
pub mod ogg;
mod stream_color;
pub mod video;
use std::sync::{Arc, RwLock};
@ -178,4 +179,6 @@ mod audio_timing_tests;
#[cfg(test)]
mod rounded_pts_tests;
#[cfg(test)]
mod stream_color_tests;
#[cfg(test)]
mod tests;

View File

@ -15,7 +15,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
use filmcraft_color::{ColorInfo, Matrix, Transfer};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig};
use filmcraft_matroska::{Codec, MkvFile, TrackKind};
@ -24,6 +24,7 @@ use filmcraft_time::{FrameRate, Tick};
use crate::audio::{PacketDecoder, decode_pcm};
use crate::gop::{GopCache, VideoSamples};
use crate::stream_color::ColorCodes;
use crate::video::VideoDecoder;
use crate::{CodecError, make_video_decoder};
@ -218,32 +219,24 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMe
})
}
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32) -> (ColorInfo, bool) {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
let Some(col) = &v.colour else { return (c, false) };
let mut explicit = false;
if let Some(m) = col.matrix_coefficients.and_then(|m| Matrix::from_code(m as u8)) {
c.matrix = m;
explicit = true;
}
if let Some(t) = col.transfer_characteristics.and_then(|t| Transfer::from_code(t as u8)) {
c.transfer = t;
explicit = true;
}
if let Some(p) = col.primaries {
c.primaries = match p {
9 => Primaries::Bt2020,
12 => Primaries::P3D65,
5 => Primaries::Bt601_625,
6 => Primaries::Bt601_525,
_ => Primaries::Bt709,
/// Colour of a video track: the `Colour` element wins; what it leaves unspecified, or all of it
/// when there is none, comes from the stream's own description (the SPS VUI in an `avcC` /
/// `hvcC`, the sequence header in an `av1C`); the rest defaults by frame size. The flag says
/// whether `Colour` signals anything that overrides the decoder.
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32, stream: Option<ColorCodes>) -> (ColorInfo, bool) {
// H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here
let code = |c: Option<u64>| c.unwrap_or(2);
let container = v.colour.as_ref().map(|col| {
let range = match col.range {
Some(1) => Some(false),
Some(2) => Some(true),
_ => None,
};
}
if col.full_range() {
c.range = Range::Full;
explicit = true;
}
(c, explicit)
ColorCodes::from_wide(code(col.primaries), code(col.transfer_characteristics), code(col.matrix_coefficients), range)
});
let explicit = container.is_some_and(|c| Matrix::from_code(c.matrix).is_some() || Transfer::from_code(c.transfer).is_some() || c.full_range == Some(true));
let sources: Vec<ColorCodes> = [container, stream].into_iter().flatten().collect();
(crate::stream_color::resolve(w, h, &sources), explicit)
}
fn codec_label(c: &Codec) -> String {
@ -312,11 +305,11 @@ impl MkvSource {
FrameRate::from_f64(1.0 / step)
}
};
let (color, explicit) = color_of(&v, w, h);
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
let (color, explicit) = color_of(&v, w, h, ventry.as_ref().and_then(|e| crate::stream_color::from_codec_config(&e.codec)));
if explicit {
explicit_color = Some(color);
}
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };

View File

@ -7,7 +7,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
use filmcraft_color::ColorInfo;
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind};
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
@ -104,48 +104,40 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u
})
}
/// Colour of a video sample entry. The `colr` box (`nclx` / `nclc`) wins; what it leaves
/// unspecified, or all of it when there is none (ffmpeg writes MP4 without `colr` by default),
/// comes from the stream's own description: `vpcC` / `apvC`, or the SPS VUI in `avcC` / `hvcC`
/// and the sequence header in `av1C`. The rest defaults by frame size.
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
// VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box).
let vpc = match &entry.codec {
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
primaries: v.colour_primaries as u16,
transfer: v.transfer_characteristics as u16,
matrix: v.matrix_coefficients as u16,
full_range: v.full_range,
}),
CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx {
primaries: a.color_primaries as u16,
transfer: a.transfer_characteristics as u16,
matrix: a.matrix_coefficients as u16,
full_range: a.full_range,
}),
use crate::stream_color::ColorCodes;
use filmcraft_isobmff::ColorInfo as Colr;
let colr = match entry.video.as_ref().and_then(|v| v.color.as_ref()) {
Some(Colr::Nclx { primaries, transfer, matrix, full_range }) => {
Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), Some(*full_range)))
}
// QuickTime `nclc` has no range flag
Some(Colr::Nclc { primaries, transfer, matrix }) => Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), None)),
// an ICC profile, or no `colr`: no code points
_ => None,
};
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
let (p, t, m, full) = match col {
filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range),
filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false),
_ => return c,
};
if let Some(m) = Matrix::from_code(m as u8) {
c.matrix = m;
}
if let Some(t) = Transfer::from_code(t as u8) {
c.transfer = t;
}
c.primaries = match p {
9 => Primaries::Bt2020,
12 => Primaries::P3D65,
5 => Primaries::Bt601_625,
6 => Primaries::Bt601_525,
_ => Primaries::Bt709,
};
if full {
c.range = Range::Full;
}
}
c
let stream = match &entry.codec {
CodecConfig::Vp9(v) => Some(ColorCodes {
primaries: v.colour_primaries,
transfer: v.transfer_characteristics,
matrix: v.matrix_coefficients,
full_range: Some(v.full_range),
}),
CodecConfig::Apv(a) if a.color_description_present => Some(ColorCodes {
primaries: a.color_primaries,
transfer: a.transfer_characteristics,
matrix: a.matrix_coefficients,
full_range: Some(a.full_range),
}),
CodecConfig::Apv(_) => None,
codec => crate::stream_color::from_codec_config(codec),
};
let sources: Vec<ColorCodes> = [colr, stream].into_iter().flatten().collect();
crate::stream_color::resolve(w, h, &sources)
}
impl Mp4Source {

View File

@ -0,0 +1,92 @@
//! The colour description a video stream carries in its own bitstream, for containers that don't
//! repeat it: the H.264 / HEVC sequence parameter set's VUI (from `avcC` / `hvcC`) and the AV1
//! sequence header's `color_config` (from `av1C`), read with the decoders' own parsers.
//!
//! ffmpeg writes MP4 without a `colr` box unless asked (`-movflags +write_colr`), so for most
//! files the bitstream is the only place their colour is described. [`resolve`] merges it with
//! what the container signals, which wins.
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
use filmcraft_isobmff::{AvcConfig, CodecConfig, HevcConfig};
use crate::video::primaries_from_code;
/// ITU-T H.273 `Unspecified`.
const UNSPECIFIED: u8 = 2;
/// Colour code points (ITU-T H.273 / ISO/IEC 23091-2) and range as one place in a file (a
/// container colour box, a sequence header) signals them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColorCodes {
pub primaries: u8,
pub transfer: u8,
pub matrix: u8,
/// `None`: this place doesn't say.
pub full_range: Option<bool>,
}
impl ColorCodes {
/// From wider code points (a `colr` box or a Matroska `Colour` element): a value that doesn't
/// fit a byte is no H.273 code point, so it counts as unspecified.
pub fn from_wide(primaries: u64, transfer: u64, matrix: u64, full_range: Option<bool>) -> Self {
let code = |v: u64| u8::try_from(v).unwrap_or(UNSPECIFIED);
Self { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix), full_range }
}
}
/// The colour a video codec configuration's bitstream signals: the VUI of the first SPS in an
/// `avcC` / `hvcC`, or the `color_config` of the sequence header in an `av1C`. `None` for other
/// codecs, an SPS without VUI, and a configuration that is truncated or corrupt.
pub fn from_codec_config(codec: &CodecConfig) -> Option<ColorCodes> {
match codec {
CodecConfig::Avc(c) => avc(c),
CodecConfig::Hevc(c) => hevc(c),
CodecConfig::Av1(c) => av1(&c.config_obus),
_ => None,
}
}
fn avc(c: &AvcConfig) -> Option<ColorCodes> {
// NAL header byte: nal_unit_type 7 is an SPS
let nal = c.sps.iter().find(|n| n.first().is_some_and(|h| h & 0x1f == 7))?;
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(1..)?)).ok()?;
let v = sps.vui?;
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
}
fn hevc(c: &HevcConfig) -> Option<ColorCodes> {
// two-byte NAL header: nal_unit_type 33 is an SPS (whatever array the record files it under)
let nal = c.arrays.iter().flat_map(|a| &a.nalus).find(|n| n.first().is_some_and(|h| (h >> 1) & 0x3f == 33))?;
let sps = filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(2..)?)).ok()?;
let v = sps.vui?;
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
}
fn av1(config_obus: &[u8]) -> Option<ColorCodes> {
// OBU type 1: the sequence header
let (_, payload) = crate::hw_frame::obus(config_obus).into_iter().find(|(t, _)| *t == 1)?;
let c = filmcraft_av1::SequenceHeader::parse(payload).ok()?.color;
Some(ColorCodes { primaries: c.color_primaries, transfer: c.transfer_characteristics, matrix: c.matrix_coefficients, full_range: Some(c.color_range) })
}
/// Colour from the code points a file signals, most authoritative first (the container's colour
/// box, then the bitstream's): the matrix, transfer, primaries and range each come from the first
/// source that signals a value we know. What no source signals keeps the defaults: the matrix by
/// frame size ([`filmcraft_frame::default_matrix`]: BT.601 for SD, BT.709 otherwise), BT.709
/// transfer and primaries, limited range.
pub fn resolve(width: u32, height: u32, sources: &[ColorCodes]) -> ColorInfo {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..ColorInfo::REC709 };
if let Some(m) = sources.iter().find_map(|s| Matrix::from_code(s.matrix)) {
c.matrix = m;
}
if let Some(t) = sources.iter().find_map(|s| Transfer::from_code(s.transfer)) {
c.transfer = t;
}
if let Some(p) = sources.iter().find_map(|s| primaries_from_code(s.primaries)) {
c.primaries = p;
}
if sources.iter().find_map(|s| s.full_range) == Some(true) {
c.range = Range::Full;
}
c
}

View File

@ -0,0 +1,340 @@
//! Colour from the bitstream when the container has none (`crate::stream_color`): ffmpeg writes MP4
//! without a `colr` box by default, and an HEVC PQ file imported as Rec. 709 SDR, a full-range
//! H.264 file as limited range, and a BT.601 SD file with BT.709 primaries. The parameter sets
//! are built here bit by bit; no media files.
use std::io::Cursor;
use std::sync::Arc;
use filmcraft_bitstream::{BitWriter, escape_rbsp};
use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer};
use filmcraft_isobmff::{Av1Config, AvcConfig, Brand, CodecConfig, HevcConfig, HevcNalArray, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
use filmcraft_media::MediaSource;
use crate::stream_color::{ColorCodes, from_codec_config, resolve};
/// The video signal description of a VUI: (colour_primaries, transfer_characteristics,
/// matrix_coefficients) when `colour_description_present_flag` is set, and the range flag.
#[derive(Clone, Copy)]
struct Signal {
codes: Option<(u8, u8, u8)>,
full_range: bool,
}
const PQ_2020: Signal = Signal { codes: Some((9, 16, 9)), full_range: false };
const SMPTE_170M: Signal = Signal { codes: Some((6, 6, 6)), full_range: false };
const FULL_ONLY: Signal = Signal { codes: None, full_range: true };
fn signal(w: &mut BitWriter, s: Signal) {
w.write_bit(true); // video_signal_type_present_flag
w.write_bits(5, 3); // video_format: unspecified
w.write_bit(s.full_range);
w.write_bit(s.codes.is_some()); // colour_description_present_flag
if let Some((p, t, m)) = s.codes {
w.write_bits(p.into(), 8);
w.write_bits(t.into(), 8);
w.write_bits(m.into(), 8);
}
}
/// A Baseline H.264 SPS NAL unit (ITU-T H.264 §7.3.2.1.1, VUI per Annex E.1.1) of
/// `mbs_w`×`mbs_h` macroblocks, with a VUI carrying `vui` (no VUI when `None`).
fn h264_sps(mbs_w: u32, mbs_h: u32, vui: Option<Signal>) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(66, 8); // profile_idc: Baseline
w.write_bits(0, 8); // constraint flags
w.write_bits(30, 8); // level_idc
w.write_ue(0); // seq_parameter_set_id
w.write_ue(0); // log2_max_frame_num_minus4
w.write_ue(2); // pic_order_cnt_type
w.write_ue(1); // max_num_ref_frames
w.write_bit(false); // gaps_in_frame_num_value_allowed_flag
w.write_ue(mbs_w - 1);
w.write_ue(mbs_h - 1);
w.write_bits(0b110, 3); // frame_mbs_only, direct_8x8_inference, no cropping
w.write_bit(vui.is_some()); // vui_parameters_present_flag
if let Some(s) = vui {
w.write_bit(false); // aspect_ratio_info_present_flag
w.write_bit(false); // overscan_info_present_flag
signal(&mut w, s);
w.write_bit(false); // chroma_loc_info_present_flag
w.write_bit(false); // timing_info_present_flag
w.write_bit(false); // nal_hrd_parameters_present_flag
w.write_bit(false); // vcl_hrd_parameters_present_flag
w.write_bit(false); // pic_struct_present_flag
w.write_bit(false); // bitstream_restriction_flag
}
w.rbsp_trailing();
let mut nal = vec![0x67];
nal.extend(escape_rbsp(&w.finish()));
nal
}
/// A Main 10 HEVC SPS NAL unit (ITU-T H.265 §7.3.2.2, VUI per Annex E.2.1) of `width`×`height`
/// (multiples of 8), with a VUI carrying `vui` (no VUI when `None`).
fn hevc_sps(width: u32, height: u32, vui: Option<Signal>) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(0, 4); // sps_video_parameter_set_id
w.write_bits(0, 3); // sps_max_sub_layers_minus1
w.write_bit(true); // sps_temporal_id_nesting_flag
// profile_tier_level: Main 10, level 4.1
w.write_bits(0, 2);
w.write_bit(false);
w.write_bits(2, 5);
w.write_bits(0x2000_0000, 32);
w.write_bits(0b1001, 4);
w.write_bits(0, 32);
w.write_bits(0, 12);
w.write_bits(123, 8);
w.write_ue(0); // sps_seq_parameter_set_id
w.write_ue(1); // chroma_format_idc 4:2:0
w.write_ue(width);
w.write_ue(height);
w.write_bit(false); // conformance_window_flag
w.write_ue(2); // bit_depth_luma_minus8
w.write_ue(2); // bit_depth_chroma_minus8
w.write_ue(4); // log2_max_pic_order_cnt_lsb_minus4
w.write_bit(true); // sps_sub_layer_ordering_info_present_flag
w.write_ue(4); // sps_max_dec_pic_buffering_minus1
w.write_ue(0); // sps_max_num_reorder_pics
w.write_ue(0); // sps_max_latency_increase_plus1
w.write_ue(0); // log2_min_luma_coding_block_size_minus3 (8)
w.write_ue(1); // log2_diff_max_min_luma_coding_block_size (CTB 16)
w.write_ue(0); // log2_min_luma_transform_block_size_minus2 (4)
w.write_ue(2); // log2_diff_max_min_luma_transform_block_size (16)
w.write_ue(0); // max_transform_hierarchy_depth_inter
w.write_ue(0); // max_transform_hierarchy_depth_intra
w.write_bit(false); // scaling_list_enabled_flag
w.write_bit(false); // amp_enabled_flag
w.write_bit(false); // sample_adaptive_offset_enabled_flag
w.write_bit(false); // pcm_enabled_flag
w.write_ue(0); // num_short_term_ref_pic_sets
w.write_bit(false); // long_term_ref_pics_present_flag
w.write_bit(false); // sps_temporal_mvp_enabled_flag
w.write_bit(false); // strong_intra_smoothing_enabled_flag
w.write_bit(vui.is_some()); // vui_parameters_present_flag
if let Some(s) = vui {
w.write_bit(false); // aspect_ratio_info_present_flag
w.write_bit(false); // overscan_info_present_flag
signal(&mut w, s);
w.write_bit(false); // chroma_loc_info_present_flag
w.write_bit(false); // neutral_chroma_indication_flag
w.write_bit(false); // field_seq_flag
w.write_bit(false); // frame_field_info_present_flag
w.write_bit(false); // default_display_window_flag
w.write_bit(false); // vui_timing_info_present_flag
w.write_bit(false); // bitstream_restriction_flag
}
w.write_bit(false); // sps_extension_present_flag
w.rbsp_trailing();
let mut nal = vec![33 << 1, 1];
nal.extend(escape_rbsp(&w.finish()));
nal
}
/// An AV1 sequence header OBU (AV1 bitstream spec §5.5; reduced still-picture header, profile 0,
/// 10-bit 4:2:0) of `width`×`height` with `color_config` signalling `codes` and `full_range`.
fn av1_sequence_header(width: u32, height: u32, codes: Option<(u8, u8, u8)>, full_range: bool) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(0, 3); // seq_profile
w.write_bit(true); // still_picture
w.write_bit(true); // reduced_still_picture_header
w.write_bits(0, 5); // seq_level_idx[0]
w.write_bits(15, 4); // frame_width_bits_minus_1
w.write_bits(15, 4); // frame_height_bits_minus_1
w.write_bits(width - 1, 16);
w.write_bits(height - 1, 16);
w.write_bits(0, 3); // use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter
w.write_bits(0, 3); // enable_superres, enable_cdef, enable_restoration
w.write_bit(true); // high_bitdepth
w.write_bit(false); // mono_chrome
w.write_bit(codes.is_some()); // color_description_present_flag
if let Some((p, t, m)) = codes {
w.write_bits(p.into(), 8);
w.write_bits(t.into(), 8);
w.write_bits(m.into(), 8);
}
w.write_bit(full_range); // color_range
w.write_bits(0, 2); // chroma_sample_position
w.write_bit(false); // separate_uv_delta_q
w.write_bit(false); // film_grain_params_present
w.rbsp_trailing();
let payload = w.finish();
// OBU header: type 1 (OBU_SEQUENCE_HEADER), obu_has_size_field; one-byte leb128 size
let mut obu = vec![(1 << 3) | 2, u8::try_from(payload.len()).expect("short payload")];
obu.extend(payload);
obu
}
fn avc(sps: Vec<u8>) -> CodecConfig {
CodecConfig::Avc(AvcConfig::new(vec![sps], vec![vec![0x68, 0xCE, 0x38, 0x80]], 4))
}
fn hevc(sps: Vec<u8>) -> CodecConfig {
CodecConfig::Hevc(HevcConfig {
general_profile_idc: 2,
chroma_format_idc: 1,
bit_depth_luma: 10,
bit_depth_chroma: 10,
length_size: 4,
arrays: vec![HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps] }],
..Default::default()
})
}
fn av1(obus: Vec<u8>) -> CodecConfig {
CodecConfig::Av1(Av1Config { high_bitdepth: true, chroma_subsampling_x: true, chroma_subsampling_y: true, config_obus: obus, ..Default::default() })
}
/// A one-sample MP4 with `codec` (the sample is never decoded) and, when given, a `colr` box.
fn mp4(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> Arc<[u8]> {
let mut entry = match codec {
CodecConfig::Avc(c) => SampleEntry::avc(c, w, h),
CodecConfig::Hevc(c) => SampleEntry::hevc(c, w, h),
other => SampleEntry::video(filmcraft_isobmff::FourCc(*b"av01"), other, w, h),
};
if let Some(v) = entry.video.as_mut() {
v.color = colr;
}
let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer");
let t = mux.add_track(TrackConfig::new(entry, 30)).expect("track");
mux.write_sample(t, WriteSample { data: &[0, 0, 0, 1, 0x65], duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
mux.finish().expect("finish").into_inner().into()
}
fn mp4_color(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> ColorInfo {
let src = crate::Mp4Source::open("clip.mp4", mp4(codec, w, h, colr)).expect("open");
src.info().video.as_ref().expect("video").color
}
#[test]
fn hevc_pq_without_colr_is_rec2100_pq() {
let c = mp4_color(hevc(hevc_sps(1920, 1080, Some(PQ_2020))), 1920, 1080, None);
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited });
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
assert!(ColorSpace::from_info(&c).is_hdr());
}
#[test]
fn full_range_h264_without_colr_is_full_range() {
// video_full_range_flag with no colour description: full range, the rest by size
let c = mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, None);
assert_eq!(c, ColorInfo { range: Range::Full, ..ColorInfo::REC709 });
}
#[test]
fn bt601_sd_h264_without_colr_has_bt601_primaries() {
let c = mp4_color(avc(h264_sps(45, 30, Some(SMPTE_170M))), 720, 480, None);
assert_eq!(c, ColorInfo { matrix: Matrix::Bt601, transfer: Transfer::Bt709, primaries: Primaries::Bt601_525, range: Range::Limited });
// PAL (BT.470 BG primaries, BT.601 matrix)
let pal = Signal { codes: Some((5, 6, 5)), full_range: false };
let c = mp4_color(avc(h264_sps(45, 36, Some(pal))), 720, 576, None);
assert_eq!((c.primaries, c.matrix), (Primaries::Bt601_625, Matrix::Bt601));
}
#[test]
fn unspecified_or_missing_vui_keeps_the_size_defaults() {
let unspecified = Signal { codes: Some((2, 2, 2)), full_range: false };
for vui in [None, Some(unspecified)] {
assert_eq!(mp4_color(avc(h264_sps(80, 45, vui)), 1280, 720, None), ColorInfo::REC709);
assert_eq!(mp4_color(hevc(hevc_sps(1920, 1080, vui)), 1920, 1080, None), ColorInfo::REC709);
// SD: the BT.601 matrix, as before
assert_eq!(mp4_color(avc(h264_sps(45, 30, vui)), 720, 480, None), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
}
}
#[test]
fn colr_wins_over_the_bitstream() {
use filmcraft_isobmff::ColorInfo::{Nclc, Nclx};
let sps = || hevc(hevc_sps(1920, 1080, Some(PQ_2020)));
// a BT.709 nclx box beats a PQ VUI, and is what the decoder's frames are tagged with
let colr = Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false };
assert_eq!(mp4_color(sps(), 1920, 1080, Some(colr.clone())), ColorInfo::REC709);
// HLG in colr, PQ in the VUI: HLG
let hlg = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: false };
assert_eq!(mp4_color(sps(), 1920, 1080, Some(hlg)).transfer, Transfer::Hlg);
// limited range in colr beats a full-range VUI
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(colr)).range, Range::Limited);
// what colr leaves unspecified comes from the VUI
let partial = Nclx { primaries: 2, transfer: 16, matrix: 2, full_range: false };
assert_eq!(mp4_color(sps(), 1920, 1080, Some(partial)), mp4_color(sps(), 1920, 1080, None));
// QuickTime nclc has no range flag: the VUI's range stands
let nclc = Nclc { primaries: 1, transfer: 1, matrix: 1 };
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(nclc)).range, Range::Full);
// an ICC profile has no code points: the bitstream decides
let icc = filmcraft_isobmff::ColorInfo::Icc { kind: filmcraft_isobmff::FourCc(*b"prof"), profile: vec![0; 8] };
assert_eq!(mp4_color(sps(), 1920, 1080, Some(icc)).transfer, Transfer::Pq);
}
#[test]
fn av1_sequence_header_colour() {
let obu = av1_sequence_header(1920, 1080, Some((9, 16, 9)), false);
assert_eq!(from_codec_config(&av1(obu.clone())), Some(ColorCodes { primaries: 9, transfer: 16, matrix: 9, full_range: Some(false) }));
let c = mp4_color(av1(obu), 1920, 1080, None);
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
// no colour description: unspecified code points, the range flag still counts
let obu = av1_sequence_header(640, 360, None, true);
assert_eq!(from_codec_config(&av1(obu)), Some(ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: Some(true) }));
}
#[test]
fn matroska_without_colour_uses_the_bitstream() {
let CodecConfig::Hevc(hvcc) = hevc(hevc_sps(1920, 1080, Some(PQ_2020))) else { panic!("an hvcC") };
let mut spec = TrackSpec::new(TrackKind::Video, "V_MPEGH/ISO/HEVC");
spec.codec_private = hvcc.to_bytes();
spec.video_size = Some((1920, 1080));
spec.default_duration_ns = Some(33_333_333);
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).expect("writer");
w.write_frame(0, 0, true, &[0, 0, 0, 1, 0x26], None).expect("frame");
let b: Arc<[u8]> = w.finish().expect("finish").into_inner().into();
let src = crate::MkvSource::open("clip.mkv", b).expect("open");
let c = src.info().video.as_ref().expect("video").color;
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
assert_eq!((c.primaries, c.matrix), (Primaries::Bt2020, Matrix::Bt2020Ncl));
}
#[test]
fn resolve_takes_each_field_from_the_first_source_that_knows_it() {
let colr = ColorCodes { primaries: 1, transfer: 2, matrix: 0, full_range: None };
let vui = ColorCodes { primaries: 9, transfer: 18, matrix: 9, full_range: Some(true) };
// matrix 0 (identity) is not one we know: the VUI's BT.2020 is used
let c = resolve(1920, 1080, &[colr, vui]);
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Hlg, primaries: Primaries::Bt709, range: Range::Full });
assert_eq!(resolve(720, 480, &[]), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
// code points that don't fit a byte are unspecified, not truncated (0x109 is not 9)
assert_eq!(ColorCodes::from_wide(0x109, 0x110, 0x109, None), ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: None });
}
/// Every truncation and many bit flips of each configuration: never a panic, and the result is
/// either some colour or none (the size defaults).
#[test]
fn hostile_parameter_sets_never_panic() {
let configs: Vec<(Vec<u8>, fn(Vec<u8>) -> CodecConfig)> = vec![
(h264_sps(45, 30, Some(SMPTE_170M)), avc),
(hevc_sps(1920, 1080, Some(PQ_2020)), hevc),
(av1_sequence_header(1920, 1080, Some((9, 16, 9)), false), av1),
];
for (good, make) in configs {
assert!(from_codec_config(&make(good.clone())).is_some());
let mut variants: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
for bit in 0..good.len() * 8 {
let mut b = good.clone();
b[bit / 8] ^= 0x80 >> (bit % 8);
variants.push(b);
}
variants.push(vec![0xff; 64]);
variants.push(Vec::new());
for v in variants {
let r = std::panic::catch_unwind(|| from_codec_config(&make(v.clone())));
assert!(r.is_ok(), "panicked on {v:02x?}");
}
}
// configurations with no parameter sets at all
assert_eq!(from_codec_config(&CodecConfig::Avc(AvcConfig::default())), None);
assert_eq!(from_codec_config(&CodecConfig::Hevc(HevcConfig::default())), None);
assert_eq!(from_codec_config(&CodecConfig::Av1(Av1Config::default())), None);
// a garbage SPS imports with the size defaults
assert_eq!(mp4_color(avc(vec![0x67, 0xff, 0xff]), 1280, 720, None), ColorInfo::REC709);
assert_eq!(mp4_color(hevc(vec![33 << 1, 1, 0xff]), 1920, 1080, None), ColorInfo::REC709);
}

View File

@ -615,3 +615,32 @@ fn apv_mp4_and_raw_bitstream_decode() {
let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame");
assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8());
}
/// ffmpeg writes MP4 without a `colr` box unless asked: the colour comes from the x265 / x264
/// SPS VUI, and matches the same stream remuxed with `colr`.
#[test]
fn mp4_colour_comes_from_the_sps_without_colr() {
use filmcraft_color::{ColorSpace, Primaries, Range};
let src = |name: &str, args: &[&str]| fixture(name, args).map(|b| crate::open_bytes(name, b).expect("open"));
let color = |s: &filmcraft_media::SharedSource| s.info().video.as_ref().expect("video").color;
let lavfi = |size: &'static str| ["-f", "lavfi", "-i", size, "-t", "0.2"];
let x265 = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:log-level=error";
let mut pq = lavfi("testsrc2=s=640x360:r=25").to_vec();
pq.extend(["-c:v", "libx265", "-tag:v", "hvc1", "-pix_fmt", "yuv420p10le", "-x265-params", x265]);
let Some(plain) = src("hevc_pq_no_colr.mp4", &pq) else { return };
assert_eq!(ColorSpace::from_info(&color(&plain)), ColorSpace::Rec2100Pq);
let colr_path = fixture_path("hevc_pq_no_colr.mp4", &pq).expect("fixture");
if let Some(with_colr) = src("hevc_pq_colr.mp4", &["-i", colr_path.to_str().expect("path"), "-c", "copy", "-movflags", "+write_colr"]) {
assert_eq!(color(&with_colr), color(&plain));
}
let mut full = lavfi("testsrc2=s=640x360:r=25").to_vec();
full.extend(["-c:v", "libx264", "-pix_fmt", "yuvj420p", "-color_range", "pc"]);
if let Some(s) = src("h264_full_no_colr.mp4", &full) {
assert_eq!(color(&s).range, Range::Full);
}
let mut sd = lavfi("testsrc2=s=720x480:r=30000/1001").to_vec();
sd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", "colorprim=smpte170m:transfer=smpte170m:colormatrix=smpte170m"]);
if let Some(s) = src("h264_sd601_no_colr.mp4", &sd) {
assert_eq!(color(&s).primaries, Primaries::Bt601_525);
}
}

View File

@ -535,15 +535,30 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt
match edge {
Edge::In => {
// extending left (d<0) needs media before source_in; shortening needs min duration
let max_ext = Tick((head(ctx, it).0 as f64 / speed).floor() as i64);
// a reversed clip plays source_out down to source_in, so its In edge consumes media after source_out
let handle = match (it.reverse, media) {
(true, Some(m)) => (m - it.source_out()).max(Tick::ZERO),
(true, None) => Tick::MAX,
(false, _) => head(ctx, it),
};
let max_ext = if handle == Tick::MAX { Tick::MAX } else { Tick((handle.0 as f64 / speed).floor() as i64) };
let lo = if mode == TrimMode::Regular { (-(it.start - prev_end)).max(-max_ext) } else { -max_ext };
let hi = it.duration - ctx.min_duration;
d = d.clamp(if it.frame_hold.is_some() { Tick::MIN } else { lo }, hi);
// a frame hold needs no source head, but a Regular trim still stops at the previous clip
let lo = match (it.frame_hold.is_some(), mode) {
(true, TrimMode::Regular) => -(it.start - prev_end),
(true, _) => Tick::MIN,
(false, _) => lo,
};
d = d.clamp(lo, hi);
}
Edge::Out => {
let lo = -(it.duration - ctx.min_duration);
let mut hi = if mode == TrimMode::Regular { next_start - it.end() } else { Tick::MAX };
if let Some(m) = media {
if it.reverse && it.frame_hold.is_none() {
// a reversed clip's Out edge consumes media before source_in
hi = hi.min(Tick((head(ctx, it).0 as f64 / speed).floor() as i64));
} else if let Some(m) = media {
let remain = Tick(((m - it.source_out()).0 as f64 / speed).floor() as i64);
hi = hi.min(remain);
}
@ -751,10 +766,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
let mut d = delta;
// left out-point limits
d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration);
if let Some(m) = media_len(ctx, l) {
// a reversed clip consumes media from the opposite end, so its media limit is the other one
if l.reverse {
d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64));
} else if let Some(m) = media_len(ctx, l) {
d = d.min(Tick(((m - l.source_out()).0 as f64 / l.speed.abs()).floor() as i64));
}
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
if r.reverse {
if let Some(m) = media_len(ctx, r) {
d = d.max(-Tick(((m - r.source_out()).0 as f64 / r.speed.abs()).floor() as i64));
}
} else {
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
}
if d == Tick::ZERO {
return Ok(d);
}
@ -762,15 +786,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
let before = seq.clone();
{
let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?;
if l.reverse {
l.source_in -= src_of(d, ls);
}
l.duration += d;
}
{
let (_, r) = seq.find_item_mut(right).ok_or(EditError::NoItem(right))?;
r.start += d;
r.duration -= d;
r.source_in += src_of(d, rs);
if !r.reverse {
r.source_in += src_of(d, rs);
}
}
let _ = ls;
transitions_follow_cuts(&before, seq);
Ok(d)
}
@ -799,7 +827,10 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
let mut d = delta;
if let Some(p) = &prev {
d = d.max(-(p.duration - ctx.min_duration));
if let Some(m) = media_len(ctx, p) {
if p.reverse {
// a reversed neighbour grows by pulling earlier media in front of its source In
d = d.min(Tick((head(ctx, p).0 as f64 / p.speed.abs()).floor() as i64));
} else if let Some(m) = media_len(ctx, p) {
d = d.min(Tick(((m - p.source_out()).0 as f64 / p.speed.abs()).floor() as i64));
}
} else {
@ -807,7 +838,14 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
}
if let Some(n) = &next {
d = d.min(n.duration - ctx.min_duration);
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
if n.reverse {
// a reversed neighbour grows leftwards by pulling later media behind its source Out
if let Some(m) = media_len(ctx, n) {
d = d.max(-Tick(((m - n.source_out()).0 as f64 / n.speed.abs()).floor() as i64));
}
} else {
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
}
} else {
d = d.min(neighbours(tr, clip).1 - it.end());
}
@ -819,12 +857,17 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
if let Some(p) = prev {
let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?;
pi.duration += d;
if p.reverse {
pi.source_in -= src_of(d, p.speed);
}
}
if let Some(n) = next {
let ni = t.item_mut(n.id).ok_or(EditError::NoItem(n.id))?;
ni.start += d;
ni.duration -= d;
ni.source_in += src_of(d, n.speed);
if !n.reverse {
ni.source_in += src_of(d, n.speed);
}
}
let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?;
me.start += d;

View File

@ -234,6 +234,27 @@ fn close_gap_works() {
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10)]);
}
#[test]
fn reversed_trim_uses_the_handle_its_playback_direction_consumes() {
let mut fx = Fx::new();
let v1 = fx.v(0);
// source frames [5, 15) played backwards; the media is 1000 frames long
let a = fx.put(v1, 30, 10, 5);
fx.seq.find_item_mut(a).unwrap().1.reverse = true;
let mut n = fx.next;
// out extends into the 5 frames before source_in, not into the 985 after source_out
let d = trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(20), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(d, f(5));
let it = fx.seq.find_item(a).unwrap().1;
assert_eq!((it.source_in, it.source_out()), (f(0), f(15)));
// in extends into the media after source_out
let d = trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, -f(10), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(d, -f(10));
let it = fx.seq.find_item(a).unwrap().1;
assert_eq!((it.source_in, it.source_out()), (f(0), f(25)));
fx.seq.check().unwrap();
}
#[test]
fn regular_trim_respects_neighbours_and_handles() {
let mut fx = Fx::new();
@ -253,6 +274,24 @@ fn regular_trim_respects_neighbours_and_handles() {
fx.seq.check().unwrap();
}
#[test]
fn held_regular_in_trim_stops_at_previous_clip() {
let mut fx = Fx::new();
let v1 = fx.v(0);
fx.put(v1, 0, 10, 0);
let b = fx.put(v1, 15, 10, 5);
fx.seq.find_item_mut(b).unwrap().1.frame_hold = Some(f(5));
let mut n = fx.next;
// 5 frames of gap: a 20 frame extension is clamped to 5 instead of overlapping the previous clip
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(d, -f(5));
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 15)]);
// no space left: nothing to do, and no error
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(3), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(d, Tick::ZERO);
fx.seq.check().unwrap();
}
#[test]
fn ripple_trim_shifts_following() {
let mut fx = Fx::new();
@ -359,6 +398,25 @@ fn roll_slip_slide() {
fx.seq.check().unwrap();
}
#[test]
fn slide_keeps_reversed_neighbours_content() {
let mut fx = Fx::new();
let v1 = fx.v(0);
let a = fx.put(v1, 0, 30, 20);
let b = fx.put(v1, 30, 30, 50);
let c = fx.put(v1, 60, 30, 70);
for id in [a, c] {
fx.seq.find_item_mut(id).unwrap().1.reverse = true;
}
let mut n = fx.next;
slide(&mut fx.seq, b, f(10), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(fx.spans(v1), vec![(0, 40), (40, 30), (70, 20)]);
assert_eq!(fx.seq.find_item(a).unwrap().1.source_in, f(10));
assert_eq!(fx.seq.find_item(b).unwrap().1.source_in, f(50));
assert_eq!(fx.seq.find_item(c).unwrap().1.source_in, f(70));
fx.seq.check().unwrap();
}
#[test]
fn transitions_stay_on_their_cuts() {
let mut fx = Fx::new();
@ -673,3 +731,21 @@ fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() {
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure");
}
}
#[test]
fn roll_reversed_clips_keeps_media_mapping() {
let mut fx = Fx::new();
let v = fx.v(0);
let mut a = fx.item(0, 30, 20);
a.reverse = true;
let mut b = fx.item(30, 30, 50);
b.reverse = true;
let (ia, ib) = (a.id, b.id);
fx.seq.track_mut(v).unwrap().items.extend([a, b]);
let mut n = 0;
roll(&mut fx.seq, ia, ib, f(10), &mut Fx::ctx(&mut n)).unwrap();
let (_, l) = fx.seq.find_item(ia).unwrap();
assert_eq!((l.start, l.duration, l.source_in), (f(0), f(40), f(10)));
let (_, r) = fx.seq.find_item(ib).unwrap();
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
}

View File

@ -59,6 +59,11 @@ pub fn through_edits(seq: &Sequence) -> Vec<ThroughEdit> {
/// piece is listed. The left piece absorbs the right one (keeping its own effects and attributes);
/// a transition sitting on the cut is removed. Returns the number of edits joined.
pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
join_through_edits_where(seq, |a, b| only.is_empty() || only.contains(&a) || only.contains(&b))
}
/// Join the through edits on unlocked tracks whose (left, right) pieces `wanted` accepts.
pub fn join_through_edits_where(seq: &mut Sequence, wanted: impl Fn(ClipId, ClipId) -> bool) -> usize {
let mut n = 0;
for tr in seq.all_tracks_mut() {
if tr.locked {
@ -67,8 +72,7 @@ pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
let mut i = 0;
while i + 1 < tr.items.len() {
let (a, b) = (&tr.items[i], &tr.items[i + 1]);
let wanted = only.is_empty() || only.contains(&a.id) || only.contains(&b.id);
if !(wanted && is_through_edit(a, b)) {
if !(wanted(a.id, b.id) && is_through_edit(a, b)) {
i += 1;
continue;
}

View File

@ -143,7 +143,7 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32
let mut done = 0usize;
while done < frames {
let n = chunk.min(frames - done);
let b = src.audio(start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
let b = src.audio_stream(need.audio_stream, start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
for (c, o) in out.iter_mut().enumerate() {
match b.channels.get(c).or(b.channels.first()) {
Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
@ -230,8 +230,19 @@ fn ensure_dir(s: &Session, dir: &str) {
}
}
fn check_linked_streams(s: &Session, seq: ItemId, plan: &AudioPlan) -> Result<()> {
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
let needs = audio_needs(&s.project, seq, &NeedOptions { nests: plan.nests, ..Default::default() });
if needs.iter().any(|need| need.audio_stream > 0) {
return Err(EngineError::Other("linked AAF media cannot select container audio streams; choose embedded or separate audio".into()));
}
}
Ok(())
}
/// Prepare the audio essence of an export; returns the essence and the files written.
pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec<AudioEssence>, Vec<String>)> {
check_linked_streams(s, seq, plan)?;
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
return Ok((Vec::new(), Vec::new()));
}
@ -255,7 +266,7 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str
let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default();
format!("{} {}", safe_name(&name), c.0)
}
EssenceKey::Media(_) => safe_name(
EssenceKey::Media(_) | EssenceKey::MediaStream { .. } => safe_name(
need.path
.as_deref()
.map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
@ -380,6 +391,7 @@ pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
let plan = plan_from(s, p, seq, cmd, false)?;
check_linked_streams(s, seq, &plan)?;
let (dir, stem) = split(&path);
let media_dir = join(&dir, &format!("{stem} Media"));
let mut files = Vec::new();

View File

@ -311,3 +311,192 @@ fn commands_are_registered_with_menus_and_validate_params() {
assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
let _ = std::fs::remove_dir_all(dir);
}
// Selected-stream essence uses only PR361 schema13 placement and decoder fields.
mod selected_streams {
use crate::{Services, Session};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource};
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, SequenceSettings};
use filmcraft_time::{FrameRate, Tick};
use serde_json::json;
use std::sync::{Arc, Mutex};
type Calls = Arc<Mutex<Vec<(usize, i64, usize, u32)>>>;
struct Tones {
info: MediaInfo,
calls: Calls,
}
impl Tones {
fn new(streams: usize) -> Self {
let source = filmcraft_media::generators::GeneratorSource::new(
filmcraft_media::Generator::BarsAndTone,
32,
18,
FrameRate::FPS_24,
Tick::from_seconds_f64(2.0),
);
let mut info = source.info().clone();
info.kind = MediaKind::AudioOnly;
info.video = None;
info.audio_streams = vec![info.audio_streams[0].clone(); streams];
Self { info, calls: Arc::default() }
}
fn sample(stream: usize, channel: usize, frame: i64, rate: u32) -> f32 {
let frequency = (stream * 2 + channel + 1) as f64 * 240.0;
let amplitude = 0.05 * (stream + 1) as f64;
(amplitude * (std::f64::consts::TAU * frequency * frame as f64 / rate as f64).sin()) as f32
}
}
impl MediaSource for Tones {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
Err(MediaError::NoStream("video"))
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
self.audio_stream(0, start, frames, sample_rate)
}
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
if stream >= self.info.audio_streams.len() {
return Err(MediaError::NoStream("audio"));
}
self.calls.lock().unwrap().push((stream, start, frames, sample_rate));
Ok(AudioBuffer {
sample_rate,
channels: (0..2).map(|channel| (0..frames).map(|i| Self::sample(stream, channel, start + i as i64, sample_rate)).collect()).collect(),
})
}
}
#[derive(Default)]
struct MemoryFiles {
writes: Mutex<Vec<(String, Vec<u8>)>>,
}
impl Services for MemoryFiles {
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
Err(std::io::ErrorKind::NotFound.into())
}
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
self.writes.lock().unwrap().push((path.to_owned(), bytes.to_vec()));
Ok(())
}
fn export_in_memory(&self) -> bool {
true
}
}
fn add_source(s: &mut Session, streams: usize) -> (ItemId, Calls) {
let source = Arc::new(Tones::new(streams));
let calls = source.calls.clone();
let mut project = (*s.project).clone();
let item = project.add_item(
"distinct tones",
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::File { path: format!("tone-source-{streams}.wav") },
info: source.info.clone(),
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
s.project = Arc::new(project);
s.media.insert(item, source);
(item, calls)
}
fn fixture() -> (Session, ItemId, ItemId, Calls, Arc<MemoryFiles>) {
let files = Arc::new(MemoryFiles::default());
let mut session = Session::new(files.clone());
let mut project = (*session.project).clone();
let sequence =
project.new_sequence("streams", SequenceSettings { width: 32, height: 18, frame_rate: FrameRate::FPS_24, ..Default::default() }, 0, 1, None);
session.project = Arc::new(project);
session.state.active_sequence = Some(sequence);
session.state.open_sequences = vec![sequence];
let (item, calls) = add_source(&mut session, 3);
session.state.project_selection = vec![item];
session.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
(session, item, sequence, calls, files)
}
fn place(s: &mut Session, item: ItemId, seconds: f64) -> Vec<ClipId> {
let result = s.execute("timeline.place", json!({"item": item.0, "track": "A1", "seconds": seconds})).unwrap();
result["clips"].as_array().unwrap().iter().map(|id| ClipId(id.as_u64().unwrap())).collect()
}
#[test]
fn aaf_essence_distinguishes_streams_and_linked_refusal_precedes_writes() {
use crate::aaf_omf::{AudioMode, AudioPlan};
use filmcraft_interchange::essence::{EssenceData, EssenceKey, NestNeeds};
let (mut s, item, sequence, calls, files) = fixture();
place(&mut s, item, 0.0);
let plan = AudioPlan {
mode: AudioMode::Embedded,
aiff: false,
mxf: false,
sample_rate: 48_000,
bits: 16,
trim: true,
handles: Tick::ZERO,
render_effects: false,
breakout: false,
nests: NestNeeds::Inside,
};
let (essences, paths) = crate::aaf_omf::prepare_audio(&s, sequence, &plan, "ignored").unwrap();
assert!(paths.is_empty());
assert_eq!(essences.len(), 3);
for (stream, essence) in essences.iter().enumerate() {
assert_eq!(essence.key, EssenceKey::media(item, stream));
let EssenceData::Embedded(bytes) = &essence.data else { panic!("expected embedded PCM") };
for channel in 0..2 {
let offset = (25 * 2 + channel) * 2;
let actual = i16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
let expected = (Tones::sample(stream, channel, 25, 48_000) * 32767.0).round() as i16;
assert_eq!(actual, expected);
}
}
let options = filmcraft_interchange::aaf::AafOptions {
media: filmcraft_interchange::essence::MediaOptions { essence: essences, ..Default::default() },
..Default::default()
};
let (aaf, _) = filmcraft_interchange::aaf::export(&s.project, sequence, &options).unwrap();
let omf_options = filmcraft_interchange::omf::OmfOptions { media: options.media.clone(), ..Default::default() };
let (omf, _) = filmcraft_interchange::omf::export(&s.project, sequence, &omf_options).unwrap();
for (kind, document) in [("AAF", aaf), ("OMF", omf)] {
let (imported, extracted, _) = if kind == "AAF" {
filmcraft_interchange::aaf::import(&document, &Default::default()).unwrap()
} else {
filmcraft_interchange::omf::import(&document, &Default::default()).unwrap()
};
assert_eq!(extracted.len(), 3, "{kind}: composition memo must retain distinct selected-stream essence");
let tracks = &imported.project.sequence(imported.sequences[0]).unwrap().audio_tracks;
assert_eq!(tracks.len(), 3, "{kind}");
let mut source_ids: Vec<_> = tracks.iter().map(|track| track.items[0].item).collect();
source_ids.sort();
source_ids.dedup();
assert_eq!(source_ids.len(), 3, "{kind}: each stream remains a distinct imported source");
let mut waveforms: Vec<_> = extracted.iter().map(|media| media.wav.clone()).collect();
waveforms.sort();
waveforms.dedup();
assert_eq!(waveforms.len(), 3, "{kind}");
}
assert!(calls.lock().unwrap().iter().any(|call| call.0 == 2));
assert!(files.writes.lock().unwrap().is_empty());
let error =
s.execute("file.exportAaf", json!({"path": "must-not-be-written.aaf", "sequence": sequence.0, "audio": "linked", "trimAudio": false})).unwrap_err();
assert!(error.to_string().contains("embedded") || error.to_string().contains("separate"));
assert!(files.writes.lock().unwrap().is_empty());
}
}

View File

@ -94,6 +94,50 @@ fn placing_media_with_two_streams_expands_tracks_and_keeps_stream_indices() {
assert_eq!(a.start, b.start);
}
#[test]
fn clip_peaks_measure_the_stream_the_clip_plays() {
let mut s = Session::default();
let mut p = (*s.project).clone();
let rate = FrameRate::FPS_24;
let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None);
let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24));
let mut info = source.info().clone();
info.audio_streams.push(info.audio_streams[0].clone());
let item = p.add_item(
"two streams",
filmcraft_project::Label::Iris,
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let (vdest, adest) = {
let q = p.sequence(seq).unwrap();
(q.video_tracks[0].id, q.audio_tracks[0].id)
};
s.project = std::sync::Arc::new(p);
s.state.active_sequence = Some(seq);
let ids =
crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None)
.unwrap();
let q = s.project.sequence(seq).unwrap();
let (a, b) = (q.audio_tracks[0].items[0].id, q.audio_tracks[1].items[0].id);
assert!(ids.contains(&a) && ids.contains(&b));
let peaks = crate::mixer::clip_peaks(&s, &[a, b]);
let db = |c: filmcraft_project::ClipId| peaks.iter().find(|(id, _)| *id == c).map(|(_, d)| *d).unwrap();
// The generator only has stream 0, so the clip playing stream 1 is silent, not a copy of stream 0.
assert!(db(a) > -30.0, "{peaks:?}");
assert!(db(b) < -100.0, "{peaks:?}");
}
#[test]
fn missing_audio_tracks_are_cloned_from_the_destination() {
let s = demo();

View File

@ -513,7 +513,11 @@ pub fn commands() -> Vec<CommandSpec> {
enabled: always,
run: |s, p| {
let Some(seq) = s.active_sequence() else { return Ok(json!({"tracks": []})) };
let only = p.get("track").and_then(|_| track_param(s, p));
// a `track` that names no caption track is an error, never every track
let only = match p.get("track") {
Some(_) => Some(track_param(s, p).ok_or_else(|| bad("captions.list", "no such caption track"))?),
None => None,
};
let tracks: Vec<Value> =
seq.caption_tracks.iter().enumerate().filter(|(_, t)| only.is_none_or(|o| o == t.id)).map(|(i, t)| track_json(s, t, i)).collect();
Ok(json!({"tracks": tracks, "selection": s.state.caption_selection.iter().map(|c| c.0).collect::<Vec<_>>()}))

View File

@ -137,3 +137,24 @@ fn caption_commands_need_tracks_and_navigate() {
s.execute("captions.deleteTrack", json!({"track": "C1"})).unwrap();
assert!(s.active_sequence().unwrap().caption_tracks.is_empty());
}
#[test]
fn caption_list_track_filter_never_falls_back_to_every_track() {
let mut s = demo();
s.execute("captions.newTrack", json!({})).unwrap();
s.execute("captions.newTrack", json!({})).unwrap();
let all = s.execute("captions.list", json!({})).unwrap();
let ids: Vec<u64> = all["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
assert_eq!(ids.len(), 2, "{all}");
for (i, id) in ids.iter().enumerate() {
for sel in [json!(id), json!(format!("C{}", i + 1))] {
let r = s.execute("captions.list", json!({"track": sel})).unwrap();
let got: Vec<u64> = r["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
assert_eq!(got, vec![*id], "{sel}");
}
}
let missing = ids.iter().max().unwrap() + 999;
for sel in [json!(missing), json!("C3"), json!("C0"), json!("nope")] {
assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track");
}
}

View File

@ -901,3 +901,25 @@ fn add_edit_cuts_only_the_selected_clips() {
let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
assert!(r["cuts"].as_u64().unwrap() > 1, "{r}");
}
#[test]
fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Slip"})).unwrap();
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap();
let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap();
let item = r["item"].as_u64().unwrap();
let fr = s.sequence_rate().tick_of(1);
let r =
s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap();
let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap());
// lengthen only the picture so the pair has unequal tail handles
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 20})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": 30})).unwrap();
assert_eq!(clip(&s, v).source_in, Tick(fr.0 * 20));
assert_eq!(clip(&s, a).source_in, Tick(fr.0 * 20), "sound must not slip alone");
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap();
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
}

View File

@ -1325,13 +1325,17 @@ fn build() -> Vec<CommandSpec> {
Ok(Value::Null)
}
),
// Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip
// selected, Enable swaps them rather than first making both the same.
cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
let sel = with_links(s, &clips_p(s, p));
let mut sel = with_links(s, &clips_p(s, p));
// a clip named twice must still flip once
sel.sort();
sel.dedup();
s.edit_sequence("Enable", |q, _, _| {
let target = !sel.iter().all(|c| q.find_item(*c).is_some_and(|(_, i)| i.enabled));
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
i.enabled = target;
i.enabled = !i.enabled;
}
}
Ok(())
@ -1837,6 +1841,15 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!(
"markers.exportCsv",
"Export Markers as CSV…",
["Markers"],
None,
r#"{"path":str} (all markers in the active sequence)"#,
has_seq,
crate::marker_export::export
),
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? {
return Ok(v);
@ -1889,8 +1902,11 @@ fn build() -> Vec<CommandSpec> {
cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| {
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
let r = s.sequence_rate();
let f = r.frame_at(s.playhead()) + n;
s.set_playhead(r.tick_of(f.max(0)));
let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0);
if f > r.frame_at(Tick::MAX) {
return Err(bad("playhead.step", "target frame exceeds the supported time range"));
}
s.set_playhead(r.tick_of(f));
Ok(json!({"frame": r.frame_at(s.playhead())}))
}),
cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))),
@ -2203,8 +2219,16 @@ fn build() -> Vec<CommandSpec> {
.unwrap_or_default();
let clips = with_links(s, &[c]);
s.edit_sequence("Slip", |q, ctx, _| {
// clamp across all linked partners, then slip them by the common delta
let mut dd = d;
for c in &clips {
edit::slip(q, *c, d, ctx)?;
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
for c in &clips {
edit::slip(q, *c, dd, ctx)?;
}
Ok(())
})?;
@ -2497,7 +2521,7 @@ fn build() -> Vec<CommandSpec> {
"Move Keyframe",
[],
None,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks}"#,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks,"value":any?,"merge":bool?,"begin":bool?}"#,
has_seq,
|s, p| keyframe_op(s, p, "move")
),
@ -2515,7 +2539,7 @@ fn build() -> Vec<CommandSpec> {
"Edit Keyframe",
[],
None,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?}"#,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?,"merge":bool?,"begin":bool?}"#,
has_seq,
|s, p| keyframe_op(s, p, "set")
),
@ -2704,7 +2728,12 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
"set" => "Edit Keyframe",
_ => "Keyframe Interpolation",
};
s.edit_sequence(label, |q, _, _| {
// a keyframe dragged on the timeline is one undo step, like a dragged value
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("keyframe:{op}:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
if bool_p(p, "begin").unwrap_or(false) {
s.history.merge_key = None;
}
s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
let e = match &eff {
@ -2730,6 +2759,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
if let Some(i) = prm.keyframes.iter().position(|k| k.time == from) {
let mut k = prm.keyframes.remove(i);
k.time = to;
if let Some(v) = p.get("value") {
k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?;
}
prm.keyframes.retain(|x| x.time != to);
let at = prm.keyframes.partition_point(|x| x.time < to);
prm.keyframes.insert(at, k);
@ -3183,6 +3215,16 @@ fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
}
fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value> {
let (track, tr) = preview_transition(s, p, kind)?;
let label = format!("Apply {}", tr.effect.def().map_or("Transition", |d| d.name));
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
Ok(json!({"transition": id.0}))
}
/// Plan an applied transition without editing the project or allocating an id.
/// Uses the same `effect`, `clip`, `edge`, `frames` and settings as transition application,
/// so a drag preview shows the exact range that release will commit.
pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(TrackId, Transition)> {
let eff_id = str_p(p, "effect")
.map(str::to_string)
.unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() });
@ -3233,6 +3275,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
}
}
let (track, from, to, cut) = found.ok_or_else(|| EngineError::Other("no edit point at the playhead on targeted tracks".into()))?;
if q.track(track).is_some_and(|tr| tr.locked) {
return Err(edit::EditError::Locked.into());
}
let start = if from.is_some() && to.is_some() {
cut - dur.mul_ratio(1, 2)
} else if to.is_some() {
@ -3241,9 +3286,7 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
cut - dur
};
let tr = Transition { id: TransitionId(0), effect: instance, start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse };
let label = format!("Apply {}", def.name);
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
Ok(json!({"transition": id.0}))
Ok((track, tr))
}
/// JSON description of the project (bins, items) for agents.

View File

@ -64,6 +64,19 @@ fn export_integer_parameters_cannot_wrap_or_overflow() {
assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers");
}
#[test]
fn nested_camel_case_settings_are_honoured() {
let mut s = demo();
let camel = json!({"audio":{"sampleRate":96000}, "effects":{"loudness":{"enabled":true, "targetLufs":-16}}});
let snake = json!({"audio":{"sample_rate":96000}, "effects":{"loudness":{"enabled":true, "target_lufs":-16}}});
let camel = s.execute("export.resolve", json!({"format":"wav", "settings":camel})).unwrap();
let snake = s.execute("export.resolve", json!({"format":"wav", "settings":snake})).unwrap();
assert_eq!(camel["output"]["sampleRate"], 96000);
assert_eq!(camel["settings"]["audio"]["sample_rate"], 96000);
assert_eq!(camel["settings"]["effects"]["loudness"]["target_lufs"], -16.0);
assert_eq!(camel["settings"], snake["settings"]);
}
fn probe(path: &str) -> Option<Value> {
let ffprobe = filmcraft_testkit::ffprobe_or_skip("export presets")?;
let out = std::process::Command::new(ffprobe).args(["-v", "error", "-of", "json", "-show_format", "-show_streams", path]).output().unwrap();
@ -359,6 +372,22 @@ fn queue_orders_cancels_and_retries() {
assert!(queue(&mut s).is_empty());
}
#[test]
fn queue_move_by_extreme_offsets_clamps_instead_of_overflowing() {
let mut s = demo();
let ids: Vec<u64> = (0..3)
.map(|_| {
let r = s.execute("export.queue.add", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": "queued-output/"})).unwrap();
r["added"][0].as_u64().unwrap()
})
.collect();
let order = |s: &mut Session| -> Vec<u64> { queue(s).iter().map(|i| i["id"].as_u64().unwrap()).collect() };
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MAX})).unwrap();
assert_eq!(order(&mut s), [ids[0], ids[2], ids[1]]);
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MIN})).unwrap();
assert_eq!(order(&mut s), [ids[1], ids[0], ids[2]]);
}
#[test]
fn queue_cancels_a_running_export_and_retries_a_failed_one() {
let mut s = demo();

View File

@ -129,12 +129,30 @@ fn parse_file(bytes: &[u8]) -> std::result::Result<PresetFile, String> {
// settings
// ---------------------------------------------------------------------------------------------
/// `sampleRate` → `sample_rate`.
fn camel_to_snake(k: &str) -> String {
let mut out = String::with_capacity(k.len() + 2);
for c in k.chars() {
if c.is_ascii_uppercase() {
out.push('_');
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
out
}
/// Deep-merge `patch` into `base` (objects merge, everything else replaces).
fn merge(base: &mut Value, patch: &Value) {
match (base, patch) {
(Value::Object(b), Value::Object(p)) => {
for (k, v) in p {
merge(b.entry(k.clone()).or_insert(Value::Null), v);
// Some nested settings structs serialize snake_case (`audio.sample_rate`); accept the documented camelCase
// spelling for them too instead of adding a second, ignored key next to the default.
let snake = camel_to_snake(k);
let key = if !b.contains_key(k) && b.contains_key(&snake) { snake } else { k.clone() };
merge(b.entry(key).or_insert(Value::Null), v);
}
}
(b, p) => *b = p.clone(),
@ -769,7 +787,7 @@ fn queue_move(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.export_queue.items.len() as i64;
let to = match (p.get("to").and_then(Value::as_i64), p.get("by").and_then(Value::as_i64)) {
(Some(t), _) => t,
(None, Some(b)) => i as i64 + b,
(None, Some(b)) => (i as i64).saturating_add(b),
_ => return Err(bad(cmd, "need `to` (index) or `by` (±n)")),
}
.clamp(0, n - 1) as usize;

View File

@ -316,7 +316,7 @@ fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
let (layers, meta) = t.instantiate((w, h));
let before = s.history.undo.len();
let dur = t.duration;
let clip = place_video_clip(s, &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
let clip = place_video_clip(s, "graphics.template.apply", &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
let src = crate::graphics::graphic_source(pr, w, h, rate);
(src, rate.snap_nearest(dur))
})?;
@ -911,7 +911,7 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> {
let name = text.lines().next().unwrap_or("Caption").chars().take(40).collect::<String>();
let dur = c.duration;
let q = json!({"time": c.start.0});
let clip = place_video_clip(s, &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
let clip = place_video_clip(s, "graphics.upgradeCaption", &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
(crate::graphics::graphic_source(pr, w, h, rate), dur)
})?;
clips.push(clip.0);

View File

@ -96,23 +96,25 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32,
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
fn new_graphic_clip(s: &mut Session, cmd: &str, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
// Here `seconds` is duration, not the generic time parser's placement alias.
let mut placement = p.clone();
if let Some(params) = placement.as_object_mut() {
params.remove("seconds");
}
place_video_clip(s, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
place_video_clip(s, cmd, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
let src = graphic_source(pr, w, h, rate);
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
})
}
/// Place a video clip of the item `source` returns (with its duration) at the playhead (or
/// `time`), above the clips there, as one undo step; selects it.
/// `time`), above the clips there, as one undo step; selects it. `cmd` is the command that asked,
/// for its errors.
pub(crate) fn place_video_clip(
s: &mut Session,
cmd: &str,
name: &str,
p: &Value,
label: &str,
@ -133,7 +135,7 @@ pub(crate) fn place_video_clip(
let src_size = pr.source_size(src).unwrap_or((w, h));
let t = rate.snap(t);
let dur = dur.max(rate.frame_duration());
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?;
ti.name = name;
ti.effects.extend(extra);
for e in &mut ti.effects {
@ -146,7 +148,7 @@ pub(crate) fn place_video_clip(
let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range));
let idx = match want_track {
Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i,
Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))),
Some(i) if i < q.video_tracks.len() => return Err(bad(cmd, format!("V{} is not free here", i + 1))),
_ => {
let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1);
match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) {
@ -190,6 +192,10 @@ fn param_id(k: &str) -> &str {
"fontSize" => "size",
"fontStyle" | "style" => "font_style",
"fillColor" | "color" => "fill_color",
"fillKind" => "fill_kind",
"gradientStart" => "gradient_start",
"gradientEnd" => "gradient_end",
"gradientAngle" => "gradient_angle",
"strokeColor" => "stroke_color",
"strokeWidth" => "stroke_width",
"backgroundColor" => "background_color",
@ -212,6 +218,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<Par
"align" => graphic::ALIGN_OPTS,
"caps" => graphic::CAPS_OPTS,
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
"fill_kind" => graphic::FILL_KIND_OPTS,
"shape" => SHAPE_OPTS,
_ => &[],
};
@ -703,7 +710,7 @@ fn menu_commands() -> Vec<CommandSpec> {
"Rectangle",
&["Graphics and Titles", "New Layer"],
Some("Cmd+Alt+R"),
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| new_shape_cmd(s, p, "rectangle"),
),
@ -712,7 +719,7 @@ fn menu_commands() -> Vec<CommandSpec> {
"Ellipse",
&["Graphics and Titles", "New Layer"],
Some("Cmd+Alt+E"),
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| new_shape_cmd(s, p, "ellipse"),
),
@ -721,7 +728,7 @@ fn menu_commands() -> Vec<CommandSpec> {
"Polygon",
&["Graphics and Titles", "New Layer"],
None,
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#,
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
@ -766,7 +773,7 @@ fn menu_commands() -> Vec<CommandSpec> {
return Err(bad("graphics.newFromFile", "the file has no picture"));
}
let (name, dur) = (pi.name.clone(), pi.duration());
let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
let clip = place_video_clip(s, "graphics.newFromFile", &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
s.state.graphic_layers.clear();
Ok(json!({"clip": clip.0, "item": item.0}))
},
@ -902,7 +909,7 @@ pub fn commands() -> Vec<CommandSpec> {
Some(c) => (c, add_layer(s, c, layer)?),
None => {
let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string();
(new_graphic_clip(s, layer, &name, p)?, 0)
(new_graphic_clip(s, "graphics.newText", layer, &name, p)?, 0)
}
};
Ok(json!({"clip": clip.0, "layer": layer_i}))
@ -913,7 +920,7 @@ pub fn commands() -> Vec<CommandSpec> {
"Shape",
&[],
None,
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]? (the shape's centre),"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id? (add the shape to this graphic),"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| {
let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase();
@ -936,7 +943,7 @@ pub fn commands() -> Vec<CommandSpec> {
let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
let (clip, layer_i) = match into {
Some(c) => (c, add_layer(s, c, layer)?),
None => (new_graphic_clip(s, layer, "Shape", p)?, 0),
None => (new_graphic_clip(s, "graphics.newShape", layer, "Shape", p)?, 0),
};
Ok(json!({"clip": clip.0, "layer": layer_i}))
},

View File

@ -65,6 +65,36 @@ fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() {
}
}
/// A new graphic goes on the track and at the time it is given, for a shape as for text; a track
/// that is not free there is an error named after the command that asked.
#[test]
fn graphic_placement_takes_a_track_and_a_time() {
let commands =
[("graphics.newText", json!({})), ("graphics.newShape", json!({})), ("graphics.newRectangle", json!({})), ("graphics.newPolygon", json!({"sides": 5}))];
for (command, mut params) in commands {
let mut s = Session::default();
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 3, "audio": 1})).unwrap();
s.execute("playhead.set", json!({"frame": 72})).unwrap();
params["track"] = json!(2);
params["frame"] = json!(24);
params["seconds"] = json!(1);
let r = s.execute(command, params.clone()).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let q = s.active_sequence().unwrap();
let (track, it) = q.find_item(clip).unwrap();
assert_eq!(track, q.video_tracks[2].id, "{command}");
assert_eq!((it.start, it.duration), (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(1.0)), "{command}");
// the same place again is taken
let e = s.execute(command, params).unwrap_err().to_string();
assert!(e.contains("V3 is not free here"), "{command}: {e}");
let named = if command == "graphics.newText" { "graphics.newText" } else { "graphics.newShape" };
assert!(e.contains(named), "{command}: {e}");
assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1, "{command}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_sequence().unwrap().find_item(clip).is_none(), "{command}");
}
}
#[test]
fn new_text_makes_a_graphic_clip_above_the_footage() {
let mut s = demo();
@ -149,6 +179,24 @@ fn align_and_distribute_layers() {
let _ = w;
}
#[test]
fn linear_gradient_fill_sets_and_evaluates() {
let mut s = demo();
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [200, 200], "size": [80, 40]})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
s.execute(
"graphics.set",
json!({"clip": clip.0, "props": {"fill_kind": "linear gradient", "gradient_start": "#ff0000", "gradient_end": "#0000ff", "gradient_angle": 0}}),
)
.unwrap();
let g = layers(&s, clip)[0].appearance.gradient.clone().expect("linear fill");
assert_eq!(g.start[0], 1.0);
assert!(g.start[1] < 0.01 && g.end[2] > 0.9 && g.end[0] < 0.01, "{g:?}");
assert_eq!(g.angle, 0.0);
s.execute("graphics.set", json!({"clip": clip.0, "props": {"fill_kind": "solid"}})).unwrap();
assert!(layers(&s, clip)[0].appearance.gradient.is_none());
}
#[test]
fn graphic_renders_in_the_program_and_survives_save() {
let mut s = demo();

View File

@ -23,6 +23,7 @@ pub mod graphic_templates;
pub mod graphics;
pub mod interchange;
pub mod keyboard;
mod marker_export;
pub mod masks;
pub mod media_browser;
pub mod media_pool;
@ -707,14 +708,15 @@ impl Session {
open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(),
active_sequence: self.state.active_sequence.filter(is_seq),
sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(),
playheads: self.state.playheads.iter().filter(|(id, _)| is_seq(id)).map(|(id, t)| (*id, *t)).collect(),
}
}
/// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are
/// not sequences of this project are dropped (also a second mention of the same sequence),
/// and numbers are brought into range. A view without any open sequence leaves the project
/// on its first sequence: a project saved by a session that never showed one (a script, the
/// CLI) should not open on an empty Timeline.
/// and numbers are brought into range (a playhead also lands on a frame of its sequence). A
/// view without any open sequence leaves the project on its first sequence: a project saved
/// by a session that never showed one (a script, the CLI) should not open on an empty Timeline.
pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) {
let mut open: Vec<ItemId> = Vec::new();
for id in view.open_sequences {
@ -728,6 +730,12 @@ impl Session {
}
self.state.timeline_views =
view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect();
for (id, t) in view.playheads {
if let Some(seq) = self.project.sequence(id) {
let t = t.clamp(Tick::ZERO, filmcraft_project::ProjectView::MAX_PLAYHEAD);
self.state.playheads.insert(id, seq.settings.frame_rate.snap(t));
}
}
}
/// Every edit passes through here: one that would put a sequence inside itself (directly or

View File

@ -0,0 +1,311 @@
//! Sequence review notes as a spreadsheet-safe CSV, using the host's atomic file service.
use filmcraft_project::Label;
use filmcraft_time::{Tick, format_timecode_frames};
use serde_json::{Value, json};
use crate::commands::bad;
use crate::{EngineError, Result, Session};
const CMD: &str = "markers.exportCsv";
const MAX_BYTES: usize = 16 * 1024 * 1024;
const MAX_MARKERS: usize = 100_000;
fn label_name(s: &Session, color: Label) -> &str {
s.prefs.labels.colors.get(&crate::settings::label_id(color)).map(|c| c.name.trim()).filter(|n| !n.is_empty()).unwrap_or(color.name())
}
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = p
.get("path")
.and_then(Value::as_str)
.filter(|v| !v.trim().is_empty() && !v.contains('\0'))
.ok_or_else(|| bad(CMD, "need a non-empty `path` without NUL characters"))?;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
seq.settings.validate().map_err(|e| bad(CMD, e))?;
let name = s.state.active_sequence.and_then(|id| s.project.item(id)).map(|i| i.name.as_str()).unwrap_or("Sequence");
if seq.markers.len() > MAX_MARKERS {
return Err(bad(CMD, "too many markers (maximum 100000)"));
}
// Bound the worst-case quoted output before sorting or allocating it. 1024 covers numeric,
// timecode and enum fields; arbitrary text may double in size when every character is a quote.
let mut bound = 512usize;
for m in &seq.markers {
let row = name
.len()
.checked_add(m.name.len())
.and_then(|n| n.checked_add(m.comment.len()))
.and_then(|n| n.checked_add(label_name(s, m.color).len()))
.and_then(|n| n.checked_mul(2))
.and_then(|n| n.checked_add(1024));
bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?;
}
let rate = seq.settings.frame_rate;
let mut markers: Vec<_> = seq.markers.iter().collect();
markers.sort_by_key(|m| (m.start, m.id));
let mut csv =
String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n");
for m in markers {
if m.start.0 < 0 || m.duration.0 < 0 {
return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0)));
}
let end = m.start.0.checked_add(m.duration.0).ok_or_else(|| bad(CMD, format!("marker {} end overflows", m.id.0)))?;
let tc = |t: Tick| -> Result<String> {
let frame = rate.frame_at(t).checked_add(seq.start_timecode).ok_or_else(|| bad(CMD, "sequence timecode overflows"))?;
Ok(format_timecode_frames(frame, rate, seq.settings.drop_frame))
};
let fields = [
name.to_string(),
m.id.0.to_string(),
m.name.clone(),
m.comment.clone(),
format!("{:?}", m.kind),
label_name(s, m.color).to_string(),
tc(m.start)?,
tc(Tick(end))?,
rate.frame_at(m.duration).to_string(),
m.start.0.to_string(),
m.duration.0.to_string(),
format!("{}/{}", rate.num, rate.den),
];
for (i, field) in fields.iter().enumerate() {
if i > 0 {
csv.push(',');
}
csv.push('"');
// Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text,
// including formulas preceded by whitespace; numeric/timecode fields stay numeric.
if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) {
csv.push('\'');
}
for ch in field.chars() {
if ch == '"' {
csv.push('"');
}
csv.push(ch);
}
csv.push('"');
}
csv.push_str("\r\n");
}
s.services.write_file(path, csv.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use filmcraft_project::{Label, Marker, MarkerId, MarkerKind};
use filmcraft_time::FrameRate;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Files {
writes: Mutex<Vec<(String, Vec<u8>)>>,
fail: bool,
}
impl Services for Files {
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
Err(std::io::ErrorKind::NotFound.into())
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
if self.fail {
return Err(std::io::ErrorKind::PermissionDenied.into());
}
self.writes.lock().unwrap().push((path.into(), data.to_vec()));
Ok(())
}
}
fn session(files: Arc<Files>) -> Session {
let mut s = Session::default();
s.services = files;
s.execute("file.newSequence", json!({"name":"Review, 日本語", "fps":24})).unwrap();
s
}
fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker {
Marker {
id: MarkerId(id),
start,
duration,
name: name.into(),
comment: "First line\r\nSecond, \"quoted\"".into(),
kind: MarkerKind::Chapter,
color: Label::Iris,
}
}
fn set_markers(s: &mut Session, markers: Vec<Marker>) {
s.edit_sequence("Review markers", |q, _, _| {
q.markers = markers;
Ok(())
})
.unwrap();
}
fn written(files: &Files) -> String {
String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap()
}
#[test]
fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
let rate = FrameRate::FPS_24;
set_markers(&mut s, vec![marker(100, rate.tick_of(48), rate.tick_of(24), "Later"), marker(101, rate.tick_of(24), Tick::ZERO, "早い")]);
s.execute("markers.markIn", json!({"frame":12})).unwrap();
s.state.hidden_marker_colors.push(Label::Iris);
let before = s.project.clone();
let state = serde_json::to_value(&s.state).unwrap();
let undo = s.history.undo.len();
let result = s.execute(CMD, json!({"path":"review.csv"})).unwrap();
let csv = written(&files);
assert_eq!(result["markers"], 2);
assert_eq!(result["bytes"], csv.len());
assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\""));
assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap());
assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n"));
assert_eq!(*s.project, *before);
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
assert_eq!(s.history.undo.len(), undo);
s.undo();
s.redo();
assert_eq!(*s.project, *before);
}
#[test]
fn drop_frame_and_sequence_start_timecode_are_used_for_both_bounds() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
let rate = FrameRate::FPS_29_97;
s.edit_sequence("Timing", |q, _, _| {
q.settings.frame_rate = rate;
q.settings.drop_frame = true;
q.start_timecode = 107892;
q.markers = vec![marker(100, rate.tick_of(1800), rate.tick_of(30), "DF")];
Ok(())
})
.unwrap();
s.execute(CMD, json!({"path":"df.csv"})).unwrap();
assert!(written(&files).contains("\"01;01;00;02\",\"01;01;01;02\",\"30\""));
assert!(written(&files).contains("\"30000/1001\""));
}
#[test]
fn spreadsheet_formulas_are_escaped_in_all_user_text_columns() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] {
let mut m = marker(100, Tick::ZERO, Tick::ZERO, text);
m.comment = text.into();
set_markers(&mut s, vec![m]);
let id = s.state.active_sequence.unwrap();
s.edit("Name", |p, _| {
p.items.get_mut(&id).unwrap().name = text.into();
Ok(())
})
.unwrap();
s.execute(CMD, json!({"path":"safe.csv"})).unwrap();
assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3);
s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into();
s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap();
assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim())));
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Iris".into();
}
}
#[test]
fn empty_sequence_writes_a_header_and_no_sequence_is_disabled() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
let spec = crate::find_command(CMD).unwrap();
assert_eq!(spec.menu, ["Markers"]);
assert!(spec.params.contains("path"));
assert!(spec.shortcut.is_none());
assert_eq!(s.execute(CMD, json!({"path":"empty.csv"})).unwrap()["markers"], 0);
assert_eq!(written(&files).lines().count(), 1);
assert!((spec.enabled)(&Session::default()).is_err());
assert!(matches!(Session::default().execute(CMD, json!({"path":"none.csv"})), Err(EngineError::Disabled(..))));
}
#[test]
fn hostile_parameters_and_damaged_timing_fail_before_writing() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
for p in [Value::Null, json!({}), json!({"path":0}), json!({"path":" "}), json!({"path":"bad\0.csv"})] {
assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(CMD, p))).unwrap().is_err());
}
let id = s.state.active_sequence.unwrap();
for (start, duration) in [(-1, 0), (0, -1), (i64::MAX, 1)] {
Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().markers = vec![marker(100, Tick(start), Tick(duration), "Bad")];
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
}
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
q.markers = vec![marker(100, Tick::ZERO, Tick::ZERO, "Bad")];
q.settings.frame_rate.num = i64::MAX;
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
q.settings.frame_rate = FrameRate::FPS_24;
q.start_timecode = i64::MAX;
q.markers[0].start = FrameRate::FPS_24.tick_of(1);
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
q_reset(&mut s, id);
assert!(files.writes.lock().unwrap().is_empty());
s.execute(CMD, json!({"path":"recovered.csv"})).unwrap();
}
fn q_reset(s: &mut Session, id: filmcraft_project::ItemId) {
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
q.start_timecode = 0;
q.markers.clear();
}
#[test]
fn oversized_reports_and_write_errors_are_actionable() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
set_markers(&mut s, vec![marker(100, Tick::ZERO, Tick::ZERO, &"x".repeat(MAX_BYTES / 2))]);
assert!(s.execute(CMD, json!({"path":"huge.csv"})).unwrap_err().to_string().contains("16 MiB"));
assert!(files.writes.lock().unwrap().is_empty());
s.services = Arc::new(Files { fail: true, ..Default::default() });
set_markers(&mut s, vec![]);
s.saved_revision = s.revision;
assert!(!s.is_dirty());
let before = s.project.clone();
assert!(s.execute(CMD, json!({"path":"denied.csv"})).unwrap_err().to_string().contains("denied.csv"));
assert_eq!(*s.project, *before);
assert!(!s.is_dirty());
}
#[test]
fn locked_tracks_custom_labels_and_unusual_paths_export_without_dirtying() {
let files = Arc::new(Files::default());
let mut s = session(files.clone());
let rate = FrameRate::FPS_23_976;
s.edit_sequence("Locked review", |q, _, _| {
q.settings.frame_rate = rate;
for t in q.video_tracks.iter_mut().chain(&mut q.audio_tracks) {
t.locked = true;
}
q.markers =
vec![marker(101, Tick(rate.tick_of(24).0 + 1), Tick(1), "A, \"B\"\n字幕"), marker(100, Tick(rate.tick_of(24).0 + 1), Tick::ZERO, "Point")];
Ok(())
})
.unwrap();
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Review, \"urgent\"\n紫".into();
let id = s.state.active_sequence.unwrap();
s.edit("Review name", |p, _| {
p.items.get_mut(&id).unwrap().name = "Cut, \"night\"\n日本語".into();
Ok(())
})
.unwrap();
s.saved_revision = s.revision;
let path = "review, \"notes\"\n日本語.csv";
s.execute(CMD, json!({"path":path})).unwrap();
assert_eq!(files.writes.lock().unwrap().last().unwrap().0, path);
let csv = written(&files);
assert!(csv.contains("\"Cut, \"\"night\"\"\n日本語\""));
assert!(csv.contains("\"Review, \"\"urgent\"\"\n紫\""));
assert!(csv.contains("\"A, \"\"B\"\"\n字幕\""));
assert!(csv.find("\"100\"").unwrap() < csv.find("\"101\"").unwrap());
assert!(csv.contains(&format!("\"00:00:01:00\",\"00:00:01:00\",\"0\",\"{}\",\"1\",\"24000/1001\"", rate.tick_of(24).0 + 1)));
assert!(!s.is_dirty());
}
}

View File

@ -366,23 +366,34 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let after = u64_p(p, "after").ok_or_else(|| bad("masks.addVertex", "need `after`"))? as usize;
let at = p.get("at").and_then(pt).ok_or_else(|| bad("masks.addVertex", "need `at`"))?;
edit_mask(s, p, "Add Mask Vertex", move |m, t| {
let n = m.path_at(t).vertices.len();
// clamp against the length before any insertion, so the index stays valid in the grown path
let i = (after + 1).min(n);
if m.path.is_animated() && m.path.keyframes.len() > 1 {
if n == 0 {
return Err(bad("masks.addVertex", "the animated mask path has no vertices to insert after"));
}
// keep every keyframe interpolable: insert the vertex into all of them
for k in &mut m.path.keyframes {
if let ParamValue::Path(path) = &mut k.value {
let i = (after + 1).min(path.vertices.len());
let a = path.vertices[after.min(path.vertices.len() - 1)].p;
let b = path.vertices[i % path.vertices.len()].p;
path.vertices.insert(i, MaskVertex::corner(a.lerp(b, 0.5)));
let len = path.vertices.len();
if len == 0 {
continue;
}
let at_i = i.min(len);
let a = path.vertices[after.min(len - 1)].p;
let b = path.vertices[at_i % len].p;
path.vertices.insert(at_i, MaskVertex::corner(a.lerp(b, 0.5)));
}
}
}
let mut path = m.path_at(t);
let i = (after + 1).min(path.vertices.len());
if m.path.keyframes.len() > 1 {
path.vertices[i] = MaskVertex::corner(at);
if let Some(v) = path.vertices.get_mut(i) {
*v = MaskVertex::corner(at);
}
} else {
path.vertices.insert(i, MaskVertex::corner(at));
path.vertices.insert(i.min(path.vertices.len()), MaskVertex::corner(at));
}
set_path_at(m, t, path);
Ok(())

View File

@ -106,6 +106,39 @@ fn keyframed_mask_path_interpolates() {
assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 4);
}
#[test]
fn add_vertex_on_animated_path_clamps_oversized_index_and_rejects_empty_path() {
let (mut s, clip) = demo();
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
let rate = s.active_sequence().unwrap().settings.frame_rate;
s.set_playhead(it.start);
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap();
s.set_playhead(it.start + rate.tick_of(10));
s.execute("masks.translate", json!({"delta": [5, 0]})).unwrap();
// an index past the last vertex appends instead of panicking
s.execute("masks.addVertex", json!({"after": 100, "at": [1, 1]})).unwrap();
let l = s.execute("masks.list", json!({})).unwrap();
assert_eq!(l["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 5);
// an empty animated path is a parameter error and leaves the mask alone
s.set_playhead(it.start);
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 1, "param": "path"})).unwrap();
s.set_playhead(it.start + rate.tick_of(10));
s.execute("masks.translate", json!({"mask": 1, "delta": [5, 0]})).unwrap();
let e = s.execute("masks.addVertex", json!({"mask": 1, "after": 0, "at": [1, 1]})).unwrap_err().to_string();
assert!(e.contains("no vertices"), "{e}");
let l = s.execute("masks.list", json!({})).unwrap();
assert_eq!(l["masks"][1]["path"]["vertices"].as_array().unwrap().len(), 0);
// a static empty path still takes its first vertex
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
s.execute("masks.addVertex", json!({"mask": 2, "after": 0, "at": [1, 1]})).unwrap();
let l = s.execute("masks.list", json!({})).unwrap();
assert_eq!(l["masks"][2]["path"]["vertices"].as_array().unwrap().len(), 1);
}
#[test]
fn merged_drag_is_one_undo_step_and_project_round_trips() {
let (mut s, _) = demo();

View File

@ -1048,7 +1048,7 @@ fn clip_set(s: &mut Session, p: &Value) -> Result<Value> {
// ------------------------------------------------------------------------------------- audio gain
/// Peak (dBFS, before clip gain) of each selected audio clip's source range.
fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
pub(crate) fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
let Some(seq) = s.active_sequence() else { return Vec::new() };
let sr = seq.settings.sample_rate.max(1);
let provider = s.media.provider(s.project.clone(), s.services.clone());
@ -1065,7 +1065,7 @@ fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraf
let mut pos = s0;
while pos < s0 + len {
let n = (s0 + len - pos).min(sr as i64) as usize;
if let Ok(b) = src.audio(pos, n, sr) {
if let Ok(b) = src.audio_stream(it.audio_stream, pos, n, sr) {
peak = b.peaks().into_iter().fold(peak, f32::max);
}
pos += n as i64;

View File

@ -44,6 +44,15 @@ pub fn used_ranges(p: &Project, seqs: &[ItemId]) -> (BTreeMap<ItemId, (Tick, Tic
Some(ItemKind::Subclip { parent, .. }) => {
used.insert(ti.item);
target = *parent;
// Loaded projects can nest subclips; keep every link up to the media (bounded like `resolve_media`).
let mut up = *parent;
for _ in 0..16 {
used.insert(up);
match p.item(up).map(|i| &i.kind) {
Some(ItemKind::Subclip { parent, .. }) => up = *parent,
_ => break,
}
}
}
Some(_) => {}
None => continue,

View File

@ -101,3 +101,24 @@ fn consolidate_trims_to_used_ranges_with_handles() {
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn nested_subclip_keeps_its_whole_parent_chain_used() {
use filmcraft_project::{ItemKind, Label};
use filmcraft_time::{Tick, TimeRange};
let root = tmp_dir("pm-nested-subclip");
let (s, items, _) = project(&root);
let seq = s.state.active_sequence.unwrap();
let mut p = (*s.project).clone();
let range = TimeRange::new(Tick::ZERO, Tick(1000));
let inner = p.add_item("Inner", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
let outer = p.add_item("Outer", Label::Iris, ItemKind::Subclip { parent: inner, range, restrict_trims: false }, None);
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
for x in t.items.iter_mut().filter(|i| i.item == items[0]) {
x.item = outer;
}
}
let (_, used) = super::project_manager::used_ranges(&p, &[seq]);
assert!(used.contains(&outer) && used.contains(&inner) && used.contains(&items[0]), "{used:?}");
let _ = std::fs::remove_dir_all(&root);
}

View File

@ -24,7 +24,7 @@
use std::collections::BTreeSet;
use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
use filmcraft_time::{Tick, TimeDisplay, TimeRange};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@ -699,14 +699,30 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
/// The selected items plus everything they need: sequence contents (recursively), subclip
/// parents, multi-camera / merged-clip sources and graphic canvases.
///
/// Bins and items share one id space, so a named bin contributes every item it holds (and
/// everything those items need), as `project.delete` does. The project's own top bin does not,
/// since no command acts on it (#244), and only ids that name a real item end up in the result.
pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet<ItemId> {
let mut keep: BTreeSet<ItemId> = BTreeSet::new();
let mut todo: Vec<ItemId> = roots.to_vec();
let mut seen_bins: BTreeSet<u64> = BTreeSet::new();
while let Some(id) = todo.pop() {
if !keep.insert(id) {
let bin = BinId(id.0);
if bin != p.root.id && p.root.find_bin(bin).is_some() {
if seen_bins.insert(id.0)
&& let Some(b) = p.root.find_bin(bin)
{
let mut items = Vec::new();
b.all_items(&mut items);
todo.extend(items);
}
continue;
}
let Some(it) = p.item(id) else { continue };
if !keep.insert(id) {
continue;
}
match &it.kind {
ItemKind::Subclip { parent, .. } => todo.push(*parent),
ItemKind::Sequence(q) => {
@ -754,6 +770,9 @@ fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("file.exportSelectionProject", "select items in the Project panel"));
}
let keep = closure(&s.project, &roots);
if keep.is_empty() {
return Err(bad("file.exportSelectionProject", "the selection has no items to export"));
}
let mut sub = project_subset(&s.project, &keep);
let name = std::path::Path::new(&path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| sub.name.clone());
sub.name = name.clone();

View File

@ -220,6 +220,59 @@ fn export_selection_project_and_ale() {
assert!(doc.rows.iter().any(|r| r[0] == "Ambient_Score.wav" && r[1] == "A1A2"));
}
#[test]
fn export_selection_project_of_a_bin_exports_its_contents() {
let mut s = demo();
let (footage_bin, bin_items) = s
.project
.root
.children
.iter()
.find_map(|c| match c {
filmcraft_project::BinEntry::Bin(b) if b.name == "Footage" => {
let mut items = Vec::new();
b.all_items(&mut items);
Some((b.id.0, items))
}
_ => None,
})
.expect("Footage bin");
assert_eq!(bin_items.len(), 6);
// Selecting the bin (its id, not its items) must export the bin's contents, not an empty project.
s.execute("project.select", json!({"items": [footage_bin]})).unwrap();
let dir = tmp_dir("export-bin");
let path = dir.join("Footage.fcproj").to_string_lossy().to_string();
let r = s.execute("file.exportSelectionProject", json!({"path": path})).unwrap();
let p = filmcraft_format::decode(&std::fs::read(&path).unwrap()).unwrap().project;
assert_eq!(r["items"].as_array().unwrap().len(), p.items.len(), "reported items match the saved project");
let reported: std::collections::BTreeSet<ItemId> = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
assert_eq!(reported, bin_items.iter().copied().collect(), "the bin's items are the exported items");
for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] {
assert!(p.items.values().any(|i| i.name == n), "{n}");
}
assert!(!p.items.values().any(|i| i.name == "Ambient_Score.wav"), "the Audio bin stayed behind");
let mut listed = Vec::new();
p.root.all_items(&mut listed);
assert!(p.items.keys().all(|k| listed.contains(k) || matches!(p.item(*k).unwrap().kind, ItemKind::Graphic { .. })));
// A mixed bin + item request must not report the bin id, which names no item in the output.
let explicit = dir.join("Mixed.fcproj").to_string_lossy().to_string();
let r = s.execute("file.exportSelectionProject", json!({"items": [footage_bin, bin_items[0].0], "path": explicit})).unwrap();
let p = filmcraft_format::decode(&std::fs::read(&explicit).unwrap()).unwrap().project;
for v in r["items"].as_array().unwrap() {
let id = ItemId(v.as_u64().unwrap());
assert!(p.item(id).is_some(), "reported item {id:?} is in the output");
}
// An empty bin has nothing to export: an actionable error, no misleading empty file.
let empty_bin = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
s.execute("project.select", json!({"items": [empty_bin]})).unwrap();
let empty_path = dir.join("Empty.fcproj").to_string_lossy().to_string();
assert!(s.execute("file.exportSelectionProject", json!({"path": empty_path})).is_err());
assert!(!std::path::Path::new(&empty_path).exists(), "no empty project written");
}
#[test]
fn media_properties_settings_and_scratch_disks() {
let dir = tmp_dir("props");

View File

@ -407,12 +407,18 @@ pub fn relink(s: &mut Session, p: &Value) -> Result<Value> {
Some(MediaRef::File { path }) => path,
_ => return Err(bad("media.relink", format!("item {} is not file media", item.0))),
};
let c = check_candidate(s, item, &path, &o)?;
let mut c = check_candidate(s, item, &path, &o)?;
if !c.ok && !force {
return Err(EngineError::Other(format!("{} doesn't match {}: {}", file_name(&path), file_name(&old), c.problems.join("; "))));
}
if c.source.is_none() && s.media.open_file(&path, &*s.services).is_err() {
return Err(EngineError::Other(format!("{path} can't be opened")));
if c.source.is_none() {
// Forced past a failed check: the file was never opened for its properties, so do it now
// and let the item take the new file's own info instead of keeping the old file's.
let Ok(src) = s.media.open_file(&path, &*s.services) else {
return Err(EngineError::Other(format!("{path} can't be opened")));
};
c.info = Some(src.info().clone());
c.source = Some(src);
}
let mut plans = vec![Plan { item, cand: c }];
if o.relink_others {

View File

@ -128,6 +128,22 @@ fn fingerprint_mismatch_is_refused_unless_forced() {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn forced_relink_takes_the_new_files_properties() {
let root = tmp_dir("relink-forced-info");
let (path, items) = saved_project(&root);
std::fs::remove_dir_all(root.join("Media")).unwrap();
// same name, different pictures and a different size
let other = root.join("Other");
std::fs::create_dir_all(&other).unwrap();
make_movie(&other.join("a.mov"), DemoScene::Dunes, W * 2, H * 2, 12);
let mut s = open(&path);
s.execute("media.relink", json!({"item": items[0].0, "path": other.join("a.mov").to_string_lossy(), "force": true, "relinkOthers": false})).unwrap();
let width = s.project.item(items[0]).and_then(|i| i.as_media()).and_then(|m| m.info.video.as_ref().map(|v| v.width));
assert_eq!(width, Some(W * 2), "the item describes the replacement file");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn make_offline_shows_the_slate_until_relinked() {
let root = tmp_dir("relink-offline");

View File

@ -1,9 +1,10 @@
//! The Timeline's sequence tabs: closing the others, reordering, and what a project file keeps of
//! them (the open tabs in order, the active one, and each sequence's zoom, scroll and track
//! heights). Premiere's behaviour was observed in Premiere Pro 26.5.2.
//! them (the open tabs in order, the active one, each sequence's zoom, scroll and track heights,
//! and each sequence's playhead). Premiere's tab behaviour was observed in Premiere Pro 26.5.2.
use super::*;
use filmcraft_project::SequenceView;
use filmcraft_time::TICKS_PER_SECOND;
use serde_json::json;
/// The demo project with two more sequences open: tabs [main, b, c], `c` active.
@ -106,6 +107,52 @@ fn a_saved_project_reopens_with_its_tabs_and_their_views() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Each sequence reopens with its playhead where it was left: the project file keeps one playhead
/// per sequence beside the tabs and views.
#[test]
fn each_sequence_reopens_with_its_playhead_where_it_was_left() {
let (mut s, [main, b, c]) = three_tabs();
let at = |s: &Session, seq: ItemId, secs: i64| s.project.sequence(seq).unwrap().settings.frame_rate.snap(Tick(secs * TICKS_PER_SECOND));
s.execute("sequence.open", json!({"item": main.0})).unwrap();
s.execute("playhead.set", json!({"seconds": 7})).unwrap();
s.execute("sequence.open", json!({"item": b.0})).unwrap();
s.execute("playhead.set", json!({"seconds": 2})).unwrap();
let (main_at, b_at) = (at(&s, main, 7), at(&s, b, 2));
assert_eq!(s.playhead(), b_at);
assert_eq!(s.project_view().playheads.get(&c), None, "a playhead that never moved is not written");
let dir = temp_dir("playheads");
let path = dir.join("tabs.fcproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut t = Session::default();
t.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(t.state.active_sequence, Some(b));
assert_eq!(t.playhead(), b_at, "the sequence that was shown is back where it was");
t.execute("sequence.open", json!({"item": main.0})).unwrap();
assert_eq!(t.playhead(), main_at, "and so is every other sequence");
t.execute("sequence.open", json!({"item": c.0})).unwrap();
assert_eq!(t.playhead(), Tick::ZERO);
assert!(!t.is_dirty(), "a restored playhead is not an edit");
// with the preference off, nothing of the view comes back, playheads included
let mut u = Session::default();
u.prefs.timeline.restore_open_sequences = false;
u.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(u.playhead(), Tick::ZERO);
// a file written before playheads were kept opens as it did then: every playhead at zero
let mut doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(doc["view"]["playheads"].is_object());
doc["view"].as_object_mut().unwrap().remove("playheads");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
let mut w = Session::default();
w.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(w.state.active_sequence, Some(b));
assert_eq!(w.playhead(), Tick::ZERO);
assert!(w.state.playheads.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// The view in a project file is not trusted: ids of things that are not sequences, repeats and
/// numbers out of range are dropped or brought into range, and a view that cannot be read at all
/// does not stop the project from opening.
@ -134,6 +181,13 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
"999999": {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
footage.0.to_string(): {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
},
"playheads": {
c.0.to_string(): -5,
b.0.to_string(): i64::MAX,
main.0.to_string(): TICKS_PER_SECOND + 1,
"999999": TICKS_PER_SECOND,
footage.0.to_string(): TICKS_PER_SECOND,
},
}));
assert_eq!(t.state.open_sequences, [c, b]);
assert_eq!(t.state.active_sequence, Some(c), "an active tab that is not open: the first tab");
@ -141,6 +195,16 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
let v = t.state.timeline_views[&b];
assert!(v.pps <= 1e5 && v.scroll == 0.0 && v.v_scroll <= 1e6 && v.a_scroll == 0.0);
assert!(v.video_track_h >= 8.0 && v.audio_track_h <= 600.0);
// playheads: only sequences keep one, never before zero or past the bound, always on a frame
let rate = |seq: ItemId| t.project.sequence(seq).unwrap().settings.frame_rate;
assert_eq!(t.state.playheads.keys().copied().collect::<Vec<_>>(), {
let mut ids = vec![main, b, c];
ids.sort();
ids
});
assert_eq!(t.state.playheads[&c], Tick::ZERO);
assert_eq!(t.state.playheads[&b], rate(b).snap(filmcraft_project::ProjectView::MAX_PLAYHEAD));
assert_eq!(t.state.playheads[&main], rate(main).snap(Tick(TICKS_PER_SECOND + 1)));
// not a view at all: the project opens as it does without one
for junk in [json!("tabs"), json!([1, 2, 3]), json!({"open_sequences": "all"}), json!({"sequences": {"x": 1}}), json!(null)] {
@ -159,6 +223,7 @@ fn the_view_of_a_deleted_sequence_is_forgotten() {
s.execute("project.delete", json!({"items": [b.0]})).unwrap();
assert_eq!(s.state.timeline_views.keys().copied().collect::<Vec<_>>(), [main, c]);
assert_eq!(s.state.open_sequences, [main, c]);
// the saved view never names it either
// the saved view never names it either, nor keeps its playhead
assert_eq!(s.project_view().open_sequences, [main, c]);
assert!(!s.project_view().playheads.contains_key(&b));
}

View File

@ -8,7 +8,7 @@
//! | `sequence.goToNextGapInTrack` / `sequence.goToPrevGapInTrack` | Sequence ▸ Go to Gap ▸ Next / Previous in Track | |
//! | `sequence.selectionFollowsPlayhead` | Sequence ▸ Selection Follows Playhead | |
//! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | |
//! | `sequence.joinThroughEdits` | (clip context menu) Join Through Edits | |
//! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | |
//! | `sequence.throughEdits` | query: the through edits of the active sequence | |
//! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U |
//! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | |
@ -103,7 +103,15 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits);
Ok(json!({"showThroughEdits": s.state.show_through_edits}))
}),
spec("sequence.joinThroughEdits", "Join Through Edits", &[], None, r#"{"clips":[id]?,"all":bool?}"#, has_seq, join_through_edits),
spec(
"sequence.joinThroughEdits",
"Join Through Edits",
&[],
None,
r#"{"clips":[id]?,"cut":[id,id]?,"all":bool?}"#,
has_seq,
join_through_edits,
),
query("sequence.throughEdits", "List Through Edits", "{}", |s, _| {
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
Ok(Value::Array(
@ -326,6 +334,9 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result<V
// ---------- through edits ----------
fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(cut) = p.get("cut") {
return join_through_edit_at(s, cut);
}
let all = bool_p(p, "all").unwrap_or(false) || (p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty());
let only = if all { Vec::new() } else { with_links(s, &clips_p(s, p)) };
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
@ -343,6 +354,26 @@ fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"joined": n}))
}
/// `sequence.joinThroughEdits {cut: [left, right]}`: join just that one cut (the edit point menu,
/// #219), with the cuts between the same pieces' linked partners when linked selection is on.
fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result<Value> {
let ids: Vec<ClipId> = cut.as_array().map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
let [left, right] = ids[..] else {
return Err(EngineError::Other("sequence.joinThroughEdits: `cut` needs two clip ids, [left, right]".into()));
};
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
if !edit::through::through_edits(q).iter().any(|e| e.left == left && e.right == right) {
return Err(EngineError::Other("that cut is not a through edit".into()));
}
let (lefts, rights) = (with_links(s, &[left]), with_links(s, &[right]));
let n = s.edit_sequence("Join Through Edits", |q, _, st| {
let n = edit::through::join_through_edits_where(q, |a, b| lefts.contains(&a) && rights.contains(&b));
st.selection.retain(|c| q.find_item(*c).is_some());
Ok(n)
})?;
Ok(json!({"joined": n}))
}
// ---------- subsequence ----------
/// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the

View File

@ -142,6 +142,41 @@ fn through_edits_show_and_join() {
assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2);
}
/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its
/// linked partner, even when one of its pieces is also part of another through edit.
#[test]
fn join_one_through_edit_by_its_cut() {
let mut s = demo();
let v1 = s.active_sequence().unwrap().video_tracks[0].id.0;
for frame in [30, 40] {
s.execute("playhead.set", json!({"frame": frame})).unwrap();
s.execute("sequence.addEditAllTracks", json!({})).unwrap();
}
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
let on_v1: Vec<&Value> = te.as_array().unwrap().iter().filter(|e| e["track"] == json!(v1)).collect();
assert_eq!(on_v1.len(), 2);
// the second cut: its left piece is also the right piece of the first one
let (second, first) = (on_v1[1], on_v1[0]);
assert_eq!(second["left"], first["right"]);
let n = te.as_array().unwrap().len();
let r = s.execute("sequence.joinThroughEdits", json!({"cut": [second["left"], second["right"]]})).unwrap();
assert_eq!(r["joined"], json!(2), "V1 + linked A1");
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
assert_eq!(te.as_array().unwrap().len(), n - 2);
assert!(te.as_array().unwrap().iter().any(|e| e["left"] == first["left"] && e["right"] == first["right"]), "the first cut stays");
}
#[test]
fn join_by_cut_rejects_hostile_parameters() {
let mut s = demo();
let items = s.active_sequence().unwrap().video_tracks[0].items.clone();
let before = s.active_sequence().unwrap().clone();
for cut in [json!([]), json!([1]), json!([1, 2, 3]), json!("x"), json!([-1, 2]), json!([u64::MAX, 0]), json!([items[0].id.0, items[1].id.0])] {
assert!(s.execute("sequence.joinThroughEdits", json!({"cut": cut})).is_err(), "{cut}");
}
assert_eq!(s.active_sequence().unwrap(), &before, "nothing changed");
}
#[test]
fn make_subsequence_from_in_out_and_from_selection() {
let mut s = demo();

View File

@ -35,6 +35,50 @@ fn demo_project_is_valid_and_renders() {
assert!(img.px.chunks(4).any(|p| p[3] > 0.9));
}
/// Ordinary and configured multi-frame offsets remain valid.
#[test]
fn frame_steps_in_new_custom_rate_sequences_move_in_the_requested_direction() {
for fps in [31.0, 23.98, 24.999] {
let mut s = Session::default();
s.execute("file.newSequence", json!({"fps": fps})).unwrap();
let rate = s.sequence_rate();
s.execute("playhead.set", json!({"frame": 10})).unwrap();
assert_eq!(rate.frame_at(s.playhead()), 10);
for frame in 11..=42 {
s.execute("playhead.stepForward", json!({})).unwrap();
assert_eq!(rate.frame_at(s.playhead()), frame);
}
for frame in (10..42).rev() {
s.execute("playhead.stepBack", json!({})).unwrap();
assert_eq!(rate.frame_at(s.playhead()), frame);
}
s.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap();
s.execute("playhead.stepForward5", json!({})).unwrap();
assert_eq!(rate.frame_at(s.playhead()), 22);
s.execute("playhead.stepBack5", json!({})).unwrap();
assert_eq!(rate.frame_at(s.playhead()), 10);
s.execute("playhead.step", json!({"frames": -20})).unwrap();
s.execute("playhead.stepBack", json!({})).unwrap();
assert_eq!(s.playhead(), Tick::ZERO);
}
}
#[test]
fn overflowing_frame_steps_are_errors_and_leave_the_playhead_unchanged() {
let mut s = Session::default();
s.execute("file.newSequence", json!({"fps": 31.0})).unwrap();
for start in [10, 0] {
s.execute("playhead.set", json!({"frame": start})).unwrap();
let before = s.playhead();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("playhead.step", json!({"frames": i64::MAX}))));
assert!(result.is_ok(), "an extreme frame offset must not panic");
assert!(matches!(result.unwrap(), Err(EngineError::BadParams { .. })));
assert_eq!(s.playhead(), before);
}
s.execute("playhead.step", json!({"frames": i64::MIN})).unwrap();
assert_eq!(s.playhead(), Tick::ZERO);
}
#[test]
fn add_edit_undo_redo() {
let mut s = demo();
@ -282,6 +326,51 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() {
assert_eq!(scale(&s), (0, 100.0));
}
/// Dragging the Volume line or one of its keyframes in the timeline (#223) sends a keyframe edit
/// every frame; `merge` keeps the whole drag one undo step and `begin` starts the next one.
#[test]
fn keyframe_drags_are_one_undo_step() {
let mut s = demo();
let c = s.active_sequence().unwrap().audio_tracks[0].items[0].id.0;
let level = |s: &Session| -> Vec<(i64, f64)> {
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
it.effect("volume").unwrap().params["level"].keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()
};
let key = json!({"clip": c, "effect": "volume", "param": "level"});
for sec in [0.5, 1.5] {
s.execute("playhead.set", json!({"seconds": sec})).unwrap();
s.execute("effects.addKeyframe", key.clone()).unwrap();
}
let before = level(&s);
let [(t0, _), (t1, _)] = before[..] else { panic!("two keyframes: {before:?}") };
// the line between them: both keyframes move together, frame after frame
for (i, v) in [-1.0, -2.0, -3.0].into_iter().enumerate() {
for t in [t0, t1] {
let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t, "value": v, "merge": true, "begin": i == 0 && t == t0});
s.execute("effects.setKeyframe", p).unwrap();
}
}
assert_eq!(level(&s), [(t0, -3.0), (t1, -3.0)]);
s.undo();
assert_eq!(level(&s), before);
// one keyframe: time and value in one command
let mut at = t1;
for (i, d) in [1000, 2000, 3000].into_iter().enumerate() {
let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": at, "to": t1 + d, "value": -6.0, "merge": true, "begin": i == 0});
s.execute("effects.moveKeyframe", p).unwrap();
at = t1 + d;
}
assert_eq!(level(&s), [before[0], (t1 + 3000, -6.0)]);
s.undo();
assert_eq!(level(&s), before);
let bad = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t0, "to": t0 + 1, "value": "loud"});
assert!(s.execute("effects.moveKeyframe", bad).is_err());
assert_eq!(level(&s), before);
}
#[test]
fn transitions_and_markers() {
let mut s = demo();
@ -641,3 +730,37 @@ fn dragging_an_effect_parameter_is_one_undo_step() {
s.undo();
assert_eq!(opacity(&s), start, "and then the whole first one");
}
/// #484: Enable flips each selected clip on its own, as in Premiere. With one enabled and one
/// disabled clip selected it swaps them, instead of first making both the same.
#[test]
fn enable_flips_each_selected_clip() {
let mut s = demo();
let enabled = |s: &Session, c: u64| s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.enabled;
let partner = |s: &Session, c: u64| {
let q = s.active_sequence().unwrap();
let link = q.find_item(ClipId(c)).unwrap().1.link?;
q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != ClipId(c)).map(|i| i.id.0)
};
let (a, b) = (v1(&s)[0].0, v1(&s)[1].0);
s.execute("clip.enable", json!({"clips": [a]})).unwrap();
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
// mixed selection: each flips
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, false));
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
// a clip named twice still flips once, and one press is one undo step
s.execute("clip.enable", json!({"clips": [a, a]})).unwrap();
assert!(enabled(&s, a));
s.undo();
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
// with linked selection on, a clip's linked audio flips with it
if let Some(au) = partner(&s, a) {
assert_eq!(enabled(&s, au), enabled(&s, a));
}
// a uniform selection still toggles as before
s.execute("clip.enable", json!({"clips": [b]})).unwrap();
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true));
}

View File

@ -169,7 +169,7 @@ pub fn toggle_type(s: &mut Session) -> Result<Value> {
/// Trim every selected edit point by `delta` (edit point movement; positive = later).
fn trim_all(s: &mut Session, delta: Tick) -> Result<Value> {
let applied = trim_all_raw(s, delta)?;
let applied = trim_all_raw(s, &|_, _| Some(delta))?;
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
add_shift(s, first);
Ok(json!({"applied": applied}))
@ -193,7 +193,8 @@ fn reset_shift(s: &mut Session) {
s.state.trim_shift = TrimShift::default();
}
fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
/// Trim every selected edit point; `delta_for` gives each point's own movement, read from the sequence as it is when that point is reached.
fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Option<Tick>) -> Result<Vec<Value>> {
if s.state.edit_points.is_empty() {
return Err(EngineError::Other("no edit points selected".into()));
}
@ -201,6 +202,7 @@ fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
let mut applied = Vec::new();
for ep in &pts {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let Some(delta) = delta_for(seq, ep) else { continue };
let r = match ep.kind {
TrimKind::Roll => {
let Some((l, r)) = roll_pair(seq, ep) else { continue };
@ -226,9 +228,10 @@ pub fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
/// `trim.extendToPlayhead`: move each selected edit point to the playhead.
pub fn extend_to_playhead(s: &mut Session) -> Result<Value> {
let ph = s.playhead();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let first = s.state.edit_points.first().and_then(|e| edit_time(seq, e)).ok_or_else(|| EngineError::Other("no edit points selected".into()))?;
trim_all(s, ph - first)
let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?;
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
add_shift(s, first);
Ok(json!({"applied": applied}))
}
/// `trim.toPlayhead {side: "previous"|"next", ripple}` (Q/W, ⌥Q/⌥W): remove the material between
@ -502,14 +505,14 @@ fn apply_dynamic(s: &mut Session, offset: Tick) -> Result<Tick> {
let redo = std::mem::take(&mut s.history.redo);
s.history.limit = usize::MAX;
s.project = d.before.clone();
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) };
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, &|_, _| Some(offset)) };
let applied = match r {
Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(),
Err(_) => {
// refused outright (e.g. it would break sync): stay where we were
s.project = d.before.clone();
if d.offset != Tick::ZERO {
let _ = trim_all_raw(s, d.offset);
let _ = trim_all_raw(s, &|_, _| Some(d.offset));
}
d.offset
}

View File

@ -210,3 +210,29 @@ fn trim_monitor_sides_and_shift_counters() {
let seq = s.execute("sequence.inspect", json!({})).unwrap();
assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty());
}
#[test]
fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() {
let mut s = demo();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
let rate = s.sequence_rate();
let q = s.active_sequence().unwrap();
// a clip on V1 and the overlay on V2 that overlap in time but end at different frames
let mut found = None;
for a in &q.video_tracks[0].items {
for b in &q.video_tracks[1].items {
let (lo, hi) = (a.start.max(b.start), a.end().min(b.end()));
if found.is_none() && a.end() != b.end() && hi - lo > rate.tick_of(2) {
found = Some((a.id, b.id, lo + rate.tick_of(1)));
}
}
}
let (a, b, ph) = found.expect("demo has overlapping clips on V1 and V2 with different ends");
s.execute("playhead.set", json!({"time": ph.0})).unwrap();
s.execute("trim.selectEditPoint", json!({"clip": a.0, "edge": "out", "kind": "trim"})).unwrap();
s.execute("trim.selectEditPoint", json!({"clip": b.0, "edge": "out", "kind": "trim", "add": true})).unwrap();
s.execute("trim.extendToPlayhead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(q.find_item(a).unwrap().1.end(), ph);
assert_eq!(q.find_item(b).unwrap().1.end(), ph);
}

View File

@ -12,6 +12,38 @@ fn demo() -> Session {
s
}
#[test]
fn transition_preview_is_read_only_and_matches_apply_and_undo() {
use crate::commands::preview_transition;
use filmcraft_project::TrackKind;
for (kind, effect) in [(TrackKind::Video, "cross_dissolve"), (TrackKind::Audio, "constant_power")] {
let mut s = demo();
let clips: Vec<_> = s.active_sequence().unwrap().tracks(kind)[0].items.iter().map(|c| c.id).collect();
for clip in clips {
for edge in ["in", "out"] {
let params = json!({"effect": effect, "clip": clip.0, "edge": edge, "frames": 7});
let before = s.project.to_json();
let revision = s.revision;
let (track, planned) = preview_transition(&s, &params, kind).unwrap();
assert_eq!(s.project.to_json(), before);
assert_eq!(s.revision, revision);
let result = s.execute("effects.apply", params).unwrap();
let applied =
s.active_sequence().unwrap().track(track).unwrap().transitions.iter().find(|t| Some(t.id.0) == result["transition"].as_u64()).unwrap();
assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
}
}
assert!(preview_transition(&s, &json!({"effect": effect, "clip": u64::MAX}), kind).is_err());
let track = s.active_sequence().unwrap().tracks(kind)[0].id;
std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true;
assert!(preview_transition(&s, &json!({"effect": effect, "clip": s.active_sequence().unwrap().track(track).unwrap().items[0].id.0}), kind).is_err());
}
}
fn first_v1_clip(s: &Session) -> u64 {
s.active_sequence().unwrap().video_tracks[0].items[0].id.0
}

View File

@ -418,6 +418,7 @@ mod tests {
open_sequences: vec![seq],
active_sequence: Some(seq),
sequences: [(seq, SequenceView { pps: 80.0, scroll: 1.5, v_scroll: 0.0, a_scroll: 4.0, video_track_h: 60.0, audio_track_h: 56.0 })].into(),
playheads: [(seq, filmcraft_time::Tick(3 * filmcraft_time::TICKS_PER_SECOND))].into(),
};
for pretty in [true, false] {
let l = decode(&encode_with_view(&p, Some(&view), pretty)).unwrap();

View File

@ -82,6 +82,10 @@ pub(crate) mod pid {
// Root
pub const ROOT_META_DICTIONARY: u16 = 0x0001;
pub const ROOT_HEADER: u16 = 0x0002;
// MetaDictionary and MetaDefinition
pub const META_CLASS_DEFINITIONS: u16 = 0x0003;
pub const META_TYPE_DEFINITIONS: u16 = 0x0004;
pub const META_IDENTIFICATION: u16 = 0x0005;
// Header
pub const BYTE_ORDER: u16 = 0x3B01;
pub const LAST_MODIFIED: u16 = 0x3B02;

View File

@ -172,6 +172,8 @@ fn prop_name(pid: u16) -> &'static str {
match pid {
0x0001 => "MetaDictionary",
0x0002 => "Header",
0x0003 => "ClassDefinitions",
0x0004 => "TypeDefinitions",
0x3B03 => "Content",
0x3B04 => "Dictionary",
0x3B06 => "IdentificationList",
@ -200,6 +202,16 @@ fn prop_name(pid: u16) -> &'static str {
}
}
/// The key size of a strong reference set keyed by `key_pid`, for a set with no entry to take it
/// from: AUIDs (definitions) are 16 bytes, MobIDs 32.
fn set_key_size(key_pid: u16) -> usize {
match key_pid {
0x0005 | 0x1B01 => 16,
0x4401 | 0x2701 => 32,
_ => 0,
}
}
fn storage_name(pid: u16) -> String {
let suffix = format!("-{pid:x}");
let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect();
@ -269,7 +281,7 @@ impl W {
index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key
index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key
let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None };
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0);
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or_else(|| set_key_size(k), <[u8]>::len)).unwrap_or(0);
if let Some(k) = set_key {
index.extend_from_slice(&k.to_le_bytes());
index.push(key_size as u8);

View File

@ -94,6 +94,32 @@ fn keys(dict: &Obj, p: u16) -> HashSet<Vec<u8>> {
dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect()
}
/// #323: readers that load the meta-dictionary (pyaaf2) need its ClassDefinitions and
/// TypeDefinitions sets. They are empty: everything written uses the baseline classes and types
/// those readers already know.
#[test]
fn the_meta_dictionary_has_its_class_and_type_definition_sets() {
let (p, s) = project();
for small in [false, true] {
let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap();
assert_eq!(cf.entries[cf.find("MetaDictionary-1").unwrap()].clsid, ids::META_DICTIONARY);
for name in ["ClassDefinitions-3", "TypeDefinitions-4"] {
// u32 count, u32 first free key, u32 last free key, u16 key pid, u8 key size
let index = cf.read_path(&format!("MetaDictionary-1/{name} index")).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(index.len(), 15, "{name}: an empty set");
assert_eq!(index[0..4], 0u32.to_le_bytes(), "{name}: no entries");
assert_eq!(u16::from_le_bytes([index[12], index[13]]), pid::META_IDENTIFICATION, "{name}: keyed by the definition's AUID");
assert_eq!(index[14], 16, "{name}: 16-byte keys");
}
let root = store::read(&bytes).unwrap();
let meta = root.strong(pid::ROOT_META_DICTIONARY).unwrap();
for set in [pid::META_CLASS_DEFINITIONS, pid::META_TYPE_DEFINITIONS] {
assert!(matches!(meta.get(set), Some(Value::StrongSet(items, k)) if items.is_empty() && *k == pid::META_IDENTIFICATION), "{set:#06x}");
}
}
}
#[test]
fn written_files_have_the_required_structure() {
let (p, s) = project();

View File

@ -349,9 +349,13 @@ pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mu
.with(pid::CONTENT, Value::Strong(Box::new(content)))
.with(pid::DICTIONARY, Value::Strong(Box::new(dict)))
.with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident]));
let root = Obj::new(ROOT)
.with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY))))
.with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
// The meta-dictionary holds only extensions to the baseline object model, and everything written
// here is baseline, so its class and type definition sets are empty; readers that load them
// (pyaaf2) still need the sets to be there (#323).
let meta = Obj::new(META_DICTIONARY)
.with(pid::META_CLASS_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION))
.with(pid::META_TYPE_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION));
let root = Obj::new(ROOT).with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(meta))).with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
store::write(&root, version).map_err(Error::Other)
}

View File

@ -81,6 +81,15 @@ pub fn file_url_to_path(url: &str) -> String {
/// Convert an absolute path to a `file://` URL (`localhost` form for FCP7 XML).
pub fn path_to_file_url(path: &str, localhost: bool) -> String {
let p = path.replace('\\', "/");
// Windows verbatim paths (`\\?\C:\x`, `\\?\UNC\server\share`, as `canonicalize` returns them)
// are not network shares: drop the prefix so `?` doesn't become the URL's host.
let p = if let Some(unc) = p.strip_prefix("//?/UNC/") {
format!("//{unc}")
} else if let Some(local) = p.strip_prefix("//?/") {
local.to_string()
} else {
p
};
let host = if localhost { "localhost" } else { "" };
if is_windows_abs(&p) {
format!("file://{host}/{}", percent_encode_path(&p))
@ -675,6 +684,8 @@ mod tests {
assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov");
assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov");
assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf");
assert_eq!(path_to_file_url("\\\\?\\C:\\Media\\a.mp4", true), "file://localhost/C:/Media/a.mp4");
assert_eq!(path_to_file_url("\\\\?\\UNC\\srv\\share\\a.mp4", false), "file://srv/share/a.mp4");
for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] {
assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p);
assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p);

View File

@ -191,7 +191,7 @@ struct Exporter<'a> {
p: &'a Project,
media: &'a MediaOptions,
doc: Document,
memo: HashMap<(String, CKind, Option<u32>), usize>,
memo: HashMap<(String, CKind, usize, Option<u32>), usize>,
report: &'a mut Report,
seq_rate: FrameRate,
nests: Nests,
@ -307,7 +307,7 @@ impl Exporter<'_> {
if ckind == CKind::Sound
&& let Some(e) = self.media.essence_for(clip_key, channel).cloned()
{
let memo_key = (format!("clip:{}", c.id.0), ckind, channel);
let memo_key = (format!("clip:{}", c.id.0), ckind, 0, channel);
if let Some(&i) = self.memo.get(&memo_key) {
return Some(i);
}
@ -346,7 +346,7 @@ impl Exporter<'_> {
self.report.warn(format!("the sound of the nested sequence \"{name}\" was not rendered (left as a gap)"));
return None;
}
let memo_key = (format!("seq:{}", item.0), ckind, channel);
let memo_key = (format!("seq:{}", item.0), ckind, 0, channel);
if let Some(&i) = self.memo.get(&memo_key) {
return Some(i);
}
@ -429,21 +429,29 @@ impl Exporter<'_> {
// the source channel this slot plays when broken out to mono
let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s));
let clip_key = EssenceKey::Clip(c.id);
let media_key = EssenceKey::Media(item);
let media_key = EssenceKey::media(item, c.audio_stream);
let ess = (ckind == CKind::Sound)
.then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel)))
.flatten()
.cloned();
let key = match &ess {
Some(e) if e.key == clip_key => format!("clip:{}", c.id.0),
_ if ckind == CKind::Sound && c.audio_stream > 0 => format!("item:{}:stream:{}", item.0, c.audio_stream),
_ => format!("item:{}", item.0),
};
let memo_key = (key.clone(), ckind, channel);
// Original linked file locators cannot name a container stream. Leave an explicit
// unsupported-feature report rather than reference its first audio stream.
if ckind == CKind::Sound && c.audio_stream > 0 && ess.is_none() {
self.report.warn("selected container audio streams require separate or embedded audio essence (left as gaps)");
return None;
}
let stream = if ckind == CKind::Sound { c.audio_stream } else { 0 };
let memo_key = (key.clone(), ckind, stream, channel);
if let Some(&i) = self.memo.get(&memo_key) {
return Some(i);
}
let video = m.info.video.as_ref();
let audio = m.info.audio();
let audio = m.info.audio_streams.get(c.audio_stream);
let still = m.info.kind == MediaKind::Still;
match ckind {
CKind::Picture if video.is_none() && !still => return None,

View File

@ -19,15 +19,25 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EssenceKey {
Media(ItemId),
MediaStream { item: ItemId, stream: usize },
Clip(ClipId),
}
impl EssenceKey {
pub fn media(item: ItemId, stream: usize) -> Self {
if stream == 0 { Self::Media(item) } else { Self::MediaStream { item, stream } }
}
}
/// One range of source audio an export references.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AudioNeed {
pub key: EssenceKey,
/// The media item (for a clip key: the clip's media).
pub item: ItemId,
/// Selected container stream; zero for legacy and sequence sources.
#[serde(default)]
pub audio_stream: usize,
/// The media file, when the item is file media.
pub path: Option<String>,
/// Media time range to supply (handles included, clamped to the media).
@ -120,7 +130,8 @@ pub struct NeedOptions {
/// Extra media before and after every used range.
pub handles: Tick,
/// One range per clip ([`EssenceKey::Clip`]; for rendering clip effects) instead of one range
/// per media item covering all its uses ([`EssenceKey::Media`]).
/// per media item and container stream covering all its uses. Stream zero keeps
/// the legacy [`EssenceKey::Media`] identity.
pub per_clip: bool,
/// Whole media instead of the used range (embedding / copying without trimming).
pub whole_media: bool,
@ -163,7 +174,7 @@ fn collect_needs(
let (mut start, mut end) = ((lo - opts.handles).max(Tick::ZERO), hi + opts.handles);
match project.item(item).map(|i| &i.kind) {
Some(ItemKind::Media(m)) => {
let Some(a) = m.info.audio() else { continue };
let Some(a) = m.info.audio_streams.get(c.audio_stream) else { continue };
let path = match &m.media {
MediaRef::File { path } => Some(path.clone()),
MediaRef::Generator(_) => None,
@ -176,8 +187,17 @@ fn collect_needs(
start = Tick::ZERO;
end = dur.max(end);
}
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::Media(item) };
let e = by_item.entry(key).or_insert_with(|| AudioNeed { key, item, path, start, end, channels: a.channels, sample_rate: a.sample_rate });
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::media(item, c.audio_stream) };
let e = by_item.entry(key).or_insert_with(|| AudioNeed {
key,
item,
audio_stream: c.audio_stream,
path,
start,
end,
channels: a.channels,
sample_rate: a.sample_rate,
});
e.start = e.start.min(start);
e.end = e.end.max(end);
}
@ -196,7 +216,10 @@ fn collect_needs(
_ => 2,
};
let key = EssenceKey::Clip(c.id);
by_item.insert(key, AudioNeed { key, item, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) });
by_item.insert(
key,
AudioNeed { key, item, audio_stream: 0, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) },
);
}
},
_ => {}

View File

@ -65,14 +65,18 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
`nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time,
so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary
CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed.
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go
in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709
limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver
that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on
the encode thread, where it competed with the render of the next frames for the thread pool and
held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe
distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it.
It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems
without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export
ends it with an error, since a hardware stream cannot be finished in software. The same session,
ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264
8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at
@ -291,6 +295,7 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors |
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |

View File

@ -129,7 +129,10 @@ impl HybridDecoder {
}
/// Switch to the software decoder after `err`, replaying the log (which ends with the sample
/// that failed).
/// that failed). The replay goes on past samples the software decoder rejects, as decoding in
/// software from the restart point would: one that the hardware had already decoded is logged
/// (its call returned long ago), and if the failed sample itself is rejected its error is this
/// call's, while the pictures replayed before it are kept for the next output.
fn fall_back(&mut self, err: CodecError) -> Result<Vec<DecodedFrame>> {
let Some(mut hw) = self.hw.take() else { return Err(err) };
log::warn!("{} failed ({err}); continuing with the software decoder", hw.name());
@ -149,12 +152,28 @@ impl HybridDecoder {
return Err(CodecError::Decode(format!("{err} (continuing in software after the next seek)")));
};
let mut frames = Vec::new();
for (s, p) in &log {
let mut rejected = None;
let last = log.len().saturating_sub(1);
for (i, (s, p)) in log.iter().enumerate() {
let Some(sw) = self.sw.as_mut() else { break };
frames.extend(sw.decode(s, *p)?);
match sw.decode(s, *p) {
Ok(out) => frames.extend(out),
Err(e) if i == last => rejected = Some(e),
Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"),
}
}
self.log = Some(log);
Ok(self.merge(frames))
match rejected {
None => Ok(self.merge(frames)),
Some(e) => {
for f in frames {
if !self.emitted.contains(&f.pts) {
self.carry.insert(f.pts, f);
}
}
Err(e)
}
}
}
}

View File

@ -444,6 +444,7 @@ fn constants_match_the_header() {
assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62");
assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION");
assert_eq!(NV_ENC_BUFFER_FORMAT_YUV420_10BIT as i64, 65536, "NV_ENC_BUFFER_FORMAT_YUV420_10BIT");
assert_eq!(NV_ENC_BUFFER_FORMAT_ABGR as i64, 268435456, "NV_ENC_BUFFER_FORMAT_ABGR");
assert_eq!(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE as i64, 39, "NV_ENC_CAPS_SUPPORT_10BIT_ENCODE");
assert_eq!(NV_ENC_VUI_COLOR_PRIMARIES_BT2020 as i64, 9, "NV_ENC_VUI_COLOR_PRIMARIES_BT2020");
assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084 as i64, 16, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084");

View File

@ -120,6 +120,8 @@ impl VideoEncoder for NvencEncoder {
}
(true, None) => return Err(ExportError::Unsupported("NVENC Main 10 needs the HDR picture (EncoderFrame::hdr)".into())),
(false, Some(_)) => return Err(ExportError::Unsupported("NVENC Main (8-bit) does not take HDR pictures".into())),
// the GPU converts the RGBA picture itself (BT.709 limited range, the same codes as `rgba_to_yuv420_8`)
(false, None) if self.enc.takes_rgba() => self.enc.encode_rgba(f.rgba, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?,
(false, None) => {
filmcraft_export::timed(filmcraft_export::Stage::Convert, || {
filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v)
@ -209,7 +211,21 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
// H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only
return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None };
}
factory_with(format, w, h, rate, s, Nvenc::new)
factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default()))
}
/// Open the encoder for `cfg`. An 8-bit one takes the export's RGBA pictures as they are and converts
/// them on the GPU, which keeps the RGB → 4:2:0 conversion off the CPU (where it competes with the
/// render of the next frames); where the driver refuses RGB input, it takes the CPU's 4:2:0 as before.
/// Main 10 takes its 10-bit planes.
fn open(cfg: &Config, signal: &Signal) -> std::result::Result<Nvenc, String> {
if cfg.profile == Profile::HevcMain10 {
return Nvenc::with_signal(cfg, signal);
}
Nvenc::with_rgba_input(cfg, signal).or_else(|why| {
log::info!("NVENC RGBA input declined ({why}): converting to 4:2:0 on the CPU");
Nvenc::with_signal(cfg, signal)
})
}
/// The H.264 side of [`factory`], with the driver call (`open`) passed in so tests can stand in for it.
@ -254,7 +270,7 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<R
Ok(c) => c,
Err(why) => return declined(&why),
};
match Nvenc::with_signal(&cfg, &nvenc_signal(&s.signal)) {
match open(&cfg, &nvenc_signal(&s.signal)) {
Ok(enc) => {
filmcraft_export::note_hw_encode_session();
Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal))))

View File

@ -94,6 +94,8 @@ pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2;
pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1;
/// P010: 10-bit 4:2:0, 16-bit little-endian samples with the value in the high 10 bits.
pub const NV_ENC_BUFFER_FORMAT_YUV420_10BIT: u32 = 0x10000;
/// ABGR: 8-bit packed, one 32-bit little-endian word per pixel with R in the lowest byte (bytes R, G, B, A).
pub const NV_ENC_BUFFER_FORMAT_ABGR: u32 = 0x10000000;
pub const NV_ENC_PARAMS_RC_VBR: u32 = 1;
pub const NV_ENC_PARAMS_RC_CBR: u32 = 2;
pub const NV_ENC_TUNING_INFO_HIGH_QUALITY: u32 = 1;

View File

@ -11,8 +11,11 @@
//! only encoder of the H.265 format (which has no software encoder).
//!
//! ```text
//! RGBA (the export pipeline) ──► BT.709 limited 4:2:0 (the software encoder's conversion)
//! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC / hvcC
//! RGBA (the export pipeline) ──► ABGR input buffer ──NVENC (BT.709 limited 4:2:0 on the GPU)──►
//! Annex B ──► length-prefixed samples + avcC / hvcC
//!
//! where the driver refuses RGB input (`Nvenc::with_rgba_input` fails), as before:
//! RGBA ──► BT.709 limited 4:2:0 (the software encoder's conversion) ──► NV12 input buffer ──NVENC──► …
//!
//! HDR (PQ / HLG) HEVC Main 10:
//! encoded BT.2020 R'G'B' floats ──► 10-bit limited 4:2:0 (`filmcraft_export::rgbf_to_yuv420_10`)
@ -120,6 +123,8 @@ pub struct Nvenc {
size: (u32, u32),
/// Main 10: pictures go in as 10-bit P010 ([`Nvenc::encode_10`]), otherwise as 8-bit NV12 ([`Nvenc::encode`]).
ten_bit: bool,
/// Pictures go in as packed RGBA ([`Nvenc::encode_rgba`]) and the GPU converts them to 4:2:0.
rgba_input: bool,
}
/// The H.264 encoder (the name it had before HEVC joined it).
@ -231,6 +236,19 @@ impl Nvenc {
/// on every IDR picture), or say why NVENC does not take this configuration. H.264 takes the
/// default signal only.
pub fn with_signal(cfg: &Config, signal: &Signal) -> Result<Self, String> {
Self::open(cfg, signal, false)
}
/// Open an 8-bit encoder that takes straight RGBA8 pictures ([`Nvenc::encode_rgba`]) and converts
/// them to 4:2:0 on the GPU, or say why not (Main 10, or a driver that refuses RGB input).
pub fn with_rgba_input(cfg: &Config, signal: &Signal) -> Result<Self, String> {
if cfg.profile == Profile::HevcMain10 {
return Err("RGBA input is 8-bit only".into());
}
Self::open(cfg, signal, true)
}
fn open(cfg: &Config, signal: &Signal, rgba_input: bool) -> Result<Self, String> {
let (w, h) = (cfg.width, cfg.height);
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
return Err(format!("{w}x{h}: NVENC needs even dimensions"));
@ -278,6 +296,7 @@ impl Nvenc {
Profile::High => 2,
},
ten_bit,
rgba_input,
signal: signal.clone(),
level,
sar: cfg.sar,
@ -312,6 +331,7 @@ impl Nvenc {
emitted: 0,
size: (w, h),
ten_bit,
rgba_input,
})
}
@ -345,6 +365,25 @@ impl Nvenc {
self.ten_bit
}
/// Whether this encoder takes packed RGBA pictures ([`Nvenc::encode_rgba`]) instead of planar ones.
pub fn takes_rgba(&self) -> bool {
self.rgba_input
}
/// Encode picture `index` from straight RGBA8 (`w * 4` bytes per row, alpha ignored); NVENC
/// converts it to 4:2:0 with the stream's matrix. The pictures that came out, as for
/// [`Nvenc::encode`]. An error for an encoder opened without [`Nvenc::with_rgba_input`].
pub fn encode_rgba(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>, String> {
if !self.rgba_input {
return Err("an RGBA picture for a planar-input encoder".into());
}
let (w, h) = (self.size.0 as usize, self.size.1 as usize);
if rgba.len() < w.saturating_mul(h).saturating_mul(4) {
return Err("the picture is smaller than the encoder's size".into());
}
self.submit_picture(index, |l| fill_rgba(l, rgba, w, h))
}
/// Encode picture `index` from planar 8-bit 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes).
/// Returns the pictures that came out (usually the oldest; none while the ring fills). An error
/// for a Main 10 encoder.
@ -352,6 +391,9 @@ impl Nvenc {
if self.ten_bit {
return Err("an 8-bit picture for a Main 10 encoder".into());
}
if self.rgba_input {
return Err("a planar picture for an RGBA-input encoder".into());
}
let (w, h) = (self.size.0 as usize, self.size.1 as usize);
let (luma, chroma) = (w.saturating_mul(h), (w / 2).saturating_mul(h / 2));
if y.len() < luma || u.len() < chroma || v.len() < chroma {
@ -455,6 +497,20 @@ fn fill_nv12(l: Locked<'_>, y: &[u8], u: &[u8], v: &[u8], w: usize, h: usize) {
}
}
/// Copy straight RGBA8 rows into an ABGR input buffer (the same byte order: R, G, B, A). A row the
/// pitch cannot hold is skipped, never overrun.
fn fill_rgba(l: Locked<'_>, rgba: &[u8], w: usize, h: usize) {
let row = w.saturating_mul(4);
if row == 0 || l.pitch == 0 {
return;
}
for (src, dst) in rgba.chunks_exact(row).zip(l.data.chunks_mut(l.pitch)).take(h) {
if let Some(d) = dst.get_mut(..row) {
d.copy_from_slice(src);
}
}
}
/// Copy planar 10-bit 4:2:0 into a P010 input buffer: each sample is a little-endian `u16` with the
/// 10-bit code in its high bits (`code << 6`), the chroma plane interleaved (Cb, Cr) after the luma rows,
/// `pitch` in bytes. Codes above 10 bits are masked; a row the pitch cannot hold is skipped, never overrun.

View File

@ -150,6 +150,8 @@ pub struct Params {
pub profile: u8,
/// HEVC Main 10: 10-bit 4:2:0 pictures (P010), otherwise 8-bit (NV12).
pub ten_bit: bool,
/// 8-bit only: pictures go in as packed RGBA (ABGR) and NVENC converts them to 4:2:0 on the GPU.
pub rgba_input: bool,
/// HEVC: the VUI colour description and the SEI messages of every IDR picture (H.264 keeps BT.709).
pub signal: Signal,
/// The codec's own level code: `level × 10` for H.264, `level × 30` for HEVC.
@ -160,7 +162,7 @@ pub struct Params {
}
/// An input buffer locked for writing: NV12 (or P010) rows go to `data` at `pitch` bytes per row, the
/// chroma plane after `pitch * height` bytes.
/// chroma plane after `pitch * height` bytes; packed RGBA (ABGR) has `height` rows and no chroma plane.
pub struct Locked<'a> {
pub data: &'a mut [u8],
pub pitch: usize,
@ -315,8 +317,14 @@ impl Session {
if !p.signal.sei.is_empty() && p.codec != Codec::Hevc {
return Err("SEI messages are written for HEVC only".into());
}
self.fmt = if p.ten_bit { NV_ENC_BUFFER_FORMAT_YUV420_10BIT } else { NV_ENC_BUFFER_FORMAT_NV12 };
self.bytes_per_sample = if p.ten_bit { 2 } else { 1 };
if p.ten_bit && p.rgba_input {
return Err("RGBA input is 8-bit only".into());
}
(self.fmt, self.bytes_per_sample) = match (p.ten_bit, p.rgba_input) {
(true, _) => (NV_ENC_BUFFER_FORMAT_YUV420_10BIT, 2),
(false, true) => (NV_ENC_BUFFER_FORMAT_ABGR, 4),
(false, false) => (NV_ENC_BUFFER_FORMAT_NV12, 1),
};
self.gop = u64::from(p.gop.max(1));
self.submitted = 0;
// the SEI payloads: heap blocks that outlive every picture (see the field documentation)
@ -516,7 +524,8 @@ impl Session {
let (w, h) = self.size;
// SAFETY: the buffer handle is one this session created. `LockInputBuffer` returns a
// writable block of `pitch * h * 3 / 2` bytes (NV12 or P010: luma rows then chroma rows, the pitch
// in bytes) that stays valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`.
// in bytes), or `pitch * h` bytes (ABGR: packed rows only), that stays valid until
// `UnlockInputBuffer`; the slice borrows it only inside `fill`.
let pitch = unsafe {
let mut l: NV_ENC_LOCK_INPUT_BUFFER = std::mem::zeroed();
l.version = NV_ENC_LOCK_INPUT_BUFFER_VER;
@ -531,7 +540,8 @@ impl Session {
let _ = unlock(self.enc, input);
return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows of {}-byte samples", l.pitch, self.bytes_per_sample));
}
let len = (l.pitch as usize).saturating_mul(h as usize).saturating_mul(3) / 2;
let rows = (l.pitch as usize).saturating_mul(h as usize);
let len = if self.fmt == NV_ENC_BUFFER_FORMAT_ABGR { rows } else { rows.saturating_mul(3) / 2 };
fill(Locked { data: std::slice::from_raw_parts_mut(l.bufferDataPtr.cast::<u8>(), len), pitch: l.pitch as usize });
let st = unlock(self.enc, input);
if st != NV_ENC_SUCCESS {

View File

@ -10,7 +10,8 @@
use std::ffi::{CStr, c_char, c_int, c_uint, c_void};
use std::fs::{File, OpenOptions};
use std::os::fd::AsRawFd;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use filmcraft_frame::VideoFrame;
use libloading::Library;
@ -125,9 +126,49 @@ pub struct Display {
_device: File,
}
// SAFETY: a VA display is not tied to the thread that opened it, and a `Display` is only used
// through its single owner (`&mut` or `&` of one `Session` at a time), never concurrently.
// SAFETY: a VA display is not tied to the thread that opened it.
unsafe impl Send for Display {}
// SAFETY: libva's functions are thread-safe and so must its drivers' be (va.h, "Multithreading
// Guide"): one display may be used from several threads at once, as long as each VA object
// (context, surfaces, buffers, images) is used by one thread at a time. Every `Session` on a
// shared display has its own configuration, context, surfaces and image, used only through that
// session (`&mut` or `&` of one owner); the display itself is only read (`api`, `dpy`, `vendor`).
unsafe impl Sync for Display {}
/// The display every [`Session`] decodes on. Opening a display takes a few milliseconds with
/// Intel's and Mesa's drivers but 0.2-0.3 s with NVIDIA's (a CUDA context each, about 100 MB of
/// video memory), and terminating one 60-70 ms, on the thread that creates or drops the decoder: a
/// frame worker at every clip on the timeline, the UI thread when media is removed. So one display
/// is opened (by [`probe`] at startup) and kept for the life of the process, like the libraries,
/// and sessions only make and destroy their own configuration, context and surfaces.
static SHARED: Mutex<Option<Arc<Display>>> = Mutex::new(None);
/// Displays opened so far (diagnostics and tests).
static OPENED: AtomicUsize = AtomicUsize::new(0);
/// The number of VA displays this process has opened (one, unless the shared one stopped working).
pub fn displays_opened() -> usize {
OPENED.load(Ordering::Relaxed)
}
/// The shared display, opened on first use.
fn shared() -> Result<Arc<Display>, String> {
let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(d) = shared.as_ref() {
return Ok(d.clone());
}
let d = Arc::new(Display::open()?);
*shared = Some(d.clone());
Ok(d)
}
/// Share `new` from now on instead of `old` (if `old` is still the shared one). Sessions on `old`
/// keep it until they end.
fn replace_shared(old: &Arc<Display>, new: Arc<Display>) {
let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
if shared.as_ref().is_none_or(|d| Arc::ptr_eq(d, old)) {
*shared = Some(new);
}
}
impl Display {
/// The first DRM render node with a working VA-API driver.
@ -164,6 +205,7 @@ impl Display {
// SAFETY: `dpy` is valid; the version outputs point to live locals.
let st = unsafe { (api.initialize)(dpy, &mut major, &mut minor) };
d.check(st, "vaInitialize")?;
OPENED.fetch_add(1, Ordering::Relaxed);
// SAFETY: `dpy` is an initialised display; the vendor string is NUL-terminated and owned by
// the driver for the life of the display (copied out here).
let vendor = unsafe { (api.vendor_string)(dpy) };
@ -206,7 +248,8 @@ impl Drop for Display {
/// One stream's decoder: a VLD configuration of a profile, its surfaces and context, and the image
/// pictures are read back through.
pub struct Session {
display: Display,
/// The shared display (see [`SHARED`]), or the one that replaced it.
display: Arc<Display>,
config: ffi::VAConfigID,
context: ffi::VAContextID,
surfaces: Vec<VASurfaceID>,
@ -231,8 +274,23 @@ impl Session {
/// 8 or 10), with `count` surfaces of `size` (coded luma samples); pictures read back are cut
/// to `geometry`.
pub fn new(profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result<Self, String> {
let display = shared()?;
match Self::on(display.clone(), profiles, size, count, geometry) {
Ok(s) => Ok(s),
Err(first) => {
// The shared display may have stopped working (a GPU reset): try a new one, and
// share that one from now on if it works. (A stream the driver does not take
// fails on both and costs the one extra display opening.)
let fresh = Arc::new(Display::open().map_err(|e| format!("{first}; a new display: {e}"))?);
let s = Self::on(fresh.clone(), profiles, size, count, geometry).map_err(|_| first)?;
replace_shared(&display, fresh);
Ok(s)
}
}
}
fn on(display: Arc<Display>, profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result<Self, String> {
let (rt_format, _, _) = formats(geometry.bits)?;
let display = Display::open()?;
let api = display.api;
let dpy = display.dpy;
let mut config = None;
@ -497,8 +555,9 @@ impl Accel for Session {
impl Drop for Session {
fn drop(&mut self) {
let (api, dpy) = (self.display.api, self.display.dpy);
// SAFETY: each object below was made on this display and is destroyed once; the display
// itself is terminated afterwards (when the `display` field is dropped).
// SAFETY: each object below was made on this display and is destroyed once, by this
// session only; the display itself is terminated when its last user lets it go (the
// shared one never is).
unsafe {
if let Some(image) = self.image.take() {
(api.destroy_image)(dpy, image.image_id);
@ -521,5 +580,6 @@ impl Drop for Session {
/// driver's description, or why not.
pub fn probe() -> &'static Result<String, String> {
static PROBE: OnceLock<Result<String, String>> = OnceLock::new();
PROBE.get_or_init(|| Display::open().map(|d| d.vendor().to_string()))
// the display opened to answer is the one sessions share
PROBE.get_or_init(|| shared().map(|d| d.vendor().to_string()))
}

View File

@ -1,7 +1,9 @@
//! [`HybridDecoder`] fallback on every platform, with a stand-in "hardware" decoder (our software
//! decoder that starts failing after N samples, as a lost hardware session does): the output must
//! be exactly the software decoder's, whatever sample the failure hits. Also: in-band parameter
//! sets that differ from the sample entry's switch to software; identical ones do not.
//! sets that differ from the sample entry's switch to software; identical ones do not; and a
//! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed
//! run, loses no picture.
mod common;
@ -105,3 +107,107 @@ fn changed_in_band_parameter_sets_switch_to_software() {
assert!(!d.is_hardware(), "changed SPS: software");
assert_same("changed SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &changed));
}
/// Like [`Failing`], but tolerant of one damaged sample the way a GPU can be: at `damaged_at` it
/// decodes the intact sample instead of the one it is given (which the software decoder rejects).
struct Tolerant {
inner: Box<dyn VideoDecoder>,
intact: Vec<u8>,
damaged_at: usize,
fail_after: usize,
fed: usize,
}
impl VideoDecoder for Tolerant {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
let i = self.fed;
self.fed += 1;
if self.fed > self.fail_after {
return Err(filmcraft_codecs::CodecError::Decode("session invalidated (test)".into()));
}
self.inner.decode(if i == self.damaged_at { &self.intact } else { sample }, pts)
}
fn flush(&mut self) -> Vec<DecodedFrame> {
self.inner.flush()
}
fn reset(&mut self) {
self.inner.reset();
}
fn name(&self) -> &str {
"stand-in hardware"
}
}
/// Feed `samples` then flush, carrying on past samples the decoder rejects (as playback does);
/// returns the pictures and the indices of the rejected samples.
fn decode_lenient(d: &mut dyn VideoDecoder, samples: &[(Vec<u8>, i64)]) -> (Vec<DecodedFrame>, Vec<usize>) {
let (mut out, mut rejected) = (Vec::new(), Vec::new());
for (i, (s, p)) in samples.iter().enumerate() {
match d.decode(s, *p) {
Ok(f) => out.extend(f),
Err(_) => rejected.push(i),
}
}
out.extend(d.flush());
(out, rejected)
}
/// The sample's NAL unit with its header kept and its payload overwritten: the slice header no
/// longer parses, so the software decoder rejects the sample.
fn damaged(sample: &[u8], nal_header: usize) -> Vec<u8> {
let mut v = sample.to_vec();
let start = (4 + nal_header).min(v.len());
v[start..].fill(0xff);
v
}
/// A replay (after the hardware fails) that meets a sample the software decoder rejects must go
/// on to the end of the replayed run, like decoding in software does, and must not lose the
/// pictures it decoded before that sample: every picture the software decoder outputs for the
/// stream is output, once and in order, and from the next restart point on the pictures are the
/// software decoder's.
#[test]
fn a_sample_software_rejects_during_the_replay_loses_no_pictures() {
let ff = filmcraft_testkit::require_ffmpeg!();
let mut checked = 0;
for (name, _) in FIXTURES {
let Some(path) = named(&ff, name) else { continue };
let s = read_stream(&path);
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
let header = if name.starts_with("hevc") { 2 } else { 1 };
// the second restart point, and a disposable picture before it that is not the first sample
let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue };
let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue };
let mut stream = s.samples.clone();
stream[d].0 = damaged(&s.samples[d].0, header);
let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream);
assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)");
let restart = stream[next..].iter().map(|x| x.1).min().unwrap();
// failing at the damaged sample itself (the software decoder rejects the failed sample
// too), and after the hardware had decoded it
for fail_after in [d, d + 1, d + 2, next - 1] {
let tolerant = Tolerant {
inner: filmcraft_codecs::software_video_decoder(&s.entry).unwrap(),
intact: s.samples[d].0.clone(),
damaged_at: d,
fail_after,
fed: 0,
};
let mut h = HybridDecoder::new(Box::new(tolerant), s.entry.clone(), info.clone());
let (out, errors) = decode_lenient(&mut h, &stream);
let what = format!("{name}: sample {d} damaged, hardware failing after {fail_after}");
assert!(!h.is_hardware(), "{what}: fell back");
// the damaged sample is reported as the software decoder reports it, unless the
// hardware had already decoded it
assert_eq!(errors, if fail_after == d { vec![d] } else { vec![] }, "{what}: rejected samples");
let pts: Vec<i64> = out.iter().map(|f| f.pts).collect();
assert!(pts.windows(2).all(|w| w[0] < w[1]), "{what}: pictures once and in order: {pts:?}");
let lost: Vec<i64> = reference.iter().map(|f| f.pts).filter(|p| !pts.contains(p)).collect();
assert!(lost.is_empty(), "{what}: lost pictures at pts {lost:?}");
let from = |v: &[DecodedFrame]| v.iter().position(|f| f.pts >= restart).unwrap_or(v.len());
assert_same(&format!("{what}, from the next restart point"), &out[from(&out)..], &reference[from(&reference)..]);
}
checked += 1;
}
assert_eq!(checked, FIXTURES.len(), "every fixture has a damaged-sample case");
}

View File

@ -0,0 +1,157 @@
//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the
//! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main
//! and H.264. Skips without an NVIDIA GPU / driver.
#![cfg(target_os = "windows")]
use filmcraft_isobmff::{AvcConfig, SampleEntry};
use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available};
/// NVENC sessions are a limited resource on consumer GPUs: one test at a time.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A moving test picture: gradient background, a moving box, a little texture.
fn moving(w: usize, h: usize, i: usize) -> Vec<u8> {
let mut v = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let o = (y * w + x) * 4;
let (bx, by) = ((i * 7) % (w - 64), (i * 3) % (h - 64));
let in_box = x >= bx && x < bx + 64 && y >= by && y < by + 64;
let tex = ((x * 31 + y * 17 + i * 5) % 23) as u8;
v[o] = if in_box { 230 } else { (x * 200 / w) as u8 + tex };
v[o + 1] = if in_box { 40 } else { (y * 200 / h) as u8 + tex };
v[o + 2] = if in_box { 60 } else { 90 + tex };
v[o + 3] = 255;
}
}
v
}
fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Vec<u8> {
[rgb[0], rgb[1], rgb[2], 255].repeat(w * h)
}
fn config(w: u32, h: u32, profile: Profile) -> Config {
Config {
width: w,
height: h,
fps: (30, 1),
bitrate_kbps: 8000,
max_bitrate_kbps: 12000,
cbr: false,
keyint: 30,
profile,
level: None,
sar: None,
bframes: true,
}
}
fn psnr(a: &[u8], b: &[u8]) -> f64 {
let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }
}
fn mean(p: &[u8]) -> f64 {
p.iter().map(|v| f64::from(*v)).sum::<f64>() / p.len() as f64
}
/// Encode `frames` through the RGBA input, decode with our own decoder: the decoded planes by picture.
fn round_trip(profile: Profile, w: usize, h: usize, frames: &[Vec<u8>]) -> Vec<[Vec<u8>; 3]> {
let mut enc =
Nvenc::with_rgba_input(&config(w as u32, h as u32, profile), &Signal::default()).unwrap_or_else(|e| panic!("{profile:?} with RGBA input: {e}"));
assert!(enc.takes_rgba());
assert!(enc.encode(&[0; 4], &[0; 1], &[0; 1], 0).is_err(), "planar pictures are refused");
let mut packets: Vec<Packet> = Vec::new();
for (i, f) in frames.iter().enumerate() {
packets.extend(enc.encode_rgba(f, i as u64).unwrap());
}
packets.extend(enc.flush().unwrap());
assert_eq!(packets.len(), frames.len(), "one packet per picture");
let entry = match enc.codec() {
Codec::Hevc => SampleEntry::hevc(enc.hevc_config().expect("an hvcC record").clone(), w as u16, h as u16),
Codec::H264 => {
let (sps, pps) = enc.parameter_sets();
SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w as u16, h as u16)
}
};
let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap();
let mut out = Vec::new();
for p in &packets {
out.extend(dec.decode(&p.data, p.pts).unwrap());
}
out.extend(dec.flush());
out.sort_by_key(|f| f.pts);
assert_eq!(out.len(), frames.len());
out.into_iter()
.map(|f| {
let filmcraft_frame::PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("8-bit planes") };
[planes[0].to_vec(), planes[1].to_vec(), planes[2].to_vec()]
})
.collect()
}
fn check(profile: Profile) {
let (w, h) = (1280usize, 720usize);
// solid colours: the decoded means are the BT.709 limited-range codes of the export's own conversion
let colours: [[u8; 3]; 6] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255], [0, 0, 0], [128, 128, 128]];
let mut frames: Vec<Vec<u8>> = colours.iter().map(|c| solid(w, h, *c)).collect();
frames.extend((0..24).map(|i| moving(w, h, i)));
let decoded = round_trip(profile, w, h, &frames);
let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new());
for (k, c) in colours.iter().enumerate() {
filmcraft_export::rgba_to_yuv420_8(&frames[k], w, h, &mut y, &mut u, &mut v);
let want = [f64::from(y[0]), f64::from(u[0]), f64::from(v[0])];
let got = [mean(&decoded[k][0]), mean(&decoded[k][1]), mean(&decoded[k][2])];
eprintln!("{profile:?} {c:?}: Y/Cb/Cr {got:.1?}, BT.709 limited {want:?}");
for (g, e) in got.iter().zip(want) {
assert!((g - e).abs() <= 2.0, "{profile:?} {c:?}: decoded {got:.1?}, expected {want:?}");
}
}
// a moving picture: close to the export's own conversion encoded the planar way
let (mut worst, mut worst_chroma) = (99.0f64, 99.0f64);
for (k, f) in frames.iter().enumerate().skip(colours.len()) {
filmcraft_export::rgba_to_yuv420_8(f, w, h, &mut y, &mut u, &mut v);
worst = worst.min(psnr(&decoded[k][0], &y));
worst_chroma = worst_chroma.min(psnr(&decoded[k][1], &u)).min(psnr(&decoded[k][2], &v));
}
eprintln!("{profile:?}: worst luma PSNR {worst:.1} dB, chroma {worst_chroma:.1} dB");
assert!(worst > 40.0, "{profile:?} luma PSNR {worst:.1} dB");
assert!(worst_chroma > 40.0, "{profile:?} chroma PSNR {worst_chroma:.1} dB");
}
#[test]
fn hevc_rgba_input_converts_like_the_export() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC encoder");
return;
}
check(Profile::HevcMain);
}
#[test]
fn h264_rgba_input_converts_like_the_export() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !available() {
eprintln!("SKIPPED: no NVENC");
return;
}
check(Profile::High);
}
#[test]
fn main10_has_no_rgba_input_and_short_pictures_are_errors() {
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !hevc_available() {
eprintln!("SKIPPED: no NVENC HEVC encoder");
return;
}
assert!(Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain10), &Signal::default()).is_err());
let mut enc = Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain), &Signal::default()).unwrap();
assert!(enc.encode_rgba(&[], 0).is_err());
assert!(enc.encode_rgba(&vec![0; 640 * 360 * 4 - 1], 0).is_err());
let mut planar = Nvenc::new(&config(640, 360, Profile::HevcMain)).unwrap();
assert!(!planar.takes_rgba());
assert!(planar.encode_rgba(&solid(640, 360, [1, 2, 3]), 0).is_err(), "RGBA pictures for a planar encoder");
}

View File

@ -0,0 +1,68 @@
//! VA-API decoders share one display (Linux). Opening a display takes 0.2-0.3 s with NVIDIA's
//! driver (about 100 MB of video memory each) and terminating one 60-70 ms, on the thread that
//! creates or drops the decoder (a frame worker at every clip, the UI thread when media is
//! removed), so the process opens one and every decoder makes only its own context and surfaces on
//! it, also several decoders at once on different threads. Its own test binary: the count of
//! opened displays is process-wide, and other tests running at the same time would change it.
//! Skips without ffmpeg (fixture generator) or without a VA-API driver.
#![cfg(target_os = "linux")]
mod common;
use common::*;
use filmcraft_codecs::VideoDecoder;
use filmcraft_platform::vaapi::va::displays_opened;
fn hardware(s: &Stream) -> Option<Box<dyn VideoDecoder>> {
match filmcraft_platform::vaapi_factory(&s.entry) {
Some(Ok(d)) if d.name().starts_with("VA-API") => Some(d),
_ => {
eprintln!("SKIPPED: no VA-API decoder for this stream on this machine");
None
}
}
}
#[test]
fn decoders_share_one_display() {
let ff = filmcraft_testkit::require_ffmpeg!();
let paths: Vec<_> = FIXTURES.iter().filter_map(|(name, _)| named(&ff, name)).collect();
let streams: Vec<_> = paths.iter().map(|p| read_stream(p)).collect();
let Some(first) = streams.first() else { return };
let Some(hw) = hardware(first) else { return };
drop(hw);
let opened = displays_opened();
assert_eq!(opened, 1, "the first decoder opened one display");
// one decoder after another, of every fixture (H.264, HEVC Main, HEVC Main 10)
for round in 0..10 {
for s in &streams {
let Some(mut hw) = hardware(s) else { return };
let n = s.samples.len().min(13);
let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples[..n]);
assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..n]), &reference);
}
}
assert_eq!(displays_opened(), opened, "decoders one after another open no display");
// several decoders at once on different threads, each used from its own thread, repeatedly
for round in 0..3 {
let jobs: Vec<_> = (0..6)
.map(|i| {
let path = paths[i % paths.len()].clone();
std::thread::spawn(move || {
let s = read_stream(&path);
let Some(mut hw) = hardware(&s) else { return };
let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples);
assert_same(&format!("round {round}, thread {i}"), &decode_all(hw.as_mut(), &s.samples), &reference);
hw.reset();
assert_same(&format!("round {round}, thread {i}, after a reset"), &decode_all(hw.as_mut(), &s.samples), &reference);
})
})
.collect();
for j in jobs {
j.join().unwrap();
}
}
assert_eq!(displays_opened(), opened, "decoders at once on several threads open no display");
}

View File

@ -24,6 +24,7 @@ pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"];
pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"];
pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"];
pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"];
pub const FILL_KIND_OPTS: &[&str] = &["Solid", "Linear Gradient"];
/// Whether an effect instance is a graphic layer.
pub fn is_layer_id(id: &str) -> bool {
@ -58,6 +59,10 @@ fn appearance() -> Vec<ParamDef> {
vec![
bo("fill", "Fill", true, A),
co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A),
chc("fill_kind", "Fill Type", FILL_KIND_OPTS, 0, A),
co("gradient_start", "Gradient Start", [1.0, 1.0, 1.0, 1.0], A),
co("gradient_end", "Gradient End", [0.15, 0.15, 0.15, 1.0], A),
p("gradient_angle", "Gradient Angle", ParamKind::Angle, ParamValue::Float(0.0), true, A),
bo("stroke", "Stroke", false, A),
co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A),
fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A),
@ -144,10 +149,22 @@ pub(crate) fn layer_defs() -> Vec<EffectDef> {
]
}
/// A linear fill across the layer's local bounds. Colours are sRGB-encoded straight RGBA.
/// Angle is degrees clockwise on screen (y down); 0 runs left to right.
#[derive(Clone, Debug, PartialEq)]
pub struct LinearGradient {
pub start: [f32; 4],
pub end: [f32; 4],
pub angle: f32,
}
/// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA).
#[derive(Clone, Debug, PartialEq)]
pub struct Appearance {
pub fill: Option<[f32; 4]>,
/// Set when Fill Type is Linear Gradient. Replaces the solid fill. A text run with its own
/// fill colour stays solid; the other characters keep the ramp.
pub gradient: Option<LinearGradient>,
/// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last.
pub strokes: Vec<([f32; 4], f32, u32)>,
/// (colour with opacity in alpha, padding px, corner radius px).
@ -334,7 +351,17 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<Laye
uid: e.layer.as_ref().map_or(0, |x| x.uid),
pin: e.layer.as_ref().and_then(|x| x.pin.clone()).filter(|p| p.any()),
content,
appearance: Appearance { fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)), strokes, background, shadow },
appearance: Appearance {
fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)),
gradient: (bb(e, "fill", t) && ch(e, "fill_kind", t) == 1).then(|| LinearGradient {
start: cc(e, "gradient_start", t),
end: cc(e, "gradient_end", t),
angle: ff(e, "gradient_angle", t),
}),
strokes,
background,
shadow,
},
transform: LayerTransform {
position,
anchor: vv(e, "anchor", t),
@ -457,8 +484,17 @@ mod tests {
assert_eq!(t.size, 72.0);
assert_eq!(t.font, "Inter");
assert_eq!(s.appearance.fill, Some([1.0, 0.0, 0.0, 1.0]));
assert!(s.appearance.gradient.is_none(), "solid fill stays solid");
assert_eq!(s.transform.position, Vec2::new(100.0, 200.0));
assert!(s.appearance.strokes.is_empty() && s.appearance.shadow.is_none());
e.params.get_mut("fill_kind").unwrap().value = ParamValue::Choice(1);
e.params.get_mut("gradient_start").unwrap().value = ParamValue::Color([1.0, 0.0, 0.0, 1.0]);
e.params.get_mut("gradient_end").unwrap().value = ParamValue::Color([0.0, 0.0, 1.0, 1.0]);
e.params.get_mut("gradient_angle").unwrap().value = ParamValue::Float(90.0);
let g = eval_layer(&e, Tick::ZERO, (1920, 1080)).unwrap().appearance.gradient.expect("linear fill");
assert_eq!(g.start, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(g.end, [0.0, 0.0, 1.0, 1.0]);
assert_eq!(g.angle, 90.0);
assert!(is_layer(&e));
assert_eq!(layer_display_name(&e, 0), "Hello");
}

View File

@ -1232,9 +1232,9 @@ impl SequenceView {
}
}
/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one
/// and how each sequence was shown. Stored beside the project in its file, not in it: it is not
/// part of the edit and never an undo step.
/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one,
/// how each sequence was shown and where its playhead was. Stored beside the project in its file,
/// not in it: it is not part of the edit and never an undo step.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ProjectView {
#[serde(default)]
@ -1243,6 +1243,17 @@ pub struct ProjectView {
pub active_sequence: Option<ItemId>,
#[serde(default)]
pub sequences: BTreeMap<ItemId, SequenceView>,
/// Each sequence's playhead, so a sequence reopens where it was left. Files
/// written before it existed have none, and every playhead starts at zero as it did then.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub playheads: BTreeMap<ItemId, Tick>,
}
impl ProjectView {
/// The latest playhead a project file may ask for: ten million seconds (about 115 days), the
/// same bound as a sequence view's scroll position. Playheads are read from project files, so
/// nothing in them is trusted.
pub const MAX_PLAYHEAD: Tick = Tick(10_000_000 * TICKS_PER_SECOND);
}
/// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:<id>`.

View File

@ -66,19 +66,6 @@ pub(crate) fn mapping(id: &str) -> Option<Mapping> {
d.set_param("mix", f(e, "mix", t));
},
},
"parametric_eq" => Mapping {
dsp: "simple_eq",
preroll: |_, _| 0.05,
apply: |d, e, t| {
d.set_param("low_freq", f(e, "low_freq", t));
d.set_param("low", f(e, "low_gain", t));
d.set_param("mid_freq", f(e, "mid_freq", t));
d.set_param("mid", f(e, "mid_gain", t));
d.set_param("mid_q", f(e, "mid_q", t));
d.set_param("high_freq", f(e, "high_freq", t));
d.set_param("high", f(e, "high_gain", t));
},
},
// Single-band filters via band 1 of the parametric EQ (types: see FILTER_TYPE_NAMES).
"highpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 4.0, f(e, "cutoff", t), 0.707) },
"lowpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 3.0, f(e, "cutoff", t), 0.707) },
@ -474,4 +461,25 @@ mod tests {
assert_eq!(latency("fft_filter", 48000), 2048);
assert_eq!(latency("amplify", 48000), 0);
}
#[test]
fn parametric_eq_applies_every_control() {
let def = filmcraft_project::find_effect("parametric_eq").unwrap();
let resp = |id: &str, v: filmcraft_project::ParamValue, extra: Option<(&str, filmcraft_project::ParamValue)>| {
let mut inst = def.instance();
inst.param_mut(id).unwrap().value = v;
if let Some((k, x)) = extra {
inst.param_mut(k).unwrap().value = x;
}
configured(&inst, Tick::ZERO, 48000).unwrap().response_db(1000.0).unwrap()
};
use filmcraft_project::ParamValue::{Bool, Float};
assert!((resp("master_gain", Float(-30.0), None) + 30.0).abs() < 1.0);
for b in ["b1", "b2", "b4", "b5"] {
let g = resp(&format!("{b}_gain"), Float(-24.0), Some((&format!("{b}_freq"), Float(1000.0))));
assert!((g + 24.0).abs() < 1.0, "{b}: {g}");
}
assert!(resp("hp_on", Bool(true), Some(("hp_freq", Float(10000.0)))) < -20.0);
assert!(resp("lp_on", Bool(true), Some(("lp_freq", Float(100.0)))) < -20.0);
}
}

View File

@ -12,7 +12,7 @@ use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::graphic::{LayerContent, LayerSpec, ShapeProps, TextProps, eval_layer};
use filmcraft_project::graphic::{LayerContent, LayerSpec, LinearGradient, ShapeProps, TextProps, eval_layer};
use filmcraft_project::graphic_design::{GraphicMeta, PinNode, remap_time, resolve_pins, roll_offset};
use filmcraft_project::{EffectInstance, TrackItem};
use filmcraft_text::raster::{Xform, apply, fill_into, xform_scale};
@ -20,7 +20,7 @@ use filmcraft_text::{Align, Caps, Layout, Mask, ParagraphStyle, StrokeKind, Styl
use filmcraft_time::Tick;
use rayon::prelude::*;
use crate::graphics::premul_linear;
use crate::graphics::{premul_linear, premul_linear_lerp};
use crate::image::Image;
/// f64 affine → the text crate's f32 transform.
@ -191,6 +191,109 @@ fn over_mask(px: &mut [f32], m: &Mask, c: [f32; 4]) {
});
}
fn invert_xform(m: &Xform) -> Option<Xform> {
let det = m[0] * m[3] - m[1] * m[2];
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let a = m[3] * inv;
let b = -m[1] * inv;
let c = -m[2] * inv;
let d = m[0] * inv;
Some([a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])])
}
/// Paint `grad` through `msk`. 0° runs left to right across `lb` in layer space; the angle is
/// clockwise on screen and turns with `inv` (the device→layer transform).
fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [f32; 4], inv: &Xform, origin: (i32, i32)) {
if msk.w == 0 {
return;
}
let (cx, cy) = ((lb[0] + lb[2]) * 0.5, (lb[1] + lb[3]) * 0.5);
let (sin, cos) = grad.angle.to_radians().sin_cos();
let (ax, ay) = (cos, sin);
let corners = [(lb[0], lb[1]), (lb[2], lb[1]), (lb[0], lb[3]), (lb[2], lb[3])];
let half = corners.iter().map(|(x, y)| ((x - cx) * ax + (y - cy) * ay).abs()).fold(1e-3_f32, f32::max);
let w = msk.w;
let (start, end) = (grad.start, grad.end);
px.par_chunks_mut(4 * w).enumerate().for_each(|(row, dest)| {
let py = origin.1 as f32 + row as f32 + 0.5;
let mrow = &msk.a[row * w..(row + 1) * w];
for (col, (p, &cov)) in dest.as_chunks_mut::<4>().0.iter_mut().zip(mrow).enumerate() {
if cov <= 0.0 {
continue;
}
let (lx, ly) = apply(inv, origin.0 as f32 + col as f32 + 0.5, py);
let t = (((((lx - cx) * ax + (ly - cy) * ay) / half) + 1.0) * 0.5).clamp(0.0, 1.0);
let c = premul_linear_lerp(start, end, t);
let k = 1.0 - c[3] * cov;
for i in 0..4 {
p[i] = c[i] * cov + p[i] * k;
}
}
});
}
/// Solid fill, one mask per distinct colour when text runs carry their own.
fn paint_solid(px: &mut [f32], cover: &Mask, color: [f32; 4], text: Option<&Layout>, m: &Xform, run_fills: &[Option<[f32; 4]>], origin: (i32, i32)) {
let Some(layout) = text.filter(|_| run_fills.iter().any(Option::is_some)) else {
over_mask(px, cover, premul_linear(color));
return;
};
let mut groups: Vec<([f32; 4], Vec<u16>)> = Vec::new();
for r in 0..=run_fills.len() {
let col = if r == 0 { color } else { run_fills[r - 1].unwrap_or(color) };
match groups.iter_mut().find(|g| g.0 == col) {
Some(g) => g.1.push(r as u16),
None => groups.push((col, vec![r as u16])),
}
}
for (col, runs) in groups {
let mut m_run = Mask::new(cover.w, cover.h);
for r in runs {
render::draw_run(layout, m, &mut m_run, origin, Some(r));
}
over_mask(px, &m_run, premul_linear(col));
}
}
/// Gradient for runs that have no fill of their own; runs with a fill stay that solid colour.
fn paint_mixed_gradient(
px: &mut [f32],
cover: &Mask,
grad: &LinearGradient,
lb: [f32; 4],
inv: &Xform,
origin: (i32, i32),
layout: &Layout,
m: &Xform,
run_fills: &[Option<[f32; 4]>],
) {
let mut grad_mask = Mask::new(cover.w, cover.h);
render::draw_run(layout, m, &mut grad_mask, origin, Some(0));
let mut solids: Vec<([f32; 4], Vec<u16>)> = Vec::new();
for (i, fill) in run_fills.iter().enumerate() {
let run = (i + 1) as u16;
if let Some(col) = fill {
match solids.iter_mut().find(|g| g.0 == *col) {
Some(g) => g.1.push(run),
None => solids.push((*col, vec![run])),
}
} else {
render::draw_run(layout, m, &mut grad_mask, origin, Some(run));
}
}
over_linear_gradient(px, &grad_mask, grad, lb, inv, origin);
for (col, runs) in solids {
let mut m_run = Mask::new(cover.w, cover.h);
for r in runs {
render::draw_run(layout, m, &mut m_run, origin, Some(r));
}
over_mask(px, &m_run, premul_linear(col));
}
}
/// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas.
pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option<LayerRaster> {
if !spec.enabled || spec.transform.opacity <= 0.0 {
@ -294,32 +397,22 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option
over_mask(&mut px, sm, premul_linear(*c));
}
}
if let Some(c) = ap.fill {
// per-character fills: each style run with its own colour is drawn on its own
let run_fills: Vec<Option<[f32; 4]>> = match &spec.content {
LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(),
_ => Vec::new(),
};
match &text {
Some(l) if run_fills.iter().any(Option::is_some) => {
let mut groups: Vec<([f32; 4], Vec<u16>)> = Vec::new();
for r in 0..=run_fills.len() {
let col = if r == 0 { c } else { run_fills[r - 1].unwrap_or(c) };
match groups.iter_mut().find(|g| g.0 == col) {
Some(g) => g.1.push(r as u16),
None => groups.push((col, vec![r as u16])),
}
}
for (col, runs) in groups {
let mut m_run = Mask::new(w, h);
for r in runs {
render::draw_run(l, m, &mut m_run, (x0, y0), Some(r));
}
over_mask(&mut px, &m_run, premul_linear(col));
}
}
_ => over_mask(&mut px, &cover, premul_linear(c)),
let run_fills: Vec<Option<[f32; 4]>> = match &spec.content {
LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(),
_ => Vec::new(),
};
if let Some(grad) = &ap.gradient
&& let Some(inv) = invert_xform(m)
{
if run_fills.iter().any(Option::is_some)
&& let Some(layout) = &text
{
paint_mixed_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0), layout, m, &run_fills);
} else {
over_linear_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0));
}
} else if let Some(c) = ap.fill {
paint_solid(&mut px, &cover, c, text.as_deref(), m, &run_fills, (x0, y0));
}
for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() {
if !outer(*k) {
@ -702,6 +795,103 @@ mod tests {
assert_eq!(img.get(20, 20)[3], 0.0);
}
#[test]
fn linear_gradient_runs_left_to_right_on_a_shape_and_on_text() {
let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]);
set(&mut shape, "fill_kind", ParamValue::Choice(1));
set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
set(&mut shape, "gradient_angle", ParamValue::Float(0.0));
let mut img = Image::new(200, 200);
render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
let left = img.get(70, 100);
let right = img.get(130, 100);
assert!(left[3] > 0.9 && left[0] > left[2], "redder on the left {left:?}");
assert!(right[3] > 0.9 && right[2] > right[0], "bluer on the right {right:?}");
let mut text = new_text_layer("HHHHHHHH", Vec2::new(16.0, 90.0), 64.0);
set(&mut text, "fill_kind", ParamValue::Choice(1));
set(&mut text, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
set(&mut text, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
let mut img = Image::new(500, 180);
render_graphic_layers(&[text], Tick::ZERO, (500, 180), &Affine::IDENTITY, &mut img);
let ink: Vec<(usize, usize)> = (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.8).collect();
let minx = ink.iter().map(|p| p.0).min().unwrap();
let maxx = ink.iter().map(|p| p.0).max().unwrap();
let span = (maxx - minx).max(1);
let (mut ln, mut rn, mut ls, mut rs) = (0.0_f32, 0.0, 0.0, 0.0);
for &(x, y) in &ink {
let p = img.get(x, y)[0];
if x < minx + span / 3 {
ls += p;
ln += 1.0;
} else if x > maxx - span / 3 {
rs += p;
rn += 1.0;
}
}
assert!(ln > 30.0 && rn > 30.0, "ink on both ends of the title: {ln} {rn} x {minx}..{maxx}");
assert!(ls / ln > rs / rn + 0.15, "text ramps from red to blue: {} {}", ls / ln, rs / rn);
}
#[test]
fn linear_gradient_blends_in_linear_light_and_turns_with_the_layer() {
let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]);
set(&mut shape, "fill_kind", ParamValue::Choice(1));
set(&mut shape, "gradient_start", ParamValue::Color([0.0, 0.0, 0.0, 1.0]));
set(&mut shape, "gradient_end", ParamValue::Color([1.0, 1.0, 1.0, 1.0]));
let mut img = Image::new(200, 200);
render_graphic_layers(&[shape.clone()], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
let mid = img.get(100, 100)[0];
// Linear-light midpoint is ~0.5. An sRGB midpoint converted to linear is ~0.21.
assert!(mid > 0.4 && mid < 0.6, "centre of black→white is linear 0.5, got {mid}");
assert!(img.get(70, 100)[0] < mid && mid < img.get(130, 100)[0]);
set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
set(&mut shape, "rotation", ParamValue::Float(90.0));
let mut turned = Image::new(200, 200);
render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut turned);
let top = turned.get(100, 70);
let bot = turned.get(100, 130);
assert!(top[3] > 0.9 && bot[3] > 0.9, "the rotated ramp is inside the shape: {top:?} {bot:?}");
assert!(top[0] > top[2], "clockwise 90° carries the start colour to the top {top:?}");
assert!(bot[2] > bot[0], "and the end colour to the bottom {bot:?}");
}
#[test]
fn linear_gradient_keeps_a_per_character_fill_solid() {
use filmcraft_project::graphic_design::{CharStyle, LayerExtra, StyleRun};
let mut e = new_text_layer("AAAA", Vec2::new(20.0, 100.0), 60.0);
set(&mut e, "fill_kind", ParamValue::Choice(1));
set(&mut e, "gradient_start", ParamValue::Color([0.0, 1.0, 0.0, 1.0]));
set(&mut e, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
let red = CharStyle { fill: Some([1.0, 0.0, 0.0, 1.0]), ..Default::default() };
e.layer = Some(Box::new(LayerExtra { runs: vec![StyleRun { start: 2, end: 4, style: red }], ..Default::default() }));
let mut img = Image::new(400, 200);
render_graphic_layers(&[e.clone()], Tick::ZERO, (400, 200), &Affine::IDENTITY, &mut img);
let l = text_layout(match &eval_layer(&e, Tick::ZERO, (400, 200)).unwrap().content {
LayerContent::Text(t) => t,
_ => unreachable!(),
});
let split = 20.0 + l.caret(2).0;
let (mut green, mut red_px) = (0, 0);
for y in 0..200 {
for x in 0..400 {
let p = img.get(x, y);
if p[3] < 0.8 {
continue;
}
if (x as f32) < split - 1.0 && p[1] > p[0] && p[1] > p[2] {
green += 1;
} else if (x as f32) > split + 1.0 && p[0] > 0.9 && p[1] < 0.05 {
red_px += 1;
}
}
}
assert!(green > 80 && red_px > 80, "gradient on the plain letters, solid red on the styled ones: {green} {red_px}");
}
#[test]
fn inner_and_centre_strokes() {
for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] {

View File

@ -14,6 +14,15 @@ pub fn premul_linear(c: [f32; 4]) -> [f32; 4] {
[srgb_to_linear(c[0]) * a, srgb_to_linear(c[1]) * a, srgb_to_linear(c[2]) * a, a]
}
/// Blend two sRGB straight colours in linear light, then premultiply. `t` is 0 at `start`.
pub fn premul_linear_lerp(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] {
let t = t.clamp(0.0, 1.0);
let s = [srgb_to_linear(start[0]), srgb_to_linear(start[1]), srgb_to_linear(start[2]), start[3].clamp(0.0, 1.0)];
let e = [srgb_to_linear(end[0]), srgb_to_linear(end[1]), srgb_to_linear(end[2]), end[3].clamp(0.0, 1.0)];
let a = s[3] * (1.0 - t) + e[3] * t;
[(s[0] * (1.0 - t) + e[0] * t) * a, (s[1] * (1.0 - t) + e[1] * t) * a, (s[2] * (1.0 - t) + e[2] * t) * a, a]
}
/// Composite `color` (premultiplied linear) through coverage `m` placed at (`x0`, `y0`), scaled by
/// `opacity`, over `img`.
pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], opacity: f32) {

View File

@ -20,6 +20,7 @@ use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
pub const TICKS_PER_SECOND: i64 = 254_016_000_000;
/// A point or duration in time, in ticks.
/// Arithmetic saturates at the i64 storage limits; representable results remain exact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Tick(pub i64);
@ -37,20 +38,26 @@ impl Tick {
}
/// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000).
pub fn from_units(units: i64, per_second: i64) -> Tick {
Tick((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0) as i64)
Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0)))
}
/// Floor conversion to a count of units at `per_second`.
pub fn to_units_floor(self, per_second: i64) -> i64 {
(self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128) as i64
clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128))
}
/// Conversion from a rational timestamp `pts * num / den` seconds (container timebases).
pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick {
let t = pts as i128 * num as i128 * TICKS_PER_SECOND as i128;
Tick(t.checked_div_euclid(den as i128).unwrap_or(0) as i64)
if den == 0 {
return Tick::ZERO;
}
let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else {
// A product beyond i128 divided by an i64 cannot fit back into i64.
return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX });
};
Tick(clamp_i64(t.div_euclid(den as i128)))
}
/// Inverse of [`Tick::from_rational`], floored to the timebase.
pub fn to_rational_floor(self, num: i64, den: i64) -> i64 {
(self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0) as i64
clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0))
}
/// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up).
///
@ -63,10 +70,10 @@ impl Tick {
let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) };
let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 };
// 0 <= r < unit: round up from the halfway point
(if r * 2 >= unit { q + 1 } else { q }) as i64
clamp_i64(if r * 2 >= unit { q + 1 } else { q })
}
pub fn abs(self) -> Tick {
Tick(self.0.abs())
Tick(self.0.saturating_abs())
}
pub fn min(self, o: Tick) -> Tick {
Tick(self.0.min(o.0))
@ -80,36 +87,36 @@ impl Tick {
}
/// Scale by a rational factor (`num/den`), flooring.
pub fn mul_ratio(self, num: i64, den: i64) -> Tick {
Tick((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0) as i64)
Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0)))
}
}
impl Add for Tick {
type Output = Tick;
fn add(self, o: Tick) -> Tick {
Tick(self.0 + o.0)
Tick(self.0.saturating_add(o.0))
}
}
impl Sub for Tick {
type Output = Tick;
fn sub(self, o: Tick) -> Tick {
Tick(self.0 - o.0)
Tick(self.0.saturating_sub(o.0))
}
}
impl Neg for Tick {
type Output = Tick;
fn neg(self) -> Tick {
Tick(-self.0)
Tick(self.0.saturating_neg())
}
}
impl AddAssign for Tick {
fn add_assign(&mut self, o: Tick) {
self.0 += o.0;
self.0 = self.0.saturating_add(o.0);
}
}
impl SubAssign for Tick {
fn sub_assign(&mut self, o: Tick) {
self.0 -= o.0;
self.0 = self.0.saturating_sub(o.0);
}
}
@ -188,8 +195,8 @@ impl FrameRate {
];
pub fn new(num: i64, den: i64) -> Self {
let g = gcd(num.abs(), den.abs()).max(1);
FrameRate { num: num / g, den: den / g }
let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1));
FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 }
}
/// Closest standard rate for a float (e.g. from a container's average rate).
@ -213,49 +220,52 @@ impl FrameRate {
}
/// Exact duration of one frame (rounded down only for exotic rates).
/// Values outside the integer tick range saturate at its limit.
pub fn frame_duration(self) -> Tick {
let r = self.sane();
Tick(((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128) as i64)
Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128))
}
/// Index of the frame containing `t` (floor).
/// Index of the frame containing `t` (floor), saturated to the integer frame range.
pub fn frame_at(self, t: Tick) -> i64 {
let r = self.sane();
(t.0 as i128 * r.num as i128).div_euclid(TICKS_PER_SECOND as i128 * r.den as i128) as i64
clamp_i64(frame_index(self.sane(), t))
}
/// Start tick of frame `f` (ceiling division).
///
/// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every
/// frame index, even when the frame duration is not an integer number of ticks
/// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks
/// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a
/// fractional remainder). Floor division truncates the remainder, causing each
/// frame boundary to drift backward by one tick until `frame_at` returns `f - 1`
/// instead of `f` — frame stepping then stalls or moves backward.
/// Ceiling is correct because the frame duration is always > 1 tick, so
/// `floor(ceil(f·D) / D) == f` for every integer `f`.
/// Values outside the integer tick range saturate at its limit.
pub fn tick_of(self, f: i64) -> Tick {
let r = self.sane();
let n = f as i128 * TICKS_PER_SECOND as i128 * r.den as i128;
Tick((-(-n).div_euclid(r.num as i128)) as i64)
Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f))))
}
/// Snap `t` down to a frame boundary.
pub fn snap(self, t: Tick) -> Tick {
self.tick_of(self.frame_at(t))
let r = self.sane();
Tick(clamp_i64(frame_boundary(r, frame_index(r, t))))
}
/// Snap `t` to the nearest frame boundary.
pub fn snap_nearest(self, t: Tick) -> Tick {
let a = self.snap(t);
let b = self.tick_of(self.frame_at(t) + 1);
if (t - a) <= (b - t) { a } else { b }
let r = self.sane();
// Keep the frame index and both boundaries wide until choosing the nearest. Saturating
// an unrepresentable upper boundary first would make it seem closer than it really is.
let f = frame_index(r, t);
let a = frame_boundary(r, f);
let b = frame_boundary(r, f.saturating_add(1));
let t = i128::from(t.0);
Tick(clamp_i64(if t - a <= b - t { a } else { b }))
}
/// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976.
pub fn timecode_base(self) -> i64 {
let r = self.sane();
((r.num + r.den - 1) / r.den).max(1)
(r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1)
}
/// NTSC (x/1001) rates can use drop-frame timecode.
@ -286,7 +296,29 @@ impl fmt::Display for FrameRate {
}
}
fn gcd(mut a: i64, mut b: i64) -> i64 {
/// Narrow only after applying the storage bounds, never by a wrapping integer cast.
fn clamp_i64(value: i128) -> i64 {
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
}
/// Callers pass a positive rate. Products of two i64 values fit in i128.
fn frame_index(rate: FrameRate, t: Tick) -> i128 {
(i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den))
}
/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot
/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries
/// derived from frame_index remain within i128 (at most one frame away from an i64 tick).
fn frame_boundary(rate: FrameRate, frame: i128) -> i128 {
let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else {
return if frame < 0 { i128::MIN } else { i128::MAX };
};
let d = i128::from(rate.num);
// Flooring a fractional boundary puts the tick in the preceding frame.
n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)
}
fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
(a, b) = (b, a % b);
}
@ -329,8 +361,9 @@ fn df_params(rate: FrameRate) -> (i64, i64) {
/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`.
pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) {
let neg = frame < 0;
let mut n = frame.saturating_abs();
let mut n = i128::from(frame).abs();
let (drop, base) = df_params(rate);
let (drop, base) = (i128::from(drop), i128::from(base));
if drop_frame && rate.supports_drop_frame() {
let per_min = base * 60 - drop;
let per_10 = per_min * 10 + drop;
@ -345,18 +378,20 @@ pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool,
let s = (n / base) % 60;
let mi = (n / (base * 60)) % 60;
let h = n / (base * 3600);
(neg, h, mi, s, f)
(neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f))
}
/// Convert SMPTE fields back to a frame count.
pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 {
let (drop, base) = df_params(rate);
let mut n = h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(s).saturating_mul(base).saturating_add(f);
let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s);
// Nominal drop-frame labels can exceed i64 even when the final frame count fits.
let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f));
if drop_frame && rate.supports_drop_frame() {
let total_min = h.saturating_mul(60).saturating_add(m);
n = n.saturating_sub(drop.saturating_mul(total_min - total_min / 10));
let total_min = i128::from(h) * 60 + i128::from(m);
n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10));
}
n
clamp_i64(n)
}
/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame).
@ -377,7 +412,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
TimeDisplay::Feet35 | TimeDisplay::Feet16 => {
let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 };
let neg = frame < 0;
let a = frame.abs();
let a = frame.unsigned_abs();
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
}
TimeDisplay::AudioSamples => {
@ -511,6 +546,119 @@ mod tests {
}
}
#[test]
fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() {
let slow = FrameRate { num: 1, den: i64::MAX };
assert_eq!(slow.frame_duration(), Tick(i64::MAX));
assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX));
assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN));
assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating");
assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO);
let fast = FrameRate { num: i64::MAX, den: 1 };
assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly");
assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND));
assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX);
assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX);
assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN);
assert_eq!(fast.timecode_base(), i64::MAX);
for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] {
assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick");
assert_eq!(fast.snap_nearest(Tick(t)), Tick(t));
}
let balanced = FrameRate { num: i64::MAX, den: i64::MAX };
assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND));
assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND));
assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2);
assert_eq!(balanced.timecode_base(), 1);
assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 });
assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 });
assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 });
}
#[test]
fn scalar_time_conversions_saturate_at_storage_limits() {
assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX));
assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN));
assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX);
assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN);
assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX));
assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN));
assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX));
assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO);
assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX);
assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN);
assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX));
assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX));
}
#[test]
fn drop_frame_fields_keep_cancelling_terms_wide_at_storage_limits() {
for rate in [FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
for frame in [0, 1, 999_999, i64::MAX - 1, i64::MAX] {
let (negative, h, m, s, f) = frames_to_fields(frame, rate, true);
assert!(!negative);
assert_eq!(fields_to_frames(h, m, s, f, rate, true), frame, "{rate}: {frame}");
assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), -frame);
}
let (negative, h, m, s, f) = frames_to_fields(i64::MIN, rate, true);
assert!(negative);
assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), i64::MIN);
}
}
#[test]
fn tick_arithmetic_saturates_only_outside_storage_limits() {
let values = [i64::MIN, i64::MIN + 1, Tick::MIN.0, -1, 0, 1, Tick::MAX.0, i64::MAX - 1, i64::MAX];
for a in values {
assert_eq!(-Tick(a), Tick(clamp_i64(-i128::from(a))));
for b in values {
let sum = Tick(clamp_i64(i128::from(a) + i128::from(b)));
let difference = Tick(clamp_i64(i128::from(a) - i128::from(b)));
assert_eq!(Tick(a) + Tick(b), sum);
assert_eq!(Tick(a) - Tick(b), difference);
let mut assigned = Tick(a);
assigned += Tick(b);
assert_eq!(assigned, sum);
assigned = Tick(a);
assigned -= Tick(b);
assert_eq!(assigned, difference);
}
}
assert_eq!(Tick::MAX + Tick(1), Tick(Tick::MAX.0 + 1), "project bounds are not storage bounds");
assert_eq!(Tick::MIN - Tick(1), Tick(Tick::MIN.0 - 1));
assert_eq!(TimeRange::new(Tick(i64::MAX), Tick(1)).end(), Tick(i64::MAX));
assert_eq!(TimeRange::new(Tick(i64::MIN), Tick(-1)).end(), Tick(i64::MIN));
assert_eq!(TimeRange::from_bounds(Tick(i64::MIN), Tick(i64::MAX)).duration, Tick(i64::MAX));
assert_eq!(TimeRange::from_bounds(Tick(i64::MAX), Tick(i64::MIN)).duration, Tick(i64::MIN));
let ordinary = TimeRange::from_bounds(Tick::MIN, Tick::MAX);
assert_eq!(ordinary.end(), Tick::MAX);
}
#[test]
fn numeric_limits_are_total_across_frame_conversion_and_display() {
let values = [i64::MIN, -i64::MAX, -1, 0, 1, TICKS_PER_SECOND, i64::MAX];
for num in values {
for den in values {
let r = FrameRate { num, den };
let _ = FrameRate::new(num, den);
let _ = r.frame_duration();
assert!(r.timecode_base() >= 1);
for value in values {
let tick = Tick(value);
let _ = r.tick_of(value);
let _ = r.frame_at(tick);
let _ = r.snap(tick);
let _ = r.snap_nearest(tick);
for display in TimeDisplay::ALL {
let _ = format_time(tick, r, true, display, 48_000);
}
let (_, h, m, sec, f) = frames_to_fields(value, r, true);
let _ = fields_to_frames(h, m, sec, f, r, true);
}
}
}
}
#[test]
fn every_common_rate_is_exact() {
for r in FrameRate::COMMON {
@ -660,6 +808,18 @@ mod tests {
}
proptest! {
#[test]
fn extreme_frame_math_stays_monotonic(num in 1i64..=i64::MAX, den in 1i64..=i64::MAX, a in any::<i64>(), b in any::<i64>()) {
let r = FrameRate { num, den };
let (lo, hi) = (a.min(b), a.max(b));
prop_assert!(r.tick_of(lo) <= r.tick_of(hi));
prop_assert!(r.frame_at(Tick(lo)) <= r.frame_at(Tick(hi)));
prop_assert!(r.snap(Tick(lo)) <= r.snap(Tick(hi)));
prop_assert!(r.snap_nearest(Tick(lo)) <= r.snap_nearest(Tick(hi)));
let snapped = r.snap_nearest(Tick(a));
prop_assert_eq!(r.snap_nearest(snapped), snapped);
}
#[test]
fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) {
let r = FrameRate::COMMON[ri];

View File

@ -3,6 +3,18 @@
The egui frontend draws panels and dialogs, and dispatches project changes through the engine.
Interactive controls keep stable automation ids regardless of the interface language.
## Transition drop previews
Dragging a video or audio transition over a compatible clip shows its actual time span, the
selected cut and an In/Out label. Shared cuts center the span; isolated edges use a one-sided
span. The preview uses the engine's read-only transition planner, including Timeline duration
preferences, and release dispatches `effects.apply` with the same edge. Locked tracks and
incompatible clip types show no valid transition preview. Ordinary effects retain their clip
outline. `timeline.transitionDropPreview` exposes the span and edge to automation.
`transition_drop_ui` covers both edges of short video/audio clips, hover without project edits,
the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screenshots with wgpu.
## Localisation
Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's
@ -19,10 +31,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 covers menus, panels, dialogs, settings and registry labels. Searches accept both the
Spanish and Japanese 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; Japanese currently covers core menus and falls back to English elsewhere.
English; Brazilian Portuguese currently covers core menus and falls 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

View File

@ -973,6 +973,8 @@
Edit… Editar…
Show graphs Mostrar gráficos
Toggle animation Activar/desactivar animación
Eyedropper (pick a color from the Program monitor) Cuentagotas (tomar un color del monitor de programa)
Eyedropper: no picture to sample Cuentagotas: no hay imagen de la que tomar el color
Add/Remove Keyframe Añadir/quitar fotograma clave
Temporal Interpolation Interpolación temporal
Linear Lineal
@ -1031,6 +1033,7 @@
Corner Radius Radio de esquina
Polygon Sides Lados del polígono
Fill Relleno
Gradient Degradado
Stroke Trazo
Stroke 2 Trazo 2
Background Fondo
@ -1891,6 +1894,11 @@
Japanese Japonés
JetBrains Mono JetBrains Mono
Join Through Edits Unir ediciones pasantes
Ripple Trim In Recorte de rizo de entrada
Ripple Trim Out Recorte de rizo de salida
Roll Edit Edición de rodillo
Trim In Recortar entrada
Trim Out Recortar salida
Kaleidoscope Caleidoscopio
Keep a recovery copy of unsaved changes Mantener una copia de recuperación de los cambios sin guardar
Key Color Color clave
@ -3480,3 +3488,5 @@
New Sequence Nueva secuencia
Tracks Pistas
tracks pistas
In Entrada
Out Salida

Can't render this file because it contains an unexpected character in line 3 and column 95.

File diff suppressed because it is too large Load Diff

View File

@ -368,7 +368,8 @@ mod tests {
if p.is_dir() {
dirs.push(p);
} else if p.extension().is_some_and(|e| e == "rs") {
let text = std::fs::read_to_string(&p).unwrap_or_default();
// a Windows checkout with core.autocrlf has CRLF, which the `\n` below would not match
let text = std::fs::read_to_string(&p).unwrap_or_default().replace("\r\n", "\n");
let cut = text.find("#[cfg(test)]\nmod tests").unwrap_or(text.len());
out.push((p.display().to_string(), text[..cut].to_string()));
}
@ -474,6 +475,7 @@ mod tests {
}
}
crate::panels::timeline::CLIP_MENU.iter().flat_map(|g| g.iter()).for_each(|(l, _)| push(&mut out, l));
crate::panels::timeline::EDIT_POINT_TYPES.iter().for_each(|(l, ..)| push(&mut out, l));
crate::panels::project::NEW_ITEMS.iter().for_each(|(l, _)| push(&mut out, l));
// section headers keyed by their English name (collapsed state), translated when drawn
let sections = [

View File

@ -86,6 +86,7 @@
Create Multi-Camera Source Sequence… Criar sequência de origem multicâmera…
Create Proxies… Criar proxies…
Cut Recortar
Dark Escuro
Darkest Mais escuro
Delete Render Files Excluir arquivos de renderização
Delete Render Files In to Out Excluir arquivos de renderização da entrada à saída
@ -203,7 +204,6 @@
Media Browser Navegador de mídia
Media Cache… Cache de mídia…
Media… Mídia…
Medium Médio
Memory… Memória…
Merge Clips… Mesclar clipes…
Metadata Metadados

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -114,6 +114,8 @@ pub enum Icon {
Network,
/// Media Browser: recent directories (a clock face).
Clock,
/// Colour parameters: pick a colour from the Program monitor (a pipette).
Eyedropper,
}
pub struct Pen16<'a> {
@ -725,6 +727,13 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.circle(8.0, 8.0, 6.0);
pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]);
}
Eyedropper => {
// a pipette: bulb at the top right, glass tube running to a tip at the bottom left
pen.closed(&[(10.0, 4.5), (11.5, 3.0), (13.0, 3.0), (13.0, 4.5), (11.5, 6.0)]);
pen.line(&[(8.2, 6.2), (10.8, 8.8)]);
pen.line(&[(9.5, 7.5), (3.5, 12.5)]);
pen.line(&[(3.5, 12.5), (2.5, 13.5)]);
}
}
}
@ -853,6 +862,7 @@ mod tests {
Drive,
Network,
Clock,
Eyedropper,
];
/// Paint `icon` at `ppp` device pixels per point into a rect of `size` points whose corner is

View File

@ -46,6 +46,7 @@ pub mod panels;
pub mod perf;
#[cfg(not(target_arch = "wasm32"))]
pub mod play_ahead;
pub mod scrub;
pub mod source_playback;
pub mod state;
pub mod theme;
@ -204,9 +205,10 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
/// timeline zoom animation finished) before capturing what is there.
const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0;
/// File ▸ Export entries that run from the menus through the save panel: command, filter label,
/// Export entries that run from the menus through the save panel: command, filter label,
/// extension.
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [
("markers.exportCsv", "Marker report (CSV)", "csv"),
("file.exportEdl", "EDL", "edl"),
("file.exportFcp7Xml", "Final Cut Pro XML", "xml"),
("file.exportFcpxml", "FCPXML", "fcpxml"),
@ -223,6 +225,8 @@ pub struct FilmcraftApp {
pub playback: Playback,
pub source_playback: source_playback::SourcePlayback,
pub audio: Option<Box<dyn AudioOut>>,
/// Audio during scrubbing (#211).
pub scrub: scrub::ScrubAudio,
pub hooks: HostHooks,
pub dialog: Option<Dialog>,
pub file_dialogs: panels::file_dialogs::FileDialogState,
@ -426,6 +430,7 @@ impl FilmcraftApp {
playback: Playback { speed: 1.0, ..Default::default() },
source_playback: Default::default(),
audio: None,
scrub: Default::default(),
hooks: HostHooks::default(),
// Unsaved changes left by a session that died are offered first thing.
dialog: recovery.then_some(Dialog::Recovery),
@ -1351,6 +1356,7 @@ impl FilmcraftApp {
self.handle_shortcuts(&ctx);
self.advance_playback(&ctx);
self.advance_source_playback(&ctx);
self.scrub_audio(&ctx);
let t = self.tokens;
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.app_bg);

View File

@ -90,8 +90,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("multicam.editCamerasDialog", "Edit Cameras…", [], None),
uic!("voiceover.recordToggle", "Voice-over Record", [], None),
uic!("voiceover.settingsDialog", "Voice-Over Record Settings…", [], None),
// ids follow `ThemeKind`, labels follow Settings ▸ Appearance ▸ Color Theme (`darkest`, `dark`, `light`)
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None),
uic!("view.theme.medium", "Medium", ["View", "Appearance"], None),
uic!("view.theme.medium", "Dark", ["View", "Appearance"], None),
uic!("view.theme.light", "Light", ["View", "Appearance"], None),
uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")),
uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")),
@ -310,8 +311,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
let view = filmcraft_engine::clip_ops::source_view(&app.session, item).ok_or("Source clip is unavailable")?;
let count = if id.ends_with('5') { i64::from(app.session.prefs.playback.step_many_frames) } else { 1 };
let direction = if id.contains("Back") { -1 } else { 1 };
let delta = view.rate.frame_duration().0.saturating_mul(count).saturating_mul(direction);
let time = filmcraft_time::Tick(app.session.state.source_playhead.0.saturating_add(delta));
let time = if view.rate.frame_duration().0 == 0 {
app.session.state.source_playhead
} else {
let frame = view.rate.frame_at(app.session.state.source_playhead).saturating_add(count.saturating_mul(direction));
view.rate.tick_of(frame)
};
return app.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string());
}
"playback.forward" => {
@ -695,13 +700,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| {
for top in MENUS {
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
ui.menu_button(app.ui.language.tr(top), |ui| {
let r = ui.menu_button(app.ui.language.tr(top), |ui| {
ui.set_min_width(260.0);
if mine.is_empty() {
ui.add_enabled(false, egui::Button::new(tl!("(empty)")));
}
menu_level(ui, &mine, 1, &mut clicked);
menu_level(ui, &mine, 1, &mut clicked, &mut app.auto);
});
app.auto.add(&format!("menu.{top}"), r.response.rect, top);
}
});
if let Some(id) = clicked {
@ -711,7 +717,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
/// One menu level: items whose path ends here, and a submenu (at its first item's position) for
/// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation).
fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, auto: &mut crate::automation::Registry) {
let mut subs: Vec<&str> = Vec::new();
for it in items {
if let Some(sub) = it.path.get(depth).map(String::as_str) {
@ -723,16 +729,16 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu
let language = ui.ctx().data(|d| d.get_temp::<crate::i18n::Language>(egui::Id::new("interface-language"))).unwrap_or_default();
ui.menu_button(language.tr(sub), |ui| {
ui.set_min_width(220.0);
menu_level(ui, &inner, depth + 1, clicked);
menu_level(ui, &inner, depth + 1, clicked, auto);
});
} else if menu_entry(ui, it) {
} else if menu_entry(ui, it, auto) {
*clicked = Some(it.id.clone());
ui.close();
}
}
}
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool {
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool {
// checkable items leave room for a checkmark drawn at the left
let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() };
let mut b = egui::Button::new(label);
@ -740,6 +746,7 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool {
b = b.shortcut_text(shortcut_text(s));
}
let r = ui.add_enabled(it.enabled, b);
auto.add(&format!("menu.{}", it.id), r.rect, &it.label);
if it.checked == Some(true) {
let c = r.rect.left_center() + egui::vec2(10.0, 0.0);
let col = ui.visuals().text_color();

View File

@ -253,10 +253,10 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) -
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
let p = ui.painter_at(r);
let t = &ed.t;
p.rect_filled(r, 2.0, Color32::from_gray(0x18));
p.rect_filled(r, 2.0, ed.t.plot_bg);
for f in [50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0] {
let x = x_of(f, r);
p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_gray(0x2c)));
p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, ed.t.plot_grid));
let label = if f >= 1000.0 { format!("{}k", f / 1000.0) } else { format!("{f}") };
p.text(pos2(x + 2.0, r.max.y - 2.0), Align2::LEFT_BOTTOM, label, Tokens::ui(9.0), t.text_faint);
}
@ -264,7 +264,7 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) -
let mut d = -range;
while d <= range + 1e-9 {
let y = y_of(d, r, range);
let col = if d.abs() < 1e-9 { Color32::from_gray(0x44) } else { Color32::from_gray(0x2c) };
let col = if d.abs() < 1e-9 { ed.t.plot_axis } else { ed.t.plot_grid };
p.line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, col));
p.text(pos2(r.min.x + 2.0, y - 1.0), Align2::LEFT_BOTTOM, format!("{d:+.0}"), Tokens::ui(9.0), t.text_faint);
d += step;
@ -310,7 +310,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) {
let id = egui::Id::new(("peq-node", ed.target.clone(), pre));
let resp = ui.interact(nr, id, Sense::click_and_drag());
let on = ed.on(&on_id);
let col = if on { Color32::from_rgb(0xe8, 0xe8, 0xe8) } else { Color32::from_gray(0x70) };
let col = if on { ed.t.eq_node } else { ed.t.eq_node_off };
ui.painter().circle_stroke(c, 7.0, Stroke::new(1.5, col));
ui.painter().text(c, Align2::CENTER_CENTER, label, Tokens::ui(8.5), col);
ed.auto(format!("fxEditor.{}.node.{pre}", ed.fx), nr, label);
@ -374,14 +374,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) {
let pid = format!("b{}", i + 1);
let x = area.min.x + colw * (i as f32 + 0.5);
let track = Rect::from_center_size(pos2(x, area.min.y + 70.0), vec2(6.0, 130.0));
ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x30));
ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, Color32::from_gray(0x60)));
ui.painter().rect_filled(track, 2.0, ed.t.eq_track);
ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, ed.t.eq_tick));
let v = ed.v(&pid);
let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5;
let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0));
let id = egui::Id::new(("geq-band", ed.target.clone(), i));
let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag());
ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { Color32::from_gray(0xf0) } else { Color32::from_gray(0xc0) });
ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob });
ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim);
let short = label.replace(" Hz", "").replace(" kHz", "k");
ui.painter().text(pos2(x, area.max.y - 8.0), Align2::CENTER_CENTER, short, Tokens::ui(8.5), ed.t.text_faint);
@ -420,14 +420,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) {
fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: String) {
let (r, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 2.0, Color32::from_gray(0x18));
p.rect_filled(r, 2.0, ed.t.plot_bg);
let lo = -60.0;
let map = |x: f64, y: f64| pos2(r.min.x + ((x - lo) / -lo) as f32 * r.width(), r.max.y - (((y - lo) / -lo).clamp(-0.05, 1.05)) as f32 * r.height());
for d in [-48.0, -36.0, -24.0, -12.0] {
p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, Color32::from_gray(0x2c)));
p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, Color32::from_gray(0x2c)));
p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, ed.t.plot_grid));
p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, ed.t.plot_grid));
}
p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, Color32::from_gray(0x44)));
p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, ed.t.plot_axis));
if let Some(dsp) = filmcraft_render::audio_fx::configured(&ed.preview(), ed.mt, 48000) {
let pts: Vec<Pos2> = (0..=120)
.filter_map(|i| {
@ -447,7 +447,7 @@ fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: S
fn multiband(ui: &mut egui::Ui, ed: &mut Ed) {
// Spectrum strip with draggable crossover handles.
let (r, _) = ui.allocate_exact_size(vec2(640.0, 70.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, Color32::from_gray(0x18));
ui.painter().rect_filled(r, 2.0, ed.t.plot_bg);
let cols =
[Color32::from_rgb(0x3d, 0x6e, 0xb4), Color32::from_rgb(0x3d, 0xa0, 0x6e), Color32::from_rgb(0xb4, 0x9a, 0x3d), Color32::from_rgb(0xb4, 0x4f, 0x3d)];
let xo = [ed.v("xo1"), ed.v("xo2"), ed.v("xo3")];
@ -461,7 +461,8 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) {
let x = edges[k + 1];
let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height()));
let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag());
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, Color32::WHITE));
ui.painter()
.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle));
ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim);
ed.auto(format!("fxEditor.{}.{pid}", ed.fx), hr, "Crossover");
if resp.dragged()

View File

@ -3,7 +3,7 @@
//! keyframe lane on the right. Also hosts the Lumetri Color panel body (same editor, grouped).
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem};
use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem, TrackKind};
use filmcraft_time::Tick;
use serde_json::{Value, json};
@ -28,24 +28,37 @@ fn timeline_time_of(it: &TrackItem, m: Tick) -> Tick {
it.start + Tick(((m - it.source_in).0 as f64 / it.speed.abs().max(1e-6)) as i64)
}
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, filmcraft_project::TrackKind)> {
let seq = app.session.active_sequence()?;
let mut best = None;
for c in &app.session.state.selection {
if let Some((tid, it)) = seq.find_item(*c) {
let kind = seq.track(tid)?.kind;
if kind == filmcraft_project::TrackKind::Video {
return Some((*c, it.clone(), kind));
}
best.get_or_insert((*c, it.clone(), kind));
}
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> {
selected_clips(app).into_iter().next()
}
/// The selected clip (video first) and, for a video clip, the selected audio linked to it.
fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> {
let Some(seq) = app.session.active_sequence() else { return Vec::new() };
let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app
.session
.state
.selection
.iter()
.filter_map(|c| {
let (tid, it) = seq.find_item(*c)?;
Some((*c, it, seq.track(tid)?.kind))
})
.collect();
let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() };
let mut out = vec![(clip, it.clone(), kind)];
if kind == TrackKind::Video
&& let Some(&(ac, ai, ak)) = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link)
{
out.push((ac, ai.clone(), ak));
}
best
out
}
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some((clip, it, kind)) = selected_clip(app) else {
let clips = selected_clips(app);
let Some((clip, it, _)) = clips.first().cloned() else {
crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)"));
return;
};
@ -57,7 +70,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0));
// Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active)
let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| {
ui.painter().rect_filled(r, 4.0, if active { Color32::from_rgb(0x3a, 0x3a, 0x3a) } else { t.panel_bg });
ui.painter().rect_filled(r, 4.0, if active { t.pill_active_bg } else { t.panel_bg });
if !active {
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
}
@ -78,7 +91,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H));
paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t);
let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H));
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, Color32::from_rgb(58, 58, 70));
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg);
ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text);
// click or drag anywhere on the ruler (or the bar) to move the playhead
let scrub = Rect::from_min_max(ruler.min, bar.max);
@ -97,90 +110,93 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut actions: Vec<(String, Value)> = Vec::new();
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body"));
bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y)));
let mt_now = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
let heading = if kind == filmcraft_project::TrackKind::Video { tl!("Video") } else { tl!("Audio") };
bui.painter().text(pos2(body.min.x + 8.0, body.min.y + 8.0), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim);
bui.add_space(18.0);
// 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 scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| {
for idx in order {
let e = &it.effects[idx];
let Some(def) = e.def() else { continue };
let key = format!("{}:{}", clip.0, idx);
let open = !app.ui.collapsed_fx.contains(&key);
let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click());
if resp.hovered() {
bui.painter().rect_filled(r, 0.0, t.hover);
}
icons::paint(
bui.painter(),
Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)),
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_dim,
);
// fx enable toggle
let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0));
let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click());
bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint });
if !e.enabled {
bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint));
}
if fxresp.clicked() {
actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx})));
}
bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text);
// reset button
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
if rresp.clicked() {
actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx})));
}
app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name);
if resp.clicked() && !fxresp.clicked() && !rresp.clicked() {
if open {
app.ui.collapsed_fx.push(key.clone());
} else {
app.ui.collapsed_fx.retain(|k| *k != key);
for (clip, it, kind) in &clips {
let clip = *clip;
let (hr, _) = bui.allocate_exact_size(vec2(body.width(), 18.0), Sense::hover());
let heading = if *kind == TrackKind::Video { tl!("Video") } else { tl!("Audio") };
bui.painter().text(pos2(hr.min.x + 8.0, hr.center().y), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim);
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
// 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));
for idx in order {
let e = &it.effects[idx];
let Some(def) = e.def() else { continue };
let key = format!("{}:{}", clip.0, idx);
let open = !app.ui.collapsed_fx.contains(&key);
let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click());
if resp.hovered() {
bui.painter().rect_filled(r, 0.0, t.hover);
}
}
let mut save_preset = false;
let fx_id = e.effect.clone();
resp.context_menu(|ui| {
let sp = ui.button(tl!("Save Preset…"));
app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…");
if sp.clicked() {
save_preset = true;
ui.close();
icons::paint(
bui.painter(),
Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)),
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_dim,
);
// fx enable toggle
let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0));
let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click());
bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint });
if !e.enabled {
bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint));
}
if !def.intrinsic && ui.button(tl!("Clear")).clicked() {
actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx})));
ui.close();
if fxresp.clicked() {
actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx})));
}
});
if save_preset {
crate::panels::presets::open_save(app, clip.0, vec![idx], def.name);
}
if !open {
continue;
}
if crate::panels::audio_fx_editor::has_editor(&e.effect) {
custom_setup_row(app, bui, body, clip, idx, &e.effect);
}
for pd in &def.params {
param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, &it);
if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id))
&& let Some(param) = e.params.get(pd.id)
&& param.is_animated()
&& matches!(param.value, ParamValue::Float(_))
{
graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, &it, &mut actions);
bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text);
// reset button
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
if rresp.clicked() {
actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx})));
}
app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name);
if resp.clicked() && !fxresp.clicked() && !rresp.clicked() {
if open {
app.ui.collapsed_fx.push(key.clone());
} else {
app.ui.collapsed_fx.retain(|k| *k != key);
}
}
let mut save_preset = false;
let fx_id = e.effect.clone();
resp.context_menu(|ui| {
let sp = ui.button(tl!("Save Preset…"));
app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…");
if sp.clicked() {
save_preset = true;
ui.close();
}
if !def.intrinsic && ui.button(tl!("Clear")).clicked() {
actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx})));
ui.close();
}
});
if save_preset {
crate::panels::presets::open_save(app, clip.0, vec![idx], def.name);
}
if !open {
continue;
}
if crate::panels::audio_fx_editor::has_editor(&e.effect) {
custom_setup_row(app, bui, body, clip, idx, &e.effect);
}
for pd in &def.params {
param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it);
if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id))
&& let Some(param) = e.params.get(pd.id)
&& param.is_animated()
&& matches!(param.value, ParamValue::Float(_))
{
graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions);
}
}
if crate::panels::masks::maskable(e) {
crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, it);
}
}
if crate::panels::masks::maskable(e) {
crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, &it);
}
}
});
@ -466,6 +482,14 @@ pub(crate) fn param_row(
if egui::color_picker::color_edit_button_rgba(&mut vui, &mut rgba, egui::color_picker::Alpha::Opaque).changed() {
set = Some(json!([rgba.r(), rgba.g(), rgba.b(), rgba.a()]));
}
// Eyedropper: arm it, then click a pixel in the Program monitor (Esc cancels)
let target = crate::state::Eyedropper { clip: clip.0, effect: idx, param: pd.id.to_string(), mask };
let armed = app.ui.eyedropper.as_ref() == Some(&target);
let drop = icons::button(&mut vui, Icon::Eyedropper, 18.0, armed, &t, tl!("Eyedropper (pick a color from the Program monitor)"));
app.auto.add(&format!("effectControls.{}.{}.eyedropper", e.effect, pkey), drop.rect, "Eyedropper");
if drop.clicked() {
app.ui.eyedropper = if armed { None } else { Some(target) };
}
}
(ParamKind::Path, _) => {
crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions);
@ -659,7 +683,8 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
/// Properties panel (Premiere 26): a compact inspector for the selected clip.
pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some((clip, it, kind)) = selected_clip(app) else {
let clips = selected_clips(app);
let Some((clip, it, kind)) = clips.first().cloned() else {
crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to see its properties"));
return;
};
@ -709,7 +734,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id);
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
if kind == filmcraft_project::TrackKind::Video {
if kind == TrackKind::Video {
if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) {
for (label, p, unit, speed, range) in [
(tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)),
@ -780,7 +805,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
properties_nav(app, &mut bui, r, clip, &it, mt, "crop", p, &mut actions);
}
}
} else if section(&mut bui, app, "Audio", eff_idx("volume").map(|i| (i, clip.0)), &mut actions) {
}
if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio)
&& section(&mut bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions)
{
let clip = *clip;
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
let r = row(&mut bui, tl!("Level"));
let mut vui = bui.new_child(
egui::UiBuilder::new()
@ -792,7 +823,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(nv) = nv {
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv})));
}
properties_nav(app, &mut bui, r, clip, &it, mt, "volume", "level", &mut actions);
properties_nav(app, &mut bui, r, clip, it, mt, "volume", "level", &mut actions);
let r = row(&mut bui, tl!("Pan"));
let mut vui = bui.new_child(
egui::UiBuilder::new()
@ -892,10 +923,10 @@ pub(crate) fn graph_rows(
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);
let p = ui.painter();
p.rect_filled(area, 0.0, Color32::from_rgb(0x19, 0x19, 0x19));
p.rect_filled(area, 0.0, t.keyframe_plot_bg);
for g in 1..4 {
let y = area.min.y + area.height() * g as f32 / 4.0;
p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)));
p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, t.keyframe_plot_grid));
}
// range labels in the property column
let dec = if let ParamKind::Float { decimals, .. } = pd.kind { decimals as usize } else { 1 };
@ -909,9 +940,9 @@ pub(crate) fn graph_rows(
let vel: Vec<f64> = (0..n).map(|i| (vals[i + 1] - vals[i]) / secs.max(1e-9)).collect();
let vmax = vel.iter().fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15;
let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0));
p.rect_filled(varea, 0.0, Color32::from_rgb(0x19, 0x19, 0x19));
p.rect_filled(varea, 0.0, t.keyframe_plot_bg);
let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0;
p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, Color32::from_rgb(0x33, 0x33, 0x33)));
p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis));
let vline: Vec<Pos2> = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect();
p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40))));
p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim);
@ -944,8 +975,8 @@ pub(crate) fn graph_rows(
let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0);
let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg);
let hp = pos2(hx, c.y);
p.line_segment([c, hp], Stroke::new(1.0, Color32::from_rgb(0x90, 0x90, 0x90)));
p.circle_filled(hp, 3.5, Color32::from_rgb(0xd0, 0xd0, 0xd0));
p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim));
p.circle_filled(hp, 3.5, t.keyframe_handle);
let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag());
if hr.dragged() {
let nx = hdx + hr.drag_delta().x;
@ -958,7 +989,7 @@ pub(crate) fn graph_rows(
actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni}))));
}
}
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { Color32::from_rgb(0xe0, 0xe0, 0xe0) });
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle });
if resp.dragged() {
let ny = dy + resp.drag_delta().y;
ui.data_mut(|d| d.insert_temp(id, ny));

View File

@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (on, label) in [(d.yuv, "YUV"), (d.float32, "32"), (d.accelerated, "⚡")] {
if on {
let br = Rect::from_min_size(pos2(bx - 22.0, r.min.y + 3.0), vec2(20.0, 14.0));
ui.painter().rect_filled(br, 2.0, Color32::from_rgb(48, 48, 48));
ui.painter().rect_filled(br, 2.0, t.field_border);
ui.painter().text(br.center(), Align2::CENTER_CENTER, label, Tokens::ui(8.5), t.text_dim);
bx -= 24.0;
}

View File

@ -360,7 +360,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) {
br,
4.0,
if sel {
Color32::from_gray(0x4b)
cx.t.pressed
} else if resp.hovered() {
t.hover
} else {
@ -387,7 +387,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) {
br,
4.0,
if sel {
Color32::from_gray(0x4b)
cx.t.pressed
} else if resp.hovered() {
t.hover
} else {
@ -467,11 +467,11 @@ fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: &
let p = ui.painter();
let rad = r.height() / 2.0;
if on {
p.rect_filled(r, rad, Color32::from_gray(0xd4));
p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, Color32::from_gray(0x1d));
p.rect_filled(r, rad, cx.t.switch_fill);
p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, cx.t.switch_knob);
} else {
p.rect_stroke(r, rad, Stroke::new(1.5, Color32::from_gray(if resp.hovered() { 0xb0 } else { 0x8a })), StrokeKind::Inside);
p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, Color32::from_gray(0xd4)));
p.rect_stroke(r, rad, Stroke::new(1.5, if resp.hovered() { cx.t.control_handle_dim } else { cx.t.switch_border }), StrokeKind::Inside);
p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, cx.t.switch_fill));
}
cx.auto.add(id, r, label);
resp.clicked()
@ -521,7 +521,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64,
let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32;
let kx = track.min.x + f * track.width();
let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag());
let line = if active { Color32::from_gray(0x8a) } else { Color32::from_gray(0x4a) };
let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track };
ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line));
ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line));
ui.painter().circle_filled(pos2(kx, y), 6.0, t.panel_bg);
@ -533,9 +533,9 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64,
if resp.dragged() {
t.hot_text
} else if active {
Color32::from_gray(0xd4)
cx.t.switch_fill
} else {
Color32::from_gray(0x6a)
cx.t.slider_disabled_knob
},
),
);
@ -587,7 +587,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us
br,
4.0,
if on {
Color32::from_gray(0x4b)
cx.t.pressed
} else if resp.hovered() && active {
t.hover
} else {
@ -605,8 +605,8 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us
fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool {
let t = cx.t;
let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click());
ui.painter().rect_filled(r, 4.0, if resp.hovered() { Color32::from_gray(0x2a) } else { t.field_bg });
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_gray(0x4b)), StrokeKind::Inside);
ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg });
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside);
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text);
cx.auto.add(id, r, text);
resp.clicked()

View File

@ -1536,8 +1536,22 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---- appearance
if section(ui, app, "Appearance", &t) {
let (_, mut vui) = cx.row(ui, tl!("Fill"));
cx.check(&mut vui, "fill", "");
let fill_on = cx.check(&mut vui, "fill", "");
cx.color(&mut vui, "fill_color");
if fill_on {
vui.add_space(6.0);
cx.choice(&mut vui, "fill_kind", graphic::FILL_KIND_OPTS, 130.0);
}
let fill_kind = match pv(e, "fill_kind", mt) {
ParamValue::Choice(c) => c,
_ => 0,
};
if fill_on && fill_kind == 1 {
let (_, mut vui) = cx.row(ui, tl!("Gradient"));
cx.color(&mut vui, "gradient_start");
cx.color(&mut vui, "gradient_end");
cx.number(ui, tl!(" Angle"), "gradient_angle", 0.5, (-3600.0, 3600.0), 0, " °");
}
for (on, col, w, kind, label) in [
("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")),
("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")),

View File

@ -478,7 +478,8 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result<Value, String> {
let n = words.len();
let paras = filmcraft_edit::transcript::paragraphs(&words, filmcraft_time::Tick::from_seconds_f64(1.5));
let para_of = |i: usize| paras.iter().position(|p| p.contains(&i)).unwrap_or(0);
let (anchor, cur) = app.ui.transcript_sel.unwrap_or_else(|| {
// a selection left over from a longer transcript (e.g. after `transcript.extract` over the control channel) counts as none, as when drawing
let (anchor, cur) = app.ui.transcript_sel.filter(|(a, b)| *a < n && *b < n).unwrap_or_else(|| {
let i = filmcraft_edit::transcript::word_at(&words, app.session.playhead()).unwrap_or(0);
(i, i)
});
@ -513,3 +514,32 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result<Value, String> {
app.session.set_playhead(words[to].start);
Ok(json!({"selection": [anchor.min(to), anchor.max(to)], "word": to}))
}
#[cfg(test)]
mod transcript_selection_tests {
use serde_json::{Value, json};
#[test]
fn prev_word_after_the_transcript_shrank_under_the_selection_does_not_panic() {
let mut session = filmcraft_engine::Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let a = session.active_sequence().unwrap().audio_tracks[0].items[0].clone();
let tk = |s: f64| a.source_in.0 + (s * filmcraft_time::TICKS_PER_SECOND as f64) as i64;
let words: Vec<Value> = ["one", "two", "three", "four"]
.iter()
.enumerate()
.map(|(i, w)| json!({"text": w, "start": tk(0.5 + i as f64 * 0.5), "end": tk(0.9 + i as f64 * 0.5)}))
.collect();
session.execute("transcript.set", json!({"item": a.item.0, "transcript": {"language": "en", "words": words}})).unwrap();
let mut app = crate::FilmcraftApp::new(session);
app.ui.text_tab = "Transcript".into();
app.ui.transcript_sel = Some((3, 3));
// the transcript shrinks behind the panel's back, as with `transcript.extract` over the control channel
app.session.execute("transcript.extract", json!({"from": 0, "to": 1})).unwrap();
assert_eq!(filmcraft_engine::transcript::sequence_words(&app.session).len(), 2);
let r = super::text_panel(&mut app, "prevWord").unwrap();
let (sa, sb) = app.ui.transcript_sel.unwrap();
assert!(sa < 2 && sb < 2, "selection {sa}..{sb} outside the 2-word transcript");
assert_eq!(r["word"], json!(sb));
}
}

View File

@ -161,6 +161,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
let accent = app.tokens.accent;
let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone());
let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p));
let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0);
egui::Window::new(tl!("Link Media"))
.id(egui::Id::new("Link Media"))
.collapsible(false)
@ -170,24 +171,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
.show(ctx, |ui| {
ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len()));
ui.add_space(6.0);
egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| {
for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for (k, (_, name, fname, path, status)) in rows.iter().enumerate() {
let r = ui.selectable_label(d.row == k, name);
push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone());
if r.clicked() && d.row != k {
d.row = k;
d.candidates.clear();
d.candidate = None;
egui::ScrollArea::vertical().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| {
for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] {
ui.label(RichText::new(h).strong());
}
ui.label(fname);
ui.label(RichText::new(path).small());
ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a)));
ui.end_row();
}
for (k, (_, name, fname, path, status)) in rows.iter().enumerate() {
let r = ui.selectable_label(d.row == k, name);
push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone());
if r.clicked() && d.row != k {
d.row = k;
d.candidates.clear();
d.candidate = None;
}
ui.label(fname);
ui.label(RichText::new(path).small());
ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a)));
ui.end_row();
}
});
});
ui.separator();
ui.horizontal(|ui| {
@ -234,24 +237,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
if d.candidates.is_empty() {
ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak());
}
for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() {
let mark = match (ok, idm) {
(true, Some(true)) => tl!("✔ same file"),
(true, _) => tl!("✔ matches"),
(false, Some(false)) => tl!("✖ different file"),
_ => "✖",
};
let text = format!("{mark} {path}");
let r = ui.selectable_label(
d.candidate == Some(k),
RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }),
);
let r = if problems.is_empty() { r } else { r.on_hover_text(problems) };
push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text);
if r.clicked() {
d.candidate = Some(k);
egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| {
for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() {
let mark = match (ok, idm) {
(true, Some(true)) => tl!("✔ same file"),
(true, _) => tl!("✔ matches"),
(false, Some(false)) => tl!("✖ different file"),
_ => "✖",
};
let text = format!("{mark} {path}");
let r = ui.selectable_label(
d.candidate == Some(k),
RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }),
);
let r = if problems.is_empty() { r } else { r.on_hover_text(problems) };
push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text);
if r.clicked() {
d.candidate = Some(k);
}
}
}
});
});
let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12));

View File

@ -61,7 +61,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
(l.momentary(), l.short_term(), l.integrated(), l.true_peak_dbtp())
});
let lr = Rect::from_min_max(pos2(rect.min.x + 4.0, rect.max.y - lufs_h), pos2(rect.max.x - 4.0, rect.max.y - 4.0));
ui.painter().rect_filled(lr, 2.0, egui::Color32::from_rgb(0x14, 0x14, 0x14));
ui.painter().rect_filled(lr, 2.0, t.meter_readout_bg);
let rows = [("M", lufs_text(m)), ("S", lufs_text(s)), ("I", lufs_text(i)), ("TP", lufs_text(tp))];
for (k, (label, val)) in rows.iter().enumerate() {
let y = lr.min.y + 8.0 + k as f32 * 14.0;
@ -84,7 +84,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("audioMeters.loudness", lr, &format!("M {} S {} I {} LUFS TP {} dBTP", lufs_text(m), lufs_text(s), lufs_text(i), lufs_text(tp)));
}
ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, egui::Color32::BLACK);
ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, t.meter_bg);
let w = ((area.width() - 4.0 * (nch as f32 - 1.0)) / nch as f32).max(3.0);
for c in 0..nch {
let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height()));

View File

@ -128,7 +128,7 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap<u64, Vec<[f
/// One bar per channel (level, peak hold) with a clip light on top; 5.1 meters carry channel names.
fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Tokens) {
let p = ui.painter();
p.rect_filled(r, 0.0, Color32::BLACK);
p.rect_filled(r, 0.0, t.meter_bg);
let n = channels.max(1);
let gap = if n > 2 { 1.0 } else { 2.0 };
let w = (r.width() - gap * (n as f32 - 1.0)) / n as f32;
@ -138,7 +138,7 @@ fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Toke
crate::widgets::meter_bar(p, br, v[0], v[1], t);
// clip light
let lr = Rect::from_min_size(pos2(br.min.x, r.min.y), vec2(w, 4.0));
p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { Color32::from_gray(0x30) });
p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { t.meter_clip_off });
}
}
@ -305,7 +305,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ky = bar.min.y + (bar.height() - kh) * voff / max_voff;
let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh));
let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag());
ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) });
ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob });
if kr.dragged() {
voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff);
}
@ -319,7 +319,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
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 { Color32::from_gray(0x5a) });
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);
}
@ -369,7 +369,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
let ap = format!("mixer.{label}");
let is_master = id == MASTER_STRIP;
let sid = json!(id.0);
ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24));
ui.painter().rect_filled(sr, 3.0, t.control_surface);
ui.painter().line_segment([pos2(sr.max.x + 1.0, sr.min.y), pos2(sr.max.x + 1.0, sr.max.y)], Stroke::new(1.0, t.separator));
let x = sr.min.x;
let w = sr.width();
@ -581,7 +581,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
let kc = pos2(sr.center().x, y + 20.0);
let kr = Rect::from_center_size(kc, vec2(38.0, 38.0));
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)"));
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) };
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim };
ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring));
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring));
@ -690,16 +690,16 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
small_text(ui, pos2(x + 26.0, yy), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim);
ui.painter().line_segment([pos2(x + 28.0, yy), pos2(x + 31.0, yy)], Stroke::new(1.0, t.text_faint));
}
ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10));
ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_gray(0x50)), StrokeKind::Inside);
ui.painter().rect_filled(track, 2.0, t.control_well);
ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, t.control_border), StrokeKind::Inside);
let vol = shown(app, &tr, LANE_VOLUME, cx.now);
let cy = ypos(vol);
let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0));
let fresp =
ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)"));
let held = app.session.previews.live.get(id, LANE_VOLUME).is_some();
let col = if fresp.dragged() || held { t.accent } else { Color32::from_gray(0xe0) };
ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a));
let col = if fresp.dragged() || held { t.accent } else { t.control_handle };
ui.painter().rect_filled(cap, 3.0, t.control_handle_bg);
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, col), StrokeKind::Inside);
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, col));
app.auto.add(&format!("{ap}.fader"), cap, &format!("Volume {}", db_text(vol)));
@ -769,14 +769,14 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr
let resp =
ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)"));
let p = ui.painter();
p.rect_filled(sq, 3.0, Color32::from_gray(0x16));
p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { Color32::from_gray(0x60) }), StrokeKind::Inside);
p.rect_filled(sq, 3.0, t.panner_bg);
p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside);
let inner = sq.shrink(5.0);
let to_pos =
|x: f64, y: f64| pos2(inner.center().x + (x / 100.0) as f32 * inner.width() / 2.0, inner.center().y - (y / 100.0) as f32 * inner.height() / 2.0);
// speakers
for (x, y) in [(-100.0, 100.0), (0.0, 100.0), (100.0, 100.0), (-100.0, -100.0), (100.0, -100.0)] {
p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, Color32::from_gray(0x90));
p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, t.panner_speaker);
}
let puck = to_pos(px, py);
p.circle_filled(puck, 4.0, if resp.dragged() { t.accent } else { t.hot_text });
@ -900,7 +900,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0));
let label = format!("A{}", i + 1);
let ap = format!("clipMixer.{label}");
ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24));
ui.painter().rect_filled(sr, 3.0, t.control_surface);
let clip = tr.item_at(now).filter(|c| c.enabled);
let active = clip.is_some();
let dimc = |c: Color32| if active { c } else { c.gamma_multiply(0.4) };
@ -930,9 +930,9 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let kc = pos2(sr.center().x, sr.min.y + 36.0);
let kr = Rect::from_center_size(kc, vec2(30.0, 30.0));
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag());
ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(Color32::from_gray(0xb0))));
ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim)));
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(Color32::from_gray(0xb0))));
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim)));
small_text(ui, pos2(kc.x - 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "L", 11.0, dimc(t.hot_text));
small_text(ui, pos2(kc.x + 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "R", 11.0, dimc(t.hot_text));
small_text(ui, pos2(kc.x, kc.y + 26.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text));
@ -989,13 +989,13 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let s = if d <= FADER_MIN_DB { "-∞".to_string() } else { format!("{}", d as i32) };
small_text(ui, pos2(sr.min.x + 26.0, ypos(d)), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim);
}
ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10));
ui.painter().rect_filled(track, 2.0, t.control_well);
let cy = ypos(vol);
let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0));
let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag());
ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a));
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(Color32::from_gray(0xe0))), StrokeKind::Inside);
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(Color32::from_gray(0xe0))));
ui.painter().rect_filled(cap, 3.0, t.control_handle_bg);
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside);
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle)));
app.auto.add(&format!("{ap}.fader"), cap, &format!("Clip volume {}", db_text(vol)));
if fresp.dragged() && active && fresp.drag_delta().y != 0.0 {
set(&mut acts, "volume", "level", pos_to_db(db_to_pos(vol) - fresp.drag_delta().y / track.height()), fresp.drag_started());
@ -1023,6 +1023,29 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
mod tests {
use super::*;
#[test]
fn meter_well_and_inactive_clip_light_follow_theme() {
for kind in [crate::theme::ThemeKind::Dark, crate::theme::ThemeKind::Medium, crate::theme::ThemeKind::Light] {
let t = Tokens::for_kind(kind);
let ctx = egui::Context::default();
let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(40.0, 100.0));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw_meters(ui, area, &[[-90.0; 2]], 1, &t));
out.textures_delta.clear();
let rectangles: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) => Some(r),
_ => None,
})
.collect();
let well = rectangles.iter().find(|r| r.rect == area).expect("painted meter well");
assert_eq!(well.fill, t.meter_bg, "meter well did not follow {kind:?}");
let light = rectangles.iter().find(|r| r.rect.height() == 4.0).expect("painted clip light");
assert_eq!(light.fill, t.meter_clip_off, "clip light did not follow {kind:?}");
}
}
#[test]
fn taper_roundtrips_and_is_monotonic() {
let mut last = f64::NEG_INFINITY;

View File

@ -46,6 +46,7 @@ pub mod timeline;
pub mod timeline_automation;
pub mod timeline_captions;
pub mod timeline_hit;
pub mod timeline_volume;
pub mod tools;
pub mod trim_monitor;
pub mod voiceover;

View File

@ -284,6 +284,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
monitor_view::pan_input(app, ui, which, video_area, pic);
}
if which == Which::Program && show_picture {
eyedropper(app, ui, &pic_resp, pic, target, rate, time);
crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size);
crate::panels::masks::monitor_overlay(app, ui, pic, frame_size);
}
@ -305,8 +306,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
// ---- controls row: timecode | zoom | res | wrench | duration
let row1 = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.max.y - controls_h + 2.0), vec2(rect.width() - 28.0, 26.0));
let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000);
ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode);
app.auto.add(&format!("{prefix}.timecode"), Rect::from_min_size(row1.min, vec2(110.0, row1.height())), &tc);
let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height()));
let min = if which == Which::Program { Tick::ZERO } else { origin };
match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) {
Some(Ok(to)) => {
let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" };
if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) {
app.ui.status = e.to_string();
}
}
Some(Err(e)) => app.ui.status = e,
None => {}
}
app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc);
let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000);
ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim);
// zoom + resolution dropdowns centred-ish
@ -455,6 +467,99 @@ fn cpu_texture(
}
}
/// A click of an armed eyedropper waiting for its frame to render.
#[derive(Clone)]
struct EyedropperPick {
/// The clicked point in the picture, 0..1 across and down.
uv: (f32, f32),
key: FrameKey,
time: Tick,
/// The project with the armed effect switched off, so a colour that effect keys out can be picked.
project: std::sync::Arc<filmcraft_project::Project>,
}
/// Output scale of the frame an eyedropper samples (half size is plenty and quick to render).
const EYEDROPPER_SCALE: f32 = 0.5;
/// Eyedropper: while a colour parameter's eyedropper is armed (Effect Controls), the Program picture
/// takes a crosshair and a click fills that parameter with the colour under the pointer. The colour is
/// read from a render of the sequence at the playhead with the armed effect switched off (an Ultra Key
/// has already removed the green it is asked to key); Esc disarms.
fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) {
let Some(armed) = app.ui.eyedropper.clone() else { return };
let ctx = ui.ctx().clone();
let pending_id = egui::Id::new("eyedropper-pending");
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.eyedropper = None;
ctx.data_mut(|d| d.remove::<EyedropperPick>(pending_id));
return;
}
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
}
if resp.clicked()
&& let (Some(pos), Target::Sequence(seq)) = (resp.interact_pointer_pos(), target)
&& pic.width() > 0.0
&& pic.height() > 0.0
{
let uv = (((pos.x - pic.min.x) / pic.width()).clamp(0.0, 1.0), ((pos.y - pic.min.y) / pic.height()).clamp(0.0, 1.0));
let mut project = (*app.session.project).clone();
if let Some(e) =
project.sequence_mut(seq).and_then(|q| q.find_item_mut(filmcraft_project::ClipId(armed.clip))).and_then(|(_, it)| it.effects.get_mut(armed.effect))
{
e.enabled = false;
}
let frame = rate.frame_at(time);
// a revision of its own, so this render never replaces what the monitor caches
let revision = app.session.revision ^ 0x5eed_d70b_0000_0000 ^ armed.clip.rotate_left(17) ^ armed.effect as u64;
let key = FrameKey { target, frame, size: (EYEDROPPER_SCALE * 1000.0) as u32, revision, draft: false };
ctx.data_mut(|d| d.insert_temp(pending_id, EyedropperPick { uv, key, time: rate.tick_of(frame), project: std::sync::Arc::new(project) }));
}
let Some(pick) = ctx.data(|d| d.get_temp::<EyedropperPick>(pending_id)) else { return };
app.frames.request(pick.key, pick.time, EYEDROPPER_SCALE, &pick.project, 0);
let Some(img) = app.frames.get(&pick.key) else {
ctx.request_repaint();
return;
};
ctx.data_mut(|d| d.remove::<EyedropperPick>(pending_id));
app.ui.eyedropper = None;
let Some(rgb) = sample_rgb(&img, pick.uv) else {
app.ui.status = tl!("Eyedropper: no picture to sample").to_string();
return;
};
// the parameter stores linear values, like the colour picker beside it
let lin = egui::Rgba::from(Color32::from_rgb(rgb[0], rgb[1], rgb[2]));
let mut params = json!({"clip": armed.clip, "effect": armed.effect, "param": armed.param, "value": [lin.r(), lin.g(), lin.b(), 1.0]});
if let Some(k) = armed.mask {
params["mask"] = json!(k);
}
if let Err(e) = app.session.execute("effects.setParam", params) {
app.ui.status = e.to_string();
}
}
/// The average colour of the 5×5 pixels around `uv` (0..1 across and down) of a frame, so a noisy
/// green screen gives its true green. `None` for an empty frame.
fn sample_rgb(img: &crate::frames::Rgba, uv: (f32, f32)) -> Option<[u8; 3]> {
if img.w == 0 || img.h == 0 || img.px.len() < img.w.saturating_mul(img.h).saturating_mul(4) {
return None;
}
let cx = ((uv.0 * img.w as f32) as usize).min(img.w - 1);
let cy = ((uv.1 * img.h as f32) as usize).min(img.h - 1);
let (mut sum, mut n) = ([0u32; 3], 0u32);
for y in cy.saturating_sub(2)..=(cy + 2).min(img.h - 1) {
for x in cx.saturating_sub(2)..=(cx + 2).min(img.w - 1) {
let i = (y * img.w + x) * 4;
for (c, s) in sum.iter_mut().enumerate() {
*s += u32::from(*img.px.get(i + c)?);
}
n += 1;
}
}
let avg = |c: usize| u8::try_from(sum[c].checked_div(n)?).ok();
Some([avg(0)?, avg(1)?, avg(2)?])
}
pub fn quantize_scale(s: f32) -> f32 {
// Buckets keep the cache effective while resizing the panel.
let buckets = [1.0 / 32.0, 1.0 / 16.0, 1.0 / 8.0, 0.1875, 0.25, 0.375, 0.5, 0.75, 1.0];
@ -674,8 +779,41 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) {
let t = match cmd {
"src.goIn" => v.mark_in.unwrap_or(v.start),
"src.goOut" => v.mark_out.unwrap_or(v.end - rate.frame_duration()),
"src.stepBack" => cur - rate.frame_duration(),
_ => cur + rate.frame_duration(),
_ if rate.frame_duration() == Tick::ZERO => cur,
"src.stepBack" => rate.tick_of(rate.frame_at(cur).saturating_sub(1)),
_ => rate.tick_of(rate.frame_at(cur).saturating_add(1)),
};
let _ = app.session.execute("source.setPlayhead", json!({"time": t.0}));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frames::Rgba;
fn flat(w: usize, h: usize, rgba: [u8; 4]) -> Rgba {
Rgba { w, h, px: rgba.iter().copied().cycle().take(w * h * 4).collect() }
}
#[test]
fn eyedropper_averages_the_pixels_around_the_point() {
let mut img = flat(8, 8, [0, 200, 0, 255]);
// one noisy pixel next to the centre is averaged away, not picked
img.px[(4 * 8 + 5) * 4 + 1] = 0;
assert_eq!(sample_rgb(&img, (0.5, 0.5)), Some([0, 192, 0]));
}
#[test]
fn eyedropper_clamps_to_the_picture_edges() {
let img = flat(4, 3, [10, 20, 30, 255]);
for uv in [(0.0, 0.0), (1.0, 1.0), (0.999, 0.0), (0.0, 0.999)] {
assert_eq!(sample_rgb(&img, uv), Some([10, 20, 30]));
}
}
#[test]
fn eyedropper_rejects_empty_and_truncated_frames() {
assert_eq!(sample_rgb(&Rgba { w: 0, h: 0, px: vec![] }, (0.5, 0.5)), None);
assert_eq!(sample_rgb(&Rgba { w: 4, h: 4, px: vec![0; 10] }, (0.5, 0.5)), None);
}
}

View File

@ -16,6 +16,15 @@ use crate::theme::Tokens;
const TABS: [&str; 3] = ["Transcript", "Captions", "Graphics"];
/// The width a tab label needs: 7 px per character, which the strip was laid out with for Latin
/// text, or the label as drawn if that is wider (Japanese glyphs are about twice as wide, and a
/// per-character estimate alone let a label run into the next tab). Measured in the semibold the
/// active tab uses, so selecting a tab does not move its neighbours.
fn tab_text_width(painter: &egui::Painter, label: &str) -> f32 {
let drawn = painter.layout_no_wrap(label.to_string(), Tokens::semibold(12.5), Color32::WHITE).size().x;
(label.chars().count() as f32 * 7.0).max(drawn)
}
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
ui.painter().rect_filled(rect, 0.0, t.panel_bg);
@ -23,7 +32,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut x = rect.min.x + 12.0;
for tab in TABS {
let shown = crate::i18n::t(tab);
let w = shown.chars().count() as f32 * 7.0 + 16.0;
let text_w = tab_text_width(ui.painter(), shown);
let w = text_w + 16.0;
let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0));
let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click());
let active = app.ui.text_tab == tab;
@ -35,7 +45,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if active { t.text } else { t.text_dim },
);
if active {
ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + w - 16.0, r.max.y)], Stroke::new(2.0, t.text));
ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + text_w, r.max.y)], Stroke::new(2.0, t.text));
}
app.auto.add(&format!("text.tab.{tab}"), r, tab);
if resp.clicked() {
@ -439,3 +449,37 @@ fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Latin labels keep the 7 px per character they were laid out with; Japanese labels are as
/// wide as they are drawn, so they no longer run into the next tab.
#[test]
fn tab_widths_follow_the_drawn_label() {
let ctx = egui::Context::default();
crate::theme::install(&ctx, &Tokens::for_kind(crate::theme::ThemeKind::default()));
// Japanese needs a craft-fonts build or an installed font; without one there is nothing wide to measure
if !crate::i18n::install_japanese_font(&ctx) {
return;
}
let labels = ["Transcript", "Captions", "Graphics", "文字起こし", "キャプション", "グラフィックス"];
let mut widths = Vec::new();
for _ in 0..2 {
// the added font is in place from the second pass
widths.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
for label in labels {
widths.push(tab_text_width(ui.painter(), label));
}
});
out.textures_delta.clear();
}
assert_eq!(widths[..3], [70.0, 56.0, 56.0], "Latin tabs keep 7 px per character");
// 5, 6 and 7 Japanese characters of about 12.5 px each, far over the 7 px per character estimate
for ((width, chars), label) in widths[3..].iter().zip([5.0_f32, 6.0, 7.0]).zip(&labels[3..]) {
assert!(*width > chars * 7.0 * 1.5, "{label}: {width} px");
}
}
}

View File

@ -114,6 +114,8 @@ pub enum Drag {
clip: ClipId,
delta: Tick,
},
/// An audio clip's Volume line (see [`super::timeline_volume`]).
Volume(super::timeline_volume::LineDrag),
Stretch {
clip: ClipId,
edge: filmcraft_edit::Edge,
@ -150,6 +152,8 @@ pub struct Row {
pub kind: TrackKind,
pub index: usize,
pub rect: Rect,
/// An audio track showing a track keyframe lane, which hides its clips' Volume lines.
pub lane: bool,
}
#[derive(Clone, Debug)]
@ -315,14 +319,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.timeline.v_scroll = app.ui.timeline.v_scroll.clamp(0.0, max_vs);
for (i, tr) in seq.video_tracks.iter().enumerate() {
let top = video_area.min.y + v_off + (nv - 1 - i) as f32 * vh - (max_vs - app.ui.timeline.v_scroll);
rows.push(Row { track: tr.id, kind: TrackKind::Video, index: i, rect: Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + vh)) });
let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + vh));
rows.push(Row { track: tr.id, kind: TrackKind::Video, index: i, rect, lane: false });
}
let na = seq.audio_tracks.len();
let max_as = (na as f32 * ah - audio_area.height()).max(0.0);
app.ui.timeline.a_scroll = app.ui.timeline.a_scroll.clamp(0.0, max_as);
for (i, tr) in seq.audio_tracks.iter().enumerate() {
let top = audio_area.min.y + i as f32 * ah - app.ui.timeline.a_scroll;
rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect: Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah)) });
let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah));
rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect, lane: app.ui.timeline.track_lanes.contains_key(&tr.id.0) });
}
let layout = Layout { content, ruler, rows: rows.clone(), pps, scroll, split_y };
app.tl.layout = Some(layout.clone());
@ -363,6 +369,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y)));
let volume_line = r.kind == TrackKind::Audio && !r.lane;
for it in &tr.items {
let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None));
if moved_track.is_some_and(|m| m != r.track) {
@ -375,6 +382,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x1 = layout.x_of(start + dur);
let body = Rect::from_min_max(pos2(x0, r.rect.min.y + 1.0), pos2(x1.max(x0 + 1.0), r.rect.max.y - 1.0));
draw_clip(app, &ctx, &p, body, it, r.kind, selection.contains(&it.id), &t, rate);
if volume_line {
super::timeline_volume::paint(&p, body, it);
}
app.auto.add(&format!("timeline.clip.{}", it.id.0), body.intersect(content), &it.name);
// a nest that runs past the end of its sequence's contents: that part is empty
if !previews.contains_key(&it.id)
@ -396,6 +406,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
{
let body = Rect::from_min_max(pos2(layout.x_of(*start), r.rect.min.y + 1.0), pos2(layout.x_of(*start + *dur), r.rect.max.y - 1.0));
draw_clip(app, &ctx, &p, body, it, r.kind, true, &t, rate);
if volume_line {
super::timeline_volume::paint(&p, body, it);
}
}
}
// Show Through Edits: a small bow-tie on cuts between continuous pieces of one clip
@ -507,6 +520,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---- interaction
interact(app, ui, &seq, &layout, rect);
super::timeline_volume::interact(app, ui, &seq, &layout, aclip);
// track keyframes (drawn and edited on top of the clips)
super::timeline_automation::show(app, ui, &seq, &layout, aclip);
if cap_n > 0 {
@ -586,7 +600,7 @@ fn draw_clip(
let base = label_color(app, it.label);
let inr = in_range(app, it);
let fill = if !it.enabled {
Color32::from_rgb(0x2a, 0x2a, 0x2a)
t.disabled_clip_bg
} else if inr {
lighten(base, 0.12)
} else if selected {
@ -720,6 +734,8 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra
for (a, b) in peaks.iter().skip(s0).take(s1 - s0) {
m = m.max(a.abs()).max(b.abs());
}
// as in Premiere, the waveform shows the clip's Volume
m *= super::timeline_volume::gain_at(it, t0);
// View ▸ Dynamic Audio Waveforms (default): logarithmic scale, −48 dB → 0, 0 dB → full;
// off: linear amplitude
let h = if dynamic {
@ -739,16 +755,6 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra
p.rect_filled(cb, 1.0, Color32::from_black_alpha(160));
p.text(cb.center(), Align2::CENTER_CENTER, "1", Tokens::ui(7.5), Color32::from_rgb(0xd9, 0xd9, 0xd9));
}
// volume rubber band (white line with a black shadow) at mid-height of the upper zone
let level = it.effect("volume").map(|e| e.f64_at("level", it.source_in)).unwrap_or(0.0);
let upper = Rect::from_min_max(pos2(body.min.x, body.min.y + 16.0), pos2(body.max.x, area.min.y));
if upper.height() > 6.0 {
let norm = ((level + 60.0) / 66.0).clamp(0.0, 1.0) as f32;
let y = upper.max.y - norm * upper.height();
let cp = p.with_clip_rect(body.intersect(p.clip_rect()));
cp.line_segment([pos2(body.min.x, y + 1.0), pos2(body.max.x, y + 1.0)], Stroke::new(1.0, Color32::BLACK));
cp.line_segment([pos2(body.min.x, y), pos2(body.max.x, y)], Stroke::new(1.0, Color32::WHITE));
}
}
/// Display gain for a clip's waveform: clip gain, plus normalisation of the source to full scale
@ -981,7 +987,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
true,
t,
egui::Id::new(("locked", r.track.0)),
Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }),
Some(if tr.locked { t.icon_active } else { t.text_dim }),
);
app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock");
if lresp.clicked() {
@ -989,7 +995,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
}
if tr.locked {
// diagonal hatch over locked lanes is drawn by the lane painter; here a subtle tint
p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, Color32::from_rgb(0x4b, 0x4b, 0x4b));
p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, t.pressed);
}
// 3. target
let targeted = tg.targeted.contains(&r.track);
@ -1108,7 +1114,16 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
// current timecode: Premiere's big blue timecode
let tc = format_time(app.session.playhead(), rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64);
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0));
p.text(pos2(tc_rect.min.x, tc_rect.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode);
let tc_id = egui::Id::new(("timeline-tc", seq_id));
match crate::widgets::timecode_field(ui, tc_id, tc_rect, &tc, app.session.playhead(), Tick::ZERO, rate, seq.settings.drop_frame, t.timecode) {
Some(Ok(time)) => {
if let Err(e) = app.session.execute("playhead.set", json!({"time": time.0})) {
app.ui.status = e.to_string();
}
}
Some(Err(e)) => app.ui.status = e,
None => {}
}
app.auto.add("timeline.timecode", tc_rect, &tc);
// toolbar: 30 × 30 buttons, "on" = #4b4b4b fill
let mut x = rect.min.x + 12.0;
@ -1126,7 +1141,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
let resp = ui.interact(r, egui::Id::new(("tl-toggle", key)), Sense::click()).on_hover_text(tip);
app.auto.add(&format!("timeline.toggle.{key}"), r, tip);
if toggle && on {
p.rect_filled(r, 4.0, Color32::from_rgb(0x4b, 0x4b, 0x4b));
p.rect_filled(r, 4.0, t.pressed);
} else if resp.hovered() {
p.rect_filled(r, 4.0, t.hover);
}
@ -1305,10 +1320,10 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco
let (left, w, per_point) = zoom_thumb(bar, seq_seconds, v.scroll, v.pps);
let thumb = Rect::from_min_max(pos2(left, bar.min.y), pos2(left + w, bar.max.y));
let hover = ui.rect_contains_pointer(thumb);
p.rect_filled(thumb, thumb.height() / 2.0, if hover { Color32::from_rgb(0x6a, 0x6a, 0x6a) } else { Color32::from_rgb(0x4b, 0x4b, 0x4b) });
p.rect_filled(thumb, thumb.height() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb });
for x in [thumb.min.x + thumb.height() / 2.0, thumb.max.x - thumb.height() / 2.0] {
p.circle_filled(pos2(x, thumb.center().y), 4.5, t.panel_bg);
p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, Color32::from_rgb(0xd1, 0xd1, 0xd1)));
p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, t.icon_active));
}
app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar");
app.auto.add("timeline.zoomBar.track", bar, "zoom scroll bar track");
@ -1388,13 +1403,12 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i
let pos = if invert { 1.0 - *value / max } else { *value / max };
let y = bar.min.y + (bar.height() - h) * pos;
let thumb = Rect::from_min_size(pos2(bar.min.x + 1.0, y), vec2(bar.width() - 2.0, h));
p.rect_filled(thumb, 3.0, Color32::from_rgb(80, 80, 80));
p.rect_filled(thumb, 3.0, t.vertical_scroll_thumb);
let resp = ui.interact(bar, egui::Id::new(("tl-vs", id)), Sense::drag());
if resp.dragged() {
let d = resp.drag_delta().y / (bar.height() - h).max(1.0) * max;
*value = (*value + if invert { -d } else { d }).clamp(0.0, max);
}
let _ = t;
}
/// Snap `t` to nearby candidates (edits, playhead, markers, in/out). Returns snapped tick.
@ -1453,6 +1467,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value {
let grab = grab_at(seq, layout, pos, app.ui.tool, mods, app.session.prefs.trim.selection_tool_roll_ripple);
let kind = match grab {
Grab::Edge { kind, .. } => json!(kind.name()),
Grab::Volume { .. } => json!("volume"),
Grab::Other(_) => Value::Null,
};
match grab.hit() {
@ -1577,6 +1592,87 @@ pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[
&[("Reveal in Project", "clip.revealInProject"), ("Join Through Edits", "sequence.joinThroughEdits")],
];
/// The cut right-clicked on a track (#219): the clip ending there and the clip starting there,
/// either missing at the ends of a gap. Its menu offers the edit point's trim types, Apply Default
/// Transitions and Join Through Edits, like Premiere's edit point menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditPointTarget {
left: Option<ClipId>,
right: Option<ClipId>,
}
impl EditPointTarget {
fn at(seq: &Sequence, track: TrackId, clip: ClipId, edge: filmcraft_edit::Edge) -> Option<Self> {
let tr = seq.track(track)?;
let it = tr.item(clip)?;
Some(match edge {
filmcraft_edit::Edge::Out => Self { left: Some(clip), right: tr.items.iter().find(|x| x.start == it.end()).map(|x| x.id) },
filmcraft_edit::Edge::In => Self { left: tr.items.iter().find(|x| x.end() == it.start).map(|x| x.id), right: Some(clip) },
})
}
}
/// The edit point menu's trim types: label, id (`timeline.editPointMenu.<id>`), trim kind, and
/// which side of the cut it selects (the incoming clip's In or the outgoing clip's Out).
pub(crate) const EDIT_POINT_TYPES: &[(&str, &str, &str, filmcraft_edit::Edge)] = &[
("Ripple Trim In", "rippleIn", "ripple", filmcraft_edit::Edge::In),
("Ripple Trim Out", "rippleOut", "ripple", filmcraft_edit::Edge::Out),
("Roll Edit", "roll", "roll", filmcraft_edit::Edge::Out),
("Trim In", "trimIn", "trim", filmcraft_edit::Edge::In),
("Trim Out", "trimOut", "trim", filmcraft_edit::Edge::Out),
];
/// The edit point context menu (#219).
fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui, seq: &Sequence, target: EditPointTarget) {
let selected = app.session.state.edit_points.first().copied();
for &(label, id, kind, edge) in EDIT_POINT_TYPES {
let clip = match edge {
filmcraft_edit::Edge::In => target.right,
filmcraft_edit::Edge::Out => target.left,
};
// a roll needs a clip on both sides of the cut
let clip = clip.filter(|_| kind != "roll" || (target.left.is_some() && target.right.is_some()));
let out = edge == filmcraft_edit::Edge::Out;
let current = selected.is_some_and(|ep| Some(ep.clip) == clip && ep.out == out && serde_json::to_value(ep.kind).ok() == Some(json!(kind)));
let label = crate::i18n::t(label);
let text = if current { format!("✓ {label}") } else { label.to_string() };
let r = ui.add_enabled(clip.is_some(), egui::Button::new(text));
app.auto.add(&format!("timeline.editPointMenu.{id}"), r.rect, label);
if r.clicked()
&& let Some(c) = clip
{
let e = if out { "out" } else { "in" };
if let Err(e) = app.session.execute("trim.selectEditPoint", json!({"clip": c.0, "edge": e, "kind": kind})) {
app.ui.status = e.to_string();
}
ui.close();
}
}
ui.separator();
let label = tl!("Apply Default Transitions");
let r = ui.add_enabled(app.session.is_enabled("trim.applyDefaultTransition"), egui::Button::new(label));
app.auto.add("timeline.editPointMenu.applyDefaultTransitions", r.rect, label);
if r.clicked() {
if let Err(e) = crate::menus::invoke(app, ctx, "trim.applyDefaultTransition", json!({})) {
app.ui.status = e;
}
ui.close();
}
// only this cut (and its linked partners), and only when it is a through edit
let through = filmcraft_edit::through::through_edits(seq).iter().any(|e| Some(e.left) == target.left && Some(e.right) == target.right);
let label = tl!("Join Through Edits");
let r = ui.add_enabled(through, egui::Button::new(label));
app.auto.add("timeline.editPointMenu.joinThroughEdits", r.rect, label);
if r.clicked()
&& let (Some(left), Some(right)) = (target.left, target.right)
{
if let Err(e) = app.session.execute("sequence.joinThroughEdits", json!({"cut": [left.0, right.0]})) {
app.ui.status = e.to_string();
}
ui.close();
}
}
/// The clip menu's Multi-Camera submenu: Enable, Flatten, and the cameras of the selected nested
/// sequence clips (greyed out when none of the selected clips is a nested sequence).
fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem]) {
@ -1730,6 +1826,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
let g = grab_at(seq, layout, p, tool, mods, roll_ripple);
let cur = match (tool, g) {
(_, Grab::Edge { .. }) => CursorIcon::ResizeColumn,
(_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair,
(_, Grab::Volume { .. }) => CursorIcon::ResizeVertical,
(Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair,
(Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal,
(Tool::Hand, _) => CursorIcon::Grab,
@ -1763,7 +1861,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
};
let (p, t) = (pressed.pos, pressed.tick);
let started = match (tool, pressed.grab) {
let adding = tool == Tool::Pen || mods.command;
// a plain click on a Volume line selects the clip, as anywhere else on it
let grab = match pressed.grab {
Grab::Volume { .. } if resp.clicked() && !adding => Grab::Other(pressed.grab.hit()),
g => g,
};
let started = match (tool, grab) {
(_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub),
(Tool::Hand, _) => Some(Drag::Pan { last: p }),
(Tool::Zoom, _) => {
@ -1824,6 +1928,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
EdgeKind::Stretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t },
EdgeKind::Remix => Drag::Remix { clip, delta: Tick::ZERO, from: t },
}),
(_, Grab::Volume { clip, .. }) if resp.drag_started() => super::timeline_volume::start(seq, layout, clip, p).map(Drag::Volume),
(_, Grab::Volume { clip, .. }) => {
super::timeline_volume::add_keyframe(app, seq, layout, clip, p.x);
None
}
(Tool::Slip, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slip { clip, delta: Tick::ZERO }),
(Tool::Slide, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slide { clip, delta: Tick::ZERO }),
(_, Grab::Other(Hit::Clip { clip, track, .. })) => {
@ -1939,6 +2048,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
let origin = ctx.input(|i| i.pointer.press_origin()).map(|o| layout.tick_at(o.x)).unwrap_or(t_here);
Some(Drag::Slide { clip, delta: rate.snap_nearest(t_here - origin) })
}
Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))),
Drag::Marquee { start } => {
let r = Rect::from_two_pos(start, p);
ui.painter().rect_filled(r, 0.0, Color32::from_white_alpha(18));
@ -1949,7 +2059,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
};
app.tl.drag = new;
// auto-scroll when dragging near the edges
if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. })) {
if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. }) | Some(Drag::Volume(_))) {
let v = &mut app.ui.timeline;
if p.x > layout.content.max.x - 20.0 {
v.target_scroll += 6.0 / v.pps;
@ -2021,20 +2131,42 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
}
// ---- context menu on clips (right-clicking an unselected clip selects it first)
// ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's
// edge, the edit point menu instead (right-clicking an unselected edit point selects it first)
let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target");
// the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected
let menu_clip_id = egui::Id::new("timeline.clipMenu.clip");
if resp.secondary_clicked()
&& let Some(p) = resp.interact_pointer_pos()
&& let Hit::Clip { clip, .. } = hit(seq, layout, p)
&& let Hit::Clip { clip, edge, track } = hit(seq, layout, p)
{
ctx.data_mut(|d| d.insert_temp(menu_clip_id, clip));
if !app.session.state.selection.contains(&clip) {
let target = edge.and_then(|e| EditPointTarget::at(seq, track, clip, e).map(|t| (t, e)));
ctx.data_mut(|d| d.insert_temp(edit_point_menu_id, target.map(|(t, _)| t)));
if let Some((t, e)) = target {
let on_it = app.session.state.edit_points.iter().any(|ep| (ep.out && Some(ep.clip) == t.left) || (!ep.out && Some(ep.clip) == t.right));
if !on_it {
let kind = match tool {
Tool::Rolling => "roll",
Tool::Ripple => "ripple",
_ => {
let (dist, neighbour) = edge_geometry(seq, layout, track, clip, e, p.x);
selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, false, false, dist, neighbour)
}
};
let edge = if e == filmcraft_edit::Edge::In { "in" } else { "out" };
let _ = app.session.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": edge, "kind": kind}));
}
} else if !app.session.state.selection.contains(&clip) {
let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]}));
}
}
resp.context_menu(|ui| {
ui.set_min_width(220.0);
if let Some(target) = ctx.data(|d| d.get_temp::<Option<EditPointTarget>>(edit_point_menu_id)).flatten() {
edit_point_menu(app, &ctx, ui, seq, target);
return;
}
let sel = app.session.state.selection.clone();
let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect();
let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled);
@ -2164,16 +2296,40 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
&& let Some(row) = layout.rows.iter().find(|r| r.track == tid)
{
{
let r = Rect::from_min_max(pos2(layout.x_of(it.start), row.rect.min.y), pos2(layout.x_of(it.end()), row.rect.max.y));
ui.painter().rect_stroke(r, 3.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside);
let transition_kind = filmcraft_project::find_effect(&effect).and_then(|d| match d.kind {
filmcraft_project::EffectKind::VideoTransition => Some(TrackKind::Video),
filmcraft_project::EffectKind::AudioTransition => Some(TrackKind::Audio),
_ => None,
});
let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" };
let params = json!({"effect": effect, "clip": clip.0, "edge": edge});
if let Some(kind) = transition_kind {
if let Ok((_, tr)) = filmcraft_engine::commands::preview_transition(&app.session, &params, kind) {
let r = Rect::from_min_max(pos2(layout.x_of(tr.start), row.rect.min.y + 1.0), pos2(layout.x_of(tr.end()), row.rect.max.y - 1.0));
let painter = ui.painter().with_clip_rect(layout.content);
painter.rect_filled(r, 2.0, app.tokens.accent.gamma_multiply(0.35));
paint_hatch(&painter, r.intersect(layout.content));
painter.rect_stroke(r, 2.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside);
let cut = if edge == "in" { it.start } else { it.end() };
let x = layout.x_of(cut);
painter.line_segment([pos2(x, r.top()), pos2(x, r.bottom())], Stroke::new(3.0, app.tokens.accent));
// Keep the edge label readable even when the clip/transition is only a few pixels wide.
let label = if edge == "in" { tl!("In") } else { tl!("Out") };
let label_width = painter.layout_no_wrap(label.to_string(), Tokens::ui(11.0), app.tokens.accent).size().x + 8.0;
let label_rect = Rect::from_center_size(pos2(p.x, row.rect.top() + 10.0), vec2(label_width, 15.0));
painter.rect_filled(label_rect, 2.0, app.tokens.panel_bg);
painter.text(label_rect.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), app.tokens.accent);
app.auto.add("timeline.transitionDropPreview", r.intersect(layout.content), edge);
}
} else {
let r = Rect::from_min_max(pos2(layout.x_of(it.start), row.rect.min.y), pos2(layout.x_of(it.end()), row.rect.max.y));
ui.painter().rect_stroke(r, 3.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside);
}
if ctx.input(|i| i.pointer.any_released()) {
let is_transition = filmcraft_project::find_effect(&effect)
.is_some_and(|d| matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition));
let r = if let Some(name) = effect.strip_prefix("preset:") {
app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]}))
} else if is_transition {
let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" };
app.session.execute("effects.apply", json!({"effect": effect, "clip": clip.0, "edge": edge}))
} else if transition_kind.is_some() {
app.session.execute("effects.apply", params)
} else {
app.session.execute("effects.apply", json!({"effect": effect, "clips": [clip.0]}))
};

View File

@ -21,7 +21,6 @@ pub const ROW_H: f32 = 28.0;
/// Caption block colours (fill, selected fill).
const FILL: Color32 = Color32::from_rgb(0x6b, 0x4f, 0x8f);
const FILL_SEL: Color32 = Color32::from_rgb(0x8c, 0x6c, 0xb4);
const ROW_BG: Color32 = Color32::from_rgb(0x23, 0x20, 0x2a);
/// Row rects (full width incl. header) for each caption track, top first.
pub fn rows(area: Rect, n: usize) -> Vec<Rect> {
@ -73,7 +72,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
let mut actions: Vec<(String, Value)> = Vec::new();
for (i, (tr, row)) in seq.caption_tracks.iter().zip(rows(area, seq.caption_tracks.len())).enumerate() {
let lane = Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y));
painter.rect_filled(lane, 0.0, ROW_BG);
painter.rect_filled(lane, 0.0, t.caption_track_bg);
painter.line_segment([pos2(row.min.x, row.max.y - 0.5), pos2(row.max.x, row.max.y - 0.5)], Stroke::new(1.0, t.tl_bg));
// header
let label = format!("C{}", i + 1);
@ -92,7 +91,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
true,
t,
egui::Id::new(("cap-lock", tr.id.0)),
Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }),
Some(if tr.locked { t.icon_active } else { t.text_dim }),
);
app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock");
if lresp.clicked() {

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