diff --git a/README.md b/README.md
index 956460d..6893649 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Omacut
-A dead-simple video **length** trimmer. Open a video, drag the two handles to pick a start and end, preview the clip, and export. On Omarchy, the interface follows your theme's accent color.
+A dead-simple video trimmer. Open a video, drag the handles to pick a start and end, split it into clips, remove unwanted sections, preview, and export the resulting timeline. On Omarchy, the interface follows your theme's accent color.
Built using **Qt Quick (QML)** UI with the Material style — the same Qt stack Quickshell builds on — and **ffmpeg** for the cut. The C++ side compiles to a single executable; the QML is embedded in it via Qt resources.
@@ -8,18 +8,38 @@ Built using **Qt Quick (QML)** UI with the Material style — the same Qt stack
## Hotkeys
-- *Space*: Start/stop video playback.
+- *Space*: Start/stop timeline playback.
+- *T*: Split at the playhead; select the right-hand clip.
+- *Delete* or *Backspace*: Remove the selected clip and close the gap.
- *Left/Right*: Move the playhead by 1 second.
- *Shift+Left/Right*: Move the playhead by 5 seconds.
- *Alt+Left/Right*: Move the playhead by 0.2 seconds.
-- *Ctrl+Space*: Move the start of the trim to the playhead.
-- *Alt+Space*: Move the end of the trim to the playhead.
-- *Z*: Zoom into the trimmed selection for fine tuning (Z again zooms back out).
+- *Ctrl+Space*: Move the selected clip's start to the playhead.
+- *Alt+Space*: Move the selected clip's end to the playhead.
+- *Z*: Zoom the selected clip; press again without changing the selection to zoom out.
- *Ctrl+O*: Open a new file to trim.
-- *Ctrl+S*: Export the current trim.
-- *Q*: Quit (asks first if the trim hasn't been exported).
+- *Ctrl+S*: Export the current timeline.
+- *Q*: Quit (asks first if the timeline hasn't been exported).
- *?*: Show the hotkeys in the app.
+## Split and delete
+
+Click a clip to select it and place the playhead, then press **T** (or the Split button) to split. Each clip has its own start/end handles; the selected clip is highlighted. Press **Delete** or **Backspace** (or the trash button) to remove it. Preview skips removed material, and export joins the remaining clips without gaps.
+
+Handles can expand into unused source material, but cannot overlap neighboring retained clips. Splits must leave at least 0.1 seconds on each side. **Z** focuses on the currently selected clip; a long timeline can be scrolled horizontally. The time display shows position and duration in the resulting video.
+
+Deleting the last clip resets the timeline, unloads the video, and returns to the **Open a video** screen. There is no undo yet: reopen the source with **Ctrl+O** to start again. Edits do not alter the source unless you explicitly export over it.
+
+To test this branch without replacing the installed application:
+
+```sh
+./bin/build
+./bin/test
+./build/omacut
+```
+
+For a quick check, split twice, select and delete the middle clip, then preview and export. The exported duration should equal the sum of the remaining clips (within frame rounding).
+
## Install
Install via the Omarchy Package Repository via the `omacut` package. It's installed by default in new installations of Omarchy (from Quattro forward).
diff --git a/bin/test b/bin/test
index 44ddaa3..1629f34 100755
--- a/bin/test
+++ b/bin/test
@@ -19,3 +19,9 @@ cd "$BUILD_DIR"
"$QMAKE" "$ROOT/tests/backend_tests.pro"
make
./backend_tests
+
+mkdir -p "$BUILD_DIR/clipmodel"
+cd "$BUILD_DIR/clipmodel"
+"$QMAKE" "$ROOT/tests/clipmodel_tests.pro"
+make
+./clipmodel_tests
diff --git a/omacut.pro b/omacut.pro
index f466a7c..d001556 100644
--- a/omacut.pro
+++ b/omacut.pro
@@ -5,6 +5,7 @@ TARGET = omacut
TEMPLATE = app
HEADERS += \
+ src/clipmodel.h \
src/filepicker.h \
src/portalfilepicker.h \
src/ffmpeg.h \
@@ -13,6 +14,7 @@ HEADERS += \
src/backend.h
SOURCES += \
+ src/clipmodel.cpp \
src/main.cpp \
src/portalfilepicker.cpp \
src/ffmpeg.cpp \
diff --git a/src/ClipTimeline.qml b/src/ClipTimeline.qml
new file mode 100644
index 0000000..3684be7
--- /dev/null
+++ b/src/ClipTimeline.qml
@@ -0,0 +1,216 @@
+import QtQuick
+import "Format.js" as Format
+
+// Source ranges are shown adjacent, with a small visual gutter for each pair
+// of independent handles. Playback positions remain source timestamps.
+Item {
+ id: root
+ implicitHeight: 76
+ required property var clips
+ property real durationSec: 0
+ property color accent: "#FFD60A"
+ property int thumbCount: 0
+ property int thumbReadyCount: 0
+ property int thumbRevision: 0
+ property real playheadSec: 0
+ property real startSec: 0
+ property real endSec: 0
+ property bool interacting: false
+ property bool trimmingRange: false
+ property bool zoomed: false
+ property real viewStartSec: 0
+ property real viewEndSec: 0
+ property var ranges: []
+ property bool syncing: false
+ readonly property int selected: clips.selectedIndex
+ readonly property real windowStart: zoomed ? viewStartSec : 0
+ readonly property real windowEnd: zoomed ? viewEndSec : durationSec
+ readonly property real totalLength: clips.duration
+ readonly property int visibleCount: zoomed && selected >= 0 ? 1 : ranges.length
+ readonly property real pixelsPerSecond: Math.max(1, filmstrip.contentWidth - visibleCount * 32) /
+ Math.max(0.001, zoomed && selected >= 0 ? endSec - startSec : totalLength)
+ signal scrub(real seconds)
+
+ function sync() {
+ syncing = true;
+ ranges = clips.snapshot();
+ var range = selected >= 0 ? ranges[selected] : null;
+ startSec = range ? range.sourceStartSec : 0;
+ endSec = range ? range.sourceEndSec : 0;
+ syncing = false;
+ }
+ function resizeSelection() {
+ if (syncing || selected < 0) return;
+ clips.resizeClip(selected, startSec, endSec);
+ sync();
+ }
+ onStartSecChanged: resizeSelection()
+ onEndSecChanged: resizeSelection()
+ Component.onCompleted: sync()
+ Connections {
+ target: root.clips
+ function onChanged() { root.sync(); }
+ function onSelectionChanged() { root.sync(); }
+ }
+ function timelinePosition(source) {
+ var offset = 0;
+ for (var i = 0; i < ranges.length; ++i) {
+ var c = ranges[i];
+ if (source < c.sourceEndSec || i === ranges.length - 1)
+ return offset + Math.max(0, Math.min(c.sourceEndSec - c.sourceStartSec, source - c.sourceStartSec));
+ offset += c.sourceEndSec - c.sourceStartSec;
+ }
+ return 0;
+ }
+ function toggleZoom() {
+ if (selected < 0) return;
+ var slack = (endSec - startSec) / 8;
+ var a = Math.max(0, startSec - slack);
+ var b = Math.min(durationSec, endSec + slack);
+ if (zoomed && a === viewStartSec && b === viewEndSec) zoomed = false;
+ else { viewStartSec = a; viewEndSec = b; zoomed = true; }
+ }
+
+ Text {
+ anchors.bottom: parent.top
+ anchors.bottomMargin: 6
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: root.trimmingRange
+ text: Format.fmt(root.playheadSec)
+ color: root.accent
+ font.pixelSize: 14
+ font.family: "monospace"
+ }
+ Text {
+ anchors.centerIn: parent
+ visible: root.ranges.length === 0
+ text: "Empty timeline — reopen the video to start again"
+ color: "#b8b8bc"
+ font.pixelSize: 12
+ }
+ Flickable {
+ id: filmstrip
+ anchors.fill: parent
+ contentWidth: Math.max(width, root.visibleCount * 48)
+ contentHeight: height
+ clip: true
+ interactive: !root.interacting
+ boundsBehavior: Flickable.StopAtBounds
+ flickableDirection: Flickable.HorizontalFlick
+ Row {
+ width: filmstrip.contentWidth
+ height: root.height
+ spacing: 4
+ Repeater {
+ model: root.clips
+ delegate: Item {
+ id: segment
+ required property int index
+ required property real sourceStartSec
+ required property real sourceEndSec
+ required property real lengthSec
+ required property real timelineStartSec
+ visible: !root.zoomed || index === root.selected
+ width: visible ? lengthSec * root.pixelsPerSecond + 28 : 0
+ height: root.height
+ readonly property bool selected: index === root.selected
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: "#252529"
+ border.width: segment.selected ? 3 : 1
+ border.color: segment.selected ? root.accent : "#77777c"
+ }
+ Image {
+ anchors { fill: parent; leftMargin: 14; rightMargin: 14; topMargin: 4; bottomMargin: 4 }
+ source: root.thumbReadyCount > 0 ? "image://thumbs/" + root.thumbRevision + "/" +
+ Math.max(0, Math.min(root.thumbReadyCount - 1, Math.floor(((segment.sourceStartSec - root.windowStart) / Math.max(root.windowEnd - root.windowStart, 0.001)) * root.thumbCount))) : ""
+ sourceSize.height: root.height
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ cache: false
+ opacity: segment.selected ? 0.8 : 0.5
+ }
+ Text {
+ anchors.centerIn: parent
+ visible: segment.width > 100
+ text: Format.fmt(segment.lengthSec)
+ color: "white"
+ style: Text.Outline
+ styleColor: "black"
+ font.pixelSize: 12
+ }
+ Rectangle {
+ visible: root.playheadSec >= segment.sourceStartSec && root.playheadSec <= segment.sourceEndSec
+ x: 14 + (root.playheadSec - segment.sourceStartSec) / Math.max(segment.lengthSec, 0.001) * (segment.width - 28)
+ y: 4
+ height: parent.height - 8
+ width: 2
+ color: "white"
+ }
+ MouseArea {
+ anchors { fill: parent; leftMargin: 14; rightMargin: 14 }
+ function seek(mouse) {
+ root.playheadSec = segment.sourceStartSec + Math.max(0, Math.min(1, mouse.x / Math.max(width, 1))) * segment.lengthSec;
+ root.scrub(root.playheadSec);
+ }
+ onPressed: function(mouse) {
+ root.clips.select(segment.index);
+ root.interacting = true;
+ seek(mouse);
+ }
+ onPositionChanged: function(mouse) { if (pressed) seek(mouse); }
+ onReleased: root.interacting = false
+ onCanceled: root.interacting = false
+ }
+ Repeater {
+ model: 2
+ delegate: Rectangle {
+ id: handle
+ required property int index
+ readonly property bool leftEdge: index === 0
+ x: leftEdge ? 0 : segment.width - width
+ width: 14
+ height: segment.height
+ radius: 5
+ color: segment.selected ? root.accent : "#77777c"
+ Rectangle { anchors.centerIn: parent; width: 2; height: 18; color: "#202024" }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.SizeHorCursor
+ property real pressX
+ property real originalStart
+ property real originalEnd
+ property real scale
+ onPressed: function(mouse) {
+ root.clips.select(segment.index);
+ pressX = mapToItem(root, mouse.x, mouse.y).x;
+ originalStart = segment.sourceStartSec;
+ originalEnd = segment.sourceEndSec;
+ scale = root.pixelsPerSecond;
+ root.interacting = true;
+ root.trimmingRange = true;
+ }
+ onPositionChanged: function(mouse) {
+ if (!pressed) return;
+ var delta = (mapToItem(root, mouse.x, mouse.y).x - pressX) / scale;
+ var low = segment.index === 0 ? 0 : root.ranges[segment.index - 1].sourceEndSec;
+ var high = segment.index === root.ranges.length - 1 ? root.durationSec : root.ranges[segment.index + 1].sourceStartSec;
+ var gap = Math.min(0.1, root.durationSec);
+ var a = handle.leftEdge ? Math.max(low, Math.min(originalStart + delta, originalEnd - gap)) : originalStart;
+ var b = handle.leftEdge ? originalEnd : Math.min(high, Math.max(originalEnd + delta, originalStart + gap));
+ if (root.clips.resizeClip(segment.index, a, b)) {
+ root.playheadSec = handle.leftEdge ? a : b;
+ root.scrub(root.playheadSec);
+ }
+ }
+ onReleased: { root.interacting = false; root.trimmingRange = false; }
+ onCanceled: { root.interacting = false; root.trimmingRange = false; }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Main.qml b/src/Main.qml
index f150b4a..3c3e156 100644
--- a/src/Main.qml
+++ b/src/Main.qml
@@ -25,13 +25,15 @@ ApplicationWindow {
// What the last export wrote, so quitting only warns about unexported work.
// A trim spanning the whole video is never dirty — that's just the source.
- property real exportedStartSec: -1
- property real exportedEndSec: -1
- property real pendingExportStartSec: 0
- property real pendingExportEndSec: 0
+ property string exportedRanges: ""
+ property string pendingExportRanges: ""
+ property int playbackClip: 0
+ readonly property bool canEdit: hasVideo && !backend.busy && !backend.dialogOpen
+ && !quitConfirmVisible && !helpVisible
readonly property bool trimDirty: hasVideo && backend.duration > 0
- && (trimBar.startSec > 0 || trimBar.endSec < backend.duration)
- && (trimBar.startSec !== exportedStartSec || trimBar.endSec !== exportedEndSec)
+ && !(trimBar.ranges.length === 1 && trimBar.ranges[0].sourceStartSec === 0
+ && trimBar.ranges[0].sourceEndSec === backend.duration)
+ && JSON.stringify(trimBar.ranges) !== exportedRanges
Material.theme: Material.Dark
Material.accent: win.accent
@@ -49,11 +51,11 @@ ApplicationWindow {
backend.openVideoDialog();
}
function exportVideo() {
- if (!win.hasVideo || backend.duration <= 0 || backend.busy)
+ if (!win.hasVideo || backend.clips.count === 0 || backend.busy || backend.dialogOpen)
return;
- pendingExportStartSec = trimBar.startSec;
- pendingExportEndSec = trimBar.endSec;
- backend.exportDialog(trimBar.startSec, trimBar.endSec);
+ player.pause();
+ pendingExportRanges = JSON.stringify(trimBar.ranges);
+ backend.exportTimelineDialog();
}
function ensureAudioOutput() {
if (audioOutput === null && win.hasVideo)
@@ -67,7 +69,7 @@ ApplicationWindow {
oldAudioOutput.destroy();
}
function togglePlay() {
- if (!win.hasVideo || backend.duration <= 0)
+ if (!win.canEdit || backend.clips.count === 0)
return;
ensureAudioOutput();
if (player.priming)
@@ -81,22 +83,26 @@ ApplicationWindow {
// millisecond before endSec. Treat anything within 10 ms of the end
// as "at the end" or play would instantly re-pause instead of
// restarting from the trim start.
- var pos = player.position / 1000;
- if (pos < trimBar.startSec || pos >= trimBar.endSec - 0.01)
- player.position = Math.round(trimBar.startSec * 1000);
+ var pos = trimBar.timelinePosition(trimBar.playheadSec);
+ if (pos >= backend.clips.duration - 0.01)
+ pos = 0;
+ movePlayheadTo(backend.clips.sourceTime(pos));
player.play();
}
- function movePlayheadTo(seconds) {
+ function movePlayheadTo(seconds, selectClip = true) {
if (player.priming)
player.finishPriming();
trimBar.playheadSec = seconds;
+ playbackClip = backend.clips.clipAt(trimBar.timelinePosition(seconds));
+ if (selectClip && playbackClip >= 0) backend.clips.select(playbackClip);
player.position = Math.round(seconds * 1000);
}
function seekBy(seconds) {
if (!win.hasVideo || backend.duration <= 0)
return;
- // The playhead lives inside the trim, same as scrubbing and preview.
- movePlayheadTo(Math.max(trimBar.startSec, Math.min(trimBar.playheadSec + seconds, trimBar.endSec)));
+ if (backend.clips.count === 0) return;
+ var position = Math.max(0, Math.min(trimBar.timelinePosition(trimBar.playheadSec) + seconds, backend.clips.duration));
+ movePlayheadTo(backend.clips.sourceTime(position));
}
// Both edges park the playhead on themselves, so you see the frame you just
// trimmed to — the same thing dragging a handle does. While zoomed, the
@@ -106,14 +112,36 @@ ApplicationWindow {
return;
var minGap = Math.min(0.1, backend.duration);
trimBar.startSec = Math.max(trimBar.windowStart, Math.min(seconds, trimBar.endSec - minGap));
- movePlayheadTo(trimBar.startSec);
+ movePlayheadTo(trimBar.startSec, false);
}
function moveTrimEndTo(seconds) {
if (!win.hasVideo || backend.duration <= 0)
return;
var minGap = Math.min(0.1, backend.duration);
trimBar.endSec = Math.min(trimBar.windowEnd, Math.max(seconds, trimBar.startSec + minGap));
- movePlayheadTo(trimBar.endSec);
+ movePlayheadTo(trimBar.endSec, false);
+ }
+ function splitClip() {
+ if (!canEdit || backend.clips.count === 0) return;
+ player.pause();
+ if (backend.clips.split(trimBar.timelinePosition(trimBar.playheadSec))) {
+ trimBar.zoomed = false;
+ backend.requestThumbs(0, backend.duration);
+ movePlayheadTo(trimBar.playheadSec);
+ } else showNotice("Move the playhead away from the clip edges to split");
+ }
+ function deleteClip() {
+ if (!canEdit || backend.clips.selectedIndex < 0) return;
+ player.pause();
+ if (backend.clips.removeSelected()) {
+ trimBar.zoomed = false;
+ if (backend.clips.count > 0) {
+ backend.requestThumbs(0, backend.duration);
+ movePlayheadTo(trimBar.startSec);
+ } else {
+ backend.clearVideo();
+ }
+ }
}
property bool quitting: false
function requestQuit() {
@@ -142,6 +170,7 @@ ApplicationWindow {
ensureAudioOutput();
} else {
player.stop();
+ videoOut.clearOutput();
releaseAudioOutput();
}
}
@@ -161,73 +190,85 @@ ApplicationWindow {
// The playback and trim shortcuts go quiet while the quit confirmation is
// up — a disabled Shortcut also stops swallowing its key, which lets the
// dialog's own keyboard navigation receive the arrows, Space and Enter.
+ Shortcut {
+ sequence: "T"
+ context: Qt.ApplicationShortcut
+ enabled: win.canEdit && backend.clips.count > 0
+ onActivated: splitClip()
+ }
+ Shortcut {
+ sequences: ["Delete", "Backspace"]
+ context: Qt.ApplicationShortcut
+ enabled: win.canEdit && backend.clips.selectedIndex >= 0
+ onActivated: deleteClip()
+ }
Shortcut {
sequence: "Space"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: togglePlay()
}
Shortcut {
sequence: "Ctrl+Space"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.selectedIndex >= 0
onActivated: moveTrimStartTo(trimBar.playheadSec)
}
Shortcut {
sequence: "Alt+Space"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.selectedIndex >= 0
onActivated: moveTrimEndTo(trimBar.playheadSec)
}
Shortcut {
sequence: "Left"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: seekBy(-1)
}
Shortcut {
sequence: "Right"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: seekBy(1)
}
Shortcut {
sequence: "Shift+Left"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: seekBy(-5)
}
Shortcut {
sequence: "Shift+Right"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: seekBy(5)
}
Shortcut {
sequence: "Alt+Left"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: seekBy(-0.2)
}
Shortcut {
sequence: "Alt+Right"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.count > 0
onActivated: seekBy(0.2)
}
Shortcut {
sequence: "Z"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && backend.duration > 0 && !win.quitConfirmVisible
+ enabled: win.canEdit && backend.clips.selectedIndex >= 0
onActivated: {
trimBar.toggleZoom();
backend.requestThumbs(trimBar.windowStart, trimBar.windowEnd);
@@ -237,7 +278,7 @@ ApplicationWindow {
Shortcut {
sequence: "Ctrl+S"
context: Qt.ApplicationShortcut
- enabled: win.hasVideo && backend.duration > 0 && !backend.busy
+ enabled: win.hasVideo && backend.clips.count > 0 && !backend.busy && !backend.dialogOpen
onActivated: {
win.quitConfirmVisible = false;
exportVideo();
@@ -247,7 +288,7 @@ ApplicationWindow {
Shortcut {
sequence: "Ctrl+O"
context: Qt.ApplicationShortcut
- enabled: !win.quitConfirmVisible
+ enabled: !win.quitConfirmVisible && !backend.busy && !backend.dialogOpen
onActivated: openVideo()
}
@@ -282,6 +323,7 @@ ApplicationWindow {
MediaPlayer {
id: player
+ objectName: "player"
source: backend.source
videoOutput: videoOut
audioOutput: win.audioOutput
@@ -315,18 +357,25 @@ ApplicationWindow {
if (mediaStatus === MediaPlayer.LoadedMedia || mediaStatus === MediaPlayer.BufferedMedia)
startPriming();
}
- onPositionChanged: {
- if (priming && position > 0) {
+ onPositionChanged: function(newPosition) {
+ if (priming && newPosition > 0) {
finishPriming();
return;
}
- // Stop at the trim end, like a clip preview.
- if (playbackState === MediaPlayer.PlayingState && position / 1000 >= trimBar.endSec) {
- pause();
- position = Math.round(trimBar.endSec * 1000);
+ // Jump over removed source material while previewing the timeline.
+ var range = trimBar.ranges[win.playbackClip];
+ if (playbackState === MediaPlayer.PlayingState && range && newPosition / 1000 >= range.sourceEndSec - 0.001) {
+ if (win.playbackClip + 1 < trimBar.ranges.length) {
+ win.playbackClip++;
+ backend.clips.select(win.playbackClip);
+ player.position = Math.round(trimBar.ranges[win.playbackClip].sourceStartSec * 1000);
+ } else {
+ pause();
+ player.position = Math.round(range.sourceEndSec * 1000);
+ }
}
if (!trimBar.interacting)
- trimBar.playheadSec = position / 1000;
+ trimBar.playheadSec = player.position / 1000;
}
}
@@ -398,6 +447,14 @@ ApplicationWindow {
color: buttonColor
opacity: enabled ? 1 : 0.45
+ activeFocusOnTab: true
+ Accessible.role: Accessible.Button
+ Accessible.name: tipText
+ Accessible.onPressAction: clicked()
+ border.width: activeFocus ? 2 : 0
+ border.color: win.accent
+ Keys.onReturnPressed: clicked()
+ Keys.onEnterPressed: clicked()
HoverHandler { id: iconHover }
ToolTip.visible: iconHover.hovered && tipText !== ""
@@ -428,6 +485,18 @@ ApplicationWindow {
ctx.lineTo(19, 12);
ctx.closePath();
ctx.fill();
+ } else if (iconButton.iconName === "split") {
+ ctx.beginPath();
+ ctx.moveTo(5, 5); ctx.lineTo(19, 19);
+ ctx.moveTo(5, 19); ctx.lineTo(19, 5);
+ ctx.moveTo(12, 2); ctx.lineTo(12, 7);
+ ctx.stroke();
+ } else if (iconButton.iconName === "delete") {
+ ctx.strokeRect(7, 7, 10, 13);
+ ctx.beginPath();
+ ctx.moveTo(5, 5); ctx.lineTo(19, 5);
+ ctx.moveTo(10, 2); ctx.lineTo(14, 2);
+ ctx.stroke();
} else if (iconButton.iconName === "download") {
ctx.beginPath();
ctx.moveTo(12, 4);
@@ -494,6 +563,7 @@ ApplicationWindow {
Button {
id: openVideoButton
+ objectName: "openVideoButton"
anchors.centerIn: parent
visible: !win.hasVideo
text: "Open a video"
@@ -525,13 +595,15 @@ ApplicationWindow {
Layout.preferredWidth: 44
Layout.preferredHeight: 44
iconName: player.playbackState === MediaPlayer.PlayingState && !player.priming ? "pause" : "play"
- tipText: player.playbackState === MediaPlayer.PlayingState ? "Pause" : "Play"
- enabled: backend.duration > 0
+ tipText: player.playbackState === MediaPlayer.PlayingState ? "Pause (Space)" : "Play (Space)"
+ enabled: win.canEdit && backend.clips.count > 0
onClicked: togglePlay()
}
- TrimBar {
+ ClipTimeline {
id: trimBar
+ clips: backend.clips
+ enabled: win.canEdit
objectName: "trimBar"
Layout.fillWidth: true
accent: win.accent
@@ -539,15 +611,29 @@ ApplicationWindow {
thumbCount: backend.thumbCount
thumbReadyCount: backend.thumbReadyCount
thumbRevision: backend.thumbRevision
- onScrub: (seconds) => player.position = Math.round(seconds * 1000)
+ onScrub: (seconds) => { player.pause(); win.movePlayheadTo(seconds, false); }
}
+ IconButton {
+ objectName: "splitButton"
+ iconName: "split"
+ tipText: "Split at playhead (T)"
+ enabled: win.canEdit && backend.clips.count > 0
+ onClicked: splitClip()
+ }
+ IconButton {
+ objectName: "deleteButton"
+ iconName: "delete"
+ tipText: "Delete selected clip (Del / Backspace)"
+ enabled: win.canEdit && backend.clips.selectedIndex >= 0
+ onClicked: deleteClip()
+ }
IconButton {
Layout.preferredWidth: 44
Layout.preferredHeight: 44
iconName: "download"
- tipText: "Export"
- enabled: backend.duration > 0 && !backend.busy
+ tipText: "Export timeline (Ctrl+S)"
+ enabled: win.canEdit && backend.clips.count > 0
onClicked: exportVideo()
}
}
@@ -575,7 +661,8 @@ ApplicationWindow {
anchors.centerIn: parent
visible: win.statusText === "" && backend.duration > 0 && !trimBar.trimmingRange
textFormat: Text.StyledText
- text: Format.fmt(trimBar.playheadSec) + " (" + Format.fmt(trimBar.endSec - trimBar.startSec) + ")"
+ text: Format.fmt(trimBar.timelinePosition(trimBar.playheadSec)) + " / " + Format.fmt(backend.clips.duration)
+ + " · " + backend.clips.count + " clip(s)"
+ (trimBar.zoomed ? " · zoomed" : "")
color: "#d6d6da"
font.pixelSize: 13
@@ -644,6 +731,8 @@ ApplicationWindow {
Repeater {
model: [
{ keys: "Space", action: "Play / pause" },
+ { keys: "T", action: "Split at playhead" },
+ { keys: "Del / Backspace", action: "Delete selected clip" },
{ keys: "← / →", action: "Move playhead 1s" },
{ keys: "Shift ← / →", action: "Move playhead 5s" },
{ keys: "Alt ← / →", action: "Move playhead 0.2s" },
@@ -772,15 +861,14 @@ ApplicationWindow {
player.priming = false;
player.primed = false;
trimBar.zoomed = false;
- trimBar.startSec = 0;
- trimBar.endSec = backend.duration;
trimBar.playheadSec = 0;
- win.exportedStartSec = -1;
- win.exportedEndSec = -1;
+ win.exportedRanges = "";
+ win.pendingExportRanges = "";
+ win.playbackClip = win.hasVideo ? 0 : -1;
+ trimBar.sync();
}
function onExportDone(path) {
- win.exportedStartSec = win.pendingExportStartSec;
- win.exportedEndSec = win.pendingExportEndSec;
+ win.exportedRanges = win.pendingExportRanges;
win.showNotice("Saved " + path);
}
function onExportFailed(message) {
diff --git a/src/backend.cpp b/src/backend.cpp
index ee86584..f809fcf 100644
--- a/src/backend.cpp
+++ b/src/backend.cpp
@@ -9,6 +9,7 @@
#include
#include
+#include
#include "filepicker.h"
#include "portalfilepicker.h"
@@ -75,9 +76,27 @@ Backend::~Backend() {
}
void Backend::wireFilePicker() {
- connect(m_filePicker, &FilePicker::openSelected, this, &Backend::load);
- connect(m_filePicker, &FilePicker::exportSelected, this, &Backend::exportClip);
- connect(m_filePicker, &FilePicker::failed, this, &Backend::loadError);
+ connect(m_filePicker, &FilePicker::openSelected, this, [this](const QUrl &url) {
+ setDialogOpen(false);
+ load(url);
+ });
+ connect(m_filePicker, &FilePicker::exportSelected, this,
+ [this](const QUrl &url, double start, double end, int height) {
+ const auto ranges = m_pendingRanges;
+ m_pendingRanges.clear();
+ setDialogOpen(false);
+ if (ranges.isEmpty()) exportClip(url, start, end, height);
+ else exportRanges(url, ranges, height);
+ });
+ connect(m_filePicker, &FilePicker::cancelled, this, [this] {
+ m_pendingRanges.clear();
+ setDialogOpen(false);
+ });
+ connect(m_filePicker, &FilePicker::failed, this, [this](const QString &message) {
+ m_pendingRanges.clear();
+ setDialogOpen(false);
+ emit loadError(message);
+ });
}
void Backend::setBusy(bool busy) {
@@ -157,6 +176,8 @@ void Backend::watchTheme() {
}
bool Backend::load(const QUrl &url) {
+ if (m_busy || m_dialogOpen)
+ return false;
const QString path = url.toLocalFile();
const ffmpeg::VideoInfo info = ffmpeg::probe(path);
if (!info.ok) {
@@ -182,6 +203,7 @@ bool Backend::load(const QUrl &url) {
m_provider->setImages(QVector(kThumbCount));
emit thumbsChanged();
+ m_clips.reset(m_info.duration);
emit infoChanged();
setStatus(QStringLiteral("Loading..."));
@@ -189,14 +211,57 @@ bool Backend::load(const QUrl &url) {
return true;
}
+void Backend::clearVideo() {
+ if (m_busy || m_dialogOpen)
+ return;
+
+ stopThumbs();
+ ++m_thumbRevision; // Invalidate any thumbnail callbacks already queued.
+ m_info = {};
+ m_path.clear();
+ m_source = QUrl();
+ m_pendingRanges.clear();
+ m_thumbStart = 0;
+ m_thumbLen = 0;
+ m_fullThumbs.clear();
+ m_fullThumbsComplete = false;
+ m_thumbCount = 0;
+ m_thumbAvailableCount = 0;
+ m_thumbReadyCount = 0;
+ m_thumbWorkerDone = false;
+ m_provider->setImages({});
+ m_clips.reset(0);
+ setStatus({});
+ emit thumbsChanged();
+ emit infoChanged();
+}
+
+void Backend::setDialogOpen(bool open) {
+ if (m_dialogOpen == open) return;
+ m_dialogOpen = open;
+ emit dialogOpenChanged();
+}
+
void Backend::openVideoDialog() {
+ if (m_busy || m_dialogOpen) return;
+ setDialogOpen(true);
m_filePicker->openVideo();
}
+void Backend::exportTimelineDialog() {
+ if (m_busy || m_dialogOpen || m_clips.count() == 0 || !m_info.ok) return;
+ m_pendingRanges = m_clips.snapshot();
+ setDialogOpen(true);
+ m_filePicker->exportVideo(suggestedExportUrl(), 0, m_clips.duration(),
+ exportHeights(m_info.width, m_info.height));
+}
+
void Backend::exportDialog(double start, double end) {
- if (m_path.isEmpty() || !m_info.ok)
+ if (m_path.isEmpty() || !m_info.ok || m_busy || m_dialogOpen)
return;
+ m_pendingRanges.clear();
+ setDialogOpen(true);
m_filePicker->exportVideo(suggestedExportUrl(), start, end,
exportHeights(m_info.width, m_info.height));
}
@@ -315,11 +380,37 @@ QUrl Backend::suggestedExportUrl() const {
}
void Backend::exportClip(const QUrl &dst, double start, double end, int scaleHeight) {
- if (m_path.isEmpty() || !m_info.ok || m_busy)
- return;
+ exportRanges(dst, {QVariantMap{{"sourceStartSec", start}, {"sourceEndSec", end}}}, scaleHeight);
+}
- if (end - start <= 0.0) {
- emit exportFailed("The selected clip has no length.");
+void Backend::exportRanges(const QUrl &dst, const QVariantList &ranges, int scaleHeight) {
+ if (m_path.isEmpty() || !m_info.ok || m_busy || m_dialogOpen)
+ return;
+ double clipLen = 0;
+ double previousEnd = 0;
+ for (const auto &value : ranges) {
+ const auto range = value.toMap();
+ bool startOk = false, endOk = false;
+ const double start = range.value("sourceStartSec").toDouble(&startOk);
+ const double end = range.value("sourceEndSec").toDouble(&endOk);
+ if (!startOk || !endOk || !std::isfinite(start) || !std::isfinite(end)
+ || start < previousEnd || end > m_info.duration || end <= start) {
+ emit exportFailed("Invalid clip range.");
+ return;
+ }
+ clipLen += end - start;
+ previousEnd = end;
+ }
+ if (ranges.isEmpty()) {
+ emit exportFailed("The timeline is empty.");
+ return;
+ }
+ if (!dst.isLocalFile() || dst.toLocalFile().isEmpty()) {
+ emit exportFailed("Choose a local output file.");
+ return;
+ }
+ if (scaleHeight != 0 && !exportHeights(m_info.width, m_info.height).contains(scaleHeight)) {
+ emit exportFailed("Invalid export quality.");
return;
}
@@ -347,14 +438,17 @@ void Backend::exportClip(const QUrl &dst, double start, double end, int scaleHei
// success, so failed/cancelled exports preserve any existing file.
const QString tmpPath = outPath + QStringLiteral(".omacut-part.mp4");
QFile::remove(tmpPath);
- const QStringList args = ffmpeg::trimArgs(m_path, tmpPath, start, end, scaleHeight);
+ const auto first = ranges.first().toMap();
+ const QStringList args = ranges.size() == 1
+ ? ffmpeg::trimArgs(m_path, tmpPath, first.value("sourceStartSec").toDouble(),
+ first.value("sourceEndSec").toDouble(), scaleHeight)
+ : ffmpeg::concatArgs(m_path, tmpPath, ranges, m_info.hasAudio, scaleHeight);
auto *proc = new QProcess(this);
auto completed = std::make_shared(false);
// ffmpeg -progress writes key=value blocks to stdout as it encodes;
// out_time_us against the clip length gives the percentage.
- const double clipLen = end - start;
auto progressBuf = std::make_shared();
connect(proc, &QProcess::readyReadStandardOutput, this,
[this, proc, progressBuf, clipLen, completed] {
diff --git a/src/backend.h b/src/backend.h
index 9c96ba4..c185386 100644
--- a/src/backend.h
+++ b/src/backend.h
@@ -9,6 +9,7 @@
#include
#include "ffmpeg.h"
+#include "clipmodel.h"
class ThumbProvider;
class FilePicker;
@@ -18,6 +19,8 @@ class ThumbWorker;
// loaded video's info and drives thumbnail generation and export.
class Backend : public QObject {
Q_OBJECT
+ Q_PROPERTY(ClipModel* clips READ clips CONSTANT)
+ Q_PROPERTY(bool dialogOpen READ dialogOpen NOTIFY dialogOpenChanged)
Q_PROPERTY(QUrl source READ source NOTIFY infoChanged)
Q_PROPERTY(double duration READ duration NOTIFY infoChanged)
Q_PROPERTY(int thumbCount READ thumbCount NOTIFY thumbsChanged)
@@ -34,6 +37,8 @@ class Backend : public QObject {
QObject *parent = nullptr);
~Backend() override;
+ ClipModel *clips() { return &m_clips; }
+ bool dialogOpen() const { return m_dialogOpen; }
QUrl source() const { return m_source; }
double duration() const { return m_info.duration; }
int thumbCount() const { return m_thumbCount; }
@@ -53,10 +58,14 @@ class Backend : public QObject {
// Load a video (probes it, then kicks off thumbnail generation).
Q_INVOKABLE bool load(const QUrl &url);
+ // Unload the source and discard timeline/thumbnail state.
+ Q_INVOKABLE void clearVideo();
// Open native desktop file dialogs.
Q_INVOKABLE void openVideoDialog();
Q_INVOKABLE void exportDialog(double start, double end);
+ Q_INVOKABLE void exportTimelineDialog();
+ Q_INVOKABLE void exportRanges(const QUrl &dst, const QVariantList &ranges, int scaleHeight = 0);
// Suggested "_trimmed.mp4" target next to the source.
Q_INVOKABLE QUrl suggestedExportUrl() const;
@@ -75,6 +84,7 @@ class Backend : public QObject {
Q_INVOKABLE void requestThumbs(double start, double end);
signals:
+ void dialogOpenChanged();
void infoChanged();
void thumbsChanged();
void busyChanged();
@@ -95,6 +105,10 @@ class Backend : public QObject {
void loadThemeAccent();
void watchTheme();
+ void setDialogOpen(bool open);
+ ClipModel m_clips;
+ bool m_dialogOpen = false;
+ QVariantList m_pendingRanges;
ThumbProvider *m_provider;
FilePicker *m_filePicker;
ThumbWorker *m_thumbWorker = nullptr;
diff --git a/src/clipmodel.cpp b/src/clipmodel.cpp
new file mode 100644
index 0000000..9b6409e
--- /dev/null
+++ b/src/clipmodel.cpp
@@ -0,0 +1,144 @@
+#include "clipmodel.h"
+
+#include
+#include
+
+ClipModel::ClipModel(QObject *parent) : QAbstractListModel(parent) {}
+
+int ClipModel::rowCount(const QModelIndex &parent) const {
+ return parent.isValid() ? 0 : count();
+}
+
+bool ClipModel::validIndex(int index) const {
+ return index >= 0 && index < count();
+}
+
+QVariant ClipModel::data(const QModelIndex &index, int role) const {
+ if (!index.isValid() || index.model() != this || !validIndex(index.row()) || index.column() != 0)
+ return {};
+ const auto &clip = m_clips[index.row()];
+ switch (role) {
+ case SourceStartRole: return clip.start;
+ case SourceEndRole: return clip.end;
+ case TimelineStartRole: return timelineStart(index.row());
+ case LengthRole: return clip.end - clip.start;
+ default: return {};
+ }
+}
+
+QHash ClipModel::roleNames() const {
+ return {{SourceStartRole, "sourceStartSec"}, {SourceEndRole, "sourceEndSec"},
+ {TimelineStartRole, "timelineStartSec"}, {LengthRole, "lengthSec"}};
+}
+
+double ClipModel::timelineStart(int index) const {
+ double result = 0;
+ for (int i = 0; i < index; ++i)
+ result += m_clips[i].end - m_clips[i].start;
+ return result;
+}
+
+double ClipModel::duration() const { return timelineStart(count()); }
+
+void ClipModel::reset(double sourceDuration) {
+ beginResetModel();
+ m_sourceDuration = std::isfinite(sourceDuration) && sourceDuration > 0 ? sourceDuration : 0;
+ m_clips.clear();
+ if (m_sourceDuration > 0)
+ m_clips.append({0, m_sourceDuration});
+ m_selected = m_clips.isEmpty() ? -1 : 0;
+ endResetModel();
+ emit changed();
+ emit selectionChanged();
+}
+
+void ClipModel::select(int index) {
+ if ((!validIndex(index) && index != -1) || m_selected == index)
+ return;
+ m_selected = index;
+ emit selectionChanged();
+}
+
+void ClipModel::notifyRanges() {
+ // Edits also shift all subsequent timelineStartSec roles.
+ if (!m_clips.isEmpty())
+ emit dataChanged(index(0), index(count() - 1));
+ emit changed();
+}
+
+int ClipModel::clipAt(double seconds) const {
+ if (!std::isfinite(seconds) || seconds < 0 || seconds > duration() || m_clips.isEmpty())
+ return -1;
+ double end = 0;
+ for (int i = 0; i < count(); ++i) {
+ end += m_clips[i].end - m_clips[i].start;
+ if (seconds < end)
+ return i;
+ }
+ return count() - 1;
+}
+
+double ClipModel::sourceTime(double seconds) const {
+ const int i = clipAt(seconds);
+ return i < 0 ? -1 : m_clips[i].start + seconds - timelineStart(i);
+}
+
+double ClipModel::timelineTime(int i, double seconds) const {
+ if (!validIndex(i) || !std::isfinite(seconds) || seconds < m_clips[i].start || seconds > m_clips[i].end)
+ return -1;
+ return timelineStart(i) + seconds - m_clips[i].start;
+}
+
+bool ClipModel::split(double seconds) {
+ const int i = clipAt(seconds);
+ if (i < 0)
+ return false;
+ const Clip original = m_clips[i];
+ const double cut = sourceTime(seconds);
+ // Tolerate floating-point rounding at the minimum-length boundary.
+ if (cut - original.start < MinimumLength - 1e-9 || original.end - cut < MinimumLength - 1e-9)
+ return false;
+ beginInsertRows({}, i + 1, i + 1);
+ m_clips[i].end = cut;
+ m_clips.insert(i + 1, {cut, original.end});
+ endInsertRows();
+ m_selected = i + 1;
+ emit selectionChanged();
+ notifyRanges();
+ return true;
+}
+
+bool ClipModel::removeSelected() {
+ if (!validIndex(m_selected))
+ return false;
+ const int removed = m_selected;
+ beginRemoveRows({}, removed, removed);
+ m_clips.removeAt(removed);
+ m_selected = m_clips.isEmpty() ? -1 : std::min(removed, count() - 1);
+ endRemoveRows();
+ // Notify even if the numeric index is unchanged: it now names a new clip.
+ emit selectionChanged();
+ notifyRanges();
+ return true;
+}
+
+bool ClipModel::resizeClip(int i, double start, double end) {
+ if (!validIndex(i) || !std::isfinite(start) || !std::isfinite(end))
+ return false;
+ const double lower = i == 0 ? 0 : m_clips[i - 1].end;
+ const double upper = i == count() - 1 ? m_sourceDuration : m_clips[i + 1].start;
+ if (start < lower || end > upper || end - start < std::min(MinimumLength, m_sourceDuration) - 1e-9)
+ return false;
+ if (m_clips[i].start == start && m_clips[i].end == end)
+ return true;
+ m_clips[i] = {start, end};
+ notifyRanges();
+ return true;
+}
+
+QVariantList ClipModel::snapshot() const {
+ QVariantList result;
+ for (const auto &clip : m_clips)
+ result.append(QVariantMap{{"sourceStartSec", clip.start}, {"sourceEndSec", clip.end}});
+ return result;
+}
diff --git a/src/clipmodel.h b/src/clipmodel.h
new file mode 100644
index 0000000..d8fb662
--- /dev/null
+++ b/src/clipmodel.h
@@ -0,0 +1,54 @@
+#pragma once
+
+#include
+#include
+#include
+
+// Non-destructive, source-ordered ranges from one video. Timeline time is the
+// concatenation of retained ranges; source time always refers to the input.
+class ClipModel : public QAbstractListModel {
+ Q_OBJECT
+ Q_PROPERTY(int count READ count NOTIFY changed)
+ Q_PROPERTY(double duration READ duration NOTIFY changed)
+ Q_PROPERTY(int selectedIndex READ selectedIndex WRITE select NOTIFY selectionChanged)
+
+public:
+ enum Role { SourceStartRole = Qt::UserRole + 1, SourceEndRole,
+ TimelineStartRole, LengthRole };
+ explicit ClipModel(QObject *parent = nullptr);
+ int rowCount(const QModelIndex &parent = {}) const override;
+ QVariant data(const QModelIndex &index, int role) const override;
+ QHash roleNames() const override;
+ int count() const { return m_clips.size(); }
+ double duration() const;
+ int selectedIndex() const { return m_selected; }
+
+ Q_INVOKABLE void reset(double sourceDuration);
+ Q_INVOKABLE void select(int index);
+ // Splitting uses concatenated timeline time and selects the right half.
+ Q_INVOKABLE bool split(double timelineSeconds);
+ Q_INVOKABLE bool removeSelected();
+ // Reject invalid edits rather than silently clamping a handle's value.
+ // Edges may expand into unused source, but never overlap a neighbor.
+ Q_INVOKABLE bool resizeClip(int index, double sourceStart, double sourceEnd);
+ // Interior boundaries belong to the right clip; the final endpoint belongs
+ // to the last clip. Invalid/empty positions return -1.
+ Q_INVOKABLE int clipAt(double timelineSeconds) const;
+ Q_INVOKABLE double sourceTime(double timelineSeconds) const;
+ Q_INVOKABLE double timelineTime(int index, double sourceSeconds) const;
+ Q_INVOKABLE QVariantList snapshot() const;
+
+signals:
+ void changed();
+ void selectionChanged();
+
+private:
+ struct Clip { double start; double end; };
+ static constexpr double MinimumLength = 0.1;
+ bool validIndex(int index) const;
+ double timelineStart(int index) const;
+ void notifyRanges();
+ QVector m_clips;
+ double m_sourceDuration = 0;
+ int m_selected = -1;
+};
diff --git a/src/ffmpeg.cpp b/src/ffmpeg.cpp
index cdaa0ff..6833246 100644
--- a/src/ffmpeg.cpp
+++ b/src/ffmpeg.cpp
@@ -36,7 +36,6 @@ VideoInfo probe(const QString &path) {
"-print_format", "json",
"-show_format",
"-show_streams",
- "-select_streams", "v:0",
path,
});
if (!proc.waitForFinished(kProbeTimeoutMs)) {
@@ -61,7 +60,19 @@ VideoInfo probe(const QString &path) {
return info;
}
- const QJsonObject stream = streams.first().toObject();
+ QJsonObject stream;
+ for (const auto &value : streams) {
+ const auto candidate = value.toObject();
+ if (candidate.value("codec_type").toString() == "audio")
+ info.hasAudio = true;
+ if (stream.isEmpty() && (candidate.value("codec_type").toString() == "video"
+ || candidate.contains("width")))
+ stream = candidate;
+ }
+ if (stream.isEmpty()) {
+ info.error = "No video stream found in this file.";
+ return info;
+ }
info.width = stream.value("width").toInt();
info.height = stream.value("height").toInt();
@@ -147,4 +158,39 @@ QStringList trimArgs(const QString &src, const QString &dst, double start, doubl
return args;
}
+QStringList concatArgs(const QString &src, const QString &dst, const QVariantList &ranges,
+ bool hasAudio, int scaleHeight) {
+ QStringList args = {"-y", "-loglevel", "error", "-progress", "pipe:1"};
+ QStringList filters;
+ QString inputs;
+ for (int i = 0; i < ranges.size(); ++i) {
+ const auto range = ranges[i].toMap();
+ const double start = range.value("sourceStartSec").toDouble();
+ const double length = range.value("sourceEndSec").toDouble() - start;
+ const QString len = QString::number(length, 'f', 6);
+ args << "-ss" << QString::number(start, 'f', 6) << "-t" << len << "-i" << src;
+ filters << QString("[%1:v:0]setpts=PTS-STARTPTS[v%1]").arg(i);
+ inputs += QString("[v%1]").arg(i);
+ if (hasAudio) {
+ // Preserve initial audio delay and pad short/missing tails so each
+ // segment's audio has exactly its requested duration.
+ filters << QString("[%1:a:0]aresample=async=1:first_pts=0,apad,atrim=duration=%2,asetpts=PTS-STARTPTS[a%1]").arg(i).arg(len);
+ inputs += QString("[a%1]").arg(i);
+ }
+ }
+ filters << inputs + QString("concat=n=%1:v=1:a=%2[vout]").arg(ranges.size()).arg(hasAudio ? 1 : 0)
+ + (hasAudio ? "[aout]" : "");
+ QString video = "[vout]";
+ if (scaleHeight > 0) {
+ filters << QString("[vout]scale='if(gt(iw,ih),-2,%1)':'if(gt(iw,ih),%1,-2)'[scaled]").arg(scaleHeight);
+ video = "[scaled]";
+ }
+ args << "-filter_complex" << filters.join(';') << "-map" << video;
+ if (hasAudio)
+ args << "-map" << "[aout]";
+ args << "-c:v" << "libx264" << "-preset" << "veryfast" << "-crf" << "18"
+ << "-c:a" << "aac" << "-movflags" << "+faststart" << dst;
+ return args;
+}
+
} // namespace ffmpeg
diff --git a/src/ffmpeg.h b/src/ffmpeg.h
index 7cb1a8b..e21ed6c 100644
--- a/src/ffmpeg.h
+++ b/src/ffmpeg.h
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include
@@ -14,6 +15,7 @@ struct VideoInfo {
double duration = 0.0; // seconds
int width = 0;
int height = 0;
+ bool hasAudio = false;
bool ok = false;
QString error;
};
@@ -34,6 +36,10 @@ QImage thumbnail(const QString &path, double time, int height = 90,
QStringList trimArgs(const QString &src, const QString &dst, double start, double end,
int scaleHeight = 0);
+// Concatenate retained source ranges into one re-encoded output.
+QStringList concatArgs(const QString &src, const QString &dst, const QVariantList &ranges,
+ bool hasAudio, int scaleHeight = 0);
+
// Locate a tool on PATH; returns empty string if missing.
QString toolPath(const QString &tool);
diff --git a/src/filepicker.h b/src/filepicker.h
index 0adb95e..266c4a1 100644
--- a/src/filepicker.h
+++ b/src/filepicker.h
@@ -22,4 +22,5 @@ class FilePicker : public QObject {
// scaleHeight is 0 for "Original", otherwise the chosen short-side size.
void exportSelected(const QUrl &url, double start, double end, int scaleHeight);
void failed(const QString &message);
+ void cancelled();
};
diff --git a/src/portalfilepicker.cpp b/src/portalfilepicker.cpp
index 82fb49f..7cb4b74 100644
--- a/src/portalfilepicker.cpp
+++ b/src/portalfilepicker.cpp
@@ -292,12 +292,16 @@ void PortalFilePicker::handleResponse(uint response, const QVariantMap &results)
const double end = m_pendingExportEnd;
clearPending();
- if (response != 0)
+ if (response != 0) {
+ emit cancelled();
return;
+ }
const QStringList uris = results.value(QStringLiteral("uris")).toStringList();
- if (uris.isEmpty())
+ if (uris.isEmpty()) {
+ emit cancelled();
return;
+ }
const QUrl url(uris.first());
if (action == Action::Open) {
diff --git a/src/resources.qrc b/src/resources.qrc
index 5c07852..0a64efc 100644
--- a/src/resources.qrc
+++ b/src/resources.qrc
@@ -2,6 +2,7 @@
Main.qml
TrimBar.qml
+ ClipTimeline.qml
Format.js
diff --git a/tests/backend_tests.cpp b/tests/backend_tests.cpp
index 68f35b3..1e0ec06 100644
--- a/tests/backend_tests.cpp
+++ b/tests/backend_tests.cpp
@@ -62,6 +62,8 @@ class EnvVarGuard {
class ShortcutBackend : public QObject {
Q_OBJECT
+ Q_PROPERTY(ClipModel* clips READ clips CONSTANT)
+ Q_PROPERTY(bool dialogOpen READ dialogOpen CONSTANT)
Q_PROPERTY(QUrl source READ source NOTIFY infoChanged)
Q_PROPERTY(double duration READ duration NOTIFY infoChanged)
Q_PROPERTY(int thumbCount READ thumbCount NOTIFY thumbsChanged)
@@ -74,7 +76,10 @@ class ShortcutBackend : public QObject {
public:
explicit ShortcutBackend(QUrl source, double duration, QObject *parent = nullptr)
- : QObject(parent), m_source(std::move(source)), m_duration(duration) {}
+ : QObject(parent), m_source(std::move(source)), m_duration(duration) { m_clips.reset(duration); }
+
+ ClipModel *clips() { return &m_clips; }
+ bool dialogOpen() const { return false; }
QUrl source() const { return m_source; }
double duration() const { return m_duration; }
@@ -87,12 +92,21 @@ class ShortcutBackend : public QObject {
QString themeAccentForeground() const { return QStringLiteral("black"); }
Q_INVOKABLE bool load(const QUrl &) { return false; }
+ Q_INVOKABLE void clearVideo() {
+ m_source = QUrl();
+ m_duration = 0;
+ m_clips.reset(0);
+ emit infoChanged();
+ }
Q_INVOKABLE void openVideoDialog() { ++openCount; }
Q_INVOKABLE void exportDialog(double start, double end) {
++exportCount;
lastStart = start;
lastEnd = end;
}
+ Q_INVOKABLE void exportTimelineDialog() {
+ exportDialog(0, m_clips.duration());
+ }
Q_INVOKABLE QUrl suggestedExportUrl() const { return {}; }
Q_INVOKABLE void exportClip(const QUrl &, double, double) {}
Q_INVOKABLE void requestThumbs(double start, double end) {
@@ -101,7 +115,7 @@ class ShortcutBackend : public QObject {
lastThumbEnd = end;
}
- void announceInfo() { emit infoChanged(); }
+ void announceInfo() { m_clips.reset(m_duration); emit infoChanged(); }
void announceExportDone() { emit exportDone(QStringLiteral("/tmp/exported.mp4")); }
int openCount = 0;
@@ -123,6 +137,7 @@ class ShortcutBackend : public QObject {
void loadError(const QString &message);
private:
+ ClipModel m_clips;
QUrl m_source;
double m_duration;
};
@@ -150,8 +165,8 @@ static QString mainQmlPath() {
// as the window is in use, so each shortcut test is just the key presses.
class QmlHarness {
public:
- explicit QmlHarness(ShortcutBackend &backend) {
- m_engine.addImageProvider(QStringLiteral("thumbs"), new ThumbProvider);
+ explicit QmlHarness(QObject &backend, ThumbProvider *provider = nullptr) {
+ m_engine.addImageProvider(QStringLiteral("thumbs"), provider ? provider : new ThumbProvider);
m_engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend);
m_engine.load(QUrl::fromLocalFile(mainQmlPath()));
if (!m_engine.rootObjects().isEmpty())
@@ -176,6 +191,7 @@ private slots:
void initTestCase();
void openDialogDelegatesToFilePicker();
void pickerSelectionLoadsVideo();
+ void clearVideoResetsStateAndCanReload();
void thumbnailSlotsAreExposedImmediately();
void thumbProviderUsesRevisionPrefixedIds();
void thumbProviderScalesHeightOnlyRequests();
@@ -188,6 +204,14 @@ private slots:
void exportRefusesRewrittenPathOverExistingFile();
void exportStartFailureClearsBusy();
void failedExportPreservesExistingFile();
+ void concatenatedExport_data();
+ void concatenatedExport();
+ void timelineDialogSnapshotsRanges();
+ void invalidTimelineExportFails();
+ void qmlSplitDeleteAndButtons_data();
+ void qmlSplitDeleteAndButtons();
+ void qmlIndependentHandles();
+ void qmlPreviewSkipsDeletedRange();
void qmlDoesNotCreateAudioOutputWithoutVideo();
void qmlShortcutsTriggerBackendActions();
void qmlArrowKeysMoveThePlayhead();
@@ -286,7 +310,14 @@ void BackendTests::openDialogDelegatesToFilePicker() {
backend.openVideoDialog();
backend.openVideoDialog();
+ QCOMPARE(picker->openCount, 1); // Ignore duplicate requests while modal.
+ QVERIFY(backend.dialogOpen());
+ emit picker->cancelled();
+ QVERIFY(!backend.dialogOpen());
+ backend.openVideoDialog();
QCOMPARE(picker->openCount, 2);
+ emit picker->failed("test failure");
+ QVERIFY(!backend.dialogOpen());
}
void BackendTests::pickerSelectionLoadsVideo() {
@@ -303,6 +334,31 @@ void BackendTests::pickerSelectionLoadsVideo() {
waitForBackgroundWork(backend);
}
+void BackendTests::clearVideoResetsStateAndCanReload() {
+ ThumbProvider provider;
+ Backend backend(&provider, new FakeFilePicker);
+ QVERIFY(backend.load(videoUrl()));
+ const int revision = backend.thumbRevision();
+ // Clear while thumbnail work may still be running.
+ backend.clearVideo();
+ QVERIFY(backend.source().isEmpty());
+ QCOMPARE(backend.duration(), 0.0);
+ QCOMPARE(backend.clips()->count(), 0);
+ QCOMPARE(backend.clips()->selectedIndex(), -1);
+ QCOMPARE(backend.thumbCount(), 0);
+ QCOMPARE(backend.thumbReadyCount(), 0);
+ QVERIFY(backend.thumbRevision() > revision);
+ QVERIFY(backend.suggestedExportUrl().isEmpty());
+ QVERIFY(backend.status().isEmpty());
+ QTest::qWait(100);
+ QCOMPARE(backend.thumbReadyCount(), 0);
+ QVERIFY(backend.status().isEmpty());
+ QVERIFY(backend.load(videoUrl()));
+ QCOMPARE(backend.clips()->count(), 1);
+ QCOMPARE(backend.clips()->selectedIndex(), 0);
+ waitForBackgroundWork(backend);
+}
+
void BackendTests::thumbnailSlotsAreExposedImmediately() {
ThumbProvider provider;
auto *picker = new FakeFilePicker;
@@ -594,6 +650,242 @@ void BackendTests::failedExportPreservesExistingFile() {
QVERIFY(!QFileInfo::exists(outPath + QStringLiteral(".omacut-part.mp4")));
}
+void BackendTests::concatenatedExport_data() {
+ QTest::addColumn("audio");
+ QTest::newRow("silent") << false;
+ QTest::newRow("with-audio") << true;
+}
+
+void BackendTests::concatenatedExport() {
+ QFETCH(bool, audio);
+ const QString src = m_dir.filePath(audio ? "colors-audio.mp4" : "colors-silent.mp4");
+ QStringList args = {"-y", "-v", "error", "-f", "lavfi", "-i", "color=red:s=64x64:r=25:d=1",
+ "-f", "lavfi", "-i", "color=lime:s=64x64:r=25:d=1",
+ "-f", "lavfi", "-i", "color=blue:s=64x64:r=25:d=1"};
+ if (audio) args << "-f" << "lavfi" << "-i" << "sine=frequency=440:duration=3";
+ args << "-filter_complex" << "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v]" << "-map" << "[v]";
+ if (audio) args << "-map" << "3:a" << "-c:a" << "aac";
+ args << "-c:v" << "libx264" << src;
+ QProcess generator;
+ generator.start(ffmpeg::toolPath("ffmpeg"), args);
+ QVERIFY(generator.waitForFinished(15000));
+ QVERIFY2(generator.exitCode() == 0, generator.readAllStandardError().constData());
+ ThumbProvider provider;
+ Backend backend(&provider, new FakeFilePicker);
+ QVERIFY(backend.load(QUrl::fromLocalFile(src)));
+ waitForBackgroundWork(backend);
+ QCOMPARE(ffmpeg::probe(src).hasAudio, audio);
+ QVERIFY(backend.clips()->split(1));
+ QVERIFY(backend.clips()->split(2));
+ backend.clips()->select(1);
+ QVERIFY(backend.clips()->removeSelected());
+ const QString dst = m_dir.filePath(audio ? "joined-audio.mp4" : "joined-silent.mp4");
+ QSignalSpy done(&backend, &Backend::exportDone);
+ QSignalSpy failed(&backend, &Backend::exportFailed);
+ backend.exportRanges(QUrl::fromLocalFile(dst), backend.clips()->snapshot());
+ QTRY_VERIFY_WITH_TIMEOUT(done.count() + failed.count() > 0, 15000);
+ QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first().first().toString()));
+ QCOMPARE(done.count(), 1);
+ const auto info = ffmpeg::probe(dst);
+ QVERIFY(info.ok);
+ QVERIFY(qAbs(info.duration - 2.0) < 0.1);
+ QCOMPARE(info.hasAudio, audio);
+ const auto first = ffmpeg::thumbnail(dst, 0.5);
+ const auto last = ffmpeg::thumbnail(dst, 1.5);
+ QVERIFY(!first.isNull() && !last.isNull());
+ const auto red = first.pixelColor(first.width()/2, first.height()/2);
+ const auto blue = last.pixelColor(last.width()/2, last.height()/2);
+ QVERIFY(red.red() > 200 && red.green() < 50 && red.blue() < 50);
+ QVERIFY(blue.blue() > 200 && blue.red() < 50 && blue.green() < 50);
+}
+
+void BackendTests::timelineDialogSnapshotsRanges() {
+ ThumbProvider provider;
+ auto *picker = new FakeFilePicker;
+ Backend backend(&provider, picker);
+ QVERIFY(backend.load(videoUrl()));
+ waitForBackgroundWork(backend);
+ backend.exportTimelineDialog();
+ QVERIFY(backend.dialogOpen());
+ QVERIFY(!backend.load(videoUrl()));
+ backend.exportTimelineDialog();
+ QCOMPARE(picker->exportCount, 1);
+ // Even programmatic edits cannot change the already-requested export.
+ backend.clips()->removeSelected();
+ QSignalSpy done(&backend, &Backend::exportDone);
+ const auto dst = QUrl::fromLocalFile(m_dir.filePath("snapshot.mp4"));
+ emit picker->exportSelected(dst, 0, 1, 0);
+ QVERIFY(!backend.dialogOpen());
+ QTRY_COMPARE_WITH_TIMEOUT(done.count(), 1, 15000);
+ QVERIFY(ffmpeg::probe(dst.toLocalFile()).duration > 0.9);
+}
+
+void BackendTests::invalidTimelineExportFails() {
+ ThumbProvider provider;
+ Backend backend(&provider, new FakeFilePicker);
+ QVERIFY(backend.load(videoUrl()));
+ waitForBackgroundWork(backend);
+ QSignalSpy failed(&backend, &Backend::exportFailed);
+ const auto dst = QUrl::fromLocalFile(m_dir.filePath("invalid.mp4"));
+ backend.exportRanges(dst, {});
+ backend.exportRanges(dst, {QVariantMap{{"sourceStartSec", -1}, {"sourceEndSec", 1}}});
+ backend.exportRanges(dst, {QVariantMap{{"sourceStartSec", 0}, {"sourceEndSec", 2}}});
+ backend.exportRanges(dst, {QVariantMap{{"sourceStartSec", 0}, {"sourceEndSec", 0.7}},
+ QVariantMap{{"sourceStartSec", 0.5}, {"sourceEndSec", 1}}});
+ QCOMPARE(failed.count(), 4);
+ QVERIFY(!backend.busy());
+ QVERIFY(!QFileInfo::exists(dst.toLocalFile()));
+}
+
+void BackendTests::qmlSplitDeleteAndButtons_data() {
+ QTest::addColumn("deleteKey");
+ QTest::newRow("delete") << int(Qt::Key_Delete);
+ QTest::newRow("backspace") << int(Qt::Key_Backspace);
+}
+
+void BackendTests::qmlSplitDeleteAndButtons() {
+ QFETCH(int, deleteKey);
+ ShortcutBackend backend(QUrl::fromLocalFile(m_dir.filePath("placeholder.mp4")), 30);
+ QmlHarness harness(backend);
+ auto *window = harness.window();
+ QVERIFY(window);
+ backend.announceInfo();
+ window->show(); window->requestActivate(); QTest::qWait(100);
+ auto *bar = harness.trimBar();
+ QVERIFY(bar);
+ auto *split = window->findChild("splitButton");
+ auto *remove = window->findChild("deleteButton");
+ QVERIFY(split && remove);
+ bar->setProperty("playheadSec", 10);
+ QTest::keyClick(window, Qt::Key_T);
+ QCOMPARE(backend.clips()->count(), 2);
+ QCOMPARE(backend.clips()->selectedIndex(), 1);
+ bar->setProperty("playheadSec", 20);
+ QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, itemCenter(split));
+ QCOMPARE(backend.clips()->count(), 3);
+ backend.clips()->select(1);
+ QTest::keyClick(window, Qt::Key(deleteKey));
+ QCOMPARE(backend.clips()->count(), 2);
+ QCOMPARE(backend.clips()->duration(), 20.0);
+ QCOMPARE(bar->property("playheadSec").toDouble(), 20.0);
+ QTest::keyClick(window, Qt::Key_Left);
+ QCOMPARE(bar->property("playheadSec").toDouble(), 9.0);
+ QTest::keyClick(window, Qt::Key_Question);
+ QTest::keyClick(window, Qt::Key_T);
+ QTest::keyClick(window, Qt::Key(deleteKey));
+ QCOMPARE(backend.clips()->count(), 2);
+ QTest::keyClick(window, Qt::Key_Escape);
+ QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, itemCenter(remove));
+ QCOMPARE(backend.clips()->count(), 1);
+ QTest::keyClick(window, Qt::Key(deleteKey));
+ QCOMPARE(backend.clips()->count(), 0);
+ QVERIFY(backend.source().isEmpty());
+ QVERIFY(!window->property("hasVideo").toBool());
+ QVERIFY(!window->property("trimDirty").toBool());
+ QVERIFY(!window->property("audioOutputReady").toBool());
+ QCOMPARE(bar->property("playheadSec").toDouble(), 0.0);
+ QVERIFY(!bar->isVisible());
+ auto *open = window->findChild("openVideoButton");
+ QVERIFY(open && open->isVisible());
+ QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, itemCenter(open));
+ QCOMPARE(backend.openCount, 1);
+ QVERIFY(!split->isEnabled() && !remove->isEnabled());
+ QTest::keyClick(window, Qt::Key_S, Qt::ControlModifier);
+ QCOMPARE(backend.exportCount, 0);
+}
+
+void BackendTests::qmlIndependentHandles() {
+ ShortcutBackend backend(QUrl::fromLocalFile(m_dir.filePath("placeholder.mp4")), 20);
+ QmlHarness harness(backend);
+ auto *window = harness.window();
+ QVERIFY(window);
+ backend.announceInfo();
+ window->show(); window->requestActivate(); QTest::qWait(100);
+ auto *bar = harness.trimBar();
+ bar->setProperty("playheadSec", 10);
+ QTest::keyClick(window, Qt::Key_T);
+ QTest::qWait(100); // Let Row position the new delegates before hit-testing.
+ // Drag the first clip's left handle right; the second range is unchanged.
+ const QPoint a = bar->mapToScene(QPointF(7, bar->height()/2)).toPoint();
+ QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, a);
+ QCOMPARE(backend.clips()->selectedIndex(), 0);
+ QVERIFY(bar->property("trimmingRange").toBool());
+ QTest::mouseMove(window, a + QPoint(35, 0), 30);
+ QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, a + QPoint(35, 0));
+ const auto ranges = backend.clips()->snapshot();
+ QVERIFY(ranges[0].toMap()["sourceStartSec"].toDouble() > 0);
+ QCOMPARE(ranges[0].toMap()["sourceEndSec"].toDouble(), 10.0);
+ QCOMPARE(ranges[1].toMap()["sourceStartSec"].toDouble(), 10.0);
+ QCOMPARE(ranges[1].toMap()["sourceEndSec"].toDouble(), 20.0);
+ QTest::qWait(50);
+ const QPoint b = bar->mapToScene(QPointF(bar->width() - 11, bar->height()/2)).toPoint();
+ QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, b);
+ QCOMPARE(backend.clips()->selectedIndex(), 1);
+ QTest::mouseMove(window, b - QPoint(35, 0), 30);
+ QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, b - QPoint(35, 0));
+ const auto resized = backend.clips()->snapshot();
+ QCOMPARE(resized[0], ranges[0]);
+ QCOMPARE(resized[1].toMap()["sourceStartSec"].toDouble(), 10.0);
+ QVERIFY(resized[1].toMap()["sourceEndSec"].toDouble() < 20.0);
+}
+
+void BackendTests::qmlPreviewSkipsDeletedRange() {
+ const QString src = m_dir.filePath("preview.mp4");
+ QProcess generator;
+ generator.start(ffmpeg::toolPath("ffmpeg"), {"-y", "-v", "error", "-f", "lavfi", "-i",
+ "testsrc2=s=64x64:r=25:d=3", "-c:v", "libx264", src});
+ QVERIFY(generator.waitForFinished(10000));
+ QCOMPARE(generator.exitCode(), 0);
+ auto *provider = new ThumbProvider;
+ Backend backend(provider, new FakeFilePicker);
+ QVERIFY(backend.load(QUrl::fromLocalFile(src)));
+ waitForBackgroundWork(backend);
+ QmlHarness harness(backend, provider);
+ auto *window = harness.window();
+ QVERIFY(window);
+ auto *player = window->findChild("player");
+ QVERIFY(player);
+ QTRY_VERIFY_WITH_TIMEOUT(player->property("primed").toBool(), 5000);
+ QTRY_VERIFY_WITH_TIMEOUT(!player->property("priming").toBool(), 5000);
+ backend.clips()->split(1);
+ backend.clips()->split(2);
+ backend.clips()->select(1);
+ backend.clips()->removeSelected();
+ auto *bar = harness.trimBar();
+ QVERIFY(bar);
+ bar->setProperty("playheadSec", 0);
+ QSignalSpy positions(player, SIGNAL(positionChanged(qint64)));
+ QVERIFY(positions.isValid());
+ QVERIFY(QMetaObject::invokeMethod(window, "togglePlay"));
+ // At the first cut the source position must jump into the final segment.
+ QTRY_VERIFY_WITH_TIMEOUT(player->property("position").toInt() >= 2100, 4000);
+ QCOMPARE(backend.clips()->selectedIndex(), 1);
+ bool jumped = false;
+ for (int i = 1; i < positions.count(); ++i) {
+ if (positions[i][0].toLongLong() - positions[i - 1][0].toLongLong() > 700)
+ jumped = true;
+ }
+ QVERIFY(jumped); // Not merely playing straight through the deleted second.
+ QTRY_VERIFY_WITH_TIMEOUT(player->property("playbackState").toInt() != 1, 4000);
+ QVERIFY(bar->property("playheadSec").toDouble() >= 2.9);
+ QVERIFY(QMetaObject::invokeMethod(window, "deleteClip"));
+ QCOMPARE(backend.clips()->count(), 1);
+ QVERIFY(window->property("hasVideo").toBool());
+ QVERIFY(QMetaObject::invokeMethod(window, "deleteClip"));
+ QVERIFY(backend.source().isEmpty());
+ QCOMPARE(backend.clips()->count(), 0);
+ QVERIFY(!window->property("hasVideo").toBool());
+ QVERIFY(!window->property("trimDirty").toBool());
+ QVERIFY(!window->property("audioOutputReady").toBool());
+ auto *open = window->findChild("openVideoButton");
+ QVERIFY(open && open->isVisible());
+ QVERIFY(backend.load(QUrl::fromLocalFile(src)));
+ QTRY_VERIFY_WITH_TIMEOUT(window->property("hasVideo").toBool(), 3000);
+ QCOMPARE(backend.clips()->count(), 1);
+ QVERIFY(!open->isVisible());
+ waitForBackgroundWork(backend);
+}
+
void BackendTests::qmlDoesNotCreateAudioOutputWithoutVideo() {
ShortcutBackend backend(QUrl(), 0.0);
QmlHarness harness(backend);
diff --git a/tests/backend_tests.pro b/tests/backend_tests.pro
index 11c3aa8..4d7c5a1 100644
--- a/tests/backend_tests.pro
+++ b/tests/backend_tests.pro
@@ -6,6 +6,7 @@ TEMPLATE = app
INCLUDEPATH += ../src
HEADERS += \
+ ../src/clipmodel.h \
../src/backend.h \
../src/ffmpeg.h \
../src/filepicker.h \
@@ -14,6 +15,7 @@ HEADERS += \
../src/thumbworker.h
SOURCES += \
+ ../src/clipmodel.cpp \
backend_tests.cpp \
../src/backend.cpp \
../src/ffmpeg.cpp \
diff --git a/tests/clipmodel_tests.cpp b/tests/clipmodel_tests.cpp
new file mode 100644
index 0000000..bafc3af
--- /dev/null
+++ b/tests/clipmodel_tests.cpp
@@ -0,0 +1,149 @@
+#include
+#include
+#include
+#include "clipmodel.h"
+
+class ClipModelTests : public QObject {
+ Q_OBJECT
+private slots:
+ void resetAndRoles() {
+ ClipModel model;
+ QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
+ QCOMPARE(model.count(), 0);
+ QCOMPARE(model.selectedIndex(), -1);
+ model.reset(30);
+ QCOMPARE(model.count(), 1);
+ QCOMPARE(model.duration(), 30.0);
+ QCOMPARE(model.selectedIndex(), 0);
+ QCOMPARE(model.data(model.index(0), ClipModel::SourceStartRole).toDouble(), 0.0);
+ QCOMPARE(model.data(model.index(0), ClipModel::SourceEndRole).toDouble(), 30.0);
+ QCOMPARE(model.roleNames()[ClipModel::TimelineStartRole], QByteArray("timelineStartSec"));
+ QVERIFY(!model.data({}, ClipModel::LengthRole).isValid());
+ }
+ void repeatedSplits() {
+ ClipModel model;
+ QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
+ model.reset(30);
+ QVERIFY(model.split(10));
+ QVERIFY(model.split(20));
+ QCOMPARE(model.count(), 3);
+ QCOMPARE(model.duration(), 30.0);
+ QCOMPARE(model.selectedIndex(), 2);
+ for (int i = 0; i < 3; ++i) {
+ QCOMPARE(model.data(model.index(i), ClipModel::SourceStartRole).toDouble(), i * 10.0);
+ QCOMPARE(model.data(model.index(i), ClipModel::LengthRole).toDouble(), 10.0);
+ }
+ }
+ void splitEdgesAndInvalidTimes() {
+ ClipModel model;
+ QVERIFY(!model.split(0));
+ model.reset(10);
+ for (double t : {-1.0, 0.0, 0.05, 9.95, 10.0, 11.0,
+ std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()})
+ QVERIFY(!model.split(t));
+ QVERIFY(model.split(0.1));
+ QVERIFY(!model.split(0.1));
+ QCOMPARE(model.count(), 2);
+ }
+ void deletionClosesGapAndMapsTime() {
+ ClipModel model;
+ QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
+ model.reset(30);
+ model.split(10);
+ model.split(20);
+ model.select(1);
+ QVERIFY(model.removeSelected());
+ QCOMPARE(model.duration(), 20.0);
+ QCOMPARE(model.selectedIndex(), 1);
+ QCOMPARE(model.clipAt(9.5), 0);
+ QCOMPARE(model.clipAt(10), 1);
+ QCOMPARE(model.sourceTime(10), 20.0);
+ QCOMPARE(model.sourceTime(15), 25.0);
+ QCOMPARE(model.sourceTime(20), 30.0);
+ QCOMPARE(model.timelineTime(1, 25), 15.0);
+ QCOMPARE(model.timelineTime(1, 15), -1.0);
+ QCOMPARE(model.sourceTime(21), -1.0);
+ QVERIFY(model.split(15));
+ QCOMPARE(model.snapshot()[1].toMap()["sourceEndSec"].toDouble(), 25.0);
+ }
+ void deletingLastClip() {
+ ClipModel model;
+ model.reset(10);
+ model.split(5);
+ QVERIFY(model.removeSelected());
+ QCOMPARE(model.selectedIndex(), 0);
+ QVERIFY(model.removeSelected());
+ QCOMPARE(model.selectedIndex(), -1);
+ QCOMPARE(model.count(), 0);
+ QCOMPARE(model.duration(), 0.0);
+ QCOMPARE(model.clipAt(0), -1);
+ QCOMPARE(model.sourceTime(0), -1.0);
+ QVERIFY(!model.removeSelected());
+ QVERIFY(!model.split(0));
+ }
+ void resizingBoundsAndNeighbors() {
+ ClipModel model;
+ model.reset(30);
+ model.split(10);
+ model.split(20);
+ QVERIFY(!model.resizeClip(1, 9, 20));
+ QVERIFY(!model.resizeClip(1, 10, 21));
+ QVERIFY(!model.resizeClip(0, -1, 10));
+ QVERIFY(!model.resizeClip(2, 20, 31));
+ QVERIFY(!model.resizeClip(1, 15, 15));
+ QVERIFY(!model.resizeClip(1, 16, 15));
+ QVERIFY(!model.resizeClip(1, 15, std::numeric_limits::quiet_NaN()));
+ QVERIFY(!model.resizeClip(-1, 0, 1));
+ QVERIFY(model.resizeClip(1, 12, 18));
+ QCOMPARE(model.duration(), 26.0);
+ QCOMPARE(model.sourceTime(10), 12.0);
+ QCOMPARE(model.sourceTime(16), 20.0);
+ model.select(1);
+ model.removeSelected();
+ QVERIFY(model.resizeClip(0, 0, 15));
+ QCOMPARE(model.duration(), 25.0);
+ }
+ void snapshotIsIndependent() {
+ ClipModel model;
+ model.reset(10);
+ const auto snapshot = model.snapshot();
+ model.split(5);
+ model.removeSelected();
+ QCOMPARE(snapshot.size(), 1);
+ QCOMPARE(snapshot[0].toMap()["sourceEndSec"].toDouble(), 10.0);
+ QCOMPARE(model.snapshot()[0].toMap()["sourceEndSec"].toDouble(), 5.0);
+ }
+ void resetInvalidAndShortSources() {
+ ClipModel model;
+ for (double duration : {-1.0, 0.0, std::numeric_limits::infinity(),
+ std::numeric_limits::quiet_NaN()}) {
+ model.reset(duration);
+ QCOMPARE(model.count(), 0);
+ }
+ model.reset(0.05);
+ QCOMPARE(model.count(), 1);
+ QVERIFY(!model.split(0.025));
+ QVERIFY(model.resizeClip(0, 0, 0.05));
+ model.reset(10);
+ QCOMPARE(model.selectedIndex(), 0);
+ }
+ void selectionAndNotifications() {
+ ClipModel model;
+ QSignalSpy changed(&model, &ClipModel::changed);
+ QSignalSpy selected(&model, &ClipModel::selectionChanged);
+ model.reset(10);
+ QCOMPARE(changed.count(), 1);
+ model.select(99);
+ QCOMPARE(model.selectedIndex(), 0);
+ model.select(-1);
+ QVERIFY(!model.removeSelected());
+ QVERIFY(model.split(5));
+ QCOMPARE(selected.count(), 3);
+ QCOMPARE(changed.count(), 2);
+ QVERIFY(!model.split(5));
+ QCOMPARE(changed.count(), 2);
+ }
+};
+
+QTEST_GUILESS_MAIN(ClipModelTests)
+#include "clipmodel_tests.moc"
diff --git a/tests/clipmodel_tests.pro b/tests/clipmodel_tests.pro
new file mode 100644
index 0000000..699183c
--- /dev/null
+++ b/tests/clipmodel_tests.pro
@@ -0,0 +1,8 @@
+QT += core testlib
+QT -= gui
+CONFIG += c++17 testcase console
+TARGET = clipmodel_tests
+TEMPLATE = app
+INCLUDEPATH += ../src
+HEADERS += ../src/clipmodel.h
+SOURCES += clipmodel_tests.cpp ../src/clipmodel.cpp