mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Add shared custom colors and complete marker editing (#1435)
* M3: Add shared custom colors and complete marker editing * #1435: refine native colors and range endpoint flags Use the macOS Colors panel for custom label colors and reuse the established egui picker on other hosts. Preserve draft confirmation, exact sRGB values, native text shortcuts and project ownership. Compact the swatches and center the add control, split range glyphs across their endpoints, localize the new controls, protect newer marker edits from stale dialogs, and cover the behavior with rendered UI and hit-test regressions. Co-authored-by: Codex <noreply@openai.com> * #1435: preserve imported palettes and marker double-click ownership Union saved OTIO palettes across timelines with deduplication, bounded capacity, first-name preservation and explicit overflow warnings. Keep null authors consistent with omitted fields, and consume marker double-clicks before the work-area gesture so opening the editor does not alter project history. Correct the CSV URL column and schema documentation. Add six regressions for palette retention/capacity, author handling, and physical overlapping gestures. Validation: full cargo xtask ci (3666 passed, 45 skipped), 42 rendered UI tests, and strict workspace/all-target Clippy on Rust 1.99.1. Co-authored-by: Codex <noreply@openai.com> * #1435: retain OTIO swatch names and generator labels Ignore orphan name entries when retaining saved palette names so input ordering does not hide valid names after the first 64 entries. Keep names bounded by the retained palette and preserve existing valid names. Include generator preset labels alongside custom label colors in OTIO metadata. Add regressions for orphan-prefixed name maps and three generator kinds with preset/custom colors through actual export/import round trips. Validation: both regressions failed before fixes; all 72 interchange tests and full cargo xtask ci pass (3668 tests, 45 skipped), plus strict workspace/all-target Clippy on Rust 1.99.1. Co-authored-by: Codex <noreply@openai.com> * #1435: preserve distinct OTIO generator item identity Use exported FilmCraft item IDs for generator media identity so identical content can retain distinct source names, labels and custom colors. Repeated references to one source item still share a single imported media entry. Preserve content-based fallback for missing or invalid item IDs. Cover black video, bars and tone, and color mattes with distinct items/shared references, plus legacy and malformed identity metadata. Validation: distinct-generator regression failed before fix; legacy compatibility passed. All 74 interchange tests and final full cargo xtask ci pass (3670 tests, 45 skipped); strict workspace/all-target Clippy also passes on Rust 1.99.1. Co-authored-by: Codex <noreply@openai.com> * #1435: align Edit Marker layout and add safe marker navigation Give the editor a wide aligned form, larger Notes area, side action column, and grouped palette with vertical marker types. Expand the Web Link field with the form and keep actions accessible in small windows. Previous/Next saves valid edits as one Undo step and stays with the original sequence/source owner, including coincident markers. Invalid drafts stay open; unchanged navigation and Done add no history. Keep native/shared color pickers, Author, Notes, URL validation and immediate marker creation. Validation: all 45 rendered sequence/marker UI tests pass, including five new navigation/owner/validation/layout regressions. Native macOS layout, picker Cancel and exact single-step Undo restoration verified. Full standard cargo xtask ci passes 3675 tests (45 skipped), doctests and all remaining gates; strict Rust 1.99.1 workspace/all-target Clippy also passes. Co-authored-by: Codex <noreply@openai.com> * #1435: refine Edit Marker proportions and action alignment Narrow the dialog to 680 logical pixels and keep the core order Name, Time/Duration, Notes, Color and marker type. Place Author after the type-specific options as secondary metadata. Preserve our palette, custom picker and range controls. Use consistently sized, centered rounded action buttons with a primary Done action. Shorten Delete Marker to Delete and remove the navigation helper and unused translations. Keep the existing navigation save/validation/Undo semantics and automation IDs. Validation: all 45 rendered marker/sequence UI tests pass, including small-window action accessibility. Actual macOS layout and no-op Previous/Next/Cancel verified. Full standard cargo xtask ci passes 3675 tests (45 skipped), doctests, formatting, Clippy, translations, layers, assets and 49 WASM crates. Strict Rust 1.99.1 workspace/all-target Clippy passes separately. Co-authored-by: Codex <noreply@openai.com> * #1435: tighten marker layout and validate text editing Use a narrower aligned form with Author below Name, bounded scrolling Notes, left-aligned colors, centered actions and an inline Web Link URL/open icon. Remove the legacy Flash Cue choice while preserving imported marker types. Constrain timecode typing/paste to complete numeric colon-separated fields, validate drop-frame labels and frame bounds, and disable Done for invalid timing. Preserve high-frame-rate timecodes and point-marker semantics when Duration is unchecked. Route native/menu clipboard and text Undo commands to focused fields; explicit engine commands retain project Undo and selection semantics. Validation: standard cargo xtask ci passes 3679 tests (45 standard skips), doctests, fmt, strict workspace/all-target Clippy, i18n, layers, 69 assets and 49 WASM crates. Also passed 48 rendered marker/sequence tests and strict Rust 1.99.1 Clippy. Actual macOS clipboard, bounded Notes, point save and exact Undo were checked. Windows/Linux desktop interaction remains pending. Co-authored-by: Codex <noreply@openai.com> * M0: Wait for frame-worker recovery before asserting Do not treat a nonempty request queue as proof of worker death in the panic-recovery regression. Keep waiting for all injected faults and enough post-fault completions, then drain the queue without adding more seeks. Retain the exact fault-count, successful-recovery and empty-queue assertions and bound the wait with a two-minute deadline. The inherited test failed twice in independent fork CI. It did not reproduce locally: baseline passed 20 normal and 10 delayed-report runs; the repair passed 50 normal and 10 delayed-report runs. No product or marker UI changes. Validation: standard cargo xtask ci passed all 4485 tests (52 standard skips), doctests, formatting, strict workspace/all-target Clippy, i18n, layers, 69 assets and 49 WASM crates. Assisted-by: OpenAI Codex --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
committed by
GitHub
parent
7e17c70466
commit
5ed4952d43
Generated
+44
-1
@@ -2371,6 +2371,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2668,6 +2669,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
@@ -3634,6 +3644,27 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -4715,7 +4746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6933,6 +6964,18 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
|
||||
@@ -66,6 +66,7 @@ filmcraft-testkit = { path = "crates/testkit" }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
url = "2.5.8"
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# FilmCraft Roadmap
|
||||
|
||||
- M3: Markers and clips share the existing sixteen clip presets plus a project-local custom hex palette. A reusable wheel/hex editor defines colors separately from marker editing; timeline markers expose Edit Marker with Name, Author and Notes. Marker/clip colors, palette edits and authors persist with undo and project saves. AVB marker import remains separate.
|
||||
|
||||
- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths.
|
||||
- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ pollster = "1"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSPanel", "NSColorPanel", "NSColor", "NSColorSpace"] }
|
||||
objc2 = "0.6"
|
||||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
@@ -35,6 +35,8 @@ mod file_filters;
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_color;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod window_raise;
|
||||
|
||||
@@ -360,7 +362,8 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
|
||||
}));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
let (rx, update, text_menu) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
app.hooks.pick_color = Some(native_color::picker(text_menu));
|
||||
app.command_inbox = Some(rx);
|
||||
app.hooks.menu_changed = Some(update);
|
||||
app.ui.show_menu_bar = false;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Nonblocking macOS custom-color selection through safe AppKit bindings.
|
||||
//! The panel edits a draft; FilmCraft's Add/Save/Cancel buttons commit or discard it.
|
||||
use filmcraft_ui_egui::widgets::color_picker::{NativeColorPicker, NativeColorSession, NativeColorState};
|
||||
use objc2::{MainThreadMarker, rc::Retained};
|
||||
use objc2_app_kit::{NSColor, NSColorPanel, NSColorSpace};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
struct ColorSession {
|
||||
panel: Retained<NSColorPanel>,
|
||||
active: Rc<Cell<u64>>,
|
||||
id: u64,
|
||||
text_menu: crate::native_menu::NativeTextMenu,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ColorSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ColorSession").field("id", &self.id).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorSession {
|
||||
fn owns_panel(&self) -> bool {
|
||||
self.active.get() == self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ColorSession {
|
||||
fn drop(&mut self) {
|
||||
// NSColorPanel is shared. An older draft must never close a newer draft's panel.
|
||||
if self.owns_panel() {
|
||||
if let Err(error) = (self.text_menu)(false) {
|
||||
log::warn!("Cannot restore FilmCraft Edit menu: {error}");
|
||||
}
|
||||
self.panel.orderOut(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn color(rgb: [u8; 3]) -> Retained<NSColor> {
|
||||
let [r, g, b] = rgb.map(|c| f64::from(c) / 255.0);
|
||||
NSColor::colorWithSRGBRed_green_blue_alpha(r, g, b, 1.0)
|
||||
}
|
||||
|
||||
fn channel(value: f64) -> Result<u8, String> {
|
||||
if !value.is_finite() {
|
||||
return Err("The selected color has an invalid component".into());
|
||||
}
|
||||
Ok((value.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
impl NativeColorSession for ColorSession {
|
||||
fn poll(&self) -> Result<NativeColorState, String> {
|
||||
if !self.owns_panel() {
|
||||
return Ok(NativeColorState { color: None, open: false });
|
||||
}
|
||||
(self.text_menu)(self.panel.isKeyWindow())?;
|
||||
let rgb = self.panel.color().colorUsingColorSpace(&NSColorSpace::sRGBColorSpace()).ok_or("Cannot convert the selected color to sRGB")?;
|
||||
let rgb = [channel(rgb.redComponent())?, channel(rgb.greenComponent())?, channel(rgb.blueComponent())?];
|
||||
Ok(NativeColorState { color: Some(rgb), open: self.panel.isVisible() })
|
||||
}
|
||||
|
||||
fn set_color(&self, rgb: [u8; 3]) -> Result<(), String> {
|
||||
if !self.owns_panel() {
|
||||
return Err("This color picker belongs to an earlier draft".into());
|
||||
}
|
||||
self.panel.setColor(&color(rgb));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn picker(text_menu: crate::native_menu::NativeTextMenu) -> NativeColorPicker {
|
||||
let active = Rc::new(Cell::new(0_u64));
|
||||
Box::new(move |rgb| {
|
||||
let mtm = MainThreadMarker::new().ok_or("The macOS color picker must open on the main thread")?;
|
||||
let id = active.get().checked_add(1).ok_or("Too many color picker requests")?;
|
||||
// Complete fallible setup before changing AppKit's shared panel or its owner.
|
||||
text_menu(true)?;
|
||||
let panel = NSColorPanel::sharedColorPanel(mtm);
|
||||
panel.setShowsAlpha(false);
|
||||
panel.setContinuous(true);
|
||||
panel.setColor(&color(rgb));
|
||||
active.set(id);
|
||||
panel.makeKeyAndOrderFront(None);
|
||||
Ok(Rc::new(ColorSession { panel, active: active.clone(), id, text_menu: text_menu.clone() }))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::channel;
|
||||
|
||||
#[test]
|
||||
fn srgb_channels_are_rounded_bounded_and_finite() {
|
||||
assert_eq!(channel(0.5).unwrap(), 128);
|
||||
assert_eq!(channel(-1.0).unwrap(), 0);
|
||||
assert_eq!(channel(2.0).unwrap(), 255);
|
||||
assert!(channel(f64::NAN).is_err());
|
||||
assert!(channel(f64::INFINITY).is_err());
|
||||
for c in 0..=255_u8 {
|
||||
assert_eq!(channel(f64::from(c) / 255.0).unwrap(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Native macOS menu bar (muda), generated from the command registry — like Premiere, menus live in
|
||||
//! the system menu bar. Items send command ids to the app's command inbox.
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
rc::Rc,
|
||||
sync::mpsc::{Receiver, channel},
|
||||
};
|
||||
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
|
||||
@@ -31,7 +35,11 @@ fn quit_item(app: &FilmcraftApp) -> (&'static str, Option<Accelerator>) {
|
||||
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
|
||||
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
|
||||
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, MenuUpdater) {
|
||||
/// Swap only Edit while a native color window owns keyboard focus. Native predefined
|
||||
/// actions go to AppKit's first responder, rather than FilmCraft's timeline inbox.
|
||||
pub type NativeTextMenu = Rc<dyn Fn(bool) -> Result<(), String>>;
|
||||
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, MenuUpdater, NativeTextMenu) {
|
||||
let items = menu_items(app);
|
||||
// each native item with the menu entry it currently shows, so updates touch only what changed
|
||||
let mut native: Vec<(Item, MenuItem)> = Vec::new();
|
||||
@@ -80,6 +88,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
&quit,
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
let mut edit_menu = None;
|
||||
for top in MENUS {
|
||||
let sub = Submenu::new(app.ui.language.tr(top), true);
|
||||
titles.push((top.into(), sub.clone()));
|
||||
@@ -106,8 +115,42 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
}
|
||||
let _ = parent.append(&mi);
|
||||
}
|
||||
if top == "Edit" {
|
||||
edit_menu = Some(sub.clone());
|
||||
}
|
||||
let _ = bar.append(&sub);
|
||||
}
|
||||
let native_edit = Submenu::new(app.ui.language.tr("Edit"), true);
|
||||
let text_items = [
|
||||
("Undo", PredefinedMenuItem::undo(Some(app.ui.language.tr("Undo")))),
|
||||
("Redo", PredefinedMenuItem::redo(Some(app.ui.language.tr("Redo")))),
|
||||
("Cut", PredefinedMenuItem::cut(Some(app.ui.language.tr("Cut")))),
|
||||
("Copy", PredefinedMenuItem::copy(Some(app.ui.language.tr("Copy")))),
|
||||
("Paste", PredefinedMenuItem::paste(Some(app.ui.language.tr("Paste")))),
|
||||
("Delete", PredefinedMenuItem::delete(Some(app.ui.language.tr("Delete")))),
|
||||
("Select All", PredefinedMenuItem::select_all(Some(app.ui.language.tr("Select All")))),
|
||||
];
|
||||
for (_, item) in &text_items {
|
||||
let _ = native_edit.append(item);
|
||||
}
|
||||
let menu_bar = bar.clone();
|
||||
let color_edit = native_edit.clone();
|
||||
let native_shown = Cell::new(false);
|
||||
let text_menu: NativeTextMenu = Rc::new(move |use_native| {
|
||||
if use_native == native_shown.get() {
|
||||
return Ok(());
|
||||
}
|
||||
let regular = edit_menu.as_ref().ok_or("FilmCraft Edit menu is unavailable")?;
|
||||
let index = MENUS.iter().position(|top| *top == "Edit").ok_or("FilmCraft Edit menu is unavailable")? + 1;
|
||||
let (old, new) = if use_native { (regular, &color_edit) } else { (&color_edit, regular) };
|
||||
menu_bar.insert(new, index).map_err(|e| e.to_string())?;
|
||||
if let Err(error) = menu_bar.remove(old) {
|
||||
let _ = menu_bar.remove(new);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
native_shown.set(use_native);
|
||||
Ok(())
|
||||
});
|
||||
bar.init_for_nsapp();
|
||||
if let Some(help) = bar.items().last().and_then(|i| i.as_submenu().cloned()) {
|
||||
help.set_as_help_menu_for_nsapp();
|
||||
@@ -166,6 +209,10 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
submenu.set_text(language.tr(title));
|
||||
}
|
||||
}
|
||||
native_edit.set_text(language.tr("Edit"));
|
||||
for (label, item) in &text_items {
|
||||
item.set_text(language.tr(label));
|
||||
}
|
||||
for (label, item) in &predefined {
|
||||
item.set_text(language.tr(label));
|
||||
}
|
||||
@@ -185,7 +232,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
|
||||
*shown = it.clone();
|
||||
}
|
||||
});
|
||||
(rx, update)
|
||||
(rx, update, text_menu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.whatever-industries]
|
||||
display_name = "Benji Stinson"
|
||||
sync_github_name = false
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
|
||||
@@ -89,6 +89,7 @@ mod tests {
|
||||
motion.param_mut("position").unwrap().value = ParamValue::Vec2(filmcraft_geom::Vec2::new(pos.0, pos.1));
|
||||
motion.param_mut("scale").unwrap().value = ParamValue::Float(scale);
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
|
||||
@@ -37,6 +37,7 @@ impl Fx {
|
||||
fn item(&mut self, start: i64, dur: i64, src_in: i64) -> TrackItem {
|
||||
self.next += 1;
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(self.next),
|
||||
item: ItemId(1),
|
||||
name: format!("c{}", self.next),
|
||||
|
||||
@@ -173,6 +173,7 @@ mod tests {
|
||||
|
||||
fn item(id: u64, start: i64, dur: i64, src: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: "c".into(),
|
||||
|
||||
@@ -34,6 +34,7 @@ fn seq() -> Sequence {
|
||||
|
||||
fn clip(id: u64, start: f64, dur: f64, src_in: f64) -> TrackItem {
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(id),
|
||||
item: MEDIA,
|
||||
name: "interview".into(),
|
||||
|
||||
@@ -215,6 +215,7 @@ mod tests {
|
||||
|
||||
fn clip(id: u64, start: i64, dur: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
|
||||
@@ -32,6 +32,9 @@ pub(super) fn breakout_to_mono(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mm.markers.clear();
|
||||
mm.proxy = None;
|
||||
let id = pr.add_item(&format!("{} {suffix}", it.name), it.label, ItemKind::Media(Box::new(mm)), bin);
|
||||
if let Some(copy) = pr.item_mut(id) {
|
||||
copy.custom_label_color = it.custom_label_color;
|
||||
}
|
||||
made.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl SourceView {
|
||||
json!({
|
||||
"item": self.item.0, "media": self.media.0, "start": self.start.0, "end": self.end.0, "fps": self.rate.as_f64(),
|
||||
"playhead": playhead.0, "markIn": self.mark_in.map(|t| t.0), "markOut": self.mark_out.map(|t| t.0),
|
||||
"markers": self.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "name": m.name, "duration": m.duration.0})).collect::<Vec<_>>(),
|
||||
"markers": self.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "name": m.name, "duration": m.duration.0, "color":m.color_value(), "author":m.author, "comment":m.comment, "url":m.url, "kind":m.kind})).collect::<Vec<_>>(),
|
||||
"subclip": self.subclip.map(|(r, restrict)| json!({"start": r.start.0, "end": r.end().0, "restrictTrims": restrict})),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(super) fn apply_label(s: &mut Session, p: &Value, l: Label) -> Result<Value>
|
||||
for i in &items {
|
||||
if let Some(it) = pr.item_mut(*i) {
|
||||
it.label = l;
|
||||
it.custom_label_color = None;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +33,7 @@ pub(super) fn apply_label(s: &mut Session, p: &Value, l: Label) -> Result<Value>
|
||||
for c in &clips {
|
||||
if let Some((_, it)) = q.find_item_mut(*c) {
|
||||
it.label = l;
|
||||
it.custom_label_color = None;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -42,17 +44,19 @@ pub(super) fn apply_label(s: &mut Session, p: &Value, l: Label) -> Result<Value>
|
||||
}
|
||||
|
||||
pub(super) fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let key = |label: Label, custom: Option<filmcraft_project::HexColor>| (if custom.is_some() { "" } else { label.name() }, custom);
|
||||
if !s.state.selection.is_empty() {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let labels: BTreeSet<&str> = s.state.selection.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.label.name())).collect();
|
||||
let sel: Vec<ClipId> = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| labels.contains(i.label.name())).map(|i| i.id).collect();
|
||||
let labels: BTreeSet<_> = s.state.selection.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| key(i.label, i.custom_label_color))).collect();
|
||||
let sel: Vec<ClipId> =
|
||||
q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| labels.contains(&key(i.label, i.custom_label_color))).map(|i| i.id).collect();
|
||||
s.state.selection = sel;
|
||||
return Ok(json!({"clips": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}));
|
||||
}
|
||||
let labels: BTreeSet<&str> = s.state.project_selection.iter().filter_map(|i| s.project.item(*i).map(|it| it.label.name())).collect();
|
||||
let labels: BTreeSet<_> = s.state.project_selection.iter().filter_map(|i| s.project.item(*i).map(|it| key(it.label, it.custom_label_color))).collect();
|
||||
let mut all = Vec::new();
|
||||
s.project.root.all_items(&mut all);
|
||||
let sel: Vec<ItemId> = all.into_iter().filter(|i| s.project.item(*i).is_some_and(|it| labels.contains(it.label.name()))).collect();
|
||||
let sel: Vec<ItemId> = all.into_iter().filter(|i| s.project.item(*i).is_some_and(|it| labels.contains(&key(it.label, it.custom_label_color)))).collect();
|
||||
s.state.project_selection = sel;
|
||||
Ok(json!({"items": s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ pub(super) fn close_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into()));
|
||||
}
|
||||
s.project = std::sync::Arc::new(Project::new("Untitled"));
|
||||
s.project_instance = std::sync::Arc::new(());
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
|
||||
@@ -73,6 +73,7 @@ pub(super) fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<V
|
||||
it.item = new_item;
|
||||
it.name = pi.name.clone();
|
||||
it.label = pi.label;
|
||||
it.custom_label_color = pi.custom_label_color;
|
||||
it.source_in = src_in;
|
||||
it.frame_hold = None;
|
||||
it.multicam = None;
|
||||
|
||||
@@ -44,6 +44,12 @@ pub fn subclip_defaults(s: &Session, p: &Value) -> Option<Value> {
|
||||
|
||||
pub(super) fn make_subclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (parent, range, base, label) = subclip_plan(s, p)?;
|
||||
let custom = if let Some(clip) = clip_p_or_selection(s, p) {
|
||||
s.active_sequence().and_then(|q| q.find_item(clip)).and_then(|(_, it)| it.custom_label_color)
|
||||
} else {
|
||||
item_p(p, "item").or_else(|| subclip_source(s)).and_then(|id| s.project.item(id)).and_then(|it| it.custom_label_color)
|
||||
};
|
||||
|
||||
let rate = item_rate(&s.project, parent);
|
||||
let a = p
|
||||
.get("start")
|
||||
@@ -61,6 +67,9 @@ pub(super) fn make_subclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bin = s.project.root.parent_of(parent).filter(|b| *b != s.project.root.id);
|
||||
let id = s.edit("Make Subclip", |pr, st| {
|
||||
let id = pr.add_item(&name, label, ItemKind::Subclip { parent, range: TimeRange::from_bounds(a, b), restrict_trims: restrict }, bin);
|
||||
if let Some(item) = pr.item_mut(id) {
|
||||
item.custom_label_color = custom;
|
||||
}
|
||||
st.project_selection = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
|
||||
@@ -880,6 +880,9 @@ fn subclip_source_monitor_trims_and_markers() {
|
||||
if let Some(ItemKind::Media(m)) = p.item_mut(ocean).map(|i| &mut i.kind) {
|
||||
for (k, n) in [10, 30, 60, 90].into_iter().enumerate() {
|
||||
m.markers.push(filmcraft_project::Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: filmcraft_project::MarkerId(9000 + k as u64),
|
||||
start: f(n),
|
||||
duration: Tick::ZERO,
|
||||
|
||||
+134
-102
@@ -190,44 +190,6 @@ pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option<
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
/// The longest marker `markers.edit` takes, in frames (over 46 hours at 60 fps).
|
||||
const MAX_MARKER_FRAMES: i64 = 10_000_000;
|
||||
|
||||
/// `markers.edit`'s `durationFrames`: absent or null is `None`; anything but a whole number from 0
|
||||
/// to [`MAX_MARKER_FRAMES`] is an error.
|
||||
fn marker_frames_p(p: &Value) -> Result<Option<i64>> {
|
||||
match p.get("durationFrames").filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => match v.as_i64() {
|
||||
Some(f @ 0..=MAX_MARKER_FRAMES) => Ok(Some(f)),
|
||||
_ => Err(bad("markers.edit", "`durationFrames` must be a whole number from 0 to 10000000")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `markers.edit`'s `kind`: absent or null is `None`; anything but one of the kind names is an
|
||||
/// error.
|
||||
pub(crate) fn marker_kind_p(p: &Value) -> Result<Option<MarkerKind>> {
|
||||
match p.get("kind").filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_str().and_then(marker_kind).map(Some).ok_or_else(|| bad("markers.edit", "`kind` must be comment, chapter, segmentation, webLink or flashCue"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker type by its `markers.edit` name.
|
||||
fn marker_kind(name: &str) -> Option<MarkerKind> {
|
||||
Some(match name {
|
||||
"comment" => MarkerKind::Comment,
|
||||
"chapter" => MarkerKind::Chapter,
|
||||
"segmentation" => MarkerKind::Segmentation,
|
||||
"webLink" => MarkerKind::WebLink,
|
||||
"flashCue" => MarkerKind::FlashCue,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
@@ -236,17 +198,33 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
pub(crate) fn reports_only(id: &str, p: &Value) -> bool {
|
||||
id == "sequence.settings" && (p.is_null() || p.as_object().is_some_and(|m| m.is_empty()))
|
||||
}
|
||||
/// A marker's `color` param: absent (or null) is `None`; anything but a label name is an error
|
||||
/// that lists the accepted names, instead of silently becoming Green (#857).
|
||||
pub(crate) fn marker_color_p(p: &Value, cmd: &str) -> Result<Option<Label>> {
|
||||
/// A marker accepts either an unchanged preset label or an exact six-digit sRGB hex color.
|
||||
pub(crate) fn marker_color_p(p: &Value, cmd: &str) -> Result<Option<(Label, Option<filmcraft_project::HexColor>)>> {
|
||||
let v = match p.get("color") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
v.as_str().and_then(Label::from_name).map(Some).ok_or_else(|| {
|
||||
let names: Vec<&str> = Label::ALL.iter().map(|l| l.name()).collect();
|
||||
bad(cmd, format!("unknown marker color {v}; use one of: {}", names.join(", ")))
|
||||
})
|
||||
if let Some(text) = v.as_str() {
|
||||
if let Some(label) = Label::from_color_name(text) {
|
||||
return Ok(Some((label, None)));
|
||||
}
|
||||
if let Some(hex) = filmcraft_project::HexColor::parse(text) {
|
||||
return Ok(Some((Label::Green, Some(hex))));
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = Label::ALL.iter().map(|l| l.name()).chain(Label::COLOR_ALIASES.iter().map(|(name, _)| *name)).collect();
|
||||
Err(bad(cmd, format!("unknown marker color {v}; use #RRGGBB or one of: {}", names.join(", "))))
|
||||
}
|
||||
|
||||
/// Missing/null authors preserve the existing value; an empty string explicitly clears it.
|
||||
/// Reject non-text values, control characters and more than 1024 UTF-8 bytes before editing.
|
||||
pub(crate) fn marker_author_p(p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
let Some(value) = p.get("author").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let text = value.as_str().ok_or_else(|| bad(cmd, "author must be text"))?;
|
||||
if text.len() > 1024 || text.chars().any(char::is_control) {
|
||||
return Err(bad(cmd, "author must be at most 1024 UTF-8 bytes without control characters"));
|
||||
}
|
||||
Ok(Some(text.to_string()))
|
||||
}
|
||||
pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
||||
p.get(k).and_then(Value::as_str)
|
||||
@@ -1489,6 +1467,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| {
|
||||
let name = str_p(p, "name").unwrap_or("Untitled").to_string();
|
||||
s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name));
|
||||
s.project_instance = std::sync::Arc::new(());
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
@@ -1505,6 +1484,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| {
|
||||
let (p, seq) = crate::demo::demo_project(&s.media);
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.project_instance = std::sync::Arc::new(());
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
@@ -2161,6 +2141,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
if let Some(it) = p.item(id).cloned() {
|
||||
let bin = p.root.parent_of(id);
|
||||
let nid = p.add_item(&format!("{} Copy", it.name), it.label, it.kind.clone(), bin);
|
||||
if let Some(copy) = p.item_mut(nid) {
|
||||
copy.custom_label_color = it.custom_label_color;
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
}
|
||||
@@ -2169,28 +2152,83 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}),
|
||||
cmd!("edit.label", "Label", [], None, r#"{"label":"Violet|Iris|…"}"#, always, |s, p| {
|
||||
let l = str_p(p, "label").and_then(Label::from_name).ok_or_else(|| bad("edit.label", "unknown label"))?;
|
||||
let clips = s.state.selection.clone();
|
||||
let items = s.state.project_selection.clone();
|
||||
let seq = s.state.active_sequence;
|
||||
s.edit("Label", |pr, _| {
|
||||
for i in &items {
|
||||
if let Some(it) = pr.item_mut(*i) {
|
||||
it.label = l;
|
||||
}
|
||||
cmd!(
|
||||
"edit.label",
|
||||
"Label",
|
||||
[],
|
||||
None,
|
||||
r##"{"label":"preset|#RRGGBB","clips":[id]?,"items":[id]?,"sequence":id?,"saveColor":bool?}"##,
|
||||
always,
|
||||
|s, p| {
|
||||
let text = str_p(p, "label").ok_or_else(|| bad("edit.label", "need a preset name or hex color"))?;
|
||||
let preset = Label::from_color_name(text);
|
||||
let custom = filmcraft_project::HexColor::parse(text);
|
||||
if preset.is_none() && custom.is_none() {
|
||||
return Err(bad("edit.label", "unknown label; use a preset or #RRGGBB"));
|
||||
}
|
||||
if let Some(q) = seq.and_then(|q| pr.sequence_mut(q)) {
|
||||
for c in &clips {
|
||||
if let Some((_, it)) = q.find_item_mut(*c) {
|
||||
it.label = l;
|
||||
let explicit = p.get("clips").is_some() || p.get("items").is_some();
|
||||
let clips: Vec<ClipId> = match p.get("clips") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|_| bad("edit.label", "clips must be an array of ids"))?,
|
||||
None => {
|
||||
if explicit {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.state.selection.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
let items: Vec<ItemId> = match p.get("items") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|_| bad("edit.label", "items must be an array of ids"))?,
|
||||
None => {
|
||||
if explicit {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.state.project_selection.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
let seq = match p.get("sequence") {
|
||||
Some(v) => Some(ItemId(v.as_u64().ok_or_else(|| bad("edit.label", "sequence must be an id"))?)),
|
||||
None => s.state.active_sequence,
|
||||
};
|
||||
let save = match p.get("saveColor") {
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("edit.label", "saveColor must be boolean"))?,
|
||||
None => false,
|
||||
};
|
||||
if save && custom.is_none() {
|
||||
return Err(bad("edit.label", "saveColor requires a hex color"));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
s.edit("Label", |pr, _| {
|
||||
if let Some(color) = custom.filter(|_| save)
|
||||
&& !pr.custom_colors.contains(&color)
|
||||
{
|
||||
if pr.custom_colors.len() >= 64 {
|
||||
return Err(bad("edit.label", "the project already has 64 custom colors"));
|
||||
}
|
||||
pr.custom_colors.push(color);
|
||||
}
|
||||
for i in &items {
|
||||
let it = pr.item_mut(*i).ok_or_else(|| bad("edit.label", "project item no longer exists"))?;
|
||||
if let Some(preset) = preset {
|
||||
it.label = preset;
|
||||
}
|
||||
it.custom_label_color = custom;
|
||||
}
|
||||
if !clips.is_empty() {
|
||||
let q = seq.and_then(|id| pr.sequence_mut(id)).ok_or(EngineError::NoSequence)?;
|
||||
for c in &clips {
|
||||
let (_, it) = q.find_item_mut(*c).ok_or_else(|| bad("edit.label", "clip no longer exists"))?;
|
||||
if let Some(preset) = preset {
|
||||
it.label = preset;
|
||||
}
|
||||
it.custom_label_color = custom;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
// ================= Clip =================
|
||||
cmd!("clip.rename", "Rename…", ["Clip"], None, r#"{"clip":id?,"clips":[id]?,"item":id?,"name":str?}"#, always, |s, p| {
|
||||
// a blank name is refused, as the Project panel's inline rename keeps the old one
|
||||
@@ -2896,12 +2934,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("colors.addCustom", "Add Custom Color", [], None, r#"{"color":hex,"name":str?}"#, always, crate::palette::add),
|
||||
cmd!(
|
||||
"markers.add",
|
||||
"Add Marker",
|
||||
["Markers"],
|
||||
Some("M"),
|
||||
r#"{"target":"program|source","time":ticks?,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
|
||||
r#"{"target":"program|source","time":ticks?,"name":str?,"comment":str?,"color":label_or_hex?,"author":str?,"durationFrames":i64?}"#,
|
||||
always,
|
||||
|s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.add", p)? {
|
||||
@@ -2911,11 +2950,23 @@ fn build() -> Vec<CommandSpec> {
|
||||
let t = sequence_time_p(s, p, "", "markers.add")?.unwrap_or(ph);
|
||||
let name = str_p(p, "name").unwrap_or("").to_string();
|
||||
let comment = str_p(p, "comment").unwrap_or("").to_string();
|
||||
let color = marker_color_p(p, "markers.add")?.unwrap_or(Label::Green);
|
||||
let (color, custom_color) = marker_color_p(p, "markers.add")?.unwrap_or((Label::Green, None));
|
||||
let author = marker_author_p(p, "markers.add")?.unwrap_or_default();
|
||||
let dur = p.get("durationFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)).unwrap_or_default();
|
||||
let id = s.edit_sequence("Add Marker", |q, ctx, _| {
|
||||
let id = MarkerId(ctx.alloc());
|
||||
q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind: MarkerKind::Comment, color });
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author,
|
||||
custom_color,
|
||||
id,
|
||||
start: t,
|
||||
duration: dur,
|
||||
name,
|
||||
comment,
|
||||
kind: MarkerKind::Comment,
|
||||
color,
|
||||
});
|
||||
q.markers.sort_by_key(|m| m.start);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -2979,41 +3030,21 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Edit Marker…",
|
||||
[],
|
||||
None,
|
||||
r#"{"target":"program|source","marker":id,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?,"kind":"comment|chapter|segmentation|webLink|flashCue"?}"#,
|
||||
r#"{"target":"program|source","sequence":id?,"item":id?,"marker":id|"markers":[id],"time":ticks?,"frame":i64?,"durationFrames":i64?,"name":str?,"comment":str?,"author":str?,"url":str?,"color":label_or_hex?,"kind":"comment|chapter|segmentation|webLink|flashCue"?}"#,
|
||||
always,
|
||||
|s, p| {
|
||||
// checked before routing, so Source markers are held to the same limits
|
||||
let frames = marker_frames_p(p)?;
|
||||
let kind = marker_kind_p(p)?;
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.edit", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?;
|
||||
let rate = s.sequence_rate();
|
||||
let color = marker_color_p(p, "markers.edit")?;
|
||||
let p = p.clone();
|
||||
s.edit_sequence("Edit Marker", |q, _, _| {
|
||||
let m = q.markers.iter_mut().find(|m| m.id == id).ok_or_else(|| bad("markers.edit", "no such marker"))?;
|
||||
if let Some(n) = str_p(&p, "name") {
|
||||
m.name = n.into();
|
||||
}
|
||||
if let Some(c) = str_p(&p, "comment") {
|
||||
m.comment = c.into();
|
||||
}
|
||||
if let Some(c) = color {
|
||||
m.color = c;
|
||||
}
|
||||
if let Some(f) = frames {
|
||||
m.duration = rate.tick_of(f);
|
||||
}
|
||||
if let Some(k) = kind {
|
||||
m.kind = k;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|s, p| crate::marker_edit::run(s, p, false)
|
||||
),
|
||||
cmd!(
|
||||
"markers.delete",
|
||||
"Delete Markers",
|
||||
[],
|
||||
None,
|
||||
r#"{"target":"program|source","sequence":id?,"item":id?,"marker":id|"markers":[id]}"#,
|
||||
always,
|
||||
|s, p| crate::marker_edit::run(s, p, true)
|
||||
),
|
||||
cmd!("colors.updateCustom", "Edit Custom Color", [], None, r#"{"color":hex,"replacement":hex?,"name":str?}"#, always, crate::palette::update),
|
||||
cmd!("colors.removeCustom", "Remove Custom Color", [], None, r#"{"color":hex}"#, always, crate::palette::remove),
|
||||
// ================= Playhead / navigation =================
|
||||
cmd!("playhead.set", "Set Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64|"timecode":str}"#, has_seq, |s, p| {
|
||||
let t = time_p(s, p, "").ok_or_else(|| bad("playhead.set", "need time/frame/seconds/timecode"))?;
|
||||
@@ -5025,7 +5056,7 @@ pub fn inspect_project(s: &Session) -> Value {
|
||||
"children": b.children.iter().map(|c| match c {
|
||||
filmcraft_project::BinEntry::Item(i) => {
|
||||
let it = p.item(*i);
|
||||
json!({"item": i.0, "name": it.map(|x| x.name.clone()), "type": it.map(|x| x.type_label()), "duration": it.map(|x| x.duration().0), "label": it.map(|x| x.label.name())})
|
||||
json!({"item": i.0, "name": it.map(|x| x.name.clone()), "type": it.map(|x| x.type_label()), "duration": it.map(|x| x.duration().0), "label": it.map(|x| x.label.name()), "customLabelColor": it.and_then(|x| x.custom_label_color)})
|
||||
}
|
||||
filmcraft_project::BinEntry::Bin(b) => bin(b, p),
|
||||
}).collect::<Vec<_>>()
|
||||
@@ -5052,7 +5083,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
||||
"clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0,
|
||||
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse,
|
||||
"end": i.end().0, "endFrame": rate.frame_at(i.end()), "sourceOut": i.source_out().0, "gainDb": i.gain_db,
|
||||
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
|
||||
"enabled": i.enabled, "link": i.link, "label": i.label.name(), "customLabelColor": i.custom_label_color,
|
||||
"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, "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<_>>(),
|
||||
@@ -5070,7 +5101,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
||||
"in": q.mark_in.map(|t| t.0),
|
||||
"out": q.mark_out.map(|t| t.0),
|
||||
"split": q.split,
|
||||
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "comment": m.comment, "color": m.color.name()})).collect::<Vec<_>>(),
|
||||
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "comment": m.comment, "color": m.color_value(), "author": m.author, "url": m.url})).collect::<Vec<_>>(),
|
||||
"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<_>>(),
|
||||
@@ -5148,6 +5179,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
|
||||
let first = proj.sequences().next().map(|i| i.id);
|
||||
s.media.add_stored(&proj, path.as_deref());
|
||||
s.project = std::sync::Arc::new(proj);
|
||||
s.project_instance = std::sync::Arc::new(());
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
|
||||
@@ -208,6 +208,9 @@ fn build_demo(pool: &MediaPool) -> Option<(Project, ItemId)> {
|
||||
.enumerate()
|
||||
{
|
||||
s.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(800_000 + i as u64),
|
||||
start: r.snap(Tick::from_seconds_f64(*sec)),
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod graphics;
|
||||
pub mod interchange;
|
||||
pub mod keyboard;
|
||||
mod keyframe_move;
|
||||
mod marker_edit;
|
||||
mod marker_export;
|
||||
pub mod masks;
|
||||
pub mod media_browser;
|
||||
@@ -35,6 +36,7 @@ pub mod media_watch;
|
||||
pub mod mixer;
|
||||
pub mod multicam;
|
||||
pub mod narration;
|
||||
mod palette;
|
||||
pub mod panels;
|
||||
pub mod paste_media;
|
||||
pub mod perf;
|
||||
@@ -400,6 +402,8 @@ pub struct EditorState {
|
||||
/// Marker colours hidden in the Markers panel (Show All Marker Colors clears this).
|
||||
#[serde(default)]
|
||||
pub hidden_marker_colors: Vec<filmcraft_project::Label>,
|
||||
#[serde(default)]
|
||||
pub hidden_custom_marker_colors: Vec<filmcraft_project::HexColor>,
|
||||
/// Sequence markers copied with the clipboard clips (start relative to the copied range).
|
||||
#[serde(skip)]
|
||||
pub clipboard_markers: Vec<filmcraft_project::Marker>,
|
||||
@@ -436,6 +440,8 @@ pub enum Event {
|
||||
|
||||
pub struct Session {
|
||||
pub project: Arc<Project>,
|
||||
/// Runtime identity of the opened project, stable across edits/undo and renewed on replacement.
|
||||
pub project_instance: Arc<()>,
|
||||
pub history: History,
|
||||
pub revision: u64,
|
||||
/// Bumped whenever the session installs a different project document (`file.newProject`,
|
||||
@@ -558,6 +564,7 @@ impl Session {
|
||||
pub fn new(services: Arc<dyn Services>) -> Self {
|
||||
Self {
|
||||
project: Arc::new(Project::new("Untitled")),
|
||||
project_instance: Arc::new(()),
|
||||
history: History { limit: 200, ..Default::default() },
|
||||
revision: 1,
|
||||
document_epoch: 0,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Explicit marker ownership and atomic single/bulk edits.
|
||||
use crate::{
|
||||
EngineError, Result, Session,
|
||||
commands::{bad, marker_author_p, marker_color_p},
|
||||
};
|
||||
use filmcraft_project::{ItemId, Marker, MarkerId, MarkerKind, Project};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn list(project: &mut Project, owner: ItemId) -> Result<&mut Vec<Marker>> {
|
||||
match &mut project.item_mut(owner).ok_or_else(|| EngineError::Other("Marker owner no longer exists".into()))?.kind {
|
||||
filmcraft_project::ItemKind::Media(m) => Ok(&mut m.markers),
|
||||
filmcraft_project::ItemKind::Sequence(q) => Ok(&mut std::sync::Arc::make_mut(q).markers),
|
||||
_ => Err(EngineError::Other("This item cannot own markers".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn item(p: &Value, key: &str, fallback: Option<ItemId>, id: &str) -> Result<ItemId> {
|
||||
match p.get(key) {
|
||||
Some(v) => v.as_u64().map(ItemId).ok_or_else(|| bad(id, "owner must be an item id")),
|
||||
None => fallback.ok_or_else(|| bad(id, "no marker owner is open")),
|
||||
}
|
||||
}
|
||||
fn frames(p: &Value, key: &str, rate: FrameRate, id: &str) -> Result<Option<Tick>> {
|
||||
p.get(key)
|
||||
.map(|v| {
|
||||
let f = v.as_i64().ok_or_else(|| bad(id, "frame values must be integers"))?;
|
||||
if f < 0 || f > rate.frame_at(Tick::MAX) {
|
||||
return Err(bad(id, "frame value is outside the supported time range"));
|
||||
}
|
||||
Ok(rate.tick_of(f))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn run(s: &mut Session, p: &Value, delete: bool) -> Result<Value> {
|
||||
let id = if delete { "markers.delete" } else { "markers.edit" };
|
||||
let (owner, rate, lower, upper, visible) = match p.get("target") {
|
||||
None | Some(Value::String(_)) if p.get("target").is_none_or(|v| v == "program") => {
|
||||
let owner = item(p, "sequence", s.state.active_sequence, id)?;
|
||||
let q = s.project.sequence(owner).ok_or_else(|| bad(id, "no such sequence"))?;
|
||||
(owner, q.settings.frame_rate, Tick::ZERO, Tick::MAX, None)
|
||||
}
|
||||
Some(Value::String(t)) if t == "source" => {
|
||||
let source = item(p, "item", s.state.source_item, id)?;
|
||||
let v = crate::clip_ops::source_view(s, source).ok_or_else(|| bad(id, "no such source"))?;
|
||||
(v.media, v.rate, v.start, v.end, Some(v.markers.iter().map(|m| m.id).collect::<BTreeSet<_>>()))
|
||||
}
|
||||
_ => return Err(bad(id, "target must be program or source")),
|
||||
};
|
||||
let ids: BTreeSet<MarkerId> = if let Some(v) = p.get("markers") {
|
||||
if p.get("marker").is_some() {
|
||||
return Err(bad(id, "use marker or markers, not both"));
|
||||
}
|
||||
let a = v.as_array().filter(|a| !a.is_empty() && a.len() <= 100_000).ok_or_else(|| bad(id, "markers must contain 1 to 100000 ids"))?;
|
||||
a.iter().map(|v| v.as_u64().map(MarkerId).ok_or_else(|| bad(id, "invalid marker id"))).collect::<Result<_>>()?
|
||||
} else {
|
||||
BTreeSet::from([MarkerId(p.get("marker").and_then(Value::as_u64).ok_or_else(|| bad(id, "need marker id"))?)])
|
||||
};
|
||||
let time = match p.get("time") {
|
||||
Some(v) => {
|
||||
if p.get("frame").is_some() {
|
||||
return Err(bad(id, "use time or frame, not both"));
|
||||
}
|
||||
Some(Tick(v.as_i64().ok_or_else(|| bad(id, "time must be integer ticks"))?))
|
||||
}
|
||||
None => frames(p, "frame", rate, id)?,
|
||||
};
|
||||
// Match the established command contract: null leaves duration unchanged, and
|
||||
// edited durations are bounded independently of a marker's imported original.
|
||||
let duration = match p.get("durationFrames").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(value) => {
|
||||
let frames =
|
||||
value.as_i64().filter(|f| (0..=10_000_000).contains(f)).ok_or_else(|| bad(id, "durationFrames must be a whole number from 0 to 10000000"))?;
|
||||
Some(rate.tick_of(frames))
|
||||
}
|
||||
};
|
||||
if ids.len() > 1 && (time.is_some() || duration.is_some()) {
|
||||
return Err(bad(id, "time edits require a single marker"));
|
||||
}
|
||||
let color = marker_color_p(p, id)?;
|
||||
let author = marker_author_p(p, id)?;
|
||||
let kind = match p.get("kind") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(match value.as_str() {
|
||||
Some("comment") => MarkerKind::Comment,
|
||||
Some("chapter") => MarkerKind::Chapter,
|
||||
Some("segmentation") => MarkerKind::Segmentation,
|
||||
Some("webLink") => MarkerKind::WebLink,
|
||||
Some("flashCue") => MarkerKind::FlashCue,
|
||||
_ => return Err(bad(id, "kind must be comment, chapter, segmentation, webLink or flashCue")),
|
||||
}),
|
||||
};
|
||||
let text = |key| -> Result<Option<String>> {
|
||||
p.get(key)
|
||||
.map(|v| v.as_str().filter(|v| v.len() <= 65536).map(str::to_owned).ok_or_else(|| bad(id, "marker text must be a string of at most 65536 bytes")))
|
||||
.transpose()
|
||||
};
|
||||
let name = text("name")?;
|
||||
let comment = text("comment")?;
|
||||
let url = match p.get("url") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => {
|
||||
let value = value
|
||||
.as_str()
|
||||
.filter(|v| v.len() <= 8192 && !v.chars().any(char::is_control))
|
||||
.ok_or_else(|| bad(id, "url must be text of at most 8192 bytes without control characters"))?
|
||||
.trim();
|
||||
if !value.is_empty() && filmcraft_project::marker_web_url(value).is_none() {
|
||||
return Err(bad(id, "url must be an absolute HTTP or HTTPS address without credentials"));
|
||||
}
|
||||
Some(value.to_string())
|
||||
}
|
||||
};
|
||||
s.edit(if delete { "Delete Markers" } else { "Edit Markers" }, |project, _| {
|
||||
let markers = list(project, owner)?;
|
||||
let present: BTreeSet<_> = markers.iter().map(|m| m.id).collect();
|
||||
if !ids.is_subset(&present) {
|
||||
return Err(bad(id, "no such marker in this owner"));
|
||||
}
|
||||
if visible.as_ref().is_some_and(|v| !ids.is_subset(v)) {
|
||||
return Err(bad(id, "marker is outside the source view"));
|
||||
}
|
||||
for m in markers.iter().filter(|m| ids.contains(&m.id)) {
|
||||
if !delete && (time.is_some() || duration.is_some()) {
|
||||
let start = time.unwrap_or(m.start);
|
||||
let dur = duration.unwrap_or(m.duration);
|
||||
let end = start.0.checked_add(dur.0).ok_or_else(|| bad(id, "marker end overflows"))?;
|
||||
if start < lower || start >= upper || dur < Tick::ZERO || end > upper.0 {
|
||||
return Err(bad(id, "marker range is outside the source or supported time range"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if delete {
|
||||
markers.retain(|m| !ids.contains(&m.id));
|
||||
} else {
|
||||
for m in markers.iter_mut().filter(|m| ids.contains(&m.id)) {
|
||||
if let Some(v) = &name {
|
||||
m.name.clone_from(v);
|
||||
}
|
||||
if let Some(v) = &comment {
|
||||
m.comment.clone_from(v);
|
||||
}
|
||||
if let Some(v) = &url {
|
||||
m.url.clone_from(v);
|
||||
}
|
||||
if let Some(v) = &author {
|
||||
m.author.clone_from(v);
|
||||
}
|
||||
if let Some(kind) = kind {
|
||||
m.kind = kind;
|
||||
}
|
||||
if let Some((preset, custom)) = color {
|
||||
if custom.is_none() {
|
||||
m.color = preset;
|
||||
}
|
||||
m.custom_color = custom;
|
||||
}
|
||||
if let Some(v) = time {
|
||||
m.start = v;
|
||||
}
|
||||
if let Some(v) = duration {
|
||||
m.duration = v;
|
||||
}
|
||||
}
|
||||
markers.sort_by_key(|m| m.start);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"count":ids.len()}))
|
||||
}
|
||||
@@ -33,9 +33,11 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for m in &seq.markers {
|
||||
let row = name
|
||||
.len()
|
||||
.checked_add(m.name.len())
|
||||
.checked_add(m.author.len())
|
||||
.and_then(|n| n.checked_add(m.name.len()))
|
||||
.and_then(|n| n.checked_add(m.comment.len()))
|
||||
.and_then(|n| n.checked_add(label_name(s, m.color).len()))
|
||||
.and_then(|n| n.checked_add(m.url.len()))
|
||||
.and_then(|n| n.checked_add(marker_color_name(s, m).len()))
|
||||
.and_then(|n| n.checked_mul(2))
|
||||
.and_then(|n| n.checked_add(1024));
|
||||
bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?;
|
||||
@@ -43,8 +45,9 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rate = seq.settings.frame_rate;
|
||||
let mut markers: Vec<_> = seq.markers.iter().collect();
|
||||
markers.sort_by_key(|m| (m.start, m.id));
|
||||
let mut csv =
|
||||
String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n");
|
||||
let mut csv = String::from(
|
||||
"Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate,Author,URL\r\n",
|
||||
);
|
||||
for m in markers {
|
||||
if m.start.0 < 0 || m.duration.0 < 0 {
|
||||
return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0)));
|
||||
@@ -60,13 +63,15 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m.name.clone(),
|
||||
m.comment.clone(),
|
||||
format!("{:?}", m.kind),
|
||||
label_name(s, m.color).to_string(),
|
||||
marker_color_name(s, m),
|
||||
tc(m.start)?,
|
||||
tc(Tick(end))?,
|
||||
rate.frame_at(m.duration).to_string(),
|
||||
m.start.0.to_string(),
|
||||
m.duration.0.to_string(),
|
||||
format!("{}/{}", rate.num, rate.den),
|
||||
m.author.clone(),
|
||||
m.url.clone(),
|
||||
];
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -75,7 +80,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
csv.push('"');
|
||||
// Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text,
|
||||
// including formulas preceded by whitespace; numeric/timecode fields stay numeric.
|
||||
if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) {
|
||||
if matches!(i, 0 | 2 | 3 | 5 | 12 | 13) && field.trim_start().starts_with(['=', '+', '-', '@']) {
|
||||
csv.push('\'');
|
||||
}
|
||||
for ch in field.chars() {
|
||||
@@ -92,6 +97,10 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()}))
|
||||
}
|
||||
|
||||
fn marker_color_name(s: &Session, marker: &filmcraft_project::Marker) -> String {
|
||||
marker.custom_color.map_or_else(|| label_name(s, marker.color).to_string(), filmcraft_project::HexColor::hex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -124,6 +133,9 @@ mod tests {
|
||||
}
|
||||
fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker {
|
||||
Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(id),
|
||||
start,
|
||||
duration,
|
||||
@@ -144,6 +156,22 @@ mod tests {
|
||||
String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_preserves_exact_custom_color_and_author() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let mut m = marker(100, Tick::ZERO, Tick::ZERO, "Note");
|
||||
m.custom_color = filmcraft_project::HexColor::parse("#ffffff");
|
||||
m.author = "Editor 日本語".into();
|
||||
m.url = "https://example.com/review?q=1,2&take=\"B\"".into();
|
||||
set_markers(&mut s, vec![m]);
|
||||
s.execute(CMD, json!({"path":"custom.csv"})).unwrap();
|
||||
let csv = written(&files);
|
||||
assert!(csv.contains("Frame Rate,Author,URL\r\n"));
|
||||
assert!(csv.contains("\"Chapter\",\"#FFFFFF\""));
|
||||
assert!(csv.contains("\"Editor 日本語\",\"https://example.com/review?q=1,2&take=\"\"B\"\"\"\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() {
|
||||
let files = Arc::new(Files::default());
|
||||
@@ -161,7 +189,7 @@ mod tests {
|
||||
assert_eq!(result["bytes"], csv.len());
|
||||
assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\""));
|
||||
assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap());
|
||||
assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n"));
|
||||
assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\",\"\",\"\"\r\n"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
@@ -195,6 +223,8 @@ mod tests {
|
||||
for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] {
|
||||
let mut m = marker(100, Tick::ZERO, Tick::ZERO, text);
|
||||
m.comment = text.into();
|
||||
m.author = text.into();
|
||||
m.url = text.into();
|
||||
set_markers(&mut s, vec![m]);
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Name", |p, _| {
|
||||
@@ -203,7 +233,7 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"safe.csv"})).unwrap();
|
||||
assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3);
|
||||
assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 5);
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into();
|
||||
s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap();
|
||||
assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim())));
|
||||
|
||||
@@ -219,6 +219,9 @@ fn timecode_marker_and_in_point_sync_within_one_frame() {
|
||||
for (it, t) in [(items[0], rate.tick_of(event_frame_a)), (items[1], rate.snap(at_b))] {
|
||||
let m = p.item_mut(it).unwrap().as_media_mut().unwrap();
|
||||
m.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(900 + it.0),
|
||||
start: t,
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Saved swatch operations do not change colors already assigned to media or markers.
|
||||
use crate::{Result, Session, commands::bad};
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::{Value, json};
|
||||
fn color(p: &Value, key: &str, id: &str) -> Result<HexColor> {
|
||||
p.get(key).and_then(Value::as_str).and_then(HexColor::parse).ok_or_else(|| bad(id, "need a six-digit hex color"))
|
||||
}
|
||||
pub(crate) fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = "colors.updateCustom";
|
||||
let old = color(p, "color", id)?;
|
||||
let new = if p.get("replacement").is_some() { color(p, "replacement", id)? } else { old };
|
||||
let name = p
|
||||
.get("name")
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.filter(|s| s.len() <= 128 && !s.chars().any(char::is_control))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| bad(id, "name must be at most 128 bytes without control characters"))
|
||||
})
|
||||
.transpose()?;
|
||||
if !s.project.custom_colors.contains(&old) {
|
||||
return Err(bad(id, "no such saved color"));
|
||||
}
|
||||
if old != new && s.project.custom_colors.contains(&new) {
|
||||
return Err(bad(id, "that color is already saved"));
|
||||
}
|
||||
s.edit("Edit Custom Color", |project, _| {
|
||||
for c in &mut project.custom_colors {
|
||||
if *c == old {
|
||||
*c = new;
|
||||
}
|
||||
}
|
||||
let previous = project.custom_color_names.remove(&old).unwrap_or_default();
|
||||
let name = name.unwrap_or(previous);
|
||||
if !name.trim().is_empty() {
|
||||
project.custom_color_names.insert(new, name);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"color":new}))
|
||||
}
|
||||
pub(crate) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = "colors.removeCustom";
|
||||
let old = color(p, "color", id)?;
|
||||
if !s.project.custom_colors.contains(&old) {
|
||||
return Err(bad(id, "no such saved color"));
|
||||
}
|
||||
s.edit("Remove Custom Color", |project, _| {
|
||||
project.custom_colors.retain(|c| *c != old);
|
||||
project.custom_color_names.remove(&old);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub(crate) fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = "colors.addCustom";
|
||||
let color = color(p, "color", id)?;
|
||||
let name = p
|
||||
.get("name")
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.filter(|s| s.len() <= 128 && !s.chars().any(char::is_control))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| bad(id, "name must be at most 128 bytes without control characters"))
|
||||
})
|
||||
.transpose()?;
|
||||
if s.project.custom_colors.contains(&color) {
|
||||
if let Some(name) = name {
|
||||
return update(s, &json!({"color":color,"name":name}));
|
||||
}
|
||||
return Ok(json!({"color":color}));
|
||||
}
|
||||
if s.project.custom_colors.len() >= 64 {
|
||||
return Err(bad(id, "the project already has 64 custom colors"));
|
||||
}
|
||||
s.edit("Add Custom Color", |project, _| {
|
||||
project.custom_colors.push(color);
|
||||
if let Some(name) = name.filter(|s| !s.trim().is_empty()) {
|
||||
project.custom_color_names.insert(color, name);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"color":color}))
|
||||
}
|
||||
@@ -194,7 +194,7 @@ pub fn metadata_fields(p: &Project, id: ItemId) -> Vec<Field> {
|
||||
let mut out = Vec::new();
|
||||
let mut add = |section, name: &str, value: String, editable| out.push(Field { section, name: name.to_string(), value, editable });
|
||||
add("Clip", "Name", it.name.clone(), true);
|
||||
add("Clip", "Label", it.label.name().to_string(), true);
|
||||
add("Clip", "Label", it.label_value(), true);
|
||||
add("Clip", "Media Type", it.type_label().to_string(), false);
|
||||
let col = |c: &str| filmcraft_project::find::column_text(p, it, c);
|
||||
add("Clip", "Frame Rate", col("Frame Rate"), false);
|
||||
@@ -300,7 +300,7 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if k.trim().is_empty() {
|
||||
return Err(bad("metadata.set", "empty field name"));
|
||||
}
|
||||
if k.eq_ignore_ascii_case("label") && Label::from_name(v).is_none() {
|
||||
if k.eq_ignore_ascii_case("label") && Label::from_color_name(v).is_none() && filmcraft_project::HexColor::parse(v).is_none() {
|
||||
return Err(bad("metadata.set", format!("unknown label `{v}`")));
|
||||
}
|
||||
if k.eq_ignore_ascii_case("name") && v.trim().is_empty() {
|
||||
@@ -314,7 +314,13 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let it = s.project.item(id).ok_or_else(|| bad("metadata.set", "no such item"))?;
|
||||
changes.iter().all(|(k, v)| match k.to_ascii_lowercase().as_str() {
|
||||
"name" => it.name == *v,
|
||||
"label" => Label::from_name(v) == Some(it.label),
|
||||
"label" => {
|
||||
if let Some(color) = filmcraft_project::HexColor::parse(v) {
|
||||
it.custom_label_color == Some(color)
|
||||
} else {
|
||||
it.custom_label_color.is_none() && Label::from_color_name(v) == Some(it.label)
|
||||
}
|
||||
}
|
||||
_ => it.metadata.get(k).map(String::as_str).unwrap_or("") == v.as_str(),
|
||||
})
|
||||
};
|
||||
@@ -324,7 +330,12 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (k, v) in &changes {
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"name" => it.name = v.trim().to_string(),
|
||||
"label" => it.label = Label::from_name(v).unwrap_or(it.label),
|
||||
"label" => {
|
||||
if let Some(label) = Label::from_color_name(v) {
|
||||
it.label = label;
|
||||
}
|
||||
it.custom_label_color = filmcraft_project::HexColor::parse(v);
|
||||
}
|
||||
_ if v.is_empty() => {
|
||||
it.metadata.remove(k);
|
||||
}
|
||||
@@ -337,7 +348,7 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
}
|
||||
let it = s.project.item(id).ok_or_else(|| bad("metadata.set", "no such item"))?;
|
||||
Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged}))
|
||||
Ok(json!({"item": id.0, "name": it.name, "label": it.label_value(), "metadata": it.metadata, "changed": !unchanged}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ project notes
|
||||
|
||||
@@ -358,7 +358,7 @@ pub fn cell(p: &Project, it: &ProjectItem, column: &str) -> (String, SortKey) {
|
||||
};
|
||||
match column {
|
||||
"Name" => text(it.name.clone()),
|
||||
"Label" => text(it.label.name().to_string()),
|
||||
"Label" => text(it.label_value()),
|
||||
"Media Type" => text(it.type_label().to_string()),
|
||||
"Frame Rate" if has_v => (format!("{} fps", rate.label()), SortKey::Num(rate.num as f64 / rate.den.max(1) as f64)),
|
||||
"Frame Rate" => (String::new(), SortKey::Num(0.0)),
|
||||
@@ -1047,7 +1047,7 @@ fn items(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in items {
|
||||
let Some(it) = pr.item(id) else { continue };
|
||||
let cells: serde_json::Map<String, Value> = view.columns.iter().map(|c| (c.name.clone(), json!(cell(pr, it, &c.name).0))).collect();
|
||||
rows.push(json!({"item": id.0, "name": it.name, "label": it.label.name(), "depth": depth, "cells": cells}));
|
||||
rows.push(json!({"item": id.0, "name": it.name, "label": it.label_value(), "depth": depth, "cells": cells}));
|
||||
}
|
||||
}
|
||||
walk(pr, bin, view, 0, recursive, &mut rows);
|
||||
|
||||
@@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
proj.name = name.clone();
|
||||
proj.root.name = name;
|
||||
s.project = std::sync::Arc::new(proj);
|
||||
s.project_instance = std::sync::Arc::new(());
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
|
||||
@@ -286,6 +286,9 @@ fn apply_results(s: &mut Session, seq_id: ItemId, apply: SceneApply, results: Ve
|
||||
continue;
|
||||
}
|
||||
m.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(id),
|
||||
start: *mt,
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -83,7 +83,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
"Add Flash Cue Marker…",
|
||||
&["Markers"],
|
||||
None,
|
||||
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#,
|
||||
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label_or_hex?,"author":str?}"#,
|
||||
has_seq,
|
||||
add_flash_cue,
|
||||
),
|
||||
@@ -543,11 +543,12 @@ fn add_flash_cue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1;
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}"));
|
||||
let comment = str_p(p, "comment").unwrap_or("").to_string();
|
||||
let color = marker_color_p(p, "markers.addFlashCue")?.unwrap_or(Label::Green);
|
||||
let (color, custom_color) = marker_color_p(p, "markers.addFlashCue")?.unwrap_or((Label::Green, None));
|
||||
let author = crate::commands::marker_author_p(p, "markers.addFlashCue")?.unwrap_or_default();
|
||||
let dur = u64_p(p, "durationFrames").map(|f| s.sequence_rate().tick_of(f as i64)).unwrap_or(Tick::ZERO);
|
||||
let id = s.edit_sequence("Add Flash Cue Marker", |q, ctx, _| {
|
||||
let id = MarkerId(ctx.alloc());
|
||||
q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind: MarkerKind::FlashCue, color });
|
||||
q.markers.push(Marker { url: String::new(), author, custom_color, id, start: t, duration: dur, name, comment, kind: MarkerKind::FlashCue, color });
|
||||
q.markers.sort_by_key(|m| m.start);
|
||||
Ok(id)
|
||||
})?;
|
||||
|
||||
@@ -198,7 +198,7 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
||||
"Add Range Marker",
|
||||
&["Markers"],
|
||||
Some("Ctrl+Shift+M"),
|
||||
r#"{"time":ticks?,"durationFrames":i64=1s,"duration":ticks?,"name":str?,"comment":str?,"color":label?}"#,
|
||||
r#"{"time":ticks?,"durationFrames":i64=1s,"duration":ticks?,"name":str?,"comment":str?,"color":label_or_hex?,"author":str?}"#,
|
||||
has_seq,
|
||||
add_range_marker,
|
||||
),
|
||||
@@ -207,7 +207,7 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
||||
"Add Range Marker to In and Out",
|
||||
&["Markers"],
|
||||
Some("Ctrl+M"),
|
||||
r#"{"name":str?,"comment":str?,"color":label?}"#,
|
||||
r#"{"name":str?,"comment":str?,"color":label_or_hex?,"author":str?}"#,
|
||||
has_in_and_out,
|
||||
add_range_marker_in_out,
|
||||
),
|
||||
@@ -218,9 +218,18 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
||||
vec![
|
||||
spec("markers.showAllMarkerColors", "Show All Marker Colors", &["Markers"], None, "{}", has_hidden_colors, |s, _| {
|
||||
s.state.hidden_marker_colors.clear();
|
||||
s.state.hidden_custom_marker_colors.clear();
|
||||
Ok(json!({"hidden": []}))
|
||||
}),
|
||||
spec("markers.filterColors", "Marker Colour Filter", &[], None, r#"{"hidden":[label]}|{"color":label,"visible":bool?}"#, always, filter_colors),
|
||||
spec(
|
||||
"markers.filterColors",
|
||||
"Marker Colour Filter",
|
||||
&[],
|
||||
None,
|
||||
r#"{"hidden":[label_or_hex]}|{"color":label_or_hex,"visible":bool?}"#,
|
||||
always,
|
||||
filter_colors,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -231,7 +240,7 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
||||
"Add Chapter Marker…",
|
||||
&["Markers"],
|
||||
None,
|
||||
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#,
|
||||
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label_or_hex?,"author":str?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let t = sequence_time_p(s, p, "", "markers.addChapter")?.unwrap_or(s.playhead());
|
||||
@@ -289,7 +298,7 @@ fn has_split(s: &Session) -> std::result::Result<(), String> {
|
||||
if program || source { Ok(()) } else { Err("there are no split points".into()) }
|
||||
}
|
||||
fn has_hidden_colors(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.hidden_marker_colors.is_empty() { Err("all marker colours are shown".into()) } else { Ok(()) }
|
||||
if s.state.hidden_marker_colors.is_empty() && s.state.hidden_custom_marker_colors.is_empty() { Err("all marker colours are shown".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
// ---------- selection follows playhead ----------
|
||||
@@ -1117,10 +1126,11 @@ struct MarkerInput<'a> {
|
||||
fn add_marker(s: &mut Session, input: MarkerInput<'_>) -> Result<MarkerId> {
|
||||
let MarkerInput { cmd, label, start, duration, kind, name, params } = input;
|
||||
let comment = str_p(params, "comment").unwrap_or("").to_string();
|
||||
let color = marker_color_p(params, cmd)?.unwrap_or(Label::Green);
|
||||
let (color, custom_color) = marker_color_p(params, cmd)?.unwrap_or((Label::Green, None));
|
||||
let author = crate::commands::marker_author_p(params, cmd)?.unwrap_or_default();
|
||||
s.edit_sequence(label, |q, ctx, _| {
|
||||
let id = MarkerId(ctx.alloc());
|
||||
q.markers.push(Marker { id, start, duration, name, comment, kind, color });
|
||||
q.markers.push(Marker { url: String::new(), author, custom_color, id, start, duration, name, comment, kind, color });
|
||||
q.markers.sort_by_key(|m| m.start);
|
||||
Ok(id)
|
||||
})
|
||||
@@ -1160,23 +1170,52 @@ fn add_range_marker_in_out(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn filter_colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(a) = p.get("hidden").and_then(Value::as_array) {
|
||||
let mut v = Vec::new();
|
||||
for x in a {
|
||||
let n = x.as_str().unwrap_or_default();
|
||||
v.push(Label::from_name(n).ok_or_else(|| bad("markers.filterColors", format!("unknown colour `{n}`")))?);
|
||||
let id = "markers.filterColors";
|
||||
let parse = |v: &Value| -> Result<(Label, Option<filmcraft_project::HexColor>)> {
|
||||
crate::commands::marker_color_p(&json!({"color":v}), id)?.ok_or_else(|| bad(id, "need a color"))
|
||||
};
|
||||
if let Some(value) = p.get("hidden") {
|
||||
let a = value.as_array().filter(|a| a.len() <= 80).ok_or_else(|| bad(id, "hidden must contain at most 80 colors"))?;
|
||||
let mut presets = Vec::new();
|
||||
let mut custom = Vec::new();
|
||||
for v in a {
|
||||
let (preset, hex) = parse(v)?;
|
||||
if let Some(hex) = hex {
|
||||
if !custom.contains(&hex) {
|
||||
custom.push(hex);
|
||||
}
|
||||
} else if !presets.contains(&preset) {
|
||||
presets.push(preset);
|
||||
}
|
||||
}
|
||||
s.state.hidden_marker_colors = v;
|
||||
} else if let Some(n) = str_p(p, "color") {
|
||||
let c = Label::from_name(n).ok_or_else(|| bad("markers.filterColors", format!("unknown colour `{n}`")))?;
|
||||
let hidden = s.state.hidden_marker_colors.contains(&c);
|
||||
let visible = bool_p(p, "visible").unwrap_or(hidden);
|
||||
s.state.hidden_marker_colors.retain(|x| *x != c);
|
||||
if !visible {
|
||||
s.state.hidden_marker_colors.push(c);
|
||||
if custom.len() > 64 {
|
||||
return Err(bad(id, "too many custom color filters"));
|
||||
}
|
||||
s.state.hidden_marker_colors = presets;
|
||||
s.state.hidden_custom_marker_colors = custom;
|
||||
} else if let Some(value) = p.get("color") {
|
||||
let (preset, hex) = parse(value)?;
|
||||
let visible = p.get("visible").map(|v| v.as_bool().ok_or_else(|| bad(id, "visible must be a boolean"))).transpose()?;
|
||||
if let Some(hex) = hex {
|
||||
let visible = visible.unwrap_or(s.state.hidden_custom_marker_colors.contains(&hex));
|
||||
s.state.hidden_custom_marker_colors.retain(|c| *c != hex);
|
||||
if !visible {
|
||||
if s.state.hidden_custom_marker_colors.len() >= 64 {
|
||||
return Err(bad(id, "too many custom color filters"));
|
||||
}
|
||||
s.state.hidden_custom_marker_colors.push(hex);
|
||||
}
|
||||
} else {
|
||||
let visible = visible.unwrap_or(s.state.hidden_marker_colors.contains(&preset));
|
||||
s.state.hidden_marker_colors.retain(|c| *c != preset);
|
||||
if !visible {
|
||||
s.state.hidden_marker_colors.push(preset);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(bad("markers.filterColors", "need `hidden` or `color`"));
|
||||
return Err(bad(id, "need hidden or color"));
|
||||
}
|
||||
Ok(json!({"hidden": s.state.hidden_marker_colors.iter().map(|c| c.name()).collect::<Vec<_>>()}))
|
||||
Ok(
|
||||
json!({"hidden":s.state.hidden_marker_colors.iter().map(|c|c.name().to_owned()).chain(s.state.hidden_custom_marker_colors.iter().map(|c|c.hex())).collect::<Vec<_>>()}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -663,11 +663,11 @@ fn range_and_chapter_markers() {
|
||||
|
||||
#[test]
|
||||
fn unknown_marker_colours_are_rejected_not_stored_as_green() {
|
||||
// #857: `markers.add {color:"Red"}` used to succeed and store Green
|
||||
// Unknown names and non-string values must not silently become Green.
|
||||
let mut s = demo();
|
||||
let before = s.project.to_json();
|
||||
for cmd in ["markers.add", "markers.addRange", "markers.addChapter", "markers.addFlashCue"] {
|
||||
for color in [json!("Red"), json!("NotAColor"), json!(3)] {
|
||||
for color in [json!("White"), json!("NotAColor"), json!(3)] {
|
||||
let err = s.execute(cmd, json!({"frame": 10, "color": color})).unwrap_err().to_string();
|
||||
assert!(err.contains("Rose"), "{cmd}: the error lists the accepted names: {err}");
|
||||
assert_eq!(s.project.to_json(), before, "{cmd} {color} must not add a marker");
|
||||
@@ -676,7 +676,7 @@ fn unknown_marker_colours_are_rejected_not_stored_as_green() {
|
||||
let id = s.execute("markers.add", json!({"frame": 10, "color": "rose"})).unwrap()["marker"].as_u64().unwrap();
|
||||
let colour = |s: &Session| s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().color;
|
||||
assert_eq!(colour(&s), Label::Rose);
|
||||
assert!(s.execute("markers.edit", json!({"marker": id, "color": "Red"})).is_err());
|
||||
assert!(s.execute("markers.edit", json!({"marker": id, "color": "NotAColor"})).is_err());
|
||||
assert_eq!(colour(&s), Label::Rose, "a bad colour leaves the marker unchanged");
|
||||
s.execute("markers.edit", json!({"marker": id, "color": "Teal"})).unwrap();
|
||||
assert_eq!(colour(&s), Label::Teal);
|
||||
@@ -691,6 +691,7 @@ fn edit_marker_sets_every_field_in_one_undo_step_and_rejects_hostile_values() {
|
||||
let before = s.project.to_json();
|
||||
for p in [
|
||||
json!({"marker": id, "durationFrames": -1}),
|
||||
json!({"marker": id, "durationFrames": 10_000_001}),
|
||||
json!({"marker": id, "durationFrames": i64::MAX}),
|
||||
json!({"marker": id, "durationFrames": "12"}),
|
||||
json!({"marker": id, "durationFrames": 1.5}),
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn source_command(id: &str) -> bool {
|
||||
| "markers.clearCurrent"
|
||||
| "markers.clearAll"
|
||||
| "markers.edit"
|
||||
| "markers.delete"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,7 +197,8 @@ pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result<Option<Value
|
||||
let duration = duration(p, &view, id)?.min(Tick(view.end.0.saturating_sub(time.0)));
|
||||
let name = text(p, "name", id)?.unwrap_or_default().to_string();
|
||||
let comment = text(p, "comment", id)?.unwrap_or_default().to_string();
|
||||
let color = color(p, id)?.unwrap_or(Label::Green);
|
||||
let (color, custom_color) = color(p, id)?.unwrap_or((Label::Green, None));
|
||||
let author = crate::commands::marker_author_p(p, id)?.unwrap_or_default();
|
||||
let root = view.media;
|
||||
let marker = s.edit("Add Source Marker", |pr, _| {
|
||||
if pr.next_id == u64::MAX {
|
||||
@@ -204,7 +206,18 @@ pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result<Option<Value
|
||||
}
|
||||
let marker = MarkerId(pr.alloc_id());
|
||||
let list = marker_list(pr, root)?;
|
||||
list.push(Marker { id: marker, start: time, duration, name, comment, color, kind: MarkerKind::Comment });
|
||||
list.push(Marker {
|
||||
url: String::new(),
|
||||
author,
|
||||
custom_color,
|
||||
id: marker,
|
||||
start: time,
|
||||
duration,
|
||||
name,
|
||||
comment,
|
||||
color,
|
||||
kind: MarkerKind::Comment,
|
||||
});
|
||||
list.sort_by_key(|m| m.start);
|
||||
Ok(marker)
|
||||
})?;
|
||||
@@ -221,33 +234,6 @@ pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result<Option<Value
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"markers.edit" => {
|
||||
let marker = MarkerId(p.get("marker").and_then(Value::as_u64).ok_or_else(|| bad(id, "need marker id"))?);
|
||||
let name = text(p, "name", id)?.map(str::to_string);
|
||||
let comment = text(p, "comment", id)?.map(str::to_string);
|
||||
let color = color(p, id)?;
|
||||
let duration = if p.get("durationFrames").is_some_and(|v| !v.is_null()) { Some(duration(p, &view, id)?) } else { None };
|
||||
let kind = crate::commands::marker_kind_p(p)?;
|
||||
s.edit("Edit Source Marker", |pr, _| {
|
||||
let m = marker_list(pr, view.media)?.iter_mut().find(|m| m.id == marker).ok_or_else(|| bad(id, "no such Source marker"))?;
|
||||
if let Some(name) = name {
|
||||
m.name = name;
|
||||
}
|
||||
if let Some(kind) = kind {
|
||||
m.kind = kind;
|
||||
}
|
||||
if let Some(comment) = comment {
|
||||
m.comment = comment;
|
||||
}
|
||||
if let Some(color) = color {
|
||||
m.color = color;
|
||||
}
|
||||
if let Some(d) = duration {
|
||||
m.duration = d.min(Tick(view.end.0.saturating_sub(m.start.0)));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
_ => return Err(bad(id, "unsupported Source command")),
|
||||
}
|
||||
Ok(Some(Value::Null))
|
||||
@@ -277,7 +263,7 @@ pub(crate) fn open_clip(s: &mut Session, clip: ClipId) -> Result<Value> {
|
||||
fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result<Option<&'a str>> {
|
||||
p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose()
|
||||
}
|
||||
fn color(p: &Value, id: &str) -> Result<Option<Label>> {
|
||||
fn color(p: &Value, id: &str) -> Result<Option<(Label, Option<filmcraft_project::HexColor>)>> {
|
||||
crate::commands::marker_color_p(p, id)
|
||||
}
|
||||
fn duration(p: &Value, view: &crate::clip_ops::SourceView, id: &str) -> Result<Tick> {
|
||||
@@ -346,16 +332,24 @@ mod tests {
|
||||
s.execute("source.setPlayhead", json!({"time":cursor.0})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let qbefore = s.active_sequence().unwrap().clone();
|
||||
let marker = s.execute("markers.add", json!({"target":"source","name":"Cue"})).unwrap()["marker"].as_u64().unwrap();
|
||||
let marker =
|
||||
s.execute("markers.add", json!({"target":"source","name":"Cue","color":"#FFFFFF","author":"Source editor"})).unwrap()["marker"].as_u64().unwrap();
|
||||
let v = source_view(&s, ItemId(5)).unwrap();
|
||||
assert_eq!(v.markers.len(), 1);
|
||||
assert_eq!(v.markers[0].rgb(), [255; 3]);
|
||||
assert_eq!(v.markers[0].author, "Source editor");
|
||||
assert_eq!(v.markers[0].start, cursor);
|
||||
assert_eq!(s.active_sequence().unwrap(), &qbefore);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("markers.edit", json!({"target":"source","marker":marker,"name":"Changed"})).unwrap();
|
||||
s.execute("markers.edit", json!({"target":"source","marker":marker,"name":"Changed","color":"#000000","author":"Other editor"})).unwrap();
|
||||
assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].name, "Changed");
|
||||
assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].rgb(), [0; 3]);
|
||||
assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].author, "Other editor");
|
||||
s.execute("markers.edit", json!({"target":"source","marker":marker,"color":"CYAN"})).unwrap();
|
||||
assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].color, Label::Teal);
|
||||
assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].custom_color, None);
|
||||
s.execute("markers.clearCurrent", json!({"target":"source"})).unwrap();
|
||||
assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_project::{HexColor, Label};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn review_null_author_is_omitted_for_creation_and_preserves_existing_author() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
for cmd in ["markers.add", "markers.addRange"] {
|
||||
let id = s.execute(cmd, json!({"frame":0,"durationFrames":10,"author":null})).unwrap()["marker"].as_u64().unwrap();
|
||||
s.execute("markers.edit", json!({"marker":id,"author":"Editor"})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
s.execute("markers.edit", json!({"marker":id,"author":null,"name":"Updated"})).unwrap();
|
||||
let marker = s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap();
|
||||
assert_eq!(marker.author, "Editor");
|
||||
assert_eq!(marker.name, "Updated");
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_link_url_is_separate_from_notes_atomic_persistent_and_optional_in_old_projects() {
|
||||
use filmcraft_project::MarkerKind;
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
let id = s.execute("markers.add", json!({"frame":0,"name":"Review","comment":"Use take two"})).unwrap()["marker"].as_u64().unwrap();
|
||||
let sequence = s.state.active_sequence.unwrap();
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
let url = "https://example.com/review?take=2#notes";
|
||||
s.execute("markers.edit", json!({"marker":id,"kind":"webLink","url":format!(" {url} "),"comment":"Awaiting approval"})).unwrap();
|
||||
let marker = s.active_sequence().unwrap().markers[0].clone();
|
||||
assert_eq!((&*marker.url, &*marker.comment, marker.kind), (url, "Awaiting approval", MarkerKind::WebLink));
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.sequence(sequence).unwrap().markers[0], marker);
|
||||
assert_eq!(s.execute("sequence.inspect", json!({})).unwrap()["markers"][0]["url"], url);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("markers.edit", json!({"marker":id,"kind":"comment","url":null})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].url, url);
|
||||
s.execute("markers.edit", json!({"marker":id,"kind":"webLink"})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].url, url);
|
||||
let mut legacy: serde_json::Value = serde_json::from_slice(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
legacy["schema_version"] = json!(16);
|
||||
fn remove_urls(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
map.remove("url");
|
||||
for value in map.values_mut() {
|
||||
remove_urls(value);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for value in values {
|
||||
remove_urls(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
remove_urls(&mut legacy);
|
||||
let loaded = filmcraft_format::decode(&serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
assert!(loaded.migrated());
|
||||
assert!(loaded.project.sequence(sequence).unwrap().markers[0].url.is_empty());
|
||||
for invalid in [
|
||||
json!("file:///tmp/a"),
|
||||
json!("javascript:alert(1)"),
|
||||
json!("https://user:pass@example.com"),
|
||||
json!("https://a\nb"),
|
||||
json!("https://"),
|
||||
json!("https:example.com"),
|
||||
json!("relative/path"),
|
||||
json!(42),
|
||||
json!({}),
|
||||
json!(["https://example.com"]),
|
||||
json!("x".repeat(8193)),
|
||||
] {
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
assert!(s.execute("markers.edit", json!({"marker":id,"url":invalid,"name":"Must not change"})).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
s.execute("markers.edit", json!({"marker":id,"url":""})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().markers[0].url.is_empty());
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].comment, "Awaiting approval");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_close_project_renews_runtime_identity() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.newSequence", json!({})).unwrap();
|
||||
let original = session.project_instance.clone();
|
||||
assert!(session.execute("file.closeProject", json!({})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &session.project_instance), "a rejected close must retain the live project's identity");
|
||||
session.execute("file.closeProject", json!({"force":true})).unwrap();
|
||||
assert!(!std::sync::Arc::ptr_eq(&original, &session.project_instance));
|
||||
assert!(session.project.items.is_empty());
|
||||
assert!(!session.history.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_kinds_edit_atomically_and_reject_invalid_values() {
|
||||
use filmcraft_project::MarkerKind;
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
let marker = s.execute("markers.add", json!({"frame":0,"name":"Original"})).unwrap()["marker"].as_u64().unwrap();
|
||||
for (value, expected) in [
|
||||
("chapter", MarkerKind::Chapter),
|
||||
("segmentation", MarkerKind::Segmentation),
|
||||
("webLink", MarkerKind::WebLink),
|
||||
("flashCue", MarkerKind::FlashCue),
|
||||
("comment", MarkerKind::Comment),
|
||||
] {
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
s.execute("markers.edit", json!({"marker":marker,"kind":value,"name":"Changed","comment":"Together"})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].kind, expected);
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].kind, expected);
|
||||
}
|
||||
for kind in [json!("unknown"), json!(true), json!(42), json!([]), json!({})] {
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
assert!(s.execute("markers.edit", json!({"marker":marker,"kind":kind,"name":"Must not change"})).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_marker_properties_survive_undo_and_project_reopen() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name":"Review"})).unwrap();
|
||||
let id = s.execute("markers.add", json!({"frame":0,"name":"Note","author":"BENJI","color":"#ff80c0"})).unwrap()["marker"].as_u64().unwrap();
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let original = s.active_sequence().unwrap().markers[0].clone();
|
||||
assert_eq!(original.rgb(), [255, 128, 192]);
|
||||
assert_eq!(original.color_value(), "#FF80C0");
|
||||
assert_eq!(original.author, "BENJI");
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.sequence(seq).unwrap().markers[0], original);
|
||||
s.execute("markers.edit", json!({"marker":id,"color":"White"})).unwrap_err();
|
||||
s.execute("markers.edit", json!({"marker":id,"comment":"Keep"})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].custom_color, original.custom_color);
|
||||
s.execute("markers.edit", json!({"marker":id,"color":"Blue","author":""})).unwrap();
|
||||
let marker = &s.active_sequence().unwrap().markers[0];
|
||||
assert_eq!(marker.color, Label::Blue);
|
||||
assert_eq!(marker.custom_color, None);
|
||||
assert_eq!(marker.author, "");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].custom_color, HexColor::parse("#FF80C0"));
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].author, "BENJI");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].custom_color, None);
|
||||
let inspected = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
assert_eq!(inspected["markers"][0]["author"], "");
|
||||
assert_eq!(inspected["markers"][0]["color"], "Blue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_marker_properties_leave_project_and_undo_unchanged() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
let id = s.execute("markers.add", json!({})).unwrap()["marker"].as_u64().unwrap();
|
||||
for change in [
|
||||
json!({"color":"#123"}),
|
||||
json!({"color":"#00000000"}),
|
||||
json!({"color":"#€aaaa"}),
|
||||
json!({"color":[0,0,0]}),
|
||||
json!({"author":true}),
|
||||
json!({"author":"a\nb"}),
|
||||
json!({"author":"a".repeat(1025)}),
|
||||
] {
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
let mut params = change;
|
||||
params["marker"] = json!(id);
|
||||
assert!(s.execute("markers.edit", params).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_marker_creation_commands_accept_hex_and_author() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
for cmd in ["markers.add", "markers.addRange", "markers.addChapter", "markers.addFlashCue"] {
|
||||
s.execute(cmd, json!({"name":"Cue", "durationFrames":10,"color":"#FFFFFF","author":"Editor"})).unwrap();
|
||||
let marker = s.active_sequence().unwrap().markers.last().unwrap();
|
||||
assert_eq!(marker.rgb(), [255; 3], "{cmd}");
|
||||
assert_eq!(marker.author, "Editor", "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_palette_is_project_local_bounded_and_undoable() {
|
||||
let mut s = Session::default();
|
||||
let before = s.project.to_json();
|
||||
for invalid in [json!("Rose"), json!("#123"), json!(true), json!(42)] {
|
||||
assert!(s.execute("colors.addCustom", json!({"color":invalid})).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
s.execute("colors.addCustom", json!({"color":"#ff80c0"})).unwrap();
|
||||
let history = s.history.undo.len();
|
||||
s.execute("colors.addCustom", json!({"color":"#FF80C0"})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), history, "duplicates do not create edits");
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.custom_colors, s.project.custom_colors);
|
||||
assert!(Session::default().project.custom_colors.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
for value in 0..63 {
|
||||
s.execute("colors.addCustom", json!({"color":format!("#{value:06X}")})).unwrap();
|
||||
}
|
||||
assert_eq!(s.project.custom_colors.len(), 64);
|
||||
let full = s.project.to_json();
|
||||
assert!(s.execute("colors.addCustom", json!({"color":"#FFFFFF"})).is_err());
|
||||
assert_eq!(s.project.to_json(), full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_clip_colors_share_palette_and_survive_save_preset_switch_and_undo() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
s.execute("edit.label", json!({"clips":[clip.id.0],"label":"#12abcd","saveColor":true})).unwrap();
|
||||
let color = HexColor::parse("#12ABCD");
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip.id).unwrap().1.custom_label_color, color);
|
||||
assert_eq!(s.project.custom_colors, vec![color.unwrap()]);
|
||||
s.execute("sequence.linkedSelection", json!({"on":false})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips":[clip.id.0]})).unwrap();
|
||||
s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(s.state.selection, vec![clip.id]);
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.sequence(seq).unwrap().find_item(clip.id).unwrap().1.custom_label_color, color);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("edit.label.violet", json!({"clips":[clip.id.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip.id).unwrap().1.custom_label_color, None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip.id).unwrap().1.custom_label_color, color);
|
||||
s.execute("edit.label", json!({"items":[clip.item.0],"label":"#12ABCD"})).unwrap();
|
||||
let mut project = (*s.project).clone();
|
||||
let created = project
|
||||
.make_track_item(
|
||||
clip.item,
|
||||
filmcraft_project::TrackKind::Video,
|
||||
clip.start,
|
||||
filmcraft_time::TimeRange::new(clip.source_in, clip.duration),
|
||||
s.sequence_rate(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(created.custom_label_color, color);
|
||||
s.execute("project.select", json!({"items":[clip.item.0]})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()["items"][0].as_u64().unwrap();
|
||||
assert_eq!(s.project.item(filmcraft_project::ItemId(copy)).unwrap().custom_label_color, color);
|
||||
let before = s.project.to_json();
|
||||
for params in [json!({"label":"#XYZXYZ"}), json!({"label":"#FFFFFF","clips":[-1]}), json!({"label":"#FFFFFF","clips":[u64::MAX],"saveColor":true})] {
|
||||
assert!(s.execute("edit.label", params).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_label_edits_use_the_effective_color_and_clear_custom_overrides() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let item = s.active_sequence().unwrap().video_tracks[0].items[0].item;
|
||||
let preset = s.project.item(item).unwrap().label;
|
||||
s.execute("edit.label", json!({"items":[item.0],"label":"#123456"})).unwrap();
|
||||
let metadata = s.execute("metadata.get", json!({"item":item.0})).unwrap();
|
||||
assert_eq!(metadata["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Label").unwrap()["value"], "#123456");
|
||||
let result = s.execute("metadata.set", json!({"item":item.0,"field":"Label","value":preset.name()})).unwrap();
|
||||
assert_eq!(result["changed"], true);
|
||||
assert_eq!(s.project.item(item).unwrap().custom_label_color, None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let result = s.execute("metadata.set", json!({"item":item.0,"field":"Label","value":"#123456"})).unwrap();
|
||||
assert_eq!(result["changed"], false);
|
||||
assert_eq!(result["label"], "#123456");
|
||||
s.execute("metadata.set", json!({"item":item.0,"field":"Label","value":"#abcdef"})).unwrap();
|
||||
assert_eq!(s.project.item(item).unwrap().label_value(), "#ABCDEF");
|
||||
s.execute("metadata.set", json!({"item":item.0,"field":"Label","value":"Red"})).unwrap();
|
||||
assert_eq!(s.project.item(item).unwrap().label, Label::Rose);
|
||||
assert_eq!(s.project.item(item).unwrap().custom_label_color, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_and_clip_aliases_resolve_to_the_same_unchanged_presets() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||||
for (alias, preset) in Label::COLOR_ALIASES {
|
||||
let id = s.execute("markers.add", json!({"color":alias.to_lowercase()})).unwrap()["marker"].as_u64().unwrap();
|
||||
let marker = s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap();
|
||||
assert_eq!(marker.color, preset);
|
||||
assert_eq!(marker.rgb(), preset.rgb());
|
||||
s.execute("edit.label", json!({"clips":[clip.0],"label":alias})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.label, preset);
|
||||
s.execute("markers.filterColors", json!({"color":alias,"visible":false})).unwrap();
|
||||
assert!(s.state.hidden_marker_colors.contains(&preset));
|
||||
}
|
||||
assert_eq!(Label::Rose.rgb(), [0x80, 0x18, 0x36]);
|
||||
assert_eq!(Label::Mango.rgb(), [0x80, 0x3f, 0x17]);
|
||||
assert_eq!(Label::Teal.rgb(), [0x1f, 0x4d, 0x45]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_time_edits_are_sorted_exact_and_reject_hostile_ranges_atomically() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
let first = s.execute("markers.add", json!({"frame":10})).unwrap()["marker"].as_u64().unwrap();
|
||||
let second = s.execute("markers.add", json!({"frame":20})).unwrap()["marker"].as_u64().unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("markers.edit", json!({"marker":first,"frame":30,"durationFrames":5})).unwrap();
|
||||
let markers = &s.active_sequence().unwrap().markers;
|
||||
assert_eq!(markers[0].id.0, second);
|
||||
assert_eq!(markers[1].start, rate.tick_of(30));
|
||||
assert_eq!(markers[1].duration, rate.tick_of(5));
|
||||
for change in [
|
||||
json!({"frame":-1}),
|
||||
json!({"frame":i64::MAX}),
|
||||
json!({"durationFrames":-1}),
|
||||
json!({"durationFrames":"2"}),
|
||||
json!({"durationFrames":1.5}),
|
||||
json!({"durationFrames":i64::MAX}),
|
||||
json!({"time":i64::MAX}),
|
||||
json!({"time":true}),
|
||||
json!({"frame":1,"time":0}),
|
||||
json!({"name":false}),
|
||||
] {
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
let mut p = change;
|
||||
p["marker"] = json!(first);
|
||||
assert!(s.execute("markers.edit", p).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
s.execute("markers.edit", json!({"marker":first,"durationFrames":0})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[1].duration, filmcraft_time::Tick::ZERO);
|
||||
s.execute("markers.edit", json!({"marker":first,"time":123})).unwrap();
|
||||
s.execute("markers.edit", json!({"marker":first,"author":"Preserve subframes"})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].start.0, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_marker_changes_are_owner_scoped_atomic_and_one_undo() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
let owner = s.state.active_sequence.unwrap();
|
||||
let a = s.execute("markers.add", json!({"frame":10})).unwrap()["marker"].as_u64().unwrap();
|
||||
let b = s.execute("markers.add", json!({"frame":10})).unwrap()["marker"].as_u64().unwrap();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
s.execute("markers.edit", json!({"sequence":owner.0,"markers":[a,b],"color":"#123456"})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
assert!(s.project.sequence(owner).unwrap().markers.iter().all(|m| m.rgb() == [0x12, 0x34, 0x56]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
for params in [
|
||||
json!({"markers":[a,u64::MAX]}),
|
||||
json!({"markers":[]}),
|
||||
json!({"marker":a,"markers":[b]}),
|
||||
json!({"markers":[-1]}),
|
||||
json!({"marker":a,"target":"nonsense"}),
|
||||
] {
|
||||
let mut p = params;
|
||||
p["sequence"] = json!(owner.0);
|
||||
for cmd in ["markers.edit", "markers.delete"] {
|
||||
assert!(s.execute(cmd, p.clone()).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
}
|
||||
s.execute("markers.delete", json!({"sequence":owner.0,"marker":a})).unwrap();
|
||||
assert_eq!(s.project.sequence(owner).unwrap().markers.iter().map(|m| m.id.0).collect::<Vec<_>>(), vec![b]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("markers.delete", json!({"sequence":owner.0,"markers":[a,b]})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_marker_edits_keep_their_owner_and_enforce_bounds() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let source = filmcraft_project::ItemId(5);
|
||||
s.execute("source.open", json!({"item":source.0})).unwrap();
|
||||
let marker = s.execute("markers.add", json!({"target":"source","time":0})).unwrap()["marker"].as_u64().unwrap();
|
||||
let view = filmcraft_engine::clip_ops::source_view(&s, source).unwrap();
|
||||
let other = s.project.items.values().find(|i| i.as_media().is_some() && i.id != source).unwrap().id;
|
||||
s.execute("source.open", json!({"item":other.0})).unwrap();
|
||||
s.execute(
|
||||
"markers.edit",
|
||||
json!({"target":"source","item":source.0,"marker":marker,"frame":1,"durationFrames":2,"kind":"webLink","url":"https://example.com/source"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(filmcraft_engine::clip_ops::source_view(&s, source).unwrap().markers[0].start, view.rate.tick_of(1));
|
||||
assert_eq!(filmcraft_engine::clip_ops::source_view(&s, source).unwrap().markers[0].kind, filmcraft_project::MarkerKind::WebLink);
|
||||
assert_eq!(filmcraft_engine::clip_ops::source_view(&s, source).unwrap().markers[0].url, "https://example.com/source");
|
||||
let before = s.project.to_json();
|
||||
let history = s.history.undo.len();
|
||||
for change in [json!({"time":view.end.0}), json!({"time":view.end.0-1,"durationFrames":2}), json!({"durationFrames":i64::MAX}), json!({"time":-1})] {
|
||||
let mut p = change;
|
||||
p["target"] = json!("source");
|
||||
p["item"] = json!(source.0);
|
||||
p["marker"] = json!(marker);
|
||||
assert!(s.execute("markers.edit", p).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
assert!(s.execute("markers.delete", json!({"target":"source","marker":marker})).is_err());
|
||||
s.execute("markers.delete", json!({"target":"source","item":source.0,"marker":marker})).unwrap();
|
||||
assert!(filmcraft_engine::clip_ops::source_view(&s, source).unwrap().markers.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_palette_management_preserves_assignments_and_roundtrips() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({})).unwrap();
|
||||
s.execute("markers.add", json!({"color":"#123456"})).unwrap();
|
||||
s.execute("colors.addCustom", json!({"color":"#123456","name":"Review"})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
s.execute("colors.updateCustom", json!({"color":"#123456","replacement":"#654321","name":"Approved"})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].color_value(), "#123456");
|
||||
let new = HexColor::parse("654321").unwrap();
|
||||
assert_eq!(s.project.custom_color_names[&new], "Approved");
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.custom_color_names, s.project.custom_color_names);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
for (cmd, p) in [
|
||||
("colors.updateCustom", json!({"color":"#123456","replacement":"oops"})),
|
||||
("colors.updateCustom", json!({"color":"#FFFFFF"})),
|
||||
("colors.addCustom", json!({"color":"#FFFFFF","name":"bad\nname"})),
|
||||
("colors.removeCustom", json!({"color":true})),
|
||||
] {
|
||||
let history = s.history.undo.len();
|
||||
assert!(s.execute(cmd, p).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.history.undo.len(), history);
|
||||
}
|
||||
s.execute("colors.removeCustom", json!({"color":"#123456"})).unwrap();
|
||||
assert!(s.project.custom_colors.is_empty());
|
||||
assert!(s.project.custom_color_names.is_empty());
|
||||
assert_eq!(s.active_sequence().unwrap().markers[0].color_value(), "#123456");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subclip_marker_edits_share_parent_storage_but_obey_view_bounds() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("source.open", json!({"item":5})).unwrap();
|
||||
let rate = filmcraft_engine::clip_ops::source_view(&s, filmcraft_project::ItemId(5)).unwrap().rate;
|
||||
let inside = s.execute("markers.add", json!({"target":"source","time":rate.tick_of(15).0})).unwrap()["marker"].as_u64().unwrap();
|
||||
let outside = s.execute("markers.add", json!({"target":"source","time":rate.tick_of(5).0})).unwrap()["marker"].as_u64().unwrap();
|
||||
let sub = s.execute("clip.makeSubclip", json!({"item":5,"startFrame":10,"endFrame":30})).unwrap()["item"].as_u64().unwrap();
|
||||
s.execute("markers.edit", json!({"target":"source","item":sub,"marker":inside,"frame":20,"durationFrames":10,"author":"Shared"})).unwrap();
|
||||
let parent = filmcraft_engine::clip_ops::source_view(&s, filmcraft_project::ItemId(5)).unwrap();
|
||||
assert_eq!(parent.markers.iter().find(|m| m.id.0 == inside).unwrap().author, "Shared");
|
||||
let before = s.project.to_json();
|
||||
for p in [
|
||||
json!({"marker":outside,"name":"Hidden"}),
|
||||
json!({"marker":inside,"frame":5}),
|
||||
json!({"marker":inside,"durationFrames":11}),
|
||||
json!({"markers":[inside,outside],"color":"Blue"}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["target"] = json!("source");
|
||||
p["item"] = json!(sub);
|
||||
assert!(s.execute("markers.edit", p).is_err());
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
s.execute("markers.delete", json!({"target":"source","item":sub,"marker":inside})).unwrap();
|
||||
assert_eq!(filmcraft_engine::clip_ops::source_view(&s, filmcraft_project::ItemId(5)).unwrap().markers.len(), 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_marker_filters_share_show_all_and_reject_invalid_updates() {
|
||||
let mut s = Session::default();
|
||||
s.execute("markers.filterColors", json!({"hidden":["Red","#123456"]})).unwrap();
|
||||
assert!(s.state.hidden_marker_colors.contains(&Label::Rose));
|
||||
assert_eq!(s.state.hidden_custom_marker_colors, vec![HexColor::parse("123456").unwrap()]);
|
||||
let before = serde_json::to_value(&s.state).unwrap();
|
||||
for p in [json!({"hidden":["Blue","bad"]}), json!({"hidden":true}), json!({"color":"#123456","visible":"false"})] {
|
||||
assert!(s.execute("markers.filterColors", p).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), before);
|
||||
}
|
||||
s.execute("markers.showAllMarkerColors", json!({})).unwrap();
|
||||
assert!(s.state.hidden_marker_colors.is_empty());
|
||||
assert!(s.state.hidden_custom_marker_colors.is_empty());
|
||||
s.execute("markers.filterColors", json!({"color":"#123456","visible":false})).unwrap();
|
||||
assert!(s.is_enabled("markers.showAllMarkerColors"));
|
||||
s.execute("markers.showAllMarkerColors", json!({})).unwrap();
|
||||
assert!(s.state.hidden_custom_marker_colors.is_empty());
|
||||
}
|
||||
@@ -39,6 +39,8 @@
|
||||
//! would drop them when saving.
|
||||
//! - **v15**: project notes (`Project::notes`, Window ▸ Project Notes). No-op step; older builds
|
||||
//! would drop them when saving.
|
||||
//! - **v16**: marker authors, named project swatches, and exact marker/clip hex colors.
|
||||
//! - **v17**: optional marker URLs, independent of notes and retained across marker-type changes.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
@@ -59,8 +61,10 @@ pub type Migration = fn(Value) -> Result<Value, String>;
|
||||
|
||||
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
|
||||
/// edit a shipped one.
|
||||
pub const MIGRATIONS: &[Migration] =
|
||||
&[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15];
|
||||
pub const MIGRATIONS: &[Migration] = &[
|
||||
v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15,
|
||||
v15_to_v16, v16_to_v17,
|
||||
];
|
||||
|
||||
/// The schema version this build writes (and the newest it reads).
|
||||
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
|
||||
@@ -392,13 +396,23 @@ fn v14_to_v15(doc: Value) -> Result<Value, String> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// v15 → v16: existing colors remain presets; authors and the named custom palette default empty.
|
||||
fn v15_to_v16(doc: Value) -> Result<Value, String> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// v16 → v17: marker URLs default empty. Older builds must not silently drop saved URLs.
|
||||
fn v16_to_v17(doc: Value) -> Result<Value, String> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_version_matches_table() {
|
||||
assert_eq!(SCHEMA_VERSION, 15);
|
||||
assert_eq!(SCHEMA_VERSION, 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -291,6 +291,9 @@ fn next_id_is_raised_past_every_id_in_the_file() {
|
||||
let base = decode(&fixture("v1-edit.fcproj")).unwrap().project;
|
||||
let seq = ItemId(21);
|
||||
let marker = |id| Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(id),
|
||||
start: Tick::ZERO,
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -58,6 +58,7 @@ pub struct AafOptions {
|
||||
/// Export `sequence` as an AAF file.
|
||||
pub fn export(project: &Project, sequence: ItemId, opts: &AafOptions) -> Result<(Vec<u8>, Report)> {
|
||||
let mut report = Report::default();
|
||||
crate::warn_marker_metadata_loss(project, sequence, &mut report);
|
||||
let name = opts.name.clone().or_else(|| project.item(sequence).map(|i| i.name.clone())).unwrap_or_else(|| "Sequence".into());
|
||||
let doc = comp::from_project(project, sequence, &name, &opts.media, comp::Nests::Compositions, &mut report)?;
|
||||
let version = if opts.small_sectors { filmcraft_cfb::Version::V3 } else { filmcraft_cfb::Version::V4 };
|
||||
|
||||
@@ -463,6 +463,7 @@ impl Builder {
|
||||
pub fn clip(&mut self, item: ItemId, kind: TrackKind, name: &str, start: Tick, duration: Tick, source_in: Tick) -> TrackItem {
|
||||
let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris);
|
||||
TrackItem {
|
||||
custom_label_color: self.p.item(item).and_then(|i| i.custom_label_color),
|
||||
id: ClipId(self.p.alloc_id()),
|
||||
item,
|
||||
name: name.to_string(),
|
||||
|
||||
@@ -653,6 +653,9 @@ fn marker_out(m: &Marker) -> CMarker {
|
||||
|
||||
fn marker_in(b: &mut Builder, m: &CMarker) -> Marker {
|
||||
Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(b.alloc()),
|
||||
start: m.start,
|
||||
duration: m.duration,
|
||||
|
||||
@@ -440,6 +440,9 @@ pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> R
|
||||
let t = ctx.rec(*tc, ev.df);
|
||||
let id = MarkerId(ctx.b.alloc());
|
||||
ctx.seq.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id,
|
||||
start: t,
|
||||
duration: Tick::ZERO,
|
||||
@@ -985,6 +988,7 @@ fn track_order(context: &TrackExport<'_>) -> u8 {
|
||||
}
|
||||
|
||||
pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result<String> {
|
||||
crate::warn_marker_metadata_loss(p, seq_id, report);
|
||||
let seq = p.sequence(seq_id).ok_or(crate::Error::NoSequence(seq_id))?;
|
||||
let rate = seq.settings.frame_rate;
|
||||
let df = seq.settings.drop_frame && rate.supports_drop_frame();
|
||||
|
||||
@@ -796,6 +796,9 @@ fn read_markers(n: Node, rate: FrameRate, b: &mut Builder) -> Vec<Marker> {
|
||||
let duration = if out_f > in_f { rate.tick_of(out_f) - start } else { Tick::ZERO };
|
||||
let color = child_text(m, "color").and_then(label_from).unwrap_or(Label::Green);
|
||||
out.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(b.alloc()),
|
||||
start,
|
||||
duration,
|
||||
@@ -827,6 +830,7 @@ struct Exp<'a, 'r> {
|
||||
}
|
||||
|
||||
pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result<String> {
|
||||
crate::warn_marker_metadata_loss(p, seq_id, report);
|
||||
p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?;
|
||||
let mut x = Exp {
|
||||
p,
|
||||
|
||||
@@ -536,6 +536,9 @@ impl<'a, 'i> Imp<'a, 'i, '_> {
|
||||
let start = if src_in == Tick::ZERO && speed == 1.0 { rel } else { src_in + scaled(rel, speed) };
|
||||
let d = attr_time(m, "duration").unwrap_or(Tick::ZERO);
|
||||
out.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(self.b.alloc()),
|
||||
start,
|
||||
duration: if d <= frame { Tick::ZERO } else { d },
|
||||
@@ -704,6 +707,7 @@ struct Exp<'a, 'r> {
|
||||
}
|
||||
|
||||
pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result<String> {
|
||||
crate::warn_marker_metadata_loss(p, seq_id, report);
|
||||
let seq = p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?;
|
||||
let mut x = Exp {
|
||||
p,
|
||||
|
||||
@@ -48,6 +48,47 @@ use serde::{Deserialize, Serialize};
|
||||
pub use common::{file_url_to_path, path_to_file_url, resolve_path};
|
||||
pub use comp::ExtractedMedia;
|
||||
|
||||
/// Warn once when an export cannot carry exact marker metadata, including nested/source markers.
|
||||
fn warn_marker_metadata_loss(project: &Project, sequence: ItemId, report: &mut Report) {
|
||||
if !project.custom_colors.is_empty() {
|
||||
report.warn("This format does not preserve the saved custom-color palette or its swatch names. Use a FilmCraft project or OTIO to retain the palette.");
|
||||
}
|
||||
let mut pending = std::collections::BTreeSet::from([sequence]);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let extra = |m: &filmcraft_project::Marker| m.custom_color.is_some() || !m.author.is_empty() || !m.url.is_empty();
|
||||
while let Some(id) = pending.pop_first() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(item) = project.item(id) else { continue };
|
||||
let has_extra = item.custom_label_color.is_some()
|
||||
|| match &item.kind {
|
||||
ItemKind::Sequence(seq) => {
|
||||
let mut found = seq.markers.iter().any(extra);
|
||||
for clip in seq.all_tracks().flat_map(|track| &track.items) {
|
||||
found |= clip.markers.iter().any(extra) || clip.custom_label_color.is_some();
|
||||
if !seen.contains(&clip.item) {
|
||||
pending.insert(clip.item);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
ItemKind::Media(media) => media.markers.iter().any(extra),
|
||||
ItemKind::Subclip { parent, .. } => {
|
||||
if !seen.contains(parent) {
|
||||
pending.insert(*parent);
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if has_extra {
|
||||
report.warn("This format does not preserve marker authors or exact custom colors (including clip labels), or marker URLs. Use a FilmCraft project or OTIO for those properties.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An interchange format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Format {
|
||||
@@ -462,6 +503,17 @@ pub fn export_edl_per_track(project: &Project, sequence: ItemId, opts: &ExportOp
|
||||
/// top-level sequences.
|
||||
pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>) -> Vec<ItemId> {
|
||||
let Imported { project: frag, sequences } = fragment;
|
||||
for color in &frag.custom_colors {
|
||||
if !target.custom_colors.contains(color) {
|
||||
if target.custom_colors.len() >= 64 {
|
||||
continue;
|
||||
}
|
||||
target.custom_colors.push(*color);
|
||||
}
|
||||
if let Some(name) = frag.custom_color_names.get(color) {
|
||||
target.custom_color_names.entry(*color).or_insert_with(|| name.clone());
|
||||
}
|
||||
}
|
||||
let mut items: HashMap<ItemId, ItemId> = HashMap::new();
|
||||
for id in frag.items.keys() {
|
||||
items.insert(*id, ItemId(target.alloc_id()));
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct OmfOptions {
|
||||
/// Export the audio of `sequence` as an OMF 2.0 file.
|
||||
pub fn export(project: &Project, sequence: ItemId, opts: &OmfOptions) -> Result<(Vec<u8>, Report)> {
|
||||
let mut report = Report::default();
|
||||
crate::warn_marker_metadata_loss(project, sequence, &mut report);
|
||||
let name = opts.name.clone().or_else(|| project.item(sequence).map(|i| i.name.clone())).unwrap_or_else(|| "Sequence".into());
|
||||
let mut media = opts.media.clone();
|
||||
media.audio_only = true;
|
||||
|
||||
@@ -159,8 +159,8 @@ impl Exp<'_, '_> {
|
||||
fn seq_meta(&self, id: ItemId) -> Value {
|
||||
let Some((it, s)) = self.p.item(id).and_then(|it| Some((it, it.as_sequence()?))) else { return json!({}) };
|
||||
json!({
|
||||
"item": id.0,
|
||||
"label": it.label,
|
||||
"item": id.0, "custom_colors": self.p.custom_colors, "custom_color_names": self.p.custom_color_names,
|
||||
"label": it.label, "custom_label_color": it.custom_label_color,
|
||||
"settings": s.settings,
|
||||
"start_timecode": s.start_timecode,
|
||||
"master_volume_db": s.master_volume_db,
|
||||
@@ -245,7 +245,7 @@ impl Exp<'_, '_> {
|
||||
return json!({"OTIO_SCHEMA": "MissingReference.1", "name": name, "available_range": null, "metadata": {}});
|
||||
};
|
||||
let avail = tr(m.info.start_timecode.map(|f| m.frame_rate().tick_of(f)).unwrap_or(Tick::ZERO), m.info.duration, rate);
|
||||
let meta = json!({"filmcraft": {"item": item.0, "label": self.p.item(item).map(|i| i.label), "info": m.info, "offline": m.offline}});
|
||||
let meta = json!({"filmcraft": {"item": item.0, "label": self.p.item(item).map(|i| i.label), "custom_label_color": self.p.item(item).and_then(|i| i.custom_label_color), "info": m.info, "offline": m.offline}});
|
||||
match &m.media {
|
||||
MediaRef::Generator(g) => json!({
|
||||
"OTIO_SCHEMA": "GeneratorReference.1",
|
||||
@@ -253,7 +253,7 @@ impl Exp<'_, '_> {
|
||||
"generator_kind": match g { Generator::ColorMatte { .. } => "SolidColor", Generator::BlackVideo => "black", Generator::BarsAndTone => "SMPTEBars", _ => "FilmCraftGenerator" },
|
||||
"parameters": match g { Generator::ColorMatte { color } => json!({"color": color}), _ => json!({}) },
|
||||
"available_range": avail,
|
||||
"metadata": {"filmcraft": {"item": item.0, "generator": g, "info": m.info}},
|
||||
"metadata": {"filmcraft": {"item": item.0, "generator": g, "info": m.info, "label": self.p.item(item).map(|i| i.label), "custom_label_color": self.p.item(item).and_then(|i| i.custom_label_color)}},
|
||||
}),
|
||||
MediaRef::File { path } => {
|
||||
let url = match &self.opts.relative_to {
|
||||
@@ -280,7 +280,7 @@ impl Exp<'_, '_> {
|
||||
effects.push(json!({"OTIO_SCHEMA": "LinearTimeWarp.1", "name": "", "effect_name": "LinearTimeWarp", "time_scalar": s, "metadata": {}}));
|
||||
}
|
||||
let meta = json!({"filmcraft": {
|
||||
"label": it.label, "link": it.link, "group": it.group, "effects": it.effects,
|
||||
"label": it.label, "custom_label_color": it.custom_label_color, "link": it.link, "group": it.group, "effects": it.effects,
|
||||
"gain_db": it.gain_db, "scale_to_frame": it.scale_to_frame, "source_in_ticks": it.source_in.0,
|
||||
"duration_ticks": it.duration.0,
|
||||
}});
|
||||
@@ -346,7 +346,7 @@ fn marker_json(m: &Marker, rate: FrameRate) -> Value {
|
||||
"comment": m.comment,
|
||||
"color": color_name(m.color),
|
||||
"marked_range": tr(m.start, m.duration, rate),
|
||||
"metadata": {"filmcraft": {"kind": m.kind, "label": m.color, "start_ticks": m.start.0, "duration_ticks": m.duration.0}},
|
||||
"metadata": {"filmcraft": {"kind": m.kind, "url": m.url, "label": m.color, "custom_color": m.custom_color, "author": m.author, "start_ticks": m.start.0, "duration_ticks": m.duration.0}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -448,6 +448,39 @@ fn first_rate(v: &Value) -> Option<FrameRate> {
|
||||
}
|
||||
|
||||
impl Imp<'_, '_> {
|
||||
/// Union each timeline's saved palette into the project's 64 retained colors.
|
||||
/// Keep the first valid name, fill missing names even at capacity, and never keep orphan names.
|
||||
fn merge_palette(&mut self, meta: Option<&Value>) {
|
||||
if let Some(colors) = meta.and_then(|m| m.get("custom_colors")).and_then(Value::as_array) {
|
||||
let mut overflow = false;
|
||||
for color in colors.iter().filter_map(Value::as_str).filter_map(filmcraft_project::HexColor::parse) {
|
||||
if self.b.p.custom_colors.contains(&color) {
|
||||
continue;
|
||||
}
|
||||
if self.b.p.custom_colors.len() >= 64 {
|
||||
overflow = true;
|
||||
break;
|
||||
}
|
||||
self.b.p.custom_colors.push(color);
|
||||
}
|
||||
if overflow {
|
||||
self.report.warn("OTIO saved palettes exceed the 64-color project limit; additional colors were not imported.");
|
||||
}
|
||||
}
|
||||
if let Some(names) = meta.and_then(|m| m.get("custom_color_names")).and_then(Value::as_object) {
|
||||
// Palette membership bounds retained names to 64; orphan input entries consume no slots.
|
||||
for (key, value) in names {
|
||||
if let (Some(color), Some(name)) = (filmcraft_project::HexColor::parse(key), value.as_str())
|
||||
&& self.b.p.custom_colors.contains(&color)
|
||||
&& name.len() <= 128
|
||||
&& !name.chars().any(char::is_control)
|
||||
{
|
||||
self.b.p.custom_color_names.entry(color).or_insert_with(|| name.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline(&mut self, tl: &Value) -> ItemId {
|
||||
let name = name_of(tl);
|
||||
let name = if name.is_empty() { self.opts.name.clone().unwrap_or_else(|| "Timeline".into()) } else { name };
|
||||
@@ -461,6 +494,8 @@ impl Imp<'_, '_> {
|
||||
_ => tl,
|
||||
};
|
||||
let meta = fc(tl).or_else(|| fc(stack));
|
||||
self.merge_palette(meta);
|
||||
|
||||
let rate = tl.get("global_start_time").and_then(read_rate).or_else(|| first_rate(stack)).unwrap_or_default();
|
||||
let id = self.sequence(stack, &name, meta, rate);
|
||||
if let Some(st) = tl.get("global_start_time").and_then(read_rt)
|
||||
@@ -480,6 +515,9 @@ impl Imp<'_, '_> {
|
||||
{
|
||||
it.label = l;
|
||||
}
|
||||
if let Some(it) = self.b.p.item_mut(id) {
|
||||
it.custom_label_color = meta.and_then(|m| m.get("custom_label_color")).and_then(Value::as_str).and_then(filmcraft_project::HexColor::parse);
|
||||
}
|
||||
let mut seq = empty_sequence(settings);
|
||||
if let Some(m) = meta {
|
||||
seq.start_timecode = m.get("start_timecode").and_then(Value::as_i64).unwrap_or(0);
|
||||
@@ -622,6 +660,7 @@ impl Imp<'_, '_> {
|
||||
}
|
||||
}
|
||||
let Some(m) = meta else { return };
|
||||
ti.custom_label_color = m.get("custom_label_color").and_then(Value::as_str).and_then(filmcraft_project::HexColor::parse);
|
||||
if let Some(l) = m.get("label").and_then(|l| serde_json::from_value::<Label>(l.clone()).ok()) {
|
||||
ti.label = l;
|
||||
}
|
||||
@@ -758,11 +797,15 @@ impl Imp<'_, '_> {
|
||||
}
|
||||
}
|
||||
});
|
||||
let key = format!("gen:{}", serde_json::to_string(&g).unwrap_or_default());
|
||||
// Equal generator content can belong to distinct project items; repeated item IDs share media.
|
||||
let key = match meta.and_then(|m| m.get("item")).and_then(Value::as_u64) {
|
||||
Some(item) => format!("gen:item:{item}"),
|
||||
None => format!("gen:{}", serde_json::to_string(&g).unwrap_or_default()),
|
||||
};
|
||||
let existed = self.b.find_media(&key);
|
||||
let id = self.b.generator_media(&key, if name.is_empty() { "Generator" } else { &name }, g, &spec, None);
|
||||
if existed.is_none() {
|
||||
self.fill_media(id, info, None, kind);
|
||||
self.fill_media(id, info, meta, kind);
|
||||
}
|
||||
id
|
||||
}
|
||||
@@ -790,6 +833,7 @@ impl Imp<'_, '_> {
|
||||
let label = meta.and_then(|m| m.get("label")).and_then(|l| serde_json::from_value::<Label>(l.clone()).ok());
|
||||
let offline = meta.and_then(|m| m.get("offline")).and_then(Value::as_bool);
|
||||
let Some(it) = self.b.p.item_mut(id) else { return };
|
||||
it.custom_label_color = meta.and_then(|m| m.get("custom_label_color")).and_then(Value::as_str).and_then(filmcraft_project::HexColor::parse);
|
||||
if let Some(l) = label {
|
||||
it.label = l;
|
||||
}
|
||||
@@ -827,6 +871,9 @@ impl Imp<'_, '_> {
|
||||
.and_then(|l| serde_json::from_value::<Label>(l.clone()).ok())
|
||||
.unwrap_or_else(|| color_label(m.get("color").and_then(Value::as_str).unwrap_or("")));
|
||||
out.push(Marker {
|
||||
url: meta.and_then(|x| x.get("url")).and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
author: meta.and_then(|x| x.get("author")).and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
custom_color: meta.and_then(|x| x.get("custom_color")).and_then(|c| c.as_str()).and_then(filmcraft_project::HexColor::parse),
|
||||
id: MarkerId(self.b.alloc()),
|
||||
start,
|
||||
duration,
|
||||
|
||||
@@ -23,6 +23,9 @@ fn sample(rate: FrameRate, df: bool) -> (Project, ItemId) {
|
||||
if let Some(mm) = p.item_mut(a).and_then(|i| i.as_media_mut()) {
|
||||
mm.info.start_timecode = Some(rate.timecode_base() * 3600 + 12);
|
||||
mm.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(9000),
|
||||
start: rate.tick_of(40),
|
||||
duration: Tick::ZERO,
|
||||
@@ -52,6 +55,9 @@ fn sample(rate: FrameRate, df: bool) -> (Project, ItemId) {
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
q.start_timecode = rate.timecode_base() * 3600;
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(9001),
|
||||
start: rate.tick_of(50),
|
||||
duration: rate.tick_of(10),
|
||||
|
||||
@@ -65,6 +65,66 @@ fn merge_reallocates_ids_and_keeps_links() {
|
||||
assert!(ids.iter().all(|i| *i < target.next_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otio_import_merges_saved_palette_without_duplicates_or_overflow() {
|
||||
use filmcraft_project::HexColor;
|
||||
let mut src = Project::new("source");
|
||||
src.custom_colors = vec![HexColor([1, 2, 3]), HexColor([4, 5, 6]), HexColor([4, 5, 6])];
|
||||
src.custom_color_names.insert(HexColor([4, 5, 6]), "Review".into());
|
||||
let seq = sequence(&mut src, "Sequence", FrameRate::FPS_25, false);
|
||||
let (bytes, _) = export(&src, seq, Format::Otio, &ExportOptions::default()).unwrap();
|
||||
let mut target = Project::new("target");
|
||||
target.custom_colors = vec![HexColor([1, 2, 3])];
|
||||
let (fragment, _) = import(&bytes, Format::Otio, None).unwrap();
|
||||
merge_into(&mut target, fragment, None);
|
||||
assert_eq!(target.custom_colors, vec![HexColor([1, 2, 3]), HexColor([4, 5, 6])]);
|
||||
assert_eq!(target.custom_color_names[&HexColor([4, 5, 6])], "Review");
|
||||
|
||||
target.custom_colors = (0..63).map(|v| HexColor([v, 0, 0])).collect();
|
||||
let (fragment, _) = import(&bytes, Format::Otio, None).unwrap();
|
||||
merge_into(&mut target, fragment, None);
|
||||
assert_eq!(target.custom_colors.len(), 64);
|
||||
assert_eq!(target.custom_colors.last(), Some(&HexColor([1, 2, 3])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_unused_saved_palette_warns_for_lossy_exports() {
|
||||
let mut project = Project::new("Palette only");
|
||||
let seq = sequence(&mut project, "Sequence", FrameRate::FPS_25, false);
|
||||
project.custom_colors.push(filmcraft_project::HexColor([18, 52, 86]));
|
||||
project.custom_color_names.insert(project.custom_colors[0], "Review".into());
|
||||
for format in [Format::Edl, Format::Fcp7Xml, Format::Fcpxml, Format::Aaf, Format::Omf] {
|
||||
let (_, report) = export(&project, seq, format, &ExportOptions::default()).unwrap();
|
||||
assert!(report.mentions("palette"), "{format:?}: {report}");
|
||||
}
|
||||
let (_, report) = export(&project, seq, Format::Otio, &ExportOptions::default()).unwrap();
|
||||
assert!(!report.mentions("palette"), "OTIO preserves the palette: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_palette_import_fills_missing_names_at_capacity_without_orphans() {
|
||||
use filmcraft_project::HexColor;
|
||||
let unnamed = HexColor([1, 0, 0]);
|
||||
let named = HexColor([2, 0, 0]);
|
||||
let excluded = HexColor([255, 255, 255]);
|
||||
for count in [3, 64] {
|
||||
let mut target = Project::new("target");
|
||||
target.custom_colors = (0..count).map(|v| HexColor([v, 0, 0])).collect();
|
||||
target.custom_color_names.insert(named, "Keep target name".into());
|
||||
let mut fragment = Project::new("fragment");
|
||||
// A new color precedes existing colors: hitting the limit must not end name merging.
|
||||
fragment.custom_colors = vec![excluded, unnamed, named];
|
||||
fragment.custom_color_names.insert(excluded, "New color".into());
|
||||
fragment.custom_color_names.insert(unnamed, "Imported name".into());
|
||||
fragment.custom_color_names.insert(named, "Do not overwrite".into());
|
||||
merge_into(&mut target, filmcraft_interchange::Imported { project: fragment, sequences: vec![] }, None);
|
||||
assert_eq!(target.custom_color_names.get(&unnamed).map(String::as_str), Some("Imported name"));
|
||||
assert_eq!(target.custom_color_names.get(&named).map(String::as_str), Some("Keep target name"));
|
||||
assert_eq!(target.custom_colors.len(), if count == 64 { 64 } else { 4 });
|
||||
assert_eq!(target.custom_color_names.contains_key(&excluded), count < 64, "no name may reference a rejected swatch");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_shifts_source_times() {
|
||||
let edl = "TITLE: T\nFCM: NON-DROP FRAME\n\n001 CAM V C 01:00:10:00 01:00:12:00 01:00:00:00 01:00:02:00\n* FROM CLIP NAME: cam.mov\n* SOURCE FILE: /m/cam.mov\n";
|
||||
|
||||
@@ -300,6 +300,9 @@ fn rich_project() -> (Project, filmcraft_project::ItemId) {
|
||||
it.effect_mut("opacity").unwrap().params.insert("opacity".into(), Param::new(ParamValue::Float(75.0)));
|
||||
it.effects.push(filmcraft_project::find_effect("gaussian_blur").unwrap().instance());
|
||||
it.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(900),
|
||||
start: r.tick_of(310),
|
||||
duration: r.tick_of(5),
|
||||
@@ -314,6 +317,9 @@ fn rich_project() -> (Project, filmcraft_project::ItemId) {
|
||||
let (_, ai) = q.find_item_mut(a1).unwrap();
|
||||
ai.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.0)));
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(901),
|
||||
start: r.tick_of(12),
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -318,6 +318,9 @@ fn project() -> (Project, filmcraft_project::ItemId) {
|
||||
u.enabled = false;
|
||||
u.effect_mut("opacity").unwrap().params.insert("opacity".into(), Param::new(ParamValue::Float(40.0)));
|
||||
u.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(77),
|
||||
start: r.tick_of(10),
|
||||
duration: filmcraft_time::Tick::ZERO,
|
||||
@@ -327,6 +330,9 @@ fn project() -> (Project, filmcraft_project::ItemId) {
|
||||
color: Label::Blue,
|
||||
});
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(78),
|
||||
start: r.tick_of(100),
|
||||
duration: r.tick_of(5),
|
||||
|
||||
@@ -12,6 +12,9 @@ fn project_with_media_marker(rate: FrameRate) -> (filmcraft_project::Project, fi
|
||||
let a = media(&mut p, "/m/src.mov", true, true, rate);
|
||||
if let Some(m) = p.item_mut(a).and_then(|i| i.as_media_mut()) {
|
||||
m.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(1),
|
||||
start: rate.tick_of(24),
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -20,6 +20,9 @@ fn nest_project(clip_has_marker: bool) -> (filmcraft_project::Project, filmcraft
|
||||
{
|
||||
let q = p.sequence_mut(nest).unwrap();
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(1),
|
||||
start: r.tick_of(12),
|
||||
duration: Tick::ZERO,
|
||||
@@ -35,6 +38,9 @@ fn nest_project(clip_has_marker: bool) -> (filmcraft_project::Project, filmcraft
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
let (_, it) = q.find_item_mut(nest_clip).unwrap();
|
||||
it.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(2),
|
||||
start: r.tick_of(6),
|
||||
duration: Tick::ZERO,
|
||||
|
||||
@@ -143,7 +143,9 @@ fn golden_gaps_and_transitions() {
|
||||
fn project() -> (Project, filmcraft_project::ItemId) {
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
p.custom_colors = vec![filmcraft_project::HexColor([18, 171, 205])];
|
||||
let a = media(&mut p, "/m/a.mov", true, true, r);
|
||||
p.item_mut(a).unwrap().custom_label_color = filmcraft_project::HexColor::parse("#ABCDEF");
|
||||
if let Some(m) = p.item_mut(a).and_then(|i| i.as_media_mut()) {
|
||||
m.info.start_timecode = Some(86_400);
|
||||
}
|
||||
@@ -170,12 +172,16 @@ fn project() -> (Project, filmcraft_project::ItemId) {
|
||||
q.audio_tracks[1].items.push(ai);
|
||||
let (_, it) = q.find_item_mut(v1).unwrap();
|
||||
it.label = Label::Teal;
|
||||
it.custom_label_color = filmcraft_project::HexColor::parse("#12ABCD");
|
||||
let mut op = Param::new(ParamValue::Float(100.0));
|
||||
op.keyframes.push(Keyframe::new(r.tick_of(24), ParamValue::Float(0.0)));
|
||||
op.keyframes.push(Keyframe::new(r.tick_of(36), ParamValue::Float(100.0)));
|
||||
it.effect_mut("opacity").unwrap().params.insert("opacity".into(), op);
|
||||
it.effects.push(filmcraft_project::find_effect("gaussian_blur").unwrap().instance());
|
||||
it.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: "Editor 日本語".into(),
|
||||
custom_color: filmcraft_project::HexColor::parse("#FF80C0"),
|
||||
id: MarkerId(5),
|
||||
start: r.tick_of(30),
|
||||
duration: r.tick_of(2),
|
||||
@@ -190,6 +196,9 @@ fn project() -> (Project, filmcraft_project::ItemId) {
|
||||
q.video_tracks[1].locked = true;
|
||||
q.audio_tracks[1].volume_db = -3.0;
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: "Editor 日本語".into(),
|
||||
custom_color: filmcraft_project::HexColor::parse("#FF80C0"),
|
||||
id: MarkerId(6),
|
||||
start: r.tick_of(70),
|
||||
duration: Tick::ZERO,
|
||||
@@ -211,6 +220,8 @@ fn lossless_roundtrip_with_filmcraft_metadata() {
|
||||
assert!(text.contains("\"target_url\": \"file:///m/a.mov\""));
|
||||
let si = only_seq(&imp);
|
||||
let q = &imp.project;
|
||||
assert_eq!(q.custom_colors, p.custom_colors);
|
||||
assert!(q.items.values().any(|i| i.custom_label_color == filmcraft_project::HexColor::parse("#ABCDEF")));
|
||||
let (qs, ps) = (q.sequence(si).unwrap(), p.sequence(s).unwrap());
|
||||
for kind in [TrackKind::Video, TrackKind::Audio] {
|
||||
assert_eq!(structure(q, si, kind), structure(&p, s, kind), "{text}");
|
||||
@@ -218,8 +229,8 @@ fn lossless_roundtrip_with_filmcraft_metadata() {
|
||||
assert_eq!(links(q, si), links(&p, s));
|
||||
assert_eq!(qs.settings, ps.settings);
|
||||
assert_eq!(
|
||||
qs.markers.iter().map(|m| (&m.name, m.start, m.kind, m.color)).collect::<Vec<_>>(),
|
||||
ps.markers.iter().map(|m| (&m.name, m.start, m.kind, m.color)).collect::<Vec<_>>()
|
||||
qs.markers.iter().map(|m| (&m.name, m.start, m.kind, m.color, m.custom_color, &m.author)).collect::<Vec<_>>(),
|
||||
ps.markers.iter().map(|m| (&m.name, m.start, m.kind, m.color, m.custom_color, &m.author)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(qs.video_tracks[1].locked);
|
||||
assert_eq!(qs.audio_tracks[1].volume_db, -3.0);
|
||||
@@ -227,9 +238,10 @@ fn lossless_roundtrip_with_filmcraft_metadata() {
|
||||
let (qa, pa) = (&qs.video_tracks[0].items[0], &ps.video_tracks[0].items[0]);
|
||||
assert_eq!(format!("{:?}", qa.effects), format!("{:?}", pa.effects)); // NaN auto points compare unequal
|
||||
assert_eq!(qa.label, Label::Teal);
|
||||
assert_eq!(qa.custom_label_color, filmcraft_project::HexColor::parse("#12ABCD"));
|
||||
assert_eq!(
|
||||
qa.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>(),
|
||||
pa.markers.iter().map(|m| (&m.name, m.start, m.duration)).collect::<Vec<_>>()
|
||||
qa.markers.iter().map(|m| (&m.name, m.start, m.duration, m.custom_color, &m.author)).collect::<Vec<_>>(),
|
||||
pa.markers.iter().map(|m| (&m.name, m.start, m.duration, m.custom_color, &m.author)).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(qs.audio_tracks[1].items[0].gain_db, 4.5);
|
||||
assert_eq!(qs.audio_tracks[1].items[0].start, ps.audio_tracks[1].items[0].start);
|
||||
@@ -272,6 +284,188 @@ fn collections_and_bare_tracks() {
|
||||
assert!(import(b"not json", Format::Otio, None).is_err());
|
||||
}
|
||||
|
||||
fn palette_collection(first: serde_json::Value, second: serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({"OTIO_SCHEMA":"SerializableCollection.1","children":[
|
||||
{"OTIO_SCHEMA":"Timeline.1","name":"First","metadata":{"filmcraft":first},"tracks":{"OTIO_SCHEMA":"Stack.1","children":[]}},
|
||||
{"OTIO_SCHEMA":"Timeline.1","name":"Second","metadata":{"filmcraft":second},"tracks":{"OTIO_SCHEMA":"Stack.1","children":[]}}
|
||||
]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_orphan_palette_names_do_not_hide_retained_names() {
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::json;
|
||||
let mut names = serde_json::Map::new();
|
||||
for i in 0..70 {
|
||||
names.insert(HexColor([0, 0, i]).hex(), json!("Orphan"));
|
||||
}
|
||||
names.insert("#FFFFFE".into(), json!("Later name"));
|
||||
names.insert("#FFFFFF".into(), json!("Retained name"));
|
||||
let doc = palette_collection(
|
||||
json!({"custom_colors":["#FFFFFE"],"custom_color_names":{"#FFFFFE":"Original name"}}),
|
||||
json!({"custom_colors":["#FFFFFF"],"custom_color_names":names}),
|
||||
);
|
||||
let (imp, _) = import(&serde_json::to_vec(&doc).unwrap(), Format::Otio, None).unwrap();
|
||||
assert_eq!(imp.project.custom_color_names.len(), 2);
|
||||
assert_eq!(imp.project.custom_color_names[&HexColor([255, 255, 254])], "Original name");
|
||||
assert_eq!(imp.project.custom_color_names[&HexColor([255, 255, 255])], "Retained name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_generator_preset_labels_survive_otio_round_trip() {
|
||||
let mut p = Project::new("Generator labels");
|
||||
let s = sequence(&mut p, "Main", FrameRate::FPS_25, false);
|
||||
for (index, (kind, label, custom)) in [
|
||||
(Generator::BlackVideo, Label::Teal, None),
|
||||
(Generator::BarsAndTone, Label::Magenta, filmcraft_project::HexColor::parse("#123456")),
|
||||
(Generator::ColorMatte { color: [0.2, 0.4, 0.6, 1.0] }, Label::Mango, None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = generator(&mut p, &format!("Generator {index}"), kind);
|
||||
let item = p.item_mut(id).unwrap();
|
||||
item.label = label;
|
||||
item.custom_label_color = custom;
|
||||
clip(&mut p, s, TrackKind::Video, 0, id, (index as i64 * 25)..(index as i64 * 25 + 25), 0);
|
||||
}
|
||||
let (bytes, _) = filmcraft_interchange::export(&p, s, Format::Otio, &ExportOptions::default()).unwrap();
|
||||
let (imp, _) = import(&bytes, Format::Otio, None).unwrap();
|
||||
for original in p.items.values().filter(|i| i.as_media().is_some()) {
|
||||
let restored = imp.project.items.values().find(|i| i.name == original.name).unwrap();
|
||||
assert_eq!(restored.label, original.label, "{}", original.name);
|
||||
assert_eq!(restored.custom_label_color, original.custom_label_color);
|
||||
assert_eq!(restored.as_media().unwrap().media, original.as_media().unwrap().media);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_distinct_identical_generators_keep_identity_and_shared_references() {
|
||||
let mut p = Project::new("Generator identity");
|
||||
let s = sequence(&mut p, "Main", FrameRate::FPS_25, false);
|
||||
let mut pairs = Vec::new();
|
||||
for (index, kind) in [Generator::BlackVideo, Generator::BarsAndTone, Generator::ColorMatte { color: [0.2, 0.4, 0.6, 1.0] }].into_iter().enumerate() {
|
||||
let first = generator(&mut p, &format!("First {index}"), kind.clone());
|
||||
let second = generator(&mut p, &format!("Second {index}"), kind);
|
||||
p.item_mut(first).unwrap().label = Label::Teal;
|
||||
let item = p.item_mut(second).unwrap();
|
||||
item.label = Label::Mango;
|
||||
item.custom_label_color = filmcraft_project::HexColor::parse("#123456");
|
||||
for (offset, id) in [first, second, first].into_iter().enumerate() {
|
||||
clip(&mut p, s, TrackKind::Video, 0, id, ((index * 3 + offset) as i64 * 25)..((index * 3 + offset) as i64 * 25 + 25), 0);
|
||||
}
|
||||
pairs.push((first, second));
|
||||
}
|
||||
let (imp, _, _) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
|
||||
let clips = &imp.project.sequence(only_seq(&imp)).unwrap().video_tracks[0].items;
|
||||
assert_eq!(clips.len(), 9);
|
||||
assert_eq!(imp.project.items.values().filter(|i| i.as_media().is_some()).count(), 6);
|
||||
for ((first, second), group) in pairs.into_iter().zip(clips.as_chunks::<3>().0) {
|
||||
assert_ne!(group[0].item, group[1].item, "distinct source items must remain distinct");
|
||||
assert_eq!(group[0].item, group[2].item, "repeated source references must remain shared");
|
||||
for (original_id, clip) in [(first, &group[0]), (second, &group[1])] {
|
||||
let original = p.item(original_id).unwrap();
|
||||
let restored = imp.project.item(clip.item).unwrap();
|
||||
assert_eq!(restored.name, original.name);
|
||||
assert_eq!(restored.label, original.label);
|
||||
assert_eq!(restored.custom_label_color, original.custom_label_color);
|
||||
assert_eq!(restored.as_media().unwrap().media, original.as_media().unwrap().media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_generators_without_valid_item_ids_keep_legacy_sharing() {
|
||||
use serde_json::json;
|
||||
let mut p = Project::new("Legacy generators");
|
||||
let s = sequence(&mut p, "Main", FrameRate::FPS_25, false);
|
||||
let first = generator(&mut p, "Black", Generator::BlackVideo);
|
||||
let second = generator(&mut p, "Other Black", Generator::BlackVideo);
|
||||
let bars = generator(&mut p, "Bars", Generator::BarsAndTone);
|
||||
for (offset, id) in [first, second, first, bars].into_iter().enumerate() {
|
||||
clip(&mut p, s, TrackKind::Video, 0, id, (offset as i64 * 25)..(offset as i64 * 25 + 25), 0);
|
||||
}
|
||||
let (bytes, _) = filmcraft_interchange::export(&p, s, Format::Otio, &ExportOptions::default()).unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
for identity in [None, Some(json!(null)), Some(json!(-1)), Some(json!("invalid")), Some(json!({})), Some(json!([]))] {
|
||||
let mut doc = doc.clone();
|
||||
for clip in doc["tracks"]["children"][0]["children"].as_array_mut().unwrap() {
|
||||
let meta = clip["media_references"]["DEFAULT_MEDIA"]["metadata"]["filmcraft"].as_object_mut().unwrap();
|
||||
meta.remove("item");
|
||||
if let Some(ref identity) = identity {
|
||||
meta.insert("item".into(), identity.clone());
|
||||
}
|
||||
}
|
||||
let (imp, _) = import(&serde_json::to_vec(&doc).unwrap(), Format::Otio, None).unwrap();
|
||||
let clips = &imp.project.sequence(only_seq(&imp)).unwrap().video_tracks[0].items;
|
||||
assert_eq!(clips[0].item, clips[1].item);
|
||||
assert_eq!(clips[0].item, clips[2].item);
|
||||
assert_ne!(clips[0].item, clips[3].item);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_collection_palettes_union_distinct_colors_and_preserve_first_names() {
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::json;
|
||||
let doc = palette_collection(
|
||||
json!({"custom_colors":["#112233"],"custom_color_names":{"#112233":"First name"}}),
|
||||
json!({"custom_colors":["#445566","#112233"],"custom_color_names":{"#445566":"Second name","#112233":"Later name"}}),
|
||||
);
|
||||
let (imp, _) = import(&serde_json::to_vec(&doc).unwrap(), Format::Otio, None).unwrap();
|
||||
let colors = [HexColor([0x11, 0x22, 0x33]), HexColor([0x44, 0x55, 0x66])];
|
||||
assert_eq!(imp.project.custom_colors, colors);
|
||||
assert_eq!(imp.project.custom_color_names[&colors[0]], "First name");
|
||||
assert_eq!(imp.project.custom_color_names[&colors[1]], "Second name");
|
||||
let mut target = Project::new("Existing");
|
||||
filmcraft_interchange::merge_into(&mut target, imp, None);
|
||||
assert_eq!(target.custom_colors, colors);
|
||||
assert_eq!(target.custom_color_names[&colors[0]], "First name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_collection_empty_or_absent_palettes_do_not_clear_earlier_colors() {
|
||||
use serde_json::json;
|
||||
for later in [json!({"custom_colors":[]}), json!({})] {
|
||||
let doc = palette_collection(json!({"custom_colors":["#112233"],"custom_color_names":{"#112233":"Saved"}}), later);
|
||||
let (imp, _) = import(&serde_json::to_vec(&doc).unwrap(), Format::Otio, None).unwrap();
|
||||
let mut target = Project::new("Existing");
|
||||
filmcraft_interchange::merge_into(&mut target, imp, None);
|
||||
let color = filmcraft_project::HexColor([0x11, 0x22, 0x33]);
|
||||
assert_eq!(target.custom_colors, [color]);
|
||||
assert_eq!(target.custom_color_names[&color], "Saved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_collection_full_palette_keeps_names_with_retained_colors() {
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::json;
|
||||
let colors: Vec<_> = (0..64).map(|i| HexColor([0, 0, i])).collect();
|
||||
let doc = palette_collection(
|
||||
json!({"custom_colors":colors.iter().map(|c|c.hex()).collect::<Vec<_>>(),"custom_color_names":{"#000000":"Original"}}),
|
||||
json!({"custom_colors":["#445566","#000001","#000000"],"custom_color_names":{"#445566":"Cannot retain","#000001":"Filled","#000000":"Later"}}),
|
||||
);
|
||||
let (imp, report) = import(&serde_json::to_vec(&doc).unwrap(), Format::Otio, None).unwrap();
|
||||
assert_eq!(imp.project.custom_colors, colors);
|
||||
assert_eq!(imp.project.custom_color_names[&colors[0]], "Original");
|
||||
assert_eq!(imp.project.custom_color_names[&colors[1]], "Filled");
|
||||
assert!(!imp.project.custom_color_names.contains_key(&HexColor([0x44, 0x55, 0x66])));
|
||||
assert!(report.to_string().contains("64"), "discarded palette entries must be reported: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_collection_palette_deduplicates_before_applying_capacity() {
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::json;
|
||||
let mut entries = vec!["#000000".to_owned(); 70];
|
||||
entries.extend((0..70).map(|i| HexColor([0, 0, i]).hex()));
|
||||
let doc = palette_collection(json!({"custom_colors":entries}), json!({}));
|
||||
let (imp, report) = import(&serde_json::to_vec(&doc).unwrap(), Format::Otio, None).unwrap();
|
||||
assert_eq!(imp.project.custom_colors, (0..64).map(|i| HexColor([0, 0, i])).collect::<Vec<_>>());
|
||||
assert!(report.to_string().contains("64"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct G {
|
||||
track: usize,
|
||||
@@ -361,6 +555,77 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_export_formats_report_marker_property_loss() {
|
||||
let (p, s) = project();
|
||||
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Edl] {
|
||||
let (_, report) = filmcraft_interchange::export(&p, s, format, &ExportOptions::default()).unwrap();
|
||||
assert!(report.to_string().contains("marker authors or exact custom colors"), "{format:?}: {report}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_urls_roundtrip_in_otio_and_warn_in_other_formats() {
|
||||
let (mut p, s) = project();
|
||||
let sequence_url = "https://example.com/sequence?q=日本語#review";
|
||||
let clip_url = "https://example.com/clip?take=2";
|
||||
{
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
q.markers[0].url = sequence_url.into();
|
||||
q.markers[0].kind = MarkerKind::WebLink;
|
||||
q.video_tracks[0].items[0].markers[0].url = clip_url.into();
|
||||
q.video_tracks[0].items[0].markers[0].kind = MarkerKind::WebLink;
|
||||
}
|
||||
let (imp, _, report) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
|
||||
assert!(!report.has_warnings(), "{report}");
|
||||
let imported = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
assert_eq!(imported.markers[0].url, sequence_url);
|
||||
assert_eq!(imported.video_tracks[0].items[0].markers[0].url, clip_url);
|
||||
assert_eq!(imported.markers[0].comment, p.sequence(s).unwrap().markers[0].comment);
|
||||
// Imported metadata remains editable text; it must never become permission to launch a handler.
|
||||
let unsafe_url = "javascript:alert(1)";
|
||||
p.sequence_mut(s).unwrap().markers[0].url = unsafe_url.into();
|
||||
let (untrusted, _, _) = roundtrip(&p, s, Format::Otio, &ExportOptions::default());
|
||||
let retained = &untrusted.project.sequence(only_seq(&untrusted)).unwrap().markers[0].url;
|
||||
assert_eq!(retained, unsafe_url);
|
||||
assert!(filmcraft_project::marker_web_url(retained).is_none());
|
||||
p.sequence_mut(s).unwrap().markers[0].url = sequence_url.into();
|
||||
|
||||
// URL loss is reported even if there are no authors, custom colors or saved swatches.
|
||||
p.custom_colors.clear();
|
||||
p.custom_color_names.clear();
|
||||
for item in p.items.values_mut() {
|
||||
item.custom_label_color = None;
|
||||
match &mut item.kind {
|
||||
filmcraft_project::ItemKind::Sequence(seq) => {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for marker in &mut seq.markers {
|
||||
marker.author.clear();
|
||||
marker.custom_color = None;
|
||||
}
|
||||
for clip in seq.all_tracks_mut().flat_map(|track| &mut track.items) {
|
||||
clip.custom_label_color = None;
|
||||
for marker in &mut clip.markers {
|
||||
marker.author.clear();
|
||||
marker.custom_color = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
filmcraft_project::ItemKind::Media(media) => {
|
||||
for marker in &mut media.markers {
|
||||
marker.author.clear();
|
||||
marker.custom_color = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Edl, Format::Aaf, Format::Omf] {
|
||||
let (_, report) = filmcraft_interchange::export(&p, s, format, &ExportOptions::default()).unwrap();
|
||||
assert!(report.to_string().contains("marker URLs"), "{format:?}: {report}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2195: a relative `target_url` is a URL: `Clip%20A.mov` is the file `Clip A.mov`.
|
||||
#[test]
|
||||
fn relative_target_urls_are_percent_decoded() {
|
||||
|
||||
@@ -58,6 +58,9 @@ fn point_roundtrips_through_the_one_frame_convention() {
|
||||
{
|
||||
let q = p.sequence_mut(s).unwrap();
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(1),
|
||||
start: r.tick_of(24),
|
||||
duration: Tick::ZERO,
|
||||
@@ -67,6 +70,9 @@ fn point_roundtrips_through_the_one_frame_convention() {
|
||||
color: Label::Blue,
|
||||
});
|
||||
q.markers.push(Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: MarkerId(2),
|
||||
start: r.tick_of(48),
|
||||
duration: r.tick_of(5),
|
||||
|
||||
@@ -16,6 +16,7 @@ filmcraft-color = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -760,6 +760,7 @@ mod tests {
|
||||
|
||||
fn clip() -> TrackItem {
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: crate::ClipId(1),
|
||||
item: crate::ItemId(2),
|
||||
name: "d".into(),
|
||||
|
||||
@@ -116,7 +116,7 @@ pub fn column_text(p: &Project, it: &ProjectItem, column: &str) -> String {
|
||||
};
|
||||
match column.to_ascii_lowercase().as_str() {
|
||||
"name" => it.name.clone(),
|
||||
"label" => it.label.name().to_string(),
|
||||
"label" => it.label_value(),
|
||||
"media type" | "type" => it.type_label().to_string(),
|
||||
"frame rate" => {
|
||||
if it.has_video() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::HexColor;
|
||||
use super::label::Label;
|
||||
use super::sequence::{Sequence, SplitMarks, checked_par};
|
||||
use super::track::{AudioChannels, Marker};
|
||||
@@ -144,6 +145,8 @@ pub struct ProjectItem {
|
||||
pub id: ItemId,
|
||||
pub name: String,
|
||||
pub label: Label,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_label_color: Option<HexColor>,
|
||||
pub kind: ItemKind,
|
||||
/// Free-form metadata (Description, Scene, Shot, Log Note…).
|
||||
#[serde(default)]
|
||||
|
||||
@@ -81,17 +81,14 @@ impl Label {
|
||||
Label::Yellow => [0x6e, 0x66, 0x28],
|
||||
}
|
||||
}
|
||||
/// Marker colours (Markers panel chips).
|
||||
/// Marker and clip defaults share the same sixteen preset colors.
|
||||
pub fn marker_rgb(self) -> [u8; 3] {
|
||||
match self {
|
||||
Label::Rose | Label::Magenta => [0xc1, 0x3c, 0x3d],
|
||||
Label::Purple | Label::Violet | Label::Lavender => [0xa9, 0x8c, 0xaf],
|
||||
Label::Mango | Label::Brown | Label::Tan => [0xda, 0x76, 0x39],
|
||||
Label::Yellow => [0xc9, 0xa3, 0x44],
|
||||
Label::Blue | Label::Iris | Label::Cerulean => [0x56, 0x8b, 0xf4],
|
||||
Label::Teal | Label::Caribbean => [0x72, 0xf0, 0xd7],
|
||||
_ => [0x75, 0x85, 0x42],
|
||||
}
|
||||
self.rgb()
|
||||
}
|
||||
/// Alternate user-facing color names resolve to presets, not additional swatches.
|
||||
pub const COLOR_ALIASES: [(&'static str, Label); 4] = [("Red", Label::Rose), ("Orange", Label::Mango), ("Cyan", Label::Teal), ("Pink", Label::Magenta)];
|
||||
pub fn from_color_name(name: &str) -> Option<Label> {
|
||||
Self::from_name(name).or_else(|| Self::COLOR_ALIASES.iter().find(|(alias, _)| alias.eq_ignore_ascii_case(name)).map(|(_, label)| *label))
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Label> {
|
||||
Self::ALL.iter().copied().find(|l| l.name().eq_ignore_ascii_case(s))
|
||||
|
||||
@@ -17,6 +17,10 @@ pub mod graphic;
|
||||
pub mod graphic_design;
|
||||
pub mod gtemplate;
|
||||
pub mod keyframe;
|
||||
mod marker_color;
|
||||
pub use marker_color::HexColor;
|
||||
mod marker_url;
|
||||
pub use marker_url::marker_web_url;
|
||||
pub mod mask;
|
||||
pub mod mixer;
|
||||
pub mod multicam;
|
||||
@@ -82,6 +86,11 @@ id_type!(MarkerId);
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
/// Custom marker/clip swatches saved only with this project (schema v16).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub custom_colors: Vec<HexColor>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub custom_color_names: BTreeMap<HexColor, String>,
|
||||
pub settings: ProjectSettings,
|
||||
pub root: Bin,
|
||||
pub items: BTreeMap<ItemId, ProjectItem>,
|
||||
@@ -128,6 +137,8 @@ impl Project {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
custom_colors: Vec::new(),
|
||||
custom_color_names: BTreeMap::new(),
|
||||
settings: ProjectSettings::default(),
|
||||
root: Bin { id: BinId(0), name: name.into(), children: Vec::new() },
|
||||
items: BTreeMap::new(),
|
||||
@@ -217,7 +228,10 @@ impl Project {
|
||||
pub fn add_item(&mut self, name: &str, label: Label, kind: ItemKind, bin: Option<BinId>) -> ItemId {
|
||||
let id = ItemId(self.alloc_id());
|
||||
let created = id.0;
|
||||
self.items.insert(id, ProjectItem { id, name: name.into(), label, kind, metadata: BTreeMap::new(), created, split: SplitMarks::default() });
|
||||
self.items.insert(
|
||||
id,
|
||||
ProjectItem { id, name: name.into(), label, custom_label_color: None, kind, metadata: BTreeMap::new(), created, split: SplitMarks::default() },
|
||||
);
|
||||
if let Some(b) = bin.and_then(|b| self.root.find_bin_mut(b)) {
|
||||
b.children.push(BinEntry::Item(id));
|
||||
} else {
|
||||
@@ -454,6 +468,7 @@ impl Project {
|
||||
let id = ClipId(self.alloc_id());
|
||||
let dur = seq_rate.snap_nearest(source.duration).max(seq_rate.frame_duration());
|
||||
Some(TrackItem {
|
||||
custom_label_color: self.item(item).and_then(|i| i.custom_label_color),
|
||||
id,
|
||||
item,
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Exact custom marker colors, serialized as canonical sRGB hex strings.
|
||||
use crate::Marker;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct HexColor(pub [u8; 3]);
|
||||
|
||||
impl HexColor {
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
let text = text.strip_prefix('#').unwrap_or(text);
|
||||
if text.len() != 6 || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |at| u8::from_str_radix(text.get(at..at + 2)?, 16).ok();
|
||||
Some(Self([byte(0)?, byte(2)?, byte(4)?]))
|
||||
}
|
||||
|
||||
pub fn hex(self) -> String {
|
||||
let [r, g, b] = self.0;
|
||||
format!("#{r:02X}{g:02X}{b:02X}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for HexColor {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for HexColor {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let text = String::deserialize(deserializer)?;
|
||||
Self::parse(&text).ok_or_else(|| serde::de::Error::custom("expected a six-digit sRGB hex color (#RRGGBB)"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Marker {
|
||||
pub fn rgb(&self) -> [u8; 3] {
|
||||
self.custom_color.map_or_else(|| self.color.marker_rgb(), |c| c.0)
|
||||
}
|
||||
|
||||
/// Preset name or exact custom hex, for commands and reports.
|
||||
pub fn color_value(&self) -> String {
|
||||
self.custom_color.map_or_else(|| self.color.name().to_string(), HexColor::hex)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ProjectItem {
|
||||
pub fn label_value(&self) -> String {
|
||||
self.custom_label_color.map_or_else(|| self.label.name().to_string(), HexColor::hex)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::TrackItem {
|
||||
pub fn label_value(&self) -> String {
|
||||
self.custom_label_color.map_or_else(|| self.label.name().to_string(), HexColor::hex)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_round_trip_and_hostile_input() {
|
||||
let color: HexColor = serde_json::from_str("\"#aBcD09\"").unwrap();
|
||||
assert_eq!(color.0, [171, 205, 9]);
|
||||
assert_eq!(serde_json::to_string(&color).unwrap(), "\"#ABCD09\"");
|
||||
for s in ["", "#FFF", "#12345678", "#GG0000", "€abc", "000000 "] {
|
||||
assert!(HexColor::parse(s).is_none(), "{s}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn legacy_marker_keeps_preset_and_has_no_invented_author() {
|
||||
let value = serde_json::json!({"id":1,"start":0,"duration":0,"name":"Old", "comment":"", "kind":"Comment", "color":"Magenta"});
|
||||
let marker: Marker = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(marker.custom_color, None);
|
||||
assert_eq!(marker.author, "");
|
||||
assert!(marker.url.is_empty());
|
||||
assert_eq!(marker.rgb(), crate::Label::Magenta.marker_rgb());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! The same URL policy applies to command edits and explicit browser opening.
|
||||
|
||||
/// Return a browser-ready absolute HTTP(S) URL, or `None` for empty/invalid addresses.
|
||||
/// Never pass project text directly to the host opener: projects and interchange are untrusted.
|
||||
pub fn marker_web_url(value: &str) -> Option<String> {
|
||||
if value.len() > 8192 || value.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim();
|
||||
let (_, rest) = value.split_once(':')?;
|
||||
if !rest.starts_with("//") || value.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
let url = url::Url::parse(value).ok()?;
|
||||
if !matches!(url.scheme(), "http" | "https") || !url.has_host() || !url.username().is_empty() || url.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(url.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn web_addresses_are_parsed_and_other_handlers_are_rejected() {
|
||||
assert_eq!(marker_web_url(" HTTPS://Example.com/review?q=a%20b#shot ").as_deref(), Some("https://example.com/review?q=a%20b#shot"));
|
||||
assert_eq!(marker_web_url("http://localhost:8080/review").as_deref(), Some("http://localhost:8080/review"));
|
||||
assert!(marker_web_url("https://例え.テスト/場面").is_some());
|
||||
for bad in [
|
||||
"",
|
||||
"example.com",
|
||||
"//example.com",
|
||||
"https:",
|
||||
"https:example.com",
|
||||
"https://",
|
||||
"https://a:99999",
|
||||
"https://a\\b",
|
||||
"https://user:pass@example.com",
|
||||
"file:///tmp/a",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,test",
|
||||
"mailto:a@example.com",
|
||||
"custom://app",
|
||||
"https://exa\nmple.com",
|
||||
"https://a\0",
|
||||
"https://a\t",
|
||||
] {
|
||||
assert_eq!(marker_web_url(bad), None, "{bad:?}");
|
||||
}
|
||||
assert!(marker_web_url(&format!("https://example.com/{}", "a".repeat(8192))).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::HexColor;
|
||||
use super::label::Label;
|
||||
use super::sequence::bounded_time_range;
|
||||
use super::{
|
||||
@@ -19,6 +20,16 @@ pub enum MarkerKind {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Marker {
|
||||
/// Optional web address, separate from notes and retained when changing marker kind.
|
||||
/// Opening is explicit and must pass `marker_web_url`, including for imported projects.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub url: String,
|
||||
/// Optional user attribution. Empty in older projects; never inferred from the OS account.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub author: String,
|
||||
/// Exact sRGB marker color. None retains the existing preset and its display mapping.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_color: Option<HexColor>,
|
||||
pub id: MarkerId,
|
||||
pub start: Tick,
|
||||
pub duration: Tick,
|
||||
@@ -55,6 +66,8 @@ pub struct TrackItem {
|
||||
pub item: ItemId,
|
||||
pub name: String,
|
||||
pub label: Label,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_label_color: Option<HexColor>,
|
||||
/// Timeline position (sequence ticks).
|
||||
pub start: Tick,
|
||||
/// Duration on the timeline.
|
||||
|
||||
@@ -132,6 +132,79 @@ through More, including their selected state and Source/Program routing. A scrol
|
||||
retains all commands if even More cannot fit. `monitor_geometry` checks the painted readouts,
|
||||
control bounds, overflow actions and Source drag payloads across monitor widths and themes.
|
||||
|
||||
## Marker properties
|
||||
|
||||
Right-click a timeline or Markers-panel marker and choose **Edit Marker…**. The window
|
||||
edits time/duration, name, notes, author, type, and color. Existing presets are
|
||||
shown as swatches; project custom swatches are below them. Done makes one undoable marker
|
||||
edit; Cancel discards the marker draft.
|
||||
The compact form orders Name, Author, Time/Duration, scrolling Notes, then Color and
|
||||
marker types. Previous/Next save valid changes before opening the neighbouring marker;
|
||||
unchanged navigation adds no Undo step. Unchecked Duration saves a point marker even
|
||||
when its disabled field retains a draft value. Timecodes require complete numeric fields
|
||||
with colons; Done stays disabled until timing is valid. Text fields support normal
|
||||
clipboard shortcuts and local text Undo/Redo.
|
||||
|
||||
The **+** square below the presets opens a separate color editor. macOS uses the native
|
||||
Colors panel; other hosts and native-picker failures reuse the established egui picker.
|
||||
FilmCraft retains exact six-digit sRGB hex entry and explicit confirmation. Add color
|
||||
saves a reusable swatch to this project's palette through `colors.addCustom` (a separate
|
||||
undoable operation) and selects it in the marker draft. Canceling the marker edit afterward
|
||||
does not remove a deliberately saved palette color. The project stores at most 64 custom
|
||||
swatches shared by markers and clips; duplicate additions are no-ops. Used custom colors also appear for reuse.
|
||||
Marker/color dialogs belong to the opened project instance, close when that project is replaced,
|
||||
and are not restored from saved UI preferences. Unrelated edits keep marker drafts open;
|
||||
editing or removing that marker elsewhere closes its stale draft rather than overwriting newer data.
|
||||
|
||||
Markers and clips now share the existing 16 clip preset colors; clip RGB values and preset
|
||||
names are unchanged. Markers no longer collapse those names onto seven display colors.
|
||||
Red/Rose, Orange/Mango, Cyan/Teal and Pink/Magenta resolve to the same presets. Exact custom colors
|
||||
(including white, black, pink, and gray) override a marker's preset. Preset color filters
|
||||
leave custom markers visible. Selecting a preset clears the override, preserving the author. The preset colors also follow Settings → Labels in both views.
|
||||
|
||||
The opaque sRGB editor lives in `widgets::color_picker`, independently of marker models,
|
||||
dialogs and project commands. Callers provide a `ColorDraft`, automation registry and id
|
||||
prefix. Its HSV conversion operates on sRGB channels; using linear-light saturation would
|
||||
make the center too white. Lumetri's grading wheels remain separate: they edit grading
|
||||
offsets. Existing effect/graphics pickers retain their alpha/float/color-space semantics.
|
||||
|
||||
The same author and hex values are available to Source marker commands. Existing markers
|
||||
have an empty author; FilmCraft never infers authorship from the operating-system account.
|
||||
Projects (schema 17) retain marker properties, Web Link URLs and the project palette. Marker CSV reports
|
||||
and FilmCraft OTIO metadata retain individual marker authors, URLs and colors. OTIO also
|
||||
retains custom clip label colors and the palette in FilmCraft metadata. Other interchange exports warn when they cannot preserve those marker properties.
|
||||
Importing an OTIO document merges its saved swatches after the current project's swatches, omitting duplicates and respecting the 64-swatch limit.
|
||||
This does not add AVB marker import or Avid track-bound markers.
|
||||
|
||||
Regression coverage includes project palette persistence/isolation/bounds/undo, marker
|
||||
commands and reopen, separate picker and marker drafts, preset/custom swatch reuse,
|
||||
sRGB wheel conversion, CSV escaping, and OTIO round trips.
|
||||
|
||||
Clip and Project-panel Label menus expose the same project custom palette below the presets.
|
||||
Add custom color opens the shared picker; Add and apply saves the swatch and applies it to
|
||||
the captured selection in one undoable operation. `edit.label` accepts a preset/alias or hex,
|
||||
explicit `clips`/`items` targets, and optional `saveColor:true`. Choosing a named preset clears
|
||||
that item's custom override. Duplicated items and newly inserted source clips retain the exact
|
||||
custom label color. Bright custom timeline fills use dark text for readability.
|
||||
|
||||
The marker editor also edits exact frame time and duration, switches between point and range
|
||||
markers, and deletes by marker ID. Double-click a timeline, monitor, or panel marker to edit it;
|
||||
right-click exposes Edit Marker and Delete Marker. Source and sequence ownership is shown and
|
||||
captured at opening, so changing the open clip or sequence does not redirect edits. Unchanged
|
||||
time fields preserve imported subframe ticks. A source range cannot extend beyond its source.
|
||||
|
||||
The Markers panel switches between Sequence and Source, searches name/notes/author, filters
|
||||
preset and custom colors, and supports Command/Ctrl-click, Shift-click, and Select all for the
|
||||
visible rows. Bulk color changes and deletion are each one undo step. Hidden rows leave the
|
||||
selection. `markers.edit` and `markers.delete` take an explicit `sequence` (program) or `item`
|
||||
(source) and either `marker` or `markers`; invalid IDs reject the entire operation.
|
||||
|
||||
Manage Colors is available from the Markers panel and clip Label menus. The separate project
|
||||
palette manager adds, names, edits, and removes swatches using the shared color picker. Editing
|
||||
or removing a saved swatch preserves already assigned hex values; used colors remain available
|
||||
for reuse. `colors.addCustom`, `colors.updateCustom`, and `colors.removeCustom` are undoable.
|
||||
Names persist in the project and FilmCraft OTIO metadata; existing sixteen presets are unchanged.
|
||||
|
||||
### Editable clip duration
|
||||
|
||||
The Clip Speed / Duration dialog lets you type a duration as timecode. Duration and
|
||||
|
||||
@@ -495,7 +495,8 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
"engine.execute" | "ui.menu.invoke" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||||
match crate::menus::invoke(app, ctx, id, params) {
|
||||
let invoke = if req.method == "engine.execute" { crate::menus::invoke_command } else { crate::menus::invoke };
|
||||
match invoke(app, ctx, id, params) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
@@ -504,7 +505,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()),
|
||||
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items_with_focus(app, ctx)).unwrap_or_default()),
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.docking" => {
|
||||
let hits = craft_ui::docking::DockArea::<PanelKind>::hit_rects(ctx, crate::dock_ui::area_id());
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct AudioDevices {
|
||||
/// Host hooks for native file dialogs etc.
|
||||
#[derive(Default)]
|
||||
pub struct HostHooks {
|
||||
/// Optional native custom-color editor; presets and project palette stay in FilmCraft.
|
||||
pub pick_color: Option<crate::widgets::color_picker::NativeColorPicker>,
|
||||
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)>>>,
|
||||
|
||||
@@ -3619,3 +3619,67 @@
|
||||
The control server is disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md) Der Kontrollserver ist deaktiviert: FILMCRAFT_CONTROL_TOKEN muss 64 zufällige Hexzeichen enthalten (siehe docs/control-protocol.md)
|
||||
|
||||
# Control server: the bind-failure notice (port collisions; #3318 may show it too).
|
||||
Temporal Interpolation Zeitliche Interpolation
|
||||
Select items to clear Wähle die zu löschenden Elemente aus
|
||||
A range marker needs a positive duration Ein Bereichsmarker benötigt eine positive Dauer
|
||||
Add Custom Color Benutzerdefinierte Farbe hinzufügen
|
||||
Add and apply Hinzufügen und anwenden
|
||||
Add color Farbe hinzufügen
|
||||
Add custom color Benutzerdefinierte Farbe hinzufügen
|
||||
Add custom color… Benutzerdefinierte Farbe hinzufügen…
|
||||
Author Autor
|
||||
Chapter Kapitel
|
||||
Choose Color… Farbe auswählen…
|
||||
Choose color with system picker Farbe mit der Systemauswahl wählen
|
||||
Color picker Farbauswahl
|
||||
Color selected markers Farbe ausgewählter Marker ändern
|
||||
Custom Color… Benutzerdefinierte Farbe…
|
||||
Delete Marker Marker löschen
|
||||
Delete selected markers Ausgewählte Marker löschen
|
||||
Edit Custom Color Benutzerdefinierte Farbe bearbeiten
|
||||
Edit Marker Marker bearbeiten
|
||||
Edit Marker… Marker bearbeiten…
|
||||
Edit color Farbe bearbeiten
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Das Bearbeiten oder Entfernen eines Farbfelds ändert bereits zugewiesene Farben nicht.
|
||||
Enter six hex digits, for example FF80C0. Sechs Hexadezimalziffern eingeben, zum Beispiel FF80C0.
|
||||
Hex Hex
|
||||
Hex color Hexadezimale Farbe
|
||||
Manage Colors Farben verwalten
|
||||
Manage Colors… Farben verwalten…
|
||||
Marker owner is unavailable Das zugehörige Markerobjekt ist nicht verfügbar
|
||||
No markers Keine Marker
|
||||
No matching markers Keine passenden Marker
|
||||
No saved colors Keine gespeicherten Farben
|
||||
No sequence open Keine Sequenz geöffnet
|
||||
No source clip open Kein Quellclip geöffnet
|
||||
Notes Notizen
|
||||
Project Colors Projektfarben
|
||||
Range marker Bereichsmarker
|
||||
Remove saved color Gespeicherte Farbe entfernen
|
||||
Save color Farbe speichern
|
||||
Search markers Marker suchen
|
||||
Search name, notes, author Name, Notizen oder Autor suchen
|
||||
Select all Alle auswählen
|
||||
Select all visible markers Alle sichtbaren Marker auswählen
|
||||
Sequence: {name} Sequenz: {name}
|
||||
Source: {name} Quelle: {name}
|
||||
Source: {name} (shared source markers) Quelle: {name} (gemeinsame Quellmarker)
|
||||
System color picker unavailable: {error} Systemfarbauswahl nicht verfügbar: {error}
|
||||
Time is outside the supported range Die Zeit liegt außerhalb des unterstützten Bereichs
|
||||
{count} selected {count} ausgewählt
|
||||
Remove Custom Color Benutzerdefinierte Farbe entfernen
|
||||
Edit Markers Marker bearbeiten
|
||||
Delete Markers Marker löschen
|
||||
Marker changed or was removed. Reopen it to continue. Die Markierung wurde geändert oder entfernt. Öffne sie erneut, um fortzufahren.
|
||||
Comment Marker Kommentarmarkierung
|
||||
Chapter Marker Kapitelmarkierung
|
||||
Segmentation Marker Segmentierungsmarkierung
|
||||
Web Link Weblink
|
||||
Flash Cue Point Flash-Cue-Punkt
|
||||
URL URL
|
||||
Open Link Link öffnen
|
||||
Opens this address in your web browser Öffnet diese Adresse im Webbrowser
|
||||
Enter a valid HTTP or HTTPS URL without credentials Eine gültige HTTP- oder HTTPS-URL ohne Zugangsdaten eingeben
|
||||
Previous Zurück
|
||||
Next Weiter
|
||||
Enter a valid HH:MM:SS:FF timecode Gültigen Timecode als HH:MM:SS:FF eingeben
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 45.
|
@@ -3664,3 +3664,62 @@
|
||||
Change the sequence to match the clip's settings? ¿Cambiar la secuencia para que coincida con los ajustes del clip?
|
||||
Change Sequence Settings Cambiar ajustes de secuencia
|
||||
Keep Existing Settings Mantener los ajustes actuales
|
||||
Temporal Interpolation Interpolación temporal
|
||||
Select items to clear Selecciona los elementos que quieres borrar
|
||||
Opens in your web browser: {url} Se abre en el navegador web: {url}
|
||||
A range marker needs a positive duration Un marcador de rango necesita una duración positiva
|
||||
Add Custom Color Añadir color personalizado
|
||||
Add and apply Añadir y aplicar
|
||||
Add color Añadir color
|
||||
Add custom color Añadir color personalizado
|
||||
Add custom color… Añadir color personalizado…
|
||||
Author Autor
|
||||
Chapter Capítulo
|
||||
Choose Color… Elegir color…
|
||||
Choose color with system picker Elegir color con el selector del sistema
|
||||
Color picker Selector de color
|
||||
Color selected markers Cambiar el color de los marcadores seleccionados
|
||||
Custom Color… Color personalizado…
|
||||
Delete Marker Eliminar marcador
|
||||
Delete selected markers Eliminar los marcadores seleccionados
|
||||
Edit Custom Color Editar color personalizado
|
||||
Edit Marker Editar marcador
|
||||
Edit color Editar color
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Editar o eliminar una muestra no cambia los colores asignados.
|
||||
Enter six hex digits, for example FF80C0. Introduce seis dígitos hexadecimales, por ejemplo FF80C0.
|
||||
Hex Hex
|
||||
Hex color Color hexadecimal
|
||||
Manage Colors Gestionar colores
|
||||
Manage Colors… Gestionar colores…
|
||||
Marker owner is unavailable El propietario del marcador no está disponible
|
||||
No markers No hay marcadores
|
||||
No matching markers No hay marcadores coincidentes
|
||||
No saved colors No hay colores guardados
|
||||
No sequence open No hay ninguna secuencia abierta
|
||||
No source clip open No hay ningún clip de origen abierto
|
||||
Notes Notas
|
||||
Project Colors Colores del proyecto
|
||||
Range marker Marcador de rango
|
||||
Remove saved color Eliminar color guardado
|
||||
Save color Guardar color
|
||||
Search markers Buscar marcadores
|
||||
Search name, notes, author Buscar nombre, notas o autor
|
||||
Select all Seleccionar todo
|
||||
Select all visible markers Seleccionar todos los marcadores visibles
|
||||
Sequence: {name} Secuencia: {name}
|
||||
Source: {name} Origen: {name}
|
||||
Source: {name} (shared source markers) Origen: {name} (marcadores de origen compartidos)
|
||||
System color picker unavailable: {error} El selector de color del sistema no está disponible: {error}
|
||||
Time is outside the supported range El tiempo está fuera del rango admitido
|
||||
{count} selected {count} seleccionados
|
||||
Remove Custom Color Eliminar color personalizado
|
||||
Edit Markers Editar marcadores
|
||||
Delete Markers Eliminar marcadores
|
||||
Marker changed or was removed. Reopen it to continue. El marcador cambió o se eliminó. Ábrelo de nuevo para continuar.
|
||||
URL URL
|
||||
Open Link Abrir enlace
|
||||
Opens this address in your web browser Abre esta dirección en tu navegador web
|
||||
Enter a valid HTTP or HTTPS URL without credentials Introduce una URL HTTP o HTTPS válida sin credenciales
|
||||
Previous Anterior
|
||||
Next Siguiente
|
||||
Enter a valid HH:MM:SS:FF timecode Introduce un código de tiempo válido HH:MM:SS:FF
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -3592,6 +3592,74 @@
|
||||
Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques
|
||||
No text graphics in this sequence. Add text with the Type tool. Aucun graphique avec du texte dans cette séquence. Ajoutez du texte avec l'outil Texte.
|
||||
No matching text. Aucun texte ne correspond.
|
||||
|
||||
# Marker editing and shared custom colors
|
||||
A range marker needs a positive duration Un marqueur de plage doit avoir une durée positive
|
||||
Add Custom Color Ajouter une couleur personnalisée
|
||||
Add and apply Ajouter et appliquer
|
||||
Add color Ajouter la couleur
|
||||
Add custom color Ajouter une couleur personnalisée
|
||||
Add custom color… Ajouter une couleur personnalisée…
|
||||
Author Auteur
|
||||
Chapter Chapitre
|
||||
Choose Color… Choisir une couleur…
|
||||
Choose color with system picker Choisir une couleur avec le sélecteur système
|
||||
Color picker Sélecteur de couleur
|
||||
Color selected markers Changer la couleur des marqueurs sélectionnés
|
||||
Custom Color… Couleur personnalisée…
|
||||
Delete Marker Supprimer le marqueur
|
||||
Delete selected markers Supprimer les marqueurs sélectionnés
|
||||
Edit Custom Color Modifier la couleur personnalisée
|
||||
Edit Marker Modifier le marqueur
|
||||
Edit Marker… Modifier le marqueur…
|
||||
Edit color Modifier la couleur
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Modifier ou supprimer un échantillon ne change pas les couleurs déjà attribuées.
|
||||
Enter six hex digits, for example FF80C0. Saisissez six chiffres hexadécimaux, par exemple FF80C0.
|
||||
Hex Hex
|
||||
Hex color Couleur hexadécimale
|
||||
Manage Colors Gérer les couleurs
|
||||
Manage Colors… Gérer les couleurs…
|
||||
Marker owner is unavailable L’élément auquel appartient le marqueur est indisponible
|
||||
No markers Aucun marqueur
|
||||
No matching markers Aucun marqueur correspondant
|
||||
No saved colors Aucune couleur enregistrée
|
||||
No sequence open Aucune séquence ouverte
|
||||
No source clip open Aucun clip source ouvert
|
||||
Notes Notes
|
||||
Project Colors Couleurs du projet
|
||||
Range marker Marqueur de plage
|
||||
Remove saved color Supprimer la couleur enregistrée
|
||||
Save color Enregistrer la couleur
|
||||
Search markers Rechercher des marqueurs
|
||||
Search name, notes, author Rechercher nom, notes ou auteur
|
||||
Select all Tout sélectionner
|
||||
Select all visible markers Sélectionner tous les marqueurs visibles
|
||||
Sequence: {name} Séquence : {name}
|
||||
Source: {name} Source : {name}
|
||||
Source: {name} (shared source markers) Source : {name} (marqueurs source partagés)
|
||||
System color picker unavailable: {error} Sélecteur de couleur système indisponible : {error}
|
||||
Time is outside the supported range Le temps est en dehors de la plage prise en charge
|
||||
{count} selected {count} sélectionnés
|
||||
Remove Custom Color Supprimer la couleur personnalisée
|
||||
Edit Markers Modifier les marqueurs
|
||||
Delete Markers Supprimer les marqueurs
|
||||
Marker changed or was removed. Reopen it to continue. Le marqueur a été modifié ou supprimé. Rouvrez-le pour continuer.
|
||||
|
||||
# Marker editing controls
|
||||
Comment Marker Marqueur de commentaire
|
||||
Chapter Marker Marqueur de chapitre
|
||||
Segmentation Marker Marqueur de segmentation
|
||||
Web Link Lien web
|
||||
Flash Cue Point Point de repère Flash
|
||||
|
||||
# Web Link marker editing.
|
||||
URL URL
|
||||
Open Link Ouvrir le lien
|
||||
Opens this address in your web browser Ouvre cette adresse dans votre navigateur web
|
||||
Enter a valid HTTP or HTTPS URL without credentials Saisissez une URL HTTP ou HTTPS valide sans identifiants
|
||||
Previous Précédent
|
||||
Next Suivant
|
||||
Enter a valid HH:MM:SS:FF timecode Saisissez un timecode valide HH:MM:SS:FF
|
||||
Hide FilmCraft Masquer FilmCraft
|
||||
Hide Others Masquer les autres
|
||||
More transport controls Autres commandes de lecture
|
||||
@@ -3652,14 +3720,8 @@
|
||||
The control server is disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md) Le serveur de contrôle est désactivé : FILMCRAFT_CONTROL_TOKEN doit contenir 64 caractères hexadécimaux aléatoires (voir docs/control-protocol.md)
|
||||
|
||||
# Markers: the Edit Marker dialog (#2416).
|
||||
Edit Marker… Modifier le marqueur…
|
||||
In: Entrée :
|
||||
Marker Color: Couleur du marqueur :
|
||||
Options: Options :
|
||||
Comment Marker Marqueur de commentaire
|
||||
Chapter Marker Marqueur de chapitre
|
||||
Segmentation Marker Marqueur de segmentation
|
||||
Web Link Lien web
|
||||
Flash Cue Point Point de repère Flash
|
||||
Invalid duration: {error} Durée non valide : {error}
|
||||
# Control server: the bind-failure notice (port collisions; #3318 may show it too).
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 83.
|
@@ -3611,3 +3611,61 @@
|
||||
Flash Cue Point Titik Isyarat Flash
|
||||
Invalid duration: {error} Durasi tidak valid: {error}
|
||||
# Control server: the bind-failure notice (port collisions; #3318 may show it too).
|
||||
Temporal Interpolation Interpolasi Temporal
|
||||
Select items to clear Pilih item yang akan dihapus
|
||||
A range marker needs a positive duration Penanda rentang memerlukan durasi positif
|
||||
Add Custom Color Tambah Warna Kustom
|
||||
Add and apply Tambah dan terapkan
|
||||
Add color Tambah warna
|
||||
Add custom color Tambah warna kustom
|
||||
Add custom color… Tambah warna kustom…
|
||||
Author Pembuat
|
||||
Chapter Bab
|
||||
Choose Color… Pilih Warna…
|
||||
Choose color with system picker Pilih warna dengan pemilih sistem
|
||||
Color picker Pemilih warna
|
||||
Color selected markers Ubah warna penanda terpilih
|
||||
Custom Color… Warna Kustom…
|
||||
Delete Marker Hapus Penanda
|
||||
Delete selected markers Hapus penanda terpilih
|
||||
Edit Custom Color Edit Warna Kustom
|
||||
Edit Marker Edit Penanda
|
||||
Edit color Edit warna
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Mengedit atau menghapus contoh warna tidak mengubah warna yang sudah diterapkan.
|
||||
Enter six hex digits, for example FF80C0. Masukkan enam digit heksadesimal, misalnya FF80C0.
|
||||
Hex Heksadesimal
|
||||
Hex color Warna heksadesimal
|
||||
Manage Colors Kelola Warna
|
||||
Manage Colors… Kelola Warna…
|
||||
Marker owner is unavailable Pemilik penanda tidak tersedia
|
||||
No markers Tidak ada penanda
|
||||
No matching markers Tidak ada penanda yang cocok
|
||||
No saved colors Tidak ada warna tersimpan
|
||||
No sequence open Tidak ada sekuens yang terbuka
|
||||
No source clip open Tidak ada klip sumber yang terbuka
|
||||
Notes Catatan
|
||||
Project Colors Warna Proyek
|
||||
Range marker Penanda rentang
|
||||
Remove saved color Hapus warna tersimpan
|
||||
Save color Simpan warna
|
||||
Search markers Cari penanda
|
||||
Search name, notes, author Cari nama, catatan, pembuat
|
||||
Select all Pilih semua
|
||||
Select all visible markers Pilih semua penanda yang terlihat
|
||||
Sequence: {name} Sekuens: {name}
|
||||
Source: {name} Sumber: {name}
|
||||
Source: {name} (shared source markers) Sumber: {name} (penanda sumber bersama)
|
||||
System color picker unavailable: {error} Pemilih warna sistem tidak tersedia: {error}
|
||||
Time is outside the supported range Waktu di luar rentang yang didukung
|
||||
{count} selected {count} dipilih
|
||||
Remove Custom Color Hapus Warna Kustom
|
||||
Edit Markers Edit Penanda
|
||||
Delete Markers Hapus Penanda
|
||||
Marker changed or was removed. Reopen it to continue. Penanda telah diubah atau dihapus. Buka kembali untuk melanjutkan.
|
||||
URL URL
|
||||
Open Link Buka Tautan
|
||||
Opens this address in your web browser Membuka alamat ini di peramban web
|
||||
Enter a valid HTTP or HTTPS URL without credentials Masukkan URL HTTP atau HTTPS yang valid tanpa kredensial
|
||||
Previous Sebelumnya
|
||||
Next Berikutnya
|
||||
Enter a valid HH:MM:SS:FF timecode Masukkan kode waktu HH:MM:SS:FF yang valid
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 14.
|
@@ -3636,3 +3636,62 @@
|
||||
Flash Cue Point Punto Flash Cue
|
||||
Invalid duration: {error} Durata non valida: {error}
|
||||
# Control server: the bind-failure notice (port collisions; #3318 may show it too).
|
||||
Temporal Interpolation Interpolazione temporale
|
||||
Select items to clear Seleziona gli elementi da cancellare
|
||||
Opens in your web browser: {url} Si apre nel browser web: {url}
|
||||
A range marker needs a positive duration Un marcatore di intervallo richiede una durata positiva
|
||||
Add Custom Color Aggiungi colore personalizzato
|
||||
Add and apply Aggiungi e applica
|
||||
Add color Aggiungi colore
|
||||
Add custom color Aggiungi colore personalizzato
|
||||
Add custom color… Aggiungi colore personalizzato…
|
||||
Author Autore
|
||||
Chapter Capitolo
|
||||
Choose Color… Scegli colore…
|
||||
Choose color with system picker Scegli il colore con il selettore di sistema
|
||||
Color picker Selettore colore
|
||||
Color selected markers Colora i marcatori selezionati
|
||||
Custom Color… Colore personalizzato…
|
||||
Delete Marker Elimina marcatore
|
||||
Delete selected markers Elimina i marcatori selezionati
|
||||
Edit Custom Color Modifica colore personalizzato
|
||||
Edit Marker Modifica marcatore
|
||||
Edit color Modifica colore
|
||||
Editing or removing a swatch leaves assigned colors unchanged. La modifica o la rimozione di un campione non cambia i colori già assegnati.
|
||||
Enter six hex digits, for example FF80C0. Inserisci sei cifre esadecimali, ad esempio FF80C0.
|
||||
Hex Esadecimale
|
||||
Hex color Colore esadecimale
|
||||
Manage Colors Gestisci colori
|
||||
Manage Colors… Gestisci colori…
|
||||
Marker owner is unavailable La sorgente del marcatore non è disponibile
|
||||
No markers Nessun marcatore
|
||||
No matching markers Nessun marcatore corrispondente
|
||||
No saved colors Nessun colore salvato
|
||||
No sequence open Nessuna sequenza aperta
|
||||
No source clip open Nessuna clip sorgente aperta
|
||||
Notes Note
|
||||
Project Colors Colori del progetto
|
||||
Range marker Marcatore di intervallo
|
||||
Remove saved color Rimuovi colore salvato
|
||||
Save color Salva colore
|
||||
Search markers Cerca marcatori
|
||||
Search name, notes, author Cerca nome, note, autore
|
||||
Select all Seleziona tutto
|
||||
Select all visible markers Seleziona tutti i marcatori visibili
|
||||
Sequence: {name} Sequenza: {name}
|
||||
Source: {name} Sorgente: {name}
|
||||
Source: {name} (shared source markers) Sorgente: {name} (marcatori sorgente condivisi)
|
||||
System color picker unavailable: {error} Selettore colore di sistema non disponibile: {error}
|
||||
Time is outside the supported range Il tempo è fuori dall’intervallo supportato
|
||||
{count} selected {count} selezionati
|
||||
Remove Custom Color Rimuovi colore personalizzato
|
||||
Edit Markers Modifica marcatori
|
||||
Delete Markers Elimina marcatori
|
||||
Marker changed or was removed. Reopen it to continue. Il marcatore è stato modificato o rimosso. Riaprilo per continuare.
|
||||
URL URL
|
||||
Open Link Apri link
|
||||
Opens this address in your web browser Apre questo indirizzo nel browser web
|
||||
Enter a valid HTTP or HTTPS URL without credentials Inserisci un URL HTTP o HTTPS valido senza credenziali
|
||||
Previous Precedente
|
||||
Next Successivo
|
||||
Enter a valid HH:MM:SS:FF timecode Inserisci un timecode valido HH:MM:SS:FF
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -3672,3 +3672,62 @@
|
||||
Change the sequence to match the clip's settings? シーケンスをクリップの設定に合わせますか?
|
||||
Change Sequence Settings シーケンス設定を変更
|
||||
Keep Existing Settings 現在の設定を維持
|
||||
Temporal Interpolation 時間補間
|
||||
Select items to clear 消去する項目を選択してください
|
||||
Opens in your web browser: {url} ウェブブラウザーで開きます:{url}
|
||||
A range marker needs a positive duration 範囲マーカーのデュレーションは正の値にしてください
|
||||
Add Custom Color カスタムカラーを追加
|
||||
Add and apply 追加して適用
|
||||
Add color カラーを追加
|
||||
Add custom color カスタムカラーを追加
|
||||
Add custom color… カスタムカラーを追加…
|
||||
Author 作成者
|
||||
Chapter チャプター
|
||||
Choose Color… カラーを選択…
|
||||
Choose color with system picker システムのカラーピッカーで選択
|
||||
Color picker カラーピッカー
|
||||
Color selected markers 選択したマーカーのカラーを変更
|
||||
Custom Color… カスタムカラー…
|
||||
Delete Marker マーカーを削除
|
||||
Delete selected markers 選択したマーカーを削除
|
||||
Edit Custom Color カスタムカラーを編集
|
||||
Edit Marker マーカーを編集
|
||||
Edit color カラーを編集
|
||||
Editing or removing a swatch leaves assigned colors unchanged. スウォッチを編集または削除しても、割り当て済みのカラーは変わりません。
|
||||
Enter six hex digits, for example FF80C0. 16進数を6桁入力してください(例:FF80C0)。
|
||||
Hex 16進数
|
||||
Hex color 16進数カラー
|
||||
Manage Colors カラーを管理
|
||||
Manage Colors… カラーを管理…
|
||||
Marker owner is unavailable マーカーの所有元を利用できません
|
||||
No markers マーカーがありません
|
||||
No matching markers 一致するマーカーがありません
|
||||
No saved colors 保存済みのカラーがありません
|
||||
No sequence open シーケンスが開かれていません
|
||||
No source clip open ソースクリップが開かれていません
|
||||
Notes メモ
|
||||
Project Colors プロジェクトカラー
|
||||
Range marker 範囲マーカー
|
||||
Remove saved color 保存済みのカラーを削除
|
||||
Save color カラーを保存
|
||||
Search markers マーカーを検索
|
||||
Search name, notes, author 名前、メモ、作成者を検索
|
||||
Select all すべて選択
|
||||
Select all visible markers 表示中のすべてのマーカーを選択
|
||||
Sequence: {name} シーケンス:{name}
|
||||
Source: {name} ソース:{name}
|
||||
Source: {name} (shared source markers) ソース:{name}(共有ソースマーカー)
|
||||
System color picker unavailable: {error} システムのカラーピッカーを利用できません:{error}
|
||||
Time is outside the supported range 時刻が対応範囲外です
|
||||
{count} selected {count} 個選択中
|
||||
Remove Custom Color カスタムカラーを削除
|
||||
Edit Markers マーカーを編集
|
||||
Delete Markers マーカーを削除
|
||||
Marker changed or was removed. Reopen it to continue. マーカーが変更または削除されました。続行するには開き直してください。
|
||||
URL URL
|
||||
Open Link リンクを開く
|
||||
Opens this address in your web browser このアドレスをウェブブラウザーで開きます
|
||||
Enter a valid HTTP or HTTPS URL without credentials 認証情報を含まない有効な HTTP または HTTPS URL を入力してください
|
||||
Previous 前へ
|
||||
Next 次へ
|
||||
Enter a valid HH:MM:SS:FF timecode 有効なタイムコードを HH:MM:SS:FF 形式で入力してください
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -3631,3 +3631,62 @@
|
||||
Flash Cue Point Flash 큐 포인트
|
||||
Invalid duration: {error} 잘못된 지속 시간: {error}
|
||||
# Control server: the bind-failure notice (port collisions; #3318 may show it too).
|
||||
Select items to clear 지울 항목 선택
|
||||
Temporal Interpolation 시간 보간
|
||||
Opens in your web browser: {url} 웹 브라우저에서 엽니다: {url}
|
||||
A range marker needs a positive duration 범위 마커에는 양수 길이가 필요합니다
|
||||
Add Custom Color 사용자 지정 색상 추가
|
||||
Add and apply 추가 및 적용
|
||||
Add color 색상 추가
|
||||
Add custom color 사용자 지정 색상 추가
|
||||
Add custom color… 사용자 지정 색상 추가…
|
||||
Author 작성자
|
||||
Chapter 챕터
|
||||
Choose Color… 색상 선택…
|
||||
Choose color with system picker 시스템 선택기로 색상 선택
|
||||
Color picker 색상 선택기
|
||||
Color selected markers 선택한 마커 색상 변경
|
||||
Custom Color… 사용자 지정 색상…
|
||||
Delete Marker 마커 삭제
|
||||
Delete selected markers 선택한 마커 삭제
|
||||
Edit Custom Color 사용자 지정 색상 편집
|
||||
Edit Marker 마커 편집
|
||||
Edit color 색상 편집
|
||||
Editing or removing a swatch leaves assigned colors unchanged. 견본을 편집하거나 제거해도 지정된 색상은 변경되지 않습니다.
|
||||
Enter six hex digits, for example FF80C0. 16진수 여섯 자리를 입력하세요. 예: FF80C0.
|
||||
Hex 16진수
|
||||
Hex color 16진수 색상
|
||||
Manage Colors 색상 관리
|
||||
Manage Colors… 색상 관리…
|
||||
Marker owner is unavailable 마커가 속한 항목을 사용할 수 없습니다
|
||||
No markers 마커 없음
|
||||
No matching markers 일치하는 마커 없음
|
||||
No saved colors 저장된 색상 없음
|
||||
No sequence open 열린 시퀀스 없음
|
||||
No source clip open 열린 소스 클립 없음
|
||||
Notes 메모
|
||||
Project Colors 프로젝트 색상
|
||||
Range marker 범위 마커
|
||||
Remove saved color 저장된 색상 제거
|
||||
Save color 색상 저장
|
||||
Search markers 마커 검색
|
||||
Search name, notes, author 이름, 메모, 작성자 검색
|
||||
Select all 모두 선택
|
||||
Select all visible markers 보이는 모든 마커 선택
|
||||
Sequence: {name} 시퀀스: {name}
|
||||
Source: {name} 소스: {name}
|
||||
Source: {name} (shared source markers) 소스: {name} (공유 소스 마커)
|
||||
System color picker unavailable: {error} 시스템 색상 선택기를 사용할 수 없음: {error}
|
||||
Time is outside the supported range 시간이 지원 범위를 벗어났습니다
|
||||
{count} selected {count}개 선택됨
|
||||
Remove Custom Color 사용자 지정 색상 제거
|
||||
Edit Markers 마커 편집
|
||||
Delete Markers 마커 삭제
|
||||
Marker changed or was removed. Reopen it to continue. 마커가 변경되거나 제거되었습니다. 계속하려면 다시 여세요.
|
||||
URL URL
|
||||
Open Link 링크 열기
|
||||
Opens this address in your web browser 이 주소를 웹 브라우저에서 엽니다
|
||||
Enter a valid HTTP or HTTPS URL without credentials 인증 정보가 없는 올바른 HTTP 또는 HTTPS URL을 입력하세요
|
||||
Previous 이전
|
||||
Next 다음
|
||||
Enter a valid HH:MM:SS:FF timecode 올바른 HH:MM:SS:FF 타임코드를 입력하세요
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2822 and column 7.
|
@@ -3614,3 +3614,61 @@
|
||||
Flash Cue Point Ponto de referência Flash
|
||||
Invalid duration: {error} Duração inválida: {error}
|
||||
# Control server: the bind-failure notice (port collisions; #3318 may show it too).
|
||||
Temporal Interpolation Interpolação temporal
|
||||
Select items to clear Selecione os itens que deseja limpar
|
||||
A range marker needs a positive duration Um marcador de intervalo precisa de uma duração positiva
|
||||
Add Custom Color Adicionar cor personalizada
|
||||
Add and apply Adicionar e aplicar
|
||||
Add color Adicionar cor
|
||||
Add custom color Adicionar cor personalizada
|
||||
Add custom color… Adicionar cor personalizada…
|
||||
Author Autor
|
||||
Chapter Capítulo
|
||||
Choose Color… Escolher cor…
|
||||
Choose color with system picker Escolher cor com o seletor do sistema
|
||||
Color picker Seletor de cores
|
||||
Color selected markers Alterar a cor dos marcadores selecionados
|
||||
Custom Color… Cor personalizada…
|
||||
Delete Marker Excluir marcador
|
||||
Delete selected markers Excluir os marcadores selecionados
|
||||
Edit Custom Color Editar cor personalizada
|
||||
Edit Marker Editar marcador
|
||||
Edit color Editar cor
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Editar ou remover uma amostra não altera as cores atribuídas.
|
||||
Enter six hex digits, for example FF80C0. Digite seis dígitos hexadecimais, por exemplo FF80C0.
|
||||
Hex Hex
|
||||
Hex color Cor hexadecimal
|
||||
Manage Colors Gerenciar cores
|
||||
Manage Colors… Gerenciar cores…
|
||||
Marker owner is unavailable O proprietário do marcador está indisponível
|
||||
No markers Nenhum marcador
|
||||
No matching markers Nenhum marcador correspondente
|
||||
No saved colors Nenhuma cor salva
|
||||
No sequence open Nenhuma sequência aberta
|
||||
No source clip open Nenhum clipe de origem aberto
|
||||
Notes Notas
|
||||
Project Colors Cores do projeto
|
||||
Range marker Marcador de intervalo
|
||||
Remove saved color Remover cor salva
|
||||
Save color Salvar cor
|
||||
Search markers Pesquisar marcadores
|
||||
Search name, notes, author Pesquisar nome, notas ou autor
|
||||
Select all Selecionar tudo
|
||||
Select all visible markers Selecionar todos os marcadores visíveis
|
||||
Sequence: {name} Sequência: {name}
|
||||
Source: {name} Origem: {name}
|
||||
Source: {name} (shared source markers) Origem: {name} (marcadores de origem compartilhados)
|
||||
System color picker unavailable: {error} Seletor de cores do sistema indisponível: {error}
|
||||
Time is outside the supported range O tempo está fora do intervalo suportado
|
||||
{count} selected {count} selecionados
|
||||
Remove Custom Color Remover cor personalizada
|
||||
Edit Markers Editar marcadores
|
||||
Delete Markers Excluir marcadores
|
||||
Marker changed or was removed. Reopen it to continue. O marcador foi alterado ou removido. Abra-o novamente para continuar.
|
||||
URL URL
|
||||
Open Link Abrir link
|
||||
Opens this address in your web browser Abre este endereço no navegador da web
|
||||
Enter a valid HTTP or HTTPS URL without credentials Insira uma URL HTTP ou HTTPS válida sem credenciais
|
||||
Previous Anterior
|
||||
Next Próximo
|
||||
Enter a valid HH:MM:SS:FF timecode Digite um timecode válido HH:MM:SS:FF
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 4.
|
@@ -3596,3 +3596,65 @@
|
||||
Couldn't open your web browser Не удалось открыть браузер
|
||||
Open this address in your browser yourself: Этот адрес нужно открыть в браузере вручную:
|
||||
Reset Interface Zoom Сбросить масштаб интерфейса
|
||||
A range marker needs a positive duration Длительность маркера диапазона должна быть положительной
|
||||
Add Custom Color Добавить свой цвет
|
||||
Add and apply Добавить и применить
|
||||
Add color Добавить цвет
|
||||
Add custom color Добавить свой цвет
|
||||
Add custom color… Добавить свой цвет…
|
||||
Author Автор
|
||||
Chapter Глава
|
||||
Choose Color… Выбрать цвет…
|
||||
Choose color with system picker Выбрать цвет в системном окне
|
||||
Color picker Выбор цвета
|
||||
Color selected markers Изменить цвет выбранных маркеров
|
||||
Custom Color… Свой цвет…
|
||||
Delete Marker Удалить маркер
|
||||
Delete selected markers Удалить выбранные маркеры
|
||||
Edit Custom Color Изменить свой цвет
|
||||
Edit Marker Изменить маркер
|
||||
Edit Marker… Изменить маркер…
|
||||
Edit color Изменить цвет
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Изменение или удаление образца не меняет назначенные цвета.
|
||||
Enter six hex digits, for example FF80C0. Введите шесть шестнадцатеричных цифр, например FF80C0.
|
||||
Hex HEX
|
||||
Hex color Цвет в формате HEX
|
||||
Manage Colors Управление цветами
|
||||
Manage Colors… Управление цветами…
|
||||
Marker owner is unavailable Объект, которому принадлежит маркер, недоступен
|
||||
No markers Нет маркеров
|
||||
No matching markers Нет подходящих маркеров
|
||||
No saved colors Нет сохранённых цветов
|
||||
No sequence open Нет открытой секвенции
|
||||
No source clip open Нет открытого исходного клипа
|
||||
Notes Примечания
|
||||
Project Colors Цвета проекта
|
||||
Range marker Маркер диапазона
|
||||
Remove saved color Удалить сохранённый цвет
|
||||
Save color Сохранить цвет
|
||||
Search markers Поиск маркеров
|
||||
Search name, notes, author Поиск по имени, примечаниям и автору
|
||||
Select all Выбрать всё
|
||||
Select all visible markers Выбрать все видимые маркеры
|
||||
Sequence: {name} Секвенция: {name}
|
||||
Source: {name} Источник: {name}
|
||||
Source: {name} (shared source markers) Источник: {name} (общие исходные маркеры)
|
||||
System color picker unavailable: {error} Системное окно выбора цвета недоступно: {error}
|
||||
Time is outside the supported range Время за пределами допустимого диапазона
|
||||
{count} selected Выбрано: {count}
|
||||
Remove Custom Color Удалить свой цвет
|
||||
Edit Markers Изменить маркеры
|
||||
Delete Markers Удалить маркеры
|
||||
Marker changed or was removed. Reopen it to continue. Маркер изменён или удалён. Откройте его заново, чтобы продолжить.
|
||||
Comment Marker Маркер комментария
|
||||
Chapter Marker Маркер главы
|
||||
Segmentation Marker Маркер сегментации
|
||||
Web Link Веб-ссылка
|
||||
Flash Cue Point Точка сигнала Flash
|
||||
URL URL
|
||||
Open Link Открыть ссылку
|
||||
Opens this address in your web browser Открывает этот адрес в веб-браузере
|
||||
Enter a valid HTTP or HTTPS URL without credentials Введите допустимый URL HTTP или HTTPS без учётных данных
|
||||
Previous Предыдущий
|
||||
Next Следующий
|
||||
Enter a valid HH:MM:SS:FF timecode Введите допустимый тайм-код ЧЧ:ММ:СС:КК (HH:MM:SS:FF)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2372 and column 7.
|
@@ -361,3 +361,82 @@
|
||||
Zoom to Sequence Умістити секвенцію
|
||||
Project Notes Нотатки проєкту
|
||||
|
||||
# Marker editing and shared custom colors
|
||||
A range marker needs a positive duration Тривалість маркера діапазону має бути додатною
|
||||
Add Custom Color Додати власний колір
|
||||
Add and apply Додати й застосувати
|
||||
Add color Додати колір
|
||||
Add custom color Додати власний колір
|
||||
Add custom color… Додати власний колір…
|
||||
Author Автор
|
||||
Chapter Розділ
|
||||
Choose Color… Вибрати колір…
|
||||
Choose color with system picker Вибрати колір у системному вікні
|
||||
Color picker Вибір кольору
|
||||
Color selected markers Змінити колір вибраних маркерів
|
||||
Custom Color Власний колір
|
||||
Custom Color… Власний колір…
|
||||
Delete Marker Видалити маркер
|
||||
Delete selected markers Видалити вибрані маркери
|
||||
Edit Custom Color Змінити власний колір
|
||||
Edit Marker Редагувати маркер
|
||||
Edit Marker… Редагувати маркер…
|
||||
Edit color Змінити колір
|
||||
Editing or removing a swatch leaves assigned colors unchanged. Зміна або видалення зразка не змінює призначені кольори.
|
||||
Enter six hex digits, for example FF80C0. Введіть шість шістнадцяткових цифр, наприклад FF80C0.
|
||||
Hex HEX
|
||||
Hex color Колір у форматі HEX
|
||||
Manage Colors Керування кольорами
|
||||
Manage Colors… Керування кольорами…
|
||||
Marker owner is unavailable Об’єкт, якому належить маркер, недоступний
|
||||
No markers Немає маркерів
|
||||
No matching markers Немає відповідних маркерів
|
||||
No saved colors Немає збережених кольорів
|
||||
No sequence open Немає відкритої секвенції
|
||||
No source clip open Немає відкритого вихідного кліпу
|
||||
Notes Примітки
|
||||
Project Colors Кольори проєкту
|
||||
Range marker Маркер діапазону
|
||||
Remove saved color Видалити збережений колір
|
||||
Save color Зберегти колір
|
||||
Search markers Пошук маркерів
|
||||
Search name, notes, author Пошук за назвою, примітками й автором
|
||||
Select all Вибрати все
|
||||
Select all visible markers Вибрати всі видимі маркери
|
||||
Sequence: {name} Секвенція: {name}
|
||||
Source: {name} Джерело: {name}
|
||||
Source: {name} (shared source markers) Джерело: {name} (спільні вихідні маркери)
|
||||
System color picker unavailable: {error} Системне вікно вибору кольору недоступне: {error}
|
||||
Time is outside the supported range Час за межами підтримуваного діапазону
|
||||
{count} selected Вибрано: {count}
|
||||
Cancel Скасувати
|
||||
Color Колір
|
||||
Delete Видалити
|
||||
Done Готово
|
||||
Duration Тривалість
|
||||
Name Назва
|
||||
Range Діапазон
|
||||
Time Час
|
||||
Marker Маркер
|
||||
Remove Вилучити
|
||||
Remove Custom Color Видалити власний колір
|
||||
Edit Markers Редагувати маркери
|
||||
Delete Markers Видалити маркери
|
||||
Marker changed or was removed. Reopen it to continue. Маркер змінено або видалено. Відкрийте його знову, щоб продовжити.
|
||||
|
||||
# Marker editing controls
|
||||
Comment Marker Маркер коментаря
|
||||
Chapter Marker Маркер розділу
|
||||
Segmentation Marker Маркер сегментації
|
||||
Web Link Вебпосилання
|
||||
Flash Cue Point Точка сигналу Flash
|
||||
|
||||
# Web Link marker editing.
|
||||
URL URL
|
||||
Open Link Відкрити посилання
|
||||
Opens this address in your web browser Відкриває цю адресу у веббраузері
|
||||
Enter a valid HTTP or HTTPS URL without credentials Введіть дійсну URL-адресу HTTP або HTTPS без облікових даних
|
||||
Previous Попередній
|
||||
Next Наступний
|
||||
Options Параметри
|
||||
Enter a valid HH:MM:SS:FF timecode Введіть коректний таймкод ГГ:ХХ:СС:КК (HH:MM:SS:FF)
|
||||
|
||||
|
@@ -3639,3 +3639,62 @@
|
||||
Change the sequence to match the clip's settings? 是否更改序列以匹配剪辑的设置?
|
||||
Change Sequence Settings 更改序列设置
|
||||
Keep Existing Settings 保留现有设置
|
||||
Temporal Interpolation 时间插值
|
||||
Select items to clear 选择要清除的项目
|
||||
Opens in your web browser: {url} 在网页浏览器中打开:{url}
|
||||
A range marker needs a positive duration 范围标记的持续时间必须大于零
|
||||
Add Custom Color 添加自定义颜色
|
||||
Add and apply 添加并应用
|
||||
Add color 添加颜色
|
||||
Add custom color 添加自定义颜色
|
||||
Add custom color… 添加自定义颜色…
|
||||
Author 作者
|
||||
Chapter 章节
|
||||
Choose Color… 选择颜色…
|
||||
Choose color with system picker 使用系统取色器选择颜色
|
||||
Color picker 取色器
|
||||
Color selected markers 更改所选标记的颜色
|
||||
Custom Color… 自定义颜色…
|
||||
Delete Marker 删除标记
|
||||
Delete selected markers 删除所选标记
|
||||
Edit Custom Color 编辑自定义颜色
|
||||
Edit Marker 编辑标记
|
||||
Edit color 编辑颜色
|
||||
Editing or removing a swatch leaves assigned colors unchanged. 编辑或移除色板不会更改已分配的颜色。
|
||||
Enter six hex digits, for example FF80C0. 请输入六位十六进制数,例如 FF80C0。
|
||||
Hex 十六进制
|
||||
Hex color 十六进制颜色
|
||||
Manage Colors 管理颜色
|
||||
Manage Colors… 管理颜色…
|
||||
Marker owner is unavailable 标记所属对象不可用
|
||||
No markers 没有标记
|
||||
No matching markers 没有匹配的标记
|
||||
No saved colors 没有已保存的颜色
|
||||
No sequence open 未打开序列
|
||||
No source clip open 未打开源片段
|
||||
Notes 备注
|
||||
Project Colors 项目颜色
|
||||
Range marker 范围标记
|
||||
Remove saved color 移除已保存的颜色
|
||||
Save color 保存颜色
|
||||
Search markers 搜索标记
|
||||
Search name, notes, author 搜索名称、备注、作者
|
||||
Select all 全选
|
||||
Select all visible markers 选择所有可见标记
|
||||
Sequence: {name} 序列:{name}
|
||||
Source: {name} 源:{name}
|
||||
Source: {name} (shared source markers) 源:{name}(共享源标记)
|
||||
System color picker unavailable: {error} 系统取色器不可用:{error}
|
||||
Time is outside the supported range 时间超出支持的范围
|
||||
{count} selected 已选择 {count} 个
|
||||
Remove Custom Color 移除自定义颜色
|
||||
Edit Markers 编辑标记
|
||||
Delete Markers 删除标记
|
||||
Marker changed or was removed. Reopen it to continue. 标记已更改或删除。请重新打开以继续。
|
||||
URL 网址
|
||||
Open Link 打开链接
|
||||
Opens this address in your web browser 在网页浏览器中打开此地址
|
||||
Enter a valid HTTP or HTTPS URL without credentials 请输入不含凭据的有效 HTTP 或 HTTPS 网址
|
||||
Previous 上一个
|
||||
Next 下一个
|
||||
Enter a valid HH:MM:SS:FF timecode 请输入有效的 HH:MM:SS:FF 时间码
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -69,6 +69,8 @@ pub enum Icon {
|
||||
ChevronRight,
|
||||
Magnet,
|
||||
Link,
|
||||
/// Open a web address: an arrow leaving an open frame.
|
||||
ExternalLink,
|
||||
Keyframe,
|
||||
Stopwatch,
|
||||
Fx,
|
||||
@@ -600,6 +602,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
pen.line(&[(5.0, 7.0), (8.0, 10.0), (11.0, 7.0)]);
|
||||
pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]);
|
||||
}
|
||||
ExternalLink => {
|
||||
pen.line(&[(6.0, 3.0), (2.0, 3.0), (2.0, 14.0), (13.0, 14.0), (13.0, 10.0)]);
|
||||
pen.line(&[(7.0, 9.0), (14.0, 2.0)]);
|
||||
pen.line(&[(9.0, 2.0), (14.0, 2.0), (14.0, 7.0)]);
|
||||
}
|
||||
Gear => {
|
||||
pen.circle(8.0, 8.0, 2.2);
|
||||
for i in 0..8 {
|
||||
@@ -883,6 +890,7 @@ mod tests {
|
||||
ChevronRight,
|
||||
Magnet,
|
||||
Link,
|
||||
ExternalLink,
|
||||
Keyframe,
|
||||
Stopwatch,
|
||||
Fx,
|
||||
|
||||
+38
-20
@@ -188,27 +188,26 @@ pub fn targets_source(app: &FilmcraftApp, params: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit ▸ Select All while a text field has the keyboard (the Project panel's search, a name being
|
||||
/// typed) selects that field's text, not the timeline clips: on macOS the native menu takes Cmd+A
|
||||
/// before the field sees the key. The selection is set on the field's state, so it holds however
|
||||
/// late in the pass the command runs (menu inbox or control channel); the field clamps the end to
|
||||
/// its text. False when no text field has the keyboard.
|
||||
fn select_all_in_text_field(ctx: &egui::Context) -> bool {
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
let Some(id) = ctx.memory(|m| m.focused()) else { return false };
|
||||
let Some(mut state) = egui::TextEdit::load_state(ctx, id) else { return false };
|
||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(0), CCursor::new(usize::MAX))));
|
||||
state.store(ctx, id);
|
||||
true
|
||||
/// Route menu editing to the focused text field; automation can explicitly invoke the engine.
|
||||
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
if text_field_window(ctx).is_some() && edits_text(id) {
|
||||
return activate_native(app, ctx, id);
|
||||
}
|
||||
invoke_command(app, ctx, id, params)
|
||||
}
|
||||
|
||||
pub(crate) fn menu_items_with_focus(app: &FilmcraftApp, ctx: &egui::Context) -> Vec<MenuItem> {
|
||||
let mut items = menu_items(app);
|
||||
if text_field_window(ctx).is_some() {
|
||||
for item in items.iter_mut().filter(|item| edits_text(&item.id)) {
|
||||
item.enabled = true;
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: Value) -> Result<Value, String> {
|
||||
// A focused text field takes Select All before the Project panel redirect below, or the
|
||||
// Project search box would select items.
|
||||
if id == "edit.selectAll" && select_all_in_text_field(ctx) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
pub fn invoke_command(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: Value) -> Result<Value, String> {
|
||||
// Select All / Deselect All act on the Project panel's items when it has focus (#168), only
|
||||
// what the shown bin lists (#456). Here and not in the key handling: on macOS the native menu
|
||||
// takes Cmd+A once Select All is enabled (a sequence is open) and sends the id here (#1752).
|
||||
@@ -692,8 +691,13 @@ pub fn activate_native(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) ->
|
||||
return Ok(graphic_text_command(app, ctx, id));
|
||||
}
|
||||
let (Some(window), Some(text)) = (text_field_window(ctx), text_command(id, ctx.os().is_mac())) else {
|
||||
return invoke(app, ctx, id, json!({}));
|
||||
return invoke_command(app, ctx, id, json!({}));
|
||||
};
|
||||
// Retain main's immediate Select All for the root window, including Project search. Floating
|
||||
// text fields keep the native event delivery that belongs to their own viewport.
|
||||
if id == "edit.selectAll" && window == egui::ViewportId::ROOT && select_all_in_text_field(ctx) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
match text {
|
||||
TextCommand::Paste => ctx.send_viewport_cmd_to(window, egui::ViewportCommand::RequestPaste),
|
||||
TextCommand::Events(events) if window == egui::ViewportId::ROOT => {
|
||||
@@ -705,6 +709,15 @@ pub fn activate_native(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) ->
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn select_all_in_text_field(ctx: &egui::Context) -> bool {
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
let Some(id) = ctx.memory(|m| m.focused()) else { return false };
|
||||
let Some(mut state) = egui::TextEdit::load_state(ctx, id) else { return false };
|
||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(0), CCursor::new(usize::MAX))));
|
||||
state.store(ctx, id);
|
||||
true
|
||||
}
|
||||
|
||||
/// The window whose text field has the keyboard: a floating panel's own OS window, or the main
|
||||
/// window.
|
||||
pub fn text_field_window(ctx: &egui::Context) -> Option<egui::ViewportId> {
|
||||
@@ -756,6 +769,11 @@ fn text_command(id: &str, mac: bool) -> Option<TextCommand> {
|
||||
TextCommand::Events([true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into())
|
||||
};
|
||||
Some(match id {
|
||||
"edit.clear" => TextCommand::Events(
|
||||
[true, false]
|
||||
.map(|pressed| egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE })
|
||||
.into(),
|
||||
),
|
||||
"edit.copy" => TextCommand::Events(vec![egui::Event::Copy]),
|
||||
"edit.cut" => TextCommand::Events(vec![egui::Event::Cut]),
|
||||
"edit.paste" => TextCommand::Paste,
|
||||
@@ -1123,7 +1141,7 @@ pub fn external_commands() -> Vec<filmcraft_engine::shortcuts::CommandInfo> {
|
||||
/// Draw the in-window menu bar.
|
||||
pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("interface-language"), app.ui.language));
|
||||
let items = menu_items(app);
|
||||
let items = menu_items_with_focus(app, ui.ctx());
|
||||
// Draw Edit ▸ Label with the user's live palette, not the baked-in label colours.
|
||||
let labels = app.session.prefs.labels.clone();
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
@@ -423,3 +423,9 @@ pub(crate) fn panel_items(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKin
|
||||
}
|
||||
|
||||
pub(crate) mod transcript_cache;
|
||||
|
||||
pub mod custom_colors;
|
||||
pub mod marker_properties;
|
||||
|
||||
pub mod markers_panel;
|
||||
pub mod palette_manager;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Project-local custom palette and clip label application using the shared sRGB editor.
|
||||
use crate::{
|
||||
FilmcraftApp,
|
||||
widgets::color_picker::{self, ColorDraft},
|
||||
};
|
||||
use egui::{Color32, Sense, Stroke, vec2};
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub project: Arc<()>,
|
||||
pub color: ColorDraft,
|
||||
pub params: Value,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// Saved project swatches first, then custom colors already used in this sequence.
|
||||
/// Keep saved order and bound the menu, including when loading hostile project data.
|
||||
pub fn available(app: &FilmcraftApp) -> Vec<HexColor> {
|
||||
let mut colors = Vec::new();
|
||||
for color in app
|
||||
.session
|
||||
.project
|
||||
.custom_colors
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(app.session.active_sequence().into_iter().flat_map(|s| &s.markers).filter_map(|m| m.custom_color))
|
||||
.chain(app.session.active_sequence().into_iter().flat_map(|s| s.all_tracks()).flat_map(|t| &t.items).filter_map(|i| i.custom_label_color))
|
||||
.chain(app.session.project.items.values().filter_map(|i| i.custom_label_color))
|
||||
.chain(
|
||||
app.session
|
||||
.state
|
||||
.source_item
|
||||
.and_then(|id| filmcraft_engine::clip_ops::source_view(&app.session, id))
|
||||
.into_iter()
|
||||
.flat_map(|v| v.markers)
|
||||
.filter_map(|m| m.custom_color),
|
||||
)
|
||||
{
|
||||
if !colors.contains(&color) {
|
||||
colors.push(color);
|
||||
if colors.len() == 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, params: Value, prefix: &str) {
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().id_salt(format!("{prefix}.customScroll")).max_height(140.0).show(ui, |ui| {
|
||||
for color in available(app) {
|
||||
let hex = color.hex();
|
||||
let response = ui
|
||||
.horizontal(|ui| {
|
||||
let (rect, swatch) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
let [r, g, b] = color.0;
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, ui.visuals().text_color()), egui::StrokeKind::Inside);
|
||||
swatch.union(ui.button(name(app, color)))
|
||||
})
|
||||
.inner;
|
||||
app.auto.add_response(&format!("{prefix}.custom.{hex}"), &response, &hex);
|
||||
if response.clicked() {
|
||||
let mut command = params.clone();
|
||||
command["label"] = json!(hex);
|
||||
if let Err(e) = app.session.execute("edit.label", command) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
let response = ui.button(tl!("Manage Colors…"));
|
||||
app.auto.add_response(&format!("{prefix}.manageColors"), &response, tl!("Manage Colors"));
|
||||
if response.clicked() {
|
||||
super::palette_manager::open(app);
|
||||
ui.close();
|
||||
}
|
||||
let response = ui.button(tl!("Add custom color…"));
|
||||
app.auto.add_response(&format!("{prefix}.addColor"), &response, tl!("Add custom color"));
|
||||
if response.clicked() {
|
||||
app.ui.custom_label_color =
|
||||
Some(Draft { project: app.session.project_instance.clone(), color: ColorDraft::new([128, 128, 128]), params, error: String::new() });
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut draft) = app.ui.custom_label_color.take() else { return };
|
||||
if !Arc::ptr_eq(&draft.project, &app.session.project_instance) {
|
||||
return;
|
||||
}
|
||||
let mut apply = false;
|
||||
let mut cancel = false;
|
||||
let modal = egui::Modal::new(egui::Id::unique("custom-clip-color")).show(ctx, |ui| {
|
||||
ui.set_width(360.0);
|
||||
ui.heading(tl!("Add Custom Color"));
|
||||
color_picker::show(ui, &mut app.auto, &mut app.hooks, "labelColor", &mut draft.color);
|
||||
if !draft.error.is_empty() {
|
||||
ui.colored_label(ui.visuals().error_fg_color, &draft.error);
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui.add_enabled(draft.color.value().is_some(), egui::Button::new(tl!("Add and apply")));
|
||||
app.auto.add_response("labelColor.apply", &response, tl!("Add and apply"));
|
||||
apply = response.clicked();
|
||||
let response = ui.button(tl!("Cancel"));
|
||||
app.auto.add_response("labelColor.cancel", &response, tl!("Cancel"));
|
||||
cancel = response.clicked();
|
||||
});
|
||||
});
|
||||
if cancel || modal.should_close() {
|
||||
return;
|
||||
}
|
||||
if apply && let Some(color) = draft.color.value() {
|
||||
draft.params["label"] = json!(color);
|
||||
draft.params["saveColor"] = json!(true);
|
||||
match app.session.execute("edit.label", draft.params.clone()) {
|
||||
Ok(_) => return,
|
||||
Err(e) => draft.error = e.to_string(),
|
||||
}
|
||||
}
|
||||
app.ui.custom_label_color = Some(draft);
|
||||
}
|
||||
|
||||
pub fn name(app: &FilmcraftApp, color: HexColor) -> String {
|
||||
app.session.project.custom_color_names.get(&color).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| color.hex())
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
use crate::{Dialog, FilmcraftApp};
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
crate::panels::marker_properties::show(app, ctx);
|
||||
crate::panels::custom_colors::show(app, ctx);
|
||||
crate::panels::palette_manager::show(app, ctx);
|
||||
crate::panels::media_dialogs::show(app, ctx);
|
||||
crate::panels::color_dialogs::show(app, ctx);
|
||||
crate::panels::clip_dialogs::show(app, ctx);
|
||||
@@ -14,7 +17,6 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
crate::panels::multicam::show_dialog(app, ctx);
|
||||
crate::panels::multicam::show_edit_cameras(app, ctx);
|
||||
crate::panels::monitor_view::dialogs(app, ctx);
|
||||
crate::panels::misc::marker_dialog(app, ctx);
|
||||
crate::panels::graphics::dialogs(app, ctx);
|
||||
crate::panels::workspaces::dialogs(app, ctx);
|
||||
crate::panels::voiceover::show(app, ctx);
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
//! Marker editing and project palette creation are separate, undoable operations.
|
||||
use crate::{
|
||||
FilmcraftApp,
|
||||
widgets::color_picker::{self, ColorDraft},
|
||||
};
|
||||
use egui::{Color32, Sense, Stroke, vec2};
|
||||
use filmcraft_project::{HexColor, Label, Marker, MarkerKind};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub project: Arc<()>,
|
||||
pub owner: Owner,
|
||||
pub time: String,
|
||||
pub duration: String,
|
||||
pub range: bool,
|
||||
pub original_duration: filmcraft_time::Tick,
|
||||
pub initial_time: String,
|
||||
pub initial_duration: String,
|
||||
pub marker: u64,
|
||||
pub original: Marker,
|
||||
pub kind: MarkerKind,
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
pub comment: String,
|
||||
pub author: String,
|
||||
pub hex: String,
|
||||
pub custom: bool,
|
||||
pub preset: Label,
|
||||
pub color_editor: Option<ColorDraft>,
|
||||
pub error: String,
|
||||
focus_name: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Owner {
|
||||
Sequence(u64),
|
||||
Source(u64),
|
||||
}
|
||||
impl Owner {
|
||||
pub fn params(self) -> serde_json::Value {
|
||||
match self {
|
||||
Self::Sequence(id) => json!({"target":"program","sequence":id}),
|
||||
Self::Source(id) => json!({"target":"source","item":id}),
|
||||
}
|
||||
}
|
||||
pub fn current(app: &FilmcraftApp, source: bool) -> Option<Self> {
|
||||
if source { app.session.state.source_item.map(|id| Self::Source(id.0)) } else { app.session.state.active_sequence.map(|id| Self::Sequence(id.0)) }
|
||||
}
|
||||
pub fn details(self, app: &FilmcraftApp) -> Option<(String, filmcraft_time::FrameRate, bool, Vec<Marker>)> {
|
||||
let id = filmcraft_project::ItemId(match self {
|
||||
Self::Sequence(id) | Self::Source(id) => id,
|
||||
});
|
||||
let item = app.session.project.item(id)?;
|
||||
match self {
|
||||
Self::Sequence(_) => {
|
||||
let q = app.session.project.sequence(id)?;
|
||||
Some((tlf!("Sequence: {name}", name = &item.name), q.settings.frame_rate, q.settings.drop_frame, q.markers.clone()))
|
||||
}
|
||||
Self::Source(_) => {
|
||||
let v = filmcraft_engine::clip_ops::source_view(&app.session, id)?;
|
||||
Some((
|
||||
if v.media != id { tlf!("Source: {name} (shared source markers)", name = &item.name) } else { tlf!("Source: {name}", name = &item.name) },
|
||||
v.rate,
|
||||
app.session.project.sequence(v.media).is_some_and(|q| q.settings.drop_frame),
|
||||
v.markers,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn open(app: &mut FilmcraftApp, marker: &Marker) {
|
||||
if let Some(owner) = Owner::current(app, false) {
|
||||
open_owned(app, marker, owner);
|
||||
}
|
||||
}
|
||||
pub fn open_owned(app: &mut FilmcraftApp, marker: &Marker, owner: Owner) {
|
||||
let Some((_, rate, drop, _)) = owner.details(app) else { return };
|
||||
let format = |ticks| filmcraft_time::format_time(ticks, rate, drop, filmcraft_time::TimeDisplay::Timecode, 48000).replace(';', ":");
|
||||
let time = format(marker.start);
|
||||
let duration = format(marker.duration);
|
||||
app.ui.marker_properties = Some(Draft {
|
||||
project: app.session.project_instance.clone(),
|
||||
owner,
|
||||
initial_time: time.clone(),
|
||||
initial_duration: duration.clone(),
|
||||
time,
|
||||
duration,
|
||||
range: marker.duration > filmcraft_time::Tick::ZERO,
|
||||
original_duration: marker.duration,
|
||||
marker: marker.id.0,
|
||||
original: marker.clone(),
|
||||
kind: marker.kind,
|
||||
url: marker.url.clone(),
|
||||
name: marker.name.clone(),
|
||||
comment: marker.comment.clone(),
|
||||
author: marker.author.clone(),
|
||||
hex: HexColor(marker.custom_color.map_or_else(|| app.session.prefs.labels.rgb(marker.color), |c| c.0)).hex(),
|
||||
custom: marker.custom_color.is_some(),
|
||||
preset: marker.color,
|
||||
color_editor: None,
|
||||
error: String::new(),
|
||||
focus_name: true,
|
||||
});
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Done,
|
||||
Cancel,
|
||||
Delete,
|
||||
Navigate(Marker),
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut draft) = app.ui.marker_properties.take() else { return };
|
||||
if !Arc::ptr_eq(&draft.project, &app.session.project_instance) {
|
||||
return;
|
||||
}
|
||||
let Some((title, rate, drop, mut markers)) = draft.owner.details(app) else {
|
||||
app.ui.status = tl!("Marker owner is unavailable").into();
|
||||
return;
|
||||
};
|
||||
// Commands can edit or remove this marker while its dialog (or native picker) is open.
|
||||
// Unrelated edits do not invalidate the draft. Ties use IDs so coincident markers remain reachable.
|
||||
markers.sort_by_key(|m| (m.start, m.id.0));
|
||||
let index = markers.iter().position(|m| m.id.0 == draft.marker);
|
||||
if index.and_then(|i| markers.get(i)) != Some(&draft.original) {
|
||||
app.ui.status = tl!("Marker changed or was removed. Reopen it to continue.").into();
|
||||
return;
|
||||
}
|
||||
if draft.color_editor.is_some() {
|
||||
color_dialog(app, ctx, &mut draft);
|
||||
app.ui.marker_properties = Some(draft);
|
||||
return;
|
||||
}
|
||||
let previous = index.and_then(|i| i.checked_sub(1)).and_then(|i| markers.get(i));
|
||||
let next = index.and_then(|i| i.checked_add(1)).and_then(|i| markers.get(i));
|
||||
let mut action = None;
|
||||
let modal = egui::Modal::new(egui::Id::unique("marker-properties")).show(ctx, |ui| {
|
||||
let width = (ctx.content_rect().width() - 40.0).clamp(240.0, 560.0);
|
||||
let max_height = (ctx.content_rect().height() - 110.0).max(100.0);
|
||||
ui.set_width(width);
|
||||
ui.heading(format!("{} @ {}", tl!("Edit Marker"), draft.time));
|
||||
ui.label(&title);
|
||||
ui.add_space(6.0);
|
||||
if width >= 560.0 {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width - 112.0);
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
field_area(app, ui, &mut draft, width - 112.0, max_height, rate);
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(96.0);
|
||||
action = actions(app, ui, previous, next, true, valid_times(&draft, rate, drop));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
field_area(app, ui, &mut draft, width, (max_height - if width < 400.0 { 150.0 } else { 85.0 }).max(80.0), rate);
|
||||
ui.separator();
|
||||
action = actions(app, ui, previous, next, false, valid_times(&draft, rate, drop));
|
||||
}
|
||||
if !draft.error.is_empty() {
|
||||
ui.colored_label(ui.visuals().error_fg_color, &draft.error);
|
||||
}
|
||||
});
|
||||
if matches!(action, Some(Action::Cancel)) || modal.should_close() {
|
||||
return;
|
||||
}
|
||||
match action {
|
||||
Some(Action::Delete) => {
|
||||
let mut p = draft.owner.params();
|
||||
p["marker"] = json!(draft.marker);
|
||||
match app.session.execute("markers.delete", p) {
|
||||
Ok(_) => return,
|
||||
Err(e) => draft.error = e.to_string(),
|
||||
}
|
||||
}
|
||||
Some(Action::Done | Action::Navigate(_)) => match save(app, &draft) {
|
||||
Ok(()) => {
|
||||
if let Some(Action::Navigate(marker)) = action {
|
||||
open_owned(app, &marker, draft.owner);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(e) => draft.error = e,
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
app.ui.marker_properties = Some(draft);
|
||||
}
|
||||
|
||||
fn field_area(app: &mut FilmcraftApp, ui: &mut egui::Ui, draft: &mut Draft, width: f32, height: f32, rate: filmcraft_time::FrameRate) {
|
||||
ui.allocate_ui_with_layout(vec2(width, height), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("marker-fields").max_height(height).show(ui, |ui| {
|
||||
ui.set_width(width - 12.0);
|
||||
fields(app, ui, draft, rate);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn action_button(ui: &mut egui::Ui, label: &str, width: f32, enabled: bool, primary: bool) -> egui::Response {
|
||||
ui.add_enabled(enabled, |ui: &mut egui::Ui| {
|
||||
let text = if primary { egui::RichText::new(label).color(ui.visuals().selection.stroke.color) } else { egui::RichText::new(label) };
|
||||
let mut button = egui::Button::new(text).corner_radius(14.0);
|
||||
if primary {
|
||||
button = button.fill(ui.visuals().selection.bg_fill);
|
||||
}
|
||||
ui.add_sized(vec2(width, 28.0), button)
|
||||
})
|
||||
}
|
||||
|
||||
fn actions(app: &mut FilmcraftApp, ui: &mut egui::Ui, previous: Option<&Marker>, next: Option<&Marker>, vertical: bool, can_save: bool) -> Option<Action> {
|
||||
let mut action = None;
|
||||
let mut buttons = |ui: &mut egui::Ui| {
|
||||
let width = if vertical { 80.0 } else { 96.0 };
|
||||
for (id, label, value) in [("apply", tl!("Done"), Action::Done), ("cancel", tl!("Cancel"), Action::Cancel)] {
|
||||
let response =
|
||||
action_button(ui, label, width, id != "apply" || can_save, id == "apply").on_disabled_hover_text(tl!("Enter a valid HH:MM:SS:FF timecode"));
|
||||
app.auto.add_response(&format!("markerProperties.{id}"), &response, label);
|
||||
if response.clicked() {
|
||||
action = Some(value);
|
||||
}
|
||||
if vertical {
|
||||
ui.add_space(if id == "apply" { 4.0 } else { 16.0 });
|
||||
}
|
||||
}
|
||||
for (id, label, marker) in [("previous", tl!("Previous"), previous), ("next", tl!("Next"), next)] {
|
||||
let response = action_button(ui, label, width, marker.is_some(), false);
|
||||
app.auto.add_response(&format!("markerProperties.{id}"), &response, label);
|
||||
if response.clicked()
|
||||
&& let Some(marker) = marker
|
||||
{
|
||||
action = Some(Action::Navigate(marker.clone()));
|
||||
}
|
||||
if vertical {
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
let response = action_button(ui, tl!("Delete"), width, true, false);
|
||||
app.auto.add_response("markerProperties.delete", &response, tl!("Delete"));
|
||||
if response.clicked() {
|
||||
action = Some(Action::Delete);
|
||||
}
|
||||
};
|
||||
if vertical {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Center), &mut buttons);
|
||||
} else {
|
||||
ui.horizontal_wrapped(&mut buttons);
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// Keep typing, paste and text Undo on the same bounded numeric/colon buffer.
|
||||
struct TimecodeBuffer<'a>(&'a mut String, usize);
|
||||
impl egui::TextBuffer for TimecodeBuffer<'_> {
|
||||
fn type_id(&self) -> std::any::TypeId {
|
||||
std::any::TypeId::of::<TimecodeBuffer<'static>>()
|
||||
}
|
||||
fn is_mutable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn as_str(&self) -> &str {
|
||||
self.0
|
||||
}
|
||||
fn insert_text(&mut self, text: &str, at: egui::epaint::text::CharIndex) -> usize {
|
||||
let room = self.1.saturating_sub(self.0.chars().count());
|
||||
let filtered: String = text.chars().filter(|c| c.is_ascii_digit() || *c == ':').take(room).collect();
|
||||
egui::TextBuffer::insert_text(self.0, &filtered, at)
|
||||
}
|
||||
fn delete_char_range(&mut self, range: std::ops::Range<egui::epaint::text::CharIndex>) {
|
||||
egui::TextBuffer::delete_char_range(self.0, range);
|
||||
}
|
||||
}
|
||||
|
||||
fn timecode_field(ui: &mut egui::Ui, value: &mut String, rate: filmcraft_time::FrameRate, label: &str) -> egui::Response {
|
||||
let font = egui::TextStyle::Monospace.resolve(ui.style());
|
||||
let sample = if rate.timecode_base() >= 100 { "00:00:00:000" } else { "00:00:00:00" };
|
||||
// egui 0.37 includes frame padding in desired_width; reserve it and the caret explicitly.
|
||||
let margin = egui::Margin::symmetric(4, 3);
|
||||
let width = ui.painter().layout_no_wrap(sample.into(), font.clone(), ui.visuals().text_color()).size().x + margin.sum().x + 2.0;
|
||||
crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut TimecodeBuffer(value, sample.len())).font(font).margin(margin).desired_width(width)),
|
||||
label,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_marker_timecode(value: &str, rate: filmcraft_time::FrameRate, drop: bool) -> Result<i64, String> {
|
||||
let error = || tl!("Enter a valid HH:MM:SS:FF timecode").to_string();
|
||||
let length = if rate.timecode_base() >= 100 { 12 } else { 11 };
|
||||
if value.len() != length || !value.bytes().enumerate().all(|(i, b)| if matches!(i, 2 | 5 | 8) { b == b':' } else { b.is_ascii_digit() }) {
|
||||
return Err(error());
|
||||
}
|
||||
let frame = filmcraft_time::parse_timecode(value, rate, drop, 0).map_err(|_| error())?;
|
||||
// Reject overflowed fields and skipped drop-frame labels rather than normalizing them.
|
||||
let formatted = filmcraft_time::format_timecode_frames(frame, rate, drop);
|
||||
if !formatted.split([':', ';']).map(|s| s.parse::<u32>().ok()).eq(value.split(':').map(|s| s.parse::<u32>().ok())) {
|
||||
return Err(error());
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn valid_times(draft: &Draft, rate: filmcraft_time::FrameRate, drop: bool) -> bool {
|
||||
// Imported timings can exceed the editable timecode width or contain subframes.
|
||||
// Metadata-only edits retain those exact original ticks; edited fields stay strict.
|
||||
(draft.time == draft.initial_time || parse_marker_timecode(&draft.time, rate, drop).is_ok())
|
||||
&& (!draft.range
|
||||
|| (draft.duration == draft.initial_duration && draft.original_duration > filmcraft_time::Tick::ZERO)
|
||||
|| parse_marker_timecode(&draft.duration, rate, drop).is_ok_and(|f| f > 0))
|
||||
}
|
||||
|
||||
fn field_row(ui: &mut egui::Ui, label: &str, contents: impl FnOnce(&mut egui::Ui)) {
|
||||
field_row_height(ui, label, 0.0, contents);
|
||||
}
|
||||
|
||||
fn field_row_height(ui: &mut egui::Ui, label: &str, min_height: f32, contents: impl FnOnce(&mut egui::Ui)) {
|
||||
// Match a TextEdit's first line and its two-pixel top/bottom margin, including Notes.
|
||||
let label_height = (ui.text_style_height(&egui::TextStyle::Body) + ui.spacing().extra_text_line_spacing + 4.0).max(min_height);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.allocate_ui_with_layout(vec2(76.0, label_height), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(label);
|
||||
});
|
||||
ui.vertical(contents);
|
||||
});
|
||||
}
|
||||
|
||||
fn fields(app: &mut FilmcraftApp, ui: &mut egui::Ui, draft: &mut Draft, rate: filmcraft_time::FrameRate) {
|
||||
field_row(ui, tl!("Name"), |ui| {
|
||||
let response = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut draft.name).desired_width(f32::INFINITY)), tl!("Name"));
|
||||
app.auto.add_response("markerProperties.name", &response, tl!("Name"));
|
||||
if draft.focus_name {
|
||||
response.request_focus();
|
||||
draft.focus_name = false;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Useful for multi-user projects: attribution belongs to the marker, not the current OS user.
|
||||
field_row(ui, tl!("Author"), |ui| {
|
||||
let response = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut draft.author).desired_width(f32::INFINITY)), tl!("Author"));
|
||||
app.auto.add_response("markerProperties.author", &response, tl!("Author"));
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
field_row_height(ui, tl!("Time"), ui.spacing().interact_size.y, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = timecode_field(ui, &mut draft.time, rate, tl!("Time"));
|
||||
app.auto.add_response("markerProperties.time", &response, tl!("Time"));
|
||||
ui.add_space(16.0);
|
||||
let response = ui.checkbox(&mut draft.range, tl!("Duration")).on_hover_text(tl!("Range marker"));
|
||||
app.auto.add_response("markerProperties.range", &response, tl!("Range marker"));
|
||||
if response.changed() && draft.range && draft.original_duration == filmcraft_time::Tick::ZERO && draft.duration == draft.initial_duration {
|
||||
draft.duration = filmcraft_time::format_timecode_frames(1, rate, false);
|
||||
}
|
||||
let response = ui.add_enabled(draft.range, |ui: &mut egui::Ui| timecode_field(ui, &mut draft.duration, rate, tl!("Duration")));
|
||||
app.auto.add_response("markerProperties.duration", &response, tl!("Duration"));
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
field_row(ui, tl!("Notes"), |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("marker-notes").max_height(120.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
let mut response = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::multiline(&mut draft.comment).desired_width(f32::INFINITY).desired_rows(8)),
|
||||
tl!("Notes"),
|
||||
);
|
||||
// Register the visible editor area so automation can still focus a long scrolled note.
|
||||
response.rect = response.rect.intersect(ui.clip_rect());
|
||||
app.auto.add_response("markerProperties.comment", &response, tl!("Notes"));
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.group(|ui| {
|
||||
ui.set_width(ui.available_width().max(100.0));
|
||||
ui.label(tl!("Options"));
|
||||
ui.label(tl!("Color"));
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
for preset in Label::ALL {
|
||||
let response =
|
||||
swatch(ui, app.session.prefs.labels.rgb(preset), !draft.custom && draft.preset == preset).on_hover_text(crate::i18n::t(preset.name()));
|
||||
app.auto.add_response(&format!("markerProperties.preset.{}", preset.name()), &response, crate::i18n::t(preset.name()));
|
||||
if response.clicked() {
|
||||
draft.preset = preset;
|
||||
draft.custom = false;
|
||||
draft.hex = HexColor(app.session.prefs.labels.rgb(preset)).hex();
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut colors = super::custom_colors::available(app);
|
||||
if draft.custom
|
||||
&& let Some(color) = HexColor::parse(&draft.hex)
|
||||
&& !colors.contains(&color)
|
||||
{
|
||||
colors.push(color);
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt("marker-custom-colors").max_height(100.0).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
for color in colors {
|
||||
let hex = color.hex();
|
||||
let response =
|
||||
swatch(ui, color.0, draft.custom && draft.hex.eq_ignore_ascii_case(&hex)).on_hover_text(super::custom_colors::name(app, color));
|
||||
app.auto.add_response(&format!("markerProperties.custom.{hex}"), &response, &hex);
|
||||
if response.clicked() {
|
||||
draft.hex = hex;
|
||||
draft.custom = true;
|
||||
}
|
||||
}
|
||||
let response = add_color_swatch(ui).on_hover_text(tl!("Add custom color"));
|
||||
app.auto.add_response("markerProperties.addColor", &response, tl!("Add custom color"));
|
||||
if response.clicked() {
|
||||
draft.error.clear();
|
||||
draft.color_editor = Some(ColorDraft::new(HexColor::parse(&draft.hex).map_or(app.session.prefs.labels.rgb(draft.preset), |c| c.0)));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let group = ui.vertical(|ui| {
|
||||
for kind in [MarkerKind::Comment, MarkerKind::Chapter, MarkerKind::Segmentation, MarkerKind::WebLink] {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let label = kind_label(kind);
|
||||
let response = ui.radio_value(&mut draft.kind, kind, label);
|
||||
app.auto.add_response(&format!("markerProperties.kind.{}", kind_id(kind)), &response, label);
|
||||
if kind == MarkerKind::WebLink && draft.kind == MarkerKind::WebLink {
|
||||
let response = ui.add(
|
||||
egui::TextEdit::singleline(&mut draft.url)
|
||||
.desired_width((ui.available_size_before_wrap().x - ui.spacing().item_spacing.x - 24.0).max(60.0))
|
||||
.char_limit(8192)
|
||||
.hint_text("https://"),
|
||||
);
|
||||
app.auto.add_response("markerProperties.url", &response, tl!("URL"));
|
||||
let address = filmcraft_project::marker_web_url(&draft.url);
|
||||
let response = ui.add_enabled(address.is_some(), |ui: &mut egui::Ui| {
|
||||
let response = ui.add_sized(vec2(24.0, 24.0), egui::Button::new(""));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Open Link")));
|
||||
let color = ui.style().interact(&response).fg_stroke.color;
|
||||
crate::icons::paint(ui.painter(), response.rect.shrink(4.0), crate::icons::Icon::ExternalLink, color);
|
||||
response
|
||||
});
|
||||
let response = response.on_hover_text(tl!("Opens this address in your web browser"));
|
||||
app.auto.add_response("markerProperties.openLink", &response, tl!("Open Link"));
|
||||
if response.clicked()
|
||||
&& let Some(address) = address
|
||||
{
|
||||
crate::links::open(app, ui.ctx(), &address);
|
||||
if let Some(failure) = app.ui.failed_link.as_ref().filter(|failure| failure.url == address) {
|
||||
draft.error.clone_from(&failure.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if kind == MarkerKind::WebLink
|
||||
&& draft.kind == MarkerKind::WebLink
|
||||
&& !draft.url.trim().is_empty()
|
||||
&& filmcraft_project::marker_web_url(&draft.url).is_none()
|
||||
{
|
||||
ui.colored_label(ui.visuals().error_fg_color, tl!("Enter a valid HTTP or HTTPS URL without credentials"));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add_response("markerProperties.kind", &group.response, tl!("Type"));
|
||||
});
|
||||
}
|
||||
|
||||
/// Navigation commits only this marker; unchanged navigation does not dirty the project or Undo.
|
||||
fn save(app: &mut FilmcraftApp, draft: &Draft) -> Result<(), String> {
|
||||
let params = edit_params(app, draft)?;
|
||||
let original = &draft.original;
|
||||
let changed = draft.name != original.name
|
||||
|| draft.author != original.author
|
||||
|| draft.comment != original.comment
|
||||
|| draft.kind != original.kind
|
||||
|| draft.url != original.url
|
||||
|| draft.time != draft.initial_time
|
||||
|| draft.range != (original.duration > filmcraft_time::Tick::ZERO)
|
||||
|| (draft.range && draft.duration != draft.initial_duration)
|
||||
|| draft.custom != original.custom_color.is_some()
|
||||
|| if draft.custom { HexColor::parse(&draft.hex) != original.custom_color } else { draft.preset != original.color };
|
||||
if changed {
|
||||
app.session.execute("markers.edit", params).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_params(app: &FilmcraftApp, draft: &Draft) -> Result<serde_json::Value, String> {
|
||||
let Some((_, rate, drop, _)) = draft.owner.details(app) else {
|
||||
return Err(tl!("Marker owner is unavailable").into());
|
||||
};
|
||||
let parse = |value: &str| -> Result<i64, String> {
|
||||
let f = parse_marker_timecode(value, rate, drop)?;
|
||||
if f < 0 || f > rate.frame_at(filmcraft_time::Tick::MAX) {
|
||||
return Err(tl!("Time is outside the supported range").into());
|
||||
}
|
||||
Ok(f)
|
||||
};
|
||||
let mut p = draft.owner.params();
|
||||
p["marker"] = json!(draft.marker);
|
||||
p["name"] = json!(draft.name);
|
||||
p["comment"] = json!(draft.comment);
|
||||
p["author"] = json!(draft.author);
|
||||
p["kind"] = json!(kind_id(draft.kind));
|
||||
if draft.url != draft.original.url {
|
||||
p["url"] = json!(draft.url);
|
||||
}
|
||||
p["color"] = json!(if draft.custom { draft.hex.clone() } else { draft.preset.name().to_string() });
|
||||
// Do not quantize imported subframe positions when only editing text/color.
|
||||
if draft.time != draft.initial_time {
|
||||
p["frame"] = json!(parse(&draft.time)?);
|
||||
}
|
||||
if !draft.range && draft.original_duration != filmcraft_time::Tick::ZERO {
|
||||
p["durationFrames"] = json!(0);
|
||||
} else if draft.range && (draft.duration != draft.initial_duration || draft.original_duration == filmcraft_time::Tick::ZERO) {
|
||||
let duration = parse(&draft.duration)?;
|
||||
if duration == 0 {
|
||||
return Err(tl!("A range marker needs a positive duration").into());
|
||||
}
|
||||
p["durationFrames"] = json!(duration);
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn kind_id(kind: MarkerKind) -> &'static str {
|
||||
match kind {
|
||||
MarkerKind::Comment => "comment",
|
||||
MarkerKind::Chapter => "chapter",
|
||||
MarkerKind::Segmentation => "segmentation",
|
||||
MarkerKind::WebLink => "webLink",
|
||||
MarkerKind::FlashCue => "flashCue",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_label(kind: MarkerKind) -> &'static str {
|
||||
match kind {
|
||||
MarkerKind::Comment => tl!("Comment Marker"),
|
||||
MarkerKind::Chapter => tl!("Chapter Marker"),
|
||||
MarkerKind::Segmentation => tl!("Segmentation Marker"),
|
||||
MarkerKind::WebLink => tl!("Web Link"),
|
||||
MarkerKind::FlashCue => tl!("Flash Cue Point"),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_dialog(app: &mut FilmcraftApp, ctx: &egui::Context, draft: &mut Draft) {
|
||||
let Some(editor) = &mut draft.color_editor else { return };
|
||||
let mut add = false;
|
||||
let mut cancel = false;
|
||||
let modal = egui::Modal::new(egui::Id::unique("marker-custom-color")).show(ctx, |ui| {
|
||||
ui.set_width(360.0);
|
||||
ui.heading(tl!("Add Custom Color"));
|
||||
color_picker::show(ui, &mut app.auto, &mut app.hooks, "markerColor", editor);
|
||||
if !draft.error.is_empty() {
|
||||
ui.colored_label(ui.visuals().error_fg_color, &draft.error);
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui.add_enabled(editor.value().is_some(), egui::Button::new(tl!("Add color")));
|
||||
app.auto.add_response("markerColor.add", &response, tl!("Add color"));
|
||||
add = response.clicked();
|
||||
let response = ui.button(tl!("Cancel"));
|
||||
app.auto.add_response("markerColor.cancel", &response, tl!("Cancel"));
|
||||
cancel = response.clicked();
|
||||
});
|
||||
});
|
||||
if cancel || modal.should_close() {
|
||||
draft.color_editor = None;
|
||||
draft.error.clear();
|
||||
} else if add && let Some(color) = editor.value() {
|
||||
match app.session.execute("colors.addCustom", json!({"color":color})) {
|
||||
Ok(_) => {
|
||||
draft.hex = color.hex();
|
||||
draft.custom = true;
|
||||
draft.color_editor = None;
|
||||
draft.error.clear();
|
||||
}
|
||||
Err(e) => draft.error = e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_color_swatch(ui: &mut egui::Ui) -> egui::Response {
|
||||
let (chip, response) = allocate_swatch(ui);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Add custom color")));
|
||||
let visuals = ui.style().interact(&response);
|
||||
ui.painter().rect_filled(chip, 3.0, visuals.bg_fill);
|
||||
ui.painter().rect_stroke(chip, 3.0, Stroke::new(1.0, ui.visuals().text_color().gamma_multiply(0.5)), egui::StrokeKind::Outside);
|
||||
let center = chip.center();
|
||||
let stroke = Stroke::new(1.25, visuals.text_color());
|
||||
ui.painter().line_segment([center - vec2(4.0, 0.0), center + vec2(4.0, 0.0)], stroke);
|
||||
ui.painter().line_segment([center - vec2(0.0, 4.0), center + vec2(0.0, 4.0)], stroke);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn swatch(ui: &mut egui::Ui, rgb: [u8; 3], selected: bool) -> egui::Response {
|
||||
let (chip, response) = allocate_swatch(ui);
|
||||
let [r, g, b] = rgb;
|
||||
ui.painter().rect_filled(chip, 3.0, Color32::from_rgb(r, g, b));
|
||||
ui.painter().rect_stroke(chip, 3.0, Stroke::new(1.0, ui.visuals().text_color().gamma_multiply(0.5)), egui::StrokeKind::Outside);
|
||||
if selected {
|
||||
ui.painter().rect_stroke(chip.expand(3.0), 5.0, Stroke::new(2.0, ui.visuals().text_color()), egui::StrokeKind::Outside);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn allocate_swatch(ui: &mut egui::Ui) -> (egui::Rect, egui::Response) {
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
(rect.shrink(3.0), response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timecode_tests {
|
||||
use super::parse_marker_timecode;
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
#[test]
|
||||
fn marker_timecode_requires_complete_fields_and_valid_drop_frame_labels() {
|
||||
let rate = FrameRate::FPS_29_97;
|
||||
assert_eq!(parse_marker_timecode("00:01:00:02", rate, true), Ok(1800));
|
||||
assert!(parse_marker_timecode("00:01:00:00", rate, true).is_err());
|
||||
assert_eq!(parse_marker_timecode("00:10:00:00", rate, true), Ok(17982));
|
||||
// High frame rates need three frame digits; their existing values remain editable.
|
||||
assert_eq!(parse_marker_timecode("00:00:00:119", FrameRate::FPS_120, false), Ok(119));
|
||||
assert!(parse_marker_timecode("00:00:00:120", FrameRate::FPS_120, false).is_err());
|
||||
for value in ["00:00:0:01", "00;00;00;01", "00:60:00:00", "00:00:60:00", "00:00:00:30", "00:00:00:01"] {
|
||||
assert!(parse_marker_timecode(value, rate, true).is_err(), "{value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Search and selection are view state; mutations dispatch atomic owner-scoped commands.
|
||||
use super::marker_properties::Owner;
|
||||
use crate::FilmcraftApp;
|
||||
use egui::{Color32, Rect, Sense, vec2};
|
||||
use filmcraft_project::Label;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct State {
|
||||
pub source: bool,
|
||||
pub search: String,
|
||||
#[serde(skip)]
|
||||
pub owner: Option<Owner>,
|
||||
#[serde(skip)]
|
||||
pub selected: std::collections::BTreeSet<u64>,
|
||||
#[serde(skip)]
|
||||
pub anchor: Option<u64>,
|
||||
#[serde(skip)]
|
||||
pub project: Option<std::sync::Arc<()>>,
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut state = std::mem::take(&mut app.ui.markers_panel);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(6.0)));
|
||||
let ui = &mut child;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (source, label) in [(false, "Sequence"), (true, "Source")] {
|
||||
let response = ui.selectable_label(state.source == source, crate::i18n::t(label));
|
||||
app.auto.add_response(&format!("markers.scope.{label}"), &response, crate::i18n::t(label));
|
||||
if response.clicked() {
|
||||
state.source = source;
|
||||
}
|
||||
}
|
||||
let response = ui.button(tl!("Manage Colors…"));
|
||||
app.auto.add_response("markers.manageColors", &response, tl!("Manage Colors"));
|
||||
if response.clicked() {
|
||||
super::palette_manager::open(app);
|
||||
}
|
||||
});
|
||||
let response = ui.add(egui::TextEdit::singleline(&mut state.search).hint_text(tl!("Search name, notes, author")).desired_width(f32::INFINITY));
|
||||
app.auto.add_response("markers.search", &response, tl!("Search markers"));
|
||||
let owner = Owner::current(app, state.source);
|
||||
if owner != state.owner || state.project.as_ref().is_none_or(|p| !std::sync::Arc::ptr_eq(p, &app.session.project_instance)) {
|
||||
state.selected.clear();
|
||||
state.anchor = None;
|
||||
state.owner = owner;
|
||||
state.project = Some(app.session.project_instance.clone());
|
||||
}
|
||||
let Some((owner, (title, rate, drop, markers))) = owner.and_then(|owner| owner.details(app).map(|d| (owner, d))) else {
|
||||
ui.label(if state.source { tl!("No source clip open") } else { tl!("No sequence open") });
|
||||
app.ui.markers_panel = state;
|
||||
return;
|
||||
};
|
||||
ui.label(title);
|
||||
let hidden = app.session.state.hidden_marker_colors.clone();
|
||||
let hidden_custom = app.session.state.hidden_custom_marker_colors.clone();
|
||||
let mut customs = super::custom_colors::available(app);
|
||||
for c in markers.iter().filter_map(|m| m.custom_color) {
|
||||
if !customs.contains(&c) && customs.len() < 64 {
|
||||
customs.push(c);
|
||||
}
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for color in Label::ALL {
|
||||
let on = !hidden.contains(&color);
|
||||
let response = filter(ui, app.session.prefs.labels.rgb(color), on).on_hover_text(crate::i18n::t(color.name()));
|
||||
app.auto.add_response(&format!("markers.filter.{}", color.name()), &response, crate::i18n::t(color.name()));
|
||||
if response.clicked()
|
||||
&& let Err(e) = app.session.execute("markers.filterColors", json!({"color":color.name(),"visible":!on}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for color in customs {
|
||||
let on = !hidden_custom.contains(&color);
|
||||
let response = filter(ui, color.0, on).on_hover_text(super::custom_colors::name(app, color));
|
||||
app.auto.add_response(&format!("markers.filter.{}", color.hex()), &response, &color.hex());
|
||||
if response.clicked()
|
||||
&& let Err(e) = app.session.execute("markers.filterColors", json!({"color":color,"visible":!on}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
let query = state.search.to_lowercase();
|
||||
let shown: Vec<_> = markers
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.custom_color.map_or(!hidden.contains(&m.color), |c| !hidden_custom.contains(&c))
|
||||
&& (query.is_empty() || [&m.name, &m.comment, &m.author].iter().any(|s| s.to_lowercase().contains(&query)))
|
||||
})
|
||||
.collect();
|
||||
// Hidden rows are never accidental bulk-operation targets.
|
||||
let visible_ids: std::collections::BTreeSet<_> = shown.iter().map(|m| m.id.0).collect();
|
||||
state.selected.retain(|id| visible_ids.contains(id));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let r = ui.button(tl!("Select all"));
|
||||
app.auto.add_response("markers.selectAll", &r, tl!("Select all visible markers"));
|
||||
if r.clicked() {
|
||||
state.selected = shown.iter().map(|m| m.id.0).collect();
|
||||
}
|
||||
let r = ui.add_enabled(!state.selected.is_empty(), egui::Button::new(tl!("Delete")));
|
||||
app.auto.add_response("markers.deleteSelected", &r, tl!("Delete selected markers"));
|
||||
if r.clicked() {
|
||||
bulk(app, owner, &state.selected, "markers.delete", None);
|
||||
}
|
||||
ui.add_enabled_ui(!state.selected.is_empty(), |ui| {
|
||||
let menu = ui.menu_button(tl!("Color"), |ui| {
|
||||
for preset in Label::ALL {
|
||||
let r = ui.button(crate::i18n::t(preset.name()));
|
||||
app.auto.add_response(&format!("markers.bulkColor.{}", preset.name()), &r, crate::i18n::t(preset.name()));
|
||||
if r.clicked() {
|
||||
bulk(app, owner, &state.selected, "markers.edit", Some(preset.name()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
for color in super::custom_colors::available(app) {
|
||||
let name = super::custom_colors::name(app, color);
|
||||
let r = ui.button(name);
|
||||
app.auto.add_response(&format!("markers.bulkColor.{}", color.hex()), &r, &color.hex());
|
||||
if r.clicked() {
|
||||
bulk(app, owner, &state.selected, "markers.edit", Some(&color.hex()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add_response("markers.bulkColor", &menu.response, tl!("Color selected markers"));
|
||||
});
|
||||
ui.label(tlf!("{count} selected", count = state.selected.len()));
|
||||
});
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if shown.is_empty() {
|
||||
ui.label(if markers.is_empty() { tl!("No markers") } else { tl!("No matching markers") });
|
||||
}
|
||||
for (index, m) in shown.iter().enumerate() {
|
||||
let selected = state.selected.contains(&m.id.0);
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 42.0), Sense::click());
|
||||
let t = app.tokens;
|
||||
if selected || response.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, if selected { t.row_selected } else { t.hover });
|
||||
}
|
||||
let [r, g, b] = m.custom_color.map_or_else(|| app.session.prefs.labels.rgb(m.color), |c| c.0);
|
||||
ui.painter().rect_filled(Rect::from_min_size(rect.min + vec2(4.0, 6.0), vec2(4.0, 30.0)), 2.0, Color32::from_rgb(r, g, b));
|
||||
let name = if m.name.is_empty() { tl!("Marker") } else { &m.name };
|
||||
let time = |tick| filmcraft_time::format_time(tick, rate, drop, filmcraft_time::TimeDisplay::Timecode, 48000);
|
||||
let mut line = time(m.start);
|
||||
if m.duration > filmcraft_time::Tick::ZERO {
|
||||
line.push_str(&format!(" ({})", time(m.duration)));
|
||||
}
|
||||
if m.kind == filmcraft_project::MarkerKind::Chapter {
|
||||
line.push_str(" ");
|
||||
line.push_str(tl!("Chapter"));
|
||||
}
|
||||
if !m.author.is_empty() {
|
||||
line.push_str(&format!(" · {}", m.author));
|
||||
}
|
||||
ui.painter().text(rect.min + vec2(16.0, 12.0), egui::Align2::LEFT_CENTER, name, crate::theme::Tokens::ui(12.0), t.text);
|
||||
ui.painter().text(rect.min + vec2(16.0, 29.0), egui::Align2::LEFT_CENTER, line, crate::theme::Tokens::mono(11.0), t.hot_text);
|
||||
app.auto.add_response(&format!("markers.row.{}", m.id.0), &response, name);
|
||||
if response.clicked() {
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
if mods.shift
|
||||
&& let Some(anchor) = state.anchor.and_then(|id| shown.iter().position(|m| m.id.0 == id))
|
||||
{
|
||||
if !mods.command {
|
||||
state.selected.clear();
|
||||
}
|
||||
for m in shown.iter().take(index.max(anchor) + 1).skip(index.min(anchor)) {
|
||||
if !state.selected.contains(&m.id.0) {
|
||||
state.selected.insert(m.id.0);
|
||||
}
|
||||
}
|
||||
} else if mods.command {
|
||||
if selected {
|
||||
state.selected.retain(|id| *id != m.id.0);
|
||||
} else {
|
||||
state.selected.insert(m.id.0);
|
||||
}
|
||||
state.anchor = Some(m.id.0);
|
||||
} else {
|
||||
state.selected = std::collections::BTreeSet::from([m.id.0]);
|
||||
state.anchor = Some(m.id.0);
|
||||
}
|
||||
if state.source {
|
||||
if let Err(e) = app.session.execute("source.setPlayhead", json!({"time":m.start.0})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
} else {
|
||||
app.session.set_playhead(m.start);
|
||||
}
|
||||
}
|
||||
if response.double_clicked() && ui.input(|i| !i.modifiers.command && !i.modifiers.shift) {
|
||||
super::marker_properties::open_owned(app, m, owner);
|
||||
}
|
||||
response.clone().on_hover_text(format!("{}\n{}", m.author, m.comment));
|
||||
response.context_menu(|ui| super::misc::marker_owned_menu(app, ui, m, "markers.menu", owner));
|
||||
}
|
||||
});
|
||||
app.ui.markers_panel = state;
|
||||
}
|
||||
fn bulk(app: &mut FilmcraftApp, owner: Owner, ids: &std::collections::BTreeSet<u64>, command: &str, color: Option<&str>) {
|
||||
let mut p = owner.params();
|
||||
p["markers"] = json!(ids);
|
||||
if let Some(color) = color {
|
||||
p["color"] = json!(color);
|
||||
}
|
||||
if let Err(e) = app.session.execute(command, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
fn filter(ui: &mut egui::Ui, rgb: [u8; 3], on: bool) -> egui::Response {
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
let [r, g, b] = rgb;
|
||||
let color = Color32::from_rgb(r, g, b);
|
||||
if on {
|
||||
ui.painter().rect_filled(rect.shrink(2.0), 3.0, color);
|
||||
} else {
|
||||
ui.painter().rect_stroke(rect.shrink(2.5), 3.0, egui::Stroke::new(1.0, color), egui::StrokeKind::Inside);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -656,6 +656,7 @@ fn preview_item(
|
||||
p.items.insert(
|
||||
ItemId(id),
|
||||
ProjectItem {
|
||||
custom_label_color: None,
|
||||
id: ItemId(id),
|
||||
name: e.name.clone(),
|
||||
label: Label::Iris,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Smaller panels: History, Markers, Info (the Media Browser is `panels::media_browser`).
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use filmcraft_time::{TimeDisplay, format_time, parse_timecode};
|
||||
use filmcraft_time::{TimeDisplay, format_time};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
@@ -120,275 +120,98 @@ pub fn history_dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker colours of the Markers panel filter (each chip stands for the labels that share its
|
||||
/// marker colour).
|
||||
pub const MARKER_FILTER: [filmcraft_project::Label; 7] = {
|
||||
use filmcraft_project::Label::*;
|
||||
[Green, Rose, Purple, Mango, Yellow, Blue, Teal]
|
||||
};
|
||||
/// Marker filtering uses the same sixteen presets as clip labels.
|
||||
pub const MARKER_FILTER: [filmcraft_project::Label; 16] = filmcraft_project::Label::ALL;
|
||||
|
||||
/// A marker's Label menu. An explicit program target keeps panel focus and clip/project
|
||||
/// selections from routing colour changes to another item (#1143).
|
||||
pub(crate) fn marker_label_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, marker: &filmcraft_project::Marker, prefix: &str) {
|
||||
if let Some(owner) = super::marker_properties::Owner::current(app, false) {
|
||||
marker_owned_menu(app, ui, marker, prefix, owner);
|
||||
}
|
||||
}
|
||||
pub(crate) fn marker_owned_menu(
|
||||
app: &mut FilmcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
marker: &filmcraft_project::Marker,
|
||||
prefix: &str,
|
||||
owner: super::marker_properties::Owner,
|
||||
) {
|
||||
let params = |color: &str| {
|
||||
let mut p = owner.params();
|
||||
p["marker"] = serde_json::json!(marker.id.0);
|
||||
p["color"] = serde_json::json!(color);
|
||||
p
|
||||
};
|
||||
let label = crate::i18n::t("Label");
|
||||
let menu = ui.menu_button(label, |ui| {
|
||||
for color in filmcraft_project::Label::ALL {
|
||||
let name = crate::i18n::t(color.name());
|
||||
let r = ui.selectable_label(color == marker.color, name);
|
||||
let r = ui.selectable_label(marker.custom_color.is_none() && color == marker.color, name);
|
||||
app.auto.add_response(&format!("{prefix}.color.{}", color.name()), &r, name);
|
||||
if r.clicked() {
|
||||
if let Err(error) = app.session.execute("markers.edit", serde_json::json!({"target":"program","marker":marker.id.0,"color":color.name()})) {
|
||||
if let Err(error) = app.session.execute("markers.edit", params(color.name())) {
|
||||
app.ui.status = error.to_string();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let customs = super::custom_colors::available(app);
|
||||
egui::ScrollArea::vertical().id_salt(format!("{prefix}.customScroll")).max_height(140.0).show(ui, |ui| {
|
||||
for color in &customs {
|
||||
let hex = color.hex();
|
||||
let [r, g, b] = color.0;
|
||||
let response = ui
|
||||
.horizontal(|ui| {
|
||||
let (rect, swatch) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
|
||||
ui.painter().rect_stroke(rect, 2.0, egui::Stroke::new(1.0, ui.visuals().text_color()), egui::StrokeKind::Inside);
|
||||
swatch.union(ui.selectable_label(marker.custom_color == Some(*color), super::custom_colors::name(app, *color)))
|
||||
})
|
||||
.inner;
|
||||
app.auto.add_response(&format!("{prefix}.custom.{hex}"), &response, &hex);
|
||||
if response.clicked() {
|
||||
if let Err(e) = app.session.execute("markers.edit", params(&hex)) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
let response = ui.button(tl!("Custom Color…"));
|
||||
app.auto.add_response(&format!("{prefix}.customColor"), &response, tl!("Custom Color"));
|
||||
if response.clicked() {
|
||||
super::marker_properties::open_owned(app, marker, owner);
|
||||
if let Some(draft) = &mut app.ui.marker_properties {
|
||||
draft.color_editor = Some(crate::widgets::color_picker::ColorDraft::new(
|
||||
marker.custom_color.map_or_else(|| app.session.prefs.labels.rgb(marker.color), |c| c.0),
|
||||
));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
app.auto.add_response(&format!("{prefix}.label"), &menu.response, label);
|
||||
}
|
||||
|
||||
/// The marker types of the Edit Marker dialog's Options: kind, `markers.edit` name, label.
|
||||
const MARKER_KINDS: [(filmcraft_project::MarkerKind, &str, &str); 5] = {
|
||||
use filmcraft_project::MarkerKind::*;
|
||||
[
|
||||
(Comment, "comment", "Comment Marker"),
|
||||
(Chapter, "chapter", "Chapter Marker"),
|
||||
(Segmentation, "segmentation", "Segmentation Marker"),
|
||||
(WebLink, "webLink", "Web Link"),
|
||||
(FlashCue, "flashCue", "Flash Cue Point"),
|
||||
]
|
||||
};
|
||||
|
||||
/// Opens the Edit Marker dialog on a marker of the active sequence (#2416).
|
||||
pub(crate) fn open_marker_dialog(app: &mut FilmcraftApp, marker: &filmcraft_project::Marker) {
|
||||
let (Some(seq), Some(sequence)) = (app.session.active_sequence(), app.session.state.active_sequence) else { return };
|
||||
let duration = format_time(marker.duration, seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
|
||||
let kind = MARKER_KINDS.iter().find(|k| k.0 == marker.kind).map_or("comment", |k| k.1);
|
||||
app.ui.marker_dialog = Some(crate::state::MarkerDraft {
|
||||
sequence: sequence.0,
|
||||
generation: app.session.document_epoch,
|
||||
duration0: duration.clone(),
|
||||
marker: marker.id.0,
|
||||
name: marker.name.clone(),
|
||||
comment: marker.comment.clone(),
|
||||
duration,
|
||||
color: marker.color.name().into(),
|
||||
kind: kind.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// "Edit Marker…" in a marker's right-click menu (automation id `<prefix>.edit`).
|
||||
pub(crate) fn marker_edit_item(app: &mut FilmcraftApp, ui: &mut egui::Ui, marker: &filmcraft_project::Marker, prefix: &str) {
|
||||
let label = tl!("Edit Marker…");
|
||||
let r = ui.button(label);
|
||||
app.auto.add_response(&format!("{prefix}.edit"), &r, label);
|
||||
if r.clicked() {
|
||||
open_marker_dialog(app, marker);
|
||||
let response = ui.button(tl!("Edit Marker…"));
|
||||
app.auto.add_response(&format!("{prefix}.properties"), &response, tl!("Edit Marker"));
|
||||
if response.clicked() {
|
||||
super::marker_properties::open_owned(app, marker, owner);
|
||||
ui.close();
|
||||
}
|
||||
let response = ui.button(tl!("Delete Marker"));
|
||||
app.auto.add_response(&format!("{prefix}.delete"), &response, tl!("Delete Marker"));
|
||||
if response.clicked() {
|
||||
let mut p = owner.params();
|
||||
p["marker"] = serde_json::json!(marker.id.0);
|
||||
if let Err(e) = app.session.execute("markers.delete", p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Marker (Premiere's Marker dialog): Name, Duration, Comments, Marker Color and Options; OK is
|
||||
/// one `markers.edit`, so one undo step. Cancel or Escape leave the marker as it was.
|
||||
///
|
||||
/// Automation ids: `markerDialog.name`, `markerDialog.duration`, `markerDialog.comment`,
|
||||
/// `markerDialog.color.<label>`, `markerDialog.kind.<kind>`, `markerDialog.ok`, `markerDialog.cancel`.
|
||||
pub(crate) fn marker_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.marker_dialog.clone() else { return };
|
||||
// another project or sequence: the marker id may name another marker there
|
||||
let same_place = app.session.state.active_sequence.map(|s| s.0) == Some(d.sequence) && app.session.document_epoch == d.generation;
|
||||
let Some(seq) = app.session.active_sequence().filter(|_| same_place) else {
|
||||
app.ui.marker_dialog = None;
|
||||
return;
|
||||
};
|
||||
let (rate, drop_frame) = (seq.settings.frame_rate, seq.settings.drop_frame);
|
||||
// the marker may be gone (undo, Clear Markers) while the dialog is open
|
||||
let Some(start) = seq.markers.iter().find(|m| m.id.0 == d.marker).map(|m| m.start) else {
|
||||
app.ui.marker_dialog = None;
|
||||
return;
|
||||
};
|
||||
let accent = app.tokens.accent;
|
||||
let mut keep = true;
|
||||
let mut ok = false;
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
egui::Window::new(tl!("Marker"))
|
||||
.id(egui::Id::unique("Edit Marker"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(Align2::CENTER_CENTER)
|
||||
.default_pos(crate::widgets::dialog_center(ctx))
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("marker-dialog").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0));
|
||||
elems.push(("markerDialog.name".into(), r.rect, d.name.clone()));
|
||||
ui.end_row();
|
||||
ui.label(tl!("In:"));
|
||||
ui.label(egui::RichText::new(format_time(start, rate, drop_frame, TimeDisplay::Timecode, 48000)).font(Tokens::mono(12.0)));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Duration:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.duration).font(Tokens::mono(12.0)).desired_width(120.0));
|
||||
elems.push(("markerDialog.duration".into(), r.rect, d.duration.clone()));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Comments:"));
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_width(260.0).desired_rows(4));
|
||||
elems.push(("markerDialog.comment".into(), r.rect, d.comment.clone()));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Marker Color:"));
|
||||
ui.horizontal(|ui| {
|
||||
for l in MARKER_FILTER {
|
||||
let c = l.marker_rgb();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
let col = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
ui.painter().rect_filled(r.shrink(3.0), 3.0, col);
|
||||
if d.color == l.name() {
|
||||
ui.painter().rect_stroke(r, 3.0, egui::Stroke::new(1.5, Color32::WHITE), egui::StrokeKind::Inside);
|
||||
}
|
||||
elems.push((format!("markerDialog.color.{}", l.name()), r, l.name().into()));
|
||||
if resp.on_hover_text(crate::i18n::t(l.name())).clicked() {
|
||||
d.color = l.name().into();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Options:"));
|
||||
ui.vertical(|ui| {
|
||||
for (_, id, label) in MARKER_KINDS {
|
||||
let label = crate::i18n::t(label);
|
||||
let r = ui.radio(d.kind == id, label);
|
||||
elems.push((format!("markerDialog.kind.{id}"), r.rect, label.to_string()));
|
||||
if r.clicked() {
|
||||
d.kind = id.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let c = ui.button(tl!("Cancel"));
|
||||
elems.push(("markerDialog.cancel".into(), c.rect, "Cancel".into()));
|
||||
if c.clicked() {
|
||||
keep = false;
|
||||
}
|
||||
let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(Color32::WHITE)).fill(accent));
|
||||
elems.push(("markerDialog.ok".into(), o.rect, "OK".into()));
|
||||
if o.clicked() {
|
||||
ok = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if crate::widgets::escape_closes(ctx) {
|
||||
keep = false;
|
||||
}
|
||||
if ok {
|
||||
let mut p = serde_json::json!({"target": "program", "marker": d.marker, "name": d.name, "comment": d.comment, "color": d.color, "kind": d.kind});
|
||||
// the duration only when it was edited: the field shows whole frames, and a marker's
|
||||
// duration need not be (#2470 review)
|
||||
let duration = if d.duration.trim() == d.duration0.trim() { Ok(None) } else { parse_timecode(&d.duration, rate, drop_frame, 0).map(Some) };
|
||||
match duration {
|
||||
Ok(frames) => {
|
||||
if let Some(f) = frames {
|
||||
p["durationFrames"] = serde_json::json!(f);
|
||||
}
|
||||
match app.session.execute("markers.edit", p) {
|
||||
Ok(_) => keep = false,
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => app.ui.status = tlf!("Invalid duration: {error}", error = e),
|
||||
}
|
||||
}
|
||||
app.ui.marker_dialog = keep.then_some(d);
|
||||
}
|
||||
|
||||
/// Markers panel: a colour filter row (click a chip to show/hide that colour; automation ids
|
||||
/// `markers.filter.<label>`), then the sequence markers (`markers.row.<id>`; click = go to it).
|
||||
pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
// Keep the immutable project snapshot alive while UI commands mutate the session.
|
||||
// Cloning a sequence here copies every clip and effect on every redraw.
|
||||
let project = app.session.project.clone();
|
||||
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
|
||||
crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)"));
|
||||
return;
|
||||
};
|
||||
let hidden: Vec<[u8; 3]> = app.session.state.hidden_marker_colors.iter().map(|l| l.marker_rgb()).collect();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(6.0)));
|
||||
let mut go = None;
|
||||
let mut toggle = None;
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
child.horizontal(|ui| {
|
||||
for l in MARKER_FILTER {
|
||||
let c = l.marker_rgb();
|
||||
let on = !hidden.contains(&c);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
let col = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
if on {
|
||||
ui.painter().rect_filled(r.shrink(2.0), 3.0, col);
|
||||
} else {
|
||||
ui.painter().rect_stroke(r.shrink(2.5), 3.0, egui::Stroke::new(1.0, col), egui::StrokeKind::Inside);
|
||||
}
|
||||
elems.push((format!("markers.filter.{}", l.name()), r, format!("{} markers {}", l.name(), if on { "shown" } else { "hidden" })));
|
||||
if resp.on_hover_text(tlf!("Show or hide {color} markers", color = crate::i18n::t(l.name()))).clicked() {
|
||||
toggle = Some((l, !on));
|
||||
}
|
||||
}
|
||||
});
|
||||
child.add_space(4.0);
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
let mut shown = 0;
|
||||
for m in seq.markers.iter().filter(|m| !hidden.contains(&m.color.marker_rgb())) {
|
||||
shown += 1;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let c = m.color.marker_rgb();
|
||||
ui.painter().rect_filled(Rect::from_min_size(r.min + vec2(4.0, 6.0), vec2(4.0, 26.0)), 2.0, Color32::from_rgb(c[0], c[1], c[2]));
|
||||
let name = if m.name.is_empty() { tl!("Marker") } else { &m.name };
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.min.y + 12.0), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
|
||||
let mut tc = format_time(m.start, seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
|
||||
if m.duration > filmcraft_time::Tick::ZERO {
|
||||
let d = format_time(m.duration, seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
|
||||
tc = format!("{tc} ({d})");
|
||||
}
|
||||
if m.kind == filmcraft_project::MarkerKind::Chapter {
|
||||
tc = tlf!("{tc} Chapter", tc);
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.min.y + 27.0), Align2::LEFT_CENTER, tc, Tokens::mono(11.0), t.hot_text);
|
||||
elems.push((format!("markers.row.{}", m.id.0), r, name.to_string()));
|
||||
if resp.clicked() {
|
||||
go = Some(m.start);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
open_marker_dialog(app, m);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
marker_edit_item(app, ui, m, "markers.menu");
|
||||
marker_label_menu(app, ui, m, "markers.menu");
|
||||
});
|
||||
}
|
||||
if seq.markers.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("No markers. Press M to add one.")).color(t.text_faint));
|
||||
} else if shown == 0 {
|
||||
ui.label(egui::RichText::new(tl!("All markers are hidden by the colour filter.")).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if let Some((l, visible)) = toggle
|
||||
&& let Err(e) = app.session.execute("markers.filterColors", serde_json::json!({"color": l.name(), "visible": visible}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if let Some(g) = go {
|
||||
app.session.set_playhead(g);
|
||||
}
|
||||
super::markers_panel::show(app, ui, rect);
|
||||
}
|
||||
|
||||
pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
@@ -954,18 +954,9 @@ fn mini_timeline(
|
||||
Which::Program => app.session.active_sequence().map(|q| q.markers.clone()),
|
||||
Which::Source => app.session.state.source_item.and_then(|i| filmcraft_engine::clip_ops::source_view(&app.session, i)).map(|v| v.markers),
|
||||
};
|
||||
if let Some(markers) = markers {
|
||||
for m in markers.iter().filter(|m| shows(m.start)) {
|
||||
let x = xof(m.start);
|
||||
let c = m.color.marker_rgb();
|
||||
let c = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let y = bar.min.y;
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 3.5, y), pos2(x + 3.5, y), pos2(x + 3.5, y + 7.0), pos2(x, y + 10.0), pos2(x - 3.5, y + 7.0)],
|
||||
c,
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
let within = |time: Tick| time >= shown.0 && time <= shown.1;
|
||||
if let Some(markers) = &markers {
|
||||
paint_ruler_markers(app, p, bar, markers, &xof, &within, &shows);
|
||||
}
|
||||
if shows(time) {
|
||||
let x = xof(time);
|
||||
@@ -984,7 +975,36 @@ fn mini_timeline(
|
||||
if interaction.out_hot {
|
||||
crate::panels::source_range::trim_cue(p, b, bar, -4.0);
|
||||
}
|
||||
let scrub = interaction.scrub.or_else(|| {
|
||||
let mut marker_seek = None;
|
||||
if let (Some(list), Some(owner)) = (&markers, super::marker_properties::Owner::current(app, which == Which::Source)) {
|
||||
for marker in list {
|
||||
let end = (marker.duration > Tick::ZERO).then(|| marker.start + marker.duration);
|
||||
for (is_end, time) in std::iter::once((false, marker.start)).chain(end.map(|time| (true, time))) {
|
||||
if !(if end.is_some() { within(time) } else { shows(time) }) {
|
||||
continue;
|
||||
}
|
||||
let rect = Rect::from_min_max(pos2(xof(time) - 4.0, bar.min.y), pos2(xof(time) + 4.0, bar.min.y + 10.0));
|
||||
// Both flags retain the exact marker owner, including coincident ranges.
|
||||
let kind = if is_end { "monitor-marker-end" } else { "monitor-marker" };
|
||||
let response = ui.interact(rect, egui::Id::unique((kind, which as u8, marker.id.0)), Sense::click());
|
||||
let control = if is_end { "markerEnd" } else { "marker" };
|
||||
app.auto.add_response(
|
||||
&format!("{}.{control}.{}", if which == Which::Source { "source" } else { "program" }, marker.id.0),
|
||||
&response,
|
||||
&marker.name,
|
||||
);
|
||||
if response.clicked() {
|
||||
marker_seek = Some(marker.start);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
super::marker_properties::open_owned(app, marker, owner);
|
||||
}
|
||||
response.context_menu(|ui| super::misc::marker_owned_menu(app, ui, marker, "monitor.markerMenu", owner));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scrub = marker_seek.or(interaction.scrub).or_else(|| {
|
||||
if !interaction.handled && (resp.dragged_by(egui::PointerButton::Primary) || resp.clicked()) {
|
||||
resp.interact_pointer_pos().map(|pos| {
|
||||
let f = ((pos.x - bar.min.x) / bar.width().max(1.0)).clamp(0.0, 1.0) as f64;
|
||||
@@ -1270,6 +1290,37 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) {
|
||||
let _ = app.session.execute("source.setPlayhead", json!({"time": t.0}));
|
||||
}
|
||||
|
||||
fn paint_ruler_markers(
|
||||
app: &FilmcraftApp,
|
||||
p: &egui::Painter,
|
||||
bar: Rect,
|
||||
markers: &[filmcraft_project::Marker],
|
||||
xof: &impl Fn(Tick) -> f32,
|
||||
within: &impl Fn(Tick) -> bool,
|
||||
shows: &impl Fn(Tick) -> bool,
|
||||
) {
|
||||
for m in markers {
|
||||
let c = m.custom_color.map_or_else(|| app.session.prefs.labels.rgb(m.color), |c| c.0);
|
||||
let c = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let glyph = |time| Rect::from_min_size(pos2(xof(time) - 3.5, bar.min.y), vec2(7.0, 10.0));
|
||||
if m.duration > Tick::ZERO {
|
||||
let end = m.start + m.duration;
|
||||
let band = Rect::from_min_max(pos2(xof(m.start), bar.min.y), pos2(xof(end), bar.min.y + 7.0));
|
||||
if band.width() > 0.0 {
|
||||
p.rect_filled(band, 0.0, c.gamma_multiply(0.6));
|
||||
}
|
||||
if within(m.start) {
|
||||
crate::widgets::marker_glyph(p, glyph(m.start), 3.0, crate::widgets::MarkerPart::Left, c);
|
||||
}
|
||||
if within(end) {
|
||||
crate::widgets::marker_glyph(p, glyph(end), 3.0, crate::widgets::MarkerPart::Right, c);
|
||||
}
|
||||
} else if shows(m.start) {
|
||||
crate::widgets::marker_glyph(p, glyph(m.start), 3.0, crate::widgets::MarkerPart::Whole, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! A separate project palette manager, shared by marker and clip menus.
|
||||
use crate::{
|
||||
FilmcraftApp,
|
||||
widgets::color_picker::{self, ColorDraft},
|
||||
};
|
||||
use filmcraft_project::HexColor;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
project: Arc<()>,
|
||||
editing: bool,
|
||||
old: Option<HexColor>,
|
||||
color: ColorDraft,
|
||||
name: String,
|
||||
error: String,
|
||||
}
|
||||
pub fn open(app: &mut FilmcraftApp) {
|
||||
app.ui.palette_manager = Some(Draft {
|
||||
project: app.session.project_instance.clone(),
|
||||
editing: false,
|
||||
old: None,
|
||||
color: ColorDraft::new([128, 128, 128]),
|
||||
name: String::new(),
|
||||
error: String::new(),
|
||||
});
|
||||
}
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut draft) = app.ui.palette_manager.take() else { return };
|
||||
if !Arc::ptr_eq(&draft.project, &app.session.project_instance) {
|
||||
return;
|
||||
}
|
||||
let mut close = false;
|
||||
let modal = egui::Modal::new(egui::Id::unique("palette-manager")).show(ctx, |ui| {
|
||||
ui.set_width(360.0);
|
||||
ui.heading(if draft.editing { if draft.old.is_some() { tl!("Edit Custom Color") } else { tl!("Add Custom Color") } } else { tl!("Project Colors") });
|
||||
if draft.editing {
|
||||
ui.label(tl!("Name"));
|
||||
let r = ui.text_edit_singleline(&mut draft.name);
|
||||
app.auto.add_response("palette.name", &r, tl!("Name"));
|
||||
color_picker::show(ui, &mut app.auto, &mut app.hooks, "paletteColor", &mut draft.color);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add_enabled(draft.color.value().is_some(), egui::Button::new(tl!("Save")));
|
||||
app.auto.add_response("palette.save", &r, tl!("Save color"));
|
||||
if r.clicked()
|
||||
&& let Some(color) = draft.color.value()
|
||||
{
|
||||
let result = if let Some(old) = draft.old {
|
||||
app.session.execute("colors.updateCustom", json!({"color":old,"replacement":color,"name":draft.name}))
|
||||
} else {
|
||||
app.session.execute("colors.addCustom", json!({"color":color,"name":draft.name}))
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
draft.editing = false;
|
||||
draft.color.close_native();
|
||||
draft.error.clear();
|
||||
}
|
||||
Err(e) => draft.error = e.to_string(),
|
||||
}
|
||||
}
|
||||
let r = ui.button(tl!("Cancel"));
|
||||
app.auto.add_response("palette.cancelEdit", &r, tl!("Cancel"));
|
||||
if r.clicked() {
|
||||
draft.editing = false;
|
||||
draft.color.close_native();
|
||||
draft.error.clear();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let colors = app.session.project.custom_colors.clone();
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
if colors.is_empty() {
|
||||
ui.label(tl!("No saved colors"));
|
||||
}
|
||||
for color in colors.into_iter().take(64) {
|
||||
ui.horizontal(|ui| {
|
||||
let r = super::marker_properties::swatch(ui, color.0, false);
|
||||
app.auto.add_response(&format!("palette.swatch.{}", color.hex()), &r, &color.hex());
|
||||
ui.label(super::custom_colors::name(app, color));
|
||||
let edit = ui.button(tl!("Edit"));
|
||||
app.auto.add_response(&format!("palette.edit.{}", color.hex()), &edit, tl!("Edit color"));
|
||||
if edit.clicked() || r.clicked() {
|
||||
draft.old = Some(color);
|
||||
draft.color = ColorDraft::new(color.0);
|
||||
draft.name = app.session.project.custom_color_names.get(&color).cloned().unwrap_or_default();
|
||||
draft.editing = true;
|
||||
draft.error.clear();
|
||||
}
|
||||
let r = ui.button(tl!("Remove"));
|
||||
app.auto.add_response(&format!("palette.remove.{}", color.hex()), &r, tl!("Remove saved color"));
|
||||
if r.clicked()
|
||||
&& let Err(e) = app.session.execute("colors.removeCustom", json!({"color":color}))
|
||||
{
|
||||
draft.error = e.to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.small(tl!("Editing or removing a swatch leaves assigned colors unchanged."));
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("+");
|
||||
app.auto.add_response("palette.add", &r, tl!("Add custom color"));
|
||||
if r.clicked() {
|
||||
draft.editing = true;
|
||||
draft.old = None;
|
||||
draft.name.clear();
|
||||
draft.color = ColorDraft::new([128, 128, 128]);
|
||||
draft.error.clear();
|
||||
}
|
||||
let r = ui.button(tl!("Done"));
|
||||
app.auto.add_response("palette.close", &r, tl!("Done"));
|
||||
close = r.clicked();
|
||||
});
|
||||
}
|
||||
if !draft.error.is_empty() {
|
||||
ui.colored_label(ui.visuals().error_fg_color, &draft.error);
|
||||
}
|
||||
});
|
||||
app.auto.add_response("palette.dialog", &modal.response, tl!("Project Colors"));
|
||||
if !close && !modal.should_close() {
|
||||
app.ui.palette_manager = Some(draft);
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,8 @@ fn name_matches(name: &str, filter: &str) -> bool {
|
||||
filter.split_whitespace().all(|word| name.contains(word))
|
||||
}
|
||||
|
||||
fn label_color(app: &FilmcraftApp, l: filmcraft_project::Label) -> Color32 {
|
||||
let c = app.session.prefs.labels.rgb(l);
|
||||
fn label_color(app: &FilmcraftApp, l: filmcraft_project::Label, custom: Option<filmcraft_project::HexColor>) -> Color32 {
|
||||
let c = custom.map_or_else(|| app.session.prefs.labels.rgb(l), |c| c.0);
|
||||
Color32::from_rgb(c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
@@ -316,6 +316,7 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
super::custom_colors::menu(app, ui, json!({"items":[id.0]}), "project.itemMenu.label");
|
||||
});
|
||||
app.auto.add("project.itemMenu.label", r.response.rect, tl!("Label"));
|
||||
let can_reveal = app.session.project.resolve_media(id).is_some_and(|(_, m, _)| matches!(m.media, filmcraft_project::MediaRef::File { .. }));
|
||||
@@ -794,8 +795,8 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
||||
*row += 1;
|
||||
// label chip
|
||||
let chip = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(12.0, 12.0));
|
||||
ui.painter().rect_filled(chip, 2.5, label_color(app, it.label));
|
||||
app.auto.add(&format!("{}.item.{}.label", v.prefix, id.0), chip, it.label.name());
|
||||
ui.painter().rect_filled(chip, 2.5, label_color(app, it.label, it.custom_label_color));
|
||||
app.auto.add(&format!("{}.item.{}.label", v.prefix, id.0), chip, &it.label_value());
|
||||
let mut x = r.min.x + LEAD;
|
||||
let name_w = lc.cols.first().map(|c| c.width).unwrap_or(200.0);
|
||||
let nx = x + depth as f32 * 14.0 + 14.0;
|
||||
@@ -825,8 +826,12 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
||||
let cr = Rect::from_min_size(pos2(x, r.min.y), vec2(c.width, r.height()));
|
||||
let p = ui.painter().with_clip_rect(cr.shrink2(vec2(2.0, 0.0)).intersect(ui.clip_rect()));
|
||||
if c.name == "Label" {
|
||||
p.rect_filled(Rect::from_center_size(pos2(cr.min.x + 12.0, cr.center().y), vec2(10.0, 10.0)), 2.0, label_color(app, it.label));
|
||||
p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(it.label.name()), Tokens::ui(lc.font - 0.5), t.text_dim);
|
||||
p.rect_filled(
|
||||
Rect::from_center_size(pos2(cr.min.x + 12.0, cr.center().y), vec2(10.0, 10.0)),
|
||||
2.0,
|
||||
label_color(app, it.label, it.custom_label_color),
|
||||
);
|
||||
p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(&it.label_value()), Tokens::ui(lc.font - 0.5), t.text_dim);
|
||||
} else {
|
||||
let (text, _) = pp::cell(&app.session.project, &it, &c.name);
|
||||
let text = if c.name == "Media Type" { crate::i18n::t(&text).to_string() } else { text };
|
||||
@@ -954,7 +959,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st
|
||||
}
|
||||
let nr = Rect::from_min_max(pos2(th.min.x, th.max.y + 2.0), pos2(th.max.x - dw - 6.0, th.max.y + 20.0));
|
||||
if show_name && !rename_field(app, ui, nr, Some(id.0), None, pre) {
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(th.min.x, ty - 5.0), vec2(8.0, 10.0)), 1.5, label_color(app, it.label));
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(th.min.x, ty - 5.0), vec2(8.0, 10.0)), 1.5, label_color(app, it.label, it.custom_label_color));
|
||||
let g = elided_name(ui, &it.name, Tokens::ui(f - 0.5), if badge.offline { OFFLINE } else { t.text }, nr.max.x - th.min.x - 12.0);
|
||||
let elided = g.elided;
|
||||
ui.painter().galley(pos2(th.min.x + 12.0, ty - g.size().y / 2.0), g, t.text);
|
||||
|
||||
@@ -30,7 +30,6 @@ mod clips;
|
||||
mod headers;
|
||||
mod interact;
|
||||
mod layout;
|
||||
mod marker_dialog;
|
||||
mod menus;
|
||||
mod ruler;
|
||||
mod scrollbars;
|
||||
@@ -284,7 +283,6 @@ fn label_color(app: &FilmcraftApp, l: filmcraft_project::Label) -> Color32 {
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
marker_dialog::show(app, &ctx);
|
||||
let Some(seq_id) = app.session.state.active_sequence else {
|
||||
empty_state(app, ui, rect);
|
||||
return;
|
||||
|
||||
@@ -191,7 +191,13 @@ pub(super) fn draw_clip(
|
||||
t: &Tokens,
|
||||
rate: FrameRate,
|
||||
) {
|
||||
let base = label_color(app, it.label);
|
||||
let base = it.custom_label_color.map_or_else(
|
||||
|| label_color(app, it.label),
|
||||
|c| {
|
||||
let [r, g, b] = c.0;
|
||||
Color32::from_rgb(r, g, b)
|
||||
},
|
||||
);
|
||||
let inr = in_range(app, it);
|
||||
let fill = if !it.enabled {
|
||||
// Keep the configured label hue when disabled, with an opaque darker fill (#1190).
|
||||
@@ -266,7 +272,13 @@ pub(super) fn draw_clip(
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
Tokens::ui(11.5),
|
||||
if it.enabled { Color32::from_rgb(0xd9, 0xd9, 0xd9) } else { t.text_faint },
|
||||
if it.custom_label_color.is_some() && u32::from(fill.r()) * 299 + u32::from(fill.g()) * 587 + u32::from(fill.b()) * 114 > 140_000 {
|
||||
Color32::BLACK
|
||||
} else if it.enabled {
|
||||
Color32::from_rgb(0xd9, 0xd9, 0xd9)
|
||||
} else {
|
||||
t.text_faint
|
||||
},
|
||||
);
|
||||
}
|
||||
// fx badge right-aligned in the header (italic-ish "fx")
|
||||
|
||||
@@ -1181,8 +1181,20 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence
|
||||
|
||||
commit_drag(app, &ctx, &resp, seq, layout);
|
||||
|
||||
// Marker flags overlap the work area's hit padding; a double-click has one owner.
|
||||
let marker_double_clicked = if resp.double_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some(marker) = marker_under_pointer(seq, layout, p)
|
||||
{
|
||||
crate::panels::marker_properties::open(app, marker);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// ---- double-click the work area bar: it spans the part of the sequence the ruler shows
|
||||
if resp.double_clicked()
|
||||
&& !marker_double_clicked
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Hit::WorkArea { .. } = hit(seq, layout, p)
|
||||
{
|
||||
@@ -1201,7 +1213,7 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some(marker) = marker_under_pointer(seq, layout, p)
|
||||
{
|
||||
super::marker_dialog::open(app, &ctx, marker.id);
|
||||
super::super::marker_properties::open(app, marker);
|
||||
}
|
||||
|
||||
// ---- double-click a transition: Set Transition Duration (Premiere)
|
||||
@@ -1353,6 +1365,9 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
super::super::custom_colors::menu(app, ui,
|
||||
json!({"clips":sel.iter().map(|id| id.0).collect::<Vec<_>>(),"items":[],"sequence":app.session.state.active_sequence.map(|id|id.0)}),
|
||||
"timeline.clipMenu.label");
|
||||
});
|
||||
app.auto.add_response("timeline.clipMenu.edit.label", &menu.response, crate::i18n::t(label));
|
||||
});
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//! The Edit Marker dialog: double-click a marker on the Timeline ruler (or Edit Marker… in its menu)
|
||||
//! to rename it, add a comment, pick its colour and give it a duration (#3023). It runs
|
||||
//! `markers.edit`, so one OK is one undo step.
|
||||
|
||||
use super::{FilmcraftApp, MarkerId, json};
|
||||
use egui::Align2;
|
||||
use filmcraft_project::{Label, Marker};
|
||||
|
||||
/// The dialog's fields while it is open, kept in egui's temporary data until OK or Cancel.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Draft {
|
||||
marker: u64,
|
||||
name: String,
|
||||
comment: String,
|
||||
color: Label,
|
||||
duration_frames: i64,
|
||||
/// The duration when the dialog opened, in whole frames: `durationFrames` is sent only once it has been edited, so a
|
||||
/// duration that is not a whole number of frames, or beyond the control's range, survives renaming the marker.
|
||||
opened_frames: i64,
|
||||
}
|
||||
|
||||
fn key() -> egui::Id {
|
||||
egui::Id::unique("timeline.markerDialog.draft")
|
||||
}
|
||||
|
||||
/// Opens the dialog for `marker`, with the marker's current fields.
|
||||
pub(crate) fn open(app: &FilmcraftApp, ctx: &egui::Context, marker: MarkerId) {
|
||||
let Some(seq) = app.session.active_sequence() else { return };
|
||||
let Some(m) = seq.markers.iter().find(|m| m.id == marker) else { return };
|
||||
ctx.data_mut(|d| d.insert_temp(key(), draft_of(m, seq.settings.frame_rate.sane())));
|
||||
}
|
||||
|
||||
fn draft_of(m: &Marker, rate: filmcraft_time::FrameRate) -> Draft {
|
||||
let frames = rate.frame_at(m.duration).max(0);
|
||||
Draft { marker: m.id.0, name: m.name.clone(), comment: m.comment.clone(), color: m.color, duration_frames: frames, opened_frames: frames }
|
||||
}
|
||||
|
||||
/// The parameters of the `markers.edit` that OK runs.
|
||||
fn edit_params(draft: &Draft) -> serde_json::Value {
|
||||
let mut params = json!({
|
||||
"target": "program",
|
||||
"marker": draft.marker,
|
||||
"name": draft.name,
|
||||
"comment": draft.comment,
|
||||
"color": draft.color.name(),
|
||||
});
|
||||
if draft.duration_frames != draft.opened_frames {
|
||||
params["durationFrames"] = json!(draft.duration_frames);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// Draws the dialog when it is open. It closes by itself when its marker is gone.
|
||||
pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut draft) = ctx.data(|d| d.get_temp::<Draft>(key())) else { return };
|
||||
let Some(seq) = app.session.active_sequence() else {
|
||||
ctx.data_mut(|d| d.remove::<Draft>(key()));
|
||||
return;
|
||||
};
|
||||
if !seq.markers.iter().any(|m| m.id.0 == draft.marker) {
|
||||
ctx.data_mut(|d| d.remove::<Draft>(key()));
|
||||
return;
|
||||
}
|
||||
let accent = app.tokens.accent;
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
egui::Window::new(tl!("Edit Marker"))
|
||||
.id(key().with("window"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(320.0)
|
||||
.pivot(Align2::CENTER_CENTER)
|
||||
.default_pos(crate::widgets::dialog_center(ctx))
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new(key().with("grid")).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut draft.name).desired_width(220.0));
|
||||
app.auto.add_response("timeline.markerDialog.name", &r, "Name");
|
||||
ui.end_row();
|
||||
|
||||
ui.label(tl!("Comments:"));
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut draft.comment).desired_width(220.0).desired_rows(3));
|
||||
app.auto.add_response("timeline.markerDialog.comment", &r, "Comments");
|
||||
ui.end_row();
|
||||
|
||||
ui.label(tl!("Color:"));
|
||||
let combo = craft_ui::ComboBox::from_id_salt(key().with("color")).selected_text(crate::i18n::t(draft.color.name())).show_ui(ui, |ui| {
|
||||
for color in Label::ALL {
|
||||
ui.selectable_value(&mut draft.color, color, crate::i18n::t(color.name()));
|
||||
}
|
||||
});
|
||||
app.auto.add_response("timeline.markerDialog.color", &combo.response, "Color");
|
||||
ui.end_row();
|
||||
|
||||
ui.label(tl!("Duration (frames):"));
|
||||
let r = ui.add(egui::DragValue::new(&mut draft.duration_frames).range(0..=draft.opened_frames.max(10_000_000)).speed(1.0));
|
||||
app.auto.add_response("timeline.markerDialog.duration", &r, "Duration");
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
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 r = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(accent));
|
||||
app.auto.add_response("timeline.markerDialog.ok", &r, "OK");
|
||||
ok = r.clicked();
|
||||
let r = ui.button(tl!("Cancel"));
|
||||
app.auto.add_response("timeline.markerDialog.cancel", &r, "Cancel");
|
||||
cancel = r.clicked();
|
||||
});
|
||||
});
|
||||
if ok && let Err(e) = app.session.execute("markers.edit", edit_params(&draft)) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if ok || cancel || crate::widgets::escape_closes(ctx) {
|
||||
ctx.data_mut(|d| d.remove::<Draft>(key()));
|
||||
} else {
|
||||
ctx.data_mut(|d| d.insert_temp(key(), draft));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub(super) fn marker_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut
|
||||
let r = ui.button(label);
|
||||
app.auto.add("timeline.markerMenu.markers.edit", r.rect, label);
|
||||
if r.clicked() {
|
||||
super::marker_dialog::open(app, ctx, marker.id);
|
||||
super::super::marker_properties::open(app, &marker);
|
||||
ui.close();
|
||||
}
|
||||
crate::panels::misc::marker_label_menu(app, ui, &marker, "timeline.markerMenu");
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::{
|
||||
Align2, Color32, Drag, EDGE_PX, FilmcraftApp, Icon, ItemId, Rect, Sense, Sequence, Stroke, ThumbnailMode, Tick, TimeDisplay, TimeRange, Tokens, Value,
|
||||
format_time, icons, json, marker_range_rect, marker_rect, pos2, vec2, work_area_rect,
|
||||
};
|
||||
use crate::panels::timeline_hit::marker_end_rect;
|
||||
|
||||
/// The Ruler's two strides, in frames: `(tick, label)`.
|
||||
///
|
||||
@@ -175,21 +176,32 @@ pub(super) fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, se
|
||||
/// The ruler's marker row: one pentagon per marker in its colour, its range band behind it and,
|
||||
/// on hover, its name.
|
||||
fn draw_markers(app: &mut FilmcraftApp, ui: &egui::Ui, clip: &egui::Painter, layout: &Layout, seq: &Sequence) {
|
||||
let marker_y = layout.ruler.min.y + 2.0;
|
||||
let ruler = layout.ruler;
|
||||
let marker_y = ruler.min.y + 2.0;
|
||||
// Point flags stay whole; ranges split the same glyph across their endpoints.
|
||||
for m in &seq.markers {
|
||||
let x = layout.x_of(m.start);
|
||||
let c = m.color.marker_rgb();
|
||||
let c = m.custom_color.map_or_else(|| app.session.prefs.labels.rgb(m.color), |c| c.0);
|
||||
let c = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let shape =
|
||||
vec![pos2(x - 4.0, marker_y), pos2(x + 4.0, marker_y), pos2(x + 4.0, marker_y + 8.0), pos2(x, marker_y + 12.0), pos2(x - 4.0, marker_y + 8.0)];
|
||||
let glyph = |x| Rect::from_min_size(pos2(x - 4.0, marker_y), vec2(8.0, 12.0));
|
||||
if let Some(band) = marker_range_rect(layout, m).filter(|r| r.width() > 0.0) {
|
||||
clip.rect_filled(band, 0.0, c.gamma_multiply(0.6));
|
||||
app.auto.add(&format!("timeline.markerRange.{}", m.id.0), band, &m.name);
|
||||
}
|
||||
clip.add(egui::Shape::convex_polygon(shape, c, Stroke::NONE));
|
||||
let end = marker_end_rect(layout, m);
|
||||
if let Some(end) = end {
|
||||
crate::widgets::marker_glyph(clip, glyph(x), 4.0, crate::widgets::MarkerPart::Left, c);
|
||||
crate::widgets::marker_glyph(clip, glyph(end.center().x), 4.0, crate::widgets::MarkerPart::Right, c);
|
||||
let visible = end.intersect(ruler);
|
||||
if visible.is_positive() {
|
||||
app.auto.add(&format!("timeline.markerEnd.{}", m.id.0), visible, &m.name);
|
||||
}
|
||||
} else {
|
||||
crate::widgets::marker_glyph(clip, glyph(x), 4.0, crate::widgets::MarkerPart::Whole, c);
|
||||
}
|
||||
let mr = marker_rect(layout, m.start);
|
||||
app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name);
|
||||
if ui.rect_contains_pointer(mr) && !m.name.is_empty() {
|
||||
if (ui.rect_contains_pointer(mr) || end.is_some_and(|r| ui.rect_contains_pointer(r))) && !m.name.is_empty() {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(&m.name);
|
||||
});
|
||||
|
||||
@@ -79,6 +79,10 @@ pub fn marker_rect(layout: &Layout, start: Tick) -> Rect {
|
||||
Rect::from_center_size(pos2(layout.x_of(start), layout.ruler.min.y + 8.0), vec2(10.0, 13.0))
|
||||
}
|
||||
|
||||
pub fn marker_end_rect(layout: &Layout, marker: &filmcraft_project::Marker) -> Option<Rect> {
|
||||
(marker.duration > Tick::ZERO).then(|| marker_rect(layout, marker.start + marker.duration))
|
||||
}
|
||||
|
||||
/// The visible duration band, shared by painting and hit testing (#1143).
|
||||
pub fn marker_range_rect(layout: &Layout, marker: &filmcraft_project::Marker) -> Option<Rect> {
|
||||
(marker.duration > Tick::ZERO).then(|| {
|
||||
@@ -93,7 +97,11 @@ pub fn marker_under_pointer<'a>(seq: &'a Sequence, layout: &Layout, pos: Pos2) -
|
||||
if !layout.ruler.contains(pos) {
|
||||
return None;
|
||||
}
|
||||
seq.markers.iter().rev().find(|m| marker_rect(layout, m.start).contains(pos) || marker_range_rect(layout, m).is_some_and(|r| r.contains(pos)))
|
||||
seq.markers.iter().rev().find(|m| {
|
||||
marker_rect(layout, m.start).contains(pos)
|
||||
|| marker_end_rect(layout, m).is_some_and(|r| r.contains(pos))
|
||||
|| marker_range_rect(layout, m).is_some_and(|r| r.contains(pos))
|
||||
})
|
||||
}
|
||||
|
||||
/// A press on a marker puts the playhead on its start for Clear Selected Marker (#695).
|
||||
@@ -343,6 +351,7 @@ mod tests {
|
||||
/// A clip on screen from `x0` to `x1` px (the layout is 100 px per second).
|
||||
fn clip(id: u64, x0: f64, x1: f64) -> TrackItem {
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
@@ -477,6 +486,9 @@ mod tests {
|
||||
let (mut seq, l, _) = fixture(clips());
|
||||
let off = Tick::from_seconds_f64(3.017);
|
||||
seq.markers.push(filmcraft_project::Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: filmcraft_project::MarkerId(9),
|
||||
start: off,
|
||||
duration: Tick::ZERO,
|
||||
@@ -497,6 +509,9 @@ mod tests {
|
||||
let (mut seq, layout, _) = fixture(clips());
|
||||
for id in [9, 10] {
|
||||
seq.markers.push(filmcraft_project::Marker {
|
||||
url: String::new(),
|
||||
author: String::new(),
|
||||
custom_color: None,
|
||||
id: filmcraft_project::MarkerId(id),
|
||||
start: Tick::from_seconds_f64(3.0),
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
@@ -510,10 +525,15 @@ mod tests {
|
||||
let at = band.center();
|
||||
assert_eq!(marker_under_pointer(&seq, &layout, at).unwrap().id.0, 10);
|
||||
assert_eq!(marker_at(&seq, &layout, at), Some(seq.markers[1].start));
|
||||
let end = marker_end_rect(&layout, &seq.markers[1]).unwrap();
|
||||
let cap = pos2(end.center().x + 3.0, end.bottom() - 2.0);
|
||||
assert!(!band.contains(cap));
|
||||
assert_eq!(marker_under_pointer(&seq, &layout, cap).unwrap().id.0, 10, "the end flag outside the range band belongs to the same marker");
|
||||
assert!(marker_under_pointer(&seq, &layout, at + vec2(0.0, 20.0)).is_none());
|
||||
assert!(marker_under_pointer(&seq, &layout, pos2(f32::NAN, at.y)).is_none());
|
||||
seq.markers[1].duration = Tick(-1);
|
||||
assert!(marker_range_rect(&layout, &seq.markers[1]).is_none());
|
||||
assert!(marker_end_rect(&layout, &seq.markers[1]).is_none());
|
||||
assert_eq!(marker_under_pointer(&seq, &layout, at).unwrap().id.0, 9);
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ mod tests {
|
||||
|
||||
fn clip(speed: f64, reverse: bool) -> TrackItem {
|
||||
TrackItem {
|
||||
custom_label_color: None,
|
||||
id: ClipId(1),
|
||||
item: ItemId(1),
|
||||
name: String::new(),
|
||||
|
||||
+12
-23
@@ -441,6 +441,14 @@ pub struct KeyframeRef {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UiState {
|
||||
#[serde(default)]
|
||||
pub markers_panel: crate::panels::markers_panel::State,
|
||||
#[serde(skip)]
|
||||
pub palette_manager: Option<crate::panels::palette_manager::Draft>,
|
||||
#[serde(skip)]
|
||||
pub marker_properties: Option<crate::panels::marker_properties::Draft>,
|
||||
#[serde(skip)]
|
||||
pub custom_label_color: Option<crate::panels::custom_colors::Draft>,
|
||||
#[serde(default)]
|
||||
pub language: crate::i18n::Language,
|
||||
pub tool: Tool,
|
||||
@@ -593,9 +601,6 @@ pub struct UiState {
|
||||
/// Open guide dialog (Add Guide / Save Guides as Template / Manage Guides).
|
||||
#[serde(default)]
|
||||
pub guide_dialog: Option<GuideDialog>,
|
||||
/// Open Edit Marker dialog (double-click a marker, or Edit Marker… in its menu).
|
||||
#[serde(default)]
|
||||
pub marker_dialog: Option<MarkerDraft>,
|
||||
/// Open Text Properties dialog (the wrench in the Properties panel's Text section).
|
||||
#[serde(default)]
|
||||
pub text_props_dialog: Option<TextPropsDialog>,
|
||||
@@ -821,25 +826,6 @@ pub enum ColorDialog {
|
||||
Sequence { working_space: String, wide_gamut: bool, auto_tone_map: bool },
|
||||
}
|
||||
|
||||
/// The Edit Marker dialog (#2416): the marker it edits and its fields as typed. `duration` is a
|
||||
/// timecode, `color` a label name, `kind` a `markers.edit` kind. `sequence` and `generation` (the
|
||||
/// session's `document_epoch`) tie it to where it was opened: in another project, or the same
|
||||
/// file opened again, the same marker id is another marker. `duration0` is the duration as the dialog opened, so OK leaves a duration it
|
||||
/// cannot show exactly (part of a frame) alone unless it was edited.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MarkerDraft {
|
||||
pub sequence: u64,
|
||||
pub generation: u64,
|
||||
pub duration0: String,
|
||||
pub marker: u64,
|
||||
pub name: String,
|
||||
pub comment: String,
|
||||
pub duration: String,
|
||||
pub color: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// The Audio Gain dialog (Clip ▸ Audio Gain…, G).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -1005,6 +991,10 @@ fn yes() -> bool {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
marker_properties: None,
|
||||
markers_panel: Default::default(),
|
||||
palette_manager: None,
|
||||
custom_label_color: None,
|
||||
language: crate::i18n::Language::default(),
|
||||
tool: Tool::Selection,
|
||||
mode: Mode::Edit,
|
||||
@@ -1063,7 +1053,6 @@ impl Default for UiState {
|
||||
multicam_record: true,
|
||||
edit_cameras: None,
|
||||
guide_dialog: None,
|
||||
marker_dialog: None,
|
||||
text_props_dialog: None,
|
||||
workspace_dialog: None,
|
||||
clip_dialog: None,
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
//! Small custom widgets in Premiere's visual language.
|
||||
|
||||
pub mod color_picker;
|
||||
|
||||
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use filmcraft_time::{FrameRate, Tick, parse_timecode};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum MarkerPart {
|
||||
Whole,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Point markers use the whole flag; a range places its two halves at its endpoints.
|
||||
pub(crate) fn marker_glyph(painter: &egui::Painter, rect: Rect, tip_height: f32, part: MarkerPart, color: Color32) {
|
||||
let x = rect.center().x;
|
||||
let y = rect.top();
|
||||
let shoulder = rect.bottom() - tip_height;
|
||||
let points = match part {
|
||||
MarkerPart::Whole => {
|
||||
vec![pos2(rect.left(), y), pos2(rect.right(), y), pos2(rect.right(), shoulder), pos2(x, rect.bottom()), pos2(rect.left(), shoulder)]
|
||||
}
|
||||
MarkerPart::Left => vec![pos2(rect.left(), y), pos2(x, y), pos2(x, rect.bottom()), pos2(rect.left(), shoulder)],
|
||||
MarkerPart::Right => vec![pos2(x, y), pos2(rect.right(), y), pos2(rect.right(), shoulder), pos2(x, rect.bottom())],
|
||||
};
|
||||
painter.add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
|
||||
}
|
||||
|
||||
/// Give a widget an explicit accessible name without changing its visible hint text.
|
||||
///
|
||||
/// `egui_kittest` 0.37 hard-fails visible input roles that have neither a label nor a placeholder.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user