Initial commit
This commit is contained in:
commit
58f27830f4
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/vendor
|
35
README.md
Normal file
35
README.md
Normal file
@ -0,0 +1,35 @@
|
||||
VfxApply: Video Effect Toolkit
|
||||
==============================
|
||||
|
||||
VfxApply is a tool to apply effects and filters using presets and plugins. A preset
|
||||
can for example apply audio normalization, visualize audio as video, or apply a
|
||||
Natron pipeline.
|
||||
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
vfxapply [-i <input>] [-o <output>|-O] [-p <preset>] [-c <key>=<value>]
|
||||
|
||||
-i Set the input file (if omitted, a file picker will be displayed)
|
||||
-o Set the output file (if omitted, a file picker will be displayed)
|
||||
-O Automatically assign output filename based on input filename
|
||||
-p Select the preset to apply
|
||||
-c Specify parameters for the preset
|
||||
|
||||
Examples:
|
||||
|
||||
vfxapply
|
||||
Ask for input, output and preset
|
||||
|
||||
vfxapply -i in.mp4 -o out.m4
|
||||
Ask for preset to apply and process in.mp4 into out.mp4
|
||||
|
||||
vfxapply -O -p ffmpeg-audio-normalize
|
||||
Normalize audio after picking input, output name will be generated.
|
||||
|
||||
## Plugins
|
||||
|
||||
### ffmpeg
|
||||
|
||||
### natron
|
9
bin/vfxapply
Executable file
9
bin/vfxapply
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (!trim(exec("which zenity"))) {
|
||||
fprintf(STDERR, "You need to install zenity to use this tool.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once __DIR__."/../src/app.php";
|
20
composer.json
Normal file
20
composer.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "noccylabs/vfxapply",
|
||||
"description": "Apply effects to video and audio",
|
||||
"type": "application",
|
||||
"license": "GPL-3.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christopher Vagnetoft",
|
||||
"email": "cvagnetoft@gmail.com"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"VfxApply\\": "src/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"symfony/yaml": "^3.2"
|
||||
}
|
||||
}
|
89
plugins/ffmpeg/plugin.php
Normal file
89
plugins/ffmpeg/plugin.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply\Plugin\Ffmpeg;
|
||||
|
||||
use VfxApply\Plugin;
|
||||
use VfxApply\Input;
|
||||
use VfxApply\Output;
|
||||
use VfxApply\Preset;
|
||||
|
||||
class FfmpegPlugin extends Plugin
|
||||
{
|
||||
public function getName()
|
||||
{
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
private function appendFilterOption($filter, $key, $value=null)
|
||||
{
|
||||
if (strpos($filter,"=")===false) {
|
||||
$filter.= "=";
|
||||
} else {
|
||||
$filter.= ":";
|
||||
}
|
||||
$filter.= $key . ($value?"={$value}":"");
|
||||
return $filter;
|
||||
}
|
||||
|
||||
public function applyPreset(Preset $preset, Input $input, Output $output)
|
||||
{
|
||||
// Get filter string from preset
|
||||
$filter = $preset->get("filter");
|
||||
$type = $preset->get("type")?:"complex";
|
||||
$extra = $preset->get("extra");
|
||||
|
||||
switch ($type) {
|
||||
case 'audio':
|
||||
$filter_type = "-af";
|
||||
$filter_extra = "";
|
||||
break;
|
||||
case 'video':
|
||||
$filter_type = "-vf";
|
||||
$filter_extra = "";
|
||||
break;
|
||||
case 'complex':
|
||||
default:
|
||||
$filter_type = "-filter_complex";
|
||||
$filter_extra = "";
|
||||
break;
|
||||
}
|
||||
/*if (($size = $preset->getParam("size"))) {
|
||||
$filter = $this->appendFilterOption($filter, "s", $size);
|
||||
}*/
|
||||
|
||||
// Create a progress dialog
|
||||
$dialog = $this->createDialog(DIALOG_PROGRESS, "Applying filter");
|
||||
$dialog->show();
|
||||
|
||||
// Create command line to run ffmpeg
|
||||
$cmdline = sprintf(
|
||||
"ffmpeg -loglevel error -y -stats -i %s %s %s %s %s 2>&1",
|
||||
escapeshellarg($input->getFilename()),
|
||||
$filter_type,
|
||||
escapeshellarg($filter),
|
||||
trim($extra." ".$filter_extra),
|
||||
escapeshellarg($output->getFilename())
|
||||
);
|
||||
|
||||
// Create a coprocess for ffmpeg
|
||||
$frames = $input->getVideoDuration()->frames;
|
||||
$ffmpeg = $this->createProcess($cmdline, function ($read) use ($dialog,$frames) {
|
||||
$dialog->setText(trim($read));
|
||||
if (preg_match('/^frame=[\s]*([0-9]+)\s/', trim($read), $match)) {
|
||||
$frame = intval($match[1]);
|
||||
$pc = round($frame/$frames*100);
|
||||
$dialog->setProgress($pc);
|
||||
}
|
||||
if ($dialog->isCancelled()) return false;
|
||||
|
||||
// frame= 4140 fps=102 q=-1.0 Lsize= 4559kB time=00:00:48.04 bitrate= 777.3kbits/s speed=1.18x
|
||||
});
|
||||
$ffmpeg->run();
|
||||
|
||||
unset($dialog);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return new FfmpegPlugin();
|
24
plugins/natron/plugin.php
Normal file
24
plugins/natron/plugin.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply\Plugin\Natron;
|
||||
|
||||
use VfxApply\Plugin;
|
||||
use VfxApply\Input;
|
||||
use VfxApply\Output;
|
||||
use VfxApply\Preset;
|
||||
|
||||
class NatronPlugin extends Plugin
|
||||
{
|
||||
public function getName()
|
||||
{
|
||||
return "natron";
|
||||
}
|
||||
|
||||
public function applyPreset(Preset $preset, Input $input, Output $output)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return new NatronPlugin();
|
6
presets/ffmpeg-a2v/ffmpeg-ahistogram.yml
Normal file
6
presets/ffmpeg-a2v/ffmpeg-ahistogram.yml
Normal file
@ -0,0 +1,6 @@
|
||||
preset:
|
||||
name: Create histogram from audio
|
||||
group: ffmpeg/a2v
|
||||
plugin: ffmpeg
|
||||
props:
|
||||
filter: "ahistogram=dmode=separate:slide=1:scale=log"
|
6
presets/ffmpeg-a2v/ffmpeg-aphasemeter.yml
Normal file
6
presets/ffmpeg-a2v/ffmpeg-aphasemeter.yml
Normal file
@ -0,0 +1,6 @@
|
||||
preset:
|
||||
name: Create phase meter from audio
|
||||
group: ffmpeg/a2v
|
||||
plugin: ffmpeg
|
||||
props:
|
||||
filter: "aphasemeter=mpc=#ff8800"
|
7
presets/ffmpeg-a2v/ffmpeg-showspectrum.yml
Normal file
7
presets/ffmpeg-a2v/ffmpeg-showspectrum.yml
Normal file
@ -0,0 +1,7 @@
|
||||
preset:
|
||||
name: Create spectrum from audio
|
||||
group: ffmpeg/a2v
|
||||
plugin: ffmpeg
|
||||
props:
|
||||
filter: "showspectrum=mode=separate:color=intensity:slide=1:scale=log"
|
||||
|
8
presets/ffmpeg-audio/ffmpeg-audio-compand.yml
Normal file
8
presets/ffmpeg-audio/ffmpeg-audio-compand.yml
Normal file
@ -0,0 +1,8 @@
|
||||
preset:
|
||||
name: Compress/expand dynamic range
|
||||
group: ffmpeg/audio
|
||||
plugin: ffmpeg
|
||||
props:
|
||||
filter: "[0:a]compand=.3|.3:1|1:-90/-60|-60/-40|-40/-30|-20/-20:6:0:-90:0.2"
|
||||
extra: "-c:v copy"
|
||||
type: complex
|
12
presets/ffmpeg-audio/ffmpeg-audio-dynaudionorm.yml
Normal file
12
presets/ffmpeg-audio/ffmpeg-audio-dynaudionorm.yml
Normal file
@ -0,0 +1,12 @@
|
||||
preset:
|
||||
name: Normalize audio level
|
||||
group: ffmpeg/audio
|
||||
plugin: ffmpeg
|
||||
props:
|
||||
filter: "dynaudnorm=m=100:s=12"
|
||||
#filter: "dynaudnorm=m={maxgain}:s={compress}"
|
||||
extra: "-c:v copy"
|
||||
type: complex
|
||||
params:
|
||||
compress: { label:"Compression factor", type:float, min:0.0, max:30.0, default:0.0 }
|
||||
maxgain: { label:"Max gain factor", type:float, min:0.0, max:100.0, default:10.0 }
|
110
src/Application.php
Normal file
110
src/Application.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
class Application
|
||||
{
|
||||
|
||||
protected $plugins = [];
|
||||
|
||||
protected $presets = [];
|
||||
|
||||
public function readPlugins()
|
||||
{
|
||||
$iter = new \DirectoryIterator(
|
||||
__DIR__."/../plugins"
|
||||
);
|
||||
foreach ($iter as $dir) {
|
||||
if (!$dir->isDir())
|
||||
continue;
|
||||
if (!file_exists($dir->getPathname()."/plugin.php"))
|
||||
continue;
|
||||
$this->loadPlugin($dir->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
public function readPresets()
|
||||
{
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator(
|
||||
__DIR__."/../presets"
|
||||
));
|
||||
foreach ($iter as $file) {
|
||||
if ($file->isDir() || ($file->getExtension()!='yml'))
|
||||
continue;
|
||||
$this->loadPreset($file->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
private function loadPlugin($path)
|
||||
{
|
||||
$plugin = require_once $path."/plugin.php";
|
||||
$this->plugins[$plugin->getName()] = $plugin;
|
||||
}
|
||||
|
||||
private function loadPreset($file)
|
||||
{
|
||||
$preset = Preset::createFromFile($file);
|
||||
$id = basename($file,".yml");
|
||||
$this->presets[$id] = $preset;
|
||||
}
|
||||
|
||||
private function selectPreset()
|
||||
{
|
||||
$cmdl = "zenity --list --column=Group --column=Preset --column=Description ".
|
||||
"--title=\"Apply VFX\" ".
|
||||
"--width=400 --height=400 ".
|
||||
"--text=\"Select the preset to apply\" ".
|
||||
"--mid-search --print-column=2 ";
|
||||
foreach ($this->presets as $id=>$preset) {
|
||||
$cmdl.=sprintf(
|
||||
"%s %s %s ",
|
||||
escapeshellarg($preset->getGroup()),
|
||||
escapeshellarg($id),
|
||||
escapeshellarg($preset->getName())
|
||||
);
|
||||
}
|
||||
|
||||
exec($cmdl." 2>/dev/null", $output, $retval);
|
||||
if ($retval == 0) {
|
||||
$name = trim($output[0]);
|
||||
return $this->presets[$name];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function run()
|
||||
{
|
||||
$this->readPlugins();
|
||||
$this->readPresets();
|
||||
|
||||
$opts = getopt("i:o:");
|
||||
|
||||
$input = new Input();
|
||||
if (!empty($opts['i'])) {
|
||||
$input->setFilename($opts['i']);
|
||||
} else {
|
||||
if (!$input->selectFile()) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$input->analyze();
|
||||
|
||||
$dur = $input->getVideoDuration();
|
||||
printf("Input: %s %.2fs (%d frames)\n", $input->getFilename(), $dur->seconds, $dur->frames);
|
||||
|
||||
if (!($preset = $this->selectPreset())) {
|
||||
return 1;
|
||||
}
|
||||
$plugin_name = $preset->getPlugin();
|
||||
$plugin = $this->plugins[$plugin_name];
|
||||
|
||||
$output = new Output();
|
||||
$output->setFilename("/tmp/out.mp4");
|
||||
|
||||
$plugin->applyPreset($preset, $input, $output);
|
||||
|
||||
}
|
||||
|
||||
}
|
29
src/Dialog.php
Normal file
29
src/Dialog.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
class Dialog
|
||||
{
|
||||
protected $title;
|
||||
|
||||
public function __construct($title=null)
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->onDestroy();
|
||||
}
|
||||
|
||||
public function show()
|
||||
{
|
||||
$this->onCreate();
|
||||
}
|
||||
|
||||
protected function onCreate()
|
||||
{}
|
||||
|
||||
protected function onDestroy()
|
||||
{}
|
||||
}
|
57
src/Dialog/ProgressDialog.php
Normal file
57
src/Dialog/ProgressDialog.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply\Dialog;
|
||||
|
||||
use VfxApply\Dialog;
|
||||
|
||||
class ProgressDialog extends Dialog
|
||||
{
|
||||
protected $ps;
|
||||
|
||||
protected $pipe;
|
||||
|
||||
protected $out;
|
||||
|
||||
protected function onCreate()
|
||||
{
|
||||
$this->out = fopen("php://temp", "wb");
|
||||
$cmdl = sprintf("zenity --progress --title=%s --auto-close --text='Running' --width=400 --progress=0 2>/dev/null", escapeshellarg($this->title));
|
||||
$ds = [
|
||||
0 => [ "pipe", "r" ],
|
||||
1 => [ "pipe", "w" ],
|
||||
2 => [ "pipe", "w" ]
|
||||
];
|
||||
$this->ps = proc_open($cmdl, $ds, $pipes);
|
||||
$this->pipe = $pipes[0];
|
||||
}
|
||||
|
||||
protected function onDestroy()
|
||||
{
|
||||
/*
|
||||
$status = proc_get_status($this->ps);
|
||||
$pid = $status['pid'];
|
||||
posix_kill($pid,15);
|
||||
pcntl_waitpid($pid,$status);
|
||||
print_r($status);
|
||||
*/
|
||||
proc_terminate($this->ps);
|
||||
proc_close($this->ps);
|
||||
}
|
||||
|
||||
public function setProgress($pc)
|
||||
{
|
||||
fprintf($this->pipe, "%d\n", $pc);
|
||||
}
|
||||
|
||||
public function setText($text)
|
||||
{
|
||||
fprintf($this->pipe, "# %s\r\n", addslashes($text));
|
||||
}
|
||||
|
||||
public function isCancelled()
|
||||
{
|
||||
$status = proc_get_status($this->ps);
|
||||
return ($status['running']==false);
|
||||
}
|
||||
|
||||
}
|
69
src/Input.php
Normal file
69
src/Input.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
class Input
|
||||
{
|
||||
protected $filename;
|
||||
|
||||
protected $audiostreams = [];
|
||||
|
||||
protected $videostreams = [];
|
||||
|
||||
public function setFilename($filename)
|
||||
{
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function getFilename()
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function selectFile()
|
||||
{
|
||||
$cmdl = "zenity --file-selection --title \"Select input file\" ";
|
||||
$cmdl.= "--file-filter=\"Video files (mp4,m4v,avi,ogv,mkv)|*.mp4 *.m4v *.avi *.ogv *.mkv\" ";
|
||||
$cmdl.= "--file-filter=\"Audio files (mp3,m4a,wav,ogg)|*.mp3 *.m4a *.wav *.ogg\" ";
|
||||
$cmdl.= "--file-filter=\"All files|*\" 2>/dev/null";
|
||||
|
||||
exec($cmdl, $out, $ret);
|
||||
if ($ret == 0) {
|
||||
$this->filename = trim(array_shift($out));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function analyze()
|
||||
{
|
||||
$cmdl = "ffprobe -loglevel error -show_streams -print_format json ".escapeshellarg($this->filename);
|
||||
|
||||
exec($cmdl, $out, $ret);
|
||||
if ($ret != 0) {
|
||||
throw new \Exception("Unable to analyze file {$this->filename}");
|
||||
}
|
||||
$info = json_decode(join("\n",$out));
|
||||
|
||||
foreach ($info->streams as $stream) {
|
||||
if ($stream->codec_type == 'video') {
|
||||
$this->videostreams[] = $stream;
|
||||
} elseif ($stream->codec_type == 'audio') {
|
||||
$this->audiostreams[] = $stream;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function getVideoDuration()
|
||||
{
|
||||
$duration = $this->videostreams[0]->duration;
|
||||
$frames = $this->videostreams[0]->nb_frames;
|
||||
|
||||
return (object)[
|
||||
"seconds" => floatval($duration),
|
||||
"frames" => intval($frames)
|
||||
];
|
||||
}
|
||||
|
||||
}
|
18
src/Output.php
Normal file
18
src/Output.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
class Output
|
||||
{
|
||||
protected $filename;
|
||||
|
||||
public function setFilename($filename)
|
||||
{
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function getFilename()
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
}
|
22
src/Plugin.php
Normal file
22
src/Plugin.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
class Plugin
|
||||
{
|
||||
protected function createDialog($type, $title=null)
|
||||
{
|
||||
switch ($type) {
|
||||
case DIALOG_PROGRESS:
|
||||
return new Dialog\ProgressDialog($title);
|
||||
default:
|
||||
throw new \Exception("Error, undefined dialog type {$type}");
|
||||
}
|
||||
}
|
||||
|
||||
protected function createProcess($command, callable $callback)
|
||||
{
|
||||
return new Process($command, $callback);
|
||||
}
|
||||
|
||||
}
|
70
src/Preset.php
Normal file
70
src/Preset.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Preset
|
||||
{
|
||||
/** @var string Preset name */
|
||||
protected $name;
|
||||
/** @var string The group this preset belong to, for organizing */
|
||||
protected $group;
|
||||
/** @var string The plugin this preset uses */
|
||||
protected $plugin;
|
||||
/** @var array The properties are used by the plugin */
|
||||
protected $props = [];
|
||||
/** @var array The params can be assigned from user data */
|
||||
protected $params = [];
|
||||
|
||||
public static function createFromFile($filename)
|
||||
{
|
||||
$body = file_get_contents($filename);
|
||||
$conf = Yaml::parse($body);
|
||||
if (!array_key_exists('preset',$conf)) {
|
||||
throw new \Exception("File does not appear to be a valid preset");
|
||||
}
|
||||
|
||||
return new Preset($conf['preset']);
|
||||
}
|
||||
|
||||
public function __construct(array $preset)
|
||||
{
|
||||
$this->name = $preset['name'];
|
||||
$this->group = empty($preset['group'])?null:$preset['group'];
|
||||
$this->plugin = $preset['plugin'];
|
||||
$this->props = $preset['props'];
|
||||
$this->params = empty($preset['params'])?null:$preset['params'];
|
||||
}
|
||||
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getGroup()
|
||||
{
|
||||
return $this->group;
|
||||
}
|
||||
|
||||
public function getPlugin()
|
||||
{
|
||||
return $this->plugin;
|
||||
}
|
||||
|
||||
public function get($prop)
|
||||
{
|
||||
if (!array_key_exists($prop, $this->props)) {
|
||||
return null;
|
||||
}
|
||||
return $this->props[$prop];
|
||||
}
|
||||
|
||||
public function getParam($param)
|
||||
{
|
||||
if (!array_key_exists($param, $this->params)) {
|
||||
throw new \Exception("No such param defined in preset: {$param}");
|
||||
}
|
||||
return $this->params[$param];
|
||||
}
|
||||
}
|
47
src/Process.php
Normal file
47
src/Process.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace VfxApply;
|
||||
|
||||
class Process
|
||||
{
|
||||
public function __construct($cmdline, callable $callback)
|
||||
{
|
||||
$this->cmdline = $cmdline;
|
||||
$this->callback = $callback;
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
}
|
||||
|
||||
public function run()
|
||||
{
|
||||
$ds = [
|
||||
0 => [ "pipe", "r" ],
|
||||
1 => [ "pipe", "w" ],
|
||||
2 => [ "pipe", "w" ],
|
||||
];
|
||||
echo "Exec: {$this->cmdline}\n";
|
||||
$ps = proc_open($this->cmdline, $ds, $pipes);
|
||||
list($stdin,$stdout,$stderr) = $pipes;
|
||||
|
||||
stream_set_blocking($stdout,false);
|
||||
|
||||
$buf = null;
|
||||
while (true) {
|
||||
$str = fgets($stdout, 4192);
|
||||
$status = proc_get_status($ps);
|
||||
if ($str) {
|
||||
$ret = call_user_func($this->callback, $str);
|
||||
if ($ret === false) {
|
||||
proc_terminate($ps);
|
||||
}
|
||||
}
|
||||
|
||||
if ($status['running']==false) break;
|
||||
usleep(10000);
|
||||
}
|
||||
proc_close($ps);
|
||||
return $status['exitcode'];
|
||||
}
|
||||
}
|
15
src/app.php
Normal file
15
src/app.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
define("DIALOG_PROGRESS", "progress");
|
||||
define("DIALOG_CONFIRM", "confirm");
|
||||
define("DIALOG_WARNING", "warning");
|
||||
define("DIALOG_MESSAGE", "message");
|
||||
define("DIALOG_ERROR", "error");
|
||||
define("DIALOG_OPENFILE", "openfile");
|
||||
define("DIALOG_SAVEFILE", "savefile");
|
||||
|
||||
require_once __DIR__."/../vendor/autoload.php";
|
||||
|
||||
use VfxApply\Application;
|
||||
$app = new Application();
|
||||
$app->run();
|
Loading…
Reference in New Issue
Block a user