public/反转视频.ps1

function 反转视频 {
    <#
    .SYNOPSIS
        把视频文件的所有音视频轨按时间轴反转(倒放),其它轨道原样保留。

    .DESCRIPTION
        使用 ffmpeg 的 reverse(视频)和 areverse(音频)滤镜,把输入文件中每一条视频轨和
        音频轨整体倒放。输出视频的分辨率、帧率与原视频保持完全一致。

        反转必须重编码,因此采用与"压缩到指定大小"一致的二次编码方案:Pass 1 全片倒放预分析,
        Pass 2 按原视频实际码率正式编码,使输出体积与原文件接近。编码器选择参照"超级合并"策略:
        目标编码统一 HEVC,优先 GPU(h264/hevc 的 NVENC → QSV → AMF),无可用 GPU 时回退 CPU libx265。
        音频统一重编码为 AAC,每轨目标码率取自该轨原码率。

        注:NVENC/libx265 支持二次编码;QSV/AMF 仅支持单遍码率控制,会回退到单遍编码并提示。

        字幕、附件等其它类型轨道无法时间反转,命令会发出警告并将其原样复制进输出;
        这些轨道的时间轴不会随画面倒转,播放时可能与画面对不上。

        未指定输出路径时,在输入文件同目录生成"原文件名_反转+原扩展名";若原扩展名容器装不下
        原样复制的字幕等轨道,会自动改用 .mkv 并提示。输出文件已存在时会被覆盖。

    .PARAMETER 输入文件
        要反转的视频文件路径,必须存在且至少包含一个视频轨。

    .PARAMETER 输出文件
        输出文件路径。省略时在输入文件同目录生成"原文件名_反转+原扩展名"。

    .EXAMPLE
        反转视频 -输入文件 'D:\片段.mp4'

        将 D:\片段.mp4 倒放为 D:\片段_反转.mp4,分辨率帧率不变,体积与原文件接近。

    .EXAMPLE
        反转视频 -输入文件 'D:\片段.mkv' -输出文件 'D:\倒放.mkv'

        倒放并指定输出路径。

    .NOTES
        依赖 ffmpeg 和 ffprobe;缺失时命令会尝试自动安装。
        reverse/areverse 会把整条轨道缓存在内存里倒排,且 NVENC/libx265 路径需要完整解码两遍
        (Pass 1/2),长视频内存占用和耗时都明显高于普通转码。
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, HelpMessage = '要反转的视频文件路径')]
        [string]$输入文件,

        [Parameter(Position = 1)]
        [string]$输出文件
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'
    $script:输出外部进程信息 = $false

    $输入文件 = 规范化_用户路径参数 -Path $输入文件
    if ($输出文件) { $输出文件 = 规范化_用户路径参数 -Path $输出文件 }

    确保_ffmpeg_ffprobe_可用

    if (-not (Test-Path -LiteralPath $输入文件)) { throw "文件不存在: $输入文件" }
    $输入绝对路径 = (Resolve-Path -LiteralPath $输入文件).Path

    # ── 探测轨道 ─────────────────────────────────────────────────
    $探测结果 = 获取_媒体流探测数据 -Path $输入绝对路径
    $全部轨道 = @($探测结果.streams)
    if ($全部轨道.Count -eq 0) { throw "输入文件没有任何媒体轨: $输入文件" }

    $视频轨列表 = @($全部轨道 | Where-Object { $_.codec_type -eq 'video' })
    $音频轨列表 = @($全部轨道 | Where-Object { $_.codec_type -eq 'audio' })
    $其它轨列表 = @($全部轨道 | Where-Object { $_.codec_type -notin @('video', 'audio') })

    if ($视频轨列表.Count -eq 0) { throw "输入文件没有视频轨: $输入文件" }
    $视频序号列表 = @($视频轨列表 | ForEach-Object { [int]$_.index })
    $音频序号列表 = @($音频轨列表 | ForEach-Object { [int]$_.index })

    # 源视频帧率(分数形式,如 5820/241):reverse 后容器重算平均帧率会漂移,
    # 用 fps=<原分数> 滤镜锁定,保证输出帧率与原视频严格一致。
    $源帧率表达式 = [string](取_对象属性值 -Obj $视频轨列表[0] -Name 'avg_frame_rate')
    if ([string]::IsNullOrWhiteSpace($源帧率表达式) -or $源帧率表达式 -eq '0/0') {
        $源帧率表达式 = [string](取_对象属性值 -Obj $视频轨列表[0] -Name 'r_frame_rate')
    }

    if ($视频轨列表.Count -gt 1) {
        Write-Warning ("检测到 {0} 条视频轨:二次编码的码率预算按第一条视频轨计算,所有视频轨共用该码率。" -f $视频轨列表.Count)
    }
    if ($其它轨列表.Count -gt 0) {
        $类型汇总 = ($其它轨列表 | Group-Object codec_type | ForEach-Object { "{0}×{1}" -f $_.Name, $_.Count }) -join '、'
        Write-Warning ("检测到 {0} 条非音视频轨道({1})无法时间反转,将原样复制进输出;其时间轴不会随画面倒转,可能与画面对不上。" -f $其它轨列表.Count, $类型汇总)
    }

    # ── 时长 ─────────────────────────────────────────────────────
    $时长秒 = [double](获取_媒体信息 -Path $输入绝对路径).Duration
    if ($时长秒 -le 0) { throw "无法从输入文件获取时长: $输入文件" }

    # ── 逐包统计各轨字节数,用于目标码率 ────────────────────────
    $所有序号 = @($全部轨道 | ForEach-Object { [int]$_.index })
    $包字节表 = @{}
    try {
        $包字节表 = 取_按流序号统计包字节数 -Path $输入绝对路径 -StreamIndices $所有序号
    } catch {
        $global:LASTEXITCODE = 0
    }

    # 单轨目标码率:优先“该轨实际字节数×8÷时长”,失败回退探测声明值,最后兜底并钳制范围
    $视频目标bps列表 = @()
    foreach ($轨 in $视频轨列表) {
        $idx = [int]$轨.index
        $bps = $null
        if ($包字节表.ContainsKey($idx) -and [int64]$包字节表[$idx] -gt 0) {
            $bps = [int64]([math]::Round([int64]$包字节表[$idx] * 8.0 / $时长秒))
        }
        if (-not $bps -or $bps -le 0) { $bps = 解析_整数或空 ([string](取_对象属性值 -Obj $轨 -Name 'bit_rate')) }
        if (-not $bps -or $bps -le 0) { $bps = [int64]2000000 }
        if ($bps -lt [int64]100000) { $bps = [int64]100000 }
        if ($bps -gt [int64]200000000) { $bps = [int64]200000000 }
        $视频目标bps列表 += $bps
    }

    # ── 逐轨目标音频编码:与超级合并一致,无损源保持无损,有损源转 AAC ──
    $目标音频编码列表 = @()
    foreach ($轨 in $音频轨列表) {
        $源音频编码 = ([string](取_对象属性值 -Obj $轨 -Name 'codec_name')).ToLowerInvariant()
        if (测试_音频编码是否无损 -Codec $源音频编码) {
            if ($源音频编码 -in @('flac', 'alac')) { $目标音频编码列表 += $源音频编码 }
            else { $目标音频编码列表 += 'flac' }
        } else {
            $目标音频编码列表 += 'aac'
        }
    }

    $音频目标bps列表 = @()
    foreach ($轨 in $音频轨列表) {
        $idx = [int]$轨.index
        $bps = $null
        if ($包字节表.ContainsKey($idx) -and [int64]$包字节表[$idx] -gt 0) {
            $bps = [int64]([math]::Round([int64]$包字节表[$idx] * 8.0 / $时长秒))
        }
        if (-not $bps -or $bps -le 0) { $bps = 解析_整数或空 ([string](取_对象属性值 -Obj $轨 -Name 'bit_rate')) }
        if (-not $bps -or $bps -le 0) { $bps = [int64]128000 }
        if ($bps -lt [int64]64000) { $bps = [int64]64000 }
        if ($bps -gt [int64]512000) { $bps = [int64]512000 }
        $音频目标bps列表 += $bps
    }
    $视频目标bps = [int64]$视频目标bps列表[0]
    $视频目标kbps = [int64]([math]::Floor($视频目标bps / 1000.0))

    # ── 选择编码器:与超级合并一致,目标编码一律 HEVC,GPU 优先回退 CPU ──
    $ffmpeg路径 = 解决_工具执行路径 -工具名 'ffmpeg'
    $源编码 = [string]$视频轨列表[0].codec_name
    $编码器 = [string](选择_视频编码器 -目标视频编码 'hevc').Encoder
    $支持二次编码 = ($编码器 -like '*_nvenc') -or ($编码器 -eq 'libx265')
    if (-not $支持二次编码) {
        Write-Warning ("编码器 {0} 不支持二次编码,将改用单遍码率控制;输出体积可能与原文件偏差稍大。" -f $编码器)
    }

    # ── 输出路径 ─────────────────────────────────────────────────
    if (-not $输出文件) {
        $目录   = [System.IO.Path]::GetDirectoryName($输入绝对路径)
        $基础名 = [System.IO.Path]::GetFileNameWithoutExtension($输入绝对路径)
        $扩展名 = [System.IO.Path]::GetExtension($输入绝对路径)
        if ([string]::IsNullOrWhiteSpace($扩展名)) { $扩展名 = '.mkv' }
        $输出文件 = Join-Path $目录 ("${基础名}_反转${扩展名}")
    }
    $输出绝对路径 = [System.IO.Path]::GetFullPath($输出文件)
    if ($输出绝对路径 -ieq $输入绝对路径) { throw '输出文件不能与输入文件相同,请换一个输出路径。' }

    # 原样复制的轨道(尤其字幕)与重编码的音频必须能被输出容器容纳,否则改用 MKV
    $输出扩展名 = [System.IO.Path]::GetExtension($输出绝对路径).ToLowerInvariant()
    if ($输出扩展名 -ne '.mkv') {
        $需要换容器 = $false
        foreach ($轨 in $其它轨列表) {
            if ([string]$轨.codec_type -eq 'subtitle') {
                if (-not (测试_容器是否支持_字幕编码 -容器扩展名 $输出扩展名 -字幕编码 ([string]$轨.codec_name))) { $需要换容器 = $true }
            }
        }
        foreach ($编 in ($目标音频编码列表 | Select-Object -Unique)) {
            if (-not (测试_容器是否支持_音频编码 -容器扩展名 $输出扩展名 -音频编码 $编)) { $需要换容器 = $true }
        }
        if ($需要换容器) {
            $输出绝对路径 = [System.IO.Path]::ChangeExtension($输出绝对路径, '.mkv')
            Write-Warning ("原样复制的轨道与容器 {0} 不兼容,输出容器已改为 .mkv:{1}" -f $输出扩展名, $输出绝对路径)
        }
    }

    # ── 组装滤镜链 ───────────────────────────────────────────────
    $视频翻转片段 = @()
    for ($i = 0; $i -lt $视频序号列表.Count; $i++) {
        $片段 = ("[0:{0}]reverse" -f $视频序号列表[$i])
        if ($源帧率表达式) { $片段 += ",fps=$源帧率表达式" }
        $视频翻转片段 += "$片段[v$i]"
    }
    $音频翻转片段 = @()
    for ($j = 0; $j -lt $音频序号列表.Count; $j++) {
        $音频翻转片段 += ("[0:{0}]areverse[a{1}]" -f $音频序号列表[$j], $j)
    }
    $pass1滤镜 = $视频翻转片段 -join ';'
    $pass2滤镜 = @($视频翻转片段 + $音频翻转片段) -join ';'

    # ── 二次编码统计日志临时目录(Pass 1/2 共用),结束后清理 ────
    $临时目录    = Join-Path ([System.IO.Path]::GetTempPath()) ("视频工坊反转_" + [Guid]::NewGuid().ToString('N'))
    $passlog前缀 = Join-Path $临时目录 'ffmpeg2pass'
    $x265统计文件 = Join-Path $临时目录 'x265_stats.log'
    New-Item -ItemType Directory -Path $临时目录 -Force | Out-Null

    # ── 打印摘要 ─────────────────────────────────────────────────
    Write-Host ''
    Write-Host '视频反转(二次编码 / 时间倒放)' -ForegroundColor Cyan
    Write-Host (" 输入 : {0}" -f $输入绝对路径)
    Write-Host (" 视频轨 : {0} 条,全部 reverse(目标码率 {1} kbps)" -f $视频轨列表.Count, ([math]::Round($视频目标bps / 1000.0, 1)))
    Write-Host (" 音频轨 : {0} 条,全部 areverse({1})" -f $音频轨列表.Count, (($目标音频编码列表 | Select-Object -Unique) -join '/'))
    if ($其它轨列表.Count -gt 0) { Write-Host (" 其它轨道 : {0} 条,原样复制(不参与反转)" -f $其它轨列表.Count) }
    Write-Host (" 源编码 : {0} → HEVC" -f $源编码)
    Write-Host (" 编码器 : {0}{1}" -f $编码器, $(if ($编码器 -like '*_nvenc') { '(NVIDIA GPU)' } elseif ($编码器 -like '*_qsv') { '(Intel QSV)' } elseif ($编码器 -like '*_amf') { '(AMD GPU)' } else { '(CPU)' }))
    Write-Host (" 时长 : {0}s" -f ([math]::Round($时长秒, 1)))
    Write-Host (" 输出 : {0}" -f $输出绝对路径)
    Write-Host ''

    # ── 组装视频编码参数(NVENC/libx265 两遍;QSV/AMF 仅单遍码率控制)──
    if ($编码器 -like '*_nvenc') {
        $视频公共参数 = @(
            '-c:v', $编码器,
            '-preset', 'p4',
            '-tune', 'hq',
            '-rc', 'vbr',
            '-b:v', "$视频目标bps",
            '-maxrate', "$视频目标bps",
            '-bufsize', "$($视频目标bps * 2)",
            '-passlogfile', $passlog前缀
        )
        $pass1视频参数 = $视频公共参数 + @('-pass', '1')
        $pass2视频参数 = $视频公共参数 + @('-pass', '2')
    } elseif ($编码器 -eq 'libx265') {
        $视频公共参数 = @(
            '-c:v', 'libx265',
            '-preset', 'medium',
            '-b:v', "$视频目标bps"
        )
        $pass1视频参数 = $视频公共参数 + @('-x265-params', ("pass=1:bitrate={0}:stats={1}" -f $视频目标kbps, $x265统计文件))
        $pass2视频参数 = $视频公共参数 + @('-x265-params', ("pass=2:bitrate={0}:stats={1}" -f $视频目标kbps, $x265统计文件))
    } else {
        # QSV/AMF:不支持二次编码,单遍码率约束(与超级合并一致:-b:v/-maxrate/-bufsize)
        $视频公共参数 = @()
        if ($编码器 -like '*_qsv') { $视频公共参数 += @('-c:v', $编码器, '-global_quality', '23') }
        elseif ($编码器 -like '*_amf') { $视频公共参数 += @('-c:v', $编码器, '-rc', 'cqp', '-qp_i', '23', '-qp_p', '23', '-qp_b', '23') }
        $视频公共参数 += @(
            '-b:v', "$视频目标bps",
            '-maxrate', "$视频目标bps",
            '-bufsize', "$($视频目标bps * 2)"
        )
        $pass1视频参数 = @()
        $pass2视频参数 = $视频公共参数
    }

    # Pass 1:只倒放视频预分析,音频字幕一律跳过(仅二次编码时需要)
    $pass1映射 = @()
    for ($i = 0; $i -lt $视频序号列表.Count; $i++) { $pass1映射 += @('-map', "[v$i]") }
    $pass1参数 = @('-hide_banner', '-nostdin', '-y', '-i', $输入绝对路径, '-filter_complex', $pass1滤镜) +
        $pass1映射 + $pass1视频参数 + @('-an', '-sn', '-dn', '-f', 'null', 'NUL')

    # Pass 2:倒放视频 + 倒放音频(AAC),其余轨道原样复制
    $pass2映射 = @()
    for ($i = 0; $i -lt $视频序号列表.Count; $i++) { $pass2映射 += @('-map', "[v$i]") }
    for ($j = 0; $j -lt $音频序号列表.Count; $j++) { $pass2映射 += @('-map', "[a$j]") }
    foreach ($轨 in $其它轨列表) { $pass2映射 += @('-map', ("0:{0}" -f ([int]$轨.index))) }

    $音频编码参数 = @()
    for ($j = 0; $j -lt $音频序号列表.Count; $j++) {
        $目标音频编码 = $目标音频编码列表[$j]
        $音频编码参数 += @("-c:a:$j", $目标音频编码)
        # FLAC/ALAC 为无损编码,码率参数无效,只给 AAC 轨设目标码率
        if ($目标音频编码 -eq 'aac') {
            $音频编码参数 += @("-b:a:$j", "$($音频目标bps列表[$j])")
        }
    }

    $pass2参数 = @('-hide_banner', '-nostdin', '-y', '-i', $输入绝对路径, '-filter_complex', $pass2滤镜) +
        $pass2映射 + $pass2视频参数 + $音频编码参数 +
        @('-c:s', 'copy', '-c:d', 'copy', $输出绝对路径)

    try {
        # Pass 1:预分析(reverse 滤镜会把整条视频轨缓冲到内存倒排;QSV/AMF 无此遍)
        $旧错误偏好 = $ErrorActionPreference
        if ($支持二次编码) {
            Write-Host 'Pass 1 / 2 倒放预分析中...' -ForegroundColor DarkYellow
            $ErrorActionPreference = 'SilentlyContinue'
            & $ffmpeg路径 @pass1参数
            $退出码 = $LASTEXITCODE
            $ErrorActionPreference = $旧错误偏好
            if ($退出码 -ne 0) { throw "反转失败:Pass 1 预分析出错(退出码 $退出码)" }
        }

        # Pass 2:正式编码输出(libx265 会在当前目录写临时分块文件,切换到临时目录避免污染)
        Write-Host $(if ($支持二次编码) { 'Pass 2 / 2 倒放编码中...' } else { '单遍倒放编码中...' }) -ForegroundColor DarkYellow
        if ($编码器 -eq 'libx265') {
            $旧目录 = (Get-Location).Path
            try {
                Set-Location -LiteralPath $临时目录
                $ErrorActionPreference = 'SilentlyContinue'
                & $ffmpeg路径 @pass2参数
                $退出码 = $LASTEXITCODE
                $ErrorActionPreference = $旧错误偏好
            } finally {
                Set-Location -LiteralPath $旧目录
            }
        } else {
            $ErrorActionPreference = 'SilentlyContinue'
            & $ffmpeg路径 @pass2参数
            $退出码 = $LASTEXITCODE
            $ErrorActionPreference = $旧错误偏好
        }
        if ($退出码 -ne 0) { throw "反转失败:Pass 2 编码出错(退出码 $退出码)" }
    } finally {
        Remove-Item -LiteralPath $临时目录 -Recurse -Force -ErrorAction SilentlyContinue
    }

    # ── 完成,报告输出体积与原文件的接近程度 ────────────────────
    $原字节 = (Get-Item -LiteralPath $输入绝对路径).Length
    $实际字节 = (Get-Item -LiteralPath $输出绝对路径).Length
    $达成比例 = [math]::Round($实际字节 * 100.0 / $原字节, 1)

    Write-Host ''
    Write-Host '反转完成' -ForegroundColor Green
    Write-Host (" 输出文件 : {0}" -f $输出绝对路径)
    Write-Host (" 实际大小 : {0}(原文件 {1},为原大小的 {2}%)" -f (格式化_字节到文本 $实际字节), (格式化_字节到文本 $原字节), $达成比例)
}