Escrevendo um shell utilizável para FFMPEG no Powershell



Saída normal do ffmpeg



Você, como eu, já ouviu falar do ffmpeg, mas estava com medo de usá-lo. Respeite caras assim, todo o programa é escrito em C (si, no # e ++).



Apesar da funcionalidade extremamente alta do programa, terríveis, prolixo, argumentos inconvenientes, padrões estranhos, falta de autocomplete e sintaxe implacável, juntamente com erros que nem sempre são detalhados e compreensíveis para o usuário, tornam este excelente programa inconveniente.



Não encontrei cmdlets prontos na Internet para interagir com o ffmpeg, então vamos finalizar o que precisa ser melhorado e fazer tudo para que não seja uma pena publicá-lo no PowershellGallery.



Fazendo um objeto para um cachimbo



class VideoFile {
    $InputFileLiteralPath
    $OutFileLiteralPath
    $Arguments
}

      
      





Tudo começa com um objeto. O programa FFmpeg é bastante simples, tudo o que precisamos saber é onde trabalhamos, como trabalhamos e onde colocamos tudo.



Comece, processe, termine



No bloco Begin, você não pode trabalhar com os argumentos recebidos de nenhuma maneira, isto é, você não pode concatenar imediatamente uma string por argumentos, no bloco Begin todos os parâmetros são zeros.



Porém, aqui você pode carregar executáveis, importar os módulos necessários e inicializar contadores para todos os arquivos que serão processados, trabalhar com constantes e variáveis ​​de sistema.



Pense na construção Begin-Process como um foreach, em que begin é executado antes que a função seja chamada e os parâmetros são definidos, e End é executado por último após foreach.



É assim que o código ficaria se não houvesse construções Begin, Process, End. Este é um exemplo de código ruim, você não deveria escrever isso.



#  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
      
      





O que deve ser colocado no bloco Begin?



Contadores, componham caminhos para arquivos executáveis ​​e façam uma saudação. É assim que o bloco Begin se parece para mim:



 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
    }
      
      





Quero chamar sua atenção para a linha, este é um hack da vida real:



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





Usando Get-Module, obtemos o caminho para a pasta com o módulo, e Split-Path pega o valor de entrada e retorna a pasta um nível abaixo. Assim, você pode armazenar arquivos executáveis ​​ao lado da pasta de módulos, mas não nesta pasta.



Como isso:



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

      
      





E com a ajuda do Split-Path, você pode estilizar até o nível abaixo.



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





Como fazer um bloco Param correto?



Imediatamente após Begin, há Process juntamente com o bloco Param. O próprio bloco Param mantém verificações nulas e valida os argumentos. Por exemplo:



Validação de lista:



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





Tudo é simples aqui. Se o valor não se parece com um na lista, então False é retornado e uma exceção é lançada.



Validação de intervalo:



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





Você pode validar em um intervalo, especificando números de e até. O crf do Ffmpeg suporta números de 0 a 51, portanto, este intervalo é especificado aqui.



Validação por script:



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





A entrada complexa pode ser validada com scripts regulares ou inteiros. O principal é que o script de validação retorna verdadeiro ou falso.



Suporta ShouldProcess e force



Portanto, você precisa recodificar os arquivos com um codec diferente, mas com o mesmo nome. A interface clássica do ffmpeg solicita que os usuários pressionem y / N para sobrescrever o arquivo. E assim para cada arquivo.



A melhor opção é o padrão Sim para todos, Sim, Não, Não para todos.



Eu escolhi “Sim para todos” e você pode reescrever os arquivos em lotes e o ffmpeg não irá parar e perguntar novamente se você deseja substituir este arquivo ou não.



function ConvertTo-WEBM {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'high')]
    param (
	 #      
  	[switch]$Force 
    )
      
      





É assim que se parece o bloco Param nu de uma pessoa saudável. Com SupportsShouldProcess, a função será capaz de perguntar antes de realizar uma ação destrutiva, e a chave de força a ignora completamente.



No nosso caso, estamos trabalhando com um arquivo de vídeo e antes de sobrescrever o arquivo, queremos ter certeza de que o usuário entende o que a função está fazendo.



# Se o parâmetro Force for especificado, todos os arquivos serão substituídos silenciosamente

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
    
}
      
      







Fazendo um tubo normal



Em um estilo funcional, um tubo normal teria a seguinte aparência:



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:\Users\nneeo\Documents\lib.Scripts\PSffmpeg\ConvertTo-WEBM\ffmpeg\" -SourceVideoPath ([IO.Path]::GetFullPath($InputObject))
            | Join-Codec -Encoder $Encoder -FfmpegPath "C:\Users\nneeo\Documents\lib.Scripts\PSffmpeg\ConvertTo-WEBM\ffmpeg\" -SourceVideoPath ([IO.Path]::GetFullPath($InputObject))
            | Join-OutFileLiterallPath -OutFileLiteralPath $OutFileLiteralPath -SourceVideoPath ([IO.Path]::GetFullPath($InputObject))
 
            return $VideoFile
        }

      
      





Mas isso é horrível, tudo parece macarrão, você não pode deixar tudo mais limpo?

Claro que você pode, mas você precisa usar funções aninhadas para isso. Eles podem examinar a declaração de variáveis ​​na função pai, o que é muito conveniente. Aqui está um exemplo:



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

      
      





Mas, ao mesmo tempo, se você tiver muitas das mesmas funções, terá que copiar e colar o mesmo código em cada função todas as vezes. No caso de ConvertTo-Mp4, ConvertTo-Webp, etc. mais fácil de fazer como eu.



Se eu usasse funções aninhadas, ficaria assim:



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





Mas, novamente, isso reduz muito a intercambialidade do código.



Fazendo funções normais



Precisamos compor argumentos para ffmpeg.exe e, para isso, nada melhor do que um pipeline. Como eu amo pipelines!



Em vez de interpolação ou um construtor de string, usamos um tubo que pode corrigir argumentos ou escrever um erro relevante. Você viu o próprio tubo acima.



Agora, sobre como são as funções mais legais do pipeline :



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 salva a taxa de bits a partir de 1080 e superior, em resolução de vídeo mais baixa não é tão importante, portanto, para codificar vídeos grandes, você deve especificar h265 como o padrão.

Return in Foreach-Object parece muito estranho. Mas não há nada que você possa fazer a respeito. FFmpeg grava tudo em stdout e esta é a maneira mais fácil de extrair um valor de tais programas. Use este truque se precisar extrair algo do stdout. Não use Start-Process, para puxar stdout você precisa chamar o arquivo executável diretamente como neste exemplo.



É impossível chamar o executável ao longo do caminho completo e obter stdout de qualquer outra forma. Você precisa ir especificamente para a pasta com o arquivo executável e chamá-lo pelo nome a partir daí. Para isso, no bloco Begin, o script lembra o caminho de onde partiu, para que após terminar seu trabalho não incomode o usuário.



  begin {
        $Location = Get-Location
    }
      
      





Essa função ficaria bem como um cmdlet separado, seria útil, mas para o futuro.



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 
        }
    }
}

      
      



Uma das minhas piadas favoritas é a mudança do avesso. Não existe um padrão correspondente no Powershell, mas tais construções o substituem, na maior parte.

A função a ser executada está entre parênteses. E se o resultado desta função for igual à condição no switch, então o bloco de script é executado nele.



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
        }
    }
}
      
      





O maior recurso no pipeline. Uma função escrita corretamente deve mostrar ao usuário sobre os erros, você tem que inchar o código assim.

Para simplificar, foi decidido não encapsular os caminhos para os arquivos executáveis ​​na classe, razão pela qual as funções recebem tantos argumentos.



Exibindo novos objetos



Para que este script possa ser incorporado em outros pipelines, você precisa fazer com que ele retorne algo. Temos um InputObject obtido de Get-ChildItem, mas o campo Name é somente leitura, você não pode apenas alterar os nomes dos arquivos.



Para fazer a saída do comando parecer a saída do sistema, você precisa salvar os nomes dos objetos recodificados e usar Get-Chilitem para adicioná-los ao array e exibi-lo.



1. No bloco Begin, declare um array



begin {
        $OutputArray = @()
}
      
      





2. No bloco Processo, insira os arquivos recodificados:



Não se esqueça das verificações de nulos, mesmo na programação funcional, elas são necessárias.



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





3. No bloco End, retorne a matriz resultante



end {
        return $OutputArray
    }
      
      





Hooray, terminou o bloco final, é hora de usar o script corretamente.



Usamos o script



Exemplo # 1



Este comando irá selecionar todos os arquivos em uma pasta, convertê-los para o formato mp4 e imediatamente enviar esses arquivos para uma unidade de rede.



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





Exemplo # 2



Vamos recodificar todos os nossos vídeos de jogos na pasta especificada e excluir as fontes.



ConvertTo-MP4 -Path  "C:\Users\Administrator\Videos\Escape From Tarkov\" | Remove-Item -Exclude $_
      
      





Exemplo # 3



Codificando todos os arquivos de uma pasta e movendo novos arquivos para outra pasta.



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





Conclusão



Então, corrigimos o ffmpeg, parece que não perdemos nada crítico. Mas o que é, ffmpeg não poderia ser usado sem um shell normal?

Acontece que sim.

Mas ainda há muito trabalho pela frente. Seria útil ter cmdlets como Measure-videoLenght como módulos, que retornam a duração de um vídeo na forma de um Timespan, com a ajuda deles seria possível simplificar o pipe e tornar o código mais compacto.

Ainda assim, você precisa fazer comandos ConvertTo-Webp e tudo com esse espírito. Também seria necessário criar uma pasta para o usuário, caso ela não exista, de forma recursiva. E verificar o acesso de leitura e gravação também seria bom.



Enquanto isso, siga o projeto no github .






All Articles