public/Split-WindowsInstallMedia.ps1

<#
.SYNOPSIS
Splits a Windows ISO into edition-specific install media and ISOs.
 
.DESCRIPTION
Mounts a source Windows ISO, stages base media, creates one media copy per requested
edition, exports matching image indexes, and builds bootable output ISOs.
 
.PARAMETER SourceIso
Path to the source ISO.
 
.PARAMETER EditionName
Edition names used to match install image names, such as Standard or Datacenter.
 
.PARAMETER WorkingRoot
Path for intermediate working files.
 
.PARAMETER OutputRoot
Path for generated ISO output files.
 
.PARAMETER OutputBaseName
Base file name prefix for generated ISO files.
 
.PARAMETER BaseIsoName
Alias of OutputBaseName.
 
.PARAMETER IsoBaseName
Alias of OutputBaseName.
 
.PARAMETER LabelPrefix
Prefix used when generating ISO labels.
 
.PARAMETER EditionLabelMap
Optional hashtable for explicit per-edition ISO labels.
 
.PARAMETER IncludeDesktopExperience
When set, only indexes with "Desktop Experience" in the name or description are
included.
 
.PARAMETER IncludeServerCore
When set, only indexes without "Desktop Experience" in the name or description are
included.
 
.PARAMETER DesktopExperience
Alias of IncludeDesktopExperience.
 
.PARAMETER ServerCore
Alias of IncludeServerCore.
 
.PARAMETER PassThru
Returns result objects for each generated edition.
 
.PARAMETER SkipBootableIso
Skips the final bootable ISO build step and leaves the edited media folder on disk.
 
.EXAMPLE
Split-WindowsInstallMedia -SourceIso '.\Windows.iso' -EditionName Standard, Datacenter -WorkingRoot '.\work' -OutputRoot '.\out' -Verbose
 
.EXAMPLE
Split-WindowsInstallMedia -SourceIso '.\WindowsServer.iso' -EditionName Standard, Datacenter -WorkingRoot 'C:\work' -OutputRoot 'C:\out' -OutputBaseName 'WinServer_2025' -Verbose
 
Creates output files like WinServer_2025-Standard.iso and WinServer_2025-Datacenter.iso.
 
.EXAMPLE
Split-WindowsInstallMedia -SourceIso '.\WindowsServer.iso' -EditionName Standard -DesktopExperience -WorkingRoot 'C:\work' -OutputRoot 'C:\out' -OutputBaseName 'WinServer_2025' -Verbose
 
Includes only Standard indexes that contain Desktop Experience.
#>

function Split-WindowsInstallMedia {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]
        $SourceIso,

        [Parameter(Mandatory)]
        [string[]]
        $EditionName,

        [Parameter(Mandatory)]
        [string]
        $WorkingRoot,

        [Parameter(Mandatory)]
        [string]
        $OutputRoot,

        [Alias('BaseIsoName', 'IsoBaseName')]
        [string]
        $OutputBaseName,

        [string]
        $LabelPrefix = 'WIN',

        [hashtable]
        $EditionLabelMap,

        [Alias('DesktopExperience')]
        [switch]
        $IncludeDesktopExperience,

        [Alias('ServerCore')]
        [switch]
        $IncludeServerCore,

        [switch]
        $SkipBootableIso,

        [switch]
        $PassThru
    )

    $resolvePathParams = @{
        LiteralPath = $SourceIso
        ErrorAction = 'Stop'
    }
    $resolvedSourceIso = Resolve-Path @resolvePathParams
    $sourceIsoPath = $resolvedSourceIso.ProviderPath
    $sourceIsoSizeBytes = (Get-Item -LiteralPath $sourceIsoPath).Length

    if (-not $OutputBaseName) {
        $outputBaseName = [System.IO.Path]::GetFileNameWithoutExtension($sourceIsoPath)
    }

    $newWorkingRootParams = @{
        Path = $WorkingRoot
        ItemType = 'Directory'
        Force = $true
    }
    New-Item @newWorkingRootParams | Out-Null

    $newOutputRootParams = @{
        Path = $OutputRoot
        ItemType = 'Directory'
        Force = $true
    }
    New-Item @newOutputRootParams | Out-Null

    $getOscdimgPathParams = @{
    }
    $oscdimgPath = Get-OscdimgPath @getOscdimgPathParams

    $mount = $null

    try {
        $mountWindowsInstallMediaParams = @{
            Path = $sourceIsoPath
        }
        if ($PSBoundParameters.ContainsKey('Verbose')) {
            $mountWindowsInstallMediaParams.Verbose = $true
        }
        $mount = Mount-WindowsInstallMedia @mountWindowsInstallMediaParams

        $baseMedia = Join-Path $WorkingRoot 'BaseMedia'
        $newBaseMediaParams = @{
            SourcePath = $mount.DriveRoot
            DestinationPath = $baseMedia
        }
        if ($PSBoundParameters.ContainsKey('Verbose')) {
            $newBaseMediaParams.Verbose = $true
        }
        New-WindowsInstallMedia @newBaseMediaParams

        $getInstallImagePathParams = @{
            MediaRoot = $baseMedia
        }
        $sourceImagePath = Get-InstallImagePath @getInstallImagePathParams
        $sourceImageExtension = [System.IO.Path]::GetExtension($sourceImagePath).ToLowerInvariant()
        $destinationImageFileName = if ($sourceImageExtension -eq '.esd') { 'install.esd' } else { 'install.wim' }
        $imageCompression = if ($sourceImageExtension -eq '.esd') { 'recovery' } else { 'max' }
        $sourceImageSizeBytes = (Get-Item -LiteralPath $sourceImagePath).Length

        Write-Verbose ("Source ISO: {0} ({1})" -f (Format-ByteSize -Bytes $sourceIsoSizeBytes), $sourceIsoPath)
        Write-Verbose ("Source image payload: {0} ({1})" -f (Format-ByteSize -Bytes $sourceImageSizeBytes), [System.IO.Path]::GetFileName($sourceImagePath))

        $getWindowsInstallImageParams = @{
            ImagePath = $sourceImagePath
        }
        $allImages = Get-WindowsInstallImage @getWindowsInstallImageParams

        $results = @()

        foreach ($edition in $EditionName) {
            $editionPattern = if ($edition -match '[\*\?\[]') {
                $edition
            }
            else {
                "*$edition*"
            }

            $matchingImages = $allImages | Where-Object {
                $_.Name -like $editionPattern -or
                $_.Description -like $editionPattern
            }

            if ($IncludeDesktopExperience -xor $IncludeServerCore) {
                if ($IncludeDesktopExperience) {
                    $matchingImages = $matchingImages | Where-Object {
                        $_.Name -like '*Desktop Experience*' -or
                        $_.Description -like '*Desktop Experience*'
                    }
                }

                if ($IncludeServerCore) {
                    $matchingImages = $matchingImages | Where-Object {
                        $_.Name -notlike '*Desktop Experience*' -and
                        $_.Description -notlike '*Desktop Experience*'
                    }
                }
            }

            if (-not $matchingImages) {
                $availableImagesText = ($allImages | ForEach-Object {
                    "[{0}] {1}" -f $_.Index, $_.Name
                }) -join '; '

                if ($IncludeDesktopExperience -and -not $IncludeServerCore) {
                    $flavorText = " and flavor 'Desktop Experience'"
                }
                elseif ($IncludeServerCore -and -not $IncludeDesktopExperience) {
                    $flavorText = " and flavor 'Server Core'"
                }
                else {
                    $flavorText = ''
                }

                throw "No install image indexes matched edition filter '$edition'$flavorText. Available images: $availableImagesText"
            }

            $safeEdition = ($edition -replace '[^A-Za-z0-9]+', '-').Trim('-')
            if ([string]::IsNullOrWhiteSpace($safeEdition)) {
                $safeEdition = "Edition$($matchingImages[0].Index)"
            }

            $editionMediaRoot = Join-Path $WorkingRoot ("{0}-{1}" -f $outputBaseName, $safeEdition)
            $newEditionMediaParams = @{
                SourcePath = $baseMedia
                DestinationPath = $editionMediaRoot
            }
            if ($PSBoundParameters.ContainsKey('Verbose')) {
                $newEditionMediaParams.Verbose = $true
            }
            New-WindowsInstallMedia @newEditionMediaParams

            $removeWindowsInstallImageParams = @{
                MediaRoot = $editionMediaRoot
            }
            if ($PSBoundParameters.ContainsKey('Verbose')) {
                $removeWindowsInstallImageParams.Verbose = $true
            }
            Remove-WindowsInstallImage @removeWindowsInstallImageParams

            $destinationImagePath = Join-Path $editionMediaRoot (Join-Path 'sources' $destinationImageFileName)
            $exportWindowsInstallImageParams = @{
                SourceImagePath = $sourceImagePath
                DestinationWim = $destinationImagePath
                Image = $matchingImages
                Compression = $imageCompression
            }
            if ($PSBoundParameters.ContainsKey('Verbose')) {
                $exportWindowsInstallImageParams.Verbose = $true
            }
            Export-WindowsInstallImage @exportWindowsInstallImageParams

            $destinationImageSizeBytes = (Get-Item -LiteralPath $destinationImagePath).Length

            $isoFileName = '{0}-{1}.iso' -f $outputBaseName, $safeEdition
            $outputIsoPath = Join-Path $OutputRoot $isoFileName

            if ($EditionLabelMap -and $EditionLabelMap.ContainsKey($edition)) {
                $isoLabel = [string] $EditionLabelMap[$edition]
            }
            else {
                $isoLabel = ('{0}{1}' -f $LabelPrefix, $safeEdition).ToUpperInvariant()
                $isoLabel = $isoLabel -replace '[^A-Z0-9]', ''
                if ($isoLabel.Length -gt 32) {
                    $isoLabel = $isoLabel.Substring(0, 32)
                }

                if ([string]::IsNullOrWhiteSpace($isoLabel)) {
                    $isoLabel = 'WININSTALL'
                }
            }

            $isoBuilt = $false
            $outputIsoSizeBytes = $null

            if (-not $SkipBootableIso) {
                Write-Verbose "Building ISO for edition '$edition' at $outputIsoPath"
                $newBootableIsoParams = @{
                    OscdimgPath = $oscdimgPath
                    MediaRoot = $editionMediaRoot
                    OutputIso = $outputIsoPath
                    Label = $isoLabel
                }
                New-BootableIso @newBootableIsoParams

                $outputIsoSizeBytes = (Get-Item -LiteralPath $outputIsoPath).Length
                $isoBuilt = $true
            }

            $installImageDeltaPct = Get-PercentDelta -Base $sourceImageSizeBytes -Current $destinationImageSizeBytes
            $isoDeltaPct = if ($isoBuilt) {
                Get-PercentDelta -Base $sourceIsoSizeBytes -Current $outputIsoSizeBytes
            }
            else {
                $null
            }

            Write-Verbose ("Size report [{0}] image: {1} -> {2} ({3:N2}% delta)" -f $edition, (Format-ByteSize -Bytes $sourceImageSizeBytes), (Format-ByteSize -Bytes $destinationImageSizeBytes), $installImageDeltaPct)
            if ($isoBuilt) {
                Write-Verbose ("Size report [{0}] iso: {1} -> {2} ({3:N2}% delta)" -f $edition, (Format-ByteSize -Bytes $sourceIsoSizeBytes), (Format-ByteSize -Bytes $outputIsoSizeBytes), $isoDeltaPct)
            }
            else {
                Write-Verbose "Skipped ISO build for edition '$edition' because -SkipBootableIso was specified."
            }

            $result = [pscustomobject]@{
                Edition = $edition
                ImageCount = @($matchingImages).Count
                MediaRoot = $editionMediaRoot
                InstallWim = $destinationImagePath
                OutputIso = $outputIsoPath
                Label = $isoLabel
                IsoBuilt = $isoBuilt
                SourceIsoSizeBytes = $sourceIsoSizeBytes
                SourceInstallImageSizeBytes = $sourceImageSizeBytes
                OutputInstallImageSizeBytes = $destinationImageSizeBytes
                OutputIsoSizeBytes = $outputIsoSizeBytes
                InstallImageDeltaPercent = $installImageDeltaPct
                IsoDeltaPercent = $isoDeltaPct
            }
            $results += $result
        }

        if ($PassThru) {
            $results
        }
    }
    finally {
        if ($mount) {
            $dismountWindowsInstallMediaParams = @{
                InputObject = $mount
            }
            if ($PSBoundParameters.ContainsKey('Verbose')) {
                $dismountWindowsInstallMediaParams.Verbose = $true
            }
            Dismount-WindowsInstallMedia @dismountWindowsInstallMediaParams
        }
    }
}