-
Notifications
You must be signed in to change notification settings - Fork 241
feat(screencast-gif): add hotkey-driven region screencast → GIF plugin #766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| } |
| 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" : "" | ||
| 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"] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| # Screencast GIF | ||
|
|
||
|  | ||
|
|
||
| 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` | | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
| 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() | ||
| } | ||
| } |
| 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." | ||
| } | ||
| } | ||
| } |
| 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", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This tag doesn't exist |
||
| "Hyprland", | ||
| "Sway", | ||
| "GIF", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.