mirror of
https://github.com/browser-use/browser-use
synced 2026-05-06 17:52:15 +02:00
163 lines
5.2 KiB
Python
163 lines
5.2 KiB
Python
"""Video Recording Service for Browser Use Sessions."""
|
|
|
|
import base64
|
|
import logging
|
|
import math
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from browser_use.browser.profile import ViewportSize
|
|
|
|
try:
|
|
import imageio.v2 as iio # type: ignore[import-not-found]
|
|
import imageio_ffmpeg # type: ignore[import-not-found]
|
|
import numpy as np # type: ignore[import-not-found]
|
|
from imageio.core.format import Format # type: ignore[import-not-found]
|
|
|
|
IMAGEIO_AVAILABLE = True
|
|
except ImportError:
|
|
IMAGEIO_AVAILABLE = False
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_padded_size(size: ViewportSize, macro_block_size: int = 16) -> ViewportSize:
|
|
"""Calculates the dimensions padded to the nearest multiple of macro_block_size."""
|
|
width = int(math.ceil(size['width'] / macro_block_size)) * macro_block_size
|
|
height = int(math.ceil(size['height'] / macro_block_size)) * macro_block_size
|
|
return ViewportSize(width=width, height=height)
|
|
|
|
|
|
class VideoRecorderService:
|
|
"""
|
|
Handles the video encoding process for a browser session using imageio.
|
|
|
|
This service captures individual frames from the CDP screencast, decodes them,
|
|
and appends them to a video file using a pip-installable ffmpeg backend.
|
|
It automatically resizes frames to match the target video dimensions.
|
|
"""
|
|
|
|
def __init__(self, output_path: Path, size: ViewportSize, framerate: int):
|
|
"""
|
|
Initializes the video recorder.
|
|
|
|
Args:
|
|
output_path: The full path where the video will be saved.
|
|
size: A ViewportSize object specifying the width and height of the video.
|
|
framerate: The desired framerate for the output video.
|
|
"""
|
|
self.output_path = output_path
|
|
self.size = size
|
|
self.framerate = framerate
|
|
self._writer: Optional['Format.Writer'] = None
|
|
self._is_active = False
|
|
self.padded_size = _get_padded_size(self.size)
|
|
|
|
def start(self) -> None:
|
|
"""
|
|
Prepares and starts the video writer.
|
|
|
|
If the required optional dependencies are not installed, this method will
|
|
log an error and do nothing.
|
|
"""
|
|
if not IMAGEIO_AVAILABLE:
|
|
logger.error(
|
|
'MP4 recording requires optional dependencies. Please install them with: pip install "browser-use[video]"'
|
|
)
|
|
return
|
|
|
|
try:
|
|
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# The macro_block_size is set to None because we handle padding ourselves
|
|
self._writer = iio.get_writer(
|
|
str(self.output_path),
|
|
fps=self.framerate,
|
|
codec='libx264',
|
|
quality=8, # A good balance of quality and file size (1-10 scale)
|
|
pixelformat='yuv420p', # Ensures compatibility with most players
|
|
macro_block_size=None,
|
|
)
|
|
self._is_active = True
|
|
logger.debug(f'Video recorder started. Output will be saved to {self.output_path}')
|
|
except Exception as e:
|
|
logger.error(f'Failed to initialize video writer: {e}')
|
|
self._is_active = False
|
|
|
|
def add_frame(self, frame_data_b64: str) -> None:
|
|
"""
|
|
Decodes a base64-encoded PNG frame, resizes it, pads it to be codec-compatible,
|
|
and appends it to the video.
|
|
|
|
Args:
|
|
frame_data_b64: A base64-encoded string of the PNG frame data.
|
|
"""
|
|
if not self._is_active or not self._writer:
|
|
return
|
|
|
|
try:
|
|
frame_bytes = base64.b64decode(frame_data_b64)
|
|
|
|
# Build a filter chain for ffmpeg:
|
|
# 1. scale: Resizes the frame to the user-specified dimensions.
|
|
# 2. pad: Adds black bars to meet codec's macro-block requirements,
|
|
# centering the original content.
|
|
vf_chain = (
|
|
f'scale={self.size["width"]}:{self.size["height"]},'
|
|
f'pad={self.padded_size["width"]}:{self.padded_size["height"]}:(ow-iw)/2:(oh-ih)/2:color=black'
|
|
)
|
|
|
|
output_pix_fmt = 'rgb24'
|
|
command = [
|
|
imageio_ffmpeg.get_ffmpeg_exe(),
|
|
'-f',
|
|
'image2pipe', # Input format from a pipe
|
|
'-c:v',
|
|
'png', # Specify input codec is PNG
|
|
'-i',
|
|
'-', # Input from stdin
|
|
'-vf',
|
|
vf_chain, # Video filter for resizing and padding
|
|
'-f',
|
|
'rawvideo', # Output format is raw video
|
|
'-pix_fmt',
|
|
output_pix_fmt, # Output pixel format
|
|
'-', # Output to stdout
|
|
]
|
|
|
|
# Execute ffmpeg as a subprocess
|
|
proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
out, err = proc.communicate(input=frame_bytes)
|
|
|
|
if proc.returncode != 0:
|
|
err_msg = err.decode(errors='ignore').strip()
|
|
if 'deprecated pixel format used' not in err_msg.lower():
|
|
raise OSError(f'ffmpeg error during resizing/padding: {err_msg}')
|
|
else:
|
|
logger.debug(f'ffmpeg warning during resizing/padding: {err_msg}')
|
|
|
|
# Convert the raw output bytes to a numpy array with the padded dimensions
|
|
img_array = np.frombuffer(out, dtype=np.uint8).reshape((self.padded_size['height'], self.padded_size['width'], 3))
|
|
|
|
self._writer.append_data(img_array)
|
|
except Exception as e:
|
|
logger.warning(f'Could not process and add video frame: {e}')
|
|
|
|
def stop_and_save(self) -> None:
|
|
"""
|
|
Finalizes the video file by closing the writer.
|
|
|
|
This method should be called when the recording session is complete.
|
|
"""
|
|
if not self._is_active or not self._writer:
|
|
return
|
|
|
|
try:
|
|
self._writer.close()
|
|
logger.info(f'📹 Video recording saved successfully to: {self.output_path}')
|
|
except Exception as e:
|
|
logger.error(f'Failed to finalize and save video: {e}')
|
|
finally:
|
|
self._is_active = False
|
|
self._writer = None
|