Schreiben einer verwendbaren Shell für FFMPEG auf Powershell



Normale ffmpeg-Ausgabe



Sie haben wie ich von ffmpeg gehört, hatten aber Angst, es zu verwenden. Respektiert solche Leute, das ganze Programm ist in C geschrieben (si, no # und ++).



Trotz der extrem hohen Funktionalität des Programms machen schreckliche, riesige, ausführliche, unbequeme Argumente, seltsame Standardeinstellungen, mangelnde automatische Vervollständigung und unversöhnliche Syntax sowie Fehler, die für den Benutzer nicht immer detailliert und verständlich sind, dieses hervorragende Programm unpraktisch.



Ich habe im Internet keine vorgefertigten Cmdlets für die Interaktion mit ffmpeg gefunden. Lassen Sie uns also abschließen, was verbessert werden muss, und alles tun, damit es keine Schande ist, es auf PowershellGallery zu veröffentlichen.



Ein Objekt für ein Rohr erstellen



class VideoFile {
    $InputFileLiteralPath
    $OutFileLiteralPath
    $Arguments
}

      
      





Alles beginnt mit einem Objekt. Das FFmpeg-Programm ist recht einfach. Wir müssen nur wissen, wo wir arbeiten, wie wir damit arbeiten und wo wir alles ablegen.



Beginnen, verarbeiten, beenden



Im Begin-Block können Sie in keiner Weise mit den empfangenen Argumenten arbeiten, dh Sie können eine Zeichenfolge nicht sofort mit Argumenten verketten. Im Begin-Block sind alle Parameter Nullen.



Hier können Sie jedoch ausführbare Dateien laden, die erforderlichen Module importieren und Zähler für alle zu verarbeitenden Dateien initialisieren, mit Konstanten und Systemvariablen arbeiten.



Stellen Sie sich das Begin-Process-Konstrukt als foreach vor, bei dem begin ausgeführt wird, bevor die Funktion aufgerufen und Parameter festgelegt werden, und End zuletzt nach foreach ausgeführt wird.



So würde der Code aussehen, wenn es keine Begin-, Process- und End-Konstruktionen gäbe. Dies ist ein Beispiel für schlechten Code. Das sollten Sie nicht schreiben.



#  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
      
      





Was sollte in den Begin-Block eingefügt werden?



Zähler, verfassen Pfade zu ausführbaren Dateien und machen eine Begrüßung. So sieht der Begin-Block für mich aus:



 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
    }
      
      





Ich möchte Ihre Aufmerksamkeit auf die Linie lenken, dies ist ein Hack im wirklichen Leben:



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





Mit Get-Module erhalten wir den Pfad zum Ordner mit dem Modul, und Split-Path nimmt den Eingabewert und gibt den Ordner eine Ebene darunter zurück. Auf diese Weise können Sie ausführbare Dateien neben dem Modulordner speichern, jedoch nicht in diesem Ordner.



So:



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

      
      





Und mit Hilfe von Split-Path können Sie bis auf die darunter liegende Ebene stylen.



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





Wie erstelle ich einen korrekten Param-Block?



Unmittelbar nach Beginn gibt es Process zusammen mit dem Param-Block. Der Param-Block selbst enthält Nullprüfungen und überprüft die Argumente. Zum Beispiel:



Listenvalidierung:



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





Hier ist alles einfach. Wenn der Wert nicht wie einer in der Liste aussieht, wird False zurückgegeben und eine Ausnahme ausgelöst.



Bereichsvalidierung:



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





Sie können einen Bereich validieren, indem Sie Zahlen von und bis angeben. Ffmpegs crf unterstützt Zahlen von 0 bis 51, daher wird dieser Bereich hier angegeben.



Validierung per Skript:



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





Komplexe Eingaben können mit regulären oder ganzen Skripten validiert werden. Die Hauptsache ist, dass das Validierungsskript true oder false zurückgibt.



Unterstützt ShouldProcess und Force



Sie müssen die Dateien also mit einem anderen Codec, jedoch mit demselben Namen, neu codieren. Die klassische ffmpeg-Oberfläche fordert Benutzer auf, j / N zu drücken, um die Datei zu überschreiben. Und so für jede Datei.



Die beste Option ist der Standard Ja zu allen, Ja, Nein, Nein zu allen.



Ich habe "Ja zu allen" gewählt und Sie können Dateien stapelweise umschreiben, und ffmpeg wird nicht anhalten und erneut fragen, ob Sie diese Datei ersetzen möchten oder nicht.



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





So sieht der nackte Param-Block eines gesunden Menschen aus. Mit SupportsShouldProcess kann die Funktion fragen, bevor eine zerstörerische Aktion ausgeführt wird, und der Kraftschalter ignoriert sie vollständig.



In unserem Fall arbeiten wir mit einer Videodatei und möchten vor dem Überschreiben der Datei sicherstellen, dass der Benutzer versteht, was die Funktion tut.



# Wenn der Force-Parameter angegeben wird, werden alle Dateien stillschweigend überschrieben,

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







Ein normales Rohr machen



Im funktionalen Stil würde ein normales Rohr folgendermaßen aussehen:



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
        }

      
      





Aber das ist einfach schrecklich, alles sieht aus wie Nudeln, kannst du nicht wirklich alles sauberer machen?

Natürlich können Sie das, aber Sie müssen dafür verschachtelte Funktionen verwenden. Sie können sich die Variablendeklaration in der übergeordneten Funktion ansehen, was sehr praktisch ist. Hier ist ein Beispiel:



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

      
      





Wenn Sie jedoch viele der gleichen Funktionen haben, müssen Sie jedes Mal den gleichen Code kopieren und in jede Funktion einfügen. Im Fall von ConvertTo-Mp4, ConvertTo-Webp usw. einfacher zu machen als ich.



Wenn ich verschachtelte Funktionen verwenden würde, würde dies folgendermaßen aussehen:



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





Dies reduziert jedoch die Austauschbarkeit von Code erheblich.



Normale Funktionen ausführen



Wir müssen Argumente für ffmpeg.exe verfassen, und dafür gibt es nichts Besseres als eine Pipeline. Wie ich Pipelines liebe!



Anstelle einer Interpolation oder eines String-Builders verwenden wir eine Pipe, die Argumente korrigieren oder einen relevanten Fehler schreiben kann. Du hast das Rohr selbst oben gesehen.



Nun zu den coolsten Funktionen der 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 speichert die Bitrate ab 1080 und höher. Bei niedrigerer Videoauflösung ist dies nicht so wichtig. Daher sollten Sie für die Codierung großer Videos h265 als Standard angeben.

Die Rückkehr in Foreach-Object sieht sehr seltsam aus. Aber Sie können nichts dagegen tun. FFmpeg schreibt alles in stdout und dies ist der einfachste Weg, einen Wert aus solchen Programmen zu extrahieren. Verwenden Sie diesen Trick, wenn Sie etwas aus stdout ziehen müssen. Verwenden Sie Start-Process nicht. Um stdout abzurufen, müssen Sie die ausführbare Datei wie in diesem Beispiel direkt aufrufen.



Es ist unmöglich, die ausführbare Datei auf dem gesamten Pfad aufzurufen und auf andere Weise stdout zu erhalten. Sie müssen speziell in den Ordner mit der ausführbaren Datei gehen und ihn von dort beim Namen nennen. Zu diesem Zweck merkt sich das Skript im Begin-Block den Pfad, von dem es gestartet wurde, so dass es den Benutzer nach Beendigung seiner Arbeit nicht stört.



  begin {
        $Location = Get-Location
    }
      
      





Diese Funktion würde als separates Cmdlet gut aussehen, es wäre nützlich, aber für die Zukunft.



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

      
      



Einer meiner Lieblingsknebel ist der Schalter von innen nach außen. In Powershell gibt es kein passendes Muster, aber solche Konstrukte ersetzen es größtenteils.

Die auszuführende Funktion steht in Klammern. Und wenn das Ergebnis dieser Funktion der Bedingung im Switch entspricht, wird der Skriptblock darin ausgeführt.



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





Das größte Feature in der Pipeline. Eine korrekt geschriebene Funktion sollte den Benutzer über Fehler informieren, Sie müssen den Code so aufblähen.

Der Einfachheit halber wurde beschlossen, die Pfade zu den ausführbaren Dateien in der Klasse nicht zu kapseln, weshalb die Funktionen so viele Argumente annehmen.



Neue Objekte anzeigen



Damit dieses Skript in andere Pipelines eingebettet werden kann, müssen Sie es so gestalten, dass es etwas zurückgibt. Wir haben ein InputObject aus Get-ChildItem, aber das Feld Name ist schreibgeschützt. Sie können nicht einfach die Dateinamen ändern.



Damit die Ausgabe des Befehls wie die Systemausgabe aussieht, müssen Sie die Namen der neu codierten Objekte speichern und sie mit Get-Chilitem zum Array hinzufügen und anzeigen.



1. Deklarieren Sie im Begin-Block ein Array



begin {
        $OutputArray = @()
}
      
      





2. Geben



Sie im Prozessblock die neu codierten Dateien ein: Vergessen Sie die Nullprüfungen nicht, auch wenn sie für die funktionale Programmierung benötigt werden.



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





3. Geben Sie im End-Block das resultierende Array zurück



end {
        return $OutputArray
    }
      
      





Hurra, der Endblock ist fertig, es ist Zeit, das Skript richtig zu verwenden.



Wir benutzen das Skript



Beispiel 1 Mit



diesem Befehl werden alle Dateien in einem Ordner ausgewählt, in das MP4-Format konvertiert und diese Dateien sofort an ein Netzwerklaufwerk gesendet.



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





Beispiel 2



Lassen Sie uns alle unsere Spielvideos im angegebenen Ordner neu codieren und die Quellen löschen.



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





Beispiel 3:



Codieren aller Dateien aus einem Ordner und Verschieben neuer Dateien in einen anderen Ordner.



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





Fazit



Also haben wir ffmpeg repariert, anscheinend haben wir nichts Kritisches verpasst. Aber was ist es, ffmpeg könnte ohne eine normale Shell nicht verwendet werden?

Es stellt sich heraus, ja.

Aber es liegt noch viel Arbeit vor uns. Es wäre nützlich, Cmdlets wie Measure-videoLenght als Module zu haben, die die Dauer eines Videos in Form einer Zeitspanne zurückgeben. Mit ihrer Hilfe wäre es möglich, die Pipe zu vereinfachen und den Code kompakter zu gestalten.

Trotzdem müssen Sie ConvertTo-Webp-Befehle und alles in diesem Sinne ausführen. Es wäre auch notwendig, rekursiv einen Ordner für den Benutzer zu erstellen, falls dieser nicht vorhanden ist. Und nach Lese- und Schreibzugriff suchen wäre auch schön. Folgen Sie



in der Zwischenzeit dem Projekt auf Github .






All Articles