Skip to content

Instantly share code, notes, and snippets.

@divinity76
Last active November 30, 2020 18:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save divinity76/8bd79831ab1fffe65b6dd001856ede85 to your computer and use it in GitHub Desktop.
Save divinity76/8bd79831ab1fffe65b6dd001856ede85 to your computer and use it in GitHub Desktop.
count videogame fps by inspecting video stream frame-by-frame - suggested tolerance: 0.4
<?php
// TODO: investigate -enc_time_base -1 / AndreKR's comment here: https://superuser.com/questions/1512575/why-total-frame-count-is-different-in-ffmpeg-than-ffprobe/1512583?noredirect=1#comment2288790_1512583
declare (strict_types = 1);
//require_once('hhb_.inc.php');
const EXT = "png"; //jpg gives unreliable results (but significantly less disk space usage =/ )
init();
global $input_file, $tolerance;
// ffmpeg -i test_30.mp4 thumb%01d.png -hide_banner
$cmd = implode(" ", array(
escapeshellarg('ffmpeg'),
// All FFmpeg tools will normally show a copyright notice, build options and library versions.
// This option can be used to suppress printing this information.
'-hide_banner',
// Print encoding progress/statistics. It is on by default, to explicitly disable it you need to specify -nostats.
'-nostats',
// input file
"-i " . escapeshellarg(_uncygwinify_filepath($input_file)),
// Each frame is passed with its timestamp from the demuxer to the muxer.
'-vsync passthrough',
// As an input option, blocks all audio streams of a file from being filtered or being automatically selected or mapped for any output. See -discard option to disable streams individually.
// As an output option, disables audio recording i.e. automatic selection or mapping of any audio stream. For full manual control see the -map option.
'-an',
// Show a line containing various information for each input video frame
'-vf showinfo',
escapeshellarg("frame%01d." . EXT),
));
echo "cmd: ";
var_dump($cmd);
my_exec2($cmd, "", $stdout, $stderr);
//var_dump(strlen($stdout), strlen($stderr));
$lines = array_values(array_filter(array_map("trim", explode("\n", $stderr)), 'strlen'));
$frames = array();
foreach ($lines as $line) {
// [Parsed_showinfo_0 @ 000000000253fc80] n: 2 pts: 400 pts_time:0.0333667 pos: 80974 fmt:yuv420p sar:1/1 s:1280x720 i:P iskey:0 type:P checksum:9AEB549C plane_checksum:[8A1C9BC4 E8699C52 5AD21C77] mean:[39 135 119] stdev:[28.3 8.3 7.2]
$rex_ret = preg_match('/n\\:\\s*(?<frame_num>\\d+)\\s*pts\\:\\s*\\d+\\s*pts_time\\:\\s*(?<pts_time>\\d+(?:\\.\\d+)?)/', $line, $matches);
//var_dump($rex_ret, $matches);
if (!$rex_ret) {
continue;
}
// why +1? because ffmpeg image name output starts at 1..
$frames[((int)$matches['frame_num']) + 1] = (float)$matches['pts_time'];
}
//var_dump($frames);
//var_dump(count($frames) , count(glob("*")));
assert(count($frames) === count(glob("*")));
unset($lines, $line, $rex_ret, $matches);
echo "total frames: " . count($frames) . "\n";
echo "WARNING, SLOW OPERATION: now removing duplicate frames, tolerance: {$tolerance}%\n";
$i = 0;
$duplicates = 0;
uglyhack:
//
$keys = array_keys($frames);
for (; $i < (count($keys) - 1); ++$i) {
$frame1 = "frame" . $keys[$i] . "." . EXT;
$frame2 = "frame" . $keys[$i + 1] . "." . EXT;
$similarity = image_compare($frame1, $frame2);
if ($similarity >= (100 - $tolerance)) {
++$duplicates;
echo "deleting " . $frame2 . " because it's a duplicate of " . $frame1 . " (similarity: {$similarity}%)\n";
unlink($frame2);
unset($frames[$keys[$i + 1]]);
// goto uglyhack;
$keys = array_keys($frames);
--$i;
} else {
echo "{$similarity}% - ";
}
}
echo "total duplicates: {$duplicates} - remaining non-duplicate frames: " . count($frames) . "\n";
echo "creating FPS summary..";
$summary = array();
foreach ($frames as $frame => $time) {
$time = (int)floor($time);
$summary[$time] = ($summary[$time] ?? 0) + 1;
}
register_shutdown_function(function () use ($summary) {
echo "<summary>\n";
foreach ($summary as $time => $counter) {
echo "at second #{$time}: {$counter} FPS.\n";
}
echo "</summary>\n";
});
function init()
{
global $argc, $argv, $input_file, $tolerance;
//hhb_init(); //better error reporting.
stream_set_blocking(STDIN, true);
if (!is_executable_pathenv('ffmpeg')) {
die("error: ffmpeg not installed");
}
if (!is_executable_pathenv('compare')) {
die("error: imagemagick compare not installed");
}
if (0) {
$input_file = "";
$tolerance = 0.4;
} else {
if ($argc !== 3) {
die("usage: {$argv[0]} \"input_file\" tolerance (tolerance is a float, i suggest 0.4)");
} else {
$input_file = trim($argv[1]);
$tolerance = trim($argv[2]);
if (false === ($tolerance = filter_var($tolerance, FILTER_VALIDATE_FLOAT, array(
'options' => array(
'min_range' => 0,
'max_range' => 100,
'default' => false
)
)))) {
die("error: cannot parse tolerance ({$argv[2]}) as float between 0-100! pick a number between 0-100");
}
}
}
$input_file = _cygwinify_filepath($input_file);
if (!is_file($input_file) || !is_readable($input_file)) {
die("input file \"{$input_file}\" not readable!");
}
$input_file = realpath($input_file);
if (!is_dir("fps_counter_workdir")) {
if (!mkdir("fps_counter_workdir")) {
die("error: cannot create folder fps_counter_workdir");
}
}
if (!chdir("fps_counter_workdir")) {
die("error: cannot chdir(fps_counter_workdir)");
}
$pid = (string)getmypid();
if (is_dir($pid)) {
delTree($pid);
}
mkdir($pid);
chdir($pid);
echo "working dir: \"" . getcwd() . "\"\n";
echo "input file: \"" . $input_file . "\"\n";
register_shutdown_function(function () {
echo "shutting down, cleaning up.\n";
chdir("..");
delTree((string)getmypid());
echo "\n";
});
}
/**
* return how similar 2 images are, in percentage
*
* @param string $image1_path comparison image filepath
* @param string $image2_path comparison image filepath
* @return float similarity percentage
*/
function image_compare(string $image1_path, string $image2_path): float
{
// compare -metric MAE testrealNEW.png testrealNEW1px.png JSON:-
my_exec("compare -metric MAE " . escapeshellarg($image1_path) . " " . escapeshellarg($image2_path) . " JSON:-", "", $stdout, $stderr);
if (empty($stdout)) {
throw new \RuntimeException("imagemagick compare errors: {$stderr}");
}
$parsed = filter_var(json_decode($stdout, true)[0]['image']['properties']['distortion'], FILTER_VALIDATE_FLOAT);
if (false === ($parsed)) {
var_dump($stdout, $stderr);
throw new \LogicException("could not parse distortion from imagemagic stdout json!");
}
$parsed = 100 - ($parsed * 100);
assert($parsed > (-1) && $parsed < 101);
// hhb_var_dump($parsed, $stdout, $stderr);
return $parsed;
}
function is_executable_pathenv(string $filename): bool
{
if (is_executable($filename)) {
return true;
}
if ($filename !== basename($filename)) {
return false;
}
$separator = (stripos(PHP_OS_FAMILY, "win") === false ? ":" : ";");
$paths = explode($separator, getenv("PATH"));
foreach ($paths as $path) {
if (is_executable($path . DIRECTORY_SEPARATOR . $filename)) {
return true;
}
}
return false;
}
function delTree($dir)
{
$dir = realpath($dir);
echo "deleting dir \"{$dir}\"\n";
$files = array_diff(scandir($dir, SCANDIR_SORT_NONE), array('.', '..'));
foreach ($files as $file) {
$path = $dir . DIRECTORY_SEPARATOR . $file;
if (is_dir($path)) {
delTree($path);
} else {
//echo "deleting file \"{$path}\"\n";
unlink($path);
}
}
return rmdir($dir);
}
function ask(string $question, string $default): string
{
$shown_default = (empty($default) ? "emptystring" : "\"{$default}\"");
echo "enter \"{$question}\" and press enter (or just press enter and it will default to {$shown_default} )\n";
echo "{$question}: ";
$ret = fgets(STDIN);
$ret = trim($ret);
if (!strlen($ret)) {
$ret = $default;
}
echo "set to \"{$ret}\".\n";
return $ret;
}
function my_exec(string $cmd, string $stdin = "", string &$stdout = null, string &$stderr = null): int
{
$stdouth = tmpfile();
$stderrh = tmpfile();
$descriptorspec = array(
0 => array("pipe", "rb"), // stdin
1 => array("file", stream_get_meta_data($stdouth)['uri'], 'ab'),
2 => array("file", stream_get_meta_data($stderrh)['uri'], 'ab')
);
$pipes = array();
$proc = proc_open($cmd, $descriptorspec, $pipes);
while (strlen($stdin) > 0) {
$written_now = fwrite($pipes[0], $stdin);
if ($written_now <= 0) {
//... can add more error checking here, but probably means that target program
// exited or crashed before consuming all of stdin.
// (could also have explicitly closed stdin before consuming all)
break;
}
$stdin = substr($stdin, $written_now);
}
fclose($pipes[0]);
unset($stdin, $pipes[0]);
$proc_ret = proc_close($proc); // this line will stall until the process has exited.
$stdout = stream_get_contents($stdouth);
$stderr = stream_get_contents($stderrh);
fclose($stdouth);
fclose($stderrh);
return $proc_ret;
}
function my_exec2(string $cmd, string $stdin = "", string &$stdout = null, string &$stderr = null): int
{
$stdout = "";
$stderr = "";
$stdouth = tmpfile();
$stderrh = tmpfile();
$descriptorspec = array(
0 => array("pipe", "rb"), // stdin
1 => array("file", stream_get_meta_data($stdouth)['uri'], 'ab'),
2 => array("file", stream_get_meta_data($stderrh)['uri'], 'ab')
);
$pipes = array();
$proc = proc_open($cmd, $descriptorspec, $pipes);
while (strlen($stdin) > 0) {
$written_now = fwrite($pipes[0], $stdin);
if ($written_now <= 0) {
//... can add more error checking here, but probably means that target program
// exited or crashed before consuming all of stdin.
// (could also have explicitly closed stdin before consuming all)
break;
}
$stdin = substr($stdin, $written_now);
}
fclose($pipes[0]);
unset($stdin, $pipes[0]);
$fetch_and_print = function () use (&$stderrh, &$stdouth, &$stderr, &$stdout) {
// feof() LIES!! TODO: BUGREPORT
if (true || !feof($stdouth)) {
$new = stream_get_contents($stdouth);
if ($new !== false && strlen($new) > 0) {
echo $new;
$stdout .= $new;
}
}
if (true || !feof($stderrh)) {
$new = stream_get_contents($stderrh);
if ($new !== false && strlen($new) > 0) {
echo $new;
$stderr .= $new;
}
}
return;
};
while (($status = proc_get_status($proc))['running']) {
$fetch_and_print();
msleep(0.1);
}
proc_close($proc);
$fetch_and_print();
fclose($stdouth);
fclose($stderrh);
return $status['exitcode'];
}
function _uncygwinify_filepath(string $path): string
{
static $is_cygwin_cache = null;
if ($is_cygwin_cache === null) {
$is_cygwin_cache = (false !== stripos(PHP_OS, "cygwin"));
}
if ($is_cygwin_cache) {
return trim(shell_exec("cygpath -aw " . escapeshellarg($path)));
} else {
return $path;
}
}
function _cygwinify_filepath(string $path): string
{
static $is_cygwin_cache = null;
if ($is_cygwin_cache === null) {
$is_cygwin_cache = (false !== stripos(PHP_OS, "cygwin"));
}
if ($is_cygwin_cache) {
return trim(shell_exec("cygpath -a " . escapeshellarg($path)));
//return "/cygdrive/" . strtr($path, array(':' => '', '\\' => '/'));
} else {
return $path;
}
}
function msleep($time)
{
usleep((int)($time * 1000000));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment