Initial first working version

This commit is contained in:
Daniel Siepmann 2020-09-30 11:46:41 +02:00
parent 0d4dd5181e
commit 86a7dbef19
Signed by: Daniel Siepmann
GPG key ID: 33D6629915560EF4
10 changed files with 3601 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/vendor/

27
composer.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "danielsiepmann/videocutting",
"description": "Allows to cut videos using ffmpeg.",
"type": "project",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Daniel Siepmann",
"email": "coding@daniel-siepmann.de"
}
],
"autoload": {
"psr-4": {
"DanielSiepmann\\Videcutting\\": "src/"
}
},
"require": {
"php": "7.3.*",
"symfony/console": "^5.1",
"symfony/process": "^5.1"
},
"require-dev": {
"phpunit/phpunit": "^9.3",
"phpspec/prophecy": "^1.12",
"phpspec/prophecy-phpunit": "^2.0"
}
}

2785
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

21
cutvideo Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
require __DIR__.'/vendor/autoload.php';
use DanielSiepmann\Videcutting\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\SingleCommandApplication;
(new SingleCommandApplication())
->setName('Cutvideo')
->setHelp('Allows to cut a given video multiple times. Uses ffmpeg under the hood.')
->setVersion('0.1.0')
->addArgument('file', InputArgument::REQUIRED, 'The video file to cut.')
->addArgument('start', InputArgument::REQUIRED, 'The starting point of the video')
->addArgument('end', InputArgument::REQUIRED, 'The end point of the video')
->addOption('ad', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Start and end of ads, separate start and end via "/".', [])
->setCode(new Command())
->run();

30
phpunit.xml.dist Normal file
View file

@ -0,0 +1,30 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertWarningsToExceptions="true"
forceCoversAnnotation="false"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
stopOnIncomplete="false"
stopOnSkipped="false"
verbose="false"
>
<testsuites>
<testsuite name="unit">
<directory>tests/Unit/</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>

102
src/Command.php Normal file
View file

@ -0,0 +1,102 @@
<?php
namespace DanielSiepmann\Videcutting;
/*
* Copyright (C) 2020 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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 2
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class Command
{
/**
* @var SymfonyStyle
*/
private $io;
public function __invoke(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
$videoInfo = new VideoInfo(
$input->getArgument('file'),
$input->getArgument('start'),
$input->getArgument('end')
// $input->getOption('ad')
);
$cutting = new Cutting($videoInfo);
$this->io->title('Generating temp cutted video files.');
foreach ($cutting->getCommandsForCutting() as $command) {
$this->runCommand($command);
}
$this->io->title('Generating Metadata');
$this->runCommand($cutting->getCommandForGeneratingMetadata());
$this->io->title('Generating Concat input file');
$this->runCommand($cutting->getCommandForGeneratingConcatInputFile());
$this->io->title('Creating target folder');
$this->createTargetFolder($videoInfo);
$this->io->title('Generating final video');
$this->runCommand($cutting->getCommandForGeneratingVideo());
$this->io->title('Cleanup');
$this->runCommand($cutting->getCommandForCleanup());
$this->io->success(sprintf(
'Generated video file "%s".',
$videoInfo->getTargetFilePath()
));
return 0;
}
private function runCommand(Process $command): void
{
if ($this->io->isVeryVerbose()) {
$this->io->comment('Executing command');
$this->io->comment($command->getCommandLine());
}
$command->run(function ($type, $buffer) {
$this->io->writeln($buffer);
});
if (!$command->isSuccessful()) {
throw new ProcessFailedException($command);
}
}
private function createTargetFolder(VideoInfo $videoInfo): void
{
$target = dirname($videoInfo->getTargetFilePath());
if (file_exists($target)) {
return;
}
mkdir($target, 0775, true);
}
}

144
src/Cutting.php Normal file
View file

@ -0,0 +1,144 @@
<?php
namespace DanielSiepmann\Videcutting;
/*
* Copyright (C) 2020 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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 2
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
use Symfony\Component\Process\Process;
class Cutting
{
/**
* @var VideoInfo
*/
private $video;
/**
* @var array
*/
private $tempFilenames = [];
public function __construct(VideoInfo $video)
{
$this->video = $video;
}
public function getCommandsForCutting(): \Generator
{
$command = sprintf(
'ffmpeg -y -ss %s -to %s -i %s -c copy %s',
escapeshellarg($this->video->getStart()),
escapeshellarg($this->video->getEnd()),
escapeshellarg($this->video->getOriginalFilename()),
escapeshellarg($this->getNextTempFilename())
);
// TODO: Generate multiple once ads are in
yield Process::fromShellCommandline($command);
}
public function getCommandForGeneratingMetadata(): Process
{
$command = sprintf(
'ffmpeg -i %s -f ffmetadata %s',
escapeshellarg($this->video->getOriginalFilename()),
escapeshellarg($this->getMetadataFilename())
);
return Process::fromShellCommandline($command);
}
public function getCommandForGeneratingConcatInputFile(): Process
{
if ($this->tempFilenames === []) {
throw new \InvalidArgumentException(
'No temp files for concat available. Did you call "getCommandsForCutting()" before?',
1601455216
);
}
$command = sprintf(
'echo "file %s" > %s',
escapeshellarg($this->tempFilenames[0]),
escapeshellarg($this->getConcatFilename())
);
// if $ads ; then
// echo "file '$result2'" >> $resultTxt
// fi
return Process::fromShellCommandline($command);
}
public function getCommandForGeneratingVideo(): Process
{
$command = sprintf(
'ffmpeg -f concat -safe 0 -i %s -c copy'
. ' -f ffmetadata -i %s -c copy -map_metadata 1'
// . ' -metadata Title="$episodeTitle"'
. ' %s',
escapeshellarg($this->getConcatFilename()),
escapeshellarg($this->getMetadataFilename()),
escapeshellarg($this->video->getTargetFilePath())
);
return Process::fromShellCommandline($command);
}
public function getCommandForCleanup(): Process
{
$tempfiles = implode(' ', array_map(function (string $filename) {
return escapeshellarg($filename);
}, $this->tempFilenames));
$command = sprintf(
'rm %s %s %s',
$tempfiles,
escapeshellarg($this->getConcatFilename()),
escapeshellarg($this->getMetadataFilename())
);
return Process::fromShellCommandline($command);
}
private function getNextTempFilename(): string
{
$count = count($this->tempFilenames) + 1;
$nextName = sys_get_temp_dir()
. DIRECTORY_SEPARATOR
. $this->video->getOriginalFilenameWithSuffix('cut-' . $count);
$this->tempFilenames[] = $nextName;
return $nextName;
}
private function getMetadataFilename(): string
{
$file = new \SplFileInfo($this->video->getOriginalFilenameWithSuffix('metadata'));
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . $file->getBasename('.' . $file->getExtension()) . '.txt';
}
private function getConcatFilename(): string
{
$file = new \SplFileInfo($this->video->getOriginalFilenameWithSuffix('concat'));
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . $file->getBasename('.' . $file->getExtension()) . '.txt';
}
}

154
src/VideoInfo.php Normal file
View file

@ -0,0 +1,154 @@
<?php
namespace DanielSiepmann\Videcutting;
/*
* Copyright (C) 2020 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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 2
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
class VideoInfo
{
private $filename = '';
private $start = '';
private $end = '';
public function __construct(
string $filename,
string $start,
string $end
) {
$this->filename = $filename;
$this->start = $start;
$this->end = $end;
}
public function getStart(): string
{
return $this->start;
}
public function getEnd(): string
{
return $this->end;
}
public function getSeries(): string
{
return $this->getFilenameParts()[0];
}
public function hasSeason(): bool
{
foreach ($this->getFilenameParts() as $part) {
if (stripos($part, 'season-') === 0) {
return true;
}
}
return false;
}
public function getSeasonNumber(): int
{
foreach ($this->getFilenameParts() as $part) {
if (stripos($part, 'season-') === 0) {
return (int) str_replace('season-', '', $part);
}
}
throw new \Exception('No season number detected.', 1601458223);
}
public function hasEpisode(): bool
{
foreach ($this->getFilenameParts() as $part) {
if (stripos($part, 'episode-') === 0) {
return true;
}
}
return false;
}
public function getEpisodeNumber(): int
{
foreach ($this->getFilenameParts() as $part) {
if (stripos($part, 'episode-') === 0) {
return (int) str_replace('episode-', '', $part);
}
}
throw new \Exception('No episode number detected.', 1601458223);
}
public function getTitle(): string
{
$parts = $this->getFilenameParts();
// Remove also the quality indicator
return $parts[count($parts) - 2];
}
public function getTargetFilePath(): string
{
$folderParts = [];
$filenameParts = [];
$folderParts[] = $this->getSeries();
if ($this->hasSeason()) {
$folderParts[] = 'Staffel-' . str_pad($this->getSeasonNumber(), 2, '0', STR_PAD_LEFT);
}
if ($this->hasEpisode()) {
$filenameParts[] = str_pad($this->getEpisodeNumber(), 2, '0', STR_PAD_LEFT);
}
$filenameParts[] = ucwords(str_replace('-', ' ', $this->getTitle()));
return implode(DIRECTORY_SEPARATOR, $folderParts)
. DIRECTORY_SEPARATOR
. implode('-', $filenameParts)
. '.' . $this->getExtension();
}
public function getOriginalFilename(): string
{
return $this->filename;
}
public function getOriginalFilenameWithSuffix(string $suffix): string
{
$file = new \SplFileInfo($this->filename);
return sprintf(
'%s-%s.%s',
$file->getBasename('.' . $this->getExtension()),
$suffix,
$this->getExtension()
);
}
private function getExtension(): string
{
$file = new \SplFileInfo($this->filename);
return $file->getExtension();
}
private function getFilenameParts(): array
{
return explode('_', $this->filename);
}
}

220
tests/Unit/CuttingTest.php Normal file
View file

@ -0,0 +1,220 @@
<?php
namespace DanielSiepmann\Videcutting\Tests\Unit;
/*
* Copyright (C) 2020 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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 2
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
use DanielSiepmann\Videcutting\Cutting;
use DanielSiepmann\Videcutting\VideoInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Process\Process;
/**
* @covers DanielSiepmann\Videcutting\Cutting
*/
class CuttingTest extends TestCase
{
use ProphecyTrait;
/**
* @test
*/
public function instanceCanBeCreated(): void
{
$video = $this->prophesize(VideoInfo::class);
$subject = new Cutting(
$video->reveal()
);
static::assertInstanceOf(Cutting::class, $subject);
}
/**
* @test
*/
public function generatesCommandsForCutting(): void
{
$video = $this->prophesize(VideoInfo::class);
$video->getStart()->willReturn('10.05.20');
$video->getEnd()->willReturn('13.10.25');
$video->getOriginalFilename()->willReturn('Some-Video.mp4');
$video->getOriginalFilenameWithSuffix('cut-1')->willReturn('Some-Video-cut-1.mp4');
$subject = new Cutting(
$video->reveal()
);
$commands = [];
foreach ($subject->getCommandsForCutting() as $command) {
$commands[] = $command;
}
static::assertCount(1, $commands);
static::assertSame(
"ffmpeg -y -ss '10.05.20' -to '13.10.25' -i 'Some-Video.mp4' -c copy '/tmp/Some-Video-cut-1.mp4'",
$commands[0]->getCommandLine()
);
}
/**
* @test
*/
public function generatesCommandForGeneratingMetadata(): void
{
$video = $this->prophesize(VideoInfo::class);
$video->getOriginalFilename()->willReturn('Some-Video.mp4');
$video->getOriginalFilenameWithSuffix('metadata')->willReturn('Some-Video-metadata.mp4');
$subject = new Cutting(
$video->reveal()
);
$command = $subject->getCommandForGeneratingMetadata();
static::assertSame(
"ffmpeg -i 'Some-Video.mp4' -f ffmetadata '/tmp/Some-Video-metadata.txt'",
$command->getCommandLine()
);
}
/**
* @test
*/
public function throwsExceptionForGeneratingConcatInputFileIfNoConcatIsAvailable(): void
{
$video = $this->prophesize(VideoInfo::class);
$subject = new Cutting(
$video->reveal()
);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('No temp files for concat available. Did you call "getCommandsForCutting()" before?');
$this->expectExceptionCode(1601455216);
$subject->getCommandForGeneratingConcatInputFile();
}
/**
* @test
*/
public function generatesCommandsForGeneratingConcatInputFile(): void
{
$video = $this->prophesize(VideoInfo::class);
$video->getStart()->willReturn('10.05.20');
$video->getEnd()->willReturn('13.10.25');
$video->getOriginalFilename()->willReturn('Some-Video.mp4');
$video->getOriginalFilenameWithSuffix('cut-1')->willReturn('Some-Video-cut-1.mp4');
$video->getOriginalFilenameWithSuffix('concat')->willReturn('Some-Video-concat.mp4');
$subject = new Cutting(
$video->reveal()
);
foreach ($subject->getCommandsForCutting() as $command) {
// Just generate all commands to have proper internal state
}
$command = $subject->getCommandForGeneratingConcatInputFile();
static::assertSame(
'echo "file \'/tmp/Some-Video-cut-1.mp4\'" > \'/tmp/Some-Video-concat.txt\'',
$command->getCommandLine()
);
}
/**
* @test
*/
public function generatesCommandForGeneratingVideo(): void
{
$video = $this->prophesize(VideoInfo::class);
$video->getTargetFilePath()->willReturn('Series-Name/Series-01/10-episode title.mp4');
$video->getOriginalFilenameWithSuffix('concat')->willReturn('Series-Name_Series-01_episode-title-01-concat.mp4');
$video->getOriginalFilenameWithSuffix('metadata')->willReturn('Series-Name_Series-01_episode-title-01-metadata.mp4');
$subject = new Cutting(
$video->reveal()
);
$command = $subject->getCommandForGeneratingVideo();
static::assertSame(
'ffmpeg -f concat -safe 0 -i \'/tmp/Series-Name_Series-01_episode-title-01-concat.txt\' -c copy'
. ' -f ffmetadata -i \'/tmp/Series-Name_Series-01_episode-title-01-metadata.txt\' -c copy -map_metadata 1'
// . ' -metadata Title="$episodeTitle"'
. ' \'Series-Name/Series-01/10-episode title.mp4\'',
$command->getCommandLine()
);
}
/**
* @test
*/
public function generatesCommandForCleanup(): void
{
$video = $this->prophesize(VideoInfo::class);
$video->getTargetFilePath()->willReturn('Series-Name/Series-01/10-episode title.mp4');
$video->getOriginalFilenameWithSuffix('concat')->willReturn('Series-Name_Series-01_episode-title-01-concat.mp4');
$video->getOriginalFilenameWithSuffix('metadata')->willReturn('Series-Name_Series-01_episode-title-01-metadata.mp4');
$subject = new Cutting(
$video->reveal()
);
$command = $subject->getCommandForCleanup();
static::assertSame(
'rm \'/tmp/Series-Name_Series-01_episode-title-01-concat.txt\' \'/tmp/Series-Name_Series-01_episode-title-01-metadata.txt\'',
$command->getCommandLine()
);
}
/**
* @test
*/
public function generatesCommandForCleanupWithSingleTempFile(): void
{
$video = $this->prophesize(VideoInfo::class);
$video->getStart()->willReturn('10.05.20');
$video->getEnd()->willReturn('13.10.25');
$video->getOriginalFilename()->willReturn('Some-Video.mp4');
$video->getTargetFilePath()->willReturn('Series-Name/Series-01/10-episode title.mp4');
$video->getOriginalFilenameWithSuffix('cut-1')->willReturn('Some-Video-cut-1.mp4');
$video->getOriginalFilenameWithSuffix('concat')->willReturn('Series-Name_Series-01_episode-title-01-concat.mp4');
$video->getOriginalFilenameWithSuffix('metadata')->willReturn('Series-Name_Series-01_episode-title-01-metadata.mp4');
$subject = new Cutting(
$video->reveal()
);
foreach ($subject->getCommandsForCutting() as $command) {
// Just generate all commands to have proper internal state
}
$command = $subject->getCommandForCleanup();
static::assertSame(
'rm \'/tmp/Some-Video-cut-1.mp4\' \'/tmp/Series-Name_Series-01_episode-title-01-concat.txt\' \'/tmp/Series-Name_Series-01_episode-title-01-metadata.txt\'',
$command->getCommandLine()
);
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace DanielSiepmann\Videcutting\Tests\Unit;
/*
* Copyright (C) 2020 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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 2
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
use DanielSiepmann\Videcutting\VideoInfo;
use PHPUnit\Framework\TestCase;
/**
* @covers DanielSiepmann\Videcutting\VideoInfo
*/
class VideoInfoTest extends TestCase
{
/**
* @test
*/
public function instanceCanBeCreated(): void
{
$subject = new VideoInfo(
'Example-Video-File.mp4',
'05:20.1',
'32:30.30'
);
static::assertInstanceOf(VideoInfo::class, $subject);
}
/**
* @test
* @dataProvider possibleFilenames
*/
public function returnsTargetFilePath(
string $filename,
string $expectedTargetFilePath
): void {
$subject = new VideoInfo(
$filename,
'05:20.1',
'32:30.30'
);
static::assertSame(
$expectedTargetFilePath,
$subject->getTargetFilePath()
);
}
/**
* @test
*/
public function returnsOriginalFilename(): void
{
$subject = new VideoInfo(
'Example-Video-File.mp4',
'05:20.1',
'32:30.30'
);
static::assertSame(
'Example-Video-File.mp4',
$subject->getOriginalFilename()
);
}
/**
* @test
*/
public function returnsOriginalFilenameWithProvidedSuffix(): void
{
$subject = new VideoInfo(
'Example-Video-File.mp4',
'05:20.1',
'32:30.30'
);
static::assertSame(
'Example-Video-File-some-suffix.mp4',
$subject->getOriginalFilenameWithSuffix('some-suffix')
);
}
public function possibleFilenames(): array
{
return [
'Movie' => [
'filename' => 'Sherlock-Holmes_Der-Vampir-Von-Whitechapel_hq.mp4',
'expectedTargetFilePath' => 'Sherlock-Holmes/Der Vampir Von Whitechapel.mp4',
],
'Series no seasons' => [
'filename' => 'Storage-Hunters_episode-2_trucker-auktion_hq.mp4',
'expectedTargetFilePath' => 'Storage-Hunters/02-Trucker Auktion.mp4',
],
'Series with seasons' => [
'filename' => 'The-Big-Bang-Theory_season-1_episode-12_das-jerusalem-projekt_hq.mp4',
'expectedTargetFilePath' => 'The-Big-Bang-Theory/Staffel-01/12-Das Jerusalem Projekt.mp4',
],
];
}
}