Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ jobs:
Comment=Video editor
Exec=concat
Icon=concat
StartupWMClass=concat
Categories=AudioVideo;Video;
Terminal=false
DESKTOP
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
"AudioVideo"
"AudioVideoEditing"
];
startupWMClass = "concat";
})
];

Expand Down
26 changes: 26 additions & 0 deletions src/crates/concat-project/src/commands/clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -565,6 +570,27 @@ pub(super) fn apply(
timeline
.clips
.retain(|clip| !doomed.contains(clip.id.as_str()));
let orphaned: Vec<usize> = 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);
Expand Down
75 changes: 75 additions & 0 deletions src/crates/concat-project/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions src/crates/concat/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = state
.timeline()
Expand Down
39 changes: 39 additions & 0 deletions src/crates/concat/src/panes/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<slint::SharedString> = 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(),
Expand All @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/crates/concat/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 15 additions & 3 deletions src/crates/concat/src/studio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<String>,
commit_pending: bool,
commit_timer: slint::Timer,
/// What the catalogue shelves were last built from; while nothing in
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<u32> = if field == ClipField::AudioTrack {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/crates/concat/ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/crates/concat/ui/dialogs/export.slint
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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); }
}
Expand Down
4 changes: 2 additions & 2 deletions src/crates/concat/ui/dialogs/project.slint
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
}
Expand Down
7 changes: 5 additions & 2 deletions src/crates/concat/ui/inspector/text-panel.slint
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export component TextPanel inherits VerticalLayout {
property <float> offset-y;
property <int> weight-index;
property <int> family-index;
property <string> content;

property <[float]> weights: [400, 500, 600, 700];
/// The label column of the rows whose label is two words.
Expand All @@ -63,6 +64,7 @@ export component TextPanel inherits VerticalLayout {
property <float> 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;
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/crates/concat/ui/timeline/lanes.slint
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ component Clip inherits Rectangle {
property <bool> right-handle-hover: false;
property <bool> 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 <bool> show-tooltip: root.has-transition
&& (root.trans-btn-hover || root.trans-btn-drag
|| root.right-handle-hover || root.right-handle-drag
Expand Down
Loading
Loading