Пишем юзабельную оболочку для FFMPEG на Powershell

в 13:10, , рубрики: powershell, ruvds-статьи, windows, Блог компании RUVDS.com, программирование windows и android, разработка под windows

Пишем юзабельную оболочку для FFMPEG на Powershell - 1
Нормальный вывод для ffmpeg

Наверное, вы, как и я, слышали про ffmpeg, но боялись его использовать. Респект таким парням, программа целиком выполнена на C ( си, без # и ++ ).

Несмотря на исключительно высокий функционал программы, ужасный, гигантский вербоуз, неудобные аргументы, странные дефолты, отсутствие автозаполнения и непрощающий синтаксис вкупе с не всегда развернутыми и понятными пользователю ошибками делают эту великолепную программу неудобной.

Я не нашел в интернете готовых командлетов для взаимодействия с ffmpeg, поэтому, давайте доработаем то, что нуждается в доработке и сделаем это все так, чтобы нестыдно было публиковать это на PowershellGallery.

Делаем объект под пайп

class VideoFile {
    $InputFileLiteralPath
    $OutFileLiteralPath
    $Arguments
}

Все начинается с объекта. FFmpeg программа достаточно простая, все что нам нужно знать это где находится то, с чем мы работаем, как мы с этим работаем и куда мы все складываем.

Begin, process, end

В Begin блоке нельзя никак работать с пришедшими аргументами, то есть конкатенировать строку по аргументам сходу не получится, в Begin блоке все параметры нули.

Однако тут можно подгружать экзешники, импортировать необходимые модули и инициализировать счетчики для всех файлов, которые будут в обработке, работать с константами и системными переменными.

Думайте о конструкции Begin-Process как о foreach, где begin выполняется раньше, чем вызывается сама функция и задаются параметры, а End выполняется в последнюю очередь, после foreach.

Вот так бы выглядел код, если бы конструкции Begin, Process, End не было. Это пример плохого кода, так писать не надо.

# это begin
$InputColection = Get-ChildItem -Path C:file.txt
 
function Invoke-FunctionName {
    param (
        $i
    )
    # это process
    $InputColection | ForEach-Object {
        $buffer = $_ | ConvertTo-Json 
    }
    
    # это end
    return $buffer
}
 
Invoke-FunctionName -i $InputColection

Что нужно класть в Begin блок?

Счетчики, составлять пути до исполняемых файлов и делать приветствие. Вот так выглядит Begin блок у меня:

 begin {
        $PathToModule = Split-Path (Get-Module -ListAvailable ConvertTo-MP4).Path
        $FfmpegPath = Join-Path (Split-Path $PathToModule) "ffmpeg"
        $Exec = (Join-Path -Path $FfmpegPath -ChildPath "ffmpeg.exe")
        $OutputArray = @()
 
        $yesToAll = $false
        $noToAll = $false
 
        $Location = Get-Location
    }

Хочу обратить внимание на строчку, это настоящий лайфхак:

$PathToModule = Split-Path (Get-Module -ListAvailable ConvertTo-MP4).Path

С помощью Get-Module мы получаем путь до папки с модулем, а Split-Path берет входное значение и возвращает папку уровнем ниже. Таким образом, можно хранить исполняемые файлы рядом с папкой с модулями, но не в самой этой папке.

Вот так:

PSffmpeg/
├── ConvertTo-MP4/
│   ├── ConvertTo-MP4.psm1
│   ├── ConvertTo-MP4.psd1
│   ├── Readme.md
└── ffmpeg/
    ├── ffmpeg.exe
    ├── ffplay.exe
    └── ffprobe.exe

А еще с помощью Split-Path можно со стилем спускаться на уровень ниже.

Set-Location ( Get-Location | Split-Path )

Как сделать правильный Param блок?

Сразу после Begin идет Process вместе с Param блоком. Param блок сам проводит null чеки, и валидирует аргументы. К примеру:

Валидация по списку:

[ValidateSet("libx264", "libx265")]
$Encoder

Тут все просто. Если значение не похоже на одно из списка, то возвращается False, а затем вызывается исключение.

Валидация по диапазону:

[ValidateRange(0, 51)]
[UInt16]$Quality = 21

Можно валидировать по диапазону, указав цифры от и до. Crf у ffmpeg поддерживает числа от 0 до 51, поэтому тут указан такой диапазон.

Валидация по скрипту:

[ValidateScript( { $_ -match "(?:(?:([01]?d|2[0-3]):)?([0-5]?d):)?([0-5]?d)" })]
[timespan]$TrimStart

Сложный инпут можно валидировать регулярками или целыми скриптами. Главное, чтобы валидирующий скрипт возвращал true или false.

SupportsShouldProcess и force

Итак, вам нужно пачкой перекодировать файлы другим кодеком, но с тем же именем. Классический интерфейс ffmpeg предлагает пользователям нажимать y/N, чтобы перезаписать файл. И так для каждого файла.

Оптимальным вариантом является стандартный Yes to all, Yes, No, No to all.

Выбрал “Yes to all” и можно пачками переписывать файлы и ffmpeg не будет останавливаться и лишний раз переспрашивать, хочешь ты заменить вот этот файл или нет.

function ConvertTo-WEBM {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'high')]
    param (
	 #все остальные параметры были удалены для наглядности
  	[switch]$Force 
    )

Так выглядит голый Param блок здорового человека. С помощью SupportsShouldProcess функция сможет спрашивать, прежде чем выполнять деструктивное действие, а свитч force полностью игнорирует его.

В нашем случае, мы работаем с видеофайлом и перед тем, как перезаписать файл, мы хотим убедиться, что пользователь понимает, что делает функция.

# Если указан параметр Force, то все файлы молча перезаписываются
if ($Force) {
$continue = $true
$yesToAll = $true
}

$Verb = "Overwrite file: " + $Arguments.OutFileLiteralPath # формируем строчку, которую отправим пользователю в слукчае вызова ShouldContinue
    
# Проверяем, не перезапишем ли мы файл.
if (Test-Path $Arguments.OutFileLiteralPath) {
    #Если файл вот вот будет перезаписан, срашиваем, перезаписывать ли все файлы в дальнейшем или нет
    $continue = $PSCmdlet.ShouldContinue($OutFileLiteralPath, $Verb, [ref]$yesToAll, [ref]$noToAll)
        
    #Если было выбрано А - Да, для всех, то продолжаем игнорируя факт того, что файл уже существует
    if ($continue) {
        Start-Process $Exec -ArgumentList $Arguments.Arguments -NoNewWindow -Wait
                
    }
    #Если было выбрано Нет - То завершаем работу скрипта
    else {
        break
    }
}
# Если файл не сущесвует, создаем новый
else {
    Start-Process $Exec -ArgumentList $Arguments.Arguments -NoNewWindow -Wait
    
}

Делаем нормальный пайп

В функциональном стиле нормальный пайп будет выглядеть так:

function New-FfmpegArgs {
            $VideoFile = $InputObject
            | Join-InputFileLiterallPath 
            | Join-Preset -Preset $Preset
            | Join-ConstantRateFactor -ConstantRateFactor $Quality
            | Join-VideoScale -Height $Height -Width $Width
            | Join-Loglevel -VerboseEnabled $PSCmdlet.MyInvocation.BoundParameters["Verbose"]
            | Join-Trim -TrimStart $TrimStart -TrimEnd $TrimEnd -FfmpegPath "C:UsersnneeoDocumentslib.ScriptsPSffmpegConvertTo-WEBMffmpeg" -SourceVideoPath ([IO.Path]::GetFullPath($InputObject))
            | Join-Codec -Encoder $Encoder -FfmpegPath "C:UsersnneeoDocumentslib.ScriptsPSffmpegConvertTo-WEBMffmpeg" -SourceVideoPath ([IO.Path]::GetFullPath($InputObject))
            | Join-OutFileLiterallPath -OutFileLiteralPath $OutFileLiteralPath -SourceVideoPath ([IO.Path]::GetFullPath($InputObject))
 
            return $VideoFile
        }

Но это просто ужасно, все похоже на лапшу, неужели нельзя сделать все чище?
Конечно можно, но нужно использовать для этого вложенные функции. Они могут смотреть в переменные объявление в родительской функции, что очень удобно. Вот пример:

function Invoke-FunctionName  {
    $ParentVar = "Hello"
    function Invoke-NetstedFunctionName {
        Write-Host $ParentVar
    }
    Invoke-NetstedFunctionName
}

Но в тоже самое время, если у вас будет много одинаковых функций, придется копипастить один и тот же код в каждую функцию каждый раз. В случае с ConvertTo-Mp4, ConvertTo-Webp и т.п. легче сделать как сделал я.

Если бы я использовал вложенные функции это все выглядело так:

$VideoFile = $InputObject
| Join-InputFileLiterallPath 
| Join-Preset 
| Join-ConstantRateFactor 
| Join-VideoScale 
| Join-Loglevel 
| Join-Trim 
| Join-Codec 
| Join-OutFileLiterallPath 

Но повторюсь, это сильно сокращает взаимозаменяемость кода.

Делаем нормальные функции

Нам нужно составить аргументы для ffmpeg.exe, и для этого нет ничего лучше пайплайна. Как же я люблю пайплайны!

Вместо интерполяции или стрингбилдера мы используем пайп, который может корректировать аргументы или писать релевантную ошибку. Сам пайп вы видели выше.

Теперь о том, как выглядят самые прикольные функции пайплайна:

1. Measure-VideoResolution

function Measure-VideoResolution {
    param (
        $SourceVideoPath,
        $FfmpegPath
    )
    Set-Location $FfmpegPath 
 
    .ffprobe.exe -v error -select_streams v:0 -show_entries stream=height -of csv=s=x:p=0 $SourceVideoPath | ForEach-Object {
        return $_
    }
}

h265 экономит битрейт начиная от 1080 и выше, при меньшем разрешении видео он не так важен, поэтому, для кодирования больших видео следует указывать h265 в качестве дефолта.
Return в Foreach-Object выглядит очень странно. Но тут ничего не поделаешь. FFmpeg пишет все в stdout и это самый простой способ выцепить значение из подобных программ. Используйте этот трюк, если вам нужно вытащить что-то из stdout. Не используйте Start-Process, чтобы вытащить stdout нужно вызвать исполняемый файл прямо так, как в этом примере.

Вызвать экзешник по полному пути и при этом получить stdout невозможно иным способом. Нужно конкретно перейти в папку с исполняемым файлом и вызвать его по имени оттуда. Именно для этого, в Begin блоке скрипт запоминает путь, с которого начал, чтобы после завершения своей работы не раздражать пользователя.

  begin {
        $Location = Get-Location
    }

Эта функция хорошо смотрелась бы как отдельный командлет, пригодилась бы, но это на будущее.

2. Join-VideoScale

function Join-VideoScale {
    param(
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [psobject]$InputObject,
        $Height,
        $Width
    )
 
    switch ($true) {
        ($null -eq $Height -and $null -eq $Width) {
            return $InputObject
        }
        ($null -ne $Height -and $null -ne $Width) {
            $InputObject.Arguments += " -vf scale=" + $Width + ":" + $Height
            return $InputObject
        }
        ($null -ne $Height) { 
            $InputObject.Arguments += " -vf scale=" + $Height + ":-2" 
            return $InputObject 
        }
        ($null -ne $Width) { 
            $InputObject.Arguments += " -vf scale=" + "-2:" + $Width 
            return $InputObject 
        }
    }
}

Один из моих любимых приколов — вывернутый наизнанку switch. Паттерн матчинга в Powershell нет, но такие конструкции заменяют его, по большей части.
В круглых скобках находится выполняемая функция. И если результат выполнения этой функции равен условию в свитче, то скриптблок в нем выполняется.

3. Join-Trim

function Join-Trim {
    param(
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [psobject]$InputObject,
        $TrimStart,
        $TrimEnd,
        $FfmpegPath,
        $SourceVideoPath
    )
    if ($null -ne $TrimStart) {
        $TrimStart = [timespan]::Parse($TrimStart)
    }
    if ($null -ne $TrimEnd) {
        $TrimEnd = [timespan]::Parse($TrimEnd)
    }
    
    if ($TrimStart -gt $TrimEnd -and $null -ne $TrimEnd) {
        Write-Error "TrimStart can not be equal to TrimEnd" -Category InvalidArgument
        break
    }
    if ($TrimStart -ge $TrimEnd -and $null -ne $TrimEnd) {
        Write-Error "TrimStart can not be greater than TrimEnd" -Category InvalidArgument
        break
    }
    $ActualVideoLenght = Measure-VideoLenght -SourceVideoPath $SourceVideoPath -FfmpegPath $FfmpegPath
   
    if ($TrimStart -gt $ActualVideoLenght) {
        Write-Error "TrimStart can not be greater than video lenght" -Category InvalidArgument
        break
    }
 
    if ($TrimEnd -gt $ActualVideoLenght) {
        Write-Error "TrimEnd can not be greater than video lenght" -Category InvalidArgument
        break
    }
 
    switch ($true) {
        ($null -eq $TrimStart -and $null -eq $TrimEnd) {
            return $InputObject
        }
        ($null -ne $TrimStart -and $null -ne $TrimEnd) {
            
            $ss = " -ss " + ("{0:hh:mm:ss}" -f $TrimStart)
            $to = " -to " + ("{0:hh:mm:ss}" -f $TrimEnd)
            $InputObject.Arguments += $ss + $to
            return $InputObject 
        }
        ($null -ne $TrimStart) { 
            $ss = " -ss " + ("{0:hh:mm:ss}" -f $TrimStart)
            $InputObject.Arguments += $ss
            return $InputObject
        }
        ($null -ne $TrimEnd) { 
            $to = " -to " + ("{0:hh:mm:ss}" -f $TrimEnd)
            $InputObject.Arguments += $to
            return $InputObject
        }
    }
}

Самая большая функция в пайплайне. Правильно написаная функция должна показывать пользователю на ошибки, приходится вот так раздувать код.
Для простоты было принято решение не инкапсулировать в класс пути до исполняемых файлов, поэтому функции принимают так много аргументов.

Выводим новые объекты

Чтобы этот скрипт можно было встраивать в другие пайплайны, нужно сделать так, чтобы он что-нибудь возвращал. У нас есть InputObject взятый из Get-ChildItem, но поле Name доступно только для чтения, просто поменять имена файлов нельзя.

Чтобы вывод команды был похож на системный, нужно сохранить имена перекодированных объектов и с помощью Get-Chilitem добавить их в массив и вывести его.

1. В Begin блоке объявляем массив

begin {
        $OutputArray = @()
}

2. В Process блоке заносим перекодированные файлы:

Не забываем про null чеки, даже в функциональном программировании они нужны.

process {    
 
  if (Test-Path $Arguments.OutFileLiteralPath) {
      $OutputArray += Get-Item -Path $Arguments.OutFileLiteralPath
  }
}

3. В End блоке возвращаем полученный массив

end {
        return $OutputArray
    }

Ура, закончили end блок, пора использовать скрипт как надо.

Используем скрипт

Пример №1

Эта команда выберет все файлы в папке, перекодирует их в формат mp4 и тут же отправит эти файлы на сетевой диск.

Get-ChildItem | ConvertTo-MP4 -Width 320 -Preset Veryslow | Copy-Item –Destination '\local.smb.servervideofiles'

Пример №2

Перекодируем все свои игровые видео в указанной папке, а исходники удаляем.

ConvertTo-MP4 -Path  "C:UsersAdministratorVideosEscape From Tarkov" | Remove-Item -Exclude $_

Пример №3

Кодирование всех файлов из папки и перемещение новых файлов в другую папку.

Get-ChildItem | ConvertTo-WEBM | Move-Item -Destination D:OtherFolder

Заключение

Вот мы и пофиксили ffmpeg, вроде бы ничего критичного не упустили. Но что это получается, ffmpeg нельзя было использовать без нормальной оболочки?
Получается, да.
Но впереди еще очень много работы. Полезно было бы иметь в качестве модулей такие командлеты как Measure-videoLenght, возвращающий длительность видео в виде Timespan, с их помощью можно будет упростить пайп и сделать код компактнее.
Еще, нужно сделать команды ConvertTo-Webp и все в этом духе. Нужно бы еще создавать папку за пользователя, если она не существует, рекурсивно. И проверку доступа на запись и чтение тоже неплохо было бы сделать.

Ну а пока что так, следите за проектом на гитхабе.

Автор: ruvds

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js