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.
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.
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.
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.
#!/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