mirror of
https://github.com/kasmtech/workspaces-images.git
synced 2026-01-23 02:25:24 +00:00
recorder
This commit is contained in:
parent
2afd833762
commit
0076cf4d4b
4 changed files with 329 additions and 3 deletions
|
|
@ -14,8 +14,8 @@ WORKDIR $HOME
|
|||
COPY ./src/ubuntu/install/chromium $INST_SCRIPTS/chromium/
|
||||
RUN bash $INST_SCRIPTS/chromium/install_chromium.sh && rm -rf $INST_SCRIPTS/chromium/
|
||||
|
||||
# Install unzip
|
||||
RUN apt-get update && apt-get install -y unzip
|
||||
# Install unzip and recording dependencies
|
||||
RUN apt-get update && apt-get install -y unzip ffmpeg python3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Update the desktop environment to be optimized for a single application
|
||||
RUN cp $HOME/.config/xfce4/xfconf/single-application-xfce-perchannel-xml/* $HOME/.config/xfce4/xfconf/xfce-perchannel-xml/
|
||||
|
|
@ -32,6 +32,8 @@ COPY ./src/common/chrome-managed-policies/urlblocklist.json /etc/chromium/polici
|
|||
|
||||
COPY ./src/ubuntu/install/chromium/custom_startup.sh $STARTUPDIR/custom_startup.sh
|
||||
RUN chmod +x $STARTUPDIR/custom_startup.sh
|
||||
COPY ./src/ubuntu/install/chromium/recording_api.py /usr/local/bin/recording_api.py
|
||||
RUN chmod +x /usr/local/bin/recording_api.py
|
||||
|
||||
# Install Custom Certificate Authority
|
||||
# COPY ./src/ubuntu/install/certificates $INST_SCRIPTS/certificates/
|
||||
|
|
@ -53,4 +55,7 @@ ENV HOME /home/kasm-user
|
|||
WORKDIR $HOME
|
||||
RUN mkdir -p $HOME && chown -R 1000:0 $HOME
|
||||
|
||||
EXPOSE 9222
|
||||
EXPOSE 18080
|
||||
|
||||
USER 1000
|
||||
|
|
|
|||
|
|
@ -10,6 +10,22 @@ if [[ $MAXIMIZE == 'true' ]] ; then
|
|||
fi
|
||||
ARGS=${APP_ARGS:-$DEFAULT_ARGS}
|
||||
|
||||
RECORDING_API_PORT=${RECORDING_API_PORT:-18080}
|
||||
RECORDINGS_DIR=${RECORDINGS_DIR:-$HOME/recordings}
|
||||
RECORDING_DISPLAY=${RECORDING_DISPLAY:-${DISPLAY:-:1}}
|
||||
|
||||
# Ensure a writable directory for recordings.
|
||||
mkdir -p "$RECORDINGS_DIR"
|
||||
|
||||
# Start the lightweight recording API if it is not already running.
|
||||
if ! pgrep -f "recording_api.py" > /dev/null ; then
|
||||
nohup python3 /usr/local/bin/recording_api.py \
|
||||
--port "$RECORDING_API_PORT" \
|
||||
--recording-dir "$RECORDINGS_DIR" \
|
||||
--display "$RECORDING_DISPLAY" \
|
||||
> /tmp/recording_api.log 2>&1 &
|
||||
fi
|
||||
|
||||
options=$(getopt -o gau: -l go,assign,url: -n "$0" -- "$@") || exit
|
||||
eval set -- "$options"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -ex
|
||||
|
||||
CHROME_ARGS="--password-store=basic --no-sandbox --ignore-gpu-blocklist --user-data-dir --no-first-run --simulate-outdated-no-au='Tue, 31 Dec 2099 23:59:59 GMT'"
|
||||
CHROME_ARGS="--remote-debugging-port=9222 --password-store=basic --no-sandbox --ignore-gpu-blocklist --user-data-dir --no-first-run --simulate-outdated-no-au='Tue, 31 Dec 2099 23:59:59 GMT'"
|
||||
ARCH=$(arch | sed 's/aarch64/arm64/g' | sed 's/x86_64/amd64/g')
|
||||
|
||||
if [[ "${DISTRO}" == @(debian|opensuse|ubuntu) ]] && [ ${ARCH} = 'amd64' ] && [ ! -z ${SKIP_CLEAN+x} ]; then
|
||||
|
|
|
|||
305
src/ubuntu/install/chromium/recording_api.py
Normal file
305
src/ubuntu/install/chromium/recording_api.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lightweight recording control API for the Chromium image.
|
||||
|
||||
Endpoints:
|
||||
- POST /record/start -> start a new recording
|
||||
- POST /record/stop -> stop the current recording
|
||||
- GET /record/file -> download a recording (defaults to last finished)
|
||||
- GET /record/status -> current recorder status
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
def _safe_json(handler, status, payload):
|
||||
body = json.dumps(payload or {}).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def _fail(handler, status, message):
|
||||
_safe_json(handler, status, {"error": message})
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Simple ffmpeg-based screen recorder."""
|
||||
|
||||
def __init__(self, display, recording_dir, default_size, default_fps):
|
||||
self.display = display
|
||||
self.recording_dir = recording_dir
|
||||
self.default_size = default_size
|
||||
self.default_fps = default_fps
|
||||
self.process = None
|
||||
self.current_file = None
|
||||
self.last_file = None
|
||||
|
||||
def is_running(self):
|
||||
return self.process is not None and self.process.poll() is None
|
||||
|
||||
def start(self, video_size=None, framerate=None, filename=None):
|
||||
if self.is_running():
|
||||
raise RuntimeError("Recording already in progress")
|
||||
|
||||
if not shutil.which("ffmpeg"):
|
||||
raise RuntimeError("ffmpeg is not available in PATH")
|
||||
|
||||
os.makedirs(self.recording_dir, exist_ok=True)
|
||||
|
||||
if not video_size:
|
||||
video_size = self._detect_resolution() or self.default_size
|
||||
|
||||
if "x" not in video_size:
|
||||
raise RuntimeError("video_size must be formatted as WIDTHxHEIGHT")
|
||||
|
||||
framerate = int(framerate or self.default_fps)
|
||||
ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||
name = filename or f"recording-{ts}.mp4"
|
||||
target = os.path.join(self.recording_dir, name)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-video_size",
|
||||
video_size,
|
||||
"-framerate",
|
||||
str(framerate),
|
||||
"-f",
|
||||
"x11grab",
|
||||
"-i",
|
||||
self.display,
|
||||
"-codec:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
target,
|
||||
]
|
||||
|
||||
# Start the ffmpeg process detached from stdin to avoid blocking.
|
||||
self.process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
self.current_file = target
|
||||
return target, video_size, framerate
|
||||
|
||||
def stop(self):
|
||||
if not self.is_running():
|
||||
raise RuntimeError("No active recording")
|
||||
|
||||
self.process.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
finished_file = self.current_file
|
||||
self.last_file = finished_file
|
||||
self.process = None
|
||||
self.current_file = None
|
||||
return finished_file
|
||||
|
||||
def _detect_resolution(self):
|
||||
"""Best-effort detection of the current X display resolution."""
|
||||
try:
|
||||
probe = subprocess.check_output(
|
||||
["xdpyinfo"], env={"DISPLAY": self.display}, stderr=subprocess.DEVNULL
|
||||
).decode()
|
||||
for line in probe.splitlines():
|
||||
if "dimensions:" in line:
|
||||
parts = line.strip().split()
|
||||
# dimensions: 1920x1080 pixels ...
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def build_handler(recorder):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "RecordingAPI/1.0"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# Keep stdout clean for other services.
|
||||
return
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/record/status":
|
||||
self._handle_status()
|
||||
elif parsed.path == "/record/file":
|
||||
self._handle_file(parsed)
|
||||
else:
|
||||
_fail(self, HTTPStatus.NOT_FOUND, "Endpoint not found")
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/record/start":
|
||||
self._handle_start()
|
||||
elif parsed.path == "/record/stop":
|
||||
self._handle_stop()
|
||||
else:
|
||||
_fail(self, HTTPStatus.NOT_FOUND, "Endpoint not found")
|
||||
|
||||
def _read_json(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if not length:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.rfile.read(length))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _handle_start(self):
|
||||
payload = self._read_json()
|
||||
try:
|
||||
target, video_size, fps = recorder.start(
|
||||
video_size=payload.get("video_size"),
|
||||
framerate=payload.get("framerate"),
|
||||
filename=payload.get("filename"),
|
||||
)
|
||||
except Exception as exc:
|
||||
_fail(self, HTTPStatus.CONFLICT, str(exc))
|
||||
return
|
||||
|
||||
_safe_json(
|
||||
self,
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"file": target,
|
||||
"video_size": video_size,
|
||||
"framerate": fps,
|
||||
"display": recorder.display,
|
||||
"recording_dir": recorder.recording_dir,
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_stop(self):
|
||||
try:
|
||||
finished = recorder.stop()
|
||||
except Exception as exc:
|
||||
_fail(self, HTTPStatus.CONFLICT, str(exc))
|
||||
return
|
||||
|
||||
_safe_json(self, HTTPStatus.OK, {"file": finished})
|
||||
|
||||
def _handle_status(self):
|
||||
_safe_json(
|
||||
self,
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"recording": recorder.is_running(),
|
||||
"current_file": recorder.current_file,
|
||||
"last_file": recorder.last_file,
|
||||
"display": recorder.display,
|
||||
"recording_dir": recorder.recording_dir,
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_file(self, parsed):
|
||||
params = parse_qs(parsed.query)
|
||||
name = params.get("name", [None])[0]
|
||||
target = None
|
||||
|
||||
if name:
|
||||
target = (
|
||||
name
|
||||
if os.path.isabs(name)
|
||||
else os.path.join(recorder.recording_dir, name)
|
||||
)
|
||||
elif recorder.last_file:
|
||||
target = recorder.last_file
|
||||
|
||||
if not target or not os.path.exists(target):
|
||||
_fail(self, HTTPStatus.NOT_FOUND, "Recording not found")
|
||||
return
|
||||
|
||||
try:
|
||||
size = os.path.getsize(target)
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(size))
|
||||
self.end_headers()
|
||||
with open(target, "rb") as f:
|
||||
shutil.copyfileobj(f, self.wfile)
|
||||
except Exception as exc:
|
||||
_fail(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Simple recording control API server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Listening interface")
|
||||
parser.add_argument("--port", type=int, default=18080, help="Listening port")
|
||||
parser.add_argument(
|
||||
"--recording-dir",
|
||||
default=os.environ.get("RECORDINGS_DIR", "/home/kasm-user/recordings"),
|
||||
help="Directory to store recordings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--display",
|
||||
default=os.environ.get("DISPLAY", ":1"),
|
||||
help="X11 display to record (e.g. :1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--video-size",
|
||||
default=os.environ.get("RECORDING_SIZE", "1920x1080"),
|
||||
help="Default video size when none provided (WIDTHxHEIGHT)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--framerate",
|
||||
type=int,
|
||||
default=int(os.environ.get("RECORDING_FPS", 25)),
|
||||
help="Default framerate when none provided",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
recorder = Recorder(
|
||||
display=args.display,
|
||||
recording_dir=args.recording_dir,
|
||||
default_size=args.video_size,
|
||||
default_fps=args.framerate,
|
||||
)
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), build_handler(recorder))
|
||||
print(
|
||||
f"[recording_api] listening on {args.host}:{args.port}, "
|
||||
f"display={recorder.display}, dir={recorder.recording_dir}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
if recorder.is_running():
|
||||
try:
|
||||
recorder.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue