renderer = new GdRenderer(); $this->dumper = new TerminalRenderer(); } private function showStats() { if ($this->nextStat === null) { $this->nextStat = microtime(true) + 2; return; } elseif ($this->nextStat > microtime(true)) { return; } $this->nextStat = microtime(true) + 2; $fps = ($this->frame - $this->lastFrames) / 2; $this->lastFrames = $this->frame; fprintf(STDERR, "rendering: f=%d fps=%.1f t=%.1d\n", $this->frame, $fps, microtime(true) - $this->startTime); } public function setOutput(string $output) { $this->output = $output; } public function setOutputVideo(string $outputVideo) { $this->outputVideo = $outputVideo; } public function loadModule(string $filename) { $funcs = include $filename; foreach ($funcs as $name=>$func) { $this->funcs[$name] = $func; } } public function executeFile(string $filename) { $this->startTime = microtime(true); $this->includeFile($filename); echo "\r\e[K"; if ($this->outputVideo) { fprintf(STDERR, "Converting %s → %s\n", $this->output, $this->outputVideo); passthru(sprintf("ffmpeg -v error -r %d -i %s -y %s", $this->fps, $this->output, $this->outputVideo)); fprintf(STDERR, "Cleaning up %d files...\n", count($this->writtenFiles)); foreach ($this->writtenFiles as $fn) { unlink($fn); } } } public function includeFile(string $filename) { $fp = fopen($filename, "r"); if (!$fp) return; $lc = 0; while (!feof($fp)) { $lc++; $ln = trim(fgets($fp)); if ("" == $ln || $ln[0] == "#") continue; $lb = str_getcsv($ln, " "); try { $this->evaluate($lb); } catch (\Throwable $t) { fprintf(STDERR, "Error executing %s line %d: %s\n", $filename, $lc, $t->getMessage()); } } fclose($fp); } public function evaluate(array $data) { if ($this->execMode == "sub") { if ($data[0] == "endsub") { $this->execMode = null; $this->execTarget = null; return; } $this->subs[$this->execTarget]->sub[] = $data; return; } $args = $data; $cmd = array_shift($args); switch ($cmd) { case 'set': $prop = array_shift($args); $value = array_shift($args); $this->setProp($prop, $value); break; case 'include': $file = array_shift($args); $this->includeFile($file); break; case 'load': $file = array_shift($args); $this->loadModule($file); break; case 'sub': $subname = array_shift($args); $subargs = $args; $this->subs[$subname] = (object)[ 'sub' => [], 'args' => $subargs ]; $this->execMode = "sub"; $this->execTarget = $subname; break; case 'log': echo "log: "; echo join("", $args); echo "\n"; break; case 'write': $this->writeTerm($args); break; case 'type': $this->writeTerm($args, true); break; case 'writefile': $file = file(array_shift($args)); $delay = intval(array_shift($args)??30); foreach ($file as $line) { $this->writeTerm([ $line ]); $this->delayMs($delay); } break; case 'delay': $delay = intval(array_shift($args)); $this->delayMs($delay); break; case 'savepng': $name = array_shift($args); $this->writeFrame($name); break; case 'mark': $id = array_shift($args); $this->bookmarks[$id] = $this->term->getCursorPosition(); break; case 'moveto': $id = array_shift($args); if (ctype_digit($id)) { $line = $id; $column = array_shift($args); } else { if (!array_key_exists($id, $this->bookmarks)) break; [$line, $column] = $this->bookmarks[$id]; } $this->term->setCursor($line, $column); break; case 'annotate': $id = array_shift($args); if ($id[0] != "@") { $text = $id; $id = uniqid(); } else { $text = array_shift($args); } $line = array_shift($args); $column = array_shift($args); if ($line == "here" || $line == null) { [$line,$column] = $this->term->getCursorPosition(); } elseif ($line == "at") { $bmid = $column; [$line,$column] = array_key_exists($bmid, $this->bookmarks) ? $this->bookmarks[$bmid] : [0,0]; } $this->renderer->addAnnotation($id, $line, $column, $text); break; case 'unannotate': $id = array_shift($args); $this->renderer->removeAnnotation($id); break; case 'clearannotate': $this->renderer->clearAnnotations(); break; case 'annotatelength': $id = array_shift($args); $this->renderer->updateAnnotation($id, [ 'length' => intval(array_shift($args))]); break; case 'showkeys': $this->renderer->setKeyOverlay($args); break; case 'cursor': $op = array_shift($args); switch ($op) { case 'push': // push cursor position break; case 'pop': // pop cursor position break; case 'blink': // set cursorBlinkRate $this->cursorBlinkRate = intval(array_shift($args)); break; case 'style': $style = array_shift($args); switch ($style) { case 'none': $this->cursorStyle = GdRenderer::CURSOR_NONE; break; case 'block': $this->cursorStyle = GdRenderer::CURSOR_BLOCK; break; case 'solid': $this->cursorStyle = GdRenderer::CURSOR_SOLID; break; } break; } break; case 'clear': $what = array_shift($args); $tw = $this->term->getColumns(); $th = $this->term->getLines(); [$cl,$cc] = $this->term->getCursorPosition(); $sa = $this->term->getAttributes(); switch ($what) { case 'screen': for ($l = 0; $l < $th; $l++) { for ($c = 0; $c < $tw; $c++) { $this->term->bufferSetRaw($l, $c, [ ' ', $sa ]); } } break; case 'line': for ($c = 0; $c < $tw; $c++) { $this->term->bufferSetRaw($cl, $c, [ ' ', $sa ]); } break; case 'to-eol': for ($c = $cc; $c < $tw; $c++) { $this->term->bufferSetRaw($cl, $c, [ ' ', $sa ]); } break; case 'to-bol': for ($c = 0; $c <= $cc; $c++) { $this->term->bufferSetRaw($cl, $c, [ ' ', $sa ]); } break; case 'above': for ($l = 0; $l < $cl; $l++) { for ($c = 0; $c < $tw; $c++) { $this->term->bufferSetRaw($l, $c, [ ' ', $sa ]); } } break; case 'to-top': for ($l = 0; $l <= $cl; $l++) { for ($c = 0; $c < $tw; $c++) { $this->term->bufferSetRaw($l, $c, [ ' ', $sa ]); } } break; case 'below': for ($l = $cl + 1; $l < $th; $l++) { for ($c = 0; $c < $tw; $c++) { $this->term->bufferSetRaw($l, $c, [ ' ', $sa ]); } } break; case 'to-bottom': for ($l = $cl; $l < $th; $l++) { for ($c = 0; $c < $tw; $c++) { $this->term->bufferSetRaw($l, $c, [ ' ', $sa ]); } } break; default: fprintf(STDERR, "error: Can't clear '%s'\n", $what); } break; default: if (array_key_exists($cmd, $this->subs)) { $this->callSub($cmd, $args); return; } if (array_key_exists($cmd, $this->funcs)) { $this->callFunc($cmd, $args); return; } throw new \LogicException("No such command ${cmd}"); } } public function writeFrame($name=null) { $ft = ($this->frame * $this->frameDuration); if ($this->cursorBlinkRate > 0) { //printf("\nft=%.1f r=%.1f\n", $ft, $this->cursorBlinkRate); $visible = (floor($ft / $this->cursorBlinkRate) % 2) == 0; if ($visible) { $this->renderer->setCursorStyle($this->cursorStyle); } else { $this->renderer->setCursorStyle(GdRenderer::CURSOR_NONE); } } else { $this->renderer->setCursorStyle($this->cursorStyle); } $this->renderer->render($this->term); if ($name) { $this->renderer->writePng($name); return; } $file = sprintf($this->output, $this->frame++); $this->renderer->writePng($file); $this->showStats(); $this->writtenFiles[] = $file; } public function setFps(float $fps) { $this->fps = $fps; if ($fps > 0) { $this->frameDuration = 1000 / $fps; } } public function delayMs(int $ms) { if ($this->fps == 0) return; $this->elapsedMs += $ms; $lastFrameMs = $this->frame * $this->frameDuration; $nextFrameMs = $lastFrameMs + $this->frameDuration; while ($this->elapsedMs >= $nextFrameMs) { $this->writeFrame(); $nextFrameMs += $this->frameDuration; } } public function writeTerm(array $args, bool $type=false) { //printf("write: %s\n", json_encode($args)); $buf = null; $lastArg = null; while (count($args)) { $arg = array_shift($args); if ($arg && $arg[0] == "<" && strlen($arg) > 1) { if (substr($arg, -1, 1) == ">") { $this->setupTerm(substr($arg, 1, -1)); continue; } $buf = [ $arg ]; while ($tail = array_shift($args)) { $buf[] = $tail; if (substr($tail, -1, 1) == ">") { break; } } $buf = join(" ", $buf); $this->setupTerm(substr($buf, 1, -1)); $buf = null; } elseif (preg_match('/u\{([0-9a-f]+)\}/', $arg, $match)) { $arg = mb_chr(hexdec($match[1])); $lastArg = $arg; //printf("write: '%s'\n", $arg); if (!$type) { $this->term->write($arg); } else { for ($c = 0; $c < mb_strlen($arg); $c++) { $this->term->write(mb_substr($arg, $c, 1)); $this->delayMs($this->typeDelay + rand(-$this->typeJitter,$this->typeJitter)); } } } elseif (preg_match('/\*([0-9]+)/', $arg, $match)) { $repeat = intval($match[1]) - 1; //printf("repeat: '%s' * %d\n", $lastArg, $repeat); for ($n = 0; $n < $repeat; $n++) { if (!$type) { $this->term->write((string)$lastArg); } else { for ($c = 0; $c < mb_strlen($lastArg); $c++) { $this->term->write(mb_substr($lastArg, $c, 1)); $this->delayMs($this->typeDelay + rand(-$this->typeJitter,$this->typeJitter)); } } } } else { //printf("write: '%s'\n", $arg); $lastArg = $arg; if (!$type) { $this->term->write($arg); } else { for ($c = 0; $c < mb_strlen($arg); $c++) { $this->term->write(mb_substr($arg, $c, 1)); $this->delayMs($this->typeDelay + rand(-$this->typeJitter,$this->typeJitter)); } } } } } public function setupTerm(string $prop) { $props = explode(" ", $prop); $mode = array_shift($props); switch ($mode) { case 'home': $this->term->setCursor($this->term->getCursorPosition()[0], 0); break; case 'br': case 'return': $this->term->setCursor($this->term->getCursorPosition()[0] + 1, 0); break; case 'move': // move [steps] // dir = { up, down, left, right } case 'erase': // erase // to = { screen, to-eol, to-bol, to-top, to-bottom } break; case 'sgr': $sgr = [ 0 ]; while ($k = array_shift($props)) { if (strpos($k, ":")!==false) { [$k,$v] = explode(":", $k, 2); } else { $v = true; } switch ($k) { case 'bold': $sgr[] = 1; break; case 'fg': $sgr[] = 30 + $this->colorToIndex($v); break; case 'bg': $sgr[] = 40 + $this->colorToIndex($v); break; default: fprintf(STDERR, "Unknown attribute: %s\n", $k); } } // printf("sgr: %s\n", json_encode($sgr)); $this->term->setAttributes($sgr); break; } } private function colorToIndex(string $color):int { switch ($color) { case 'black': return 0; case 'red': return 1; case 'green': return 2; case 'yellow': return 3; case 'blue': return 4; case 'magenta': return 5; case 'cyan': return 6; case 'white': return 7; } } public function callSub(string $sub, array $args=[]) { foreach ($this->subs[$sub]->sub as $data) { foreach ($data as $k=>$v) { if ($v[0] == '$' && strlen($v) > 1) { $i = intval(substr($v, 1)) - 1; $data[$k] = array_key_exists($i, $args)?$args[$i]:$v; } } $this->evaluate($data); } } public function callFunc(string $func, array $args=[]) { call_user_func($this->funcs[$func], $this->term, ...$args); } public function setProp(string $prop, $value) { switch ($prop) { case 'terminal': [ $columns, $lines ] = explode("x", $value, 2); $this->setTerminalSize(intval($columns), intval($lines)); break; case 'colors': $this->colors = intval($value); break; case 'typedelay': $this->typeDelay = intval($value); break; case 'typejitter': $this->typeJitter = intval($value); break; case 'fps': $this->setFps(floatval($value)); break; case 'annotationsize': $this->annotationFontSize = intval($value); break; case 'video': $this->outputVideo = $value; break; default: throw new \LogicException("Bad property for set: ${prop}"); } } public function setTerminalSize(int $columns, int $rows) { $this->term = new TerminalBuffer($columns, $rows); } }