Compare commits
129
Commits
19a83f8359
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
017dc80a16 | ||
|
|
c94d7f479e | ||
|
|
8747eafe78 | ||
|
|
982593aa2c | ||
|
|
fac63e24d4 | ||
|
|
5f92db9dc7 | ||
|
|
aba7298ab3 | ||
|
|
c9ba8695fc | ||
|
|
8f6527887d | ||
|
|
d213826924 | ||
|
|
e27bc690f1 | ||
|
|
ad3dbfe928 | ||
|
|
08c48473a6 | ||
|
|
563a3625ca | ||
|
|
725a1d4da0 | ||
|
|
bd447e1a7d | ||
|
|
dd003ba6bb | ||
|
|
df65a3f1a4 | ||
|
|
a1268df2f6 | ||
|
|
1dc5a77ab0 | ||
|
|
6de991de33 | ||
|
|
f817483e4e | ||
|
|
133b139036 | ||
|
|
ad90fd34ae | ||
|
|
1fd32d3d48 | ||
|
|
88a5bee50e | ||
|
|
7b64e4119b | ||
|
|
5fd1c151da | ||
|
|
ea96357806 | ||
|
|
7f544a65e6 | ||
|
|
73f7e451c9 | ||
|
|
9b3a031d58 | ||
|
|
61cb4b71ed | ||
|
|
9a7d0765a5 | ||
|
|
224a1474b7 | ||
|
|
deb55d770e | ||
|
|
d0a8fcd2a5 | ||
|
|
c50d489dc0 | ||
|
|
12e7c9b053 | ||
|
|
77214bff79 | ||
|
|
5625f28c94 | ||
|
|
67cea4844c | ||
|
|
b94158a941 | ||
|
|
c64dd5ce06 | ||
|
|
38e231c480 | ||
|
|
b13ef477a8 | ||
|
|
21dbde8435 | ||
|
|
eed019c002 | ||
|
|
13ddd8c0e7 | ||
|
|
274ae84c01 | ||
|
|
9cfcb0af92 | ||
|
|
c86bcae68e | ||
|
|
7a9e11b729 | ||
|
|
eddfeb59b4 | ||
|
|
d88e2d6ec1 | ||
|
|
415262a7b2 | ||
|
|
4cc30d8711 | ||
|
|
fe8fbff8a9 | ||
|
|
e04ce03058 | ||
|
|
666ac9b4d4 | ||
|
|
12ca9d1d7a | ||
|
|
df3957b0dc | ||
|
|
18abdec423 | ||
|
|
f188bc79e7 | ||
|
|
800011ef73 | ||
|
|
ee7ea18d94 | ||
|
|
7e87ec135e | ||
|
|
ba1143e7a3 | ||
|
|
794850daf6 | ||
|
|
fec6e9dba7 | ||
|
|
4337559ed0 | ||
|
|
48e99e56b7 | ||
|
|
a5b0b2a1b1 | ||
|
|
23174512c0 | ||
|
|
96e6ce327d | ||
|
|
0d404b2427 | ||
|
|
9e750cc78a | ||
|
|
876e32c1a5 | ||
|
|
9a2c7e5dde | ||
|
|
4f0f5cbba6 | ||
|
|
faebc79c31 | ||
|
|
80e6b8bb6e | ||
|
|
4413676780 | ||
|
|
29204d1f63 | ||
|
|
3a48dd4991 | ||
|
|
3c05f7107b | ||
|
|
87ed45ffee | ||
|
|
d216567bcd | ||
|
|
20535546d6 | ||
|
|
5b0f5939f3 | ||
|
|
f9b1863e5f | ||
|
|
2817ac286c | ||
|
|
16364d414a | ||
|
|
882d9091ad | ||
|
|
19b7d3ac78 | ||
|
|
4a2614b3fc | ||
|
|
1e0d48578e | ||
|
|
a7916aa93d | ||
|
|
5f8db8e31c | ||
|
|
a97bc19c14 | ||
|
|
d028a45ffa | ||
|
|
280118a342 | ||
|
|
71e41d2d82 | ||
|
|
012659bf57 | ||
|
|
fc90604245 | ||
|
|
7ce80bf8b3 | ||
|
|
37df1d3d34 | ||
|
|
4f404f8cc6 | ||
|
|
5ab12b937f | ||
|
|
cde4dddf32 | ||
|
|
4babbf5de8 | ||
|
|
49fed365a4 | ||
|
|
b06c4fbbb6 | ||
|
|
4ef5b3e31d | ||
|
|
6b5b30fd36 | ||
|
|
1d45216c1f | ||
|
|
6153a2ac33 | ||
|
|
1ab1c29df4 | ||
|
|
a72e86dfaf | ||
|
|
fe017aa060 | ||
|
|
22a17442ac | ||
|
|
726b7d6a60 | ||
|
|
83dd13234b | ||
|
|
2c64317a58 | ||
|
|
92813707bc | ||
|
|
499246b48e | ||
|
|
56af662b83 | ||
|
|
660a32a1b3 | ||
|
|
c900804941 |
@@ -1,10 +0,0 @@
|
||||
# actionlint configuration: whitelist the custom self-hosted runner labels
|
||||
# used by the CI/CD workflows (Warp.dev runners on GitHub; the oak Gitea
|
||||
# instance's runners) so `actionlint ci.yml` / `actionlint cd.yml` passes.
|
||||
# See https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- oak-ubuntu-2404
|
||||
- oak-windows-2025
|
||||
@@ -1,335 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Build & test (Linux)
|
||||
runs-on: oak-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# gpui/ is a git submodule; its crates are workspace members of
|
||||
# their own repo and build as path dependencies of oakapp.
|
||||
submodules: true
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# System dependencies
|
||||
# ------------------------------------------------------------------
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
# Codec/filter libraries for the ffmpeg-sys-next build feature
|
||||
# (see tooling/install-deps.sh).
|
||||
tooling/install-deps.sh
|
||||
# cmake/make for the vendored OpenColorIO build (ocio-sys
|
||||
# `bundled`; Ubuntu's libopencolorio-dev is 2.1, older than the
|
||||
# bridge's API floor) plus the headless test infra gpui needs:
|
||||
# X11, software Mesa Vulkan (lavapipe) and xvfb.
|
||||
sudo apt-get install -y \
|
||||
cmake \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl1-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev xvfb build-essential clang libclang-dev
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build environment
|
||||
# ------------------------------------------------------------------
|
||||
# ocio-sys builds a stub bridge unless these are set; the oak-common
|
||||
# ocioutils tests need the real library (see crates/oak-common/.cargo/
|
||||
# config.toml, which only applies to builds run from that directory).
|
||||
# tooling/ocio-env.sh: vendored static OCIO (the [patch.crates-io]
|
||||
# ocio-sys tracks shaloong/ocio-rs main, whose vendored sources build
|
||||
# on GCC >= 16).
|
||||
- name: Configure build environment
|
||||
run: |
|
||||
tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
echo "RUSTUP_HOME=/opt/rust/rustup" >> "$GITHUB_ENV"
|
||||
echo "CARGO_HOME=/opt/rust/cargo" >> "$GITHUB_ENV"
|
||||
echo "PATH=$PATH:/opt/rust/cargo/bin" >> "$GITHUB_ENV"
|
||||
echo "FFMPEG_DIR=.cache/ffmpeg" >> "$GITHUB_ENV"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Caches
|
||||
# ------------------------------------------------------------------
|
||||
# Covers the whole target/ dir plus ~/.cargo; shared across branches
|
||||
# of the same OS.
|
||||
# TEMP: cache disabled ntil the Gitea instance cache is provisioned (Cache cargo artifacts)
|
||||
# uses: Swatinem/rust-cache@v2
|
||||
# with:
|
||||
# shared-key: oak-workspace
|
||||
# cache-on-failure: true
|
||||
|
||||
# The project FFmpeg (release/8.0, static, all free codecs + hwaccel)
|
||||
# is built by tooling/ffmpeg/build-ffmpeg.sh — 10-20 min on a cold
|
||||
# cache. It does not depend on the Rust toolchain, so key it on the
|
||||
# script itself and keep it out of rust-cache.
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache project FFmpeg)
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: .cache/ffmpeg
|
||||
# key: ffmpeg-${{ runner.os }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh') }}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project FFmpeg (script + FFMPEG_DIR; see docs/build.md)
|
||||
# ------------------------------------------------------------------
|
||||
- name: Build project FFmpeg
|
||||
run: |
|
||||
tooling/ffmpeg/build-ffmpeg.sh
|
||||
echo "FFMPEG_DIR=$PWD/.cache/ffmpeg" >> "$GITHUB_ENV"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build & test
|
||||
# ------------------------------------------------------------------
|
||||
# `cargo check` (not build): the Test step links the test binaries
|
||||
# anyway, and a full build would codegen every workspace crate twice
|
||||
# (once without and once with cfg(test)).
|
||||
- name: Build
|
||||
run: cargo check --workspace --locked
|
||||
|
||||
# xvfb + 24-bit screen: the gpui #[gpui::test] tests open real windows
|
||||
# and render through wgpu on Mesa's software Vulkan (lavapipe).
|
||||
# The watchdog bounds the step: a deadlocked test produces no output
|
||||
# and no failure, so after 1500 s (a green run needs ~4 min) it dumps
|
||||
# every hung process's thread stacks and kills the suite.
|
||||
- name: Test
|
||||
run: |
|
||||
sudo apt-get install -y gdb
|
||||
run_suite() {
|
||||
xvfb-run -a -s "-screen 0 1920x1080x24" cargo test --workspace --locked &
|
||||
TEST_PID=$!
|
||||
# The watchdog inherits the step's stdout/stderr; detach it so
|
||||
# it cannot keep the runner's I/O pipes open after the step
|
||||
# ends ("WaitDelay expired before I/O complete" otherwise).
|
||||
(
|
||||
sleep 1500
|
||||
echo "::warning::test suite exceeded 1500s; dumping hung-process stacks"
|
||||
for p in $(pgrep -f 'target/debug/deps/|target/debug/oak-worker'); do
|
||||
echo "===== thread stacks of pid $p ($(readlink /proc/$p/exe 2>/dev/null)) ====="
|
||||
sudo gdb -batch -ex 'thread apply all bt' -p "$p" || true
|
||||
done
|
||||
pkill -9 -f 'target/debug/deps/' || true
|
||||
pkill -9 -f 'target/debug/oak-worker' || true
|
||||
) >/dev/null 2>&1 &
|
||||
WATCHDOG_PID=$!
|
||||
wait $TEST_PID
|
||||
rc=$?
|
||||
kill $WATCHDOG_PID 2>/dev/null || true
|
||||
# Reap the watchdog so no child holds the step's pipes.
|
||||
wait $WATCHDOG_PID 2>/dev/null || true
|
||||
return $rc
|
||||
}
|
||||
# Retry once (same policy as the Windows job): worker-pool
|
||||
# startup under full-suite parallelism has flaked once
|
||||
# (full_res_worker_outlives_a_dropped_project: the render
|
||||
# manager's process dispatcher failed to start while every other
|
||||
# test passed). A real regression fails both passes.
|
||||
if ! run_suite; then
|
||||
echo "first pass failed; retrying once for worker-pool flakes"
|
||||
run_suite
|
||||
fi
|
||||
|
||||
# A crashing (SIGSEGV) test gives no Rust backtrace; rerun the
|
||||
# crashing test binaries under gdb to capture the native stack.
|
||||
# `--args` is required — plain `--` makes gdb treat the test args as
|
||||
# a core file. The extra probes target loader-stage crashes (the
|
||||
# copier_test SIGSEGV happens inside ld.so's dl_main): si_addr/si_code
|
||||
# pin down the fault type, the dynsym dump exposes symbols the
|
||||
# executable exports for interposition, strace shows the last loader
|
||||
# syscalls, and valgrind catches a corrupting static initializer.
|
||||
- name: Backtrace on test failure
|
||||
if: failure()
|
||||
run: |
|
||||
sudo apt-get install -y gdb strace valgrind
|
||||
for name in node_e2e_test suites_test copier_test; do
|
||||
BIN=$(ls -t target/debug/deps/$name-* | grep -v '\.d$' | head -1)
|
||||
[ -n "$BIN" ] || continue
|
||||
echo "===== $BIN ====="
|
||||
file "$BIN" || true
|
||||
echo "--- exported defined dynsyms:"
|
||||
readelf --dyn-syms -W "$BIN" 2>/dev/null | grep -v ' UND ' | tail -n +4 | head -30 || true
|
||||
echo "--- strace tail:"
|
||||
strace -f "$BIN" --list 2>&1 | tail -15 || true
|
||||
echo "--- valgrind tail:"
|
||||
valgrind -q "$BIN" --list 2>&1 | tail -25 || true
|
||||
echo "--- gdb:"
|
||||
xvfb-run -a gdb -batch \
|
||||
-ex run \
|
||||
-ex 'bt' \
|
||||
-ex 'p $_siginfo.si_code' \
|
||||
-ex 'p/x $_siginfo._sifields._sigfault.si_addr' \
|
||||
-ex 'x/6i $rip' \
|
||||
--args "$BIN" --nocapture || true
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OFX plugin discovery end-to-end
|
||||
# ------------------------------------------------------------------
|
||||
# Build a minimal but real OFX plugin into a .ofx.bundle, point
|
||||
# OFX_PLUGIN_PATH at it and let the scan_probe example run the full
|
||||
# host path (directory scan -> dlopen -> setHost -> load -> describe
|
||||
# -> register). The assertion is the plugin's registration line; CI
|
||||
# machines have no system-wide OFX plugins, so the fixture is the
|
||||
# only discovery.
|
||||
- name: Build OFX fixture plugin
|
||||
run: crates/oak-plugin/tests/fixtures/build_fixture.sh .cache/ofx-fixture
|
||||
|
||||
- name: Probe OFX plugin discovery
|
||||
run: |
|
||||
OFX_PLUGIN_PATH="$PWD/.cache/ofx-fixture" \
|
||||
cargo run --locked -p oak-plugin --example scan_probe > probe.log 2>&1
|
||||
grep -q 'type_id=rs.oak.CiTestPlugin' probe.log
|
||||
# A project carrying a plugin node must survive save/load (the
|
||||
# serializer resolves plugin types via the dynamic factory).
|
||||
OAK_OFX_FIXTURE_DIR="$PWD/.cache/ofx-fixture" \
|
||||
cargo test --locked -p oak-plugin --test ofx_roundtrip
|
||||
|
||||
windows:
|
||||
name: Build & test (Windows)
|
||||
runs-on: oak-windows-2025
|
||||
env:
|
||||
# The runner's msys2 shell starts as the base MSYS environment; the
|
||||
# build needs the UCRT64 one (mingw-w64-ucrt-x86_64-* toolchain).
|
||||
MSYSTEM: UCRT64
|
||||
defaults:
|
||||
run:
|
||||
shell: msys2 {0}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# gpui/ is a git submodule; its crates are workspace members of
|
||||
# their own repo and build as path dependencies of oakapp.
|
||||
submodules: true
|
||||
# ------------------------------------------------------------------
|
||||
# System dependencies
|
||||
# ------------------------------------------------------------------
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
export PATH="/ucrt64/bin:$PATH"
|
||||
bash ./tooling/install-deps.sh
|
||||
pacman -S --needed --noconfirm \
|
||||
mingw-w64-ucrt-x86_64-cmake \
|
||||
mingw-w64-ucrt-x86_64-opencolorio \
|
||||
mingw-w64-ucrt-x86_64-ffnvcodec-headers \
|
||||
mingw-w64-ucrt-x86_64-pkgconf \
|
||||
mingw-w64-ucrt-x86_64-clang mingw-w64-ucrt-x86_64-clang-libs git
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build environment
|
||||
# ------------------------------------------------------------------
|
||||
# Windows uses the MSYS2 OpenColorIO package (the exact 2.5.2 the
|
||||
# bridge targets; the vendored source needs MSVC-only constructs).
|
||||
# Dynamic here — the CD packages the DLLs next to the binaries.
|
||||
- name: Configure build environment
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
export PATH="/ucrt64/bin:$PATH"
|
||||
# System OCIO (MSYS2, the exact 2.5.2 the bridge targets);
|
||||
# tooling/ocio-env.sh links it STATICALLY when the package ships
|
||||
# libOpenColorIO.a, dynamically otherwise.
|
||||
tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
echo "FFMPEG_DIR=.cache/ffmpeg" >> "$GITHUB_ENV"
|
||||
# ocio-sys' build.rs force-adds the MSVC + Windows SDK include
|
||||
# dirs on Windows (meant for MSVC hosts); with the GNU toolchain
|
||||
# that drags MSVC-only headers into the g++ compile. Unpack the
|
||||
# crate and gate that block behind OCIO_RS_NO_MSVC_INCLUDES.
|
||||
echo "OCIO_RS_NO_MSVC_INCLUDES=1" >> "$GITHUB_ENV"
|
||||
CH=$(cygpath -u "${CARGO_HOME:-$HOME/.cargo}")
|
||||
cargo fetch --locked
|
||||
for cache in "$CH"/registry/cache/*/; do
|
||||
src="$CH/registry/src/$(basename "$cache")"
|
||||
mkdir -p "$src"
|
||||
[ -f "$cache/ocio-sys-0.2.1.crate" ] && tar xzf "$cache/ocio-sys-0.2.1.crate" -C "$src"
|
||||
done
|
||||
BS=$(ls "$CH"/registry/src/*/ocio-sys-0.2.1/build.rs)
|
||||
grep -q 'OCIO_RS_NO_MSVC_INCLUDES' "$BS" || sed -i \
|
||||
's|if cfg!(target_os = "windows") && has_real_ocio {|if cfg!(target_os = "windows") \&\& has_real_ocio \&\& std::env::var_os("OCIO_RS_NO_MSVC_INCLUDES").is_none() {|' \
|
||||
"$BS"
|
||||
grep -q 'OCIO_RS_NO_MSVC_INCLUDES' "$BS"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Caches
|
||||
# ------------------------------------------------------------------
|
||||
# Covers the whole target/ dir plus ~/.cargo; shared across branches
|
||||
# of the same OS.
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache cargo artifacts)
|
||||
# uses: Swatinem/rust-cache@v2
|
||||
# with:
|
||||
# shared-key: oak-workspace
|
||||
# cache-on-failure: true
|
||||
|
||||
# The project FFmpeg (release/8.0, static, all free codecs + hwaccel)
|
||||
# is built by tooling/ffmpeg/build-ffmpeg.sh — 10-20 min on a cold
|
||||
# cache. It does not depend on the Rust toolchain, so key it on the
|
||||
# script itself and keep it out of rust-cache.
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache project FFmpeg)
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: .cache/ffmpeg
|
||||
# key: ffmpeg-${{ runner.os }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh') }}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project FFmpeg (script + FFMPEG_DIR; see docs/build.md)
|
||||
# ------------------------------------------------------------------
|
||||
- name: Build project FFmpeg
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
export PATH="/ucrt64/bin:$PATH"
|
||||
bash tooling/ffmpeg/build-ffmpeg.sh
|
||||
echo "FFMPEG_DIR=$(cygpath -m "$PWD/.cache/ffmpeg")" >> "$GITHUB_ENV"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build & test
|
||||
# ------------------------------------------------------------------
|
||||
# `cargo check` (not build): the Test step links the test binaries
|
||||
# anyway, and a full build would codegen every workspace crate twice
|
||||
# (once without and once with cfg(test)).
|
||||
- name: Build
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
export PATH="/ucrt64/bin:$PATH"
|
||||
# The runner's job hook injects the MSVC INCLUDE/LIB into every
|
||||
# step; clear them in-step (they poison the MinGW compiles with
|
||||
# MSVC SDK headers).
|
||||
# unset INCLUDE LIB
|
||||
# mingw-w64 >= Nov 2025 forwards _assert to __msvcrt_assert inside
|
||||
# libmingwex.a; rustc's link order puts -lmingwex last, so any
|
||||
# binary that pulls _assert.o leaves _fileno/_setmode/
|
||||
# __imp___msvcrt_assert unresolved. A trailing -lmsvcrt re-scans
|
||||
# the CRT import lib after libmingwex.
|
||||
# export RUSTFLAGS="-C link-args=-lmsvcrt"
|
||||
cargo check --workspace --locked
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
export PATH="/ucrt64/bin:$PATH"
|
||||
unset INCLUDE LIB
|
||||
# See Build (Windows): trailing -lmsvcrt for the mingw-w64
|
||||
# _assert/__msvcrt_assert link-order breakage.
|
||||
export RUSTFLAGS="-C link-args=-lmsvcrt"
|
||||
if ! cargo test --workspace --locked; then
|
||||
# Retry once: a few gpui keystroke tests flake on Windows CI —
|
||||
# a synthetic keystroke is occasionally never delivered (the
|
||||
# undo/redo pair and a plain 's' toggle both failed once,
|
||||
# each identically to its pass state). A real regression
|
||||
# fails both passes.
|
||||
echo "first pass failed; retrying once for gpui keystroke flakes"
|
||||
cargo test --workspace --locked
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
# actionlint configuration (dev tooling; not used by GitHub Actions).
|
||||
#
|
||||
# WarpBuild's runner labels are not in actionlint's built-in list; declare
|
||||
# the sizes this repository uses so `actionlint` stops warning about them.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- warp-ubuntu-latest-x64-8x
|
||||
- warp-ubuntu-latest-x64-16x
|
||||
- warp-ubuntu-latest-x64-32x
|
||||
- warp-ubuntu-latest-arm64-16x
|
||||
- warp-ubuntu-latest-arm64-32x
|
||||
- warp-macos-26-arm64-6x
|
||||
- warp-macos-26-arm64-12x
|
||||
- warp-windows-2025-vs2026-x64-16x
|
||||
- warp-windows-2025-vs2026-x64-32x
|
||||
+404
-369
@@ -9,420 +9,453 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# One matrix, seven platforms, the same environments CI tests in (see
|
||||
# .github/workflows/ci.yml): Debian 13 / Fedora 43 / Arch / openKylin x64
|
||||
# and arm64 containers plus the macOS and Windows hosts. Every package is
|
||||
# built from scratch — no FFmpeg/cargo caches: a restored tree has
|
||||
# masked packaging problems before (stale ports, missing tools), and a
|
||||
# release must not depend on restored state.
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# Linux: deb + AppImage + pacman in one job. cargo-packager does not
|
||||
# support rpm (its format list is deb/appimage/pacman/nsis/dmg/app/wix),
|
||||
# and its "pacman" format emits a PKGBUILD + source tarball rather than a
|
||||
# compiled pkg.tar.zst — both are upstream limitations.
|
||||
# ------------------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# Linux: one native package per distro, each built INSIDE that
|
||||
# distro's container so the declared dependencies always resolve to
|
||||
# the distro's own package names (dpkg-shlibdeps / rpmbuild
|
||||
# auto-requires / Arch static base list). deb: hand-rolled dpkg-deb;
|
||||
# rpm: rpmbuild; arch: makepkg. AppImage stays on the Ubuntu runner
|
||||
# (self-contained by design).
|
||||
# ------------------------------------------------------------------
|
||||
linux:
|
||||
name: Linux packages (${{ matrix.distro }})
|
||||
runs-on: warp-ubuntu-latest-x64-8x
|
||||
container: ${{ matrix.image }}
|
||||
|
||||
package:
|
||||
name: Package (${{ matrix.name }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Container entries carry the container as JSON ({"image":...,
|
||||
# "options":...}); the empty string means "run on the host"
|
||||
# (actions/runner#265 allows an empty container value).
|
||||
container: ${{ matrix.container != '' && fromJSON(matrix.container) || '' }}
|
||||
# Cold FFmpeg build + release build + packaging.
|
||||
timeout-minutes: 150
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: debian
|
||||
image: debian:12
|
||||
- distro: fedora
|
||||
image: fedora:41
|
||||
- distro: arch
|
||||
image: archlinux:latest
|
||||
- name: Debian
|
||||
platform: linux
|
||||
distro: debian
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-32x
|
||||
triplet: x64-linux
|
||||
artifact: linux-debian
|
||||
container: '{"image":"debian:13","options":"--shm-size=8g"}'
|
||||
- name: Fedora
|
||||
platform: linux
|
||||
distro: fedora
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-32x
|
||||
triplet: x64-linux
|
||||
artifact: linux-fedora
|
||||
container: '{"image":"fedora:43","options":"--shm-size=8g"}'
|
||||
- name: Arch
|
||||
platform: linux
|
||||
distro: arch
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-32x
|
||||
triplet: x64-linux
|
||||
artifact: linux-arch
|
||||
container: '{"image":"archlinux:latest","options":"--shm-size=8g"}'
|
||||
- name: openKylin x64
|
||||
platform: linux
|
||||
distro: openkylin
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-32x
|
||||
triplet: x64-linux
|
||||
artifact: linux-openkylin-x64
|
||||
container: '{"image":"openkylin/openkylin:latest","options":"--shm-size=8g"}'
|
||||
- name: openKylin arm64
|
||||
platform: linux
|
||||
distro: openkylin
|
||||
arch: arm64
|
||||
runner: warp-ubuntu-latest-arm64-32x
|
||||
triplet: arm64-linux
|
||||
artifact: linux-openkylin-arm64
|
||||
container: '{"image":"openkylin/openkylin:latest","options":"--shm-size=8g"}'
|
||||
- name: macOS
|
||||
platform: macos
|
||||
distro: macos
|
||||
arch: arm64
|
||||
runner: warp-macos-26-arm64-12x
|
||||
triplet: arm64-osx
|
||||
artifact: macos
|
||||
container: ''
|
||||
- name: Windows
|
||||
platform: windows
|
||||
distro: windows
|
||||
arch: x64
|
||||
runner: warp-windows-2025-vs2026-x64-32x
|
||||
triplet: x64-windows
|
||||
artifact: windows
|
||||
container: ''
|
||||
|
||||
steps:
|
||||
# git/curl must land BEFORE actions/checkout runs inside the
|
||||
# container.
|
||||
- name: Install git and fetch tools
|
||||
# The container images are bare (Fedora/Arch even lack git);
|
||||
# checkout needs git/curl. First step of the job, so the
|
||||
# package lists are still fresh.
|
||||
- name: Bootstrap container (git, curl, wget)
|
||||
if: matrix.container != ''
|
||||
shell: bash
|
||||
run: |
|
||||
case "${{ matrix.distro }}" in
|
||||
debian) apt-get update && apt-get install -y git curl ;;
|
||||
fedora) dnf install -y git curl ;;
|
||||
arch) pacman -Sy --noconfirm git curl ;;
|
||||
fedora) dnf install -y --setopt=install_weak_deps=False --setopt=max_parallel_downloads=16 git curl wget which ;;
|
||||
arch) pacman -Sy --noconfirm git curl wget which ;;
|
||||
debian|openkylin) apt-get update && apt-get install -y git curl ca-certificates wget ;;
|
||||
esac
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# gpui/ is a git submodule; its crates are workspace members of
|
||||
# their own repo and build as path dependencies of oakapp.
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust (stable)
|
||||
# Defender's real-time scanning slows the MSVC build down
|
||||
# badly; disable it for the job and keep exclusions as the fallback
|
||||
# when policy blocks the change.
|
||||
- name: Disable Windows Defender scanning
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
try {
|
||||
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop
|
||||
Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue
|
||||
Set-MpPreference -DisableArchiveScanning $true -ErrorAction SilentlyContinue
|
||||
Write-Host "Windows Defender real-time scanning disabled for this job"
|
||||
} catch {
|
||||
Write-Host "Windows Defender could not be disabled (non-fatal, falling back to exclusions): $_"
|
||||
}
|
||||
foreach ($path in @(
|
||||
$env:GITHUB_WORKSPACE,
|
||||
"$env:USERPROFILE\.cargo",
|
||||
"$env:USERPROFILE\.rustup"
|
||||
)) {
|
||||
Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue
|
||||
}
|
||||
try {
|
||||
Get-MpPreference |
|
||||
Select-Object DisableRealtimeMonitoring, DisableScriptScanning, ExclusionPath |
|
||||
Format-List
|
||||
} catch {
|
||||
Write-Host "Defender status unavailable: $_"
|
||||
}
|
||||
|
||||
# The containers run as root but Actions sets HOME=/github/home;
|
||||
# rustup refuses the euid mismatch ("$HOME differs from
|
||||
# euid-obtained home directory") and would install a toolchain the
|
||||
# later steps cannot find under the Actions home. Pin the job to
|
||||
# root's home so rustup/cargo and the toolchain agree.
|
||||
- name: Pin HOME for rustup
|
||||
if: matrix.container != ''
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "HOME=/root"
|
||||
echo "CARGO_HOME=/root/.cargo"
|
||||
echo "RUSTUP_HOME=/root/.rustup"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
# The Windows build is MSVC-ABI (the runner carries VS 2026):
|
||||
# The prebuilt FFmpeg and the vendored OCIO build both want it.
|
||||
toolchain: ${{ matrix.platform == 'windows' && 'stable-x86_64-pc-windows-msvc' || 'stable' }}
|
||||
|
||||
- name: Install system dependencies
|
||||
# ------------------------------------------------------------------
|
||||
# System dependencies — one list per distro, byte-for-byte the same
|
||||
# lists CI uses (see .github/workflows/ci.yml): what compiles there
|
||||
# compiles here.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Install system dependencies (Debian)
|
||||
if: matrix.distro == 'debian'
|
||||
shell: bash
|
||||
run: |
|
||||
case "${{ matrix.distro }}" in
|
||||
debian)
|
||||
apt-get install -y \
|
||||
build-essential cmake pkg-config nasm dpkg-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl1-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev \
|
||||
librsvg2-bin libffnvcodec-dev
|
||||
;;
|
||||
fedora)
|
||||
dnf install -y \
|
||||
gcc gcc-c++ cmake pkgconf-pkg-config nasm \
|
||||
pipewire-devel jack-audio-connection-kit-devel \
|
||||
alsa-lib-devel pulseaudio-libs-devel libsndfile-devel \
|
||||
mesa-libGL-devel mesa-vulkan-drivers \
|
||||
vulkan-headers vulkan-loader-devel \
|
||||
libxkbcommon-devel libxkbcommon-x11-devel \
|
||||
rpm-build librsvg2-tools
|
||||
# ffnvcodec headers (NVDEC for the project FFmpeg build) are
|
||||
# not in Fedora's base repos (RPM Fusion only); they are
|
||||
# distribution-free headers, so install them from source.
|
||||
git clone --depth 1 https://git.videolan.org/git/ffmpeg/nv-codec-headers.git /tmp/nv-codec-headers
|
||||
make -C /tmp/nv-codec-headers install PREFIX=/usr
|
||||
;;
|
||||
arch)
|
||||
pacman -S --needed --noconfirm \
|
||||
base-devel cmake pkgconf nasm \
|
||||
pipewire jack2 alsa-lib libpulse libsndfile \
|
||||
mesa vulkan-headers vulkan-icd-loader \
|
||||
libxkbcommon libxkbcommon-x11 librsvg \
|
||||
ffnvcodec-headers
|
||||
;;
|
||||
esac
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
build-essential clang libclang-dev cmake pkgconf nasm \
|
||||
git curl zip unzip tar python3 dpkg-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl-dev libglvnd-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev xvfb libdrm-dev \
|
||||
icc-profiles-free gdb file librsvg2-bin patchelf fonts-dejavu-core \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
- name: Configure build environment
|
||||
- name: Install system dependencies (Fedora)
|
||||
if: matrix.distro == 'fedora'
|
||||
shell: bash
|
||||
run: |
|
||||
# tooling/ocio-env.sh: vendored static OCIO everywhere it
|
||||
# builds (the [patch.crates-io] ocio-sys tracks shaloong/ocio-rs
|
||||
# main, whose vendored sources build on GCC >= 16).
|
||||
tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
# Fedora 41 is EOL (its mirrors moved to the slow archive), so
|
||||
# the matrix uses the current release; weak dependencies (docs,
|
||||
# fonts, optional tooling) are skipped and downloads run wide.
|
||||
dnf install -y --setopt=install_weak_deps=False \
|
||||
--setopt=max_parallel_downloads=16 \
|
||||
gcc gcc-c++ clang clang-devel cmake pkgconf-pkg-config nasm \
|
||||
git curl zip unzip tar python3 patch xz which \
|
||||
pipewire-devel jack-audio-connection-kit-devel \
|
||||
alsa-lib-devel pulseaudio-libs-devel libsndfile-devel \
|
||||
mesa-libGL-devel mesa-vulkan-drivers \
|
||||
vulkan-headers vulkan-loader-devel \
|
||||
libxkbcommon-devel libxkbcommon-x11-devel \
|
||||
rpm-build librsvg2-tools libdrm-devel \
|
||||
perl-IPC-Cmd perl-FindBin perl-File-Basename perl-File-Compare \
|
||||
perl-File-Copy perl-File-Path perl-File-Temp perl-Time-Piece \
|
||||
xorg-x11-server-Xvfb xorg-x11-xauth gdb file dejavu-sans-fonts \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache cargo artifacts)
|
||||
# uses: Swatinem/rust-cache@v2
|
||||
# with:
|
||||
# shared-key: oak-${{ matrix.distro }}
|
||||
# cache-on-failure: true
|
||||
- name: Install system dependencies (Arch)
|
||||
if: matrix.distro == 'arch'
|
||||
shell: bash
|
||||
run: |
|
||||
pacman -S --needed --noconfirm \
|
||||
base-devel clang cmake pkgconf nasm \
|
||||
git curl zip unzip tar python patch xz which \
|
||||
pipewire jack2 alsa-lib libpulse libsndfile \
|
||||
mesa vulkan-headers vulkan-icd-loader \
|
||||
libxkbcommon libxkbcommon-x11 librsvg libdrm \
|
||||
xorg-server-xvfb xorg-xauth gdb file ttf-dejavu \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache project FFmpeg)
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: .cache/ffmpeg
|
||||
# key: ffmpeg-${{ matrix.distro }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh') }}
|
||||
- name: Install system dependencies (openKylin)
|
||||
if: matrix.distro == 'openkylin'
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
build-essential clang libclang-dev cmake pkg-config nasm \
|
||||
git curl zip unzip tar python3 patch xz-utils dpkg-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl1-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev xvfb libdrm-dev \
|
||||
gdb file patchelf fonts-dejavu-core \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
- name: Build project FFmpeg (static, GPL + free codecs + hwaccel)
|
||||
- name: Install system dependencies (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
# Homebrew's pkgconf installs a `pkg-config` symlink, which is
|
||||
# the name crates/oak-ffmpeg-link/build.rs invokes; nasm is what the
|
||||
# project FFmpeg build (tooling/ffmpeg/build-ffmpeg.sh) requires.
|
||||
# librsvg stays for rsvg-convert (app icon) and is bundled into
|
||||
# the .app by the dylib script.
|
||||
brew install cmake pkg-config nasm librsvg autoconf automake libtool autoconf-archive
|
||||
|
||||
# Windows uses BtbN's prebuilt FFmpeg (GPL, shared): the archive
|
||||
# ships include/, MSVC import libs (.lib), pkg-config files and the
|
||||
# runtime DLLs, built from FFmpeg's release/8.1 branch by BtbN's
|
||||
# public GitHub Actions (BtbN is an FFmpeg developer and ffmpeg.org
|
||||
# links these builds). Downloaded from his release page and
|
||||
# verified against the checksums.sha256 published in the same
|
||||
# release; nothing is mirrored here, so provenance stays upstream.
|
||||
# The alternative — a source build — costs ~40 minutes per run on
|
||||
# this platform.
|
||||
- name: Install prebuilt FFmpeg (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$base = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
|
||||
$asset = "ffmpeg-n8.1-latest-win64-gpl-shared-8.1.zip"
|
||||
$dir = Join-Path $env:RUNNER_TEMP "ffmpeg-prebuilt"
|
||||
New-Item -ItemType Directory -Force $dir | Out-Null
|
||||
Invoke-WebRequest -Uri "$base/checksums.sha256" -OutFile "$dir\checksums.sha256"
|
||||
$line = Select-String -Path "$dir\checksums.sha256" -Pattern ([regex]::Escape($asset) + "\s*$") |
|
||||
Select-Object -First 1
|
||||
if (-not $line) { throw "no checksum for $asset in the release's checksums.sha256" }
|
||||
$expected = $line.Line.Split()[0].ToLowerInvariant()
|
||||
Invoke-WebRequest -Uri "$base/$asset" -OutFile "$dir\$asset"
|
||||
$actual = (Get-FileHash "$dir\$asset" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) { throw "FFmpeg checksum mismatch: expected $expected, got $actual" }
|
||||
$root = "$env:GITHUB_WORKSPACE\.cache"
|
||||
Remove-Item "$root\ffmpeg", "$root\ffmpeg-extract" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive -Path "$dir\$asset" -DestinationPath "$root\ffmpeg-extract" -Force
|
||||
$inner = Get-ChildItem "$root\ffmpeg-extract" -Directory | Select-Object -First 1
|
||||
Move-Item $inner.FullName "$root\ffmpeg"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FFmpeg — built from source with the distro's codec packages
|
||||
# (Linux/macOS) or the prebuilt archive (Windows), no caches
|
||||
# ------------------------------------------------------------------
|
||||
# The Linux/macOS jobs follow the local-build path
|
||||
# (tooling/install-deps.sh + tooling/ffmpeg/build-ffmpeg.sh); the
|
||||
# release policy is a from-scratch build, so nothing is restored
|
||||
# from a cache here.
|
||||
- name: Install FFmpeg dependencies
|
||||
if: matrix.platform != 'windows'
|
||||
shell: bash
|
||||
run: tooling/install-deps.sh
|
||||
|
||||
- name: Build FFmpeg
|
||||
if: matrix.platform != 'windows'
|
||||
shell: bash
|
||||
run: tooling/ffmpeg/build-ffmpeg.sh
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build environment
|
||||
# ------------------------------------------------------------------
|
||||
# ocio-sys builds a stub bridge unless these are set; the oak-core
|
||||
# ocioutils tests need the real library.
|
||||
# tooling/ocio-env.sh: vendored static OCIO (the [patch.crates-io]
|
||||
# ocio-sys tracks shaloong/ocio-rs main, whose vendored sources build
|
||||
# on GCC >= 16).
|
||||
- name: Configure build environment (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "CC=clang"
|
||||
echo "CXX=clang++"
|
||||
} >> "$GITHUB_ENV"
|
||||
bash tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
ffmpeg="$PWD/.cache/ffmpeg"
|
||||
echo "FFMPEG_DIR=$ffmpeg" >> "$GITHUB_ENV"
|
||||
echo "PKG_CONFIG_PATH=$ffmpeg/lib/pkgconfig" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure build environment (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
run: |
|
||||
# Vendored static OCIO (same as every non-Windows platform via
|
||||
# tooling/ocio-env.sh); no OCIO_INSTALL_DIR override.
|
||||
bash tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
ffmpeg="$PWD/.cache/ffmpeg"
|
||||
echo "FFMPEG_DIR=$ffmpeg" >> "$GITHUB_ENV"
|
||||
echo "PKG_CONFIG_PATH=$ffmpeg/lib/pkgconfig" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure build environment (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# FFmpeg comes from the prebuilt archive extracted above.
|
||||
$ffmpeg = "$env:GITHUB_WORKSPACE\.cache\ffmpeg"
|
||||
"FFMPEG_DIR=$ffmpeg" >> $env:GITHUB_ENV
|
||||
"PKG_CONFIG_PATH=$ffmpeg\lib\pkgconfig" >> $env:GITHUB_ENV
|
||||
"$ffmpeg\bin" >> $env:GITHUB_PATH
|
||||
# Bundled OCIO: ocio-sys' vendored sources build with the MSVC
|
||||
# toolchain (what they need — the MSYS2 package was the
|
||||
# workaround, not the preference), so no OCIO_INSTALL_DIR and
|
||||
# no OCIO_RS_NO_MSVC_INCLUDES anywhere.
|
||||
"OCIO_RS_ENABLE_REAL=1" >> $env:GITHUB_ENV
|
||||
"OCIO_RS_LINK=static" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Install cargo-packager
|
||||
if: matrix.distro == 'debian' || matrix.platform != 'linux'
|
||||
run: cargo install cargo-packager --locked
|
||||
|
||||
- name: Generate app icon (PNG from Oak_Icon.svg)
|
||||
if: matrix.platform != 'windows'
|
||||
run: |
|
||||
mkdir -p icons
|
||||
if command -v rsvg-convert >/dev/null 2>&1; then
|
||||
rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
|
||||
else
|
||||
# Defensive: some containers may not carry an SVG renderer;
|
||||
# fall back to the committed 512x512 render.
|
||||
cp assets/app-icon.png icons/icon.png
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build
|
||||
# ------------------------------------------------------------------
|
||||
# Default dynamic CRT on Windows: the vendored OCIO is compiled /MD,
|
||||
# so forcing Rust to /MT fails with LNK2038 'RuntimeLibrary'
|
||||
# mismatch; the redistributable DLLs ship with the installer below.
|
||||
- name: Build (release)
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Generate app icon (PNG from Oak_Icon.svg)
|
||||
run: rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
|
||||
|
||||
- name: Package
|
||||
# ------------------------------------------------------------------
|
||||
# Package
|
||||
# ------------------------------------------------------------------
|
||||
- name: Package (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The release version lives in [workspace.package] of the root
|
||||
# Cargo.toml (single source of truth; tags do not carry it).
|
||||
VERSION=$(sed -n '/^\[workspace\.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
case "${{ matrix.distro }}" in
|
||||
debian) tooling/package/build-deb.sh "$VERSION" ;;
|
||||
fedora) tooling/package/build-rpm.sh "$VERSION" ;;
|
||||
arch) tooling/package/build-pkg.sh "$VERSION" ;;
|
||||
debian)
|
||||
# The general Debian-family package, labeled "+debian".
|
||||
tooling/package/build-deb.sh "$VERSION" debian
|
||||
# appimagetool self-extracts instead of mounting (containers
|
||||
# have no FUSE).
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 cargo packager --release --formats appimage
|
||||
;;
|
||||
fedora)
|
||||
tooling/package/build-rpm.sh "$VERSION"
|
||||
;;
|
||||
arch)
|
||||
tooling/package/build-pkg.sh "$VERSION"
|
||||
;;
|
||||
openkylin)
|
||||
# The openKylin build, labeled "+openkylin"; dpkg-shlibdeps
|
||||
# resolves the runtime deps against openKylin's own repos.
|
||||
tooling/package/build-deb.sh "$VERSION" openkylin
|
||||
;;
|
||||
esac
|
||||
shell: bash
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oak-linux-${{ matrix.distro }}
|
||||
path: |
|
||||
target/release/*.deb
|
||||
target/release/*.rpm
|
||||
target/release/*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AppImage (self-contained; cargo-packager on the Ubuntu runner).
|
||||
# ------------------------------------------------------------------
|
||||
appimage:
|
||||
name: Linux AppImage
|
||||
runs-on: warp-ubuntu-latest-x64-8x
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
tooling/install-deps.sh
|
||||
sudo apt-get install -y \
|
||||
cmake librsvg2-bin \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl1-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev \
|
||||
libffnvcodec-dev
|
||||
|
||||
- name: Configure build environment
|
||||
run: tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache cargo artifacts)
|
||||
# uses: Swatinem/rust-cache@v2
|
||||
# with:
|
||||
# shared-key: oak-appimage
|
||||
# cache-on-failure: true
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache project FFmpeg)
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: .cache/ffmpeg
|
||||
# key: ffmpeg-appimage-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh') }}
|
||||
|
||||
- name: Build project FFmpeg (static, GPL + free codecs + hwaccel)
|
||||
run: tooling/ffmpeg/build-ffmpeg.sh
|
||||
|
||||
- name: Install cargo-packager
|
||||
run: cargo install cargo-packager --locked
|
||||
|
||||
- name: Generate app icon (PNG from Oak_Icon.svg)
|
||||
run: |
|
||||
mkdir -p icons
|
||||
rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
|
||||
file icons/icon.png
|
||||
|
||||
- name: Build (release)
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Package (AppImage)
|
||||
run: cargo packager --release --formats appimage
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oak-linux-appimage
|
||||
path: target/release/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS DMG (Apple Silicon)
|
||||
runs-on: warp-macos-15-arm64-6x
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
tooling/install-deps.sh
|
||||
brew install cmake librsvg
|
||||
|
||||
- name: Configure build environment
|
||||
run: |
|
||||
# Vendored static OCIO (same as every non-Windows platform via
|
||||
# tooling/ocio-env.sh); no OCIO_INSTALL_DIR override.
|
||||
tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
{
|
||||
echo "CFLAGS=-I/opt/homebrew/include"
|
||||
echo "LDFLAGS=-L/opt/homebrew/lib"
|
||||
echo "PKG_CONFIG_PATH=/opt/homebrew/lib/pkgconfig/openjpeg"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache cargo artifacts)
|
||||
# uses: Swatinem/rust-cache@v2
|
||||
# with:
|
||||
# shared-key: oak-workspace
|
||||
# cache-on-failure: true
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache project FFmpeg)
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: .cache/ffmpeg
|
||||
# key: ffmpeg-${{ runner.os }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh') }}
|
||||
|
||||
- name: Build project FFmpeg (static, GPL + free codecs + hwaccel)
|
||||
run: |
|
||||
tooling/ffmpeg/build-ffmpeg.sh
|
||||
echo "FFMPEG_DIR=$PWD/.cache/ffmpeg" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install cargo-packager
|
||||
run: cargo install cargo-packager --locked
|
||||
|
||||
- name: Generate app icon (PNG from Oak_Icon.svg)
|
||||
run: |
|
||||
mkdir -p icons
|
||||
# cargo-packager's tauri-icns 0.1.0 maps only 512x512@1x (and
|
||||
# 1024x1024@2x); a plain 1024x1024 PNG aborts with "No matching
|
||||
# IconType", so render 512x512.
|
||||
rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
|
||||
file icons/icon.png
|
||||
|
||||
# Build the packaged binaries (default members: the app, oak-cli,
|
||||
# oak-worker).
|
||||
- name: Build (release)
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Package .app bundle
|
||||
run: cargo packager --release --formats app
|
||||
|
||||
# Pull the Homebrew dylibs the binaries reference into
|
||||
# Contents/Frameworks and rewrite install names to
|
||||
# @executable_path-relative (the script ad-hoc re-signs the bundle).
|
||||
- name: Bundle dylibs into the .app
|
||||
run: tooling/package/bundle-dylibs-macos.sh target/release/Oak.app
|
||||
|
||||
- name: Create DMG
|
||||
- name: Package (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
cargo packager --release --formats app
|
||||
# cargo-packager names the bundle after the packager
|
||||
# `productName` ("Oak Video Editor.app"), so resolve it instead
|
||||
# of guessing.
|
||||
APP="$(ls -d target/release/*.app | head -1)"
|
||||
tooling/package/bundle-dylibs-macos.sh "$APP"
|
||||
rm -rf dmg-staging
|
||||
mkdir -p dmg-staging
|
||||
cp -R target/release/Oak.app dmg-staging/
|
||||
cp -R "$APP" dmg-staging/
|
||||
ln -s /Applications dmg-staging/Applications
|
||||
hdiutil create -volname "Oak Video Editor" \
|
||||
-srcfolder dmg-staging -ov -format UDZO Oak-macOS-arm64.dmg
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oak-macos
|
||||
path: Oak-macOS-arm64.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows: NSIS installer (restored; cargo-packager downloads its own
|
||||
# makensis, SHA-1 verified). The obsolete `-p oakengine` cdylib prebuild
|
||||
# from before M14 R4 is dropped — no packaged binary links the cdylib.
|
||||
# ------------------------------------------------------------------
|
||||
windows:
|
||||
name: Windows installer (NSIS)
|
||||
runs-on: warp-windows-latest-x64-16x
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: msys2 {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Setup MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# MSYS2's own Rust targets x86_64-pc-windows-gnu by default —
|
||||
# the Windows build is GNU-target (the MSVC linker rejects the
|
||||
# Unix-style link args the build scripts emit).
|
||||
install: >-
|
||||
git
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-rust
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
bash tooling/install-deps.sh
|
||||
pacman -S --needed --noconfirm \
|
||||
mingw-w64-ucrt-x86_64-cmake \
|
||||
mingw-w64-ucrt-x86_64-opencolorio \
|
||||
mingw-w64-ucrt-x86_64-librsvg \
|
||||
mingw-w64-ucrt-x86_64-ffnvcodec-headers \
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
|
||||
- name: Configure build environment
|
||||
run: |
|
||||
# System OCIO (MSYS2, the exact 2.5.2 the bridge targets);
|
||||
# tooling/ocio-env.sh links it STATICALLY when the package ships
|
||||
# libOpenColorIO.a, dynamically otherwise (the DLL bundling step
|
||||
# below then still applies).
|
||||
tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
# ocio-sys' build.rs force-adds the MSVC + Windows SDK include
|
||||
# dirs on Windows (meant for MSVC hosts); with the GNU toolchain
|
||||
# that drags MSVC-only headers into the g++ compile. Unpack the
|
||||
# crate and gate that block behind OCIO_RS_NO_MSVC_INCLUDES.
|
||||
echo "OCIO_RS_NO_MSVC_INCLUDES=1" >> "$GITHUB_ENV"
|
||||
CH=$(cygpath -u "${CARGO_HOME:-$HOME/.cargo}")
|
||||
cargo fetch --locked
|
||||
for cache in "$CH"/registry/cache/*/; do
|
||||
src="$CH/registry/src/$(basename "$cache")"
|
||||
mkdir -p "$src"
|
||||
[ -f "$cache/ocio-sys-0.2.1.crate" ] && tar xzf "$cache/ocio-sys-0.2.1.crate" -C "$src"
|
||||
done
|
||||
BS=$(ls "$CH"/registry/src/*/ocio-sys-0.2.1/build.rs)
|
||||
grep -q 'OCIO_RS_NO_MSVC_INCLUDES' "$BS" || sed -i \
|
||||
's|if cfg!(target_os = "windows") && has_real_ocio {|if cfg!(target_os = "windows") \&\& has_real_ocio \&\& std::env::var_os("OCIO_RS_NO_MSVC_INCLUDES").is_none() {|' \
|
||||
"$BS"
|
||||
grep -q 'OCIO_RS_NO_MSVC_INCLUDES' "$BS"
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache cargo artifacts)
|
||||
# uses: Swatinem/rust-cache@v2
|
||||
# with:
|
||||
# shared-key: oak-workspace
|
||||
# cache-on-failure: true
|
||||
|
||||
# TEMP: cache disabled until the Gitea instance cache is provisioned (Cache project FFmpeg)
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: .cache/ffmpeg
|
||||
# key: ffmpeg-${{ runner.os }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh') }}
|
||||
|
||||
- name: Build project FFmpeg (static, GPL + free codecs + hwaccel)
|
||||
run: |
|
||||
bash tooling/ffmpeg/build-ffmpeg.sh
|
||||
echo "FFMPEG_DIR=$(cygpath -m "$PWD/.cache/ffmpeg")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install cargo-packager
|
||||
run: cargo install cargo-packager --locked
|
||||
|
||||
- name: Generate app icon (PNG from Oak_Icon.svg)
|
||||
run: |
|
||||
mkdir -p icons
|
||||
rsvg-convert -w 512 -h 512 Oak_Icon.svg -o icons/icon.png
|
||||
|
||||
- name: Build (release)
|
||||
run: |
|
||||
# Clear the job-hook-injected MSVC INCLUDE/LIB before the GNU
|
||||
# build (they poison the MinGW compiles with MSVC SDK headers).
|
||||
unset INCLUDE LIB
|
||||
cargo build --release --locked
|
||||
|
||||
# Collect the MSYS2 runtime DLLs (libstdc++/libgcc/OpenColorIO/...)
|
||||
# into target/pkg/win-dlls; the packager `resources` glob then
|
||||
# installs them next to the executables.
|
||||
- name: Bundle runtime DLLs
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
unset INCLUDE LIB
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-ntldd
|
||||
tooling/package/bundle-dylibs-windows.sh target/pkg/win-dlls \
|
||||
target/release/oak-editor.exe target/release/oak-cli.exe \
|
||||
target/release/oak-worker.exe
|
||||
New-Item -ItemType Directory -Force target/pkg/win-dlls | Out-Null
|
||||
# FFmpeg + codec DLLs from the prebuilt archive (see above).
|
||||
Copy-Item "$env:GITHUB_WORKSPACE\.cache\ffmpeg\bin\*.dll" target/pkg/win-dlls/
|
||||
# The MSVC runtime: the build keeps the default dynamic CRT (see
|
||||
# the Build step), so ship the redistributable DLLs app-locally.
|
||||
$crt = Get-ChildItem "$env:ProgramFiles\Microsoft Visual Studio\*\*\VC\Redist\MSVC\*\x64\Microsoft.VC*.CRT" -Directory -ErrorAction SilentlyContinue |
|
||||
Sort-Object FullName | Select-Object -Last 1
|
||||
if (-not $crt) { throw "MSVC CRT redist directory not found" }
|
||||
Copy-Item "$($crt.FullName)\*.dll" target/pkg/win-dlls/
|
||||
|
||||
- name: Package (NSIS)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: cargo packager --release --formats nsis
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Upload
|
||||
# ------------------------------------------------------------------
|
||||
- name: Stage artifacts
|
||||
if: matrix.platform != 'windows'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/release/*.deb dist/ 2>/dev/null || true
|
||||
cp target/release/*.rpm dist/ 2>/dev/null || true
|
||||
cp target/release/*.pkg.tar.zst dist/ 2>/dev/null || true
|
||||
cp target/release/*.AppImage dist/ 2>/dev/null || true
|
||||
cp ./*.dmg dist/ 2>/dev/null || true
|
||||
ls -la dist
|
||||
|
||||
- name: Stage artifacts (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force dist | Out-Null
|
||||
Copy-Item target/release/*-setup.exe dist/
|
||||
Get-ChildItem dist
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: oak-windows
|
||||
path: target/release/*-setup.exe
|
||||
name: oak-${{ matrix.artifact }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -431,21 +464,23 @@ jobs:
|
||||
# ------------------------------------------------------------------
|
||||
release:
|
||||
name: Publish GitHub release
|
||||
needs: [linux, appimage, macos, windows]
|
||||
needs: [package]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: warp-ubuntu-latest-x64-8x
|
||||
runs-on: warp-ubuntu-latest-x64-32x
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
# Drafts only: a human reviews the assets and publishes the
|
||||
# release deliberately (a published version must not be silently
|
||||
# replaced, and the repo protects v* tags from being moved).
|
||||
draft: true
|
||||
files: artifacts/*
|
||||
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One matrix, seven platforms: the three Linux-family packaging distros
|
||||
# (Debian 13, Fedora 43, Arch), openKylin on x64 and arm64, macOS and
|
||||
# Windows. The environments match .github/workflows/cd.yml exactly (same
|
||||
# container images, same dependency lists, same runner sizes), so a
|
||||
# "passes CI, fails CD" dependency drift is caught here first.
|
||||
jobs:
|
||||
build-test:
|
||||
name: Build & test (${{ matrix.name }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Container entries carry the container as JSON ({"image":...,
|
||||
# "options":...}); the empty string means "run on the host"
|
||||
# (actions/runner#265 allows an empty container value).
|
||||
container: ${{ matrix.container != '' && fromJSON(matrix.container) || '' }}
|
||||
# The Test step's watchdog caps a hung suite at 30 min; leave a cold
|
||||
# FFmpeg build + full compile room beyond that.
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Debian
|
||||
platform: linux
|
||||
distro: debian
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-8x
|
||||
triplet: x64-linux
|
||||
container: '{"image":"debian:13","options":"--shm-size=8g"}'
|
||||
- name: Fedora
|
||||
platform: linux
|
||||
distro: fedora
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-8x
|
||||
triplet: x64-linux
|
||||
container: '{"image":"fedora:43","options":"--shm-size=8g"}'
|
||||
- name: Arch
|
||||
platform: linux
|
||||
distro: arch
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-8x
|
||||
triplet: x64-linux
|
||||
container: '{"image":"archlinux:latest","options":"--shm-size=8g"}'
|
||||
- name: openKylin x64
|
||||
platform: linux
|
||||
distro: openkylin
|
||||
arch: x64
|
||||
runner: warp-ubuntu-latest-x64-8x
|
||||
triplet: x64-linux
|
||||
container: '{"image":"openkylin/openkylin:latest","options":"--shm-size=8g"}'
|
||||
- name: openKylin arm64
|
||||
platform: linux
|
||||
distro: openkylin
|
||||
arch: arm64
|
||||
runner: warp-ubuntu-latest-arm64-16x
|
||||
triplet: arm64-linux
|
||||
container: '{"image":"openkylin/openkylin:latest","options":"--shm-size=8g"}'
|
||||
- name: macOS
|
||||
platform: macos
|
||||
distro: macos
|
||||
arch: arm64
|
||||
runner: warp-macos-26-arm64-12x
|
||||
triplet: arm64-osx
|
||||
container: ''
|
||||
- name: Windows
|
||||
platform: windows
|
||||
distro: windows
|
||||
arch: x64
|
||||
runner: warp-windows-2025-vs2026-x64-32x
|
||||
triplet: x64-windows
|
||||
container: ''
|
||||
|
||||
steps:
|
||||
# The container images are bare (Fedora/Arch even lack git);
|
||||
# checkout needs git/curl, and WarpCache needs wget inside
|
||||
# a container (its README requires it). First step of the job, so
|
||||
# the package lists are still fresh.
|
||||
- name: Bootstrap container (git, curl, wget)
|
||||
if: matrix.container != ''
|
||||
shell: bash
|
||||
run: |
|
||||
case "${{ matrix.distro }}" in
|
||||
fedora) dnf install -y --setopt=install_weak_deps=False --setopt=max_parallel_downloads=16 git curl wget which ;;
|
||||
arch) pacman -Sy --noconfirm git curl wget which ;;
|
||||
debian|openkylin) apt-get update && apt-get install -y git curl ca-certificates wget ;;
|
||||
esac
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# gpui/ is a git submodule; its crates are workspace members of
|
||||
# their own repo and build as path dependencies of oakapp.
|
||||
submodules: true
|
||||
|
||||
# Taste the disk before the toolchains land: Defender scans every
|
||||
# file the cargo/FFmpeg builds touch (tens of thousands of small
|
||||
# writes), which dominates a cold Windows build. The runner is an
|
||||
# ephemeral VM, so the scanner is turned off for the job
|
||||
# (exclusions are kept as a fallback for images where real-time
|
||||
# protection cannot be disabled).
|
||||
- name: Disable Windows Defender scanning
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
try {
|
||||
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop
|
||||
Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue
|
||||
Set-MpPreference -DisableArchiveScanning $true -ErrorAction SilentlyContinue
|
||||
Write-Host "Windows Defender real-time scanning disabled for this job"
|
||||
} catch {
|
||||
Write-Host "Windows Defender could not be disabled (non-fatal, falling back to exclusions): $_"
|
||||
}
|
||||
foreach ($path in @(
|
||||
$env:GITHUB_WORKSPACE,
|
||||
"$env:USERPROFILE\.cargo",
|
||||
"$env:USERPROFILE\.rustup"
|
||||
)) {
|
||||
Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue
|
||||
}
|
||||
try {
|
||||
Get-MpPreference |
|
||||
Select-Object DisableRealtimeMonitoring, DisableScriptScanning, ExclusionPath |
|
||||
Format-List
|
||||
} catch {
|
||||
Write-Host "Defender status unavailable: $_"
|
||||
}
|
||||
|
||||
# The containers run as root but Actions sets HOME=/github/home;
|
||||
# rustup refuses the euid mismatch ("$HOME differs from
|
||||
# euid-obtained home directory") and would install a toolchain the
|
||||
# later steps cannot find under the Actions home. Pin the job to
|
||||
# root's home so rustup/cargo and the toolchain agree.
|
||||
- name: Pin HOME for rustup
|
||||
if: matrix.container != ''
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "HOME=/root"
|
||||
echo "CARGO_HOME=/root/.cargo"
|
||||
echo "RUSTUP_HOME=/root/.rustup"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
# The Windows build is MSVC-ABI (the runner carries VS 2026):
|
||||
# The prebuilt FFmpeg and the vendored OCIO build both want it.
|
||||
toolchain: ${{ matrix.platform == 'windows' && 'stable-x86_64-pc-windows-msvc' || 'stable' }}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# System dependencies — one list per distro, byte-for-byte the same
|
||||
# lists CD uses (see .github/workflows/cd.yml): a package a CD build
|
||||
# needs cannot be missing here.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Install system dependencies (Debian)
|
||||
if: matrix.distro == 'debian'
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
build-essential clang libclang-dev cmake pkgconf nasm \
|
||||
git curl zip unzip tar python3 dpkg-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl-dev libglvnd-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev xvfb libdrm-dev \
|
||||
icc-profiles-free gdb file librsvg2-bin patchelf fonts-dejavu-core \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
- name: Install system dependencies (Fedora)
|
||||
if: matrix.distro == 'fedora'
|
||||
shell: bash
|
||||
run: |
|
||||
# Fedora 41 is EOL (its mirrors moved to the slow archive), so
|
||||
# the matrix uses the current release; weak dependencies (docs,
|
||||
# fonts, optional tooling) are skipped and downloads run wide.
|
||||
dnf install -y --setopt=install_weak_deps=False \
|
||||
--setopt=max_parallel_downloads=16 \
|
||||
gcc gcc-c++ clang clang-devel cmake pkgconf-pkg-config nasm \
|
||||
git curl zip unzip tar python3 patch xz which \
|
||||
pipewire-devel jack-audio-connection-kit-devel \
|
||||
alsa-lib-devel pulseaudio-libs-devel libsndfile-devel \
|
||||
mesa-libGL-devel mesa-vulkan-drivers \
|
||||
vulkan-headers vulkan-loader-devel \
|
||||
libxkbcommon-devel libxkbcommon-x11-devel \
|
||||
rpm-build librsvg2-tools libdrm-devel \
|
||||
perl-IPC-Cmd perl-FindBin perl-File-Basename perl-File-Compare \
|
||||
perl-File-Copy perl-File-Path perl-File-Temp perl-Time-Piece \
|
||||
xorg-x11-server-Xvfb xorg-x11-xauth gdb file dejavu-sans-fonts \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
- name: Install system dependencies (Arch)
|
||||
if: matrix.distro == 'arch'
|
||||
shell: bash
|
||||
run: |
|
||||
pacman -S --needed --noconfirm \
|
||||
base-devel clang cmake pkgconf nasm \
|
||||
git curl zip unzip tar python patch xz which \
|
||||
pipewire jack2 alsa-lib libpulse libsndfile \
|
||||
mesa vulkan-headers vulkan-icd-loader \
|
||||
libxkbcommon libxkbcommon-x11 librsvg libdrm \
|
||||
xorg-server-xvfb xorg-xauth gdb file ttf-dejavu \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
- name: Install system dependencies (openKylin)
|
||||
if: matrix.distro == 'openkylin'
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
build-essential clang libclang-dev cmake pkg-config nasm \
|
||||
git curl zip unzip tar python3 patch xz-utils dpkg-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libjack-jackd2-dev \
|
||||
libasound2-dev libpulse-dev libsndfile1-dev \
|
||||
libgl1-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
libvulkan-dev libxkbcommon-dev libxkbcommon-x11-dev xvfb libdrm-dev \
|
||||
gdb file patchelf fonts-dejavu-core \
|
||||
autoconf autoconf-archive automake libtool
|
||||
|
||||
- name: Install system dependencies (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
# Homebrew's pkgconf installs a `pkg-config` symlink, which is
|
||||
# the name crates/oak-ffmpeg-link/build.rs invokes; nasm is what the
|
||||
# project FFmpeg build (tooling/ffmpeg/build-ffmpeg.sh) requires.
|
||||
# librsvg is CD's icon renderer.
|
||||
brew install cmake pkg-config nasm librsvg autoconf automake libtool autoconf-archive
|
||||
|
||||
# Windows uses BtbN's prebuilt FFmpeg (GPL, shared): the archive
|
||||
# ships include/, MSVC import libs (.lib), pkg-config files and the
|
||||
# runtime DLLs, built from FFmpeg's release/8.1 branch by BtbN's
|
||||
# public GitHub Actions (BtbN is an FFmpeg developer and ffmpeg.org
|
||||
# links these builds). Downloaded from his release page and
|
||||
# verified against the checksums.sha256 published in the same
|
||||
# release; nothing is mirrored here, so provenance stays upstream.
|
||||
# The alternative — a source build — costs ~40 minutes per run on
|
||||
# this platform.
|
||||
- name: Install prebuilt FFmpeg (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$base = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
|
||||
$asset = "ffmpeg-n8.1-latest-win64-gpl-shared-8.1.zip"
|
||||
$dir = Join-Path $env:RUNNER_TEMP "ffmpeg-prebuilt"
|
||||
New-Item -ItemType Directory -Force $dir | Out-Null
|
||||
Invoke-WebRequest -Uri "$base/checksums.sha256" -OutFile "$dir\checksums.sha256"
|
||||
$line = Select-String -Path "$dir\checksums.sha256" -Pattern ([regex]::Escape($asset) + "\s*$") |
|
||||
Select-Object -First 1
|
||||
if (-not $line) { throw "no checksum for $asset in the release's checksums.sha256" }
|
||||
$expected = $line.Line.Split()[0].ToLowerInvariant()
|
||||
Invoke-WebRequest -Uri "$base/$asset" -OutFile "$dir\$asset"
|
||||
$actual = (Get-FileHash "$dir\$asset" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) { throw "FFmpeg checksum mismatch: expected $expected, got $actual" }
|
||||
$root = "$env:GITHUB_WORKSPACE\.cache"
|
||||
Remove-Item "$root\ffmpeg", "$root\ffmpeg-extract" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive -Path "$dir\$asset" -DestinationPath "$root\ffmpeg-extract" -Force
|
||||
$inner = Get-ChildItem "$root\ffmpeg-extract" -Directory | Select-Object -First 1
|
||||
Move-Item $inner.FullName "$root\ffmpeg"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FFmpeg + caches
|
||||
# ------------------------------------------------------------------
|
||||
# The Linux/macOS jobs build the project FFmpeg from source with the
|
||||
# distro's codec packages — the same path local builds use
|
||||
# (tooling/install-deps.sh + tooling/ffmpeg/build-ffmpeg.sh; the
|
||||
# latter also drives the vendored static OCIO). Windows uses the
|
||||
# prebuilt archive downloaded above.
|
||||
- name: Install FFmpeg dependencies
|
||||
if: matrix.platform != 'windows'
|
||||
shell: bash
|
||||
run: tooling/install-deps.sh
|
||||
|
||||
# The built FFmpeg is cached as a whole, keyed on distro, arch and
|
||||
# BOTH configure inputs — build-ffmpeg.sh and install-deps.sh (the
|
||||
# enable_if_pkg probes depend on which packages that script
|
||||
# installed). There is deliberately no bare distro/arch restore-key:
|
||||
# it once restored an openh264-linked FFmpeg after the codec was
|
||||
# dropped, and the `always()` save re-saved that stale tree under
|
||||
# the new key, breaking every later macOS link (`-lopenh264` not
|
||||
# found). A changed configure input must cost a ~10 minute rebuild,
|
||||
# never a mismatched cache. The `.build-complete` marker
|
||||
# distinguishes a finished build from a partial cache save; without
|
||||
# it the tree is rebuilt.
|
||||
- name: Restore FFmpeg (WarpCache)
|
||||
if: matrix.platform == 'linux'
|
||||
id: ffmpeg-cache
|
||||
uses: WarpBuilds/cache/restore@v2
|
||||
# A job container does not inherit the runner environment;
|
||||
# WarpCache authenticates with this token (README: "Running
|
||||
# inside a container").
|
||||
env:
|
||||
WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }}
|
||||
with:
|
||||
path: .cache/ffmpeg
|
||||
key: ffmpeg-${{ matrix.distro }}-${{ matrix.arch }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh', 'tooling/install-deps.sh') }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
ffmpeg-${{ matrix.distro }}-${{ matrix.arch }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh', 'tooling/install-deps.sh') }}-
|
||||
|
||||
- name: Restore FFmpeg (GitHub)
|
||||
if: matrix.platform == 'macos'
|
||||
id: ffmpeg-cache-github
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .cache/ffmpeg
|
||||
key: ffmpeg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh', 'tooling/install-deps.sh') }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
ffmpeg-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('tooling/ffmpeg/build-ffmpeg.sh', 'tooling/install-deps.sh') }}-
|
||||
|
||||
- name: Build FFmpeg
|
||||
if: matrix.platform != 'windows'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f .cache/ffmpeg/.build-complete ]; then
|
||||
echo "FFmpeg restored from cache; skipping the build"
|
||||
else
|
||||
rm -rf .cache/ffmpeg
|
||||
tooling/ffmpeg/build-ffmpeg.sh
|
||||
# The cargo cache may hold ffmpeg-sys-next rlibs that bundled
|
||||
# the previous archives (cargo does not track archive
|
||||
# changes); flag a refresh so the build relinks the new one.
|
||||
echo "FFMPEG_REBUILT=1" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Save FFmpeg (WarpCache)
|
||||
if: always() && matrix.platform == 'linux'
|
||||
uses: WarpBuilds/cache/save@v2
|
||||
env:
|
||||
WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }}
|
||||
with:
|
||||
path: .cache/ffmpeg
|
||||
key: ${{ steps.ffmpeg-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save FFmpeg (GitHub)
|
||||
if: always() && matrix.platform == 'macos'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: .cache/ffmpeg
|
||||
key: ${{ steps.ffmpeg-cache-github.outputs.cache-primary-key }}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build environment
|
||||
# ------------------------------------------------------------------
|
||||
# ocio-sys builds a stub bridge unless these are set; the oak-core
|
||||
# ocioutils tests need the real library.
|
||||
# tooling/ocio-env.sh: vendored static OCIO (the [patch.crates-io]
|
||||
# ocio-sys tracks shaloong/ocio-rs main, whose vendored sources build
|
||||
# on GCC >= 16).
|
||||
- name: Configure build environment (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "CC=clang"
|
||||
echo "CXX=clang++"
|
||||
} >> "$GITHUB_ENV"
|
||||
bash tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
ffmpeg="$PWD/.cache/ffmpeg"
|
||||
echo "FFMPEG_DIR=$ffmpeg" >> "$GITHUB_ENV"
|
||||
echo "PKG_CONFIG_PATH=$ffmpeg/lib/pkgconfig" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure build environment (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
run: |
|
||||
# Vendored static OCIO (same as every non-Windows platform via
|
||||
# tooling/ocio-env.sh); no OCIO_INSTALL_DIR override.
|
||||
bash tooling/ocio-env.sh >> "$GITHUB_ENV"
|
||||
ffmpeg="$PWD/.cache/ffmpeg"
|
||||
echo "FFMPEG_DIR=$ffmpeg" >> "$GITHUB_ENV"
|
||||
echo "PKG_CONFIG_PATH=$ffmpeg/lib/pkgconfig" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure build environment (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# FFmpeg comes from the prebuilt archive extracted above.
|
||||
$ffmpeg = "$env:GITHUB_WORKSPACE\.cache\ffmpeg"
|
||||
"FFMPEG_DIR=$ffmpeg" >> $env:GITHUB_ENV
|
||||
"PKG_CONFIG_PATH=$ffmpeg\lib\pkgconfig" >> $env:GITHUB_ENV
|
||||
# The test binaries load the FFmpeg DLLs: without this the
|
||||
# runner reports STATUS_DLL_NOT_FOUND when the first test
|
||||
# executable starts.
|
||||
"$ffmpeg\bin" >> $env:GITHUB_PATH
|
||||
# Bundled OCIO: ocio-sys' vendored sources build with the MSVC
|
||||
# toolchain (what they need — the MSYS2 package was the
|
||||
# workaround, not the preference), so no OCIO_INSTALL_DIR and
|
||||
# no OCIO_RS_NO_MSVC_INCLUDES anywhere.
|
||||
"OCIO_RS_ENABLE_REAL=1" >> $env:GITHUB_ENV
|
||||
"OCIO_RS_LINK=static" >> $env:GITHUB_ENV
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cargo caches: whole target/ dir plus ~/.cargo, shared per
|
||||
# distro+arch (WarpCache for Linux, GitHub for macOS/Windows).
|
||||
# ------------------------------------------------------------------
|
||||
- name: Cache cargo artifacts (WarpCache)
|
||||
if: matrix.platform == 'linux'
|
||||
uses: WarpBuilds/rust-cache@v2
|
||||
env:
|
||||
WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }}
|
||||
with:
|
||||
shared-key: oak-ci-${{ matrix.distro }}-${{ matrix.arch }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Cache cargo artifacts (GitHub)
|
||||
if: matrix.platform != 'linux'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: oak-ci-${{ matrix.distro }}-${{ matrix.arch }}
|
||||
cache-on-failure: true
|
||||
|
||||
# A freshly built FFmpeg must not keep linking the archives the
|
||||
# cargo cache bundled in ffmpeg-sys-next's rlib (cargo does not
|
||||
# track them); drop just that package so the link picks up the new
|
||||
# .cache/ffmpeg. Skipped on cache hits and on Windows (prebuilt).
|
||||
- name: Refresh the FFmpeg binding
|
||||
if: matrix.platform != 'windows' && env.FFMPEG_REBUILT == '1'
|
||||
run: cargo clean -p ffmpeg-sys-next
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build & test
|
||||
# ------------------------------------------------------------------
|
||||
# `cargo check` (not build): the Test step links the test binaries
|
||||
# anyway, and a full build would codegen every workspace crate twice
|
||||
# (once without and once with cfg(test)).
|
||||
- name: Build
|
||||
run: cargo check --workspace --locked
|
||||
|
||||
# xvfb + 24-bit screen: the gpui #[gpui::test] tests open real
|
||||
# windows and render through wgpu on Mesa's software Vulkan
|
||||
# (lavapipe). The watchdog bounds the step: a deadlocked test
|
||||
# produces no output and no failure, so after 1800 s it dumps every
|
||||
# hung process's thread stacks and kills the suite.
|
||||
- name: Test (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
timeout-minutes: 45
|
||||
shell: bash
|
||||
env:
|
||||
# lavapipe is present in these images: a missing adapter must
|
||||
# fail the GPU acceptance tests instead of silently skipping
|
||||
# them (Debian is the x64 reference; the other distros keep the
|
||||
# historical lenient policy).
|
||||
OAK_REQUIRE_GPU: ${{ matrix.distro == 'debian' && '1' || '0' }}
|
||||
run: |
|
||||
run_suite() {
|
||||
xvfb-run -a -s "-screen 0 1920x1080x24" cargo test --workspace --locked &
|
||||
TEST_PID=$!
|
||||
# The watchdog inherits the step's stdout/stderr; detach it so
|
||||
# it cannot keep the runner's I/O pipes open after the step
|
||||
# ends ("WaitDelay expired before I/O complete" otherwise).
|
||||
(
|
||||
sleep 1800
|
||||
echo "::warning::test suite exceeded 1800s; dumping hung-process stacks"
|
||||
for p in $(pgrep -f 'target/debug/deps/|target/debug/oak-worker'); do
|
||||
echo "===== thread stacks of pid $p ($(readlink /proc/$p/exe 2>/dev/null)) ====="
|
||||
gdb -batch -ex 'thread apply all bt' -p "$p" || true
|
||||
done
|
||||
pkill -9 -f 'target/debug/deps/' || true
|
||||
pkill -9 -f 'target/debug/oak-worker' || true
|
||||
) >/dev/null 2>&1 &
|
||||
WATCHDOG_PID=$!
|
||||
wait $TEST_PID
|
||||
rc=$?
|
||||
kill $WATCHDOG_PID 2>/dev/null || true
|
||||
# Reap the watchdog so no child holds the step's pipes.
|
||||
wait $WATCHDOG_PID 2>/dev/null || true
|
||||
return $rc
|
||||
}
|
||||
# Retry once (same policy as the Windows job): worker-pool
|
||||
# startup under full-suite parallelism has flaked once
|
||||
# (full_res_worker_outlives_a_dropped_project: the render
|
||||
# manager's process dispatcher failed to start while every other
|
||||
# test passed). A real regression fails both passes.
|
||||
if ! run_suite; then
|
||||
echo "first pass failed; retrying once for worker-pool flakes"
|
||||
run_suite
|
||||
fi
|
||||
|
||||
# The runner's GUI session doubles as the display for the gpui
|
||||
# #[gpui::test] windows; wgpu renders through Metal.
|
||||
- name: Test (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
timeout-minutes: 40
|
||||
run: |
|
||||
# Retry once (same policy as the other platforms). A hang trips
|
||||
# the in-script watchdog, which samples the test processes (the
|
||||
# offending test's native stack lands in the log) before killing
|
||||
# the suite.
|
||||
run_suite() {
|
||||
cargo test --workspace --locked &
|
||||
TEST_PID=$!
|
||||
(
|
||||
sleep 900
|
||||
echo "::warning::macOS test suite exceeded 900s; sampling hung processes"
|
||||
for p in $(pgrep -f 'target/debug/deps/' || true); do
|
||||
echo "===== sample of pid $p ====="
|
||||
sample "$p" 2 10 2>&1 | head -120 || true
|
||||
done
|
||||
pkill -9 -f 'target/debug/deps/' || true
|
||||
pkill -9 -f 'target/debug/oak-worker' || true
|
||||
) &
|
||||
WATCHDOG_PID=$!
|
||||
wait $TEST_PID
|
||||
rc=$?
|
||||
kill $WATCHDOG_PID 2>/dev/null || true
|
||||
wait $WATCHDOG_PID 2>/dev/null || true
|
||||
return $rc
|
||||
}
|
||||
if ! run_suite; then
|
||||
echo "first pass failed; retrying once for worker-pool flakes"
|
||||
run_suite
|
||||
fi
|
||||
|
||||
- name: Test (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
timeout-minutes: 40
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo test --workspace --locked
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
# Retry once: a few gpui keystroke tests flake on Windows CI —
|
||||
# a synthetic keystroke is occasionally never delivered (the
|
||||
# undo/redo pair and a plain 's' toggle both failed once,
|
||||
# each identically to its pass state). A real regression
|
||||
# fails both passes.
|
||||
Write-Host "first pass failed; retrying once for gpui keystroke flakes"
|
||||
cargo test --workspace --locked
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Failure diagnostics
|
||||
# ------------------------------------------------------------------
|
||||
# A crashing (SIGSEGV) test gives no Rust backtrace; rerun the
|
||||
# crashing test binaries under gdb to capture the native stack.
|
||||
# `--args` is required — plain `--` makes gdb treat the test args as
|
||||
# a core file. The extra probes target loader-stage crashes (the
|
||||
# copier_test SIGSEGV happens inside ld.so's dl_main): si_addr/si_code
|
||||
# pin down the fault type, the dynsym dump exposes symbols the
|
||||
# executable exports for interposition, strace shows the last loader
|
||||
# syscalls, and valgrind catches a corrupting static initializer.
|
||||
- name: Backtrace on test failure (Debian)
|
||||
if: failure() && matrix.distro == 'debian'
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get install -y gdb strace valgrind
|
||||
for name in node_e2e_test suites_test copier_test; do
|
||||
BIN=$(ls -t target/debug/deps/$name-* | grep -v '\.d$' | head -1)
|
||||
[ -n "$BIN" ] || continue
|
||||
echo "===== $BIN ====="
|
||||
file "$BIN" || true
|
||||
echo "--- exported defined dynsyms:"
|
||||
readelf --dyn-syms -W "$BIN" 2>/dev/null | grep -v ' UND ' | tail -n +4 | head -30 || true
|
||||
echo "--- strace tail:"
|
||||
strace -f "$BIN" --list 2>&1 | tail -15 || true
|
||||
echo "--- valgrind tail:"
|
||||
valgrind -q "$BIN" --list 2>&1 | tail -25 || true
|
||||
echo "--- gdb:"
|
||||
xvfb-run -a gdb -batch \
|
||||
-ex run \
|
||||
-ex 'bt' \
|
||||
-ex 'p $_siginfo.si_code' \
|
||||
-ex 'p/x $_siginfo._sifields._sigfault.si_addr' \
|
||||
-ex 'x/6i $rip' \
|
||||
--args "$BIN" --nocapture || true
|
||||
done
|
||||
|
||||
# Same idea for the other distro containers (gdb is installed with
|
||||
# the system dependencies).
|
||||
- name: Backtrace on test failure (containers)
|
||||
if: failure() && matrix.platform == 'linux' && matrix.distro != 'debian'
|
||||
shell: bash
|
||||
run: |
|
||||
for name in oak_render oakapp oak_plugin; do
|
||||
BIN=$(ls -t target/debug/deps/$name-* 2>/dev/null | grep -v '\.d$' | head -1)
|
||||
[ -n "$BIN" ] || continue
|
||||
echo "===== $BIN ====="
|
||||
timeout 900 xvfb-run -a gdb -batch \
|
||||
-ex run \
|
||||
-ex 'thread apply all bt' \
|
||||
--args "$BIN" || true
|
||||
done
|
||||
|
||||
# A SIGSEGV in a test binary gives no Rust backtrace; Apple's crash
|
||||
# reports carry the native stack, so surface the newest ones.
|
||||
- name: Crash reports (macOS)
|
||||
if: failure() && matrix.platform == 'macos'
|
||||
run: |
|
||||
for f in $(ls -t ~/Library/Logs/DiagnosticReports/*.ips 2>/dev/null | head -3); do
|
||||
echo "===== $f"
|
||||
head -c 6000 "$f"
|
||||
echo
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OFX plugin discovery end-to-end (x64 Linux reference)
|
||||
# ------------------------------------------------------------------
|
||||
# Build a minimal but real OFX plugin into a .ofx.bundle, point
|
||||
# OFX_PLUGIN_PATH at it and let the scan_probe example run the full
|
||||
# host path (directory scan -> dlopen -> setHost -> load -> describe
|
||||
# -> register). The assertion is the plugin's registration line; CI
|
||||
# machines have no system-wide OFX plugins, so the fixture is the
|
||||
# only discovery.
|
||||
- name: Build OFX fixture plugin
|
||||
if: matrix.distro == 'debian'
|
||||
run: crates/oak-plugin/tests/fixtures/build_fixture.sh .cache/ofx-fixture
|
||||
|
||||
- name: Probe OFX plugin discovery
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
OFX_PLUGIN_PATH="$PWD/.cache/ofx-fixture" \
|
||||
cargo run --locked -p oak-plugin --example scan_probe > probe.log 2>&1
|
||||
grep -q 'type_id=rs.oak.CiTestPlugin' probe.log
|
||||
# A project carrying a plugin node must survive save/load (the
|
||||
# serializer resolves plugin types via the dynamic factory).
|
||||
OAK_OFX_FIXTURE_DIR="$PWD/.cache/ofx-fixture" \
|
||||
cargo test --locked -p oak-plugin --test ofx_roundtrip
|
||||
@@ -15,6 +15,9 @@ CmakeSettings.json
|
||||
# clangd's index and likely other things that need not be in the repository
|
||||
.cache/
|
||||
|
||||
# vcpkg manifest-mode install tree (CI/CD; see vcpkg.json)
|
||||
vcpkg_installed/
|
||||
|
||||
# Generated packaging assets (icons/icon.png is produced from Oak_Icon.svg
|
||||
# by rsvg-convert in the CD workflow; see .github/workflows/cd.yml)
|
||||
/icons/
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "gpui"]
|
||||
path = gpui
|
||||
url = https://git.oakvideoeditor.org/oak-team/oak-gpui.git
|
||||
url = https://github.com/OakVideoEditorCommunity/oak-gpui.git
|
||||
|
||||
Generated
+201
-411
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -70,8 +70,9 @@ opt-level = 1
|
||||
|
||||
# ocio-sys 0.2.1's vendored sources fail to build on GCC >= 16 (yaml-cpp
|
||||
# misses <cstdint>) and misdetect a system zlib-ng (minizip-ng then emits
|
||||
# zng_* symbols the vendored zlib cannot satisfy). The fork tracks
|
||||
# shaloong/ocio-rs main plus both build fixes; return to the crates.io
|
||||
# release once it carries them.
|
||||
# zng_* symbols the vendored zlib cannot satisfy); upstream also hard-codes
|
||||
# `char*` as `*const i8`, which does not compile on aarch64 (c_char = u8).
|
||||
# The fork carries all three fixes; return to the crates.io release once it
|
||||
# does (the ocio-rs patch pulls the fork's `ocio-sys` path dependency too).
|
||||
[patch.crates-io]
|
||||
ocio-sys = { git = "https://github.com/Mike-Solar/ocio-rs.git", branch = "main" }
|
||||
ocio-rs = { git = "https://github.com/Mike-Solar/ocio-rs.git", rev = "30338c6a169bbbada862fb3ac256e79b656159cf" }
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
# Oak Video Editor 
|
||||
# Oak Video Editor 
|
||||
[中文](docs/zh/README.md)
|
||||
|
||||
Oak Video Editor is a free non-linear video editor for Windows, macOS, and Linux.
|
||||
Oak Video Editor is a renamed fork of Olive, aiming to deliver a more complete and user-friendly editing experience.
|
||||
|
||||
This project is a community-maintained fork of Olive Video Editor.
|
||||

|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
|
||||
**NOTE: Oak Video Editor is alpha software and is considered highly unstable. While we highly appreciate users testing and providing usage information, please use at your own risk.**
|
||||
# Features
|
||||
|
||||
## Binaries
|
||||
- Premiere-like keyboard shortcut experience
|
||||
- OpenFX plugin support — the plugin system used by DaVinci Resolve
|
||||
- End-to-end color management support
|
||||
- 10-bit display output
|
||||
- OCIO LUT support
|
||||
- Proxy editing workflow
|
||||
- Timeline interchange with DaVinci Resolve, Premiere Pro, and Final Cut Pro via OpenTimelineIO and Final Cut Pro XML
|
||||
|
||||
The binary can be downloaded here:
|
||||
## Download
|
||||
|
||||
[v0.4.2](https://github.com/OakVideoEditorCommunity/oak/releases/tag/v0.4.2-alpha)
|
||||
[v0.5.0](https://github.com/OakVideoEditorCommunity/oak/releases/tag/v0.5.0-alpha2)
|
||||
|
||||
[v0.4.1](https://github.com/OakVideoEditorCommunity/oak/releases/tag/v0.4.1-alpha)
|
||||
## Build Instructions
|
||||
|
||||
[v0.4.0](https://github.com/OakVideoEditorCommunity/oak/releases/tag/v0.4.0-alpha)
|
||||
|
||||
## Building from Source
|
||||
|
||||
See [`docs/build.md`](docs/build.md) for build instructions on Windows (MSYS2), Linux (Debian/Ubuntu, Fedora, Arch Linux), and macOS.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Project Storage Architecture](docs/project-storage.md) ([中文](docs/zh/project-storage.md)) — database write-through persistence, node-granular journal, persistent undo
|
||||
- [Build guide](docs/build.md) · [工程文件格式](docs/zh/project-file-reference.md)
|
||||
See the [Build Guide](docs/build.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Version | Theme | Core Deliverables | Boundary Notes |
|
||||
|:--------|:--|:--|:--|
|
||||
| **0.3** | **Plugin Architecture Milestone** | Production-ready OpenFX host support | Not about quantity of plugins, but "any OFX plugin loads without crashing" |
|
||||
| **0.4** | **Color, Audio & Performance** | `.cube`/`.3dl` support, scopes (waveform/vectorscope/histogram), three-way color wheels, waveform auto-sync, BWF timecode sync, audio meters (LUFS/VU), proxy media workflow, hardware-accelerated export (NVENC/VideoToolbox), batch render queue | Combines the previous 0.4-0.6 scope into one usability milestone: color workflow, audio sync, and 4K/8K performance |
|
||||
| **0.5** | **Animation, Tracking & Collaboration** | Bézier keyframe curve editor, basic point tracking, image stabilizer, full multicam angle switching, OpenTimelineIO, EDL/XML import/export | Combines the previous 0.7-0.8 scope into one timeline/interchange milestone |
|
||||
| **0.6** | **Stability Milestone** | Project file format freeze (backward compatibility promise), crash recovery, autosave, memory optimization | "Feature freeze" testing period before 1.0 |
|
||||
| **1.0** | **Production Ready** | Complete documentation, installers, known issues list, community support channels | Declared "ready for serious projects" |
|
||||
| Version | Theme | Core Deliverables |
|
||||
|:--|:--|:--|
|
||||
| **0.5 (current)** | **Rust Rewrite** | Oak rewritten in Rust |
|
||||
| **0.6** | **Color, Audio & Performance** | AI video editing, scopes (waveform/vectorscope/histogram), three-way color wheel panel, multicam editing support, BWF timecode sync, audio meters (LUFS/VU), batch render queue |
|
||||
| **0.7** | **Animation, Tracking & Collaboration** | Bézier keyframe curve editor, basic point tracking, image stabilizer |
|
||||
| **0.8** | **Stability Milestone** | Project file format freeze (backward compatibility promise) — the "feature freeze" testing period before 1.0 |
|
||||
| **1.0** | **Production Ready** | Complete documentation, installers, known issues list, community support channels — declared "ready for serious projects" |
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Oak Video Editor
|
||||
|
||||
[](https://github.com/OakVideoEditorCommunity/oak/actions/workflows/ci.yml)
|
||||
[中文](docs/zh/README.new.md)
|
||||
|
||||
Oak Video Editor is a free, open-source **non-linear video editor** for Windows, macOS, and Linux.
|
||||
|
||||
This project is a community-maintained fork of Olive Video Editor.
|
||||
|
||||
> **NOTE: Oak Video Editor is alpha software and is considered highly unstable. We appreciate users testing it and sharing feedback, but please use it at your own risk.**
|
||||
|
||||
<!-- SCREENSHOT: main editing window (timeline + viewer) -->
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Responsive timeline editing with smart disk/playback caching
|
||||
- Node-based compositing and effects, including an OpenFX (OFX) plugin host
|
||||
- Full color management (OpenColorIO): `.cube`/`.3dl` LUTs, configurable display/view/look transforms
|
||||
- Scopes: waveform, vectorscope, histogram, and audio meters (LUFS/VU)
|
||||
- Bézier keyframe animation with a curve editor
|
||||
- Multicam editing and waveform-based audio sync
|
||||
- Proxy media workflow for smooth 4K/8K editing
|
||||
- Hardware-accelerated and batch export (H.264/H.265, image sequences, audio)
|
||||
- Project crash recovery and autosave
|
||||
|
||||
<!-- SCREENSHOT: node editor -->
|
||||

|
||||
|
||||
## Download
|
||||
|
||||
Pre-built binaries for Windows, macOS, and Linux are on the [Releases](https://github.com/OakVideoEditorCommunity/oak/releases) page.
|
||||
|
||||
Latest: [v0.4.2-alpha](https://github.com/OakVideoEditorCommunity/oak/releases/tag/v0.4.2-alpha)
|
||||
|
||||
## Architecture
|
||||
|
||||
Oak is split into small, independently testable components with a pure C ABI at the boundary:
|
||||
|
||||
| Component | Kind | Purpose |
|
||||
|---|---|---|
|
||||
| `liboakcore` | shared library | Qt-free core types (rational, timecode, bezier, sample buffer, audio/video params) with a pure C ABI |
|
||||
| `liboakengine` | shared library | the editing engine (node graph, timeline, render, codec, tasks), exposed only through the `oakengine_*` C ABI facade |
|
||||
| `oak-editor` | application | the Qt GUI; talks to the engine **only** through the C ABI |
|
||||
| `oak-render-worker` | process | headless render process that executes frames off the GUI thread (NDJSON IPC) |
|
||||
| `oak-cli` | tool | command-line frontend for the engine: media info, probing, rendering, and transcoding without the GUI |
|
||||
|
||||
The C ABI boundary is what makes the engine embeddable and is the foundation for a planned module-by-module rewrite of the engine in Rust (see [`docs/zh/plans/completed/riir.md`](docs/zh/plans/completed/riir.md)).
|
||||
|
||||
<!-- DIAGRAM: component / ABI layout -->
|
||||

|
||||
|
||||
## Command-Line Tools
|
||||
|
||||
`oak-cli` is a standalone, pure-C-ABI consumer of the engine:
|
||||
|
||||
```bash
|
||||
oak-cli info <file> # media information
|
||||
oak-cli probe <file> # stream/decoder probe
|
||||
oak-cli render <project.ove> <out> # render a project range
|
||||
oak-cli transcode <in> <out> # transcode media
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
See [`docs/build.md`](docs/build.md) for full instructions (Windows/MSYS2, Linux Debian/Ubuntu/Fedora/Arch, and [`docs/build_macos.md`](docs/build_macos.md) for macOS). In short:
|
||||
|
||||
```bash
|
||||
cmake -B build -G Ninja
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Version | Theme | Core Deliverables |
|
||||
|:--|:--|:--|
|
||||
| **0.3** | **Plugin Architecture** | Production-ready OpenFX host support — "any OFX plugin loads without crashing" |
|
||||
| **0.4** | **Color, Audio & Performance** | `.cube`/`.3dl`, scopes, three-way color wheels, waveform auto-sync, BWF timecode sync, audio meters, proxy media, hardware-accelerated export, batch render queue |
|
||||
| **0.5** | **Animation, Tracking & Collaboration** | Bézier keyframe curve editor, point tracking, image stabilizer, full multicam, OpenTimelineIO, EDL/XML interchange |
|
||||
| **0.6** | **Stability** | Project file format freeze (backward compatibility), crash recovery, autosave, memory optimization |
|
||||
| **1.0** | **Production Ready** | Complete documentation, installers, known-issues list, community support |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) first — it covers:
|
||||
|
||||
- the code style (naming rules, including `PascalCase` struct typedefs),
|
||||
- the **Google Test** requirement for all tests,
|
||||
- the C ABI boundary contract for engine-facing code.
|
||||
|
||||
Useful project docs: [`docs/zh/`](docs/zh/) (中文文档), [`docs/zh/facade-migration-roadmap.md`](docs/zh/facade-migration-roadmap.md), [`docs/zh/plans/completed/riir.md`](docs/zh/plans/completed/riir.md).
|
||||
|
||||
## License
|
||||
|
||||
Oak Video Editor is free software licensed under the [GNU General Public License v3](LICENSE).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "Projektdatei exportieren…"
|
||||
"menu.file.save_as": "Speichern unter…"
|
||||
"menu.file.revert": "Wiederherstellen"
|
||||
"menu.file.export_media": "Medien exportieren…"
|
||||
"menu.file.export_media": "Sequenz exportieren…"
|
||||
"menu.file.project_properties": "Projekteigenschaften…"
|
||||
"menu.file.close": "Projekt schließen"
|
||||
"menu.file.quit": "Beenden"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "Proxy-Medien verwenden"
|
||||
"menu.tools.proxy_settings": "Proxy-Einstellungen…"
|
||||
"menu.help.action_search": "Aktionen durchsuchen…"
|
||||
"menu.help.bug_report": "Fehler melden…"
|
||||
"menu.help.feedback": "Feedback senden…"
|
||||
"menu.help.about": "Über Oak…"
|
||||
"shortcut.space": "Leertaste"
|
||||
@@ -171,6 +172,384 @@
|
||||
"multicam.switch_9": "Zu Kamera 9 wechseln"
|
||||
"effect_library.hint": "Doppelklicken, um zum ausgewählten Clip hinzuzufügen"
|
||||
"effect_library.group.builtin": "Integriert"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "Graph-Eingang"
|
||||
"node.graphinput.input.feed_in": "Zufuhr"
|
||||
"node.graphoutput.name": "Graph-Ausgang"
|
||||
"node.graphoutput.input.tex_in": "Textur"
|
||||
"status.ready": "Bereit"
|
||||
"status.cache": "Cache: Aktiviert"
|
||||
"status.proxy.on": "Proxy: Ein"
|
||||
@@ -183,6 +562,7 @@
|
||||
"manager.title": "Projektmanager"
|
||||
"manager.new": "Neues Projekt"
|
||||
"manager.new.default_name": "Unbenanntes Projekt"
|
||||
"manager.new.project_name": "Projektname"
|
||||
"manager.open": "Öffnen"
|
||||
"manager.rename": "Umbenennen…"
|
||||
"manager.rename.title": "Projekt umbenennen"
|
||||
@@ -303,7 +683,13 @@
|
||||
"about.description": "Oak Video Editor ist ein freier, quelloffener nichtlinearer Videoeditor. Diese Software ist unter der GNU GPL Version 3 lizenziert."
|
||||
"about.thanks": "Besonderer Dank gilt Enzo GD, unserem Community Promoter, für seine großzügige Unterstützung bei der Verbreitung dieses Projekts in dessen Anfangszeit."
|
||||
"about.fork_notice": "Dieses Projekt ist ein Fork des Olive Video Editors."
|
||||
"file.open.title": "Projekt öffnen"
|
||||
"update.title": "Update verfügbar"
|
||||
"update.available": "Version {version} ist verfügbar; installiert ist {current}."
|
||||
"update.notes": "Neuerungen"
|
||||
"update.no_notes": "Es wurden keine Versionshinweise angegeben."
|
||||
"update.download": "Zu den Downloads"
|
||||
"update.later": "Später"
|
||||
"file.open.title": "Projekt öffnen (.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "Material importieren"
|
||||
"preferences.title": "Einstellungen"
|
||||
"preferences.section.general": "Allgemein"
|
||||
@@ -327,6 +713,7 @@
|
||||
"preferences.language": "Sprache"
|
||||
"preferences.language.placeholder": "Sprache auswählen…"
|
||||
"preferences.theme": "Design"
|
||||
"preferences.check_updates": "Beim Start nach Updates suchen"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "Verzeichnis für den Platten-Cache"
|
||||
@@ -374,8 +761,21 @@
|
||||
"actionsearch.no_actions": "Keine Aktionen verfügbar"
|
||||
"export.title": "Sequenz exportieren"
|
||||
"export.format": "Format"
|
||||
"export.video_codec": "Video-Codec"
|
||||
"export.audio_codec": "Audio-Codec"
|
||||
"export.color": "Farbraum"
|
||||
"export.color.sdr": "SDR — Rec.709 8-bit"
|
||||
"export.resolution": "Auflösung (0 = Sequenz)"
|
||||
"export.framerate": "Bildrate (0 = Sequenz)"
|
||||
"export.bitrate": "Bitrate (0 = Codec-Standard)"
|
||||
"export.color.hdr": "HDR — BT.2020 10-bit"
|
||||
"export.range": "Exportbereich"
|
||||
"export.range.all": "Ganze Timeline"
|
||||
"export.range.inout": "Ein-/Ausstiegspunkte"
|
||||
"project.export.format": "Projektformat"
|
||||
"export.format.placeholder": "Format auswählen…"
|
||||
"export.path": "Ausgabepfad"
|
||||
"export.sequence": "Sequenz"
|
||||
"export.run": "Exportieren"
|
||||
"export.hint": "Die Sequenz wird über den oaktask-Exportpfad exportiert; der Fortschritt wird im Dialog angezeigt."
|
||||
"export.progress.title": "Exportieren"
|
||||
@@ -467,9 +867,12 @@
|
||||
"node.category.group": "Gruppe"
|
||||
"project.context.new": "Neu"
|
||||
"project.new_sequence": "Neue Sequenz"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Im Finder anzeigen"
|
||||
"project.context.replace_footage": "Material ersetzen"
|
||||
"project.context.rename": "Umbenennen"
|
||||
"project.context.export_sequence": "Sequenz exportieren…"
|
||||
"project.context.delete": "Löschen"
|
||||
"project.context.open_in_new_tab": "In neuem Tab öffnen"
|
||||
"project.context.open_in_new_window": "In neuem Fenster öffnen"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "Export Project File…"
|
||||
"menu.file.save_as": "Save As…"
|
||||
"menu.file.revert": "Revert"
|
||||
"menu.file.export_media": "Export Media…"
|
||||
"menu.file.export_media": "Export Sequence…"
|
||||
"menu.file.project_properties": "Project Properties…"
|
||||
"menu.file.close": "Close Project"
|
||||
"menu.file.quit": "Quit"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "Use Proxy Media"
|
||||
"menu.tools.proxy_settings": "Proxy Settings…"
|
||||
"menu.help.action_search": "Search Actions…"
|
||||
"menu.help.bug_report": "Report a Bug…"
|
||||
"menu.help.feedback": "Send Feedback…"
|
||||
"menu.help.about": "About Oak…"
|
||||
"shortcut.space": "Space"
|
||||
@@ -169,6 +170,384 @@
|
||||
"multicam.switch_9": "Switch to Camera 9"
|
||||
"effect_library.hint": "Double-click to add to the selected clip"
|
||||
"effect_library.group.builtin": "Built-in"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "Graph Input"
|
||||
"node.graphinput.input.feed_in": "Feed"
|
||||
"node.graphoutput.name": "Graph Output"
|
||||
"node.graphoutput.input.tex_in": "Texture"
|
||||
"status.ready": "Ready"
|
||||
"status.cache": "Cache: Enabled"
|
||||
"status.proxy.on": "Proxy: On"
|
||||
@@ -181,6 +560,7 @@
|
||||
"manager.title": "Project Manager"
|
||||
"manager.new": "New Project"
|
||||
"manager.new.default_name": "Untitled Project"
|
||||
"manager.new.project_name": "Project Name"
|
||||
"manager.open": "Open"
|
||||
"manager.rename": "Rename…"
|
||||
"manager.rename.title": "Rename Project"
|
||||
@@ -301,7 +681,13 @@
|
||||
"about.description": "Oak Video Editor is a free open source non-linear video editor. This software is licensed under the GNU GPL Version 3."
|
||||
"about.thanks": "Special thanks to Enzo GD, our Community Promoter, for his generous support in spreading the word about this project in its early days."
|
||||
"about.fork_notice": "This project is a fork of Olive Video Editor."
|
||||
"file.open.title": "Open Project"
|
||||
"update.title": "Update Available"
|
||||
"update.available": "Version {version} is available; you are running {current}."
|
||||
"update.notes": "What's new"
|
||||
"update.no_notes": "No release notes were provided."
|
||||
"update.download": "Go to Downloads"
|
||||
"update.later": "Later"
|
||||
"file.open.title": "Open Project (.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "Import Footage"
|
||||
"preferences.title": "Preferences"
|
||||
"preferences.section.general": "General"
|
||||
@@ -325,6 +711,7 @@
|
||||
"preferences.language": "Language"
|
||||
"preferences.language.placeholder": "Select a language…"
|
||||
"preferences.theme": "Theme"
|
||||
"preferences.check_updates": "Check for updates on startup"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "Disk cache directory"
|
||||
@@ -372,8 +759,21 @@
|
||||
"actionsearch.no_actions": "No actions available"
|
||||
"export.title": "Export Sequence"
|
||||
"export.format": "Format"
|
||||
"export.video_codec": "Video Codec"
|
||||
"export.audio_codec": "Audio Codec"
|
||||
"export.color": "Color Space"
|
||||
"export.color.sdr": "SDR — Rec.709 8-bit"
|
||||
"export.resolution": "Resolution (0 = sequence)"
|
||||
"export.framerate": "Frame Rate (0 = sequence)"
|
||||
"export.bitrate": "Bit Rate (0 = codec default)"
|
||||
"export.color.hdr": "HDR — BT.2020 10-bit"
|
||||
"export.range": "Export Range"
|
||||
"export.range.all": "Whole Timeline"
|
||||
"export.range.inout": "In / Out Points"
|
||||
"project.export.format": "Project Format"
|
||||
"export.format.placeholder": "Select a format…"
|
||||
"export.path": "Output path"
|
||||
"export.sequence": "Sequence"
|
||||
"export.run": "Export"
|
||||
"export.hint": "The sequence is exported through the oaktask export path; progress is shown in the dialog."
|
||||
"export.progress.title": "Exporting"
|
||||
@@ -465,9 +865,12 @@
|
||||
"node.category.group": "Group"
|
||||
"project.context.new": "New"
|
||||
"project.new_sequence": "New Sequence"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Reveal in Finder"
|
||||
"project.context.replace_footage": "Replace Footage"
|
||||
"project.context.rename": "Rename"
|
||||
"project.context.export_sequence": "Export Sequence…"
|
||||
"project.context.delete": "Delete"
|
||||
"project.context.open_in_new_tab": "Open in New Tab"
|
||||
"project.context.open_in_new_window": "Open in New Window"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "Exportar archivo de proyecto…"
|
||||
"menu.file.save_as": "Guardar como…"
|
||||
"menu.file.revert": "Revertir"
|
||||
"menu.file.export_media": "Exportar medios…"
|
||||
"menu.file.export_media": "Exportar secuencia…"
|
||||
"menu.file.project_properties": "Propiedades del proyecto…"
|
||||
"menu.file.close": "Cerrar proyecto"
|
||||
"menu.file.quit": "Salir"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "Usar medios proxy"
|
||||
"menu.tools.proxy_settings": "Configuración de proxy…"
|
||||
"menu.help.action_search": "Buscar acciones…"
|
||||
"menu.help.bug_report": "Informar de un error…"
|
||||
"menu.help.feedback": "Enviar comentarios…"
|
||||
"menu.help.about": "Acerca de Oak…"
|
||||
"shortcut.space": "Barra espaciadora"
|
||||
@@ -171,6 +172,384 @@
|
||||
"multicam.switch_9": "Cambiar a cámara 9"
|
||||
"effect_library.hint": "Haz doble clic para añadir al clip seleccionado"
|
||||
"effect_library.group.builtin": "Integradas"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "Entrada del grafo"
|
||||
"node.graphinput.input.feed_in": "Entrada"
|
||||
"node.graphoutput.name": "Salida del grafo"
|
||||
"node.graphoutput.input.tex_in": "Textura"
|
||||
"status.ready": "Listo"
|
||||
"status.cache": "Caché: activada"
|
||||
"status.proxy.on": "Proxy: activado"
|
||||
@@ -183,6 +562,7 @@
|
||||
"manager.title": "Administrador de proyectos"
|
||||
"manager.new": "Nuevo proyecto"
|
||||
"manager.new.default_name": "Proyecto sin título"
|
||||
"manager.new.project_name": "Nombre del proyecto"
|
||||
"manager.open": "Abrir"
|
||||
"manager.rename": "Cambiar nombre…"
|
||||
"manager.rename.title": "Cambiar nombre del proyecto"
|
||||
@@ -303,7 +683,13 @@
|
||||
"about.description": "Oak Video Editor es un editor de vídeo no lineal libre y de código abierto. Este software está licenciado bajo la GNU GPL versión 3."
|
||||
"about.thanks": "Un agradecimiento especial a Enzo GD, nuestro Community Promoter, por su generoso apoyo al difundir este proyecto en sus primeros días."
|
||||
"about.fork_notice": "Este proyecto es un fork de Olive Video Editor."
|
||||
"file.open.title": "Abrir proyecto"
|
||||
"update.title": "Actualización disponible"
|
||||
"update.available": "La versión {version} está disponible; estás usando la {current}."
|
||||
"update.notes": "Novedades"
|
||||
"update.no_notes": "No se proporcionaron notas de la versión."
|
||||
"update.download": "Ir a descargas"
|
||||
"update.later": "Más tarde"
|
||||
"file.open.title": "Abrir proyecto (.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "Importar material"
|
||||
"preferences.title": "Preferencias"
|
||||
"preferences.section.general": "General"
|
||||
@@ -327,6 +713,7 @@
|
||||
"preferences.language": "Idioma"
|
||||
"preferences.language.placeholder": "Seleccionar un idioma…"
|
||||
"preferences.theme": "Tema"
|
||||
"preferences.check_updates": "Buscar actualizaciones al iniciar"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "Directorio de caché en disco"
|
||||
@@ -374,8 +761,21 @@
|
||||
"actionsearch.no_actions": "No hay acciones disponibles"
|
||||
"export.title": "Exportar secuencia"
|
||||
"export.format": "Formato"
|
||||
"export.video_codec": "Códec de vídeo"
|
||||
"export.audio_codec": "Códec de audio"
|
||||
"export.color": "Espacio de color"
|
||||
"export.color.sdr": "SDR — Rec.709 8-bit"
|
||||
"export.resolution": "Resolución (0 = secuencia)"
|
||||
"export.framerate": "Frecuencia de imagen (0 = secuencia)"
|
||||
"export.bitrate": "Tasa de bits (0 = valor por defecto)"
|
||||
"export.color.hdr": "HDR — BT.2020 10-bit"
|
||||
"export.range": "Rango de exportación"
|
||||
"export.range.all": "Toda la línea de tiempo"
|
||||
"export.range.inout": "Puntos de entrada/salida"
|
||||
"project.export.format": "Formato del proyecto"
|
||||
"export.format.placeholder": "Seleccionar un formato…"
|
||||
"export.path": "Ruta de salida"
|
||||
"export.sequence": "Secuencia"
|
||||
"export.run": "Exportar"
|
||||
"export.hint": "La secuencia se exporta a través de la ruta de exportación oaktask; el progreso se muestra en el diálogo."
|
||||
"export.progress.title": "Exportando"
|
||||
@@ -467,9 +867,12 @@
|
||||
"node.category.group": "Grupo"
|
||||
"project.context.new": "Nuevo"
|
||||
"project.new_sequence": "Nueva secuencia"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Mostrar en Finder"
|
||||
"project.context.replace_footage": "Reemplazar material"
|
||||
"project.context.rename": "Cambiar nombre"
|
||||
"project.context.export_sequence": "Exportar secuencia…"
|
||||
"project.context.delete": "Eliminar"
|
||||
"project.context.open_in_new_tab": "Abrir en pestaña nueva"
|
||||
"project.context.open_in_new_window": "Abrir en ventana nueva"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "Exporter un fichier projet…"
|
||||
"menu.file.save_as": "Enregistrer sous…"
|
||||
"menu.file.revert": "Restaurer"
|
||||
"menu.file.export_media": "Exporter les médias…"
|
||||
"menu.file.export_media": "Exporter la séquence…"
|
||||
"menu.file.project_properties": "Propriétés du projet…"
|
||||
"menu.file.close": "Fermer le projet"
|
||||
"menu.file.quit": "Quitter"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "Utiliser les médias proxy"
|
||||
"menu.tools.proxy_settings": "Paramètres de proxy…"
|
||||
"menu.help.action_search": "Rechercher des actions…"
|
||||
"menu.help.bug_report": "Signaler un bug…"
|
||||
"menu.help.feedback": "Envoyer un retour…"
|
||||
"menu.help.about": "À propos d'Oak…"
|
||||
"shortcut.space": "Espace"
|
||||
@@ -171,6 +172,384 @@
|
||||
"multicam.switch_9": "Passer à la caméra 9"
|
||||
"effect_library.hint": "Double-cliquer pour ajouter au clip sélectionné"
|
||||
"effect_library.group.builtin": "Intégré"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "Entrée du graphe"
|
||||
"node.graphinput.input.feed_in": "Entrée"
|
||||
"node.graphoutput.name": "Sortie du graphe"
|
||||
"node.graphoutput.input.tex_in": "Texture"
|
||||
"status.ready": "Prêt"
|
||||
"status.cache": "Cache : activé"
|
||||
"status.proxy.on": "Proxy : activé"
|
||||
@@ -183,6 +562,7 @@
|
||||
"manager.title": "Gestionnaire de projets"
|
||||
"manager.new": "Nouveau projet"
|
||||
"manager.new.default_name": "Projet sans titre"
|
||||
"manager.new.project_name": "Nom du projet"
|
||||
"manager.open": "Ouvrir"
|
||||
"manager.rename": "Renommer…"
|
||||
"manager.rename.title": "Renommer le projet"
|
||||
@@ -303,7 +683,13 @@
|
||||
"about.description": "Oak Video Editor est un éditeur vidéo non linéaire libre et open source. Ce logiciel est sous licence GNU GPL version 3."
|
||||
"about.thanks": "Remerciements particuliers à Enzo GD, notre Community Promoter, pour son généreux soutien à la diffusion de ce projet à ses débuts."
|
||||
"about.fork_notice": "Ce projet est un fork d'Olive Video Editor."
|
||||
"file.open.title": "Ouvrir le projet"
|
||||
"update.title": "Mise à jour disponible"
|
||||
"update.available": "La version {version} est disponible ; vous utilisez la {current}."
|
||||
"update.notes": "Nouveautés"
|
||||
"update.no_notes": "Aucune note de version fournie."
|
||||
"update.download": "Aller aux téléchargements"
|
||||
"update.later": "Plus tard"
|
||||
"file.open.title": "Ouvrir le projet (.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "Importer des médias"
|
||||
"preferences.title": "Préférences"
|
||||
"preferences.section.general": "Général"
|
||||
@@ -327,6 +713,7 @@
|
||||
"preferences.language": "Langue"
|
||||
"preferences.language.placeholder": "Sélectionner une langue…"
|
||||
"preferences.theme": "Thème"
|
||||
"preferences.check_updates": "Rechercher des mises à jour au démarrage"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "Dossier du cache disque"
|
||||
@@ -374,8 +761,21 @@
|
||||
"actionsearch.no_actions": "Aucune action disponible"
|
||||
"export.title": "Exporter la séquence"
|
||||
"export.format": "Format"
|
||||
"export.video_codec": "Codec vidéo"
|
||||
"export.audio_codec": "Codec audio"
|
||||
"export.color": "Espace colorimétrique"
|
||||
"export.color.sdr": "SDR — Rec.709 8 bits"
|
||||
"export.resolution": "Résolution (0 = séquence)"
|
||||
"export.framerate": "Cadence (0 = séquence)"
|
||||
"export.bitrate": "Débit (0 = défaut codec)"
|
||||
"export.color.hdr": "HDR — BT.2020 10 bits"
|
||||
"export.range": "Plage d’export"
|
||||
"export.range.all": "Toute la timeline"
|
||||
"export.range.inout": "Points d’entrée/sortie"
|
||||
"project.export.format": "Format du projet"
|
||||
"export.format.placeholder": "Sélectionner un format…"
|
||||
"export.path": "Chemin de sortie"
|
||||
"export.sequence": "Séquence"
|
||||
"export.run": "Exporter"
|
||||
"export.hint": "La séquence est exportée via le chemin d'exportation oaktask ; la progression s'affiche dans la boîte de dialogue."
|
||||
"export.progress.title": "Exportation"
|
||||
@@ -467,9 +867,12 @@
|
||||
"node.category.group": "Groupe"
|
||||
"project.context.new": "Nouveau"
|
||||
"project.new_sequence": "Nouvelle séquence"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Révéler dans le Finder"
|
||||
"project.context.replace_footage": "Remplacer les médias"
|
||||
"project.context.rename": "Renommer"
|
||||
"project.context.export_sequence": "Exporter la séquence…"
|
||||
"project.context.delete": "Supprimer"
|
||||
"project.context.open_in_new_tab": "Ouvrir dans un nouvel onglet"
|
||||
"project.context.open_in_new_window": "Ouvrir dans une nouvelle fenêtre"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "プロジェクトファイルを書き出し…"
|
||||
"menu.file.save_as": "別名で保存…"
|
||||
"menu.file.revert": "保存済みに戻す"
|
||||
"menu.file.export_media": "メディアを書き出し…"
|
||||
"menu.file.export_media": "シーケンスを書き出し…"
|
||||
"menu.file.project_properties": "プロジェクトのプロパティ…"
|
||||
"menu.file.close": "プロジェクトを閉じる"
|
||||
"menu.file.quit": "終了"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "プロキシメディアを使用"
|
||||
"menu.tools.proxy_settings": "プロキシ設定…"
|
||||
"menu.help.action_search": "アクションを検索…"
|
||||
"menu.help.bug_report": "バグを報告…"
|
||||
"menu.help.feedback": "フィードバックを送信…"
|
||||
"menu.help.about": "Oak について…"
|
||||
"shortcut.space": "スペース"
|
||||
@@ -171,6 +172,384 @@
|
||||
"multicam.switch_9": "カメラ9に切り替え"
|
||||
"effect_library.hint": "ダブルクリックで選択中のクリップに追加します"
|
||||
"effect_library.group.builtin": "内蔵"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "グラフ入力"
|
||||
"node.graphinput.input.feed_in": "入力"
|
||||
"node.graphoutput.name": "グラフ出力"
|
||||
"node.graphoutput.input.tex_in": "テクスチャ"
|
||||
"status.ready": "準備完了"
|
||||
"status.cache": "キャッシュ:有効"
|
||||
"status.proxy.on": "プロキシ:オン"
|
||||
@@ -183,6 +562,7 @@
|
||||
"manager.title": "プロジェクトマネージャー"
|
||||
"manager.new": "新規プロジェクト"
|
||||
"manager.new.default_name": "無題のプロジェクト"
|
||||
"manager.new.project_name": "プロジェクト名"
|
||||
"manager.open": "開く"
|
||||
"manager.rename": "名前を変更…"
|
||||
"manager.rename.title": "プロジェクトの名前を変更"
|
||||
@@ -303,7 +683,13 @@
|
||||
"about.description": "Oak Video Editor はフリーでオープンソースのノンリニア動画エディタです。本ソフトウェアは GNU GPL バージョン 3 でライセンスされています。"
|
||||
"about.thanks": "プロジェクト初期に広く紹介してくれたコミュニティプロモーター Enzo GD に心より感謝します。"
|
||||
"about.fork_notice": "本プロジェクトは Olive Video Editor のフォークです。"
|
||||
"file.open.title": "プロジェクトを開く"
|
||||
"update.title": "更新があります"
|
||||
"update.available": "バージョン {version} が公開されています(現在は {current})。"
|
||||
"update.notes": "更新内容"
|
||||
"update.no_notes": "リリースノートはありません。"
|
||||
"update.download": "ダウンロードへ"
|
||||
"update.later": "後で"
|
||||
"file.open.title": "プロジェクトを開く(.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "素材を読み込み"
|
||||
"preferences.title": "環境設定"
|
||||
"preferences.section.general": "一般"
|
||||
@@ -327,6 +713,7 @@
|
||||
"preferences.language": "言語"
|
||||
"preferences.language.placeholder": "言語を選択…"
|
||||
"preferences.theme": "テーマ"
|
||||
"preferences.check_updates": "起動時に更新を確認"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "ディスクキャッシュの保存先"
|
||||
@@ -374,8 +761,21 @@
|
||||
"actionsearch.no_actions": "利用可能なアクションがありません"
|
||||
"export.title": "シーケンスを書き出し"
|
||||
"export.format": "フォーマット"
|
||||
"export.video_codec": "ビデオコーデック"
|
||||
"export.audio_codec": "オーディオコーデック"
|
||||
"export.color": "色域"
|
||||
"export.color.sdr": "SDR — Rec.709 8bit"
|
||||
"export.resolution": "解像度(0 = シーケンスに従う)"
|
||||
"export.framerate": "フレームレート(0 = シーケンスに従う)"
|
||||
"export.bitrate": "ビットレート(0 = コーデック既定)"
|
||||
"export.color.hdr": "HDR — BT.2020 10bit"
|
||||
"export.range": "書き出し範囲"
|
||||
"export.range.all": "タイムライン全体"
|
||||
"export.range.inout": "イン/アウトポイント間"
|
||||
"project.export.format": "プロジェクト形式"
|
||||
"export.format.placeholder": "フォーマットを選択…"
|
||||
"export.path": "出力先"
|
||||
"export.sequence": "シーケンス"
|
||||
"export.run": "書き出し"
|
||||
"export.hint": "シーケンスはoaktaskの書き出しパスを通じて書き出されます。進捗はダイアログに表示されます。"
|
||||
"export.progress.title": "書き出し中"
|
||||
@@ -467,9 +867,12 @@
|
||||
"node.category.group": "グループ"
|
||||
"project.context.new": "新規"
|
||||
"project.new_sequence": "新規シーケンス"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Finderに表示"
|
||||
"project.context.replace_footage": "素材を置き換え"
|
||||
"project.context.rename": "名前を変更"
|
||||
"project.context.export_sequence": "シーケンスを書き出し…"
|
||||
"project.context.delete": "削除"
|
||||
"project.context.open_in_new_tab": "新しいタブで開く"
|
||||
"project.context.open_in_new_window": "新しいウィンドウで開く"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "Exportar arquivo de projeto…"
|
||||
"menu.file.save_as": "Salvar como…"
|
||||
"menu.file.revert": "Reverter"
|
||||
"menu.file.export_media": "Exportar mídia…"
|
||||
"menu.file.export_media": "Exportar sequência…"
|
||||
"menu.file.project_properties": "Propriedades do projeto…"
|
||||
"menu.file.close": "Fechar projeto"
|
||||
"menu.file.quit": "Sair"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "Usar mídia proxy"
|
||||
"menu.tools.proxy_settings": "Configurações de proxy…"
|
||||
"menu.help.action_search": "Pesquisar ações…"
|
||||
"menu.help.bug_report": "Relatar um bug…"
|
||||
"menu.help.feedback": "Enviar comentários…"
|
||||
"menu.help.about": "Sobre o Oak…"
|
||||
"shortcut.space": "Espaço"
|
||||
@@ -171,6 +172,384 @@
|
||||
"multicam.switch_9": "Alternar para a câmera 9"
|
||||
"effect_library.hint": "Clique duas vezes para adicionar ao clipe selecionado"
|
||||
"effect_library.group.builtin": "Integrados"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "Entrada do grafo"
|
||||
"node.graphinput.input.feed_in": "Entrada"
|
||||
"node.graphoutput.name": "Saída do grafo"
|
||||
"node.graphoutput.input.tex_in": "Textura"
|
||||
"status.ready": "Pronto"
|
||||
"status.cache": "Cache: ativado"
|
||||
"status.proxy.on": "Proxy: ativado"
|
||||
@@ -183,6 +562,7 @@
|
||||
"manager.title": "Gerenciador de projetos"
|
||||
"manager.new": "Novo projeto"
|
||||
"manager.new.default_name": "Projeto sem título"
|
||||
"manager.new.project_name": "Nome do projeto"
|
||||
"manager.open": "Abrir"
|
||||
"manager.rename": "Renomear…"
|
||||
"manager.rename.title": "Renomear projeto"
|
||||
@@ -303,7 +683,13 @@
|
||||
"about.description": "O Oak Video Editor é um editor de vídeo não linear livre e de código aberto. Este software é licenciado sob a GNU GPL versão 3."
|
||||
"about.thanks": "Um agradecimento especial a Enzo GD, nosso Community Promoter, por seu generoso apoio na divulgação deste projeto em seus primórdios."
|
||||
"about.fork_notice": "Este projeto é um fork do Olive Video Editor."
|
||||
"file.open.title": "Abrir projeto"
|
||||
"update.title": "Atualização disponível"
|
||||
"update.available": "A versão {version} está disponível; você está usando a {current}."
|
||||
"update.notes": "Novidades"
|
||||
"update.no_notes": "Nenhuma nota de versão fornecida."
|
||||
"update.download": "Ir para downloads"
|
||||
"update.later": "Mais tarde"
|
||||
"file.open.title": "Abrir projeto (.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "Importar mídia"
|
||||
"preferences.title": "Preferências"
|
||||
"preferences.section.general": "Geral"
|
||||
@@ -327,6 +713,7 @@
|
||||
"preferences.language": "Idioma"
|
||||
"preferences.language.placeholder": "Selecionar um idioma…"
|
||||
"preferences.theme": "Tema"
|
||||
"preferences.check_updates": "Verificar atualizações ao iniciar"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "Diretório do cache em disco"
|
||||
@@ -374,8 +761,21 @@
|
||||
"actionsearch.no_actions": "Nenhuma ação disponível"
|
||||
"export.title": "Exportar sequência"
|
||||
"export.format": "Formato"
|
||||
"export.video_codec": "Codec de vídeo"
|
||||
"export.audio_codec": "Codec de áudio"
|
||||
"export.color": "Espaço de cor"
|
||||
"export.color.sdr": "SDR — Rec.709 8-bit"
|
||||
"export.resolution": "Resolução (0 = sequência)"
|
||||
"export.framerate": "Taxa de quadros (0 = sequência)"
|
||||
"export.bitrate": "Taxa de bits (0 = padrão do codec)"
|
||||
"export.color.hdr": "HDR — BT.2020 10-bit"
|
||||
"export.range": "Faixa de exportação"
|
||||
"export.range.all": "Linha do tempo inteira"
|
||||
"export.range.inout": "Entre os pontos de entrada/saída"
|
||||
"project.export.format": "Formato do projeto"
|
||||
"export.format.placeholder": "Selecionar um formato…"
|
||||
"export.path": "Caminho de saída"
|
||||
"export.sequence": "Sequência"
|
||||
"export.run": "Exportar"
|
||||
"export.hint": "A sequência é exportada pelo caminho de exportação do oaktask; o progresso é exibido no diálogo."
|
||||
"export.progress.title": "Exportando"
|
||||
@@ -467,9 +867,12 @@
|
||||
"node.category.group": "Grupo"
|
||||
"project.context.new": "Novo"
|
||||
"project.new_sequence": "Nova sequência"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Revelar no Finder"
|
||||
"project.context.replace_footage": "Substituir mídia"
|
||||
"project.context.rename": "Renomear"
|
||||
"project.context.export_sequence": "Exportar sequência…"
|
||||
"project.context.delete": "Excluir"
|
||||
"project.context.open_in_new_tab": "Abrir em nova guia"
|
||||
"project.context.open_in_new_window": "Abrir em nova janela"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "Экспортировать файл проекта…"
|
||||
"menu.file.save_as": "Сохранить как…"
|
||||
"menu.file.revert": "Восстановить"
|
||||
"menu.file.export_media": "Экспортировать медиафайлы…"
|
||||
"menu.file.export_media": "Экспортировать секвенцию…"
|
||||
"menu.file.project_properties": "Свойства проекта…"
|
||||
"menu.file.close": "Закрыть проект"
|
||||
"menu.file.quit": "Выход"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "Использовать прокси-медиа"
|
||||
"menu.tools.proxy_settings": "Параметры прокси…"
|
||||
"menu.help.action_search": "Поиск действий…"
|
||||
"menu.help.bug_report": "Сообщить об ошибке…"
|
||||
"menu.help.feedback": "Отправить отзыв…"
|
||||
"menu.help.about": "О программе Oak…"
|
||||
"shortcut.space": "Пробел"
|
||||
@@ -171,6 +172,384 @@
|
||||
"multicam.switch_9": "Переключиться на камеру 9"
|
||||
"effect_library.hint": "Двойной щелчок добавляет эффект к выбранному клипу"
|
||||
"effect_library.group.builtin": "Встроенные"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"node.graphinput.name": "Вход графа"
|
||||
"node.graphinput.input.feed_in": "Вход"
|
||||
"node.graphoutput.name": "Выход графа"
|
||||
"node.graphoutput.input.tex_in": "Текстура"
|
||||
"status.ready": "Готово"
|
||||
"status.cache": "Кэш: включён"
|
||||
"status.proxy.on": "Прокси: вкл"
|
||||
@@ -183,6 +562,7 @@
|
||||
"manager.title": "Диспетчер проектов"
|
||||
"manager.new": "Новый проект"
|
||||
"manager.new.default_name": "Проект без названия"
|
||||
"manager.new.project_name": "Название проекта"
|
||||
"manager.open": "Открыть"
|
||||
"manager.rename": "Переименовать…"
|
||||
"manager.rename.title": "Переименовать проект"
|
||||
@@ -303,7 +683,13 @@
|
||||
"about.description": "Oak Video Editor — свободный нелинейный видеоредактор с открытым исходным кодом. Программа распространяется по лицензии GNU GPL версии 3."
|
||||
"about.thanks": "Особая благодарность Enzo GD, нашему Community Promoter, за щедрую поддержку в распространении этого проекта в первые дни."
|
||||
"about.fork_notice": "Этот проект является форком Olive Video Editor."
|
||||
"file.open.title": "Открыть проект"
|
||||
"update.title": "Доступно обновление"
|
||||
"update.available": "Доступна версия {version}; у вас установлена {current}."
|
||||
"update.notes": "Что нового"
|
||||
"update.no_notes": "Примечания к выпуску не указаны."
|
||||
"update.download": "Перейти к загрузкам"
|
||||
"update.later": "Позже"
|
||||
"file.open.title": "Открыть проект (.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "Импорт медиафайлов"
|
||||
"preferences.title": "Настройки"
|
||||
"preferences.section.general": "Общие"
|
||||
@@ -327,6 +713,7 @@
|
||||
"preferences.language": "Язык"
|
||||
"preferences.language.placeholder": "Выберите язык…"
|
||||
"preferences.theme": "Тема"
|
||||
"preferences.check_updates": "Проверять обновления при запуске"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "Каталог дискового кэша"
|
||||
@@ -374,8 +761,21 @@
|
||||
"actionsearch.no_actions": "Нет доступных действий"
|
||||
"export.title": "Экспорт последовательности"
|
||||
"export.format": "Формат"
|
||||
"export.video_codec": "Видеокодек"
|
||||
"export.audio_codec": "Аудиокодек"
|
||||
"export.color": "Цветовое пространство"
|
||||
"export.color.sdr": "SDR — Rec.709 8 бит"
|
||||
"export.resolution": "Разрешение (0 = как в последовательности)"
|
||||
"export.framerate": "Частота кадров (0 = как в последовательности)"
|
||||
"export.bitrate": "Битрейт (0 = по умолчанию кодекa)"
|
||||
"export.color.hdr": "HDR — BT.2020 10 бит"
|
||||
"export.range": "Диапазон экспорта"
|
||||
"export.range.all": "Вся таймлиния"
|
||||
"export.range.inout": "Точки входа/выхода"
|
||||
"project.export.format": "Формат проекта"
|
||||
"export.format.placeholder": "Выберите формат…"
|
||||
"export.path": "Путь вывода"
|
||||
"export.sequence": "Секвенция"
|
||||
"export.run": "Экспортировать"
|
||||
"export.hint": "Последовательность экспортируется через путь экспорта oaktask; ход выполнения отображается в диалоговом окне."
|
||||
"export.progress.title": "Экспорт"
|
||||
@@ -467,9 +867,12 @@
|
||||
"node.category.group": "Группа"
|
||||
"project.context.new": "Создать"
|
||||
"project.new_sequence": "Новая последовательность"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Показать в Finder"
|
||||
"project.context.replace_footage": "Заменить медиафайлы"
|
||||
"project.context.rename": "Переименовать"
|
||||
"project.context.export_sequence": "Экспортировать секвенцию…"
|
||||
"project.context.delete": "Удалить"
|
||||
"project.context.open_in_new_tab": "Открыть в новой вкладке"
|
||||
"project.context.open_in_new_window": "Открыть в новом окне"
|
||||
|
||||
+405
-2
@@ -22,7 +22,7 @@
|
||||
"menu.file.export_project": "导出工程文件…"
|
||||
"menu.file.save_as": "另存为…"
|
||||
"menu.file.revert": "还原"
|
||||
"menu.file.export_media": "导出媒体…"
|
||||
"menu.file.export_media": "导出序列…"
|
||||
"menu.file.project_properties": "项目属性…"
|
||||
"menu.file.close": "关闭项目"
|
||||
"menu.file.quit": "退出"
|
||||
@@ -133,6 +133,7 @@
|
||||
"menu.tools.use_proxy": "使用代理媒体"
|
||||
"menu.tools.proxy_settings": "代理设置…"
|
||||
"menu.help.action_search": "搜索动作…"
|
||||
"menu.help.bug_report": "报告问题…"
|
||||
"menu.help.feedback": "发送反馈…"
|
||||
"menu.help.about": "关于 Oak…"
|
||||
"shortcut.space": "空格"
|
||||
@@ -169,6 +170,384 @@
|
||||
"multicam.switch_9": "切换到机位 9"
|
||||
"effect_library.hint": "双击添加到选中片段"
|
||||
"effect_library.group.builtin": "内置"
|
||||
"effect_library.group.color": "调色"
|
||||
"effect_library.group.filter": "滤镜"
|
||||
"effect_library.group.distort": "扭曲"
|
||||
"effect_library.group.keying": "键控"
|
||||
"effect_library.group.generator": "生成器"
|
||||
"effect_library.group.math": "数学"
|
||||
"effect_library.group.time": "时间"
|
||||
"effect_library.group.channel": "通道"
|
||||
"effect_library.group.transition": "转场"
|
||||
"effect_library.group.general": "通用"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "多边形"
|
||||
"node.polygon.input.base_in": "基础"
|
||||
"node.polygon.input.points_in": "顶点"
|
||||
"node.polygon.input.color_in": "颜色"
|
||||
"node.ortho.name": "正交矩阵"
|
||||
"node.ortho.input.pos_in": "位置"
|
||||
"node.ortho.input.rot_in": "旋转"
|
||||
"node.ortho.input.scale_in": "缩放"
|
||||
"node.ortho.input.uniform_scale_in": "等比缩放"
|
||||
"node.ortho.input.anchor_in": "锚点"
|
||||
"node.transform.name": "变换"
|
||||
"node.transform.input.tex_in": "纹理"
|
||||
"node.transform.input.parent_in": "父级"
|
||||
"node.transform.input.autoscale_in": "自动缩放"
|
||||
"node.transform.input.interpolation_in": "插值"
|
||||
"node.transform.input.pos_in": "位置"
|
||||
"node.transform.input.rot_in": "旋转"
|
||||
"node.transform.input.scale_in": "缩放"
|
||||
"node.transform.input.uniform_scale_in": "等比缩放"
|
||||
"node.transform.input.anchor_in": "锚点"
|
||||
"node.volume.name": "音量"
|
||||
"node.volume.input.samples_in": "采样数"
|
||||
"node.volume.input.volume_in": "音量"
|
||||
"node.pan.name": "声像"
|
||||
"node.pan.input.samples_in": "采样数"
|
||||
"node.pan.input.panning_in": "声像"
|
||||
"node.math.name": "数学"
|
||||
"node.math.input.method_in": "方法"
|
||||
"node.math.input.param_a_in": "数值"
|
||||
"node.math.input.param_b_in": "数值"
|
||||
"node.time.name": "时间"
|
||||
"node.time.input.enabled_in": "启用"
|
||||
"node.trigonometry.name": "三角函数"
|
||||
"node.trigonometry.input.method_in": "方法"
|
||||
"node.trigonometry.input.x_in": "数值"
|
||||
"node.blur.name": "模糊"
|
||||
"node.blur.input.tex_in": "输入"
|
||||
"node.blur.input.method_in": "方法"
|
||||
"node.blur.input.radius_in": "半径"
|
||||
"node.blur.input.horiz_in": "水平"
|
||||
"node.blur.input.vert_in": "垂直"
|
||||
"node.blur.input.directional_degrees_in": "方向"
|
||||
"node.blur.input.radial_center_in": "中心"
|
||||
"node.blur.input.repeat_edge_pixels_in": "重复边缘像素"
|
||||
"node.solidgenerator.name": "纯色"
|
||||
"node.solidgenerator.input.color_in": "颜色"
|
||||
"node.merge.name": "合并"
|
||||
"node.merge.input.base_in": "基础"
|
||||
"node.merge.input.blend_in": "混合层"
|
||||
"node.stroke.name": "描边"
|
||||
"node.stroke.input.tex_in": "输入"
|
||||
"node.stroke.input.color_in": "颜色"
|
||||
"node.stroke.input.radius_in": "半径"
|
||||
"node.stroke.input.opacity_in": "不透明度"
|
||||
"node.stroke.input.inner_in": "内部"
|
||||
"node.textgenerator.name": "文本(旧版)"
|
||||
"node.textgenerator.input.text_in": "文本"
|
||||
"node.textgenerator.input.html_in": "启用 HTML"
|
||||
"node.textgenerator.input.color_in": "颜色"
|
||||
"node.textgenerator.input.valign_in": "垂直对齐"
|
||||
"node.textgenerator.input.font_in": "字体"
|
||||
"node.textgenerator.input.font_size_in": "字号"
|
||||
"node.text2.name": "文本(旧版)"
|
||||
"node.text2.input.base_in": "基础"
|
||||
"node.text2.input.pos_in": "位置"
|
||||
"node.text2.input.size_in": "尺寸"
|
||||
"node.text2.input.color_in": "颜色"
|
||||
"node.text2.input.text_in": "文本"
|
||||
"node.text2.input.html_in": "启用 HTML"
|
||||
"node.text2.input.valign_in": "垂直对齐"
|
||||
"node.text2.input.font_in": "字体"
|
||||
"node.text2.input.font_size_in": "字号"
|
||||
"node.text3.name": "文本"
|
||||
"node.text3.input.base_in": "基础"
|
||||
"node.text3.input.pos_in": "位置"
|
||||
"node.text3.input.size_in": "尺寸"
|
||||
"node.text3.input.plain_text_in": "文本"
|
||||
"node.text3.input.text_in": "文本"
|
||||
"node.text3.input.font_family_in": "字体族"
|
||||
"node.text3.input.font_size_in": "字号"
|
||||
"node.text3.input.outline_enabled_in": "描边"
|
||||
"node.text3.input.outline_color_in": "描边颜色"
|
||||
"node.text3.input.outline_width_in": "描边宽度"
|
||||
"node.text3.input.glow_enabled_in": "辉光"
|
||||
"node.text3.input.glow_color_in": "辉光颜色"
|
||||
"node.text3.input.glow_radius_in": "辉光半径"
|
||||
"node.text3.input.color_in": "颜色"
|
||||
"node.text3.input.valign_in": "垂直对齐"
|
||||
"node.text3.input.args_in": "参数列表"
|
||||
"node.mosaicfilter.name": "马赛克"
|
||||
"node.mosaicfilter.input.tex_in": "纹理"
|
||||
"node.mosaicfilter.input.horiz_in": "水平"
|
||||
"node.mosaicfilter.input.vert_in": "垂直"
|
||||
"node.crop.name": "裁剪"
|
||||
"node.crop.input.tex_in": "纹理"
|
||||
"node.crop.input.left_in": "左"
|
||||
"node.crop.input.top_in": "上"
|
||||
"node.crop.input.right_in": "右"
|
||||
"node.crop.input.bottom_in": "下"
|
||||
"node.crop.input.feather_in": "羽化"
|
||||
"node.value.name": "数值"
|
||||
"node.value.input.type_in": "类型"
|
||||
"node.value.input.value_in": "数值"
|
||||
"node.timeremap.name": "时间重映射"
|
||||
"node.timeremap.input.time_in": "时间"
|
||||
"node.timeremap.input.input_in": "输入"
|
||||
"node.shape.name": "形状"
|
||||
"node.shape.input.type_in": "类型"
|
||||
"node.shape.input.base_in": "基础"
|
||||
"node.shape.input.pos_in": "位置"
|
||||
"node.shape.input.size_in": "尺寸"
|
||||
"node.shape.input.color_in": "颜色"
|
||||
"node.shape.input.radius_in": "半径"
|
||||
"node.colordifferencekey.name": "色差键控"
|
||||
"node.colordifferencekey.input.tex_in": "输入"
|
||||
"node.colordifferencekey.input.garbage_in": "垃圾遮罩"
|
||||
"node.colordifferencekey.input.core_in": "核心遮罩"
|
||||
"node.colordifferencekey.input.color_in": "键控色"
|
||||
"node.colordifferencekey.input.highlights_in": "高光"
|
||||
"node.colordifferencekey.input.shadows_in": "阴影"
|
||||
"node.colordifferencekey.input.mask_only_in": "仅显示遮罩"
|
||||
"node.despill.name": "去溢色"
|
||||
"node.despill.input.tex_in": "输入"
|
||||
"node.despill.input.color_in": "键控色"
|
||||
"node.despill.input.method_in": "方法"
|
||||
"node.despill.input.preserve_luminance_input": "保持亮度"
|
||||
"node.group.name": "组"
|
||||
"node.opacity.name": "不透明度"
|
||||
"node.opacity.input.tex_in": "纹理"
|
||||
"node.opacity.input.opacity_in": "不透明度"
|
||||
"node.flip.name": "翻转"
|
||||
"node.flip.input.tex_in": "输入"
|
||||
"node.flip.input.horiz_in": "水平"
|
||||
"node.flip.input.vert_in": "垂直"
|
||||
"node.noise.name": "噪波"
|
||||
"node.noise.input.base_in": "基础"
|
||||
"node.noise.input.strength_in": "强度"
|
||||
"node.noise.input.color_in": "颜色"
|
||||
"node.timeoffset.name": "时间偏移"
|
||||
"node.timeoffset.input.time_in": "时间"
|
||||
"node.timeoffset.input.input_in": "输入"
|
||||
"node.transition.name": "转场"
|
||||
"node.transition.input.tex_in": "起点"
|
||||
"node.transition.input.blend_in": "终点"
|
||||
"node.transition.input.progress_in": "进度"
|
||||
"node.transition.input.type_in": "类型"
|
||||
"node.transitionfx.name": "转场特效"
|
||||
"node.transitionfx.input.tex_in": "起点"
|
||||
"node.transitionfx.input.blend_in": "终点"
|
||||
"node.transitionfx.input.progress_in": "进度"
|
||||
"node.transitionfx.input.color_in": "颜色"
|
||||
"node.transitionfx.input.direction_in": "方向"
|
||||
"node.transitionfx.input.type_in": "类型"
|
||||
"node.cornerpin.name": "角点定位"
|
||||
"node.cornerpin.input.tex_in": "纹理"
|
||||
"node.cornerpin.input.perspective_in": "透视"
|
||||
"node.cornerpin.input.top_left_in": "左上角"
|
||||
"node.cornerpin.input.top_right_in": "右上角"
|
||||
"node.cornerpin.input.bottom_right_in": "右下角"
|
||||
"node.cornerpin.input.bottom_left_in": "左下角"
|
||||
"node.displaytransform.name": "显示变换"
|
||||
"node.displaytransform.input.tex_in": "输入"
|
||||
"node.displaytransform.input.display_in": "显示"
|
||||
"node.displaytransform.input.view_in": "视图"
|
||||
"node.displaytransform.input.dir_in": "方向"
|
||||
"node.ociogradingtransformlinear.name": "OCIO 调色(线性)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "输入"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "对比度"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "偏移"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "曝光"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "饱和度"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "轴心"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "启用黑场限幅"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "黑场限幅"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "启用白场限幅"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "白场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO 调色(对数)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "输入"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "提升"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "增益"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "伽马"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "饱和度"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "轴心"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "启用黑场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "黑场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "启用白场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "白场限幅"
|
||||
"node.whitebalance.name": "白平衡"
|
||||
"node.whitebalance.input.tex_in": "输入"
|
||||
"node.whitebalance.input.temperature_in": "色温 (K)"
|
||||
"node.whitebalance.input.tint_in": "色调"
|
||||
"node.ociolut.name": "OCIO 查找表"
|
||||
"node.ociolut.input.tex_in": "输入"
|
||||
"node.ociolut.input.lut_file_in": "LUT 文件"
|
||||
"node.ociolut.input.lut_dir_in": "方向"
|
||||
"node.threewaycolor.name": "三向调色"
|
||||
"node.threewaycolor.input.tex_in": "输入"
|
||||
"node.threewaycolor.input.shadows_color_in": "阴影"
|
||||
"node.threewaycolor.input.midtones_color_in": "中间调"
|
||||
"node.threewaycolor.input.highlights_color_in": "高光"
|
||||
"node.threewaycolor.input.shadows_amount_in": "阴影量"
|
||||
"node.threewaycolor.input.midtones_amount_in": "中间调量"
|
||||
"node.threewaycolor.input.highlights_amount_in": "高光量"
|
||||
"node.chromakey.name": "色度键"
|
||||
"node.chromakey.input.tex_in": "输入"
|
||||
"node.chromakey.input.color_key": "键控色"
|
||||
"node.chromakey.input.lower_tolerance_in": "下限容差"
|
||||
"node.chromakey.input.upper_tolerance_in": "上限容差"
|
||||
"node.chromakey.input.garbage_in": "垃圾遮罩"
|
||||
"node.chromakey.input.core_in": "核心遮罩"
|
||||
"node.chromakey.input.highlights_in": "高光"
|
||||
"node.chromakey.input.shadows_in": "阴影"
|
||||
"node.chromakey.input.invert_in": "反转遮罩"
|
||||
"node.chromakey.input.mask_only_in": "仅显示遮罩"
|
||||
"node.mask.name": "遮罩"
|
||||
"node.mask.input.base_in": "纹理"
|
||||
"node.mask.input.points_in": "顶点"
|
||||
"node.mask.input.color_in": "颜色"
|
||||
"node.mask.input.invert_in": "反转"
|
||||
"node.mask.input.feather_in": "羽化"
|
||||
"node.dropshadow.name": "投影"
|
||||
"node.dropshadow.input.tex_in": "纹理"
|
||||
"node.dropshadow.input.color_in": "颜色"
|
||||
"node.dropshadow.input.distance_in": "距离"
|
||||
"node.dropshadow.input.angle_in": "角度"
|
||||
"node.dropshadow.input.radius_in": "柔和度"
|
||||
"node.dropshadow.input.opacity_in": "不透明度"
|
||||
"node.dropshadow.input.fast_in": "更快(较低质量)"
|
||||
"node.timeformat.name": "时间格式"
|
||||
"node.timeformat.input.time_in": "时间"
|
||||
"node.timeformat.input.format_in": "格式"
|
||||
"node.timeformat.input.localtime_in": "按本地时间解释"
|
||||
"node.wave.name": "波纹"
|
||||
"node.wave.input.tex_in": "输入"
|
||||
"node.wave.input.frequency_in": "频率"
|
||||
"node.wave.input.intensity_in": "强度"
|
||||
"node.wave.input.evolution_in": "演化"
|
||||
"node.wave.input.vertical_in": "方向"
|
||||
"node.tile.name": "平铺"
|
||||
"node.tile.input.tex_in": "输入"
|
||||
"node.tile.input.scale_in": "缩放"
|
||||
"node.tile.input.position_in": "位置"
|
||||
"node.tile.input.anchor_in": "锚点"
|
||||
"node.tile.input.mirrorx_in": "水平镜像"
|
||||
"node.tile.input.mirrory_in": "垂直镜像"
|
||||
"node.swirl.name": "漩涡"
|
||||
"node.swirl.input.tex_in": "输入"
|
||||
"node.swirl.input.radius_in": "半径"
|
||||
"node.swirl.input.angle_in": "角度"
|
||||
"node.swirl.input.pos_in": "位置"
|
||||
"node.ripple.name": "涟漪"
|
||||
"node.ripple.input.tex_in": "输入"
|
||||
"node.ripple.input.evolution_in": "演化"
|
||||
"node.ripple.input.intensity_in": "强度"
|
||||
"node.ripple.input.frequency_in": "频率"
|
||||
"node.ripple.input.position_in": "位置"
|
||||
"node.ripple.input.stretch_in": "拉伸"
|
||||
"node.colormatrix.name": "颜色矩阵"
|
||||
"node.colormatrix.input.tex_in": "输入"
|
||||
"node.colormatrix.input.m0": "R ← R"
|
||||
"node.colormatrix.input.m1": "R ← G"
|
||||
"node.colormatrix.input.m2": "R ← B"
|
||||
"node.colormatrix.input.m3": "R ← A"
|
||||
"node.colormatrix.input.m4": "G ← R"
|
||||
"node.colormatrix.input.m5": "G ← G"
|
||||
"node.colormatrix.input.m6": "G ← B"
|
||||
"node.colormatrix.input.m7": "G ← A"
|
||||
"node.colormatrix.input.m8": "B ← R"
|
||||
"node.colormatrix.input.m9": "B ← G"
|
||||
"node.colormatrix.input.m10": "B ← B"
|
||||
"node.colormatrix.input.m11": "B ← A"
|
||||
"node.colormatrix.input.m12": "A ← R"
|
||||
"node.colormatrix.input.m13": "A ← G"
|
||||
"node.colormatrix.input.m14": "A ← B"
|
||||
"node.colormatrix.input.m15": "A ← A"
|
||||
"node.dilate.name": "膨胀"
|
||||
"node.dilate.input.tex_in": "输入"
|
||||
"node.dilate.input.radius_in": "半径"
|
||||
"node.edgedetect.name": "边缘检测"
|
||||
"node.edgedetect.input.tex_in": "输入"
|
||||
"node.edgedetect.input.threshold_in": "阈值"
|
||||
"node.erode.name": "腐蚀"
|
||||
"node.erode.input.tex_in": "输入"
|
||||
"node.erode.input.radius_in": "半径"
|
||||
"node.colorcorrect.name": "颜色校正"
|
||||
"node.colorcorrect.input.tex_in": "输入"
|
||||
"node.colorcorrect.input.saturation_in": "饱和度"
|
||||
"node.colorcorrect.input.contrast_in": "对比度"
|
||||
"node.colorcorrect.input.gamma_in": "伽马"
|
||||
"node.colorcorrect.input.gain_in": "增益"
|
||||
"node.colorcorrect.input.offset_in": "偏移"
|
||||
"node.gamma.name": "伽马"
|
||||
"node.gamma.input.tex_in": "输入"
|
||||
"node.gamma.input.gamma_in": "伽马"
|
||||
"node.saturation.name": "饱和度"
|
||||
"node.saturation.input.tex_in": "输入"
|
||||
"node.saturation.input.saturation_in": "饱和度"
|
||||
"node.invert.name": "反转"
|
||||
"node.invert.input.tex_in": "输入"
|
||||
"node.invert.input.invert_r_in": "反转红"
|
||||
"node.invert.input.invert_g_in": "反转绿"
|
||||
"node.invert.input.invert_b_in": "反转蓝"
|
||||
"node.invert.input.invert_a_in": "反转 Alpha"
|
||||
"node.clamp.name": "限幅"
|
||||
"node.clamp.input.tex_in": "输入"
|
||||
"node.clamp.input.min_in": "最小"
|
||||
"node.clamp.input.max_in": "最大"
|
||||
"node.grade.name": "调色"
|
||||
"node.grade.input.tex_in": "输入"
|
||||
"node.grade.input.blackpoint_in": "黑点"
|
||||
"node.grade.input.whitepoint_in": "白点"
|
||||
"node.grade.input.black_in": "黑场输出"
|
||||
"node.grade.input.white_in": "白场输出"
|
||||
"node.grade.input.gamma_in": "伽马"
|
||||
"node.dirblur.name": "方向模糊"
|
||||
"node.dirblur.input.tex_in": "输入"
|
||||
"node.dirblur.input.amount_in": "强度"
|
||||
"node.dirblur.input.angle_in": "角度"
|
||||
"node.sharpen.name": "锐化"
|
||||
"node.sharpen.input.tex_in": "输入"
|
||||
"node.sharpen.input.amount_in": "强度"
|
||||
"node.dissolve.name": "溶解"
|
||||
"node.dissolve.input.tex_in": "输入"
|
||||
"node.dissolve.input.blend_in": "混合层"
|
||||
"node.dissolve.input.mix_in": "混合"
|
||||
"node.keymix.name": "键控混合"
|
||||
"node.keymix.input.tex_in": "输入"
|
||||
"node.keymix.input.blend_in": "混合层"
|
||||
"node.keymix.input.mask_in": "遮罩"
|
||||
"node.premult.name": "预乘"
|
||||
"node.premult.input.tex_in": "输入"
|
||||
"node.premult.input.premult_channel_in": "通道"
|
||||
"node.unpremult.name": "反预乘"
|
||||
"node.unpremult.input.tex_in": "输入"
|
||||
"node.unpremult.input.unpremult_channel_in": "通道"
|
||||
"node.position.name": "位置"
|
||||
"node.position.input.tex_in": "输入"
|
||||
"node.position.input.offset_in": "平移"
|
||||
"node.mirror.name": "镜像"
|
||||
"node.mirror.input.tex_in": "输入"
|
||||
"node.mirror.input.horizontal_in": "水平(翻转)"
|
||||
"node.mirror.input.vertical_in": "垂直(翻转)"
|
||||
"node.checkerboard.name": "棋盘格"
|
||||
"node.checkerboard.input.base_in": "基础"
|
||||
"node.checkerboard.input.size_in": "盒子尺寸"
|
||||
"node.checkerboard.input.color1_in": "颜色 1"
|
||||
"node.checkerboard.input.color2_in": "颜色 2"
|
||||
"node.colorbars.name": "彩条"
|
||||
"node.colorbars.input.base_in": "基础"
|
||||
"node.colorbars.input.standard_in": "标准"
|
||||
"node.ramp.name": "渐变"
|
||||
"node.ramp.input.base_in": "基础"
|
||||
"node.ramp.input.point0_in": "点 0"
|
||||
"node.ramp.input.point1_in": "点 1"
|
||||
"node.ramp.input.color0_in": "颜色 0"
|
||||
"node.ramp.input.color1_in": "颜色 1"
|
||||
"node.multicam.name": "多机位"
|
||||
"node.multicam.input.current_in": "当前"
|
||||
"node.multicam.input.sources_in": "源"
|
||||
"node.multicam.input.sequence_in": "序列"
|
||||
"node.multicam.input.sequence_type_in": "序列类型"
|
||||
"node.graphinput.name": "图输入"
|
||||
"node.graphinput.input.feed_in": "馈入"
|
||||
"node.graphoutput.name": "图输出"
|
||||
"node.graphoutput.input.tex_in": "纹理"
|
||||
"status.ready": "就绪"
|
||||
"status.cache": "缓存:已启用"
|
||||
"status.proxy.on": "代理:开"
|
||||
@@ -181,6 +560,7 @@
|
||||
"manager.title": "项目管理器"
|
||||
"manager.new": "新建项目"
|
||||
"manager.new.default_name": "未命名项目"
|
||||
"manager.new.project_name": "项目名称"
|
||||
"manager.open": "打开"
|
||||
"manager.rename": "重命名…"
|
||||
"manager.rename.title": "重命名工程"
|
||||
@@ -301,7 +681,13 @@
|
||||
"about.description": "Oak 视频编辑器是一款自由开源的非线性视频编辑器。本软件以 GNU GPL 第三版许可证发布。"
|
||||
"about.thanks": "特别感谢我们的社区推广者 Enzo GD,感谢他在项目早期为传播本项目所给予的慷慨支持。"
|
||||
"about.fork_notice": "本项目是 Olive 视频编辑器的 fork。"
|
||||
"file.open.title": "打开项目"
|
||||
"update.title": "有新版本可用"
|
||||
"update.available": "新版本 {version} 已发布,当前版本为 {current}。"
|
||||
"update.notes": "更新说明"
|
||||
"update.no_notes": "未提供更新说明。"
|
||||
"update.download": "前往下载"
|
||||
"update.later": "稍后"
|
||||
"file.open.title": "打开项目(.ove / .otio / .fcpxml)"
|
||||
"file.import_footage.title": "导入素材"
|
||||
"preferences.title": "偏好设置"
|
||||
"preferences.section.general": "常规"
|
||||
@@ -325,6 +711,7 @@
|
||||
"preferences.language": "语言"
|
||||
"preferences.language.placeholder": "选择语言…"
|
||||
"preferences.theme": "主题"
|
||||
"preferences.check_updates": "启动时检查更新"
|
||||
"preferences.theme.dark": "Olive Dark"
|
||||
"preferences.theme.light": "Olive Light"
|
||||
"preferences.cache.dir": "磁盘缓存目录"
|
||||
@@ -372,8 +759,21 @@
|
||||
"actionsearch.no_actions": "没有可用动作"
|
||||
"export.title": "导出序列"
|
||||
"export.format": "格式"
|
||||
"export.video_codec": "视频编码"
|
||||
"export.audio_codec": "音频编码"
|
||||
"export.color": "色域/色深"
|
||||
"export.color.sdr": "SDR — Rec.709 8 位"
|
||||
"export.resolution": "分辨率(0 = 跟随序列)"
|
||||
"export.framerate": "帧率(0 = 跟随序列)"
|
||||
"export.bitrate": "码率(0 = 编码器默认)"
|
||||
"export.color.hdr": "HDR — BT.2020 10 位"
|
||||
"export.range": "导出范围"
|
||||
"export.range.all": "整条时间轴"
|
||||
"export.range.inout": "入点-出点之间"
|
||||
"project.export.format": "项目格式"
|
||||
"export.format.placeholder": "选择格式…"
|
||||
"export.path": "输出路径"
|
||||
"export.sequence": "序列"
|
||||
"export.run": "导出"
|
||||
"export.hint": "序列通过 oaktask 导出路径导出;进度显示在对话框中。"
|
||||
"export.progress.title": "正在导出"
|
||||
@@ -465,9 +865,12 @@
|
||||
"node.category.group": "组"
|
||||
"project.context.new": "新建"
|
||||
"project.new_sequence": "新建序列"
|
||||
"project.add_text_footage": "添加文本素材"
|
||||
"timeline.context.add_adjustment_layer": "添加调整图层"
|
||||
"project.context.reveal_in_finder": "在 Finder 中显示"
|
||||
"project.context.replace_footage": "替换素材"
|
||||
"project.context.rename": "重命名"
|
||||
"project.context.export_sequence": "导出序列…"
|
||||
"project.context.delete": "删除"
|
||||
"project.context.open_in_new_tab": "在新标签页中打开"
|
||||
"project.context.open_in_new_window": "在新窗口中打开"
|
||||
|
||||
@@ -61,6 +61,12 @@ gpui_elements = { path = "../../gpui/crates/gpui_elements" }
|
||||
# runtime (bundled with the app, user-extensible) with the compiled-in
|
||||
# English table as the fallback.
|
||||
serde_yaml = "0.9"
|
||||
# The startup update check and the external oakvideoeditor.org links: a
|
||||
# blocking, bounded HTTPS GET (rustls) plus JSON response parsing. The
|
||||
# fetch runs on the gpui background executor, never on the UI thread.
|
||||
ureq = "3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The gpui/wgpu stack logs through the `log` facade; the app installs the
|
||||
# stderr backend (see oakapp::logging) so validation errors and warnings
|
||||
# are actually visible (RUST_LOG selects the verbosity).
|
||||
@@ -76,7 +82,6 @@ log = "0.4"
|
||||
# dylib, no C ABI, no build.rs link step, no host shims.
|
||||
oak-audio = { path = "../oak-audio" }
|
||||
oak-codec = { path = "../oak-codec" }
|
||||
oak-common = { path = "../oak-common" }
|
||||
oak-core = { path = "../oak-core" }
|
||||
oak-node = { path = "../oak-node" }
|
||||
oak-plugin = { path = "../oak-plugin" }
|
||||
@@ -93,6 +98,14 @@ default = []
|
||||
# is given at runtime (or this feature is enabled at build time).
|
||||
mock-engine = []
|
||||
|
||||
# The Windows window/taskbar icon: embeds resources/windows/oak.rc (icon
|
||||
# resource ID 1, read by gpui_windows' `load_icon`) into oak-editor.exe.
|
||||
# Same crate gpui uses for its Windows manifest (see gpui's build.rs).
|
||||
# Unconditional (not target-gated): the build script references it behind
|
||||
# a runtime `CARGO_CFG_TARGET_OS` check, so it must resolve on every host.
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# `#[gpui::test]` harness for engine-seam smoke tests (test-support feature).
|
||||
gpui = { path = "../../gpui/crates/gpui", features = ["test-support"] }
|
||||
@@ -129,17 +142,21 @@ description = "Oak Video Editor"
|
||||
longDescription = "Oak Video Editor: a free, open-source non-linear video editor written in Rust."
|
||||
authors = ["Oak Team"]
|
||||
copyright = "Copyright (C) 2026 Oak Team"
|
||||
licenseFile = "LICENSE"
|
||||
licenseFile = "../../LICENSE"
|
||||
category = "Video"
|
||||
icons = ["../../icons/icon.png"]
|
||||
# The UI string tables (language packs); users can drop extra
|
||||
# <lang>.yaml files into the installed i18n directory.
|
||||
# The theme-aware UI glyphs (dark/ + light/), resolved at runtime by
|
||||
# crates/oak-app/src/oakui/icons.rs from `<exe dir>/icons` (Windows/Linux
|
||||
# packages) or `Contents/Resources/icons` (macOS bundle).
|
||||
# The Windows DLL staging dir is filled by
|
||||
# tooling/package/bundle-dylibs-windows.sh before `cargo packager` runs
|
||||
# (NSIS/WiX place resources next to the executable, which is exactly
|
||||
# where Windows resolves DLLs from). Empty locally = no bundled DLLs.
|
||||
resources = [
|
||||
"../../assets/i18n",
|
||||
"../../assets/icons",
|
||||
{ src = "../../target/pkg/win-dlls/*.dll", target = "." },
|
||||
]
|
||||
binaries = [
|
||||
|
||||
@@ -37,6 +37,16 @@ fn main() {
|
||||
// at the real system library.
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib");
|
||||
}
|
||||
if os == "windows" {
|
||||
// The window/taskbar icon on Windows comes from the exe's icon
|
||||
// resource (ID 1 — gpui_windows' `load_icon`); compile it in. The
|
||||
// same resource is the exe's Explorer icon.
|
||||
println!("cargo:rerun-if-changed=resources/windows/oak.rc");
|
||||
println!("cargo:rerun-if-changed=../../assets/appicon/icon.ico");
|
||||
embed_resource::compile("resources/windows/oak.rc", embed_resource::NONE)
|
||||
.manifest_optional()
|
||||
.unwrap();
|
||||
}
|
||||
// --- OFX interact end-to-end test plugin ---------------------------------
|
||||
// The app-side interact tests (src/oakui/ofx.rs) drive the *real*
|
||||
// minimal test plugin (../../crates/oak-plugin/cbits/oak_test_plugin.c) through
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Icon resource ID 1 is what gpui_windows' `load_icon` reads for the
|
||||
// window/taskbar icon; it is also the exe's Explorer icon. The path is
|
||||
// resolved from the crate root (the build script's working directory).
|
||||
1 ICON "../../assets/appicon/icon.ico"
|
||||
@@ -275,6 +275,7 @@ define_actions! {
|
||||
// --- Help ---------------------------------------------------------------
|
||||
ActionSearch { cpp: "actionsearch", i18n: "menu.help.action_search", keys: ["/"], route: Global, menu_id: 1120 };
|
||||
Feedback { cpp: "feedback", i18n: "menu.help.feedback", keys: [], route: Global, menu_id: 1121 };
|
||||
BugReport { cpp: "bugreport", i18n: "menu.help.bug_report", keys: [], route: Global, menu_id: 1122 };
|
||||
About { cpp: "about", i18n: "menu.help.about", keys: [], route: Global, menu_id: 801 };
|
||||
}
|
||||
|
||||
@@ -332,6 +333,20 @@ pub fn entry_for_menu_id(id: usize) -> Option<&'static ActionEntry> {
|
||||
REGISTRY.iter().find(|entry| entry.action.menu_id() == id)
|
||||
}
|
||||
|
||||
/// The key-context every app hotkey stays out of: the editable-text
|
||||
/// element's `"EditableText"` context (shared by the app's single-line
|
||||
/// and multiline fields). NLEs run on single-key shuttles (j/k/l, i/o,
|
||||
/// c/v, …), and a context-less binding is treated as matching the
|
||||
/// DEEPEST context, so an ungated global would swallow every letter a
|
||||
/// user types into a value box / hex field / text input (the "cannot
|
||||
/// type into the text field" report).
|
||||
pub(crate) fn input_exclusion_predicate() -> Rc<KeyBindingContextPredicate> {
|
||||
Rc::new(
|
||||
KeyBindingContextPredicate::parse("!EditableText")
|
||||
.expect("the input-exclusion predicate parses"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The global key bindings for every action's *effective* key. The shell
|
||||
/// dispatches them wherever the focus is (the modal guard in the shell's
|
||||
/// action listeners suppresses them while a dialog is open). An unbound
|
||||
@@ -344,14 +359,17 @@ pub fn entry_for_menu_id(id: usize) -> Option<&'static ActionEntry> {
|
||||
/// value box / hex field / text input can type `1..9`.
|
||||
pub fn key_bindings() -> Vec<KeyBinding> {
|
||||
let mut bindings = Vec::new();
|
||||
let not_input = input_exclusion_predicate();
|
||||
for entry in REGISTRY {
|
||||
// The stable C++ ids of the 18 source-switch actions all start
|
||||
// with `multicamswitch` (1..9 and 1nosplit..9nosplit); nothing
|
||||
// else shares the prefix.
|
||||
let context = entry
|
||||
.cpp_id
|
||||
.starts_with("multicamswitch")
|
||||
.then(|| Rc::new(KeyBindingContextPredicate::parse("MulticamPanel").unwrap()));
|
||||
let context = if entry.cpp_id.starts_with("multicamswitch") {
|
||||
Rc::new(KeyBindingContextPredicate::parse("MulticamPanel").unwrap())
|
||||
} else {
|
||||
not_input.clone()
|
||||
};
|
||||
let context = Some(context);
|
||||
for key in effective_keys(entry) {
|
||||
let binding = KeyBinding::load(
|
||||
&key,
|
||||
@@ -481,7 +499,7 @@ pub(crate) fn shortcuts_test_lock() -> &'static Mutex<()> {
|
||||
/// The path of the custom-shortcuts file: `<config>/shortcuts`, exactly like
|
||||
/// the C++ `MainWindow::get_custom_shortcuts_file`.
|
||||
pub fn custom_shortcuts_path() -> String {
|
||||
let dir = oak_common::filefunctions::FileFunctions::new()
|
||||
let dir = oak_core::filefunctions::FileFunctions::new()
|
||||
.get_configuration_location()
|
||||
.unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
|
||||
format!("{}/shortcuts", dir.trim_end_matches('/'))
|
||||
@@ -796,7 +814,7 @@ impl Tool {
|
||||
}
|
||||
|
||||
/// Extension trait mapping the timeline widget's tool back to the app tool
|
||||
/// (for the toolbar highlight). Unmodeled tools fall back to the pointer.
|
||||
/// (for the toolbar highlight).
|
||||
pub trait TimelineToolExt {
|
||||
fn app_tool(self) -> Tool;
|
||||
}
|
||||
@@ -812,7 +830,6 @@ impl TimelineToolExt for gpui::timeline::TimelineTool {
|
||||
gpui::timeline::TimelineTool::Slip => Tool::Slip,
|
||||
gpui::timeline::TimelineTool::Slide => Tool::Slide,
|
||||
gpui::timeline::TimelineTool::Zoom => Tool::Zoom,
|
||||
_ => Tool::Pointer,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,15 +938,37 @@ mod tests {
|
||||
"the source-switch binding must be gated on the MulticamPanel key context"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
binding.predicate().is_none(),
|
||||
"non-multicam bindings must stay global"
|
||||
assert_eq!(
|
||||
binding.predicate(),
|
||||
Some(input_exclusion_predicate()),
|
||||
"non-multicam bindings must stay out of text fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(scoped, 18, "all 18 source-switch bindings are panel-scoped");
|
||||
}
|
||||
|
||||
/// The input-exclusion predicate matches everywhere EXCEPT inside an
|
||||
/// editable-text field, so single-key shuttles (j/k/l, i/o, c/v, …)
|
||||
/// fire on the timeline but never swallow a letter typed into a value
|
||||
/// box / hex field / text input (the "cannot type into the text field"
|
||||
/// report).
|
||||
#[test]
|
||||
fn input_exclusion_predicate_stays_out_of_text_fields() {
|
||||
let predicate = input_exclusion_predicate();
|
||||
let text_context = gpui::KeyContext::parse("EditableText").unwrap();
|
||||
let panel_context = gpui::KeyContext::parse("Timeline").unwrap();
|
||||
assert_eq!(
|
||||
predicate.depth_of(&[text_context]),
|
||||
None,
|
||||
"no app hotkey matches while a text field is focused"
|
||||
);
|
||||
assert!(
|
||||
predicate.depth_of(&[panel_context]).is_some(),
|
||||
"hotkeys still match outside text fields"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every registry action appears somewhere in the menu tree (an action
|
||||
/// without a menu entry is unreachable with the mouse, and the registry
|
||||
/// is meant to drive the menus).
|
||||
|
||||
+3729
-97
File diff suppressed because it is too large
Load Diff
+3149
-199
File diff suppressed because it is too large
Load Diff
+75
-17
@@ -141,6 +141,27 @@ pub fn tr(key: &'static str) -> &'static str {
|
||||
key
|
||||
}
|
||||
|
||||
/// Translates a key built at runtime — the built-in effect and parameter
|
||||
/// names, whose keys are `node.<type-id suffix>.name` and
|
||||
/// `node.<type-id suffix>.input.<input id>` (see
|
||||
/// [`oakui::effectchain::addable_effects`](crate::oakui::effectchain::addable_effects)).
|
||||
///
|
||||
/// Same lookup as [`tr`] — the active language, then `en-US` — with the
|
||||
/// node layer's own English name as the last resort: `tr` takes
|
||||
/// `&'static str` keys and returns the key itself on a miss, which for a
|
||||
/// formatted key would put a raw `node.…` identifier in the UI.
|
||||
pub fn tr_or(key: &str, fallback: &str) -> String {
|
||||
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
|
||||
let current = language_code();
|
||||
if let Some(value) = tables.get(¤t).and_then(|t| t.get(key)) {
|
||||
return value.to_string();
|
||||
}
|
||||
if let Some(value) = tables.get(FALLBACK_CODE).and_then(|t| t.get(key)) {
|
||||
return value.to_string();
|
||||
}
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
/// The keys the gpui widget crates localize through
|
||||
/// `gpui_widgets::i18n` (viewer transport labels, effect-stack empty state).
|
||||
pub const WIDGET_KEYS: &[&str] = &[
|
||||
@@ -198,6 +219,8 @@ fn pack_dirs() -> Vec<std::path::PathBuf> {
|
||||
if let Some(contents) = dir.parent() {
|
||||
// macOS bundle: <exe>/../Resources/i18n.
|
||||
dirs.push(contents.join("Resources/i18n"));
|
||||
// cargo-packager deb/AppImage: <exe>/../lib/oak-editor/i18n.
|
||||
dirs.push(contents.join("lib/oak-editor/i18n"));
|
||||
// System install (deb/rpm/pkg): <exe>/../share/oak/i18n.
|
||||
dirs.push(contents.join("share/oak/i18n"));
|
||||
}
|
||||
@@ -275,6 +298,23 @@ fn reload_for_test() {
|
||||
*tables = load_packs();
|
||||
}
|
||||
|
||||
/// Serializes tests that mutate the language global / HOME.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
lang_test_lock().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Compatibility alias used by the app/actions test modules. Both entry
|
||||
/// points MUST share the one static mutex: with two separate mutexes the
|
||||
/// i18n tests and the app/actions tests do not exclude each other, and
|
||||
/// the language global races (observed as a Windows-only CI failure
|
||||
/// where `tr` returned the English value mid-test).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lang_test_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
&LOCK
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -358,6 +398,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every built-in node type the factory registers carries a
|
||||
/// `node.<type-id suffix>.name` entry in the reference pack *and* in
|
||||
/// the translation: the effect library and the inspector build that key
|
||||
/// at runtime, so a node without an entry would show its English name
|
||||
/// in every language — and one whose zh value repeats the English name
|
||||
/// was never translated at all. The matching parameter keys are held by
|
||||
/// `effectchain`'s own test against the live parameter list.
|
||||
#[test]
|
||||
fn every_builtin_node_name_is_translated() {
|
||||
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
|
||||
let en = tables.get("en-US").expect("en-US pack");
|
||||
let zh = tables.get("zh-CN").expect("zh-CN pack");
|
||||
let mut nodes = 0usize;
|
||||
for meta in oak_node::factory::Factory::global().entries() {
|
||||
let suffix = meta.type_id.rsplit('.').next().unwrap_or(meta.type_id);
|
||||
let key = format!("node.{suffix}.name");
|
||||
let en_value = en
|
||||
.get(&key)
|
||||
.unwrap_or_else(|| panic!("{key} missing from the en-US pack"));
|
||||
assert_eq!(
|
||||
*en_value, meta.name,
|
||||
"{key} does not carry the node's English name"
|
||||
);
|
||||
let zh_value = zh
|
||||
.get(&key)
|
||||
.unwrap_or_else(|| panic!("{key} missing from the zh-CN pack"));
|
||||
assert_ne!(zh_value, en_value, "{key} is not translated ({en_value:?})");
|
||||
nodes += 1;
|
||||
}
|
||||
assert!(
|
||||
nodes >= 60,
|
||||
"the factory registers the built-in node types ({nodes})"
|
||||
);
|
||||
}
|
||||
|
||||
/// `tr` falls back from the active language to English and then to
|
||||
/// the key itself.
|
||||
#[test]
|
||||
@@ -398,20 +473,3 @@ mod tests {
|
||||
set_language_code("en-US");
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes tests that mutate the language global / HOME.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
lang_test_lock().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Compatibility alias used by the app/actions test modules. Both entry
|
||||
/// points MUST share the one static mutex: with two separate mutexes the
|
||||
/// i18n tests and the app/actions tests do not exclude each other, and
|
||||
/// the language global races (observed as a Windows-only CI failure
|
||||
/// where `tr` returned the English value mid-test).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lang_test_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
&LOCK
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! directly (M14 R3: project open/save through the oaknode serializer,
|
||||
//! timeline edits through the oaktimeline edit commands on the oakundo
|
||||
//! global stack, the oakrender ticket arena for the viewers, the oaktask
|
||||
//! export path, the oakcommon config store, and the oakstorage
|
||||
//! export path, the oak_core config store, and the oakstorage
|
||||
//! write-through library — no `liboakengine` dylib, no C ABI).
|
||||
//!
|
||||
//! # Layout
|
||||
@@ -64,6 +64,7 @@ pub mod logging;
|
||||
pub mod manager;
|
||||
pub mod oakui;
|
||||
pub mod panels;
|
||||
pub mod update;
|
||||
|
||||
/// The application entry point (called from `main.rs`).
|
||||
pub fn run() {
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
|
||||
//! The `oakapp` binary: opens the Oak main window (see [`oakapp::app`]).
|
||||
|
||||
// Windows release builds are GUI applications: without the subsystem
|
||||
// attribute the linker marks the executable as a console program and
|
||||
// launching Oak opens a console window. Debug builds keep the console
|
||||
// (`cargo run` shows logs). The attribute is ignored elsewhere.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
oakapp::run();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
//! are unit tested here; the app-level flows are covered by the
|
||||
//! `OakApp<MockEngine>` tests in `crate::app`.
|
||||
|
||||
use crate::oakui::component::text_input;
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
@@ -37,7 +38,6 @@ use gpui::{
|
||||
Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage};
|
||||
use crate::oakui::component::text_input;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::{AppEngine, LibraryProject};
|
||||
@@ -162,7 +162,11 @@ impl<E: AppEngine> ProjectManager<E> {
|
||||
}
|
||||
|
||||
/// An action-row button targeting the selection.
|
||||
fn selected_action(&mut self, action: impl FnOnce(String) -> ManagerEvent, cx: &mut Context<Self>) {
|
||||
fn selected_action(
|
||||
&mut self,
|
||||
action: impl FnOnce(String) -> ManagerEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(uuid) = self.selected_uuid() {
|
||||
self.emit(action(uuid), cx);
|
||||
}
|
||||
@@ -252,13 +256,11 @@ impl<E: AppEngine> Render for ProjectManager<E> {
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.children(if self.rows.is_empty() {
|
||||
vec![
|
||||
div()
|
||||
.p_4()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("manager.empty"))
|
||||
.into_any_element(),
|
||||
]
|
||||
vec![div()
|
||||
.p_4()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("manager.empty"))
|
||||
.into_any_element()]
|
||||
} else {
|
||||
self.rows
|
||||
.iter()
|
||||
@@ -272,11 +274,14 @@ impl<E: AppEngine> Render for ProjectManager<E> {
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(move |this, event: &ClickEvent, _w, cx| {
|
||||
this.row_clicked(index, event.click_count(), cx);
|
||||
}));
|
||||
.on_click(cx.listener(
|
||||
move |this, event: &ClickEvent, _w, cx| {
|
||||
this.row_clicked(index, event.click_count(), cx);
|
||||
},
|
||||
));
|
||||
if selected {
|
||||
line = line.bg(colors.selected).text_color(colors.selected_text);
|
||||
line =
|
||||
line.bg(colors.selected).text_color(colors.selected_text);
|
||||
} else {
|
||||
line = line.text_color(colors.text);
|
||||
}
|
||||
@@ -301,72 +306,69 @@ impl<E: AppEngine> Render for ProjectManager<E> {
|
||||
}),
|
||||
);
|
||||
|
||||
let mut root = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
tool("manager-new", i18n::tr("manager.new")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Create, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
tool("manager-import", i18n::tr("manager.import")).on_click(
|
||||
let mut root =
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
tool("manager-new", i18n::tr("manager.new")).on_click(cx.listener(
|
||||
|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Create, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(tool("manager-import", i18n::tr("manager.import")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.emit(ManagerEvent::Import, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
action("manager-open", i18n::tr("manager.open")).on_click(
|
||||
)),
|
||||
)
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(action("manager-open", i18n::tr("manager.open")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Open, cx);
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
action("manager-rename", i18n::tr("manager.rename")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Rename, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-duplicate", i18n::tr("manager.duplicate")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Duplicate, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-delete", i18n::tr("manager.delete")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Delete, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-export", i18n::tr("manager.export")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Export, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-rename", i18n::tr("manager.rename")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Rename, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-duplicate", i18n::tr("manager.duplicate")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Duplicate, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-delete", i18n::tr("manager.delete")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Delete, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
action("manager-export", i18n::tr("manager.export")).on_click(
|
||||
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
||||
this.selected_action(ManagerEvent::Export, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
if let Some(status) = &self.status {
|
||||
root = root.child(
|
||||
div()
|
||||
@@ -396,10 +398,7 @@ pub struct NamePrompt {
|
||||
impl NamePrompt {
|
||||
/// Builds the prompt seeded with `initial`.
|
||||
pub fn new(initial: &str, cx: &mut Context<Self>) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
let editor = EditableTextState::new(StringStorage::default(), cx);
|
||||
editor
|
||||
});
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
editor.update(cx, |editor, cx| editor.emplace(initial, cx));
|
||||
Self { editor }
|
||||
}
|
||||
@@ -411,7 +410,8 @@ impl NamePrompt {
|
||||
|
||||
/// Replaces the entered name (tests / prefill).
|
||||
pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| editor.emplace(value, cx));
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.emplace(value, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,8 @@ impl ContextMenuHandle {
|
||||
|
||||
/// Open the menu at `position` (window coordinates).
|
||||
pub fn show(&self, position: Point<Pixels>, menu: Menu, cx: &mut App) {
|
||||
self.menu.update(cx, |menu_view, cx| menu_view.show(position, menu, cx));
|
||||
self.menu
|
||||
.update(cx, |menu_view, cx| menu_view.show(position, menu, cx));
|
||||
}
|
||||
|
||||
/// The popup entity, to be rendered as a child of the panel so the
|
||||
@@ -126,7 +127,8 @@ pub const LANG_ITEM_COUNT: usize = 32;
|
||||
/// Maps a menu item id in the language range back to its index into
|
||||
/// [`crate::i18n::available_languages`].
|
||||
pub fn language_item_index(item: usize) -> Option<usize> {
|
||||
(item >= LANG_ITEM_BASE && item < LANG_ITEM_BASE + LANG_ITEM_COUNT)
|
||||
(LANG_ITEM_BASE..LANG_ITEM_BASE + LANG_ITEM_COUNT)
|
||||
.contains(&item)
|
||||
.then(|| item - LANG_ITEM_BASE)
|
||||
}
|
||||
|
||||
@@ -262,7 +264,8 @@ pub fn color_label_item(selected: Option<usize>) -> MenuItem {
|
||||
/// The color index (`0..COLOR_LABEL_COUNT`) behind a triggered menu item id,
|
||||
/// when `item` is one of the color-label items.
|
||||
pub fn color_label_index(item: usize) -> Option<usize> {
|
||||
(item >= COLOR_LABEL_BASE && item < COLOR_LABEL_BASE + COLOR_LABEL_COUNT)
|
||||
(COLOR_LABEL_BASE..COLOR_LABEL_BASE + COLOR_LABEL_COUNT)
|
||||
.contains(&item)
|
||||
.then(|| item - COLOR_LABEL_BASE)
|
||||
}
|
||||
|
||||
@@ -334,12 +337,17 @@ impl Default for ViewerMenuState {
|
||||
pub fn viewer_menu(state: &ViewerMenuState) -> Menu {
|
||||
use crate::i18n::tr;
|
||||
// Zoom: Fit + one entry per zoom level, checked against the live state.
|
||||
let mut zoom_items = vec![MenuItem::new(LOCAL_VIEWER_ZOOM_FIT, tr("viewer.context.zoom_fit"))
|
||||
.with_checked(state.zoom == ViewerZoom::Fit)];
|
||||
let mut zoom_items = vec![
|
||||
MenuItem::new(LOCAL_VIEWER_ZOOM_FIT, tr("viewer.context.zoom_fit"))
|
||||
.with_checked(state.zoom == ViewerZoom::Fit),
|
||||
];
|
||||
for (index, level) in VIEWER_ZOOM_LEVELS.iter().enumerate() {
|
||||
zoom_items.push(
|
||||
MenuItem::new(viewer_zoom_level_id(index), format!("{:.0}%", level * 100.0))
|
||||
.with_checked(state.zoom == ViewerZoom::Level(index)),
|
||||
MenuItem::new(
|
||||
viewer_zoom_level_id(index),
|
||||
format!("{:.0}%", level * 100.0),
|
||||
)
|
||||
.with_checked(state.zoom == ViewerZoom::Level(index)),
|
||||
);
|
||||
}
|
||||
// Playback Resolution radio group (the C++ `PlaybackDivider` config):
|
||||
@@ -376,9 +384,10 @@ pub fn viewer_menu(state: &ViewerMenuState) -> Menu {
|
||||
Menu::new(vec![
|
||||
MenuItem::new(0, tr("viewer.context.zoom")).with_submenu(Menu::new(zoom_items)),
|
||||
MenuItem::new(LOCAL_VIEWER_FULL_SCREEN, tr("viewer.context.full_screen")),
|
||||
MenuItem::new(0, tr("viewer.context.playback_resolution"))
|
||||
.with_submenu(resolution_menu),
|
||||
MenuItem::new(0, tr("viewer.context.safe_margins")).with_submenu(safe_menu).separated(),
|
||||
MenuItem::new(0, tr("viewer.context.playback_resolution")).with_submenu(resolution_menu),
|
||||
MenuItem::new(0, tr("viewer.context.safe_margins"))
|
||||
.with_submenu(safe_menu)
|
||||
.separated(),
|
||||
MenuItem::new(LOCAL_VIEWER_STOP_ON_LAST, tr("viewer.context.stop_on_last"))
|
||||
.with_checked(state.stop_on_last)
|
||||
.separated(),
|
||||
@@ -426,7 +435,9 @@ pub fn viewer_menu_action(item: usize) -> Option<ViewerMenuAction> {
|
||||
} else if item >= LOCAL_VIEWER_ZOOM_LEVELS_BASE
|
||||
&& item < LOCAL_VIEWER_ZOOM_LEVELS_BASE + VIEWER_ZOOM_LEVELS.len()
|
||||
{
|
||||
Some(ViewerMenuAction::ZoomLevel(item - LOCAL_VIEWER_ZOOM_LEVELS_BASE))
|
||||
Some(ViewerMenuAction::ZoomLevel(
|
||||
item - LOCAL_VIEWER_ZOOM_LEVELS_BASE,
|
||||
))
|
||||
} else {
|
||||
match item {
|
||||
LOCAL_VIEWER_FULL_SCREEN => Some(ViewerMenuAction::FullScreen),
|
||||
@@ -585,7 +596,9 @@ mod tests {
|
||||
fn viewer_menu_offers_every_zoom_level() {
|
||||
// The label lookups below race with tests that flip the process
|
||||
// language: pin en-US under the shared lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(&ViewerMenuState::default());
|
||||
let zoom = menu
|
||||
@@ -599,7 +612,10 @@ mod tests {
|
||||
assert_eq!(zoom_items[0].checked, Some(true), "Fit checked by default");
|
||||
for (index, level) in VIEWER_ZOOM_LEVELS.iter().enumerate() {
|
||||
assert_eq!(zoom_items[index + 1].id, viewer_zoom_level_id(index));
|
||||
assert_eq!(zoom_items[index + 1].label, format!("{:.0}%", level * 100.0));
|
||||
assert_eq!(
|
||||
zoom_items[index + 1].label,
|
||||
format!("{:.0}%", level * 100.0)
|
||||
);
|
||||
assert_eq!(
|
||||
zoom_items[index + 1].checked,
|
||||
Some(false),
|
||||
@@ -614,7 +630,9 @@ mod tests {
|
||||
fn viewer_menu_radio_groups_default_to_the_first_entry() {
|
||||
// The label lookups below race with tests that flip the process
|
||||
// language: pin en-US under the shared lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(&ViewerMenuState::default());
|
||||
for label_key in [
|
||||
@@ -630,7 +648,9 @@ mod tests {
|
||||
let sub = item.submenu.as_ref().unwrap();
|
||||
assert_eq!(sub.items[0].checked, Some(true), "{label_key} default");
|
||||
assert!(
|
||||
sub.items[1..].iter().all(|item| item.checked == Some(false)),
|
||||
sub.items[1..]
|
||||
.iter()
|
||||
.all(|item| item.checked == Some(false)),
|
||||
"{label_key} non-defaults unchecked"
|
||||
);
|
||||
}
|
||||
@@ -640,7 +660,9 @@ mod tests {
|
||||
#[test]
|
||||
fn viewer_menu_resolution_radio_follows_the_divider() {
|
||||
// Label lookup: pin en-US under the shared language lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(&ViewerMenuState {
|
||||
playback_divider: 4,
|
||||
@@ -662,7 +684,9 @@ mod tests {
|
||||
#[test]
|
||||
fn viewer_menu_reflects_live_state() {
|
||||
// Label lookup: pin en-US under the shared language lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = viewer_menu(&ViewerMenuState {
|
||||
playback_divider: 2,
|
||||
@@ -721,7 +745,10 @@ mod tests {
|
||||
None,
|
||||
"zoom id past the last level"
|
||||
);
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_FULL_SCREEN), Some(V::FullScreen));
|
||||
assert_eq!(
|
||||
viewer_menu_action(LOCAL_VIEWER_FULL_SCREEN),
|
||||
Some(V::FullScreen)
|
||||
);
|
||||
for (id, divider) in [
|
||||
(LOCAL_VIEWER_RES_FULL, 1),
|
||||
(LOCAL_VIEWER_RES_HALF, 2),
|
||||
@@ -732,8 +759,14 @@ mod tests {
|
||||
}
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAFE_OFF), Some(V::SafeOff));
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAFE_ON), Some(V::SafeOn));
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAFE_CUSTOM), Some(V::SafeCustom));
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_STOP_ON_LAST), Some(V::StopOnLast));
|
||||
assert_eq!(
|
||||
viewer_menu_action(LOCAL_VIEWER_SAFE_CUSTOM),
|
||||
Some(V::SafeCustom)
|
||||
);
|
||||
assert_eq!(
|
||||
viewer_menu_action(LOCAL_VIEWER_STOP_ON_LAST),
|
||||
Some(V::StopOnLast)
|
||||
);
|
||||
assert_eq!(
|
||||
viewer_menu_action(LOCAL_VIEWER_WF_AUTOMATIC),
|
||||
Some(V::Waveform(WaveformMode::Automatic))
|
||||
@@ -747,8 +780,15 @@ mod tests {
|
||||
Some(V::Waveform(WaveformMode::Both))
|
||||
);
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SHOW_FPS), Some(V::ShowFps));
|
||||
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAVE_FRAME), Some(V::SaveFrame));
|
||||
assert_eq!(viewer_menu_action(0), None, "registry-range id is not local");
|
||||
assert_eq!(
|
||||
viewer_menu_action(LOCAL_VIEWER_SAVE_FRAME),
|
||||
Some(V::SaveFrame)
|
||||
);
|
||||
assert_eq!(
|
||||
viewer_menu_action(0),
|
||||
None,
|
||||
"registry-range id is not local"
|
||||
);
|
||||
assert_eq!(viewer_menu_action(LOCAL_ID_BASE), None, "color-label id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
colors::{Colors, DefaultColors},
|
||||
div, App, Div, ElementId, Hsla, IntoElement, ParentElement, Styled, WeakEntity, Window,
|
||||
div, App, Div, ElementId, Hsla, IntoElement, ParentElement, Styled, WeakEntity,
|
||||
};
|
||||
use gpui_elements::editable_text::actions::{default_bindings, DEFAULT_INPUT_CONTEXT};
|
||||
use gpui_elements::editable_text::{EditableTextElement, EditableTextState};
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
//! ## The content space
|
||||
//!
|
||||
//! The chain starts from the project's output spec
|
||||
//! ([`oak_render::color::pipeline_output_spec`]): sRGB content runs
|
||||
//! ([`oak_core::color::pipeline_output_spec`]): sRGB content runs
|
||||
//! through the named sRGB/Rec.709 space of the active OCIO config, and
|
||||
//! non-sRGB content (P3/BT.2020 gamuts, PQ/HLG transfers) is converted to
|
||||
//! CIE XYZ (D65, unit luminance) first and flows through the ICC's
|
||||
@@ -82,9 +82,9 @@
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oak_common::colormath::{output_spec_to_xyz_d65, OutputColorSpec, OutputGamut, OutputTransfer};
|
||||
use oak_common::configstore::ConfigStore;
|
||||
use oak_render::color::{pipeline_output_spec, ColorProcessor};
|
||||
use oak_core::color::{pipeline_output_spec, ColorProcessor};
|
||||
use oak_core::colormath::{output_spec_to_xyz_d65, OutputColorSpec, OutputGamut, OutputTransfer};
|
||||
use oak_core::configstore::ConfigStore;
|
||||
|
||||
/// Config key: the display color management mode ("icc" / "off"). The
|
||||
/// preference only applies where the platform policy allows self-management
|
||||
@@ -167,9 +167,7 @@ pub fn display_policy() -> DisplayPolicy {
|
||||
"os" => return DisplayPolicy::OsManaged,
|
||||
"self" => return DisplayPolicy::SelfManaged,
|
||||
other => {
|
||||
log::warn!(
|
||||
"OAK_DISPLAY_POLICY={other:?} ignored (expected \"os\" or \"self\")"
|
||||
);
|
||||
log::warn!("OAK_DISPLAY_POLICY={other:?} ignored (expected \"os\" or \"self\")");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +186,7 @@ pub fn display_policy() -> DisplayPolicy {
|
||||
// sRGB; a non-sRGB project output can't be honored through it — warn
|
||||
// once so the degradation is visible in the log.
|
||||
#[cfg(target_os = "windows")]
|
||||
if oak_common::displayicc::windows_acm_active() {
|
||||
if oak_core::displayicc::windows_acm_active() {
|
||||
if pipeline_output_spec() != OutputColorSpec::default() {
|
||||
static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
WARNED.get_or_init(|| {
|
||||
@@ -208,7 +206,10 @@ pub fn display_policy() -> DisplayPolicy {
|
||||
// mapping, so self-management is the only way to honor wide-gamut
|
||||
// displays there (the "off" preference opts back into OS-management).
|
||||
#[cfg(target_os = "macos")]
|
||||
match ConfigStore::instance().get(None, CONFIG_KEY_COLOR_MODE).as_deref() {
|
||||
match ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_COLOR_MODE)
|
||||
.as_deref()
|
||||
{
|
||||
Ok("icc") => DisplayPolicy::SelfManaged,
|
||||
_ => DisplayPolicy::OsManaged,
|
||||
}
|
||||
@@ -280,7 +281,7 @@ fn current_monitor_fingerprint(window: &gpui::Window, cx: &gpui::App) -> Option<
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let id = u64::from(window.display(cx)?.id());
|
||||
return oak_common::displayicc::windows_monitor_fingerprint(id);
|
||||
return oak_core::displayicc::windows_monitor_fingerprint(id);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -288,7 +289,7 @@ fn current_monitor_fingerprint(window: &gpui::Window, cx: &gpui::App) -> Option<
|
||||
let center = window.bounds().center();
|
||||
let x = f64::from(center.x) * f64::from(window.scale_factor());
|
||||
let y = f64::from(center.y) * f64::from(window.scale_factor());
|
||||
return oak_common::displayicc::x11_monitor_fingerprint_at(x, y);
|
||||
oak_core::displayicc::x11_monitor_fingerprint_at(x, y)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
@@ -414,9 +415,9 @@ fn current() -> Option<State> {
|
||||
/// inside `system_display_icc_for`); an unknown one (empty or malformed)
|
||||
/// falls back to the main-display profile — the pre-multi-monitor behavior.
|
||||
fn monitor_icc_path(monitor: &str) -> Option<String> {
|
||||
match oak_common::displayicc::monitor_ref_from_fingerprint(monitor) {
|
||||
Some(monitor) => oak_common::displayicc::system_display_icc_for(&monitor),
|
||||
None => oak_common::displayicc::system_display_icc(),
|
||||
match oak_core::displayicc::monitor_ref_from_fingerprint(monitor) {
|
||||
Some(monitor) => oak_core::displayicc::system_display_icc_for(&monitor),
|
||||
None => oak_core::displayicc::system_display_icc(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,3 +510,219 @@ pub fn apply_bgra8(data: &mut [u8], pixels: i64) {
|
||||
let _ = processor.convert_bgra8(data, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Restores the tracked monitor fingerprint on drop: a failed
|
||||
/// assertion must not leak a fake monitor into the rest of the
|
||||
/// process (the key and generation of other tests depend on it).
|
||||
struct MonitorRestore(Option<String>);
|
||||
|
||||
impl MonitorRestore {
|
||||
fn capture() -> Self {
|
||||
Self(
|
||||
CURRENT_MONITOR
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MonitorRestore {
|
||||
fn drop(&mut self) {
|
||||
note_monitor(self.0.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// The active-key tuple follows the monitor fingerprint and the
|
||||
/// content-space override, and resets cleanly when both are cleared.
|
||||
#[test]
|
||||
fn key_tracks_monitor_and_content_override() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let _monitor = MonitorRestore::capture();
|
||||
let previous = ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_default();
|
||||
|
||||
note_monitor(Some("x11:TEST-1".into()));
|
||||
let key = current_key();
|
||||
assert_eq!(key.0, display_policy());
|
||||
assert_eq!(key.1, configured_mode());
|
||||
assert_eq!(key.4, "x11:TEST-1");
|
||||
assert!(!key.3.is_empty());
|
||||
assert!(
|
||||
!key.3.starts_with("override:"),
|
||||
"no override installed by default: {}",
|
||||
key.3
|
||||
);
|
||||
|
||||
ConfigStore::instance().set(None, CONFIG_KEY_CONTENT_SPACE, "ACEScg");
|
||||
let key = current_key();
|
||||
assert_eq!(key.3, "override:ACEScg");
|
||||
|
||||
// Clearing the override returns to the project output spec, and an
|
||||
// unknown monitor falls back to the main display.
|
||||
ConfigStore::instance().set(None, CONFIG_KEY_CONTENT_SPACE, &previous);
|
||||
note_monitor(None);
|
||||
let key = current_key();
|
||||
assert_eq!(key.4, "");
|
||||
assert_eq!(key.3, format!(
|
||||
"{}:{}",
|
||||
pipeline_output_spec().gamut.as_setting(),
|
||||
pipeline_output_spec().transfer.as_setting()
|
||||
));
|
||||
}
|
||||
|
||||
/// `current()` caches per key: a rebuild only happens after a key
|
||||
/// change (and bumps the generation only when a prior state existed).
|
||||
#[test]
|
||||
fn current_caches_per_key() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let _monitor = MonitorRestore::capture();
|
||||
let previous = ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_CONTENT_SPACE)
|
||||
.unwrap_or_default();
|
||||
|
||||
note_monitor(None);
|
||||
invalidate();
|
||||
let first = current().expect("the state always builds");
|
||||
let generation_after_first = generation();
|
||||
|
||||
// Same key: the cached state is cloned, not rebuilt — the
|
||||
// generation stands still and the chain Arcs are shared.
|
||||
let cached = current().expect("the cached state is cloned");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first,
|
||||
"a same-key read does not rebuild"
|
||||
);
|
||||
assert_eq!(cached.key, first.key, "the cached state keeps the key");
|
||||
match (&first.f32, &cached.f32) {
|
||||
(Some(first_chain), Some(cached_chain)) => assert!(
|
||||
Arc::ptr_eq(first_chain, cached_chain),
|
||||
"the cached state shares the chain Arcs"
|
||||
),
|
||||
(None, None) => {}
|
||||
_ => panic!("a same-key read changed the chain availability"),
|
||||
}
|
||||
|
||||
// A monitor change rekeys: exactly one bump and a rebuilt state.
|
||||
note_monitor(Some("x11:OTHER".into()));
|
||||
let rekeyed = current().expect("the state rebuilds");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 1,
|
||||
"a key change bumps the generation exactly once"
|
||||
);
|
||||
assert_ne!(rekeyed.key, first.key, "the key followed the monitor");
|
||||
assert_eq!(rekeyed.key.4, "x11:OTHER");
|
||||
if let (Some(first_chain), Some(rekeyed_chain)) = (&first.f32, &rekeyed.f32) {
|
||||
assert!(
|
||||
!Arc::ptr_eq(first_chain, rekeyed_chain),
|
||||
"a key change rebuilds the chain"
|
||||
);
|
||||
}
|
||||
|
||||
// The rekeyed state is now the cache: reading it again stands still.
|
||||
let _ = current().expect("the rekeyed state is cached");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 1,
|
||||
"the rebuilt key is cached"
|
||||
);
|
||||
|
||||
// Clearing the monitor rekeys back: another single bump.
|
||||
note_monitor(None);
|
||||
let _ = current().expect("the state rebuilds again");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 2,
|
||||
"each key change bumps exactly once"
|
||||
);
|
||||
|
||||
// invalidate() drops the cache: the next build is a first build
|
||||
// (no prior state), so it must not bump a third time.
|
||||
invalidate();
|
||||
let _ = current().expect("the state builds after invalidate");
|
||||
assert_eq!(
|
||||
generation(),
|
||||
generation_after_first + 2,
|
||||
"a first build after invalidate does not bump"
|
||||
);
|
||||
|
||||
ConfigStore::instance().set(None, CONFIG_KEY_CONTENT_SPACE, &previous);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/// A clone of the cached state preserves the key and the chain slots.
|
||||
#[test]
|
||||
fn clone_state_preserves_the_chain_slots() {
|
||||
invalidate();
|
||||
let state = current().expect("state");
|
||||
let cloned = clone_state(&state).expect("clone");
|
||||
assert_eq!(cloned.key, state.key);
|
||||
assert_eq!(cloned.f32.is_some(), state.f32.is_some());
|
||||
assert_eq!(cloned.xyz.is_some(), state.xyz.is_some());
|
||||
assert_eq!(cloned.bgra.is_some(), state.bgra.is_some());
|
||||
}
|
||||
|
||||
/// Monitor fingerprints (known, unknown, empty) resolve or degrade
|
||||
/// without panicking, and the empty key field follows the noted
|
||||
/// fingerprint.
|
||||
#[test]
|
||||
fn monitor_profiles_resolve_or_degrade() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let _monitor = MonitorRestore::capture();
|
||||
invalidate();
|
||||
|
||||
// An unparseable fingerprint degrades to the same profile as "no
|
||||
// fingerprint" (the main display), never to a per-monitor lookup
|
||||
// or a panic.
|
||||
assert_eq!(
|
||||
monitor_icc_path("garbage"),
|
||||
monitor_icc_path(""),
|
||||
"a malformed fingerprint falls back to the main display"
|
||||
);
|
||||
|
||||
for fingerprint in ["", "x11:NO-SUCH-OUTPUT", "garbage", "mac:1", "win:X"] {
|
||||
note_monitor(Some(fingerprint.to_string()));
|
||||
assert_eq!(
|
||||
current_key().4,
|
||||
fingerprint,
|
||||
"the effective key follows the noted fingerprint"
|
||||
);
|
||||
let _ = monitor_icc_path(fingerprint);
|
||||
}
|
||||
|
||||
// Clearing the fingerprint returns the key to the main-display
|
||||
// sentinel.
|
||||
note_monitor(None);
|
||||
assert_eq!(current_key().4, "");
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/// Zero-pixel (or inactive) conversions never panic and produce only
|
||||
/// finite output samples.
|
||||
#[test]
|
||||
fn empty_conversions_are_safe() {
|
||||
let mut samples: [f32; 0] = [];
|
||||
apply_f32_rgba(&mut samples, 0);
|
||||
let mut bytes: [u8; 0] = [];
|
||||
apply_bgra8(&mut bytes, 0);
|
||||
|
||||
// A one-pixel conversion either transforms or passes through; it
|
||||
// must never produce NaN.
|
||||
let mut one = [0.25f32, 0.5, 0.75, 1.0];
|
||||
apply_f32_rgba(&mut one, 1);
|
||||
assert!(one.iter().all(|v| v.is_finite()));
|
||||
assert_eq!(one[3], 1.0, "alpha is untouched");
|
||||
|
||||
// The active flag and the content-space declaration agree with the
|
||||
// policy and never panic.
|
||||
let _ = is_active();
|
||||
let _ = content_colorspace();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +93,96 @@ pub fn is_enabled(g: &Graph, node: NodeId) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The built-in effect group keys, in presentation order.
|
||||
///
|
||||
/// The effect library and the inspector's add menu head the built-in
|
||||
/// effects with these groups, in this order; `general` collects every
|
||||
/// category without a group of its own and sorts last. The keys are
|
||||
/// **language-pack keys**, never display strings: a header's label is
|
||||
/// [`group_label`]'s `effect_library.group.<key>` lookup.
|
||||
pub const GROUP_KEYS: &[&str] = &[
|
||||
"color",
|
||||
"filter",
|
||||
"distort",
|
||||
"keying",
|
||||
"generator",
|
||||
"math",
|
||||
"time",
|
||||
"channel",
|
||||
"transition",
|
||||
"general",
|
||||
];
|
||||
|
||||
/// The group key of a node [`Category`](oak_node::node::Category).
|
||||
///
|
||||
/// The category enum mirrors the C++ `Node::CategoryID`, which is finer
|
||||
/// grained than the create menu: outputs, plain effects, inputs, OpenFX
|
||||
/// plugins, timeline and group nodes have no group of their own and land
|
||||
/// in `general`. `time` / `channel` / `transition` are part of the key
|
||||
/// set (the language packs carry their labels) but no category maps to
|
||||
/// them yet.
|
||||
pub fn category_group_key(category: oak_node::node::Category) -> &'static str {
|
||||
use oak_node::node::Category;
|
||||
match category {
|
||||
Category::Color => "color",
|
||||
Category::Filter => "filter",
|
||||
Category::Distort => "distort",
|
||||
Category::Keying => "keying",
|
||||
Category::Generator => "generator",
|
||||
Category::Math => "math",
|
||||
_ => "general",
|
||||
}
|
||||
}
|
||||
|
||||
/// The display label of an effect-group key: the localized
|
||||
/// `effect_library.group.<key>` string for the built-in keys, and the key
|
||||
/// itself for OpenFX sub-categories (their labels come from the plugin
|
||||
/// descriptor, not from the language packs).
|
||||
pub fn group_label(group_key: &str) -> String {
|
||||
match group_key {
|
||||
"color" => crate::i18n::tr("effect_library.group.color"),
|
||||
"filter" => crate::i18n::tr("effect_library.group.filter"),
|
||||
"distort" => crate::i18n::tr("effect_library.group.distort"),
|
||||
"keying" => crate::i18n::tr("effect_library.group.keying"),
|
||||
"generator" => crate::i18n::tr("effect_library.group.generator"),
|
||||
"math" => crate::i18n::tr("effect_library.group.math"),
|
||||
"time" => crate::i18n::tr("effect_library.group.time"),
|
||||
"channel" => crate::i18n::tr("effect_library.group.channel"),
|
||||
"transition" => crate::i18n::tr("effect_library.group.transition"),
|
||||
"general" => crate::i18n::tr("effect_library.group.general"),
|
||||
// OpenFX sub-categories (and any key a future pack ships without a
|
||||
// label): shown verbatim rather than as a raw pack key.
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The language-pack key of a built-in node's display name, built at
|
||||
/// runtime: `node.<type-id suffix>.name`, the suffix being the type id's
|
||||
/// last dotted segment (`org.olivevideoeditor.Olive.colorcorrect` →
|
||||
/// `colorcorrect`).
|
||||
fn node_name_key(type_id: &str) -> String {
|
||||
format!("node.{}.name", type_suffix(type_id))
|
||||
}
|
||||
|
||||
/// The language-pack key of a built-in node input's display name:
|
||||
/// `node.<type-id suffix>.input.<input id>`.
|
||||
fn input_name_key(type_id: &str, input_id: &str) -> String {
|
||||
format!("node.{}.input.{input_id}", type_suffix(type_id))
|
||||
}
|
||||
|
||||
/// The last dotted segment of a node type id — the language-pack key's
|
||||
/// `<type-id suffix>`.
|
||||
fn type_suffix(type_id: &str) -> &str {
|
||||
type_id.rsplit('.').next().unwrap_or(type_id)
|
||||
}
|
||||
|
||||
/// The effect types the user can add to a clip's chain — the built-in
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu (no group), plus every runtime-registered OpenFX plugin entry
|
||||
/// (grouped by its sub-category: Filter / Generator / Transition /
|
||||
/// General — the C++ `factorymenu.cpp` OpenFX branch).
|
||||
/// menu (grouped by their first category, see [`category_group_key`]),
|
||||
/// plus every runtime-registered OpenFX plugin entry (grouped by its
|
||||
/// sub-category: Filter / Generator / Transition / General — the C++
|
||||
/// `factorymenu.cpp` OpenFX branch).
|
||||
pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
use super::engine::EffectEntry;
|
||||
use oak_node::node::flags as node_flags;
|
||||
@@ -115,10 +200,18 @@ pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
} else {
|
||||
meta.name.to_string()
|
||||
};
|
||||
// The library shows the pack's `node.<type-id suffix>.name`; a
|
||||
// type the packs don't know keeps the node's own English name.
|
||||
let name = crate::i18n::tr_or(&node_name_key(meta.type_id), &name);
|
||||
let group = meta
|
||||
.categories
|
||||
.first()
|
||||
.map(|category| category_group_key(*category))
|
||||
.unwrap_or("general");
|
||||
out.push(EffectEntry {
|
||||
type_id: meta.type_id.to_string(),
|
||||
name,
|
||||
group: None,
|
||||
group: Some(group.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,18 +229,30 @@ pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
group: Some(meta.sub_category),
|
||||
});
|
||||
}
|
||||
// Default presentation order: built-ins first, then the OpenFX
|
||||
// sub-category groups alphabetically; names alphabetical (folded)
|
||||
// within each group.
|
||||
// Default presentation order: built-in groups first, in GROUP_KEYS
|
||||
// order (`general` last), then the OpenFX sub-category groups
|
||||
// alphabetically; names alphabetical (folded) within each group.
|
||||
out.sort_by(|a, b| {
|
||||
let ga = a.group.as_deref().unwrap_or("");
|
||||
let gb = b.group.as_deref().unwrap_or("");
|
||||
ga.cmp(gb)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
match (group_rank(ga), group_rank(gb)) {
|
||||
(Some(ra), Some(rb)) => ra.cmp(&rb),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => ga.cmp(gb),
|
||||
}
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The presentation rank of a built-in group key, or `None` for a key
|
||||
/// outside [`GROUP_KEYS`] (an OpenFX sub-category, which the sort orders
|
||||
/// after every built-in group).
|
||||
fn group_rank(group: &str) -> Option<usize> {
|
||||
GROUP_KEYS.iter().position(|key| *key == group)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OFX parameter data model (stage 6b)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -176,6 +281,13 @@ pub fn effect_params(
|
||||
use oak_node::value::ValueType;
|
||||
|
||||
let entry = g.get(node)?;
|
||||
// The pack keys describe the built-in nodes only: an OpenFX plugin's
|
||||
// labels come from the plugin (and the OFX translation pass), and a
|
||||
// plugin type id happens to end in a built-in type's suffix, the
|
||||
// built-in name would otherwise leak into it.
|
||||
let builtin = oak_node::factory::Factory::global()
|
||||
.find(entry.behavior.type_id())
|
||||
.is_some();
|
||||
let mut out = Vec::new();
|
||||
for input in &entry.core.inputs {
|
||||
// Clip/texture/sample/matrix inputs are graph connections or
|
||||
@@ -202,6 +314,18 @@ pub fn effect_params(
|
||||
} else {
|
||||
input.display_name.clone()
|
||||
};
|
||||
// Built-in nodes label their inputs from the pack instead
|
||||
// (`node.<type-id suffix>.input.<input id>`), so an input the
|
||||
// behavior names in English — or leaves at its raw input id —
|
||||
// follows the active language. The behavior's name is the fallback.
|
||||
let display_name = if builtin {
|
||||
crate::i18n::tr_or(
|
||||
&input_name_key(entry.behavior.type_id(), &input.id),
|
||||
&display_name,
|
||||
)
|
||||
} else {
|
||||
display_name
|
||||
};
|
||||
// Combo options: built-in nodes carry them on the behavior (C++
|
||||
// `set_combo_box_strings`); OFX plugin params already carry the
|
||||
// ("combo_option", _) properties from the translation pass.
|
||||
@@ -215,6 +339,35 @@ pub fn effect_params(
|
||||
));
|
||||
}
|
||||
}
|
||||
// The v3 text node's `font_family_in` is a str-combo whose options
|
||||
// the node layer cannot supply: oak-node deliberately links no font
|
||||
// or shaping crate, so the app's text backend enumerates the system
|
||||
// families and the facade injects them here (the same
|
||||
// `("combo_option", Text)` shape the OFX translation pass uses).
|
||||
if input.id == "font_family_in" && !properties.iter().any(|(k, _)| k == "combo_option") {
|
||||
for name in super::textengine::font_families() {
|
||||
properties.push((
|
||||
"combo_option".to_string(),
|
||||
oak_node::value::NodeValue::Text(name),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The v3 text node's editable text inputs hold more than one line
|
||||
// (the node shapes each `\n`-separated line of the string), and the
|
||||
// inspector renders them as multi-line fields so the user can type
|
||||
// line breaks. The node layer cannot say so — an input carries no
|
||||
// presentation metadata beyond its properties — so the facade marks
|
||||
// the multi-line text ids here, the same way it injects the font
|
||||
// families above.
|
||||
if input.value_type == ValueType::Text
|
||||
&& matches!(input.id.as_str(), "plain_text_in" | "text_in")
|
||||
&& !properties.iter().any(|(k, _)| k == "multiline")
|
||||
{
|
||||
properties.push((
|
||||
"multiline".to_string(),
|
||||
oak_node::value::NodeValue::Boolean(true),
|
||||
));
|
||||
}
|
||||
out.push(super::engine::EffectParam {
|
||||
input_id: input.id.clone(),
|
||||
display_name,
|
||||
@@ -780,21 +933,146 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect-library grouping: every addable effect is either an
|
||||
/// ungrouped built-in or an OpenFX entry with one of the four
|
||||
/// sub-categories.
|
||||
/// Every built-in entry carries its category's group key — one of
|
||||
/// [`GROUP_KEYS`], never a display string — and the shipped nodes use
|
||||
/// more than one group (a single "general" for everything would mean
|
||||
/// the category mapping is dead).
|
||||
#[test]
|
||||
fn addable_effects_group_builtins_by_category() {
|
||||
let factory = oak_node::factory::Factory::global();
|
||||
let mut groups: Vec<&'static str> = Vec::new();
|
||||
let mut builtins = 0usize;
|
||||
for entry in addable_effects() {
|
||||
if factory.find(&entry.type_id).is_none() {
|
||||
continue; // an OpenFX plugin entry
|
||||
}
|
||||
builtins += 1;
|
||||
let group = entry
|
||||
.group
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("built-in {} has no group", entry.type_id));
|
||||
let key = GROUP_KEYS.iter().copied().find(|key| *key == group).unwrap_or_else(|| {
|
||||
panic!("built-in {} has the unknown group {group:?}", entry.type_id)
|
||||
});
|
||||
if !groups.contains(&key) {
|
||||
groups.push(key);
|
||||
}
|
||||
}
|
||||
assert!(builtins > 0, "the factory ships built-in video effects");
|
||||
assert!(
|
||||
groups.len() > 1,
|
||||
"built-ins spread over several groups: {groups:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// OpenFX plugin entries keep their plugin-supplied sub-category
|
||||
/// (Filter / Generator / Transition / General), which never collides
|
||||
/// with a built-in group key.
|
||||
#[test]
|
||||
fn addable_effects_group_openfx_by_sub_category() {
|
||||
let factory = oak_node::factory::Factory::global();
|
||||
for entry in addable_effects() {
|
||||
if let Some(group) = entry.group {
|
||||
if factory.find_dynamic(&entry.type_id).is_none() {
|
||||
continue; // a built-in
|
||||
}
|
||||
let group = entry
|
||||
.group
|
||||
.as_deref()
|
||||
.expect("an OpenFX entry carries a sub-category");
|
||||
assert!(
|
||||
["Filter", "Generator", "Transition", "General"].contains(&group),
|
||||
"unexpected OpenFX sub-category {group:?}"
|
||||
);
|
||||
assert!(
|
||||
!GROUP_KEYS.contains(&group),
|
||||
"OpenFX sub-category {group:?} collides with a built-in group key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sort the library and the inspector render: built-in groups in
|
||||
/// [`GROUP_KEYS`] order, then the OpenFX sub-categories, names folded
|
||||
/// alphabetically inside a group.
|
||||
#[test]
|
||||
fn addable_effects_sort_builtins_first() {
|
||||
let entries = addable_effects();
|
||||
let rank = |entry: &crate::oakui::engine::EffectEntry| {
|
||||
entry
|
||||
.group
|
||||
.as_deref()
|
||||
.and_then(group_rank)
|
||||
.unwrap_or(GROUP_KEYS.len())
|
||||
};
|
||||
for pair in entries.windows(2) {
|
||||
let (a, b) = (&pair[0], &pair[1]);
|
||||
let (ra, rb) = (rank(a), rank(b));
|
||||
if ra != rb {
|
||||
assert!(
|
||||
["Filter", "Generator", "Transition", "General"].contains(&group.as_str()),
|
||||
"unexpected OpenFX sub-category {group:?}"
|
||||
ra < rb,
|
||||
"group order: {a:?} ({ra}) before {b:?} ({rb})"
|
||||
);
|
||||
} else if a.group == b.group {
|
||||
assert!(
|
||||
a.name.to_lowercase() <= b.name.to_lowercase(),
|
||||
"name order inside {:?}: {a:?} before {b:?}",
|
||||
a.group
|
||||
);
|
||||
} else {
|
||||
// Two OpenFX sub-categories (rank only separates built-ins).
|
||||
assert!(
|
||||
a.group.as_deref().unwrap_or("") <= b.group.as_deref().unwrap_or(""),
|
||||
"OpenFX sub-category order: {a:?} before {b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The category → group-key mapping covers every `Category` variant
|
||||
/// and falls back to `general`.
|
||||
#[test]
|
||||
fn category_group_key_maps_every_category() {
|
||||
use oak_node::node::Category;
|
||||
let all = [
|
||||
Category::Output,
|
||||
Category::Effect,
|
||||
Category::Generator,
|
||||
Category::Input,
|
||||
Category::Math,
|
||||
Category::Color,
|
||||
Category::Distort,
|
||||
Category::Filter,
|
||||
Category::Keying,
|
||||
Category::OpenFx,
|
||||
Category::Timeline,
|
||||
Category::Group,
|
||||
];
|
||||
for category in all {
|
||||
assert!(
|
||||
GROUP_KEYS.contains(&category_group_key(category)),
|
||||
"{category:?} maps outside GROUP_KEYS"
|
||||
);
|
||||
}
|
||||
assert_eq!(category_group_key(Category::Color), "color");
|
||||
assert_eq!(category_group_key(Category::Keying), "keying");
|
||||
assert_eq!(category_group_key(Category::Effect), "general");
|
||||
assert_eq!(category_group_key(Category::OpenFx), "general");
|
||||
}
|
||||
|
||||
/// A group label is the language pack's string for the built-in keys —
|
||||
/// never the raw key — and passes OpenFX sub-categories through.
|
||||
#[test]
|
||||
fn group_label_localizes_keys_and_passes_sub_categories_through() {
|
||||
for key in GROUP_KEYS {
|
||||
let label = group_label(key);
|
||||
assert!(
|
||||
!label.starts_with("effect_library.group."),
|
||||
"language pack entry missing for the {key} group (got {label:?})"
|
||||
);
|
||||
}
|
||||
assert_eq!(group_label("Filter"), "Filter");
|
||||
assert_eq!(group_label("Sapphire"), "Sapphire");
|
||||
}
|
||||
|
||||
/// `set_input_value` writes the standard value undoably and rejects
|
||||
/// unknown input ids.
|
||||
#[test]
|
||||
@@ -1072,4 +1350,180 @@ mod tests {
|
||||
};
|
||||
assert_eq!(ui_section_of(&grouped), Some(("Basic".into(), "Main".into())));
|
||||
}
|
||||
|
||||
/// W6: the v3 text node is a footage entry now (the project panel's
|
||||
/// "add text footage" button and the timeline drop), so the effect
|
||||
/// library / add menus no longer offer it. A text3 node that is in a
|
||||
/// project anyway — a legacy chain, or the one the footage path
|
||||
/// creates — must keep chaining and keep exposing its params.
|
||||
#[test]
|
||||
fn text3_is_not_addable_but_a_chain_node_still_reports_its_params() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
|
||||
const TEXT3: &str = "org.olivevideoeditor.Olive.text3";
|
||||
assert!(
|
||||
!addable_effects().iter().any(|e| e.type_id == TEXT3),
|
||||
"text3 left the effect add menus"
|
||||
);
|
||||
|
||||
// `insert` resolves through the factory (which still registers the
|
||||
// type) rather than the addable table: legacy chains and the
|
||||
// footage path keep working.
|
||||
let (project, host) = project_with_clip();
|
||||
let node = insert(&project, host, 0, TEXT3).expect("chain a legacy text3 effect");
|
||||
assert_eq!(chain(&lock(&project).graph, host), vec![node]);
|
||||
|
||||
let params = effect_params(&lock(&project).graph, node).expect("the node exposes params");
|
||||
let plain_text = params
|
||||
.iter()
|
||||
.find(|p| p.input_id == "plain_text_in")
|
||||
.expect("the structured text parameter survives");
|
||||
assert_eq!(plain_text.value_type, oak_node::value::ValueType::Text);
|
||||
// The facade marks the multi-line text ids (the inspector renders a
|
||||
// multi-line field off this property); the node itself carries no
|
||||
// presentation metadata.
|
||||
assert!(
|
||||
plain_text
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "multiline"
|
||||
&& matches!(v, oak_node::value::NodeValue::Boolean(true))),
|
||||
"plain_text_in is marked as multi-line"
|
||||
);
|
||||
assert!(params.iter().any(|p| p.input_id == "font_size_in"));
|
||||
assert!(params.iter().any(|p| p.input_id == "outline_enabled_in"));
|
||||
// The hidden legacy HTML input stays a param *input* in the node,
|
||||
// never in the inspector.
|
||||
assert!(!params.iter().any(|p| p.input_id == "text_in"));
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The effect library and the inspector name built-in effects and their
|
||||
/// parameters from runtime-built pack keys (`node.<type-id suffix>.name`
|
||||
/// / `node.<type-id suffix>.input.<input id>`): switching the active
|
||||
/// language renames the whole library and parameter list, while a node
|
||||
/// the packs don't know keeps the name it ships with.
|
||||
#[test]
|
||||
fn effect_and_parameter_names_follow_the_language_pack() {
|
||||
let _lang = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
crate::i18n::set_language_code("zh-CN");
|
||||
|
||||
// Every addable built-in shows its pack name, never the factory's
|
||||
// English one.
|
||||
let factory = oak_node::factory::Factory::global();
|
||||
let mut builtins = 0usize;
|
||||
for entry in addable_effects() {
|
||||
let Some(meta) = factory.find(&entry.type_id) else {
|
||||
continue; // an OpenFX plugin entry: the plugin names it
|
||||
};
|
||||
assert_ne!(
|
||||
entry.name, meta.name,
|
||||
"{} is still English in zh-CN",
|
||||
entry.type_id
|
||||
);
|
||||
builtins += 1;
|
||||
}
|
||||
assert!(builtins > 0, "the factory ships addable built-ins");
|
||||
let name_of = |type_id: &str| {
|
||||
addable_effects()
|
||||
.into_iter()
|
||||
.find(|e| e.type_id == type_id)
|
||||
.unwrap_or_else(|| panic!("{type_id} is addable"))
|
||||
.name
|
||||
};
|
||||
assert_eq!(
|
||||
name_of("org.olivevideoeditor.Olive.colorcorrect"),
|
||||
"颜色校正"
|
||||
);
|
||||
assert_eq!(name_of("org.olivevideoeditor.Olive.position"), "位置");
|
||||
|
||||
// The inspector's parameter labels follow the same keys.
|
||||
let (project, host) = project_with_clip();
|
||||
let node = insert(&project, host, 0, "org.olivevideoeditor.Olive.colorcorrect")
|
||||
.expect("chain Color Correct");
|
||||
let params = effect_params(&lock(&project).graph, node).expect("the node exposes params");
|
||||
let label = |input_id: &str| {
|
||||
params
|
||||
.iter()
|
||||
.find(|p| p.input_id == input_id)
|
||||
.unwrap_or_else(|| panic!("{input_id} is a parameter"))
|
||||
.display_name
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(label("saturation_in"), "饱和度");
|
||||
assert_eq!(label("gamma_in"), "伽马");
|
||||
assert_eq!(label("gain_in"), "增益");
|
||||
|
||||
crate::i18n::set_language_code("en-US");
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The pack coverage behind the lookup above: every name a built-in
|
||||
/// node gives one of its inputs has a `node.<type-id suffix>.input.<id>`
|
||||
/// entry, so no parameter label falls back to English. Inputs the
|
||||
/// behavior does not name (`enabled_in`, `use_args_in`) carry no entry —
|
||||
/// their label is the raw input id, and the enabled input never renders.
|
||||
#[test]
|
||||
fn every_builtin_parameter_name_has_a_pack_entry() {
|
||||
let _lang = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("zh-CN");
|
||||
let mut missing = Vec::new();
|
||||
let mut named = 0usize;
|
||||
for meta in oak_node::factory::Factory::global().entries() {
|
||||
let (core, behavior) = (meta.create)();
|
||||
for input in &core.inputs {
|
||||
let english = behavior.input_name(&input.id);
|
||||
if english == input.id {
|
||||
continue;
|
||||
}
|
||||
if crate::i18n::tr_or(&input_name_key(meta.type_id, &input.id), english) == english {
|
||||
missing.push(format!("node.{}.input.{}", type_suffix(meta.type_id), input.id));
|
||||
}
|
||||
named += 1;
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "no pack entry for {missing:?}");
|
||||
assert!(named >= 250, "the built-in nodes name their inputs ({named})");
|
||||
crate::i18n::set_language_code("en-US");
|
||||
}
|
||||
|
||||
/// The v3 text node's `font_family_in` is a str-combo the node layer
|
||||
/// cannot populate (oak-node links no font crate): `effect_params`
|
||||
/// injects the app's font backend families as its combo options, sorted
|
||||
/// and de-duplicated.
|
||||
#[test]
|
||||
fn text_font_family_combo_is_injected_from_the_font_backend() {
|
||||
let _g = stack_lock();
|
||||
let (project, _clip) = project_with_clip();
|
||||
let node = {
|
||||
let mut g = lock(&project);
|
||||
let (core, behavior) = oak_node::factory::Factory::global()
|
||||
.create_any("org.olivevideoeditor.Olive.text3")
|
||||
.expect("the factory registers the v3 text node");
|
||||
g.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
let params = effect_params(&lock(&project).graph, node).expect("the node exposes params");
|
||||
let param = params
|
||||
.iter()
|
||||
.find(|p| p.input_id == "font_family_in")
|
||||
.expect("the font family combo is a parameter");
|
||||
assert_eq!(param.value_type, oak_node::value::ValueType::StrCombo);
|
||||
|
||||
let options = combo_options(param);
|
||||
assert!(!options.is_empty(), "the font backend found no families");
|
||||
assert_eq!(options, crate::oakui::textengine::font_families());
|
||||
let mut sorted = options.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(options, sorted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::effect_stack::{EffectId, EffectStackDataSource, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent, NodeId};
|
||||
use gpui::timeline::{
|
||||
ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind,
|
||||
};
|
||||
@@ -339,6 +339,20 @@ pub trait AppEngine:
|
||||
/// it).
|
||||
fn remove_track(&mut self, index: usize, cx: &mut Context<Self>);
|
||||
|
||||
/// Adds an adjustment layer to the display track at `index`, starting
|
||||
/// at `frame` and five seconds long (undoable where the backend
|
||||
/// supports it). Returns a user-facing error message on failure.
|
||||
/// Default: unsupported.
|
||||
fn add_adjustment_layer(
|
||||
&mut self,
|
||||
index: usize,
|
||||
frame: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (index, frame, cx);
|
||||
Err("add adjustment layer not supported".into())
|
||||
}
|
||||
|
||||
/// Sets the row height of every timeline track (timeline toolbar).
|
||||
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>);
|
||||
|
||||
@@ -363,6 +377,15 @@ pub trait AppEngine:
|
||||
None
|
||||
}
|
||||
|
||||
/// The graph nodes the UI must not let the user edit: the virtual
|
||||
/// endpoints (`GraphInput` / `GraphOutput`), which every project graph
|
||||
/// carries as fixed furniture. The node-editor panel hides these nodes'
|
||||
/// edit entries and drops delete requests naming them, so the model
|
||||
/// never sees that edit. Default: none (engines without endpoints).
|
||||
fn protected_graph_nodes(&self) -> Vec<NodeId> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The effect types the user can add to the selected clip's chain — the
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu, plus every runtime-registered OpenFX plugin entry (grouped by
|
||||
@@ -726,6 +749,14 @@ pub trait AppEngine:
|
||||
Err("project library not supported".into())
|
||||
}
|
||||
|
||||
/// 另存为: saves the OPEN project into the library as a differently-
|
||||
/// named copy and switches the current project to that copy. Default:
|
||||
/// unsupported.
|
||||
fn save_project_as(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
let _ = (name, cx);
|
||||
Err("save-as not supported".into())
|
||||
}
|
||||
|
||||
/// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library
|
||||
/// as a new row; returns the new row's uuid.
|
||||
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
|
||||
@@ -802,6 +833,42 @@ pub trait AppEngine:
|
||||
/// [`ExportSession`] carries the event channel and the cancel handle.
|
||||
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String>;
|
||||
|
||||
/// Like [`Self::start_export`] but with the full dialog settings
|
||||
/// (codecs, color space/bit depth, size/rate/bitrate, range). The
|
||||
/// default start_export stays for the legacy callers.
|
||||
fn start_export_with(
|
||||
&mut self,
|
||||
settings: &ExportSettings,
|
||||
path: PathBuf,
|
||||
) -> Result<ExportSession, String>;
|
||||
|
||||
/// The project's sequence entries `(id, display name)` in
|
||||
/// project-explorer order — the export dialog's sequence picker.
|
||||
/// Default: none (engines without a project browser cannot export).
|
||||
fn sequence_entries(&self) -> Vec<(u64, SharedString)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The project-entry id of the sequence currently open in the
|
||||
/// timeline (the export dialog's preselection), if any.
|
||||
fn current_sequence_id(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Starts an export of the sequence behind project entry `id` (the
|
||||
/// export dialog's sequence picker / the explorer's 导出序列 item).
|
||||
/// The work area only applies when `id` is the open sequence.
|
||||
/// Default: unsupported.
|
||||
fn start_export_of(
|
||||
&mut self,
|
||||
id: u64,
|
||||
settings: &ExportSettings,
|
||||
path: PathBuf,
|
||||
) -> Result<ExportSession, String> {
|
||||
let _ = (id, settings, path);
|
||||
Err("export not supported".into())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Proxy media (the C++ Tools > proxy pipeline): global switch, per
|
||||
// footage state and the generate / delete / reveal entries. Defaults
|
||||
@@ -812,7 +879,7 @@ pub trait AppEngine:
|
||||
/// The global "Use Proxy Media" switch (the C++ `UseProxyMedia`
|
||||
/// config; preview-only — exports always decode the original media).
|
||||
fn use_proxy_media(&self) -> bool {
|
||||
oak_common::configstore::ConfigStore::instance()
|
||||
oak_core::configstore::ConfigStore::instance()
|
||||
.get_bool(None, "UseProxyMedia", 1)
|
||||
!= 0
|
||||
}
|
||||
@@ -821,7 +888,7 @@ pub trait AppEngine:
|
||||
/// footage's rendered frames (the C++ toggles the config and
|
||||
/// re-renders; the preview path reads the switch on every montage).
|
||||
fn set_use_proxy_media(&mut self, enabled: bool, cx: &mut Context<Self>) {
|
||||
oak_common::configstore::ConfigStore::instance().set(
|
||||
oak_core::configstore::ConfigStore::instance().set(
|
||||
None,
|
||||
"UseProxyMedia",
|
||||
if enabled { "true" } else { "false" },
|
||||
@@ -834,7 +901,7 @@ pub trait AppEngine:
|
||||
/// size, 2/4/8 = progressively smaller preview renders for machines
|
||||
/// that cannot keep up. Preview-only; exports always render native.
|
||||
fn playback_divider(&self) -> i64 {
|
||||
oak_common::configstore::ConfigStore::instance()
|
||||
oak_core::configstore::ConfigStore::instance()
|
||||
.get_int(None, "PlaybackDivider", 1)
|
||||
.clamp(1, 8) as i64
|
||||
}
|
||||
@@ -842,7 +909,7 @@ pub trait AppEngine:
|
||||
/// Sets the playback resolution divider and invalidates the rendered
|
||||
/// frames so the next pull re-renders at the new geometry.
|
||||
fn set_playback_divider(&mut self, divider: i64, cx: &mut Context<Self>) {
|
||||
oak_common::configstore::ConfigStore::instance().set(
|
||||
oak_core::configstore::ConfigStore::instance().set(
|
||||
None,
|
||||
"PlaybackDivider",
|
||||
÷r.clamp(1, 8).to_string(),
|
||||
@@ -860,7 +927,7 @@ pub trait AppEngine:
|
||||
/// Whether playback stops at the last frame instead of looping (the C++
|
||||
/// viewer `Stop on Last` toggle / `StopOnLastFrame` config).
|
||||
fn stop_on_last(&self) -> bool {
|
||||
oak_common::configstore::ConfigStore::instance()
|
||||
oak_core::configstore::ConfigStore::instance()
|
||||
.get_bool(None, "StopOnLastFrame", 0)
|
||||
!= 0
|
||||
}
|
||||
@@ -868,7 +935,7 @@ pub trait AppEngine:
|
||||
/// Sets the `StopOnLastFrame` config (the next tick past the end either
|
||||
/// pauses at the last frame or wraps around).
|
||||
fn set_stop_on_last(&mut self, enabled: bool, cx: &mut Context<Self>) {
|
||||
oak_common::configstore::ConfigStore::instance().set(
|
||||
oak_core::configstore::ConfigStore::instance().set(
|
||||
None,
|
||||
"StopOnLastFrame",
|
||||
if enabled { "true" } else { "false" },
|
||||
@@ -880,14 +947,14 @@ pub trait AppEngine:
|
||||
/// value (`0` automatic / `1` only / `2` both), clamped to the valid
|
||||
/// range.
|
||||
fn waveform_mode(&self) -> i32 {
|
||||
oak_common::configstore::ConfigStore::instance()
|
||||
oak_core::configstore::ConfigStore::instance()
|
||||
.get_int(None, "ViewerWaveformMode", 0)
|
||||
.clamp(0, 2)
|
||||
}
|
||||
|
||||
/// Sets the `ViewerWaveformMode` config value (clamped to `0..=2`).
|
||||
fn set_waveform_mode(&mut self, mode: i32, cx: &mut Context<Self>) {
|
||||
oak_common::configstore::ConfigStore::instance().set(
|
||||
oak_core::configstore::ConfigStore::instance().set(
|
||||
None,
|
||||
"ViewerWaveformMode",
|
||||
&mode.clamp(0, 2).to_string(),
|
||||
@@ -955,15 +1022,15 @@ pub trait AppEngine:
|
||||
|
||||
/// The project's color pipeline settings:
|
||||
/// `(working colorspace, output gamut, output transfer)` as the
|
||||
/// persisted setting strings (see `oak_common::colormath`). The
|
||||
/// persisted setting strings (see `oak_core::colormath`). The
|
||||
/// working colorspace is the pipeline's scene space (ACEScg by
|
||||
/// default, not hard-coded sRGB); the output pair is the delivery
|
||||
/// target for export and presentation.
|
||||
fn project_color_settings(&self) -> (String, String, String) {
|
||||
(
|
||||
oak_common::colormath::WorkingColorSpace::default().as_setting().to_string(),
|
||||
oak_common::colormath::OutputGamut::default().as_setting().to_string(),
|
||||
oak_common::colormath::OutputTransfer::default().as_setting().to_string(),
|
||||
oak_core::colormath::WorkingColorSpace::default().as_setting().to_string(),
|
||||
oak_core::colormath::OutputGamut::default().as_setting().to_string(),
|
||||
oak_core::colormath::OutputTransfer::default().as_setting().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1027,6 +1094,47 @@ pub trait AppEngine:
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Creates a text generator in the open project's root folder (the
|
||||
/// project explorer's "添加文本素材" action) and returns its entry id.
|
||||
/// The bin entry is NOT a sequence: a double-click selects it like
|
||||
/// footage, and the timeline drop places a generator clip.
|
||||
fn create_text_footage(&mut self, cx: &mut Context<Self>) -> Result<u64, String> {
|
||||
let _ = cx;
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Drops a GENERATOR effect (checkerboard / color bars / shape / …)
|
||||
/// from the effect library onto the timeline at `(track_index, time)`:
|
||||
/// creates the generator node and places a 5-second generator clip fed
|
||||
/// by it — a standalone clip, not an effect on another clip (the
|
||||
/// inspector just shows the generator's params). Default: unsupported.
|
||||
fn drop_generator_clip(
|
||||
&mut self,
|
||||
type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (type_id, track_index, time, cx);
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Drops a TRANSITION effect onto the timeline near `(track_index,
|
||||
/// time)`: snaps to the nearest clip edge on that track and creates a
|
||||
/// transition there — a junction transition when the edge is a
|
||||
/// contiguous cut, otherwise a single-sided head/tail transition
|
||||
/// (PR-style edge drops). Default: unsupported.
|
||||
fn drop_transition_at(
|
||||
&mut self,
|
||||
type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (type_id, track_index, time, cx);
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Applies new name/format/interlaced parameters to the sequence `id`.
|
||||
fn update_sequence_parameters(
|
||||
&mut self,
|
||||
@@ -1169,6 +1277,18 @@ pub trait AppEngine:
|
||||
let _ = (clips, cx);
|
||||
}
|
||||
|
||||
/// Adds a transition of the configured default length (the
|
||||
/// `DefaultTransitionLength` preference, seconds) at every contiguous
|
||||
/// seam around `clips` — the clip menu's Default Transition item and the
|
||||
/// Ctrl+Shift+D action (the C++ `timeline::add_default_transition`).
|
||||
/// One undoable "Add Transition" entry; returns how many transitions
|
||||
/// were created, or `Err` when no selected seam accepts one.
|
||||
fn add_default_transition(
|
||||
&mut self,
|
||||
clips: Vec<ClipId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<usize, String>;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Multi-camera (the C++ MulticamWidget / timeline Multi-Cam menu):
|
||||
// detection state for the panel, angle-frame rendering, the timeline
|
||||
@@ -1496,11 +1616,510 @@ pub enum ExportEvent {
|
||||
/// A running export: the event channel the host drains plus the cancel
|
||||
/// handle. Dropping the session does not abort the export thread; the
|
||||
/// thread owns the task and frees it when it finishes.
|
||||
pub struct ExportSession {
|
||||
/// The event receiver (the background thread's sender lives as long as
|
||||
/// The export dialog's settings (文件 → 导出媒体…): container format,
|
||||
/// video/audio codecs, color space + bit depth, output size / rate /
|
||||
/// bitrate and the export range. The engine's `start_export` consumes
|
||||
/// these to build the oak-task `EncodingParams` (the codec tables gate
|
||||
/// which combos are selectable — an incompatible codec is never offered).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExportSettings {
|
||||
/// The container format id ([`oak_codec::exportformat::Format`]).
|
||||
pub format: i32,
|
||||
/// The video codec ([`oak_codec::exportcodec::Codec`]).
|
||||
pub video_codec: i32,
|
||||
/// The audio codec ([`oak_codec::exportcodec::Codec`]).
|
||||
pub audio_codec: i32,
|
||||
/// Output size (`(width, height)`; `(0,0)` keeps the sequence size).
|
||||
pub size: (i32, i32),
|
||||
/// Output frame rate (fps; `0` = the sequence rate).
|
||||
pub frame_rate: f64,
|
||||
/// Video bitrate in bit/s (`0` = codec default).
|
||||
pub video_bitrate: i64,
|
||||
/// Audio bitrate in bit/s (`0` = codec default).
|
||||
pub audio_bitrate: i64,
|
||||
/// Bit depth: 8 → U8, 10 → U10 (SDR / HDR respectively).
|
||||
pub bit_depth: i32,
|
||||
/// AVColorPrimaries (SDR 709 = 1, HDR 2020 = 9).
|
||||
pub color_primaries: i32,
|
||||
/// AVColorTransferCharacteristic (SDR 709 = 1, HDR PQ = 16).
|
||||
pub color_transfer: i32,
|
||||
/// AVColorSpace (SDR 709 = 1, HDR 2020 = 9).
|
||||
pub color_space: i32,
|
||||
/// Export range: `None` = whole sequence, `Some((in_s, out_s))`.
|
||||
pub range: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: 2, // MPEG4Video (.mp4)
|
||||
video_codec: 1, // H.264
|
||||
audio_codec: 12, // AAC
|
||||
size: (0, 0),
|
||||
frame_rate: 0.0,
|
||||
video_bitrate: 0,
|
||||
audio_bitrate: 0,
|
||||
bit_depth: 8,
|
||||
color_primaries: 1,
|
||||
color_transfer: 1,
|
||||
color_space: 1,
|
||||
range: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExportSession { /// The event receiver (the background thread's sender lives as long as
|
||||
/// the session's `cancel` side, so a dropped receiver just stops
|
||||
/// delivering).
|
||||
pub events: std::sync::mpsc::Receiver<ExportEvent>,
|
||||
/// Cancels the running export as soon as possible.
|
||||
pub cancel: Box<dyn Fn() + Send>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockClock;
|
||||
use gpui::effect_stack::EffectData;
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
use gpui::node_graph::PortId;
|
||||
use gpui_widgets::project_explorer::ProjectEntry;
|
||||
|
||||
#[test]
|
||||
fn node_category_key_covers_every_category() {
|
||||
use oak_node::node::Category as C;
|
||||
for category in [
|
||||
C::Output,
|
||||
C::Effect,
|
||||
C::Generator,
|
||||
C::Input,
|
||||
C::Math,
|
||||
C::Color,
|
||||
C::Distort,
|
||||
C::Filter,
|
||||
C::Keying,
|
||||
C::OpenFx,
|
||||
C::Group,
|
||||
] {
|
||||
let key = node_category_key(category).expect("category has an i18n key");
|
||||
assert!(key.starts_with("node.category."), "{category:?} -> {key}");
|
||||
}
|
||||
// Timeline-structural nodes never show in the Add menu.
|
||||
assert_eq!(node_category_key(C::Timeline), None);
|
||||
}
|
||||
|
||||
/// A minimal engine that implements only the required methods, so every
|
||||
/// `AppEngine` default body (the "not supported" / no-op fallbacks)
|
||||
/// executes in the tests below.
|
||||
struct NullEngine {
|
||||
source_clock: Entity<MockClock>,
|
||||
program_clock: Entity<MockClock>,
|
||||
}
|
||||
|
||||
impl NullEngine {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let rate = FrameRate { num: 25, den: 1 };
|
||||
NullEngine {
|
||||
source_clock: cx.new(|_| MockClock::new(rate)),
|
||||
program_clock: cx.new(|_| MockClock::new(rate)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineGateway for NullEngine {
|
||||
fn project(&self) -> Option<&Project> {
|
||||
None
|
||||
}
|
||||
fn current_sequence(&self) -> Option<&Sequence> {
|
||||
None
|
||||
}
|
||||
fn open_project(&mut self, _path: PathBuf, _cx: &mut Context<Self>) {}
|
||||
fn request_frame(&mut self, _m: Monitor, _f: Frame, _cx: &mut Context<Self>) {}
|
||||
fn play(&mut self, _m: Monitor, _cx: &mut Context<Self>) {}
|
||||
fn pause(&mut self, _m: Monitor, _cx: &mut Context<Self>) {}
|
||||
fn step(&mut self, _m: Monitor, _d: i64, _cx: &mut Context<Self>) {}
|
||||
fn tick(&mut self, _cx: &mut Context<Self>) {}
|
||||
}
|
||||
|
||||
impl TimelineDataSource for NullEngine {
|
||||
type Track = <crate::oakui::MockEngine as TimelineDataSource>::Track;
|
||||
fn frame_rate(&self) -> FrameRate {
|
||||
FrameRate { num: 25, den: 1 }
|
||||
}
|
||||
fn sequence_length(&self) -> Frame {
|
||||
Frame::ZERO
|
||||
}
|
||||
fn track_count(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn track(&self, _index: usize) -> Option<Self::Track> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectStackDataSource for NullEngine {
|
||||
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
|
||||
Vec::new()
|
||||
}
|
||||
fn target_label(&self) -> Option<SharedString> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeGraphDataSource for NullEngine {
|
||||
type Node = <crate::oakui::MockEngine as NodeGraphDataSource>::Node;
|
||||
type Edge = <crate::oakui::MockEngine as NodeGraphDataSource>::Edge;
|
||||
fn nodes(&self) -> Vec<Self::Node> {
|
||||
Vec::new()
|
||||
}
|
||||
fn edges(&self) -> Vec<Self::Edge> {
|
||||
Vec::new()
|
||||
}
|
||||
fn can_connect(&self, _from: PortId, _to: PortId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectDataSource for NullEngine {
|
||||
fn roots(&self) -> Vec<ProjectEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
fn children(&self, _parent: u64) -> Vec<ProjectEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioMeterDataSource for NullEngine {
|
||||
fn levels(&self) -> Vec<f32> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppEngine for NullEngine {
|
||||
type Clock = MockClock;
|
||||
|
||||
fn create(cx: &mut Context<Self>) -> Self {
|
||||
NullEngine::new(cx)
|
||||
}
|
||||
fn source_clock(&self) -> &Entity<Self::Clock> {
|
||||
&self.source_clock
|
||||
}
|
||||
fn program_clock(&self) -> &Entity<Self::Clock> {
|
||||
&self.program_clock
|
||||
}
|
||||
fn clock_frame(&self, _m: Monitor, _cx: &App) -> Frame {
|
||||
Frame::ZERO
|
||||
}
|
||||
fn cpu_frame(&self, _m: Monitor, _cx: &App) -> Arc<RenderImage> {
|
||||
// A 2×2 transparent frame, so frame-consuming defaults
|
||||
// (save_frame) run instead of panicking.
|
||||
let samples = [0.0f32; 16];
|
||||
Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(2, 2, &samples))
|
||||
}
|
||||
fn scope_data(&self, _m: Monitor, _cx: &App) -> ScopeData {
|
||||
ScopeData::default()
|
||||
}
|
||||
fn add_track(&mut self, _kind: TrackKind, _cx: &mut Context<Self>) {}
|
||||
fn remove_track(&mut self, _index: usize, _cx: &mut Context<Self>) {}
|
||||
fn set_track_height(&mut self, _height: Pixels, _cx: &mut Context<Self>) {}
|
||||
fn select_item(&mut self, _id: u64, _cx: &mut Context<Self>) {}
|
||||
fn apply_effect_event(&mut self, _e: &EffectStackEvent, _cx: &mut Context<Self>) {}
|
||||
fn apply_node_graph_event(&mut self, _e: &NodeGraphEvent, _cx: &mut Context<Self>) {}
|
||||
fn apply_timeline_event(&mut self, _e: &TimelineEvent, _cx: &mut Context<Self>) {}
|
||||
fn split_clip(&mut self, _clip: ClipId, _time: Frame, _cx: &mut Context<Self>) {}
|
||||
fn split_at_playhead(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn delete_clip(&mut self, _clip: ClipId, _ripple: bool, _cx: &mut Context<Self>) {}
|
||||
fn can_undo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn can_redo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn undo(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn redo(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn new_project(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn open_project_path(
|
||||
&mut self,
|
||||
_path: PathBuf,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn export_project_path(
|
||||
&mut self,
|
||||
_path: PathBuf,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn close_project(&mut self, _cx: &mut Context<Self>) {}
|
||||
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn start_export_with(
|
||||
&mut self,
|
||||
_settings: &ExportSettings,
|
||||
_path: PathBuf,
|
||||
) -> Result<ExportSession, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn add_default_transition(
|
||||
&mut self,
|
||||
_clips: Vec<ClipId>,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<usize, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"null"
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn app_engine_defaults_degrade_gracefully(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = app.new(NullEngine::create);
|
||||
|
||||
// Gateway defaults.
|
||||
assert_eq!(engine.read(app).source_media_name(), "");
|
||||
|
||||
// Add-menu / inspector / OFX defaults.
|
||||
assert_eq!(engine.read(app).selected_graph_node(), None);
|
||||
assert!(engine.read(app).protected_graph_nodes().is_empty());
|
||||
assert!(engine.read(app).addable_effects().is_empty());
|
||||
assert!(engine.read(app).effect_params(EffectId(0)).is_none());
|
||||
assert_eq!(engine.read(app).ofx_interact_target(app), None);
|
||||
assert!(engine.read(app).addable_effects().is_empty());
|
||||
|
||||
// History defaults.
|
||||
assert!(engine.read(app).history_entries().is_empty());
|
||||
assert_eq!(engine.read(app).history_index(), 0);
|
||||
assert!(!engine.read(app).storage_bound());
|
||||
assert_eq!(engine.read(app).storage_last_error(), None);
|
||||
assert_eq!(engine.read(app).waveform_cache().map(|_| ()), None);
|
||||
|
||||
// Footage defaults.
|
||||
assert_eq!(engine.read(app).entry_path(1), None);
|
||||
assert_eq!(engine.read(app).project_entry_name(1).map(|s| s.to_string()), None);
|
||||
assert_eq!(engine.read(app).footage_length_frames(1), None);
|
||||
assert_eq!(engine.read(app).footage_video_params(1), None);
|
||||
assert_eq!(engine.read(app).workarea(), None);
|
||||
|
||||
// Unsupported / no-op mutators return their error strings.
|
||||
engine.update(app, |engine, cx| {
|
||||
assert!(engine.add_adjustment_layer(0, Frame::ZERO, cx).is_err());
|
||||
assert!(engine.add_effect(0, "no.such.effect", cx).is_err());
|
||||
assert!(engine
|
||||
.set_effect_param(EffectId(0), "p", oak_node::value::NodeValue::None, cx)
|
||||
.is_err());
|
||||
assert!(engine.effect_push_button(EffectId(0), "p", cx).is_err());
|
||||
assert!(engine.add_node_at("no.such.node", Point::default(), cx).is_err());
|
||||
assert!(engine
|
||||
.replace_footage(1, PathBuf::from("/tmp/x.mp4"), cx)
|
||||
.is_err());
|
||||
assert!(engine
|
||||
.library_projects()
|
||||
.is_err());
|
||||
assert!(engine.library_create_project("x", cx).is_err());
|
||||
assert!(engine.library_open_project("u", cx).is_err());
|
||||
assert!(engine.library_delete_project("u").is_err());
|
||||
assert!(engine.library_rename_project("u", "n").is_err());
|
||||
assert!(engine.library_duplicate_project("u").is_err());
|
||||
assert!(engine.save_project_as("x", cx).is_err());
|
||||
assert!(engine.library_import_project(PathBuf::from("/tmp/x.ove")).is_err());
|
||||
assert!(engine.library_export_project("u", PathBuf::from("/tmp/x.ove")).is_err());
|
||||
assert!(engine.import_footage(PathBuf::from("/tmp/x.mp4"), cx).is_ok());
|
||||
|
||||
// All panelless operations are silent no-ops.
|
||||
engine.set_selected_clips(vec![ClipId(1), ClipId(2)], cx);
|
||||
engine.rename_entry(1, "name".into(), cx);
|
||||
engine.delete_entry(1, cx);
|
||||
engine.add_marker_at_playhead(cx);
|
||||
engine.remove_marker_at_playhead(cx);
|
||||
engine.set_workarea_preview(Frame(1), Frame(2), cx);
|
||||
engine.commit_workarea(Frame(0), Frame(1), Frame(2), Frame(3), cx);
|
||||
engine.clear_workarea(cx);
|
||||
engine.clipboard_copy(vec![ClipId(1)], cx);
|
||||
engine.clipboard_cut(vec![ClipId(1)], cx);
|
||||
engine.clipboard_paste(cx);
|
||||
engine.jump_history(-1, cx);
|
||||
engine.drop_footage(1, TrackKind::Video, 0, Frame::ZERO, cx);
|
||||
});
|
||||
|
||||
// `delete_empty_tracks` walks the (empty) track list and stops.
|
||||
engine.update(app, |engine, cx| engine.delete_empty_tracks(cx));
|
||||
|
||||
// The node library comes from the global factory; every entry
|
||||
// carries an Add-menu category key.
|
||||
let library = engine.read(app).node_library();
|
||||
assert!(!library.is_empty(), "the factory has creatable entries");
|
||||
for entry in &library {
|
||||
assert!(entry.category_key.starts_with("node.category."));
|
||||
assert!(!entry.type_id.is_empty());
|
||||
assert!(!entry.name.is_empty());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_params_ui_round_trips_through_the_codec_type() {
|
||||
let codec = oak_codec::proxymanager::ProxyParams {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
divider: 2,
|
||||
crf: 23,
|
||||
include_audio: 0,
|
||||
..oak_codec::proxymanager::ProxyParams::default()
|
||||
};
|
||||
let ui = ProxyParamsUi::from_codec(&codec);
|
||||
assert_eq!(ui.width, 1280);
|
||||
assert_eq!(ui.height, 720);
|
||||
assert_eq!(ui.divider, 2);
|
||||
assert_eq!(ui.crf, 23);
|
||||
assert!(!ui.include_audio);
|
||||
let back = ui.to_codec();
|
||||
assert_eq!(back.width, 1280);
|
||||
assert_eq!(back.divider, 2);
|
||||
assert_eq!(back.include_audio, 0);
|
||||
|
||||
// A long preset is truncated to the 31-byte codec field.
|
||||
let long = ProxyParamsUi {
|
||||
preset: "x".repeat(40),
|
||||
include_audio: true,
|
||||
..ui
|
||||
};
|
||||
let codec = long.to_codec();
|
||||
assert_eq!(&codec.preset[..31], "x".repeat(31).as_bytes());
|
||||
assert_eq!(codec.preset[31], 0);
|
||||
assert_eq!(codec.include_audio, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_params_from_config_returns_the_defaults() {
|
||||
// No Proxy* config is installed by the test environment: the codec
|
||||
// defaults come back.
|
||||
let params = proxy_params_from_config();
|
||||
let codec_defaults = oak_codec::proxymanager::ProxyManager::proxy_params_default();
|
||||
assert_eq!(params.width, codec_defaults.width);
|
||||
assert_eq!(params.height, codec_defaults.height);
|
||||
assert_eq!(params.divider, codec_defaults.divider);
|
||||
assert_eq!(params.crf, codec_defaults.crf);
|
||||
}
|
||||
|
||||
/// The remaining `AppEngine` defaults: project/library/proxy/sync/
|
||||
/// multicam/eyedropper fallbacks that a minimal engine must survive.
|
||||
#[gpui::test]
|
||||
async fn app_engine_remaining_defaults_are_safe(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|app| {
|
||||
let engine = app.new(NullEngine::create);
|
||||
|
||||
// Read-only fallbacks.
|
||||
assert!(engine.read(app).sequence_entries().is_empty());
|
||||
assert_eq!(engine.read(app).current_sequence_id(), None);
|
||||
assert_eq!(engine.read(app).sequence_parameters(1), None);
|
||||
assert!(!engine.read(app).entry_is_sequence(1));
|
||||
assert!(engine.read(app).proxy_rows().is_empty());
|
||||
assert_eq!(engine.read(app).proxy_state(1), None);
|
||||
assert!(engine.read(app).proxy_row(1).is_none());
|
||||
assert_eq!(engine.read(app).proxy_task_progress(), None);
|
||||
assert_eq!(engine.read(app).proxy_custom_params(1), None);
|
||||
assert!(engine.read(app).clip_footage_entries(&[]).is_empty());
|
||||
assert_eq!(engine.read(app).multicam_state(), None);
|
||||
assert!(!engine.read(app).multicam_eligible(&[]));
|
||||
assert!(!engine.read(app).multicam_enabled_on_selection(&[]));
|
||||
assert_eq!(engine.read(app).multicam_wizard_footage(), None);
|
||||
assert!(engine.read(app).multicam_wizard_sync_offsets(&[]).is_err());
|
||||
assert!(!engine.read(app).eyedropper_armed(app));
|
||||
let _ = engine
|
||||
.read(app)
|
||||
.save_frame(Monitor::Program, PathBuf::from("/tmp/oak-null.png"), app);
|
||||
|
||||
// Config-backed getters; the setters write their compiled-in
|
||||
// defaults back so the process config stays neutral.
|
||||
let _ = engine.read(app).use_proxy_media();
|
||||
let _ = engine.read(app).playback_divider();
|
||||
let _ = engine.read(app).stop_on_last();
|
||||
let _ = engine.read(app).waveform_mode();
|
||||
let _ = engine.read(app).project_ocio_config();
|
||||
let _ = engine.read(app).project_cache_location();
|
||||
let _ = engine.read(app).project_color_settings();
|
||||
let _ = engine.read(app).sync_eligibility(&[]);
|
||||
let _ = engine.read(app).proxy_effective_params(1);
|
||||
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.set_use_proxy_media(false, cx);
|
||||
engine.set_playback_divider(1, cx);
|
||||
engine.set_stop_on_last(false, cx);
|
||||
engine.set_waveform_mode(0, cx);
|
||||
assert!(engine.set_project_ocio_config(String::new(), cx).is_ok());
|
||||
engine.set_project_cache_location(0, String::new(), cx);
|
||||
engine.set_project_color_settings(String::new(), String::new(), String::new(), cx);
|
||||
|
||||
// Unsupported operations report errors instead of panicking.
|
||||
assert!(engine
|
||||
.start_export_of(1, &ExportSettings::default(), PathBuf::from("/tmp/x.mp4"))
|
||||
.is_err());
|
||||
assert!(engine
|
||||
.create_sequence_with_params(
|
||||
"s".into(),
|
||||
VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.is_err());
|
||||
assert!(engine.create_folder("f".into(), cx).is_err());
|
||||
assert!(engine.create_text_footage(cx).is_err());
|
||||
assert!(engine.drop_generator_clip("t", 0, Frame::ZERO, cx).is_err());
|
||||
assert!(engine.drop_transition_at("t", 0, Frame::ZERO, cx).is_err());
|
||||
assert!(engine
|
||||
.update_sequence_parameters(
|
||||
1,
|
||||
"n".into(),
|
||||
VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.is_err());
|
||||
engine.open_sequence_id(1, cx);
|
||||
|
||||
assert!(engine.proxy_generate(1, cx).is_err());
|
||||
engine.proxy_delete(1, cx);
|
||||
engine.proxy_set_enabled(1, true, cx);
|
||||
engine.proxy_reveal(1);
|
||||
engine.proxy_set_custom_params(
|
||||
1,
|
||||
ProxyParamsUi {
|
||||
width: 0,
|
||||
height: 0,
|
||||
divider: 1,
|
||||
crf: 0,
|
||||
preset: String::new(),
|
||||
include_audio: false,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
engine.proxy_clear_custom_params(1, cx);
|
||||
|
||||
engine.sync_clips_by_source_time(Vec::new(), cx);
|
||||
engine.sync_clips_by_waveform(Vec::new(), false, cx);
|
||||
engine.toggle_clip_links(Vec::new(), cx);
|
||||
engine.multicam_enable_selected(Vec::new(), true, cx);
|
||||
engine.multicam_enable_selected(Vec::new(), false, cx);
|
||||
engine.multicam_switch_to(0, false, cx);
|
||||
assert!(engine.multicam_angle_frame(0, cx).is_none());
|
||||
assert!(engine
|
||||
.multicam_create_sequence(Vec::new(), Vec::new(), "m".into(), cx)
|
||||
.is_err());
|
||||
|
||||
// Eyedropper mailbox round trip.
|
||||
engine.set_eyedropper_armed(true, cx);
|
||||
assert!(engine.eyedropper_armed(cx));
|
||||
engine.eyedropper_picked(gpui::rgba(0x11223344), cx);
|
||||
assert!(engine.take_eyedropper_result(cx).is_some());
|
||||
engine.set_eyedropper_armed(false, cx);
|
||||
assert!(engine.take_eyedropper_result(cx).is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,125 @@ use std::sync::Mutex;
|
||||
static GPU_CONTEXT: Mutex<Option<(std::sync::Arc<wgpu::Device>, std::sync::Arc<wgpu::Queue>)>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// Register the window's wgpu device/queue for the 10-bit display path.
|
||||
/// The app's window builder calls this once per window (last one wins; the
|
||||
/// renderers all share the same device).
|
||||
/// The cached CPU-baked display LUT (M2): keyed by the display/color
|
||||
/// generation so a settings or monitor change rebuilds it, and
|
||||
/// re-installed whenever the engine context does not have it.
|
||||
static DISPLAY_LUT: Mutex<Option<(String, oak_core::lut::Lut3d)>> = Mutex::new(None);
|
||||
|
||||
/// A key covering every input of the display chain: the displaycolor
|
||||
/// generation (policy + monitor ICC) and the project's working/output
|
||||
/// color settings.
|
||||
fn display_lut_key() -> String {
|
||||
format!(
|
||||
"{}|{:?}|{:?}",
|
||||
crate::oakui::displaycolor::generation(),
|
||||
oak_core::color::pipeline_working_space(),
|
||||
oak_core::color::pipeline_output_spec()
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the working-space → display-device 3D LUT with the exact CPU
|
||||
/// reference implementation: the output node
|
||||
/// ([`oak_core::colormath::working_to_display_target`]) followed by the
|
||||
/// display ICC chain ([`super::displaycolor::apply_f32_rgba`]). This is
|
||||
/// what makes the GPU present path color-managed: every per-pixel step the
|
||||
/// CPU path performs runs here once per settings change, on the GPU's
|
||||
/// behalf, at full precision.
|
||||
fn build_display_lut() -> oak_core::lut::Lut3d {
|
||||
let edge = oak_core::lut::Lut3d::DISPLAY_EDGE;
|
||||
let lo = oak_core::lut::Lut3d::DISPLAY_LO;
|
||||
let hi = oak_core::lut::Lut3d::DISPLAY_HI;
|
||||
let n = (edge as usize).pow(3);
|
||||
let mut samples = vec![0.0f32; n * 4];
|
||||
let step = |i: usize, axis: usize| -> f32 {
|
||||
let t = i as f32 / (edge - 1) as f32;
|
||||
lo[axis] + (hi[axis] - lo[axis]) * t
|
||||
};
|
||||
for b in 0..edge as usize {
|
||||
for g in 0..edge as usize {
|
||||
for r in 0..edge as usize {
|
||||
let idx = ((b * edge as usize + g) * edge as usize + r) * 4;
|
||||
samples[idx] = step(r, 0);
|
||||
samples[idx + 1] = step(g, 1);
|
||||
samples[idx + 2] = step(b, 2);
|
||||
samples[idx + 3] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
oak_core::colormath::working_to_display_target(
|
||||
&mut samples,
|
||||
oak_core::color::pipeline_working_space(),
|
||||
oak_core::color::pipeline_output_spec(),
|
||||
);
|
||||
super::displaycolor::apply_f32_rgba(&mut samples, n as i64);
|
||||
let mut data = Vec::with_capacity(n * 3);
|
||||
for px in samples.as_chunks::<4>().0 {
|
||||
data.extend_from_slice(&px[..3]);
|
||||
}
|
||||
oak_core::lut::Lut3d { edge, lo, hi, data }
|
||||
}
|
||||
|
||||
/// Install the display LUT on the engine context when missing or stale.
|
||||
fn ensure_display_lut(ctx: &oak_core::backend::GpuContext) {
|
||||
let key = display_lut_key();
|
||||
let mut cache = DISPLAY_LUT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fresh = cache.as_ref().is_some_and(|(k, _)| *k == key);
|
||||
if fresh && ctx.has_display_lut() {
|
||||
return;
|
||||
}
|
||||
let lut = if fresh {
|
||||
cache
|
||||
.as_ref()
|
||||
.map(|(_, l)| l.clone())
|
||||
.unwrap_or_else(build_display_lut)
|
||||
} else {
|
||||
build_display_lut()
|
||||
};
|
||||
if ctx.set_display_lut(&lut).is_ok() {
|
||||
*cache = Some((key, lut));
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the window's wgpu device/queue for the 10-bit display path
|
||||
/// and adopt it into the engine (M2). The engine's render thread then
|
||||
/// renders on the very device gpui presents with, so finished frames are
|
||||
/// sampled zero-copy via [`gpui::SurfaceSource::Texture`]. The adoption
|
||||
/// is a no-op when the engine already created its own device (then
|
||||
/// [`present_gpu_frame`] reports `None` and the caller stages through the
|
||||
/// CPU as before).
|
||||
pub fn register_context(device: std::sync::Arc<wgpu::Device>, queue: std::sync::Arc<wgpu::Queue>) {
|
||||
if let Ok(mut ctx) = GPU_CONTEXT.lock() {
|
||||
*ctx = Some((device, queue));
|
||||
*ctx = Some((device.clone(), queue.clone()));
|
||||
}
|
||||
let adopted =
|
||||
oak_core::backend::GpuContext::adopt(device, queue, oak_core::backend::BackendKind::Auto);
|
||||
if !oak_core::backend::GpuContext::install_shared(Some(adopted)) {
|
||||
// The engine context was already used for GPU work before the
|
||||
// window opened: it cannot be replaced, so present falls back to
|
||||
// the single staging readback. Log once — this is the only silent
|
||||
// degradation of the M2 zero-copy path.
|
||||
static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
if oak_core::backend::GpuContext::shared().is_some_and(|c| !c.is_adopted())
|
||||
&& !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
log::error!(
|
||||
"GPU device adoption refused: the engine created and used a device before the \
|
||||
window opened; preview presentation falls back to a staging readback"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare the engine's shared GPU context as the host's render device
|
||||
/// (M5) on platforms where gpui does not expose the window device
|
||||
/// (macOS/Windows): there the engine-created shared context is what the
|
||||
/// render thread renders on, so the decoder's zero-copy hardware import
|
||||
/// must treat it as the host device. Same rationale as
|
||||
/// [`register_context`]'s adoption on Linux/FreeBSD. No-op result is
|
||||
/// reported for diagnostics only.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
|
||||
pub fn register_engine_context() -> bool {
|
||||
oak_core::backend::GpuContext::mark_host_context()
|
||||
}
|
||||
|
||||
/// Whether a GPU context is registered (a window is open). The viewer uses
|
||||
@@ -53,6 +165,31 @@ pub fn context_ready() -> bool {
|
||||
GPU_CONTEXT.lock().map(|ctx| ctx.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Present an engine-rendered GPU texture (M2): apply the display LUT on
|
||||
/// the shared device and return the raw `wgpu::Texture` gpui samples.
|
||||
/// `None` when the texture is CPU-resident, the engine device is not the
|
||||
/// adopted one, or the LUT pass is unavailable — the caller then falls
|
||||
/// back to the CPU display path.
|
||||
pub fn present_gpu_frame(
|
||||
texture: &oak_core::texture::Texture,
|
||||
) -> Option<std::sync::Arc<wgpu::Texture>> {
|
||||
let oak_core::texture::Texture::Gpu { token, ctx, .. } = texture else {
|
||||
return None;
|
||||
};
|
||||
let concrete = ctx
|
||||
.as_any()?
|
||||
.downcast_ref::<oak_core::backend::GpuContext>()?;
|
||||
if !concrete.is_adopted() {
|
||||
return None;
|
||||
}
|
||||
ensure_display_lut(concrete);
|
||||
let dst = concrete.present_texture(*token).ok()?;
|
||||
let handle = concrete.texture_handle(dst);
|
||||
// gpui's `Arc` owns the texture now; release the engine registry entry.
|
||||
concrete.destroy_texture(dst);
|
||||
handle
|
||||
}
|
||||
|
||||
/// Upload F32 RGBA samples (tightly packed, `width * height * 4` values) as
|
||||
/// a half-float RGBA16F GPU texture for the 10-bit display path. Returns
|
||||
/// `None` when no context is registered or the samples are malformed — the
|
||||
@@ -69,8 +206,7 @@ pub fn upload_rgba16f(
|
||||
let ctx = GPU_CONTEXT.lock().ok()?;
|
||||
ctx.as_ref().map(|(d, q)| (d.clone(), q.clone()))?
|
||||
};
|
||||
let (bytes, bytes_per_row) =
|
||||
super::frames::f32_rgba_to_16f_bytes(width, height, samples)?;
|
||||
let (bytes, bytes_per_row) = super::frames::f32_rgba_to_16f_bytes(width, height, samples)?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("oak_display_rgba16f"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -117,6 +253,18 @@ pub fn register_display_frame(image_id: usize, width: u32, height: u32, samples:
|
||||
let Some(texture) = upload_rgba16f(width, height, samples) else {
|
||||
return;
|
||||
};
|
||||
register_texture(image_id, width, height, texture);
|
||||
}
|
||||
|
||||
/// Register an already-created `wgpu::Texture` (the M2 zero-copy present
|
||||
/// result) for `image_id`, so the viewer samples it instead of a CPU
|
||||
/// upload. The texture must live on the registered window device.
|
||||
pub fn register_texture(
|
||||
image_id: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
texture: std::sync::Arc<wgpu::Texture>,
|
||||
) {
|
||||
gpui_widgets::viewer::register_gpu_frame(
|
||||
image_id,
|
||||
texture,
|
||||
@@ -126,3 +274,87 @@ pub fn register_display_frame(image_id: usize, width: u32, height: u32, samples:
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Malformed uploads are rejected before any GPU context is touched
|
||||
/// (review §10.2: the validation arms of the M5 display path had no
|
||||
/// tests at all).
|
||||
#[test]
|
||||
fn rgba16f_upload_rejects_malformed_arguments() {
|
||||
// Zero dimensions.
|
||||
assert!(upload_rgba16f(0, 1, &[]).is_none());
|
||||
assert!(upload_rgba16f(1, 0, &[]).is_none());
|
||||
// Wrong sample counts: short, long and empty for a 2x1 frame.
|
||||
assert!(upload_rgba16f(2, 1, &[]).is_none());
|
||||
assert!(upload_rgba16f(2, 1, &[0.0; 7]).is_none());
|
||||
assert!(upload_rgba16f(2, 1, &[0.0; 9]).is_none());
|
||||
// Valid arguments without a registered window context decline
|
||||
// cleanly (the boolean is context-dependent, so only the
|
||||
// no-panic contract is asserted here).
|
||||
let _ = upload_rgba16f(2, 1, &[0.0; 8]);
|
||||
}
|
||||
|
||||
/// The half-float packer clamps to [0, 1], writes little-endian f16
|
||||
/// values and pads rows to the 256-byte copy pitch the upload needs.
|
||||
#[test]
|
||||
fn rgba16f_pack_clamps_pins_values_and_pads_rows() {
|
||||
let samples = [1.0, 0.5, 0.0, 1.0, -1.0, 2.0, 0.25, 0.5];
|
||||
let (bytes, pitch) = super::super::frames::f32_rgba_to_16f_bytes(2, 1, &samples)
|
||||
.expect("valid samples");
|
||||
assert_eq!(pitch, 256, "rows are 256-byte aligned for write_texture");
|
||||
assert_eq!(bytes.len(), 256);
|
||||
let word = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]);
|
||||
assert_eq!(word(0), half::f16::from_f32(1.0).to_bits());
|
||||
assert_eq!(word(2), half::f16::from_f32(0.5).to_bits());
|
||||
assert_eq!(word(4), half::f16::from_f32(0.0).to_bits());
|
||||
assert_eq!(word(6), half::f16::from_f32(1.0).to_bits());
|
||||
// Clamping: -1 → 0, 2 → 1.
|
||||
assert_eq!(word(8), half::f16::from_f32(0.0).to_bits());
|
||||
assert_eq!(word(10), half::f16::from_f32(1.0).to_bits());
|
||||
assert_eq!(word(12), half::f16::from_f32(0.25).to_bits());
|
||||
assert_eq!(word(14), half::f16::from_f32(0.5).to_bits());
|
||||
// Wrong sample counts decline.
|
||||
assert!(super::super::frames::f32_rgba_to_16f_bytes(2, 1, &[0.0; 7]).is_none());
|
||||
}
|
||||
|
||||
/// The display LUT covers the configured grid with finite, deterministic
|
||||
/// data (the CPU reference is the GPU path's ground truth).
|
||||
#[test]
|
||||
fn display_lut_grid_is_complete_finite_and_deterministic() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let key = display_lut_key();
|
||||
let lut = build_display_lut();
|
||||
assert_eq!(lut.edge, oak_core::lut::Lut3d::DISPLAY_EDGE);
|
||||
let n = (lut.edge as usize).pow(3);
|
||||
assert_eq!(lut.data.len(), n * 3, "three channels per grid point");
|
||||
assert!(
|
||||
lut.data.iter().all(|v| v.is_finite()),
|
||||
"the LUT contains only finite samples"
|
||||
);
|
||||
assert!(
|
||||
lut.data.iter().any(|v| *v != 0.0),
|
||||
"the LUT is not an all-zero placeholder"
|
||||
);
|
||||
if display_lut_key() == key {
|
||||
let again = build_display_lut();
|
||||
assert_eq!(lut.data, again.data, "the build is deterministic");
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache key follows the display-color generation and the project
|
||||
/// color settings.
|
||||
#[test]
|
||||
fn display_lut_key_tracks_color_settings() {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
let key = display_lut_key();
|
||||
assert!(
|
||||
key.starts_with(&crate::oakui::displaycolor::generation().to_string()),
|
||||
"the generation is the first key component: {key}"
|
||||
);
|
||||
assert!(key.contains(&format!("{:?}", oak_core::color::pipeline_working_space())));
|
||||
assert!(key.contains(&format!("{:?}", oak_core::color::pipeline_output_spec())));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,8 +49,12 @@ pub const ICON_NEXT: &str = "next";
|
||||
pub const ICON_REW: &str = "rew";
|
||||
pub const ICON_FF: &str = "ff";
|
||||
|
||||
/// The theme-dependent filesystem path of an icon (`assets/icons/{dark,light}`
|
||||
/// under the crate root). Absolute, so it works from any working directory.
|
||||
/// The theme-dependent filesystem path of an icon. The icons root is
|
||||
/// resolved at runtime — the packagers install `assets/icons` next to the
|
||||
/// binary (Windows/Linux packages), into `Contents/Resources` (macOS
|
||||
/// bundle) or `/usr/share/oak` (deb/rpm/pacman system installs) — with
|
||||
/// the dev checkout as a fallback. Absolute, so it works from any working
|
||||
/// directory.
|
||||
pub fn icon_path(name: &str, cx: &App) -> PathBuf {
|
||||
let theme = gpui_widgets::theme::current_theme(cx);
|
||||
let family = if theme.name == "Olive Dark" {
|
||||
@@ -58,10 +62,36 @@ pub fn icon_path(name: &str, cx: &App) -> PathBuf {
|
||||
} else {
|
||||
"light"
|
||||
};
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../assets/icons")
|
||||
.join(family)
|
||||
.join(format!("{name}.png"))
|
||||
icon_root().join(family).join(format!("{name}.png"))
|
||||
}
|
||||
|
||||
/// The first icons directory that exists, in precedence order (mirrors the
|
||||
/// i18n pack search in `crate::i18n`). Cached: one process resolves one
|
||||
/// directory.
|
||||
fn icon_root() -> &'static PathBuf {
|
||||
static ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
ROOT.get_or_init(|| {
|
||||
let dev = || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons");
|
||||
let mut candidates = vec![dev()];
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("icons"));
|
||||
candidates.push(dir.join("assets/icons"));
|
||||
if let Some(contents) = dir.parent() {
|
||||
candidates.push(contents.join("Resources/icons"));
|
||||
candidates.push(contents.join("Resources/assets/icons"));
|
||||
// cargo-packager deb/AppImage: <exe>/../lib/oak-editor/icons.
|
||||
candidates.push(contents.join("lib/oak-editor/icons"));
|
||||
candidates.push(contents.join("share/oak/icons"));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.push(PathBuf::from("assets/icons"));
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|dir| dir.join("dark").is_dir())
|
||||
.unwrap_or_else(dev)
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers the app's icon resolver, so widget-crate consumers (the viewer
|
||||
|
||||
+1294
-15
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@
|
||||
//! * [`real`] — [`RealEngine`](real::RealEngine) and
|
||||
//! [`RealClock`](real::RealClock), the real engine. M14 R3: it calls the
|
||||
//! oak* module crates' Rust APIs directly (oaknode / oaktimeline /
|
||||
//! oakundo / oakrender / oaktask / oakcodec / oakaudio / oakcommon /
|
||||
//! oakundo / oakrender / oaktask / oakcodec / oakaudio / oak_core /
|
||||
//! oakstorage — no `liboakengine` dylib, no C ABI) behind the same
|
||||
//! [`EngineGateway`](engine::EngineGateway) seam the mock implements.
|
||||
//! * [`graphops`] / [`effectchain`] / [`renderops`] — the app's assembly
|
||||
@@ -39,6 +39,9 @@
|
||||
//! tested).
|
||||
//! * [`timecode`] — timecode / duration / fps / resolution formatting (pure,
|
||||
//! unit tested).
|
||||
//! * [`textengine`] — the cosmic-text backend behind the text generator
|
||||
//! nodes: the measure/render hooks they call through (`install`), and the
|
||||
//! system font list feeding their font-family combo.
|
||||
|
||||
pub mod audio_thread;
|
||||
pub mod component;
|
||||
@@ -57,6 +60,7 @@ pub mod projectbrowser;
|
||||
pub mod real;
|
||||
pub mod renderops;
|
||||
pub mod scopes;
|
||||
pub mod textengine;
|
||||
pub mod timecode;
|
||||
pub mod transport;
|
||||
pub mod waveform;
|
||||
|
||||
@@ -42,7 +42,7 @@ use oak_node::track::TrackType;
|
||||
|
||||
use super::engine::{MulticamState, WizardFootage};
|
||||
use super::graphops::{
|
||||
self, lock, sequence_behavior, track_list_behavior, track_list_of, ProjectRef,
|
||||
self, lock, sequence_behavior, track_list_of, ProjectRef,
|
||||
};
|
||||
|
||||
/// Whether `id` names a sequence node (C++ `dynamic_cast<Sequence*>` /
|
||||
@@ -287,11 +287,11 @@ pub fn build_multicam_sequence(
|
||||
return Err("angle/offset count mismatch".to_string());
|
||||
}
|
||||
|
||||
let mut seq: Option<NodeId> = None;
|
||||
let mut mc: Option<NodeId> = None;
|
||||
let seq: Option<NodeId>;
|
||||
let mc: Option<NodeId>;
|
||||
{
|
||||
let mut g = lock(p);
|
||||
let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
|
||||
let (_score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
|
||||
let mut core = oak_node::node::NodeCore::new();
|
||||
core.label = name.to_string();
|
||||
let seq_id = g.graph.add_node(core, sbehavior);
|
||||
@@ -444,7 +444,7 @@ pub fn build_multicam_sequence(
|
||||
// AFV: the angle's audio clip on its own source audio track —
|
||||
// same range as the video angle (the sync offset aligns them),
|
||||
// fed from the footage's audio stream.
|
||||
if let (Some(audio_list), Some(audio_track)) = (audio_list, audio_track_ids.get(i)) {
|
||||
if let (Some(_audio_list), Some(audio_track)) = (audio_list, audio_track_ids.get(i)) {
|
||||
if entry.has_audio.unwrap_or(false) {
|
||||
let (acore, abehavior) = oak_node::block::clip_create();
|
||||
let aclip = g.graph.add_node(acore, abehavior);
|
||||
|
||||
@@ -140,7 +140,8 @@ pub struct RealNode {
|
||||
pub position: Point<Pixels>,
|
||||
/// Input ports (top to bottom).
|
||||
pub inputs: Vec<RealPort>,
|
||||
/// The single implicit output port.
|
||||
/// The implicit output port (`out`); empty on the output endpoint,
|
||||
/// which is a pure sink.
|
||||
pub outputs: Vec<RealPort>,
|
||||
/// Category accent.
|
||||
pub header_color: Option<Hsla>,
|
||||
@@ -148,6 +149,9 @@ pub struct RealNode {
|
||||
pub collapsed: bool,
|
||||
/// Enabled state.
|
||||
pub enabled: bool,
|
||||
/// The virtual endpoint role, when this card is one of the fixed
|
||||
/// endpoint pair (`None` for ordinary effect / media cards).
|
||||
pub endpoint: Option<GraphEndpoint>,
|
||||
}
|
||||
|
||||
impl NodeData for RealNode {
|
||||
@@ -280,6 +284,41 @@ fn is_displayed(type_id: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The virtual endpoint pair every project graph carries: `GraphInput`
|
||||
/// (the evaluation walk's root) and `GraphOutput` (where the frame is
|
||||
/// read). Endpoints are shown as fixed cards — the panel hides their edit
|
||||
/// entries and filters delete requests naming them, and the graph model
|
||||
/// itself refuses to remove or copy the pair.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GraphEndpoint {
|
||||
/// `GraphInput`: data enters through `feed_in`, leaves through `tex_out`.
|
||||
Input,
|
||||
/// `GraphOutput`: every branch converges on `tex_in`; a pure sink.
|
||||
Output,
|
||||
}
|
||||
|
||||
/// Whether `type_id` names one of the virtual endpoints, and which.
|
||||
pub fn endpoint_of_type_id(type_id: &str) -> Option<GraphEndpoint> {
|
||||
use oak_node::nodes::graphendpoints::{GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_TYPE_ID};
|
||||
if type_id == GRAPH_INPUT_TYPE_ID {
|
||||
Some(GraphEndpoint::Input)
|
||||
} else if type_id == GRAPH_OUTPUT_TYPE_ID {
|
||||
Some(GraphEndpoint::Output)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed header accent of an endpoint card: the pair reads as
|
||||
/// structural furniture, not as one more effect (deliberately outside the
|
||||
/// [`node_color`] palette — and the demo graph's hand-picked hues).
|
||||
fn endpoint_color(endpoint: GraphEndpoint) -> Hsla {
|
||||
match endpoint {
|
||||
GraphEndpoint::Input => hsla(0.40, 0.55, 0.35, 1.0),
|
||||
GraphEndpoint::Output => hsla(0.85, 0.65, 0.45, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A displayed node: its domain id plus its type id.
|
||||
struct TypedNode {
|
||||
/// The node's domain id.
|
||||
@@ -429,6 +468,7 @@ fn build_graph_impl(
|
||||
label
|
||||
}
|
||||
};
|
||||
let endpoint = endpoint_of_type_id(&typed.type_id);
|
||||
|
||||
// Inputs: only stream inputs (texture / samples) become ports — an
|
||||
// OFX plugin declares every PARAMETER as an input too, and those
|
||||
@@ -476,16 +516,22 @@ fn build_graph_impl(
|
||||
});
|
||||
}
|
||||
|
||||
// The single implicit output.
|
||||
let out_count = g.output_connections(typed.id).len();
|
||||
let outputs = vec![RealPort {
|
||||
id: port_id(ident, PortKind::Output, 0),
|
||||
kind: PortKind::Output,
|
||||
input_id: SharedString::new_static(""),
|
||||
label: SharedString::new_static("out"),
|
||||
data_type: out_type(),
|
||||
connected: out_count > 0,
|
||||
}];
|
||||
// The implicit output port. The output endpoint is a sink — nothing
|
||||
// is wired out of it, so it gets no port; the input endpoint keeps
|
||||
// its `tex_out` (the walk's root output).
|
||||
let outputs = if endpoint == Some(GraphEndpoint::Output) {
|
||||
Vec::new()
|
||||
} else {
|
||||
let out_count = g.output_connections(typed.id).len();
|
||||
vec![RealPort {
|
||||
id: port_id(ident, PortKind::Output, 0),
|
||||
kind: PortKind::Output,
|
||||
input_id: SharedString::new_static(""),
|
||||
label: SharedString::new_static("out"),
|
||||
data_type: out_type(),
|
||||
connected: out_count > 0,
|
||||
}]
|
||||
};
|
||||
|
||||
let position = context_position(g, seq, typed.id);
|
||||
built.push((
|
||||
@@ -496,9 +542,13 @@ fn build_graph_impl(
|
||||
position,
|
||||
inputs,
|
||||
outputs,
|
||||
header_color: Some(node_color(ident)),
|
||||
header_color: Some(match endpoint {
|
||||
Some(endpoint) => endpoint_color(endpoint),
|
||||
None => node_color(ident),
|
||||
}),
|
||||
collapsed: false,
|
||||
enabled: true,
|
||||
endpoint,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -817,6 +867,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::effectchain;
|
||||
use crate::oakui::graphops;
|
||||
use gpui::node_graph::NodeGraphEvent;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_node::value::ValueType;
|
||||
|
||||
/// Serializes with the other app test modules (the process-global undo
|
||||
/// stack).
|
||||
@@ -824,6 +877,552 @@ mod tests {
|
||||
crate::oakui::graphops::test_lock()
|
||||
}
|
||||
|
||||
/// Places an already-created block node onto the sequence's first video
|
||||
/// track through the same undoable command the timeline drag uses (so
|
||||
/// the block really gets its outgoing structural edges).
|
||||
fn place_on_first_video_track(p: &ProjectRef, seq: DomainNodeId, block: DomainNodeId) {
|
||||
let (list, index, in_r) = {
|
||||
let g = graphops::lock(p);
|
||||
let track = graphops::track_ids(&g.graph, seq, TrackType::Video)
|
||||
.first()
|
||||
.copied()
|
||||
.expect("the sequence has a video track");
|
||||
let list = graphops::track_behavior(&g.graph, track)
|
||||
.and_then(|t| t.track_list)
|
||||
.expect("the track has a list");
|
||||
let index = graphops::track_list_behavior(&g.graph, list)
|
||||
.and_then(|l| l.tracks.iter().position(|&t| t == track))
|
||||
.expect("the track is in its list") as i32;
|
||||
let tb = graphops::sequence_time_base(&g.graph, seq).expect("the sequence timebase");
|
||||
(list, index, graphops::ts_to_rational(0, tb))
|
||||
};
|
||||
graphops::push_command(
|
||||
oak_timeline::undopointer::TrackPlaceBlockCommand::new(
|
||||
graphops::node_ref(p, list),
|
||||
index,
|
||||
graphops::node_ref(p, block),
|
||||
in_r,
|
||||
)
|
||||
.to_command(),
|
||||
"Place Clip",
|
||||
)
|
||||
.expect("place the block");
|
||||
}
|
||||
|
||||
/// The `NodeData` / `PortData` / `EdgeData` accessor impls expose their
|
||||
/// fields, and the port-id packing round-trips (including the output
|
||||
/// tag).
|
||||
#[test]
|
||||
fn data_traits_and_port_packing_expose_the_fields() {
|
||||
let node = RealNode {
|
||||
id: NodeId(7),
|
||||
title: "Card".into(),
|
||||
position: point(px(1.0), px(2.0)),
|
||||
inputs: Vec::new(),
|
||||
outputs: Vec::new(),
|
||||
header_color: Some(hsla(0.5, 0.5, 0.5, 1.0)),
|
||||
collapsed: true,
|
||||
enabled: false,
|
||||
endpoint: Some(GraphEndpoint::Input),
|
||||
};
|
||||
assert_eq!(NodeData::id(&node), NodeId(7));
|
||||
assert_eq!(NodeData::title(&node).as_ref(), "Card");
|
||||
assert_eq!(NodeData::position(&node), point(px(1.0), px(2.0)));
|
||||
assert!(NodeData::inputs(&node).is_empty());
|
||||
assert!(NodeData::outputs(&node).is_empty());
|
||||
assert_eq!(NodeData::header_color(&node), Some(hsla(0.5, 0.5, 0.5, 1.0)));
|
||||
assert!(NodeData::is_collapsed(&node));
|
||||
assert!(!NodeData::is_enabled(&node));
|
||||
|
||||
let port = RealPort {
|
||||
id: PortId(9),
|
||||
kind: PortKind::Input,
|
||||
input_id: "tex_in".into(),
|
||||
label: "Texture".into(),
|
||||
data_type: video_type(),
|
||||
connected: true,
|
||||
};
|
||||
assert_eq!(PortData::id(&port), PortId(9));
|
||||
assert_eq!(PortData::kind(&port), PortKind::Input);
|
||||
assert_eq!(PortData::label(&port).as_ref(), "Texture");
|
||||
assert_eq!(PortData::data_type(&port).name.as_ref(), "video");
|
||||
assert!(PortData::is_connected(&port));
|
||||
|
||||
let edge = RealEdge {
|
||||
id: EdgeId(3),
|
||||
from_node: NodeId(1),
|
||||
from_port: PortId(5),
|
||||
to_node: NodeId(2),
|
||||
to_port: PortId(11),
|
||||
};
|
||||
assert_eq!(EdgeData::id(&edge), EdgeId(3));
|
||||
assert_eq!(EdgeData::from_node(&edge), NodeId(1));
|
||||
assert_eq!(EdgeData::from_port(&edge), PortId(5));
|
||||
assert_eq!(EdgeData::to_node(&edge), NodeId(2));
|
||||
assert_eq!(EdgeData::to_port(&edge), PortId(11));
|
||||
|
||||
// Output ports pack `(ident << 4) | 1` (index 0), inputs keep their
|
||||
// declaration index.
|
||||
assert_eq!(
|
||||
unpack_port(port_id(7, PortKind::Output, 3)),
|
||||
(7, PortKind::Output, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
unpack_port(port_id(7, PortKind::Input, 3)),
|
||||
(7, PortKind::Input, 3)
|
||||
);
|
||||
assert!(is_output_wire(output_wire_id(7)));
|
||||
assert!(!is_output_wire(real_edge_id(1, 2, "tex_in")));
|
||||
|
||||
// Endpoint type ids round-trip; anything else is no endpoint.
|
||||
use oak_node::nodes::graphendpoints::{GRAPH_INPUT_TYPE_ID, GRAPH_OUTPUT_TYPE_ID};
|
||||
assert_eq!(
|
||||
endpoint_of_type_id(GRAPH_INPUT_TYPE_ID),
|
||||
Some(GraphEndpoint::Input)
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_of_type_id(GRAPH_OUTPUT_TYPE_ID),
|
||||
Some(GraphEndpoint::Output)
|
||||
);
|
||||
assert_eq!(endpoint_of_type_id(TYPE_ID_CLIP_BLOCK), None);
|
||||
assert_eq!(endpoint_color(GraphEndpoint::Output), hsla(0.85, 0.65, 0.45, 1.0));
|
||||
assert_eq!(node_color(9), node_color(1));
|
||||
assert!(role_of(TYPE_ID_FOOTAGE, false) == 0);
|
||||
assert!(role_of(TYPE_ID_CLIP_BLOCK, false) == 2);
|
||||
assert!(role_of("x", true) == 3);
|
||||
}
|
||||
|
||||
/// Builder rejection and placement arms: invalid sequence/clip ids
|
||||
/// yield empty graphs, persisted context positions win over the role
|
||||
/// grid, an empty sequence label falls back to the behavior name and
|
||||
/// the per-clip chain skips edges whose target is outside the view.
|
||||
#[test]
|
||||
fn build_graph_handles_placement_and_invalid_ids() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, _effect, _footage) = project_with_chained_clip();
|
||||
|
||||
// Invalid sequence / clip identities: id_of accepts any non-sentinel
|
||||
// identity, the graph validity check rejects it.
|
||||
let bogus = graphops::id_of(12_345).expect("a syntactically valid id");
|
||||
assert!(build_graph(&project, bogus).0.is_empty());
|
||||
assert!(build_graph_for_clip(&project, seq, 12_345).0.is_empty());
|
||||
// The invalid sentinel has no domain id at all.
|
||||
assert!(build_graph_for_clip(&project, seq, u64::from(u32::MAX)).0.is_empty());
|
||||
|
||||
// Persist a context position: the builder must use it (not the
|
||||
// fallback grid), and the fallback skips placed nodes.
|
||||
graphops::push_command(
|
||||
graphops::set_context_position_command(&project, clip, seq, 120.0, 60.0)
|
||||
.expect("the position command"),
|
||||
"Set Position",
|
||||
)
|
||||
.expect("apply the position");
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let placed = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(clip.identity()))
|
||||
.expect("the clip card");
|
||||
assert_eq!(placed.position, point(px(120.0), px(60.0)));
|
||||
let unplaced = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(_footage.identity()))
|
||||
.expect("the footage card");
|
||||
assert_ne!(unplaced.position, point(px(0.0), px(0.0)));
|
||||
|
||||
// An empty sequence label falls back to the behavior name.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
g.graph.get_mut(seq).expect("the sequence node").core.label.clear();
|
||||
}
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let seq_card = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(seq.identity()))
|
||||
.expect("the sequence card");
|
||||
assert!(!seq_card.title.is_empty());
|
||||
|
||||
// A placed clip's outgoing wire targets a track that is not part of
|
||||
// the per-clip view, so the edge is skipped.
|
||||
let placed_clip = {
|
||||
let mut g = graphops::lock(&project);
|
||||
let (core, behavior) = oak_node::block::clip_create();
|
||||
g.graph.add_node(core, behavior)
|
||||
};
|
||||
place_on_first_video_track(&project, seq, placed_clip);
|
||||
let (chain, chain_edges) = build_graph_for_clip(&project, seq, placed_clip.identity());
|
||||
assert!(!chain.is_empty(), "the placed clip card exists");
|
||||
assert!(
|
||||
chain_edges.iter().all(|e| e.to_node != NodeId(seq.identity())),
|
||||
"the structural clip -> track edge does not leak into the clip view"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Stream-input port filtering: an empty display name falls back to the
|
||||
/// input id, an empty input id drops the port entirely.
|
||||
#[test]
|
||||
fn build_graph_filters_stream_input_ports() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, _, _) = project_with_chained_clip();
|
||||
|
||||
// The clip's texture input (a stream input that is not hidden).
|
||||
let input_index = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip node")
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| {
|
||||
matches!(
|
||||
i.value_type,
|
||||
ValueType::Texture | ValueType::Samples
|
||||
) && i.flags & oak_node::input::flags::HIDDEN == 0
|
||||
})
|
||||
.expect("the clip has a stream input")
|
||||
};
|
||||
|
||||
// Empty display name: the label falls back to the input id.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
let input = &mut g.graph.get_mut(clip).expect("clip").core.inputs[input_index];
|
||||
input.display_name.clear();
|
||||
input.id = "tex_in".to_string();
|
||||
}
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let card = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(clip.identity()))
|
||||
.expect("the clip card");
|
||||
let port = card
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|p| p.input_id.as_ref() == "tex_in")
|
||||
.expect("the tex_in port");
|
||||
assert_eq!(port.label.as_ref(), "tex_in");
|
||||
|
||||
// Empty input id: the port is dropped.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
g.graph.get_mut(clip).expect("clip").core.inputs[input_index]
|
||||
.id
|
||||
.clear();
|
||||
}
|
||||
let (nodes, _) = build_graph(&project, seq);
|
||||
let card = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == NodeId(clip.identity()))
|
||||
.expect("the clip card");
|
||||
assert!(
|
||||
card.inputs.iter().all(|p| !p.input_id.is_empty()),
|
||||
"an input with an empty id is not exposed"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The connection rules reject wrong directions, same-node pairs,
|
||||
/// unknown nodes, missing/empty target inputs and already-connected
|
||||
/// inputs, and accept a free stream input.
|
||||
#[test]
|
||||
fn can_connect_rejects_the_invalid_pairs() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, _seq, _clip, effect, footage) = project_with_chained_clip();
|
||||
|
||||
let (effect_input_id, effect_input_index) = {
|
||||
let g = graphops::lock(&project);
|
||||
let entry = g.graph.get(effect).expect("the effect node");
|
||||
let index = entry
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| i.id == entry.core.effect_input)
|
||||
.expect("the effect input");
|
||||
(entry.core.effect_input.clone(), index)
|
||||
};
|
||||
let effect_input = port_id(effect.identity(), PortKind::Input, effect_input_index as u32);
|
||||
let footage_out = port_id(footage.identity(), PortKind::Output, 0);
|
||||
let clip_out = port_id(_clip.identity(), PortKind::Output, 0);
|
||||
let clip_input = port_id(_clip.identity(), PortKind::Input, 0);
|
||||
|
||||
// Wrong direction and same-node pairs.
|
||||
assert!(!can_connect(&project, clip_input, effect_input));
|
||||
assert!(!can_connect(&project, clip_out, clip_input));
|
||||
// Unknown target node (a syntactically valid, non-arena identity).
|
||||
assert!(!can_connect(
|
||||
&project,
|
||||
clip_out,
|
||||
port_id(u64::from(u32::MAX) - 1, PortKind::Input, 0)
|
||||
));
|
||||
// Declaration index past the end of the target's inputs (the clip
|
||||
// declares far fewer than eight inputs).
|
||||
assert!(!can_connect(
|
||||
&project,
|
||||
clip_out,
|
||||
port_id(_clip.identity(), PortKind::Input, 7)
|
||||
));
|
||||
// The footage -> effect wire already exists: the target is busy.
|
||||
assert!(!can_connect(&project, footage_out, effect_input));
|
||||
assert!(!effect_input_id.is_empty());
|
||||
// A free clip -> sequence input is accepted (the sequence's tex_in).
|
||||
let seq_input = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(_seq)
|
||||
.expect("the sequence node")
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| {
|
||||
matches!(i.value_type, ValueType::Texture | ValueType::Samples)
|
||||
&& !i.id.is_empty()
|
||||
})
|
||||
.map(|index| port_id(_seq.identity(), PortKind::Input, index as u32))
|
||||
.expect("the sequence has a stream input")
|
||||
};
|
||||
assert!(can_connect(&project, clip_out, seq_input));
|
||||
|
||||
// Empty input id: rejected.
|
||||
{
|
||||
let mut g = graphops::lock(&project);
|
||||
g.graph.get_mut(effect).expect("effect").core.inputs
|
||||
[effect_input_index]
|
||||
.id
|
||||
.clear();
|
||||
}
|
||||
assert!(!can_connect(&project, clip_out, effect_input));
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// `apply_edit` covers every event: connection rejection/success,
|
||||
/// synthesized-wire disconnection, real-edge removal, node moves (both
|
||||
/// the fallback base and the persisted position) and node deletion
|
||||
/// (including the protected sequence and the no-op preview event).
|
||||
#[test]
|
||||
fn apply_edit_covers_connect_move_and_delete() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, effect, _footage) = project_with_chained_clip();
|
||||
|
||||
// Wrong endpoint kinds.
|
||||
let err = apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(clip.identity(), PortKind::Input, 0),
|
||||
to: port_id(seq.identity(), PortKind::Input, 0),
|
||||
},
|
||||
)
|
||||
.expect_err("input -> input is invalid");
|
||||
assert!(err.contains("output"));
|
||||
|
||||
// Unknown source node.
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(123_456, PortKind::Output, 0),
|
||||
to: port_id(seq.identity(), PortKind::Input, 0),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// Target input index missing (the clip declares fewer than eight
|
||||
// inputs).
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(seq.identity(), PortKind::Output, 0),
|
||||
to: port_id(clip.identity(), PortKind::Input, 7),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// A real connection: the clip output into the sequence's tex_in.
|
||||
let seq_input = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(seq)
|
||||
.expect("the sequence node")
|
||||
.core
|
||||
.inputs
|
||||
.iter()
|
||||
.position(|i| {
|
||||
matches!(i.value_type, ValueType::Texture | ValueType::Samples)
|
||||
&& !i.id.is_empty()
|
||||
})
|
||||
.map(|index| port_id(seq.identity(), PortKind::Input, index as u32))
|
||||
.expect("the sequence has a stream input")
|
||||
};
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::ConnectionRequested {
|
||||
from: port_id(clip.identity(), PortKind::Output, 0),
|
||||
to: seq_input,
|
||||
},
|
||||
)
|
||||
.expect("the connection applies");
|
||||
|
||||
// Disconnecting the synthesized wire is a no-op.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DisconnectionRequested {
|
||||
edge: output_wire_id(clip.identity()),
|
||||
},
|
||||
)
|
||||
.expect("the structural wire is ignored");
|
||||
// An unknown edge id errors.
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DisconnectionRequested {
|
||||
edge: EdgeId(999_999),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
// The real edge disconnects by its stable id.
|
||||
let edge_id = real_edge_id(clip.identity(), seq.identity(), "tex_in");
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DisconnectionRequested { edge: edge_id },
|
||||
)
|
||||
.expect("the real edge disconnects");
|
||||
|
||||
// A move of an unplaced node: the fallback grid base plus delta.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(clip.identity())],
|
||||
delta: point(px(10.0), px(20.0)),
|
||||
},
|
||||
)
|
||||
.expect("the first move applies");
|
||||
let moved = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip")
|
||||
.core
|
||||
.context_positions
|
||||
.iter()
|
||||
.find(|(c, _, _)| *c == seq)
|
||||
.map(|(_, pos, _)| *pos)
|
||||
.expect("the position was persisted")
|
||||
};
|
||||
assert_eq!(moved, (570.0, 60.0), "fallback column 2, row 0, plus delta");
|
||||
|
||||
// A move list with an unknown node applies the earlier entries and
|
||||
// still reports the error; the next move starts from the persisted
|
||||
// position (not the origin).
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(clip.identity()), NodeId(123_456)],
|
||||
delta: point(px(5.0), px(5.0)),
|
||||
},
|
||||
)
|
||||
.expect_err("the unknown node rejects the whole move");
|
||||
let moved = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip")
|
||||
.core
|
||||
.context_positions
|
||||
.iter()
|
||||
.find(|(c, _, _)| *c == seq)
|
||||
.map(|(_, pos, _)| *pos)
|
||||
.expect("the position")
|
||||
};
|
||||
assert_eq!(moved, (575.0, 65.0), "the valid entry still moved");
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMoveRequested {
|
||||
nodes: vec![NodeId(clip.identity())],
|
||||
delta: point(px(5.0), px(5.0)),
|
||||
},
|
||||
)
|
||||
.expect("the persisted position is the next base");
|
||||
let moved = {
|
||||
let g = graphops::lock(&project);
|
||||
g.graph
|
||||
.get(clip)
|
||||
.expect("the clip")
|
||||
.core
|
||||
.context_positions
|
||||
.iter()
|
||||
.find(|(c, _, _)| *c == seq)
|
||||
.map(|(_, pos, _)| *pos)
|
||||
.expect("the position")
|
||||
};
|
||||
assert_eq!(moved, (580.0, 70.0));
|
||||
|
||||
// The preview event is a no-op.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::NodeMovePreview {
|
||||
nodes: vec![NodeId(clip.identity())],
|
||||
delta: point(px(1.0), px(1.0)),
|
||||
},
|
||||
)
|
||||
.expect("the preview is inert");
|
||||
|
||||
// Delete: the sequence is protected, a real node is removed and an
|
||||
// unknown node errors.
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(seq.identity())],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("the sequence cannot be deleted");
|
||||
{
|
||||
let g = graphops::lock(&project);
|
||||
assert!(g.graph.is_valid(seq), "the sequence survives");
|
||||
}
|
||||
apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(effect.identity())],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("the effect is removed");
|
||||
{
|
||||
let g = graphops::lock(&project);
|
||||
assert!(!g.graph.is_valid(effect), "the effect is gone");
|
||||
}
|
||||
assert!(apply_edit(
|
||||
&project,
|
||||
seq,
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(123_456)],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
)
|
||||
.is_err());
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
|
||||
/// A project with a sequence and a clip whose chain runs footage → one
|
||||
/// effect → clip: `(project, seq, clip, effect, footage)`.
|
||||
fn project_with_chained_clip() -> (
|
||||
@@ -923,4 +1522,50 @@ mod tests {
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Every project graph carries the virtual endpoint pair: the full view
|
||||
/// shows both as marked cards (the output endpoint as a pure sink), and
|
||||
/// a clip's context chain shows neither.
|
||||
#[test]
|
||||
fn endpoints_build_as_marked_cards() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let (project, seq, clip, _, _) = project_with_chained_clip();
|
||||
|
||||
let (nodes, _edges) = build_graph(&project, seq);
|
||||
assert_eq!(
|
||||
nodes.iter().filter(|n| n.endpoint.is_some()).count(),
|
||||
2,
|
||||
"exactly one endpoint pair is displayed"
|
||||
);
|
||||
let input = nodes
|
||||
.iter()
|
||||
.find(|n| n.endpoint == Some(GraphEndpoint::Input))
|
||||
.expect("the input endpoint card");
|
||||
let output = nodes
|
||||
.iter()
|
||||
.find(|n| n.endpoint == Some(GraphEndpoint::Output))
|
||||
.expect("the output endpoint card");
|
||||
assert!(
|
||||
!input.outputs.is_empty(),
|
||||
"the input endpoint keeps its `tex_out`"
|
||||
);
|
||||
assert!(
|
||||
output.outputs.is_empty(),
|
||||
"the output endpoint is a sink and has no output port"
|
||||
);
|
||||
assert!(
|
||||
!input.title.is_empty() && !output.title.is_empty(),
|
||||
"endpoint cards carry their behavior name as the title"
|
||||
);
|
||||
|
||||
// The per-clip chain view contains no endpoint cards.
|
||||
let (chain, _) = build_graph_for_clip(&project, seq, clip.identity());
|
||||
assert!(
|
||||
chain.iter().all(|n| n.endpoint.is_none()),
|
||||
"a clip's context chain contains no endpoints"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+335
-36
@@ -309,7 +309,9 @@ fn active_interact_slot() -> &'static Mutex<Option<ActiveInteract>> {
|
||||
pub fn sync_active_interact(instance: Option<u64>) {
|
||||
// Unchanged target: nothing to do.
|
||||
{
|
||||
let mut slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = active_interact_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|active| Some(active.instance) == instance)
|
||||
@@ -340,7 +342,9 @@ pub fn sync_active_interact(instance: Option<u64>) {
|
||||
// Describe → CreateInstance before any draw/pen/key action).
|
||||
interact.describe();
|
||||
interact.create_instance();
|
||||
let mut slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = active_interact_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*slot = Some(ActiveInteract {
|
||||
instance: id,
|
||||
interact,
|
||||
@@ -357,14 +361,19 @@ pub fn sync_active_interact(instance: Option<u64>) {
|
||||
/// interact). The viewport reports the last drawn size and updates as the
|
||||
/// viewer's frame size changes.
|
||||
pub fn active_interact() -> Option<(u64, Arc<Interact>, InteractViewport)> {
|
||||
let slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
slot.as_ref().map(|a| (a.instance, a.interact.clone(), a.viewport))
|
||||
let slot = active_interact_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
slot.as_ref()
|
||||
.map(|a| (a.instance, a.interact.clone(), a.viewport))
|
||||
}
|
||||
|
||||
/// Records the viewport the interact was last drawn at (keeps
|
||||
/// [`active_interact`]'s viewport current).
|
||||
fn note_interact_viewport(instance: u64, viewport: InteractViewport) {
|
||||
let mut slot = active_interact_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = active_interact_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(active) = slot.as_mut() {
|
||||
if active.instance == instance {
|
||||
active.viewport = viewport;
|
||||
@@ -389,7 +398,7 @@ fn bgra_image_to_f32_rgba(img: &RenderImage) -> Option<(u32, u32, Vec<f32>)> {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(expected);
|
||||
for px in bytes[..expected].chunks_exact(4) {
|
||||
for px in bytes[..expected].as_chunks::<4>().0 {
|
||||
out.push(px[2] as f32 / 255.0); // R
|
||||
out.push(px[1] as f32 / 255.0); // G
|
||||
out.push(px[0] as f32 / 255.0); // B
|
||||
@@ -401,7 +410,9 @@ fn bgra_image_to_f32_rgba(img: &RenderImage) -> Option<(u32, u32, Vec<f32>)> {
|
||||
/// Reads an `oakplugin` F32 RGBA image into a tightly packed `Vec<f32>`.
|
||||
fn read_image_f32(img: &oak_plugin::image::Image) -> Vec<f32> {
|
||||
img.pixels()
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f32::from_ne_bytes(c[0..4].try_into().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
@@ -423,7 +434,7 @@ fn read_image_f32(img: &oak_plugin::image::Image) -> Vec<f32> {
|
||||
/// straight-alpha "over". `base` is the displayed frame (alpha 1.0), so
|
||||
/// the result alpha stays 1.0. Returns `None` on a length mismatch.
|
||||
pub fn composite_overlay(overlay: &[f32], base: &[f32]) -> Option<Vec<f32>> {
|
||||
if overlay.len() != base.len() || overlay.len() % 4 != 0 {
|
||||
if overlay.len() != base.len() || !overlay.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0.0f32; overlay.len()];
|
||||
@@ -519,7 +530,9 @@ pub fn draw_interact_composite(
|
||||
}
|
||||
let merged = composite_overlay(&read_image_f32(&overlay), &base_f32)?;
|
||||
note_interact_viewport(instance, *viewport);
|
||||
Some(Arc::new(super::frames::f32_rgba_to_bgra_image(bw, bh, &merged)))
|
||||
Some(Arc::new(super::frames::f32_rgba_to_bgra_image(
|
||||
bw, bh, &merged,
|
||||
)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -695,8 +708,14 @@ mod tests {
|
||||
let p = viewport_pixel_to_pen(point(320.0, 10.0), area, frame);
|
||||
assert_eq!(p, None);
|
||||
// Zero-size inputs.
|
||||
assert_eq!(viewport_pixel_to_pen(point(0.0, 0.0), size(0.0, 0.0), frame), None);
|
||||
assert_eq!(viewport_pixel_to_pen(point(0.0, 0.0), area, size(0.0, 0.0)), None);
|
||||
assert_eq!(
|
||||
viewport_pixel_to_pen(point(0.0, 0.0), size(0.0, 0.0), frame),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
viewport_pixel_to_pen(point(0.0, 0.0), area, size(0.0, 0.0)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Key mapping: the common keys land on the ofxKeySyms values; printable
|
||||
@@ -710,22 +729,49 @@ mod tests {
|
||||
assert_eq!(key_symbol(&ks("z")), (ofx_key::KEY_Z, "z".to_string()));
|
||||
assert_eq!(key_symbol(&ks("1")), (b'1' as i32, "1".to_string()));
|
||||
// Named keys: symbol only, empty string.
|
||||
assert_eq!(key_symbol(&ks("space")), (ofx_key::KEY_SPACE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("enter")), (ofx_key::KEY_RETURN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("escape")), (ofx_key::KEY_ESCAPE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("backspace")), (ofx_key::KEY_BACKSPACE, String::new()));
|
||||
assert_eq!(key_symbol(&ks("delete")), (ofx_key::KEY_DELETE, String::new()));
|
||||
assert_eq!(
|
||||
key_symbol(&ks("space")),
|
||||
(ofx_key::KEY_SPACE, String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
key_symbol(&ks("enter")),
|
||||
(ofx_key::KEY_RETURN, String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
key_symbol(&ks("escape")),
|
||||
(ofx_key::KEY_ESCAPE, String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
key_symbol(&ks("backspace")),
|
||||
(ofx_key::KEY_BACKSPACE, String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
key_symbol(&ks("delete")),
|
||||
(ofx_key::KEY_DELETE, String::new())
|
||||
);
|
||||
assert_eq!(key_symbol(&ks("left")), (ofx_key::KEY_LEFT, String::new()));
|
||||
assert_eq!(key_symbol(&ks("right")), (ofx_key::KEY_RIGHT, String::new()));
|
||||
assert_eq!(
|
||||
key_symbol(&ks("right")),
|
||||
(ofx_key::KEY_RIGHT, String::new())
|
||||
);
|
||||
assert_eq!(key_symbol(&ks("up")), (ofx_key::KEY_UP, String::new()));
|
||||
assert_eq!(key_symbol(&ks("down")), (ofx_key::KEY_DOWN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("home")), (ofx_key::KEY_HOME, String::new()));
|
||||
assert_eq!(key_symbol(&ks("end")), (ofx_key::KEY_END, String::new()));
|
||||
assert_eq!(key_symbol(&ks("pageup")), (ofx_key::KEY_PAGE_UP, String::new()));
|
||||
assert_eq!(key_symbol(&ks("pagedown")), (ofx_key::KEY_PAGE_DOWN, String::new()));
|
||||
assert_eq!(
|
||||
key_symbol(&ks("pageup")),
|
||||
(ofx_key::KEY_PAGE_UP, String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
key_symbol(&ks("pagedown")),
|
||||
(ofx_key::KEY_PAGE_DOWN, String::new())
|
||||
);
|
||||
assert_eq!(key_symbol(&ks("tab")), (ofx_key::KEY_TAB, String::new()));
|
||||
assert_eq!(key_symbol(&ks("f1")), (ofx_key::KEY_F1, String::new()));
|
||||
assert_eq!(key_symbol(&ks("f12")), (ofx_key::KEY_F1 + 11, String::new()));
|
||||
assert_eq!(
|
||||
key_symbol(&ks("f12")),
|
||||
(ofx_key::KEY_F1 + 11, String::new())
|
||||
);
|
||||
// Unknown multi-character keys.
|
||||
let (sym, s) = key_symbol(&ks("insert"));
|
||||
assert_eq!(sym, ofx_key::KEY_UNKNOWN);
|
||||
@@ -745,7 +791,11 @@ mod tests {
|
||||
let overlay = [1.0, 0.0, 0.0, 0.5];
|
||||
let out = composite_overlay(&overlay, &base).unwrap();
|
||||
for (i, expected) in [(0, 0.5), (1, 0.0), (2, 0.5), (3, 1.0)] {
|
||||
assert!((out[i] - expected).abs() < 1e-6, "channel {i}: {} != {expected}", out[i]);
|
||||
assert!(
|
||||
(out[i] - expected).abs() < 1e-6,
|
||||
"channel {i}: {} != {expected}",
|
||||
out[i]
|
||||
);
|
||||
}
|
||||
// Transparent overlay → base unchanged.
|
||||
let overlay = [0.0, 0.0, 0.0, 0.0];
|
||||
@@ -763,8 +813,21 @@ mod tests {
|
||||
let img = super::super::frames::f32_rgba_to_bgra_image(w, h, &samples);
|
||||
let (got_w, got_h, rgba) = bgra_image_to_f32_rgba(&img).unwrap();
|
||||
assert_eq!((got_w, got_h), (w, h));
|
||||
for (i, expected) in [(0, 1.0), (1, 0.0), (2, 0.5), (3, 1.0), (4, 0.25), (5, 0.5), (6, 0.75), (7, 1.0)] {
|
||||
assert!((rgba[i] - expected).abs() < 0.01, "channel {i}: {} != {expected}", rgba[i]);
|
||||
for (i, expected) in [
|
||||
(0, 1.0),
|
||||
(1, 0.0),
|
||||
(2, 0.5),
|
||||
(3, 1.0),
|
||||
(4, 0.25),
|
||||
(5, 0.5),
|
||||
(6, 0.75),
|
||||
(7, 1.0),
|
||||
] {
|
||||
assert!(
|
||||
(rgba[i] - expected).abs() < 0.01,
|
||||
"channel {i}: {} != {expected}",
|
||||
rgba[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +843,28 @@ mod tests {
|
||||
/// tests (the plugin cache is a process singleton with no lock).
|
||||
static HOST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Saves the marker env var and restores it (including the was-unset
|
||||
/// case) on drop, so a panicking assertion cannot leak the redirect into
|
||||
/// the next serialized host test.
|
||||
struct MarkerEnvGuard(Option<std::ffi::OsString>);
|
||||
|
||||
impl MarkerEnvGuard {
|
||||
fn set(path: &std::path::Path) -> MarkerEnvGuard {
|
||||
let previous = std::env::var_os(MARKER_ENV);
|
||||
unsafe { std::env::set_var(MARKER_ENV, path) };
|
||||
MarkerEnvGuard(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MarkerEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => unsafe { std::env::set_var(MARKER_ENV, value) },
|
||||
None => unsafe { std::env::remove_var(MARKER_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal test plugin's scan directory (the bundle is assembled
|
||||
/// from the dylib the app build script compiles into OUT_DIR), or `None`
|
||||
/// when the plugin is unavailable (the test skips).
|
||||
@@ -823,7 +908,11 @@ mod tests {
|
||||
println!("SKIP: minimal test plugin not built");
|
||||
return false;
|
||||
};
|
||||
if oak_plugin::host::Host::global().cache.scan_path(&dir).is_err() {
|
||||
if oak_plugin::host::Host::global()
|
||||
.cache
|
||||
.scan_path(&dir)
|
||||
.is_err()
|
||||
{
|
||||
println!("SKIP: test plugin scan failed");
|
||||
return false;
|
||||
}
|
||||
@@ -858,7 +947,7 @@ mod tests {
|
||||
}
|
||||
let marker = marker_path("e2e");
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
unsafe { std::env::set_var(MARKER_ENV, &marker) };
|
||||
let _marker_env = MarkerEnvGuard::set(&marker);
|
||||
|
||||
// (1) app-layer creation: `sync_active_interact` on the plugin
|
||||
// instance handle creates the interact (new_interact → describe →
|
||||
@@ -913,15 +1002,12 @@ mod tests {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let lines = loop {
|
||||
let lines = read_marker(&marker);
|
||||
if lines.iter().any(|l| l == "destroy")
|
||||
|| std::time::Instant::now() >= deadline
|
||||
{
|
||||
if lines.iter().any(|l| l == "destroy") || std::time::Instant::now() >= deadline {
|
||||
break lines;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
|
||||
unsafe { std::env::remove_var(MARKER_ENV) };
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
|
||||
// Lifecycle reached the plugin, in order.
|
||||
@@ -939,15 +1025,21 @@ mod tests {
|
||||
// The forwarded events with real arguments (C %g drops trailing
|
||||
// zeros).
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "pen_motion vp=20,20 canon=20,20 pressure=1"),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l == "pen_motion vp=20,20 canon=20,20 pressure=1"),
|
||||
"pen_motion not recorded with pen-down state: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "pen_down vp=20,20 canon=20,20 pressure=1"),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l == "pen_down vp=20,20 canon=20,20 pressure=1"),
|
||||
"pen_down not recorded: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l == "pen_up vp=20,20 canon=20,20 pressure=0"),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l == "pen_up vp=20,20 canon=20,20 pressure=0"),
|
||||
"pen_up not recorded: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -977,7 +1069,7 @@ mod tests {
|
||||
}
|
||||
let marker = marker_path("draw");
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
unsafe { std::env::set_var(MARKER_ENV, &marker) };
|
||||
let _marker_env = MarkerEnvGuard::set(&marker);
|
||||
|
||||
let inst = oak_plugin::host::Host::global()
|
||||
.create_instance(INTERACT_PLUGIN_ID, None)
|
||||
@@ -989,10 +1081,14 @@ mod tests {
|
||||
// A 64×64 opaque blue base frame.
|
||||
let (w, h) = (64u32, 64u32);
|
||||
let mut base_samples = vec![0.0f32; (w * h * 4) as usize];
|
||||
for px in base_samples.chunks_exact_mut(4) {
|
||||
for px in base_samples.chunks_mut(4) {
|
||||
px.copy_from_slice(&[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
let base = Arc::new(super::super::frames::f32_rgba_to_bgra_image(w, h, &base_samples));
|
||||
let base = Arc::new(super::super::frames::f32_rgba_to_bgra_image(
|
||||
w,
|
||||
h,
|
||||
&base_samples,
|
||||
));
|
||||
let viewport = InteractViewport::at_frame_size(w, h);
|
||||
let composite = draw_interact_composite(handle, &interact, &viewport, 0.0, &base)
|
||||
.expect("GL overlay composite");
|
||||
@@ -1026,7 +1122,6 @@ mod tests {
|
||||
);
|
||||
|
||||
// The draw action reached the plugin with the viewport args.
|
||||
unsafe { std::env::remove_var(MARKER_ENV) };
|
||||
let lines = read_marker(&marker);
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
assert!(
|
||||
@@ -1037,4 +1132,208 @@ mod tests {
|
||||
sync_active_interact(None);
|
||||
oak_plugin::host::Host::global().shutdown();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers / idle pump
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `InteractViewport::at_frame_size` is the 1:1 viewport of a frame.
|
||||
#[test]
|
||||
fn interact_viewport_at_frame_size_is_unit_scale() {
|
||||
let vp = InteractViewport::at_frame_size(1920, 1080);
|
||||
assert_eq!(vp.width, 1920.0);
|
||||
assert_eq!(vp.height, 1080.0);
|
||||
assert_eq!(vp.pixel_scale, (1.0, 1.0));
|
||||
}
|
||||
|
||||
/// A `RenderImage` without CPU bytes converts to `None` (the viewer
|
||||
/// shows the base frame unchanged).
|
||||
#[test]
|
||||
fn bgra_image_to_f32_rgba_returns_none_without_bytes() {
|
||||
let empty = RenderImage::new(smallvec::SmallVec::<[image::Frame; 1]>::new());
|
||||
assert!(bgra_image_to_f32_rgba(&empty).is_none());
|
||||
}
|
||||
|
||||
/// `read_image_f32` unpacks the plugin image's native-endian F32 RGBA
|
||||
/// bytes.
|
||||
#[test]
|
||||
fn read_image_f32_unpacks_native_float_samples() {
|
||||
use oak_plugin::image::{BitDepth, Components, Image};
|
||||
use oak_plugin::instance::OfxRectD;
|
||||
let mut img = Image::allocate(
|
||||
BitDepth::Float,
|
||||
Components::Rgba,
|
||||
OfxRectD {
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
x2: 2.0,
|
||||
y2: 1.0,
|
||||
},
|
||||
);
|
||||
let samples: [f32; 8] = [0.25, 0.5, 0.75, 1.0, -1.0, 2.0, 0.0, 0.5];
|
||||
let bytes: Vec<u8> = samples.iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
img.pixels_mut()[..bytes.len()].copy_from_slice(&bytes);
|
||||
assert_eq!(read_image_f32(&img), samples);
|
||||
}
|
||||
|
||||
/// `composite_overlay` rejects buffers that are not whole RGBA pixels
|
||||
/// and clamps out-of-range alpha.
|
||||
#[test]
|
||||
fn composite_overlay_rejects_unaligned_and_clamps_alpha() {
|
||||
assert!(
|
||||
composite_overlay(&[0.0, 0.0], &[0.0, 0.0]).is_none(),
|
||||
"a two-sample buffer is not an RGBA pixel"
|
||||
);
|
||||
// Alpha above one clamps to fully opaque (the overlay wins).
|
||||
let out = composite_overlay(&[1.0, 0.0, 0.0, 2.0], &[0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
assert_eq!(out, [1.0, 0.0, 0.0, 1.0]);
|
||||
// Alpha below zero clamps to fully transparent (the base stays).
|
||||
let out = composite_overlay(&[1.0, 0.0, 0.0, -1.0], &[0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
assert_eq!(out, [0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// The f-key mapping parses f1..f35; out-of-range or unparseable
|
||||
/// suffixes fall through to the unknown symbol.
|
||||
#[test]
|
||||
fn key_symbol_function_keys_and_unknown_multi_char_keys() {
|
||||
use oak_plugin::host as ofx_key;
|
||||
let ks = |key: &str| gpui::Keystroke::parse(key).unwrap();
|
||||
assert_eq!(
|
||||
key_symbol(&ks("f35")),
|
||||
(ofx_key::KEY_F1 + 34, String::new())
|
||||
);
|
||||
assert_eq!(key_symbol(&ks("f36")), (ofx_key::KEY_UNKNOWN, String::new()));
|
||||
assert_eq!(key_symbol(&ks("f0")), (ofx_key::KEY_UNKNOWN, String::new()));
|
||||
// A bare "f" is the printable character, not a function key.
|
||||
assert_eq!(key_symbol(&ks("f")), (b'f' as i32, "f".to_string()));
|
||||
assert_eq!(
|
||||
key_symbol(&ks("f99999999999999999999")),
|
||||
(ofx_key::KEY_UNKNOWN, String::new())
|
||||
);
|
||||
}
|
||||
|
||||
/// The idle pump is a no-op without an active interact and throttles
|
||||
/// repeat calls: two back-to-back calls must not run the idle action
|
||||
/// twice.
|
||||
#[test]
|
||||
fn pump_interact_idle_is_inert_without_an_interact() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Neither call may panic; with no interact installed they only
|
||||
// exercise the throttle guard.
|
||||
pump_interact_idle();
|
||||
pump_interact_idle();
|
||||
}
|
||||
|
||||
/// `sync_active_interact`: an unknown instance handle and a cleared
|
||||
/// target are no-ops (no interact is installed).
|
||||
#[test]
|
||||
fn sync_active_interact_missing_instance_is_a_noop() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert!(
|
||||
oak_plugin::node_factory::instance_from_id(u64::MAX - 7).is_none(),
|
||||
"the probe handle is not registered"
|
||||
);
|
||||
// The node/instance is gone: nothing to attach.
|
||||
sync_active_interact(Some(u64::MAX - 7));
|
||||
// A cleared target with a (possibly) empty slot: nothing to destroy.
|
||||
sync_active_interact(None);
|
||||
}
|
||||
|
||||
/// `init` is idempotent: a second scan/registration must not duplicate
|
||||
/// plugin node types, and it installs the app-side bridges.
|
||||
#[test]
|
||||
fn init_wiring_is_idempotent() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = init();
|
||||
let second = init();
|
||||
assert_eq!(
|
||||
second, 0,
|
||||
"a second init must not register duplicates (the first registered {first} plugin type(s))"
|
||||
);
|
||||
assert!(
|
||||
oak_plugin::progress::has_reporter_factory(),
|
||||
"init installs the progress reporter factory"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interact lifecycle / composite early returns (needs the test plugin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The app-layer interact management with a real plugin instance:
|
||||
/// re-targeting the same instance is a no-op, `note_interact_viewport`
|
||||
/// updates only the matching instance, and `draw_interact_composite`
|
||||
/// reports `None` for an inert viewport/frame (zero size, mismatch, no
|
||||
/// CPU bytes) without touching GL.
|
||||
#[test]
|
||||
fn interact_viewport_tracking_and_composite_early_returns() {
|
||||
let _lock = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !scan_interact_plugin() {
|
||||
return;
|
||||
}
|
||||
let inst = oak_plugin::host::Host::global()
|
||||
.create_instance(INTERACT_PLUGIN_ID, None)
|
||||
.expect("interact variant instance");
|
||||
let handle = oak_plugin::node_factory::register_instance(inst);
|
||||
sync_active_interact(Some(handle));
|
||||
|
||||
// Re-targeting the same instance keeps the live interact (the
|
||||
// unchanged-target early return).
|
||||
sync_active_interact(Some(handle));
|
||||
let (active_handle, interact, _) = active_interact().expect("active interact");
|
||||
assert_eq!(active_handle, handle);
|
||||
|
||||
// The viewport tracker updates the matching instance...
|
||||
let vp = InteractViewport::at_frame_size(64, 36);
|
||||
note_interact_viewport(handle, vp);
|
||||
assert_eq!(active_interact().expect("active interact").2, vp);
|
||||
// ... and ignores other instances.
|
||||
note_interact_viewport(
|
||||
handle.wrapping_add(1),
|
||||
InteractViewport::at_frame_size(1, 1),
|
||||
);
|
||||
assert_eq!(active_interact().expect("active interact").2, vp);
|
||||
|
||||
// A 2×2 base frame.
|
||||
let samples = vec![0.25f32; 2 * 2 * 4];
|
||||
let base = super::super::frames::f32_rgba_to_bgra_image(2, 2, &samples);
|
||||
|
||||
// A zero-size viewport is inert before any GL work.
|
||||
let zero = InteractViewport {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
pixel_scale: (1.0, 1.0),
|
||||
};
|
||||
assert!(draw_interact_composite(handle, &interact, &zero, 0.0, &base).is_none());
|
||||
|
||||
// Viewport/frame size mismatch.
|
||||
let mismatch = InteractViewport::at_frame_size(4, 4);
|
||||
assert!(draw_interact_composite(handle, &interact, &mismatch, 0.0, &base).is_none());
|
||||
|
||||
// A base image without CPU bytes.
|
||||
let empty = RenderImage::new(smallvec::SmallVec::<[image::Frame; 1]>::new());
|
||||
let matching = InteractViewport::at_frame_size(2, 2);
|
||||
assert!(draw_interact_composite(handle, &interact, &matching, 0.0, &empty).is_none());
|
||||
|
||||
// The in-process GL path: headless Linux has no GL implementation,
|
||||
// so this reports None; a GL-capable host may composite. Either way
|
||||
// it must not panic.
|
||||
let _ = draw_interact_composite(handle, &interact, &matching, 0.0, &base);
|
||||
|
||||
sync_active_interact(None);
|
||||
|
||||
// A plugin that does not declare an overlay interact reports
|
||||
// `new_interact() == None` and stays inert.
|
||||
let plain = oak_plugin::host::Host::global()
|
||||
.create_instance("org.oak.test-plugin", None)
|
||||
.expect("plain effect variant instance");
|
||||
let plain_handle = oak_plugin::node_factory::register_instance(plain);
|
||||
sync_active_interact(Some(plain_handle));
|
||||
assert!(
|
||||
active_interact().is_none(),
|
||||
"a plugin without an interact installs nothing"
|
||||
);
|
||||
|
||||
oak_plugin::host::Host::global().shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
+6728
-357
File diff suppressed because it is too large
Load Diff
+1963
-140
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,7 @@ pub(crate) fn analyze_bgra8(width: u32, height: u32, bytes: &[u8]) -> ScopeData
|
||||
let mut luma = Vec::with_capacity(pixels);
|
||||
let mut chroma = Vec::with_capacity(pixels);
|
||||
let src = bytes.get(..pixels * 4).unwrap_or_default();
|
||||
for px in src.chunks_exact(4) {
|
||||
for px in src.as_chunks::<4>().0 {
|
||||
let b = f32::from(px[0]) / 255.0;
|
||||
let g = f32::from(px[1]) / 255.0;
|
||||
let r = f32::from(px[2]) / 255.0;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The text engine re-export: the implementation lives in
|
||||
//! [`oak_render::textengine`] so the app AND the worker pool share one
|
||||
//! installation (the workers render the text nodes — installing the
|
||||
//! hooks only in the UI process left text invisible in every preview).
|
||||
|
||||
pub use oak_render::textengine::{font_families, install};
|
||||
@@ -90,7 +90,11 @@ impl WaveformCache {
|
||||
|
||||
/// The cached waveform of `clip`, if extracted.
|
||||
pub fn get(&self, clip: u64) -> Option<Arc<ClipWaveform>> {
|
||||
self.map.lock().unwrap_or_else(|e| e.into_inner()).get(&clip).cloned()
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&clip)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Ensure `clip` (with media `filename`, `duration_frames` long) has a
|
||||
@@ -105,16 +109,17 @@ impl WaveformCache {
|
||||
return;
|
||||
};
|
||||
let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !map.contains_key(&clip) {
|
||||
map.insert(clip, Arc::new(waveform));
|
||||
self.version.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = map.entry(clip) {
|
||||
e.insert(Arc::new(waveform));
|
||||
self.version
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a clip's waveform (first channel) via oakaudio's waveform
|
||||
/// extractor.
|
||||
fn extract(filename: &str, duration_frames: i64, _fps: f32) -> Option<ClipWaveform> {
|
||||
fn extract(filename: &str, duration_frames: i64, _fps: f32) -> Option<ClipWaveform> {
|
||||
let cname = std::ffi::CString::new(filename).ok()?;
|
||||
const SAMPLES_PER_POINT: i32 = 256;
|
||||
// Audio-stream index 0 (probe numbering).
|
||||
@@ -239,3 +244,136 @@ impl ClipDecorator for OakClipDecorator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
|
||||
/// Media tests share the codec/undo test lock (FFmpeg is not safe
|
||||
/// against concurrent decode sessions).
|
||||
fn media_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
crate::oakui::graphops::test_lock()
|
||||
}
|
||||
|
||||
fn test_media(tag: &str, media: bool) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"oakapp_waveform_unit_{tag}_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
if media {
|
||||
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test media");
|
||||
} else {
|
||||
std::fs::write(&path, b"not media").expect("write");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// `extract` reads the first channel of a real audio stream and
|
||||
/// reports the media geometry; non-media and missing files yield None.
|
||||
#[test]
|
||||
fn extract_reads_audio_and_rejects_non_media() {
|
||||
let _lock = media_lock();
|
||||
let good = test_media("ok", true);
|
||||
let waveform = extract(&good.to_string_lossy(), 250, 25.0).expect("waveform");
|
||||
assert!(waveform.channel_count >= 1);
|
||||
assert!(!waveform.peaks.is_empty());
|
||||
assert_eq!(waveform.sample_rate, 48000);
|
||||
assert_eq!(waveform.samples_per_point, 256);
|
||||
assert_eq!(waveform.duration_frames, 250);
|
||||
// The peaks are finite min/max pairs.
|
||||
for p in &waveform.peaks {
|
||||
assert!(p.min.is_finite() && p.max.is_finite());
|
||||
assert!(p.min <= p.max);
|
||||
}
|
||||
|
||||
let not_media = test_media("bad", false);
|
||||
assert!(extract(¬_media.to_string_lossy(), 250, 25.0).is_none());
|
||||
assert!(extract("/definitely/missing.mp4", 250, 25.0).is_none());
|
||||
|
||||
let _ = std::fs::remove_file(&good);
|
||||
let _ = std::fs::remove_file(¬_media);
|
||||
}
|
||||
|
||||
/// The multicam wizard envelope windows the first-channel peaks
|
||||
/// (~100 ms windows) and degrades to None without audio.
|
||||
#[test]
|
||||
fn envelope_windows_peaks_and_degrades_without_audio() {
|
||||
let _lock = media_lock();
|
||||
let media = test_media("env", true);
|
||||
let envelope =
|
||||
extract_audio_envelope(&media.to_string_lossy(), 0).expect("envelope");
|
||||
assert!(!envelope.is_empty());
|
||||
assert!(
|
||||
envelope.len() >= 2,
|
||||
"a 0.4s clip yields several windows: {}",
|
||||
envelope.len()
|
||||
);
|
||||
for peak in &envelope {
|
||||
assert!(peak.is_finite() && (0.0..=1.5).contains(peak), "{peak}");
|
||||
}
|
||||
|
||||
assert!(extract_audio_envelope("/definitely/missing.mp4", 0).is_none());
|
||||
assert!(extract_audio_envelope(&media.to_string_lossy(), 99).is_none());
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl gpui::Render for Probe {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut gpui::Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
gpui::div()
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeline decorator's guard paths: a clip without a cache entry
|
||||
/// and zero-width bounds both return before touching the window (the
|
||||
/// drawing loop itself only runs inside a real paint pass, which the
|
||||
/// widget-level tests cover).
|
||||
#[gpui::test]
|
||||
async fn decorator_skips_missing_entries_and_empty_bounds(cx: &mut TestAppContext) {
|
||||
let _lock = media_lock();
|
||||
let media = test_media("paint", true);
|
||||
let cache = WaveformCache::new(25.0);
|
||||
cache.refresh(1, &media.to_string_lossy(), 250);
|
||||
assert!(cache.get(1).is_some(), "the clip has a waveform");
|
||||
assert!(cache.get(99).is_none());
|
||||
assert!(cache.version() >= 1);
|
||||
// A second refresh is a cache hit and does not bump the version.
|
||||
let version = cache.version();
|
||||
cache.refresh(1, &media.to_string_lossy(), 250);
|
||||
assert_eq!(cache.version(), version);
|
||||
|
||||
let window = cx.open_window(
|
||||
gpui::size(gpui::px(200.0), gpui::px(100.0)),
|
||||
|_, _| Probe,
|
||||
);
|
||||
cx.run_until_parked();
|
||||
let mut visual = VisualTestContext::from_window(window.into(), cx);
|
||||
visual.update(|window, _app| {
|
||||
let mut decorator = OakClipDecorator {
|
||||
cache: cache.clone(),
|
||||
};
|
||||
let bounds = Bounds {
|
||||
origin: gpui::point(gpui::px(0.0), gpui::px(0.0)),
|
||||
size: gpui::size(gpui::px(100.0), gpui::px(40.0)),
|
||||
};
|
||||
let range = FrameRange {
|
||||
start: Frame(0),
|
||||
end: Frame(250),
|
||||
};
|
||||
decorator.paint_waveform(window, ClipId(99), range, bounds);
|
||||
let empty = Bounds {
|
||||
origin: bounds.origin,
|
||||
size: gpui::size(gpui::px(0.0), gpui::px(40.0)),
|
||||
};
|
||||
decorator.paint_waveform(window, ClipId(1), range, empty);
|
||||
});
|
||||
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ mod tests {
|
||||
let peaks: Vec<MinMax> = (0..points)
|
||||
.map(|i| {
|
||||
let sample = i64::from(i) * i64::from(SPP);
|
||||
let inside = sample >= 48_000 && sample < 96_000;
|
||||
let inside = (48_000..96_000).contains(&sample);
|
||||
if inside {
|
||||
MinMax { min: -1.0, max: 1.0 }
|
||||
} else {
|
||||
|
||||
@@ -135,6 +135,12 @@ pub trait PanelCommandHandler: Sized {
|
||||
fn speed_duration(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
/// The clip menu's "Default Transition" item / the Ctrl+Shift+D action:
|
||||
/// add a transition of the configured default length at every seam
|
||||
/// around the selected clips.
|
||||
fn default_transition(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn toggle_links(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -259,6 +265,7 @@ pub fn dispatch_to<P: PanelCommandHandler>(
|
||||
ActionId::RippleDelete => panel.ripple_delete(cx),
|
||||
ActionId::SplitAtPlayhead => panel.split_at_playhead(cx),
|
||||
ActionId::SpeedDuration => panel.speed_duration(cx),
|
||||
ActionId::DefaultTransition => panel.default_transition(cx),
|
||||
ActionId::LinkUnlink => panel.toggle_links(cx),
|
||||
ActionId::EnableDisable => panel.toggle_selected_enabled(cx),
|
||||
ActionId::Insert => panel.insert(cx),
|
||||
@@ -382,3 +389,202 @@ pub fn viewer_transport<E: AppEngine>(
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::{ActionId, REGISTRY};
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::timeline::{Frame, TimelineDataSource as _};
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
|
||||
/// A panel that overrides nothing: every command falls through.
|
||||
struct DefaultPanel;
|
||||
impl PanelCommandHandler for DefaultPanel {}
|
||||
|
||||
/// A panel that overrides a couple of commands and records the
|
||||
/// multi-camera routing arguments.
|
||||
#[derive(Default)]
|
||||
struct RecordingPanel {
|
||||
value: bool,
|
||||
multicam: Option<(i32, bool)>,
|
||||
}
|
||||
|
||||
impl PanelCommandHandler for RecordingPanel {
|
||||
fn play_pause(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
self.value = true;
|
||||
self.value
|
||||
}
|
||||
fn multicam_switch(&mut self, source: i32, split_clip: bool, _cx: &mut Context<Self>) -> bool {
|
||||
self.multicam = Some((source, split_clip));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Every registry action dispatches onto the matching trait method,
|
||||
/// and a panel that implements none of them declines all of them —
|
||||
/// which also exercises every default body.
|
||||
#[gpui::test]
|
||||
async fn default_panel_declines_every_registered_action(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let panel = app.new(|_| DefaultPanel);
|
||||
for entry in REGISTRY {
|
||||
let handled =
|
||||
panel.update(app, |panel, cx| dispatch_to(panel, entry.action, cx));
|
||||
assert!(
|
||||
!handled,
|
||||
"the default panel must decline {:?}",
|
||||
entry.action
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A panel that handles `PlayPause` stops the fall-through for that
|
||||
/// action only; the multicam actions map their numbered ids onto
|
||||
/// `(source, split_clip)` pairs.
|
||||
#[gpui::test]
|
||||
async fn dispatch_propagates_handled_actions_and_multicam_arguments(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
cx.update(|app| {
|
||||
let panel = app.new(|_| RecordingPanel::default());
|
||||
|
||||
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::PlayPause, cx));
|
||||
assert!(handled, "the overridden command handles the action");
|
||||
assert!(panel.read(app).value);
|
||||
|
||||
// Everything else still falls through to the shell.
|
||||
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::Cut, cx));
|
||||
assert!(!handled);
|
||||
|
||||
let handled =
|
||||
panel.update(app, |panel, cx| dispatch_to(panel, ActionId::MulticamSwitch3, cx));
|
||||
assert!(handled);
|
||||
assert_eq!(panel.read(app).multicam, Some((2, true)));
|
||||
|
||||
let handled = panel.update(app, |panel, cx| {
|
||||
dispatch_to(panel, ActionId::MulticamSwitchNoSplit7, cx)
|
||||
});
|
||||
assert!(handled);
|
||||
assert_eq!(panel.read(app).multicam, Some((6, false)));
|
||||
|
||||
// Actions with no panel command at all also decline.
|
||||
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::NewProject, cx));
|
||||
assert!(!handled);
|
||||
});
|
||||
}
|
||||
|
||||
/// The viewer transport maps actions onto the monitor's clock: play /
|
||||
/// pause toggling, stepping, jumps to start/end and the shuttle keys.
|
||||
#[gpui::test]
|
||||
async fn viewer_transport_drives_the_monitor_clock(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = app.new(MockEngine::create);
|
||||
let clock = engine.read(app).program_clock().clone();
|
||||
let monitor = Monitor::Program;
|
||||
|
||||
// Commands the viewers do not implement fall through.
|
||||
assert!(!viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::Cut,
|
||||
app
|
||||
));
|
||||
|
||||
// PlayPause toggles, and ShuttleStop forces the paused state.
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::PlayPause,
|
||||
app
|
||||
));
|
||||
assert!(clock.read(app).is_playing());
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::PlayPause,
|
||||
app
|
||||
));
|
||||
assert!(!clock.read(app).is_playing());
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::ShuttleRight,
|
||||
app
|
||||
));
|
||||
assert!(clock.read(app).is_playing());
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::ShuttleStop,
|
||||
app
|
||||
));
|
||||
assert!(!clock.read(app).is_playing());
|
||||
|
||||
// Frame stepping clamps at the sequence bounds.
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::GoToStart,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame::ZERO);
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::NextFrame,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(1));
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::PrevFrame,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(0));
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::ShuttleLeft,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(0));
|
||||
|
||||
// GoToEnd requests one past the end; the seek clamps to the
|
||||
// last frame.
|
||||
let length = engine.read(app).sequence_length();
|
||||
assert!(viewer_transport(
|
||||
&engine,
|
||||
&clock,
|
||||
monitor,
|
||||
ActionId::GoToEnd,
|
||||
app
|
||||
));
|
||||
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(length.0 - 1));
|
||||
|
||||
// Work-area commands still claim the action even when the
|
||||
// engine has no work area to jump to.
|
||||
for action in [
|
||||
ActionId::GoToIn,
|
||||
ActionId::GoToOut,
|
||||
ActionId::PlayInToOut,
|
||||
] {
|
||||
assert!(
|
||||
viewer_transport(&engine, &clock, monitor, action, app),
|
||||
"{action:?} is handled"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The effect library panel (效果库): every effect type the engine can add
|
||||
//! to a clip's chain, as a flat list. Double-clicking an entry appends the
|
||||
//! effect to the selected clip's effect chain (the undoable
|
||||
//! to a clip's chain, as a grouped list. Double-clicking an entry appends
|
||||
//! the effect to the selected clip's effect chain (the undoable
|
||||
//! [`AppEngine::add_effect`]; the insertion index is clamped to the chain
|
||||
//! end by the backend).
|
||||
//! end by the backend). Every group header collapses and expands its
|
||||
//! entries; the collapsed set is persisted across sessions.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::oakui::component::text_input;
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
@@ -27,19 +31,28 @@ use gpui::{
|
||||
Render, SharedString, Window,
|
||||
};
|
||||
use gpui_elements::editable_text::{EditableTextState, StringStorage, TextChanged};
|
||||
use crate::oakui::component::text_input;
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::effectchain::group_label;
|
||||
use crate::oakui::engine::EffectEntry;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::EFFECT_LIBRARY;
|
||||
|
||||
/// The config key the collapsed groups are persisted under (a comma
|
||||
/// separated list of group keys — the keys [`EffectEntry::group`] holds,
|
||||
/// not the display labels).
|
||||
const COLLAPSED_CONFIG_KEY: &str = "EffectLibraryCollapsed";
|
||||
|
||||
/// The effect library panel.
|
||||
pub struct EffectLibraryPanel<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
/// The search box state: live-filters the list by name / type id
|
||||
/// (case-insensitive substring).
|
||||
search: Entity<EditableTextState>,
|
||||
/// The group keys whose entries are collapsed away. The headers stay
|
||||
/// rendered (and clickable) so a collapsed group can be reopened.
|
||||
collapsed: HashSet<String>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EffectLibraryPanel<E> {
|
||||
@@ -47,16 +60,88 @@ impl<E: AppEngine> EffectLibraryPanel<E> {
|
||||
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// Re-read the effect table whenever the engine notifies (the table
|
||||
// itself is static, but the selection hint depends on the target).
|
||||
cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach();
|
||||
cx.observe(&engine, |_this, _engine, cx| cx.notify())
|
||||
.detach();
|
||||
let search = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
cx.subscribe(&search, |_this, _state, _event: &TextChanged, cx| {
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
Self { engine, search }
|
||||
Self {
|
||||
engine,
|
||||
search,
|
||||
collapsed: load_collapsed(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the persisted collapsed groups (a missing or malformed value
|
||||
/// simply starts with every group expanded).
|
||||
fn load_collapsed() -> HashSet<String> {
|
||||
parse_collapsed(
|
||||
&oak_core::configstore::ConfigStore::instance()
|
||||
.get(None, COLLAPSED_CONFIG_KEY)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parses the persisted comma separated key list; blanks and padding
|
||||
/// drop, so a hand-edited value cannot produce a phantom group.
|
||||
fn parse_collapsed(raw: &str) -> HashSet<String> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Serializes the collapsed set for the config: sorted and comma
|
||||
/// separated, so an unchanged set persists an unchanged string.
|
||||
fn format_collapsed(collapsed: &HashSet<String>) -> String {
|
||||
let mut keys: Vec<&str> = collapsed.iter().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
keys.join(",")
|
||||
}
|
||||
|
||||
/// Writes the collapsed set back to the config (the `UseProxyMedia`
|
||||
/// pattern: the config store is the source of truth, read once at panel
|
||||
/// construction).
|
||||
fn store_collapsed(collapsed: &HashSet<String>) {
|
||||
oak_core::configstore::ConfigStore::instance().set(
|
||||
None,
|
||||
COLLAPSED_CONFIG_KEY,
|
||||
&format_collapsed(collapsed),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flips the collapsed state of `group_key`, returning the new state
|
||||
/// (`true` = collapsed).
|
||||
fn toggle_collapsed(collapsed: &mut HashSet<String>, group_key: &str) -> bool {
|
||||
if collapsed.remove(group_key) {
|
||||
false
|
||||
} else {
|
||||
collapsed.insert(group_key.to_string());
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entry` matches the trimmed, lowercased search query.
|
||||
fn matches_query(entry: &EffectEntry, query: &str) -> bool {
|
||||
query.is_empty()
|
||||
|| entry.name.to_lowercase().contains(query)
|
||||
|| entry.type_id.to_lowercase().contains(query)
|
||||
}
|
||||
|
||||
/// Whether `entry`'s row is collapsed away. The collapse only narrows the
|
||||
/// unfiltered list: a search looks through every group, collapsed or not.
|
||||
fn hidden_by_collapse(entry: &EffectEntry, query: &str, collapsed: &HashSet<String>) -> bool {
|
||||
query.is_empty()
|
||||
&& entry
|
||||
.group
|
||||
.as_deref()
|
||||
.is_some_and(|group| collapsed.contains(group))
|
||||
}
|
||||
|
||||
/// The effect library implements no focused-panel commands: everything
|
||||
/// falls through to the shell's global handler.
|
||||
impl<E: AppEngine> PanelCommandHandler for EffectLibraryPanel<E> {}
|
||||
@@ -77,28 +162,45 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
.p_2()
|
||||
.overflow_y_scroll();
|
||||
|
||||
// Built-in effects render flat under a Built-in header; OpenFX
|
||||
// plugin entries are grouped under their sub-category header
|
||||
// (Filter / Generator / Transition / General — the C++
|
||||
// `factorymenu` OpenFX branch). The engine table arrives sorted
|
||||
// (built-ins first, then groups and names alphabetically); the
|
||||
// search box live-filters by name / type id.
|
||||
// Built-in effects group by their category (color / filter /
|
||||
// distort / keying / generator / math / general, see
|
||||
// `effectchain::category_group_key`); OpenFX plugin entries group
|
||||
// by their sub-category (Filter / Generator / Transition /
|
||||
// General — the C++ `factorymenu` OpenFX branch). The engine table
|
||||
// arrives sorted (built-ins first, then groups and names
|
||||
// alphabetically); the search box live-filters by name / type id
|
||||
// and always searches collapsed groups too.
|
||||
let mut last_group: Option<Option<String>> = None;
|
||||
for entry in &effects {
|
||||
if !query.is_empty()
|
||||
&& !entry.name.to_lowercase().contains(&query)
|
||||
&& !entry.type_id.to_lowercase().contains(&query)
|
||||
{
|
||||
if !matches_query(entry, &query) {
|
||||
continue;
|
||||
}
|
||||
let group_key = entry.group.clone();
|
||||
if last_group.as_ref() != Some(&group_key) {
|
||||
last_group = Some(group_key);
|
||||
last_group = Some(group_key.clone());
|
||||
let key = group_key.clone().unwrap_or_default();
|
||||
let label = match &entry.group {
|
||||
Some(group) => group.clone(),
|
||||
Some(group) => group_label(group),
|
||||
None => i18n::tr("effect_library.group.builtin").to_string(),
|
||||
};
|
||||
list = list.child(group_header(&colors, &label));
|
||||
let collapsed = self.collapsed.contains(&key);
|
||||
list = list.child(group_header(
|
||||
&colors,
|
||||
&key,
|
||||
&label,
|
||||
collapsed,
|
||||
cx.listener({
|
||||
let key = key.clone();
|
||||
move |this, _event: &ClickEvent, _window, cx| {
|
||||
toggle_collapsed(&mut this.collapsed, &key);
|
||||
store_collapsed(&this.collapsed);
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
if hidden_by_collapse(entry, &query, &self.collapsed) {
|
||||
continue;
|
||||
}
|
||||
let engine = self.engine.clone();
|
||||
let row_id = entry.type_id.clone();
|
||||
@@ -111,7 +213,7 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
list = list.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("effect-library-{type_id}")))
|
||||
.debug_selector(move || format!("effect-library-row-{row_id}").into())
|
||||
.debug_selector(move || format!("effect-library-row-{row_id}"))
|
||||
.cursor_pointer()
|
||||
.px_2()
|
||||
.py_1()
|
||||
@@ -183,18 +285,39 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sub-category header row of the OpenFX group (a muted, all-caps
|
||||
/// line above the plugin entries).
|
||||
fn group_header(colors: &gpui::colors::Colors, group: &str) -> impl IntoElement {
|
||||
/// The header row of a group: a muted semibold line with a ▶ / ▼ marker
|
||||
/// that collapses and expands the group's entries. `group_key` is the raw
|
||||
/// key ([`EffectEntry::group`], also the element id suffix); `label` is
|
||||
/// its display string.
|
||||
fn group_header(
|
||||
colors: &gpui::colors::Colors,
|
||||
group_key: &str,
|
||||
label: &str,
|
||||
collapsed: bool,
|
||||
on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.id(SharedString::from(format!("effect-library-group-{group}")))
|
||||
.id(SharedString::from(format!(
|
||||
"effect-library-group-{group_key}"
|
||||
)))
|
||||
.debug_selector({
|
||||
let key = group_key.to_string();
|
||||
move || format!("effect-library-group-row-{key}")
|
||||
})
|
||||
.cursor_pointer()
|
||||
.pt_2()
|
||||
.pb_1()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight(600.0))
|
||||
.text_color(colors.disabled)
|
||||
.child(group.to_string())
|
||||
.hover(|style| style.text_color(colors.text))
|
||||
.child(if collapsed { "▶" } else { "▼" })
|
||||
.child(label.to_string())
|
||||
.on_click(on_toggle)
|
||||
}
|
||||
|
||||
/// The drag ghost shown under the pointer while an effect is dragged out
|
||||
@@ -241,15 +364,113 @@ impl<E: AppEngine> DockPanel for EffectLibraryPanel<E> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{px, size, TestAppContext, VisualTestContext};
|
||||
use gpui::{px, size, Modifiers, Point, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Serializes the tests that read or write the process-global
|
||||
/// collapsed-group key, and starts them from a clean value (a
|
||||
/// developer's own persisted choice would otherwise hide rows from
|
||||
/// the render tests).
|
||||
fn collapsed_config_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
store_collapsed(&HashSet::new());
|
||||
guard
|
||||
}
|
||||
|
||||
/// A one-entry table row for the pure collapse logic.
|
||||
fn entry(group: Option<&str>) -> EffectEntry {
|
||||
EffectEntry {
|
||||
type_id: "oak:testeffect".to_string(),
|
||||
name: "Test Effect".to_string(),
|
||||
group: group.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The row selector `debug_bounds` looks up (leaked: it needs a
|
||||
/// `&'static str` and tests are process-lifetime).
|
||||
fn row_selector(type_id: &str) -> &'static str {
|
||||
Box::leak(format!("effect-library-row-{type_id}").into_boxed_str())
|
||||
}
|
||||
|
||||
/// The collapse toggle and the collapsed-row predicate — a collapsed
|
||||
/// group hides its rows, but a search still looks inside it.
|
||||
#[test]
|
||||
fn collapsed_rows_toggle_and_stay_searchable() {
|
||||
let color = entry(Some("color"));
|
||||
let mut collapsed = HashSet::new();
|
||||
|
||||
assert!(!hidden_by_collapse(&color, "", &collapsed));
|
||||
assert!(
|
||||
toggle_collapsed(&mut collapsed, "color"),
|
||||
"first toggle collapses"
|
||||
);
|
||||
assert!(hidden_by_collapse(&color, "", &collapsed));
|
||||
assert!(
|
||||
!hidden_by_collapse(&color, "blur", &collapsed),
|
||||
"a search looks through collapsed groups"
|
||||
);
|
||||
assert!(
|
||||
!hidden_by_collapse(&color, "", &HashSet::new()),
|
||||
"an unlisted group stays open"
|
||||
);
|
||||
assert!(
|
||||
!toggle_collapsed(&mut collapsed, "color"),
|
||||
"second toggle expands"
|
||||
);
|
||||
assert!(collapsed.is_empty());
|
||||
}
|
||||
|
||||
/// The search predicate: name or type id, case-folded substring.
|
||||
#[test]
|
||||
fn query_matches_name_and_type_id() {
|
||||
let color = entry(Some("color"));
|
||||
assert!(matches_query(&color, ""));
|
||||
assert!(matches_query(&color, "test"));
|
||||
assert!(matches_query(&color, "oak:test"));
|
||||
assert!(!matches_query(&color, "blur"));
|
||||
}
|
||||
|
||||
/// The persisted form round-trips: sorted comma separated keys, with
|
||||
/// padding and blank entries dropped.
|
||||
#[test]
|
||||
fn collapsed_list_round_trips() {
|
||||
let collapsed: HashSet<String> =
|
||||
["keying", "color"].iter().map(|k| k.to_string()).collect();
|
||||
let raw = format_collapsed(&collapsed);
|
||||
assert_eq!(raw, "color,keying");
|
||||
assert_eq!(parse_collapsed(&raw), collapsed);
|
||||
assert_eq!(parse_collapsed(&format!(" {raw} , ")), collapsed);
|
||||
assert!(parse_collapsed("").is_empty());
|
||||
assert!(parse_collapsed(" , ").is_empty());
|
||||
}
|
||||
|
||||
/// The panel's storage path: the collapsed set survives a store/load
|
||||
/// cycle through the process config store.
|
||||
#[test]
|
||||
fn collapsed_state_persists_in_the_config() {
|
||||
let _guard = collapsed_config_lock();
|
||||
assert!(
|
||||
load_collapsed().is_empty(),
|
||||
"the lock starts from a clear key"
|
||||
);
|
||||
let collapsed: HashSet<String> = ["distort", "general"]
|
||||
.iter()
|
||||
.map(|k| k.to_string())
|
||||
.collect();
|
||||
store_collapsed(&collapsed);
|
||||
assert_eq!(load_collapsed(), collapsed);
|
||||
store_collapsed(&HashSet::new());
|
||||
assert!(load_collapsed().is_empty());
|
||||
}
|
||||
|
||||
/// The panel renders one row per addable effect of the engine (the
|
||||
/// mock exposes the real factory's video-effect table).
|
||||
#[gpui::test]
|
||||
async fn lists_every_addable_effect(cx: &mut TestAppContext) {
|
||||
let _guard = collapsed_config_lock();
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| MockEngine::demo(cx));
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
EffectLibraryPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
@@ -259,14 +480,121 @@ mod tests {
|
||||
assert!(!expected.is_empty());
|
||||
for entry in &expected {
|
||||
let type_id = &entry.type_id;
|
||||
// `debug_bounds` takes a &'static selector; the per-row selector is
|
||||
// dynamic, so the test leaks it (process-lifetime, test-only).
|
||||
let selector: &'static str =
|
||||
Box::leak(format!("effect-library-row-{type_id}").into_boxed_str());
|
||||
assert!(
|
||||
cx.debug_bounds(selector).is_some(),
|
||||
cx.debug_bounds(row_selector(type_id)).is_some(),
|
||||
"effect row {type_id} rendered"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A collapsed group keeps its header (so it can be reopened) but
|
||||
/// renders none of its rows; the groups below it are unaffected.
|
||||
#[gpui::test]
|
||||
async fn collapsing_a_group_hides_its_rows(cx: &mut TestAppContext) {
|
||||
let _guard = collapsed_config_lock();
|
||||
|
||||
let entries = crate::oakui::effectchain::addable_effects();
|
||||
let group = entries
|
||||
.first()
|
||||
.and_then(|entry| entry.group.clone())
|
||||
.expect("the engine's table is grouped");
|
||||
let other = entries
|
||||
.iter()
|
||||
.find(|entry| entry.group.as_deref() != Some(group.as_str()))
|
||||
.expect("the table spreads over more than one group")
|
||||
.clone();
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let mut panel = EffectLibraryPanel::new(engine, window, cx);
|
||||
// The state the toggle writes (and the config reloads on the
|
||||
// next start), without touching the process-global key.
|
||||
panel.collapsed.insert(group.clone());
|
||||
panel
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
|
||||
assert!(
|
||||
cx.debug_bounds(Box::leak(
|
||||
format!("effect-library-group-row-{group}").into_boxed_str()
|
||||
))
|
||||
.is_some(),
|
||||
"the collapsed group keeps its header"
|
||||
);
|
||||
for entry in entries
|
||||
.iter()
|
||||
.filter(|entry| entry.group.as_deref() == Some(group.as_str()))
|
||||
{
|
||||
assert!(
|
||||
cx.debug_bounds(row_selector(&entry.type_id)).is_none(),
|
||||
"collapsed row {} is hidden",
|
||||
entry.type_id
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
cx.debug_bounds(row_selector(&other.type_id)).is_some(),
|
||||
"the next group still renders its rows"
|
||||
);
|
||||
}
|
||||
|
||||
/// Clicking a group header is what writes the state: the group's rows
|
||||
/// leave the render and the collapsed key lands in the persisted
|
||||
/// config.
|
||||
#[gpui::test]
|
||||
async fn clicking_a_group_header_collapses_and_persists_it(cx: &mut TestAppContext) {
|
||||
let _guard = collapsed_config_lock();
|
||||
|
||||
let entries = crate::oakui::effectchain::addable_effects();
|
||||
let group = entries
|
||||
.first()
|
||||
.and_then(|entry| entry.group.clone())
|
||||
.expect("the engine's table is grouped");
|
||||
let row = entries
|
||||
.iter()
|
||||
.find(|entry| entry.group.as_deref() == Some(group.as_str()))
|
||||
.expect("the group has rows")
|
||||
.type_id
|
||||
.clone();
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
EffectLibraryPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let visual = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
let header = visual
|
||||
.debug_bounds(Box::leak(
|
||||
format!("effect-library-group-row-{group}").into_boxed_str(),
|
||||
))
|
||||
.expect("the group header is painted");
|
||||
let center = Point::new(
|
||||
header.origin.x + header.size.width * 0.5,
|
||||
header.origin.y + header.size.height * 0.5,
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds(row_selector(&row)).is_some(),
|
||||
"the row starts visible"
|
||||
);
|
||||
|
||||
visual.simulate_click(center, Modifiers::default());
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
assert!(
|
||||
visual.debug_bounds(row_selector(&row)).is_none(),
|
||||
"the clicked group collapsed"
|
||||
);
|
||||
assert!(
|
||||
load_collapsed().contains(&group),
|
||||
"the collapse was persisted under {COLLAPSED_CONFIG_KEY}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ mod tests {
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(320.0), px(400.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| MockEngine::demo(cx));
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
HistoryPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
@@ -317,4 +317,115 @@ mod tests {
|
||||
.expect("context menu opened on right-click");
|
||||
assert!(popup.size.height > px(20.0), "popup lists the items");
|
||||
}
|
||||
|
||||
/// A real engine's undo stack renders as rows (the row-list branch of
|
||||
/// `render`); the menu actions route to the engine, the blank-area
|
||||
/// right-click opens the menu, and the dock metadata is populated.
|
||||
#[gpui::test]
|
||||
async fn rows_render_and_interact(cx: &mut TestAppContext) {
|
||||
let _lock = crate::oakui::graphops::test_lock();
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(320.0), px(400.0)), |window, cx| {
|
||||
let engine = cx.new(crate::oakui::RealEngine::create);
|
||||
HistoryPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("history panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
|
||||
// Seed a project and one undoable edit through the panel's engine:
|
||||
// the panel now renders the row list instead of the placeholder.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.engine.update(cx, |engine, cx| {
|
||||
engine.new_project(cx);
|
||||
engine.add_track(gpui::timeline::TrackKind::Video, cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let entries = cx.read(|app| panel.read(app).engine.read(app).history_entries().len());
|
||||
assert!(entries >= 2, "new project + add track: {entries}");
|
||||
let total = entries as i64;
|
||||
assert_eq!(
|
||||
cx.read(|app| panel.read(app).engine.read(app).history_index()),
|
||||
total,
|
||||
"the pointer starts at the top of the seeded stack"
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
// Dock metadata.
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
assert_eq!(panel.read(app).panel_id(), HISTORY, "the dock routes by id");
|
||||
// `tab_content` only builds an element with no mounted bounds to
|
||||
// inspect in this test, so the call is a no-panic smoke check.
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
});
|
||||
|
||||
// Left click focuses the panel (the dock re-emits the event).
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(160.0), px(380.0)),
|
||||
MouseButton::Left,
|
||||
Modifiers::none(),
|
||||
);
|
||||
// Right-clicking the blank body opens the menu without a row.
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(160.0), px(380.0)),
|
||||
MouseButton::Right,
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(cx.debug_bounds("menu-popup").is_some(), "blank-area menu opens");
|
||||
|
||||
// Every menu action routes to the engine; unknown ids are ignored,
|
||||
// and show_menu toggles between row and blank-area targets.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
// `menu_row` is still None: jump-here is a no-op and the
|
||||
// pointer stays at the top.
|
||||
panel.on_menu(MENU_JUMP_HERE, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total);
|
||||
|
||||
// Undo/redo route to the engine stack: the pointer moves,
|
||||
// the row count stays, and the top row joins/leaves the
|
||||
// redoable tail.
|
||||
panel.on_menu(MENU_UNDO, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total - 1);
|
||||
let entries = panel.engine.read(cx).history_entries();
|
||||
assert_eq!(entries.len(), total as usize, "undo keeps rows listed");
|
||||
assert!(!entries.last().expect("rows").done, "the undone row is gray");
|
||||
panel.on_menu(MENU_REDO, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total);
|
||||
assert!(
|
||||
panel
|
||||
.engine
|
||||
.read(cx)
|
||||
.history_entries()
|
||||
.last()
|
||||
.expect("rows")
|
||||
.done,
|
||||
"redo restores the top row"
|
||||
);
|
||||
|
||||
// Unknown ids are ignored (the pointer does not move).
|
||||
panel.on_menu(9999, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), total);
|
||||
|
||||
panel.show_menu(None, gpui::point(px(5.0), px(5.0)), cx);
|
||||
assert_eq!(panel.menu_row, None);
|
||||
panel.show_menu(Some(0), gpui::point(px(5.0), px(5.0)), cx);
|
||||
assert_eq!(panel.menu_row, Some(0));
|
||||
|
||||
// Jump-here targets the last right-clicked row: row 0 maps
|
||||
// to stack index 0 + 1 and undoes everything above it.
|
||||
panel.on_menu(MENU_JUMP_HERE, cx);
|
||||
assert_eq!(panel.engine.read(cx).history_index(), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! 输出) with add / remove / reorder — no engine, the mock applies the edits
|
||||
//! to its own model.
|
||||
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::effect_stack::{EffectCardKind, EffectId, EffectStackEvent, EffectStackView};
|
||||
@@ -26,9 +27,10 @@ use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, Render,
|
||||
SharedString, Window,
|
||||
};
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::effectchain::group_label;
|
||||
use crate::oakui::engine::EffectEntry;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::INSPECTOR;
|
||||
@@ -55,15 +57,13 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let stack = cx.new(|cx| {
|
||||
let engine_for_params = engine.clone();
|
||||
EffectStackView::new(engine.clone(), cx).params_renderer(
|
||||
move |effect, window, cx| {
|
||||
// The OFX parameter view: auto-generated controls for the
|
||||
// effect's inputs (empty state for effects without a
|
||||
// parameter UI).
|
||||
cx.new(|cx| OfxParamsView::new(*effect, engine_for_params.clone(), window, cx))
|
||||
.into()
|
||||
},
|
||||
)
|
||||
EffectStackView::new(engine.clone(), cx).params_renderer(move |effect, window, cx| {
|
||||
// The OFX parameter view: auto-generated controls for the
|
||||
// effect's inputs (empty state for effects without a
|
||||
// parameter UI).
|
||||
cx.new(|cx| OfxParamsView::new(*effect, engine_for_params.clone(), window, cx))
|
||||
.into()
|
||||
})
|
||||
});
|
||||
// The "edits are requests" loop: forward each request to the engine,
|
||||
// which applies it to its model and notifies. `AddRequested` carries
|
||||
@@ -96,7 +96,8 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
// The stack re-renders whenever the engine changes (selection-driven
|
||||
// highlight, effect edits, project drops) — the stack itself does not
|
||||
// subscribe to the engine entity.
|
||||
cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach();
|
||||
cx.observe(&engine, |_this, _engine, cx| cx.notify())
|
||||
.detach();
|
||||
|
||||
let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
|
||||
|
||||
@@ -141,13 +142,13 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "add effect" menu: one clickable row per addable effect of the
|
||||
/// engine. Selecting a row inserts that effect at the recorded stack
|
||||
/// index; the ✕ in the pinned header closes the menu without adding.
|
||||
/// The list is height-capped and scrollable (with the OFX plugins
|
||||
/// registered it runs to 150+ rows — an uncapped list pushed the
|
||||
/// dismiss affordance far off-screen, making the menu impossible to
|
||||
/// close).
|
||||
/// The "add effect" menu: a group header row per resolved effect group,
|
||||
/// each followed by that group's effect rows. Selecting an effect row
|
||||
/// inserts it at the recorded stack index; the ✕ in the pinned header
|
||||
/// closes the menu without adding. The list is height-capped and
|
||||
/// scrollable (with the OFX plugins registered it runs to 150+ rows — an
|
||||
/// uncapped list pushed the dismiss affordance far off-screen, making the
|
||||
/// menu impossible to close).
|
||||
fn render_add_menu(
|
||||
&mut self,
|
||||
index: usize,
|
||||
@@ -165,7 +166,26 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
.px_2()
|
||||
.py_1();
|
||||
|
||||
for entry in &effects {
|
||||
for row in add_menu_rows(&effects) {
|
||||
let entry = match row {
|
||||
AddMenuRow::Header { key, label } => {
|
||||
let key = key.unwrap_or_default().to_string();
|
||||
list = list.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("add-effect-group-{key}")))
|
||||
.debug_selector(move || format!("add-effect-group-row-{key}"))
|
||||
.pt_2()
|
||||
.pb_1()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight(600.0))
|
||||
.text_color(colors.disabled)
|
||||
.child(label),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
AddMenuRow::Effect(entry) => entry,
|
||||
};
|
||||
let engine = self.engine.clone();
|
||||
let type_id = entry.type_id.clone();
|
||||
let name = entry.name.clone();
|
||||
@@ -236,6 +256,51 @@ impl<E: AppEngine> InspectorPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add-effect menu rows — the engine's addable-effect table (already grouped
|
||||
// and sorted) flattened into one non-clickable header per group followed by
|
||||
// the group's entries. The labels come from the same
|
||||
// `effectchain::group_label` resolution the effect library uses, so both
|
||||
// surfaces name a group alike.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One row of the add-effect menu.
|
||||
enum AddMenuRow<'a> {
|
||||
/// A group's header: the raw group key (`None` for an entry without a
|
||||
/// group, i.e. a built-in from before the category grouping) and its
|
||||
/// display label.
|
||||
Header {
|
||||
/// The group key, the same value [`EffectEntry::group`] holds.
|
||||
key: Option<&'a str>,
|
||||
/// The localized label.
|
||||
label: String,
|
||||
},
|
||||
/// One addable effect.
|
||||
Effect(&'a EffectEntry),
|
||||
}
|
||||
|
||||
/// Flattens `entries` — in the order the engine yields them, groups
|
||||
/// contiguous and sorted — into header + entry rows.
|
||||
fn add_menu_rows(entries: &[EffectEntry]) -> Vec<AddMenuRow<'_>> {
|
||||
let mut rows = Vec::with_capacity(entries.len() + 8);
|
||||
let mut last: Option<Option<&str>> = None;
|
||||
for entry in entries {
|
||||
let group = entry.group.as_deref();
|
||||
if last != Some(group) {
|
||||
last = Some(group);
|
||||
rows.push(AddMenuRow::Header {
|
||||
key: group,
|
||||
label: match group {
|
||||
Some(group) => group_label(group),
|
||||
None => crate::i18n::tr("effect_library.group.builtin").to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
rows.push(AddMenuRow::Effect(entry));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The inspector implements no focused-panel commands: everything falls
|
||||
/// through to the shell's global handler.
|
||||
impl<E: AppEngine> PanelCommandHandler for InspectorPanel<E> {}
|
||||
@@ -378,4 +443,145 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The add menu flattens the engine's table into one header per group,
|
||||
/// labelled through the same resolution the effect library uses, with
|
||||
/// the entries keeping the engine's (grouped, sorted) order.
|
||||
#[test]
|
||||
fn add_menu_rows_emit_one_header_per_group() {
|
||||
let entries = vec![
|
||||
EffectEntry {
|
||||
type_id: "oak:first".to_string(),
|
||||
name: "First".to_string(),
|
||||
group: Some("color".to_string()),
|
||||
},
|
||||
EffectEntry {
|
||||
type_id: "oak:second".to_string(),
|
||||
name: "Second".to_string(),
|
||||
group: Some("color".to_string()),
|
||||
},
|
||||
EffectEntry {
|
||||
type_id: "ofx:plugin".to_string(),
|
||||
name: "Plugin".to_string(),
|
||||
group: Some("Filter".to_string()),
|
||||
},
|
||||
EffectEntry {
|
||||
type_id: "oak:ungrouped".to_string(),
|
||||
name: "Ungrouped".to_string(),
|
||||
group: None,
|
||||
},
|
||||
];
|
||||
let shape: Vec<String> = add_menu_rows(&entries)
|
||||
.iter()
|
||||
.map(|row| match row {
|
||||
AddMenuRow::Header { key, label } => {
|
||||
format!("header:{}:{label}", key.unwrap_or("-"))
|
||||
}
|
||||
AddMenuRow::Effect(entry) => format!("effect:{}", entry.type_id),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
vec![
|
||||
format!("header:color:{}", group_label("color")),
|
||||
"effect:oak:first".to_string(),
|
||||
"effect:oak:second".to_string(),
|
||||
format!("header:Filter:{}", group_label("Filter")),
|
||||
"effect:ofx:plugin".to_string(),
|
||||
format!(
|
||||
"header:-:{}",
|
||||
crate::i18n::tr("effect_library.group.builtin")
|
||||
),
|
||||
"effect:oak:ungrouped".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{Modifiers, px, size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// The panel's context-menu routing (card menu target, every local
|
||||
/// item, no-target guard), the pending add-menu render and the dock
|
||||
/// metadata.
|
||||
#[gpui::test]
|
||||
async fn panel_context_and_add_menu_paths(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(420.0), px(500.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
InspectorPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("inspector panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
|
||||
// Dock metadata.
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
assert_eq!(panel.read(app).panel_id(), INSPECTOR);
|
||||
});
|
||||
|
||||
// Right click targets the first effect card and opens the card menu.
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(200.0), px(200.0)),
|
||||
MouseButton::Right,
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the card context menu opens"
|
||||
);
|
||||
|
||||
// Every local item routes; a missing target is a no-op.
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(LOCAL_ENABLE, cx);
|
||||
panel.on_local_menu_item(LOCAL_RENAME, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
panel.on_local_menu_item(9999, cx);
|
||||
panel.context_effect = None;
|
||||
panel.on_local_menu_item(LOCAL_ENABLE, cx);
|
||||
panel.context_effect = Some((EffectId(1), true, true));
|
||||
panel.on_local_menu_item(LOCAL_REMOVE, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// The add-effect menu renders while an add is pending (the mock
|
||||
// always reports a stack target).
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.pending_add = Some(0);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
// The add-menu list itself carries an element id but no debug
|
||||
// selector; its group headers do, so the menu's presence is
|
||||
// asserted through the built-in group header it renders.
|
||||
let menu_rendered = [
|
||||
"add-effect-group-row-color",
|
||||
"add-effect-group-row-filter",
|
||||
"add-effect-group-row-distort",
|
||||
"add-effect-group-row-keying",
|
||||
"add-effect-group-row-generator",
|
||||
"add-effect-group-row-math",
|
||||
]
|
||||
.into_iter()
|
||||
.any(|selector| cx.debug_bounds(selector).is_some());
|
||||
assert!(
|
||||
menu_rendered,
|
||||
"the add menu renders while an add is pending"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.update(|_window, app| panel.read(app).pending_add),
|
||||
Some(0),
|
||||
"the mock engine has a stack target, so the pending add stays open"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ impl<E: AppEngine> DockPanel for MulticamPanel<E> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::MulticamSwitch5;
|
||||
use gpui::{point, size, Focusable, TestAppContext, VisualTestContext};
|
||||
use gpui::{size, Focusable, TestAppContext, VisualTestContext};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -538,7 +538,7 @@ mod tests {
|
||||
async fn panel_renders_the_mock_grid(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
|
||||
let engine = cx.new(crate::oakui::MockEngine::demo);
|
||||
let clock = engine.read(cx).program_clock().clone();
|
||||
MulticamPanel::new(engine, clock, window, cx)
|
||||
});
|
||||
|
||||
@@ -27,20 +27,20 @@
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::node_graph::{
|
||||
NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeId, NodeVisualState, MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
EdgeData, EdgeId, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeId,
|
||||
NodeVisualState, MAX_ZOOM, MIN_ZOOM,
|
||||
};
|
||||
use gpui::{
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
|
||||
EventEmitter, MouseButton, Pixels, Point, Render, SharedString, Window,
|
||||
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, EventEmitter,
|
||||
MouseButton, Pixels, Point, Render, SharedString, Window,
|
||||
};
|
||||
use crate::oakui::component::menu::{Menu, MenuItem};
|
||||
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::component::menu;
|
||||
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
|
||||
use crate::oakui::{AppEngine, NodeLibraryEntry};
|
||||
use crate::panels::commands::PanelCommandHandler;
|
||||
use crate::panels::ids::NODE_EDITOR;
|
||||
@@ -79,10 +79,15 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let graph = cx.new(|cx| NodeGraphView::new(engine.clone(), window, cx));
|
||||
// The "edits are requests" loop: every graph gesture goes to the
|
||||
// engine, which applies it to its model and notifies.
|
||||
// engine, which applies it to its model and notifies. The one
|
||||
// exception is the fixed endpoint pair, which the panel filters out
|
||||
// of delete requests before the engine can see them.
|
||||
cx.subscribe(&graph, |this, _graph, event: &NodeGraphEvent, cx| {
|
||||
let Some(event) = this.filter_delete_request(event, cx) else {
|
||||
return;
|
||||
};
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.apply_node_graph_event(event, cx));
|
||||
.update(cx, |engine, cx| engine.apply_node_graph_event(&event, cx));
|
||||
})
|
||||
.detach();
|
||||
// The panel-side half of the graph events: the context menus.
|
||||
@@ -99,8 +104,9 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
this.context_menu.show(window_position, menu, cx);
|
||||
}
|
||||
}
|
||||
NodeGraphEvent::NodeContextMenuRequested { position, .. } => {
|
||||
this.context_menu.show(*position, node_menu(), cx);
|
||||
NodeGraphEvent::NodeContextMenuRequested { node, position } => {
|
||||
let protected = this.engine.read(cx).protected_graph_nodes().contains(node);
|
||||
this.context_menu.show(*position, node_menu(protected), cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -135,6 +141,53 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
panel
|
||||
}
|
||||
|
||||
/// The UI-layer guard for the fixed endpoint pair: a graph gesture that
|
||||
/// names a protected node (the `GraphInput` / `GraphOutput` cards the
|
||||
/// engine reports) never reaches the engine's model. Only delete requests
|
||||
/// can name nodes today, so every other event passes through untouched; a
|
||||
/// delete request that names an endpoint is narrowed to the rest of the
|
||||
/// selection — the endpoints stay, and so do the wires hanging off them.
|
||||
/// A request left with nothing to delete is dropped whole (`None`).
|
||||
fn filter_delete_request(&self, event: &NodeGraphEvent, cx: &App) -> Option<NodeGraphEvent> {
|
||||
let NodeGraphEvent::DeleteRequested { nodes, edges } = event else {
|
||||
return Some(event.clone());
|
||||
};
|
||||
let protected = self.engine.read(cx).protected_graph_nodes();
|
||||
if protected.is_empty() || !nodes.iter().any(|node| protected.contains(node)) {
|
||||
return Some(event.clone());
|
||||
}
|
||||
let kept_nodes: Vec<NodeId> = nodes
|
||||
.iter()
|
||||
.filter(|node| !protected.contains(node))
|
||||
.copied()
|
||||
.collect();
|
||||
// The widget packs every edge incident to a deleted node into the
|
||||
// request; an edge that touches a surviving endpoint would be
|
||||
// stranded, so it stays with its node.
|
||||
let stranded: Vec<EdgeId> = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.edges()
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
protected.contains(&edge.from_node()) || protected.contains(&edge.to_node())
|
||||
})
|
||||
.map(|edge| edge.id())
|
||||
.collect();
|
||||
let kept_edges: Vec<EdgeId> = edges
|
||||
.iter()
|
||||
.filter(|edge| !stranded.contains(edge))
|
||||
.copied()
|
||||
.collect();
|
||||
if kept_nodes.is_empty() && kept_edges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(NodeGraphEvent::DeleteRequested {
|
||||
nodes: kept_nodes,
|
||||
edges: kept_edges,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pushes the engine's selection mirror into the graph widget: the
|
||||
/// single selected node becomes the widget selection, so a timeline
|
||||
/// clip selection highlights that clip's block node and an inspector
|
||||
@@ -150,8 +203,9 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
let Some(node) = node else {
|
||||
return;
|
||||
};
|
||||
self.graph
|
||||
.update(cx, |graph, cx| graph.set_selection(BTreeSet::from([NodeId(node)]), cx));
|
||||
self.graph.update(cx, |graph, cx| {
|
||||
graph.set_selection(BTreeSet::from([NodeId(node)]), cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles the node editor's local (non-registry) context-menu items.
|
||||
@@ -168,9 +222,10 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
.map(|entry| entry.1.clone());
|
||||
if let Some(type_id) = type_id {
|
||||
let position = self.add_node_position.unwrap_or_default();
|
||||
if let Err(err) = self.engine.update(cx, |engine, cx| {
|
||||
engine.add_node_at(&type_id, position, cx)
|
||||
}) {
|
||||
if let Err(err) = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.add_node_at(&type_id, position, cx))
|
||||
{
|
||||
println!("[node editor] add node failed: {err}");
|
||||
}
|
||||
}
|
||||
@@ -341,8 +396,7 @@ impl<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
.graph_position_at(window.mouse_position());
|
||||
let type_id = payload.type_id.to_string();
|
||||
this.engine.update(cx, |engine, cx| {
|
||||
if let Err(err) = engine.add_node_at(&type_id, graph_position, cx)
|
||||
{
|
||||
if let Err(err) = engine.add_node_at(&type_id, graph_position, cx) {
|
||||
println!("[node editor] add node failed: {err}");
|
||||
}
|
||||
});
|
||||
@@ -440,21 +494,33 @@ const LOCAL_ADD_NODE_BASE: usize = 2420;
|
||||
|
||||
/// The node context menu: the shared edit section, grouping, color labels,
|
||||
/// viewer/parameter-editor reveals and properties (the C++ node branch).
|
||||
pub(crate) fn node_menu() -> Menu {
|
||||
/// With `protected` (the fixed endpoint pair) the whole edit section is left
|
||||
/// out: the endpoints cannot be cut, copied, pasted over, duplicated,
|
||||
/// renamed or deleted, so none of those entries are offered.
|
||||
pub(crate) fn node_menu(protected: bool) -> Menu {
|
||||
use crate::i18n::tr;
|
||||
let mut items = menu::edit_section(false);
|
||||
if let Some(last) = items.last_mut() {
|
||||
last.separator_after = true;
|
||||
let mut items = Vec::new();
|
||||
if !protected {
|
||||
items = menu::edit_section(false);
|
||||
if let Some(last) = items.last_mut() {
|
||||
last.separator_after = true;
|
||||
}
|
||||
}
|
||||
items.push(MenuItem::new(LOCAL_GROUP, tr("node.context.group")));
|
||||
items.push(MenuItem::new(LOCAL_UNGROUP, tr("node.context.ungroup")));
|
||||
items.push(menu::color_label_item(None).separated());
|
||||
items.push(MenuItem::new(LOCAL_OPEN_IN_VIEWER, tr("node.context.open_in_viewer")));
|
||||
items.push(MenuItem::new(
|
||||
LOCAL_OPEN_IN_VIEWER,
|
||||
tr("node.context.open_in_viewer"),
|
||||
));
|
||||
items.push(MenuItem::new(
|
||||
LOCAL_SHOW_IN_PARAM_EDITOR,
|
||||
tr("node.context.show_in_param_editor"),
|
||||
));
|
||||
items.push(MenuItem::new(LOCAL_NODE_PROPERTIES, tr("menu.context.properties")));
|
||||
items.push(MenuItem::new(
|
||||
LOCAL_NODE_PROPERTIES,
|
||||
tr("menu.context.properties"),
|
||||
));
|
||||
Menu::new(items)
|
||||
}
|
||||
|
||||
@@ -479,27 +545,21 @@ pub(crate) fn background_menu(
|
||||
let mut add_items: Vec<MenuItem> = Vec::new();
|
||||
let mut next_id = LOCAL_ADD_NODE_BASE;
|
||||
for (category_key, mut entries) in groups {
|
||||
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
entries.sort_by_key(|a| a.name.to_lowercase());
|
||||
let mut submenu = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
submenu.push(MenuItem::new(next_id, entry.name.clone()));
|
||||
add_menu_ids.push((next_id, entry.type_id));
|
||||
next_id += 1;
|
||||
}
|
||||
add_items.push(
|
||||
MenuItem::new(0, tr(category_key)).with_submenu(Menu::new(submenu)),
|
||||
);
|
||||
add_items.push(MenuItem::new(0, tr(category_key)).with_submenu(Menu::new(submenu)));
|
||||
}
|
||||
|
||||
let direction_menu = Menu::new(vec![
|
||||
MenuItem::new(LOCAL_DIR_TOP_BOTTOM, tr("node.context.dir_top_bottom"))
|
||||
.with_checked(true),
|
||||
MenuItem::new(LOCAL_DIR_BOTTOM_TOP, tr("node.context.dir_bottom_top"))
|
||||
.with_checked(false),
|
||||
MenuItem::new(LOCAL_DIR_LEFT_RIGHT, tr("node.context.dir_left_right"))
|
||||
.with_checked(false),
|
||||
MenuItem::new(LOCAL_DIR_RIGHT_LEFT, tr("node.context.dir_right_left"))
|
||||
.with_checked(false),
|
||||
MenuItem::new(LOCAL_DIR_TOP_BOTTOM, tr("node.context.dir_top_bottom")).with_checked(true),
|
||||
MenuItem::new(LOCAL_DIR_BOTTOM_TOP, tr("node.context.dir_bottom_top")).with_checked(false),
|
||||
MenuItem::new(LOCAL_DIR_LEFT_RIGHT, tr("node.context.dir_left_right")).with_checked(false),
|
||||
MenuItem::new(LOCAL_DIR_RIGHT_LEFT, tr("node.context.dir_right_left")).with_checked(false),
|
||||
]);
|
||||
|
||||
Menu::new(vec![
|
||||
@@ -516,8 +576,11 @@ pub(crate) fn background_menu(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::ActionId;
|
||||
use crate::oakui::nodegraph::GraphEndpoint;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::effect_stack::{EffectId, EffectStackEvent};
|
||||
use gpui::node_graph::{NodeGraphDataSource, PortData, PortId};
|
||||
use gpui::{size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext` for
|
||||
@@ -530,7 +593,7 @@ mod tests {
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
|
||||
let engine = cx.new(crate::oakui::MockEngine::demo);
|
||||
NodeEditorPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
@@ -576,9 +639,11 @@ mod tests {
|
||||
fn node_menu_carries_grouping_and_reveals() {
|
||||
// The color-label lookup below matches on a localized label: pin
|
||||
// en-US under the shared language lock.
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("en-US");
|
||||
let menu = node_menu();
|
||||
let menu = node_menu(false);
|
||||
let ids: Vec<usize> = menu.items.iter().map(|item| item.id).collect();
|
||||
assert!(ids.contains(&LOCAL_GROUP));
|
||||
assert!(ids.contains(&LOCAL_UNGROUP));
|
||||
@@ -699,7 +764,12 @@ mod tests {
|
||||
cx.update(|_window, app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_effect_event(&EffectStackEvent::CardSelected { effect: EffectId(1) }, cx);
|
||||
engine.apply_effect_event(
|
||||
&EffectStackEvent::CardSelected {
|
||||
effect: EffectId(1),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
@@ -710,4 +780,208 @@ mod tests {
|
||||
"the card click highlights the matching node (got {selection:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// The demo graph carries the fixed endpoint pair as real graph data: two
|
||||
/// endpoint-marked cards, the input one with the output port the walk
|
||||
/// pulls from, the output one a pure sink. Both are reported as protected,
|
||||
/// and the connection rules follow the ports — the input's `feed_in` takes
|
||||
/// a wire, its output starts one, and the sink can never source a wire
|
||||
/// (it has no output port to drag from).
|
||||
#[gpui::test]
|
||||
async fn demo_endpoints_are_protected_and_marked(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
let protected = cx.read(|app| panel.read(app).engine.read(app).protected_graph_nodes());
|
||||
assert_eq!(protected.len(), 2, "the endpoint pair is protected");
|
||||
|
||||
let nodes = cx.read(|app| panel.read(app).engine.read(app).nodes());
|
||||
assert_eq!(
|
||||
nodes
|
||||
.iter()
|
||||
.filter(|node| node.endpoint().is_some())
|
||||
.count(),
|
||||
2,
|
||||
"exactly the fixed pair is marked as endpoints"
|
||||
);
|
||||
let input = nodes
|
||||
.iter()
|
||||
.find(|node| node.endpoint() == Some(GraphEndpoint::Input))
|
||||
.expect("the demo graph has a graph-input endpoint");
|
||||
let output = nodes
|
||||
.iter()
|
||||
.find(|node| node.endpoint() == Some(GraphEndpoint::Output))
|
||||
.expect("the demo graph has a graph-output endpoint");
|
||||
assert!(protected.contains(&input.id()));
|
||||
assert!(protected.contains(&output.id()));
|
||||
assert_eq!((input.inputs().len(), input.outputs().len()), (1, 1));
|
||||
assert_eq!((output.inputs().len(), output.outputs().len()), (1, 0));
|
||||
|
||||
let can_connect = |from: PortId, to: PortId| {
|
||||
cx.read(|app| panel.read(app).engine.read(app).can_connect(from, to))
|
||||
};
|
||||
let feed_in = input.inputs()[0].id();
|
||||
let tex_out = input.outputs()[0].id();
|
||||
let tex_in = output.inputs()[0].id();
|
||||
assert!(can_connect(PortId(42), feed_in), "feed_in accepts a wire");
|
||||
assert!(
|
||||
can_connect(tex_out, PortId(22)),
|
||||
"the input endpoint sources a wire"
|
||||
);
|
||||
assert!(
|
||||
!can_connect(tex_in, PortId(42)),
|
||||
"the sink has no output port, so no wire can leave it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The UI-layer guard over the endpoint pair: a delete request that names
|
||||
/// an endpoint is narrowed by the panel before the engine sees it — the
|
||||
/// endpoints (and the wires hanging off them) survive, the rest of the
|
||||
/// request still lands, and a request left with nothing to delete is
|
||||
/// dropped whole. The last step goes behind the panel to show the model
|
||||
/// itself has no protection: the panel is the only thing standing between
|
||||
/// a Delete/Backspace gesture and the pair.
|
||||
#[gpui::test]
|
||||
async fn delete_requests_cannot_remove_endpoints(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
// The widget's Delete/Backspace path: the request is emitted by the
|
||||
// graph entity (a node selection plus the incident edges it packs),
|
||||
// exactly as the widget emits it.
|
||||
let emit_delete = |cx: &mut VisualTestContext, nodes: Vec<NodeId>, edges: Vec<EdgeId>| {
|
||||
let graph = cx.read(|app| panel.read(app).graph.clone());
|
||||
graph.update(cx, |_graph, cx| {
|
||||
cx.emit(NodeGraphEvent::DeleteRequested { nodes, edges })
|
||||
});
|
||||
cx.run_until_parked();
|
||||
};
|
||||
let counts = |cx: &VisualTestContext| {
|
||||
cx.read(|app| {
|
||||
let engine = panel.read(app).engine.read(app);
|
||||
(engine.nodes().len(), engine.edges().len())
|
||||
})
|
||||
};
|
||||
assert_eq!(counts(cx), (8, 6));
|
||||
|
||||
// A mixed request: the ordinary node goes, the endpoint stays — and so
|
||||
// does the wire it carries, even though the request named it.
|
||||
emit_delete(cx, vec![NodeId(2), NodeId(6)], vec![EdgeId(6)]);
|
||||
assert_eq!(counts(cx), (7, 4));
|
||||
let input_survived = cx.read(|app| {
|
||||
panel
|
||||
.read(app)
|
||||
.engine
|
||||
.read(app)
|
||||
.nodes()
|
||||
.iter()
|
||||
.any(|node| node.endpoint() == Some(GraphEndpoint::Input))
|
||||
});
|
||||
assert!(input_survived, "the endpoint survives the mixed request");
|
||||
|
||||
// A request naming only the pair is dropped whole: nothing changes.
|
||||
emit_delete(cx, vec![NodeId(6), NodeId(7)], Vec::new());
|
||||
assert_eq!(counts(cx), (7, 4));
|
||||
|
||||
// A plain edge deletion names no node and passes through untouched.
|
||||
emit_delete(cx, Vec::new(), vec![EdgeId(4)]);
|
||||
assert_eq!(counts(cx), (7, 3));
|
||||
|
||||
// Behind the panel, the mock model deletes an endpoint happily, so the
|
||||
// survival above is the panel's doing.
|
||||
cx.update(|_window, app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_node_graph_event(
|
||||
&NodeGraphEvent::DeleteRequested {
|
||||
nodes: vec![NodeId(6)],
|
||||
edges: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(counts(cx), (6, 2));
|
||||
}
|
||||
|
||||
/// The endpoint cards get the protected node menu: the whole edit section
|
||||
/// (undo/redo, cut/copy/paste, duplicate, rename, delete) is left out,
|
||||
/// while the grouping, reveal and properties entries stay. The ordinary
|
||||
/// node menu keeps its edit entries.
|
||||
#[test]
|
||||
fn protected_node_menu_has_no_edit_section() {
|
||||
let ids = |menu: &Menu| -> Vec<usize> { menu.items.iter().map(|item| item.id).collect() };
|
||||
|
||||
let protected = node_menu(true);
|
||||
let protected_ids = ids(&protected);
|
||||
let edit_items = [
|
||||
ActionId::Undo,
|
||||
ActionId::Redo,
|
||||
ActionId::Cut,
|
||||
ActionId::Copy,
|
||||
ActionId::Paste,
|
||||
ActionId::PasteInsert,
|
||||
ActionId::Duplicate,
|
||||
ActionId::Rename,
|
||||
ActionId::Delete,
|
||||
];
|
||||
for action in edit_items {
|
||||
assert!(
|
||||
!protected_ids.contains(&action.menu_id()),
|
||||
"the endpoint menu hides {action:?}"
|
||||
);
|
||||
}
|
||||
assert!(protected_ids.contains(&LOCAL_GROUP));
|
||||
assert!(protected_ids.contains(&LOCAL_UNGROUP));
|
||||
assert!(protected_ids.contains(&LOCAL_OPEN_IN_VIEWER));
|
||||
assert!(protected_ids.contains(&LOCAL_SHOW_IN_PARAM_EDITOR));
|
||||
assert!(protected_ids.contains(&LOCAL_NODE_PROPERTIES));
|
||||
|
||||
let ordinary_ids = ids(&node_menu(false));
|
||||
for action in edit_items {
|
||||
assert!(
|
||||
ordinary_ids.contains(&action.menu_id()),
|
||||
"an ordinary node menu offers {action:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local menu routing (color labels, add-node entries, unknown ids),
|
||||
/// the fit/zoom viewport helpers and the dock metadata.
|
||||
#[gpui::test]
|
||||
async fn menu_fit_zoom_and_dock_paths(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
cx.update(|window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
// A color-label id takes the early return.
|
||||
let color_item = (0..4096)
|
||||
.find(|item| menu::color_label_index(*item).is_some())
|
||||
.expect("a color label id exists");
|
||||
panel.on_local_menu_item(color_item, cx);
|
||||
|
||||
// An add-node entry (the mock engine rejects the add) and an
|
||||
// unknown id both return without panicking.
|
||||
panel.add_menu_ids.push((
|
||||
LOCAL_ADD_NODE_BASE + 41,
|
||||
"oak:test-node".to_string(),
|
||||
));
|
||||
panel.on_local_menu_item(LOCAL_ADD_NODE_BASE + 41, cx);
|
||||
panel.on_local_menu_item(LOCAL_ADD_NODE_BASE + 42, cx);
|
||||
panel.on_local_menu_item(0, cx);
|
||||
|
||||
// Fit/zoom/viewport helpers.
|
||||
assert!(panel.graph_bounds(cx).is_some());
|
||||
let _ = panel.fit_viewport(window, cx);
|
||||
panel.fit_graph(window, cx);
|
||||
panel.zoom(1.25, window, cx);
|
||||
panel.zoom(0.5, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
assert_eq!(panel.read(app).panel_id(), NODE_EDITOR);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3789
-361
File diff suppressed because it is too large
Load Diff
@@ -138,45 +138,55 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
// Route every transport request to the engine's program monitor, and
|
||||
// forward the picture's pointer/key events to the active OFX interact
|
||||
// (no-op when none is live).
|
||||
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| match event {
|
||||
ViewerEvent::InteractPointer {
|
||||
kind,
|
||||
position,
|
||||
button,
|
||||
pressed,
|
||||
} => this.forward_interact_pointer(*kind, *position, *button, *pressed, cx),
|
||||
ViewerEvent::InteractKey { down, keystroke } => {
|
||||
this.forward_interact_key(*down, keystroke, cx)
|
||||
}
|
||||
// The widget already toggled its own overlay state; nothing to
|
||||
// forward to the engine.
|
||||
ViewerEvent::ToggleSafeFramesRequested { .. } | ViewerEvent::ToggleZoomRequested { .. } => {}
|
||||
// The loop in/out range is the shell-owned program workarea, so
|
||||
// the panel re-emits the request.
|
||||
ViewerEvent::InPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetInPoint),
|
||||
ViewerEvent::OutPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetOutPoint),
|
||||
ViewerEvent::ClearRangeRequested { .. } => cx.emit(menu::ViewerPanelEvent::ClearRange),
|
||||
// The OFX color picker armed the eyedropper and the user clicked
|
||||
// the frame: hand the sampled colour back to the engine's mailbox.
|
||||
ViewerEvent::EyedropperPick { color } => {
|
||||
this.engine
|
||||
.update(cx, |engine, cx| engine.eyedropper_picked(*color, cx))
|
||||
}
|
||||
event => {
|
||||
let monitor = Monitor::Program;
|
||||
this.engine.update(cx, |engine, cx| match event {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
|
||||
other => println!("[program viewer] request: {other:?}"),
|
||||
});
|
||||
}
|
||||
})
|
||||
cx.subscribe(
|
||||
&viewer,
|
||||
|this, _viewer, event: &ViewerEvent, cx| match event {
|
||||
ViewerEvent::InteractPointer {
|
||||
kind,
|
||||
position,
|
||||
button,
|
||||
pressed,
|
||||
} => this.forward_interact_pointer(*kind, *position, *button, *pressed, cx),
|
||||
ViewerEvent::InteractKey { down, keystroke } => {
|
||||
this.forward_interact_key(*down, keystroke, cx)
|
||||
}
|
||||
// The widget already toggled its own overlay state; nothing to
|
||||
// forward to the engine.
|
||||
ViewerEvent::ToggleSafeFramesRequested { .. }
|
||||
| ViewerEvent::ToggleZoomRequested { .. } => {}
|
||||
// The loop in/out range is the shell-owned program workarea, so
|
||||
// the panel re-emits the request.
|
||||
ViewerEvent::InPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetInPoint),
|
||||
ViewerEvent::OutPointRequested { .. } => {
|
||||
cx.emit(menu::ViewerPanelEvent::SetOutPoint)
|
||||
}
|
||||
ViewerEvent::ClearRangeRequested { .. } => {
|
||||
cx.emit(menu::ViewerPanelEvent::ClearRange)
|
||||
}
|
||||
// The OFX color picker armed the eyedropper and the user clicked
|
||||
// the frame: hand the sampled colour back to the engine's mailbox.
|
||||
ViewerEvent::EyedropperPick { color } => this
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.eyedropper_picked(*color, cx)),
|
||||
event => {
|
||||
let monitor = Monitor::Program;
|
||||
this.engine.update(cx, |engine, cx| match event {
|
||||
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
|
||||
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
|
||||
ViewerEvent::StepRequested { delta, .. } => {
|
||||
engine.step(monitor, *delta, cx)
|
||||
}
|
||||
other => println!("[program viewer] request: {other:?}"),
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// Re-render whenever the engine's global eyedropper arm state flips,
|
||||
// so `sync_frame` mirrors it into the viewer widget.
|
||||
cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach();
|
||||
cx.observe(&engine, |_this, _engine, cx| cx.notify())
|
||||
.detach();
|
||||
|
||||
// A throttled idle pump for the OFX interact: the plugin's UI work
|
||||
// loop is served on the viewer's own timer (the app tick is
|
||||
@@ -198,8 +208,7 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
})
|
||||
.detach();
|
||||
|
||||
let context_menu =
|
||||
ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
|
||||
let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
|
||||
|
||||
let scope_state = cx.new(|_cx| ScopeState {
|
||||
luma: Vec::new(),
|
||||
@@ -246,11 +255,13 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
match action {
|
||||
menu::ViewerMenuAction::ZoomFit => {
|
||||
let zoom = ViewerZoom::Fit;
|
||||
self.viewer.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
|
||||
}
|
||||
menu::ViewerMenuAction::ZoomLevel(index) => {
|
||||
let zoom = ViewerZoom::Level(index);
|
||||
self.viewer.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
|
||||
}
|
||||
menu::ViewerMenuAction::Resolution(divider) => self
|
||||
.engine
|
||||
@@ -280,7 +291,8 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
}),
|
||||
menu::ViewerMenuAction::ShowFps => {
|
||||
let show = self.viewer.read(cx).show_fps();
|
||||
self.viewer.update(cx, |viewer, cx| viewer.set_show_fps(!show, cx));
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_show_fps(!show, cx));
|
||||
}
|
||||
menu::ViewerMenuAction::SaveFrame => self.save_frame(cx),
|
||||
menu::ViewerMenuAction::FullScreen => {
|
||||
@@ -306,7 +318,11 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
.filter(|dir| std::fs::create_dir_all(dir).is_ok())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let path = dir.join(name);
|
||||
match self.engine.read(cx).save_frame(Monitor::Program, path.clone(), cx) {
|
||||
match self
|
||||
.engine
|
||||
.read(cx)
|
||||
.save_frame(Monitor::Program, path.clone(), cx)
|
||||
{
|
||||
Ok(path) => println!("[program viewer] saved frame to {}", path.display()),
|
||||
Err(error) => println!("[program viewer] save frame failed: {error}"),
|
||||
}
|
||||
@@ -335,7 +351,8 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
// cursor (the setter no-ops when the state is unchanged, so a paused
|
||||
// viewer stays inert).
|
||||
let armed = self.engine.read(cx).eyedropper_armed(cx);
|
||||
self.viewer.update(cx, |viewer, cx| viewer.set_eyedropper_armed(armed, cx));
|
||||
self.viewer
|
||||
.update(cx, |viewer, cx| viewer.set_eyedropper_armed(armed, cx));
|
||||
|
||||
// Keep the main-process interact in sync with the inspector's
|
||||
// current selection (creates/destroys the interact as the target
|
||||
@@ -380,7 +397,7 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
if let Some(bytes) = displayed.as_bytes(0) {
|
||||
let (w, h) = (sz.width.0 as usize, sz.height.0 as usize);
|
||||
let mut ppm = format!("P6\n{w} {h}\n255\n").into_bytes();
|
||||
for px in bytes[..w * h * 4].chunks_exact(4) {
|
||||
for px in bytes[..w * h * 4].as_chunks::<4>().0 {
|
||||
ppm.extend_from_slice(&[px[2], px[1], px[0]]);
|
||||
}
|
||||
let _ = std::fs::write("/tmp/oak_viewer_frame.ppm", ppm);
|
||||
@@ -406,10 +423,8 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
return frame.clone();
|
||||
};
|
||||
let frame_size = frame.size(0);
|
||||
let viewport = InteractViewport::at_frame_size(
|
||||
frame_size.width.0 as u32,
|
||||
frame_size.height.0 as u32,
|
||||
);
|
||||
let viewport =
|
||||
InteractViewport::at_frame_size(frame_size.width.0 as u32, frame_size.height.0 as u32);
|
||||
let time = self.playhead_seconds(cx);
|
||||
|
||||
// Redraw when the base frame, the target instance, the playhead, or
|
||||
@@ -428,11 +443,7 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
});
|
||||
if stale {
|
||||
if let Some(image) = crate::oakui::ofx::draw_interact_composite(
|
||||
instance,
|
||||
&interact,
|
||||
&viewport,
|
||||
time,
|
||||
frame,
|
||||
instance, &interact, &viewport, time, frame,
|
||||
) {
|
||||
self.overlay = Some(OverlayComposite {
|
||||
frame: frame.clone(),
|
||||
@@ -480,14 +491,9 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
return;
|
||||
};
|
||||
let area = size(f32::from(bounds.size.width), f32::from(bounds.size.height));
|
||||
let frame_size = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.cpu_frame(Monitor::Program, cx)
|
||||
.size(0);
|
||||
let frame_size = self.engine.read(cx).cpu_frame(Monitor::Program, cx).size(0);
|
||||
let frame = size(frame_size.width.0 as f32, frame_size.height.0 as f32);
|
||||
let Some((px, py)) = crate::oakui::ofx::viewport_pixel_to_pen(position, area, frame)
|
||||
else {
|
||||
let Some((px, py)) = crate::oakui::ofx::viewport_pixel_to_pen(position, area, frame) else {
|
||||
// The pointer is in the letterbox (outside the frame rect).
|
||||
return;
|
||||
};
|
||||
@@ -519,7 +525,12 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
/// consumed by any binding. The plugin's return status is deliberately
|
||||
/// not acted on: the app does not steal key repeat or other listeners,
|
||||
/// so the interact is a passive consumer of otherwise-unused keys.
|
||||
fn forward_interact_key(&mut self, down: bool, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
|
||||
fn forward_interact_key(
|
||||
&mut self,
|
||||
down: bool,
|
||||
keystroke: &gpui::Keystroke,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((_, interact, _)) = crate::oakui::ofx::active_interact() else {
|
||||
return;
|
||||
};
|
||||
@@ -781,8 +792,351 @@ impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::component::menu::ViewerMenuAction;
|
||||
use crate::oakui::graphops;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{size, TestAppContext, VisualTestContext};
|
||||
use gpui::timeline::Frame;
|
||||
use gpui::{size, Keystroke, Modifiers, Rgba, TestAppContext, VisualTestContext};
|
||||
use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
/// Serializes the tests that mutate process-global state (HOME, the
|
||||
/// debug env var) and the undo stack.
|
||||
fn stack_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
graphops::test_lock()
|
||||
}
|
||||
|
||||
/// Saves `HOME` and restores it (including the was-unset case) on drop,
|
||||
/// so a panicking assertion cannot leave the process pointed at the
|
||||
/// scratch directory. Callers hold [`stack_lock`] across the mutation.
|
||||
struct HomeGuard(Option<std::ffi::OsString>);
|
||||
|
||||
impl HomeGuard {
|
||||
fn redirect(path: &std::path::Path) -> HomeGuard {
|
||||
let previous = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", path);
|
||||
HomeGuard(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `ProgramViewerPanel` over the demo mock engine in a test
|
||||
/// window.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<ProgramViewerPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let clock = engine.read(cx).program_clock().clone();
|
||||
let meter = cx.new(|cx| AudioLevelMeter::new(30, engine.clone(), window, cx));
|
||||
ProgramViewerPanel::new(engine, clock, meter, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("program viewer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// The panel's viewer subscription routes every event family: the
|
||||
/// interact forwarders (no-op without an interact), the overlay toggles,
|
||||
/// the workarea re-emissions, the eyedropper mailbox and the transport
|
||||
/// requests against the program monitor's clock.
|
||||
#[gpui::test]
|
||||
async fn viewer_events_route_through_the_panel(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
let viewer = cx.update(|_, cx| panel.read(cx).viewer.clone());
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
let clock = cx.update(|_, cx| panel.read(cx).clock.clone());
|
||||
|
||||
for event in [
|
||||
ViewerEvent::InteractPointer {
|
||||
kind: InteractPointerKind::Move,
|
||||
position: Point { x: 1.0, y: 1.0 },
|
||||
button: Some(MouseButton::Left),
|
||||
pressed: false,
|
||||
},
|
||||
ViewerEvent::InteractKey {
|
||||
down: true,
|
||||
keystroke: Keystroke::parse("a").expect("parse"),
|
||||
},
|
||||
ViewerEvent::ToggleSafeFramesRequested { control: 3 },
|
||||
ViewerEvent::ToggleZoomRequested { control: 3 },
|
||||
ViewerEvent::InPointRequested { control: 3 },
|
||||
ViewerEvent::OutPointRequested { control: 3 },
|
||||
ViewerEvent::ClearRangeRequested { control: 3 },
|
||||
ViewerEvent::EyedropperPick {
|
||||
color: Rgba {
|
||||
r: 1.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
},
|
||||
},
|
||||
] {
|
||||
cx.update(|_, cx| viewer.update(cx, |_viewer, cx| cx.emit(event.clone())));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let picked =
|
||||
cx.update(|_, cx| engine.update(cx, |engine, cx| engine.take_eyedropper_result(cx)));
|
||||
assert_eq!(
|
||||
picked,
|
||||
Some(Rgba {
|
||||
r: 1.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
}),
|
||||
"the eyedropper pick lands in the engine's mailbox"
|
||||
);
|
||||
|
||||
// Transport requests reach the program clock.
|
||||
cx.update(|_, cx| {
|
||||
viewer.update(cx, |_viewer, cx| {
|
||||
cx.emit(ViewerEvent::PlayRequested { control: 3 })
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.read(|app| clock.read(app).is_playing()));
|
||||
cx.update(|_, cx| {
|
||||
viewer.update(cx, |_viewer, cx| {
|
||||
cx.emit(ViewerEvent::PauseRequested { control: 3 })
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(!cx.read(|app| clock.read(app).is_playing()));
|
||||
cx.update(|_, cx| {
|
||||
viewer.update(cx, |_viewer, cx| {
|
||||
cx.emit(ViewerEvent::StepRequested {
|
||||
control: 3,
|
||||
delta: 5,
|
||||
})
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(cx.read(|app| clock.read(app).current_frame()), Frame(5));
|
||||
}
|
||||
|
||||
/// The context-menu resolver and every viewer menu action, including the
|
||||
/// Save Frame arm (which writes through the engine's capture path).
|
||||
#[gpui::test]
|
||||
async fn menu_actions_apply_to_viewer_and_engine(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
// A valid local item id resolves through the shared table; an
|
||||
// unknown one logs and returns.
|
||||
let valid = (0..4096)
|
||||
.find(|&item| crate::oakui::component::menu::viewer_menu_action(item).is_some())
|
||||
.expect("the viewer menu has at least one item");
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.on_local_menu_item(999_999, cx);
|
||||
panel.on_local_menu_item(valid, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Every action variant.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.apply_viewer_action(ViewerMenuAction::ZoomFit, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::ZoomLevel(4), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::Resolution(2), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::SafeOff, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::SafeOn, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::SafeCustom, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::StopOnLast, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::Waveform(WaveformMode::Only), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::Waveform(WaveformMode::Both), cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::ShowFps, cx);
|
||||
panel.apply_viewer_action(ViewerMenuAction::FullScreen, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
// The viewer-local actions are observable; the divider/stop-on-last/
|
||||
// waveform actions write the process-global config store (asserted
|
||||
// by the engine's own tests, not here).
|
||||
cx.update(|_, cx| {
|
||||
let panel = panel.read(cx);
|
||||
assert_eq!(panel.viewer.read(cx).zoom(), ViewerZoom::Level(4));
|
||||
assert_eq!(
|
||||
panel.viewer.read(cx).safe_margins(),
|
||||
SafeMargins::Custom(0.9, 0.8)
|
||||
);
|
||||
assert!(panel.viewer.read(cx).show_fps());
|
||||
});
|
||||
let _ = engine;
|
||||
|
||||
// Save Frame: HOME points at a scratch directory so the capture
|
||||
// lands in `$HOME/Pictures` instead of the developer's home.
|
||||
let _lock = stack_lock();
|
||||
let home = std::env::temp_dir().join(format!("oakapp_viewer_home_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
std::fs::create_dir_all(&home).expect("create the scratch home");
|
||||
let _home_guard = HomeGuard::redirect(&home);
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.apply_viewer_action(ViewerMenuAction::SaveFrame, cx)
|
||||
});
|
||||
});
|
||||
let saved = std::fs::read_dir(home.join("Pictures"))
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with("oak-frame-program-"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(saved, "Save Frame writes a PNG into $HOME/Pictures");
|
||||
|
||||
// Non-writable target: the engine's error arm logs instead of
|
||||
// panicking. (Only meaningful when the test user owns the mode
|
||||
// bits; a root run writes through and still passes.)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let pictures = home.join("Pictures");
|
||||
std::fs::set_permissions(&pictures, std::fs::Permissions::from_mode(0o555))
|
||||
.expect("make Pictures read-only");
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.apply_viewer_action(ViewerMenuAction::SaveFrame, cx)
|
||||
});
|
||||
});
|
||||
std::fs::set_permissions(&pictures, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("restore the mode");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// The panel's own mouse handlers: a left click focuses the panel, a
|
||||
/// right click builds the viewer menu state and opens the popup.
|
||||
#[gpui::test]
|
||||
async fn mouse_downs_focus_and_open_the_context_menu(cx: &mut TestAppContext) {
|
||||
let (cx, _panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
let center = gpui::point(px(320.0), px(180.0));
|
||||
cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.simulate_mouse_down(center, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the viewer context menu renders"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every focused-panel transport hook, the tab button click and the
|
||||
/// dock tab content.
|
||||
#[gpui::test]
|
||||
async fn transport_hooks_and_dock_content(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(panel.prev_frame(cx));
|
||||
assert!(panel.next_frame(cx));
|
||||
assert!(panel.go_to_start(cx));
|
||||
assert!(panel.go_to_end(cx));
|
||||
assert!(panel.play_in_to_out(cx));
|
||||
assert!(panel.go_to_in(cx));
|
||||
assert!(panel.go_to_out(cx));
|
||||
assert!(panel.shuttle_left(cx));
|
||||
assert!(panel.shuttle_stop(cx));
|
||||
assert!(panel.shuttle_right(cx));
|
||||
});
|
||||
});
|
||||
let _ = cx.update(|_, cx| panel.read(cx).tab_content(cx));
|
||||
assert_eq!(
|
||||
cx.update(|_, cx| panel.read(cx).panel_id()),
|
||||
crate::panels::ids::PROGRAM_VIEWER
|
||||
);
|
||||
|
||||
// The header's tab buttons switch the body. They are the rightmost
|
||||
// row elements; sweep from the right edge to find them.
|
||||
let mut switched = false;
|
||||
for step in 0..320 {
|
||||
let x = 639.0 - f64::from(step) * 2.0;
|
||||
cx.simulate_click(
|
||||
gpui::point(px(x as f32), px(14.0)),
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
if cx.read(|app| panel.read(app).tab == ProgramViewTab::Scopes) {
|
||||
switched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(switched, "a click on the scopes tab switches the body");
|
||||
}
|
||||
|
||||
/// The frame-sync debug dump, the interact forwarders without an active
|
||||
/// interact, the playhead helper and the idle pump timer.
|
||||
#[gpui::test]
|
||||
async fn sync_frame_debug_and_interact_noops(cx: &mut TestAppContext) {
|
||||
let _lock = stack_lock();
|
||||
let (cx, panel) = panel_window(cx);
|
||||
|
||||
let prev_debug = std::env::var_os("OAK_DEBUG_VIEWER");
|
||||
std::env::set_var("OAK_DEBUG_VIEWER", "1");
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
std::fs::remove_file("/tmp/oak_viewer_frame.ppm").ok();
|
||||
match prev_debug {
|
||||
Some(value) => std::env::set_var("OAK_DEBUG_VIEWER", value),
|
||||
None => std::env::remove_var("OAK_DEBUG_VIEWER"),
|
||||
}
|
||||
|
||||
// The forwarders bail out before touching the picture when no OFX
|
||||
// interact is live.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.forward_interact_pointer(
|
||||
InteractPointerKind::Down,
|
||||
Point { x: 1.0, y: 1.0 },
|
||||
Some(MouseButton::Left),
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
let keystroke = Keystroke::parse("a").expect("parse");
|
||||
panel.forward_interact_key(true, &keystroke, cx);
|
||||
panel.forward_interact_key(false, &keystroke, cx);
|
||||
let _seconds = panel.playhead_seconds(cx);
|
||||
// `displayed_frame` returns the raw frame without an
|
||||
// overlay.
|
||||
let frame = panel.engine.read(cx).cpu_frame(Monitor::Program, cx);
|
||||
let displayed = panel.displayed_frame(&frame, cx);
|
||||
assert!(std::sync::Arc::ptr_eq(&displayed, &frame));
|
||||
});
|
||||
});
|
||||
|
||||
// The idle pump's 50 ms timer runs on the test clock.
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_millis(120));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The scopes tab renders from the mock engine's synthetic frame without
|
||||
/// crashing, and the scope state carries that frame's samples.
|
||||
@@ -790,7 +1144,7 @@ mod tests {
|
||||
async fn scopes_tab_renders_from_the_current_frame(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| MockEngine::demo(cx));
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let clock = engine.read(cx).program_clock().clone();
|
||||
let meter = cx.new(|cx| AudioLevelMeter::new(30, engine.clone(), window, cx));
|
||||
ProgramViewerPanel::new(engine, clock, meter, window, cx)
|
||||
|
||||
@@ -114,6 +114,8 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
if self.engine.read(cx).entry_path(id).is_some() {
|
||||
let proxy = self.engine.read(cx).proxy_row(id);
|
||||
footage_menu(true, proxy.as_ref())
|
||||
} else if self.engine.read(cx).entry_is_sequence(id) {
|
||||
sequence_menu()
|
||||
} else {
|
||||
entry_menu()
|
||||
}
|
||||
@@ -142,6 +144,7 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some(crate::i18n::tr("project.context.replace_footage").into()),
|
||||
allowed_extensions: Vec::new(),
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(Ok(Some(paths))) = receiver.await {
|
||||
@@ -183,6 +186,12 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
println!("[project explorer] properties for non-sequence entry {id} (not implemented yet)");
|
||||
}
|
||||
}
|
||||
LOCAL_EXPORT_SEQUENCE => {
|
||||
let Some(id) = self.context_entry else {
|
||||
return;
|
||||
};
|
||||
cx.emit(ExportSequenceRequested(id));
|
||||
}
|
||||
LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => {
|
||||
let Some(id) = self.context_entry else {
|
||||
return;
|
||||
@@ -249,10 +258,31 @@ impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
.tooltip(move |window, cx| {
|
||||
tooltip_view(crate::i18n::tr("project.new_sequence").into(), window, cx)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||
.on_click(cx.listener(|_this, _event: &ClickEvent, _window, cx| {
|
||||
cx.emit(NewSequenceRequested);
|
||||
}))
|
||||
.child(crate::i18n::tr("project.new_sequence"));
|
||||
// 添加文本素材: creates a text generator (a bin entry that drops on
|
||||
// the timeline as a generator clip).
|
||||
let add_text_button = div()
|
||||
.id("project-add-text-footage")
|
||||
.debug_selector(|| "project-add-text-footage".into())
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded_sm()
|
||||
.flex()
|
||||
.items_center()
|
||||
.cursor_pointer()
|
||||
.text_color(colors.text)
|
||||
.text_xs()
|
||||
.hover(|style| style.bg(colors.selected))
|
||||
.tooltip(move |window, cx| {
|
||||
tooltip_view(crate::i18n::tr("project.add_text_footage").into(), window, cx)
|
||||
})
|
||||
.on_click(cx.listener(|_this, _event: &ClickEvent, _window, cx| {
|
||||
cx.emit(NewTextFootageRequested);
|
||||
}))
|
||||
.child(crate::i18n::tr("project.add_text_footage"));
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -265,6 +295,7 @@ impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.child(div().flex_1().child(crate::i18n::tr("panel.project")))
|
||||
.child(add_text_button)
|
||||
.child(new_sequence_button);
|
||||
div()
|
||||
.size_full()
|
||||
@@ -307,6 +338,13 @@ pub struct NewSequenceRequested;
|
||||
|
||||
impl<E: AppEngine> EventEmitter<NewSequenceRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
/// The project explorer asked the shell to create a text generator bin
|
||||
/// entry (the header's 添加文本素材 action).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NewTextFootageRequested;
|
||||
|
||||
impl<E: AppEngine> EventEmitter<NewTextFootageRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
/// The project explorer asked the shell to rename entry `id`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RenameRequested(pub u64);
|
||||
@@ -319,6 +357,13 @@ pub struct DeleteRequested(pub u64);
|
||||
|
||||
impl<E: AppEngine> EventEmitter<DeleteRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
/// The project explorer asked the shell to open the 导出序列 dialog for
|
||||
/// the given sequence entry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExportSequenceRequested(pub u64);
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ExportSequenceRequested> for ProjectExplorerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROJECT
|
||||
@@ -352,6 +397,7 @@ const LOCAL_PROXY_DELETE: usize = 2208;
|
||||
const LOCAL_RENAME: usize = 2209;
|
||||
const LOCAL_DELETE: usize = 2210;
|
||||
const LOCAL_PROPERTIES: usize = 2211;
|
||||
const LOCAL_EXPORT_SEQUENCE: usize = 2212;
|
||||
|
||||
/// The proxy submenu (shared shape with the timeline's): enable state
|
||||
/// follows the footage's proxy fields (the C++ project explorer gates
|
||||
@@ -447,6 +493,20 @@ pub(crate) fn entry_menu() -> Menu {
|
||||
])
|
||||
}
|
||||
|
||||
/// A sequence entry's context menu: the generic entry items plus 导出序列
|
||||
/// (the 文件 → 导出序列 dialog preselected to this sequence).
|
||||
pub(crate) fn sequence_menu() -> Menu {
|
||||
let mut menu = entry_menu();
|
||||
menu.items.push(
|
||||
MenuItem::new(
|
||||
LOCAL_EXPORT_SEQUENCE,
|
||||
crate::i18n::tr("project.context.export_sequence"),
|
||||
)
|
||||
.separated(),
|
||||
);
|
||||
menu
|
||||
}
|
||||
|
||||
/// Reveals `path` in the platform file manager (Finder on macOS, Explorer
|
||||
/// on Windows, `xdg-open` on the parent directory elsewhere).
|
||||
fn reveal_in_finder(path: &std::path::Path) {
|
||||
@@ -466,6 +526,422 @@ fn reveal_in_finder(path: &std::path::Path) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{px, size, TestAppContext, VisualTestContext};
|
||||
use gpui_widgets::project_explorer::ProjectDataSource;
|
||||
|
||||
/// Serializes the tests that touch process-global state (the undo stack,
|
||||
/// the media/codec library and the panel's HOME override).
|
||||
fn stack_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
crate::oakui::graphops::test_lock()
|
||||
}
|
||||
|
||||
/// Builds a `ProjectExplorerPanel` over the demo mock engine in a test
|
||||
/// window.
|
||||
fn mock_panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<ProjectExplorerPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
ProjectExplorerPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("project explorer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// The widget's requests reach the panel's engine: open selects/opens
|
||||
/// the entry, dropped files import, and a right-click request opens the
|
||||
/// context-menu popup.
|
||||
#[gpui::test]
|
||||
async fn explorer_events_route_to_the_engine_and_open_the_menu(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = mock_panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
let explorer = cx.update(|_, cx| panel.read(cx).explorer.clone());
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
// OpenRequested on a non-sequence entry selects it into the shop
|
||||
// window (the mock reports no sequences).
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::OpenRequested {
|
||||
control: 1,
|
||||
id: 3,
|
||||
name: "第一稿.mp4".into(),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).selected_item()),
|
||||
Some(3),
|
||||
"a footage open request selects the entry"
|
||||
);
|
||||
|
||||
// FileDropRequested imports every dropped path.
|
||||
let dropped = std::env::temp_dir().join("oakapp_explorer_drop.mp4");
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::FileDropRequested {
|
||||
control: 1,
|
||||
paths: vec![dropped.clone()],
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).imported_footage().contains(&dropped)),
|
||||
"the dropped path reaches the engine's import"
|
||||
);
|
||||
|
||||
// ContextMenuRequested opens the popup for the requested entry.
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::ContextMenuRequested {
|
||||
control: 1,
|
||||
id: Some(3),
|
||||
position: gpui::point(px(20.0), px(20.0)),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the requested context menu renders"
|
||||
);
|
||||
}
|
||||
|
||||
/// The full context-menu selection: an empty-area click gets the blank
|
||||
/// menu, footage gets the footage menu (with its proxy state), a
|
||||
/// sequence gets the sequence menu and anything else the entry menu.
|
||||
#[gpui::test]
|
||||
async fn context_menu_selection_follows_the_engine(cx: &mut TestAppContext) {
|
||||
use crate::oakui::real::RealEngine;
|
||||
let _lock = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let media = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/demo.mp4");
|
||||
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
|
||||
let engine = cx.new(RealEngine::create);
|
||||
engine.update(cx, |engine, cx| {
|
||||
engine.new_project(cx);
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Context".into(),
|
||||
crate::oakui::VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create the sequence");
|
||||
engine
|
||||
.import_footage(media.clone(), cx)
|
||||
.expect("import the fixture media");
|
||||
});
|
||||
ProjectExplorerPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("project explorer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
let (seq_id, footage_id) = cx.read(|app| {
|
||||
let engine = engine.read(app);
|
||||
let seq = engine.sequence_entries().first().expect("a sequence entry").0;
|
||||
let footage = engine
|
||||
.roots()
|
||||
.into_iter()
|
||||
.chain(
|
||||
engine
|
||||
.roots()
|
||||
.into_iter()
|
||||
.flat_map(|root| engine.children(root.id)),
|
||||
)
|
||||
.find(|entry| entry.name.as_ref() == "demo.mp4")
|
||||
.expect("the imported footage entry")
|
||||
.id;
|
||||
(seq, footage)
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).entry_is_sequence(seq_id)),
|
||||
"the created sequence is an explorer sequence entry"
|
||||
);
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).entry_path(footage_id).is_some()),
|
||||
"the imported footage has an on-disk path"
|
||||
);
|
||||
|
||||
// A second sequence makes the open-request routing observable: the
|
||||
// engine's current sequence must follow a sequence OpenRequested.
|
||||
let second_seq = cx.update(|_, cx| {
|
||||
engine.update(cx, |engine, cx| {
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Second".into(),
|
||||
crate::oakui::VideoFormat::hd_1080p25(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create the second sequence")
|
||||
})
|
||||
});
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).current_sequence_id()),
|
||||
Some(second_seq),
|
||||
"creating a sequence opens it"
|
||||
);
|
||||
|
||||
// The widget's open / drop requests: a sequence opens, footage
|
||||
// selects, a bogus dropped path logs the first error and a real one
|
||||
// imports.
|
||||
let explorer = cx.update(|_, cx| panel.read(cx).explorer.clone());
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::OpenRequested {
|
||||
control: 1,
|
||||
id: seq_id,
|
||||
name: "Context".into(),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).current_sequence_id()),
|
||||
Some(seq_id),
|
||||
"a sequence open request opens that sequence"
|
||||
);
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::OpenRequested {
|
||||
control: 1,
|
||||
id: footage_id,
|
||||
name: "demo.mp4".into(),
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.read(|app| engine.read(app).current_sequence_id()),
|
||||
Some(seq_id),
|
||||
"a footage open request selects instead of switching the sequence"
|
||||
);
|
||||
|
||||
// The import path counts what is actually in the project: a bogus
|
||||
// dropped path adds nothing (the engine rejects it before any node
|
||||
// lands), a real one adds exactly one entry.
|
||||
let entry_count = |app: &gpui::App| -> usize {
|
||||
let engine = engine.read(app);
|
||||
let roots = engine.roots();
|
||||
let children: usize = roots
|
||||
.iter()
|
||||
.map(|root| engine.children(root.id).len())
|
||||
.sum();
|
||||
roots.len() + children
|
||||
};
|
||||
let entries_before = cx.update(|_, app| entry_count(app));
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::FileDropRequested {
|
||||
control: 1,
|
||||
paths: vec![std::path::PathBuf::from("/nonexistent/oakapp-drop.mp4")],
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, app| entry_count(app)),
|
||||
entries_before,
|
||||
"a missing dropped path imports nothing"
|
||||
);
|
||||
explorer.update(cx, |_explorer, cx| {
|
||||
cx.emit(ProjectExplorerEvent::FileDropRequested {
|
||||
control: 1,
|
||||
paths: vec![media.clone()],
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, app| entry_count(app)),
|
||||
entries_before + 1,
|
||||
"the real dropped path imports exactly one entry"
|
||||
);
|
||||
let demo_entries = cx.read(|app| {
|
||||
let engine = engine.read(app);
|
||||
let roots = engine.roots();
|
||||
let mut names: Vec<String> = roots.iter().map(|e| e.name.to_string()).collect();
|
||||
for root in &roots {
|
||||
names.extend(
|
||||
engine
|
||||
.children(root.id)
|
||||
.into_iter()
|
||||
.map(|e| e.name.to_string()),
|
||||
);
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.filter(|name| name == "demo.mp4")
|
||||
.count()
|
||||
});
|
||||
assert_eq!(
|
||||
demo_entries, 2,
|
||||
"the dropped media is listed a second time under its file name"
|
||||
);
|
||||
|
||||
// Empty area -> the blank menu; footage -> footage menu (with proxy
|
||||
// state); sequence -> sequence menu; anything else -> entry menu.
|
||||
// Pin the engine data each choice keys off, then assert the open
|
||||
// state and the recorded context entry for every choice.
|
||||
assert!(cx.read(|app| engine.read(app).entry_path(footage_id).is_some()));
|
||||
assert!(!cx.read(|app| engine.read(app).entry_is_sequence(footage_id)));
|
||||
assert!(cx.read(|app| engine.read(app).entry_path(seq_id)).is_none());
|
||||
assert!(cx.read(|app| engine.read(app).entry_is_sequence(seq_id)));
|
||||
assert!(cx.read(|app| engine.read(app).entry_path(1)).is_none());
|
||||
assert!(!cx.read(|app| engine.read(app).entry_is_sequence(1)));
|
||||
for id in [None, Some(footage_id), Some(seq_id), Some(1)] {
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_context_menu(id, gpui::point(px(20.0), px(20.0)), cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.update(|_, cx| panel.read(cx).context_entry),
|
||||
id,
|
||||
"the opened menu records its context entry"
|
||||
);
|
||||
let menu = cx.update(|_, cx| panel.read(cx).context_menu.widget());
|
||||
assert!(
|
||||
cx.read(|app| menu.read(app).is_open()),
|
||||
"the requested menu is open for {id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The properties item on a sequence emits the properties request
|
||||
// while the same item on a non-sequence is a logged no-op.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(seq_id);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
panel.context_entry = Some(footage_id);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Every local menu item path: the guard returns without a context
|
||||
/// entry, the emissions fire with one, the prompt-driven replace runs
|
||||
/// its async body (both a selection and a cancellation) and the proxy
|
||||
/// items apply to the mock's footage.
|
||||
#[gpui::test]
|
||||
async fn local_menu_items_apply_and_guard(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = mock_panel_window(cx);
|
||||
let engine = cx.update(|_, cx| panel.read(cx).engine.clone());
|
||||
|
||||
// No context entry: every item returns early (reveal has no path
|
||||
// either, so its inner branch is skipped too).
|
||||
for item in [
|
||||
LOCAL_REVEAL_IN_FINDER,
|
||||
LOCAL_REPLACE_FOOTAGE,
|
||||
LOCAL_RENAME,
|
||||
LOCAL_DELETE,
|
||||
LOCAL_PROPERTIES,
|
||||
LOCAL_EXPORT_SEQUENCE,
|
||||
LOCAL_PROXY_GENERATE,
|
||||
] {
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = None;
|
||||
panel.on_local_menu_item(item, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// With a video footage entry: reveal (the mock has no path),
|
||||
// rename/delete/export emissions, the unimplemented tab entry, the
|
||||
// non-sequence properties log and the proxy actions.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_REVEAL_IN_FINDER, cx);
|
||||
panel.on_local_menu_item(LOCAL_RENAME, cx);
|
||||
panel.on_local_menu_item(LOCAL_DELETE, cx);
|
||||
panel.on_local_menu_item(LOCAL_OPEN_IN_NEW_TAB, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROPERTIES, cx);
|
||||
panel.on_local_menu_item(LOCAL_EXPORT_SEQUENCE, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_USE, cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).proxy_row(10).expect("row").enabled),
|
||||
"the Use Proxy item toggles the footprint's proxy flag on"
|
||||
);
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_USE, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_REVEAL, cx);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_DELETE, cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).proxy_row(10).is_some_and(|row| !row.enabled)),
|
||||
"the second Use Proxy toggles it back off"
|
||||
);
|
||||
|
||||
// Generate succeeds for video, fails for audio-only footage.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_GENERATE, cx);
|
||||
panel.context_entry = Some(20);
|
||||
panel.on_local_menu_item(LOCAL_PROXY_GENERATE, cx);
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
cx.read(|app| engine.read(app).proxy_state(10).is_some()),
|
||||
"generating a video proxy lands a state"
|
||||
);
|
||||
|
||||
// The unknown item logs through the fallback arm.
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.on_local_menu_item(999_999, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Replace footage: cancel the prompt (the async body takes the
|
||||
// non-selection path), then answer it (the mock returns Err, the
|
||||
// logged failure path).
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_REPLACE_FOOTAGE, cx);
|
||||
});
|
||||
});
|
||||
assert!(cx.did_prompt_for_paths(), "the replace item prompts");
|
||||
cx.simulate_path_prompt_response(|_| None);
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update(|_, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.context_entry = Some(10);
|
||||
panel.on_local_menu_item(LOCAL_REPLACE_FOOTAGE, cx);
|
||||
});
|
||||
});
|
||||
cx.simulate_path_prompt_response(|_| {
|
||||
Some(vec![std::env::temp_dir().join("oakapp_explorer_replace.mp4")])
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The panel's dock metadata and the platform reveal helper.
|
||||
#[gpui::test]
|
||||
async fn panel_metadata_and_reveal(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = mock_panel_window(cx);
|
||||
let _ = cx.update(|_, cx| panel.read(cx).tab_content(cx));
|
||||
let _ = cx.update(|_, cx| panel.read(cx).title(cx));
|
||||
reveal_in_finder(std::path::Path::new("/tmp"));
|
||||
}
|
||||
|
||||
/// The blank-area menu is New ▸ (the shared new section) + Import.
|
||||
#[test]
|
||||
@@ -530,4 +1006,28 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The sequence menu appends 导出序列 to the generic entry items.
|
||||
#[test]
|
||||
fn sequence_menu_appends_export() {
|
||||
let menu = sequence_menu();
|
||||
let ids: Vec<usize> = menu.items.iter().map(|item| item.id).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
LOCAL_OPEN_IN_NEW_TAB,
|
||||
LOCAL_OPEN_IN_NEW_WINDOW,
|
||||
LOCAL_RENAME,
|
||||
LOCAL_DELETE,
|
||||
LOCAL_PROPERTIES,
|
||||
LOCAL_EXPORT_SEQUENCE,
|
||||
]
|
||||
);
|
||||
let export = menu.items.last().expect("export item");
|
||||
assert_eq!(
|
||||
export.label,
|
||||
crate::i18n::tr("project.context.export_sequence")
|
||||
);
|
||||
assert!(export.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,3 +341,150 @@ impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{Modifiers, px, size, TestAppContext, VisualTestContext};
|
||||
use gpui_widgets::viewer::PlaybackClock as _;
|
||||
|
||||
/// Builds the panel in a window and returns a `VisualTestContext`.
|
||||
fn panel_window(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
&'static mut VisualTestContext,
|
||||
Entity<SourceViewerPanel<MockEngine>>,
|
||||
) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
|
||||
let engine = cx.new(MockEngine::demo);
|
||||
let clock = engine.read(cx).source_clock().clone();
|
||||
SourceViewerPanel::new(engine, clock, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let panel = window.root(cx).expect("source viewer panel root");
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
(cx, panel)
|
||||
}
|
||||
|
||||
/// Every viewer context-menu action updates the viewer widget state or
|
||||
/// the engine config; a real menu item id resolves through the shared
|
||||
/// menu mapping, unknown ids are ignored.
|
||||
#[gpui::test]
|
||||
async fn menu_actions_update_viewer_and_engine_state(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
panel.on_local_menu_item(9999, cx);
|
||||
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::ZoomFit, cx);
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::ZoomLevel(3), cx);
|
||||
assert_eq!(panel.viewer.read(cx).zoom(), ViewerZoom::Level(3));
|
||||
|
||||
// Resolution/stop-on-last/waveform persist through the
|
||||
// engine's config-backed defaults; exercise the reads and
|
||||
// writes without assuming a fresh config entry type.
|
||||
let _ = panel.engine.read(cx).playback_divider();
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::Resolution(4), cx);
|
||||
let _ = panel.engine.read(cx).playback_divider();
|
||||
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::SafeOff, cx);
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::SafeOn, cx);
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::SafeCustom, cx);
|
||||
|
||||
let _ = panel.engine.read(cx).stop_on_last();
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::StopOnLast, cx);
|
||||
let _ = panel.engine.read(cx).stop_on_last();
|
||||
|
||||
let _ = panel.engine.read(cx).waveform_mode();
|
||||
panel.apply_viewer_action(
|
||||
menu::ViewerMenuAction::Waveform(WaveformMode::Both),
|
||||
cx,
|
||||
);
|
||||
let _ = panel.engine.read(cx).waveform_mode();
|
||||
|
||||
let show_fps = panel.viewer.read(cx).show_fps();
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::ShowFps, cx);
|
||||
assert_eq!(panel.viewer.read(cx).show_fps(), !show_fps);
|
||||
|
||||
panel.apply_viewer_action(menu::ViewerMenuAction::FullScreen, cx);
|
||||
|
||||
// A real menu id from the shared builder resolves and applies.
|
||||
let state = menu::ViewerMenuState {
|
||||
playback_divider: panel.engine.read(cx).playback_divider(),
|
||||
zoom: panel.viewer.read(cx).zoom(),
|
||||
safe: panel.viewer.read(cx).safe_margins(),
|
||||
stop_on_last: panel.engine.read(cx).stop_on_last(),
|
||||
waveform: WaveformMode::from_config_value(
|
||||
panel.engine.read(cx).waveform_mode(),
|
||||
),
|
||||
show_fps: panel.viewer.read(cx).show_fps(),
|
||||
};
|
||||
let menu = menu::viewer_menu(&state);
|
||||
let item = menu.items[0].id;
|
||||
panel.on_local_menu_item(item, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The focused-panel transport commands drive the SOURCE monitor.
|
||||
#[gpui::test]
|
||||
async fn transport_commands_drive_the_source_clock(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|_window, app| {
|
||||
panel.update(app, |panel, cx| {
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(panel.clock.read(cx).is_playing());
|
||||
assert!(panel.play_pause(cx));
|
||||
assert!(!panel.clock.read(cx).is_playing());
|
||||
|
||||
assert!(panel.next_frame(cx));
|
||||
assert!(panel.prev_frame(cx));
|
||||
assert!(panel.go_to_start(cx));
|
||||
assert!(panel.go_to_end(cx));
|
||||
assert!(panel.shuttle_left(cx));
|
||||
assert!(panel.shuttle_stop(cx));
|
||||
assert!(panel.shuttle_right(cx));
|
||||
assert!(panel.play_in_to_out(cx));
|
||||
assert!(panel.go_to_in(cx));
|
||||
assert!(panel.go_to_out(cx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel renders, right-click opens the shared viewer menu, and the
|
||||
/// dock metadata is populated.
|
||||
#[gpui::test]
|
||||
async fn render_right_click_menu_and_dock_metadata(cx: &mut TestAppContext) {
|
||||
let (cx, panel) = panel_window(cx);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
cx.update(|_window, app| {
|
||||
assert!(!panel.read(app).title(app).is_empty());
|
||||
let _ = panel.read(app).tab_content(app);
|
||||
});
|
||||
|
||||
// A right-click anywhere in the panel opens the viewer menu.
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(320.0), px(180.0)),
|
||||
MouseButton::Right,
|
||||
Modifiers::none(),
|
||||
);
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
assert!(
|
||||
cx.debug_bounds("menu-popup").is_some(),
|
||||
"the viewer context menu opens"
|
||||
);
|
||||
|
||||
// A left click focuses the panel (the dock re-emits the event).
|
||||
cx.simulate_mouse_down(
|
||||
gpui::point(px(320.0), px(180.0)),
|
||||
MouseButton::Left,
|
||||
Modifiers::none(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The startup update check and the external oakvideoeditor.org links.
|
||||
//!
|
||||
//! On every launch (unless the user turned it off in the preferences, the
|
||||
//! `CheckForUpdates` config key) the app GETs [`UPDATE_ENDPOINT`], parses
|
||||
//! the `LatestRelease` JSON and — when the remote version is newer than
|
||||
//! [`current_version`] — prompts with a dialog pointing at
|
||||
//! [`DOWNLOAD_URL`]. Network and parse failures are silent (a missing
|
||||
//! update server must never disturb startup); the fetch is blocking and
|
||||
//! runs on the gpui background executor.
|
||||
//!
|
||||
//! The transport sits behind [`UpdateTransport`] so tests can script
|
||||
//! responses without touching the network.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The latest-release endpoint (see the API contract in the module docs
|
||||
/// of the website; the response is the struct below).
|
||||
pub const UPDATE_ENDPOINT: &str = "https://www.oakvideoeditor.org/api/v1/update/latest";
|
||||
/// Where the update dialog sends the user to download the new build.
|
||||
pub const DOWNLOAD_URL: &str = "https://www.oakvideoeditor.org/downloads";
|
||||
/// The bug-report form opened from the Help menu.
|
||||
pub const BUG_REPORT_URL: &str = "https://www.oakvideoeditor.org/bug-report";
|
||||
/// Config key: run the startup update check (preferences toggle, default on).
|
||||
pub const CONFIG_KEY_CHECK_UPDATES: &str = "CheckForUpdates";
|
||||
|
||||
/// One `GET /api/v1/update/latest` response.
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "version": "v1.0.0",
|
||||
/// "tag_name": "v1.0.0",
|
||||
/// "notes": "更新说明(Markdown)",
|
||||
/// "is_prerelease": false,
|
||||
/// "published_at": "2026-09-20T08:00:00+00:00",
|
||||
/// "download_url": "/api/v1/releases/<id>/download?asset_id=<asset_id>"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct LatestRelease {
|
||||
/// The release version (e.g. `v1.0.0`).
|
||||
pub version: String,
|
||||
/// The release tag (usually the same as `version`).
|
||||
#[serde(default)]
|
||||
pub tag_name: String,
|
||||
/// The release notes (Markdown; displayed as plain text).
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
/// Whether the release is flagged as a pre-release.
|
||||
#[serde(default)]
|
||||
pub is_prerelease: bool,
|
||||
/// The publication timestamp (ISO-8601).
|
||||
#[serde(default)]
|
||||
pub published_at: String,
|
||||
/// The download path relative to the API host.
|
||||
#[serde(default)]
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
/// The running app's version (`Cargo.toml` `version`, e.g. `0.5.0`).
|
||||
pub fn current_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// Parses one endpoint body. A missing `version` is rejected (an answer
|
||||
/// without a version cannot drive the comparison).
|
||||
pub fn parse_latest(body: &str) -> Result<LatestRelease, String> {
|
||||
let release: LatestRelease =
|
||||
serde_json::from_str(body).map_err(|e| format!("invalid update response: {e}"))?;
|
||||
if release.version.trim().is_empty() {
|
||||
return Err("the update response carries no version".to_string());
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// Parses `v1.2.3` / `1.2.3-rc.1` / `1.2` into a comparable triple; `None`
|
||||
/// when the string is not a dotted numeric version.
|
||||
pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
|
||||
let trimmed = version.trim();
|
||||
let trimmed = trimmed.strip_prefix(['v', 'V']).unwrap_or(trimmed);
|
||||
// Pre-release/build metadata does not participate in the ordering the
|
||||
// prompt needs (`1.2.3-rc.1` compares as `1.2.3`).
|
||||
let core = trimmed.split(['-', '+']).next().unwrap_or(trimmed);
|
||||
let mut parts = core.split('.');
|
||||
let major = parts.next()?.trim().parse().ok()?;
|
||||
let minor = parts.next().unwrap_or("0").trim().parse().ok()?;
|
||||
let patch = parts.next().unwrap_or("0").trim().parse().ok()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// Whether `remote` names a release newer than `current`.
|
||||
///
|
||||
/// Both sides accept the leading `v`; unparsable versions fall back to a
|
||||
/// plain string inequality (a differently-named release is worth telling
|
||||
/// the user about, per the "different version" contract).
|
||||
pub fn is_newer(remote: &str, current: &str) -> bool {
|
||||
match (parse_version(remote), parse_version(current)) {
|
||||
(Some(remote), Some(current)) => remote > current,
|
||||
_ => {
|
||||
remote.trim().trim_start_matches(['v', 'V']) != current.trim().trim_start_matches(['v', 'V'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the preferences allow the startup check (default on).
|
||||
pub fn check_enabled() -> bool {
|
||||
crate::oakui::real::config_get_bool(CONFIG_KEY_CHECK_UPDATES, true)
|
||||
}
|
||||
|
||||
/// Persists the preferences toggle.
|
||||
pub fn set_check_enabled(enabled: bool) {
|
||||
crate::oakui::real::config_set_bool(CONFIG_KEY_CHECK_UPDATES, enabled);
|
||||
}
|
||||
|
||||
/// Whether a fetched release is worth prompting about (newer than the
|
||||
/// running build).
|
||||
pub fn should_notify(release: &LatestRelease) -> bool {
|
||||
is_newer(&release.version, current_version())
|
||||
}
|
||||
|
||||
/// The transport seam: one blocking GET. Implementations must be cheap to
|
||||
/// clone-free share (`Send + Sync`); the checked-in implementation is
|
||||
/// [`HttpTransport`].
|
||||
pub trait UpdateTransport: Send + Sync {
|
||||
/// GETs `url` and returns the body as text.
|
||||
fn get(&self, url: &str) -> Result<String, String>;
|
||||
}
|
||||
|
||||
/// The real HTTPS transport (ureq + rustls, bounded at 5 s); the call is
|
||||
/// blocking, so callers run it on the background executor.
|
||||
pub struct HttpTransport;
|
||||
|
||||
impl UpdateTransport for HttpTransport {
|
||||
fn get(&self, url: &str) -> Result<String, String> {
|
||||
let config = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(5)))
|
||||
.build();
|
||||
let agent: ureq::Agent = config.into();
|
||||
let mut response = agent.get(url).call().map_err(|e| e.to_string())?;
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches and parses the latest release through `transport`.
|
||||
pub fn fetch_latest(transport: &dyn UpdateTransport) -> Result<LatestRelease, String> {
|
||||
let body = transport.get(UPDATE_ENDPOINT)?;
|
||||
parse_latest(&body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The documented response shape parses field by field.
|
||||
#[test]
|
||||
fn parse_latest_reads_the_documented_shape() {
|
||||
let body = r#"{
|
||||
"version": "v1.0.0",
|
||||
"tag_name": "v1.0.0",
|
||||
"notes": "更新说明(Markdown)",
|
||||
"is_prerelease": false,
|
||||
"published_at": "2026-09-20T08:00:00+00:00",
|
||||
"download_url": "/api/v1/releases/7/download?asset_id=12"
|
||||
}"#;
|
||||
let release = parse_latest(body).expect("valid response");
|
||||
assert_eq!(release.version, "v1.0.0");
|
||||
assert_eq!(release.tag_name, "v1.0.0");
|
||||
assert_eq!(release.notes, "更新说明(Markdown)");
|
||||
assert!(!release.is_prerelease);
|
||||
assert_eq!(release.published_at, "2026-09-20T08:00:00+00:00");
|
||||
assert_eq!(release.download_url, "/api/v1/releases/7/download?asset_id=12");
|
||||
}
|
||||
|
||||
/// Optional fields default; a response without a version is rejected.
|
||||
#[test]
|
||||
fn parse_latest_rejects_missing_versions() {
|
||||
let minimal = parse_latest(r#"{"version":"v2.1.0"}"#).expect("minimal response");
|
||||
assert!(minimal.notes.is_empty());
|
||||
assert!(!minimal.is_prerelease);
|
||||
|
||||
assert!(parse_latest("{}").is_err(), "no version");
|
||||
assert!(parse_latest(r#"{"version":" "}"#).is_err(), "blank version");
|
||||
assert!(parse_latest("not json").is_err());
|
||||
}
|
||||
|
||||
/// The version parser strips the tag prefix and ignores pre-release
|
||||
/// metadata; the comparison orders numerically.
|
||||
#[test]
|
||||
fn version_comparison_orders_numerically() {
|
||||
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
|
||||
assert_eq!(parse_version("1.2.3-rc.1"), Some((1, 2, 3)));
|
||||
assert_eq!(parse_version("1.2"), Some((1, 2, 0)));
|
||||
assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
|
||||
assert_eq!(parse_version("nightly"), None);
|
||||
|
||||
assert!(is_newer("v1.0.0", "0.5.0"));
|
||||
assert!(is_newer("v0.5.1", "0.5.0"));
|
||||
assert!(!is_newer("v0.5.0", "0.5.0"));
|
||||
assert!(!is_newer("v0.4.9", "0.5.0"));
|
||||
assert!(is_newer("2026.1", "0.5.0"), "unparsable remote falls back to inequality");
|
||||
assert!(!is_newer("v0.5.0", "0.5.0-rc.1"), "pre-release current compares by core");
|
||||
}
|
||||
|
||||
/// The check is on by default and follows the preferences toggle.
|
||||
#[test]
|
||||
fn check_enabled_follows_the_config() {
|
||||
let _guard = crate::oakui::graphops::test_lock();
|
||||
// Restore the key on drop (the config store is process-global).
|
||||
struct Restore(&'static str, String);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
crate::oakui::real::config_set_string(self.0, &self.1);
|
||||
}
|
||||
}
|
||||
let _save = Restore(
|
||||
CONFIG_KEY_CHECK_UPDATES,
|
||||
crate::oakui::real::config_get_string(CONFIG_KEY_CHECK_UPDATES),
|
||||
);
|
||||
crate::oakui::real::config_set_string(CONFIG_KEY_CHECK_UPDATES, "");
|
||||
assert!(check_enabled(), "default-on");
|
||||
set_check_enabled(false);
|
||||
assert!(!check_enabled());
|
||||
set_check_enabled(true);
|
||||
assert!(check_enabled());
|
||||
}
|
||||
|
||||
/// A scripted transport drives `fetch_latest` end to end.
|
||||
#[test]
|
||||
fn fetch_latest_reads_through_the_transport() {
|
||||
struct Scripted;
|
||||
impl UpdateTransport for Scripted {
|
||||
fn get(&self, url: &str) -> Result<String, String> {
|
||||
assert_eq!(url, UPDATE_ENDPOINT);
|
||||
Ok(r#"{"version":"v9.9.9"}"#.to_string())
|
||||
}
|
||||
}
|
||||
let release = fetch_latest(&Scripted).expect("scripted response");
|
||||
assert_eq!(release.version, "v9.9.9");
|
||||
assert!(should_notify(&release));
|
||||
|
||||
struct Failing;
|
||||
impl UpdateTransport for Failing {
|
||||
fn get(&self, _url: &str) -> Result<String, String> {
|
||||
Err("offline".to_string())
|
||||
}
|
||||
}
|
||||
assert!(fetch_latest(&Failing).is_err());
|
||||
}
|
||||
}
|
||||
@@ -78,8 +78,8 @@
|
||||
|
||||
| C++ | Rust 落点 |
|
||||
|---|---|
|
||||
| `output_buffer_size()` | `config::output_buffer_size`(`oakcommon_config_get_int(nullptr,"AudioOutputBufferSize",0)`) |
|
||||
| `device_name(is_output_device)` | `config::device_name`(`oakcommon_config_get` 两阶段;key = "AudioOutput"/"AudioInput") |
|
||||
| `output_buffer_size()` | `config::output_buffer_size`(`oak_core_config_get_int(nullptr,"AudioOutputBufferSize",0)`) |
|
||||
| `device_name(is_output_device)` | `config::device_name`(`oak_core_config_get` 两阶段;key = "AudioOutput"/"AudioInput") |
|
||||
|
||||
## 9. 刻意不迁移(drop)
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -305,7 +305,7 @@ name = "oakaudio"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"oakcodec",
|
||||
"oakcommon",
|
||||
"oak_core",
|
||||
"oakcore-rs",
|
||||
]
|
||||
|
||||
@@ -318,7 +318,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oakcommon"
|
||||
name = "oak_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
|
||||
@@ -11,7 +11,6 @@ crate-type = ["staticlib", "rlib"]
|
||||
[dependencies]
|
||||
oak-ffmpeg-link = { path = "../oak-ffmpeg-link" }
|
||||
oak-core = { path = "../oak-core" }
|
||||
oak-common = { path = "../oak-common" }
|
||||
oak-codec = { path = "../oak-codec" }
|
||||
# Real resample/channel-convert/time-stretch filter graph. The C++
|
||||
# ffmpeg_bridge library existed only to absorb FFmpeg API churn; the Rust
|
||||
|
||||
@@ -47,8 +47,8 @@ functions) — frozen, implemented verbatim by `src/ffi.rs`.
|
||||
oakcodec encoder C ABI (`bridge::codec`) and waveform extraction
|
||||
decodes through the oakcodec decoder C ABI — exactly as the C++
|
||||
does. No direct ffmpeg_bridge use in the record path.
|
||||
7. **Config via oakcommon.** Device names and the output buffer size
|
||||
read through `bridge::common` (`oakcommon_config_*`), preserving the
|
||||
7. **Config via oak_core.** Device names and the output buffer size
|
||||
read through `bridge::common` (`oak_core_config_*`), preserving the
|
||||
`audio_config` namespace semantics as a `config.rs` free-function
|
||||
module.
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The `audio_config` namespace from `src/audio/src/configbridge.*`:
|
||||
//! audio-specific configuration read through the oakcommon C ABI.
|
||||
//! audio-specific configuration read through the oak_core C ABI.
|
||||
|
||||
use std::error::Error;
|
||||
use std::ffi::CString;
|
||||
use std::str::FromStr;
|
||||
use oak_common::configstore::*;
|
||||
use oak_core::configstore::*;
|
||||
/// PortAudio output buffer size in frames; 0 = let PortAudio choose.
|
||||
///
|
||||
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:30`
|
||||
@@ -44,10 +43,9 @@ pub fn device_name(is_output_device: bool) -> Result<String, Box<dyn Error>> {
|
||||
let store = ConfigStore::instance();
|
||||
let size = i32::from_str(store.get(None, key)?.as_str())?;
|
||||
if size <= 1 {
|
||||
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
|
||||
// Absent (oak_core_E_NOT_FOUND) or empty
|
||||
return Err(Box::new(crate::error::Error::NotFound));
|
||||
}
|
||||
let mut buf = vec![0u8; size as usize];
|
||||
let name = store.get(None, key)?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
@@ -22,19 +22,17 @@
|
||||
//! `include/audio/manager.h`). An empty handle reports `OAKAUDIO_E_STATE`.
|
||||
//!
|
||||
//! Recording goes through the oakcodec encoder C ABI ([`crate::bridge`]);
|
||||
//! device/config lookups go through oakcommon.
|
||||
//! device/config lookups go through oak_core.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||
use cpal::{Device, DeviceId};
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
use oak_codec::encoder::Encoder;
|
||||
use oak_codec::encodingparams::EncodingParams;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::params::AudioParams;
|
||||
use crate::previewdevice::PreviewAudioDevice;
|
||||
use crate::error::Error::NotFound;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
use cpal::Device;
|
||||
use oak_codec::encoder::Encoder;
|
||||
use oak_codec::encodingparams::EncodingParams;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||
|
||||
/// `paNoDevice` (PortAudio "no device" sentinel; also the default when no
|
||||
/// device is configured).
|
||||
@@ -138,9 +136,9 @@ impl ManagerInner {
|
||||
return None;
|
||||
}
|
||||
match MANAGER.get() {
|
||||
Some(m) => {
|
||||
// SAFETY: `m` is the process-wide singleton; borrowed handles do
|
||||
// not free it, so it outlives every handle.
|
||||
Some(_) => {
|
||||
// SAFETY: the process-wide singleton is never freed; borrowed
|
||||
// handles do not free it, so it outlives every handle.
|
||||
Some(self)
|
||||
}
|
||||
None => None,
|
||||
@@ -180,7 +178,9 @@ impl ManagerInner {
|
||||
let rate = params.sample_rate;
|
||||
let channels = params.channel_count();
|
||||
let sink = self.output_buffer.clone();
|
||||
let _ = self.output_device_stream.ensure_open(device, rate, channels, sink);
|
||||
let _ = self
|
||||
.output_device_stream
|
||||
.ensure_open(device, rate, channels, sink);
|
||||
}
|
||||
self.output_buffer.write(samples);
|
||||
self.output_started = true;
|
||||
@@ -341,8 +341,6 @@ impl ManagerInner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Peak level (linear, 0..1 and above) of each channel of the buffered,
|
||||
/// not-yet-consumed output, written to `peaks` in channel order.
|
||||
/// Returns the channel count (0 when no output is configured or the
|
||||
@@ -396,7 +394,7 @@ impl ManagerInner {
|
||||
} else {
|
||||
for (ch, plane) in planes.iter_mut().enumerate() {
|
||||
let start = ch * frame_count * 4;
|
||||
for b in buf[start..start + frame_count * 4].chunks_exact(4) {
|
||||
for b in buf[start..start + frame_count * 4].as_chunks::<4>().0 {
|
||||
plane.push(f32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
}
|
||||
}
|
||||
@@ -408,7 +406,6 @@ impl ManagerInner {
|
||||
}
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Lock the process-wide manager singleton (the direct-Rust replacement
|
||||
@@ -443,7 +440,7 @@ pub fn find_config_device_by_name_s(is_output_device: bool) -> Result<Device> {
|
||||
pub fn find_device_by_name_s_or_default(name: &String, _is_output_device: bool) -> Result<Device> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.devices()?.find(|d| {
|
||||
if d.id().is_err(){
|
||||
if d.id().is_err() {
|
||||
return false;
|
||||
}
|
||||
if d.supports_output() && d.id().unwrap().id() == name {
|
||||
@@ -453,16 +450,13 @@ pub fn find_device_by_name_s_or_default(name: &String, _is_output_device: bool)
|
||||
});
|
||||
if let Some(device) = device {
|
||||
Ok(device)
|
||||
}
|
||||
else{
|
||||
} else {
|
||||
if let Some(device) = host.default_output_device() {
|
||||
Ok(device)
|
||||
}
|
||||
else{
|
||||
} else {
|
||||
Err(Box::new(Error::NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The host's output device names in enumeration order; the list index is
|
||||
@@ -507,7 +501,6 @@ pub fn device_index_by_name(name: &str, output: bool) -> Option<i32> {
|
||||
names.iter().position(|n| n == name).map(|i| i as i32)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -529,22 +522,19 @@ mod tests {
|
||||
output_callback_body();
|
||||
let _ = tx.send(());
|
||||
});
|
||||
if rx
|
||||
.recv_timeout(std::time::Duration::from_secs(60))
|
||||
.is_err()
|
||||
{
|
||||
if rx.recv_timeout(std::time::Duration::from_secs(60)).is_err() {
|
||||
eprintln!("audio host wedged (headless/CI); skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// The test body (see the wrapping test for the watchdog rationale).
|
||||
fn output_callback_body() { // Skip when the audio system cannot actually run a stream: open a
|
||||
fn output_callback_body() {
|
||||
// Skip when the audio system cannot actually run a stream: open a
|
||||
// silent stream and require at least one callback within 2 s. A
|
||||
// device existing is not enough — headless sessions report the
|
||||
// stream running while delivering zero callbacks.
|
||||
use std::sync::atomic::AtomicI64 as A;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use std::sync::atomic::AtomicI64 as A;
|
||||
static PROBE: A = A::new(0);
|
||||
PROBE.store(0, Ordering::Relaxed);
|
||||
let can_play = (|| {
|
||||
@@ -558,12 +548,7 @@ mod tests {
|
||||
}
|
||||
};
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
config.config(),
|
||||
cb,
|
||||
|_| {},
|
||||
None,
|
||||
)
|
||||
.build_output_stream(config.config(), cb, |_| {}, None)
|
||||
.ok()?;
|
||||
stream.play().ok()?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
@@ -595,16 +580,12 @@ mod tests {
|
||||
samples.push(v);
|
||||
samples.push(v);
|
||||
}
|
||||
let bytes: Vec<u8> = samples
|
||||
.iter()
|
||||
.flat_map(|s| s.to_le_bytes())
|
||||
.collect();
|
||||
manager.lock().unwrap().push_to_output(
|
||||
params,
|
||||
&bytes,
|
||||
&mut vec![0u8; 256],
|
||||
)
|
||||
.expect("push succeeds even without an explicit device");
|
||||
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_to_output(params, &bytes, &mut vec![0u8; 256])
|
||||
.expect("push succeeds even without an explicit device");
|
||||
|
||||
// Give the audio thread time to consume. PortAudio/CoreAudio
|
||||
// stream startup can take SECONDS in some environments (audio HAL
|
||||
@@ -616,7 +597,11 @@ mod tests {
|
||||
let target = frames as i64 - 1024;
|
||||
let mut consumed = 0i64;
|
||||
while std::time::Instant::now() < deadline {
|
||||
consumed = manager.lock().unwrap().output_buffer.output_frames_consumed();
|
||||
consumed = manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output_buffer
|
||||
.output_frames_consumed();
|
||||
if consumed >= target {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,9 @@
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{
|
||||
BufferSize, Device, Host, SampleFormat, SampleRate, Stream, StreamConfig,
|
||||
SupportedBufferSize,
|
||||
};
|
||||
use crate::previewdevice::PreviewAudioDevice;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{BufferSize, Device, Host, SampleFormat, Stream, StreamConfig, SupportedBufferSize};
|
||||
|
||||
/// The default frames-per-buffer requested for the preview stream
|
||||
/// (clamped to the device's supported range).
|
||||
@@ -100,21 +97,19 @@ impl PortAudioOutput {
|
||||
}
|
||||
self.close();
|
||||
|
||||
if self.host.is_none(){
|
||||
if self.host.is_none() {
|
||||
self.host = Some(cpal::default_host())
|
||||
}
|
||||
|
||||
let host = self.host.as_ref().unwrap().clone();
|
||||
let host = self.host.as_ref().unwrap();
|
||||
|
||||
let output_device = resolve_device(&host, device)
|
||||
.ok_or_else(|| "no output device available".to_string())?;
|
||||
let output_device =
|
||||
resolve_device(host, device).ok_or_else(|| "no output device available".to_string())?;
|
||||
let config = pick_config(&output_device, rate, channels)?;
|
||||
if stream_dbg_enabled() {
|
||||
eprintln!(
|
||||
"[audio-stream] config: {} Hz, {} ch, buffer {:?}",
|
||||
u32::from(config.sample_rate),
|
||||
config.channels,
|
||||
config.buffer_size
|
||||
config.sample_rate, config.channels, config.buffer_size
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,8 +212,8 @@ fn pick_config(device: &Device, rate: i32, channels: i32) -> Result<StreamConfig
|
||||
let want_rate = rate as u32;
|
||||
let want_channels = channels as u16;
|
||||
|
||||
if let Ok(mut configs) = device.supported_output_configs() {
|
||||
while let Some(c) = configs.next() {
|
||||
if let Ok(configs) = device.supported_output_configs() {
|
||||
for c in configs {
|
||||
if c.sample_format() != SampleFormat::F32 || c.channels() != want_channels {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ impl PreviewAudioDevice {
|
||||
if copy_length > 0 {
|
||||
let new_bytes_read = inner.bytes_read + copy_length;
|
||||
|
||||
if inner.notify_interval > 0 && inner.notify_callback.is_some() {
|
||||
if (inner.bytes_read / inner.notify_interval)
|
||||
if inner.notify_interval > 0
|
||||
&& inner.notify_callback.is_some()
|
||||
&& (inner.bytes_read / inner.notify_interval)
|
||||
!= (new_bytes_read / inner.notify_interval)
|
||||
{
|
||||
notify = true;
|
||||
}
|
||||
{
|
||||
notify = true;
|
||||
}
|
||||
|
||||
inner.bytes_read = new_bytes_read;
|
||||
|
||||
@@ -77,7 +77,7 @@ impl Default for ProcessorInner {
|
||||
}
|
||||
|
||||
/// Map an oakcore [`SampleFormat`] to the equivalent ffmpeg [`Sample`].
|
||||
/// Replaces `FFmpegUtils::get_ffmpeg_sample_format` crossing the oakcommon
|
||||
/// Replaces `FFmpegUtils::get_ffmpeg_sample_format` crossing the oak_core
|
||||
/// C ABI (`// CPP-PARITY: src/common/src/ffmpegutils.cpp:83`).
|
||||
fn to_ffmpeg_sample_format(fmt: SampleFormat) -> Sample {
|
||||
match fmt {
|
||||
@@ -135,7 +135,9 @@ fn fix_channel_layout(params: AudioParams) -> AudioParams {
|
||||
fn build_graph(from: &AudioParams, to: &AudioParams, speed: f64) -> Result<ffmpeg::filter::Graph> {
|
||||
let in_format = to_ffmpeg_sample_format(from.format);
|
||||
if in_format == Sample::None {
|
||||
return Err(Box::new(Error::Failed("invalid input sample format".to_string())));
|
||||
return Err(Box::new(Error::Failed(
|
||||
"invalid input sample format".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
let abuffer = ffmpeg::filter::find("abuffer")
|
||||
@@ -257,10 +259,19 @@ impl Processor {
|
||||
/// Push planar float input and pull converted output. Returns the number
|
||||
/// of output frames written.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `in_planar` must be null or point to at least `from.channel_count()`
|
||||
/// readable `*const f32` plane pointers, each valid for
|
||||
/// `in_frame_count` samples; `out_planar` must be null or point to at
|
||||
/// least `to.channel_count()` writable `*mut f32` plane pointers, each
|
||||
/// valid for `out_capacity_frames` samples (NULL plane entries are
|
||||
/// skipped).
|
||||
///
|
||||
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
|
||||
/// check, null `out_planar` short-circuit) and
|
||||
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
|
||||
pub fn convert(
|
||||
pub unsafe fn convert(
|
||||
&self,
|
||||
in_planar: *const *const f32,
|
||||
in_frame_count: i32,
|
||||
@@ -273,7 +284,9 @@ impl Processor {
|
||||
if inner.graph.is_none() {
|
||||
return Err(Box::from(Error::State));
|
||||
}
|
||||
if in_frame_count < 0 || out_capacity_frames < 0 || (in_frame_count > 0 && in_planar.is_null())
|
||||
if in_frame_count < 0
|
||||
|| out_capacity_frames < 0
|
||||
|| (in_frame_count > 0 && in_planar.is_null())
|
||||
{
|
||||
return Err(Box::from(Error::Invalid));
|
||||
}
|
||||
@@ -294,7 +307,8 @@ impl Processor {
|
||||
let nb = in_frame_count as usize;
|
||||
let in_channels = from.channel_count().max(0) as usize;
|
||||
let layout = channel_layout_from_mask(from.channel_layout);
|
||||
let mut frame = ffmpeg::frame::Audio::new(to_ffmpeg_sample_format(from.format), nb, layout);
|
||||
let mut frame =
|
||||
ffmpeg::frame::Audio::new(to_ffmpeg_sample_format(from.format), nb, layout);
|
||||
frame.set_rate(from.sample_rate as u32);
|
||||
let planar = from.format.is_planar();
|
||||
// `plane_mut::<T>` requires the exact sample type of the frame
|
||||
@@ -317,7 +331,9 @@ impl Processor {
|
||||
// samples.
|
||||
let src = unsafe { *in_planar } as *const $t;
|
||||
let dst = frame.plane_mut::<$t>(0);
|
||||
unsafe { ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb * in_channels) };
|
||||
unsafe {
|
||||
ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb * in_channels)
|
||||
};
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
//! `waveform::extract` goes through oakcodec's in-process FFmpeg
|
||||
//! decoder; the processor drives a real FFmpeg filter graph.
|
||||
|
||||
// Every integration-test binary in this crate compiles this module on its
|
||||
// own and uses only the helpers it needs, so items unused in one binary are
|
||||
// still part of the shared fixture API.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
mod common;
|
||||
|
||||
use common::lock_manager;
|
||||
use oak_codec::encodingparams::EncodingParams;
|
||||
use oak_audio::error::{Error, OAKAUDIO_E_INVALID};
|
||||
use oak_audio::manager::instance;
|
||||
use oak_audio::params::{AudioParams, SampleFormat};
|
||||
use oak_codec::encodingparams::EncodingParams;
|
||||
|
||||
/// Audio params for the tests: stereo f32 at 48 kHz.
|
||||
fn stereo() -> AudioParams {
|
||||
@@ -97,11 +97,13 @@ fn push_output_starts_clock() {
|
||||
// With no explicit device the push still succeeds (M12 P1).
|
||||
m.set_output_device(-1).unwrap();
|
||||
let samples = vec![0u8; 480 * 2 * 4];
|
||||
m.push_to_output(stereo(), &samples, &mut vec![0u8; 64]).unwrap();
|
||||
m.push_to_output(stereo(), &samples, &mut [0u8; 64])
|
||||
.unwrap();
|
||||
|
||||
// After selecting a device the push succeeds and the clock starts at 0.
|
||||
m.set_output_device(0).unwrap();
|
||||
m.push_to_output(stereo(), &samples, &mut vec![0u8; 64]).unwrap();
|
||||
m.push_to_output(stereo(), &samples, &mut [0u8; 64])
|
||||
.unwrap();
|
||||
m.seconds(&mut secs).unwrap();
|
||||
assert!(secs >= 0.0, "clock started (got {secs})");
|
||||
}
|
||||
@@ -142,7 +144,8 @@ fn output_control_flags() {
|
||||
m.set_output_notify_interval(1024).unwrap();
|
||||
let err = m.set_output_notify_interval(-1).unwrap_err();
|
||||
assert_eq!(
|
||||
err.downcast_ref::<oak_audio::error::Error>().map(|e| e.code()),
|
||||
err.downcast_ref::<oak_audio::error::Error>()
|
||||
.map(|e| e.code()),
|
||||
Some(OAKAUDIO_E_INVALID)
|
||||
);
|
||||
m.clear_buffered_output().unwrap();
|
||||
@@ -151,7 +154,8 @@ fn output_control_flags() {
|
||||
// Push starts the stream, then stop_output halts it (clock -> -1).
|
||||
m.set_output_device(0).unwrap();
|
||||
let samples = vec![0u8; 480 * 2 * 4];
|
||||
m.push_to_output(stereo(), &samples, &mut vec![0u8; 64]).unwrap();
|
||||
m.push_to_output(stereo(), &samples, &mut [0u8; 64])
|
||||
.unwrap();
|
||||
m.stop_output().unwrap();
|
||||
let mut secs = 1.0f64;
|
||||
m.seconds(&mut secs).unwrap();
|
||||
@@ -171,7 +175,9 @@ fn recording_start_stop() {
|
||||
|
||||
// No input device -> explainable failure, no crash.
|
||||
m.set_input_device(-1).unwrap();
|
||||
assert!(m.start_recording(&wav_params("unused.wav"), &mut vec![0u8; 64]).is_err());
|
||||
assert!(m
|
||||
.start_recording(&wav_params("unused.wav"), &mut [0u8; 64])
|
||||
.is_err());
|
||||
|
||||
m.set_input_device(0).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("oakaudio_rec_{}.wav", std::process::id()));
|
||||
@@ -193,8 +199,14 @@ fn recording_start_stop() {
|
||||
#[test]
|
||||
fn config_defaults() {
|
||||
assert_eq!(oak_audio::config::output_buffer_size(), 0);
|
||||
assert!(oak_audio::config::device_name(true).is_err(), "no configured output device");
|
||||
assert!(oak_audio::config::device_name(false).is_err(), "no configured input device");
|
||||
assert!(
|
||||
oak_audio::config::device_name(true).is_err(),
|
||||
"no configured output device"
|
||||
);
|
||||
assert!(
|
||||
oak_audio::config::device_name(false).is_err(),
|
||||
"no configured input device"
|
||||
);
|
||||
}
|
||||
|
||||
/// PreviewAudioDevice pull-side plumbing (read/notify callback/clock).
|
||||
@@ -262,7 +274,7 @@ fn output_levels_reports_buffered_peaks() {
|
||||
packed.extend_from_slice(&(0.05f32 * t).to_le_bytes());
|
||||
packed.extend_from_slice(&(0.2f32 * t).to_le_bytes());
|
||||
}
|
||||
m.push_to_output(stereo(), &packed, &mut vec![0u8; 64]).unwrap();
|
||||
m.push_to_output(stereo(), &packed, &mut [0u8; 64]).unwrap();
|
||||
|
||||
let mut peaks = [0.0f32; 4];
|
||||
let n = m.output_levels(&mut peaks).unwrap();
|
||||
@@ -288,7 +300,10 @@ fn output_levels_reports_buffered_peaks() {
|
||||
|
||||
// The error mapping is intact.
|
||||
assert_eq!(Error::Invalid.code(), OAKAUDIO_E_INVALID);
|
||||
assert_eq!(Error::Failed("x".to_string()).code(), oak_audio::error::OAKAUDIO_E_FAILED);
|
||||
assert_eq!(
|
||||
Error::Failed("x".to_string()).code(),
|
||||
oak_audio::error::OAKAUDIO_E_FAILED
|
||||
);
|
||||
|
||||
// Leave the singleton as we found it: the push flipped
|
||||
// `output_started`, which other tests' seconds() assertions depend on.
|
||||
|
||||
@@ -54,6 +54,19 @@ fn plane_mut_ptrs(planes: &mut [Vec<f32>]) -> Vec<*mut f32> {
|
||||
planes.iter_mut().map(|p| p.as_mut_ptr()).collect()
|
||||
}
|
||||
|
||||
/// [`Processor::convert`] with its raw-pointer contract upheld by the
|
||||
/// plane helpers above (the pointer arrays outlive the call). The single
|
||||
/// `unsafe` block keeps the call sites readable.
|
||||
fn convert(
|
||||
p: &Processor,
|
||||
in_ptrs: *const *const f32,
|
||||
in_frames: i32,
|
||||
out_ptrs: *const *mut f32,
|
||||
out_capacity: i32,
|
||||
) -> oak_audio::error::Result<i32> {
|
||||
unsafe { p.convert(in_ptrs, in_frames, out_ptrs, out_capacity) }
|
||||
}
|
||||
|
||||
/// The crate error code of a failed call (the API surfaces
|
||||
/// `Box<dyn std::error::Error>`).
|
||||
fn code(err: Box<dyn std::error::Error>) -> i32 {
|
||||
@@ -67,11 +80,11 @@ fn code(err: Box<dyn std::error::Error>) -> i32 {
|
||||
#[test]
|
||||
fn processor_open_isopen_close() {
|
||||
let p = Processor::init();
|
||||
assert_eq!(p.is_open().unwrap(), false);
|
||||
assert!(!p.is_open().unwrap());
|
||||
open_identity(&p).unwrap();
|
||||
assert_eq!(p.is_open().unwrap(), true);
|
||||
assert!(p.is_open().unwrap());
|
||||
p.close().unwrap();
|
||||
assert_eq!(p.is_open().unwrap(), false);
|
||||
assert!(!p.is_open().unwrap());
|
||||
}
|
||||
|
||||
/// open with matching in/out rate and format is an identity passthrough:
|
||||
@@ -84,9 +97,9 @@ fn identity_convert_passthrough() {
|
||||
let planes = ramp_planes(32);
|
||||
let in_ptrs = plane_ptrs(&planes);
|
||||
let mut out = vec![vec![0f32; 32]; 2];
|
||||
let mut out_ptrs = plane_mut_ptrs(&mut out);
|
||||
let out_ptrs = plane_mut_ptrs(&mut out);
|
||||
|
||||
let n = p.convert(in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32).unwrap();
|
||||
let n = convert(&p, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32).unwrap();
|
||||
assert_eq!(n, 32);
|
||||
for ch in 0..2 {
|
||||
for i in 0..32 {
|
||||
@@ -110,9 +123,9 @@ fn convert_capacity_truncation() {
|
||||
let planes = ramp_planes(32);
|
||||
let in_ptrs = plane_ptrs(&planes);
|
||||
let mut out = vec![vec![9.9f32; 10]; 2];
|
||||
let mut out_ptrs = plane_mut_ptrs(&mut out);
|
||||
let out_ptrs = plane_mut_ptrs(&mut out);
|
||||
|
||||
let n = p.convert(in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 10).unwrap();
|
||||
let n = convert(&p, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 10).unwrap();
|
||||
assert_eq!(n, 10);
|
||||
for ch in 0..2 {
|
||||
for i in 0..10 {
|
||||
@@ -122,8 +135,8 @@ fn convert_capacity_truncation() {
|
||||
|
||||
// The graph has already drained; nothing further to pull.
|
||||
let mut out2 = vec![vec![0f32; 32]; 2];
|
||||
let mut out2_ptrs = plane_mut_ptrs(&mut out2);
|
||||
let n = p.convert(in_ptrs.as_ptr(), 0, out2_ptrs.as_ptr(), 32).unwrap();
|
||||
let out2_ptrs = plane_mut_ptrs(&mut out2);
|
||||
let n = convert(&p, in_ptrs.as_ptr(), 0, out2_ptrs.as_ptr(), 32).unwrap();
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
@@ -136,7 +149,7 @@ fn open_invalid_params() {
|
||||
code(p.open(params(0), params(48000), 1.0).unwrap_err()),
|
||||
OAKAUDIO_E_INVALID
|
||||
);
|
||||
assert_eq!(p.is_open().unwrap(), false);
|
||||
assert!(!p.is_open().unwrap());
|
||||
// The output format is forced to planar f32 by the processor contract.
|
||||
let mut wrong_out = params(48000);
|
||||
wrong_out.format = SampleFormat::F32;
|
||||
@@ -144,12 +157,12 @@ fn open_invalid_params() {
|
||||
code(p.open(params(48000), wrong_out, 1.0).unwrap_err()),
|
||||
OAKAUDIO_E_INVALID
|
||||
);
|
||||
assert_eq!(p.is_open().unwrap(), false);
|
||||
assert!(!p.is_open().unwrap());
|
||||
|
||||
// convert before open is a state error; a non-positive speed is
|
||||
// rejected on open.
|
||||
assert_eq!(
|
||||
code(p.convert(std::ptr::null(), 0, std::ptr::null(), 0).unwrap_err()),
|
||||
code(convert(&p, std::ptr::null(), 0, std::ptr::null(), 0).unwrap_err()),
|
||||
OAKAUDIO_E_STATE
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -171,15 +184,20 @@ fn resample_and_flush() {
|
||||
let planes = ramp_planes(frames);
|
||||
let in_ptrs = plane_ptrs(&planes);
|
||||
let mut out = vec![vec![0f32; frames]; 2];
|
||||
let mut out_ptrs = plane_mut_ptrs(&mut out);
|
||||
let out_ptrs = plane_mut_ptrs(&mut out);
|
||||
|
||||
let mut total = p
|
||||
.convert(in_ptrs.as_ptr(), frames as i32, out_ptrs.as_ptr(), frames as i32)
|
||||
.unwrap();
|
||||
let mut total = convert(
|
||||
&p,
|
||||
in_ptrs.as_ptr(),
|
||||
frames as i32,
|
||||
out_ptrs.as_ptr(),
|
||||
frames as i32,
|
||||
)
|
||||
.unwrap();
|
||||
p.flush().unwrap();
|
||||
// Drain the resampler delay after end-of-input.
|
||||
while total < frames as i32 {
|
||||
let n = p.convert(std::ptr::null(), 0, out_ptrs.as_ptr(), frames as i32).unwrap();
|
||||
let n = convert(&p, std::ptr::null(), 0, out_ptrs.as_ptr(), frames as i32).unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -189,7 +207,7 @@ fn resample_and_flush() {
|
||||
(total - 22050).abs() <= 2,
|
||||
"half-rate output must halve the frame count (got {total})"
|
||||
);
|
||||
assert_eq!(p.is_open().unwrap(), true);
|
||||
assert!(p.is_open().unwrap());
|
||||
}
|
||||
|
||||
/// A tempo factor != 1.0 time-stretches: tempo 2.0 halves the frame count
|
||||
@@ -205,14 +223,19 @@ fn tempo_stretch() {
|
||||
let planes = ramp_planes(frames);
|
||||
let in_ptrs = plane_ptrs(&planes);
|
||||
let mut out = vec![vec![0f32; frames]; 2];
|
||||
let mut out_ptrs = plane_mut_ptrs(&mut out);
|
||||
let out_ptrs = plane_mut_ptrs(&mut out);
|
||||
|
||||
let mut total = p
|
||||
.convert(in_ptrs.as_ptr(), frames as i32, out_ptrs.as_ptr(), frames as i32)
|
||||
.unwrap();
|
||||
let mut total = convert(
|
||||
&p,
|
||||
in_ptrs.as_ptr(),
|
||||
frames as i32,
|
||||
out_ptrs.as_ptr(),
|
||||
frames as i32,
|
||||
)
|
||||
.unwrap();
|
||||
p.flush().unwrap();
|
||||
while total < frames as i32 {
|
||||
let n = p.convert(std::ptr::null(), 0, out_ptrs.as_ptr(), frames as i32).unwrap();
|
||||
let n = convert(&p, std::ptr::null(), 0, out_ptrs.as_ptr(), frames as i32).unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -222,7 +245,7 @@ fn tempo_stretch() {
|
||||
(total - 24000).abs() <= 2400,
|
||||
"tempo 2.0 must halve the frame count (got {total})"
|
||||
);
|
||||
assert_eq!(p.is_open().unwrap(), true);
|
||||
assert!(p.is_open().unwrap());
|
||||
p.close().unwrap();
|
||||
|
||||
// Re-opening a closed processor works; opening an open one is a state
|
||||
|
||||
Generated
+2
-2
@@ -879,7 +879,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oakcommon"
|
||||
name = "oak_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
@@ -900,7 +900,7 @@ dependencies = [
|
||||
"libc",
|
||||
"oakaudio",
|
||||
"oakcodec",
|
||||
"oakcommon",
|
||||
"oak_core",
|
||||
"oakcore-rs",
|
||||
"oaknode",
|
||||
"oakplugin",
|
||||
|
||||
@@ -32,9 +32,8 @@ clap = { version = "4", features = ["derive"] }
|
||||
# direct Rust call into the oak* rlibs (oaknode for projects/footage,
|
||||
# oaktimeline for the track/clip commands, oakrender for the ticket arena,
|
||||
# oakcodec for the export formats/codecs, oaktask for the export task,
|
||||
# oakcommon/oakcore-rs for the shared value types). No liboakengine dylib,
|
||||
# oak_core/oakcore-rs for the shared value types). No liboakengine dylib,
|
||||
# no C ABI, no build.rs link step, no host shims.
|
||||
oak-common = { path = "../oak-common" }
|
||||
oak-core = { path = "../oak-core" }
|
||||
oak-node = { path = "../oak-node" }
|
||||
oak-timeline = { path = "../oak-timeline" }
|
||||
|
||||
@@ -20,7 +20,7 @@ cargo test # unit + integration tests
|
||||
|
||||
The crate is **self-contained** (M14 R2): it links the oak* module rlibs
|
||||
directly (`oaknode`, `oaktimeline`, `oakcodec`, `oakrender`, `oaktask`,
|
||||
`oakcommon`) — no `liboakengine` dylib, no C ABI, no build.rs link step.
|
||||
`oak_core`) — no `liboakengine` dylib, no C ABI, no build.rs link step.
|
||||
`cargo test -p oak-cli` stands alone.
|
||||
|
||||
## Subcommands
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! Module-native engine helpers (M14 R2).
|
||||
//!
|
||||
//! oak-cli links the oak* module rlibs directly (oaknode / oaktimeline /
|
||||
//! oakcodec / oakrender / oaktask / oakcommon) instead of the built
|
||||
//! oakcodec / oakrender / oaktask / oak_core) instead of the built
|
||||
//! liboakengine dylib's C ABI. This module is the CLI's own assembly
|
||||
//! layer: it reproduces the facade operations the subcommands need —
|
||||
//! project load/create, footage probe, sequence + clip assembly, montage
|
||||
@@ -43,14 +43,14 @@ use oak_node::project::Project;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
|
||||
use oak_node::value::VideoParams;
|
||||
use oak_timeline::undogeneral::TimelineAddTrackCommand;
|
||||
use oak_timeline::undopointer::TrackPlaceBlockCommand;
|
||||
use oak_timeline::util::NodeRef;
|
||||
use oak_render::manager::RenderManager;
|
||||
use oak_render::procpool::bgra8_to_rgba8;
|
||||
use oak_render::ticket::{
|
||||
AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams,
|
||||
AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams,
|
||||
};
|
||||
use oak_timeline::undogeneral::TimelineAddTrackCommand;
|
||||
use oak_timeline::undopointer::TrackPlaceBlockCommand;
|
||||
use oak_timeline::util::NodeRef;
|
||||
|
||||
/// The shared project reference (the modules' domain project handle).
|
||||
pub type ProjectRef = Arc<Mutex<Project>>;
|
||||
@@ -411,14 +411,14 @@ pub fn place_footage_clip(
|
||||
};
|
||||
|
||||
// The clip block, positioned by media-in + length (the facade's
|
||||
// `oaknode_clip_set_media_in` + `oaknode_block_set_length_and_media_in`).
|
||||
// `oaknode_clip_set_media_in` + `oaknode_block_set_length_and_media_out`).
|
||||
let clip_id = {
|
||||
let mut guard = lock(project);
|
||||
let (core, behavior) = block::clip_create();
|
||||
let id = guard.graph.add_node(core, behavior);
|
||||
if let Some(c) = clip_behavior_mut(&mut guard.graph, id) {
|
||||
c.core.media_in = media_r;
|
||||
c.core.set_length_and_media_in(length);
|
||||
c.core.set_length_and_media_out(length);
|
||||
}
|
||||
id
|
||||
};
|
||||
@@ -657,13 +657,14 @@ pub fn render_frame(
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let id = m.tickets.next_id();
|
||||
m.tickets.submit_video_with_id(id, params, Box::new(|_| {}));
|
||||
m.tickets.wait(id).map_err(|e| e.to_string())?;
|
||||
let result = m.tickets.result(id).ok_or_else(|| "render ticket produced no result".to_string())?;
|
||||
match &result {
|
||||
Ok(TicketPayload::Video(oak_render::texture::Texture::Cpu(frame))) => {
|
||||
Ok(TicketPayload::Video(oak_core::texture::Texture::Cpu(frame))) => {
|
||||
Ok(RenderedFrame {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
@@ -672,12 +673,25 @@ pub fn render_frame(
|
||||
data: frame.data.clone(),
|
||||
})
|
||||
}
|
||||
Ok(TicketPayload::Video(texture @ oak_core::texture::Texture::Gpu { .. })) => {
|
||||
// M2: the thread pipeline renders all-GPU; the CLI writes CPU
|
||||
// pixels, so this is an explicit readback boundary.
|
||||
let frame = texture
|
||||
.to_frame()
|
||||
.map_err(|e| format!("render readback: {e:?}"))?;
|
||||
Ok(RenderedFrame {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
format: frame.format as i32,
|
||||
linesize: frame.linesize_bytes() as i32,
|
||||
data: frame.data,
|
||||
})
|
||||
}
|
||||
Ok(TicketPayload::ShmFrame(frame)) => {
|
||||
let out = shm_to_rendered_frame(frame);
|
||||
m.release_frame(frame);
|
||||
Ok(out)
|
||||
}
|
||||
Ok(TicketPayload::Video(_)) => Err("render produced a non-CPU frame".to_string()),
|
||||
_ => Err("render produced no video frame".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -806,6 +820,12 @@ pub fn export_sequence(
|
||||
custom_range_in_den: fr_num,
|
||||
custom_range_out_num: out_num as i32,
|
||||
custom_range_out_den: fr_num,
|
||||
video_bit_rate: 0,
|
||||
audio_bit_rate: 0,
|
||||
color_override_enabled: false,
|
||||
color_primaries: 0,
|
||||
color_trc: 0,
|
||||
color_space: 0,
|
||||
};
|
||||
let inner = oak_task::export::ExportTask::new((project.clone(), seq_id), encoding);
|
||||
let mut driver = oak_task::task::Task::new("Exporting...", None);
|
||||
|
||||
@@ -162,7 +162,7 @@ fn info_on_the_fixture_prints_the_project() {
|
||||
assert!(stdout.contains("Modified: no"), "{stdout}");
|
||||
assert!(stdout.contains("Sequences: 1"), "{stdout}");
|
||||
assert!(stdout.contains("[0] \"Fixture Sequence\""), "{stdout}");
|
||||
assert!(stdout.contains("frame rate: 30/1 (30.000 fps)"), "{stdout}");
|
||||
assert!(stdout.contains("frame rate: 30000/1001 (29.970 fps)"), "{stdout}");
|
||||
assert!(stdout.contains("Footage: 1"), "{stdout}");
|
||||
// The C++ fixture stores the footage path relative to the project;
|
||||
// the CLI resolves it against the project directory and reports it
|
||||
|
||||
+23
-10
@@ -9,23 +9,36 @@ license = "GPL-3.0-or-later"
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
oak-common = { path = "../oak-common" }
|
||||
oak-ffmpeg-link = { path = "../oak-ffmpeg-link" }
|
||||
oak-core = { path = "../oak-core" }
|
||||
# Real media decode/encode. The C++ ffmpeg_bridge library existed only to
|
||||
# absorb FFmpeg API churn; the Rust crate calls ffmpeg-next directly.
|
||||
#
|
||||
# ffmpeg-next 9.x pairs with FFmpeg 8.x headers, which most distros do
|
||||
# not ship yet — build a matching GPL FFmpeg (all free-license codecs +
|
||||
# per-OS hardware acceleration, static+PIC) with
|
||||
# ffmpeg-next 9.x compiles against the FFmpeg 8.x and 9.x headers (both
|
||||
# verified locally with the project FFmpeg build), but most distros do
|
||||
# not ship a matching prebuilt library yet — build one (all free-license
|
||||
# codecs + per-OS hardware acceleration, static+PIC) with
|
||||
# tooling/ffmpeg/build-ffmpeg.sh and point cargo at it via FFMPEG_DIR
|
||||
# (see docs/build.md). The `static` feature makes the linker prefer the
|
||||
# archives.
|
||||
# archives. CI/CD uses the same script on Linux/macOS and BtbN's
|
||||
# prebuilt shared archive on Windows.
|
||||
#
|
||||
# ffmpeg-next's own `build` feature is NOT usable: it clones
|
||||
# release/<crate-version>, and every such pairing is broken upstream
|
||||
# (9.0.0 -> FFmpeg 9.0 removed AVCodec fields; 8.1.0 -> FFmpeg 8.1 added
|
||||
# enum variants; 8.0.0 -> FFmpeg 8.0 renamed FF_PROFILE_*).
|
||||
ffmpeg-next = { version = "9.0", features = ["static"] }
|
||||
# ffmpeg-next's own `build` cargo feature is still not what we use: it
|
||||
# clones release/<crate-version>, tying the FFmpeg version to the crate
|
||||
# version instead of the project-pinned one.
|
||||
ffmpeg-next = { version = "9", features = ["static"] }
|
||||
# `std::error::Error` impls for the crate-internal error enum.
|
||||
thiserror = "2"
|
||||
|
||||
# M5 zero-copy import: the D3D11VA COM side (shared NT handle creation
|
||||
# from the decoder's ID3D11Texture2D). Same `windows` generation as
|
||||
# wgpu-hal 29 so the Foundation/DXGI types unify.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Security",
|
||||
"Win32_System_Com",
|
||||
] }
|
||||
|
||||
@@ -42,15 +42,15 @@ and never blocks.
|
||||
virtual chain.
|
||||
3. **`Frame` owns its params by value.** `olive::Frame` wraps an
|
||||
`OakVideoParams` handle plus a `Vec<u8>` pixel buffer. In Rust the
|
||||
params are held as an `oakcommon::videoparams::VideoParams` value
|
||||
(single-lib unification dropped the refcounted oakcommon handle);
|
||||
params are held as an `oak_core::videoparams::VideoParams` value
|
||||
(single-lib unification dropped the refcounted oak_core handle);
|
||||
the buffer is a plain `Vec<u8>`.
|
||||
4. **No adapter layer.** Codec calls the other module crates directly
|
||||
(`oakcommon`, `oakcore-rs`, `oakffmpeg-link`), keeping the 2026-08
|
||||
(`oak_core`, `oakcore-rs`, `oakffmpeg-link`), keeping the 2026-08
|
||||
decision recorded in NOTES.md §6. Only genuinely repeated
|
||||
conversions survive as small module-local helpers.
|
||||
5. **XML stays on the C++ side.** `EncodingParams::load/save` use
|
||||
oakcommon's C++ `XmlStreamWriter/Reader` classes
|
||||
oak_core's C++ `XmlStreamWriter/Reader` classes
|
||||
(`src/common/src/xmlutils.h`), exactly as oaknode/oakrender do —
|
||||
the one C++-to-C++ coupling the bridge cannot cover (NOTES.md §7).
|
||||
6. **Threading.** `FrameManager` keeps its background GC thread behind
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! `Unavailable`. Deterministic per-channel filenames derive from the
|
||||
//! source + target audio params.
|
||||
|
||||
use oak_common::filefunctions::FileFunctions;
|
||||
use oak_core::filefunctions::FileFunctions;
|
||||
use std::path::Path;
|
||||
|
||||
/// Conform state of one audio stream.
|
||||
@@ -184,7 +184,7 @@ fn conform_filenames(
|
||||
out
|
||||
}
|
||||
|
||||
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper.
|
||||
/// `oak_core_filefunctions_get_unique_file_identifier` wrapper.
|
||||
fn unique_file_identifier(filename: &str) -> String {
|
||||
FileFunctions::new()
|
||||
.get_unique_file_identifier(filename)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use oak_common::cancelatom::CancelAtom;
|
||||
use oak_core::cancelatom::CancelAtom;
|
||||
use oak_core::{Rational, TimeRange};
|
||||
|
||||
use crate::footagedescription::FootageDescription;
|
||||
@@ -113,7 +113,7 @@ pub enum RenderMode {
|
||||
|
||||
/// "Don't force a color range" sentinel for
|
||||
/// [`RetrieveVideoParams::force_range`] (the actual ranges are the
|
||||
/// `OAKCOMMON_COLOR_RANGE_*` values).
|
||||
/// `oak_core_COLOR_RANGE_*` values).
|
||||
pub const K_COLOR_RANGE_DEFAULT: i32 = -1;
|
||||
|
||||
/// `Decoder::RetrieveVideoParams` — what a video retrieve call needs.
|
||||
@@ -250,7 +250,7 @@ impl CodecStream {
|
||||
|
||||
/// Associated timeline block (borrowed; only compared, never used).
|
||||
pub fn block(&self) -> Option<OakNodeBlock> {
|
||||
self.block.clone()
|
||||
self.block
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +292,21 @@ pub trait Decoder: Send + Sync {
|
||||
/// Retrieve a video frame into CPU memory.
|
||||
fn retrieve_video_frame(&self, p: &RetrieveVideoParams) -> crate::error::Result<Arc<Frame>>;
|
||||
|
||||
/// Retrieve a video frame as an imported GPU planar texture (M5 zero
|
||||
/// copy). `Ok(None)` when the frame is not an importable hardware
|
||||
/// surface, the import switch is off, no GPU context is available, or
|
||||
/// the requested output cannot be delivered zero-copy (a size/format
|
||||
/// that needs the CPU scaler) — the caller then falls back to
|
||||
/// [`Decoder::retrieve_video_frame`]. Implementations that decode only
|
||||
/// in software keep the default.
|
||||
fn retrieve_video_frame_gpu(
|
||||
&self,
|
||||
p: &RetrieveVideoParams,
|
||||
) -> crate::error::Result<Option<oak_core::texture::Texture>> {
|
||||
let _ = p;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Retrieve a video frame as a render texture (owned by caller).
|
||||
fn retrieve_video(&self, p: &RetrieveVideoParams) -> crate::error::Result<OakRenderTexture>;
|
||||
|
||||
@@ -445,13 +460,30 @@ pub fn create_from_id(id: &str) -> Option<Arc<dyn Decoder>> {
|
||||
/// not injected, in which case the built-in list below is used.
|
||||
static TEST_DECODERS: OnceLock<Mutex<Vec<Arc<dyn Decoder>>>> = OnceLock::new();
|
||||
|
||||
/// Serializes every test that reads the built-in decoder registry. Tests
|
||||
/// inject through [`set_test_decoders`] under [`crate::lock_tests`] (the
|
||||
/// shared test lock), so the registry assertions below take that same lock
|
||||
/// to never race with an injected list.
|
||||
/// Serializes every test that reads the built-in decoder registry and
|
||||
/// clears the test injection on drop: a panicking assertion must not leak
|
||||
/// fake decoders into the process-wide registry used by later tests. The
|
||||
/// held [`crate::TestLock`] is released only after the clear.
|
||||
#[cfg(test)]
|
||||
fn registry_guard() -> crate::TestLock {
|
||||
crate::lock_tests()
|
||||
struct RegistryGuard {
|
||||
/// Held for the test's duration; never read, only dropped.
|
||||
_lock: crate::TestLock,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RegistryGuard {
|
||||
fn drop(&mut self) {
|
||||
set_test_decoders(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the shared test lock and clear any injection left behind by an
|
||||
/// earlier panicking test, then return the guard.
|
||||
#[cfg(test)]
|
||||
fn registry_guard() -> RegistryGuard {
|
||||
let lock = crate::lock_tests();
|
||||
set_test_decoders(Vec::new());
|
||||
RegistryGuard { _lock: lock }
|
||||
}
|
||||
|
||||
/// Replace the decoder registry with `list`; pass an empty list to restore
|
||||
@@ -770,4 +802,149 @@ mod tests_unimplemented {
|
||||
assert_eq!(off.numerator(), 0);
|
||||
assert_eq!(off.denominator(), 1);
|
||||
}
|
||||
|
||||
/// A decoder that relies on every trait default (no overrides), so the
|
||||
/// conservative default bodies are covered.
|
||||
struct DefaultsOnly;
|
||||
|
||||
impl Decoder for DefaultsOnly {
|
||||
fn id(&self) -> String {
|
||||
"defaults".to_string()
|
||||
}
|
||||
fn probe(&self, _f: &str, _c: Option<&CancelAtom>) -> Option<FootageDescription> {
|
||||
None
|
||||
}
|
||||
fn open(&self, _s: &CodecStream) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Invalid)
|
||||
}
|
||||
fn close(&self) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn stream(&self) -> CodecStream {
|
||||
CodecStream::new()
|
||||
}
|
||||
fn retrieve_video_frame(
|
||||
&self,
|
||||
_p: &RetrieveVideoParams,
|
||||
) -> crate::error::Result<Arc<Frame>> {
|
||||
Err(crate::error::Error::Invalid)
|
||||
}
|
||||
fn retrieve_video(
|
||||
&self,
|
||||
_p: &RetrieveVideoParams,
|
||||
) -> crate::error::Result<OakRenderTexture> {
|
||||
Err(crate::error::Error::Invalid)
|
||||
}
|
||||
fn retrieve_audio(
|
||||
&self,
|
||||
_d: &mut [f32],
|
||||
_r: &TimeRange,
|
||||
_s: i32,
|
||||
_l: u64,
|
||||
) -> crate::error::Result<RetrieveAudioStatus> {
|
||||
Ok(RetrieveAudioStatus::Unsupported)
|
||||
}
|
||||
fn conform_audio(
|
||||
&self,
|
||||
_o: &[String],
|
||||
_s: i32,
|
||||
_l: u64,
|
||||
_sf: i32,
|
||||
_c: Option<&CancelAtom>,
|
||||
) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_params() -> RetrieveVideoParams {
|
||||
RetrieveVideoParams {
|
||||
stream: CodecStream::new(),
|
||||
time: Rational::new(0, 1),
|
||||
length: TimeRange::default(),
|
||||
force_range: K_COLOR_RANGE_DEFAULT,
|
||||
is_image_sequence: false,
|
||||
image_sequence_digits: 0,
|
||||
image_sequence_number: 0,
|
||||
mode: RenderMode::Offline,
|
||||
alpha_is_premultiplied: false,
|
||||
target_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_trait_defaults_are_conservative() {
|
||||
let d = DefaultsOnly;
|
||||
assert!(!d.supports_video());
|
||||
assert!(!d.supports_audio());
|
||||
assert!(!d.hardware_decoding());
|
||||
assert_eq!(d.get_audio_start_offset(), Rational::new(0, 1));
|
||||
assert!(d.retrieve_video_frame_gpu(&test_params()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oiio_placeholder_reports_unimplemented() {
|
||||
let _g = registry_guard();
|
||||
let d = builtin("oiio");
|
||||
assert!(d.probe("x.exr", None).is_none());
|
||||
assert!(d
|
||||
.open(&CodecStream::with_block("x.exr".to_string(), 0, None))
|
||||
.is_err());
|
||||
assert!(d.close().is_err());
|
||||
assert_eq!(d.stream().filename(), "");
|
||||
let p = test_params();
|
||||
assert!(d.retrieve_video_frame(&p).is_err());
|
||||
assert!(d.retrieve_video(&p).is_err());
|
||||
let mut dest = [0f32; 4];
|
||||
assert!(d
|
||||
.retrieve_audio(
|
||||
&mut dest,
|
||||
&TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
48000,
|
||||
0x3
|
||||
)
|
||||
.is_err());
|
||||
assert!(d
|
||||
.conform_audio(&["a.pcm".to_string()], 48000, 0x3, 10, None)
|
||||
.is_err());
|
||||
// The placeholder keeps the conservative defaults too.
|
||||
assert!(!d.hardware_decoding());
|
||||
assert_eq!(d.get_audio_start_offset(), Rational::new(0, 1));
|
||||
assert!(d.retrieve_video_frame_gpu(&p).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_registry_injection_wins_and_restores() {
|
||||
let _g = registry_guard();
|
||||
set_test_decoders(vec![Arc::new(DefaultsOnly)]);
|
||||
assert_eq!(
|
||||
create_from_id("defaults").map(|d| d.id()).as_deref(),
|
||||
Some("defaults")
|
||||
);
|
||||
assert!(create_from_id("ffmpeg").is_none());
|
||||
set_test_decoders(Vec::new());
|
||||
assert!(create_from_id("ffmpeg").is_some());
|
||||
}
|
||||
|
||||
/// The injection restore is panic-safe: the guard clears the registry
|
||||
/// on unwind, so a failing assertion cannot leave the fake decoder
|
||||
/// installed for later tests in the same process.
|
||||
#[test]
|
||||
fn decoder_registry_clears_after_a_panicking_injection() {
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
let _g = registry_guard();
|
||||
set_test_decoders(vec![Arc::new(DefaultsOnly)]);
|
||||
assert!(
|
||||
create_from_id("defaults").is_some(),
|
||||
"the fake decoder is installed"
|
||||
);
|
||||
panic!("simulated assertion failure after injection");
|
||||
});
|
||||
assert!(panicked.is_err(), "the closure must panic");
|
||||
let _g = registry_guard();
|
||||
assert!(
|
||||
create_from_id("ffmpeg").is_some(),
|
||||
"no fake decoder may leak into the next test"
|
||||
);
|
||||
assert!(create_from_id("defaults").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,31 @@ pub fn set_test_encoders(list: Vec<Arc<dyn Encoder>>) {
|
||||
*store.lock().unwrap() = list;
|
||||
}
|
||||
|
||||
/// Serializes every test that reads the built-in encoder registry and
|
||||
/// clears the test injection on drop: a panicking assertion must not leak
|
||||
/// fake encoders into the process-wide registry used by later tests. The
|
||||
/// held [`crate::TestLock`] is released only after the clear.
|
||||
#[cfg(test)]
|
||||
struct RegistryGuard {
|
||||
/// Held for the test's duration; never read, only dropped.
|
||||
_lock: crate::TestLock,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RegistryGuard {
|
||||
fn drop(&mut self) {
|
||||
set_test_encoders(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the shared test lock and return the guard.
|
||||
#[cfg(test)]
|
||||
fn registry_guard() -> RegistryGuard {
|
||||
RegistryGuard {
|
||||
_lock: crate::lock_tests(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Encoder::create_from_params` — instantiate an encoder for `params`.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
@@ -251,6 +276,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_from_params_maps_formats() {
|
||||
let _guard = registry_guard();
|
||||
let mut p = EncodingParams::default();
|
||||
|
||||
// FFmpeg-backed containers.
|
||||
@@ -346,4 +372,63 @@ mod tests {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_trait_defaults_are_conservative() {
|
||||
let e = UnimplementedDummy;
|
||||
assert!(!e.supports_video());
|
||||
assert!(!e.supports_audio());
|
||||
assert!(!e.supports_subtitles());
|
||||
assert!(!e.supports_image_sequences());
|
||||
assert!(!e.is_configurable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_registry_injection_wins_and_restores() {
|
||||
let _guard = registry_guard();
|
||||
// Unknown format: the built-in mapping returns None.
|
||||
let p = EncodingParams {
|
||||
format: 999,
|
||||
..EncodingParams::default()
|
||||
};
|
||||
assert!(create_from_params(&p).is_none());
|
||||
|
||||
set_test_encoders(vec![Arc::new(UnimplementedDummy)]);
|
||||
let injected = create_from_params(&p).expect("injected encoder wins");
|
||||
assert_eq!(injected.id(), "dummy");
|
||||
|
||||
set_test_encoders(Vec::new());
|
||||
assert!(create_from_params(&p).is_none());
|
||||
}
|
||||
|
||||
/// The injection restore is panic-safe: the guard clears the registry
|
||||
/// on unwind, so a failing assertion cannot leave the fake encoder
|
||||
/// installed for later tests in the same process.
|
||||
#[test]
|
||||
fn encoder_registry_clears_after_a_panicking_injection() {
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
let _guard = registry_guard();
|
||||
set_test_encoders(vec![Arc::new(UnimplementedDummy)]);
|
||||
let p = EncodingParams {
|
||||
format: 999,
|
||||
..EncodingParams::default()
|
||||
};
|
||||
assert_eq!(
|
||||
create_from_params(&p).map(|e| e.id()).as_deref(),
|
||||
Some("dummy"),
|
||||
"the fake encoder is installed"
|
||||
);
|
||||
panic!("simulated assertion failure after injection");
|
||||
});
|
||||
assert!(panicked.is_err(), "the closure must panic");
|
||||
let _guard = registry_guard();
|
||||
let p = EncodingParams {
|
||||
format: 999,
|
||||
..EncodingParams::default()
|
||||
};
|
||||
assert!(
|
||||
create_from_params(&p).is_none(),
|
||||
"no fake encoder may leak into the next test"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,10 +269,10 @@ impl EncodingParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load from a compact XML preset string (oakcommon C++ XmlStreamReader).
|
||||
/// Load from a compact XML preset string (oak_core C++ XmlStreamReader).
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `EncodingParams::load` — uses oakcommon's C++ `XmlStreamReader`
|
||||
/// `EncodingParams::load` — uses oak_core's C++ `XmlStreamReader`
|
||||
/// (`src/common/src/xmlutils.h`), a C++-to-C++ coupling the bridge
|
||||
/// cannot cover (NOTES.md §7). Preserves the load_v1 bug of not
|
||||
/// assigning `custom_range`.
|
||||
@@ -524,7 +524,7 @@ impl EncodingParams {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal XML helpers for the round-trip `load`/`save_to_string`.
|
||||
//
|
||||
// CPP-PARITY: the C++ `load`/`save_to_string` go through oakcommon's
|
||||
// CPP-PARITY: the C++ `load`/`save_to_string` go through oak_core's
|
||||
// `XmlStreamReader`/`XmlStreamWriter` (a C++-to-C++ coupling the Rust bridge
|
||||
// cannot cover, NOTES.md §7). Rather than returning `Err`, this port keeps a
|
||||
// minimal but faithful round-trip for the fields representable without the
|
||||
@@ -558,7 +558,7 @@ impl<'a> Cursor<'a> {
|
||||
}
|
||||
|
||||
fn skip_ws(&mut self) {
|
||||
while self.peek().map_or(false, char::is_whitespace) {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
@@ -1118,3 +1118,65 @@ mod tests {
|
||||
assert_eq!(offset_of!(EncodingParams, color_range), 1548);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_coverage {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_rejects_malformed_and_foreign_documents() {
|
||||
let mut p = EncodingParams::default();
|
||||
for doc in [
|
||||
"",
|
||||
"<export",
|
||||
"<export x=1></export>",
|
||||
"<export x=\"1></export>",
|
||||
"<export></other>",
|
||||
"<export><video/>text</export>",
|
||||
"<not-export/>",
|
||||
] {
|
||||
assert!(p.load(doc).is_err(), "must reject: {doc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_maps_video_audio_and_falls_back_on_unknown_codes() {
|
||||
let mut p = EncodingParams::default();
|
||||
p.load(
|
||||
r#"<export><filename>out.mov</filename><format>2</format>
|
||||
<video enabled="1"><codec>4</codec><width>1920</width><height>1080</height>
|
||||
<format>4</format><timebase>30000/1001</timebase>
|
||||
<pixelaspect>1/1</pixelaspect><vscale>2</vscale></video>
|
||||
<audio enabled="0"><format>5</format><bitrate>192000</bitrate></audio>
|
||||
</export>"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.extension(), "mp4", "format 2 is MPEG-4 video");
|
||||
assert_eq!(p.video_width, 1920);
|
||||
assert_eq!(p.video_height, 1080);
|
||||
assert_eq!(p.video_pixel_format, PixelFormat::F32);
|
||||
assert_eq!(p.video_time_base_num, 30000);
|
||||
assert_eq!(p.video_time_base_den, 1001);
|
||||
assert_eq!(p.video_scaling_method, VideoScalingMethod::Crop);
|
||||
assert_eq!(p.audio_sample_format, SampleFormat::F64Planar);
|
||||
assert_eq!(p.audio_bit_rate, 192000);
|
||||
|
||||
// Unknown numeric codes fall back to Invalid / Stretch.
|
||||
let mut p = EncodingParams::default();
|
||||
p.load(
|
||||
r#"<export><video><format>99</format><vscale>99</vscale></video>
|
||||
<audio><format>99</format></audio></export>"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.video_pixel_format, PixelFormat::Invalid);
|
||||
assert_eq!(p.video_scaling_method, VideoScalingMethod::Stretch);
|
||||
assert_eq!(p.audio_sample_format, SampleFormat::Invalid);
|
||||
|
||||
// -1 is the explicit Invalid code; vscale 0 is Fit.
|
||||
let mut p = EncodingParams::default();
|
||||
p.load(r#"<export><video><format>-1</format><vscale>0</vscale></video></export>"#)
|
||||
.unwrap();
|
||||
assert_eq!(p.video_pixel_format, PixelFormat::Invalid);
|
||||
assert_eq!(p.video_scaling_method, VideoScalingMethod::Fit);
|
||||
}
|
||||
}
|
||||
|
||||
+1510
-86
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,13 @@
|
||||
//!
|
||||
//! Mirrors `src/codec/src/footagedescription.h`. A value type describing
|
||||
//! the streams a `Decoder::probe()` found in a file. Video and subtitle
|
||||
//! streams are stored as oakcommon by-value handles; audio streams as
|
||||
//! streams are stored as oak_core by-value handles; audio streams as
|
||||
//! `oak_core::TimeRangeList`/raw audio params. The original's
|
||||
//! `Track::Type` mapping and XML load/save are intentionally not reproduced
|
||||
//! (NOTES.md §4) — use [`FootageDescription::stream_is_video`] etc.
|
||||
|
||||
use oak_common::subtitleparams::SubtitleParams;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::subtitleparams::SubtitleParams;
|
||||
use oak_core::videoparams::VideoParams;
|
||||
use oak_core::{Rational, TimeRange};
|
||||
|
||||
use crate::audioparams::AudioParams;
|
||||
@@ -214,14 +214,14 @@ mod tests {
|
||||
|
||||
fn video_params(index: i32) -> VideoParams {
|
||||
let mut vp = VideoParams::new_basic(
|
||||
1920,
|
||||
1080,
|
||||
oak_common::ocioutils::PixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1920,
|
||||
1080,
|
||||
oak_core::ocioutils::PixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
vp.set_stream_index(index);
|
||||
vp
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
//! `olive::Frame` — a CPU pixel buffer plus a [`VideoParams`] value.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/frame.h`. The params are held as an oakcommon
|
||||
//! Mirrors `src/codec/src/frame.h`. The params are held as an oak_core
|
||||
//! [`VideoParams`] value (single-lib unification; the former refcounted
|
||||
//! oakcommon handle is gone, so copies are plain clones); the pixel data
|
||||
//! oak_core handle is gone, so copies are plain clones); the pixel data
|
||||
//! itself is a plain `Vec<u8>`. Line-size and pixel-format math lives
|
||||
//! here.
|
||||
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::videoparams::VideoParams;
|
||||
use oak_core::{PixelFormat, Rational};
|
||||
|
||||
/// Number of channels in the internal RGBA pipeline layout
|
||||
@@ -331,7 +331,7 @@ impl Frame {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
|
||||
|
||||
fn frame(w: i32, h: i32) -> Frame {
|
||||
let params = VideoParams::new_basic(w, h, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
@@ -422,7 +422,7 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod tests_extra {
|
||||
use super::*;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
|
||||
|
||||
fn frame(w: i32, h: i32) -> Frame {
|
||||
let params = VideoParams::new_basic(w, h, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::videoparams::VideoParams;
|
||||
use crate::frame::Frame;
|
||||
|
||||
/// `olive::FrameManager`: singleton frame pool with background GC.
|
||||
@@ -148,7 +148,7 @@ fn frame_matches(frame: &Frame, params: &VideoParams) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
|
||||
|
||||
fn test_params(w: i32, h: i32) -> VideoParams {
|
||||
VideoParams::new_basic(w, h, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1)
|
||||
@@ -214,4 +214,12 @@ mod tests {
|
||||
mgr.clear();
|
||||
assert_eq!(mgr.pool.lock().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_is_a_process_singleton() {
|
||||
let a = FrameManager::instance();
|
||||
let b = FrameManager::instance();
|
||||
assert!(std::ptr::eq(a, b));
|
||||
a.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! M5: zero-copy hardware-decode import.
|
||||
//!
|
||||
//! A decoded hardware surface (VAAPI/NVDEC/D3D11VA/VideoToolbox) is
|
||||
//! imported into the engine's wgpu device as a pair of planar textures
|
||||
//! (luma + interleaved chroma) instead of being downloaded with
|
||||
//! `av_hwframe_transfer_data`. The render side turns the planes into
|
||||
//! working-space RGBA with the M2 YUV→RGB pass; the CPU staging path
|
||||
//! stays the per-frame fallback.
|
||||
//!
|
||||
//! Platform surface extraction lives here (FFmpeg types); the raw
|
||||
//! Vulkan/Metal import lives in `oak_core::backend::external`.
|
||||
//!
|
||||
//! ## Counters
|
||||
//!
|
||||
//! - [`HW_IMPORTS`] — frames that took the zero-copy path.
|
||||
//! - [`HW_IMPORT_FALLBACKS`] — attempts that were tried and failed.
|
||||
//! - [`HW_IMPORT_UNSUPPORTED`] — frames that skipped the import because
|
||||
//! no import path exists (NVDEC, a platform switch off, an
|
||||
//! unimportable layout); not failures.
|
||||
//! - `hwdecode::HW_TRANSFERS` — frames downloaded to system memory; a
|
||||
//! zero-copy hardware frame must not move it.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
use ffmpeg::ffi as sys;
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use oak_core::backend::{GpuContext, YuvTransform};
|
||||
#[cfg(target_os = "linux")]
|
||||
use oak_core::backend::ImportGuard;
|
||||
use oak_core::colormath::YuvMatrix;
|
||||
use oak_core::texture::PlanarFormat;
|
||||
|
||||
/// Frames that were imported to the GPU instead of downloaded (M5
|
||||
/// acceptance: the hardware path keeps `hwdecode::HW_TRANSFERS` at 0).
|
||||
pub static HW_IMPORTS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Import attempts that were actually attempted and failed (per-frame
|
||||
/// fallback; feeds the consecutive-failure breaker). "Unsupported"
|
||||
/// outcomes are deliberately NOT counted here — a missing import path is
|
||||
/// not a failure (M5 audit).
|
||||
pub static HW_IMPORT_FALLBACKS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Frames that took the staging path because no import path exists for
|
||||
/// this device/machine/configuration: NVDEC/CUDA has no public handle
|
||||
/// export, a per-platform switch is off, or the surface layout is not
|
||||
/// importable. Observability only: neither counted as a fallback nor fed
|
||||
/// to the breaker.
|
||||
pub static HW_IMPORT_UNSUPPORTED: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Consecutive import failures after which the process stops attempting
|
||||
/// (the platform combination is evidently unsupported on this machine).
|
||||
/// A single failure does not disable anything — the next frame is
|
||||
/// attempted afresh, as the per-frame fallback contract requires.
|
||||
const STICKY_FAILURE_LIMIT: u32 = 8;
|
||||
|
||||
static CONSECUTIVE_FAILURES: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// The config key of the zero-copy import switch (1 = import, 0 =
|
||||
/// staging). Default ON; `OAK_GPU_IMPORT=0` overrides both.
|
||||
pub const CONFIG_KEY_GPU_DECODE_IMPORT: &str = "GpuDecodeImport";
|
||||
|
||||
/// The per-platform import switches (each platform's import is an
|
||||
/// independent, revertable row of design §3.6).
|
||||
pub const CONFIG_KEY_VAAPI_IMPORT: &str = "GpuDecodeImportVaapi";
|
||||
/// Windows D3D11VA import switch.
|
||||
pub const CONFIG_KEY_D3D11_IMPORT: &str = "GpuDecodeImportD3d11";
|
||||
/// macOS VideoToolbox import switch.
|
||||
pub const CONFIG_KEY_VIDEOTOOLBOX_IMPORT: &str = "GpuDecodeImportVideotoolbox";
|
||||
|
||||
/// Whether the zero-copy hardware import is preferred. `OAK_GPU_IMPORT=0`
|
||||
/// force-disables it (diagnostics/CI without importable surfaces), same
|
||||
/// convention as `OAK_HWACCEL`.
|
||||
pub fn gpu_import_enabled() -> bool {
|
||||
if let Ok(v) = std::env::var("OAK_GPU_IMPORT") {
|
||||
return v != "0";
|
||||
}
|
||||
switch_from_config(CONFIG_KEY_GPU_DECODE_IMPORT)
|
||||
}
|
||||
|
||||
/// One platform's import switch (global import switch AND platform
|
||||
/// switch). `env` overrides the config key, like the global switch.
|
||||
fn platform_import_enabled(env: &str, config: &str) -> bool {
|
||||
if let Ok(v) = std::env::var(env) {
|
||||
return v != "0";
|
||||
}
|
||||
switch_from_config(config)
|
||||
}
|
||||
|
||||
fn switch_from_config(key: &str) -> bool {
|
||||
match oak_core::configstore::ConfigStore::instance().get(None, key) {
|
||||
Ok(value) => value != "false",
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the import counters (tests).
|
||||
pub fn reset_import_counters() {
|
||||
HW_IMPORTS.store(0, Ordering::Relaxed);
|
||||
HW_IMPORT_FALLBACKS.store(0, Ordering::Relaxed);
|
||||
HW_IMPORT_UNSUPPORTED.store(0, Ordering::Relaxed);
|
||||
CONSECUTIVE_FAILURES.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Everything the import needs from the decoder's frame and colorimetry.
|
||||
pub struct HwImportRequest<'a> {
|
||||
/// The decoded frame (a hardware surface when import is possible).
|
||||
/// Borrowed for the duration of the attempt; the import guard takes
|
||||
/// its own reference to the underlying surface.
|
||||
pub frame: *const sys::AVFrame,
|
||||
/// Lifetime marker for the borrowed frame.
|
||||
pub _marker: std::marker::PhantomData<&'a ()>,
|
||||
/// The session's hardware device type (`None` = software decode).
|
||||
pub device_type: Option<sys::AVHWDeviceType>,
|
||||
/// Frame dimensions (luma).
|
||||
pub luma_size: (u32, u32),
|
||||
/// The frame's YUV→RGB luma matrix.
|
||||
pub matrix: YuvMatrix,
|
||||
/// Full/limited range.
|
||||
pub full_range: bool,
|
||||
/// Source color-primaries code point.
|
||||
pub color_primaries: i32,
|
||||
/// Source transfer-characteristic code point.
|
||||
pub color_trc: i32,
|
||||
}
|
||||
|
||||
/// A successfully imported hardware frame: the plane tokens plus the
|
||||
/// colorimetry the render side needs to resolve them to working-space
|
||||
/// RGBA.
|
||||
pub struct ImportedHwFrame {
|
||||
/// Luma plane token.
|
||||
pub y: u64,
|
||||
/// Interleaved chroma plane token.
|
||||
pub uv: u64,
|
||||
/// Plane layout (bit depth / interleave).
|
||||
pub format: PlanarFormat,
|
||||
/// Luma dimensions.
|
||||
pub width: u32,
|
||||
/// Frame height.
|
||||
pub height: u32,
|
||||
/// The GPU device that owns the planes.
|
||||
pub ctx: Arc<GpuContext>,
|
||||
/// Extra lifetime guard for platforms whose GPU texture types cannot
|
||||
/// carry a drop callback (macOS): the decoder's pixel buffer.
|
||||
pub keep_alive: Option<Arc<dyn std::any::Any + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ImportedHwFrame {
|
||||
/// The YUV→RGB transform for the planes (the bit depth picks the
|
||||
/// exact limited-range expansion: 8 for NV12, 16 for P010).
|
||||
pub fn transform(&self, matrix: YuvMatrix, full_range: bool) -> YuvTransform {
|
||||
YuvTransform::from_matrix_depth(matrix, full_range, self.format.bit_depth())
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to import `req.frame` as a pair of planar GPU textures. `None`
|
||||
/// means "use the staging path for this frame" — the caller must then
|
||||
/// call the CPU retrieval (the decoder session cache still holds the
|
||||
/// decoded surface, so no re-decode happens).
|
||||
pub fn try_import_hw_frame(req: &HwImportRequest<'_>) -> Option<ImportedHwFrame> {
|
||||
if !gpu_import_enabled() {
|
||||
return None;
|
||||
}
|
||||
if CONSECUTIVE_FAILURES.load(Ordering::Relaxed) >= STICKY_FAILURE_LIMIT {
|
||||
return None;
|
||||
}
|
||||
let device_type = req.device_type?;
|
||||
// Only a host-installed device (the app's UI/render context) may take
|
||||
// the frame: a lazily created worker/CLI context must keep decoding on
|
||||
// the CPU path. Checked before touching the frame so the common
|
||||
// "no import here" case costs nothing.
|
||||
if !GpuContext::host_gpu_installed() {
|
||||
return None;
|
||||
}
|
||||
if !crate::hwdecode::is_hw_format(frame_format(req.frame)) {
|
||||
return None;
|
||||
}
|
||||
let Some(ctx) = GpuContext::shared() else {
|
||||
// The host slot was cleared between the two checks (shutdown):
|
||||
// nothing was attempted, so this is not a failure.
|
||||
HW_IMPORT_UNSUPPORTED.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
};
|
||||
match platform_import(req, &ctx, device_type) {
|
||||
ImportOutcome::Imported {
|
||||
y,
|
||||
uv,
|
||||
format,
|
||||
keep_alive,
|
||||
} => {
|
||||
CONSECUTIVE_FAILURES.store(0, Ordering::Relaxed);
|
||||
HW_IMPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
Some(ImportedHwFrame {
|
||||
y,
|
||||
uv,
|
||||
format,
|
||||
width: req.luma_size.0,
|
||||
height: req.luma_size.1,
|
||||
ctx,
|
||||
keep_alive,
|
||||
})
|
||||
}
|
||||
ImportOutcome::Unsupported => {
|
||||
HW_IMPORT_UNSUPPORTED.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
ImportOutcome::Failed => {
|
||||
bump_fallback();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of one platform import attempt.
|
||||
enum ImportOutcome {
|
||||
/// The planes were imported; the tokens and keep-alive guard.
|
||||
Imported {
|
||||
y: u64,
|
||||
uv: u64,
|
||||
format: PlanarFormat,
|
||||
keep_alive: Option<Arc<dyn std::any::Any + Send + Sync>>,
|
||||
},
|
||||
/// No import path exists for this device/machine/configuration (the
|
||||
/// frame takes the staging path without being a failure).
|
||||
Unsupported,
|
||||
/// The import path exists and the attempt failed: per-frame fallback,
|
||||
/// counted and fed to the consecutive-failure breaker.
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn bump_fallback() {
|
||||
HW_IMPORT_FALLBACKS.fetch_add(1, Ordering::Relaxed);
|
||||
CONSECUTIVE_FAILURES.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The frame's raw pixel format (hardware variants included).
|
||||
fn frame_format(frame: *const sys::AVFrame) -> sys::AVPixelFormat {
|
||||
// SAFETY: plain read of the frame's format field.
|
||||
unsafe { std::mem::transmute::<i32, sys::AVPixelFormat>((*frame).format) }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_import(
|
||||
req: &HwImportRequest<'_>,
|
||||
ctx: &Arc<GpuContext>,
|
||||
device_type: sys::AVHWDeviceType,
|
||||
) -> ImportOutcome {
|
||||
use sys::AVHWDeviceType;
|
||||
if device_type != AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI {
|
||||
// CUDA/NVDEC surfaces have no public fd/handle export; the
|
||||
// staging path is the honest fallback (design §3.6) — NOT a
|
||||
// failure.
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
if !platform_import_enabled("OAK_GPU_IMPORT_VAAPI", CONFIG_KEY_VAAPI_IMPORT) {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
vaapi_import(req, ctx)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform_import(
|
||||
req: &HwImportRequest<'_>,
|
||||
ctx: &Arc<GpuContext>,
|
||||
device_type: sys::AVHWDeviceType,
|
||||
) -> ImportOutcome {
|
||||
if device_type != sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
if !platform_import_enabled("OAK_GPU_IMPORT_D3D11", CONFIG_KEY_D3D11_IMPORT) {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
d3d11_import(req, ctx)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_import(
|
||||
req: &HwImportRequest<'_>,
|
||||
ctx: &Arc<GpuContext>,
|
||||
device_type: sys::AVHWDeviceType,
|
||||
) -> ImportOutcome {
|
||||
if device_type != sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
if !platform_import_enabled("OAK_GPU_IMPORT_VIDEOTOOLBOX", CONFIG_KEY_VIDEOTOOLBOX_IMPORT) {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
videotoolbox_import(req, ctx)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
fn platform_import(
|
||||
_req: &HwImportRequest<'_>,
|
||||
_ctx: &Arc<GpuContext>,
|
||||
_device_type: sys::AVHWDeviceType,
|
||||
) -> ImportOutcome {
|
||||
ImportOutcome::Unsupported
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linux: VAAPI surface -> DRM PRIME descriptor -> DMA-BUF plane imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// DRM format fourcc values (little-endian `fourcc_code`).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod drm_fourcc {
|
||||
pub const R8: u32 = 0x2020_3852; // 'R','8',' ',' '
|
||||
pub const R16: u32 = 0x2036_3152; // 'R','1','6',' '
|
||||
pub const GR88: u32 = 0x3838_5252; // 'R','R','8','8'
|
||||
pub const RG88: u32 = 0x3838_4752; // 'R','G','8','8'
|
||||
pub const RG1616: u32 = 0x3631_4752; // 'R','G','1','6'
|
||||
}
|
||||
|
||||
|
||||
/// Map a VAAPI frame to its DRM PRIME descriptor (FFmpeg calls
|
||||
/// `vaExportSurfaceHandle` internally; no CPU download happens).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn vaapi_import(req: &HwImportRequest<'_>, ctx: &Arc<GpuContext>) -> ImportOutcome {
|
||||
let mut drm_frame = ffmpeg::frame::Video::empty();
|
||||
// SAFETY: `drm_frame` is a valid AVFrame; the map only writes into it
|
||||
// and takes references to the source frame's hwframe mapping.
|
||||
let rc = unsafe {
|
||||
let raw = drm_frame.as_mut_ptr();
|
||||
(*raw).format = sys::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||
sys::av_hwframe_map(raw, req.frame, sys::AV_HWFRAME_MAP_READ as i32)
|
||||
};
|
||||
if rc < 0 {
|
||||
// The VAAPI session exists but the surface could not be exported
|
||||
// as DRM PRIME: a real failure (the breaker bounds the retries).
|
||||
return ImportOutcome::Failed;
|
||||
}
|
||||
// SAFETY: on success `data[0]` is the descriptor FFmpeg allocated for
|
||||
// this mapping; it stays valid until `drm_frame` is unref'd.
|
||||
let desc = unsafe { &*((*drm_frame.as_ptr()).data[0] as *const sys::AVDRMFrameDescriptor) };
|
||||
if desc.nb_layers != 2 {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
let layer_format = |i: usize| desc.layers[i].format;
|
||||
let (format, y_fourcc, uv_fourcc) = match (layer_format(0), layer_format(1)) {
|
||||
(a, b) if a == drm_fourcc::R8 && (b == drm_fourcc::GR88 || b == drm_fourcc::RG88) => {
|
||||
(PlanarFormat::Nv12, a, b)
|
||||
}
|
||||
(a, b) if a == drm_fourcc::R16 && b == drm_fourcc::RG1616 => {
|
||||
(PlanarFormat::P010, a, b)
|
||||
}
|
||||
_ => return ImportOutcome::Unsupported,
|
||||
};
|
||||
let _ = (y_fourcc, uv_fourcc);
|
||||
for layer in &desc.layers[..desc.nb_layers as usize] {
|
||||
if layer.nb_planes != 1 {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
}
|
||||
let (lw, lh) = req.luma_size;
|
||||
if lw == 0 || lh == 0 {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
let (cw, ch) = (lw.div_ceil(2), lh.div_ceil(2));
|
||||
let plane = |layer: usize| {
|
||||
let l = &desc.layers[layer];
|
||||
let p = &l.planes[0];
|
||||
let obj = &desc.objects[p.object_index as usize];
|
||||
oak_core::backend::external::DmaBufPlane {
|
||||
fd: obj.fd,
|
||||
offset: p.offset as u64,
|
||||
pitch: p.pitch as u64,
|
||||
modifier: obj.format_modifier,
|
||||
}
|
||||
};
|
||||
// Shared keep-alive: the source frame pins the VAAPI surface until
|
||||
// the last plane texture is destroyed (the dmabuf fds Vulkan imported
|
||||
// keep the buffer itself alive even after the mapping drops).
|
||||
let Some(keep) = crate::ffmpeg::RefFrame::clone_raw(req.frame) else {
|
||||
return ImportOutcome::Failed;
|
||||
};
|
||||
let keep = Arc::new(keep);
|
||||
let y_guard = keep.clone();
|
||||
let y = match ctx.import_dmabuf_plane(lw, lh, format.y_format(), plane(0), guard(y_guard)) {
|
||||
Ok(y) => y,
|
||||
Err(oak_core::error::Error::State) | Err(oak_core::error::Error::Invalid) => {
|
||||
return ImportOutcome::Unsupported
|
||||
}
|
||||
Err(_) => return ImportOutcome::Failed,
|
||||
};
|
||||
let uv_guard = keep.clone();
|
||||
match ctx.import_dmabuf_plane(cw, ch, format.uv_format(), plane(1), guard(uv_guard)) {
|
||||
Ok(uv) => ImportOutcome::Imported {
|
||||
y,
|
||||
uv,
|
||||
format,
|
||||
keep_alive: None,
|
||||
},
|
||||
Err(_) => {
|
||||
ctx.destroy_texture(y);
|
||||
ImportOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A keep-alive import guard holding one `Arc` of the decoder frame
|
||||
/// (Linux/VAAPI path; Windows attaches its guard inline and macOS keeps
|
||||
/// the frame on the planar texture).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn guard(value: Arc<crate::ffmpeg::RefFrame>) -> Option<ImportGuard> {
|
||||
Some(Box::new(move || drop(value)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows: D3D11VA texture -> shared NT handle -> Vulkan import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Import a D3D11VA texture (Windows): one NV12/P010 texture shared as an
|
||||
/// NT handle, imported as a single multi-planar image whose two plane
|
||||
/// views feed the planar YUV→RGB pass. The decoder's frame reference is
|
||||
/// kept alive by the texture's drop guard.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn d3d11_import(req: &HwImportRequest<'_>, ctx: &Arc<GpuContext>) -> ImportOutcome {
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::Graphics::Direct3D11::{ID3D11Texture2D, D3D11_TEXTURE2D_DESC};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_FORMAT_P010};
|
||||
use windows::Win32::Graphics::Dxgi::{IDXGIResource1, DXGI_SHARED_RESOURCE_READ};
|
||||
|
||||
// AV_PIX_FMT_D3D11: data[0] = ID3D11Texture2D*, data[1] = subresource
|
||||
// index. D3D11VA frames cycle through the decoder's surface ARRAY, so
|
||||
// the slice index is part of the frame identity — the import selects
|
||||
// it via an array-layer view (M5 audit).
|
||||
let texture_ptr = unsafe { (*req.frame).data[0] } as *mut std::ffi::c_void;
|
||||
if texture_ptr.is_null() {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
let slice = unsafe { (*req.frame).data[1] } as usize as u32;
|
||||
// SAFETY: FFmpeg owns the texture reference for the frame's lifetime.
|
||||
let texture: &ID3D11Texture2D = unsafe { &*(texture_ptr as *const ID3D11Texture2D) };
|
||||
let mut desc = D3D11_TEXTURE2D_DESC::default();
|
||||
unsafe { texture.GetDesc(&mut desc) };
|
||||
let array_size = desc.ArraySize.max(1);
|
||||
if slice >= array_size {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
let (texture_format, planar) = match desc.Format {
|
||||
DXGI_FORMAT_NV12 => (oak_core::wgpu::TextureFormat::NV12, PlanarFormat::Nv12),
|
||||
DXGI_FORMAT_P010 => (oak_core::wgpu::TextureFormat::P010, PlanarFormat::P010),
|
||||
_ => return ImportOutcome::Unsupported,
|
||||
};
|
||||
// SAFETY: the D3D11 texture supports IDXGIResource1 (every shared
|
||||
// texture does); a cast failure means it is not a shareable resource.
|
||||
let Ok(resource) = texture.cast::<IDXGIResource1>() else {
|
||||
return ImportOutcome::Unsupported;
|
||||
};
|
||||
// Read-only NT handle; Vulkan references the resource, so the handle
|
||||
// is closed again right after the import.
|
||||
let handle = unsafe {
|
||||
resource.CreateSharedHandle(
|
||||
None,
|
||||
DXGI_SHARED_RESOURCE_READ.0,
|
||||
None::<&windows::core::PCWSTR>,
|
||||
)
|
||||
};
|
||||
let Ok(handle) = handle else {
|
||||
return ImportOutcome::Failed;
|
||||
};
|
||||
let Some(keep) = crate::ffmpeg::RefFrame::clone_raw(req.frame) else {
|
||||
unsafe {
|
||||
let _ = CloseHandle(handle);
|
||||
}
|
||||
return ImportOutcome::Failed;
|
||||
};
|
||||
let (w, h) = req.luma_size;
|
||||
let guard: Arc<crate::ffmpeg::RefFrame> = Arc::new(keep);
|
||||
let result = ctx.import_d3d11_shared_texture(
|
||||
handle.0 as isize,
|
||||
w,
|
||||
h,
|
||||
texture_format,
|
||||
array_size,
|
||||
slice,
|
||||
Some(Box::new(move || drop(guard))),
|
||||
);
|
||||
unsafe {
|
||||
let _ = CloseHandle(handle);
|
||||
}
|
||||
match result {
|
||||
Ok((y, uv)) => ImportOutcome::Imported {
|
||||
y,
|
||||
uv,
|
||||
format: planar,
|
||||
keep_alive: None,
|
||||
},
|
||||
// State/Invalid are capability gaps (missing device feature, wrong
|
||||
// format), not failed attempts.
|
||||
Err(oak_core::error::Error::State) | Err(oak_core::error::Error::Invalid) => {
|
||||
ImportOutcome::Unsupported
|
||||
}
|
||||
Err(_) => ImportOutcome::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// macOS: VideoToolbox CVPixelBuffer -> IOSurface -> Metal textures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `CVPixelBufferGetIOSurface` (CoreVideo).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "CoreVideo", kind = "framework")]
|
||||
extern "C" {
|
||||
fn CVPixelBufferGetIOSurface(pixel_buffer: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
|
||||
}
|
||||
|
||||
/// Import a VideoToolbox frame (macOS): its CVPixelBuffer's IOSurface is
|
||||
/// wrapped in one Metal texture per plane. Metal textures cannot carry a
|
||||
/// drop callback, so the decoder's frame reference travels back in
|
||||
/// `ImportedHwFrame::keep_alive` and is held by the planar texture.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn videotoolbox_import(req: &HwImportRequest<'_>, ctx: &Arc<GpuContext>) -> ImportOutcome {
|
||||
// AV_PIX_FMT_VIDEOTOOLBOX keeps the CVPixelBuffer in data[3].
|
||||
let pixel_buffer = unsafe { (*req.frame).data[3] } as *mut std::ffi::c_void;
|
||||
if pixel_buffer.is_null() {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
let surface = unsafe { CVPixelBufferGetIOSurface(pixel_buffer) };
|
||||
if surface.is_null() {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
let frames = unsafe { (*req.frame).hw_frames_ctx };
|
||||
if frames.is_null() {
|
||||
return ImportOutcome::Unsupported;
|
||||
}
|
||||
// SAFETY: `hw_frames_ctx` is an AVHWFramesContext for this frame.
|
||||
let hw_frames = unsafe { (*frames).data as *const sys::AVHWFramesContext };
|
||||
let sw_format = unsafe { (*hw_frames).sw_format };
|
||||
let format = match sw_format {
|
||||
sys::AVPixelFormat::AV_PIX_FMT_NV12 => PlanarFormat::Nv12,
|
||||
sys::AVPixelFormat::AV_PIX_FMT_P010LE | sys::AVPixelFormat::AV_PIX_FMT_P010BE => {
|
||||
PlanarFormat::P010
|
||||
}
|
||||
_ => return ImportOutcome::Unsupported,
|
||||
};
|
||||
let (w, h) = req.luma_size;
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
// SAFETY: `surface` is the CVPixelBuffer's IOSurface and the decoder
|
||||
// frame reference is kept alive by the import guard (planar texture).
|
||||
let Ok(y) = (unsafe { ctx.import_iosurface_plane(surface, 0, w, h, format.y_format()) }) else {
|
||||
return ImportOutcome::Failed;
|
||||
};
|
||||
let uv = match unsafe { ctx.import_iosurface_plane(surface, 1, cw, ch, format.uv_format()) } {
|
||||
Ok(uv) => uv,
|
||||
Err(_) => {
|
||||
ctx.destroy_texture(y);
|
||||
return ImportOutcome::Failed;
|
||||
}
|
||||
};
|
||||
let Some(keep) = crate::ffmpeg::RefFrame::clone_raw(req.frame) else {
|
||||
ctx.destroy_texture(y);
|
||||
ctx.destroy_texture(uv);
|
||||
return ImportOutcome::Failed;
|
||||
};
|
||||
ImportOutcome::Imported {
|
||||
y,
|
||||
uv,
|
||||
format,
|
||||
keep_alive: Some(Arc::new(keep)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_frame() -> ffmpeg::frame::Video {
|
||||
ffmpeg::frame::Video::empty()
|
||||
}
|
||||
|
||||
fn request(frame: &ffmpeg::frame::Video, device_type: sys::AVHWDeviceType) -> HwImportRequest<'_> {
|
||||
HwImportRequest {
|
||||
frame: unsafe { frame.as_ptr() },
|
||||
_marker: std::marker::PhantomData,
|
||||
device_type: Some(device_type),
|
||||
luma_size: (64, 64),
|
||||
matrix: YuvMatrix::Bt709,
|
||||
full_range: false,
|
||||
color_primaries: 1,
|
||||
color_trc: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only RAII override for a process-global environment variable:
|
||||
/// restores the previous value (or absence) on drop, so a panicking
|
||||
/// assertion cannot leak the override into the next serialized test and
|
||||
/// an externally preset value survives the test.
|
||||
struct EnvVarGuard {
|
||||
name: &'static str,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
/// Set `name=value` for the guard's lifetime.
|
||||
fn set(name: &'static str, value: &str) -> Self {
|
||||
let previous = std::env::var_os(name);
|
||||
std::env::set_var(name, value);
|
||||
Self { name, previous }
|
||||
}
|
||||
|
||||
/// Remove `name` for the guard's lifetime.
|
||||
fn remove(name: &'static str) -> Self {
|
||||
let previous = std::env::var_os(name);
|
||||
std::env::remove_var(name);
|
||||
Self { name, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.previous.take() {
|
||||
Some(value) => std::env::set_var(self.name, value),
|
||||
None => std::env::remove_var(self.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a host-installed device the import declines before any
|
||||
/// frame work (and is not counted as a fallback: nothing failed).
|
||||
#[test]
|
||||
fn import_requires_a_host_installed_context() {
|
||||
let _g = crate::lock_tests();
|
||||
reset_import_counters();
|
||||
assert!(!GpuContext::host_gpu_installed());
|
||||
let frame = empty_frame();
|
||||
let req = request(&frame, sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI);
|
||||
assert!(try_import_hw_frame(&req).is_none());
|
||||
assert_eq!(HW_IMPORTS.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(HW_IMPORT_FALLBACKS.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(HW_IMPORT_UNSUPPORTED.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
/// The counter classification (M5 audit): a device type with no import
|
||||
/// path (NVDEC/CUDA) is "unsupported", never a failure — it must not
|
||||
/// feed the consecutive-failure breaker or `HW_IMPORT_FALLBACKS`.
|
||||
/// Exercised at the platform-function level so no real GPU is needed.
|
||||
///
|
||||
/// (`platform_import` is private; this test lives in the same module.)
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn non_importable_device_type_is_unsupported_not_failed() {
|
||||
let _g = crate::lock_tests();
|
||||
// A context is required by the platform function's signature;
|
||||
// the non-VAAPI device check happens before any of its fields are
|
||||
// touched, so a lazily-created local context is never used.
|
||||
let Some(ctx) = GpuContext::create(oak_core::backend::BackendKind::Auto) else {
|
||||
eprintln!("no GPU adapter; skipping classification test");
|
||||
return;
|
||||
};
|
||||
let frame = empty_frame();
|
||||
let req = request(&frame, sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
|
||||
assert!(
|
||||
matches!(
|
||||
platform_import(&req, &ctx, sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA),
|
||||
ImportOutcome::Unsupported
|
||||
),
|
||||
"CUDA/NVDEC has no export path and must classify as unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `OAK_GPU_IMPORT=0` escape hatch disables the path outright, and
|
||||
/// removing the override falls back to the default-on config. The
|
||||
/// override is RAII-scoped, so an externally preset value survives the
|
||||
/// test instead of being clobbered by `remove_var`.
|
||||
#[test]
|
||||
fn import_switch_disables_the_path() {
|
||||
let _g = crate::lock_tests();
|
||||
let previous = std::env::var_os("OAK_GPU_IMPORT");
|
||||
{
|
||||
let _off = EnvVarGuard::set("OAK_GPU_IMPORT", "0");
|
||||
assert!(!gpu_import_enabled(), "explicit 0 disables the import path");
|
||||
}
|
||||
assert_eq!(
|
||||
std::env::var_os("OAK_GPU_IMPORT"),
|
||||
previous,
|
||||
"the guard restores the externally preset value"
|
||||
);
|
||||
if previous.is_none() {
|
||||
// No override: the config-store default keeps the import path
|
||||
// on. (With an external value the caller's setting governs, so
|
||||
// asserting the default would be wrong.)
|
||||
let _unset = EnvVarGuard::remove("OAK_GPU_IMPORT");
|
||||
assert!(
|
||||
gpu_import_enabled(),
|
||||
"unset falls back to the default-on config"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plane layouts carry the expansion depth the YUV transform needs.
|
||||
#[test]
|
||||
fn planar_formats_carry_their_bit_depth() {
|
||||
assert_eq!(PlanarFormat::Nv12.bit_depth(), 8);
|
||||
assert_eq!(PlanarFormat::P010.bit_depth(), 16);
|
||||
assert_eq!(PlanarFormat::Nv12.name(), "nv12");
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,15 @@
|
||||
//! hardware surface.
|
||||
//!
|
||||
//! - **macOS**: `AV_HWDEVICE_TYPE_VIDEOTOOLBOX`
|
||||
//! - **Linux**: `CUDA` (NVDEC, the discrete-GPU path), then `VAAPI`
|
||||
//! - **Linux**: `VAAPI` (DMA-BUF, the M5 import path), then `CUDA` (NVDEC)
|
||||
//! - **Windows**: `D3D11VA`, then `CUDA` (NVDEC)
|
||||
//!
|
||||
//! Device creation can fail on machines without the device/driver (a
|
||||
//! headless Linux box, no NVIDIA GPU) — the candidate is skipped and
|
||||
//! the next one (or the software decoder) is used. Hardware frames
|
||||
//! (`AV_PIX_FMT_VIDEOTOOLBOX` / `VAAPI` / `CUDA` / `D3D11VA_VLD` /
|
||||
//! `D3D11`) are transferred to system memory with
|
||||
//! `D3D11`) either go through the M5 zero-copy import
|
||||
//! (`crate::gpuinterop`) or are transferred to system memory with
|
||||
//! `av_hwframe_transfer_data` before the swscale conversion.
|
||||
|
||||
use ffmpeg::ffi as sys;
|
||||
@@ -71,12 +72,37 @@ pub const CONFIG_KEY_HARDWARE_DECODING: &str = "HardwareDecoding";
|
||||
static UNAVAILABLE_DEVICES: [std::sync::atomic::AtomicBool; 64] =
|
||||
[const { std::sync::atomic::AtomicBool::new(false) }; 64];
|
||||
|
||||
/// Test-only counter of `open_hw_accel` device-context creation attempts
|
||||
/// (incremented at the top of the function, before any FFmpeg call). Lets
|
||||
/// tests prove the negative cache short-circuits before FFmpeg is
|
||||
/// involved.
|
||||
#[cfg(test)]
|
||||
static CREATE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
thread_local! {
|
||||
/// Test-only counter of `open_hw_accel` device-context creation
|
||||
/// attempts on the calling thread (incremented at the top of the
|
||||
/// function, before any FFmpeg call). Lets tests prove the negative
|
||||
/// cache short-circuits before FFmpeg is involved.
|
||||
///
|
||||
/// Thread-local on purpose: the real-media tests decode on other
|
||||
/// threads *without* the shared test lock, so a process-wide counter
|
||||
/// would let their attempts perturb the negative-cache assertion on
|
||||
/// this thread.
|
||||
static CREATE_ATTEMPTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The calling thread's creation-attempt count (tests only).
|
||||
#[cfg(test)]
|
||||
fn creation_attempts() -> u64 {
|
||||
CREATE_ATTEMPTS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// Count one creation attempt on the calling thread (tests only).
|
||||
#[cfg(test)]
|
||||
fn note_creation_attempt() {
|
||||
CREATE_ATTEMPTS.with(|count| count.set(count.get() + 1));
|
||||
}
|
||||
|
||||
/// Reset the calling thread's creation-attempt count (tests only).
|
||||
#[cfg(test)]
|
||||
fn reset_creation_attempts() {
|
||||
CREATE_ATTEMPTS.with(|count| count.set(0));
|
||||
}
|
||||
|
||||
/// Whether `device_type` is known unavailable — a device-context
|
||||
/// creation failed once earlier in this process.
|
||||
@@ -106,7 +132,7 @@ pub fn hardware_decoding_enabled() -> bool {
|
||||
if let Ok(v) = std::env::var("OAK_HWACCEL") {
|
||||
return v != "0";
|
||||
}
|
||||
match oak_common::configstore::ConfigStore::instance()
|
||||
match oak_core::configstore::ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_HARDWARE_DECODING)
|
||||
{
|
||||
Ok(value) => value != "false",
|
||||
@@ -131,16 +157,17 @@ pub fn device_type_candidates() -> &'static [sys::AVHWDeviceType] {
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// CUDA (native NVDEC) first: it is the discrete-GPU path, and on
|
||||
// multi-GPU boxes VAAPI's default render node can point at a
|
||||
// device with no VA driver (e.g. NVIDIA without
|
||||
// libva-nvidia-driver) while the AMD/Intel node would work —
|
||||
// trying NVDEC first sidesteps that misdirection. VAAPI stays as
|
||||
// the fallback for AMD/Intel-only machines (CUDA device creation
|
||||
// fails fast without libcuda).
|
||||
// VAAPI first: it is the DMA-BUF path the M5 zero-copy import
|
||||
// consumes (`VK_EXT_external_memory_dma_buf`), and on NVIDIA boxes
|
||||
// with the VA-API driver it is just as reachable as NVDEC — while
|
||||
// a CUDA (NVDEC) surface has no public handle export, so frames
|
||||
// decoded through it can only take the staging fallback. CUDA
|
||||
// stays as the fallback for machines without libva; it also
|
||||
// sidesteps VAAPI's default render node pointing at a device with
|
||||
// no VA driver (CUDA device creation fails fast without libcuda).
|
||||
&[
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -176,9 +203,7 @@ pub fn open_hw_accel(
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
CREATE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
note_creation_attempt();
|
||||
let mut context = ffmpeg::codec::Context::from_parameters(params.clone()).ok()?;
|
||||
let mut device: *mut sys::AVBufferRef = std::ptr::null_mut();
|
||||
// SAFETY: `device` is a valid out-pointer; on success it owns the
|
||||
@@ -327,6 +352,10 @@ mod tests {
|
||||
/// boxes without the driver).
|
||||
#[test]
|
||||
fn negative_cache_skips_marked_device() {
|
||||
// Serialize with the media tests that hold the shared test lock
|
||||
// (the counter itself is thread-local, but the negative cache is
|
||||
// process-wide).
|
||||
let _lock = crate::lock_tests();
|
||||
// VDPAU is not a candidate on any supported platform, so marking
|
||||
// it cannot disturb the platform tests in this process (e.g. the
|
||||
// macOS VideoToolbox test above).
|
||||
@@ -340,11 +369,11 @@ mod tests {
|
||||
let params = fstream.parameters();
|
||||
let codec = ffmpeg::decoder::find(params.id()).expect("software h264 codec");
|
||||
|
||||
CREATE_ATTEMPTS.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
reset_creation_attempts();
|
||||
let opened = open_hw_accel(¶ms, codec, dev);
|
||||
assert!(opened.is_none(), "marked device must not open");
|
||||
assert_eq!(
|
||||
CREATE_ATTEMPTS.load(std::sync::atomic::Ordering::Relaxed),
|
||||
creation_attempts(),
|
||||
0,
|
||||
"marked device must be skipped before any creation attempt"
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ pub mod ffmpeg;
|
||||
pub mod footagedescription;
|
||||
pub mod frame;
|
||||
pub mod framemanager;
|
||||
pub mod gpuinterop;
|
||||
pub mod hwdecode;
|
||||
pub mod oiio;
|
||||
pub mod oiioframebridge;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//!
|
||||
//! Mirrors `src/codec/src/oiio/{oiiodecoder,oiioencoder}.{h,cpp}`. OIIO
|
||||
//! frame conversion goes through the local
|
||||
//! [`crate::oiioframebridge`] helpers plus oakcommon's OIIO mapping
|
||||
//! [`crate::oiioframebridge`] helpers plus oak_core's OIIO mapping
|
||||
//! functions.
|
||||
//!
|
||||
//! The OIIO dylib (`liboakoiio`) is not linked into this build, so every
|
||||
@@ -58,9 +58,9 @@ impl Decoder for OIIODecoder {
|
||||
}
|
||||
|
||||
fn probe(
|
||||
&self,
|
||||
_filename: &str,
|
||||
_cancelled: Option<&oak_common::cancelatom::CancelAtom>,
|
||||
&self,
|
||||
_filename: &str,
|
||||
_cancelled: Option<&oak_core::cancelatom::CancelAtom>,
|
||||
) -> Option<crate::footagedescription::FootageDescription> {
|
||||
// Probing is a dylib operation; without it we cannot report anything.
|
||||
None
|
||||
@@ -113,12 +113,12 @@ impl Decoder for OIIODecoder {
|
||||
}
|
||||
|
||||
fn conform_audio(
|
||||
&self,
|
||||
_output_filenames: &[String],
|
||||
_sample_rate: i32,
|
||||
_channel_layout: u64,
|
||||
_sample_format: i32,
|
||||
_cancelled: Option<&oak_common::cancelatom::CancelAtom>,
|
||||
&self,
|
||||
_output_filenames: &[String],
|
||||
_sample_rate: i32,
|
||||
_channel_layout: u64,
|
||||
_sample_format: i32,
|
||||
_cancelled: Option<&oak_core::cancelatom::CancelAtom>,
|
||||
) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
//! frame <-> pixel-buffer conversion.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/oiioframebridge.{h,cpp}`. These are internal C++
|
||||
//! functions that moved into codec from oakcommon (NOTES.md §oakcommon侧修复);
|
||||
//! oakcommon keeps its OIIO mapping functions; the frame conversion itself
|
||||
//! functions that moved into codec from oak_core (NOTES.md §oak_core侧修复);
|
||||
//! oak_core keeps its OIIO mapping functions; the frame conversion itself
|
||||
//! lives here.
|
||||
//!
|
||||
//! The C++ bridge copies pixels through the live OpenImageIO `ImageBuf`
|
||||
@@ -30,8 +30,8 @@
|
||||
//! timestamp and time base alongside the raw pixel rows, so a buffer can be
|
||||
//! turned back into an equivalent [`Frame`] without any external state.
|
||||
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_core::videoparams::VideoParams;
|
||||
use crate::frame::Frame;
|
||||
use oak_core::Rational;
|
||||
use std::ffi::c_int;
|
||||
|
||||
@@ -243,7 +243,7 @@ mod tests {
|
||||
assert!(!d.is_open());
|
||||
|
||||
// Already open -> refuse.
|
||||
assert!(d.open(&[ok.clone()], OpenMode::WriteOnly));
|
||||
assert!(d.open(std::slice::from_ref(&ok), OpenMode::WriteOnly));
|
||||
assert!(!d.open(&[ok], OpenMode::WriteOnly));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
//! Mirrors `src/codec/src/proxymanager.h`. Stateless (NOTES.md): actual
|
||||
//! transcodes are delegated to the global task submit callback
|
||||
//! ([`crate::task`]); with no registrar, `get_or_start` reports the proxy
|
||||
//! as missing. `proxy_params_from_config` reads the oakcommon config store
|
||||
//! as missing. `proxy_params_from_config` reads the oak_core config store
|
||||
//! with the compiled-in defaults as fallback (1280x720 / divider 1 / crf 23
|
||||
//! / "mp4" / "veryfast" / audio included).
|
||||
|
||||
use oak_common::configstore::ConfigStore;
|
||||
use oak_common::filefunctions::FileFunctions;
|
||||
use oak_core::configstore::ConfigStore;
|
||||
use oak_core::filefunctions::FileFunctions;
|
||||
use std::path::Path;
|
||||
|
||||
/// Proxy state of a proxy file on disk.
|
||||
@@ -97,6 +97,9 @@ impl ProxyParams {
|
||||
cstr_slice(&self.extension)
|
||||
}
|
||||
|
||||
/// Only the proxy-params tests read the preset through this accessor
|
||||
/// (the struct field itself is `pub`).
|
||||
#[allow(dead_code)]
|
||||
fn preset_str(&self) -> &str {
|
||||
cstr_slice(&self.preset)
|
||||
}
|
||||
@@ -127,13 +130,13 @@ impl ProxyManager {
|
||||
ProxyParams::default()
|
||||
}
|
||||
|
||||
/// Proxy parameters read from the oakcommon config, with the compiled-in
|
||||
/// Proxy parameters read from the oak_core config, with the compiled-in
|
||||
/// defaults as fallback.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/proxymanager.h` `proxy_params_from_config` — reads
|
||||
/// ProxyWidth/ProxyHeight/ProxyDivider/ProxyCRF/ProxyPreset/
|
||||
/// ProxyIncludeAudio via `oakcommon_config_*`.
|
||||
/// ProxyIncludeAudio via `oak_core_config_*`.
|
||||
pub fn proxy_params_from_config() -> ProxyParams {
|
||||
let mut p = ProxyParams::default();
|
||||
p.width = config_get_int("ProxyWidth", p.width);
|
||||
@@ -314,11 +317,10 @@ impl ProxyManager {
|
||||
/// Locate an ffmpeg executable (empty string when none found).
|
||||
pub fn find_ffmpeg(configured_path: &str) -> String {
|
||||
// An explicitly configured path takes precedence if it is usable.
|
||||
if !configured_path.is_empty() {
|
||||
if is_executable_file(Path::new(configured_path)) {
|
||||
if !configured_path.is_empty()
|
||||
&& is_executable_file(Path::new(configured_path)) {
|
||||
return absolute(configured_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to searching the system PATH (split per platform:
|
||||
// `;` on Windows, `:` elsewhere — see [`split_path_env`]).
|
||||
@@ -387,17 +389,17 @@ fn cstr_slice(a: &[u8; 32]) -> &str {
|
||||
std::str::from_utf8(&a[..end]).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get_int` wrapper (null group).
|
||||
/// `oak_core_config_get_int` wrapper (null group).
|
||||
fn config_get_int(key: &str, default: i32) -> i32 {
|
||||
ConfigStore::instance().get_int(None, key, default)
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get_bool` wrapper (null group).
|
||||
/// `oak_core_config_get_bool` wrapper (null group).
|
||||
fn config_get_bool(key: &str, default: i32) -> i32 {
|
||||
ConfigStore::instance().get_bool(None, key, default)
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get` string read; `None` when the stored value is
|
||||
/// `oak_core_config_get` string read; `None` when the stored value is
|
||||
/// empty or absent.
|
||||
fn config_get_str(key: &str) -> Option<String> {
|
||||
match ConfigStore::instance().get(None, key) {
|
||||
@@ -406,14 +408,14 @@ fn config_get_str(key: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper.
|
||||
/// `oak_core_filefunctions_get_unique_file_identifier` wrapper.
|
||||
fn unique_file_identifier(filename: &str) -> String {
|
||||
FileFunctions::new()
|
||||
.get_unique_file_identifier(filename)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `oakcommon_filefunctions_get_application_path` read.
|
||||
/// `oak_core_filefunctions_get_application_path` read.
|
||||
fn application_path() -> String {
|
||||
FileFunctions::new()
|
||||
.get_application_path()
|
||||
@@ -767,3 +769,86 @@ mod tests_extra {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_coverage {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proxy_state_numeric_and_string_conversions() {
|
||||
assert_eq!(ProxyState::try_from(0), Ok(ProxyState::Missing));
|
||||
assert_eq!(ProxyState::try_from(1), Ok(ProxyState::Generating));
|
||||
assert_eq!(ProxyState::try_from(2), Ok(ProxyState::Ready));
|
||||
assert_eq!(ProxyState::try_from(3), Ok(ProxyState::Failed));
|
||||
assert_eq!(ProxyState::try_from(4), Err(()));
|
||||
assert_eq!(ProxyState::try_from(-1), Err(()));
|
||||
|
||||
for state in [
|
||||
ProxyState::Missing,
|
||||
ProxyState::Generating,
|
||||
ProxyState::Ready,
|
||||
ProxyState::Failed,
|
||||
] {
|
||||
let name = ProxyManager::proxy_state_to_string(state);
|
||||
assert_eq!(ProxyManager::proxy_state_from_string(&name), state);
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_from_string(&(state as i32).to_string()),
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
// Whitespace is trimmed; unknown names fall back to Missing.
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_from_string(" generating "),
|
||||
ProxyState::Generating
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_from_string("bogus"),
|
||||
ProxyState::Missing
|
||||
);
|
||||
assert_eq!(ProxyManager::proxy_state_from_string(""), ProxyState::Missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_filename_audio_tag_detection() {
|
||||
assert!(ProxyManager::proxy_filename_has_audio("/c/shot.a1.mp4"));
|
||||
assert!(ProxyManager::proxy_filename_has_audio("shot.a1.mov"));
|
||||
assert!(!ProxyManager::proxy_filename_has_audio("/c/shot.a2.mp4"));
|
||||
assert!(!ProxyManager::proxy_filename_has_audio("/c/shot.mp4"));
|
||||
assert!(!ProxyManager::proxy_filename_has_audio(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_proxy_state_reads_the_filesystem() {
|
||||
assert_eq!(ProxyManager::get_proxy_state(""), ProxyState::Missing);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("oakcodec-proxy-coverage-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// A finished proxy exists on disk.
|
||||
let proxy = dir.join("clip.mp4");
|
||||
std::fs::write(&proxy, b"proxy").unwrap();
|
||||
assert_eq!(
|
||||
ProxyManager::get_proxy_state(&proxy.to_string_lossy()),
|
||||
ProxyState::Ready
|
||||
);
|
||||
|
||||
// Only the working file exists: still generating.
|
||||
let pending = dir.join("pending.mp4");
|
||||
let working =
|
||||
ProxyManager::get_working_filename(&pending.to_string_lossy()).unwrap();
|
||||
std::fs::write(&working, b"partial").unwrap();
|
||||
assert_eq!(
|
||||
ProxyManager::get_proxy_state(&pending.to_string_lossy()),
|
||||
ProxyState::Generating
|
||||
);
|
||||
|
||||
// Neither file exists.
|
||||
assert_eq!(
|
||||
ProxyManager::get_proxy_state(&dir.join("none.mp4").to_string_lossy()),
|
||||
ProxyState::Missing
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
//! compiled without `#[cfg(test)]` and cannot resolve those symbols; see
|
||||
//! `tests/ffi_contract_test.rs`).
|
||||
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_core::videoparams::VideoParams;
|
||||
use crate::decoder::{
|
||||
CodecStream, Decoder, RenderMode, RetrieveAudioStatus, RetrieveVideoParams,
|
||||
K_COLOR_RANGE_DEFAULT,
|
||||
@@ -503,7 +503,7 @@ static HW_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
#[test]
|
||||
fn hardware_decode_matches_software_decode() {
|
||||
let _guard = HW_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let config = oak_common::configstore::ConfigStore::instance();
|
||||
let config = oak_core::configstore::ConfigStore::instance();
|
||||
let key = crate::hwdecode::CONFIG_KEY_HARDWARE_DECODING;
|
||||
|
||||
let decode_at = |time: i64| -> (Option<String>, Arc<Frame>) {
|
||||
|
||||
@@ -107,6 +107,10 @@ enum SubmitCb {
|
||||
/// No callback registered.
|
||||
None,
|
||||
/// Extern-C callback registered via `oakcodec_set_task_submit_cb`.
|
||||
///
|
||||
/// Only constructed by the tests below and by the extern-C entry point
|
||||
/// that the removed C ABI used to expose; kept for that lane.
|
||||
#[allow(dead_code)]
|
||||
Extern {
|
||||
/// The C function pointer.
|
||||
cb: OakCodecTaskSubmitFn,
|
||||
@@ -151,6 +155,8 @@ pub fn set_task_submit_cb(cb: Option<&'static TaskSubmitFn>, userdata: *mut std:
|
||||
/// Register an extern-C submit callback (used by `ffi::task`).
|
||||
///
|
||||
/// Mirrors `oakcodec_set_task_submit_cb`: a `None` pointer clears it.
|
||||
/// Only the tests below call it (the extern-C lane is exercised there).
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn set_task_submit_cb_extern(
|
||||
cb: Option<OakCodecTaskSubmitFn>,
|
||||
userdata: *mut std::ffi::c_void,
|
||||
@@ -329,8 +335,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn task_kind_values_match_abi() {
|
||||
assert_eq!(TaskKind::Conform as i32, OAKCODEC_TASK_CONFORM as i32);
|
||||
assert_eq!(TaskKind::Proxy as i32, OAKCODEC_TASK_PROXY as i32);
|
||||
assert_eq!(TaskKind::Conform as i32, OAKCODEC_TASK_CONFORM);
|
||||
assert_eq!(TaskKind::Proxy as i32, OAKCODEC_TASK_PROXY);
|
||||
}
|
||||
|
||||
// ABI constants mirrored from include/codec/task.h.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user