Skip to content
Merged
239 changes: 149 additions & 90 deletions .github/skills/video-to-gif/scripts/convert.ps1
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#!/usr/bin/env pwsh
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT

Expand Down Expand Up @@ -103,6 +104,8 @@ param(
[switch]$SkipPalette
)

#region Functions

function Test-FFmpegAvailable {
$ffmpegPath = Get-Command -Name 'ffmpeg' -ErrorAction SilentlyContinue
if (-not $ffmpegPath) {
Expand Down Expand Up @@ -326,113 +329,169 @@ function Invoke-TwoPassConversion {
}
}

# Main execution
if (-not (Test-FFmpegAvailable)) {
exit 1
}
function Invoke-VideoConversion {
[CmdletBinding()]
[OutputType([void])]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$InputPath,

# Search for input file
$resolvedInput = $null
if (Test-Path -Path $InputPath -PathType Leaf) {
$resolvedInput = (Resolve-Path -Path $InputPath).Path
}
else {
$resolvedInput = Find-VideoFile -Filename $InputPath
if ($resolvedInput) {
Write-Host "Found: $resolvedInput"
[Parameter(Mandatory = $false)]
[string]$OutputPath,

[Parameter(Mandatory = $false)]
[ValidateRange(1, 30)]
[int]$Fps = 10,

[Parameter(Mandatory = $false)]
[ValidateRange(100, 3840)]
[int]$Width = 1280,

[Parameter(Mandatory = $false)]
[ValidateSet('sierra2_4a', 'floyd_steinberg', 'bayer', 'none')]
[string]$Dither = 'sierra2_4a',

[Parameter(Mandatory = $false)]
[ValidateSet('hable', 'reinhard', 'mobius', 'bt2390')]
[string]$Tonemap = 'hable',

[Parameter(Mandatory = $false)]
[ValidateRange(0, [int]::MaxValue)]
[int]$Loop = 0,

[Parameter(Mandatory = $false)]
[ValidateRange(0, [double]::MaxValue)]
[double]$Start,

[Parameter(Mandatory = $false)]
[ValidateRange(0.1, [double]::MaxValue)]
[double]$Duration,

[Parameter(Mandatory = $false)]
[switch]$SkipPalette
)

if (-not (Test-FFmpegAvailable)) {
throw "FFmpeg is not available"
}

# Search for input file
$resolvedInput = $null
if (Test-Path -Path $InputPath -PathType Leaf) {
$resolvedInput = (Resolve-Path -Path $InputPath).Path
}
else {
$searchLocations = @(
"current directory"
"workspace root"
)
if ($IsMacOS) {
$searchLocations += @("~/Movies", "~/Downloads", "~/Desktop")
$resolvedInput = Find-VideoFile -Filename $InputPath
if ($resolvedInput) {
Write-Host "Found: $resolvedInput"
}
else {
$searchLocations += @("~/Videos", "~/Downloads", "~/Desktop")
$searchLocations = @(
"current directory"
"workspace root"
)
if ($IsMacOS) {
$searchLocations += @("~/Movies", "~/Downloads", "~/Desktop")
}
else {
$searchLocations += @("~/Videos", "~/Downloads", "~/Desktop")
}
throw "Input file not found: $InputPath`nSearched: $($searchLocations -join ', ')"
}
Write-Error "Input file not found: $InputPath`nSearched: $($searchLocations -join ', ')"
exit 1
}
}

# Set default output path if not specified
if ([string]::IsNullOrEmpty($OutputPath)) {
$inputItem = Get-Item -Path $resolvedInput
$OutputPath = Join-Path -Path $inputItem.DirectoryName -ChildPath "$($inputItem.BaseName).gif"
}
# Set default output path if not specified
if ([string]::IsNullOrEmpty($OutputPath)) {
$inputItem = Get-Item -Path $resolvedInput
$OutputPath = Join-Path -Path $inputItem.DirectoryName -ChildPath "$($inputItem.BaseName).gif"
}

# Detect HDR content
$isHDR = Test-HDRContent -FilePath $resolvedInput
# Detect HDR content
$isHDR = Test-HDRContent -FilePath $resolvedInput

# Build base filter chain
$baseFilter = "fps=$Fps,scale=${Width}:-1:flags=lanczos"
# Build base filter chain
$baseFilter = "fps=$Fps,scale=${Width}:-1:flags=lanczos"

# Add HDR tonemapping if detected
# Convert HDR to SDR using selected tonemapping algorithm, then explicitly convert to sRGB for accurate GIF colors
if ($isHDR) {
$hdrFilter = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=${Tonemap}:desat=0,zscale=t=iec61966-2-1:m=bt709:r=full,format=rgb24"
$baseFilter = "$hdrFilter,$baseFilter"
}
# Add HDR tonemapping if detected
if ($isHDR) {
$hdrFilter = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=${Tonemap}:desat=0,zscale=t=iec61966-2-1:m=bt709:r=full,format=rgb24"
$baseFilter = "$hdrFilter,$baseFilter"
}

# Build time arguments
$timeArgs = @()
if ($PSBoundParameters.ContainsKey('Start')) {
$timeArgs += $Start
}
else {
$timeArgs += -1 # Sentinel value indicating no start time
}
if ($PSBoundParameters.ContainsKey('Duration')) {
$timeArgs += $Duration
}
# Build time arguments
$timeArgs = @()
if ($PSBoundParameters.ContainsKey('Start')) {
$timeArgs += $Start
}
else {
$timeArgs += -1 # Sentinel value indicating no start time
}
if ($PSBoundParameters.ContainsKey('Duration')) {
$timeArgs += $Duration
}

Write-Host "Converting: $resolvedInput"
Write-Host "Output: $OutputPath"
Write-Host "Settings: $Fps FPS, ${Width}px width, $Dither dithering, loop=$Loop"
Write-Host "Converting: $resolvedInput"
Write-Host "Output: $OutputPath"
Write-Host "Settings: $Fps FPS, ${Width}px width, $Dither dithering, loop=$Loop"

if ($PSBoundParameters.ContainsKey('Start') -or $PSBoundParameters.ContainsKey('Duration')) {
$startDisplay = if ($PSBoundParameters.ContainsKey('Start')) { "${Start}s" } else { "0s" }
$durationDisplay = if ($PSBoundParameters.ContainsKey('Duration')) { "${Duration}s" } else { "full" }
Write-Host "Time range: start=$startDisplay, duration=$durationDisplay"
}
if ($PSBoundParameters.ContainsKey('Start') -or $PSBoundParameters.ContainsKey('Duration')) {
$startDisplay = if ($PSBoundParameters.ContainsKey('Start')) { "${Start}s" } else { "0s" }
$durationDisplay = if ($PSBoundParameters.ContainsKey('Duration')) { "${Duration}s" } else { "full" }
Write-Host "Time range: start=$startDisplay, duration=$durationDisplay"
}

if ($isHDR) {
Write-Host "HDR: Detected, applying $Tonemap tonemapping"
}
if ($isHDR) {
Write-Host "HDR: Detected, applying $Tonemap tonemapping"
}

if ($SkipPalette) {
Write-Host "Mode: Single-pass (faster, lower quality)"
Write-Host ""
if ($SkipPalette) {
Write-Host "Mode: Single-pass (faster, lower quality)"
Write-Host ""

$success = Invoke-SinglePassConversion `
-SourcePath $resolvedInput `
-DestinationPath $OutputPath `
-LoopCount $Loop `
-BaseFilter $baseFilter `
-TimeArgs $timeArgs
}
else {
Write-Host "Mode: Two-pass palette optimization"
Write-Host ""

$success = Invoke-TwoPassConversion `
-SourcePath $resolvedInput `
-DestinationPath $OutputPath `
-DitherAlgorithm $Dither `
-LoopCount $Loop `
-BaseFilter $baseFilter `
-TimeArgs $timeArgs
}
$success = Invoke-SinglePassConversion `
-SourcePath $resolvedInput `
-DestinationPath $OutputPath `
-LoopCount $Loop `
-BaseFilter $baseFilter `
-TimeArgs $timeArgs
}
else {
Write-Host "Mode: Two-pass palette optimization"
Write-Host ""

$success = Invoke-TwoPassConversion `
-SourcePath $resolvedInput `
-DestinationPath $OutputPath `
-DitherAlgorithm $Dither `
-LoopCount $Loop `
-BaseFilter $baseFilter `
-TimeArgs $timeArgs
}

if ($success -and (Test-Path -Path $OutputPath)) {
$outputFile = Get-Item -Path $OutputPath
$formattedSize = Format-FileSize -Bytes $outputFile.Length
Write-Host ""
Write-Host "Conversion complete: $OutputPath ($formattedSize)" -ForegroundColor Green
if ($success -and (Test-Path -Path $OutputPath)) {
$outputFile = Get-Item -Path $OutputPath
$formattedSize = Format-FileSize -Bytes $outputFile.Length
Write-Host ""
Write-Host "Conversion complete: $OutputPath ($formattedSize)" -ForegroundColor Green
}
else {
throw "Conversion failed. Output file was not created."
}
}
else {
Write-Error "Conversion failed. Output file was not created."
exit 1

#endregion Functions

#region Main Execution

if ($MyInvocation.InvocationName -ne '.') {
try {
Invoke-VideoConversion @PSBoundParameters
exit 0
}
catch {
Write-Error -ErrorAction Continue "Video conversion failed: $($_.Exception.Message)"
exit 1
}
}

#endregion Main Execution
30 changes: 24 additions & 6 deletions .github/workflows/pester-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ jobs:

if ($changedFiles.Count -eq 0) {
Write-Host "No changed PowerShell files found"
"HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append
if ($env:GITHUB_ENV) {
"HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append
}
exit 0
}

Expand Down Expand Up @@ -93,12 +95,16 @@ jobs:
$testFiles = @($testFiles | Select-Object -Unique)
if ($testFiles.Count -eq 0) {
Write-Host "No matching test files for changed PowerShell files"
"HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append
if ($env:GITHUB_ENV) {
"HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append
}
} else {
Write-Host "Found $($testFiles.Count) test file(s) to run:"
$testFiles | ForEach-Object { Write-Host " - $_" }
"HAS_CHANGES=true" | Out-File -FilePath $env:GITHUB_ENV -Append
"TEST_PATHS=$($testFiles -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append
if ($env:GITHUB_ENV) {
"HAS_CHANGES=true" | Out-File -FilePath $env:GITHUB_ENV -Append
"TEST_PATHS=$($testFiles -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append
}
}

- name: Run Pester Tests
Expand All @@ -123,15 +129,27 @@ jobs:
$config = & './scripts/tests/pester.config.ps1' @params
$results = Invoke-Pester -Configuration $config

if ($results.FailedCount -gt 0) {
if ($results.FailedCount -gt 0 -and $env:GITHUB_ENV) {
"PESTER_FAILED=true" | Out-File -FilePath $env:GITHUB_ENV -Append
}

# Output coverage percentage if available
if ($codeCoverage -and $results.CodeCoverage) {
$coveragePercent = [math]::Round($results.CodeCoverage.CoveragePercent, 2)
Write-Host "Code coverage: $coveragePercent%"
"coverage=$coveragePercent" >> $env:GITHUB_OUTPUT
if ($env:GITHUB_OUTPUT) {
$outDir = Split-Path -Parent $env:GITHUB_OUTPUT
if ($outDir -and -not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
if (-not (Test-Path $env:GITHUB_OUTPUT)) {
New-Item -ItemType File -Path $env:GITHUB_OUTPUT -Force | Out-Null
}
"coverage=$coveragePercent" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
}
else {
Write-Warning "GITHUB_OUTPUT not set; skipping coverage output export."
}
}
continue-on-error: ${{ inputs.soft-fail }}

Expand Down
48 changes: 43 additions & 5 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,49 @@ All scripts automatically detect GitHub Actions environment and provide appropri
When adding new scripts:

1. Follow PowerShell best practices (PSScriptAnalyzer compliant)
2. Support `-Verbose` and `-Debug` parameters
3. Add GitHub Actions integration using `LintingHelpers` module functions
4. Include inline help with `.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, and `.EXAMPLE`
5. Document in relevant README files
6. Test locally before creating PR
2. Include the entry point guard pattern (see below)
3. Support `-Verbose` and `-Debug` parameters
4. Add GitHub Actions integration using `LintingHelpers` module functions
5. Include inline help with `.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, and `.EXAMPLE`
6. Document in relevant README files
7. Test locally before creating PR

### Entry Point Guard Pattern

All production scripts use a dot-source guard that enables Pester tests to import functions without executing main logic. Extract main logic into an `Invoke-*` orchestrator function and wrap direct execution in a guard block:

```powershell
#region Functions

function Invoke-ScriptMain {
[CmdletBinding()]
param( <# script params #> )
# Main logic here
}

#endregion Functions

#region Main Execution
if ($MyInvocation.InvocationName -ne '.') {
try {
Invoke-ScriptMain @PSBoundParameters
exit 0
}
catch {
Write-Error -ErrorAction Continue "ScriptName failed: $($_.Exception.Message)"
Write-CIAnnotation -Message $_.Exception.Message -Level Error
exit 1
}
}
#endregion Main Execution
```

Key rules:

* The `if` guard wraps `try`/`catch` (not the reverse)
* Name the orchestrator `Invoke-*` matching the script noun
* Use `#region Functions` and `#region Main Execution` markers
* See [Generate-PrReference.ps1](dev-tools/Generate-PrReference.ps1) for a canonical example

## Related Documentation

Expand Down
Loading
Loading