Escribir un shell utilizable para FFMPEG en Powershell



Salida normal de ffmpeg



Usted, como yo, ha oído hablar de ffmpeg, pero tenía miedo de usarlo. Respeta a tipos así, todo el programa está escrito en C (si, no # y ++).



A pesar de la funcionalidad extremadamente alta del programa, los argumentos terribles, gigantescos, inconvenientes, extraños valores predeterminados, la falta de autocompletar y la sintaxis implacable, junto con errores que no siempre son detallados y comprensibles para el usuario, hacen que este excelente programa sea inconveniente.



No encontré cmdlets listos para usar en Internet para interactuar con ffmpeg, así que finalicemos lo que se necesita mejorar y hagámoslo todo para que no sea una pena publicarlo en PowershellGallery.



Hacer un objeto para una pipa



class VideoFile {
    $InputFileLiteralPath
    $OutFileLiteralPath
    $Arguments
}

      
      





Todo comienza con un objeto. El programa FFmpeg es bastante simple, todo lo que necesitamos saber es dónde trabajamos, cómo trabajamos con él y dónde ponemos todo.



Comenzar, procesar, terminar



En el bloque Begin, no puede trabajar con los argumentos recibidos de ninguna manera, es decir, no puede concatenar inmediatamente una cadena por argumentos, en el bloque Begin todos los parámetros son ceros.



Sin embargo, aquí puede cargar ejecutables, importar los módulos necesarios e inicializar contadores para todos los archivos que serán procesados, trabajar con constantes y variables del sistema.



Piense en la construcción Begin-Process como un foreach, donde begin se ejecuta antes de que se llame a la función y se establezcan los parámetros, y End se ejecuta en último lugar después de foreach.



Así es como se vería el código si no hubiera construcciones Begin, Process, End. Este es un ejemplo de código incorrecto, no debería escribirlo.



#  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
      
      





¿Qué se debe poner en el bloque Begin?



Contadores, componga rutas a archivos ejecutables y haga un saludo. Así es como se ve el bloque Begin para mí:



 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
    }
      
      





Quiero llamar su atención sobre la línea, este es un truco de la vida real:



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





Usando Get-Module, obtenemos la ruta a la carpeta con el módulo, y Split-Path toma el valor de entrada y devuelve la carpeta un nivel por debajo. Por lo tanto, puede almacenar archivos ejecutables junto a la carpeta de módulos, pero no en esta carpeta.



Me gusta esto:



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

      
      





Y con la ayuda de Split-Path, puede diseñar hasta el nivel inferior.



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





¿Cómo hacer un bloque de Param correcto?



Inmediatamente después de Begin, hay Process junto con el bloque Param. El bloque Param en sí mismo contiene comprobaciones nulas y valida los argumentos. Por ejemplo:



Validación de lista:



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





Aquí todo es sencillo. Si el valor no se parece a uno de la lista, se devuelve False y se lanza una excepción.



Validación de rango:



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





Puede validar en un rango especificando números desde y hasta. El crf de Ffmpeg admite números del 0 al 51, por lo que este rango se especifica aquí.



Validación por script:



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





La entrada compleja se puede validar con scripts regulares o completos. Lo principal es que el script de validación devuelve verdadero o falso.



Soportes Debe Procesar y forzar



Por lo tanto, debe volver a codificar los archivos con un códec diferente, pero con el mismo nombre. La interfaz clásica de ffmpeg solicita a los usuarios que presionen y / N para sobrescribir el archivo. Y así para cada archivo.



La mejor opción es el estándar Sí a todos, Sí, No, No a todos.



Elegí "Sí a todo" y puede reescribir archivos en lotes y ffmpeg no se detendrá y preguntará nuevamente si desea reemplazar este archivo o no.



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





Así es como se ve el bloque Param desnudo de una persona sana. Con SupportsShouldProcess, la función podrá preguntar antes de realizar una acción destructiva y el interruptor de fuerza lo ignorará por completo.



En nuestro caso, estamos trabajando con un archivo de video y antes de sobrescribir el archivo, queremos asegurarnos de que el usuario comprende lo que está haciendo la función.



# Si se especifica el parámetro Force, todos los archivos se sobrescriben silenciosamente

si ($ 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
    
}
      
      







Hacer una pipa normal



En estilo funcional, una tubería normal se vería así:



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
        }

      
      





Pero esto es horrible, todo parece fideos, ¿no puedes hacer que todo esté más limpio?

Por supuesto que puede, pero necesita usar funciones anidadas para esto. Pueden mirar la declaración de variables en la función padre, lo cual es muy conveniente. He aquí un ejemplo:



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

      
      





Pero al mismo tiempo, si tiene muchas de las mismas funciones, tendrá que copiar y pegar el mismo código en cada función cada vez. En el caso de ConvertTo-Mp4, ConvertTo-Webp, etc. más fácil de hacer como yo.



Si usara funciones anidadas, se vería así:



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





Pero nuevamente, esto reduce en gran medida la intercambiabilidad de código.



Realización de funciones normales



Necesitamos componer argumentos para ffmpeg.exe, y para esto no hay nada mejor que un pipeline. ¡Cómo amo las tuberías!



En lugar de interpolación o un generador de cadenas, usamos una tubería que puede corregir argumentos o escribir un error relevante. Viste la tubería en sí arriba.



Ahora, sobre cómo se ven las funciones más interesantes de la tubería :



1. Medir-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 guarda una tasa de bits a partir de 1080 y superior, a una resolución de video más baja no es tan importante, por lo tanto, para codificar videos grandes, debe especificar h265 como predeterminado.

El retorno en Foreach-Object se ve muy extraño. Pero no hay nada que puedas hacer al respecto. FFmpeg escribe todo en stdout y esta es la forma más fácil de extraer un valor de dichos programas. Use este truco si necesita sacar algo de la salida estándar. No use Start-Process, para sacar stdout necesita llamar al archivo ejecutable directamente como en este ejemplo.



Es imposible llamar al ejecutable a lo largo de la ruta completa y obtener stdout de otra manera. Debe ir específicamente a la carpeta con el archivo ejecutable y llamarlo por su nombre desde allí. Para ello, en el bloque Comenzar, el script recuerda la ruta desde la que partió, para que luego de terminar su trabajo no moleste al usuario.



  begin {
        $Location = Get-Location
    }
      
      





Esta función se vería bien como un cmdlet independiente, sería útil, pero para el 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 
        }
    }
}

      
      



Uno de mis chistes favoritos es el interruptor de adentro hacia afuera. No hay un patrón coincidente en Powershell, pero tales construcciones lo reemplazan, en su mayor parte.

La función a ejecutar está entre paréntesis. Y si el resultado de esta función es igual a la condición en el conmutador, entonces el bloque de script se ejecuta en él.



3. Unir-Recortar



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





La característica más importante en la tubería. Una función escrita correctamente debería mostrarle al usuario acerca de los errores, tienes que inflar el código de esta manera.

Por simplicidad, se decidió no encapsular las rutas a los archivos ejecutables en la clase, razón por la cual las funciones toman tantos argumentos.



Visualización de nuevos objetos



Para que este script pueda incrustarse en otras canalizaciones, debe configurarlo para que devuelva algo. Tenemos un InputObject tomado de Get-ChildItem, pero el campo Nombre es de solo lectura, no puede simplemente cambiar los nombres de los archivos.



Para que la salida del comando se vea como la salida del sistema, necesita guardar los nombres de los objetos recodificados y usar Get-Chilitem para agregarlos a la matriz y mostrarlos.



1. En el bloque Begin, declare una matriz



begin {
        $OutputArray = @()
}
      
      





2. En el bloque Proceso, ingrese los archivos recodificados:



No se olvide de las verificaciones nulas, incluso en programación funcional son necesarias.



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





3. En el bloque Final, devuelve la matriz resultante



end {
        return $OutputArray
    }
      
      





Hurra, terminó el bloque final, es hora de usar el script correctamente.



Usamos el guion



Ejemplo # 1



Este comando seleccionará todos los archivos en una carpeta, los convertirá al formato mp4 y enviará inmediatamente estos archivos a una unidad de red.



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





Ejemplo # 2



Recodifiquemos todos nuestros videos de juegos en la carpeta especificada y eliminemos las fuentes.



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





Ejemplo # 3



Codificar todos los archivos de una carpeta y mover archivos nuevos a otra carpeta.



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





Conclusión



Así que arreglamos ffmpeg, parece que no nos perdimos nada crítico. Pero, ¿qué es, ffmpeg no podría usarse sin un shell normal?

Resulta que sí.

Pero aún queda mucho trabajo por delante. Sería útil tener cmdlets como Measure-videoLenght como módulos, que devuelvan la duración de un video en forma de Timespan, con su ayuda sería posible simplificar la tubería y hacer el código más compacto.

Aún así, necesita hacer comandos ConvertTo-Webp y todo con este espíritu. También sería necesario crear una carpeta para el usuario, si no existe, de forma recursiva. Y comprobar el acceso de lectura y escritura también sería bueno.



Mientras tanto, sigue el proyecto en github .






All Articles