timescreenlapse
Fabrice P. Lauss𝕪's Web

timescreenlapse

timescreenlapse is a python script that photographs my screen every ten seconds and turns the day—or a memorable moment of it—into a film. It lives in the KDE panel: one click starts it, it then sits in the system tray as a red record button, and when I stop it, it hands over an mp4: a day of work running past in about a minute.

Written with the help of Claude Opus 5 on 6 August (2026). Version 1.1.0.

The idea

A timelapse of a screen is only a folder of screenshots with a number in the name, so the entire craft is in the two things that go wrong when one leaves such a thing running for hours: the cost of each frame, and the cost of the frames where nothing happened.

The first is settled by taking the picture with ffmpeg's x11grab rather than with ImageMagick's import, which is the obvious tool but the wrong one. On my 3440×1440 screen, import needs 1.7 s and writes 957 kB; x11grab needs 0.22 s and writes 431 kB. Eight times faster for half the disk, and the difference is not academic: a grab that takes 1.7 seconds is a grab one notices, which is exactly what a recorder running all afternoon must never be.

The second is settled by hashing each frame and, when it is identical to the one before, storing it as a hard link instead of a copy. An idle screen then costs nothing at all, while the numbering stays gap-free—which matters, because the film is assembled with -i frame-%06d.png and a hole in the sequence would silently truncate it. The frames that were merely held still occupy their slot, so the timelapse keeps its true pacing.

Usage

timescreenlapse                    # tray applet, one frame every 10 s
timescreenlapse -i 30              # ... every 30 s (also -i 2m, -i 500ms)
timescreenlapse --select           # drag out a region first
timescreenlapse -j -s 1600         # JPEG, downscaled: ~25 MB an hour
timescreenlapse -d 2h              # stop by itself after two hours

timescreenlapse --video            # latest session -> mp4
timescreenlapse --gif --fps 12     # ... or an animated GIF
timescreenlapse --list             # past sessions, frames, sizes
timescreenlapse --stop             # finish the running session

Frames land in ~/Pictures/Timescreenlapse/2026-08-06_172707/frame-000001.png, one folder per session, with an index.tsv giving each frame its wall-clock time. Assembling at 10 fps from a 10 s interval is a hundredfold speed-up; passing --fps 10 when the interval was -i 10 would be real time. Full screen in PNG costs some 150 MB an hour, which -j brings down to 25.

In the panel

There is a desktop entry pinned to the task manager. Its right-click menu holds Stop recording, Record every 30 seconds, Record a region… and Open the timelapse folder; the tray icon underneath offers pause, capture now, and the two assembling commands.

A button one can click twice needs a lock, and that is the only thing the panel really added to the script: a running recorder leaves its pid in $XDG_RUNTIME_DIR, so a second click says already recording, 329 frames instead of quietly starting a rival recorder into a second folder. --toggle turns the same button into start/stop.

Two KDE lessons were paid for here. Writing the launcher into the panel's launchers key through plasmashell's scripting interface is not enough—the icons-only task manager does not rebuild its list from an external write, and the panel went on showing a stale row until systemctl --user restart plasma-plasmashell.service. And an icon that is to survive a change of theme must be drawn in Breeze's ColorScheme-Text convention rather than in a colour: my first tray icon was a near-white ring, perfectly invisible on my pink panel.

The script

#!/usr/bin/env python3
#
# timescreenlapse -- capture the screen at fixed intervals and turn it into a timelapse.
#
# Runs as a small tray applet (KDE/X11): while it runs, it grabs one frame every
# N seconds (10 by default) into a per-session folder, and can assemble the whole
# session into an mp4 or a GIF when you are done.
#
# Usage:
#   timescreenlapse                       # tray applet, one frame every 10 s
#   timescreenlapse -i 5                  # ... every 5 s
#   timescreenlapse --toggle              # start it, or stop the running one
#   timescreenlapse --select              # pick a region with the mouse first
#   timescreenlapse --nogui -i 30 -d 2h   # headless, 30 s apart, stop after 2 h
#   timescreenlapse --video               # assemble the latest session into mp4
#   timescreenlapse --gif --fps 12 DIR    # assemble session DIR into a GIF
#   timescreenlapse --list                # list past sessions
#
# Only one recorder runs at a time: launching a second one just says so, so the
# panel button is safe to click twice.
#
# Frames go to  ~/Pictures/Timescreenlapse/<YYYY-MM-DD_HHMMSS>/frame-000001.png
# Identical consecutive frames are stored as hard links (no disk cost) so the
# numbering stays gap-free and the timelapse keeps its true pacing.
#
# Options:
#   -i, --interval SECS   seconds between frames (default 10; accepts 500ms, 2m)
#   -o, --outdir DIR      parent folder for sessions (default ~/Pictures/Timescreenlapse)
#   -j, --jpeg [Q]        store JPEG instead of PNG (default quality 3, 1=best)
#   -s, --scale W         downscale frames to width W (height follows)
#   -r, --region GEOM     capture WxH+X+Y instead of the whole screen
#       --select          pick that region interactively (slop)
#   -n, --frames N        stop after N frames
#   -d, --duration T      stop after T (e.g. 90, 20m, 2h, 1h30m)
#       --no-dedup        write every frame, even when nothing changed
#       --nogui           no tray icon; Ctrl-C to stop
#       --start-paused    launch the applet in the paused state
#   -q, --quiet           no desktop notifications
#
# Assembling (no capture is done when one of these is given):
#       --video [DIR]     encode DIR (default: latest session) to mp4
#       --gif [DIR]       encode DIR to an animated GIF
#       --fps N           playback rate of the assembled clip (default 10)
#       --width W         scale the assembled clip to width W (default 1280 for GIF)
#       --list            list sessions with frame counts and sizes
#
# Controlling a running recorder:
#       --stop            finish the running session and quit its applet
#       --toggle          start one, or stop it if it is already running
#       --status          say whether one is recording (exit 0 if it is)
#
VERSION = "1.1.0"

import argparse
import hashlib
import os
import re
import shutil
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

DEFAULT_OUTDIR = Path.home() / "Pictures" / "Timescreenlapse"
FRAME_GLOB = "frame-*"
FRAME_FMT = "frame-%06d"
INDEX_NAME = "index.tsv"


# --------------------------------------------------------------------------- #
#  helpers
# --------------------------------------------------------------------------- #

def die(msg, code=1):
    print(f"timescreenlapse: {msg}", file=sys.stderr)
    sys.exit(code)


def parse_duration(text):
    """'90' -> 90.0, '500ms' -> 0.5, '2m' -> 120, '1h30m' -> 5400."""
    text = str(text).strip().lower()
    if not text:
        raise ValueError("empty duration")
    if re.fullmatch(r"[0-9.]+", text):
        return float(text)
    if text.endswith("ms") and re.fullmatch(r"[0-9.]+ms", text):
        return float(text[:-2]) / 1000.0
    units = {"h": 3600, "m": 60, "s": 1}
    total, seen = 0.0, False
    for value, unit in re.findall(r"([0-9.]+)\s*([hms])", text):
        total += float(value) * units[unit]
        seen = True
    if not seen:
        raise ValueError(f"cannot read duration {text!r}")
    return total


def human_size(nbytes):
    for unit in ("B", "kB", "MB", "GB", "TB"):
        if nbytes < 1024 or unit == "TB":
            return f"{nbytes:.0f} {unit}" if unit == "B" else f"{nbytes:.1f} {unit}"
        nbytes /= 1024.0


def human_time(seconds):
    seconds = int(seconds)
    h, m, s = seconds // 3600, (seconds // 60) % 60, seconds % 60
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"


def screen_geometry():
    """(width, height) of the whole X screen."""
    out = subprocess.run(["xdpyinfo"], capture_output=True, text=True).stdout
    match = re.search(r"dimensions:\s+(\d+)x(\d+)", out)
    if not match:
        die("cannot read the screen size from xdpyinfo")
    return int(match.group(1)), int(match.group(2))


def parse_region(geom):
    """'WxH+X+Y' or 'WxH' -> (w, h, x, y), sizes rounded down to even numbers."""
    match = re.fullmatch(r"(\d+)x(\d+)(?:\+(\d+)\+(\d+))?", geom.strip())
    if not match:
        die(f"bad region {geom!r}, expected WxH+X+Y")
    w, h = int(match.group(1)) // 2 * 2, int(match.group(2)) // 2 * 2
    x, y = int(match.group(3) or 0), int(match.group(4) or 0)
    if w < 2 or h < 2:
        die(f"region {geom!r} is too small")
    return w, h, x, y


def select_region():
    """Ask the user to drag a rectangle; returns (w, h, x, y)."""
    if not shutil.which("slop"):
        die("--select needs slop (sudo apt install slop)")
    print("Select the area with your mouse (Escape to cancel)...", file=sys.stderr)
    proc = subprocess.run(["slop", "-f", "%wx%h+%x+%y", "--highlight", "--tolerance", "0"],
                          capture_output=True, text=True)
    if proc.returncode != 0 or not proc.stdout.strip():
        die("selection cancelled", 0)
    return parse_region(proc.stdout.strip())


def notify(title, body, quiet=False, icon="camera-photo"):
    if quiet or not shutil.which("notify-send"):
        return
    subprocess.run(["notify-send", "-a", "timescreenlapse", "-i", icon, title, body],
                   check=False)


# -- single instance ------------------------------------------------------- #
#  Clicking a panel launcher twice must not start a second recorder, so a
#  running applet leaves a pid file behind and later launches defer to it.

def lock_path():
    runtime = os.environ.get("XDG_RUNTIME_DIR")
    folder = Path(runtime) if runtime else Path.home() / ".cache"
    return folder / "timescreenlapse.pid"


def read_lock():
    """(pid, session folder) of the running applet, or None."""
    try:
        pid_text, folder = lock_path().read_text().split("\n", 1)
        pid = int(pid_text)
        cmdline = Path(f"/proc/{pid}/cmdline").read_bytes()
    except (OSError, ValueError):
        return None
    if b"timescreenlapse" not in cmdline:      # dead, or the pid was recycled
        lock_path().unlink(missing_ok=True)
        return None
    return pid, Path(folder.strip())


def write_lock(folder):
    try:
        lock_path().write_text(f"{os.getpid()}\n{folder}\n")
    except OSError:
        pass


def clear_lock():
    running = read_lock()
    if running and running[0] == os.getpid():
        lock_path().unlink(missing_ok=True)


def stop_running(quiet=False):
    """Ask a running applet to finish its session. True if one was there."""
    running = read_lock()
    if not running:
        return False
    pid, folder = running
    os.kill(pid, signal.SIGTERM)
    for _ in range(60):                        # give it a moment to tidy up
        time.sleep(0.1)
        if not Path(f"/proc/{pid}").exists():
            break
    print(f"stopped the recorder in {folder}", file=sys.stderr)
    return True


def sessions(outdir):
    """Past session folders, oldest first."""
    if not outdir.is_dir():
        return []
    return sorted((p for p in outdir.iterdir()
                   if p.is_dir() and re.fullmatch(r"\d{4}-\d\d-\d\d_\d{6}", p.name)),
                  key=lambda p: p.name)


def frames_of(folder):
    return sorted(p for p in folder.glob(FRAME_GLOB) if p.is_file())


# --------------------------------------------------------------------------- #
#  capture engine
# --------------------------------------------------------------------------- #

class Session:
    """One recording session: owns the folder, grabs and stores the frames."""

    def __init__(self, args):
        self.args = args
        self.width, self.height, self.x, self.y = args.geometry
        self.suffix = ".jpg" if args.jpeg is not None else ".png"
        self.display = os.environ.get("DISPLAY", ":0")

        stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
        self.folder = args.outdir / stamp
        self.folder.mkdir(parents=True, exist_ok=True)
        self.index = (self.folder / INDEX_NAME).open("a", buffering=1)
        self.index.write(f"# timescreenlapse {VERSION}\t{stamp}\t"
                         f"{self.width}x{self.height}+{self.x}+{self.y}\t"
                         f"every {self.args.interval}s\n")

        self.count = 0          # frames written (links included)
        self.linked = 0         # frames that were identical to their predecessor
        self.failed = 0
        self.started = time.time()
        self._last_hash = None
        self._last_path = None
        write_lock(self.folder)

    # -- one grab ----------------------------------------------------------- #

    def _ffmpeg_cmd(self, target):
        cmd = ["ffmpeg", "-loglevel", "error", "-y",
               "-f", "x11grab", "-draw_mouse", "1",
               "-video_size", f"{self.width}x{self.height}",
               "-i", f"{self.display}+{self.x},{self.y}",
               "-frames:v", "1"]
        if self.args.scale:
            cmd += ["-vf", f"scale={self.args.scale}:-2:flags=lanczos"]
        if self.args.jpeg is not None:
            cmd += ["-q:v", str(self.args.jpeg)]
        cmd.append(str(target))
        return cmd

    def capture(self):
        """Grab one frame. Returns (ok, path_or_error, was_link)."""
        target = self.folder / (FRAME_FMT % (self.count + 1) + self.suffix)
        temp = self.folder / (".pending" + self.suffix)
        proc = subprocess.run(self._ffmpeg_cmd(temp), capture_output=True, text=True)
        if proc.returncode != 0 or not temp.exists() or temp.stat().st_size == 0:
            temp.unlink(missing_ok=True)
            self.failed += 1
            error = (proc.stderr or "ffmpeg produced no frame").strip().splitlines()
            return False, (error[-1] if error else "grab failed"), False

        digest = hashlib.blake2b(temp.read_bytes(), digest_size=16).hexdigest()
        is_link = False
        if (self.args.dedup and digest == self._last_hash
                and self._last_path and self._last_path.exists()):
            temp.unlink(missing_ok=True)
            try:
                os.link(self._last_path, target)   # same content, no disk cost
                is_link = True
            except OSError:
                shutil.copy2(self._last_path, target)
        else:
            temp.replace(target)
            self._last_hash = digest
            self._last_path = target

        self.count += 1
        self.linked += is_link
        self.index.write(f"{self.count}\t{datetime.now().isoformat(timespec='seconds')}"
                         f"\t{target.name}\t{'link' if is_link else 'new'}\n")
        return True, target, is_link

    # -- bookkeeping -------------------------------------------------------- #

    @property
    def elapsed(self):
        return time.time() - self.started

    def disk_used(self):
        seen, total = set(), 0
        for path in frames_of(self.folder):
            stat = path.stat()
            if stat.st_ino in seen:
                continue
            seen.add(stat.st_ino)
            total += stat.st_size
        return total

    def summary(self):
        return (f"{self.count} frame{'s' if self.count != 1 else ''} in "
                f"{human_time(self.elapsed)} · {human_size(self.disk_used())}"
                + (f" · {self.linked} unchanged" if self.linked else "")
                + (f" · {self.failed} failed" if self.failed else ""))

    def close(self):
        if self.index.closed:            # a second signal while already quitting
            return
        clear_lock()
        (self.folder / (".pending" + self.suffix)).unlink(missing_ok=True)
        self.index.write(f"# stopped\t{datetime.now().isoformat(timespec='seconds')}"
                         f"\t{self.summary()}\n")
        self.index.close()
        if self.count == 0:
            try:
                (self.folder / INDEX_NAME).unlink(missing_ok=True)
                self.folder.rmdir()
            except OSError:
                pass


# --------------------------------------------------------------------------- #
#  assembling
# --------------------------------------------------------------------------- #

def assemble(folder, kind, fps, width, quiet=False):
    """Encode a session folder into an mp4 or a GIF; returns the output path."""
    frames = frames_of(folder)
    if not frames:
        die(f"no frames in {folder}")
    suffix = frames[0].suffix
    pattern = str(folder / (FRAME_FMT + suffix))
    out = folder / f"{folder.name}.{'gif' if kind == 'gif' else 'mp4'}"

    if kind == "gif":
        width = width or 1280
        vf = (f"fps={fps},scale={width}:-1:flags=lanczos,"
              "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer")
        cmd = ["ffmpeg", "-loglevel", "error", "-y", "-framerate", str(fps),
               "-i", pattern, "-vf", vf, "-loop", "0", str(out)]
    else:
        scale = f"scale={width}:-2:flags=lanczos," if width else ""
        cmd = ["ffmpeg", "-loglevel", "error", "-y", "-framerate", str(fps),
               "-i", pattern, "-vf", f"{scale}pad=ceil(iw/2)*2:ceil(ih/2)*2",
               "-c:v", "libx264", "-preset", "slow", "-crf", "20",
               "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out)]

    print(f"Assembling {len(frames)} frames at {fps} fps -> {out.name} ...", file=sys.stderr)
    proc = subprocess.run(cmd, capture_output=True, text=True)
    if proc.returncode != 0:
        die(f"ffmpeg failed:\n{proc.stderr.strip()}")
    print(f"{out}  ({human_size(out.stat().st_size)}, "
          f"{len(frames) / fps:.1f} s of playback)", file=sys.stderr)
    notify("Timelapse ready", f"{out.name} · {human_size(out.stat().st_size)}",
           quiet, icon="video-x-generic")
    return out


def list_sessions(outdir):
    found = sessions(outdir)
    if not found:
        print(f"no sessions yet in {outdir}")
        return
    print(f"{'session':<20} {'frames':>7} {'size':>10}   clips")
    for folder in found:
        frames = frames_of(folder)
        seen, total = set(), 0
        for path in frames:
            stat = path.stat()
            if stat.st_ino not in seen:
                seen.add(stat.st_ino)
                total += stat.st_size
        clips = ", ".join(sorted(p.name for p in folder.iterdir()
                                 if p.suffix in (".mp4", ".gif")))
        print(f"{folder.name:<20} {len(frames):>7} {human_size(total):>10}   {clips}")
    print(f"\nin {outdir}")


# --------------------------------------------------------------------------- #
#  headless runner
# --------------------------------------------------------------------------- #

def run_headless(args):
    session = Session(args)
    stopping = {"now": False}

    def stop(*_):
        stopping["now"] = True
    signal.signal(signal.SIGINT, stop)
    signal.signal(signal.SIGTERM, stop)

    print(f"timescreenlapse {VERSION} -> {session.folder}\n"
          f"one frame every {args.interval}s, Ctrl-C to stop", file=sys.stderr)
    notify("Timescreenlapse recording",
           f"one frame every {args.interval:g}s -> {session.folder.name}", args.quiet)

    next_shot = time.monotonic()
    while not stopping["now"]:
        ok, result, is_link = session.capture()
        if ok:
            print(f"\r{session.summary()}   ", end="", file=sys.stderr, flush=True)
        else:
            print(f"\n[{session.count + 1}] {result}", file=sys.stderr)
        if args.frames and session.count >= args.frames:
            break
        if args.duration and session.elapsed >= args.duration:
            break
        next_shot += args.interval
        while not stopping["now"]:
            remaining = next_shot - time.monotonic()
            if remaining <= 0:
                break
            time.sleep(min(remaining, 0.25))

    print(file=sys.stderr)
    session.close()
    print(f"Stopped: {session.summary()}\n{session.folder}", file=sys.stderr)
    notify("Timescreenlapse stopped", session.summary(), args.quiet)
    if session.count > 1 and args.then_video:
        assemble(session.folder, args.then_video, args.fps, args.width, args.quiet)
    return 0


# --------------------------------------------------------------------------- #
#  tray applet
# --------------------------------------------------------------------------- #

def run_tray(args):
    try:
        from PyQt5.QtCore import Qt, QTimer
        from PyQt5.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
        from PyQt5.QtWidgets import (QAction, QApplication, QInputDialog, QMenu,
                                     QMessageBox, QSystemTrayIcon)
    except ImportError as exc:
        die(f"PyQt5 is needed for the tray applet ({exc}); use --nogui instead")

    app = QApplication(sys.argv[:1])
    app.setApplicationName("timescreenlapse")
    app.setQuitOnLastWindowClosed(False)

    if not QSystemTrayIcon.isSystemTrayAvailable():
        die("no system tray available; use --nogui instead")

    # The panel may be light or dark, so the ring borrows the theme's own text
    # colour; only the recording dot is a fixed red.
    ink = app.palette().windowText().color()
    faded = QColor(ink)
    faded.setAlpha(110)

    def make_icon(state):
        """A shutter ring: red dot while recording, two bars while paused."""
        pixmap = QPixmap(64, 64)
        pixmap.fill(Qt.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setPen(QPen(faded if state == "paused" else ink, 6))
        painter.setBrush(Qt.NoBrush)
        painter.drawEllipse(6, 6, 52, 52)
        if state == "paused":
            painter.setPen(QPen(faded, 7, Qt.SolidLine, Qt.RoundCap))
            painter.drawLine(26, 22, 26, 42)
            painter.drawLine(38, 22, 38, 42)
        else:
            painter.setPen(Qt.NoPen)
            painter.setBrush(QColor("#e5484d") if state == "recording" else ink)
            painter.drawEllipse(22, 22, 20, 20)
        painter.end()
        return QIcon(pixmap)

    icons = {state: make_icon(state) for state in ("recording", "paused", "busy")}
    session = Session(args)
    state = {"paused": args.start_paused, "flash": False}

    tray = QSystemTrayIcon(icons["paused" if args.start_paused else "recording"])
    menu = QMenu()
    act_pause = QAction("Pause")
    act_now = QAction("Capture now")
    act_open = QAction("Open folder")
    act_video = QAction("Assemble video…")
    act_gif = QAction("Assemble GIF…")
    act_quit = QAction("Stop and quit")
    header = QAction(f"timescreenlapse {VERSION}")
    header.setEnabled(False)
    font = QFont()
    font.setBold(True)
    header.setFont(font)
    for action in (header, None, act_pause, act_now, None, act_open,
                   act_video, act_gif, None, act_quit):
        menu.addSeparator() if action is None else menu.addAction(action)
    tray.setContextMenu(menu)

    timer = QTimer()
    timer.setTimerType(Qt.PreciseTimer)
    timer.setInterval(int(args.interval * 1000))

    def refresh():
        act_pause.setText("Resume" if state["paused"] else "Pause")
        tray.setToolTip(
            f"timescreenlapse {VERSION}{'paused' if state['paused'] else 'recording'}\n"
            f"{session.summary()}\n"
            f"every {args.interval:g}s · {session.width}x{session.height}"
            f"{' scaled to ' + str(args.scale) + 'px' if args.scale else ''}\n"
            f"{session.folder}")
        tray.setIcon(icons["busy"] if state["flash"]
                     else icons["paused" if state["paused"] else "recording"])

    def unflash():
        state["flash"] = False
        refresh()

    def shoot():
        ok, result, _ = session.capture()
        if ok:
            state["flash"] = True
            refresh()
            QTimer.singleShot(220, unflash)
        else:
            refresh()
            if session.failed in (1, 10, 100):   # complain, but do not nag
                tray.showMessage("Capture failed", str(result),
                                 QSystemTrayIcon.Warning, 4000)
        if args.frames and session.count >= args.frames:
            finish(f"reached {args.frames} frames")
        elif args.duration and session.elapsed >= args.duration:
            finish(f"reached {human_time(args.duration)}")

    def toggle_pause():
        state["paused"] = not state["paused"]
        timer.stop() if state["paused"] else timer.start()
        refresh()

    def open_folder():
        subprocess.Popen(["xdg-open", str(session.folder)],
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    def assemble_dialog(kind):
        if session.count < 2:
            QMessageBox.information(None, "timescreenlapse",
                                    "Not enough frames yet.")
            return
        fps, ok = QInputDialog.getInt(
            None, f"Assemble {kind.upper()}",
            f"{session.count} frames captured every {args.interval:g}s.\n"
            f"Playback rate (fps) — {args.interval:g} fps would be real time:",
            args.fps, 1, 60, 1)
        if not ok:
            return
        was_paused = state["paused"]
        if not was_paused:
            timer.stop()
        try:
            out = assemble(session.folder, kind, fps, args.width, quiet=True)
            tray.showMessage("Timelapse ready",
                             f"{out.name} · {human_size(out.stat().st_size)} · "
                             f"{session.count / fps:.1f} s",
                             QSystemTrayIcon.Information, 5000)
        except SystemExit as exc:
            QMessageBox.warning(None, "timescreenlapse", str(exc) or "encoding failed")
        if not was_paused:
            timer.start()

    def finish(reason=""):
        timer.stop()
        session.close()
        notify("Timescreenlapse stopped",
               session.summary() + (f" ({reason})" if reason else ""), args.quiet)
        print(f"{session.summary()}\n{session.folder}", file=sys.stderr)
        if session.count > 1 and args.then_video:
            assemble(session.folder, args.then_video, args.fps, args.width, args.quiet)
        app.quit()

    def activated(reason):
        if reason == QSystemTrayIcon.Trigger:
            toggle_pause()

    timer.timeout.connect(shoot)
    act_pause.triggered.connect(toggle_pause)
    act_now.triggered.connect(shoot)
    act_open.triggered.connect(open_folder)
    act_video.triggered.connect(lambda: assemble_dialog("mp4"))
    act_gif.triggered.connect(lambda: assemble_dialog("gif"))
    act_quit.triggered.connect(lambda: finish())
    tray.activated.connect(activated)
    signal.signal(signal.SIGINT, lambda *_: finish("interrupted"))
    signal.signal(signal.SIGTERM, lambda *_: finish("terminated"))
    heartbeat = QTimer(app)          # wake the interpreter so Ctrl-C is noticed
    heartbeat.timeout.connect(lambda: None)
    heartbeat.start(300)

    tray.show()
    refresh()
    notify("Timescreenlapse recording",
           f"one frame every {args.interval:g}s -> {session.folder.name}", args.quiet)
    if not state["paused"]:
        shoot()          # first frame straight away
        timer.start()
    return app.exec_()


# --------------------------------------------------------------------------- #
#  command line
# --------------------------------------------------------------------------- #

def main():
    parser = argparse.ArgumentParser(
        prog="timescreenlapse", add_help=False,
        description="Capture the screen every N seconds and make a timelapse of it.")
    add = parser.add_argument
    add("-i", "--interval", default="10", metavar="SECS")
    add("-o", "--outdir", default=str(DEFAULT_OUTDIR), metavar="DIR")
    add("-j", "--jpeg", nargs="?", const=3, type=int, default=None, metavar="Q")
    add("-s", "--scale", type=int, default=None, metavar="W")
    add("-r", "--region", default=None, metavar="GEOM")
    add("--select", action="store_true")
    add("-n", "--frames", type=int, default=None, metavar="N")
    add("-d", "--duration", default=None, metavar="T")
    add("--no-dedup", dest="dedup", action="store_false")
    add("--nogui", action="store_true")
    add("--start-paused", action="store_true")
    add("-q", "--quiet", action="store_true")
    add("--video", nargs="?", const="", default=None, metavar="DIR")
    add("--gif", nargs="?", const="", default=None, metavar="DIR")
    add("--fps", type=int, default=10)
    add("--width", type=int, default=None, metavar="W")
    add("--list", action="store_true")
    add("--stop", action="store_true")
    add("--toggle", action="store_true")
    add("--status", action="store_true")
    add("-h", "--help", action="store_true")
    add("-V", "--version", action="store_true")
    args = parser.parse_args()

    if args.help:
        with open(__file__) as handle:
            next(handle)                       # skip the shebang
            for line in handle:
                if not line.startswith("#"):
                    break
                print(line[2:] if line.startswith("# ") else line[1:], end="")
        return 0
    if args.version:
        print(f"timescreenlapse {VERSION}")
        return 0

    args.outdir = Path(args.outdir).expanduser()

    if args.list:
        list_sessions(args.outdir)
        return 0

    # -- talk to an already running applet -- #
    running = read_lock()
    if args.status:
        if running:
            frames = len(frames_of(running[1]))
            print(f"recording · {frames} frame{'s' if frames != 1 else ''} · {running[1]}")
        else:
            print("not running")
        return 0 if running else 1
    if args.stop or (args.toggle and running):
        if stop_running(args.quiet):
            return 0
        print("nothing to stop", file=sys.stderr)
        return 0 if args.toggle else 1
    if running and not (args.video is not None or args.gif is not None):
        pid, folder = running
        message = (f"already recording into {folder.name} "
                   f"({len(frames_of(folder))} frames so far)")
        print(f"timescreenlapse: {message}\nUse the tray icon to pause, "
               "--stop to finish, or --toggle for a start/stop button.",
              file=sys.stderr)
        notify("Timescreenlapse is already recording",
               f"{message}.\nClick the tray icon to pause it.", args.quiet)
        return 0

    # -- assemble an existing session and stop -- #
    if args.video is not None or args.gif is not None:
        kind = "gif" if args.gif is not None else "mp4"
        target = args.gif if kind == "gif" else args.video
        if target:
            folder = Path(target).expanduser()
            if not folder.is_dir():
                candidate = args.outdir / target
                folder = candidate if candidate.is_dir() else folder
            if not folder.is_dir():
                die(f"no such session: {target}")
        else:
            found = sessions(args.outdir)
            if not found:
                die(f"no sessions in {args.outdir}")
            folder = found[-1]
        assemble(folder, kind, args.fps, args.width, args.quiet)
        return 0

    # -- otherwise: record -- #
    for tool in ("ffmpeg", "xdpyinfo"):
        if not shutil.which(tool):
            die(f"{tool} is required but not installed")
    if os.environ.get("XDG_SESSION_TYPE") == "wayland":
        die("this build grabs X11 (x11grab); log into an X11 session")

    try:
        args.interval = parse_duration(args.interval)
    except ValueError as exc:
        die(str(exc))
    if args.interval < 0.2:
        die("interval must be at least 0.2 s")
    if args.duration:
        try:
            args.duration = parse_duration(args.duration)
        except ValueError as exc:
            die(str(exc))
    if args.jpeg is not None and not 1 <= args.jpeg <= 31:
        die("--jpeg quality must be between 1 (best) and 31")

    if args.select:
        args.geometry = select_region()
    elif args.region:
        args.geometry = parse_region(args.region)
    else:
        width, height = screen_geometry()
        args.geometry = (width // 2 * 2, height // 2 * 2, 0, 0)

    args.then_video = None      # reserved: assemble automatically when stopping
    return run_headless(args) if args.nogui else run_tray(args)


if __name__ == "__main__":
    sys.exit(main())
[Desktop Entry]
Type=Application
Name=Timescreenlapse
GenericName=Screen timelapse recorder
Comment=Capture the screen every few seconds and assemble it into a timelapse
Exec=/home/laussy/bin/timescreenlapse
Icon=timescreenlapse
Terminal=false
Categories=AudioVideo;Recorder;
Keywords=screenshot;timelapse;capture;record;screen;
StartupNotify=false
Actions=stop;every30;everyminute;region;browse;

[Desktop Action stop]
Name=Stop recording
Exec=/home/laussy/bin/timescreenlapse --stop

[Desktop Action every30]
Name=Record every 30 seconds
Exec=/home/laussy/bin/timescreenlapse -i 30

[Desktop Action everyminute]
Name=Record every minute
Exec=/home/laussy/bin/timescreenlapse -i 1m

[Desktop Action region]
Name=Record a region…
Exec=/home/laussy/bin/timescreenlapse --select

[Desktop Action browse]
Name=Open the timelapse folder
Exec=xdg-open /home/laussy/Pictures/Timescreenlapse