Initial commit

This commit is contained in:
Chris 2021-02-01 16:14:51 +01:00
commit eceacf34cd
5 changed files with 642 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/composer.lock
/vendor
*~

167
SCRIPTING.md Normal file
View File

@ -0,0 +1,167 @@
# FakeBash Scripting
## Understanding the parser
The parser is CSV-based, and as such does not care about unquoted spaces.
For that reason, the following two examples are the same:
write "Hello World"
write H e l l o " " W o r l d
write "Hello " World
You can use unicode with the syntax `u{CODE}`:
write u{26c4} " A snowman!"
### Conventions
* Annotations are prefixed with an at-sign (`@`)
* Bookmarks are prefixed with a percent sign (`%`)
* Comments start with a hash sign (`#`) and cover the entire line
## Commands
### annotate - Add an anotation
Annotations can be added to point to any cell in the virtual terminal.
annotate @useful-tip "This is a useful tip\nthat spans 2 lines"
delay 2000
unannotate @useful-tip
Annotations doesn't need to have a name and may have a position:
annotate "This will annotate char 5 on line 1" 0 4
You can also use bookmarks for positioning the annotations:
mark %where
...
annotate "This will annotate the bookmark %where" at %where
### clearannotate - Remove all annotations
Removes all annontations in one go.
clearannotate
### delay - Wait for time to pass
Only useful if you have `set fps` to a non-zero value, this command will
simulate time passing and render any frames needed.
delay 100
### goto - Goto a bookmark
mark %somewhere
write "Hello"
goto %somewhere
write "Goodbye"
### log - Log debug info
This command just sends output to the console for debugging.
log "The parameter is " $1
### mark - Set a bookmark
mark %here
### moveto - Go to position or bookmark
moveto %here
moveto 0 0
### savepng - Save the terminal as a png
savepng output.png
### set - Set options
The `set` command is used to configure Faketerm.
Terminal-related:
set terminal 80x25 # Width and height of window
Typing related:
set typedelay 250 # Delay in ms when using the type command
set typejitter 100 # Random amount of +/- ms when using type
Animation:
set fps 30 # Enable rendering of frames
set video out.mp4 # Convert frames to a video/gif
Annotations:
set annotationsize 3 # Font size for annotations
### sub - Subroutine
The `sub` command defines a subroutine, all the way up to the
`endsub` command. Subroutines can take indexed arguments. The
arguments are not checked, and empty arguments are replaced with
empty strings.
sub hello
write "Hello, " $1 <return>
endsub
hello "World"
### type - Simulate typing to the fake terminal
This command works just like `write`, only it writes character by character.
type "Hello World"
### unannotate - Remove an annotation
Removes an annotation
unannotate @id
### write - Write to the fake terminal
Write output to the current cursor position of the virtual terminal. Extra
tags can be added as arguments, but be aware there are no closing tags.
write <sgr bold> "Hello World" <sgr> <return>
Supported tags are:
<sgr> - Set output style, no parameters resets style.
<home> - Move to the beginning of the current line
<move> - Move the cursor
<erase> - Erase line or screen
<return> - Move to the beginning of the next line
### writefile - Write a file to the fake terminal
This command writes the output of a file line by line to the fake terminal
with an optional delay between each line.
writefile "command-output.txt" 100
## Write and Type tags
The following can be used with the <sgr> tag:
sgr fg:<color>
bg:<color>
bold
underline
The following with the <move> tag:
move [steps] <direction>
The following with the <erase> tag:
erase {screen|to-eol|to-bol|to-top|to-bottom}

29
bin/faketerm Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env php
<?php
use NoccyLabs\FakeTerm\FakeTerminal;
require_once __DIR__."/../vendor/autoload.php";
$opts = getopt("hf:o:v:");
$r = sprintf("%02x%02x", rand(0,255), rand(0,255));
$output = array_key_exists("o", $opts) ? $opts["o"] : "/tmp/ft_${r}_%04d.png";
$input = array_key_exists("f", $opts) ? $opts["f"] : null;
$video = array_key_exists("v", $opts) ? $opts["v"] : null;
if (!$input) {
fwrite(STDERR, "Error: Need to specify at least -f, preferably -o\n");
exit(1);
}
$ft = new FakeTerminal();
if ($video) {
printf("Rendering to video: %s\n", $video);
$ft->setOutputVideo($video);
}
$ft->setOutput($output);
$ft->executeFile($input);

26
composer.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "noccylabs/faketerm",
"description": "Script fake terminals into videos",
"type": "application",
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Christopher Vagnetoft",
"email": "cvagnetoft@gmail.com"
}
],
"autoload": {
"psr-4": {
"NoccyLabs\\FakeTerm\\": "src/"
}
},
"repositories": [
{
"type": "path",
"url": "../php-termbuf"
}
],
"require": {
"noccylabs/termbuf": "@dev"
}
}

416
src/FakeTerminal.php Normal file
View File

@ -0,0 +1,416 @@
<?php
namespace NoccyLabs\FakeTerm;
use NoccyLabs\TermBuf\Renderer\GdRenderer;
use NoccyLabs\TermBuf\Renderer\TerminalRenderer;
use NoccyLabs\TermBuf\TerminalBuffer;
class FakeTerminal
{
/** @var TerminalBuffer */
private $term;
/** @var GdRenderer */
private $renderer;
private $subs = [];
private $execMode = null;
private $execTarget = null;
private $typeDelay = 100;
private $typeJitter = 0;
private $elapsedMs = 0;
private $fps = 0;
private $frame = 0;
private $frameDuration = 0;
private $output;
private $bookmarks = [];
private $outputVideo = null;
private $writtenFiles = [];
public function __construct()
{
$this->renderer = new GdRenderer();
$this->dumper = new TerminalRenderer();
}
public function setOutput(string $output)
{
$this->output = $output;
}
public function setOutputVideo(string $outputVideo)
{
$this->outputVideo = $outputVideo;
}
public function executeFile(string $filename)
{
$fp = fopen($filename, "r");
$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());
}
}
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 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 '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;
default:
if (array_key_exists($cmd, $this->subs)) {
$this->callSub($cmd, $args);
return;
}
throw new \LogicException("No such command ${cmd}");
}
}
public function writeFrame($name=null)
{
$this->renderer->render($this->term);
if ($name) {
$this->renderer->writePng($name);
return;
}
$file = sprintf($this->output, $this->frame++);
$this->renderer->writePng($file);
//$this->dumper->render($this->term);
//printf("\e[0m\rwrite: \e[1m%s\e[0m", $file);
$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;
while (count($args)) {
$arg = array_shift($args);
if ($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]));
//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));
}
}
} else {
//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));
}
}
}
}
}
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 'return':
$this->term->setCursor($this->term->getCursorPosition()[0] + 1, 0);
break;
case 'move':
// move [steps] <dir>
// dir = { up, down, left, right }
case 'erase':
// erase <to>
// 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]:"";
}
}
$this->evaluate($data);
}
}
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);
}
}