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 .