<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*
* Runs the audio and video work from a command line: reads a video and
* writes a thumbnail from it, still or moving, and tells what a file
* holds without decoding it.
*/
namespace seekquarry\yioop\executables;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\av_processing\AudioConverter;
use seekquarry\yioop\library\av_processing\PlainSound;
use seekquarry\yioop\library\av_processing\SoundTrack;
use seekquarry\yioop\library\av_processing\SpeechDecoder;
use seekquarry\yioop\library\av_processing\VideoExtractor;
use seekquarry\yioop\library\av_processing\VideoException;
if (php_sapi_name() != 'cli' ||
defined("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER")) {
echo "BAD REQUEST"; exit();
}
/** Load in global configuration settings */
require_once __DIR__ . '/../configs/Config.php';
/**
* avToolUsage gives the help the tool prints when asked, or when it is run with
* nothing it can work on.
*
* @return string the help text, without a line ending at the end
*/
function avToolUsage(): string
{
$lines = [
"AVTool.php - work on audio and video files, in pure PHP",
"",
"Yioop uses it for the media work a site does: a picture to show",
"for a video, and a recording turned into the one kind every",
"browser plays. Both are done in PHP itself, with no other",
"program to install.",
"",
"Usage:",
" php AVTool.php <video> [options] picture from a video",
" php AVTool.php <sound> <out.m4a> convert a recording",
" php AVTool.php <file> --probe say what is inside",
" php AVTool.php <rec> --speech -o out.wav hear a recording",
"",
"Video it reads: MP4, M4V, MOV, AVI, Ogg and WebM files, holding",
"H.264, H.265, VP8, VP9, Theora or motion JPEG. A video in",
"another codec still reports its size and how long it runs with",
"--probe.",
"",
"Sound it reads: Ogg and WebM files holding Opus, which is what",
"a browser records, and WAV and AIFF files, which keep their",
"samples as they are. It writes .m4a, which every browser plays,",
"and .wav and .aiff, which any editor opens. Reading Vorbis, MP3",
"and AAC is not written yet.",
"",
"Naming a video and a sound file writes the video's sound on its",
"own. The sound track of an MP4, WebM or Ogg file is found",
"without decoding any picture. Sound already compressed the way",
"an MP4 holds it is carried across as it stands rather than",
"compressed again.",
"",
"A still picture is written from the keyframe at or before the"
. " time asked for.",
"Pass --animated for a moving picture built from evenly spaced"
. " keyframes instead.",
"",
"Options:",
" -o, --out PATH output file (default: thumb.webp)",
" -t, --time SECS timestamp for the still frame (default: 0)",
" -a, --animated write a moving picture instead",
" -s, --still write a single frame (what it does"
. " anyway)",
" -n, --frames N frames in the animation (default: 10)",
" -d, --delay MS milliseconds each animation frame is shown",
" (default: 1000)",
" -w, --width PX scale the output down to this width",
" -q, --quality N WebP quality, 0 to 100 (default: 80)",
" -p, --probe print size, duration and codec as JSON,"
. " decode nothing",
" --speech read a recording with Yioop's own speech"
. " decoder",
" and write it as a wave file to --out",
" -i, --info print track and keyframe information,"
. " write nothing",
" -h, --help show this message",
"",
"Examples:",
" php AVTool.php clip.mp4 -t 12.5 -o poster.webp -w 640",
" php AVTool.php movie.mp4 --animated -n 10 -d 1000 -w 320",
" php AVTool.php movie.mp4 --probe",
" php AVTool.php voice.webm voice.m4a",
" php AVTool.php voice.ogg --probe",
"",
"Only the keyframe at or before a given time can be decoded,"
. " so a time",
"asked for snaps back to the nearest one.",
];
return implode("\n", $lines);
}
/**
* avToolSound handles a recording: it says what is inside one when
* asked to describe it, and otherwise writes it out as an MP4 holding
* AAC, which every browser plays.
*
* @param string $path The recording to read.
* @param string $kind The word ogg or webm, as the reader worked it out.
* @param array $options What the command line asked for.
* @return int Zero where the work was done, and a number saying what
* went wrong where it was not.
*/
function avToolSound(string $path, string $kind, array $options): int
{
try {
if (!$options['speech'] && ($options['probe'] ||
$options['info'] || !$options['out_given'])) {
$said = ['file' => basename($path)]
+ (($kind === 'wav' || $kind === 'aiff')
? PlainSound::describe($path, $kind)
: AudioConverter::describe($path, $kind));
fwrite(STDOUT, json_encode($said,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
return 0;
}
$out = $options['out'];
if (!empty($options['speech'])) {
/* A recording made the way built for speech is read by
Yioop's own decoder and written as a wave file, so a
person can listen to what that decoder made. */
$samples = SpeechDecoder::samplesOfRecording($path);
if (count($samples) < 1) {
fwrite(STDERR, "no speech was found in that file\n");
return 3;
}
file_put_contents($out,
SpeechDecoder::waveOfSamples($samples));
fprintf(STDERR, "wrote %s (%d samples, %.2fs, %s bytes)\n",
basename($out), count($samples),
count($samples) / SpeechDecoder::SAMPLE_RATE,
number_format(filesize($out)));
return 0;
}
if (AudioConverter::kindWanted($out) === '') {
fwrite(STDERR, "sound is written as .m4a, .mp4, .wav or"
. " .aiff\n");
return 2;
}
$made = AudioConverter::convert($path, $out);
fprintf(STDERR, "wrote %s (%d samples, %.2fs, %s bytes)\n",
basename($out), $made['samples'], $made['seconds'],
number_format(filesize($out)));
return 0;
} catch (\Throwable $trouble) {
fwrite(STDERR, $trouble->getMessage() . "\n");
return 4;
}
}
/**
* avToolMain runs the tool. It reads the options, opens the video, and
* writes either a still picture, a moving one, or a description of what
* the file holds.
*
* @param array $argv The command line, the tool's own name first.
* @return int Zero where the work was done, and a number saying what
* went wrong where it was not.
*/
function avToolMain(array $argv): int
{
$options = [
'out' => 'thumb.webp', 'out_given' => false, 'speech' => false,
'time' => 0.0,
'mode' => 'still', 'frames' => 10,
'delay' => 1000, 'width' => null, 'quality' => 80, 'info' => false,
'probe' => false,
];
$path = null;
for ($i = 1, $total = count($argv); $i < $total; $i++) {
$flag = $argv[$i];
$needsValue
= static function (string $flag) use ($argv, &$i, $total): string {
if ($i + 1 >= $total) {
throw new \InvalidArgumentException("$flag needs a value");
}
return $argv[++$i];
};
switch ($flag) {
case '-h': case '--help':
fwrite(STDOUT, avToolUsage() . "\n");
return 0;
case '-a': case '--animated': $options['mode'] = 'animated'; break;
case '-s': case '--still': $options['mode'] = 'still'; break;
case '-i': case '--info': $options['info'] = true; break;
case '-p': case '--probe': $options['probe'] = true; break;
case '--speech': $options['speech'] = true; break;
case '-o': case '--out':
$options['out'] = $needsValue($flag);
break;
case '-t': case '--time':
$options['time'] = (float)$needsValue($flag);
break;
case '-n': case '--frames':
$options['frames'] = (int)$needsValue($flag);
break;
case '-d': case '--delay':
$options['delay'] = (int)$needsValue($flag);
break;
case '-w': case '--width':
$options['width'] = (int)$needsValue($flag);
break;
case '-q': case '--quality':
$options['quality'] = (int)$needsValue($flag);
break;
default:
if ($flag !== '' && $flag[0] === '-') {
fwrite(STDERR, "unknown option: $flag\n\n"
. avToolUsage() . "\n");
return 2;
}
if ($path === null) {
$path = $flag;
break;
}
if ($options['out_given']) {
fwrite(STDERR, "only two file names are read\n");
return 2;
}
$options['out'] = $flag;
$options['out_given'] = true;
}
}
if ($path === null) {
fwrite(STDERR, avToolUsage() . "\n");
return 1;
}
/* A recording is read by the audio side rather than the video one,
and which side to use is settled by what the file holds rather
than by what it is called. */
$sound = AudioConverter::soundKind($path);
if ($sound === '') {
$sound = PlainSound::kindOf($path);
}
/* A video asked to be written as sound gives up its sound track;
asked for anything else it gives a picture, which is what the
rest of this method does. */
if ($sound === '' && $options['out_given']
&& AudioConverter::kindWanted($options['out']) !== ''
&& SoundTrack::kindOf($path) !== '') {
$sound = 'video';
}
if ($sound !== '') {
return avToolSound($path, $sound, $options);
}
if (!function_exists('imagewebp')) {
fwrite(STDERR, "this build of PHP has no WebP support in GD\n");
return 3;
}
try {
$trouble = VideoExtractor::open($path);
if ($options['probe']) {
/* machine readable on standard output, so it can be piped */
fwrite(STDOUT, json_encode(
['file' => basename($path)] + $trouble->describeFile(),
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) . "\n");
return 0;
}
$duration = $trouble->durationSeconds();
$sync = $trouble->syncSamples();
fprintf(
STDERR,
"%s: %s %dx%d, %.2fs, %d keyframes\n",
basename($path), $trouble->codecName(), $trouble->frameWidth(),
$trouble->frameHeight(),
$duration, count($sync)
);
if ($options['info']) {
foreach ($sync as $sample) {
fprintf(STDERR, " keyframe sample #%d at %.3fs\n",
$sample, $trouble->sampleTime($sample));
}
return 0;
}
$animate = $options['mode'] === 'animated';
$t0 = microtime(true);
if ($animate) {
$data = $trouble->animatedThumbnail(
$options['frames'], $options['delay'], $options['width'],
$options['quality']);
file_put_contents($options['out'], $data);
fprintf(
STDERR,
"wrote %s (animated, %d bytes) in %.2fs\n",
$options['out'], strlen($data), microtime(true) - $t0
);
} else {
$image = $trouble->thumbnail($options['time'], $options['width']);
imagewebp($image, $options['out'], $options['quality']);
$wide = imagesx($image);
$tall = imagesy($image);
fprintf(
STDERR,
"wrote %s (%dx%d, %d bytes) in %.2fs\n",
$options['out'], $wide, $tall, (int) @filesize($options['out']),
microtime(true) - $t0
);
}
return 0;
} catch (\Throwable $error) {
fwrite(STDERR, $error->getMessage() . "\n");
return 4;
}
}
if (isset($argv[0])) {
exit(avToolMain($argv));
}