-
Notifications
You must be signed in to change notification settings - Fork 279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
capture with ffmpeg #450
Comments
You can use Page.startScreencast to save a jpeg file for each loaded frame and then combine them in a video using FFMPEG or any other video software. |
wanted to do this too, made a <?php
declare(strict_types=1);
require_once __DIR__ . "/vendor/autoload.php";
$browserFactory = new \HeadlessChromium\BrowserFactory('chromium');
$browser = $browserFactory->createBrowser([
'headless' => true,
//'debugLogger' => 'php://stdout', // will enable verbose mode
'windowSize' => [1920, 1080],
]);
$page = $browser->createPage();
$recorder = new HeadlessChromiumRecorder($page);
$recorder->startRecording(everyNthFrame: 1);
$navigate = $page->navigate('https://youtube.com/');
$navigate->waitForNavigation(\HeadlessChromium\Page::NETWORK_IDLE);
$recorder->stopRecording();
$recorder->createVideo1(__DIR__ . '/output2.mp4');
die(); implementation: class HeadlessChromiumRecorder
{
private bool $is_recording = false;
// Wonder why we use a temp file handle instead of a temp file for each individual image?
// Because we need to respond to the Page.screencastFrame with a
// Page.screencastFrameAck ASAP to get smooth animations, and
// writing to an existing file is faster than creating a new file.
// it's an optimization to get smooth animations.
// Wonder why we don't just keep it all in RAM?
// Because we don't want to run out of RAM.
// disk format: double(double timestamp) + uint32_t(length_of_data) + char[length_of_data](data)
private $recording_temp_file_handle = null;
private string $recording_temp_file_path;
// what's the difference between id and sessionId? idk! but they are different.
private $sessionId;
private $id;
private $size_of_float = null; // basically always 8, IEEE 754 double precision
function __construct(
private \HeadlessChromium\Page $page
) {
$this->size_of_float = strlen(pack("d", 0)); // 8 on all normal systems...
$this->recording_temp_file_handle = tmpfile();
if ($this->recording_temp_file_handle === false) {
throw new RuntimeException("Failed to create temp file for recording: " . var_export(error_get_last(), true));
}
$this->recording_temp_file_path = stream_get_meta_data($this->recording_temp_file_handle)['uri'];
}
public function startRecording(?string $format = null, ?int $quality = null, ?int $maxWidth = null, ?int $maxHeight = null, ?int $everyNthFrame = null): void
{
if ($this->is_recording) {
throw new LogicException("Recording already started!");
}
$this->page->getSession()->on('method:Page.screencastFrame', $this->method_Page_screencastFrame_Handler(...));
$params = [];
if ($format !== null) {
$params['format'] = $format;
}
if ($quality !== null) {
$params['quality'] = $quality;
}
if ($maxWidth !== null) {
$params['maxWidth'] = $maxWidth;
}
if ($maxHeight !== null) {
$params['maxHeight'] = $maxHeight;
}
if ($everyNthFrame !== null) {
$params['everyNthFrame'] = $everyNthFrame;
}
$startScreencastResponse = $this->page->getsession()->sendMessageSync(new \HeadlessChromium\Communication\Message(
'Page.startScreencast',
$params
));
$data = $startScreencastResponse->getData();
$this->sessionId = $data['sessionId'];
$this->id = $data['id']; // what's the difference between id and sessionId? idk! but they are different.
$this->is_recording = true;
}
private function method_Page_screencastFrame_Handler(array $params): void
{
if (false) {
// $params looks like:
array(
'data' => '<base64 image jpeg>',
'metadata' => array(
'offsetTop' => 0,
'pageScaleFactor' => 1,
'deviceWidth' => 1920,
'deviceHeight' => 1080,
'scrollOffsetX' => 0,
'scrollOffsetY' => 0,
'timestamp' => 1693206269.851577,
),
'sessionId' => 1,
);
}
// need to send this message ASAP:
// any delay in sending this message will cause the animation to be laggy.
$this->page->getSession()->sendMessage(new \HeadlessChromium\Communication\Message(
'Page.screencastFrameAck',
[
'sessionId' => $params['sessionId'],
]
));
$stringToWriteToDisk = base64_decode($params['data'], true);
$stringToWriteToDisk = pack("d", $params['metadata']['timestamp']) . pack("L", strlen($stringToWriteToDisk)) . $stringToWriteToDisk;
fwrite($this->recording_temp_file_handle, $stringToWriteToDisk);
}
public function stopRecording(): void
{
if (!$this->is_recording) {
throw new LogicException("Recording not started!");
}
$this->page->getSession()->sendMessageSync(new \HeadlessChromium\Communication\Message(
'Page.stopScreencast',
[
'sessionId' => $this->sessionId,
]
));
$this->is_recording = false;
}
/**
* get all frames, useful if you want to create a video from the recording,
* with customizations not supported by createVideo1()
*
* @return array<array{timestamp:float,handle:resource,path:string}>
*/
public function getFrames(): array
{
$ret = [];
rewind($this->recording_temp_file_handle);
// warning: don't trust feof():. https://3v4l.org/CMooJ
for (;;) {
$timestamp = fread($this->recording_temp_file_handle, $this->size_of_float);
if ($timestamp === false || $timestamp === '') {
// it's documented to return false, but sometimes it returns emptystring on eof..
break;
}
if (strlen($timestamp) !== $this->size_of_float) {
throw new LogicException("... should be unreachable?");
}
$timestamp = unpack("d", $timestamp)[1];
$length_of_data = unpack("L", fread($this->recording_temp_file_handle, 4))[1];
$handle = tmpfile();
if ($length_of_data !== stream_copy_to_stream($this->recording_temp_file_handle, $handle, $length_of_data)) {
throw new RuntimeException("Failed to copy data from temp file to handle!");
}
$ret[] = [
'timestamp' => $timestamp,
'handle' => $handle,
'path' => stream_get_meta_data($handle)['uri'],
];
}
return $ret;
}
/**
* create a video file from the recording
* using ffmpeg..
*
*/
public function createVideo1(string $output_file_path = 'output.mp4', string $ffmpeg_binary = 'ffmpeg'): void
{
$frames = $this->getFrames();
$ffmpegInputTxtFileHandle = tmpfile();
$ffmpegInputTxtFilePath = stream_get_meta_data($ffmpegInputTxtFileHandle)['uri'];
$ffmpegInputTxtString = '';
$lastTimestamp = null;
foreach ($frames as $frame) {
$duration = ($lastTimestamp === null) ? 0 : ($frame['timestamp'] - $lastTimestamp);
$ffmpegInputTxtString .= "file '" . $frame['path'] . "'\nduration " . number_format($duration, 5, '.', '') . "\n";
$lastTimestamp = $frame['timestamp'];
}
fwrite($ffmpegInputTxtFileHandle, $ffmpegInputTxtString);
$cmd = array(
escapeshellarg($ffmpeg_binary),
'-y', // overwrite output file
'-f concat',
'-safe 0',
'-i ' . escapeshellarg($ffmpegInputTxtFilePath),
// '-c:v:libx264',
//'-preset', 'veryslow',
'-vsync vfr',
escapeshellarg($output_file_path),
);
passthru(implode(' ', $cmd), $exitCode);
if ($exitCode !== 0) {
throw new RuntimeException("Failed to create video! ffmpeg exit code: " . $exitCode);
}
}
} |
Seems that due to a shitty implementation of
instead of A proper fix would be replace the usleep()-loop with a select()-loop in ... i think it's |
seems in order to fix the usleep() issue, first SocketInterface needs to be updated, along with everything that implements SocketInterface 🤔 (to either be able to supply the raw connection resource directly, or get a select() signature in the interface) |
Was wondering if anyone has any ideas on how to capture this with FFMPEG?
Somehow pipe this back?
The text was updated successfully, but these errors were encountered: