Skip to content
Merged
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
71 changes: 71 additions & 0 deletions region-recorder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Region Recorder

A minimal, high-performance Noctalia plugin for region screen recording with interactive `slurp` selection.

## Plugin

| Field | Value |
| --- | --- |
| ID | `h-jangra/region-recorder` |
| Entries | Bar widget: `widget`; shortcut: `toggle`; service: `service` |

## Requirements

- `slurp`
- At least one recorder engine: `gpu-screen-recorder`, `wl-screenrec`, or `wf-recorder`
- `ffmpeg`

## Usage

### Bar Widget & Shortcut

- **Bar Widget (`widget`)**: Add `h-jangra/region-recorder:widget` to your bar items in Noctalia settings to select a region and start recording.
- **Left Click**: Toggle region selection / recording.
- **Right Click**: Start full-screen recording directly or stop active recording.
- **Control Center Shortcut (`toggle`)**: Add `h-jangra/region-recorder:toggle` to your control center quick tiles for one-tap region recording.
- **Left Click**: Toggle region recording mode.
- **Right Click**: Start full-screen recording or stop active recording.

### IPC Commands

Control the region recorder service directly via IPC:

```sh
# Toggle region recording
noctalia msg plugin h-jangra/region-recorder:service all toggle

# Start region selection recording explicitly
noctalia msg plugin h-jangra/region-recorder:service all select-region

# Start fullscreen recording explicitly
noctalia msg plugin h-jangra/region-recorder:service all record-fullscreen

# Stop active recording
noctalia msg plugin h-jangra/region-recorder:service all stop
```

## Settings

Configure Region Recorder in **Noctalia Settings → Plugins → Region Recorder**:

| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `video_source` | `select` | `region` | Default source mode for recording (`region`, `focused`, `portal`). |
| `directory` | `folder` | `~/Videos/Recordings` | Directory where recorded video files are saved. |
| `filename_pattern` | `string` | `recording_%Y%m%d_%H%M%S` | Strftime format string for output file names. |
| `frame_rate` | `int` | `60` | Target framerate (FPS) for recording (`1` – `240`). |
| `framerate_mode` | `select` | `cfr` | Framerate mode (`cfr`, `vfr`, `content`). `cfr` (Constant Frame Rate) is editor-friendly and required by editors like Kdenlive. |
| `video_codec` | `select` | `h264` | Video encoding codec (`h264`, `hevc`, `av1`). |
| `audio_source` | `select` | `none` | Audio stream to record (`none`, `default_output`, `default_input`, `both`). |
| `show_cursor` | `bool` | `true` | Include mouse cursor in the screen recording. |
| `copy_to_clipboard` | `bool` | `false` | Copy `file://` path URI to clipboard when recording completes. |
| `hide_inactive` | `bool` | `false` | Hide the bar widget when not actively selecting or recording. |

## Notes

- **Process Management**: Recording processes receive `SIGINT` on stop to ensure MP4 video files are properly finalized and playable.
- **Window Snapping**: When `slurp` is invoked on Hyprland or Sway, window boundaries are queried via `hyprctl` / `swaymsg` to enable snapping to individual windows.

## License

MIT © [Himanshu Jangra](https://github.com/h-jangra)
115 changes: 115 additions & 0 deletions region-recorder/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
id = "h-jangra/region-recorder"
name = "Region Recorder"
version = "0.1.0"
plugin_api = 24
author = "h-jangra"
license = "MIT"
dependencies = ["slurp", "gpu-screen-recorder", "wl-screenrec", "wf-recorder", "ffmpeg"]
tags = ["recording", "video", "utility", "bar", "shortcut", "service"]
icon = "crop"
description = "Minimal region screen recorder for Noctalia."

[[setting]]
key = "video_source"
type = "select"
label_key = "settings.video_source.label"
description_key = "settings.video_source.description"
default = "region"
options = [
{ value = "region", label_key = "settings.video_source.options.region" },
{ value = "focused", label_key = "settings.video_source.options.focused" },
{ value = "portal", label_key = "settings.video_source.options.portal" },
]

[[setting]]
key = "directory"
type = "folder"
label_key = "settings.directory.label"
description_key = "settings.directory.description"
default = "~/Videos/Recordings"

[[setting]]
key = "filename_pattern"
type = "string"
label_key = "settings.filename_pattern.label"
description_key = "settings.filename_pattern.description"
default = "recording_%Y%m%d_%H%M%S"

[[setting]]
key = "frame_rate"
type = "int"
label_key = "settings.frame_rate.label"
description_key = "settings.frame_rate.description"
default = 60
min = 1
max = 240

[[setting]]
key = "framerate_mode"
type = "select"
label_key = "settings.framerate_mode.label"
description_key = "settings.framerate_mode.description"
default = "cfr"
options = [
{ value = "cfr", label_key = "settings.framerate_mode.options.cfr" },
{ value = "vfr", label_key = "settings.framerate_mode.options.vfr" },
{ value = "content", label_key = "settings.framerate_mode.options.content" },
]

[[setting]]
key = "video_codec"
type = "select"
label_key = "settings.video_codec.label"
description_key = "settings.video_codec.description"
default = "h264"
options = [
{ value = "h264", label_key = "settings.video_codec.options.h264" },
{ value = "hevc", label_key = "settings.video_codec.options.hevc" },
{ value = "av1", label_key = "settings.video_codec.options.av1" },
]

[[setting]]
key = "audio_source"
type = "select"
label_key = "settings.audio_source.label"
description_key = "settings.audio_source.description"
default = "none"
options = [
{ value = "none", label_key = "settings.audio_source.options.none" },
{ value = "default_output", label_key = "settings.audio_source.options.default_output" },
{ value = "default_input", label_key = "settings.audio_source.options.default_input" },
{ value = "both", label_key = "settings.audio_source.options.both" },
]

[[setting]]
key = "show_cursor"
type = "bool"
label_key = "settings.show_cursor.label"
description_key = "settings.show_cursor.description"
default = true

[[setting]]
key = "copy_to_clipboard"
type = "bool"
label_key = "settings.copy_to_clipboard.label"
description_key = "settings.copy_to_clipboard.description"
default = false

[[setting]]
key = "hide_inactive"
type = "bool"
label_key = "settings.hide_inactive.label"
description_key = "settings.hide_inactive.description"
default = false

[[service]]
id = "service"
entry = "service.luau"

[[widget]]
id = "widget"
entry = "widget.luau"

[[shortcut]]
id = "toggle"
entry = "shortcut.luau"
14 changes: 14 additions & 0 deletions region-recorder/scripts/get_windows.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/sh
if command -v hyprctl >/dev/null 2>&1; then
if command -v jq >/dev/null 2>&1; then
hyprctl clients -j 2>/dev/null | jq -r '.[] | select(.mapped == true and .hidden == false) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"'
elif command -v python3 >/dev/null 2>&1; then
hyprctl clients -j 2>/dev/null | python3 -c 'import sys, json; [print(f"{c[\"at\"][0]},{c[\"at\"][1]} {c[\"size\"][0]}x{c[\"size\"][1]}") for c in json.load(sys.stdin) if c.get("mapped") and not c.get("hidden")]'
fi
elif command -v swaymsg >/dev/null 2>&1; then
if command -v jq >/dev/null 2>&1; then
swaymsg -t get_tree 2>/dev/null | jq -r '.. | select(.pid? and .visible? and .rect?) | "\(.rect.x),\(.rect.y) \(.rect.width)x\(.rect.height)"'
elif command -v python3 >/dev/null 2>&1; then
swaymsg -t get_tree 2>/dev/null | python3 -c 'import sys, json; fn=lambda n: [print(f"{n[\"rect\"][\"x\"]},{n[\"rect\"][\"y\"]} {n[\"rect\"][\"width\"]}x{n[\"rect\"][\"height\"]}") if isinstance(n, dict) and n.get("pid") and n.get("visible") and "rect" in n else None, [fn(v) for v in (n.values() if isinstance(n, dict) else n if isinstance(n, list) else [])]]; fn(json.load(sys.stdin))'
fi
fi
189 changes: 189 additions & 0 deletions region-recorder/service.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
--!nonstrict
-- Region Recorder — Headless Background Service

noctalia.setUpdateInterval(250)

local state = "idle" -- idle | selecting | recording
local isAvailable = false
local outputPath = ""
local recordingProcessName = ""
local tickCount = 0
local CHECK_TICKS = 8

local function cfg(key) return noctalia.getConfig(key) end
local function log(msg) noctalia.log(`region_recorder: {msg}`) end
local function shellQuote(val) return "'" .. tostring(val):gsub("'", [["'"']]) .. "'" end

local function publishStatus()
noctalia.state.set("status", { state = state, available = isAvailable, outputPath = outputPath })
end

local function setState(nextState)
if state == nextState then return end
state = nextState
publishStatus()
end

local function checkAvailability()
local hasSlurp = noctalia.commandExists("slurp")
local hasGSR = noctalia.commandExists("gpu-screen-recorder")
local hasWl = noctalia.commandExists("wl-screenrec")
local hasWf = noctalia.commandExists("wf-recorder")
return hasSlurp and (hasGSR or hasWl or hasWf)
end

local function outputDirectory()
local configured = cfg("directory")
local dir = noctalia.expandPath(if configured == "" or configured == nil then "~/Videos/Recordings" else configured)
if dir:sub(-1) ~= "/" then dir ..= "/" end
noctalia.mkdirAll(dir)
return dir
end

local function copyToClipboard(path)
local escapedPath = path:gsub(" ", "%%20"):gsub("'", "%%27"):gsub('"', "%%22")
noctalia.copyToClipboard(`file://{escapedPath}`, "text/uri-list")
end

local function buildRecordCommand(geom, targetPath)
local fps = cfg("frame_rate") or 60
local framerateMode = cfg("framerate_mode") or "cfr"
local codec = cfg("video_codec") or "h264"
local cursor = if cfg("show_cursor") then "yes" else "no"
local audioSource = cfg("audio_source") or "none"

if noctalia.commandExists("gpu-screen-recorder") then
recordingProcessName = "gpu-screen-recorder"
local audioFlags = ""
if audioSource == "both" then audioFlags = '-a "default_output|default_input" -ac aac'
elseif audioSource == "default_output" then audioFlags = "-a default_output -ac aac"
elseif audioSource == "default_input" then audioFlags = "-a default_input -ac aac"
end

local targetFlag = "-w screen"
if geom and geom ~= "" and geom ~= "screen" then
local gx, gy, gw, gh = tostring(geom):match("^(%d+),(%d+) (%d+)x(%d+)$")
if gx and gy and gw and gh then targetFlag = string.format("-w %sx%s+%s+%s", gw, gh, gx, gy) end
end

return string.format("gpu-screen-recorder %s -f %d -fm %s -k %s -cursor %s -cr limited %s -v no -o %s", targetFlag, fps, framerateMode, codec, cursor, audioFlags, shellQuote(targetPath))
elseif noctalia.commandExists("wl-screenrec") then
recordingProcessName = "wl-screenrec"
local wlCodec = if codec == "h264" then "avc" else codec
local parts = { "wl-screenrec", string.format("-f %s", shellQuote(targetPath)), string.format("-m %d", fps), string.format("--codec %s", wlCodec) }
if framerateMode == "cfr" then table.insert(parts, "--no-damage") end
if geom and geom ~= "" and geom ~= "screen" then table.insert(parts, string.format("-g %s", shellQuote(geom))) end
if not cfg("show_cursor") then table.insert(parts, "--no-cursor") end
if audioSource ~= "none" then table.insert(parts, "--audio") end
return table.concat(parts, " ")
elseif noctalia.commandExists("wf-recorder") then
recordingProcessName = "wf-recorder"
local parts = { "wf-recorder", string.format("-f %s", shellQuote(targetPath)), string.format("-r %d", fps), string.format("-c %s", codec) }
if geom and geom ~= "" and geom ~= "screen" then table.insert(parts, string.format("-g %s", shellQuote(geom))) end
if audioSource ~= "none" then table.insert(parts, "-a") end
return table.concat(parts, " ")
end
return ""
end

local function startRecordingWithGeometry(geom)
local dir = outputDirectory()
local pattern = cfg("filename_pattern") or "recording_%Y%m%d_%H%M%S"
local filename = `{noctalia.formatTime(pattern)}.mp4`
outputPath = `{dir}{filename}`

local cmd = buildRecordCommand(geom, outputPath)
if cmd == "" then
setState("idle")
noctalia.notifyError(noctalia.tr("notify.recording_failed"), noctalia.tr("notify.no_recorder"))
return
end

log(`executing recorder: {cmd}`)
if noctalia.runAsync(cmd) then
setState("recording")
else
setState("idle")
outputPath = ""
noctalia.notifyError(noctalia.tr("notify.recording_failed"))
end
end

local function startSlurpSelection()
if not isAvailable or state ~= "idle" then return end
setState("selecting")
log("starting slurp region selection...")

local scriptPath = `{noctalia.pluginDir()}/scripts/get_windows.sh`
local slurpCmd = string.format("sleep 0.2 && ( sh '%s' 2>/dev/null | slurp -f '%%x,%%y %%wx%%h' )", scriptPath)

noctalia.runAsync(slurpCmd, function(res)
if state ~= "selecting" then return end
if res.exitCode == 0 and res.stdout and res.stdout:match("%d+,%d+ %d+x%d+") then
local geom = res.stdout:match("%d+,%d+ %d+x%d+")
log(`slurp selected region: {geom}`)
startRecordingWithGeometry(geom)
else
log("slurp selection cancelled or empty")
setState("idle")
end
end)
end

local function stopRecording()
if state ~= "recording" then return end
log(`stopping recording process for {outputPath}`)
local targetEscaped = shellQuote(outputPath)
noctalia.runAsync(string.format("pkill -SIGINT -f %s 2>/dev/null || pkill -SIGINT -f %s 2>/dev/null || true", targetEscaped, shellQuote(recordingProcessName)))

local savedPath = outputPath
setState("idle")
noctalia.runAsync(string.format("sleep 0.5; test -s %s", targetEscaped), function(res)
if res.exitCode == 0 then
log(`recording saved successfully: {savedPath}`)
noctalia.notify(noctalia.tr("notify.recording_saved"):format(savedPath), savedPath)
if cfg("copy_to_clipboard") then copyToClipboard(savedPath) end
else
log("recording process stopped, file empty or missing")
end
outputPath = ""
end)
end

function update()
tickCount += 1
if state == "recording" and tickCount % CHECK_TICKS == 0 and outputPath ~= "" then
noctalia.processMatches(function(matched)
if not matched and state == "recording" then
log("recording process exited externally")
stopRecording()
end
end, recordingProcessName, outputPath)
end
end

local function handleCommand(cmd)
local action = if type(cmd) == "table" and cmd.action ~= nil then cmd.action else cmd
if type(action) ~= "string" then return end

if action == "start" or action == "select-region" then
if (cfg("video_source") or "region") == "region" or action == "select-region" then startSlurpSelection() else startRecordingWithGeometry("screen") end
elseif action == "record-fullscreen" then
startRecordingWithGeometry("screen")
elseif action == "stop" then
stopRecording()
elseif action == "toggle" then
if state == "recording" then stopRecording()
elseif state == "idle" then
if (cfg("video_source") or "region") == "region" then startSlurpSelection() else startRecordingWithGeometry("screen") end
end
end
end

function onIpc(event, payload)
handleCommand(if type(payload) == "table" and payload.action ~= nil then payload else event)
end

isAvailable = checkAvailability()
publishStatus()
noctalia.state.watch("command", handleCommand)
Loading
Loading