Writing a usable shell for FFMPEG on Powershell



Normal ffmpeg output



You, like me, have heard about ffmpeg, but were afraid to use it. Respect guys like that, the whole program is written in C (si, no # and ++).



Despite the extremely high functionality of the program, terrible, gigantic verbose, inconvenient arguments, strange defaults, lack of autocomplete and unforgiving syntax, coupled with errors that are not always detailed and understandable to the user, make this great program inconvenient.



I have not found ready-made cmdlets on the Internet for interacting with ffmpeg, so let's finalize what needs to be improved and do it all so that it would not be a shame to publish it on PowershellGallery.



Making an object for a pipe



class VideoFile {
    $InputFileLiteralPath
    $OutFileLiteralPath
    $Arguments
}

      
      





It all starts with an object. FFmpeg program is quite simple, all we need to know is where what we work with, how we work with it and where we put everything.



Begin, process, end



In the Begin block, you cannot work with the received arguments in any way, that is, you cannot immediately concatenate a string by arguments, in the Begin block all parameters are zeros.



However, here you can load executables, import the necessary modules and initialize counters for all files that will be processed, work with constants and system variables.



Think of the Begin-Process construct as a foreach, where begin is executed before the function is called and parameters are set, and End is executed last after foreach.



This is how the code would look if there were no Begin, Process, End constructions. This is an example of bad code, you shouldn't write that.



#  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
      
      





What should be put in the Begin block?



Counters, compose paths to executable files and make a greeting. This is how the Begin block looks like for me:



 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
    }
      
      





I want to draw your attention to the line, this is a real life hack:



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





Using Get-Module, we get the path to the folder with the module, and Split-Path takes the input value and returns the folder one level below. Thus, you can store executable files next to the modules folder, but not in this folder itself.



Like this:



PSffmpeg/
β”œβ”€β”€ ConvertTo-MP4/
β”‚   β”œβ”€β”€ ConvertTo-MP4.psm1
β”‚   β”œβ”€β”€ ConvertTo-MP4.psd1
β”‚   β”œβ”€β”€ Readme.md
└── ffmpeg/
    β”œβ”€β”€ ffmpeg.exe
    β”œβ”€β”€ ffplay.exe
    └── ffprobe.exe

      
      





And with the help of Split-Path, you can style down to the level below.



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





How to make a correct Param block?



Immediately after Begin, there is a Process along with a Param block. The Param block itself holds null checks, and validates the arguments. For example:



List Validation:



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





Everything is simple here. If the value does not appear to be one in the list, then False is returned and then an exception is thrown.



Range validation:



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





You can validate on a range by specifying numbers from and to. Ffmpeg's crf supports numbers from 0 to 51, so this range is specified here.



Validation by script:



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





Complex input can be validated with regulars or whole scripts. The main thing is that the validating script returns true or false.



SupportsShouldProcess and force



So, you need to re-encode the files with a different codec, but with the same name. The classic ffmpeg interface prompts users to press y / N to overwrite the file. And so for each file.



The best option is the standard Yes to all, Yes, No, No to all.



I chose β€œYes to all” and you can rewrite files in batches and ffmpeg will not stop and ask again if you want to replace this file or not.



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





This is how the naked Param block of a healthy person looks like. With SupportsShouldProcess, the function will be able to ask before performing a destructive action, and the force switch completely ignores it.



In our case, we are working with a video file and before overwriting the file, we want to make sure that the user understands what the function is doing.



# If Force is specified, all files are silently overwritten

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
    
}
      
      







Making a normal pipe



In functional style, a normal pipe would look like this:



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
        }

      
      





But this is just awful, everything looks like noodles, can't you really make everything cleaner?

Of course you can, but you need to use nested functions for this. They can look at the variables declaration in the parent function, which is very convenient. Here's an example:



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

      
      





But at the same time, if you have a lot of the same functions, you will have to copy and paste the same code into each function every time. In the case of ConvertTo-Mp4, ConvertTo-Webp, etc. easier to do as I did.



If I used nested functions, it would look like this:



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





But again, this greatly reduces code interchangeability.



Making normal functions



We need to compose arguments for ffmpeg.exe, and for this there is nothing better than a pipeline. How I love pipelines!



Instead of interpolation or a string builder, we use a pipe that can correct arguments or write a relevant error. You saw the pipe itself above.



Now about what the coolest functions of the pipeline look like:



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 saves bitrate starting from 1080 and higher, at lower video resolution it is not so important, therefore, for encoding large videos, you should specify h265 as the default.

Return in Foreach-Object looks very strange. But there is nothing you can do about it. FFmpeg writes everything to stdout and this is the easiest way to extract a value from such programs. Use this trick if you need to pull something from stdout. Do not use Start-Process, to pull stdout you need to call the executable file directly as in this example.



It is impossible to call the executable along the full path and get stdout in any other way. You need to specifically go to the folder with the executable file and call it by name from there. For this, in the Begin block, the script remembers the path from which it started, so that after the completion of its work it does not annoy the user.



  begin {
        $Location = Get-Location
    }
      
      





This function would look good as a separate cmdlet, it would be useful, but for the future.



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

      
      



One of my favorite gags is the switch inside out. There is no matching pattern in Powershell, but such constructs replace it, for the most part.

The function to be executed is in parentheses. And if the result of executing this function is equal to the condition in the switch, then the script block is executed in it.



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





The biggest feature in the pipeline. A correctly written function should show the user about errors, you have to bloat the code like this.

For simplicity, it was decided not to encapsulate the paths to the executable files in the class, which is why the functions take so many arguments.



Displaying new objects



In order for this script to be able to be embedded in other pipelines, you need to make it so that it returns something. We have an InputObject taken from Get-ChildItem, but the Name field is read-only, you can't just change the file names.



To make the output of the command look like the system output, you need to save the names of the recoded objects and use Get-Chilitem to add them to the array and display it.



1. In the Begin block, declare an array



begin {
        $OutputArray = @()
}
      
      





2. Enter the recoded files in the Process block:



Don't forget about null checks, even in functional programming they are needed.



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





3. In the End block, return the resulting array



end {
        return $OutputArray
    }
      
      





Hooray, finished the end block, it's time to use the script properly.



We use the script



Example # 1



This command will select all files in a folder, convert them to mp4 format and immediately send these files to a network drive.



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





Example # 2



Let's recode all our game videos in the specified folder, and delete the sources.



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





Example # 3



Encoding all files from a folder and moving new files to another folder.



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





Conclusion



So we fixed ffmpeg, it seems like we didn't miss anything critical. But what is it, ffmpeg could not be used without a normal shell?

It turns out, yes.

But there is still a lot of work ahead. It would be useful to have cmdlets such as Measure-videoLenght as modules, which return the duration of a video in the form of a Timespan, with their help it would be possible to simplify the pipe and make the code more compact.

Still, you need to make ConvertTo-Webp commands and everything in this spirit. It would also be necessary to create a folder for the user, if it does not exist, recursively. And check for read and write access would be nice too.



In the meantime, so, follow the project on github .






All Articles