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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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 **length** trimmer. Open a video — or paste a link to one — 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.

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.

Expand All @@ -16,6 +16,7 @@ Built using **Qt Quick (QML)** UI with the Material style — the same Qt stack
- *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+O*: Open a new file to trim.
- *Ctrl+V*: Paste a link and trim the video behind it.
- *Ctrl+S*: Export the current trim.
- *Q*: Quit (asks first if the trim hasn't been exported).
- *?*: Show the hotkeys in the app.
Expand All @@ -28,9 +29,16 @@ Install via the Omarchy Package Repository via the `omacut` package. It's instal

- `xdg-desktop-portal` and a portal backend for the file picker
- `ffmpeg` and `ffprobe` on your PATH (used at runtime)
- `yt-dlp` on your PATH, but only to open links

Exports are always written as MP4 files, regardless of the input video's container. The export dialog offers Original/1080p/720p quality — never upscaling, and always preserving the aspect ratio.

## Links

Copy a video's address and press *Ctrl+V* to trim it without downloading it yourself first. omacut hands the link to `yt-dlp`, which fetches the video into `~/.cache/omacut/downloads`, and it opens for trimming as soon as it lands. Pasting the same link again reuses what's already there.

Links come down at up to 1080p, preferring H.264 — trimming is a length edit, not a mastering job, and that's the codec exports are re-encoded to anyway. Because a downloaded source is a cache entry rather than a file you keep, its export is suggested in your videos folder instead of next to it.

## Build

Uses Qt's own build tool, `qmake6` (no cmake needed):
Expand Down
2 changes: 2 additions & 0 deletions omacut.pro
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ HEADERS += \
src/filepicker.h \
src/portalfilepicker.h \
src/ffmpeg.h \
src/ytdlp.h \
src/thumbworker.h \
src/thumbprovider.h \
src/backend.h
Expand All @@ -16,6 +17,7 @@ SOURCES += \
src/main.cpp \
src/portalfilepicker.cpp \
src/ffmpeg.cpp \
src/ytdlp.cpp \
src/thumbworker.cpp \
src/thumbprovider.cpp \
src/backend.cpp
Expand Down
1 change: 1 addition & 0 deletions pkgbuild/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ url='https://github.com/omacom-io/omacut'
license=('MIT')
install='omacut.install'
depends=('ffmpeg' 'qt6-base' 'qt6-declarative' 'qt6-multimedia' 'xdg-desktop-portal')
optdepends=('yt-dlp: open videos straight from a link')
makedepends=('gcc' 'make' 'qt6-base' 'qt6-declarative' 'qt6-multimedia')
source=()
sha256sums=()
Expand Down
20 changes: 19 additions & 1 deletion src/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,13 @@ ApplicationWindow {
onActivated: openVideo()
}

Shortcut {
sequence: "Ctrl+V"
context: Qt.ApplicationShortcut
enabled: !win.quitConfirmVisible && !backend.busy
onActivated: backend.openClipboardLink()
}

Shortcut {
sequence: "Q"
context: Qt.ApplicationShortcut
Expand Down Expand Up @@ -513,6 +520,16 @@ ApplicationWindow {
}
onClicked: openVideo()
}

Label {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: openVideoButton.bottom
anchors.topMargin: 16
visible: !win.hasVideo
text: "or press Ctrl+V to paste a video link"
color: "#7a7a80"
font.pixelSize: 13
}
}

// --- timeline ---
Expand Down Expand Up @@ -554,7 +571,7 @@ ApplicationWindow {

// --- status line ---
Item {
visible: win.hasVideo
visible: win.hasVideo || win.statusText !== ""
Layout.fillWidth: true
Layout.preferredHeight: 26

Expand Down Expand Up @@ -651,6 +668,7 @@ ApplicationWindow {
{ keys: "Alt Space", action: "Trim end to playhead" },
{ keys: "Z", action: "Zoom the selection" },
{ keys: "Ctrl O", action: "Open a video" },
{ keys: "Ctrl V", action: "Paste a video link" },
{ keys: "Ctrl S", action: "Export" },
{ keys: "Q", action: "Quit" },
{ keys: "?", action: "Show these shortcuts" }
Expand Down
194 changes: 188 additions & 6 deletions src/backend.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
#include "backend.h"

#include <QClipboard>
#include <QColor>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QProcess>
#include <QStandardPaths>
#include <QTextStream>

#include <cstdio>
Expand All @@ -14,10 +17,16 @@
#include "portalfilepicker.h"
#include "thumbprovider.h"
#include "thumbworker.h"
#include "ytdlp.h"

namespace {
constexpr int kThumbCount = 12;
constexpr int kThumbRevealMs = 70;
// Trimming is a length edit, not a mastering job, so a link is fetched at a
// sane size rather than at whatever 4K the source happens to offer.
constexpr int kMaxDownloadHeight = 1080;
// How long a killed download gets to die before the app stops waiting on it.
constexpr int kDownloadStopMs = 2000;
const QString kDefaultAccent = QStringLiteral("#FFD60A");

QString omarchyCurrentDir() {
Expand All @@ -39,6 +48,43 @@ QString mp4PathFor(const QString &path) {
return file.dir().filePath(baseName + QStringLiteral(".mp4"));
}

// Downloads are re-fetchable, so they belong in the cache rather than in the
// user's own folders.
QString downloadDir() {
const QString base = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
if (base.isEmpty())
return {};
const QString dir = base + QStringLiteral("/downloads");
return QDir().mkpath(dir) ? dir : QString();
}

// Somewhere the user will actually look for a finished clip.
QString videosDir() {
for (const QStandardPaths::StandardLocation location : {QStandardPaths::MoviesLocation,
QStandardPaths::DownloadLocation,
QStandardPaths::HomeLocation}) {
const QString dir = QStandardPaths::writableLocation(location);
if (!dir.isEmpty() && QFileInfo(dir).isWritable())
return dir;
}
return QDir::homePath();
}

QString pathFromFile(const QString &pathFile) {
QFile file(pathFile);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return {};

QString last;
QTextStream in(&file);
while (!in.atEnd()) {
const QString line = in.readLine().trimmed();
if (!line.isEmpty())
last = line;
}
return last;
}

bool replaceWithTemp(const QString &tmpPath, const QString &outPath) {
const QByteArray tmpName = QFile::encodeName(tmpPath);
const QByteArray outName = QFile::encodeName(outPath);
Expand Down Expand Up @@ -71,6 +117,7 @@ Backend::Backend(ThumbProvider *provider, FilePicker *filePicker, QObject *paren
}

Backend::~Backend() {
stopDownload();
stopThumbs();
}

Expand Down Expand Up @@ -157,7 +204,14 @@ void Backend::watchTheme() {
}

bool Backend::load(const QUrl &url) {
const QString path = url.toLocalFile();
return loadPath(url.toLocalFile(), false);
}

bool Backend::loadPath(const QString &path, bool fromLink) {
// Opening a file by hand abandons whatever a link was still fetching.
if (!fromLink)
stopDownload();

const ffmpeg::VideoInfo info = ffmpeg::probe(path);
if (!info.ok) {
emit loadError(info.error);
Expand All @@ -166,7 +220,8 @@ bool Backend::load(const QUrl &url) {

m_info = info;
m_path = path;
m_source = url;
m_source = QUrl::fromLocalFile(path);
m_sourceIsDownload = fromLink;

// New video: drop the old filmstrip and bump the revision so QML reloads.
stopThumbs();
Expand All @@ -189,6 +244,28 @@ bool Backend::load(const QUrl &url) {
return true;
}

void Backend::openClipboardLink() {
const QString text = QGuiApplication::clipboard()->text().trimmed();
if (text.isEmpty()) {
emit loadError(QStringLiteral("The clipboard is empty. Copy a video link first."));
return;
}
openLink(text);
}

void Backend::openLink(const QString &text) {
const QString link = text.trimmed();
if (!ytdlp::isLink(link)) {
emit loadError(QStringLiteral("That isn't a video link."));
return;
}
// An export in flight owns the status line and the ffmpeg it's driving.
if (m_busy)
return;

startDownload(link);
}

void Backend::openVideoDialog() {
m_filePicker->openVideo();
}
Expand All @@ -211,6 +288,106 @@ QList<int> Backend::exportHeights(int width, int height) {
return heights;
}

void Backend::startDownload(const QString &url) {
const QString tool = ytdlp::toolPath();
if (tool.isEmpty()) {
emit loadError(QStringLiteral("`yt-dlp` was not found on your PATH. "
"Install yt-dlp to open links."));
return;
}

const QString dir = downloadDir();
if (dir.isEmpty()) {
emit loadError(QStringLiteral("Could not create a folder to download into."));
return;
}

stopDownload();

// yt-dlp writes the finished file's path here. A leftover from an earlier
// fetch would otherwise be read back as this download's result.
const QString pathFile = dir + QStringLiteral("/.omacut-filepath");
QFile::remove(pathFile);

setBusy(true);
setStatus(QStringLiteral("Fetching..."));

auto *proc = new QProcess(this);
m_download = proc;
auto completed = std::make_shared<bool>(false);
auto progressBuf = std::make_shared<QByteArray>();

connect(proc, &QProcess::readyReadStandardOutput, this, [this, proc, progressBuf] {
progressBuf->append(proc->readAllStandardOutput());
int newline;
while ((newline = progressBuf->indexOf('\n')) >= 0) {
const QString line = QString::fromUtf8(progressBuf->left(newline)).trimmed();
progressBuf->remove(0, newline + 1);

const double percent = ytdlp::percentFromProgress(line);
if (percent >= 0.0)
setStatus(QStringLiteral("Downloading %1%").arg(qRound(percent)));
else if (line.startsWith(QLatin1String("[Merger]")))
setStatus(QStringLiteral("Merging..."));
}
});

connect(proc, &QProcess::finished, this,
[this, proc, pathFile, completed](int code, QProcess::ExitStatus exitStatus) {
if (*completed)
return;
*completed = true;
const QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
m_download = nullptr;
proc->deleteLater();
setBusy(false);
setStatus(QString());

if (exitStatus != QProcess::NormalExit || code != 0) {
emit loadError(ytdlp::errorFrom(err));
return;
}

const QString path = pathFromFile(pathFile);
if (path.isEmpty()) {
emit loadError(QStringLiteral("yt-dlp did not report a downloaded file."));
return;
}
loadPath(path, true);
});

connect(proc, &QProcess::errorOccurred, this,
[this, proc, completed](QProcess::ProcessError error) {
if (error != QProcess::FailedToStart || *completed)
return;
*completed = true;
const QString err = proc->errorString();
m_download = nullptr;
proc->deleteLater();
setBusy(false);
setStatus(QString());
emit loadError(err.isEmpty() ? QStringLiteral("Could not start yt-dlp.") : err);
});

proc->start(tool, ytdlp::downloadArgs(url, dir, pathFile, kMaxDownloadHeight));
}

void Backend::stopDownload() {
if (!m_download)
return;

QProcess *proc = m_download;
m_download = nullptr;
// Drop the handlers first: killing the child fires finished(), which would
// otherwise report the abandoned download as a failure.
proc->disconnect(this);
proc->kill();
proc->waitForFinished(kDownloadStopMs);
proc->deleteLater();
setBusy(false);
setStatus(QString());
}

void Backend::startThumbs() {
auto *worker = new ThumbWorker(m_path, m_thumbStart, m_thumbLen, kThumbCount);
m_thumbWorker = worker;
Expand Down Expand Up @@ -307,11 +484,16 @@ void Backend::requestThumbs(double start, double end) {
}

QUrl Backend::suggestedExportUrl() const {
if (m_path.isEmpty())
return suggestedExportUrlFor(m_path, m_sourceIsDownload, videosDir());
}

QUrl Backend::suggestedExportUrlFor(const QString &sourcePath, bool sourceIsDownload,
const QString &downloadsTarget) {
if (sourcePath.isEmpty())
return {};
const QFileInfo src(m_path);
const QString target = src.dir().filePath(src.completeBaseName() + "_trimmed.mp4");
return QUrl::fromLocalFile(target);
const QFileInfo src(sourcePath);
const QDir dir = sourceIsDownload ? QDir(downloadsTarget) : src.dir();
return QUrl::fromLocalFile(dir.filePath(src.completeBaseName() + "_trimmed.mp4"));
}

void Backend::exportClip(const QUrl &dst, double start, double end, int scaleHeight) {
Expand Down
Loading