From 9b763743bdc6ef531301c534cd75cd7446d8292d Mon Sep 17 00:00:00 2001 From: Joao Porta Date: Thu, 30 Jul 2026 16:04:29 -0300 Subject: [PATCH] gif --- flake.lock | 42 ++++---- modules/home-manager/gif-recorder/default.nix | 96 +++++++++++++++++++ modules/home-manager/hyprland/default.nix | 1 + .../2026-07-30-gif-recorder/.openspec.yaml | 2 + .../archive/2026-07-30-gif-recorder/design.md | 64 +++++++++++++ .../2026-07-30-gif-recorder/proposal.md | 31 ++++++ .../specs/gif-recorder/spec.md | 49 ++++++++++ .../archive/2026-07-30-gif-recorder/tasks.md | 18 ++++ openspec/specs/gif-recorder/spec.md | 55 +++++++++++ 9 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 modules/home-manager/gif-recorder/default.nix create mode 100644 openspec/changes/archive/2026-07-30-gif-recorder/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-30-gif-recorder/design.md create mode 100644 openspec/changes/archive/2026-07-30-gif-recorder/proposal.md create mode 100644 openspec/changes/archive/2026-07-30-gif-recorder/specs/gif-recorder/spec.md create mode 100644 openspec/changes/archive/2026-07-30-gif-recorder/tasks.md create mode 100644 openspec/specs/gif-recorder/spec.md diff --git a/flake.lock b/flake.lock index 2cc6a0a..0be4ec0 100644 --- a/flake.lock +++ b/flake.lock @@ -70,11 +70,11 @@ "uv2nix": "uv2nix" }, "locked": { - "lastModified": 1784633374, - "narHash": "sha256-kaRIIOZwlfLI3dwI5TgnyuLyG5RsuwM6jsuw3s5m5wc=", + "lastModified": 1785270744, + "narHash": "sha256-8F3atV7WA32b2d2WvljENtW14XxUtJ7fOduU8HQyOAQ=", "owner": "NousResearch", "repo": "hermes-agent", - "rev": "64702f8f91661149128ca1a721f7a0fd4c22113b", + "rev": "28d11ab38c4cbee47f14a1064ada363f7ea93d3f", "type": "github" }, "original": { @@ -90,11 +90,11 @@ ] }, "locked": { - "lastModified": 1783740085, - "narHash": "sha256-qajyHfZY29G2oEQk+uHxmsJcRoBUBXP9maTpFlwP/dI=", + "lastModified": 1785119570, + "narHash": "sha256-Rgs2xKnGLFWQscxUaXX07oyZeuMDOHEbqDOsgliLFGM=", "owner": "nix-community", "repo": "home-manager", - "rev": "3cd22efe6471dc7365c822bd9ad73a21e55f38fb", + "rev": "d4fd24667c8cbef124bb70a20380cab75ec8474d", "type": "github" }, "original": { @@ -111,11 +111,11 @@ ] }, "locked": { - "lastModified": 1783823409, - "narHash": "sha256-OI4IkRjRXa1e7hYmCGJDPDq5H/kPwhsyoS80cNUF9fI=", + "lastModified": 1785269816, + "narHash": "sha256-6JkNDlJ18NY1iAuoSYCPDO7O+yYN/vPwpFmHOHNpVvY=", "owner": "nix-community", "repo": "home-manager", - "rev": "7566825d4652a1b885bd4ce65bd9e8def432fec9", + "rev": "3d25fa94cf355cf7da64c77bb9c3c15806486329", "type": "github" }, "original": { @@ -132,11 +132,11 @@ ] }, "locked": { - "lastModified": 1782839684, - "narHash": "sha256-vzs4SBgPsK4aNzlJR2PpFwtARazXMOxZonQnDz0YHxk=", + "lastModified": 1783963347, + "narHash": "sha256-r376E2XpakiXwModDHIxlvB6qLq4iFVEq730vxOO4JY=", "owner": "nix-community", "repo": "home-manager", - "rev": "2a37d71bbe69e1522ddabf03a4cea0374958bdbe", + "rev": "a45a7c451455a51ae740ec3bce4024b312809c29", "type": "github" }, "original": { @@ -163,11 +163,11 @@ }, "nixpkgs-deck": { "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "lastModified": 1785090369, + "narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "rev": "624af665418d3c65d544145b4d34ad696439570e", "type": "github" }, "original": { @@ -179,11 +179,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1783703440, - "narHash": "sha256-O3/YajjWo001VUIgD8BwaRdSNLUFe7nZ1qV5TwhRBcw=", + "lastModified": 1785133411, + "narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8f0500b9660505dc3cb647775fe9a978a74b5283", + "rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3", "type": "github" }, "original": { @@ -323,11 +323,11 @@ ] }, "locked": { - "lastModified": 1783866614, - "narHash": "sha256-o2h5BfVz9MPIERBPVHtvQGilphtNn5+cCuqqfY5Lkq8=", + "lastModified": 1785224333, + "narHash": "sha256-8DNJUVM10ktK02V9ZIV064CH+fuPF272VCAZBn6arig=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "51602966429e8ccae61324e56b51c37308d1b64e", + "rev": "5f1a4be4d280e1ae06d61a75775a234b1714da3f", "type": "github" }, "original": { diff --git a/modules/home-manager/gif-recorder/default.nix b/modules/home-manager/gif-recorder/default.nix new file mode 100644 index 0000000..00d9137 --- /dev/null +++ b/modules/home-manager/gif-recorder/default.nix @@ -0,0 +1,96 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.custom.gif-recorder; + + gif-recorder = pkgs.writeShellApplication { + name = "gif-recorder"; + runtimeInputs = with pkgs; [ + coreutils + ffmpeg + slurp + wf-recorder + wl-clipboard + ]; + text = '' + runtime_dir="''${XDG_RUNTIME_DIR:-/tmp}" + state="$runtime_dir/gif-recorder.pid" + selection="" + video="" + gif_tmp="" + child_pid="" + stopped=0 + + cleanup() { + rm -f "$state" "$selection" "$video" "$gif_tmp" + } + + stop_child() { + stopped=1 + if [ -n "$child_pid" ]; then + kill -INT "$child_pid" 2>/dev/null || true + fi + } + + trap cleanup EXIT + trap stop_child INT TERM + + if [ -f "$state" ]; then + active_pid="$(cat "$state" 2>/dev/null || true)" + if [ -n "$active_pid" ] && kill -0 "$active_pid" 2>/dev/null; then + kill -INT "$active_pid" 2>/dev/null || true + exit 0 + fi + rm -f "$state" + fi + + echo "$$" > "$state" + selection="$(mktemp "$runtime_dir/gif-recorder-selection.XXXXXX")" + slurp > "$selection" & + child_pid="$!" + selection_status=0 + wait "$child_pid" || selection_status="$?" + child_pid="" + + if [ "$stopped" -eq 1 ] || [ "$selection_status" -ne 0 ]; then + exit 0 + fi + + geometry="$(cat "$selection")" + video="$(mktemp "$runtime_dir/gif-recorder.XXXXXX.mkv")" + wf-recorder --geometry "$geometry" -f "$video" & + child_pid="$!" + recorder_status=0 + wait "$child_pid" || recorder_status="$?" + child_pid="" + + if [ "$stopped" -ne 1 ] || { [ "$recorder_status" -ne 0 ] && [ "$recorder_status" -ne 130 ]; }; then + exit 1 + fi + + output_dir="$HOME/Pictures/Screenshots" + mkdir -p "$output_dir" + gif_tmp="$(mktemp "$runtime_dir/gif-recorder.XXXXXX.gif")" + ffmpeg -y -i "$video" \ + -vf 'fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=sierra2_4a' \ + "$gif_tmp" + + output="$output_dir/gif-$(date +%Y%m%d-%H%M%S-%N).gif" + mv "$gif_tmp" "$output" + gif_tmp="" + wl-copy --foreground --type image/gif < "$output" & + ''; + }; +in +{ + options.custom.gif-recorder.enable = lib.mkEnableOption "Wayland region GIF recorder"; + + config = lib.mkIf cfg.enable { + home.packages = [ gif-recorder ]; + }; +} diff --git a/modules/home-manager/hyprland/default.nix b/modules/home-manager/hyprland/default.nix index 2703e0e..0a5cae8 100644 --- a/modules/home-manager/hyprland/default.nix +++ b/modules/home-manager/hyprland/default.nix @@ -240,6 +240,7 @@ in hl.bind(mod_shft .. " + 2", hl.dsp.exec_cmd("hyprshot -m output -m DP-1 -o ~/Pictures/Screenshots")) hl.bind(mod_shft .. " + 3", hl.dsp.exec_cmd("hyprshot -m window -m active -o ~/Pictures/Screenshots")) hl.bind(mod_shft .. " + 4", hl.dsp.exec_cmd("hyprshot -m region -o ~/Pictures/Screenshots")) + hl.bind(mod_shft .. " + 5", hl.dsp.exec_cmd("gif-recorder")) -- ---------- OBS Recording ---------- hl.bind(mod_shft .. " + 9", hl.dsp.exec_cmd("obs-cmd recording toggle")) diff --git a/openspec/changes/archive/2026-07-30-gif-recorder/.openspec.yaml b/openspec/changes/archive/2026-07-30-gif-recorder/.openspec.yaml new file mode 100644 index 0000000..ab39675 --- /dev/null +++ b/openspec/changes/archive/2026-07-30-gif-recorder/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-30 diff --git a/openspec/changes/archive/2026-07-30-gif-recorder/design.md b/openspec/changes/archive/2026-07-30-gif-recorder/design.md new file mode 100644 index 0000000..88073c6 --- /dev/null +++ b/openspec/changes/archive/2026-07-30-gif-recorder/design.md @@ -0,0 +1,64 @@ +## Context + +The desktop already uses Home Manager modules under `modules/home-manager` and keeps Hyprland keybinds in `modules/home-manager/hyprland/default.nix`. The new capability is a small user-session workflow, not a daemon: select a region with `slurp`, record it with the Wayland-native `wf-recorder`, encode the result with `ffmpeg`, and publish the finished file with `wl-copy`. + +The same Hyprland bind must start and stop the workflow. The first invocation needs to remain alive while recording; a later invocation needs a stable way to find and signal that process. The implementation also needs to avoid leaving partial files in the screenshots directory when selection is cancelled or encoding fails. + +## Goals / Non-Goals + +**Goals:** + +- Encapsulate packages, script, option, output directory, and runtime state in `modules/home-manager/gif-recorder`. +- Use a single toggle command suitable for a Hyprland `bind`. +- Select a rectangular region interactively through `slurp`. +- Save optimized GIF output in `~/Pictures/Screenshots` and copy it as `image/gif` with `wl-copy`. +- Keep host wiring minimal: import/enable in `hosts/jpporta-nixos/home.nix`, bind in the existing Hyprland module. + +**Non-Goals:** + +- Full-screen or window recording modes. +- A recording history, notification UI, configurable encoder settings, or a separate stop bind. +- A new service or background daemon. +- Changes to the existing screenshot flow. + +## Decisions + +### D1. One shell script owns the toggle state + +The module installs one `gif-recorder` executable and uses a runtime-directory state file containing the active script PID. If the state file names a live process, a new invocation sends it `SIGINT`; otherwise it runs `slurp` and starts a recording. This keeps the bind to one command and avoids adding a service or IPC layer. + +**Alternative:** use separate start/stop scripts or a systemd user service. Rejected because they add commands and lifecycle machinery for one short-lived action. + +### D2. Use `wf-recorder`, `slurp`, `ffmpeg`, and `wl-copy` + +These tools are small, composable Wayland utilities already suited to the requested flow. `wf-recorder` receives the geometry selected by `slurp`; `ffmpeg` handles palette generation and GIF encoding; `wl-copy --type image/gif` makes the resulting file available to Wayland clipboard consumers. + +**Alternative:** OBS or a screenshot/GIF application. Rejected because a GUI recorder is heavier than the requested Hyprshot-like interaction. + +### D3. Encode through a temporary video file + +Recording first to a temporary Matroska file keeps the output atomic: the final timestamped GIF is moved into `~/Pictures/Screenshots` only after `ffmpeg` succeeds. The encoder uses a palette-generation filter and a bounded frame rate/width to avoid the worst GIF size and color-quality problems without introducing configuration options. + +**Alternative:** record directly to GIF. Rejected because palette generation and interruption handling are worse, and `wf-recorder` is designed primarily for video output. + +### D4. Store state under `XDG_RUNTIME_DIR` + +The state file belongs in `${XDG_RUNTIME_DIR:-/tmp}` and is removed on normal completion or cancellation. It is session-local and avoids polluting the home directory with a lock file. + +## Risks / Trade-offs + +- [GIFs are inherently large and limited to 256 colors] -> Cap output at a modest frame rate and width; keep the original temporary recording out of the screenshots directory. +- [A crashed process can leave a stale state file] -> Check whether the recorded PID is alive before treating the workflow as active, and remove stale state before starting. +- [Clipboard consumers differ in GIF support] -> Publish with the explicit `image/gif` MIME type; the saved file remains the fallback when a consumer cannot paste animated images. +- [The second bind may be pressed while region selection is active] -> Keep the controlling script PID in the state file before invoking `slurp`, and handle interruption by cleaning up without creating output. + +## Migration Plan + +1. Apply the Home Manager configuration for `jpporta-nixos`. +2. Press the new Hyprland bind and select a region; press it again to stop and wait for encoding. +3. Verify the GIF in `~/Pictures/Screenshots` and paste it into a GIF-capable Wayland application. +4. Roll back by disabling/removing the module import and bind; no persistent data migration is required, and existing GIF files are left untouched. + +## Open Questions + +- The exact key combination should follow the existing screenshot bind convention in the Hyprland module; implementation should reuse that convention rather than introduce a new modifier scheme. diff --git a/openspec/changes/archive/2026-07-30-gif-recorder/proposal.md b/openspec/changes/archive/2026-07-30-gif-recorder/proposal.md new file mode 100644 index 0000000..f43da25 --- /dev/null +++ b/openspec/changes/archive/2026-07-30-gif-recorder/proposal.md @@ -0,0 +1,31 @@ +## Why + +The Hyprland desktop has a screenshot flow but no equally convenient way to capture a short animated region of the screen. A Wayland-native GIF recorder should let the user select a region, press the same bind to stop, and receive the finished GIF in `~/Pictures/Screenshots` and on the clipboard. + +## What Changes + +- Add a new Home Manager module, `modules/home-manager/gif-recorder`, with a single enable option. +- Provide a Wayland-native recording script using `wf-recorder` and `slurp` to select and record a screen region. +- Toggle recording with one Hyprland bind: the first press starts region selection/recording and the next press stops it. +- Convert the recording to an optimized GIF after stopping. +- Save completed GIFs under `~/Pictures/Screenshots` with timestamped filenames. +- Copy the completed GIF to the Wayland clipboard for immediate pasting. +- Enable the module from `hosts/jpporta-nixos/home.nix` and add only the required bind in the existing Hyprland Home Manager module. + +## Capabilities + +### New Capabilities + +- `gif-recorder`: Select, toggle-record, save, and clipboard-copy short Wayland screen-region GIFs. + +### Modified Capabilities + +- None. + +## Impact + +- New Home Manager module and user-facing recording script. +- Existing `modules/home-manager/hyprland/default.nix` gains the toggle bind. +- `hosts/jpporta-nixos/home.nix` imports and enables the module. +- Adds the runtime packages `wf-recorder`, `slurp`, `ffmpeg`, and a Wayland clipboard utility through the module. +- Creates `~/Pictures/Screenshots` when a recording is completed; existing screenshot behavior remains unchanged. diff --git a/openspec/changes/archive/2026-07-30-gif-recorder/specs/gif-recorder/spec.md b/openspec/changes/archive/2026-07-30-gif-recorder/specs/gif-recorder/spec.md new file mode 100644 index 0000000..3a59df3 --- /dev/null +++ b/openspec/changes/archive/2026-07-30-gif-recorder/specs/gif-recorder/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Region recording is toggled by one command +The GIF recorder SHALL use one executable that starts an interactive region recording when idle and stops the active recording when invoked again. + +#### Scenario: Start a recording +- **WHEN** the recorder is invoked while no recording is active +- **THEN** it lets the user select a rectangular Wayland region and starts recording that region + +#### Scenario: Stop a recording +- **WHEN** the recorder is invoked while a recording is active +- **THEN** it stops the active recording and begins finalization without starting a second recording + +#### Scenario: Cancel region selection +- **WHEN** the user cancels region selection before recording starts +- **THEN** the recorder exits without creating a GIF and clears its runtime state + +### Requirement: Completed recordings are saved as GIF screenshots +The recorder SHALL save each successfully finalized recording as a timestamped `.gif` file below `~/Pictures/Screenshots`. + +#### Scenario: Successful finalization +- **WHEN** an active recording is stopped and encoding succeeds +- **THEN** the recorder creates the screenshots directory if needed and stores one completed GIF there + +#### Scenario: Failed finalization +- **WHEN** recording or GIF encoding fails +- **THEN** the recorder does not publish a partial GIF as a completed screenshot and removes temporary recording data + +### Requirement: Completed recordings are copied to the Wayland clipboard +After successful GIF encoding, the recorder SHALL copy the resulting file to the Wayland clipboard using the `image/gif` MIME type. + +#### Scenario: Clipboard copy succeeds +- **WHEN** a GIF is successfully written +- **THEN** the recorder publishes that GIF to the clipboard as `image/gif` + +#### Scenario: Clipboard copy is unavailable +- **WHEN** a GIF is successfully written but clipboard publication fails +- **THEN** the recorder preserves the GIF in `~/Pictures/Screenshots` and exits without deleting it + +### Requirement: Home Manager integration is opt-in +The feature SHALL be exposed as a Home Manager module with an enable option, and enabling it SHALL install all required runtime tools and the recorder executable. + +#### Scenario: Module enabled +- **WHEN** `custom.gif-recorder.enable` is true +- **THEN** the user environment contains the recorder command and its Wayland recording, selection, encoding, and clipboard dependencies + +#### Scenario: Module disabled +- **WHEN** `custom.gif-recorder.enable` is false +- **THEN** the module adds no GIF recorder command, packages, or Hyprland bind diff --git a/openspec/changes/archive/2026-07-30-gif-recorder/tasks.md b/openspec/changes/archive/2026-07-30-gif-recorder/tasks.md new file mode 100644 index 0000000..c72e77d --- /dev/null +++ b/openspec/changes/archive/2026-07-30-gif-recorder/tasks.md @@ -0,0 +1,18 @@ +## 1. Add the Home Manager module + +- [x] 1.1 Create `modules/home-manager/gif-recorder/default.nix` with `custom.gif-recorder.enable` and an enabled-only configuration. +- [x] 1.2 Install `wf-recorder`, `slurp`, `ffmpeg`, and `wl-clipboard` through the module and expose one `gif-recorder` script in the user's session path. +- [x] 1.3 Implement the toggle state under `XDG_RUNTIME_DIR`: start region selection/recording when idle, signal the active process on the second invocation, clean up on cancellation/interruption, and reject stale state. +- [x] 1.4 Finalize successful recordings with palette-based `ffmpeg` GIF encoding, timestamp the output below `~/Pictures/Screenshots`, and copy it with `wl-copy --type image/gif` while preserving the saved file if clipboard copy fails. + +## 2. Wire the feature into the existing host + +- [x] 2.1 Import `../../modules/home-manager/gif-recorder` and set `custom.gif-recorder.enable = true` in `hosts/jpporta-nixos/home.nix`. +- [x] 2.2 Add `SUPER + SHIFT + 5` to the existing `Screenshots` section of `modules/home-manager/hyprland/default.nix`, invoking `gif-recorder` so the same bind starts and stops the capture. + +## 3. Verify the workflow + +- [x] 3.1 Evaluate/build the `jpporta-nixos` Home Manager activation package and confirm the module and script parse successfully. +- [x] 3.2 Confirm the bind invokes the installed command and that the runtime dependency paths are available in the script. +- [ ] 3.3 In a live Hyprland session, select a region, stop with the same bind, and verify a timestamped GIF appears in `~/Pictures/Screenshots` and can be pasted from the Wayland clipboard. +- [ ] 3.4 Verify canceling `slurp`, stopping early, and an encoding failure leave no partial GIF and no stale runtime state. diff --git a/openspec/specs/gif-recorder/spec.md b/openspec/specs/gif-recorder/spec.md new file mode 100644 index 0000000..d164252 --- /dev/null +++ b/openspec/specs/gif-recorder/spec.md @@ -0,0 +1,55 @@ +# GIF Recorder + +## Purpose + +This capability provides an opt-in Wayland workflow for recording a selected screen region as a GIF, saving it as a screenshot, and copying it to the clipboard. + +## Requirements + +### Requirement: Region recording is toggled by one command +The GIF recorder SHALL use one executable that starts an interactive region recording when idle and stops the active recording when invoked again. + +#### Scenario: Start a recording +- **WHEN** the recorder is invoked while no recording is active +- **THEN** it lets the user select a rectangular Wayland region and starts recording that region + +#### Scenario: Stop a recording +- **WHEN** the recorder is invoked while a recording is active +- **THEN** it stops the active recording and begins finalization without starting a second recording + +#### Scenario: Cancel region selection +- **WHEN** the user cancels region selection before recording starts +- **THEN** the recorder exits without creating a GIF and clears its runtime state + +### Requirement: Completed recordings are saved as GIF screenshots +The recorder SHALL save each successfully finalized recording as a timestamped `.gif` file below `~/Pictures/Screenshots`. + +#### Scenario: Successful finalization +- **WHEN** an active recording is stopped and encoding succeeds +- **THEN** the recorder creates the screenshots directory if needed and stores one completed GIF there + +#### Scenario: Failed finalization +- **WHEN** recording or GIF encoding fails +- **THEN** the recorder does not publish a partial GIF as a completed screenshot and removes temporary recording data + +### Requirement: Completed recordings are copied to the Wayland clipboard +After successful GIF encoding, the recorder SHALL copy the resulting file to the Wayland clipboard using the `image/gif` MIME type. + +#### Scenario: Clipboard copy succeeds +- **WHEN** a GIF is successfully written +- **THEN** the recorder publishes that GIF to the clipboard as `image/gif` + +#### Scenario: Clipboard copy is unavailable +- **WHEN** a GIF is successfully written but clipboard publication fails +- **THEN** the recorder preserves the GIF in `~/Pictures/Screenshots` and exits without deleting it + +### Requirement: Home Manager integration is opt-in +The feature SHALL be exposed as a Home Manager module with an enable option, and enabling it SHALL install all required runtime tools and the recorder executable. + +#### Scenario: Module enabled +- **WHEN** `custom.gif-recorder.enable` is true +- **THEN** the user environment contains the recorder command and its Wayland recording, selection, encoding, and clipboard dependencies + +#### Scenario: Module disabled +- **WHEN** `custom.gif-recorder.enable` is false +- **THEN** the module adds no GIF recorder command, packages, or Hyprland bind