Skip to content
Draft
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
36 changes: 36 additions & 0 deletions screencast-gif/BarWidget.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets

NIconButton {
id: root

property ShellScreen screen
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0
property var pluginApi: null

readonly property bool recording: pluginApi?.mainInstance?.recordingActive ?? false
readonly property string screenName: screen?.name ?? ""

baseSize: Style.getCapsuleHeightForScreen(screenName)
applyUiScale: false
customRadius: Style.radiusL
icon: "circle-filled"
tooltipText: recording
? pluginApi?.tr("widget.tooltip.recording")
: pluginApi?.tr("widget.tooltip.idle")
tooltipDirection: BarService.getTooltipDirection(screenName)
colorBg: recording ? Color.mError : Style.capsuleColor
colorFg: recording ? Color.mOnError : Color.mOnSurface
colorBgHover: recording ? Color.mError : Color.mHover
colorFgHover: recording ? Color.mOnError : Color.mOnHover
colorBorder: Style.capsuleBorderColor
colorBorderHover: Style.capsuleBorderColor

onClicked: pluginApi?.mainInstance?.trigger()
}
50 changes: 50 additions & 0 deletions screencast-gif/Main.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import QtQuick
import Quickshell
import Quickshell.Io

Item {
id: root

property var pluginApi: null
property bool recordingActive: false

readonly property string scriptPath: pluginApi?.pluginDir ? pluginApi.pluginDir + "/screencast-gif.sh" : ""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use the following shorter syntax pluginApi?.pluginDir + "/screencast-gif.sh" ?? "" instead.

readonly property var cfg: pluginApi?.pluginSettings || ({})
readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})

function shellQuote(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'"
}

function trigger() {
if (!scriptPath) return
const fps = cfg.fps ?? defaults.fps ?? 20
const maxSecs = cfg.maxRecordingSeconds ?? defaults.maxRecordingSeconds ?? 120
const outDir = cfg.outputDir ?? defaults.outputDir ?? "~/Screenshots"
const env = "FPS=" + fps + " MAX_SECS=" + maxSecs + " OUTDIR=" + shellQuote(outDir)
Quickshell.execDetached(["sh", "-c", env + " " + shellQuote(scriptPath)])
}

Process {
id: checker
running: false
command: ["sh", "-c", "[ -f /tmp/screencast-gif.pid ] && kill -0 \"$(awk '{print $1}' /tmp/screencast-gif.pid 2>/dev/null)\" 2>/dev/null && printf 1 || printf 0"]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to have this script have its own script file? For better debugging and organization.

stdout: StdioCollector {
onStreamFinished: root.recordingActive = (text === "1")
}
onExited: checker.running = false
}

Timer {
interval: 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: checker.running = true
}

IpcHandler {
target: "plugin:screencast-gif"
function toggle() { root.trigger() }
}
}
147 changes: 147 additions & 0 deletions screencast-gif/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Screencast GIF

![Preview](preview.png)

A toggle-style screen recorder for [Hyprland](https://hyprland.org/) /
[Sway](https://swaywm.org/) / any wlroots-based Wayland compositor that:

- selects a screen region with `slurp`,
- records it with `wf-recorder`,
- converts the result to an animated `.gif` with `gifski`,
- copies the GIF straight to the Wayland clipboard (as `image/gif`),
- saves the file alongside in a configurable directory,
- shows a **red pill in the noctalia bar** while recording is active, and
turns back to neutral when the GIF is ready.

Press your hotkey once to pick a region and start recording, press again to
stop and grab the GIF — both from the keyboard and by clicking the pill.

## Why

Most existing Wayland screen recorders either give you a `.mp4` or open a GUI.
There was no off-the-shelf "press hotkey, draw region, get a GIF in clipboard,
with a visible recording indicator" tool — so this plugin glues the best
existing pieces (`wf-recorder` + `gifski`) together and exposes the whole
thing through a noctalia bar widget.

## Requirements

| Tool | Purpose | Arch package |
|---|---|---|
| [`wf-recorder`](https://github.com/ammen99/wf-recorder) | screen capture | `wf-recorder` |
| [`gifski`](https://gif.ski/) | high-quality GIF encoding | `gifski` |
| [`slurp`](https://github.com/emersion/slurp) | region selection | `slurp` |
| `ffmpeg` | extract frames from the captured mp4 | `ffmpeg` |
| `wl-clipboard` | clipboard plumbing (`wl-copy`) | `wl-clipboard` |
| `flock` (util-linux) | invocation locking | `util-linux` |
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this requirement needed? I'm a bit confused.

| `notify-send` | start/end notifications (optional) | `libnotify` |

```bash
sudo pacman -S --needed wf-recorder gifski slurp ffmpeg wl-clipboard libnotify
```

## Usage

### Bar widget

- **Left click** — start/stop recording (same as the hotkey)
- **Right click** — open plugin settings

### Hotkey (Hyprland)

Bind any modifier+key to the plugin's IPC `toggle`:

```ini
bind = SHIFT, XF86Cut, exec, qs -c noctalia-shell ipc call plugin:screencast-gif toggle
```

The first press picks a region with `slurp`. The second press stops the
recorder, runs the GIF conversion, and copies the GIF to your clipboard so
you can paste it straight into Telegram / Discord / Element / etc.

## Settings

| Setting | Default | Notes |
|---|---|---|
| Output directory | `~/Screenshots` | Tilde is expanded. Created if missing. |
| Frame rate | `20` | 15–25 is a good balance between smoothness and file size. |
| Auto-stop after (seconds) | `120` | Safety net so you don't leave it recording forever. `0` disables. |

The settings are passed to the script via environment variables (`FPS`,
`MAX_SECS`, `OUTDIR`), so direct invocations of the script can override them
the same way.

## How it works

`screencast-gif.sh` is a toggle:

- **First call** acquires a lock, asks `slurp` for a region, rounds the
coordinates to the nearest even values (h264 + yuv420p requires this), and
starts `wf-recorder --no-dmabuf -D --pixel-format yuv420p` writing to a
per-invocation directory under `/tmp/screencast-gif/`. The PID and that
directory are written to `/tmp/screencast-gif.pid`. A backgrounded watchdog
re-invokes the script after `MAX_SECS` to enforce the auto-stop.
- **Second call** sees the live PID in the pidfile, releases the lock
immediately (so a third invocation can start a brand-new recording while
conversion is still running), sends `SIGINT` to `wf-recorder`, waits for it
to flush the mp4, then runs `ffmpeg` to extract frames and `gifski` to
encode the GIF, copies it to the clipboard, and saves it to `OUTDIR`.

The QML side (`Main.qml`) polls `/tmp/screencast-gif.pid` once a second to
keep the bar widget's `recordingActive` flag in sync. Cheap, robust, and
independent of the daemon's notification quirks.

### Notable quirks handled

- **`Failed to copy frame too many times` from wf-recorder** — fixed by
passing `--no-dmabuf` (forces CPU buffer copy instead of DMA-BUF).
- **Single-frame GIFs from static regions** — wf-recorder defaults to
damage-tracking and only requests new frames when the screen changes,
which collapses a quiet recording to a single frame. `-D` /
`--no-damage` forces continuous capture.
- **Odd coordinates from slurp on fractional-scale monitors** — rounded to
even values before being passed to `wf-recorder` (h264 + yuv420p won't
encode odd dimensions).
- **`flock` leaked into the recording subprocess** — every backgrounded
child closes inherited fds before exec'ing, so the recorder doesn't
pin the lock and outlive its purpose.
- **Lock held during slow GIF conversion** — released as soon as the
recorder is signalled, so a new recording can start immediately.
- **`wl-copy` keeping test harnesses alive** — `wl-copy` daemonises to
serve the clipboard contents until they're replaced. The daemon
inherits all parent fds; the script strips them before invoking
`wl-copy` so test harnesses (and any pipe-driven caller) can reach EOF.

## Advanced

State file paths can be overridden via env vars (mostly for tests and
scripted automation):

| Env var | Default |
|---|---|
| `SCREENCAST_GIF_PIDFILE` | `/tmp/screencast-gif.pid` |
| `SCREENCAST_GIF_LOCKFILE` | `/tmp/screencast-gif.lock` |
| `SCREENCAST_GIF_WORKDIR` | `/tmp/screencast-gif` |
| `SCREENCAST_GIF_LOG` | `/tmp/screencast-gif.log` |
| `SCREENCAST_GIF_REGION` | (unset — fall back to `slurp`) |

The bar widget's polling assumes the defaults, so changing the pidfile
location will hide ongoing recordings from the bar pill — only do it
for tests or one-off scripted recordings.

## Development

Source, issue tracker, and a bats-core test suite live at
[github.com/mewmewmemw/noctalia-screencast-gif](https://github.com/mewmewmemw/noctalia-screencast-gif).

## License

MIT.

## Credits

- [wf-recorder](https://github.com/ammen99/wf-recorder) by Ilia Bozhinov
- [gifski](https://github.com/ImageOptim/gifski) by Kornel Lesiński
- The [screen-shot-and-record](https://github.com/noctalia-dev/noctalia-plugins/tree/main/screen-shot-and-record)
noctalia plugin, which served as the reference for the bar-widget
recording-state pattern.
51 changes: 51 additions & 0 deletions screencast-gif/Settings.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets

ColumnLayout {
id: root

property var pluginApi: null

property var cfg: pluginApi?.pluginSettings || ({})
property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})

property string valueOutputDir: cfg.outputDir ?? defaults.outputDir
property int valueFps: cfg.fps ?? defaults.fps
property int valueMaxRecordingSeconds: cfg.maxRecordingSeconds ?? defaults.maxRecordingSeconds

spacing: Style.marginL

NTextInput {
Layout.fillWidth: true
label: pluginApi?.tr("settings.outputDir.label")
description: pluginApi?.tr("settings.outputDir.description")
text: root.valueOutputDir
onTextChanged: root.valueOutputDir = text
}

NTextInput {
Layout.fillWidth: true
label: pluginApi?.tr("settings.fps.label")
description: pluginApi?.tr("settings.fps.description")
text: String(root.valueFps)
onTextChanged: root.valueFps = parseInt(text) || 20
}

NTextInput {
Layout.fillWidth: true
label: pluginApi?.tr("settings.maxRecordingSeconds.label")
description: pluginApi?.tr("settings.maxRecordingSeconds.description")
text: String(root.valueMaxRecordingSeconds)
onTextChanged: root.valueMaxRecordingSeconds = parseInt(text) || 0
}

function saveSettings() {
if (!pluginApi) return
pluginApi.pluginSettings.outputDir = root.valueOutputDir
pluginApi.pluginSettings.fps = root.valueFps
pluginApi.pluginSettings.maxRecordingSeconds = root.valueMaxRecordingSeconds
pluginApi.saveSettings()
}
}
22 changes: 22 additions & 0 deletions screencast-gif/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"widget": {
"tooltip": {
"idle": "Record GIF",
"recording": "Recording GIF — click to stop"
}
},
"settings": {
"outputDir": {
"label": "Output directory",
"description": "Where to save the .gif files. Leading ~ is expanded to $HOME."
},
"fps": {
"label": "Frame rate",
"description": "GIF frame rate. 15-25 is a good balance between smoothness and file size."
},
"maxRecordingSeconds": {
"label": "Auto-stop after (seconds)",
"description": "Maximum recording duration before auto-stopping. Use 0 to disable."
}
}
}
33 changes: 33 additions & 0 deletions screencast-gif/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"id": "screencast-gif",
"name": "Screencast GIF",
"version": "0.1.0",
"minNoctaliaVersion": "4.6.6",
"author": "mewmewmemw",
"license": "MIT",
"repository": "https://github.com/noctalia-dev/noctalia-plugins",
"description": "Hotkey-driven region screencast that produces an animated GIF and copies it to the clipboard. Bar pill turns red while recording. Backed by wf-recorder + gifski.",
"tags": [
"Bar",
"Recording",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tag doesn't exist

"Hyprland",
"Sway",
"GIF",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tag doesn't exist either.

"Utility"
],
"entryPoints": {
"main": "Main.qml",
"barWidget": "BarWidget.qml",
"settings": "Settings.qml"
},
"dependencies": {
"plugins": []
},
"metadata": {
"defaultSettings": {
"fps": 20,
"maxRecordingSeconds": 120,
"outputDir": "~/Screenshots"
}
}
}
Binary file added screencast-gif/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading