Skip to content
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

Closed
audas opened this issue Nov 10, 2022 · 4 comments
Closed

capture with ffmpeg #450

audas opened this issue Nov 10, 2022 · 4 comments

Comments

@audas
Copy link

audas commented Nov 10, 2022

Was wondering if anyone has any ideas on how to capture this with FFMPEG?

Somehow pipe this back?

@enricodias
Copy link
Member

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.

@divinity76
Copy link
Contributor

divinity76 commented Aug 28, 2023

wanted to do this too, made a HeadlessChromiumRecorder class, sample usage:

<?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);
        }
    }
}

@divinity76
Copy link
Contributor

divinity76 commented Nov 16, 2023

Seems that due to a shitty implementation of waitForNavigation where it use usleep() instead of socket_select()/stream_select() , recording with waitForNavigation causes laggy recordings of animations. A quickfix to this is to use

$target_time = microtime(true) + 5;
while (microtime(true) < $target_time) {
    $page->getSession()->getConnection()->readData();
}

instead of waitForNavigation
and animations will be smooth :)

A proper fix would be replace the usleep()-loop with a select()-loop in ... i think it's https://github.com/chrome-php/wrench edit: this specific usleep , but i cba looking too much into it now

@divinity76
Copy link
Contributor

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

4 participants