mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
5ed4952d43
* 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>
521 lines
27 KiB
Rust
521 lines
27 KiB
Rust
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());
|
|
}
|