From 49b300f2f28db31bd8b82cb839b48d4825e229cc Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Sun, 18 Oct 2026 23:21:56 +0200 Subject: [PATCH 01/28] #2185: preview files are read in place, not loaded whole Rendered previews are full-resolution ProRes (gigabytes a minute), and PreviewStore::open read each file whole into memory, keeping up to six open; the render also opened every finished segment right away. Rendering previews and playing them back filled memory until the OS killed the app. Native builds now open preview files through a file reader (the container's index now, frames on demand), refusing any whole-file read, and a finished segment is opened when it is first played. Fixes #2185 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/previews.rs | 21 ++++++++++++++++----- crates/engine/src/previews_tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 5b75c02f4..859ba1bb9 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -535,8 +535,7 @@ impl PreviewStore { } } let path = self.dir()?.join(name); - let bytes = std::fs::read(&path).ok()?; - let src = pool.open_bytes(name, bytes.into()).ok()?; + let src = open_preview_file(pool, name, &path)?; let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); g.push((name.to_string(), src.clone())); if g.len() > 6 { @@ -546,6 +545,21 @@ impl PreviewStore { } } +/// Open a rendered preview file. Native builds read it in place (the container's index now, +/// frames on demand): previews are full-resolution ProRes, gigabytes a minute, and reading each +/// one whole filled memory during render and playback (#2185). +#[cfg(any(unix, windows))] +pub(crate) fn open_preview_file(_pool: &MediaPool, name: &str, path: &std::path::Path) -> Option { + let reader = filmcraft_media::reader::FileReader::open(path).ok()?; + filmcraft_media::reader::open_reader_within(name, std::sync::Arc::new(reader), &filmcraft_codecs::reader_openers(), &[], 0).ok() +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_preview_file(pool: &MediaPool, name: &str, path: &std::path::Path) -> Option { + let bytes = std::fs::read(path).ok()?; + pool.open_bytes(name, bytes.into()).ok() +} + fn need_state(seg: &Segment) -> BarState { match seg.need { Need::None => BarState::None, @@ -639,7 +653,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let provider = s.media.full_res_provider(project.clone(), s.services.clone()); let (prog, res) = (job.progress.clone(), job.result.clone()); let nseg = todo.len(); - let pool = s.media.clone(); let run = move || { let t0 = web_time::Instant::now(); let mut bytes = 0u64; @@ -679,8 +692,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { break; } store.add_video(&name); - // open it now so the first playback doesn't wait for the file read - let _ = store.open(&pool, &name); } Err(e) => { let _ = std::fs::remove_file(&part); diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index dfc6b7a48..6c5bea75a 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -282,3 +282,26 @@ fn float_wav_round_trip() { fn bar_state_serializes_lowercase() { assert_eq!(serde_json::to_value(BarState::Green).unwrap(), json!("green")); } + +/// #2185: preview files are read in place, never whole. Rendered previews still play from their +/// files, and a file only a whole-file reader could open is refused rather than loaded. +#[test] +fn preview_files_are_streamed_not_read_whole() { + let (mut s, _) = session("stream"); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + let seq = s.state.active_sequence.unwrap(); + assert!(s.previews.frame(&s.media, &s.project, seq, 5, 1.0).is_some(), "a rendered frame plays from its file"); + // a still image opens only from its whole bytes: as a preview file it is not opened + let dir = s.previews.dir().unwrap(); + let png = dir.join("still.png"); + // a 1×1 PNG + const PNG: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, + 120, 218, 99, 100, 96, 248, 95, 15, 0, 2, 135, 1, 128, 235, 71, 186, 146, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + std::fs::write(&png, PNG).unwrap(); + assert!(s.media.open_bytes("still.png", PNG.into()).is_ok(), "the image opens from its bytes"); + assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none()); + let _ = std::fs::remove_dir_all(dir); +} From 1968616c3f4bf51910fd07109681dd3db887bc6f Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Mon, 19 Oct 2026 07:18:36 +0200 Subject: [PATCH 02/28] #1682: thumbnail textures are capped, least recently used freed first Every frame shown as a timeline tile or a Project panel hover scrub got a texture of its own (thumb---) that was kept until the project was replaced, so scrolling, zooming and scrubbing grew GPU (on integrated GPUs, system) memory without bound. Beyond 512 thumbnail textures the least recently used are freed, never one drawn in the current pass. Refs #1682 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 38 ++++++++++++++++++++++++++++++++- crates/ui-egui/tests/perf_ui.rs | 29 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e3a644ff8..30856ca4e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,9 @@ pub mod credits; pub mod dock; mod dock_shared; mod dock_ui; +/// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide). +const MAX_THUMB_TEXTURES: usize = 512; + pub mod frames; pub mod graphics_notice; pub mod header; @@ -289,6 +292,9 @@ pub struct FilmcraftApp { pub auto: automation::Registry, /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, + /// The UI pass each thumbnail texture was last used in: thumbnails beyond + /// [`MAX_THUMB_TEXTURES`] are freed, least recently used first (#1682). + thumb_used: HashMap, control_rx: Option>, /// One explicit panel reveal; consumed by the custom renderer, never repeated per frame. pub(crate) pending_panel_raise: Option, @@ -553,6 +559,7 @@ impl FilmcraftApp { native_dialog: None, auto: Default::default(), textures: HashMap::new(), + thumb_used: HashMap::new(), control_rx: None, pending_panel_raise: None, control_input_only: false, @@ -633,6 +640,7 @@ impl FilmcraftApp { FrameServer::default_workers(), )); self.textures.clear(); + self.thumb_used.clear(); self.tl.reset_media_caches(); // the new frame server needs the Memory settings self.applied_prefs = None; @@ -1251,12 +1259,40 @@ impl FilmcraftApp { let name = format!("thumb-{}-{}-{}", item.0, frame, width); if let Some(img) = self.frames.get(&key) { let id = self.texture_for(ctx, &name, key, &img); + self.thumb_touched(ctx, &name); return Some((id, egui::vec2(img.w as f32, img.h as f32))); } let scale = width as f32 / src_w.max(1) as f32; let project = self.session.project.clone(); self.frames.request(key, rate.tick_of(frame), scale, &project, 50); - self.texture_existing(&name) + let shown = self.texture_existing(&name); + if shown.is_some() { + self.thumb_touched(ctx, &name); + } + shown + } + + /// Note that thumbnail texture `name` is used in this pass, and free the least recently used + /// thumbnails once there are more than [`MAX_THUMB_TEXTURES`]: every frame shown in a timeline + /// tile or a hover scrub got a texture of its own that was never freed (#1682). + fn thumb_touched(&mut self, ctx: &egui::Context, name: &str) { + let pass = ctx.cumulative_pass_nr(); + if self.thumb_used.insert(name.to_string(), pass).is_some() || self.thumb_used.len() <= MAX_THUMB_TEXTURES { + return; + } + // down to three quarters, never a thumbnail already drawn in this pass + let mut old: Vec<(u64, String)> = self.thumb_used.iter().filter(|(_, p)| **p < pass).map(|(n, p)| (*p, n.clone())).collect(); + old.sort_unstable(); + let excess = self.thumb_used.len().saturating_sub(MAX_THUMB_TEXTURES * 3 / 4); + for (_, n) in old.into_iter().take(excess) { + self.thumb_used.remove(&n); + self.textures.remove(&n); + } + } + + /// The number of thumbnail textures held (tests, diagnostics). + pub fn thumbnail_textures(&self) -> usize { + self.textures.keys().filter(|n| n.starts_with("thumb-")).count() } // ---------------------------------------------------------------- files diff --git a/crates/ui-egui/tests/perf_ui.rs b/crates/ui-egui/tests/perf_ui.rs index 5b9d6c845..9b392a239 100644 --- a/crates/ui-egui/tests/perf_ui.rs +++ b/crates/ui-egui/tests/perf_ui.rs @@ -190,3 +190,32 @@ fn a_newer_revision_keeps_the_frame_being_rendered() { d.ready(key(9002)); d.ready(key(9001)); } + +/// #1682: thumbnail textures (timeline tiles, hover scrubs) are capped instead of one being kept +/// for every frame ever shown. +#[test] +fn thumbnail_textures_are_capped() { + let mut d = Driver::demo(); + let ctx = d.harness.ctx.clone(); + let item = { + let p = &d.harness.state().session.project; + p.items.values().find(|i| i.as_media().is_some_and(|m| m.info.video.is_some())).expect("a video item").id + }; + let rate = d.harness.state().session.project.item(item).unwrap().frame_rate(); + // 800 distinct frames, 100 per UI pass: request, wait until rendered, then make the textures + for chunk in 0..8 { + let frames: Vec = (chunk * 100..chunk * 100 + 100).collect(); + for &f in &frames { + d.harness.state_mut().thumbnail(&ctx, item, rate.tick_of(f), 32); + } + for _ in 0..2000 { + if frames.iter().all(|&f| d.harness.state_mut().thumbnail(&ctx, item, rate.tick_of(f), 32).is_some()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + d.frames(1); + } + let n = d.harness.state().thumbnail_textures(); + assert!((100..=512).contains(&n), "{n} thumbnail textures"); +} From eff5f024e597d850b489ab4338952bc7233ee546 Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Mon, 19 Oct 2026 09:22:03 +0200 Subject: [PATCH 03/28] #2185: the not-read-whole assertion applies where previews stream Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/previews_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index 6c5bea75a..95c6289c2 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -302,6 +302,9 @@ fn preview_files_are_streamed_not_read_whole() { ]; std::fs::write(&png, PNG).unwrap(); assert!(s.media.open_bytes("still.png", PNG.into()).is_ok(), "the image opens from its bytes"); - assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none()); + // (the web, without a file reader, still reads preview files whole) + if cfg!(any(unix, windows)) { + assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none()); + } let _ = std::fs::remove_dir_all(dir); } From 35076281b3bd7e85462d1457a40ecf28e832253b Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Sun, 18 Oct 2026 11:38:22 +0000 Subject: [PATCH 04/28] #2325: Settings > Appearance can turn the interface animations off "Animate zoom, scrolling and menus" (appearance.animations, on by default). Off, the Timeline zooms and scrolls in one step instead of easing, and egui's fades and slides (menus, collapsing sections, tooltips) are instant. The style is checked every frame, because a theme or font change installs it again. Translated in the catalogs that cover the Settings rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn --- crates/engine/src/settings.rs | 5 ++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/lib.rs | 5 ++++ crates/ui-egui/src/panels/timeline.rs | 3 ++- crates/ui-egui/src/theme.rs | 6 ++++- crates/ui-egui/tests/timeline_scroll_ui.rs | 30 ++++++++++++++++++++++ 13 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 79ecd6051..f1f39c484 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -109,6 +109,9 @@ pub struct AppearancePrefs { /// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed /// percentage of [`UI_SCALES`] (`100` = one point per physical pixel). pub ui_scale: String, + /// "Animate zoom, scrolling and menus" (#2325): off, the Timeline zooms and scrolls in one + /// step and menus, collapsing sections and tooltips appear without a fade. + pub animations: bool, } impl Default for AppearancePrefs { @@ -122,6 +125,7 @@ impl Default for AppearancePrefs { accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into(), ui_scale: "auto".into(), + animations: true, } } } @@ -1053,6 +1057,7 @@ static CATEGORIES: &[Category] = &[ b("appearance.accessibleContrast", "Accessible color contrast", true), f("appearance.highlightColor", "Highlight Color", Kind::Color, true), f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), + b("appearance.animations", "Animate zoom, scrolling and menus", true), ], }, Category { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0af8835d1..298b29c5f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1266,6 +1266,7 @@ 59.94 fps Non Drop-Frame 59,94 fps sin eliminación de fotogramas ASC CDL ASC CDL Accessible color contrast Contraste de color accesible + Animate zoom, scrolling and menus Animar el zoom, el desplazamiento y los menús Action-Safe Margin Margen seguro de acción Active Monitor Monitor activo Add Añadir diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1075b0c15..31912e22e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1266,6 +1266,7 @@ 59.94 fps Non Drop-Frame 59,94 fps non-drop frame ASC CDL ASC CDL Accessible color contrast Contraste des couleurs accessible + Animate zoom, scrolling and menus Animer le zoom, le défilement et les menus Action-Safe Margin Marge de sécurité de l'action Active Monitor Moniteur actif Add Ajouter diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 508a6e9d4..e453514f2 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1271,6 +1271,7 @@ 59.94 fps Non Drop-Frame 59,94 fps Non Drop-Frame ASC CDL ASC CDL Accessible color contrast Kontras warna yang aksesibel + Animate zoom, scrolling and menus Animasikan zoom, gulir, dan menu Action-Safe Margin Margin Aman Aksi Active Monitor Monitor Aktif Add Tambah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 22d47ccbd..f5bdbef18 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1263,6 +1263,7 @@ 59.94 fps Non Drop-Frame 59,94 fps non drop-frame ASC CDL ASC CDL Accessible color contrast Contrasto dei colori accessibile + Animate zoom, scrolling and menus Anima zoom, scorrimento e menu Action-Safe Margin Margine di sicurezza azione Active Monitor Monitor attivo Add Aggiungi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b6a7fb8a2..312db6b4b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1260,6 +1260,7 @@ 59.94 fps Non Drop-Frame 59.94 fps ノンドロップフレーム ASC CDL ASC CDL Accessible color contrast アクセシビリティに配慮したカラーコントラスト + Animate zoom, scrolling and menus ズーム、スクロール、メニューをアニメーション表示 Action-Safe Margin アクションセーフの余白 Active Monitor アクティブなモニター Add 加算 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 20341b9a6..38e93034f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -76,6 +76,7 @@ About FilmCraft FilmCraft 정보 Accent Color 강조 색상 Accessible color contrast 접근성 색상 대비 + Animate zoom, scrolling and menus 확대/축소, 스크롤 및 메뉴 애니메이션 Action and Title Safe Areas 액션 및 제목 안전 영역 Action Safe Area 액션 안전 영역 Action-Safe Margin 액션 세이프 여백 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index aeb0e0691..5474818cb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1268,6 +1268,7 @@ 59.94 fps Non Drop-Frame 59,94 fps sem drop-frame ASC CDL ASC CDL Accessible color contrast Contraste de cores acessível + Animate zoom, scrolling and menus Animar zoom, rolagem e menus Action-Safe Margin Margem de segurança de ação Active Monitor Monitor ativo Add Adicionar diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 066279eef..1f1245bc6 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -1254,6 +1254,7 @@ 59.94 fps Non Drop-Frame 59.94 fps 非掉帧 ASC CDL ASC CDL Accessible color contrast 无障碍颜色对比度 + Animate zoom, scrolling and menus 缩放、滚动和菜单使用动画 Action-Safe Margin 动作安全边距 Active Monitor 活动监视器 Add 添加 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e3a644ff8..dea29e221 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -697,6 +697,11 @@ impl FilmcraftApp { self.set_theme(ctx, k); } } + // checked every frame: a theme or font change installs the style again + let animation = if self.session.prefs.appearance.animations { theme::ANIMATION_TIME } else { 0.0 }; + if ctx.global_style().animation_time != animation { + ctx.global_style_mut(|s| s.animation_time = animation); + } if self.applied_prefs.as_ref() == Some(&self.session.prefs) { return; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c4c369298..19bbb3f94 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -340,7 +340,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { v.fit_pending = false; v.fit_empty = empty.then_some(v.target_pps); } - let k = 1.0 - (-dt * 20.0).exp(); + // eased, or in one step with Settings ▸ Appearance ▸ animations off (#2325) + let k = if app.session.prefs.appearance.animations { 1.0 - (-dt * 20.0).exp() } else { 1.0 }; let zooming = (v.pps - v.target_pps).abs() / v.target_pps > 0.001; // video thumbnails keep the layout of the zoom a gesture or animation started at (#1094) if zooming || matches!(app.tl.drag, Some(Drag::ZoomBar { mode: 1 | 2, .. })) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 21a50012a..36b57165c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -507,10 +507,14 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Heading, FontId::new(15.0, FontFamily::Name("semibold".into()))); s.text_styles.insert(TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace)); - s.animation_time = 0.12; + s.animation_time = ANIMATION_TIME; }); } +/// How long egui's fades and slides take (menus, collapsing sections, tooltips); 0 when +/// Settings ▸ Appearance ▸ "Animate zoom, scrolling and menus" is off (#2325). +pub const ANIMATION_TIME: f32 = 0.12; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/tests/timeline_scroll_ui.rs b/crates/ui-egui/tests/timeline_scroll_ui.rs index 7587ef14a..aa9dec2b1 100644 --- a/crates/ui-egui/tests/timeline_scroll_ui.rs +++ b/crates/ui-egui/tests/timeline_scroll_ui.rs @@ -342,3 +342,33 @@ fn repeated_home_and_end_keys_reveal_a_playhead_already_at_the_endpoint() { fn repeated_start_and_end_commands_reveal_a_playhead_already_at_the_endpoint() { repeated_endpoint_navigation_reveals_the_same_playhead(false); } + +/// #2325: Settings ▸ Appearance ▸ "Animate zoom, scrolling and menus" off makes a zoom and a +/// scroll land in one frame, and egui's fades (menus, collapsing sections, tooltips) instant. +#[test] +fn turning_animations_off_zooms_and_scrolls_in_one_step() { + let mut d = Driver::tall(); + let animation_time = |d: &mut Driver| d.harness.ctx.global_style().animation_time; + assert!(animation_time(&mut d) > 0.0, "animated by default"); + // animated: one frame later the zoom is on its way, not there + let v = &mut d.app().ui.timeline; + v.target_pps = 200.0; + v.target_scroll = 8.0; + d.frames(1); + let v = &d.app().ui.timeline; + assert!(v.pps > 50.0 && v.pps < 200.0 && v.scroll < 8.0, "eased: {} {}", v.pps, v.scroll); + assert_eq!(d.view(), (8.0, 200.0, 0.0, 0.0)); + d.exec("prefs.set", json!({"key": "appearance.animations", "value": false})); + d.frames(2); + assert_eq!(animation_time(&mut d), 0.0); + let v = &mut d.app().ui.timeline; + v.target_pps = 50.0; + v.target_scroll = 3.0; + d.frames(1); + let v = &d.app().ui.timeline; + assert_eq!((v.pps, v.scroll), (50.0, 3.0), "in one step"); + // and back on + d.exec("prefs.set", json!({"key": "appearance.animations", "value": true})); + d.frames(2); + assert!(animation_time(&mut d) > 0.0); +} From 7ac0269d2c9c5b4fc50fac3765e65007594c14e7 Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Sun, 18 Oct 2026 17:24:26 +0000 Subject: [PATCH 05/28] Timeline: colour labels for tracks (#1186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A track can carry a colour label, as many NLEs offer. The strip at the left edge of a track header opens a menu with None and the 16 labels (with the user's label names and colours, as in Edit > Label). The label tints the header and the lane. - `Track.label: Option