private/媒体过程.ps1

function 转义_ConCat路径 {
    param([Parameter(Mandatory)] [string]$Path)
    $p = $Path -replace '\\','/'
    $p = $p -replace "'", "'\\''"
    return $p
}

function 获取_视频包范围 {
    param([Parameter(Mandatory)] [string]$Path)
    $res = 调用_外部命令 -Exe 'ffprobe' -ArgumentList @('-v','error','-select_streams','v:0','-show_entries','packet=pts_time','-of','csv=p=0', $Path) -CaptureOutput
    $pts = New-Object 'System.Collections.Generic.List[double]'
    foreach ($line in ($res.StdOut -split "`r?`n")) { $s = ([string]$line).Trim(); if ($s -match '^-?\d+(?:\.\d+)?') { $pts.Add([double]::Parse($Matches[0], [System.Globalization.CultureInfo]::InvariantCulture)) } }
    if ($pts.Count -lt 1) { return $null }
    $firstPts = [double]$pts[0]; $lastPts = [double]$pts[$pts.Count - 1]
    if ($pts.Count -ge 2) { $frameInterval = ($lastPts - $firstPts) / ($pts.Count - 1); if ([double]::IsNaN($frameInterval) -or [double]::IsInfinity($frameInterval) -or $frameInterval -le 0) { $frameInterval = 0.041709 }; $duration = [math]::Max(0.0, ($lastPts - $firstPts + $frameInterval)) } else { $frameInterval = 0.041709; $duration = $frameInterval }
    return [pscustomobject]@{ FirstPts = $firstPts; LastPts = $lastPts; PacketCount = $pts.Count; FrameInterval = $frameInterval; DurationSeconds = [double]$duration }
}

function 获取_媒体信息 {
    param([Parameter(Mandatory)] [string]$Path)

    $res = 调用_外部命令 -Exe 'ffprobe' -ArgumentList @('-v','error','-print_format','json','-show_streams','-show_format', $Path) -CaptureOutput
    $json = $res.StdOut | ConvertFrom-Json

    $v = $json.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1
    $a = $json.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1
    $s = $json.streams | Where-Object { $_.codec_type -eq 'subtitle' } | Select-Object -First 1

    # 流签名:按流索引列出 "type:codec"。用于同构检测(是否来自同一原片的多段裁剪)。
    $signatureTokens = @()
    foreach ($st in @($json.streams)) {
        $t = [string](取_对象属性值 -Obj $st -Name 'codec_type')
        $c = [string](取_对象属性值 -Obj $st -Name 'codec_name')
        $signatureTokens += ("{0}:{1}" -f $t, $c)
    }

    $duration = $null
    if ($json.format -and $json.format.duration) {
        try { $duration = [double]$json.format.duration } catch { $duration = $null }
    }
    if ($null -eq $duration -and $v -and (取_对象属性值 -Obj $v -Name 'duration')) {
        try { $duration = [double](取_对象属性值 -Obj $v -Name 'duration') } catch { $duration = $null }
    }
    if ($null -eq $duration) { $duration = 0.0 }

    $fpsRaw = $null
    $fps = $null
    if ($v) {
        $fpsRaw = [string](取_对象属性值 -Obj $v -Name 'avg_frame_rate')
        if ([string]::IsNullOrWhiteSpace($fpsRaw)) { $fpsRaw = [string](取_对象属性值 -Obj $v -Name 'r_frame_rate') }
        $fps = 解析_有理数 $fpsRaw
    }

    return [pscustomobject]@{
        Path = $Path
        Duration = [double]$duration
        StreamSignature = ($signatureTokens -join '|')
        StreamCount = @($json.streams).Count
        Video = if ($v) {
            [pscustomobject]@{
                Codec = [string](取_对象属性值 -Obj $v -Name 'codec_name')
                Width = 解析_整数或空 ([string](取_对象属性值 -Obj $v -Name 'width'))
                Height = 解析_整数或空 ([string](取_对象属性值 -Obj $v -Name 'height'))
                PixFmt = [string](取_对象属性值 -Obj $v -Name 'pix_fmt')
                FpsRaw = [string]$fpsRaw
                Fps = $fps
                BitRate = 解析_整数或空 ([string](取_对象属性值 -Obj $v -Name 'bit_rate'))
            }
        } else { $null }
        Audio = if ($a) {
            [pscustomobject]@{
                Codec = [string](取_对象属性值 -Obj $a -Name 'codec_name')
                SampleRate = 解析_整数或空 ([string](取_对象属性值 -Obj $a -Name 'sample_rate'))
                Channels = 解析_整数或空 ([string](取_对象属性值 -Obj $a -Name 'channels'))
                BitRate = 解析_整数或空 ([string](取_对象属性值 -Obj $a -Name 'bit_rate'))
            }
        } else { $null }
        Subtitle = if ($s) {
            [pscustomobject]@{
                Codec = [string](取_对象属性值 -Obj $s -Name 'codec_name')
            }
        } else { $null }
    }
}

function 获取_媒体码率估算 {
    param([Parameter(Mandatory)] [string]$Path)

    # 尽量快速:优先使用 stream.bit_rate / format.bit_rate;其次用 size/duration 估算。
    $res = 调用_外部命令 -Exe 'ffprobe' -ArgumentList @(
        '-v','error',
        '-print_format','json',
        '-show_entries','format=duration,size,bit_rate:stream=index,codec_type,bit_rate',
        $Path
    ) -CaptureOutput

    $j = $res.StdOut | ConvertFrom-Json

    $duration = 0.0
    if ($j.format -and $j.format.duration) {
        try { $duration = [double]$j.format.duration } catch { $duration = 0.0 }
    }

    $sizeBytes = $null
    if ($j.format -and $j.format.size) {
        try { $sizeBytes = [int64]$j.format.size } catch { $sizeBytes = $null }
    }

    $formatBps = $null
    if ($j.format -and $j.format.bit_rate) {
        $formatBps = 解析_整数或空 ([string]$j.format.bit_rate)
    }

    $v = $j.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1
    $a = $j.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1

    $videoBps = $null
    $audioBps = $null

    if ($v) { $videoBps = 解析_整数或空 ([string](取_对象属性值 -Obj $v -Name 'bit_rate')) }
    if ($a) { $audioBps = 解析_整数或空 ([string](取_对象属性值 -Obj $a -Name 'bit_rate')) }

    if ((-not $videoBps -or $videoBps -le 0) -and $formatBps -and $formatBps -gt 0) {
        if ($audioBps -and $audioBps -gt 0) {
            $videoBps = [int64]([math]::Max(0.0, ($formatBps - $audioBps)))
        } else {
            $videoBps = $formatBps
        }
    }

    if ((-not $videoBps -or $videoBps -le 0) -and $duration -gt 0 -and $sizeBytes -and $sizeBytes -gt 0) {
        $totalBps = [int64]([math]::Round(($sizeBytes * 8.0) / $duration))
        if ($audioBps -and $audioBps -gt 0) {
            $videoBps = [int64]([math]::Max(0.0, ($totalBps - $audioBps)))
        } else {
            $videoBps = $totalBps
        }
    }

    if ((-not $audioBps -or $audioBps -le 0) -and $formatBps -and $formatBps -gt 0 -and $videoBps -and $videoBps -gt 0) {
        $audioBps = [int64]([math]::Max(0.0, ($formatBps - $videoBps)))
    }

    return [pscustomobject]@{
        DurationSeconds = $duration
        SizeBytes = $sizeBytes
        FormatBps = $formatBps
        VideoBps = $videoBps
        AudioBps = $audioBps
    }
}

function 获取_输出探测数据_快速 {
    param([Parameter(Mandatory)] [string]$Path)

    $probe = 调用_外部命令 -Exe 'ffprobe' -ArgumentList @('-v','error','-print_format','json','-show_streams','-show_format', $Path) -CaptureOutput
    $j = $probe.StdOut | ConvertFrom-Json

    $v = $j.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1
    $a = $j.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1
    $s = $j.streams | Where-Object { $_.codec_type -eq 'subtitle' } | Select-Object -First 1

    $duration = 0.0
    if ($j.format -and $j.format.duration) {
        try { $duration = [double]$j.format.duration } catch { $duration = 0.0 }
    }

    $sizeBytes = 0
    if ($j.format -and $j.format.size) {
        try { $sizeBytes = [int64]$j.format.size } catch { $sizeBytes = 0 }
    }

    $fps = $null
    if ($v) {
        $fps = [string](取_对象属性值 -Obj $v -Name 'avg_frame_rate')
        if ([string]::IsNullOrWhiteSpace($fps)) { $fps = [string](取_对象属性值 -Obj $v -Name 'r_frame_rate') }
    }

    $videoBpsDeclared = $null
    $audioBpsDeclared = $null
    if ($v) {
        $vt = 取_对象属性值 -Obj $v -Name 'tags'
        $bpsTag = if ($vt) { 取_对象属性值 -Obj $vt -Name 'BPS' } else { $null }
        if ($bpsTag) { $videoBpsDeclared = 转换_码率到bps -Value ([string]$bpsTag) }
        if (-not $videoBpsDeclared) { $videoBpsDeclared = 解析_整数或空 ([string](取_对象属性值 -Obj $v -Name 'bit_rate')) }
    }
    if ($a) {
        $at = 取_对象属性值 -Obj $a -Name 'tags'
        $bpsTag = if ($at) { 取_对象属性值 -Obj $at -Name 'BPS' } else { $null }
        if ($bpsTag) { $audioBpsDeclared = 转换_码率到bps -Value ([string]$bpsTag) }
        if (-not $audioBpsDeclared) { $audioBpsDeclared = 解析_整数或空 ([string](取_对象属性值 -Obj $a -Name 'bit_rate')) }
    }

    $aBytes = $null
    $vBytes = $null
    if ($duration -gt 0 -and $v) {
        $vIndex = 解析_整数或空 ([string](取_对象属性值 -Obj $v -Name 'index'))
        $aIndex = if ($a) { 解析_整数或空 ([string](取_对象属性值 -Obj $a -Name 'index')) } else { $null }
        try {
            $sums = 取_按流序号统计包字节数 -Path $Path -StreamIndices @($vIndex, $aIndex)
            if ($null -ne $vIndex -and $sums.ContainsKey([int]$vIndex)) { $vBytes = $sums[[int]$vIndex] }
            if ($null -ne $aIndex -and $sums.ContainsKey([int]$aIndex)) { $aBytes = $sums[[int]$aIndex] }
        } catch {
            $aBytes = $null
            $vBytes = $null
            # 这里允许 ffprobe 扫包失败(不影响合并结果),但要清理 $LASTEXITCODE,避免脚本最终返回非 0。
            $global:LASTEXITCODE = 0
        }
    }

    $videoBpsEff = $null
    $audioBpsUsed = $null

    if ($duration -gt 0) {
        if ($vBytes -and $vBytes -gt 0) { $videoBpsEff = [int64]([math]::Round(($vBytes * 8.0) / $duration)) }
        if ($aBytes -and $aBytes -gt 0) { $audioBpsUsed = [int64]([math]::Round(($aBytes * 8.0) / $duration)) }
    }

    if (-not $videoBpsEff) { $videoBpsEff = $videoBpsDeclared }
    if (-not $audioBpsUsed) { $audioBpsUsed = $audioBpsDeclared }

    if ($duration -gt 0 -and $sizeBytes -gt 0 -and (-not $vBytes -or $vBytes -le 0)) {
        if ($audioBpsUsed -and $audioBpsUsed -gt 0) {
            $aBytes = [int64]([math]::Round($audioBpsUsed * $duration / 8.0))
            $vBytes = [int64]([math]::Max(0.0, ($sizeBytes - $aBytes)))
        } else {
            $vBytes = $sizeBytes
        }
        if (-not $videoBpsEff -and $vBytes -and $vBytes -gt 0) {
            $videoBpsEff = [int64]([math]::Round(($vBytes * 8.0) / $duration))
        }
    }

    $audioLang = $null
    if ($a) {
        $at = 取_对象属性值 -Obj $a -Name 'tags'
        if ($at) { $audioLang = [string](取_对象属性值 -Obj $at -Name 'language') }
    }
    $subLang = $null
    if ($s) {
        $st = 取_对象属性值 -Obj $s -Name 'tags'
        if ($st) { $subLang = [string](取_对象属性值 -Obj $st -Name 'language') }
    }

    return [pscustomobject]@{
        DurationSeconds = $duration
        SizeBytes = $sizeBytes
        VideoFps = $fps
        VideoBps = $videoBpsEff
        AudioBps = $audioBpsUsed
        VideoBytes = $vBytes
        AudioBytes = $aBytes
        SourceVideoBps = $videoBpsDeclared
        SourceAudioBps = $audioBpsDeclared
        AudioLanguage = $audioLang
        SubtitleLanguage = $subLang
    }
}

function 通过Remux写入_流标签 {
    param(
        [Parameter(Mandatory)] [string]$InputPath,
        [Parameter(Mandatory)] [string]$OutputPath,
        [int64]$VideoBps,
        [int64]$AudioBps,
        [int64]$VideoTargetBps,
        [int64]$AudioTargetBps,
        [double]$DurationSeconds,
        [string]$VideoFps,
        [int64]$VideoBytes,
        [int64]$AudioBytes,
        [string]$AudioLanguage,
        [string]$SubtitleLanguage
    )

    $outDir = Split-Path -Parent $OutputPath
    if ([string]::IsNullOrWhiteSpace($outDir)) { $outDir = (Get-Location).Path }
    $outBase = [System.IO.Path]::GetFileNameWithoutExtension($OutputPath)
    $outExt = [System.IO.Path]::GetExtension($OutputPath)
    if ([string]::IsNullOrWhiteSpace($outExt)) { $outExt = '.mkv' }
    $tmp = Join-Path $outDir ("{0}.tagged.tmp{1}" -f $outBase, $outExt)

    $ffArgs = @(
        '-nostdin','-y',
        '-i', $InputPath,
        '-map_metadata','-1',
        '-map_metadata:s:v','-1',
        '-map_metadata:s:a','-1',
        '-map_metadata:s:s','-1',
        '-map','0',
        '-c','copy'
    )

    if ($VideoBps -and $VideoBps -gt 0) {
        $ffArgs += @('-metadata:s:v:0', "BPS=$VideoBps")
        $ffArgs += @('-metadata:s:v:0', "BPS-eng=$VideoBps")
    }
    if ($VideoTargetBps -and $VideoTargetBps -gt 0 -and $VideoTargetBps -ne $VideoBps) {
        $ffArgs += @('-metadata:s:v:0', "BPS_TARGET=$VideoTargetBps")
        $ffArgs += @('-metadata:s:v:0', "BPS_TARGET-eng=$VideoTargetBps")
    }
    if (-not [string]::IsNullOrWhiteSpace($VideoFps)) {
        $ffArgs += @('-metadata:s:v:0', "FPS=$VideoFps")
        $ffArgs += @('-metadata:s:v:0', "FPS-eng=$VideoFps")
    }
    if ($VideoBytes -and $VideoBytes -gt 0) {
        $ffArgs += @('-metadata:s:v:0', "NUMBER_OF_BYTES=$VideoBytes")
        $ffArgs += @('-metadata:s:v:0', "NUMBER_OF_BYTES-eng=$VideoBytes")
    }
    if ($DurationSeconds -and $DurationSeconds -gt 0) {
        $ffArgs += @('-metadata:s:v:0', ("DURATION={0:0.###}" -f [double]$DurationSeconds))
    }

    if ($AudioBps -and $AudioBps -gt 0) {
        $ffArgs += @('-metadata:s:a:0', "BPS=$AudioBps")
        $ffArgs += @('-metadata:s:a:0', "BPS-eng=$AudioBps")
    }
    if (-not [string]::IsNullOrWhiteSpace($AudioLanguage)) {
        $ffArgs += @('-metadata:s:a:0', "language=$AudioLanguage")
    }
    if ($AudioTargetBps -and $AudioTargetBps -gt 0 -and $AudioTargetBps -ne $AudioBps) {
        $ffArgs += @('-metadata:s:a:0', "BPS_TARGET=$AudioTargetBps")
        $ffArgs += @('-metadata:s:a:0', "BPS_TARGET-eng=$AudioTargetBps")
    }
    if ($AudioBytes -and $AudioBytes -gt 0) {
        $ffArgs += @('-metadata:s:a:0', "NUMBER_OF_BYTES=$AudioBytes")
        $ffArgs += @('-metadata:s:a:0', "NUMBER_OF_BYTES-eng=$AudioBytes")
    }
    if ($DurationSeconds -and $DurationSeconds -gt 0) {
        $ffArgs += @('-metadata:s:a:0', ("DURATION={0:0.###}" -f [double]$DurationSeconds))
    }

    if (-not [string]::IsNullOrWhiteSpace($SubtitleLanguage)) {
        $ffArgs += @('-metadata:s:s:0', "language=$SubtitleLanguage")
    }

    调用_外部命令 -Exe 'ffmpeg' -ArgumentList ($ffArgs + @($tmp)) | Out-Null
    Move-Item -LiteralPath $tmp -Destination $OutputPath -Force
}

function 测试_容器是否支持_字幕编码 {
    param(
        [Parameter(Mandatory)] [string]$容器扩展名,
        [AllowNull()] [string]$字幕编码
    )
    if ([string]::IsNullOrWhiteSpace($字幕编码)) { return $true }

    $ext = $容器扩展名.ToLowerInvariant()
    $codec = $字幕编码.ToLowerInvariant()
    $movFamily = @('.mp4','.m4v','.mov')

    if ($ext -in $movFamily) {
        # mp4/mov 体系:ffmpeg 仅支持 mov_text 等少数字幕;ass/subrip 都不支持内嵌。
        return ($codec -eq 'mov_text')
    }

    if ($ext -eq '.mkv') {
        # Matroska:能很好支持 ass/subrip
        return ($codec -in @('ass','subrip'))
    }

    if ($ext -eq '.webm') {
        # WebM:常见为 WebVTT;这里不尝试支持其它字幕
        return ($codec -in @('webvtt'))
    }

    return $false
}

function 测试_容器是否支持_音频编码 {
    param(
        [Parameter(Mandatory)] [string]$容器扩展名,
        [AllowNull()] [string]$音频编码
    )
    if ([string]::IsNullOrWhiteSpace($音频编码)) { return $true }

    $ext = $容器扩展名.ToLowerInvariant()
    $codec = $音频编码.ToLowerInvariant()

    $movFamily = @('.mp4','.m4v','.mov')
    if ($ext -in $movFamily) {
        # mov/mp4:常见支持 aac/alac/mp3(此处保守列举)
        return ($codec -in @('aac','alac','mp3'))
    }

    if ($ext -eq '.mkv') {
        # mkv:对音频轨支持很广;这里按本脚本可能输出的编码做白名单
        if ($codec -like 'pcm_*') { return $true }
        return ($codec -in @('aac','flac','alac','opus','vorbis','mp3'))
    }

    if ($ext -eq '.webm') {
        return ($codec -in @('opus','vorbis'))
    }

    return $false
}

function 测试_音频编码是否无损 {
    param([AllowNull()] [string]$Codec)
    if ([string]::IsNullOrWhiteSpace($Codec)) { return $false }
    $c = $Codec.ToLowerInvariant()
    if ($c -like 'pcm_*') { return $true }
    return ($c -in @('flac','alac','wavpack','truehd','mlp','tta','ape'))
}

function 测试_视频编码是否支持动态分辨率 {
    param([AllowNull()] [string]$Codec)
    if ([string]::IsNullOrWhiteSpace($Codec)) { return $false }
    $c = $Codec.ToLowerInvariant()
    return ($c -in @('h264','avc1','hevc','h265','av1','vp9'))
}

function 选择_输出容器扩展名 {
    param(
        [Parameter(Mandatory)] [string[]]$输入文件列表,
        [Parameter(Mandatory)] [bool]$存在字幕,
        [AllowNull()] [string]$目标字幕编码,
        [Parameter(Mandatory)] [bool]$用户明确指定输出文件,
        [AllowNull()] [string]$用户输出扩展名
    )

    # 容器决定优先级(高->低):
    # 1) 若字幕编码与候选容器不兼容,则使用兼容容器
    # 2) 若用户明确指定输出文件路径,则根据扩展名推断容器
    # 3) 尽量让输出容器和所有输入一致(混合则优先 mkv)

    $known = @('.mkv','.mp4','.m4v','.mov','.webm')
    $movFamily = @('.mp4','.m4v','.mov')

    $candidate = $null

    if ($用户明确指定输出文件 -and -not [string]::IsNullOrWhiteSpace($用户输出扩展名)) {
        $uExt = $用户输出扩展名.ToLowerInvariant()
        if ($uExt -in $known) { $candidate = $uExt }
    }

    if (-not $candidate) {
        $exts = @(
            $输入文件列表 |
                ForEach-Object { [System.IO.Path]::GetExtension($_) } |
                ForEach-Object { if ([string]::IsNullOrWhiteSpace($_)) { '' } else { $_.ToLowerInvariant() } }
        )
        $knownExts = @($exts | Where-Object { $_ -in $known })
        $uniqKnown = @($knownExts | Select-Object -Unique)

        if ($knownExts.Count -eq $exts.Count -and $uniqKnown.Count -eq 1) {
            $candidate = $uniqKnown[0]
        } else {
            $allInMovFamily = ($knownExts.Count -eq $exts.Count -and (@($knownExts | Where-Object { $_ -notin $movFamily }).Count -eq 0))
            if ($allInMovFamily) {
                # 都是 mp4/m4v/mov:选出现次数最多的扩展名
                $grouped = $knownExts | Group-Object | Sort-Object Count -Descending
                $candidate = $grouped[0].Name
            } else {
                $candidate = '.mkv'
            }
        }
    }

    if ($存在字幕) {
        if (-not (测试_容器是否支持_字幕编码 -容器扩展名 $candidate -字幕编码 $目标字幕编码)) {
            return '.mkv'
        }
    }

    return $candidate
}

function 解析_输入文件列表_来自文本 {
    param([Parameter(Mandatory)] [string]$Text)

    $lines = @($Text -split "`r?`n")
    $out = New-Object System.Collections.Generic.List[string]

    foreach ($raw in $lines) {
        if ($null -eq $raw) { continue }
        $line = ([string]$raw).Trim()
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        if ($line -match '^\s*#') { continue }

        # 兼容 ffmpeg concat 列表格式:file 'path' / file "path"
        # 注意:PowerShell 里反斜杠不转义引号,所以这里用单引号字符串。
        if ($line -match '^\s*file\s+([''"])(.*)\1\s*$') {
            $p = $matches[2]
            # 兼容本脚本生成的转义形式:'\''
            $p = $p.Replace("'\''", "'")
            $p = $p.Trim()
            if (-not [string]::IsNullOrWhiteSpace($p)) { [void]$out.Add($p) }
            continue
        }

        if ($line -match '^\s*file\s+(.+)$') {
            $p = $matches[1].Trim()
            if (($p.StartsWith('"') -and $p.EndsWith('"')) -or ($p.StartsWith("'") -and $p.EndsWith("'"))) {
                if ($p.Length -ge 2) { $p = $p.Substring(1, $p.Length - 2) }
            }
            $p = $p.Trim()
            if (-not [string]::IsNullOrWhiteSpace($p)) { [void]$out.Add($p) }
            continue
        }

        $p2 = $line
        if (($p2.StartsWith('"') -and $p2.EndsWith('"')) -or ($p2.StartsWith("'") -and $p2.EndsWith("'"))) {
            if ($p2.Length -ge 2) { $p2 = $p2.Substring(1, $p2.Length - 2) }
        }
        $p2 = $p2.Trim()
        if (-not [string]::IsNullOrWhiteSpace($p2)) { [void]$out.Add($p2) }
    }

    return ,@($out.ToArray())
}