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.5.0, reached on 3 September (2026).
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. The frames that were merely held still occupy their slot, so the timelapse keeps its true pacing. Version 1.2.0 stopped relying on that: the film used to be assembled with -i frame-%06d.png, which is ffmpeg's image2 demuxer walking the numbering, and it stops dead at the first missing number—quietly, exit code 0, having written a perfectly valid clip of however many frames it got before the hole. Delete frame 3 of 166 by hand and the GIF is one frame long, reported as a success. Assembly now globs the folder and takes what is there, gaps and all, and counts the frames on disk rather than the ones it thinks it captured.
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 --resume # carry on the latest session
timescreenlapse --new # a fresh one, without being asked
timescreenlapse --video # latest session -> mp4
timescreenlapse --gif --fps 12 # ... or an animated GIF
timescreenlapse --list # past sessions, AF tags, frames, sizes
timescreenlapse --stop # finish the running session
timescreenlapse --delete-last # bin the most recent session
timescreenlapse --delete-last --permanent # ... for good, not to the trash
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.
A session is a folder, and since 1.3.0 quitting the applet no longer ends it for good: --resume reopens the last one and carries on the old numbering in the old folder, so a film can be shot over several sittings and still assemble as one story rather than as today's instalment. A resumed session adopts the interval, region, format and frame size it was recorded with, whatever the command line asks for, because ffmpeg will not encode a folder whose frames change size halfway through; index.tsv writes those down now, and for the older sessions that do not have them the last frame is measured with ffprobe.
Version 1.4.0 turned the startup question into a session manager rather than a fork in the road. Coming back to a session is usually about the clip and not about more frames, so the list that opens when neither --new nor --resume has said which way to go will also assemble, open and delete; only Resume and New session start the camera, and closing the window records nothing at all. The headless runner puts the same list on the terminal.
Two bugs went out with that release, both of the kind that only appear in use: ffmpeg was reading the terminal's standard input and eating what one typed at the applet (-nostdin settles it), and quitting the tray applet printed a traceback when a timer tick landed after the session had already been closed.
Since 1.5.0 every session carries an Anno Fabri tag—five base62 characters standing for the second it began, so that af 05JM4 reads that instant straight back out of it. The tag is derived from the folder's own timestamp rather than stored in it, and that is the whole of the trick: a tag is the start time, losslessly, so computing it on demand is as good as having recorded it. Every session ever made acquired one without being renamed, and a folder renamed by hand does not lose its identity.
Assembled clips are named by it—timelapse-05JM4.mp4 rather than 2026-09-03_100000.mp4—because the clip is the thing that leaves the folder, mailed or put on a page somewhere, and a name that decodes to its own date still says which session it came from and when. Sessions older than the Anno Fabri epoch (18 August (2026)) have no tag: they show a dash in --list and keep the old <session>.mp4 name.
session AF frames size last clips
2026-08-17_183100 - 333 790.6 MB 17 days ago 2026-08-17_183100.gif, 2026-08-17_183100.mp4
2026-08-25_190135 02xcj 369 568.9 MB 9 days ago 2026-08-25_190135.mp4
2026-08-26_115129 03FIO 1 464.1 kB 8 days ago
af 03FIO answers Wednesday 26 August 2026, 11:51:29—which is, to the second, the folder it names.
A session can be six thousand frames and three gigabytes, so nothing is deleted on the strength of a flag alone. --delete-last bins the most recent one from the terminal, which until 1.5.0 needed the startup chooser: it prints what is about to go—name, tag, frames, size, and any clips already made—and then asks. --permanent erases instead of trashing, --yes answers in advance, and with no terminal to ask at and no --yes it refuses and says so rather than guessing.
Three guards sit under it. It refuses the session that is being recorded into at that moment, and says to --stop it first. It refuses any folder that is not a dated session of the outdir, so that a stray --outdir or a hand-typed path cannot turn the thing into rm -rf. And it comes before the "already recording" notice in the command order, because wanting to throw away the last session while a new one is running is an ordinary thing to want, and the two are different folders.
#!/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
# timescreenlapse --resume # add frames to the latest session
# timescreenlapse --new # start a fresh one without asking
#
# A session survives quitting. When earlier sessions exist and neither --new nor
# --resume says which way to go, startup opens on the list of them (frames,
# size, when they were last touched, clips already made) and nothing is captured
# until you say so: any session can be resumed, assembled into an mp4 or a GIF,
# opened, or deleted, and only "Resume" and "New session" start the camera.
# Closing the window records nothing at all. The headless runner puts the same
# list on the terminal. Resumed frames carry on the old numbering in the old
# folder, so the assembled clip is the whole story, not just today's instalment.
#
# Deleting goes through the trash (gio) unless you ask for it to be permanent,
# and only ever touches a dated session folder of the outdir.
#
# 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
# --resume [DIR] carry on an earlier session (default: the latest one)
# --new start a new session without asking (skips the list)
# -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, sizes and AF tags
#
# Anno Fabri tags:
# Every session carries one -- five base62 characters standing for the
# second it began, so `af 05JM4` reads that instant straight back out.
# It is DERIVED from the folder's own timestamp, not stored, so every
# session ever recorded has one and nothing had to be renamed. Assembled
# clips are named by it: timelapse-05JM4.mp4. Sessions from before the
# Anno Fabri epoch (18 August 2026) have no tag; those still assemble
# under the old <session>.mp4 name.
#
# Deleting from the command line:
# --delete-last bin the most recent session (the trash, by default)
# --permanent with --delete-last: erase it instead of trashing it
# --yes with --delete-last: do not ask first
#
# 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)
#
# 1.2.0 Assembling reads the frames by glob instead of by the frame-%06d
# counter. Deleting frames by hand used to truncate the clip in silence:
# ffmpeg's image2 demuxer stops at the first missing number, exits 0, and
# writes whatever it had -- delete frame 3 of 166 and the GIF is one
# frame long, reported as a success. Assembly errors are now raised as
# AssemblyError and shown in the dialog, instead of the bare "1" the
# SystemExit of die() used to put there; the dialog counts the frames
# on disk, not the ones captured.
# 1.3.0 Sessions can be resumed. Quitting the applet no longer ends the
# recording for good: --resume, or the startup chooser, reopens an
# earlier folder and keeps numbering where it stopped. A resumed session
# adopts the interval, region, format and frame size it was recorded
# with (index.tsv now writes scale/jpeg down, and the last frame is
# measured with ffprobe for the sessions that do not), because ffmpeg
# will not encode a folder whose frames change size halfway.
# 1.4.0 That startup list is a session manager rather than a fork in the road.
# Coming back to a session is usually about the clip, not about more
# frames, so it assembles, opens and deletes from the list too, and only
# Resume and New session start capturing -- picking a session no longer
# commits you to recording into it. Deletions go to the trash unless
# told otherwise, and refuse any folder that is not a dated session of
# the outdir. Two older bugs went with it: ffmpeg was reading the
# terminal's standard input and eating what was typed at the applet
# (-nostdin now), and quitting the tray applet printed a traceback when
# a timer tick landed after the session had been closed.
# 1.5.0 Anno Fabri tags, and --delete-last. A session's tag is computed from
# the second its folder is named after rather than stored in it, so the
# seventeen sessions already on disk acquired theirs without being
# touched and a folder renamed by hand does not lose its identity --
# the tag IS the start time, losslessly, which is what makes deriving
# it as good as recording it. Clips are named timelapse-<tag>.mp4
# instead of <session>.mp4, so a clip carried off somewhere else still
# says which session it came from and when. --delete-last bins the most
# recent session from the terminal, which until now needed the startup
# chooser; it goes to the trash unless --permanent, asks unless --yes,
# refuses a session that is being recorded into, and refuses to run
# unasked with no terminal to ask at.
VERSION = "1.5.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
# --------------------------------------------------------------------------- #
AF_TOOL = "AF" # ~/bin/AF, also on PATH as `af`
AF_RE = re.compile(r"[0-9A-Za-z]{5,6}")
SESSION_RE = re.compile(r"(\d{4})-(\d\d)-(\d\d)_(\d\d)(\d\d)(\d\d)")
_af_cache = {}
def af_tag(epoch):
"""The Anno Fabri tag for an instant, or None if there is none to be had.
The tag is not a name given to the session, it is the second it started,
written in five base62 characters -- so `af 05JM4` prints the date back and
nothing has to be stored anywhere for that to keep working. Which is why
this is computed on demand from the folder's own timestamp instead of being
written into it: every session ever recorded has a tag, including the ones
that predate this, and none of them had to be renamed to get one.
None comes back when AF is not installed, or when the instant is outside
the window AF covers (it begins on 18 August 2026, its epoch, so sessions
older than that have no tag). Callers fall back to the folder name.
"""
key = int(epoch)
if key in _af_cache:
return _af_cache[key]
tag = None
if shutil.which(AF_TOOL):
try:
proc = subprocess.run([AF_TOOL, f"@{key}"], capture_output=True,
text=True, timeout=5)
first = (proc.stdout or "").strip().split()
if proc.returncode == 0 and first and AF_RE.fullmatch(first[0]):
tag = first[0]
except (OSError, subprocess.SubprocessError):
tag = None
_af_cache[key] = tag
return tag
def session_start(folder):
"""The instant a session began, read out of its folder name."""
match = SESSION_RE.fullmatch(Path(folder).name)
if not match:
return None
try:
return datetime(*(int(part) for part in match.groups())).timestamp()
except ValueError: # a name that looks like a date but is not
return None
def session_tag(folder):
"""The AF tag of a session folder, or None."""
when = session_start(folder)
return af_tag(when) if when is not None else None
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())
def frame_number(path):
"""frame-000042.png -> 42."""
digits = re.search(r"(\d+)", path.stem)
return int(digits.group(1)) if digits else 0
def last_frame_number(folder):
return max((frame_number(p) for p in frames_of(folder)), default=0)
def read_settings(folder):
"""How a session was recorded, as its index.tsv remembers it.
Every run of a session -- the first one and each resumption -- files a
"# timescreenlapse" header line, so the last one read is the settings that
were in force when it last grew. Sessions recorded before 1.3.0 wrote only
the geometry and the interval; the rest is filled in from the frames.
"""
settings = {}
try:
text = (folder / INDEX_NAME).read_text(errors="replace")
except OSError:
return settings
for line in text.splitlines():
if not line.startswith("# timescreenlapse"):
continue
for field in line.split("\t"):
geometry = re.fullmatch(r"(\d+)x(\d+)\+(\d+)\+(\d+)", field)
if geometry:
settings["geometry"] = tuple(int(g) for g in geometry.groups())
for key, pattern in (("interval", r"every ([0-9.]+)s"),
("scale", r"scale=(\d+)"),
("jpeg", r"jpeg=(\d+)")):
found = re.fullmatch(pattern, field)
if found:
settings[key] = (float(found.group(1)) if key == "interval"
else int(found.group(1)))
return settings
def session_info(folder):
"""Everything the chooser and a resumption need to know about a folder."""
frames = frames_of(folder)
seen, total = set(), 0
for path in frames:
stat = path.stat()
if stat.st_ino in seen:
continue
seen.add(stat.st_ino)
total += stat.st_size
try:
clips = sorted(p.name for p in folder.iterdir() if p.suffix in (".mp4", ".gif"))
except OSError:
clips = []
info = read_settings(folder)
info.update(folder=folder, frames=len(frames), bytes=total, clips=clips,
last=(frames[-1] if frames else None),
suffix=(frames[-1].suffix if frames else None),
touched=max((p.stat().st_mtime for p in frames),
default=folder.stat().st_mtime))
return info
def probe_size(path):
"""(width, height) of a frame on disk, or None when ffprobe cannot say."""
if not path or not shutil.which("ffprobe"):
return None
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x", str(path)],
capture_output=True, text=True,
stdin=subprocess.DEVNULL).stdout.strip()
match = re.fullmatch(r"(\d+)x(\d+)", out)
return (int(match.group(1)), int(match.group(2))) if match else None
def human_ago(when):
delta = max(0.0, time.time() - when)
if delta < 90:
return "just now"
if delta < 3600:
return f"{delta / 60:.0f} min ago"
if delta < 36 * 3600:
return f"{delta / 3600:.0f} h ago"
return f"{delta / 86400:.0f} days ago"
def describe(info):
"""One line about a session, for the chooser lists."""
line = (f"{info['folder'].name} {info['frames']} frame"
f"{'s' if info['frames'] != 1 else ''} · {human_size(info['bytes'])}")
if info.get("interval"):
line += f" · every {info['interval']:g}s"
line += f" · {human_ago(info['touched'])}"
if info["clips"]:
line += " · " + ", ".join(info["clips"])
return line
# --------------------------------------------------------------------------- #
# capture engine
# --------------------------------------------------------------------------- #
class Session:
"""One recording session: owns the folder, grabs and stores the frames."""
def __init__(self, args, resume=None):
self.args = args
self.width, self.height, self.x, self.y = args.geometry
self.display = os.environ.get("DISPLAY", ":0")
self.resumed = resume is not None
stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
if self.resumed:
self.folder = resume
info = session_info(self.folder)
# The frames of one session must all look alike, or ffmpeg refuses
# to encode them: keep the format the folder already has, whatever
# the command line asked for (adopt_session has already matched the
# interval, the region and the frame size).
self.suffix = info["suffix"] or (".jpg" if args.jpeg is not None else ".png")
self.before = last_frame_number(self.folder)
self._last_path = info["last"]
else:
self.suffix = ".jpg" if args.jpeg is not None else ".png"
self.folder = args.outdir / stamp
self.before = 0
self._last_path = None
self.folder.mkdir(parents=True, exist_ok=True)
self.index = (self.folder / INDEX_NAME).open("a", buffering=1)
# Written down as well as derivable: index.tsv is what a session says
# about itself, and the tag survives here even if the folder is renamed.
self.tag = session_tag(self.folder)
self.index.write(f"# timescreenlapse {VERSION}"
f"{' resumed' if self.resumed else ''}\t{stamp}\t"
f"{('AF ' + self.tag) if self.tag else 'AF -'}\t"
f"{self.width}x{self.height}+{self.x}+{self.y}\t"
f"every {self.args.interval}s"
+ (f"\tscale={args.scale}" if args.scale else "")
+ ("\tsize=%dx%d" % args.force_size
if getattr(args, "force_size", None) else "")
+ (f"\tjpeg={args.jpeg}" if args.jpeg is not None else "")
+ "\n")
self.count = self.before # frames written (links included)
self.linked = 0 # frames that were identical to their predecessor
self.failed = 0
self.started = time.time()
# Carry the dedup chain across the break, so the first frame after a
# resumption is hard-linked too when nothing has changed meanwhile.
self._last_hash = None
if self._last_path is not None:
try:
self._last_hash = hashlib.blake2b(self._last_path.read_bytes(),
digest_size=16).hexdigest()
except OSError:
self._last_path = None
write_lock(self.folder)
# -- one grab ----------------------------------------------------------- #
def _ffmpeg_cmd(self, target):
cmd = ["ffmpeg", "-nostdin", "-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 getattr(self.args, "force_size", None):
# A resumed session whose frames are not the size this capture
# would produce: scale to them exactly, height included.
cmd += ["-vf", "scale=%d:%d:flags=lanczos" % self.args.force_size]
elif 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,
stdin=subprocess.DEVNULL)
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
@property
def fresh(self):
"""Frames shot since this run started -- what -n and -d are about."""
return self.count - self.before
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):
head = (f"{self.count} frames, {self.fresh} new in {human_time(self.elapsed)}"
if self.before else
f"{self.count} frame{'s' if self.count != 1 else ''} in "
f"{human_time(self.elapsed)}")
return (f"{head} · {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 and not self.resumed:
try:
(self.folder / INDEX_NAME).unlink(missing_ok=True)
self.folder.rmdir()
except OSError:
pass
# --------------------------------------------------------------------------- #
# assembling
# --------------------------------------------------------------------------- #
class AssemblyError(Exception):
"""Encoding failed -- carries the message, so a GUI caller can show it."""
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:
raise AssemblyError(f"no frames in {folder}")
suffix = frames[0].suffix
# Glob, not the frame-%06d counter. The counter is ffmpeg's image2 demuxer,
# which walks the numbering and STOPS DEAD at the first missing number --
# and it does so quietly, exit code 0, having written a perfectly valid clip
# of however many frames it got before the hole. Delete one frame by hand
# and you get a one-frame GIF and no complaint. Glob just takes what is
# there, in sorted order, gaps and all.
pattern = str(folder / (FRAME_GLOB + suffix))
# Named by the session's Anno Fabri tag: a clip is the thing that leaves
# this folder -- mailed, put on the wiki, dropped somewhere else -- and
# "timelapse-05JM4.mp4" still says which session it came from and, since
# the tag IS the second that session began, exactly when. A session older
# than the AF epoch has no tag and keeps the old <session>.mp4 name.
tag = session_tag(folder)
stem = f"timelapse-{tag}" if tag else folder.name
out = folder / f"{stem}.{'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", "-nostdin", "-loglevel", "error", "-y", "-framerate", str(fps),
"-pattern_type", "glob", "-i", pattern,
"-vf", vf, "-loop", "0", str(out)]
else:
scale = f"scale={width}:-2:flags=lanczos," if width else ""
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-framerate", str(fps),
"-pattern_type", "glob", "-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,
stdin=subprocess.DEVNULL)
if proc.returncode != 0:
raise AssemblyError(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} {'AF':<7} {'frames':>7} {'size':>10} "
f"{'last':>12} clips")
for folder in found:
info = session_info(folder)
print(f"{folder.name:<20} {session_tag(folder) or '-':<7} {info['frames']:>7} "
f"{human_size(info['bytes']):>10} {human_ago(info['touched']):>12} "
+ ", ".join(info["clips"]))
print(f"\nin {outdir}\nresume one with: timescreenlapse --resume SESSION"
f"\nan AF tag reads back as its date: af {session_tag(found[-1]) or 'TAG'}")
# --------------------------------------------------------------------------- #
# picking a session at startup
# --------------------------------------------------------------------------- #
def discard_session(folder, outdir, permanent=False):
"""Bin a session folder; returns the word for what happened to it.
The trash is the default because a session can be gigabytes of frames one
is only fairly sure about. Deleting for good is a separate answer, and both
refuse anything that is not a session folder of this outdir -- a stray
--outdir or a hand-typed path must not turn this into rm -rf.
"""
folder = Path(folder).resolve()
if folder.parent != Path(outdir).resolve() or not re.fullmatch(
r"\d{4}-\d\d-\d\d_\d{6}", folder.name):
raise OSError(f"{folder} is not a session of {outdir}")
if not permanent:
if not shutil.which("gio"):
raise OSError("no gio here to reach the trash; delete for good instead")
proc = subprocess.run(["gio", "trash", "--", str(folder)],
capture_output=True, text=True)
if proc.returncode != 0:
raise OSError(proc.stderr.strip() or "gio trash refused the folder")
return "moved to the trash"
shutil.rmtree(folder)
return "deleted"
def choose_session_dialog(args):
"""What to do at startup: an earlier session, or a new recording?
Coming back to a session usually means wanting the clip out of it, not more
frames, so assembling, opening and deleting are on the same list as Resume,
and none of them ends the dialog: only Resume, New session, and closing it
do. Returns ('new', None), ('resume', folder), or None when the dialog is
dismissed -- which means "record nothing at all".
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QAbstractItemView, QApplication, QDialog,
QDialogButtonBox, QHBoxLayout, QInputDialog,
QLabel, QListWidget, QMessageBox, QPushButton,
QVBoxLayout)
if not sessions(args.outdir):
return ("new", None)
dialog = QDialog()
dialog.setWindowTitle(f"timescreenlapse {VERSION}")
layout = QVBoxLayout(dialog)
layout.addWidget(QLabel("Carry on with an earlier session, make a clip out of one, "
"or start a new recording?"))
listing = QListWidget()
listing.setSelectionMode(QAbstractItemView.SingleSelection)
listing.setTextElideMode(Qt.ElideRight) # a long list of clips
listing.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
layout.addWidget(listing)
row = QHBoxLayout()
act_resume = QPushButton("Resume recording")
act_video = QPushButton("Assemble video…")
act_gif = QPushButton("Assemble GIF…")
act_open = QPushButton("Open folder")
act_delete = QPushButton("Delete…")
for button in (act_resume, act_video, act_gif, act_open, act_delete):
row.addWidget(button)
layout.addLayout(row)
status = QLabel(str(args.outdir))
status.setEnabled(False)
layout.addWidget(status)
buttons = QDialogButtonBox()
fresh = buttons.addButton("New session", QDialogButtonBox.AcceptRole)
buttons.addButton(QDialogButtonBox.Close)
layout.addWidget(buttons)
infos = []
picked = {"what": None}
def reload(keep=0):
infos.clear()
infos.extend(session_info(folder) for folder in reversed(sessions(args.outdir)))
listing.clear()
for info in infos:
listing.addItem(describe(info))
if infos:
listing.setCurrentRow(min(max(keep, 0), len(infos) - 1))
for button in (act_resume, act_video, act_gif, act_open, act_delete):
button.setEnabled(bool(infos))
def current():
return infos[listing.currentRow()] if infos and listing.currentRow() >= 0 else None
def say(message, faded=False):
status.setText(message)
status.setEnabled(not faded)
def resume():
if current():
picked["what"] = "resume"
dialog.accept()
def start_new():
picked["what"] = "new"
dialog.accept()
def make(kind):
info = current()
if not info:
return
if info["frames"] < 2:
QMessageBox.information(dialog, "timescreenlapse",
f"{info['folder'].name} has "
f"{info['frames']} frame — nothing to assemble yet.")
return
pace = (f", captured every {info['interval']:g}s" if info.get("interval") else "")
fps, ok = QInputDialog.getInt(
dialog, f"Assemble {kind.upper()}",
f"{info['folder'].name} — {info['frames']} frames{pace}.\n"
f"Playback rate (fps)"
+ (f" — {info['interval']:g} fps would be real time" if info.get("interval") else "")
+ ":", args.fps, 1, 60, 1)
if not ok:
return
keep = listing.currentRow()
say(f"Assembling {info['frames']} frames at {fps} fps…")
QApplication.setOverrideCursor(Qt.WaitCursor)
QApplication.processEvents()
try:
out = assemble(info["folder"], kind, fps, args.width, quiet=args.quiet)
except AssemblyError as exc:
QApplication.restoreOverrideCursor()
say(str(args.outdir), faded=True)
QMessageBox.warning(dialog, "timescreenlapse", str(exc) or "encoding failed")
return
QApplication.restoreOverrideCursor()
reload(keep)
say(f"{out.name} · {human_size(out.stat().st_size)} · "
f"{info['frames'] / fps:.1f} s of playback")
def open_folder():
info = current()
if info:
subprocess.Popen(["xdg-open", str(info["folder"])],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def discard():
info = current()
if not info:
return
keep = listing.currentRow()
box = QMessageBox(dialog)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("timescreenlapse")
box.setText(f"Delete the session {info['folder'].name}?")
box.setInformativeText(
f"{info['frames']} frames · {human_size(info['bytes'])}"
+ (" · " + ", ".join(info["clips"]) + " would go too" if info["clips"] else ""))
trash = (box.addButton("Move to trash", QMessageBox.AcceptRole)
if shutil.which("gio") else None)
forever = box.addButton("Delete for good", QMessageBox.DestructiveRole)
cancel = box.addButton(QMessageBox.Cancel)
box.setDefaultButton(trash or cancel)
box.exec_()
if box.clickedButton() not in (trash, forever):
return
try:
what = discard_session(info["folder"], args.outdir,
permanent=box.clickedButton() is forever)
except OSError as exc:
QMessageBox.warning(dialog, "timescreenlapse", str(exc))
return
reload(keep)
say(f"{info['folder'].name} {what} · {human_size(info['bytes'])} freed")
# Enter must mean Resume and nothing else: Qt hands the default button to
# whichever one it fancies otherwise, and here that would start recording
# a brand new session by mistake.
for button in (act_video, act_gif, act_open, act_delete, fresh,
buttons.button(QDialogButtonBox.Close)):
button.setAutoDefault(False)
act_resume.setAutoDefault(True)
act_resume.setDefault(True)
act_resume.clicked.connect(resume)
act_video.clicked.connect(lambda: make("mp4"))
act_gif.clicked.connect(lambda: make("gif"))
act_open.clicked.connect(open_folder)
act_delete.clicked.connect(discard)
listing.itemDoubleClicked.connect(lambda *_: resume())
fresh.clicked.connect(start_new)
buttons.rejected.connect(dialog.reject)
dialog.resize(720, 400)
reload()
listing.setFocus()
if not dialog.exec_() or not picked["what"]:
return None
if picked["what"] == "new":
return ("new", None)
return ("resume", infos[max(listing.currentRow(), 0)]["folder"])
def choose_session_tty(args):
"""The same question on the terminal, for --nogui."""
if not sessions(args.outdir) or not sys.stdin.isatty():
return ("new", None)
while True:
infos = [session_info(folder)
for folder in reversed(sessions(args.outdir))][:30]
if not infos:
return ("new", None)
print("\nEarlier sessions:", file=sys.stderr)
for number, info in enumerate(infos, 1):
print(f" {number:>2}. {describe(info)}", file=sys.stderr)
print(" N resume session N vN assemble it into an mp4\n"
" n start a new session gN assemble it into a GIF\n"
" q quit dN delete it", file=sys.stderr)
try:
answer = input("timescreenlapse> ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
return None
if answer in ("", "n", "new"):
return ("new", None)
if answer in ("q", "quit"):
return None
verb, _, number = (answer[0], "", answer[1:]) if answer[:1] in "vgd" else ("", "", answer)
if not number.strip().isdigit() or not 1 <= int(number) <= len(infos):
print(f"1-{len(infos)}, optionally prefixed by v, g or d; or n, or q",
file=sys.stderr)
continue
info = infos[int(number) - 1]
if not verb:
return ("resume", info["folder"])
if verb in "vg":
if info["frames"] < 2:
print(f"{info['folder'].name} has {info['frames']} frame — "
"nothing to assemble yet", file=sys.stderr)
continue
kind = "mp4" if verb == "v" else "gif"
try:
assemble(info["folder"], kind, args.fps, args.width, args.quiet)
except AssemblyError as exc:
print(f"timescreenlapse: {exc}", file=sys.stderr)
continue
print(f"{info['folder'].name}: {info['frames']} "
f"frame{'s' if info['frames'] != 1 else ''}, {human_size(info['bytes'])}"
+ (" and " + ", ".join(info["clips"]) if info["clips"] else ""), file=sys.stderr)
try:
sure = input("delete it? [t]rash / [D]elete for good / [n]o: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
continue
if sure not in ("t", "trash", "d", "delete"):
continue
try:
what = discard_session(info["folder"], args.outdir,
permanent=sure in ("d", "delete"))
print(f"{info['folder'].name} {what}", file=sys.stderr)
except OSError as exc:
print(f"timescreenlapse: {exc}", file=sys.stderr)
def adopt_session(args, folder):
"""Match a resumed session's own settings; returns what had to be changed.
ffmpeg will not encode a folder whose frames change size or format halfway
through, so the old session wins over the defaults: its interval, its
region, its PNG-or-JPEG, and its exact frame size (measured, for the
sessions recorded before that was written down).
"""
info = session_info(folder)
notes = []
if not args.interval_given and info.get("interval"):
args.interval = info["interval"]
notes.append(f"every {args.interval:g}s")
if not args.geometry_given and info.get("geometry"):
args.geometry = info["geometry"]
if info["suffix"] == ".jpg" and args.jpeg is None:
args.jpeg = info.get("jpeg", 3)
notes.append(f"JPEG q{args.jpeg}")
elif info["suffix"] == ".png" and args.jpeg is not None:
args.jpeg = None
notes.append("PNG")
size = probe_size(info["last"])
if size and size != (args.geometry[0], args.geometry[1]):
args.force_size = size
notes.append(f"{size[0]}x{size[1]} frames")
return info, notes
def pick_session(args, gui):
"""Which folder to record into: None for a new one, else the old folder."""
folder = None
if args.new:
return None, []
if args.resume is not None:
if args.resume:
folder = Path(args.resume).expanduser()
if not folder.is_dir() and (args.outdir / args.resume).is_dir():
folder = args.outdir / args.resume
if not folder.is_dir():
die(f"no such session: {args.resume}")
else:
found = sessions(args.outdir)
if not found:
print("timescreenlapse: nothing to resume, starting a new session",
file=sys.stderr)
return None, []
folder = found[-1]
else:
choice = choose_session_dialog(args) if gui else choose_session_tty(args)
if choice is None:
sys.exit(0) # dismissed: record nothing
if choice[0] == "new":
return None, []
folder = choice[1]
if not frames_of(folder):
print(f"timescreenlapse: {folder.name} has no frames; starting it over",
file=sys.stderr)
return folder, adopt_session(args, folder)[1]
# --------------------------------------------------------------------------- #
# headless runner
# --------------------------------------------------------------------------- #
def run_headless(args):
resume, notes = pick_session(args, gui=False)
session = Session(args, resume)
stopping = {"now": False}
def stop(*_):
stopping["now"] = True
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
print(f"timescreenlapse {VERSION} -> {session.folder}", file=sys.stderr)
if session.resumed:
print(f"resuming after frame {session.before}"
+ (f" · keeping {', '.join(notes)}" if notes else ""), file=sys.stderr)
print(f"one frame every {args.interval:g}s, Ctrl-C to stop", file=sys.stderr)
notify("Timescreenlapse recording",
f"one frame every {args.interval:g}s -> {session.folder.name}"
+ (f" (resumed at {session.before})" if session.resumed else ""), 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.fresh >= 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:
try:
assemble(session.folder, args.then_video, args.fps, args.width, args.quiet)
except AssemblyError as exc:
die(str(exc))
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")}
# Ask before anything is created: the chooser can also say "not at all".
resume, notes = pick_session(args, gui=True)
session = Session(args, resume)
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'}"
f"{' (resumed session)' if session.resumed else ''}\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():
# Stopping does not unqueue a timeout Qt has already delivered, so one
# last shot can arrive after finish() closed the session: it used to
# write to the closed index and print a traceback on the way out.
if session.index.closed:
return
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.fresh >= 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):
# Count what is on disk, not what was captured: frames can be deleted
# between the shooting and the assembling, and then session.count lies.
on_disk = len(frames_of(session.folder))
if on_disk < 2:
QMessageBox.information(None, "timescreenlapse",
"Not enough frames yet.")
return
deleted = session.count - on_disk
note = f" ({deleted} since deleted)" if deleted > 0 else ""
fps, ok = QInputDialog.getInt(
None, f"Assemble {kind.upper()}",
f"{on_disk} frames{note}, 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"{on_disk / fps:.1f} s",
QSystemTrayIcon.Information, 5000)
except AssemblyError 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:
try:
assemble(session.folder, args.then_video, args.fps, args.width, args.quiet)
except AssemblyError as exc:
print(f"timescreenlapse: {exc}", file=sys.stderr)
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()
if session.resumed:
print(f"resuming {session.folder} after frame {session.before}"
+ (f" · keeping {', '.join(notes)}" if notes else ""), file=sys.stderr)
notify("Timescreenlapse recording",
(f"resuming {session.folder.name} after {session.before} frames, "
f"one every {args.interval:g}s" if session.resumed else
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=None, 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("--resume", nargs="?", const="", default=None, metavar="DIR")
add("--new", 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("--delete-last", action="store_true")
add("--permanent", action="store_true")
add("--yes", "-y", 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
# -- bin the most recent session and stop -- #
# Ahead of the "already recording" notice below, which would otherwise
# swallow this: wanting to throw away the last session while a new one runs
# is an ordinary thing to want, and the two are different folders.
if args.delete_last:
found = sessions(args.outdir)
if not found:
die(f"no sessions in {args.outdir}")
folder = found[-1]
if running and Path(running[1]).resolve() == folder.resolve():
die(f"{folder.name} is the session being recorded into right now; "
"--stop it first")
info = session_info(folder)
tag = session_tag(folder)
print(f"{folder.name}{' ' + tag if tag else ''}: {info['frames']} "
f"frame{'s' if info['frames'] != 1 else ''}, "
f"{human_size(info['bytes'])}"
+ (", " + ", ".join(info["clips"]) if info["clips"] else ""),
file=sys.stderr)
# A session is hours of somebody's screen and can be gigabytes; it is
# never deleted on the strength of the flag alone. With no terminal to
# ask at, --yes has to have said so in advance.
if not args.yes:
if not sys.stdin.isatty():
die("nothing deleted: no terminal to ask at, and --yes was not given")
question = ("delete it for good" if args.permanent
else "move it to the trash")
try:
sure = input(f"{question}? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print(file=sys.stderr)
sure = ""
if sure not in ("y", "yes"):
print("nothing deleted", file=sys.stderr)
return 1
try:
what = discard_session(folder, args.outdir, permanent=args.permanent)
except OSError as exc:
die(str(exc))
print(f"{folder.name} {what}", file=sys.stderr)
notify("Timescreenlapse", f"{folder.name} {what}", args.quiet)
return 0
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]
try:
assemble(folder, kind, args.fps, args.width, args.quiet)
except AssemblyError as exc:
die(str(exc))
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")
args.interval_given = args.interval is not None
try:
args.interval = parse_duration(args.interval if args.interval_given else "10")
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")
args.geometry_given = bool(args.select or args.region)
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.force_size = None # set when a resumed session needs its own size
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