From 0076cf4d4bd0f070d0e8c9486a10c4229b788d08 Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Tue, 20 Jan 2026 17:16:10 +0800 Subject: [PATCH 1/8] recorder --- dockerfile-kasm-chromium | 9 +- src/ubuntu/install/chromium/custom_startup.sh | 16 + .../install/chromium/install_chromium.sh | 2 +- src/ubuntu/install/chromium/recording_api.py | 305 ++++++++++++++++++ 4 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 src/ubuntu/install/chromium/recording_api.py diff --git a/dockerfile-kasm-chromium b/dockerfile-kasm-chromium index 552bbbab..24d2c920 100644 --- a/dockerfile-kasm-chromium +++ b/dockerfile-kasm-chromium @@ -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 diff --git a/src/ubuntu/install/chromium/custom_startup.sh b/src/ubuntu/install/chromium/custom_startup.sh index cb13c909..90d7d884 100644 --- a/src/ubuntu/install/chromium/custom_startup.sh +++ b/src/ubuntu/install/chromium/custom_startup.sh @@ -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" diff --git a/src/ubuntu/install/chromium/install_chromium.sh b/src/ubuntu/install/chromium/install_chromium.sh index c12474e8..4201997b 100644 --- a/src/ubuntu/install/chromium/install_chromium.sh +++ b/src/ubuntu/install/chromium/install_chromium.sh @@ -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 diff --git a/src/ubuntu/install/chromium/recording_api.py b/src/ubuntu/install/chromium/recording_api.py new file mode 100644 index 00000000..2d5647f4 --- /dev/null +++ b/src/ubuntu/install/chromium/recording_api.py @@ -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()) From 602a6a60d5e60c099af3a126a38fee4a59ae3234 Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Tue, 20 Jan 2026 18:09:30 +0800 Subject: [PATCH 2/8] init --- dockerfile-kasm-chromium | 2 +- src/ubuntu/install/chromium/recording_api.py | 414 +++++++++++++++---- 2 files changed, 341 insertions(+), 75 deletions(-) diff --git a/dockerfile-kasm-chromium b/dockerfile-kasm-chromium index 24d2c920..dbe8ece0 100644 --- a/dockerfile-kasm-chromium +++ b/dockerfile-kasm-chromium @@ -15,7 +15,7 @@ COPY ./src/ubuntu/install/chromium $INST_SCRIPTS/chromium/ RUN bash $INST_SCRIPTS/chromium/install_chromium.sh && rm -rf $INST_SCRIPTS/chromium/ # Install unzip and recording dependencies -RUN apt-get update && apt-get install -y unzip ffmpeg python3 && rm -rf /var/lib/apt/lists/* +RUN 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/ diff --git a/src/ubuntu/install/chromium/recording_api.py b/src/ubuntu/install/chromium/recording_api.py index 2d5647f4..6aaada3f 100644 --- a/src/ubuntu/install/chromium/recording_api.py +++ b/src/ubuntu/install/chromium/recording_api.py @@ -3,23 +3,32 @@ 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) + - POST /record/start -> start a new recording (returns UUID) + - POST /record/stop -> stop a recording (requires UUID in body) + - GET /record/file -> download a recording (use ?uuid=... or ?name=...) - GET /record/status -> current recorder status + +Each recording session is identified by a unique UUID. """ import argparse import json import os +import platform import shutil import signal import subprocess import sys +import time +import uuid from datetime import datetime from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse +# Detect operating system +IS_MACOS = platform.system() == "Darwin" +IS_LINUX = platform.system() == "Linux" + def _safe_json(handler, status, payload): body = json.dumps(payload or {}).encode("utf-8") @@ -35,18 +44,32 @@ def _fail(handler, status, message): class Recorder: - """Simple ffmpeg-based screen recorder.""" + """Simple ffmpeg-based screen recorder. Supports Linux (x11grab) and macOS (avfoundation).""" - def __init__(self, display, recording_dir, default_size, default_fps): - self.display = display + def __init__(self, display, recording_dir, default_size, default_fps, capture_input=None): + self.display = display # X11 display for Linux + self.capture_input = capture_input # avfoundation input for macOS (e.g., "1:none") 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): + # UUID-based recording management + # Format: {uuid: {"process": process, "file": file_path, "video_size": size, "framerate": fps}} + self.recordings = {} + self.current_uuid = None + # Platform info + self.platform = "macos" if IS_MACOS else "linux" + + def is_running(self, recording_uuid=None): + if recording_uuid: + if recording_uuid not in self.recordings: + return False + rec = self.recordings[recording_uuid] + process = rec.get("process") + return process is not None and process.poll() is None + # Backward compatibility: check current recording return self.process is not None and self.process.poll() is None def start(self, video_size=None, framerate=None, filename=None): @@ -57,6 +80,9 @@ def start(self, video_size=None, framerate=None, filename=None): raise RuntimeError("ffmpeg is not available in PATH") os.makedirs(self.recording_dir, exist_ok=True) + + # Clean up old recording files to avoid disk bloat + self._cleanup_old_recordings() if not video_size: video_size = self._detect_resolution() or self.default_size @@ -65,56 +91,248 @@ def start(self, video_size=None, framerate=None, filename=None): raise RuntimeError("video_size must be formatted as WIDTHxHEIGHT") framerate = int(framerate or self.default_fps) + + # Generate unique UUID for this recording + recording_uuid = str(uuid.uuid4()) + ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") - name = filename or f"recording-{ts}.mp4" + # Include UUID in filename if not provided + if filename: + name = filename + else: + name = f"recording-{recording_uuid[:8]}-{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, - ] + # Build ffmpeg command based on platform + cmd = self._build_ffmpeg_cmd(video_size, framerate, target) # Start the ffmpeg process detached from stdin to avoid blocking. - self.process = subprocess.Popen( + process = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) + + # Store recording info with UUID + self.recordings[recording_uuid] = { + "process": process, + "file": target, + "video_size": video_size, + "framerate": framerate, + } + + # Update current recording (for backward compatibility) + self.process = process 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) + self.current_uuid = recording_uuid + + return recording_uuid, target, video_size, framerate + + def _build_ffmpeg_cmd(self, video_size, framerate, target): + """Build ffmpeg command based on platform.""" + if IS_MACOS: + # macOS: use avfoundation + # capture_input format: "video_device:audio_device" e.g., "1:none" or "0:none" + capture_input = self.capture_input or "1:none" + return [ + "ffmpeg", + "-y", + "-f", "avfoundation", + "-framerate", str(framerate), + "-capture_cursor", "1", # Capture mouse cursor + "-i", capture_input, + "-vf", f"scale={video_size.replace('x', ':')}", # Scale to desired size + "-codec:v", "libx264", + "-preset", "ultrafast", + "-pix_fmt", "yuv420p", + target, + ] + else: + # Linux: use x11grab + return [ + "ffmpeg", + "-y", + "-video_size", video_size, + "-framerate", str(framerate), + "-f", "x11grab", + "-i", self.display, + "-codec:v", "libx264", + "-preset", "ultrafast", + "-pix_fmt", "yuv420p", + target, + ] + + def stop(self, recording_uuid=None): + # If UUID provided, use it; otherwise use current recording + if recording_uuid: + if recording_uuid not in self.recordings: + raise RuntimeError(f"Recording with UUID {recording_uuid} not found") + rec = self.recordings[recording_uuid] + process = rec["process"] + finished_file = rec["file"] + + if process is None or process.poll() is not None: + raise RuntimeError(f"Recording with UUID {recording_uuid} is not running") + else: + # Backward compatibility: use current recording + if not self.is_running(): + raise RuntimeError("No active recording") + process = self.process + finished_file = self.current_file + recording_uuid = self.current_uuid + + # Send SIGINT to gracefully stop ffmpeg + process.send_signal(signal.SIGINT) try: - self.process.wait(timeout=5) + process.wait(timeout=10) # Increased timeout for file finalization except subprocess.TimeoutExpired: - self.process.kill() - self.process.wait(timeout=5) + # If graceful shutdown fails, force kill + process.kill() + process.wait(timeout=5) + + # Wait for file to be finalized and verify it exists + max_wait = 3 # Maximum seconds to wait for file + wait_interval = 0.1 # Check every 100ms + waited = 0 + + while waited < max_wait: + if finished_file and os.path.exists(finished_file): + # Check if file is readable and has content + try: + size = os.path.getsize(finished_file) + if size > 0: + # File exists and has content + break + except OSError: + pass + time.sleep(wait_interval) + waited += wait_interval + + # Verify the file exists and is readable + if not finished_file or not os.path.exists(finished_file): + raise RuntimeError(f"Recording file not found: {finished_file}") + + try: + file_size = os.path.getsize(finished_file) + if file_size == 0: + raise RuntimeError(f"Recording file is empty: {finished_file}") + except OSError as e: + raise RuntimeError(f"Cannot access recording file: {e}") + + # Update recording info + if recording_uuid and recording_uuid in self.recordings: + self.recordings[recording_uuid]["process"] = None + self.last_file = finished_file + + # Update current recording state (for backward compatibility) + if recording_uuid == self.current_uuid: + self.last_file = finished_file + self.process = None + self.current_file = None + self.current_uuid = None + + # Ensure we always return a UUID (even if None for backward compatibility) + return recording_uuid, finished_file + + def get_file_by_uuid(self, recording_uuid): + """Get recording file path by UUID.""" + if recording_uuid not in self.recordings: + return None + rec = self.recordings[recording_uuid] + file_path = rec.get("file") + if file_path and os.path.exists(file_path): + return file_path + return None - finished_file = self.current_file - self.last_file = finished_file - self.process = None - self.current_file = None - return finished_file + def _cleanup_old_recordings(self): + """Clean up old recording files to avoid disk bloat. + + Removes files from completed recordings (where process is None or exited). + Keeps the last_file for potential download. + """ + uuids_to_remove = [] + + for rec_uuid, rec_info in self.recordings.items(): + process = rec_info.get("process") + file_path = rec_info.get("file") + + # Skip if recording is still running + if process is not None and process.poll() is None: + continue + + # Skip the last finished file (user might want to download it) + if file_path == self.last_file: + continue + + # Delete the file if it exists + if file_path and os.path.exists(file_path): + try: + os.remove(file_path) + except OSError: + pass # Ignore errors during cleanup + + uuids_to_remove.append(rec_uuid) + + # Remove cleaned up recordings from the dictionary + for rec_uuid in uuids_to_remove: + del self.recordings[rec_uuid] def _detect_resolution(self): - """Best-effort detection of the current X display resolution.""" + """Best-effort detection of the current display resolution.""" + if IS_MACOS: + return self._detect_resolution_macos() + else: + return self._detect_resolution_linux() + + def _detect_resolution_macos(self): + """Detect screen resolution on macOS using system_profiler.""" + try: + # Use system_profiler to get display info + probe = subprocess.check_output( + ["system_profiler", "SPDisplaysDataType"], + stderr=subprocess.DEVNULL + ).decode() + for line in probe.splitlines(): + # Look for resolution line like "Resolution: 2560 x 1440" + if "Resolution:" in line and "x" in line.lower(): + parts = line.split(":") + if len(parts) >= 2: + res_part = parts[1].strip() + # Parse "2560 x 1440 (QHD/WQHD...)" or "2560 x 1440" + res_match = res_part.split() + if len(res_match) >= 3 and res_match[1].lower() == "x": + width = res_match[0].strip() + height = res_match[2].strip().split()[0] # Remove any trailing text + # Remove non-digit characters + width = ''.join(filter(str.isdigit, width)) + height = ''.join(filter(str.isdigit, height)) + if width and height: + return f"{width}x{height}" + except Exception: + pass + + # Fallback: try using screenresolution if available + try: + if shutil.which("screenresolution"): + probe = subprocess.check_output( + ["screenresolution", "get"], + stderr=subprocess.DEVNULL + ).decode() + # Output format: "Display 0: 2560x1440x32@60Hz" + for line in probe.splitlines(): + if "x" in line: + parts = line.split() + for part in parts: + if "x" in part and part[0].isdigit(): + # Extract WIDTHxHEIGHT from "2560x1440x32@60Hz" + dims = part.split("x") + if len(dims) >= 2: + return f"{dims[0]}x{dims[1]}" + except Exception: + pass + + return None + + def _detect_resolution_linux(self): + """Detect screen resolution on Linux using xdpyinfo.""" try: probe = subprocess.check_output( ["xdpyinfo"], env={"DISPLAY": self.display}, stderr=subprocess.DEVNULL @@ -169,7 +387,7 @@ def _read_json(self): def _handle_start(self): payload = self._read_json() try: - target, video_size, fps = recorder.start( + recording_uuid, target, video_size, fps = recorder.start( video_size=payload.get("video_size"), framerate=payload.get("framerate"), filename=payload.get("filename"), @@ -178,46 +396,75 @@ def _handle_start(self): _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, - }, - ) + response = { + "uuid": recording_uuid, + "file": target, + "video_size": video_size, + "framerate": fps, + "recording_dir": recorder.recording_dir, + "platform": recorder.platform, + } + # Add platform-specific capture info + if IS_MACOS: + response["capture_input"] = recorder.capture_input + else: + response["display"] = recorder.display + + _safe_json(self, HTTPStatus.OK, response) def _handle_stop(self): + payload = self._read_json() + recording_uuid = payload.get("uuid") if payload else None + try: - finished = recorder.stop() + uuid_result, finished = recorder.stop(recording_uuid=recording_uuid) except Exception as exc: _fail(self, HTTPStatus.CONFLICT, str(exc)) return - _safe_json(self, HTTPStatus.OK, {"file": finished}) + # Get file information + file_info = { + "uuid": uuid_result, + "file": finished + } + try: + if os.path.exists(finished): + file_info["size"] = os.path.getsize(finished) + file_info["exists"] = True + else: + file_info["exists"] = False + except Exception: + file_info["exists"] = False + + _safe_json(self, HTTPStatus.OK, file_info) 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, - }, - ) + status = { + "recording": recorder.is_running(), + "current_uuid": recorder.current_uuid, + "current_file": recorder.current_file, + "last_file": recorder.last_file, + "recording_dir": recorder.recording_dir, + "platform": recorder.platform, + } + # Add platform-specific capture info + if IS_MACOS: + status["capture_input"] = recorder.capture_input + else: + status["display"] = recorder.display + + _safe_json(self, HTTPStatus.OK, status) def _handle_file(self, parsed): params = parse_qs(parsed.query) + recording_uuid = params.get("uuid", [None])[0] name = params.get("name", [None])[0] target = None - if name: + # Priority: uuid > name > last_file + if recording_uuid: + target = recorder.get_file_by_uuid(recording_uuid) + elif name: target = ( name if os.path.isabs(name) @@ -248,15 +495,26 @@ 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") + + # Determine default recording directory based on platform + default_recording_dir = ( + os.path.expanduser("~/recordings") if IS_MACOS + else os.environ.get("RECORDINGS_DIR", "/home/kasm-user/recordings") + ) parser.add_argument( "--recording-dir", - default=os.environ.get("RECORDINGS_DIR", "/home/kasm-user/recordings"), + default=os.environ.get("RECORDINGS_DIR", default_recording_dir), help="Directory to store recordings", ) parser.add_argument( "--display", default=os.environ.get("DISPLAY", ":1"), - help="X11 display to record (e.g. :1)", + help="X11 display to record (Linux only, e.g. :1)", + ) + parser.add_argument( + "--capture-input", + default=os.environ.get("CAPTURE_INPUT", "2:none"), + help="macOS avfoundation capture input (e.g. '2:none' for screen without audio). Use 'ffmpeg -f avfoundation -list_devices true -i \"\"' to list devices.", ) parser.add_argument( "--video-size", @@ -279,12 +537,20 @@ def main(): recording_dir=args.recording_dir, default_size=args.video_size, default_fps=args.framerate, + capture_input=args.capture_input, ) server = ThreadingHTTPServer((args.host, args.port), build_handler(recorder)) + + # Platform-specific info in startup message + if IS_MACOS: + capture_info = f"capture_input={recorder.capture_input}" + else: + capture_info = f"display={recorder.display}" + print( f"[recording_api] listening on {args.host}:{args.port}, " - f"display={recorder.display}, dir={recorder.recording_dir}", + f"platform={recorder.platform}, {capture_info}, dir={recorder.recording_dir}", flush=True, ) From a674be966bc1e4e9099ad451e0a75a5784628a1d Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Tue, 20 Jan 2026 18:10:05 +0800 Subject: [PATCH 3/8] doc --- .../install/chromium/API_TEST_COMMANDS.md | 304 ++++++++++++++++++ .../install/chromium/test_recording_api.sh | 166 ++++++++++ 2 files changed, 470 insertions(+) create mode 100644 src/ubuntu/install/chromium/API_TEST_COMMANDS.md create mode 100755 src/ubuntu/install/chromium/test_recording_api.sh diff --git a/src/ubuntu/install/chromium/API_TEST_COMMANDS.md b/src/ubuntu/install/chromium/API_TEST_COMMANDS.md new file mode 100644 index 00000000..75924b76 --- /dev/null +++ b/src/ubuntu/install/chromium/API_TEST_COMMANDS.md @@ -0,0 +1,304 @@ +# 录屏API测试命令 + +默认服务器地址: `http://localhost:18080` + +如果服务器运行在其他地址,请替换 `localhost:18080` 为实际地址。 + +**重要:** 每个录屏会话都有一个唯一的UUID标识符。开始录屏时会返回UUID,停止录屏和获取文件时需要使用这个UUID。 + +--- + +## 1. 检查录屏状态 + +```bash +curl -X GET http://localhost:18080/record/status +``` + +**格式化输出(需要 jq):** +```bash +curl -s -X GET http://localhost:18080/record/status | jq '.' +``` + +**预期响应:** +```json +{ + "recording": false, + "current_uuid": null, + "current_file": null, + "last_file": null, + "display": ":1", + "recording_dir": "/home/kasm-user/recordings" +} +``` + +**响应字段说明:** +- `recording`: 是否正在录屏 +- `current_uuid`: 当前录屏会话的UUID(如果正在录屏) +- `current_file`: 当前录制的文件路径 +- `last_file`: 最后一个完成的录屏文件路径 + +--- + +## 2. 开始录屏(使用默认参数) + +```bash +curl -X POST http://localhost:18080/record/start \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**格式化输出:** +```bash +curl -s -X POST http://localhost:18080/record/start \ + -H "Content-Type: application/json" \ + -d '{}' | jq '.' +``` + +**预期响应:** +```json +{ + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "file": "/home/kasm-user/recordings/recording-550e8400-20240101T120000Z.mp4", + "video_size": "1920x1080", + "framerate": 25, + "display": ":1", + "recording_dir": "/home/kasm-user/recordings" +} +``` + +**注意:** 响应中包含 `uuid` 字段,这是本次录屏的唯一标识符。后续停止录屏和获取文件时需要使用这个UUID。 + +--- + +## 3. 开始录屏(自定义参数) + +```bash +curl -X POST http://localhost:18080/record/start \ + -H "Content-Type: application/json" \ + -d '{ + "video_size": "1920x1080", + "framerate": 30, + "filename": "my-recording.mp4" + }' +``` + +**格式化输出:** +```bash +curl -s -X POST http://localhost:18080/record/start \ + -H "Content-Type: application/json" \ + -d '{ + "video_size": "1920x1080", + "framerate": 30, + "filename": "my-recording.mp4" + }' | jq '.' +``` + +**参数说明:** +- `video_size`: 视频分辨率,格式为 "WIDTHxHEIGHT"(可选) +- `framerate`: 帧率,整数(可选) +- `filename`: 自定义文件名(可选) + +--- + +## 4. 停止录屏 + +**使用UUID停止录屏(推荐):** +```bash +curl -X POST http://localhost:18080/record/stop \ + -H "Content-Type: application/json" \ + -d '{"uuid": "550e8400-e29b-41d4-a716-446655440000"}' +``` + +**不使用UUID(停止当前录制的会话):** +```bash +curl -X POST http://localhost:18080/record/stop \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**格式化输出:** +```bash +curl -s -X POST http://localhost:18080/record/stop \ + -H "Content-Type: application/json" \ + -d '{"uuid": "550e8400-e29b-41d4-a716-446655440000"}' | jq '.' +``` + +**预期响应:** +```json +{ + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "file": "/home/kasm-user/recordings/recording-550e8400-20240101T120000Z.mp4", + "size": 1234567, + "exists": true +} +``` + +**响应字段说明:** +- `uuid`: 录屏会话的UUID +- `file`: 录屏文件的完整路径 +- `size`: 文件大小(字节) +- `exists`: 文件是否存在 + +--- + +## 5. 通过UUID下载录屏文件(推荐) + +```bash +curl -X GET "http://localhost:18080/record/file?uuid=550e8400-e29b-41d4-a716-446655440000" -o recording.mp4 +``` + +**带进度条:** +```bash +curl -X GET "http://localhost:18080/record/file?uuid=550e8400-e29b-41d4-a716-446655440000" -o recording.mp4 --progress-bar +``` + +--- + +## 6. 下载录屏文件(最后一个录制的文件,不使用UUID) + +```bash +curl -X GET http://localhost:18080/record/file -o recording.mp4 +``` + +--- + +## 7. 下载指定文件名的录屏文件 + +```bash +curl -X GET "http://localhost:18080/record/file?name=my-recording.mp4" -o my-recording.mp4 +``` + +**或者使用绝对路径:** +```bash +curl -X GET "http://localhost:18080/record/file?name=/home/kasm-user/recordings/my-recording.mp4" -o my-recording.mp4 +``` + +**注意:** 参数优先级:`uuid` > `name` > 最后一个录制的文件 + +--- + +## 完整测试流程示例(使用UUID) + +```bash +# 1. 检查状态 +curl -s -X GET http://localhost:18080/record/status | jq '.' + +# 2. 开始录屏并获取UUID +START_RESPONSE=$(curl -s -X POST http://localhost:18080/record/start \ + -H "Content-Type: application/json" \ + -d '{"video_size": "1920x1080", "framerate": 30}') +echo "$START_RESPONSE" | jq '.' + +# 3. 提取UUID +RECORDING_UUID=$(echo "$START_RESPONSE" | jq -r '.uuid') +echo "录屏UUID: $RECORDING_UUID" + +# 4. 等待几秒(录屏中) +sleep 5 + +# 5. 检查状态(应该显示正在录屏) +curl -s -X GET http://localhost:18080/record/status | jq '.' + +# 6. 使用UUID停止录屏 +STOP_RESPONSE=$(curl -s -X POST http://localhost:18080/record/stop \ + -H "Content-Type: application/json" \ + -d "{\"uuid\": \"$RECORDING_UUID\"}") +echo "$STOP_RESPONSE" | jq '.' + +# 7. 通过UUID下载文件 +curl -X GET "http://localhost:18080/record/file?uuid=$RECORDING_UUID" -o "recording-${RECORDING_UUID}.mp4" + +# 8. 验证文件 +ls -lh "recording-${RECORDING_UUID}.mp4" +``` + +--- + +## 错误处理示例 + +### 尝试在录屏已运行时再次开始录屏 +```bash +curl -s -X POST http://localhost:18080/record/start \ + -H "Content-Type: application/json" \ + -d '{}' | jq '.' +# 预期: {"error": "Recording already in progress"} +``` + +### 尝试在没有录屏时停止录屏 +```bash +curl -s -X POST http://localhost:18080/record/stop \ + -H "Content-Type: application/json" | jq '.' +# 预期: {"error": "No active recording"} +``` + +### 尝试下载不存在的文件 +```bash +curl -s -X GET "http://localhost:18080/record/file?name=non-existent.mp4" +# 预期: {"error": "Recording not found"} +``` + +--- + +## 使用 Python requests 库测试 + +```python +import requests +import time + +BASE_URL = "http://localhost:18080" + +# 1. 检查状态 +response = requests.get(f"{BASE_URL}/record/status") +print("状态:", response.json()) + +# 2. 开始录屏并获取UUID +response = requests.post( + f"{BASE_URL}/record/start", + json={"video_size": "1920x1080", "framerate": 30} +) +start_result = response.json() +print("开始录屏:", start_result) + +# 3. 提取UUID +recording_uuid = start_result.get("uuid") +print(f"录屏UUID: {recording_uuid}") + +# 4. 等待5秒 +time.sleep(5) + +# 5. 使用UUID停止录屏 +response = requests.post( + f"{BASE_URL}/record/stop", + json={"uuid": recording_uuid} +) +stop_result = response.json() +print("停止录屏:", stop_result) + +# 6. 通过UUID下载文件 +if stop_result.get("exists"): + file_response = requests.get(f"{BASE_URL}/record/file?uuid={recording_uuid}") + filename = f"recording-{recording_uuid}.mp4" + with open(filename, "wb") as f: + f.write(file_response.content) + print(f"文件已下载: {filename}") +``` + +--- + +## 使用 HTTPie 测试(更简洁) + +```bash +# 安装: pip install httpie + +# 检查状态 +http GET localhost:18080/record/status + +# 开始录屏 +http POST localhost:18080/record/start video_size=1920x1080 framerate=30 + +# 停止录屏 +http POST localhost:18080/record/stop + +# 下载文件 +http GET localhost:18080/record/file --download +``` diff --git a/src/ubuntu/install/chromium/test_recording_api.sh b/src/ubuntu/install/chromium/test_recording_api.sh new file mode 100755 index 00000000..675e2ad8 --- /dev/null +++ b/src/ubuntu/install/chromium/test_recording_api.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# 录屏API测试脚本 +# 默认服务器地址和端口 +HOST="${HOST:-localhost}" +PORT="${PORT:-18080}" +BASE_URL="http://${HOST}:${PORT}" + +echo "==========================================" +echo "录屏API测试脚本" +echo "服务器地址: ${BASE_URL}" +echo "==========================================" +echo "" + +# 颜色输出 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 1. 检查API状态 +echo -e "${YELLOW}[1] 检查录屏状态${NC}" +echo "请求: GET ${BASE_URL}/record/status" +echo "" +curl -s -X GET "${BASE_URL}/record/status" | jq '.' || curl -s -X GET "${BASE_URL}/record/status" +echo "" +echo "----------------------------------------" +echo "" + +# 2. 开始录屏(使用默认参数) +echo -e "${YELLOW}[2] 开始录屏(使用默认参数)${NC}" +echo "请求: POST ${BASE_URL}/record/start" +echo "请求体: {}" +echo "" +RESPONSE=$(curl -s -X POST "${BASE_URL}/record/start" \ + -H "Content-Type: application/json" \ + -d '{}') +echo "$RESPONSE" | jq '.' || echo "$RESPONSE" +# 提取UUID +RECORDING_UUID=$(echo "$RESPONSE" | jq -r '.uuid' 2>/dev/null || echo "") +if [ -n "$RECORDING_UUID" ] && [ "$RECORDING_UUID" != "null" ]; then + echo -e "${GREEN}录屏UUID: ${RECORDING_UUID}${NC}" +fi +echo "" +echo "----------------------------------------" +echo "" + +# 3. 开始录屏(自定义参数) +echo -e "${YELLOW}[3] 开始录屏(自定义参数)${NC}" +echo "请求: POST ${BASE_URL}/record/start" +echo "请求体: {\"video_size\": \"1920x1080\", \"framerate\": 30, \"filename\": \"test-recording.mp4\"}" +echo "" +RESPONSE=$(curl -s -X POST "${BASE_URL}/record/start" \ + -H "Content-Type: application/json" \ + -d '{ + "video_size": "1920x1080", + "framerate": 30, + "filename": "test-recording.mp4" + }') +echo "$RESPONSE" | jq '.' || echo "$RESPONSE" +echo "" +echo "----------------------------------------" +echo "" + +# 4. 再次检查状态(应该显示正在录屏) +echo -e "${YELLOW}[4] 检查录屏状态(应该显示正在录屏)${NC}" +echo "请求: GET ${BASE_URL}/record/status" +echo "" +curl -s -X GET "${BASE_URL}/record/status" | jq '.' || curl -s -X GET "${BASE_URL}/record/status" +echo "" +echo "----------------------------------------" +echo "" + +# 5. 等待几秒(模拟录屏过程) +echo -e "${YELLOW}[5] 等待5秒(模拟录屏过程)${NC}" +sleep 5 +echo "等待完成" +echo "" +echo "----------------------------------------" +echo "" + +# 6. 停止录屏(使用UUID) +echo -e "${YELLOW}[6] 停止录屏(使用UUID)${NC}" +if [ -n "$RECORDING_UUID" ] && [ "$RECORDING_UUID" != "null" ]; then + echo "请求: POST ${BASE_URL}/record/stop" + echo "请求体: {\"uuid\": \"${RECORDING_UUID}\"}" + echo "" + RESPONSE=$(curl -s -X POST "${BASE_URL}/record/stop" \ + -H "Content-Type: application/json" \ + -d "{\"uuid\": \"${RECORDING_UUID}\"}") +else + echo "请求: POST ${BASE_URL}/record/stop" + echo "请求体: {} (使用当前录制的UUID)" + echo "" + RESPONSE=$(curl -s -X POST "${BASE_URL}/record/stop" \ + -H "Content-Type: application/json" \ + -d '{}') +fi +echo "$RESPONSE" | jq '.' || echo "$RESPONSE" +echo "" +echo "----------------------------------------" +echo "" + +# 7. 再次检查状态(应该显示已停止) +echo -e "${YELLOW}[7] 检查录屏状态(应该显示已停止)${NC}" +echo "请求: GET ${BASE_URL}/record/status" +echo "" +curl -s -X GET "${BASE_URL}/record/status" | jq '.' || curl -s -X GET "${BASE_URL}/record/status" +echo "" +echo "----------------------------------------" +echo "" + +# 8. 通过UUID下载录屏文件 +echo -e "${YELLOW}[8] 通过UUID下载录屏文件${NC}" +if [ -n "$RECORDING_UUID" ] && [ "$RECORDING_UUID" != "null" ]; then + echo "请求: GET ${BASE_URL}/record/file?uuid=${RECORDING_UUID}" + echo "" + FILE_PATH=$(echo "$RESPONSE" | jq -r '.file' 2>/dev/null || echo "") + if [ -n "$FILE_PATH" ] && [ "$FILE_PATH" != "null" ]; then + FILENAME=$(basename "$FILE_PATH") + echo "正在通过UUID下载文件到: ${FILENAME}" + curl -s -X GET "${BASE_URL}/record/file?uuid=${RECORDING_UUID}" -o "${FILENAME}" + if [ $? -eq 0 ]; then + echo -e "${GREEN}下载成功: ${FILENAME}${NC}" + ls -lh "${FILENAME}" + else + echo -e "${RED}下载失败${NC}" + fi + else + echo "无法获取文件路径,尝试直接通过UUID下载..." + curl -s -X GET "${BASE_URL}/record/file?uuid=${RECORDING_UUID}" -o "recording-${RECORDING_UUID}.mp4" + if [ $? -eq 0 ]; then + echo -e "${GREEN}下载成功: recording-${RECORDING_UUID}.mp4${NC}" + ls -lh "recording-${RECORDING_UUID}.mp4" + else + echo -e "${RED}下载失败${NC}" + fi + fi +else + echo "没有UUID,尝试下载最后一个文件..." + curl -s -X GET "${BASE_URL}/record/file" -o "recording.mp4" + if [ $? -eq 0 ]; then + echo -e "${GREEN}下载成功: recording.mp4${NC}" + ls -lh "recording.mp4" + else + echo -e "${RED}下载失败${NC}" + fi +fi +echo "" +echo "----------------------------------------" +echo "" + +# 9. 下载指定文件名的录屏文件 +echo -e "${YELLOW}[9] 下载指定文件名的录屏文件${NC}" +echo "请求: GET ${BASE_URL}/record/file?name=test-recording.mp4" +echo "" +curl -s -X GET "${BASE_URL}/record/file?name=test-recording.mp4" -o "test-recording-download.mp4" +if [ $? -eq 0 ]; then + echo -e "${GREEN}下载成功: test-recording-download.mp4${NC}" + ls -lh "test-recording-download.mp4" +else + echo -e "${RED}下载失败(文件可能不存在)${NC}" +fi +echo "" +echo "==========================================" +echo "测试完成" +echo "==========================================" From 77cfa374eebbae8f90ca8597d61e898a027afdca Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Tue, 20 Jan 2026 18:17:31 +0800 Subject: [PATCH 4/8] apt-get --- dockerfile-kasm-chromium | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dockerfile-kasm-chromium b/dockerfile-kasm-chromium index dbe8ece0..f3bad2d7 100644 --- a/dockerfile-kasm-chromium +++ b/dockerfile-kasm-chromium @@ -10,13 +10,13 @@ WORKDIR $HOME ######### Customize Container Here ########### +# Install unzip and recording dependencies +RUN apt-get update && apt-get install -y unzip ffmpeg python3 && rm -rf /var/lib/apt/lists/* + # Install Chromium COPY ./src/ubuntu/install/chromium $INST_SCRIPTS/chromium/ RUN bash $INST_SCRIPTS/chromium/install_chromium.sh && rm -rf $INST_SCRIPTS/chromium/ -# Install unzip and recording dependencies -RUN 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/ RUN cp /usr/share/backgrounds/bg_kasm.png /usr/share/backgrounds/bg_default.png From c9ed713e517b21754d1741ae0477596d9c8f9a5b Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Tue, 20 Jan 2026 19:18:06 +0800 Subject: [PATCH 5/8] recorder --- src/ubuntu/install/chromium/install_chromium.sh | 2 +- src/ubuntu/install/chromium/recording_api.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ubuntu/install/chromium/install_chromium.sh b/src/ubuntu/install/chromium/install_chromium.sh index 4201997b..cc282b6d 100644 --- a/src/ubuntu/install/chromium/install_chromium.sh +++ b/src/ubuntu/install/chromium/install_chromium.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -ex -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'" +CHROME_ARGS="--remote-debugging-port=9222 --remote-debugging-address=0.0.0.0 --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 diff --git a/src/ubuntu/install/chromium/recording_api.py b/src/ubuntu/install/chromium/recording_api.py index 6aaada3f..df96a611 100644 --- a/src/ubuntu/install/chromium/recording_api.py +++ b/src/ubuntu/install/chromium/recording_api.py @@ -334,8 +334,12 @@ def _detect_resolution_macos(self): def _detect_resolution_linux(self): """Detect screen resolution on Linux using xdpyinfo.""" try: + # Preserve existing environment and set/override DISPLAY + env = os.environ.copy() + env["DISPLAY"] = self.display + probe = subprocess.check_output( - ["xdpyinfo"], env={"DISPLAY": self.display}, stderr=subprocess.DEVNULL + ["xdpyinfo"], env=env, stderr=subprocess.DEVNULL ).decode() for line in probe.splitlines(): if "dimensions:" in line: From 5c8eec5eb15b70e9f7d5526b24c85cddc2830b3b Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Wed, 28 Jan 2026 15:46:02 +0800 Subject: [PATCH 6/8] update --- src/ubuntu/install/chromium/RECORDING_API.md | 0 src/ubuntu/install/chromium/recording_api.py | 81 +++++++++++++++++--- 2 files changed, 69 insertions(+), 12 deletions(-) create mode 100644 src/ubuntu/install/chromium/RECORDING_API.md diff --git a/src/ubuntu/install/chromium/RECORDING_API.md b/src/ubuntu/install/chromium/RECORDING_API.md new file mode 100644 index 00000000..e69de29b diff --git a/src/ubuntu/install/chromium/recording_api.py b/src/ubuntu/install/chromium/recording_api.py index df96a611..3eb2b363 100644 --- a/src/ubuntu/install/chromium/recording_api.py +++ b/src/ubuntu/install/chromium/recording_api.py @@ -74,13 +74,17 @@ def is_running(self, recording_uuid=None): def start(self, video_size=None, framerate=None, filename=None): if self.is_running(): - raise RuntimeError("Recording already in progress") + # Stop the current recording before starting a new one + try: + self.stop() + except Exception: + pass # Ignore errors when stopping if not shutil.which("ffmpeg"): raise RuntimeError("ffmpeg is not available in PATH") os.makedirs(self.recording_dir, exist_ok=True) - + # Clean up old recording files to avoid disk bloat self._cleanup_old_recordings() @@ -91,10 +95,10 @@ def start(self, video_size=None, framerate=None, filename=None): raise RuntimeError("video_size must be formatted as WIDTHxHEIGHT") framerate = int(framerate or self.default_fps) - + # Generate unique UUID for this recording recording_uuid = str(uuid.uuid4()) - + ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") # Include UUID in filename if not provided if filename: @@ -107,10 +111,26 @@ def start(self, video_size=None, framerate=None, filename=None): cmd = self._build_ffmpeg_cmd(video_size, framerate, target) # Start the ffmpeg process detached from stdin to avoid blocking. + # Capture stderr to detect startup failures. process = subprocess.Popen( - cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE ) - + + # Wait briefly and check if the process started successfully + time.sleep(0.3) + if process.poll() is not None: + # Process exited immediately, likely a startup failure + stderr_output = "" + if process.stderr: + try: + stderr_output = process.stderr.read().decode(errors="replace") + except Exception: + pass + exit_code = process.returncode + raise RuntimeError( + f"ffmpeg failed to start (exit code {exit_code}): {stderr_output}" + ) + # Store recording info with UUID self.recordings[recording_uuid] = { "process": process, @@ -118,16 +138,20 @@ def start(self, video_size=None, framerate=None, filename=None): "video_size": video_size, "framerate": framerate, } - + # Update current recording (for backward compatibility) self.process = process self.current_file = target self.current_uuid = recording_uuid - + return recording_uuid, target, video_size, framerate def _build_ffmpeg_cmd(self, video_size, framerate, target): """Build ffmpeg command based on platform.""" + # libx264 requires width and height to be divisible by 2 + # Use scale filter to ensure dimensions are even + scale_filter = "scale=trunc(iw/2)*2:trunc(ih/2)*2" + if IS_MACOS: # macOS: use avfoundation # capture_input format: "video_device:audio_device" e.g., "1:none" or "0:none" @@ -139,7 +163,7 @@ def _build_ffmpeg_cmd(self, video_size, framerate, target): "-framerate", str(framerate), "-capture_cursor", "1", # Capture mouse cursor "-i", capture_input, - "-vf", f"scale={video_size.replace('x', ':')}", # Scale to desired size + "-vf", scale_filter, "-codec:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", @@ -154,6 +178,7 @@ def _build_ffmpeg_cmd(self, video_size, framerate, target): "-framerate", str(framerate), "-f", "x11grab", "-i", self.display, + "-vf", scale_filter, # Ensure dimensions are divisible by 2 "-codec:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", @@ -168,13 +193,45 @@ def stop(self, recording_uuid=None): rec = self.recordings[recording_uuid] process = rec["process"] finished_file = rec["file"] - + if process is None or process.poll() is not None: - raise RuntimeError(f"Recording with UUID {recording_uuid} is not running") + # Process has exited, try to read stderr for error details + stderr_output = "" + exit_code = None + if process is not None: + exit_code = process.returncode + if process.stderr: + try: + stderr_output = process.stderr.read().decode(errors="replace") + except Exception: + pass + + error_msg = f"Recording with UUID {recording_uuid} is not running" + if exit_code is not None: + error_msg += f" (exit code: {exit_code})" + if stderr_output: + error_msg += f": {stderr_output}" + raise RuntimeError(error_msg) else: # Backward compatibility: use current recording if not self.is_running(): - raise RuntimeError("No active recording") + # Process has exited, try to read stderr for error details + stderr_output = "" + exit_code = None + if self.process is not None: + exit_code = self.process.returncode + if self.process.stderr: + try: + stderr_output = self.process.stderr.read().decode(errors="replace") + except Exception: + pass + + error_msg = "No active recording" + if exit_code is not None: + error_msg += f" (exit code: {exit_code})" + if stderr_output: + error_msg += f": {stderr_output}" + raise RuntimeError(error_msg) process = self.process finished_file = self.current_file recording_uuid = self.current_uuid From ec6a7591a38f7f5fb605418695fcebbed971542b Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Wed, 28 Jan 2026 17:01:15 +0800 Subject: [PATCH 7/8] log --- src/ubuntu/install/chromium/recording_api.py | 29 ++++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/ubuntu/install/chromium/recording_api.py b/src/ubuntu/install/chromium/recording_api.py index 3eb2b363..3bfa964e 100644 --- a/src/ubuntu/install/chromium/recording_api.py +++ b/src/ubuntu/install/chromium/recording_api.py @@ -55,8 +55,9 @@ def __init__(self, display, recording_dir, default_size, default_fps, capture_in self.process = None self.current_file = None self.last_file = None + self.current_cmd = None # UUID-based recording management - # Format: {uuid: {"process": process, "file": file_path, "video_size": size, "framerate": fps}} + # Format: {uuid: {"process": process, "file": file_path, "video_size": size, "framerate": fps, "cmd": [...]}} self.recordings = {} self.current_uuid = None # Platform info @@ -128,7 +129,9 @@ def start(self, video_size=None, framerate=None, filename=None): pass exit_code = process.returncode raise RuntimeError( - f"ffmpeg failed to start (exit code {exit_code}): {stderr_output}" + f"ffmpeg failed to start (exit code {exit_code}). " + f"cmd: {cmd}. " + f"stderr: {stderr_output}" ) # Store recording info with UUID @@ -137,12 +140,14 @@ def start(self, video_size=None, framerate=None, filename=None): "file": target, "video_size": video_size, "framerate": framerate, + "cmd": cmd, } # Update current recording (for backward compatibility) self.process = process self.current_file = target self.current_uuid = recording_uuid + self.current_cmd = cmd return recording_uuid, target, video_size, framerate @@ -193,6 +198,7 @@ def stop(self, recording_uuid=None): rec = self.recordings[recording_uuid] process = rec["process"] finished_file = rec["file"] + cmd = rec.get("cmd") if process is None or process.poll() is not None: # Process has exited, try to read stderr for error details @@ -209,8 +215,10 @@ def stop(self, recording_uuid=None): error_msg = f"Recording with UUID {recording_uuid} is not running" if exit_code is not None: error_msg += f" (exit code: {exit_code})" + if cmd: + error_msg += f" cmd: {cmd}" if stderr_output: - error_msg += f": {stderr_output}" + error_msg += f" stderr: {stderr_output}" raise RuntimeError(error_msg) else: # Backward compatibility: use current recording @@ -229,13 +237,15 @@ def stop(self, recording_uuid=None): error_msg = "No active recording" if exit_code is not None: error_msg += f" (exit code: {exit_code})" + if self.current_cmd: + error_msg += f" cmd: {self.current_cmd}" if stderr_output: - error_msg += f": {stderr_output}" + error_msg += f" stderr: {stderr_output}" raise RuntimeError(error_msg) process = self.process finished_file = self.current_file recording_uuid = self.current_uuid - + # Send SIGINT to gracefully stop ffmpeg process.send_signal(signal.SIGINT) try: @@ -249,7 +259,7 @@ def stop(self, recording_uuid=None): max_wait = 3 # Maximum seconds to wait for file wait_interval = 0.1 # Check every 100ms waited = 0 - + while waited < max_wait: if finished_file and os.path.exists(finished_file): # Check if file is readable and has content @@ -262,11 +272,11 @@ def stop(self, recording_uuid=None): pass time.sleep(wait_interval) waited += wait_interval - + # Verify the file exists and is readable if not finished_file or not os.path.exists(finished_file): raise RuntimeError(f"Recording file not found: {finished_file}") - + try: file_size = os.path.getsize(finished_file) if file_size == 0: @@ -278,13 +288,14 @@ def stop(self, recording_uuid=None): if recording_uuid and recording_uuid in self.recordings: self.recordings[recording_uuid]["process"] = None self.last_file = finished_file - + # Update current recording state (for backward compatibility) if recording_uuid == self.current_uuid: self.last_file = finished_file self.process = None self.current_file = None self.current_uuid = None + self.current_cmd = None # Ensure we always return a UUID (even if None for backward compatibility) return recording_uuid, finished_file From 2737f8714878f97d399e24e5121285863c58ee04 Mon Sep 17 00:00:00 2001 From: liyuhao03 Date: Wed, 28 Jan 2026 19:56:20 +0800 Subject: [PATCH 8/8] fix crash --- src/ubuntu/install/chromium/recording_api.py | 47 +++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/ubuntu/install/chromium/recording_api.py b/src/ubuntu/install/chromium/recording_api.py index 3bfa964e..7f3bb866 100644 --- a/src/ubuntu/install/chromium/recording_api.py +++ b/src/ubuntu/install/chromium/recording_api.py @@ -172,6 +172,8 @@ def _build_ffmpeg_cmd(self, video_size, framerate, target): "-codec:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-profile:v", "baseline", # Chrome / QuickTime / Safari compatibility + "-level", "4.0", target, ] else: @@ -187,6 +189,8 @@ def _build_ffmpeg_cmd(self, video_size, framerate, target): "-codec:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-profile:v", "baseline", # Chrome / QuickTime / Safari compatibility + "-level", "4.0", target, ] @@ -284,6 +288,9 @@ def stop(self, recording_uuid=None): except OSError as e: raise RuntimeError(f"Cannot access recording file: {e}") + # Remux to put moov at start (Chrome / QuickTime require this) + finished_file = self._remux_faststart(finished_file) + # Update recording info if recording_uuid and recording_uuid in self.recordings: self.recordings[recording_uuid]["process"] = None @@ -296,7 +303,7 @@ def stop(self, recording_uuid=None): self.current_file = None self.current_uuid = None self.current_cmd = None - + # Ensure we always return a UUID (even if None for backward compatibility) return recording_uuid, finished_file @@ -310,6 +317,44 @@ def get_file_by_uuid(self, recording_uuid): return file_path return None + def _remux_faststart(self, path): + """Remux MP4 so moov atom is at start (required by Chrome / QuickTime).""" + if not shutil.which("ffmpeg"): + return path + tmp = path + ".tmp" + try: + proc = subprocess.run( + ["ffmpeg", "-y", "-i", path, "-c", "copy", "-movflags", "+faststart", tmp], + capture_output=True, + timeout=60, + ) + if proc.returncode != 0: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + stderr = proc.stderr.decode(errors="replace") if proc.stderr else "" + raise RuntimeError( + f"Remux for browser compatibility failed (exit {proc.returncode}): {stderr[:500]}" + ) + os.replace(tmp, path) + return path + except subprocess.TimeoutExpired: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + raise RuntimeError("Remux for browser compatibility timed out") + except Exception: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + raise + def _cleanup_old_recordings(self): """Clean up old recording files to avoid disk bloat.