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