Last active
November 30, 2020 18:23
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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