From e00f68a93e6e9eeba89f75b78aca6af82afb25a2 Mon Sep 17 00:00:00 2001 From: maubrickastuto Date: Wed, 23 Sep 2026 21:50:40 +0800 Subject: [PATCH] fix: resolve GitHub issues #184, #109, #139, #160, #100 - feat(project): add 1440p (QHD / 2560x1440) support across launcher, monitor, and project dialog (#184) - fix(export): match project aspect ratio and dynamic resolution options in export dialog (#109) - fix(inspector): eliminate text typing lag, cursor jump, and click-outside reversion race (#139) - fix(timeline): clean up orphaned transitions and ghost seam artifacts on split/delete (#160) - fix(shell): configure Wayland app_id, StartupWMClass, and window icon for Linux dock/taskbar (#100) --- .github/workflows/build-app.yml | 1 + flake.nix | 1 + .../concat-project/src/commands/clips.rs | 26 +++++++ src/crates/concat-project/src/lib.rs | 75 +++++++++++++++++++ src/crates/concat/src/lib.rs | 1 + src/crates/concat/src/panes/export.rs | 39 ++++++++++ src/crates/concat/src/platform.rs | 9 ++- src/crates/concat/src/studio.rs | 18 ++++- src/crates/concat/ui/app.slint | 1 + src/crates/concat/ui/dialogs/export.slint | 3 +- src/crates/concat/ui/dialogs/project.slint | 4 +- .../concat/ui/inspector/text-panel.slint | 7 +- src/crates/concat/ui/timeline/lanes.slint | 9 +++ .../concat/ui/workspace/preview-pane.slint | 6 +- 14 files changed, 187 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 052643d6..8195cae5 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -530,6 +530,7 @@ jobs: Comment=Video editor Exec=concat Icon=concat + StartupWMClass=concat Categories=AudioVideo;Video; Terminal=false DESKTOP diff --git a/flake.nix b/flake.nix index 138e3d0b..2af75894 100644 --- a/flake.nix +++ b/flake.nix @@ -156,6 +156,7 @@ "AudioVideo" "AudioVideoEditing" ]; + startupWMClass = "concat"; }) ]; diff --git a/src/crates/concat-project/src/commands/clips.rs b/src/crates/concat-project/src/commands/clips.rs index 5051cd60..6a5fe748 100644 --- a/src/crates/concat-project/src/commands/clips.rs +++ b/src/crates/concat-project/src/commands/clips.rs @@ -308,6 +308,11 @@ pub(super) fn apply( head.fade_out = 0.0; head.animation_out = None; head.rewindow_keys(whole, 0.0, offset); + if offset < 0.05 { + head.transition_in = None; + } else if let Some(t) = head.transition_in.as_mut() { + t.duration = t.duration.min(offset); + } timeline.clips.insert(index + 1, Arc::new(tail)); } // A split always mints the tail, so "minted anything" and @@ -565,6 +570,27 @@ pub(super) fn apply( timeline .clips .retain(|clip| !doomed.contains(clip.id.as_str())); + let orphaned: Vec = timeline + .clips + .iter() + .enumerate() + .filter_map(|(i, clip)| { + if clip.transition_in.is_some() { + let has_preceding = timeline.clips.iter().any(|c| { + c.track_id == clip.track_id + && c.id != clip.id + && (c.start + c.duration - clip.start).abs() < 1e-4 + }); + if !has_preceding { + return Some(i); + } + } + None + }) + .collect(); + for i in orphaned { + timeline.clip_at_mut(i).transition_in = None; + } let applied = timeline.clips.len() != clip_count; if ripple && applied { close_gaps(timeline, &removed); diff --git a/src/crates/concat-project/src/lib.rs b/src/crates/concat-project/src/lib.rs index 0351db5c..b02a580e 100644 --- a/src/crates/concat-project/src/lib.rs +++ b/src/crates/concat-project/src/lib.rs @@ -3627,4 +3627,79 @@ mod tests { let missing = project.missing_media(); assert_eq!(missing.len(), 0); } + + #[test] + fn remove_preceding_clip_clears_transition_in() { + let (mut editor, media_id, clip1_id) = fixture(); + let track_id = editor.project().active().tracks[0].id.clone(); + let clip2_id = editor + .apply(Command::AddClip { + media_id, + track_id, + start: 10.0, + ripple: false, + }) + .expect("adds clip2") + .created_id + .expect("id"); + editor + .apply(Command::UpdateClip { + clip_id: clip2_id.clone(), + patch: ClipPatch { + transition_in: Some(Some(crate::model::Transition { + id: "cross-fade".to_owned(), + duration: 1.0, + })), + ..Default::default() + }, + }) + .expect("adds transition"); + let clip2 = editor.project().active().clip(&clip2_id).unwrap(); + assert!(clip2.transition_in.is_some()); + + editor + .apply(Command::RemoveClips { + clip_ids: vec![clip1_id], + ripple: false, + }) + .expect("removes clip1"); + + let clip2 = editor.project().active().clip(&clip2_id).unwrap(); + assert_eq!(clip2.transition_in, None); + } + + #[test] + fn split_clip_clamps_or_removes_transition_in() { + let (mut editor, _, clip_id) = fixture(); + editor + .apply(Command::UpdateClip { + clip_id: clip_id.clone(), + patch: ClipPatch { + transition_in: Some(Some(crate::model::Transition { + id: "cross-fade".to_owned(), + duration: 2.0, + })), + ..Default::default() + }, + }) + .expect("adds transition"); + + editor + .apply(Command::SplitClips { + clip_ids: vec![clip_id.clone()], + time: 1.0, + }) + .expect("splits"); + let head = editor.project().active().clip(&clip_id).unwrap(); + assert_eq!(head.transition_in.as_ref().map(|t| t.duration), Some(1.0)); + + editor + .apply(Command::SplitClips { + clip_ids: vec![clip_id.clone()], + time: 0.03, + }) + .expect("splits again"); + let head2 = editor.project().active().clip(&clip_id).unwrap(); + assert_eq!(head2.transition_in, None); + } } diff --git a/src/crates/concat/src/lib.rs b/src/crates/concat/src/lib.rs index 4128df1e..55e87e76 100644 --- a/src/crates/concat/src/lib.rs +++ b/src/crates/concat/src/lib.rs @@ -755,6 +755,7 @@ pub fn run() -> Result<(), slint::PlatformError> { })); editor.on_band_selected(on_lanes!( |state, from: f32, to: f32, from_y: f32, to_y: f32, additive: bool| { + state.flush_commit(); let (from_row, to_row) = (state.row_at(from_y), state.row_at(to_y)); let caught: Vec = state .timeline() diff --git a/src/crates/concat/src/panes/export.rs b/src/crates/concat/src/panes/export.rs index c874fd1f..4a78abf6 100644 --- a/src/crates/concat/src/panes/export.rs +++ b/src/crates/concat/src/panes/export.rs @@ -16,6 +16,8 @@ use concat_host::export::{self, ExportSpec}; use concat_media::ColorRange; +use slint::VecModel; + use crate::format::{bytes, eta}; use crate::host::{on_ui, spawn}; use crate::i18n::{self, t, tf}; @@ -134,6 +136,28 @@ impl ExportPane { self.open = true; self.phase = ExportPhase::Idle; self.message.clear(); + if self.name.is_empty() || self.name == "Untitled" { + self.name = studio.project_name.clone(); + } + let (proj_w, proj_h) = studio.output_size(); + let short_side = proj_w.min(proj_h); + if let Some((idx, _)) = EXPORT_SHORT_SIDES + .iter() + .enumerate() + .min_by_key(|(_, side)| (**side as i64 - short_side as i64).abs()) + { + self.resolution = idx; + } + let rate = studio.project().active().video.rate(); + if let Some((idx, _)) = + EXPORT_RATES.iter().enumerate().min_by(|(_, r1), (_, r2)| { + let diff1 = (r1.0 as f64 / r1.1 as f64 - rate).abs(); + let diff2 = (r2.0 as f64 / r2.1 as f64 - rate).abs(); + diff1.total_cmp(&diff2) + }) + { + self.rate = idx; + } } ExportMsg::Close => self.open = false, ExportMsg::NameEdited(name) => self.name = name, @@ -354,6 +378,20 @@ impl ExportPane { .iter() .filter(|clip| clip.kind == concat_project::model::ClipKind::Text) .count(); + let (proj_w, proj_h) = studio.output_size(); + let (proj_w, proj_h) = (proj_w.max(1) as f64, proj_h.max(1) as f64); + let even = |side: f64| ((side / 2.0).round() as u32 * 2).max(2); + let resolution_options: Vec = EXPORT_SHORT_SIDES + .iter() + .map(|&short| { + let (w, h) = if proj_w >= proj_h { + (even(short as f64 * proj_w / proj_h), short) + } else { + (short, even(short as f64 * proj_h / proj_w)) + }; + slint::SharedString::from(format!("{w} × {h}")) + }) + .collect(); ExportData { open: self.open, name: self.name.as_str().into(), @@ -370,6 +408,7 @@ impl ExportPane { } .into(), resolution: self.resolution as i32, + resolution_options: slint::ModelRc::new(VecModel::from(resolution_options)), rate: self.rate as i32, quality: self.quality as i32, codec: self.codec as i32, diff --git a/src/crates/concat/src/platform.rs b/src/crates/concat/src/platform.rs index 43ff9084..57a6e5e9 100644 --- a/src/crates/concat/src/platform.rs +++ b/src/crates/concat/src/platform.rs @@ -185,8 +185,13 @@ pub fn select_backend( } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "android")))] { - selector = selector - .with_winit_window_attributes_hook(|attributes| attributes.with_decorations(false)); + use slint::winit_030::winit::platform::wayland::WindowAttributesExtWayland; + use slint::winit_030::winit::platform::x11::WindowAttributesExtX11; + selector = selector.with_winit_window_attributes_hook(|attributes| { + attributes + .with_decorations(false) + .with_name("concat", "concat") + }); } selector.select()?; Ok(gpu) diff --git a/src/crates/concat/src/studio.rs b/src/crates/concat/src/studio.rs index 08230730..745bab71 100644 --- a/src/crates/concat/src/studio.rs +++ b/src/crates/concat/src/studio.rs @@ -63,8 +63,9 @@ use crate::presets::{self, TextPreset}; use crate::ui::*; /// The monitor's output sizes, matching the picker's rows. -pub const OUTPUTS: [(i32, i32); 6] = [ +pub const OUTPUTS: [(i32, i32); 7] = [ (1920, 1080), + (2560, 1440), (3840, 2160), (1080, 1920), (1080, 1080), @@ -181,7 +182,12 @@ pub const ASPECTS: [(&str, u32, u32); 4] = [ /// and 1080p vertical is 1080 x 1920, the same number of lines either way. /// Naming the long edge instead would make a vertical 1080p a 1080 x 1920 /// frame at one moment and a 608 x 1080 frame at another. -pub const SIZES: [(&str, u32); 3] = [("720p", 720), ("1080p", 1080), ("4K", 2160)]; +pub const SIZES: [(&str, u32); 4] = [ + ("720p", 720), + ("1080p", 1080), + ("1440p", 1440), + ("4K", 2160), +]; /// The frame an aspect and a size name, in pixels. /// @@ -674,6 +680,7 @@ pub struct Studio { /// on every move, and each commit was a command, an undo of the last, /// a rebuild of the mix and a full publish. The commit is held until /// the moves pause; the echo shows the value meanwhile. + pub commit_target: Option, commit_pending: bool, commit_timer: slint::Timer, /// What the catalogue shelves were last built from; while nothing in @@ -1354,6 +1361,7 @@ impl Studio { audition: None, revision: 0, flat: None, + commit_target: None, commit_pending: false, commit_timer: slint::Timer::default(), shelf_stamp: std::cell::RefCell::new(None), @@ -3236,6 +3244,7 @@ impl Studio { let Some(id) = self.sole_selection() else { return; }; + self.commit_target = Some(id.clone()); // The media's tracks, read before the echo is borrowed: a row of // the Audio panel's list is a stream index of the file. let audio_tracks: Vec = if field == ClipField::AudioTrack { @@ -3423,6 +3432,7 @@ impl Studio { let Some(id) = self.sole_selection() else { return; }; + self.commit_target = Some(id.clone()); self.begin_echo(); let Some(clip) = self.echo_clip_mut(&id) else { return; @@ -3513,7 +3523,8 @@ impl Studio { } fn commit_now(&mut self) { - let Some(id) = self.sole_selection() else { + let target_id = self.commit_target.take().or_else(|| self.sole_selection()); + let Some(id) = target_id else { self.echo = None; return; }; @@ -5606,6 +5617,7 @@ impl Studio { transition_duration: clip .transition_in .as_ref() + .filter(|_| self.outgoing_of(clip).is_some()) .map(|transition| transition.duration as f32) .unwrap_or(0.0), fade_in: clip.fade_in as f32, diff --git a/src/crates/concat/ui/app.slint b/src/crates/concat/ui/app.slint index 25edaa51..8853c401 100644 --- a/src/crates/concat/ui/app.slint +++ b/src/crates/concat/ui/app.slint @@ -60,6 +60,7 @@ export { export component App inherits Window { title: "Concat"; + icon: @image-url("../../../../assets/icons/concat_logo_256.png"); // Where the strip carries its own window buttons the platform's // decorations are not wanted, and this is the property Slint keeps // them off with: the winit backend re-applies `no-frame` to the diff --git a/src/crates/concat/ui/dialogs/export.slint b/src/crates/concat/ui/dialogs/export.slint index 1ec09b3a..adc09022 100644 --- a/src/crates/concat/ui/dialogs/export.slint +++ b/src/crates/concat/ui/dialogs/export.slint @@ -29,6 +29,7 @@ export struct ExportData { /// "8 clips · 3 titles" contents: string, resolution: int, + resolution-options: [string], rate: int, quality: int, /// index into the codec list: H.264, HEVC, AV1 @@ -244,7 +245,7 @@ export component ExportDialog inherits Modal { label: I18n.t("Resolution"); Select { current: root.data.resolution; - options: ["3840 × 2160", "2560 × 1440", "1920 × 1080", "1280 × 720"]; + options: root.data.resolution-options.length > 0 ? root.data.resolution-options : ["3840 × 2160", "2560 × 1440", "1920 × 1080", "1280 × 720"]; details: ["4K", "QHD", "1080p", "720p"]; changed(index) => { root.resolution-changed(index); } } diff --git a/src/crates/concat/ui/dialogs/project.slint b/src/crates/concat/ui/dialogs/project.slint index b5d3a08a..1f501f94 100644 --- a/src/crates/concat/ui/dialogs/project.slint +++ b/src/crates/concat/ui/dialogs/project.slint @@ -77,9 +77,9 @@ export component ProjectDialog inherits Modal { Select { current: root.data.size; placeholder: I18n.t("Custom"); - options: ["1920 × 1080", "3840 × 2160", "1080 × 1920", + options: ["1920 × 1080", "2560 × 1440", "3840 × 2160", "1080 × 1920", "1080 × 1080", "1440 × 1080", "2560 × 1080"]; - details: ["16:9", "16:9 4K", "9:16", "1:1", "4:3", "21:9"]; + details: ["16:9", "16:9 QHD", "16:9 4K", "9:16", "1:1", "4:3", "21:9"]; changed(index) => { root.size-changed(index); } } } diff --git a/src/crates/concat/ui/inspector/text-panel.slint b/src/crates/concat/ui/inspector/text-panel.slint index 5231a4ef..5422c41b 100644 --- a/src/crates/concat/ui/inspector/text-panel.slint +++ b/src/crates/concat/ui/inspector/text-panel.slint @@ -55,6 +55,7 @@ export component TextPanel inherits VerticalLayout { property offset-y; property weight-index; property family-index; + property content; property <[float]> weights: [400, 500, 600, 700]; /// The label column of the rows whose label is two words. @@ -63,6 +64,7 @@ export component TextPanel inherits VerticalLayout { property frame-h: Math.max(1, root.selected.frame-height); function seed() { + root.content = root.selected.content; root.size = root.selected.font-size; root.alpha = root.selected.text-opacity; root.stroke-width = root.selected.stroke-width; @@ -116,7 +118,7 @@ export component TextPanel inherits VerticalLayout { y: 6px; width: parent.width - 16px; height: parent.height - 12px; - text: root.selected.content; + text: root.content; color: Theme.fg; font-size: Theme.fs; // Multi-line: a lower third is routinely two lines, and a @@ -131,11 +133,12 @@ export component TextPanel inherits VerticalLayout { // way out, so a title being typed is one change and not // forty. edited => { + root.content = self.text; root.set-text(ClipTextField.content, self.text); } changed has-focus => { if (!self.has-focus) { - root.set-text(ClipTextField.content, self.text); + root.set-text(ClipTextField.content, root.content); root.commit(); } } diff --git a/src/crates/concat/ui/timeline/lanes.slint b/src/crates/concat/ui/timeline/lanes.slint index f2ab6db5..f9c00251 100644 --- a/src/crates/concat/ui/timeline/lanes.slint +++ b/src/crates/concat/ui/timeline/lanes.slint @@ -183,6 +183,15 @@ component Clip inherits Rectangle { property right-handle-hover: false; property right-handle-drag: false; + changed item => { + root.trans-btn-hover = false; + root.trans-btn-drag = false; + root.left-handle-hover = false; + root.left-handle-drag = false; + root.right-handle-hover = false; + root.right-handle-drag = false; + } + property show-tooltip: root.has-transition && (root.trans-btn-hover || root.trans-btn-drag || root.right-handle-hover || root.right-handle-drag diff --git a/src/crates/concat/ui/workspace/preview-pane.slint b/src/crates/concat/ui/workspace/preview-pane.slint index f95b934b..e4774c7a 100644 --- a/src/crates/concat/ui/workspace/preview-pane.slint +++ b/src/crates/concat/ui/workspace/preview-pane.slint @@ -657,9 +657,9 @@ export component PreviewPane inherits Rectangle { /// That row was used. callback pane-removed(); - property <[int]> ratios-w: [1920, 3840, 1080, 1080, 1440, 2560]; - property <[int]> ratios-h: [1080, 2160, 1920, 1080, 1080, 1080]; - property <[string]> ratio-names: ["16:9", "16:9 4K", "9:16", "1:1", "4:3", "21:9"]; + property <[int]> ratios-w: [1920, 2560, 3840, 1080, 1080, 1440, 2560]; + property <[int]> ratios-h: [1080, 1440, 2160, 1920, 1080, 1080, 1080]; + property <[string]> ratio-names: ["16:9", "16:9 QHD", "16:9 4K", "9:16", "1:1", "4:3", "21:9"]; property aspect: root.output-height > 0 ? root.output-width / root.output-height : 16 / 9;