211 lines
6.5 KiB
Python
211 lines
6.5 KiB
Python
import os
|
|
import json
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
app = typer.Typer()
|
|
|
|
RECORDER = "gpu-screen-recorder"
|
|
HOME = str(os.getenv("HOME", str(Path.home())))
|
|
CONFIG = Path(HOME) / ".config/zshell/config.json"
|
|
|
|
STATE_DIR = Path(HOME) / ".local/state/zshell/record"
|
|
TEMP_RECORDING = STATE_DIR / "recording.mp4"
|
|
REPLAY_RECORDING = STATE_DIR / "replay.mp4"
|
|
NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
|
|
|
|
RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings"))
|
|
|
|
|
|
def _read_extra_args() -> list[str]:
|
|
try:
|
|
if CONFIG.is_file():
|
|
data = json.loads(CONFIG.read_text())
|
|
return data.get("record", {}).get("extraArgs", [])
|
|
except Exception:
|
|
pass
|
|
return []
|
|
|
|
|
|
def _is_recording() -> bool:
|
|
return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
|
|
|
|
|
def _notify(summary: str, body: str = "", actions: list | None = None, timeout: int = 5000) -> Optional[int]:
|
|
args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
|
|
if actions:
|
|
for action in actions:
|
|
args.extend(["-A", action])
|
|
try:
|
|
proc = subprocess.run(args, capture_output=True, text=True)
|
|
return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _close_notification(notif_id: int):
|
|
subprocess.run(["notify-send", "--close", str(notif_id)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
|
|
def _get_monitors() -> list[dict]:
|
|
try:
|
|
res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True)
|
|
return json.loads(res.stdout)
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _focused_monitor_name() -> Optional[str]:
|
|
for m in _get_monitors():
|
|
if m.get("focused"):
|
|
return m["name"]
|
|
return None
|
|
|
|
|
|
def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
|
|
region = (x, y, x + w, y + h)
|
|
intersecting = []
|
|
for m in _get_monitors():
|
|
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"]
|
|
if not (region[2] <= mx or region[0] >= mx + mw or region[3] <= my or region[1] >= my + mh):
|
|
intersecting.append(m)
|
|
return intersecting
|
|
|
|
|
|
def _highest_refresh(monitors: list[dict]) -> float:
|
|
return max((m["refreshRate"] for m in monitors), default=60.0)
|
|
|
|
|
|
def _slurp_region() -> Optional[str]:
|
|
try:
|
|
return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip()
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
|
|
|
|
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
|
|
import re
|
|
|
|
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
|
|
if match:
|
|
return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2))
|
|
return None
|
|
|
|
|
|
def start_recording(region: Optional[str], sound: bool):
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
cmd = [RECORDER]
|
|
extra_args = _read_extra_args()
|
|
|
|
if region:
|
|
if region.lower() == "slurp" or not region:
|
|
geometry = _slurp_region()
|
|
if not geometry:
|
|
typer.echo("Region selection cancelled.")
|
|
raise typer.Abort()
|
|
else:
|
|
geometry = region
|
|
|
|
parsed = _parse_geometry(geometry)
|
|
if not parsed:
|
|
typer.echo("Invalid geometry format.")
|
|
raise typer.Abort()
|
|
x, y, w, h = parsed
|
|
|
|
monitors = _monitors_intersecting_region(x, y, w, h)
|
|
framerate = _highest_refresh(monitors)
|
|
cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))])
|
|
|
|
else:
|
|
monitor_name = _focused_monitor_name()
|
|
if not monitor_name:
|
|
typer.echo("No focused monitor found.")
|
|
raise typer.Abort()
|
|
|
|
monitors = _get_monitors()
|
|
mon = next((m for m in monitors if m["name"] == monitor_name), None)
|
|
rate = int(mon["refreshRate"]) if mon else 60
|
|
cmd.extend(["-w", monitor_name, "-f", str(rate)])
|
|
|
|
if sound:
|
|
cmd.extend(["-a", "default_output"])
|
|
|
|
cmd.extend(extra_args)
|
|
cmd.extend(["-o", str(TEMP_RECORDING)])
|
|
|
|
subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
|
|
if notif_id is not None:
|
|
NOTIF_ID_FILE.write_text(str(notif_id))
|
|
|
|
time.sleep(1)
|
|
if not _is_recording():
|
|
_notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000)
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
def stop_recording(clipboard: bool):
|
|
subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
for _ in range(50):
|
|
if not _is_recording():
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
dest_dir = Path(RECORDINGS_DIR)
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
|
|
final_path = dest_dir / f"recording_{timestamp}.mp4"
|
|
|
|
if TEMP_RECORDING.exists():
|
|
TEMP_RECORDING.rename(final_path)
|
|
|
|
if NOTIF_ID_FILE.is_file():
|
|
try:
|
|
_close_notification(int(NOTIF_ID_FILE.read_text().strip()))
|
|
except Exception:
|
|
pass
|
|
NOTIF_ID_FILE.unlink()
|
|
|
|
if clipboard:
|
|
subprocess.run(
|
|
["wl-copy", "--type", "text/uri-list", f"file://{final_path}"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
_notify("Recording stopped", f"Saved to {final_path}", timeout=5000)
|
|
|
|
|
|
def toggle_pause():
|
|
subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
typer.echo("Toggled pause.")
|
|
|
|
|
|
@app.command()
|
|
def record(
|
|
region: Optional[str] = typer.Option(
|
|
None,
|
|
"--region",
|
|
"-r",
|
|
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
|
|
),
|
|
sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."),
|
|
pause: bool = typer.Option(False, "--pause", "-p", help="Toggle pause/resume."),
|
|
clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Copy the final recording path to clipboard."),
|
|
):
|
|
"""Start or stop a screen recording with gpu-screen-recorder."""
|
|
if pause:
|
|
toggle_pause()
|
|
raise typer.Exit()
|
|
|
|
if _is_recording():
|
|
stop_recording(clipboard)
|
|
else:
|
|
start_recording(region, sound)
|