public/烧录字幕.ps1

function 烧录字幕 {
    <#
    .SYNOPSIS
        将输入文件的第一个字幕轨硬烧到第一个视频轨。
 
    .DESCRIPTION
        使用 ffmpeg 读取输入媒体流,选取第一个视频轨和第一个字幕轨生成硬字幕视频。
 
        文本字幕会通过 subtitles 滤镜渲染;位图字幕(DVD/PGS/DVB/XSub)会先缩放到视频尺寸,再通过 overlay 叠加。
        输出中的视频轨一定会重新编码为 HEVC:优先使用 hevc_nvenc(CQ 23),不可用时回退到 libx265(CRF 23、medium)。
 
        除原第一个视频轨和被烧录的第一个字幕轨外,其它轨道会按原顺序复制到输出文件,例如音频轨、其它字幕轨等。
        如果未指定输出路径,会在输入文件同目录生成“原文件名_烧录字幕 + 原扩展名”。输出文件已存在时会被覆盖。
 
        输入文件必须至少包含一个视频轨和一个字幕轨;缺少任一轨道时命令会报错。
 
    .PARAMETER 输入文件
        要处理的媒体文件路径。命令会通过 ffprobe 探测轨道,并要求该文件存在且包含视频轨和字幕轨。
 
    .PARAMETER 输出文件
        输出文件路径。省略时使用输入文件所在目录,并将文件名追加“_烧录字幕”。扩展名沿用输入文件扩展名。
 
    .EXAMPLE
        烧录字幕 -输入文件 'D:\电影.mkv'
 
        将 D:\电影.mkv 的第一个字幕轨烧录进第一个视频轨,输出 D:\电影_烧录字幕.mkv。
 
    .EXAMPLE
        烧录字幕 -输入文件 'D:\电影.mkv' -输出文件 'D:\电影.hardsub.mkv'
 
        使用指定路径保存硬字幕版本。
 
    .NOTES
        依赖 ffmpeg 和 ffprobe。命令会尝试自动检测或安装依赖;实际字幕渲染效果取决于 ffmpeg 构建、字体环境和字幕格式。
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$输入文件,

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

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

    确保_ffmpeg_ffprobe_可用

    if (-not (Test-Path -LiteralPath $输入文件)) {
        throw "文件不存在: $输入文件"
    }

    if (-not $输出文件) {
        $目录 = [IO.Path]::GetDirectoryName($输入文件)
        $文件名 = [IO.Path]::GetFileNameWithoutExtension($输入文件)
        $扩展名 = [IO.Path]::GetExtension($输入文件)
        $输出文件 = [IO.Path]::Combine($目录, "${文件名}_烧录字幕${扩展名}")
    }

    $探测结果 = 获取_媒体流探测数据 -Path $输入文件
    $全部轨道 = @($探测结果.streams)

    $视频轨列表 = @($全部轨道 | Where-Object codec_type -eq 'video')
    $字幕轨列表 = @($全部轨道 | Where-Object codec_type -eq 'subtitle')

    if ($视频轨列表.Count -eq 0) { throw '输入文件没有视频轨' }
    if ($字幕轨列表.Count -eq 0) { throw '输入文件没有字幕轨' }

    $视频轨 = $视频轨列表[0]
    $字幕轨 = $字幕轨列表[0]
    $视频序号 = [int]$视频轨.index
    $字幕序号 = [int]$字幕轨.index
    $字幕编码 = [string]$字幕轨.codec_name
    $视频宽 = [int]$视频轨.width
    $视频高 = [int]$视频轨.height

    Write-Host "视频轨 #$视频序号 : $($视频轨.codec_name) ${视频宽}x${视频高}" -ForegroundColor Cyan
    Write-Host "字幕轨 #$字幕序号 : $字幕编码" -ForegroundColor Cyan
    Write-Host "输出 : $输出文件" -ForegroundColor Green

    if (测试_ffmpeg视频编码器可用 -FfmpegPath 'ffmpeg' -编码器名称 'hevc_nvenc') {
        $编码参数 = @('-c:v', 'hevc_nvenc', '-cq', '23', '-b:v', '0')
        Write-Host '编码器: hevc_nvenc (GPU)' -ForegroundColor Cyan
    } else {
        $编码参数 = @('-c:v', 'libx265', '-crf', '23', '-preset', 'medium')
        Write-Host '编码器: libx265 (CPU 回退,NVENC 不可用)' -ForegroundColor Yellow
    }

    $图形字幕编码 = 'dvd_subtitle', 'hdmv_pgs_subtitle', 'dvb_subtitle', 'xsub'

    if ($字幕编码 -in $图形字幕编码) {
        $滤镜 = "[0:$字幕序号]scale=${视频宽}:${视频高}[sub];[0:$视频序号][sub]overlay=eof_action=pass[vout]"
    } else {
        $转义路径 = ($输入文件 -replace '\\', '/') -replace "([\[\]:;,'])", '\$1'
        $滤镜 = "[0:$视频序号]subtitles='${转义路径}':si=0[vout]"
    }

    $映射参数 = [Collections.Generic.List[string]]::new()
    $映射参数.Add('-map')
    $映射参数.Add('[vout]')

    foreach ($轨 in $全部轨道) {
        $序号 = [int]$轨.index
        if ($序号 -eq $视频序号 -or $序号 -eq $字幕序号) { continue }
        $映射参数.Add('-map')
        $映射参数.Add("0:$序号")
    }

    $ff参数 = @('-i', $输入文件, '-filter_complex', $滤镜) +
        $映射参数.ToArray() +
        $编码参数 +
        @('-c:a', 'copy', '-c:s', 'copy', '-y', $输出文件)

    Write-Host "`n> ffmpeg $($ff参数 -join ' ')`n" -ForegroundColor DarkGray

    $旧错误偏好 = $ErrorActionPreference
    $ErrorActionPreference = 'SilentlyContinue'
    & ffmpeg @ff参数
    $退出码 = $LASTEXITCODE
    $ErrorActionPreference = $旧错误偏好

    if ($退出码 -eq 0) {
        Write-Host "`n完成: $输出文件" -ForegroundColor Green
    } else {
        throw "ffmpeg 失败,退出码 $退出码"
    }
}