Merge origin/main into tts-narration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c030f78762
|
|
@ -1674,6 +1674,7 @@ dependencies = [
|
|||
"filmcraft-engine",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-ui-egui",
|
||||
"glob",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
|
|
@ -2850,6 +2851,12 @@ dependencies = [
|
|||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
|
|
|
|||
|
|
@ -233,9 +233,15 @@ async fn cli() {
|
|||
}
|
||||
register_hardware_decoders();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
|
||||
// the parser takes it as a flag option, so it never reaches the subcommand match.
|
||||
if a.flag("--help") {
|
||||
out!("{HELP}");
|
||||
return;
|
||||
}
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "--help" | "-h" => out!("{HELP}"),
|
||||
"help" | "-h" => out!("{HELP}"),
|
||||
"version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"probe" => {
|
||||
let path = a.pos(1).unwrap_or_else(|| usage("probe <media> [--image-sequence]"));
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ fn help_and_usage_errors() {
|
|||
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_help_matches_help_command() {
|
||||
let reference = cli(&["help"]);
|
||||
assert!(reference.status.success());
|
||||
let long_help = cli(&["--help"]);
|
||||
assert!(long_help.status.success(), "--help failed: {}", String::from_utf8_lossy(&long_help.stderr));
|
||||
assert_eq!(long_help.stdout, reference.stdout);
|
||||
assert!(long_help.stderr.is_empty());
|
||||
let short_help = cli(&["-h"]);
|
||||
assert!(short_help.status.success());
|
||||
assert_eq!(short_help.stdout, reference.stdout);
|
||||
let sub_help = cli(&["export", "--help"]);
|
||||
assert!(sub_help.status.success(), "export --help failed: {}", String::from_utf8_lossy(&sub_help.stderr));
|
||||
assert_eq!(sub_help.stdout, reference.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_mpeg_transport_and_program_streams() {
|
||||
let Some(ffmpeg) = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].into_iter().find(|p| std::path::Path::new(p).exists()) else {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ cpal = "0.16"
|
|||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
glob = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host
|
||||
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use filmcraft_engine::settings::AudioHardwarePrefs;
|
||||
use filmcraft_ui_egui::{AudioDevices, AudioOut};
|
||||
|
|
@ -17,6 +18,31 @@ pub struct CpalOut {
|
|||
hw: AudioHardwarePrefs,
|
||||
/// Sequence sample rate for "Attempt to force hardware to document sample rate".
|
||||
document_rate: Option<u32>,
|
||||
/// Status-bar note: the host's default device could not be opened and a fallback device is
|
||||
/// in use, or no output device was found (playing without sound).
|
||||
note: Option<Note>,
|
||||
/// The fallback picked when the default device could not be opened, as (host, device) names,
|
||||
/// so later lookups (`devices`, `configure`, `start`) reuse it instead of probing every PCM.
|
||||
fallback: Mutex<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
/// A hardware note for the status bar, translated when it is shown.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Note {
|
||||
/// The default output device cannot be opened; this named device is used instead.
|
||||
Fallback(String),
|
||||
/// No output device at all: playback runs without sound.
|
||||
NoDevice,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
fn text(&self) -> String {
|
||||
use filmcraft_ui_egui::i18n::{fmt, t};
|
||||
match self {
|
||||
Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]),
|
||||
Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host named in the settings (empty or unknown = the default host).
|
||||
|
|
@ -30,21 +56,76 @@ fn host(name: &str) -> cpal::Host {
|
|||
cpal::default_host()
|
||||
}
|
||||
|
||||
/// The output device named in the settings (empty or missing = the host's default).
|
||||
fn output_device(h: &cpal::Host, name: &str) -> Option<cpal::Device> {
|
||||
/// The output device the settings ask for, with a status-bar note when a fallback is in use.
|
||||
struct DeviceChoice {
|
||||
device: cpal::Device,
|
||||
/// The host's default device could not be opened: this device is the fallback.
|
||||
fallback: Option<Note>,
|
||||
}
|
||||
|
||||
/// Fallback order when the default device cannot be opened: the sound-server bridges first
|
||||
/// (`pipewire`, `pulse`: they share the card with every other app), then other named PCMs, and
|
||||
/// raw `hw:`/`plughw:` devices last (opening one takes the card exclusively and silences the
|
||||
/// rest of the desktop).
|
||||
fn fallback_rank(name: &str) -> u8 {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n == "pipewire" || n.starts_with("pipewire:") {
|
||||
0
|
||||
} else if n == "pulse" || n.starts_with("pulse:") {
|
||||
1
|
||||
} else if n.starts_with("hw:") || n.starts_with("plughw:") {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// The output device named in the settings (empty or missing = the host's default). When the
|
||||
/// default cannot be opened — a broken ALSA `default` (missing `99-pipewire-default.conf`,
|
||||
/// #23/#106) — a fallback that can, so the app is not silently mute. The fallback list is probed
|
||||
/// only after the default fails, in [`fallback_rank`] order, and the pick is cached in `cache`.
|
||||
fn output_device(h: &cpal::Host, name: &str, cache: &Mutex<Option<(String, String)>>) -> Option<DeviceChoice> {
|
||||
if !name.is_empty()
|
||||
&& let Ok(mut devs) = h.output_devices()
|
||||
&& let Some(d) = devs.find(|d| d.name().is_ok_and(|n| n == name))
|
||||
{
|
||||
return Some(d);
|
||||
return Some(DeviceChoice { device: d, fallback: None });
|
||||
}
|
||||
h.default_output_device()
|
||||
if let Some(d) = h.default_output_device()
|
||||
&& d.default_output_config().is_ok()
|
||||
{
|
||||
return Some(DeviceChoice { device: d, fallback: None });
|
||||
}
|
||||
let host_name = h.id().name().to_string();
|
||||
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut devs: Vec<(String, cpal::Device)> = h.output_devices().ok()?.filter_map(|d| d.name().ok().map(|n| (n, d))).collect();
|
||||
if let Some((cached_host, cached)) = cache.as_ref()
|
||||
&& *cached_host == host_name
|
||||
&& let Some(i) = devs.iter().position(|(n, _)| n == cached)
|
||||
{
|
||||
let (found, d) = devs.swap_remove(i);
|
||||
return Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) });
|
||||
}
|
||||
devs.sort_by_key(|(n, _)| fallback_rank(n));
|
||||
let (found, d) = devs.into_iter().find(|(_, d)| d.default_output_config().is_ok())?;
|
||||
log::warn!("the default audio output device cannot be opened; falling back to '{found}'");
|
||||
*cache = Some((host_name, found.clone()));
|
||||
Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) })
|
||||
}
|
||||
|
||||
impl CpalOut {
|
||||
pub fn new() -> Self {
|
||||
let host = cpal::default_host();
|
||||
let cfg = host.default_output_device().and_then(|dev| dev.default_output_config().ok());
|
||||
let fallback = Mutex::new(None);
|
||||
let choice = output_device(&host, "", &fallback);
|
||||
let cfg = choice.as_ref().and_then(|c| c.device.default_output_config().ok());
|
||||
let note = match choice {
|
||||
Some(c) => c.fallback,
|
||||
None => {
|
||||
log::warn!("no audio output device found");
|
||||
Some(Note::NoDevice)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
stream: None,
|
||||
played: Arc::new(AtomicU64::new(0)),
|
||||
|
|
@ -53,6 +134,8 @@ impl CpalOut {
|
|||
channels: cfg.as_ref().map_or(2, |c| c.channels()),
|
||||
hw: AudioHardwarePrefs::default(),
|
||||
document_rate: None,
|
||||
note,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +207,8 @@ impl AudioOut for CpalOut {
|
|||
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.stop();
|
||||
let host = host(&self.hw.device_class);
|
||||
let dev = output_device(&host, &self.hw.default_output).ok_or("no output device")?;
|
||||
let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?;
|
||||
self.note = fallback;
|
||||
let cfg = self.config(&dev)?;
|
||||
let channels = cfg.channels() as usize;
|
||||
if channels == 0 || cfg.sample_rate().0 == 0 {
|
||||
|
|
@ -185,7 +269,8 @@ impl AudioOut for CpalOut {
|
|||
let h = host(&self.hw.device_class);
|
||||
let outputs = h.output_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
|
||||
let inputs = h.input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
|
||||
let output_channels = output_device(&h, &self.hw.default_output).and_then(|d| self.config(&d).ok()).map(|c| c.channels()).unwrap_or(0);
|
||||
let output_channels =
|
||||
output_device(&h, &self.hw.default_output, &self.fallback).and_then(|c| self.config(&c.device).ok()).map(|cfg| cfg.channels()).unwrap_or(0);
|
||||
AudioDevices { hosts: cpal::available_hosts().iter().map(|h| h.name().to_string()).collect(), inputs, outputs, output_channels }
|
||||
}
|
||||
fn configure(&mut self, hw: &AudioHardwarePrefs, document_rate: Option<u32>) {
|
||||
|
|
@ -193,13 +278,17 @@ impl AudioOut for CpalOut {
|
|||
self.document_rate = document_rate;
|
||||
// the rate playback mixes at must be known before `start`
|
||||
let host = host(&self.hw.device_class);
|
||||
if let Some(dev) = output_device(&host, &self.hw.default_output)
|
||||
&& let Ok(cfg) = self.config(&dev)
|
||||
if let Some(choice) = output_device(&host, &self.hw.default_output, &self.fallback)
|
||||
&& let Ok(cfg) = self.config(&choice.device)
|
||||
{
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.note = choice.fallback;
|
||||
}
|
||||
}
|
||||
fn note(&self) -> Option<String> {
|
||||
self.note.as_ref().map(Note::text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -207,6 +296,19 @@ mod tests {
|
|||
use super::*;
|
||||
use cpal::Sample;
|
||||
|
||||
#[test]
|
||||
fn fallback_prefers_sound_servers_over_raw_hardware() {
|
||||
let mut names = vec!["plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0", "sysdefault:CARD=PCH", "pulse", "pipewire"];
|
||||
names.sort_by_key(|n| fallback_rank(n));
|
||||
assert_eq!(names, ["pipewire", "pulse", "sysdefault:CARD=PCH", "plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_name_the_fallback_device() {
|
||||
assert!(Note::Fallback("pipewire".into()).text().contains("'pipewire'"));
|
||||
assert!(!Note::NoDevice.text().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_conversion_clips_and_preserves_interleaved_channels() {
|
||||
let mut signed = [0_i16; 8];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
//! Dev-time Linux desktop entry: the shell icon fallback.
|
||||
//!
|
||||
//! The Linux shell (Wayland *and* X11) resolves the window to a `.desktop` file by app id (set in
|
||||
//! `main.rs` via `eframe`'s `with_app_id`) and takes the taskbar / window-list icon from its
|
||||
//! `Icon=` entry in the hicolor theme. Packaging installs both
|
||||
//! (`packaging/linux/ai.storyteller.filmcraft.desktop` and the hicolor PNGs) — but a source-tree
|
||||
//! run has neither, so KDE/GNOME fall back to the generic Wayland icon. The fix is to ensure a
|
||||
//! user-level entry for those runs: when no entry for the app id exists in any XDG data dir,
|
||||
//! install one (plus the hicolor icon PNGs, read from this checkout's `assets/app-icon`, so
|
||||
//! nothing is embedded in the binary) into `$XDG_DATA_HOME`/`~/.local/share`. An existing
|
||||
//! packaged entry is never overridden; the user-owned dev copy is rewritten when it points at
|
||||
//! another binary (a dev build moves between checkouts). The shell picks the icon up at the *next* app start;
|
||||
//! `update-desktop-database`/`kbuildsycoca` is not needed for icon lookup.
|
||||
//!
|
||||
//! Opt-in only: nothing is written unless `FILMCRAFT_DEV_DESKTOP_ENTRY=1` is set, so ordinary
|
||||
//! `cargo run`s (agents, worktrees, CI) never touch the user's profile. Packaged runs are
|
||||
//! excluded even then — AppImage (`APPIMAGE`) mounts at a fresh `/tmp/.mount_*` each launch,
|
||||
//! so its `Exec` path changes every time and it ships its own entry, and a Flatpak
|
||||
//! (`FLATPAK_ID`) installs its entry through the manifest. `packaging/linux/install.sh` writes the packaged
|
||||
//! `Exec=` the same two-layer way as here; keep the two in sync.
|
||||
//!
|
||||
//! Every failure is logged and skipped — a missing dev icon must never stop the app
|
||||
//! ([`AGENTS.md`](/AGENTS.md) §0 "never crash").
|
||||
|
||||
/// Install the user-level desktop entry and icons for a source-tree run (Linux).
|
||||
pub fn ensure_dev_desktop_entry() {
|
||||
// Packaged and sandboxed runs never write into the user's profile: an AppImage mounts at a
|
||||
// fresh /tmp/.mount_* every launch (the Exec path changes each time) and a Flatpak installs
|
||||
// its own entry.
|
||||
if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("FLATPAK_ID").is_some() {
|
||||
return;
|
||||
}
|
||||
// Opt-in only: a plain `cargo run` (agents, worktrees, other checkouts) must not write into
|
||||
// the user's profile, and a packaged release never does (packages and install.sh install
|
||||
// the real entry).
|
||||
if std::env::var_os("FILMCRAFT_DEV_DESKTOP_ENTRY").is_none_or(|v| v != "1") {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = ensure() {
|
||||
log::warn!("desktop entry (taskbar icon) not installed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Install missing files; `Ok` whether anything was written or an entry already existed.
|
||||
fn ensure() -> Result<(), DevIconError> {
|
||||
let file_name = format!("{}.desktop", crate::APP_ID);
|
||||
let user_dir = user_data_dir()?;
|
||||
let exe = std::env::current_exe().map_err(DevIconError::Io)?;
|
||||
// Not `to_string_lossy`: a substituted U+FFFD would install an `Exec` that can never start
|
||||
// the app, and the same lossy string would match on every later run, so the broken entry
|
||||
// would never be rewritten or warned about.
|
||||
let exe = exe.to_str().ok_or(DevIconError::BadExecPath)?;
|
||||
if exe.contains('=') || exe.contains('%') || exe.chars().any(|c| c.is_control()) {
|
||||
// A desktop entry cannot carry these (install.sh rejects them for the same reason):
|
||||
// '=' separates key and value, '%' introduces a field code, control characters are
|
||||
// not valid in the string layer.
|
||||
return Err(DevIconError::BadExecPath);
|
||||
}
|
||||
for dir in search_dirs(&user_dir) {
|
||||
let candidate = dir.join("applications").join(&file_name);
|
||||
if !candidate.is_file() {
|
||||
continue;
|
||||
}
|
||||
// A packaged entry (any system dir) already gives the shell its icon; only our own
|
||||
// user-level copy is kept fresh.
|
||||
if dir != user_dir {
|
||||
return Ok(());
|
||||
}
|
||||
let body = std::fs::read_to_string(&candidate).map_err(DevIconError::Io)?;
|
||||
if desktop_file_exec_is_ours(&body, exe) {
|
||||
return Ok(());
|
||||
}
|
||||
break; // a dev entry from another checkout: fall through and rewrite it
|
||||
}
|
||||
for size in ICON_SIZES {
|
||||
let rel = std::path::Path::new("hicolor").join(format!("{size}x{size}")).join("apps").join(format!("{}.png", crate::APP_ID));
|
||||
let dest = user_dir.join("icons").join(&rel);
|
||||
if !dest.is_file()
|
||||
&& let Some(parent) = dest.parent()
|
||||
{
|
||||
// The checkout this binary was built from; a missing icon only costs the icon.
|
||||
let src = std::path::Path::new(ICON_DIR).join(&rel);
|
||||
if !src.is_file() {
|
||||
log::warn!("dev desktop entry: icon {} not found; skipping it", src.display());
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(parent).map_err(DevIconError::Io)?;
|
||||
std::fs::copy(&src, &dest).map_err(DevIconError::Io)?;
|
||||
}
|
||||
}
|
||||
let applications = user_dir.join("applications");
|
||||
std::fs::create_dir_all(&applications).map_err(DevIconError::Io)?;
|
||||
let body = desktop_file_content(crate::APP_ID, "FilmCraft", exe);
|
||||
std::fs::write(applications.join(&file_name), body).map_err(DevIconError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The hicolor icon sizes installed for the taskbar (a subset large enough for panel and topbar;
|
||||
/// the shell picks the nearest size, so 256 and 512 cover both).
|
||||
const ICON_SIZES: [u16; 2] = [256, 512];
|
||||
|
||||
/// The app icons in the source tree this binary was built from (a path, not embedded bytes:
|
||||
/// the entry is a dev-checkout convenience and release binaries must not carry the PNGs).
|
||||
const ICON_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/app-icon");
|
||||
|
||||
/// The user data dir we install into: `$XDG_DATA_HOME`, else `$HOME/.local/share` (XDG base
|
||||
/// directory spec). Untrusted environment: an empty or relative `XDG_DATA_HOME` means the
|
||||
/// default, and a missing or relative `HOME` is an error — never a guess that would write
|
||||
/// into the working directory.
|
||||
fn user_data_dir() -> Result<std::path::PathBuf, DevIconError> {
|
||||
if let Some(v) = std::env::var_os("XDG_DATA_HOME")
|
||||
&& !v.is_empty()
|
||||
&& std::path::Path::new(&v).is_absolute()
|
||||
{
|
||||
return Ok(std::path::PathBuf::from(v));
|
||||
}
|
||||
match std::env::var_os("HOME").filter(|h| !h.is_empty() && std::path::Path::new(h).is_absolute()) {
|
||||
Some(home) => Ok(std::path::PathBuf::from(home).join(".local").join("share")),
|
||||
None => Err(DevIconError::NoHome),
|
||||
}
|
||||
}
|
||||
|
||||
/// The XDG data dirs to search for an existing entry, user dir first, then `$XDG_DATA_DIRS` (or
|
||||
/// the spec default `/usr/local/share:/usr/share`). Only absolute paths are taken; the spec
|
||||
/// requires them and a relative one would read the working directory.
|
||||
fn search_dirs(user: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = vec![user.to_path_buf()];
|
||||
let system = std::env::var_os("XDG_DATA_DIRS")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
for dir in system.split(':') {
|
||||
if std::path::Path::new(dir).is_absolute() {
|
||||
dirs.push(std::path::PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Whether the `.desktop` body's `Exec` points at our binary exactly (a stale dev entry from
|
||||
/// another checkout — `/a/filmcraft-old` is not `/a/filmcraft` — does not, and is rewritten; a
|
||||
/// packaged `Exec=filmcraft` never matches a source-tree path).
|
||||
fn desktop_file_exec_is_ours(body: &str, exe: &str) -> bool {
|
||||
exec_line(body).is_some_and(|arg| arg == exe)
|
||||
}
|
||||
|
||||
/// The first `Exec=` argument (the executable), unescaped through both Desktop Entry layers:
|
||||
/// first the string layer of the file value (`\\` `\s` `\n` `\t` `\r`, per
|
||||
/// <https://specifications.freedesktop.org/desktop-entry/latest/>), then the shell layer of the
|
||||
/// argument (double quotes, and `\` before `` ` `"` `\` `$` inside them). `None` when there is no
|
||||
/// `Exec=` line or its first argument cannot be parsed.
|
||||
fn exec_line(body: &str) -> Option<String> {
|
||||
let value = body.lines().find(|l| l.starts_with("Exec=")).and_then(|l| l.strip_prefix("Exec="))?;
|
||||
let value = unescape_string_value(value);
|
||||
if let Some(rest) = value.strip_prefix('"') {
|
||||
let mut out = String::new();
|
||||
let mut chars = rest.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => return Some(out),
|
||||
'\\' => match chars.next() {
|
||||
Some(e @ ('\\' | '"' | '`' | '$')) => out.push(e),
|
||||
Some(e) => {
|
||||
out.push('\\');
|
||||
out.push(e);
|
||||
}
|
||||
None => return None, // a dangling escape ends the argument
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
None // no closing quote
|
||||
} else {
|
||||
Some(value.split(' ').next().unwrap_or_default().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo the desktop entry's string layer: `\\` `\s` `\n` `\t` `\r` are the defined sequences; a
|
||||
/// backslash before anything else is kept as the character itself (lenient, like desktop file
|
||||
/// parsers in the wild).
|
||||
fn unescape_string_value(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('\\') => out.push('\\'),
|
||||
Some('s') => out.push(' '),
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some(e) => out.push(e),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.desktop` body: same fields as `packaging/linux/ai.storyteller.filmcraft.desktop`, with
|
||||
/// `Exec` pointing at this exact binary (a dev build isn't on `$PATH`, so `TryExec` is left out)
|
||||
/// and the icon by theme name.
|
||||
fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("[Desktop Entry]\n");
|
||||
out.push_str("Type=Application\n");
|
||||
out.push_str(&format!("Name={name}\n"));
|
||||
out.push_str("GenericName=Video Editor\n");
|
||||
out.push_str("Comment=Edit video, color and sound\n");
|
||||
out.push_str(&format!("Exec=\"{}\" %F\n", escape_exec_argument(exe)));
|
||||
out.push_str(&format!("Icon={app_id}\n"));
|
||||
out.push_str("Terminal=false\n");
|
||||
out.push_str("StartupNotify=true\n");
|
||||
out.push_str(&format!("StartupWMClass={app_id}\n"));
|
||||
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
|
||||
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape `s` for the `Exec=` value's two layers, like `packaging/linux/install.sh`: first the
|
||||
/// shell layer (`` ` `` `"` `$` `\` get a backslash, and the argument is double-quoted), then
|
||||
/// the string layer of the file value (every backslash doubled).
|
||||
fn escape_exec_argument(s: &str) -> String {
|
||||
let mut shell = String::with_capacity(s.len() + 2);
|
||||
for c in s.chars() {
|
||||
if matches!(c, '`' | '"' | '$' | '\\') {
|
||||
shell.push('\\');
|
||||
}
|
||||
shell.push(c);
|
||||
}
|
||||
shell.replace('\\', "\\\\")
|
||||
}
|
||||
|
||||
/// Everything that can keep the dev icon out of the taskbar; logged, never fatal.
|
||||
#[derive(Debug)]
|
||||
enum DevIconError {
|
||||
Io(std::io::Error),
|
||||
NoHome,
|
||||
BadExecPath,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DevIconError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DevIconError::Io(e) => write!(f, "{e}"),
|
||||
DevIconError::NoHome => write!(f, "no home directory for the user data dir"),
|
||||
DevIconError::BadExecPath => {
|
||||
write!(f, "the binary path has a character a desktop entry cannot carry (=, % or a control character), or is not valid UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The entry mirrors the packaged one, derived from the real file so the two cannot drift:
|
||||
/// minus `TryExec` (a dev build isn't on `$PATH`), plus the quoted dev `Exec` path.
|
||||
/// The icons the entry installs exist in the checkout.
|
||||
#[test]
|
||||
fn icon_sources_exist_in_the_checkout() {
|
||||
for size in ICON_SIZES {
|
||||
let p = std::path::Path::new(ICON_DIR).join(format!("hicolor/{size}x{size}/apps/ai.storyteller.filmcraft.png"));
|
||||
assert!(p.is_file(), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_file_matches_the_packaged_fields() {
|
||||
let packaged = include_str!("../../../packaging/linux/ai.storyteller.filmcraft.desktop");
|
||||
let expected = packaged
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("TryExec="))
|
||||
.map(|l| if l.starts_with("Exec=") { "Exec=\"/target/debug/filmcraft\" %F" } else { l })
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", "/target/debug/filmcraft");
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
|
||||
/// Both layers are escaped, like install.sh: shell first (`` ` `` `"` `$` `\` get a
|
||||
/// backslash), then *every* backslash doubled for the file value — including the escape
|
||||
/// backslashes just added.
|
||||
#[test]
|
||||
fn exec_escapes_both_desktop_entry_layers() {
|
||||
assert_eq!(escape_exec_argument("/opt/film craft/filmcraft"), "/opt/film craft/filmcraft");
|
||||
// a\b → shell a\\b → string a\\\\b
|
||||
assert_eq!(escape_exec_argument("a\\b"), "a\\\\\\\\b");
|
||||
// a$b → shell a\$b → string a\\$b
|
||||
assert_eq!(escape_exec_argument("a$b"), "a\\\\$b");
|
||||
}
|
||||
|
||||
/// A written entry reads back as the exact binary it was written for, through both layers.
|
||||
#[test]
|
||||
fn exec_round_trips_through_both_layers() {
|
||||
for exe in ["/home/dev/Projects/filmcraft/target/debug/filmcraft", "/opt/film craft/filmcraft", "/a$b\"c`d\\e"] {
|
||||
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", exe);
|
||||
assert_eq!(exec_line(&body).as_deref(), Some(exe));
|
||||
assert!(desktop_file_exec_is_ours(&body, exe));
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the exact executable counts: another checkout's path, a path with our path as a
|
||||
/// prefix, and a packaged `Exec=filmcraft` are all someone else's entry.
|
||||
#[test]
|
||||
fn desktop_file_exec_is_ours_matches_only_our_binary() {
|
||||
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", "/a/filmcraft");
|
||||
assert!(!desktop_file_exec_is_ours(&body, "/a/filmcraft-old"));
|
||||
assert!(!desktop_file_exec_is_ours(&body, "/b/filmcraft"));
|
||||
assert!(!desktop_file_exec_is_ours("Exec=filmcraft %F\n", "/a/filmcraft"));
|
||||
assert!(!desktop_file_exec_is_ours("Name=filmcraft\n", "/a/filmcraft"));
|
||||
assert!(!desktop_file_exec_is_ours("Exec=\"dangling\n", "/a/filmcraft"));
|
||||
}
|
||||
|
||||
/// The string layer is undone before the argument is split: `\s` becomes a space, which
|
||||
/// ends a bare argument (the shell layer's quoting, not `\s`, is how a space travels), and
|
||||
/// `\\` is one backslash. install.sh always quotes, so a space in our own `Exec` never
|
||||
/// splits.
|
||||
#[test]
|
||||
fn exec_reads_the_string_layer() {
|
||||
assert_eq!(exec_line("Exec=/a\\sb %F\n").as_deref(), Some("/a"));
|
||||
assert_eq!(exec_line("Exec=\"/a\\sb\" %F\n").as_deref(), Some("/a b"));
|
||||
assert_eq!(exec_line("Exec=/a\\\\b %F\n").as_deref(), Some("/a\\b"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
//! Extension filters for native file dialogs.
|
||||
//!
|
||||
//! Every filtered file dialog (import, relink, open file/preset, open/save project) builds
|
||||
//! its filter with [`extensions`]. On Linux and the BSDs, rfd's XDG portal, GTK and Zenity
|
||||
//! backends turn each extension into a case-sensitive `*.{ext}` glob, so camera files such
|
||||
//! as `shot.MP4` or `shot.Mp4` would be hidden. The helper appends one bracket pattern per
|
||||
//! extension (`mp4` -> `[mM][pP]4`) after the literal extensions, which stay first so save
|
||||
//! dialogs keep their normal default suffix. macOS and Windows dialogs already ignore case
|
||||
//! and receive the plain list.
|
||||
|
||||
pub fn extensions(exts: &[&str]) -> Vec<String> {
|
||||
let extensions: Vec<_> = exts.iter().map(|ext| (*ext).to_owned()).collect();
|
||||
// XDG portal/Zenity globs are case-sensitive. Keep the literal extensions first so
|
||||
// Save dialogs can still infer a normal default suffix, then cover mixed case too.
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let mut extensions = extensions;
|
||||
for ext in exts {
|
||||
if !ext.bytes().any(|b| b.is_ascii_alphabetic()) {
|
||||
continue;
|
||||
}
|
||||
let mut pattern = String::new();
|
||||
for c in ext.chars() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
pattern.extend(['[', c.to_ascii_lowercase(), c.to_ascii_uppercase(), ']']);
|
||||
} else {
|
||||
pattern.push(c);
|
||||
}
|
||||
}
|
||||
extensions.push(pattern);
|
||||
}
|
||||
extensions
|
||||
}
|
||||
// Native macOS and Windows filters take ordinary extensions.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extensions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extensions;
|
||||
|
||||
// rfd's XDG portal and Zenity backends turn each extension into `*.{ext}`.
|
||||
// Exercise those case-sensitive patterns against filenames, as the picker does.
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
#[test]
|
||||
fn camera_files_are_visible_in_every_capitalization() {
|
||||
let filters: Vec<_> = extensions(&["mp4", "mov", "m2ts", "wav", "fcproj"]).iter().map(|ext| glob::Pattern::new(&format!("*.{ext}")).unwrap()).collect();
|
||||
for name in ["camera.mp4", "camera.MP4", "camera.Mp4", "clip.MOV", "clip.moV", "take.M2TS", "sound.WaV", "Edit.FCPROJ", "Edit.FcProj"] {
|
||||
assert!(filters.iter().any(|f| f.matches(name)), "the picker hides {name}");
|
||||
}
|
||||
for name in ["camera.mp4.bak", "camera.mp44", "camera.xmp4", "notes.txt", "mp4"] {
|
||||
assert!(!filters.iter().any(|f| f.matches(name)), "the picker admits {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_filters_keep_the_literal_default_extension() {
|
||||
assert_eq!(extensions(&["mp4", "mov"]).first().map(String::as_str), Some("mp4"));
|
||||
assert!(extensions(&[]).is_empty());
|
||||
assert_eq!(extensions(&["*", "", "123"]), ["*", "", "123"]);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
#[test]
|
||||
fn native_filters_receive_only_literal_extensions() {
|
||||
assert_eq!(extensions(&["mp4", "mov", "fcproj"]), ["mp4", "mov", "fcproj"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ mod args;
|
|||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod dev_icon;
|
||||
mod file_filters;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
|
|
@ -107,6 +110,8 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
}
|
||||
app_nap::disable();
|
||||
#[cfg(target_os = "linux")]
|
||||
dev_icon::ensure_dev_desktop_entry();
|
||||
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
|
||||
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
|
||||
filmcraft_ui_egui::crash::install(log_dir);
|
||||
|
|
@ -199,7 +204,7 @@ fn main() -> eframe::Result {
|
|||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), exts)
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
|
|
@ -208,18 +213,21 @@ fn main() -> eframe::Result {
|
|||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t("Media"), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), exts)
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
|
|
@ -229,6 +237,7 @@ fn main() -> eframe::Result {
|
|||
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
|
||||
// compositor reports no theme to winit (no polling: see appearance.rs).
|
||||
|
|
@ -237,11 +246,14 @@ fn main() -> eframe::Result {
|
|||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t(filter), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ real_name = "Brandon Thomas"
|
|||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ahmedhosnypro]
|
||||
real_name = "Ahmed Hosny"
|
||||
display_name = "Ahmed Hosny"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
|
@ -41,6 +46,10 @@ sync_github_name = true
|
|||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
[people.Hathor875]
|
||||
real_name = "Krzysztof Cieślik"
|
||||
sync_github_name = true
|
||||
|
||||
[people.ReReddo]
|
||||
display_name = "ReReddo"
|
||||
sync_github_name = false
|
||||
|
|
|
|||
|
|
@ -139,6 +139,30 @@ pub struct TypeParams {
|
|||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct MediaImportParams {
|
||||
/// Absolute paths to media files (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).
|
||||
pub paths: Vec<String>,
|
||||
/// Bin ID to import into (optional).
|
||||
#[serde(default)]
|
||||
pub bin: Option<u64>,
|
||||
/// Import numbered stills as an image sequence (optional, default false).
|
||||
#[serde(default)]
|
||||
pub image_sequence: Option<bool>,
|
||||
}
|
||||
|
||||
/// `file.import` params for a `media_import` call.
|
||||
fn media_import_params(p: MediaImportParams) -> Value {
|
||||
let mut params = json!({"paths": p.paths});
|
||||
if let Some(bin) = p.bin {
|
||||
params["bin"] = json!(bin);
|
||||
}
|
||||
if let Some(seq) = p.image_sequence {
|
||||
params["imageSequence"] = json!(seq);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct ElementsParams {
|
||||
/// Only element ids starting with this prefix (e.g. `timeline.clip.`).
|
||||
|
|
@ -381,9 +405,8 @@ impl FilmcraftMcp {
|
|||
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
|
||||
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
|
||||
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
wrap(self.run("file.import", json!({"paths": paths})).await)
|
||||
async fn media_import(&self, Parameters(p): Parameters<MediaImportParams>) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("file.import", media_import_params(p)).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -735,6 +758,21 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// #499: `media_import` takes `paths` (a required array), and forwards `bin` / `image_sequence` to `file.import`.
|
||||
#[test]
|
||||
fn media_import_schema_and_params() {
|
||||
let m = FilmcraftMcp::headless(Session::default());
|
||||
let def = m.tool_router.get("media_import").unwrap();
|
||||
let schema = Value::Object((*def.input_schema).clone());
|
||||
assert_eq!(schema["properties"]["paths"]["type"], "array", "{schema}");
|
||||
assert!(schema["required"].as_array().unwrap().iter().any(|r| r == "paths"), "{schema}");
|
||||
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.png"], "bin": 7, "image_sequence": true})).unwrap();
|
||||
assert_eq!(media_import_params(p), json!({"paths": ["/a.png"], "bin": 7, "imageSequence": true}));
|
||||
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.wav"]})).unwrap();
|
||||
assert_eq!(media_import_params(p), json!({"paths": ["/a.wav"]}));
|
||||
assert!(serde_json::from_value::<MediaImportParams>(json!({"text": "/a.wav"})).is_err());
|
||||
}
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub mod frame_size;
|
|||
pub mod multicam;
|
||||
pub mod through;
|
||||
pub mod transcript;
|
||||
pub mod transitions;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
//! Applied transitions: where one may sit over its cut, its alignment, and moving / resizing /
|
||||
//! removing one (Effect Controls' Duration and Alignment, dragging its edges or its middle in the
|
||||
//! Timeline, Delete).
|
||||
//!
|
||||
//! Premiere (Align transitions; Change transition duration): a transition between two clips stays
|
||||
//! over their cut, "Center at Cut", "Start at Cut", "End at Cut" or, once dragged off-centre,
|
||||
//! "Custom Start". Changing its duration moves both ends equally for Center at Cut / Custom Start,
|
||||
//! only the end for Start at Cut and only the beginning for End at Cut. A one-sided transition (a
|
||||
//! fade from or to nothing) starts or ends at its clip's edge.
|
||||
//!
|
||||
//! Premiere limits a transition by the media its clips have beyond the cut (handles). FilmCraft
|
||||
//! does not check handles when applying one, so here a transition may cover at most the clips it
|
||||
//! joins: from the outgoing clip's start to the incoming clip's end.
|
||||
|
||||
use filmcraft_project::{Sequence, Track, Transition, TransitionAlign, TransitionId};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
use crate::{Edge, EditError, Result};
|
||||
|
||||
/// How a transition sits on its cut, as Effect Controls' Alignment menu shows it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Alignment {
|
||||
Center,
|
||||
Start,
|
||||
End,
|
||||
/// Premiere's "Custom Start": dragged to a place over the cut that is none of the others.
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Alignment {
|
||||
pub const MENU: [Alignment; 3] = [Alignment::Center, Alignment::Start, Alignment::End];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Alignment::Center => "center",
|
||||
Alignment::Start => "start",
|
||||
Alignment::End => "end",
|
||||
Alignment::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Alignment::Center => "Center at Cut",
|
||||
Alignment::Start => "Start at Cut",
|
||||
Alignment::End => "End at Cut",
|
||||
Alignment::Custom => "Custom Start",
|
||||
}
|
||||
}
|
||||
|
||||
/// `center`, `start` or `end` (Custom Start is reached by moving a transition, not chosen).
|
||||
pub fn parse(s: &str) -> Option<Alignment> {
|
||||
Alignment::MENU.into_iter().find(|a| a.id().eq_ignore_ascii_case(s) || a.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a transition may be: `(earliest start, cut, latest end)`. Between two clips that is the
|
||||
/// outgoing clip's start, the cut and the incoming clip's end; for a fade to or from nothing the cut
|
||||
/// is its clip's edge. `None` when its clips are gone.
|
||||
pub fn bounds(track: &Track, tr: &Transition) -> Option<(Tick, Tick, Tick)> {
|
||||
let from = tr.from.and_then(|c| track.item(c));
|
||||
let to = tr.to.and_then(|c| track.item(c));
|
||||
match (from, to) {
|
||||
(Some(f), Some(t)) => Some((f.start, t.start, t.end())),
|
||||
(None, Some(t)) => Some((t.start, t.start, t.end())),
|
||||
(Some(f), None) => Some((f.start, f.end(), f.end())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A transition's alignment on `cut`. Centred means within half a `frame` of the cut, as
|
||||
/// centring an odd number of frames lands on a frame boundary.
|
||||
pub fn alignment(tr: &Transition, cut: Tick, frame: Tick) -> Alignment {
|
||||
match (tr.from, tr.to) {
|
||||
(None, Some(_)) => return Alignment::Start,
|
||||
(Some(_), None) => return Alignment::End,
|
||||
_ => {}
|
||||
}
|
||||
if tr.start == cut {
|
||||
Alignment::Start
|
||||
} else if tr.end() == cut {
|
||||
Alignment::End
|
||||
} else if (tr.start.0.saturating_mul(2).saturating_add(tr.duration.0) - cut.0.saturating_mul(2)).abs() <= frame.0.max(0) {
|
||||
Alignment::Center
|
||||
} else {
|
||||
Alignment::Custom
|
||||
}
|
||||
}
|
||||
|
||||
fn find(seq: &Sequence, id: TransitionId) -> Option<(&Track, &Transition)> {
|
||||
seq.all_tracks().find_map(|t| t.transitions.iter().find(|x| x.id == id).map(|x| (t, x)))
|
||||
}
|
||||
|
||||
/// Where a transition would start for `duration` and `align`, keeping its cut (and, for Center
|
||||
/// at Cut / Custom Start, its centre; `snap` puts a centred start on a frame).
|
||||
pub fn start_for(seq: &Sequence, id: TransitionId, duration: Tick, align: Alignment, frame: Tick, snap: impl Fn(Tick) -> Tick) -> Option<Tick> {
|
||||
let (track, tr) = find(seq, id)?;
|
||||
let (_, cut, _) = bounds(track, tr)?;
|
||||
let one_sided = alignment(tr, cut, frame);
|
||||
let align = match one_sided {
|
||||
Alignment::Start | Alignment::End if tr.from.is_none() || tr.to.is_none() => one_sided,
|
||||
_ => align,
|
||||
};
|
||||
Some(match align {
|
||||
Alignment::Start => cut,
|
||||
Alignment::End => cut - duration,
|
||||
Alignment::Center => snap(cut - duration.mul_ratio(1, 2)),
|
||||
// Custom Start: the ends move equally, around where it is now
|
||||
Alignment::Custom => snap(tr.start + tr.duration.mul_ratio(1, 2) - duration.mul_ratio(1, 2)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Move / resize a transition to cover `start .. start + duration`. Refused (leaving the sequence
|
||||
/// as it was) when its track is locked, it would be shorter than `min`, it would leave its clips,
|
||||
/// or it would no longer touch its cut (a one-sided one must keep its edge).
|
||||
pub fn set_span(seq: &mut Sequence, id: TransitionId, start: Tick, duration: Tick, min: Tick, frame: Tick) -> Result<()> {
|
||||
let (track, tr) = find(seq, id).ok_or_else(|| EditError::Other(format!("no transition {}", id.0)))?;
|
||||
if track.locked {
|
||||
return Err(EditError::Locked);
|
||||
}
|
||||
let (lo, cut, hi) = bounds(track, tr).ok_or_else(|| EditError::Other("the transition's clips are gone".into()))?;
|
||||
if duration < min.max(Tick(1)) {
|
||||
return Err(EditError::Other("a transition lasts at least one frame".into()));
|
||||
}
|
||||
let end = start.0.checked_add(duration.0).map(Tick).ok_or_else(|| EditError::Other("transition out of range".into()))?;
|
||||
if start < lo || end > hi {
|
||||
return Err(EditError::Other("a transition can't extend past the clips it joins".into()));
|
||||
}
|
||||
let on_cut = match (tr.from, tr.to) {
|
||||
(None, Some(_)) => start == cut,
|
||||
(Some(_), None) => end == cut,
|
||||
_ => start <= cut && cut <= end,
|
||||
};
|
||||
if !on_cut {
|
||||
return Err(EditError::Other(if tr.from.is_some() && tr.to.is_some() {
|
||||
"a transition between two clips must stay over their cut".into()
|
||||
} else {
|
||||
"a one-sided transition stays on its clip's edge".into()
|
||||
}));
|
||||
}
|
||||
let tid = track.id;
|
||||
let Some(x) = seq.track_mut(tid).and_then(|t| t.transitions.iter_mut().find(|x| x.id == id)) else {
|
||||
return Err(EditError::Other(format!("no transition {}", id.0)));
|
||||
};
|
||||
x.start = start;
|
||||
x.duration = duration;
|
||||
// the stored alignment (interchange): Custom Start has no variant, its span says it all
|
||||
x.align = match alignment(x, cut, frame) {
|
||||
Alignment::Start => TransitionAlign::StartAtCut,
|
||||
Alignment::End => TransitionAlign::EndAtCut,
|
||||
Alignment::Center | Alignment::Custom => TransitionAlign::CenterAtCut,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where dragging a transition by `delta` in the Timeline puts it, as `(start, duration)`. Its
|
||||
/// middle (`edge` None) slides it over the cut. An end trims that end only (Premiere shows the
|
||||
/// Trim-In / Trim-Out icon there): the other end stays, whatever the alignment, so a Center at
|
||||
/// Cut transition becomes Custom Start. (A typed duration, in Effect Controls or Set Transition
|
||||
/// Duration, follows the alignment instead: see [`start_for`].) A fade to or from nothing keeps
|
||||
/// its clip-edge end, and does not slide. Always clamped to where it may be ([`bounds`], over the
|
||||
/// cut, at least one `frame`). `None` when its clips are gone.
|
||||
pub fn drag_span(track: &Track, tr: &Transition, edge: Option<Edge>, delta: Tick, frame: Tick) -> Option<(Tick, Tick)> {
|
||||
let (lo, cut, hi) = bounds(track, tr)?;
|
||||
let frame = frame.max(Tick(1));
|
||||
let (s0, e0, d0) = (tr.start, tr.end(), tr.duration);
|
||||
let one_sided = tr.from.is_none() || tr.to.is_none();
|
||||
let unchanged = Some((s0, d0));
|
||||
match edge {
|
||||
None if one_sided => unchanged,
|
||||
None => {
|
||||
let (min_s, max_s) = (lo.max(cut - d0), (hi - d0).min(cut));
|
||||
if min_s > max_s { unchanged } else { Some(((s0 + delta).clamp(min_s, max_s), d0)) }
|
||||
}
|
||||
Some(Edge::In) if tr.from.is_none() => unchanged,
|
||||
Some(Edge::In) => {
|
||||
let max_s = cut.min(e0 - frame).max(lo);
|
||||
let st = (s0 + delta).clamp(lo, max_s);
|
||||
Some((st, e0 - st))
|
||||
}
|
||||
Some(Edge::Out) if tr.to.is_none() => unchanged,
|
||||
Some(Edge::Out) => {
|
||||
let min_e = cut.max(s0 + frame).min(hi);
|
||||
let en = (e0 + delta).clamp(min_e, hi);
|
||||
Some((s0, en - s0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove transitions (on unlocked tracks). Returns how many went.
|
||||
pub fn remove(seq: &mut Sequence, ids: &[TransitionId]) -> Result<usize> {
|
||||
if seq.all_tracks().any(|t| t.locked && t.transitions.iter().any(|x| ids.contains(&x.id))) {
|
||||
return Err(EditError::Locked);
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in seq.all_tracks_mut() {
|
||||
let before = t.transitions.len();
|
||||
t.transitions.retain(|x| !ids.contains(&x.id));
|
||||
n += before - t.transitions.len();
|
||||
}
|
||||
if n == 0 { Err(EditError::Nothing) } else { Ok(n) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_project::{ClipId, ItemId, Label, Project, SequenceSettings, TrackItem, find_effect};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
const R: FrameRate = FrameRate::FPS_24;
|
||||
|
||||
fn f(n: i64) -> Tick {
|
||||
R.tick_of(n)
|
||||
}
|
||||
|
||||
fn clip(id: u64, start: i64, dur: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
label: Label::Iris,
|
||||
start: f(start),
|
||||
duration: f(dur),
|
||||
source_in: Tick::ZERO,
|
||||
speed: 1.0,
|
||||
reverse: false,
|
||||
enabled: true,
|
||||
link: None,
|
||||
group: None,
|
||||
effects: vec![],
|
||||
markers: vec![],
|
||||
gain_db: 0.0,
|
||||
frame_hold: None,
|
||||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
audio_stream: 0,
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(id: u64, start: i64, dur: i64, from: Option<u64>, to: Option<u64>) -> Transition {
|
||||
// any effect: these tests are about where the transition sits
|
||||
let effect = find_effect("opacity").unwrap().instance();
|
||||
Transition {
|
||||
id: TransitionId(id),
|
||||
effect,
|
||||
start: f(start),
|
||||
duration: f(dur),
|
||||
from: from.map(ClipId),
|
||||
to: to.map(ClipId),
|
||||
align: TransitionAlign::CenterAtCut,
|
||||
reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// V1: c1 0..48, c2 48..96, c3 96..144; a centred 24-frame transition on 48 (id 1), a fade in
|
||||
/// at 0 (id 2) and a fade out at 144 (id 3).
|
||||
fn seq() -> Sequence {
|
||||
let mut p = Project::new("t");
|
||||
let s = p.new_sequence("s", SequenceSettings { frame_rate: R, ..Default::default() }, 1, 0, None);
|
||||
let mut q = p.sequence(s).unwrap().clone();
|
||||
let t = &mut q.video_tracks[0];
|
||||
t.items = vec![clip(1, 0, 48), clip(2, 48, 48), clip(3, 96, 48)];
|
||||
t.transitions = vec![transition(1, 36, 24, Some(1), Some(2)), transition(2, 0, 12, None, Some(1)), transition(3, 132, 12, Some(3), None)];
|
||||
q
|
||||
}
|
||||
|
||||
fn span(q: &Sequence, id: u64) -> (Tick, Tick) {
|
||||
let x = q.video_tracks[0].transitions.iter().find(|x| x.id.0 == id).unwrap();
|
||||
(x.start, x.duration)
|
||||
}
|
||||
|
||||
fn align(q: &Sequence, id: u64) -> Alignment {
|
||||
let (t, x) = find(q, TransitionId(id)).unwrap();
|
||||
let (_, cut, _) = bounds(t, x).unwrap();
|
||||
alignment(x, cut, f(1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_reads_the_span() {
|
||||
let mut q = seq();
|
||||
assert_eq!((align(&q, 1), align(&q, 2), align(&q, 3)), (Alignment::Center, Alignment::Start, Alignment::End));
|
||||
set_span(&mut q, TransitionId(1), f(48), f(24), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::Start);
|
||||
set_span(&mut q, TransitionId(1), f(24), f(24), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::End);
|
||||
set_span(&mut q, TransitionId(1), f(40), f(24), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::Custom);
|
||||
// an odd centred duration sits half a frame off the cut and is still Center at Cut
|
||||
set_span(&mut q, TransitionId(1), f(42), f(13), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_changes_follow_the_alignment() {
|
||||
let q = seq();
|
||||
let snap = |t: Tick| R.snap(t);
|
||||
// Center at Cut: both ends move
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(36), Alignment::Center, f(1), snap), Some(f(30)));
|
||||
// Start at Cut: only the end moves; End at Cut: only the beginning
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(36), Alignment::Start, f(1), snap), Some(f(48)));
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(36), Alignment::End, f(1), snap), Some(f(12)));
|
||||
// a one-sided transition keeps its edge whatever is asked
|
||||
assert_eq!(start_for(&q, TransitionId(2), f(20), Alignment::Center, f(1), snap), Some(f(0)));
|
||||
assert_eq!(start_for(&q, TransitionId(3), f(20), Alignment::Center, f(1), snap), Some(f(124)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_start_resizes_around_its_middle() {
|
||||
let mut q = seq();
|
||||
set_span(&mut q, TransitionId(1), f(40), f(20), f(1), f(1)).unwrap();
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(10), Alignment::Custom, f(1), |t| R.snap(t)), Some(f(45)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_stay_on_the_cut_and_inside_the_clips() {
|
||||
let mut q = seq();
|
||||
let before = q.clone();
|
||||
for (id, start, dur) in [
|
||||
(1, -1, 24), // before the outgoing clip
|
||||
(1, 40, 60), // past the incoming clip's end
|
||||
(1, 10, 20), // entirely before the cut
|
||||
(1, 50, 10), // entirely after it
|
||||
(1, 40, 0), // empty
|
||||
(2, 1, 12), // a fade in must start at its clip's start
|
||||
(3, 120, 12), // a fade out must end at its clip's end
|
||||
(9, 0, 12), // no such transition
|
||||
] {
|
||||
assert!(set_span(&mut q, TransitionId(id), f(start), f(dur), f(1), f(1)).is_err(), "{id} {start} {dur}");
|
||||
assert_eq!(q, before, "refused changes leave the sequence as it was");
|
||||
}
|
||||
// the whole of both clips is allowed
|
||||
set_span(&mut q, TransitionId(1), f(0), f(96), f(1), f(1)).unwrap();
|
||||
assert_eq!(span(&q, 1), (f(0), f(96)));
|
||||
// Tick::MAX never overflows
|
||||
assert!(set_span(&mut q, TransitionId(1), Tick(i64::MAX - 1), Tick(i64::MAX), f(1), f(1)).is_err());
|
||||
}
|
||||
|
||||
fn drag(q: &Sequence, id: u64, edge: Option<Edge>, frames: i64) -> (i64, i64) {
|
||||
let (t, x) = find(q, TransitionId(id)).unwrap();
|
||||
let (st, d) = drag_span(t, x, edge, f(frames), f(1)).unwrap();
|
||||
(R.frame_at(st), R.frame_at(d))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_transition() {
|
||||
let mut q = seq();
|
||||
// centred (36..60 over the cut at 48): an end trims that end only, the other stays
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), -4), (32, 28));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), -10), (36, 14));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), -100), (36, 12), "the end stops at the cut");
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), 100), (36, 60), "and at the incoming clip's end");
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), -100), (0, 60), "the start at the outgoing clip's start");
|
||||
// the middle slides it, staying over the cut and inside the clips
|
||||
assert_eq!(drag(&q, 1, None, 5), (41, 24));
|
||||
assert_eq!(drag(&q, 1, None, 100), (48, 24));
|
||||
assert_eq!(drag(&q, 1, None, -100), (24, 24));
|
||||
// not centred: the other end stays
|
||||
set_span(&mut q, TransitionId(1), f(40), f(20), f(1), f(1)).unwrap();
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), -4), (36, 24));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), 100), (48, 12), "the start stops at the cut");
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), 6), (40, 26));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), -100), (40, 8), "the end stops at the cut");
|
||||
// a fade in keeps its start, a fade out its end; neither slides
|
||||
assert_eq!(drag(&q, 2, Some(Edge::In), 3), (0, 12));
|
||||
assert_eq!(drag(&q, 2, Some(Edge::Out), 3), (0, 15));
|
||||
assert_eq!(drag(&q, 2, None, 3), (0, 12));
|
||||
assert_eq!(drag(&q, 3, Some(Edge::Out), 3), (132, 12));
|
||||
assert_eq!(drag(&q, 3, Some(Edge::In), -3), (129, 15));
|
||||
// whatever comes out is a span set_span accepts
|
||||
for (id, edge, n) in [(1, Some(Edge::In), -70), (1, None, 33), (2, Some(Edge::Out), 99), (3, Some(Edge::In), -99)] {
|
||||
let (t, x) = find(&q, TransitionId(id)).unwrap();
|
||||
let (st, d) = drag_span(t, x, edge, f(n), f(1)).unwrap();
|
||||
let mut c = q.clone();
|
||||
set_span(&mut c, TransitionId(id), st, d, f(1), f(1)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_tracks_refuse() {
|
||||
let mut q = seq();
|
||||
q.video_tracks[0].locked = true;
|
||||
assert_eq!(set_span(&mut q, TransitionId(1), f(40), f(20), f(1), f(1)), Err(EditError::Locked));
|
||||
assert_eq!(remove(&mut q, &[TransitionId(1)]), Err(EditError::Locked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_only_the_named_transitions() {
|
||||
let mut q = seq();
|
||||
assert_eq!(remove(&mut q, &[TransitionId(1), TransitionId(3)]), Ok(2));
|
||||
let left: Vec<u64> = q.video_tracks[0].transitions.iter().map(|x| x.id.0).collect();
|
||||
assert_eq!(left, vec![2]);
|
||||
assert_eq!(q.video_tracks[0].items.len(), 3, "the clips stay");
|
||||
assert_eq!(remove(&mut q, &[TransitionId(1)]), Err(EditError::Nothing));
|
||||
}
|
||||
}
|
||||
|
|
@ -949,6 +949,7 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
for it in pr.items.values_mut() {
|
||||
match &mut it.kind {
|
||||
ItemKind::Sequence(q) => {
|
||||
let q = std::sync::Arc::make_mut(q);
|
||||
for t in q.all_tracks_mut() {
|
||||
for i in &mut t.items {
|
||||
if let Some(k) = map.get(&i.item) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
/// Clips or captions selected (Clear / Ripple Delete work on either).
|
||||
fn has_any_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() {
|
||||
Err("nothing selected".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Clips or captions selected (Ripple Delete: a transition has no length of its own to close).
|
||||
fn has_clip_or_caption_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
|
@ -1204,6 +1213,11 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!("edit.pasteInsert", "Paste Insert", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, _| paste(s, true)),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Backspace"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| {
|
||||
if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() {
|
||||
// a selected transition (clicked in the Timeline) goes, as a clip would
|
||||
if !s.state.transition_selection.is_empty() {
|
||||
let ids = s.state.transition_selection.clone();
|
||||
return remove_transitions(s, &ids, "Clear");
|
||||
}
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, false);
|
||||
}
|
||||
|
|
@ -1215,7 +1229,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| {
|
||||
cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_clip_or_caption_selection, |s, p| {
|
||||
if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() {
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, true);
|
||||
|
|
@ -1363,10 +1377,70 @@ fn build() -> Vec<CommandSpec> {
|
|||
let sel = clips_p(s, p);
|
||||
let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some()));
|
||||
s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| {
|
||||
let l = if linked { None } else { Some(ctx.alloc()) };
|
||||
if linked {
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.link = l;
|
||||
i.link = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// each selected clip with its track kind, worked out once
|
||||
type Spec = (ClipId, filmcraft_project::ItemId, filmcraft_time::TimeRange);
|
||||
let (mut v_clips, mut audio_specs): (Vec<Spec>, Vec<Spec>) = (Vec::new(), Vec::new());
|
||||
for c in &sel {
|
||||
if let Some((tid, i)) = q.find_item(*c) {
|
||||
if q.video_tracks.iter().any(|t| t.id == tid) {
|
||||
v_clips.push((*c, i.item, i.range()));
|
||||
} else {
|
||||
audio_specs.push((*c, i.item, i.range()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !audio_specs.is_empty() && v_clips.len() > 1 {
|
||||
let mut linked_audios = std::collections::HashSet::new();
|
||||
for (vid, item_id, vrange) in &v_clips {
|
||||
// Pair with overlapping audio clips from the same item, or overlapping audio clips if no same-item audio
|
||||
let matching: Vec<ClipId> = audio_specs
|
||||
.iter()
|
||||
.filter(|(aid, aitem, arange)| !linked_audios.contains(aid) && aitem == item_id && arange.overlaps(vrange))
|
||||
.map(|(aid, _, _)| *aid)
|
||||
.collect();
|
||||
let to_link = if !matching.is_empty() {
|
||||
matching
|
||||
} else {
|
||||
audio_specs
|
||||
.iter()
|
||||
.filter(|(aid, _, arange)| !linked_audios.contains(aid) && arange.overlaps(vrange))
|
||||
.map(|(aid, _, _)| *aid)
|
||||
.collect()
|
||||
};
|
||||
// a video clip with no audio partner stays unlinked: a one-member link would read as "linked"
|
||||
let l = (!to_link.is_empty()).then(|| ctx.alloc());
|
||||
if let Some((_, vi)) = q.find_item_mut(*vid) {
|
||||
vi.link = l;
|
||||
}
|
||||
for aid in to_link {
|
||||
linked_audios.insert(aid);
|
||||
if let Some((_, ai)) = q.find_item_mut(aid) {
|
||||
ai.link = l;
|
||||
}
|
||||
}
|
||||
}
|
||||
// selected audio clips with no overlapping video stay unlinked
|
||||
for (aid, _, _) in audio_specs {
|
||||
if !linked_audios.contains(&aid)
|
||||
&& let Some((_, ai)) = q.find_item_mut(aid)
|
||||
{
|
||||
ai.link = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let l = ctx.alloc();
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.link = Some(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -1557,10 +1631,20 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Edit Transition Settings",
|
||||
[],
|
||||
None,
|
||||
r#"{"transition":id,"params":{param:value}?,"reverse":bool?,"reset":bool?}"#,
|
||||
r#"{"transition":id,"params":{param:value}?,"reverse":bool?,"reset":bool?,"duration":ticks?|"frames":n?,"align":"center"|"start"|"end"?|"start":ticks?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
set_transition
|
||||
),
|
||||
cmd!("sequence.removeTransition", "Clear Transition", [], None, r#"{"transitions":[id]?}"#, has_seq, |s, p| {
|
||||
let ids: Vec<TransitionId> = match p.get("transitions").or_else(|| p.get("transition")) {
|
||||
Some(v) => transition_ids_p(v).ok_or_else(|| bad("sequence.removeTransition", "`transitions` is a list of transition ids"))?,
|
||||
None => s.state.transition_selection.clone(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad("sequence.removeTransition", "no transition given or selected"));
|
||||
}
|
||||
remove_transitions(s, &ids, "Clear Transition")
|
||||
}),
|
||||
cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| {
|
||||
let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
|
|
@ -2052,13 +2136,18 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
),
|
||||
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"add":bool,"toggle":bool}"#, has_seq, |s, p| {
|
||||
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| {
|
||||
if let Some(ids) = p.get("transitions") {
|
||||
return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false));
|
||||
}
|
||||
let clips: Vec<ClipId> =
|
||||
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
|
||||
let clips = with_links(s, &clips);
|
||||
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
|
||||
s.state.edit_points.clear();
|
||||
s.state.trim_shift = Default::default();
|
||||
// and a clip selection replaces a transition one
|
||||
s.state.transition_selection.clear();
|
||||
if bool_p(p, "toggle").unwrap_or(false) {
|
||||
for c in clips {
|
||||
if let Some(i) = s.state.selection.iter().position(|x| *x == c) {
|
||||
|
|
@ -2432,13 +2521,14 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Set Effect Parameter",
|
||||
[],
|
||||
None,
|
||||
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path,"time":ticks?,"merge":bool?,"begin":bool?}"##,
|
||||
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?;
|
||||
let pid = str_p(p, "param").ok_or_else(|| bad("effects.setParam", "need `param`"))?.to_string();
|
||||
let val = p.get("value").cloned().ok_or_else(|| bad("effects.setParam", "need `value`"))?;
|
||||
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
|
||||
let keyframe = bool_p(p, "keyframe").unwrap_or(false);
|
||||
let ph = s.playhead();
|
||||
let tl = time_p(s, p, "").unwrap_or(ph);
|
||||
let pq = p.clone();
|
||||
|
|
@ -2447,7 +2537,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
|
||||
let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(tl.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
|
|
@ -2456,6 +2546,22 @@ fn build() -> Vec<CommandSpec> {
|
|||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad("effects.setParam", "no such effect on clip"))?;
|
||||
// the parameter's definition (a mask's own parameters have none)
|
||||
let def = if pq.get("mask").is_none_or(Value::is_null) { e.def().and_then(|d| d.param(&pid)) } else { None };
|
||||
// a choice takes its option name as well as its index, as transition parameters do
|
||||
let val = match (def.map(|d| &d.kind), &val) {
|
||||
(Some(filmcraft_project::ParamKind::Choice(opts)), Value::String(name)) => {
|
||||
let i = opts
|
||||
.iter()
|
||||
.position(|o| o.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("effects.setParam", format!("`{pid}` must be one of {opts:?}")))?;
|
||||
json!(i)
|
||||
}
|
||||
_ => val.clone(),
|
||||
};
|
||||
if keyframe && def.is_some_and(|d| !d.animatable) {
|
||||
return Err(bad("effects.setParam", format!("`{pid}` cannot be animated")));
|
||||
}
|
||||
if eff == json!("opacity") || eff == json!("motion") {
|
||||
e.enabled = true;
|
||||
}
|
||||
|
|
@ -2467,10 +2573,16 @@ fn build() -> Vec<CommandSpec> {
|
|||
}
|
||||
let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?;
|
||||
let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?;
|
||||
if keyframe {
|
||||
// the stopwatch and the value in one step
|
||||
prm.put_keyframe(mt, v);
|
||||
} else {
|
||||
prm.set_at(mt, v);
|
||||
Ok(())
|
||||
}
|
||||
Ok(prm.keyframes.len())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
// 0 keyframes: the value is static (`time` was not used)
|
||||
Ok(json!({"keyframes": keyframes}))
|
||||
}
|
||||
),
|
||||
cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mask":n?}"#, has_seq, |s, p| {
|
||||
|
|
@ -3201,13 +3313,109 @@ fn set_transition_params(e: &mut filmcraft_project::EffectInstance, params: &Val
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `sequence.setTransition`: edit an applied transition's settings (Effect Controls).
|
||||
/// A JSON list of transition ids (or one id).
|
||||
fn transition_ids_p(v: &Value) -> Option<Vec<TransitionId>> {
|
||||
match v {
|
||||
Value::Array(a) => a.iter().map(|x| x.as_u64().map(TransitionId)).collect(),
|
||||
x => x.as_u64().map(|i| vec![TransitionId(i)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `timeline.select {"transitions": […]}`: select transitions (clicking one in the Timeline). It
|
||||
/// replaces the clip, edit point and caption selections, as a clip selection replaces it.
|
||||
fn select_transitions(s: &mut Session, ids: &Value, add: bool, toggle: bool) -> Result<Value> {
|
||||
const CMD: &str = "timeline.select";
|
||||
let ids = transition_ids_p(ids).ok_or_else(|| bad(CMD, "`transitions` is a list of transition ids"))?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(missing) = ids.iter().find(|id| !q.all_tracks().any(|t| t.transitions.iter().any(|x| x.id == **id))) {
|
||||
return Err(bad(CMD, format!("no transition {}", missing.0)));
|
||||
}
|
||||
if toggle {
|
||||
for id in ids {
|
||||
match s.state.transition_selection.iter().position(|x| *x == id) {
|
||||
Some(i) => {
|
||||
s.state.transition_selection.remove(i);
|
||||
}
|
||||
None => s.state.transition_selection.push(id),
|
||||
}
|
||||
}
|
||||
} else if add {
|
||||
for id in ids {
|
||||
if !s.state.transition_selection.contains(&id) {
|
||||
s.state.transition_selection.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.transition_selection = ids;
|
||||
}
|
||||
s.state.selection.clear();
|
||||
s.state.caption_selection.clear();
|
||||
s.state.edit_points.clear();
|
||||
s.state.trim_shift = Default::default();
|
||||
Ok(json!({"selection": [], "transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Remove transitions in one undo step and drop them from the selection.
|
||||
fn remove_transitions(s: &mut Session, ids: &[TransitionId], label: &str) -> Result<Value> {
|
||||
let ids = ids.to_vec();
|
||||
let n = s.edit_sequence(label, |q, _, st| {
|
||||
let n = edit::transitions::remove(q, &ids)?;
|
||||
st.transition_selection.retain(|x| !ids.contains(x));
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
/// `sequence.setTransition`: edit an applied transition (Effect Controls, the Timeline): its
|
||||
/// settings, Reverse, and where it sits. `duration` / `frames` resize it the way its alignment
|
||||
/// says (Center at Cut and Custom Start: both ends; Start at Cut: the end; End at Cut: the
|
||||
/// beginning), `align` re-aligns it on its cut, `start` moves it (dragging it over the cut; with
|
||||
/// `duration`, dragging one edge). One undo step; with `merge`, a drag's steps share one.
|
||||
fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "sequence.setTransition";
|
||||
let id = p.get("transition").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "need `transition`"))?;
|
||||
let tid = TransitionId(id);
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let found = q.video_tracks.iter().chain(q.audio_tracks.iter()).find_map(|tr| tr.transitions.iter().find(|x| x.id.0 == id)).cloned();
|
||||
let mut cur = found.ok_or_else(|| bad(CMD, format!("no transition {id}")))?;
|
||||
let rate = q.settings.frame_rate.sane();
|
||||
let frame = rate.frame_duration();
|
||||
let found = q.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == tid).map(|x| (tr, x)));
|
||||
let (track, cur) = found.ok_or_else(|| bad(CMD, format!("no transition {id}")))?;
|
||||
let mut cur = cur.clone();
|
||||
let (lo, cut, hi) = edit::transitions::bounds(track, &cur).ok_or_else(|| bad(CMD, "the transition's clips are gone"))?;
|
||||
// times outside the clips it joins are refused before any frame arithmetic (hostile input)
|
||||
let room = Tick(hi.0.saturating_sub(lo.0));
|
||||
let duration = match (p.get("frames").and_then(Value::as_i64), p.get("duration").and_then(Value::as_i64)) {
|
||||
(Some(_), Some(_)) => return Err(bad(CMD, "give `duration` or `frames`, not both")),
|
||||
(Some(f), None) if (1..=10_000_000).contains(&f) && rate.tick_of(f) <= room => Some(rate.tick_of(f)),
|
||||
(None, Some(d)) if d > 0 && d <= room.0 => Some(rate.snap_nearest(Tick(d)).max(frame)),
|
||||
(Some(_), None) | (None, Some(_)) => return Err(bad(CMD, "a transition lasts at least one frame and can't extend past the clips it joins")),
|
||||
(None, None) => None,
|
||||
};
|
||||
let align = match str_p(p, "align") {
|
||||
Some(a) => Some(edit::transitions::Alignment::parse(a).ok_or_else(|| bad(CMD, format!("`align` is center, start or end, not `{a}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let start = match p.get("start").and_then(Value::as_i64) {
|
||||
Some(t) if (lo.0..=hi.0).contains(&t) => Some(rate.snap_nearest(Tick(t))),
|
||||
Some(_) => return Err(bad(CMD, "a transition can't extend past the clips it joins")),
|
||||
None => None,
|
||||
};
|
||||
if start.is_some() && align.is_some() {
|
||||
return Err(bad(CMD, "give `start` or `align`, not both"));
|
||||
}
|
||||
let span = if duration.is_some() || align.is_some() || start.is_some() {
|
||||
let d = duration.unwrap_or(cur.duration);
|
||||
let st = match start {
|
||||
Some(st) => st,
|
||||
None => {
|
||||
let al = align.unwrap_or_else(|| edit::transitions::alignment(&cur, cut, frame));
|
||||
edit::transitions::start_for(q, tid, d, al, frame, |t| rate.snap(t)).ok_or_else(|| bad(CMD, format!("no transition {id}")))?
|
||||
}
|
||||
};
|
||||
Some((st, d))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if p.get("reset").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(def) = cur.effect.def()
|
||||
{
|
||||
|
|
@ -3220,16 +3428,32 @@ fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
cur.reverse = r;
|
||||
}
|
||||
let name = cur.effect.def().map_or("Transition", |d| d.name).to_string();
|
||||
s.edit_sequence(&format!("Edit {name}"), |q, _, _| {
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("setTransition:{id}"));
|
||||
let after = s.edit_sequence_as(&format!("Edit {name}"), merge.as_deref(), |q, _, _| {
|
||||
if let Some((st, d)) = span {
|
||||
edit::transitions::set_span(q, tid, st, d, frame, frame)?;
|
||||
}
|
||||
let mut after = None;
|
||||
for tr in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) {
|
||||
if let Some(x) = tr.transitions.iter_mut().find(|x| x.id.0 == id) {
|
||||
if let Some(x) = tr.transitions.iter_mut().find(|x| x.id == tid) {
|
||||
x.effect = cur.effect.clone();
|
||||
x.reverse = cur.reverse;
|
||||
after = Some((x.start, x.duration));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(after)
|
||||
})?;
|
||||
Ok(json!({"transition": id, "effect": cur.effect.effect, "reverse": cur.reverse}))
|
||||
let (st, d) = after.unwrap_or((cur.start, cur.duration));
|
||||
let al = s
|
||||
.active_sequence()
|
||||
.and_then(|q| {
|
||||
q.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == tid).and_then(|x| edit::transitions::bounds(tr, x).map(|b| (x.clone(), b.1))))
|
||||
})
|
||||
.map(|(x, cut)| edit::transitions::alignment(&x, cut, frame).id());
|
||||
Ok(json!({"transition": id, "effect": cur.effect.effect, "reverse": cur.reverse, "start": st.0, "duration": d.0, "align": al}))
|
||||
}
|
||||
|
||||
fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value> {
|
||||
|
|
@ -3346,7 +3570,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
|||
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
|
||||
"effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0), "reverse": x.reverse, "params": x.effect.params.iter().map(|(k, v)| (k.clone(), serde_json::to_value(&v.value).unwrap_or_default())).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
"transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0), "reverse": x.reverse, "align": filmcraft_edit::transitions::bounds(t, x).map(|b| filmcraft_edit::transitions::alignment(x, b.1, rate.frame_duration()).id()), "params": x.effect.params.iter().map(|(k, v)| (k.clone(), serde_json::to_value(&v.value).unwrap_or_default())).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
})
|
||||
};
|
||||
json!({
|
||||
|
|
@ -3363,6 +3587,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
|||
"video": q.video_tracks.iter().map(tr).collect::<Vec<_>>(),
|
||||
"audio": q.audio_tracks.iter().map(tr).collect::<Vec<_>>(),
|
||||
"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
"transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,3 +211,56 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() {
|
|||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_multiple_video_and_audio_pairs_creates_one_link_per_pair() {
|
||||
let mut s = demo();
|
||||
let v_clips = v1(&s);
|
||||
let a_clips = a1(&s);
|
||||
assert!(v_clips.len() >= 3 && a_clips.len() >= 3);
|
||||
let (v0, v1, v2) = (v_clips[0].id, v_clips[1].id, v_clips[2].id);
|
||||
let (a0, a1, a2) = (a_clips[0].id, a_clips[1].id, a_clips[2].id);
|
||||
|
||||
// First, unlink all three pairs
|
||||
let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0, a1.0, v2.0, a2.0]})).unwrap();
|
||||
assert_eq!(r["linked"], false);
|
||||
for cid in [v0, a0, v1, a1, v2, a2] {
|
||||
assert_eq!(clip(&s, cid).unwrap().link, None);
|
||||
}
|
||||
|
||||
// Now, run clip.link on all six clips. In Premiere parity (#507),
|
||||
// it should create 3 separate links (one per pair), not one big link for all six.
|
||||
let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0, a1.0, v2.0, a2.0]})).unwrap();
|
||||
assert_eq!(r["linked"], true);
|
||||
|
||||
let l0 = clip(&s, v0).unwrap().link;
|
||||
let la0 = clip(&s, a0).unwrap().link;
|
||||
let l1 = clip(&s, v1).unwrap().link;
|
||||
let la1 = clip(&s, a1).unwrap().link;
|
||||
let l2 = clip(&s, v2).unwrap().link;
|
||||
let la2 = clip(&s, a2).unwrap().link;
|
||||
|
||||
assert!(l0.is_some() && l1.is_some() && l2.is_some());
|
||||
assert_eq!(l0, la0, "v0 linked to a0");
|
||||
assert_eq!(l1, la1, "v1 linked to a1");
|
||||
assert_eq!(l2, la2, "v2 linked to a2");
|
||||
|
||||
assert_ne!(l0, l1, "different pairs must not share the same link id");
|
||||
assert_ne!(l1, l2, "different pairs must not share the same link id");
|
||||
assert_ne!(l0, l2, "different pairs must not share the same link id");
|
||||
}
|
||||
|
||||
/// A video clip with no overlapping selected audio, and audio with no overlapping video, stay
|
||||
/// unlinked: a one-member link would make the clip read as "linked" with no partner.
|
||||
#[test]
|
||||
fn link_leaves_clips_without_a_partner_unlinked() {
|
||||
let mut s = demo();
|
||||
let (v, a) = (v1(&s), a1(&s));
|
||||
let (v0, v1, a0, a2) = (v[0].id, v[1].id, a[0].id, a[2].id);
|
||||
s.execute("clip.link", json!({"clips": [v0.0, v1.0, a0.0, a2.0]})).unwrap();
|
||||
s.execute("clip.link", json!({"clips": [v0.0, v1.0, a0.0, a2.0]})).unwrap();
|
||||
assert!(clip(&s, v0).unwrap().link.is_some());
|
||||
assert_eq!(clip(&s, v0).unwrap().link, clip(&s, a0).unwrap().link);
|
||||
assert_eq!(clip(&s, v1).unwrap().link, None, "v1 has no overlapping audio in the selection");
|
||||
assert_eq!(clip(&s, a2).unwrap().link, None, "a2 has no overlapping video in the selection");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ fn set_controls(s: &mut Session, clip: ClipId, values: &serde_json::Map<String,
|
|||
props.insert(c.param.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
set_props(s, clip, ei, &props, tl, "Change Template Property")?;
|
||||
set_props(s, clip, ei, &props, tl, "Change Template Property", None)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer
|
|||
use filmcraft_time::{Tick, TimeRange};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, f64_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
|
|
@ -262,10 +262,19 @@ fn apply_props(e: &mut EffectInstance, props: &serde_json::Map<String, Value>, m
|
|||
}
|
||||
|
||||
/// Set properties `props` on a layer (keyframe-aware at time `tl`). Changing the text keeps
|
||||
/// per-character styles on their characters.
|
||||
pub(crate) fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serde_json::Map<String, Value>, tl: Tick, label: &str) -> Result<()> {
|
||||
/// per-character styles on their characters. `merge` folds consecutive calls with the same key
|
||||
/// into one undo step (a drag is one undoable change, like `effects.setParam`).
|
||||
pub(crate) fn set_props(
|
||||
s: &mut Session,
|
||||
clip: ClipId,
|
||||
eidx: usize,
|
||||
props: &serde_json::Map<String, Value>,
|
||||
tl: Tick,
|
||||
label: &str,
|
||||
merge: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let props = props.clone();
|
||||
s.edit_sequence(label, |q, _, _| {
|
||||
s.edit_sequence_as(label, merge, |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
|
||||
let e = it.effects.get_mut(eidx).ok_or_else(|| bad("graphics.set", "no such layer"))?;
|
||||
|
|
@ -744,7 +753,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
props.insert("sides".into(), json!(n));
|
||||
let ph = s.playhead();
|
||||
let before = s.history.undo.len();
|
||||
set_props(s, clip, ei, &props, ph, "Change Graphic Property")?;
|
||||
set_props(s, clip, ei, &props, ph, "Change Graphic Property", None)?;
|
||||
if s.history.undo.len() > before {
|
||||
s.history.undo.pop();
|
||||
}
|
||||
|
|
@ -963,7 +972,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
let mut props = serde_json::Map::new();
|
||||
props.insert("text".into(), Value::String(text));
|
||||
let ph = s.playhead();
|
||||
set_props(s, clip, ei, &props, ph, "Edit Text")?;
|
||||
set_props(s, clip, ei, &props, ph, "Edit Text", None)?;
|
||||
if merge && s.history.undo.len() >= 2 {
|
||||
// keep the snapshot from before the typing session
|
||||
s.history.undo.pop();
|
||||
|
|
@ -976,14 +985,19 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
"Set Graphic Properties",
|
||||
&[],
|
||||
None,
|
||||
r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?}"##,
|
||||
r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?,"merge":bool?,"begin":bool?}"##,
|
||||
has_graphic,
|
||||
|s, p| {
|
||||
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.set", "no graphic clip"))?;
|
||||
let (_, ei) = layer_effect_index(s, clip, p)?;
|
||||
let props = p.get("props").and_then(Value::as_object).ok_or_else(|| bad("graphics.set", "need `props`"))?.clone();
|
||||
let tl = time_p(s, p, "").unwrap_or_else(|| s.playhead());
|
||||
set_props(s, clip, ei, &props, tl, "Change Graphic Property")?;
|
||||
// `merge`: a drag of a property is one undo step; `begin` starts a new one (#201)
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("graphics.set:{}:{ei}", clip.0));
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
set_props(s, clip, ei, &props, tl, "Change Graphic Property", merge.as_deref())?;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -153,6 +153,38 @@ fn typing_coalesces_into_one_undo_step_and_props_set() {
|
|||
assert_eq!(text_of(&layers(&s, clip)[0]), "");
|
||||
}
|
||||
|
||||
/// A drag of a property is one undo step: `graphics.set` with `merge` folds consecutive calls
|
||||
/// into the step the first change of the press began (`begin`), so one undo restores the
|
||||
/// original value (the color picker in Essential Graphics was committing one step per frame).
|
||||
#[test]
|
||||
fn a_property_drag_is_one_undo_step() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": "Title"})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": [0.0, 0.0, 0.0, 1.0]}})).unwrap();
|
||||
let color = |s: &Session| -> [f32; 4] {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (_, it) = q.find_item(clip).unwrap();
|
||||
let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
|
||||
e.param("shadow_color").unwrap().value_at(Tick::ZERO).as_color().unwrap()
|
||||
};
|
||||
let before = s.history.undo.len();
|
||||
// the drag: the first change of the press begins the step, the rest fold into it
|
||||
for (i, c) in [[0.1, 0.0, 0.0, 1.0], [0.2, 0.0, 0.0, 1.0], [0.3, 0.0, 0.0, 1.0], [0.4, 0.0, 0.0, 1.0]].iter().enumerate() {
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": c}, "merge": true, "begin": i == 0})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), before + 1, "one undo step for the whole drag");
|
||||
assert_eq!(color(&s)[0], 0.4);
|
||||
// a second drag (a new press) begins its own step
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": [1.0, 0.0, 0.0, 1.0]}, "merge": true, "begin": true})).unwrap();
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": [0.0, 1.0, 0.0, 1.0]}, "merge": true})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), before + 2, "a new press begins a new step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(color(&s)[0], 0.4, "undo takes back only the second drag");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(color(&s)[0], 0.0, "and then the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_and_distribute_layers() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -262,6 +262,10 @@ pub struct EditorState {
|
|||
/// Selected captions (caption tracks / Captions panel).
|
||||
#[serde(default)]
|
||||
pub caption_selection: Vec<ClipId>,
|
||||
/// Selected transitions (clicked in the Timeline): Effect Controls shows one, Delete removes
|
||||
/// them. Selecting clips clears it and selecting transitions clears the clip selection.
|
||||
#[serde(default)]
|
||||
pub transition_selection: Vec<filmcraft_project::TransitionId>,
|
||||
/// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip.
|
||||
#[serde(default)]
|
||||
pub graphic_layers: Vec<usize>,
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let report = simplify_sequence(pr, &mut q, &o);
|
||||
q.check().map_err(EngineError::Other)?;
|
||||
let bin = pr.root.parent_of(seq_id).filter(|b| b.0 != 0);
|
||||
let id = pr.add_item(&name, src.label, ItemKind::Sequence(Box::new(q)), bin);
|
||||
let id = pr.add_item(&name, src.label, ItemKind::Sequence(std::sync::Arc::new(q)), bin);
|
||||
st.active_sequence = Some(id);
|
||||
if !st.open_sequences.contains(&id) {
|
||||
st.open_sequences.push(id);
|
||||
|
|
|
|||
|
|
@ -486,6 +486,8 @@ pub struct TimelinePrefs {
|
|||
pub match_frame_sets_in: bool,
|
||||
pub restore_open_sequences: bool,
|
||||
pub add_tracks_automatically: bool,
|
||||
/// Files dropped onto the Timeline are placed directly on it.
|
||||
pub drop_import_to_timeline: bool,
|
||||
}
|
||||
|
||||
impl Default for TimelinePrefs {
|
||||
|
|
@ -512,6 +514,7 @@ impl Default for TimelinePrefs {
|
|||
match_frame_sets_in: false,
|
||||
restore_open_sequences: true,
|
||||
add_tracks_automatically: true,
|
||||
drop_import_to_timeline: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1080,6 +1083,7 @@ static CATEGORIES: &[Category] = &[
|
|||
b("timeline.matchFrameSetsIn", "Match frame sets in point", false),
|
||||
b("timeline.restoreOpenSequences", "Restore open sequences when opening projects", false),
|
||||
b("timeline.addTracksAutomatically", "Add tracks automatically when editing source clips onto the Timeline", false),
|
||||
b("timeline.dropImportToTimeline", "Place files dropped onto the Timeline directly on the Timeline", true),
|
||||
],
|
||||
},
|
||||
Category {
|
||||
|
|
|
|||
|
|
@ -552,6 +552,47 @@ fn hardware_decoding_setting_drives_the_decoder_switch() {
|
|||
assert!(settings::field("playback.hardwareDecoding").is_some_and(|f| f.wired && matches!(f.kind, Kind::Choice(_))));
|
||||
}
|
||||
|
||||
/// Settings ▸ Timeline ▸ "Place files dropped onto the Timeline directly on the Timeline": on by default.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_defaults_to_on() {
|
||||
assert!(settings::TimelinePrefs::default().drop_import_to_timeline);
|
||||
assert!(Preferences::default().timeline.drop_import_to_timeline);
|
||||
}
|
||||
|
||||
/// The key is saved under `timeline.dropImportToTimeline`, read back by `prefs.get`, and persisted.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_round_trips_through_prefs() {
|
||||
let dir = tmp_dir("drop-import-to-timeline");
|
||||
let path = dir.join("preferences.json");
|
||||
let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() };
|
||||
set(&mut s, "timeline.dropImportToTimeline", json!(false));
|
||||
assert!(!s.prefs.timeline.drop_import_to_timeline);
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "timeline.dropImportToTimeline"})).unwrap(), json!(false));
|
||||
assert!(!Preferences::load(&path).timeline.drop_import_to_timeline);
|
||||
// a non-boolean is rejected and the stored value is kept
|
||||
assert!(s.execute("prefs.set", json!({"key": "timeline.dropImportToTimeline", "value": "no"})).is_err());
|
||||
assert!(!s.prefs.timeline.drop_import_to_timeline);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Preferences files written before the key existed load it as on.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_defaults_to_on_for_older_prefs() {
|
||||
assert!(serde_json::from_value::<settings::TimelinePrefs>(json!({})).unwrap().drop_import_to_timeline);
|
||||
let old = Preferences::from_value(json!({"version": 2, "timeline": {"stillImageDuration": 3}}));
|
||||
assert!(old.timeline.drop_import_to_timeline);
|
||||
assert_eq!(old.timeline.still_image_duration, 3.0);
|
||||
}
|
||||
|
||||
/// The key is a Boolean field of the Timeline category in the settings registry.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_is_in_the_settings_registry() {
|
||||
let (cat, f) = settings::fields().into_iter().find(|(_, f)| f.key == "timeline.dropImportToTimeline").expect("registry field");
|
||||
assert_eq!(cat, "timeline");
|
||||
assert!(matches!(f.kind, Kind::Bool));
|
||||
assert_eq!(settings::field("timeline.dropImportToTimeline").map(|f| f.key), Some("timeline.dropImportToTimeline"));
|
||||
}
|
||||
|
||||
/// New users keep the dark Darkest theme (following the system is opt-in); a file saved before
|
||||
/// appearance modes keeps showing its Color Theme as a fixed Dark or Light mode.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ fn marker_list(p: &mut Project, item: ItemId) -> Result<&mut Vec<Marker>> {
|
|||
let it = p.item_mut(item).ok_or_else(|| EngineError::Other("Source item is unavailable".into()))?;
|
||||
match &mut it.kind {
|
||||
filmcraft_project::ItemKind::Media(m) => Ok(&mut m.markers),
|
||||
filmcraft_project::ItemKind::Sequence(q) => Ok(&mut q.markers),
|
||||
filmcraft_project::ItemKind::Sequence(q) => Ok(&mut std::sync::Arc::make_mut(q).markers),
|
||||
_ => Err(EngineError::Other("Source marker editing requires media or a sequence".into())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,6 +326,92 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() {
|
|||
assert_eq!(scale(&s), (0, 100.0));
|
||||
}
|
||||
|
||||
/// `effects.setParam` writes a keyframe only on an animated parameter. An agent that sends a value
|
||||
/// with a `time` to a parameter that is not animated gets a static value (the result says so:
|
||||
/// 0 keyframes); `"keyframe": true` is the stopwatch and the value in one step.
|
||||
#[test]
|
||||
fn set_param_writes_a_keyframe_when_asked_or_animated() {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
// (keyframes, static value, value at 0.5 s, value at 2.5 s)
|
||||
let scale = |s: &Session| {
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
let p = &it.effect("motion").unwrap().params["scale"];
|
||||
let at = |seconds: f64| p.f64_at(it.source_time_at(Tick::from_seconds_f64(seconds)));
|
||||
(p.keyframes.len(), p.value.as_f64().unwrap(), at(0.5), at(2.5))
|
||||
};
|
||||
let set = |value: f64, seconds: f64| json!({"clip": c, "effect": "motion", "param": "scale", "value": value, "seconds": seconds});
|
||||
// not animated: the time is not used and the value is static
|
||||
let r = s.execute("effects.setParam", set(80.0, 0.5)).unwrap();
|
||||
assert_eq!(r["keyframes"], 0, "{r}");
|
||||
assert_eq!(scale(&s), (0, 80.0, 80.0, 80.0));
|
||||
// `keyframe`: the parameter becomes animated at that time
|
||||
let mut first = set(60.0, 0.5);
|
||||
first["keyframe"] = json!(true);
|
||||
let r = s.execute("effects.setParam", first).unwrap();
|
||||
assert_eq!(r["keyframes"], 1, "{r}");
|
||||
assert_eq!(scale(&s).0, 1);
|
||||
// animated now, so a plain set at another time is a second keyframe
|
||||
let r = s.execute("effects.setParam", set(120.0, 2.5)).unwrap();
|
||||
assert_eq!(r["keyframes"], 2, "{r}");
|
||||
let (n, _, a, b) = scale(&s);
|
||||
assert_eq!((n, a, b), (2, 60.0, 120.0));
|
||||
// a keyframe that is already there is replaced, not doubled
|
||||
let mut again = set(70.0, 0.5);
|
||||
again["keyframe"] = json!(true);
|
||||
assert_eq!(s.execute("effects.setParam", again).unwrap()["keyframes"], 2);
|
||||
assert_eq!(scale(&s).2, 70.0);
|
||||
s.undo();
|
||||
assert_eq!(scale(&s).2, 60.0);
|
||||
s.undo();
|
||||
assert_eq!(scale(&s).0, 1);
|
||||
s.redo();
|
||||
s.redo();
|
||||
let (n, _, a, b) = scale(&s);
|
||||
assert_eq!((n, a, b), (2, 70.0, 120.0));
|
||||
// a parameter that cannot be animated refuses the keyframe and keeps its value
|
||||
let blend = json!({"clip": c, "effect": "opacity", "param": "blend", "value": 8, "keyframe": true});
|
||||
let e = s.execute("effects.setParam", blend).unwrap_err().to_string();
|
||||
assert!(e.contains("cannot be animated"), "{e}");
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
assert_eq!(it.effect("opacity").unwrap().params["blend"].value, filmcraft_project::ParamValue::Choice(0));
|
||||
// `keyframe` that is not a bool means no keyframe request
|
||||
let mut odd = set(70.0, 0.5);
|
||||
odd["keyframe"] = json!("yes");
|
||||
assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2);
|
||||
}
|
||||
|
||||
/// A choice parameter takes its option name as well as its index, as transition parameters do
|
||||
/// (`effects.list {"detail": true}` lists the names).
|
||||
#[test]
|
||||
fn set_param_takes_a_choice_by_name() {
|
||||
use filmcraft_project::ParamValue;
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
let blend =
|
||||
|s: &Session| s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.effect("opacity").unwrap().params["blend"].value.clone();
|
||||
let set = |value: serde_json::Value| json!({"clip": c, "effect": "opacity", "param": "blend", "value": value});
|
||||
s.execute("effects.setParam", set(json!("Screen"))).unwrap();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(8));
|
||||
s.execute("effects.setParam", set(json!("linear dodge (add)"))).unwrap();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(10), "names are matched without case");
|
||||
s.execute("effects.setParam", set(json!(12))).unwrap();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(12), "an index works as before");
|
||||
s.undo();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(10));
|
||||
s.redo();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(12));
|
||||
// a name that is not an option is an error that lists the options and changes nothing
|
||||
let e = s.execute("effects.setParam", set(json!("Glow"))).unwrap_err().to_string();
|
||||
assert!(e.contains("Screen") && e.contains("Overlay"), "{e}");
|
||||
for bad in [json!(""), json!(null), json!([8]), json!({"name": "Screen"}), json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("effects.setParam", set(bad.clone())).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(blend(&s), ParamValue::Choice(12));
|
||||
// a name is still the wrong type for a parameter that is not a choice
|
||||
assert!(s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": "Screen"})).is_err());
|
||||
}
|
||||
|
||||
/// Dragging the Volume line or one of its keyframes in the timeline (#223) sends a keyframe edit
|
||||
/// every frame; `merge` keeps the whole drag one undo step and `begin` starts the next one.
|
||||
#[test]
|
||||
|
|
@ -731,6 +817,114 @@ fn dragging_an_effect_parameter_is_one_undo_step() {
|
|||
assert_eq!(opacity(&s), start, "and then the whole first one");
|
||||
}
|
||||
|
||||
/// The demo's first video transition between two clips: (id, start, duration, cut).
|
||||
fn demo_crossing(s: &Session) -> (u64, Tick, Tick, Tick) {
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.video_tracks
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
t.transitions.iter().find(|x| x.from.is_some() && x.to.is_some()).map(|x| (x.id.0, x.start, x.duration, t.item(x.to.unwrap()).unwrap().start))
|
||||
})
|
||||
.expect("the demo has a transition between two clips")
|
||||
}
|
||||
|
||||
fn transition_ids(s: &Session) -> Vec<u64> {
|
||||
s.active_sequence().unwrap().all_tracks().flat_map(|t| t.transitions.iter().map(|x| x.id.0)).collect()
|
||||
}
|
||||
|
||||
/// #430: a transition can be selected (it replaces the clip selection and back), Delete removes
|
||||
/// it and leaves its clips, undo brings it back; Ripple Delete is not for a transition alone.
|
||||
#[test]
|
||||
fn transitions_select_and_delete() {
|
||||
let mut s = demo();
|
||||
let (tid, ..) = demo_crossing(&s);
|
||||
let clips = s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>();
|
||||
let some_clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
s.execute("timeline.select", json!({"clips": [some_clip]})).unwrap();
|
||||
let r = s.execute("timeline.select", json!({"transitions": [tid]})).unwrap();
|
||||
assert_eq!(r["transitionSelection"], json!([tid]));
|
||||
assert!(s.state.selection.is_empty(), "selecting a transition replaces the clip selection");
|
||||
assert!(s.execute("timeline.select", json!({"transitions": [987_654]})).is_err(), "unknown ids are refused");
|
||||
assert!(s.execute("edit.rippleDelete", json!({})).is_err(), "Ripple Delete needs clips");
|
||||
let before = (*s.project).clone();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert!(!transition_ids(&s).contains(&tid), "Delete removes the selected transition");
|
||||
assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), clips, "and leaves the clips");
|
||||
assert!(s.state.transition_selection.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, before, "one undo step");
|
||||
// selecting clips clears a transition selection
|
||||
s.execute("timeline.select", json!({"transitions": [tid]})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [some_clip]})).unwrap();
|
||||
assert!(s.state.transition_selection.is_empty());
|
||||
// by id, without a selection
|
||||
s.execute("sequence.removeTransition", json!({"transitions": [tid]})).unwrap();
|
||||
assert!(!transition_ids(&s).contains(&tid));
|
||||
assert!(s.execute("sequence.removeTransition", json!({"transitions": [tid]})).is_err(), "nothing left to remove");
|
||||
assert!(s.execute("sequence.removeTransition", json!({})).is_err(), "nothing given or selected");
|
||||
}
|
||||
|
||||
/// #224 / Effect Controls: Duration follows the alignment, Alignment re-aligns on the cut, Start
|
||||
/// moves it (Custom Start), all within the clips it joins; a drag is one undo step.
|
||||
#[test]
|
||||
fn transition_duration_alignment_and_position() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let (tid, _, _, cut) = demo_crossing(&s);
|
||||
let set = |s: &mut Session, p: Value| {
|
||||
let mut p = p;
|
||||
p["transition"] = json!(tid);
|
||||
s.execute("sequence.setTransition", p)
|
||||
};
|
||||
let r = set(&mut s, json!({"align": "start"})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["align"].as_str()), (Some(cut.0), Some("start")));
|
||||
// Start at Cut: only the end moves
|
||||
let r = set(&mut s, json!({"frames": 10})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64()), (Some(cut.0), Some(f(10).0)));
|
||||
// End at Cut: only the beginning moves
|
||||
let r = set(&mut s, json!({"align": "end", "frames": 8})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64(), r["align"].as_str()), (Some((cut - f(8)).0), Some(f(8).0), Some("end")));
|
||||
// Center at Cut: both ends
|
||||
let r = set(&mut s, json!({"align": "center", "frames": 12})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["align"].as_str()), (Some((cut - f(6)).0), Some("center")));
|
||||
// dragging it over the cut: Custom Start; then a new duration keeps its middle
|
||||
let r = set(&mut s, json!({"start": (cut - f(3)).0})).unwrap();
|
||||
assert_eq!(r["align"], json!("custom"));
|
||||
let r = set(&mut s, json!({"frames": 6})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64()), (Some(cut.0), Some(f(6).0)), "centre at cut + 3 frames, 6 frames long");
|
||||
// dragging one edge: start and duration together
|
||||
let r = set(&mut s, json!({"start": (cut - f(4)).0, "duration": f(10).0})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64()), (Some((cut - f(4)).0), Some(f(10).0)));
|
||||
// refused, and nothing changes
|
||||
let before = (*s.project).clone();
|
||||
let history = s.history.undo.len();
|
||||
for p in [
|
||||
json!({"frames": 0}),
|
||||
json!({"frames": 100_000_000}),
|
||||
json!({"duration": -5}),
|
||||
json!({"duration": i64::MAX}),
|
||||
json!({"frames": 10, "duration": f(10).0}),
|
||||
json!({"align": "middle"}),
|
||||
json!({"align": "start", "start": cut.0}),
|
||||
json!({"start": i64::MIN}),
|
||||
json!({"start": (cut + f(1)).0, "duration": f(1).0}),
|
||||
] {
|
||||
assert!(set(&mut s, p.clone()).is_err(), "{p}");
|
||||
assert_eq!(*s.project, before, "{p}");
|
||||
assert_eq!(s.history.undo.len(), history, "{p}");
|
||||
}
|
||||
assert!(s.execute("sequence.setTransition", json!({"transition": 987_654, "frames": 5})).is_err());
|
||||
// a drag (merge) is one undo step
|
||||
let history = s.history.undo.len();
|
||||
for (i, n) in [11, 12, 13, 14].into_iter().enumerate() {
|
||||
set(&mut s, json!({"frames": n, "merge": true, "begin": i == 0})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, before);
|
||||
}
|
||||
|
||||
/// #484: Enable flips each selected clip on its own, as in Premiere. With one enabled and one
|
||||
/// disabled clip selected it swaps them, instead of first making both the same.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ fn validate_loaded(mut loaded: Loaded) -> Result<Loaded, FormatError> {
|
|||
m.info.audio_streams.truncate(max);
|
||||
}
|
||||
filmcraft_project::ItemKind::Sequence(sequence) => {
|
||||
let sequence = std::sync::Arc::make_mut(sequence);
|
||||
for clip in sequence.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()) {
|
||||
if clip.audio_stream >= max {
|
||||
log::warn!("sequence `{name}`: clip `{}` uses audio stream {}, reset to 0", clip.name, clip.audio_stream);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ pub fn big_project(n: usize) -> Project {
|
|||
let seq_id = ItemId(21);
|
||||
let mut next = 1_000_000u64;
|
||||
let seq = match &mut p.items.get_mut(&seq_id).unwrap().kind {
|
||||
ItemKind::Sequence(s) => s,
|
||||
ItemKind::Sequence(s) => std::sync::Arc::make_mut(s),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let v0 = seq.video_tracks[0].items[0].clone();
|
||||
|
|
|
|||
|
|
@ -387,7 +387,7 @@ impl Builder {
|
|||
/// Add an empty sequence item (filled later with [`Builder::put_sequence`]).
|
||||
pub fn reserve_sequence(&mut self, name: &str, settings: SequenceSettings, bin: Option<BinId>) -> ItemId {
|
||||
let seq = empty_sequence(settings);
|
||||
self.p.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), bin)
|
||||
self.p.add_item(name, Label::Forest, ItemKind::Sequence(std::sync::Arc::new(seq)), bin)
|
||||
}
|
||||
|
||||
pub fn put_sequence(&mut self, id: ItemId, mut seq: Sequence) {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,24 @@ struct Imp<'a, 'i, 'r> {
|
|||
in_progress: HashSet<String>,
|
||||
links: Vec<PendingLink>,
|
||||
xml_clip_ids: HashMap<(ItemId, String), ClipId>,
|
||||
/// `(sequence key, file key)` pairs where some audio clip item of that file uses `sourcetrack/trackindex` > 1,
|
||||
/// i.e. the file's channels are split onto mono tracks in that sequence (#463).
|
||||
split_files: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// The `(sequence key, file key)` an audio clip item belongs to, if it references a file.
|
||||
fn clip_seq_file(c: Node) -> Option<(String, String)> {
|
||||
let seq = c.ancestors().skip(1).find(|a| a.has_tag_name("sequence"))?;
|
||||
Some((node_key(seq), node_key(child(c, "file")?)))
|
||||
}
|
||||
|
||||
/// The 1-based `sourcetrack/trackindex` of an audio clip item.
|
||||
fn audio_track_index(c: Node) -> Option<i64> {
|
||||
let st = child(c, "sourcetrack")?;
|
||||
if child_text(st, "mediatype").is_some_and(|m| m != "audio") {
|
||||
return None;
|
||||
}
|
||||
child_i64(st, "trackindex")
|
||||
}
|
||||
|
||||
fn node_key(n: Node) -> String {
|
||||
|
|
@ -136,9 +154,15 @@ pub(crate) fn import(text_in: &str, opts: &ImportOptions, report: &mut Report) -
|
|||
in_progress: HashSet::new(),
|
||||
links: Vec::new(),
|
||||
xml_clip_ids: HashMap::new(),
|
||||
split_files: HashSet::new(),
|
||||
};
|
||||
for n in root.descendants().filter(|n| n.is_element()) {
|
||||
match n.tag_name().name() {
|
||||
"clipitem" if audio_track_index(n).is_some_and(|i| i > 1) => {
|
||||
if let Some(key) = clip_seq_file(n) {
|
||||
imp.split_files.insert(key);
|
||||
}
|
||||
}
|
||||
"file" if elements(n).next().is_some() => {
|
||||
imp.files.entry(node_key(n)).or_insert(n);
|
||||
}
|
||||
|
|
@ -482,6 +506,17 @@ impl<'a, 'i> Imp<'a, 'i, '_> {
|
|||
if let Some(l) = path_text(c, &["labels", "label2"]).and_then(label_from) {
|
||||
ti.label = l;
|
||||
}
|
||||
// `sourcetrack/trackindex` (1-based) picks the source channel of a mono track split off a multichannel file (#463).
|
||||
// Index 1 is also what a stereo clip on a stereo track carries, so it means channel 0 only when another clip
|
||||
// item of the same file in this sequence uses a higher index (the file is split); otherwise it keeps the
|
||||
// default (empty) channel mapping.
|
||||
if kind == TrackKind::Audio
|
||||
&& let Some(idx) = audio_track_index(c)
|
||||
&& (idx > 1 || (idx == 1 && clip_seq_file(c).is_some_and(|k| self.split_files.contains(&k))))
|
||||
&& let Ok(ch) = u16::try_from(idx - 1)
|
||||
{
|
||||
ti.source_channels = vec![ch];
|
||||
}
|
||||
if let Some(mode) = child_text(c, "compositemode") {
|
||||
match BLEND_XML.iter().find(|(n, _)| n.eq_ignore_ascii_case(mode)) {
|
||||
Some((_, v)) if *v != 0 => set_param(&mut ti, "opacity", "blend", Param::new(ParamValue::Choice(*v))),
|
||||
|
|
@ -962,7 +997,7 @@ impl Exp<'_, '_> {
|
|||
if kind == TrackKind::Audio {
|
||||
self.w.open("sourcetrack", &[]);
|
||||
self.w.text("mediatype", "audio");
|
||||
self.w.text("trackindex", 1);
|
||||
self.w.text("trackindex", c.source_channels.first().map_or(1, |ch| u32::from(*ch).saturating_add(1)));
|
||||
self.w.close();
|
||||
}
|
||||
if let Some(blend) = param(c, "opacity", "blend").and_then(|p| p.value.as_f64()).map(|v| v as u32).filter(|v| *v != 0) {
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>)
|
|||
it.created = it.id.0;
|
||||
match &mut it.kind {
|
||||
ItemKind::Sequence(seq) => {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for m in &mut seq.markers {
|
||||
m.id = MarkerId(target.alloc_id());
|
||||
}
|
||||
|
|
@ -510,6 +511,7 @@ pub fn rebase_source_timecode(project: &mut Project, item: ItemId, start_tc_fram
|
|||
}
|
||||
for it in project.items.values_mut() {
|
||||
if let ItemKind::Sequence(seq) = &mut it.kind {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for t in seq.all_tracks_mut() {
|
||||
for ti in t.items.iter_mut().filter(|ti| ti.item == item) {
|
||||
ti.source_in -= off;
|
||||
|
|
|
|||
|
|
@ -527,3 +527,76 @@ fn sub_frame_clips_keep_equal_source_and_record_spans() {
|
|||
assert_eq!(out_f - in_f, end_f - start_f, "start {start}, in {src}, duration {dur} (tenths of a second): {spans:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #463: a stereo file split onto two mono tracks (A1 = channel 1, A2 = channel 2) lost the right channel,
|
||||
/// because every audio clip item was written with `sourcetrack/trackindex` 1 and the importer ignored it.
|
||||
#[test]
|
||||
fn split_mono_tracks_keep_their_source_channel() {
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.wav", false, true, r);
|
||||
let s = sequence(&mut p, "t", r, false);
|
||||
let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 24, 0);
|
||||
let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0, 24, 0);
|
||||
for (id, ch) in [(left, 0u16), (right, 1u16)] {
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap();
|
||||
it.source_channels = vec![ch];
|
||||
}
|
||||
let (imp, xml, _) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default());
|
||||
assert!(xml.contains("<trackindex>2</trackindex>"), "{xml}");
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
let channels = |t: usize| seq.tracks(TrackKind::Audio)[t].items.iter().map(|i| i.source_channels.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(channels(0), vec![vec![0u16]], "{xml}");
|
||||
assert_eq!(channels(1), vec![vec![1u16]], "{xml}");
|
||||
let (bytes, _) = export(&imp.project, only_seq(&imp), Format::Fcp7Xml, &ExportOptions::default()).expect("export");
|
||||
assert!(String::from_utf8(bytes).unwrap().contains("<trackindex>2</trackindex>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sourcetrack_trackindex_2_imports_as_source_channel_1() {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio><track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem></track></audio></media>
|
||||
</sequence></xmeml>"#
|
||||
);
|
||||
let (imp, _) = import(doc.as_bytes(), Format::Fcp7Xml, None).unwrap();
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
assert_eq!(seq.tracks(TrackKind::Audio)[0].items[0].source_channels, vec![1u16]);
|
||||
}
|
||||
|
||||
/// #463: Premiere writes a stereo file split onto two mono tracks as clip items with `trackindex` 1 and 2;
|
||||
/// a file used only with `trackindex` 1 is a stereo clip and keeps the default (all channels) mapping.
|
||||
#[test]
|
||||
fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio>
|
||||
<track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
</clipitem>
|
||||
<clipitem id="clipitem-3"><name>other</name>{RATE}<start>48</start><end>96</end><in>0</in><out>48</out>
|
||||
<file id="file-2"><name>other.wav</name><pathurl>file://localhost/media/other.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
</clipitem></track>
|
||||
<track><clipitem id="clipitem-2"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"/>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem></track>
|
||||
</audio></media>
|
||||
</sequence></xmeml>"#
|
||||
);
|
||||
let (imp, _) = import(doc.as_bytes(), Format::Fcp7Xml, None).unwrap();
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
let a1 = &seq.tracks(TrackKind::Audio)[0].items;
|
||||
assert_eq!(a1[0].source_channels, vec![0u16]);
|
||||
assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels);
|
||||
assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ windows = { version = "0.62", features = [
|
|||
"Win32_Media_MediaFoundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# Linux: libva (VA-API) is loaded at run time with libloading (ISC; already in the lockfile through
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
//! The mouse position in screen pixels, for when the windowing layer has none.
|
||||
//!
|
||||
//! While files are dragged in from the OS file manager, winit on Windows delivers no cursor events
|
||||
//! (it only reports hovered and dropped files), so the UI cannot learn where a drop landed.
|
||||
//!
|
||||
//! This is the one non-media OS call in this crate: `crates/platform` is the only crate allowed
|
||||
//! `unsafe` (AGENTS.md §0.3, ADR 0001), so the single `GetCursorPos` call lives here rather than
|
||||
//! in the UI. It is read-only, has a safe `Option`-returning API, and every other platform (and a
|
||||
//! failed call) falls back to the last pointer position egui saw. Drop it once winit reports the
|
||||
//! pointer position during an OS drag-and-drop.
|
||||
|
||||
/// The cursor's position on the virtual screen in physical pixels, or `None` where the OS cannot
|
||||
/// say or this platform's windowing layer already reports it during a drag.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn cursor_screen_position() -> Option<(i32, i32)> {
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||
let mut p = POINT { x: 0, y: 0 };
|
||||
// SAFETY: `p` is a valid, writable POINT for the duration of the call.
|
||||
unsafe { GetCursorPos(&mut p) }.ok()?;
|
||||
Some((p.x, p.y))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn cursor_screen_position() -> Option<(i32, i32)> {
|
||||
None
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
mod annexb;
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
|
||||
mod biplanar;
|
||||
pub mod cursor;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod hardware_encode;
|
||||
pub mod hybrid;
|
||||
|
|
|
|||
|
|
@ -126,6 +126,25 @@ fn layouts_match_the_header() {
|
|||
(112, 4),
|
||||
"NV_ENC_CONFIG_HEVC_VUI_PARAMETERS"
|
||||
);
|
||||
// The header typedefs the HEVC VUI to the H.264 one; pin the fields session.rs writes, both
|
||||
// inside the VUI and where they land in NV_ENC_CONFIG_HEVC (hevcVUIParameters at 64), so a
|
||||
// binding mismatch cannot hide behind the typedef (#442: HEVC timing came out reversed).
|
||||
for (field, offset, name) in [
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, videoSignalTypePresentFlag), 8, "videoSignalTypePresentFlag"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, videoFormat), 12, "videoFormat"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, videoFullRangeFlag), 16, "videoFullRangeFlag"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, colourDescriptionPresentFlag), 20, "colourDescriptionPresentFlag"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, colourPrimaries), 24, "colourPrimaries"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, transferCharacteristics), 28, "transferCharacteristics"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, colourMatrix), 32, "colourMatrix"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, timingInfoPresentFlag), 52, "timingInfoPresentFlag"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, numUnitInTicks), 56, "numUnitInTicks"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, timeScale), 60, "timeScale"),
|
||||
(offset_of!(NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, reserved), 64, "reserved"),
|
||||
] {
|
||||
assert_eq!(field, offset, "NV_ENC_CONFIG_HEVC_VUI_PARAMETERS.{name}");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, hevcVUIParameters) + field, 64 + offset, "NV_ENC_CONFIG_HEVC.hevcVUIParameters.{name}");
|
||||
}
|
||||
assert_eq!((size_of::<NV_ENC_CONFIG_HEVC>(), align_of::<NV_ENC_CONFIG_HEVC>()), (1560, 8), "NV_ENC_CONFIG_HEVC");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, level), 0, "NV_ENC_CONFIG_HEVC.level");
|
||||
assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, tier), 4, "NV_ENC_CONFIG_HEVC.tier");
|
||||
|
|
|
|||
|
|
@ -416,9 +416,11 @@ impl Session {
|
|||
vui.colourPrimaries = u32::from(p.signal.primaries);
|
||||
vui.transferCharacteristics = u32::from(p.signal.transfer);
|
||||
vui.colourMatrix = u32::from(p.signal.matrix);
|
||||
vui.timingInfoPresentFlag = 1;
|
||||
vui.numUnitInTicks = p.fps.1;
|
||||
vui.timeScale = p.fps.0;
|
||||
// Let NVENC derive timing from frameRateNum/frameRateDen. Some drivers
|
||||
// reverse the explicitly supplied HEVC VUI timing pair.
|
||||
vui.timingInfoPresentFlag = 0;
|
||||
vui.numUnitInTicks = 0;
|
||||
vui.timeScale = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -321,3 +321,19 @@ fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() {
|
|||
assert_eq!(n + dec.flush().len(), 12, "keyint {keyint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vui_timing_matches_integer_and_fractional_frame_rates() {
|
||||
let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for fps in [(24, 1), (25, 1), (30_000, 1_001)] {
|
||||
let mut cfg = config(640, 360);
|
||||
cfg.fps = fps;
|
||||
cfg.bframes = false;
|
||||
let Some((enc, _, _)) = encode_all(&cfg, 2) else { return };
|
||||
let sps_nal = enc.parameter_sets().0;
|
||||
let sps = Sps::parse(&unescape_rbsp(&sps_nal[2..])).unwrap();
|
||||
let (units, scale) = sps.vui.and_then(|v| v.timing).expect("HEVC VUI timing");
|
||||
assert!(units > 0 && scale > 0);
|
||||
assert_eq!(u64::from(scale) * u64::from(fps.1), u64::from(units) * u64::from(fps.0), "timing for {fps:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,7 +264,9 @@ impl MediaClip {
|
|||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ItemKind {
|
||||
Media(MediaClip),
|
||||
Sequence(Box<Sequence>),
|
||||
/// Shared so project snapshots used by undo and background readers do not deep-copy every
|
||||
/// timeline. Mutators detach with `Arc::make_mut`.
|
||||
Sequence(std::sync::Arc<Sequence>),
|
||||
/// A subclip: a media item restricted to a range.
|
||||
Subclip {
|
||||
parent: ItemId,
|
||||
|
|
@ -329,7 +331,7 @@ impl ProjectItem {
|
|||
}
|
||||
pub fn as_sequence_mut(&mut self) -> Option<&mut Sequence> {
|
||||
match &mut self.kind {
|
||||
ItemKind::Sequence(s) => Some(s),
|
||||
ItemKind::Sequence(s) => Some(std::sync::Arc::make_mut(s)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -1509,7 +1511,7 @@ impl Project {
|
|||
let id = TrackId(self.alloc_id());
|
||||
seq.audio_tracks.push(Track::new(id, TrackKind::Audio, format!("Audio {}", i + 1)));
|
||||
}
|
||||
self.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), bin)
|
||||
self.add_item(name, Label::Forest, ItemKind::Sequence(std::sync::Arc::new(seq)), bin)
|
||||
}
|
||||
|
||||
/// Re-base an item's media time: whatever was at media time `t` is at `t - delta` afterwards
|
||||
|
|
@ -1531,6 +1533,7 @@ impl Project {
|
|||
}
|
||||
for it in self.items.values_mut() {
|
||||
if let ItemKind::Sequence(seq) = &mut it.kind {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for t in seq.all_tracks_mut() {
|
||||
for ti in t.items.iter_mut().filter(|ti| users.contains(&ti.item)) {
|
||||
ti.source_in -= delta;
|
||||
|
|
@ -1991,6 +1994,30 @@ mod tests {
|
|||
assert_eq!(p.root.parent_of(i), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_clone_shares_sequences_until_mutated() {
|
||||
fn sequence_arc(p: &Project, id: ItemId) -> &std::sync::Arc<Sequence> {
|
||||
match &p.item(id).unwrap().kind {
|
||||
ItemKind::Sequence(q) => q,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
let (mut p, _, a) = demo_project();
|
||||
let b = p.new_sequence("Sequence 02", SequenceSettings::default(), 1, 1, None);
|
||||
let before = p.clone();
|
||||
|
||||
assert!(std::sync::Arc::ptr_eq(sequence_arc(&p, a), sequence_arc(&before, a)));
|
||||
assert!(std::sync::Arc::ptr_eq(sequence_arc(&p, b), sequence_arc(&before, b)));
|
||||
|
||||
p.sequence_mut(a).unwrap().mark_in = Some(Tick(1));
|
||||
|
||||
assert!(!std::sync::Arc::ptr_eq(sequence_arc(&p, a), sequence_arc(&before, a)));
|
||||
assert!(std::sync::Arc::ptr_eq(sequence_arc(&p, b), sequence_arc(&before, b)));
|
||||
assert_eq!(before.sequence(a).unwrap().mark_in, None);
|
||||
assert_eq!(Project::from_json(&p.to_json()).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_cycle_finds_sequences_that_contain_themselves() {
|
||||
let (mut p, clip, a) = demo_project();
|
||||
|
|
|
|||
|
|
@ -1253,6 +1253,9 @@
|
|||
Active Monitor Monitor activo
|
||||
Add Añadir
|
||||
Add tracks automatically when editing source clips onto the Timeline Añadir pistas automáticamente al editar clips de origen en la línea de tiempo
|
||||
Place files dropped onto the Timeline directly on the Timeline Colocar los archivos soltados en la línea de tiempo directamente en ella
|
||||
1 file 1 archivo
|
||||
{n} files {n} archivos
|
||||
Additional Scale Escala adicional
|
||||
Additive Dissolve Disolución aditiva
|
||||
Additive Dissolve (Legacy) Disolución aditiva (heredada)
|
||||
|
|
@ -3488,6 +3491,8 @@
|
|||
New Sequence Nueva secuencia
|
||||
Tracks Pistas
|
||||
tracks pistas
|
||||
Clear Transition Borrar transición
|
||||
Set Transition Duration Establecer duración de la transición
|
||||
|
||||
# System appearance modes and theme cards.
|
||||
Active Activo
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -1253,6 +1253,9 @@
|
|||
Active Monitor アクティブなモニター
|
||||
Add 加算
|
||||
Add tracks automatically when editing source clips onto the Timeline ソースクリップをタイムラインに配置するとき、トラックを自動的に追加する
|
||||
Place files dropped onto the Timeline directly on the Timeline タイムラインにドロップしたファイルをタイムラインに直接配置
|
||||
1 file 1 個のファイル
|
||||
{n} files {n} 個のファイル
|
||||
Additional Scale 追加スケール
|
||||
Additive Dissolve 加算ディゾルブ
|
||||
Additive Dissolve (Legacy) 加算ディゾルブ (従来版)
|
||||
|
|
@ -3498,3 +3501,5 @@
|
|||
Solid べた塗り
|
||||
Text to Speech テキスト読み上げ
|
||||
Edit Narration ナレーションを編集
|
||||
Clear Transition トランジションを消去
|
||||
Set Transition Duration トランジションのデュレーションを設定
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
|
|
@ -1246,6 +1246,9 @@
|
|||
Active Monitor 活动监视器
|
||||
Add 添加
|
||||
Add tracks automatically when editing source clips onto the Timeline 将源剪辑编辑到时间轴时自动添加轨道
|
||||
Place files dropped onto the Timeline directly on the Timeline 将拖放到时间轴上的文件直接放置在时间轴上
|
||||
1 file 1 个文件
|
||||
{n} files {n} 个文件
|
||||
Additional Scale 附加缩放
|
||||
Additive Dissolve 添加式溶解
|
||||
Additive Dissolve (Legacy) 添加式溶解(旧版)
|
||||
|
|
@ -3487,3 +3490,5 @@
|
|||
Sync with system 跟随系统
|
||||
Text to Speech 文字转语音
|
||||
Edit Narration 编辑旁白
|
||||
Clear Transition 清除过渡
|
||||
Set Transition Duration 设置过渡持续时间
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
|
|
@ -88,6 +88,11 @@ pub trait AudioOut {
|
|||
/// effect at the next `start`; `document_rate` is the sequence sample rate for "Attempt to
|
||||
/// force hardware to document sample rate".
|
||||
fn configure(&mut self, _hw: &filmcraft_engine::settings::AudioHardwarePrefs, _document_rate: Option<u32>) {}
|
||||
/// A hardware note for the status bar — e.g. the default output device could not be opened
|
||||
/// and a fallback device is in use, or no output device was found — or None.
|
||||
fn note(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What the platform audio layer can open (Settings ▸ Audio Hardware).
|
||||
|
|
@ -105,6 +110,8 @@ pub struct AudioDevices {
|
|||
#[derive(Default)]
|
||||
pub struct HostHooks {
|
||||
pub pick_files: Option<Box<dyn FnMut(&[&str]) -> Vec<String>>>,
|
||||
/// The cursor in physical screen pixels. Where the windowing layer reports no pointer while files are dragged in from the OS (Windows), a drop is placed where this says it landed.
|
||||
pub cursor_screen_position: Option<Box<dyn Fn() -> Option<(i32, i32)>>>,
|
||||
pub pick_save: Option<Box<dyn FnMut(&str) -> Option<String>>>,
|
||||
pub pick_open_project: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// Save dialog with a filter: (filter name, extensions, suggested file name) → path.
|
||||
|
|
@ -170,6 +177,8 @@ pub enum Dialog {
|
|||
AddTracks,
|
||||
/// Sequence ▸ Sequence Settings….
|
||||
SequenceSettings,
|
||||
/// Set Transition Duration (double-click a transition in the Timeline).
|
||||
TransitionDuration,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -277,6 +286,10 @@ pub struct FilmcraftApp {
|
|||
/// The sequence whose view `ui.timeline` holds, and that view as it was last exchanged with
|
||||
/// `session.state.timeline_views` (see `sync_timeline_view`).
|
||||
timeline_view_of: Option<filmcraft_engine::project::ItemId>,
|
||||
/// Items imported by an OS file drop, to place on the Timeline at the drop point this frame.
|
||||
pending_timeline_drop: Option<(Vec<filmcraft_engine::project::ItemId>, egui::Pos2)>,
|
||||
/// Files being dragged over the window from the OS (count, pointer): the Timeline previews where they would land.
|
||||
file_drag_hover: Option<(usize, egui::Pos2)>,
|
||||
timeline_view_last: Option<filmcraft_engine::project::SequenceView>,
|
||||
pub fps: f32,
|
||||
last_time: f64,
|
||||
|
|
@ -465,6 +478,8 @@ impl FilmcraftApp {
|
|||
integrated_titlebar: false,
|
||||
last_timeline_width: 1000.0,
|
||||
timeline_view_of: None,
|
||||
pending_timeline_drop: None,
|
||||
file_drag_hover: None,
|
||||
timeline_view_last: None,
|
||||
fps: 60.0,
|
||||
last_time: 0.0,
|
||||
|
|
@ -597,6 +612,9 @@ impl FilmcraftApp {
|
|||
self.start_audio();
|
||||
}
|
||||
}
|
||||
if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) {
|
||||
self.ui.status = note;
|
||||
}
|
||||
if prev.is_none() && !self.workspace_restored {
|
||||
// reopen the workspace in use when the app last closed
|
||||
self.workspace_restored = true;
|
||||
|
|
@ -895,7 +913,12 @@ impl FilmcraftApp {
|
|||
})
|
||||
};
|
||||
match a.start(fill) {
|
||||
Ok(_) => self.playback.audio_clock = true,
|
||||
Ok(_) => {
|
||||
self.playback.audio_clock = true;
|
||||
if let Some(note) = self.audio.as_ref().and_then(|audio| audio.note()) {
|
||||
self.ui.status = note;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("audio output unavailable: {e}");
|
||||
self.playback.audio_clock = false;
|
||||
|
|
@ -1195,8 +1218,17 @@ impl FilmcraftApp {
|
|||
r
|
||||
}
|
||||
|
||||
/// Import dropped files.
|
||||
/// Import dropped files. With Timeline ▸ drop imports to the timeline, the new items are also
|
||||
/// queued to be placed at the pointer (see `timeline::interact`).
|
||||
fn handle_drops(&mut self, ctx: &egui::Context) {
|
||||
// While files hover, the OS sends no cursor events: poll the cursor each frame to draw the Timeline preview.
|
||||
let hovering = ctx.input(|i| i.raw.hovered_files.len());
|
||||
self.file_drag_hover = if hovering > 0 && self.session.prefs.timeline.drop_import_to_timeline {
|
||||
ctx.request_repaint();
|
||||
self.drop_pointer(ctx).map(|p| (hovering, p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
let mut paths = Vec::new();
|
||||
for f in dropped {
|
||||
|
|
@ -1206,9 +1238,32 @@ impl FilmcraftApp {
|
|||
}
|
||||
}
|
||||
if !paths.is_empty() {
|
||||
let _ = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0}));
|
||||
let pointer = self.drop_pointer(ctx);
|
||||
let imported = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0}));
|
||||
if let Ok(v) = imported
|
||||
&& let Some(pos) = pointer
|
||||
&& self.session.prefs.timeline.drop_import_to_timeline
|
||||
{
|
||||
let items: Vec<filmcraft_engine::project::ItemId> =
|
||||
v["items"].as_array().map(|a| a.iter().filter_map(|i| i.as_u64()).map(filmcraft_engine::project::ItemId).collect()).unwrap_or_default();
|
||||
if !items.is_empty() {
|
||||
self.pending_timeline_drop = Some((items, pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where files dragged in from the OS are over the window. The OS reports no cursor motion during such a drag
|
||||
/// (winit, Windows), so ask it through the host hook, else use the last pointer position egui saw.
|
||||
fn drop_pointer(&self, ctx: &egui::Context) -> Option<egui::Pos2> {
|
||||
let os_pointer = self
|
||||
.hooks
|
||||
.cursor_screen_position
|
||||
.as_ref()
|
||||
.and_then(|f| f())
|
||||
.and_then(|px| ctx.input(|i| i.viewport().inner_rect.map(|r| screen_px_to_ui(px, i.pixels_per_point, r.min))));
|
||||
os_pointer.or_else(|| ctx.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos())))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input
|
||||
|
||||
|
|
@ -1441,6 +1496,8 @@ impl FilmcraftApp {
|
|||
state::Mode::Import => panels::import_mode::show(self, ui, body),
|
||||
state::Mode::Export => panels::export_mode::show(self, ui, body),
|
||||
}
|
||||
// the Timeline has had its chance to place a file drop this frame; anything left stays a bin import
|
||||
self.pending_timeline_drop = None;
|
||||
panels::dialogs::show(self, &ctx);
|
||||
if !self.ui.show_status_bar {
|
||||
// no bar to draw the job in, but a finished preview render still plays
|
||||
|
|
@ -1989,3 +2046,31 @@ mod audio_recovery_tests {
|
|||
app.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points.
|
||||
///
|
||||
/// Assumes the whole virtual screen uses the window's `pixels_per_point`: with monitors of mixed
|
||||
/// DPI the point can be off when the cursor and the window's origin are on different monitors.
|
||||
fn screen_px_to_ui(px: (i32, i32), pixels_per_point: f32, content_min: egui::Pos2) -> egui::Pos2 {
|
||||
let ppp = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { pixels_per_point } else { 1.0 };
|
||||
egui::pos2(px.0 as f32 / ppp - content_min.x, px.1 as f32 / ppp - content_min.y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod screen_px_tests {
|
||||
use super::screen_px_to_ui;
|
||||
|
||||
#[test]
|
||||
fn maps_screen_pixels_into_the_window() {
|
||||
let p = screen_px_to_ui((1300, 900), 2.0, egui::pos2(100.0, 50.0));
|
||||
assert_eq!((p.x, p.y), (550.0, 400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_scale_does_not_divide_by_zero() {
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
let p = screen_px_to_ui((10, 20), ppp, egui::pos2(0.0, 0.0));
|
||||
assert!(p.x.is_finite() && p.y.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
Dialog::TransitionDuration => {
|
||||
if !crate::panels::transition_controls::duration_dialog(app, ctx) && app.dialog == Some(d) {
|
||||
app.dialog = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Dialog::SequenceSettings => {
|
||||
if !crate::panels::sequence_settings::show(app, ctx) && app.dialog == Some(d) {
|
||||
app.dialog = None;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ fn timeline_time_of(it: &TrackItem, m: Tick) -> Tick {
|
|||
it.start + Tick(((m - it.source_in).0 as f64 / it.speed.abs().max(1e-6)) as i64)
|
||||
}
|
||||
|
||||
// The press frame can include movement before the button went down. Start from the press
|
||||
// origin so that only gesture motion enters the preview, including same-frame movement.
|
||||
fn graph_drag_offset(ui: &egui::Ui, response: &egui::Response, previous: egui::Vec2) -> egui::Vec2 {
|
||||
if response.drag_started() {
|
||||
response
|
||||
.interact_pointer_pos()
|
||||
.zip(ui.input(|i| i.pointer.press_origin()))
|
||||
.map_or_else(|| response.drag_delta(), |(position, origin)| position - origin)
|
||||
} else {
|
||||
previous + response.drag_delta()
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> {
|
||||
selected_clips(app).into_iter().next()
|
||||
}
|
||||
|
|
@ -59,6 +72,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let t = app.tokens;
|
||||
let clips = selected_clips(app);
|
||||
let Some((clip, it, _)) = clips.first().cloned() else {
|
||||
// a transition clicked in the Timeline (#430)
|
||||
if let Some(id) = crate::panels::transition_controls::selected(app) {
|
||||
crate::panels::transition_controls::effect_controls(app, ui, rect, id);
|
||||
return;
|
||||
}
|
||||
crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)"));
|
||||
return;
|
||||
};
|
||||
|
|
@ -979,7 +997,7 @@ pub(crate) fn graph_rows(
|
|||
p.circle_filled(hp, 3.5, t.keyframe_handle);
|
||||
let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag());
|
||||
if hr.dragged() {
|
||||
let nx = hdx + hr.drag_delta().x;
|
||||
let nx = graph_drag_offset(ui, &hr, vec2(hdx, 0.0)).x;
|
||||
ui.data_mut(|d| d.insert_temp(hid, nx));
|
||||
}
|
||||
if hr.drag_stopped() {
|
||||
|
|
@ -991,7 +1009,7 @@ pub(crate) fn graph_rows(
|
|||
}
|
||||
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle });
|
||||
if resp.dragged() {
|
||||
let ny = dy + resp.drag_delta().y;
|
||||
let ny = graph_drag_offset(ui, &resp, vec2(0.0, dy)).y;
|
||||
ui.data_mut(|d| d.insert_temp(id, ny));
|
||||
p.text(c + vec2(8.0, -10.0), Align2::LEFT_BOTTOM, format!("{:.dec$}", v_of(c.y)), Tokens::ui(10.5), t.hot_text);
|
||||
}
|
||||
|
|
@ -1172,3 +1190,134 @@ mod lane_tests {
|
|||
assert_eq!(labels, ["00:01:00:00", "00:01:02:00", "00:01:04:00"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod graph_drag_tests {
|
||||
use super::*;
|
||||
use filmcraft_project::{Interpolation, Keyframe, Param};
|
||||
|
||||
struct Driver {
|
||||
app: FilmcraftApp,
|
||||
ctx: egui::Context,
|
||||
clip: ClipId,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new() -> Self {
|
||||
let mut session = filmcraft_engine::Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let seq = session.state.active_sequence.unwrap();
|
||||
let clip = session.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||||
let it = std::sync::Arc::make_mut(&mut session.project).sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1;
|
||||
let mut a = Keyframe::new(it.source_in + Tick(it.duration.0 / 4), ParamValue::Float(40.0));
|
||||
let mut b = Keyframe::new(it.source_in + Tick(it.duration.0 * 3 / 4), ParamValue::Float(80.0));
|
||||
a.interp = Interpolation::Bezier;
|
||||
b.interp = Interpolation::Bezier;
|
||||
it.effects
|
||||
.iter_mut()
|
||||
.find(|e| e.effect == "motion")
|
||||
.unwrap()
|
||||
.params
|
||||
.insert("scale".into(), Param { value: ParamValue::Float(40.0), keyframes: vec![a, b] });
|
||||
let app = FilmcraftApp::new(session);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens);
|
||||
Self { app, ctx, clip, time: 0.0 }
|
||||
}
|
||||
|
||||
fn param(&self) -> Param {
|
||||
self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.effect("motion").unwrap().params["scale"].clone()
|
||||
}
|
||||
|
||||
fn undo_len(&mut self) -> usize {
|
||||
self.app.session.execute("history.list", json!({})).unwrap()["undo"].as_array().unwrap().len()
|
||||
}
|
||||
|
||||
// Draw the actual scalar graph and execute its emitted command, as the panel does.
|
||||
// Current paint shapes give a real held-frame position rather than previous telemetry.
|
||||
fn frame(&mut self, events: Vec<egui::Event>) -> [Pos2; 2] {
|
||||
self.time += 0.1;
|
||||
self.app.auto.begin_frame();
|
||||
let mut out = self.ctx.run_ui(egui::RawInput { time: Some(self.time), events, ..Default::default() }, |ui| {
|
||||
let it = self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.clone();
|
||||
let index = it.effects.iter().position(|e| e.effect == "motion").unwrap();
|
||||
let effect = &it.effects[index];
|
||||
let pd = effect.def().unwrap().param("scale").unwrap();
|
||||
let body = Rect::from_min_size(pos2(8.0, 8.0), vec2(320.0, 200.0));
|
||||
let lane = Rect::from_min_max(pos2(360.0, 8.0), pos2(760.0, 220.0));
|
||||
let mut actions = Vec::new();
|
||||
graph_rows(&mut self.app, ui, body, self.clip, index, None, pd, &effect.params["scale"], &lane, &it, &mut actions);
|
||||
run(&mut self.app, ui.ctx(), actions);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
fn first_circle(shape: &egui::Shape, radius: f32) -> Option<Pos2> {
|
||||
match shape {
|
||||
egui::Shape::Circle(circle) if circle.radius == radius => Some(circle.center),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| first_circle(shape, radius)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
[4.5, 3.5].map(|radius| {
|
||||
out.shapes.iter().find_map(|shape| first_circle(&shape.shape, radius)).expect("scalar keyframe and outgoing influence are painted")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_graph_press_starts_at_the_pointer_origin_and_release_is_one_undo_step() {
|
||||
for influence in [false, true] {
|
||||
for batched in [true, false] {
|
||||
let mut d = Driver::new();
|
||||
let before = d.param();
|
||||
let undo = d.undo_len();
|
||||
let target = usize::from(influence);
|
||||
let start = d.frame(vec![])[target];
|
||||
// A different prior cursor makes the pre-press movement observable.
|
||||
d.frame(vec![egui::Event::PointerMoved(start - vec2(80.0, 40.0))]);
|
||||
if batched {
|
||||
d.frame(vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||||
} else {
|
||||
d.frame(vec![egui::Event::PointerMoved(start)]);
|
||||
d.frame(vec![button(start, true)]);
|
||||
}
|
||||
let pressed = d.frame(vec![])[target];
|
||||
assert!(pressed.distance(start) < 0.05, "press cannot jump: influence={influence}, batched={batched}, start={start:?}, pressed={pressed:?}");
|
||||
assert_eq!(d.param(), before);
|
||||
assert_eq!(d.undo_len(), undo);
|
||||
let offset = if influence { vec2(20.0, 0.0) } else { vec2(0.0, -20.0) };
|
||||
d.frame(vec![egui::Event::PointerMoved(start + offset)]);
|
||||
let preview = d.frame(vec![])[target];
|
||||
assert!(
|
||||
preview.distance(start + offset) < 0.05,
|
||||
"preview follows only gesture motion: influence={influence}, batched={batched}, start={start:?}, preview={preview:?}"
|
||||
);
|
||||
assert_eq!(d.param(), before, "held preview cannot commit");
|
||||
assert_eq!(d.undo_len(), undo);
|
||||
d.frame(vec![button(start + offset, false)]);
|
||||
d.frame(vec![]);
|
||||
let changed = d.param();
|
||||
assert_eq!(d.undo_len(), undo + 1);
|
||||
assert_eq!(changed.keyframes[0].time, before.keyframes[0].time);
|
||||
assert_eq!(changed.keyframes[1], before.keyframes[1]);
|
||||
if influence {
|
||||
assert_eq!(changed.keyframes[0].value, before.keyframes[0].value);
|
||||
assert!(changed.keyframes[0].out_influence > before.keyframes[0].out_influence);
|
||||
} else {
|
||||
assert!(changed.keyframes[0].value.as_f64().unwrap() > before.keyframes[0].value.as_f64().unwrap());
|
||||
assert_eq!(changed.keyframes[0].out_influence, before.keyframes[0].out_influence);
|
||||
}
|
||||
d.app.session.undo().expect("one graph gesture to undo");
|
||||
assert_eq!(d.param(), before);
|
||||
assert_eq!(d.undo_len(), undo);
|
||||
d.app.session.redo().expect("the graph gesture to redo");
|
||||
assert_eq!(d.param(), changed);
|
||||
assert_eq!(d.undo_len(), undo + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1594,7 +1594,17 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
for (id, r, label) in autos {
|
||||
app.auto.add(&id, r, &label);
|
||||
}
|
||||
for (cmd, p) in actions {
|
||||
for (cmd, mut p) in actions {
|
||||
// a drag of a property is one undo step: the first change of each press begins a new one,
|
||||
// the rest fold into it (like Effect Controls, #201)
|
||||
if cmd == "graphics.set" && ui.ctx().input(|i| i.pointer.any_down()) {
|
||||
let key = egui::Id::new("gfx-props-drag-step");
|
||||
let press = ui.ctx().input(|i| i.pointer.press_start_time());
|
||||
let begun = ui.ctx().data(|d| d.get_temp::<Option<f64>>(key)).flatten();
|
||||
p["merge"] = json!(true);
|
||||
p["begin"] = json!(begun != press);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(key, press));
|
||||
}
|
||||
if let Err(e) = app.session.execute(&cmd, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub mod timeline_captions;
|
|||
pub mod timeline_hit;
|
||||
pub mod timeline_volume;
|
||||
pub mod tools;
|
||||
pub mod transition_controls;
|
||||
pub mod trim_monitor;
|
||||
pub mod tts;
|
||||
pub mod voiceover;
|
||||
|
|
|
|||
|
|
@ -130,6 +130,16 @@ pub enum Drag {
|
|||
/// As in [`Drag::Trim`].
|
||||
from: Tick,
|
||||
},
|
||||
/// A transition dragged by an end (`edge`: its duration) or its middle (slides it over the
|
||||
/// cut); `start` / `duration` are where it is shown now, committed on release (#224).
|
||||
Transition {
|
||||
id: filmcraft_project::TransitionId,
|
||||
edge: Option<filmcraft_edit::Edge>,
|
||||
/// As in [`Drag::Trim`].
|
||||
from: Tick,
|
||||
start: Tick,
|
||||
duration: Tick,
|
||||
},
|
||||
Pan {
|
||||
last: Pos2,
|
||||
},
|
||||
|
|
@ -424,13 +434,22 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// selected edit points (trim mode): red brackets for ripple, yellow for roll / regular trim
|
||||
super::trim_monitor::paint_edit_points(app, &p, &seq, tr, r, &layout);
|
||||
// transitions
|
||||
// transitions (one being dragged where the drag puts it; the selected ones outlined)
|
||||
for trn in &tr.transitions {
|
||||
let x0 = layout.x_of(trn.start);
|
||||
let x1 = layout.x_of(trn.end());
|
||||
let (start, end) = match &app.tl.drag {
|
||||
Some(Drag::Transition { id, start, duration, .. }) if *id == trn.id => (*start, *start + *duration),
|
||||
_ => (trn.start, trn.end()),
|
||||
};
|
||||
let x0 = layout.x_of(start);
|
||||
let x1 = layout.x_of(end);
|
||||
let tr_rect = Rect::from_min_max(pos2(x0, r.rect.min.y + 17.0), pos2(x1, r.rect.max.y - 1.0));
|
||||
draw_transition(&p, tr_rect, trn, &t);
|
||||
let selected = app.session.state.transition_selection.contains(&trn.id);
|
||||
draw_transition(&p, tr_rect, trn, selected, &t);
|
||||
app.auto.add(&format!("timeline.transition.{}", trn.id.0), tr_rect, &trn.effect.effect);
|
||||
// its ends, for dragging the duration (the same zones as the hit test)
|
||||
let zone = super::timeline_hit::EDGE_PX.min(tr_rect.width() / 3.0);
|
||||
app.auto.add(&format!("timeline.transition.{}.in", trn.id.0), Rect::from_min_max(tr_rect.min, pos2(x0 + zone, tr_rect.max.y)), "start");
|
||||
app.auto.add(&format!("timeline.transition.{}.out", trn.id.0), Rect::from_min_max(pos2(x1 - zone, tr_rect.min.y), tr_rect.max), "end");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -902,7 +921,7 @@ fn paint_hatch(p: &egui::Painter, r: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transition, _t: &Tokens) {
|
||||
fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transition, selected: bool, t: &Tokens) {
|
||||
let cp = p.with_clip_rect(r.intersect(p.clip_rect()));
|
||||
cp.rect_filled(r, 0.0, Color32::from_black_alpha(90));
|
||||
paint_hatch(p, r);
|
||||
|
|
@ -924,6 +943,9 @@ fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transiti
|
|||
cp.add(egui::Shape::line(b, Stroke::new(1.0, Color32::WHITE)));
|
||||
}
|
||||
cp.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(0xeb, 0xeb, 0xeb)), StrokeKind::Inside);
|
||||
if selected {
|
||||
cp.rect_stroke(r, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
if r.width() > 34.0 && !audio {
|
||||
let name = trn.effect.def().map(|d| d.name).unwrap_or(&trn.effect.effect);
|
||||
let tr = Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2((r.width() - 4.0).min(80.0), 13.0));
|
||||
|
|
@ -1475,7 +1497,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value {
|
|||
Hit::Clip { track, clip, edge } => {
|
||||
json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "kind": kind, "time": t.0})
|
||||
}
|
||||
Hit::Transition { track, id } => json!({"hit": "transition", "track": track.0, "transition": id.0}),
|
||||
Hit::Transition { track, id, edge } => json!({"hit": "transition", "track": track.0, "transition": id.0, "edge": edge.map(|e| format!("{e:?}"))}),
|
||||
Hit::Empty { track } => json!({"hit": "empty", "track": track.0, "time": t.0}),
|
||||
Hit::None => json!({"hit": "none"}),
|
||||
}
|
||||
|
|
@ -1826,6 +1848,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
let g = grab_at(seq, layout, p, tool, mods, roll_ripple);
|
||||
let cur = match (tool, g) {
|
||||
(_, Grab::Edge { .. }) => CursorIcon::ResizeColumn,
|
||||
(Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn,
|
||||
(Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab,
|
||||
(_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair,
|
||||
(_, Grab::Volume { .. }) => CursorIcon::ResizeVertical,
|
||||
(Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair,
|
||||
|
|
@ -1943,6 +1967,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
} else if !sel.contains(&clip) {
|
||||
if mods.alt {
|
||||
app.session.state.selection = vec![clip];
|
||||
app.session.state.transition_selection.clear();
|
||||
} else {
|
||||
let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]}));
|
||||
}
|
||||
|
|
@ -1954,6 +1979,22 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
None
|
||||
}
|
||||
}
|
||||
// click a transition to select it (Shift adds / removes); drag an end to change its
|
||||
// duration or its middle to slide it over the cut (#430, #224)
|
||||
(Tool::Selection, Grab::Other(Hit::Transition { id, edge, .. })) => {
|
||||
// a drag of a selected transition (Shift too) moves it rather than toggling it out first
|
||||
if !(app.session.state.transition_selection.contains(&id) && resp.drag_started()) {
|
||||
let r = app.session.execute("timeline.select", json!({"transitions": [id.0], "toggle": mods.shift}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
let span = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).map(|x| (x.start, x.duration));
|
||||
match span {
|
||||
Some((start, duration)) if resp.drag_started() => Some(Drag::Transition { id, edge, from: t, start, duration }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(_, Grab::Other(Hit::Transition { .. })) => None,
|
||||
(_, Grab::Other(Hit::Empty { .. })) => {
|
||||
if resp.drag_started() {
|
||||
|
|
@ -1961,6 +2002,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
} else {
|
||||
app.session.state.selection.clear();
|
||||
app.session.state.edit_points.clear();
|
||||
app.session.state.transition_selection.clear();
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -2048,6 +2090,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
let origin = ctx.input(|i| i.pointer.press_origin()).map(|o| layout.tick_at(o.x)).unwrap_or(t_here);
|
||||
Some(Drag::Slide { clip, delta: rate.snap_nearest(t_here - origin) })
|
||||
}
|
||||
Drag::Transition { id, edge, from, start, duration } => {
|
||||
let delta = rate.snap_nearest(t_here - from);
|
||||
let span = seq.all_tracks().find_map(|tr| {
|
||||
tr.transitions.iter().find(|x| x.id == id).and_then(|x| filmcraft_edit::transitions::drag_span(tr, x, edge, delta, rate.frame_duration()))
|
||||
});
|
||||
let (start, duration) = span.unwrap_or((start, duration));
|
||||
Some(Drag::Transition { id, edge, from, start, duration })
|
||||
}
|
||||
Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))),
|
||||
Drag::Marquee { start } => {
|
||||
let r = Rect::from_two_pos(start, p);
|
||||
|
|
@ -2095,6 +2145,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
Drag::Roll { left, right, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))),
|
||||
Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))),
|
||||
Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))),
|
||||
Drag::Transition { id, start, duration, .. } => {
|
||||
let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration));
|
||||
moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0})))
|
||||
}
|
||||
Drag::Marquee { start } => {
|
||||
if let Some(end) = resp.interact_pointer_pos() {
|
||||
let r = Rect::from_two_pos(start, end);
|
||||
|
|
@ -2118,6 +2172,15 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
}
|
||||
}
|
||||
|
||||
// ---- double-click a transition: Set Transition Duration (Premiere)
|
||||
if resp.double_clicked()
|
||||
&& tool == Tool::Selection
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Hit::Transition { id, .. } = hit(seq, layout, p)
|
||||
{
|
||||
crate::panels::transition_controls::open_duration(app, id);
|
||||
}
|
||||
|
||||
// ---- double-click a nested sequence clip: open it in its own Timeline tab
|
||||
if resp.double_clicked()
|
||||
&& tool == Tool::Selection
|
||||
|
|
@ -2342,9 +2405,65 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
}
|
||||
}
|
||||
}
|
||||
// ---- OS file drops imported with "drop imports to the timeline": placed at the drop point, one after another
|
||||
if let Some((items, p)) = app.pending_timeline_drop.clone()
|
||||
&& layout.content.contains(p)
|
||||
{
|
||||
app.pending_timeline_drop = None;
|
||||
let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned());
|
||||
if let Some(row) = row {
|
||||
let (vt, at) = match row.kind {
|
||||
TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)),
|
||||
TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)),
|
||||
};
|
||||
let still = app.session.prefs.timeline.still_duration(rate);
|
||||
let durations: Vec<Tick> = items
|
||||
.iter()
|
||||
.map(|&item| {
|
||||
let is_still = app.session.project.item(item).and_then(|i| i.as_media()).is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Still);
|
||||
app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still)
|
||||
})
|
||||
.collect();
|
||||
let start = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]);
|
||||
for (item, t) in items.iter().zip(chain_starts(start, &durations)) {
|
||||
let params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": false});
|
||||
if let Err(e) = app.session.execute("timeline.place", params) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// preview of OS files being dragged over the Timeline: a placeholder where the drop would start
|
||||
if let Some((count, p)) = app.file_drag_hover
|
||||
&& layout.content.contains(p)
|
||||
{
|
||||
let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned());
|
||||
if let Some(row) = row {
|
||||
let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]);
|
||||
let x = layout.x_of(t);
|
||||
let r = Rect::from_min_max(pos2(x, row.rect.min.y + 1.0), pos2((x + 120.0).min(layout.content.max.x), row.rect.max.y - 1.0));
|
||||
ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40));
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside);
|
||||
let label = if count == 1 { tl!("1 file").to_string() } else { tlf!("{n} files", n = count) };
|
||||
ui.painter().text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, label, Tokens::ui(10.0), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
app.auto.add("timeline.tracks", layout.content, "tracks");
|
||||
}
|
||||
|
||||
/// Start tick of each clip when the clips are laid end to end from `start`. Saturates rather than overflows.
|
||||
pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec<Tick> {
|
||||
let mut t = start;
|
||||
durations
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let at = t;
|
||||
t = Tick(t.0.saturating_add(d.0.max(0)));
|
||||
at
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod waveform_tests {
|
||||
use super::waveform_display_gain;
|
||||
|
|
@ -2367,3 +2486,41 @@ mod waveform_tests {
|
|||
assert!((db(waveform_display_gain(1.0, -6.0)) - (-6.0)).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod chain_starts_tests {
|
||||
use super::chain_starts;
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
#[test]
|
||||
fn empty_list_has_no_starts() {
|
||||
assert!(chain_starts(Tick(100), &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_clip_starts_at_the_drop_point() {
|
||||
assert_eq!(chain_starts(Tick(7), &[Tick(30)]), vec![Tick(7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_clips_are_laid_end_to_end() {
|
||||
let durations = [Tick(10), Tick(25), Tick(5)];
|
||||
assert_eq!(chain_starts(Tick(100), &durations), vec![Tick(100), Tick(110), Tick(135)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_near_i64_max_saturates_without_panicking() {
|
||||
let starts = chain_starts(Tick(i64::MAX - 5), &[Tick(10), Tick(10), Tick(10)]);
|
||||
assert_eq!(starts, vec![Tick(i64::MAX - 5), Tick(i64::MAX), Tick(i64::MAX)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_clips_share_one_start() {
|
||||
assert_eq!(chain_starts(Tick(42), &[Tick(0), Tick(0), Tick(9)]), vec![Tick(42), Tick(42), Tick(42)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_durations_count_as_zero() {
|
||||
assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,21 @@ pub(crate) fn edge_geometry(seq: &Sequence, layout: &Layout, track: TrackId, cli
|
|||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Hit {
|
||||
Ruler,
|
||||
Clip { track: TrackId, clip: ClipId, edge: Option<Edge> },
|
||||
Transition { track: TrackId, id: TransitionId },
|
||||
Empty { track: TrackId },
|
||||
Clip {
|
||||
track: TrackId,
|
||||
clip: ClipId,
|
||||
edge: Option<Edge>,
|
||||
},
|
||||
/// A transition; `edge` is its start (`In`) or end (`Out`) when the press is on one of its
|
||||
/// ends (dragging it changes the duration), `None` on its middle (dragging slides it).
|
||||
Transition {
|
||||
track: TrackId,
|
||||
id: TransitionId,
|
||||
edge: Option<Edge>,
|
||||
},
|
||||
Empty {
|
||||
track: TrackId,
|
||||
},
|
||||
None,
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +81,16 @@ pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit {
|
|||
let x0 = layout.x_of(trn.start);
|
||||
let x1 = layout.x_of(trn.end());
|
||||
if pos.x >= x0 && pos.x <= x1 && pos.y > row.rect.min.y + 17.0 {
|
||||
return Hit::Transition { track: row.track, id: trn.id };
|
||||
// the ends are edge zones, narrower on a short transition so its middle stays grabbable
|
||||
let zone = EDGE_PX.min((x1 - x0) / 3.0);
|
||||
let edge = if pos.x - x0 <= zone {
|
||||
Some(Edge::In)
|
||||
} else if x1 - pos.x <= zone {
|
||||
Some(Edge::Out)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return Hit::Transition { track: row.track, id: trn.id, edge };
|
||||
}
|
||||
}
|
||||
if let Some((clip, edge)) = edge_at(tr, layout, pos.x) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
//! A selected transition (#430): its Effect Controls (Duration, Alignment, Reverse and its own
|
||||
//! settings, as Premiere's Change transition settings / Align transitions / Change transition
|
||||
//! duration pages describe them) and the Set Transition Duration dialog a double-click on it in
|
||||
//! the Timeline opens. Every change is one `sequence.setTransition`; dragging a value is one undo
|
||||
//! step.
|
||||
//!
|
||||
//! Not here yet: the A/B preview thumbnails with their Start / End sliders, Show Actual Sources,
|
||||
//! and dragging the transition in Effect Controls' own time ruler (the Timeline does that).
|
||||
//!
|
||||
//! Automation ids: `effectControls.transition.duration`, `.alignment` (+ `.alignment.option.<center|
|
||||
//! start|end>` while open), `.reverse`, `.reset`, `.param.<id>` (a point's are `.param.<id>.x` /
|
||||
//! `.y`; a list's entries `.param.<id>.option.<n>` while open); the dialog's
|
||||
//! `transitionDuration.value`, `.ok`, `.cancel`.
|
||||
|
||||
use egui::{Rect, RichText, vec2};
|
||||
use filmcraft_edit::transitions::{self as trn, Alignment};
|
||||
use filmcraft_project::{ParamKind, ParamValue, Transition, TransitionId};
|
||||
use filmcraft_time::FrameRate;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::state::TransitionDurationDraft;
|
||||
|
||||
type Elems = Vec<(String, Rect, String)>;
|
||||
|
||||
/// The selected transition, when it still exists, with its alignment and how many frames it may
|
||||
/// last at most (the clips it joins).
|
||||
fn lookup(app: &FilmcraftApp, id: TransitionId) -> Option<(Transition, Alignment, i64)> {
|
||||
let seq = app.session.active_sequence()?;
|
||||
let rate = seq.settings.frame_rate.sane();
|
||||
seq.all_tracks().find_map(|tr| {
|
||||
let x = tr.transitions.iter().find(|x| x.id == id)?;
|
||||
let (lo, cut, hi) = trn::bounds(tr, x)?;
|
||||
let max = rate.frame_at(hi - lo).max(1);
|
||||
Some((x.clone(), trn::alignment(x, cut, rate.frame_duration()), max))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first selected transition that still exists.
|
||||
pub fn selected(app: &FilmcraftApp) -> Option<TransitionId> {
|
||||
app.session.state.transition_selection.iter().copied().find(|id| lookup(app, *id).is_some())
|
||||
}
|
||||
|
||||
fn timecode(frames: f64, rate: FrameRate, drop_frame: bool) -> String {
|
||||
filmcraft_time::format_timecode_frames(frames.round() as i64, rate, drop_frame)
|
||||
}
|
||||
|
||||
/// A duration field in frames that reads and takes timecode (or a plain frame count).
|
||||
fn duration_field(ui: &mut egui::Ui, frames: &mut i64, max: i64, rate: FrameRate, drop_frame: bool) -> egui::Response {
|
||||
ui.add(egui::DragValue::new(frames).range(1..=max.max(1)).speed(0.25).custom_formatter(move |v, _| timecode(v, rate, drop_frame)).custom_parser(move |s| {
|
||||
match s.trim().parse::<i64>() {
|
||||
Ok(n) => Some(n as f64),
|
||||
Err(_) => filmcraft_time::parse_timecode(s, rate, drop_frame, 0).ok().map(|n| n as f64),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Effect Controls for a selected transition. `rect` is the panel.
|
||||
pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id: TransitionId) {
|
||||
let t = app.tokens;
|
||||
let Some((x, align, max_frames)) = lookup(app, id) else {
|
||||
crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)"));
|
||||
return;
|
||||
};
|
||||
let Some(seq) = app.session.active_sequence() else { return };
|
||||
let (rate, drop_frame) = (seq.settings.frame_rate.sane(), seq.settings.drop_frame);
|
||||
let (fw, fh) = (f64::from(seq.settings.width), f64::from(seq.settings.height));
|
||||
let seq_name = app.session.state.active_sequence.and_then(|i| app.session.project.item(i)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let def = x.effect.def();
|
||||
let name = def.map_or(x.effect.effect.as_str(), |d| d.name).to_string();
|
||||
let one_sided = x.from.is_none() || x.to.is_none();
|
||||
let mut changes: Vec<Value> = Vec::new();
|
||||
let mut elems: Elems = Vec::new();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0)));
|
||||
child.label(RichText::new(format!("{seq_name} · {name}")).color(t.text_dim));
|
||||
child.add_space(4.0);
|
||||
egui::ScrollArea::vertical().id_salt(("tr-ec-scroll", id.0)).auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
egui::Grid::new(("tr-ec-grid", id.0)).num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Duration"));
|
||||
let mut frames = rate.frame_at(x.duration).max(1);
|
||||
let r = duration_field(ui, &mut frames, max_frames, rate, drop_frame);
|
||||
elems.push(("effectControls.transition.duration".into(), r.rect, timecode(frames as f64, rate, drop_frame)));
|
||||
if r.changed() {
|
||||
changes.push(json!({"frames": frames, "merge": true, "begin": r.drag_started() || !r.dragged()}));
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label(tl!("Alignment"));
|
||||
let shown = align.label();
|
||||
let r = ui
|
||||
.add_enabled_ui(!one_sided, |ui| {
|
||||
egui::ComboBox::from_id_salt(("tr-align", id.0))
|
||||
.selected_text(shown)
|
||||
.width(170.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for a in Alignment::MENU {
|
||||
let o = ui.selectable_label(a == align, a.label());
|
||||
if o.clicked() && a != align {
|
||||
changes.push(json!({"align": a.id()}));
|
||||
}
|
||||
elems.push((format!("effectControls.transition.alignment.option.{}", a.id()), o.rect, a.label().to_string()));
|
||||
}
|
||||
})
|
||||
.response
|
||||
})
|
||||
.inner;
|
||||
elems.push(("effectControls.transition.alignment".into(), r.rect, shown.to_string()));
|
||||
ui.end_row();
|
||||
|
||||
ui.label(tl!("Reverse"));
|
||||
let mut rev = x.reverse;
|
||||
let r = ui.checkbox(&mut rev, "");
|
||||
elems.push(("effectControls.transition.reverse".into(), r.rect, rev.to_string()));
|
||||
if r.changed() {
|
||||
changes.push(json!({"reverse": rev}));
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
for pd in def.map(|d| d.params.as_slice()).unwrap_or(&[]) {
|
||||
let value = x.effect.params.get(pd.id).map_or(&pd.default, |p| &p.value);
|
||||
ui.label(crate::i18n::t(pd.label));
|
||||
if let Some(v) = param_widget(ui, &mut elems, pd.id, &pd.kind, value, (fw, fh)) {
|
||||
changes.push(v);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.button(tl!("Reset"));
|
||||
elems.push(("effectControls.transition.reset".into(), r.rect, "Reset".into()));
|
||||
if r.clicked() {
|
||||
changes.push(json!({"reset": true}));
|
||||
}
|
||||
});
|
||||
for (eid, r, l) in elems {
|
||||
app.auto.add(&eid, r, &l);
|
||||
}
|
||||
for mut c in changes {
|
||||
c["transition"] = json!(id.0);
|
||||
if let Err(e) = app.session.execute("sequence.setTransition", c) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of a transition's own settings. Returns the change, as `sequence.setTransition` params.
|
||||
fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKind, value: &ParamValue, frame: (f64, f64)) -> Option<Value> {
|
||||
let eid = format!("effectControls.transition.param.{pid}");
|
||||
let drag = |r: &egui::Response| json!({"merge": true, "begin": r.drag_started() || !r.dragged()});
|
||||
match (kind, value) {
|
||||
(ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => {
|
||||
let mut v = *v;
|
||||
let speed = ((soft_max - soft_min) / 200.0).max(0.001);
|
||||
let r = ui.add(egui::DragValue::new(&mut v).range(*min..=*max).speed(speed).max_decimals(usize::from(*decimals)).suffix(*unit));
|
||||
elems.push((eid, r.rect, format!("{v}")));
|
||||
r.changed().then(|| {
|
||||
let mut c = drag(&r);
|
||||
c["params"] = json!({ pid: v });
|
||||
c
|
||||
})
|
||||
}
|
||||
(ParamKind::Angle, ParamValue::Float(v)) => {
|
||||
let mut v = *v;
|
||||
let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).suffix("°"));
|
||||
elems.push((eid, r.rect, format!("{v}")));
|
||||
r.changed().then(|| {
|
||||
let mut c = drag(&r);
|
||||
c["params"] = json!({ pid: v });
|
||||
c
|
||||
})
|
||||
}
|
||||
(ParamKind::Bool, ParamValue::Bool(b)) => {
|
||||
let mut b = *b;
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
elems.push((eid, r.rect, b.to_string()));
|
||||
r.changed().then(|| json!({"params": { pid: b }}))
|
||||
}
|
||||
(ParamKind::Choice(opts), ParamValue::Choice(c)) => {
|
||||
let shown = opts.get(*c as usize).copied().unwrap_or("");
|
||||
let mut picked = None;
|
||||
let mut options = Vec::new();
|
||||
let r = egui::ComboBox::from_id_salt(("tr-param", pid.to_string())).selected_text(crate::i18n::t(shown)).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
let r = ui.selectable_label(i == *c as usize, crate::i18n::t(o));
|
||||
if r.clicked() && i != *c as usize {
|
||||
picked = Some(i);
|
||||
}
|
||||
options.push((format!("{eid}.option.{i}"), r.rect, o.to_string()));
|
||||
}
|
||||
});
|
||||
elems.extend(options);
|
||||
elems.push((eid, r.response.rect, shown.to_string()));
|
||||
picked.map(|i| json!({"params": { pid: i }}))
|
||||
}
|
||||
(ParamKind::Color, ParamValue::Color(c)) => {
|
||||
let mut c = *c;
|
||||
let r = ui.color_edit_button_rgba_unmultiplied(&mut c);
|
||||
elems.push((eid, r.rect, format!("{c:?}")));
|
||||
r.changed().then(|| {
|
||||
let mut v = drag(&r);
|
||||
v["params"] = json!({ pid: [c[0], c[1], c[2], c[3]] });
|
||||
v
|
||||
})
|
||||
}
|
||||
(ParamKind::Point, ParamValue::Vec2(p)) => {
|
||||
// an automatic point (NaN) is the frame's centre until it is moved
|
||||
let (mut px, mut py) = (if p.x.is_nan() { frame.0 / 2.0 } else { p.x }, if p.y.is_nan() { frame.1 / 2.0 } else { p.y });
|
||||
let mut out = None;
|
||||
ui.horizontal(|ui| {
|
||||
let rx = ui.add(egui::DragValue::new(&mut px).speed(1.0).prefix("x "));
|
||||
let ry = ui.add(egui::DragValue::new(&mut py).speed(1.0).prefix("y "));
|
||||
elems.push((format!("{eid}.x"), rx.rect, format!("{px}")));
|
||||
elems.push((format!("{eid}.y"), ry.rect, format!("{py}")));
|
||||
for r in [&rx, &ry] {
|
||||
if r.changed() {
|
||||
let mut v = drag(r);
|
||||
v["params"] = json!({ pid: [px, py] });
|
||||
out = Some(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
(_, v) => {
|
||||
let r = ui.label(RichText::new(format!("{v:?}")).weak());
|
||||
elems.push((eid, r.rect, String::new()));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click a transition in the Timeline: Set Transition Duration.
|
||||
pub fn open_duration(app: &mut FilmcraftApp, id: TransitionId) {
|
||||
let Some((x, ..)) = lookup(app, id) else { return };
|
||||
let rate = app.session.active_sequence().map(|q| q.settings.frame_rate.sane()).unwrap_or_default();
|
||||
app.ui.transition_duration = TransitionDurationDraft { transition: id.0, frames: rate.frame_at(x.duration).max(1) };
|
||||
let _ = app.session.execute("timeline.select", json!({"transitions": [id.0]}));
|
||||
app.dialog = Some(crate::Dialog::TransitionDuration);
|
||||
}
|
||||
|
||||
/// The Set Transition Duration dialog. Returns whether it stays open.
|
||||
pub fn duration_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let mut d = app.ui.transition_duration.clone();
|
||||
let id = TransitionId(d.transition);
|
||||
let Some((_, _, max_frames)) = lookup(app, id) else { return false };
|
||||
let Some(seq) = app.session.active_sequence() else { return false };
|
||||
let (rate, drop_frame) = (seq.settings.frame_rate.sane(), seq.settings.drop_frame);
|
||||
let accent = app.tokens.accent;
|
||||
let (mut keep, mut apply) = (true, false);
|
||||
let mut elems: Elems = Vec::new();
|
||||
egui::Window::new(tl!("Set Transition Duration"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(260.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Duration:"));
|
||||
let r = duration_field(ui, &mut d.frames, max_frames, rate, drop_frame);
|
||||
elems.push(("transitionDuration.value".into(), r.rect, timecode(d.frames as f64, rate, drop_frame)));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
// one row high: a right-to-left layout would otherwise take the window's whole height
|
||||
let row = egui::vec2(ui.available_width().max(220.0), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let o = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(accent));
|
||||
elems.push(("transitionDuration.ok".into(), o.rect, "OK".into()));
|
||||
apply |= o.clicked();
|
||||
let c = ui.button(tl!("Cancel"));
|
||||
elems.push(("transitionDuration.cancel".into(), c.rect, "Cancel".into()));
|
||||
keep &= !c.clicked();
|
||||
});
|
||||
});
|
||||
for (eid, r, l) in elems {
|
||||
app.auto.add(&eid, r, &l);
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
keep = false;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input() {
|
||||
apply = true;
|
||||
}
|
||||
if apply {
|
||||
let cur = lookup(app, id).map(|(x, ..)| rate.frame_at(x.duration));
|
||||
if cur != Some(d.frames)
|
||||
&& let Err(e) = app.session.execute("sequence.setTransition", json!({"transition": id.0, "frames": d.frames}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
keep = false;
|
||||
}
|
||||
app.ui.transition_duration = d;
|
||||
keep
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn durations_read_as_timecode() {
|
||||
assert_eq!(timecode(24.0, FrameRate::FPS_24, false), "00:00:01:00");
|
||||
assert_eq!(timecode(30.0, FrameRate::FPS_29_97, true), "00;00;01;00");
|
||||
}
|
||||
}
|
||||
|
|
@ -457,6 +457,9 @@ pub struct UiState {
|
|||
/// Sequence Settings dialog draft.
|
||||
#[serde(default)]
|
||||
pub sequence_settings: SequenceSettingsDraft,
|
||||
/// Set Transition Duration dialog draft (double-click a transition).
|
||||
#[serde(default)]
|
||||
pub transition_duration: TransitionDurationDraft,
|
||||
/// On-monitor text editing (Type tool / double-click on a text layer).
|
||||
#[serde(default)]
|
||||
pub gfx_edit: Option<GfxEdit>,
|
||||
|
|
@ -760,6 +763,15 @@ impl Default for AddTracksDraft {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Set Transition Duration dialog (double-click a transition in the Timeline): which
|
||||
/// transition, and its duration in frames.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TransitionDurationDraft {
|
||||
pub transition: u64,
|
||||
pub frames: i64,
|
||||
}
|
||||
|
||||
/// The Sequence Settings dialog (Sequence ▸ Sequence Settings…), filled from the active sequence
|
||||
/// when it opens (`panels::sequence_settings::open`). `tab`: `general`, `color` or `vr`; the
|
||||
/// timebase is `fps_num`/`fps_den`; `mix`: `Stereo`, `Mono`, `5.1` or `Adaptive`; `working_space`:
|
||||
|
|
@ -908,6 +920,7 @@ impl Default for UiState {
|
|||
add_tracks: AddTracksDraft::default(),
|
||||
delete_tracks: DeleteTracksDraft::default(),
|
||||
sequence_settings: SequenceSettingsDraft::default(),
|
||||
transition_duration: TransitionDurationDraft::default(),
|
||||
gfx_edit: None,
|
||||
pen_points: vec![],
|
||||
link_media: None,
|
||||
|
|
|
|||
|
|
@ -152,3 +152,81 @@ fn type_tool_edit_style_and_move() {
|
|||
d.ok("ui.set", json!({"tool": "Selection"}));
|
||||
d.shot("shape-layer");
|
||||
}
|
||||
|
||||
/// The value of `param` on the text layer of graphic clip `clip`, at its first frame.
|
||||
fn text_param(d: &mut Driver, clip: u64, param: &str) -> Option<filmcraft_project::ParamValue> {
|
||||
let s = &d.harness.state().session;
|
||||
let (_, it) = s.active_sequence()?.find_item(filmcraft_project::ClipId(clip))?;
|
||||
let e = it.effects.iter().find(|e| e.effect == "graphic_text")?;
|
||||
Some(e.param(param)?.value_at(filmcraft_time::Tick::ZERO))
|
||||
}
|
||||
|
||||
/// A drag of a property in the Properties panel is one undo step, so one Cmd+Z restores the
|
||||
/// original value (a drag was committing one undo step per frame, so undo only stepped back one
|
||||
/// frame's worth; the shadow color picker showed it as "sometimes undo works, sometimes not").
|
||||
#[test]
|
||||
fn a_property_drag_is_one_undo_step() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
|
||||
let r = d.exec("graphics.newText", json!({"text": "Title"}));
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
d.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]}));
|
||||
d.frames(4);
|
||||
// the layer's Opacity number (visible in the Align and Transform section)
|
||||
d.exec("graphics.set", json!({"clip": clip, "layer": 0, "props": {"opacity": 50}}));
|
||||
d.frames(2);
|
||||
let opacity = |d: &mut Driver| -> f64 { text_param(d, clip, "opacity").and_then(|v| v.as_f64()).unwrap() };
|
||||
let before = opacity(&mut d);
|
||||
let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
|
||||
// drag the Opacity number to the left: several move frames, one undo step
|
||||
let n = d.rect("graphics.prop.opacity");
|
||||
let (cx, cy) = (n[0] + n[2] / 2.0, n[1] + n[3] / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx - 60.0, "y": cy}, "steps": 6}));
|
||||
d.frames(4);
|
||||
let after = opacity(&mut d);
|
||||
assert!(after < before - 5.0, "the drag changed the opacity: {before} -> {after}");
|
||||
let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
|
||||
assert_eq!(undo1, undo0 + 1, "a property drag is one undo step");
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert_eq!(opacity(&mut d), before, "one undo restores the original value");
|
||||
}
|
||||
|
||||
/// The shadow color picker popup: a drag in the 2D picker is one undo step, so one Cmd+Z
|
||||
/// restores the original color (a drag was committing one undo step per frame, so undo only
|
||||
/// stepped back one frame's worth — the reported "sometimes undo works, sometimes not").
|
||||
#[test]
|
||||
fn shadow_color_drag_is_one_undo_step() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
|
||||
let r = d.exec("graphics.newText", json!({"text": "Title"}));
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
d.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]}));
|
||||
d.frames(4);
|
||||
let color = |d: &mut Driver| text_param(d, clip, "shadow_color").and_then(|v| v.as_color());
|
||||
// scroll the properties panel so the shadow row is visible, then open the picker
|
||||
d.ok("ui.scroll", json!({"x": 1400.0, "y": 500.0, "dx": 0.0, "dy": -400.0}));
|
||||
d.frames(3);
|
||||
let sw = d.rect("graphics.prop.shadow_color");
|
||||
d.ok("ui.click", json!({"x": sw[0] + sw[2] / 2.0, "y": sw[1] + sw[3] / 2.0}));
|
||||
d.frames(3);
|
||||
// the popup flips above the swatch (it does not fit below); find a point in its 2D picker
|
||||
let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
|
||||
let mut hit = None;
|
||||
for (dx, dy) in [(30.0, 30.0), (150.0, 30.0), (30.0, 150.0), (150.0, 150.0)] {
|
||||
let (px, py) = (sw[0] + dx, sw[1] - dy);
|
||||
let before = color(&mut d);
|
||||
d.ok("ui.drag", json!({"from": {"x": px, "y": py}, "to": {"x": px + 60.0, "y": py + 40.0}, "steps": 4}));
|
||||
d.frames(3);
|
||||
let after = color(&mut d);
|
||||
if after != before {
|
||||
hit = Some((before, after));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (before, after) = hit.expect("no drag point hit the color picker");
|
||||
assert_ne!(after, before, "the drag changed the shadow color");
|
||||
let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
|
||||
assert_eq!(undo1, undo0 + 1, "a color drag is one undo step");
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert_eq!(color(&mut d), before, "one undo restores the original color");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ struct FakeOut {
|
|||
played: Arc<AtomicU64>,
|
||||
per_poll: u64,
|
||||
started: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioOut for FakeOut {
|
||||
|
|
@ -33,6 +34,9 @@ impl AudioOut for FakeOut {
|
|||
fn played_frames(&self) -> Option<u64> {
|
||||
self.started.then(|| self.played.fetch_add(self.per_poll, Ordering::SeqCst) + self.per_poll)
|
||||
}
|
||||
fn note(&self) -> Option<String> {
|
||||
self.note.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Play for `steps` × 0.25 s of simulated time with the given output; the playhead in seconds.
|
||||
|
|
@ -41,7 +45,7 @@ fn play_with(per_poll: u64, steps: usize) -> (f64, bool) {
|
|||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.set_playhead(Tick::ZERO);
|
||||
let mut app = FilmcraftApp::new(s);
|
||||
app.audio = Some(Box::new(FakeOut { played: Arc::new(AtomicU64::new(0)), per_poll, started: false }));
|
||||
app.audio = Some(Box::new(FakeOut { played: Arc::new(AtomicU64::new(0)), per_poll, started: false, note: None }));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_step_dt(0.25).build_eframe(move |_cc| app);
|
||||
h.step();
|
||||
h.state_mut().play(1.0);
|
||||
|
|
@ -68,3 +72,23 @@ fn a_playing_audio_device_drives_the_clock() {
|
|||
assert!(t > 0.2, "{t:.2} s");
|
||||
assert!(audio_clock);
|
||||
}
|
||||
|
||||
/// A fallback note from the audio device (broken ALSA `default`, #23/#106) reaches the status
|
||||
/// bar on the first frame, and survives while playback runs.
|
||||
#[test]
|
||||
fn a_fallback_note_from_the_audio_device_reaches_the_status_bar() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.set_playhead(Tick::ZERO);
|
||||
let note = "Audio hardware: the default output device cannot be opened; using 'sofhdadsp'".to_string();
|
||||
let mut app = FilmcraftApp::new(s);
|
||||
app.audio = Some(Box::new(FakeOut { played: Arc::new(AtomicU64::new(0)), per_poll: 4_800, started: false, note: Some(note.clone()) }));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_step_dt(0.25).build_eframe(move |_cc| app);
|
||||
h.step();
|
||||
assert_eq!(h.state_mut().ui.status, note, "the note surfaces on the first frame (apply_prefs)");
|
||||
h.state_mut().play(1.0);
|
||||
for _ in 0..16 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.state_mut().ui.status, note, "the note survives while playback runs");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
//! Files dropped from the OS onto the Timeline. With Preferences ▸ Timeline ▸ "Place files dropped
|
||||
//! onto the Timeline directly on the Timeline" (`drop_import_to_timeline`) they are also placed at
|
||||
//! the pointer; with it off they are only imported to the bin.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use serde_json::json;
|
||||
|
||||
fn step(h: &mut Harness<'static, FilmcraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
let mut raw = std::mem::take(h.input_mut());
|
||||
eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw);
|
||||
*h.input_mut() = raw;
|
||||
ctx.request_repaint();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, FilmcraftApp> {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name":"Drop test","width":1280,"height":720,"fps":24})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(s));
|
||||
for _ in 0..4 {
|
||||
step(&mut h);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn pointer_to(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2) {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(pos));
|
||||
step(h);
|
||||
}
|
||||
|
||||
fn control_rect(h: &Harness<'static, FilmcraftApp>, id: &str) -> egui::Rect {
|
||||
let [x, y, w, height] = h.state().auto.elements.iter().find(|e| e.id == id).or_else(|| h.state().auto.find(id)).unwrap().rect;
|
||||
egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, height))
|
||||
}
|
||||
|
||||
/// A point 60 px into the Timeline's clip area on the named track row.
|
||||
fn point_on_row(h: &Harness<'static, FilmcraftApp>, row_control: &str) -> egui::Pos2 {
|
||||
let row = control_rect(h, row_control);
|
||||
let panel = control_rect(h, "panel.Timeline");
|
||||
egui::pos2(panel.min.x + h.state().ui.timeline.header_w + 60.0, row.center().y)
|
||||
}
|
||||
|
||||
/// A file the OS dropped on the window, as the native integration hands it to egui.
|
||||
#[derive(Debug)]
|
||||
struct OsFile(PathBuf);
|
||||
|
||||
impl egui::DroppedFile for OsFile {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
std::fs::read(&self.0).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `path` with the pointer over `row_control`, as the OS does: the pointer is hovering first,
|
||||
/// then one frame carries the dropped file. The file is cleared afterwards, as egui only reports it once.
|
||||
fn drop_on_row(h: &mut Harness<'static, FilmcraftApp>, row_control: &str, path: &Path) {
|
||||
let at = point_on_row(h, row_control);
|
||||
pointer_to(h, at);
|
||||
h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf())));
|
||||
step(h);
|
||||
h.input_mut().dropped_files.clear();
|
||||
step(h);
|
||||
}
|
||||
|
||||
/// (video clips, audio clips) on the active sequence.
|
||||
fn clip_counts(h: &Harness<'static, FilmcraftApp>) -> (usize, usize) {
|
||||
let q = h.state().session.active_sequence().unwrap();
|
||||
(q.video_tracks.iter().map(|t| t.items.len()).sum(), q.audio_tracks.iter().map(|t| t.items.len()).sum())
|
||||
}
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/ui-egui");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
/// A small generated PNG still.
|
||||
fn still(name: &str) -> PathBuf {
|
||||
let p = fixture(name);
|
||||
image::RgbaImage::from_pixel(64, 36, image::Rgba([40, 140, 220, 255])).save(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// A 0.5 s mono 48 kHz 16-bit WAV tone (audio only).
|
||||
fn tone(name: &str) -> PathBuf {
|
||||
let p = fixture(name);
|
||||
let rate = 48_000u32;
|
||||
let n = rate / 2;
|
||||
let mut data = Vec::with_capacity(n as usize * 2);
|
||||
for i in 0..n {
|
||||
let s = (f32::sin(2.0 * std::f32::consts::PI * 440.0 * i as f32 / rate as f32) * 0.5 * i16::MAX as f32) as i16;
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
let mut wav = Vec::with_capacity(44 + data.len());
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
wav.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||
wav.extend_from_slice(&rate.to_le_bytes());
|
||||
wav.extend_from_slice(&(rate * 2).to_le_bytes());
|
||||
wav.extend_from_slice(&2u16.to_le_bytes());
|
||||
wav.extend_from_slice(&16u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
wav.extend_from_slice(&data);
|
||||
std::fs::write(&p, wav).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_dropped_on_the_video_row_is_placed_at_the_pointer() {
|
||||
let mut h = harness();
|
||||
let items_before = h.state().session.project.items.len();
|
||||
drop_on_row(&mut h, "timeline.track.V1.target", &still("drop_still_video_row.png"));
|
||||
assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin");
|
||||
assert_eq!(clip_counts(&h), (1, 0), "status: {}", h.state().ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_dropped_on_an_audio_row_goes_to_the_video_track_without_error() {
|
||||
// a video-only item over an audio row: the picture takes the matching video track, no error
|
||||
let mut h = harness();
|
||||
drop_on_row(&mut h, "timeline.track.A1.target", &still("drop_still_audio_row.png"));
|
||||
assert_eq!(clip_counts(&h), (1, 0), "status: {}", h.state().ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_dropped_on_a_video_row_goes_to_the_audio_track_without_error() {
|
||||
// an audio-only item over a video row: the sound takes the matching audio track, no error
|
||||
let mut h = harness();
|
||||
drop_on_row(&mut h, "timeline.track.V1.target", &tone("drop_tone_video_row.wav"));
|
||||
assert_eq!(clip_counts(&h), (0, 1), "status: {}", h.state().ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_drop_import_off_the_file_is_only_imported() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.prefs.timeline.drop_import_to_timeline = false;
|
||||
let items_before = h.state().session.project.items.len();
|
||||
drop_on_row(&mut h, "timeline.track.V1.target", &still("drop_still_pref_off.png"));
|
||||
assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin");
|
||||
assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status);
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
//! Headless UI tests of applied transitions in the Timeline (#430, #224): click one to select it
|
||||
//! and see it in Effect Controls, Delete it, drag its end or its middle, double-click it for Set
|
||||
//! Transition Duration. The real `FilmcraftApp` under `egui_kittest`, driven over the control
|
||||
//! channel by automation id.
|
||||
//!
|
||||
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render the window offscreen with wgpu and write
|
||||
//! `transition-*.png` there; without it no GPU is needed.
|
||||
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
snapshots: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// The demo's first video transition between two clips.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Crossing {
|
||||
id: u64,
|
||||
start: i64,
|
||||
duration: i64,
|
||||
cut: i64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn demo() -> Self {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
|
||||
// 60 fps steps, so a double-click's two clicks fall within egui's double-click time
|
||||
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000);
|
||||
if snapshots.is_some() {
|
||||
b = b.wgpu();
|
||||
}
|
||||
let harness = b.build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx, snapshots };
|
||||
d.frames(4);
|
||||
d.ok("ui.panel.show", json!({"panel": "EffectControls"}));
|
||||
d.frames(3);
|
||||
d
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn find(&mut self, id: &str) -> Option<(f64, f64, f64, f64, String)> {
|
||||
self.frames(2);
|
||||
let v = self.ok("ui.elements", json!({"prefix": id}));
|
||||
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?.clone();
|
||||
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
Some((r[0], r[1], r[2], r[3], e["label"].as_str().unwrap_or_default().to_string()))
|
||||
}
|
||||
|
||||
/// With `FILMCRAFT_UI_SNAPSHOT_DIR` set, render the whole window offscreen to `<name>.png`.
|
||||
fn snapshot(&mut self, name: &str) {
|
||||
let Some(dir) = self.snapshots.clone() else { return };
|
||||
self.frames(2);
|
||||
match self.harness.render() {
|
||||
Ok(img) => {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
img.save(dir.join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
Err(e) => eprintln!("snapshot {name} skipped: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&mut self, id: &str) -> String {
|
||||
self.find(id).unwrap_or_else(|| panic!("no element {id}")).4
|
||||
}
|
||||
|
||||
fn click(&mut self, id: &str) {
|
||||
self.find(id).unwrap_or_else(|| panic!("no element {id}"));
|
||||
self.ok("ui.click", json!({"id": id}));
|
||||
self.frames(3);
|
||||
}
|
||||
|
||||
fn crossing(&mut self) -> Crossing {
|
||||
let q = self.exec("sequence.inspect", json!({}));
|
||||
for tr in q["video"].as_array().unwrap() {
|
||||
for x in tr["transitions"].as_array().unwrap() {
|
||||
if let (Some(_), Some(to)) = (x["from"].as_u64(), x["to"].as_u64()) {
|
||||
let cut = tr["items"].as_array().unwrap().iter().find(|i| i["clip"] == to).unwrap()["start"].as_i64().unwrap();
|
||||
return Crossing { id: x["id"].as_u64().unwrap(), start: x["start"].as_i64().unwrap(), duration: x["duration"].as_i64().unwrap(), cut };
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("the demo has a transition between two clips");
|
||||
}
|
||||
|
||||
fn transition(&mut self, id: u64) -> Option<Value> {
|
||||
let q = self.exec("sequence.inspect", json!({}));
|
||||
q["video"].as_array().unwrap().iter().flat_map(|t| t["transitions"].as_array().unwrap().clone()).find(|x| x["id"] == id)
|
||||
}
|
||||
|
||||
fn rate(&mut self) -> (i64, i64) {
|
||||
let q = self.exec("sequence.inspect", json!({}));
|
||||
(q["settings"]["frame_rate"]["num"].as_i64().unwrap(), q["settings"]["frame_rate"]["den"].as_i64().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks in one frame of `rate`.
|
||||
fn frame_ticks((num, den): (i64, i64)) -> i64 {
|
||||
254_016_000_000 * den / num
|
||||
}
|
||||
|
||||
/// Clicking a transition selects it (and only it) and opens it in Effect Controls; Delete removes
|
||||
/// it, leaving its clips; undo brings it back.
|
||||
#[test]
|
||||
fn click_selects_a_transition_and_delete_removes_it() {
|
||||
let mut d = Driver::demo();
|
||||
let c = d.crossing();
|
||||
let tid = format!("timeline.transition.{}", c.id);
|
||||
d.click(&tid);
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
assert_eq!(q["transitionSelection"], json!([c.id]), "{q}");
|
||||
assert_eq!(q["selection"], json!([]), "a transition replaces the clip selection");
|
||||
// Effect Controls shows it: duration as timecode, its alignment, Reverse
|
||||
let (num, den) = d.rate();
|
||||
let rate = filmcraft_engine::time::FrameRate { num, den };
|
||||
let frames = c.duration / frame_ticks((num, den));
|
||||
assert_eq!(d.label("effectControls.transition.duration"), filmcraft_engine::time::format_timecode_frames(frames, rate, false));
|
||||
assert_eq!(d.label("effectControls.transition.alignment"), "Center at Cut");
|
||||
assert_eq!(d.label("effectControls.transition.reverse"), "false");
|
||||
d.snapshot("transition-selected");
|
||||
let clips_before = q["video"].as_array().unwrap().iter().map(|t| t["items"].as_array().unwrap().len()).sum::<usize>();
|
||||
// Delete
|
||||
d.ok("ui.key", json!({"key": "Delete"}));
|
||||
d.frames(3);
|
||||
assert!(d.transition(c.id).is_none(), "Delete removes the selected transition");
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
assert_eq!(q["video"].as_array().unwrap().iter().map(|t| t["items"].as_array().unwrap().len()).sum::<usize>(), clips_before, "its clips stay");
|
||||
assert!(d.find("effectControls.transition.duration").is_none(), "Effect Controls lets go of it");
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert!(d.transition(c.id).is_some(), "undo brings it back");
|
||||
// clicking a clip selects the clip instead
|
||||
let clip = q["video"][0]["items"][0]["clip"].as_u64().unwrap();
|
||||
d.exec("timeline.select", json!({"transitions": [c.id]}));
|
||||
d.exec("timeline.select", json!({"clips": [clip]}));
|
||||
assert_eq!(d.exec("sequence.inspect", json!({}))["transitionSelection"], json!([]));
|
||||
}
|
||||
|
||||
/// Dragging a transition's end trims that end only (#224); dragging its middle slides it over the
|
||||
/// cut (Custom Start). Each drag is one undo step.
|
||||
#[test]
|
||||
fn dragging_a_transition_end_or_middle() {
|
||||
let mut d = Driver::demo();
|
||||
let c = d.crossing();
|
||||
let frame = frame_ticks(d.rate());
|
||||
let undo_before = d.exec("sequence.inspect", json!({}));
|
||||
let (x, y, w, h, _) = d.find(&format!("timeline.transition.{}.out", c.id)).expect("the transition's end");
|
||||
let (fx, fy) = (x + w / 2.0, y + h / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": fx, "y": fy}, "to": {"x": fx + 40.0, "y": fy}, "steps": 8}));
|
||||
d.frames(3);
|
||||
let x1 = d.transition(c.id).unwrap();
|
||||
let (s1, d1) = (x1["start"].as_i64().unwrap(), x1["duration"].as_i64().unwrap());
|
||||
assert!(d1 > c.duration, "dragging the end out lengthens it: {} -> {d1}", c.duration);
|
||||
assert_eq!(s1, c.start, "the start stays where it was");
|
||||
assert_eq!((d1 - c.duration) % frame, 0, "by whole frames");
|
||||
assert_eq!(x1["align"], json!("custom"), "no longer centred: Custom Start");
|
||||
// the middle: slide it right of centre
|
||||
let (x, y, w, h, _) = d.find(&format!("timeline.transition.{}", c.id)).unwrap();
|
||||
let (mx, my) = (x + w / 2.0, y + h / 2.0);
|
||||
// more than egui's 6 px click tolerance, or the press is a click
|
||||
d.ok("ui.drag", json!({"from": {"x": mx, "y": my}, "to": {"x": mx + 14.0, "y": my}, "steps": 6}));
|
||||
d.frames(3);
|
||||
let x2 = d.transition(c.id).unwrap();
|
||||
assert_eq!(x2["duration"].as_i64(), Some(d1), "sliding keeps the duration");
|
||||
assert!(x2["start"].as_i64().unwrap() > s1, "and moves it right");
|
||||
assert!(x2["start"].as_i64().unwrap() <= c.cut, "still over the cut");
|
||||
// off-centre (Custom Start), or Start at Cut if the slide reached the cut, where it stops
|
||||
assert!(matches!(x2["align"].as_str(), Some("custom" | "start")), "{}", x2["align"]);
|
||||
// two drags, two undo steps
|
||||
d.exec("edit.undo", json!({}));
|
||||
d.exec("edit.undo", json!({}));
|
||||
let back = d.transition(c.id).unwrap();
|
||||
assert_eq!((back["start"].as_i64(), back["duration"].as_i64()), (Some(c.start), Some(c.duration)), "{undo_before}");
|
||||
}
|
||||
|
||||
/// Double-clicking a transition opens Set Transition Duration, whose OK sets the duration
|
||||
/// (following the alignment); Effect Controls' Alignment menu re-aligns it on the cut.
|
||||
#[test]
|
||||
fn set_transition_duration_and_alignment_menu() {
|
||||
let mut d = Driver::demo();
|
||||
let c = d.crossing();
|
||||
let frame = frame_ticks(d.rate());
|
||||
// double-click it
|
||||
let (x0, y0, w, h, _) = d.find(&format!("timeline.transition.{}", c.id)).unwrap();
|
||||
d.ok("ui.click", json!({"x": x0 + w / 2.0, "y": y0 + h / 2.0, "count": 2}));
|
||||
d.frames(3);
|
||||
let frames = c.duration / frame;
|
||||
assert!(d.find("transitionDuration.value").is_some(), "Set Transition Duration opened");
|
||||
assert_eq!(d.harness.state().ui.transition_duration.frames, frames, "with its duration");
|
||||
assert_eq!(d.exec("sequence.inspect", json!({}))["transitionSelection"], json!([c.id]), "and selected it");
|
||||
d.snapshot("transition-duration-dialog");
|
||||
// Center at Cut: a new duration moves both ends
|
||||
d.harness.state_mut().ui.transition_duration.frames = 10;
|
||||
d.frames(2);
|
||||
d.click("transitionDuration.ok");
|
||||
assert!(d.find("transitionDuration.value").is_none(), "closed");
|
||||
let x = d.transition(c.id).unwrap();
|
||||
assert_eq!((x["start"].as_i64(), x["duration"].as_i64(), x["align"].as_str()), (Some(c.cut - 5 * frame), Some(10 * frame), Some("center")));
|
||||
// the Alignment menu: Start at Cut
|
||||
d.click("effectControls.transition.alignment");
|
||||
d.click("effectControls.transition.alignment.option.start");
|
||||
let x = d.transition(c.id).unwrap();
|
||||
assert_eq!((x["start"].as_i64(), x["duration"].as_i64(), x["align"].as_str()), (Some(c.cut), Some(10 * frame), Some("start")));
|
||||
assert_eq!(d.label("effectControls.transition.alignment"), "Start at Cut");
|
||||
// Reverse
|
||||
d.click("effectControls.transition.reverse");
|
||||
assert_eq!(d.transition(c.id).unwrap()["reverse"], json!(true));
|
||||
}
|
||||
|
||||
/// A transition's own settings are in Effect Controls and change it (Push: Direction, Motion Blur).
|
||||
#[test]
|
||||
fn transition_settings_in_effect_controls() {
|
||||
let mut d = Driver::demo();
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
let push = q["video"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|t| t["transitions"].as_array().unwrap().clone())
|
||||
.find(|x| x["effect"] == "push")
|
||||
.expect("the demo has a Push");
|
||||
let id = push["id"].as_u64().unwrap();
|
||||
d.click(&format!("timeline.transition.{id}"));
|
||||
for p in ["direction", "motion_blur"] {
|
||||
assert!(d.find(&format!("effectControls.transition.param.{p}")).is_some(), "{p} shown");
|
||||
}
|
||||
d.snapshot("transition-push-settings");
|
||||
d.click("effectControls.transition.param.direction");
|
||||
d.click("effectControls.transition.param.direction.option.0");
|
||||
assert_eq!(d.transition(id).unwrap()["params"]["direction"], json!({"Choice": 0}));
|
||||
d.click("effectControls.transition.reset");
|
||||
let def = filmcraft_engine::project::vtransition::find_transition("push", filmcraft_engine::project::EffectKind::VideoTransition).unwrap();
|
||||
let want = serde_json::to_value(&def.instance().params.get("direction").unwrap().value).unwrap();
|
||||
assert_eq!(d.transition(id).unwrap()["params"]["direction"], want, "Reset puts Push's own defaults back");
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ claude mcp add filmcraft -- filmcraft-cli mcp
|
|||
| `render_preview` | both | same as `render_frame` |
|
||||
| `project_inspect` | both | bins and items with ids, types, durations; active sequence |
|
||||
| `sequence_inspect` | both | the active sequence: tracks, clips (`start` / `end` and `sourceIn` / `sourceOut` in ticks, frames, `speed`, `reverse`: the clip plays its `sourceIn` to `sourceOut` stretch backward, `gainDb`), effects, transitions, markers (name and `comment`), playhead, selection |
|
||||
| `media_import` | both | import files by absolute path (`text`, one path per line) |
|
||||
| `media_import` | both | import files by absolute path (`paths`, an array; optional `bin`, `image_sequence`) |
|
||||
| `render_frame` | both | PNG of the program frame at `seconds` (headless renders; bridge screenshots the Program monitor). Read-only: the playhead and selection are left as they were |
|
||||
| `ui_inspect` | bridge | UI state: tool, workspace, panels, zoom, playback, fps |
|
||||
| `ui_elements` | bridge | on-screen interactive elements with id, label and rect (`prefix` filter) |
|
||||
|
|
@ -182,7 +182,8 @@ Notes:
|
|||
## 2b. Command-line interface
|
||||
|
||||
Every command is also one shell call away. Options go anywhere; output is JSON; exit status is 0 on
|
||||
success, 1 when a command fails and 2 on a usage error. `filmcraft-cli help` prints the reference.
|
||||
success, 1 when a command fails and 2 on a usage error. `filmcraft-cli help`, `filmcraft-cli --help`
|
||||
and `filmcraft-cli -h` print the reference and exit successfully.
|
||||
If the reader of stdout closes the pipe early (`filmcraft-cli commands | head`), the CLI drops the rest
|
||||
of its output but still finishes the work, saves included, and the exit status still reports
|
||||
failures; any other stdout write error is reported and makes the status 1.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ cargo xtask web --serve 8765 # the web app on http://12
|
|||
|---|---|
|
||||
| `FILMCRAFT_CONTROL_PORT` | Same as `--control <port>` |
|
||||
| `FILMCRAFT_CPU_COMPOSITE=1` | Disables the GPU compositor |
|
||||
| `FILMCRAFT_DEV_DESKTOP_ENTRY=1` | Linux, opt-in: a run from a source tree installs a user-level desktop entry (and the icons from `assets/app-icon`) so the taskbar shows the FilmCraft icon. Nothing is written without it; packaged runs never write one |
|
||||
| `FILMCRAFT_DATA_DIR` | Same as `--data-dir <dir>`: auto-save, crash recovery, settings and logs |
|
||||
| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) |
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ Timecode and Clip Name effects also draw with the text engine.
|
|||
| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position` (the shape's centre), `size`, `points`, `clip` (add to this graphic), `seconds` (5), `track`, `time` |
|
||||
| `graphics.setTextType` | Text Properties ▸ Text Layer Type | `clip`, `layer`, `type` (`point` / `paragraph`); the text stays where it is |
|
||||
| `graphics.setText` | typing on the monitor | `clip`, `layer`, `text`, `merge` (coalesce one typing session into one undo step) |
|
||||
| `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time` |
|
||||
| `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time`, `merge` + `begin` (a drag is one undo step: send `merge: true` on every change and `begin: true` on the first change of each press) |
|
||||
| `graphics.selectLayer` | layer list / monitor click | `clip`, `layers` |
|
||||
| `graphics.deleteLayer`, `graphics.arrangeLayer` | layer list | `clip`, `layer`, `to` (front/back/forward/backward/index) |
|
||||
| `graphics.align` | Align and Transform | `align` (left/hcenter/right/top/vcenter/bottom), `to` (frame/group/selection), `layers` |
|
||||
|
|
|
|||
Loading…
Reference in New Issue