public/轨道叠加.ps1

function 轨道叠加 {
    <#
    .SYNOPSIS
        把所有输入文件的所有轨道叠加到同一个输出文件,轨道并行、不在时间轴上拼接、不做重编码。

    .DESCRIPTION
        读取一组输入文件,将每个输入的全部轨道(视频、音频、字幕、附件等)以流复制方式原样搬进同一个输出文件。与“超级合并”的按顺序首尾相接不同,轨道叠加是把各文件的轨道并列放在同一个容器里(多个视频轨/音频轨并存),时间轴互不影响,任何轨道都不重编码。

        输入可以是显式传入的一个或多个文件路径(-输入文件),也可以不传 -输入文件 而改从 -列表文件 或剪贴板读取路径列表(与 超级合并 相同规则)。列表支持一行一个路径、带引号路径和 ffmpeg concat 的 file '路径' 格式,空行和 # 注释会被忽略。

        命令会自动识别并还原 B站客户端缓存的混淆 m4s:这类文件在标准 MP4/M4S 头部填充了一段 ASCII '0'(0x30)字节,导致普通播放器无法识别。命令会定位真正的 ftyp box 并整体剥离前面的填充字节,生成干净的标准 MP4 副本后再参与叠加。

        输出容器固定使用 MKV(唯一能无差别容纳任意编码轨道的常见容器)。每个轨道会写入 title 元数据注明来源文件名,方便在播放器里区分同名类型(如两条音频轨)。若用户指定的输出扩展名不是 .mkv,命令会先写一个 .mkv 再改名为用户指定的文件名,并提示实际容器。

    .PARAMETER 输入文件
        一个或多个输入媒体文件路径。省略时使用 -列表文件(或剪贴板)。

    .PARAMETER 输出文件
        目标输出文件路径。默认值为 .\merged.mkv。不带扩展名时会补 .mkv。

    .PARAMETER 列表文件
        包含输入文件路径的 UTF-8 文本文件,仅在未显式传入 -输入文件 时生效。未显式传入且 -输入文件 为空时会从剪贴板读取路径列表。

    .PARAMETER 详细模式
        显示外部命令的详细输出,并保留临时工作目录以便排查混淆还原等中间文件。默认会清理临时目录。

    .EXAMPLE
        轨道叠加 'D:\视频.m4s' 'D:\音频.m4s' -输出文件 'D:\成片.mkv'

        把两个 m4s 的轨道叠加到同一个 mkv:一条视频轨和一条音频轨并行存在,均为流复制。

    .EXAMPLE
        轨道叠加 -输入文件 $files -输出文件 'D:\out.mkv' -详细模式

        显式传入文件数组叠加所有轨道,显示外部工具输出并保留临时目录。

    .EXAMPLE
        轨道叠加 -列表文件 'D:\列表.txt'

        按列表文件中的顺序读取所有输入,叠加到默认输出 .\merged.mkv。

    .NOTES
        依赖 ffmpeg 和 ffprobe;缺失时命令会尝试自动安装。
        多文件包含多条同类型轨道(如两条视频轨)时,播放器一般默认播放第一条;可在播放器中手动切换轨道。
    #>

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

        [Parameter(Mandatory = $false, Position = 1)]
        [string]$输出文件 = '.\merged.mkv',

        [Parameter(Mandatory = $false)]
        [string]$列表文件 = '.\要合并的文件列表.txt',

        [switch]$详细模式
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $script:输出外部进程信息 = [bool]$详细模式

    $工作目录 = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("视频工坊_stack_work_{0}" -f ([Guid]::NewGuid().ToString('N')))
    New-Item -ItemType Directory -Path $工作目录 -Force | Out-Null

    try {
        确保_ffmpeg_ffprobe_可用

        # -------- 收集输入列表 --------
        $输入文件列表 = @($输入文件 | ForEach-Object { 规范化_用户路径参数 -Path $_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

        if ($输入文件列表.Count -eq 0) {
            # 与 超级合并 相同的回退规则:显式列表文件优先,否则读剪贴板
            $列表文件是否显式指定 = $PSBoundParameters.ContainsKey('列表文件')
            if ($列表文件是否显式指定) {
                if (-not (Test-Path -LiteralPath $列表文件)) { throw "未找到列表文件:$列表文件" }
                $列表文本 = Get-Content -LiteralPath $列表文件 -Encoding UTF8 -Raw
            } else {
                if (-not (Get-Command Get-Clipboard -ErrorAction SilentlyContinue)) {
                    throw "未传入 -输入文件,且当前环境不支持 Get-Clipboard。请显式传入 -输入文件 或 -列表文件。"
                }
                $clip = Get-Clipboard -Raw
                if ([string]::IsNullOrWhiteSpace($clip)) {
                    throw "未传入 -输入文件,但剪贴板为空(或无法读取)。请先在剪贴板中放入文件路径列表(每行一个),或显式传入 -输入文件。"
                }
                $列表文本 = $clip
            }

            $输入文件列表 = 解析_输入文件列表_来自文本 -Text $列表文本
            $输入文件列表 = @($输入文件列表 | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
        }

        if ($输入文件列表.Count -lt 1) { throw "没有可用的输入文件。请通过 -输入文件、-列表文件 或剪贴板提供至少一个文件。" }
        foreach ($f in $输入文件列表) { if (-not (Test-Path -LiteralPath $f)) { throw "输入文件不存在:$f" } }

        # -------- 输出路径 --------
        $输出文件 = 规范化_用户路径参数 -Path $输出文件
        $用户输出文件 = $输出文件
        $用户输出扩展名 = [System.IO.Path]::GetExtension($用户输出文件)
        if ([string]::IsNullOrWhiteSpace($用户输出扩展名)) {
            $用户输出文件 = $用户输出文件 + '.mkv'
            $用户输出扩展名 = '.mkv'
        }
        $用户输出扩展名 = $用户输出扩展名.ToLowerInvariant()

        $outDir = Split-Path -Parent $用户输出文件
        if ([string]::IsNullOrWhiteSpace($outDir)) { $outDir = (Get-Location).Path }
        if (-not (Test-Path -LiteralPath $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }

        $内部输出文件 = if ($用户输出扩展名 -eq '.mkv') { $用户输出文件 } else { Join-Path $工作目录 'stack_output.mkv' }

        # -------- 探测与混淆还原 --------
        Write-Host ("探测 {0} 个输入文件..." -f $输入文件列表.Count) -ForegroundColor Cyan
        $有效输入列表 = New-Object System.Collections.Generic.List[object]
        for ($i = 0; $i -lt $输入文件列表.Count; $i++) {
            $raw = $输入文件列表[$i]
            $有效路径 = 还原_B站混淆媒体文件 -Path $raw -WorkDir $工作目录 -Index ($i + 1)
            $信息 = 获取_媒体信息 -Path $有效路径
            if ([int]$信息.StreamCount -lt 1) { throw "文件不包含任何媒体轨道:$raw" }
            $有效输入列表.Add([pscustomobject]@{
                原始路径  = $raw
                有效路径  = $有效路径
                信息      = $信息
                是否还原  = ($有效路径 -ne $raw)
            })
        }

        # -------- 叠加:每个输入的所有轨道原样搬进同一容器 --------
        $ffmpegArgs = New-Object System.Collections.Generic.List[string]
        $ffmpegArgs.Add('-nostdin'); $ffmpegArgs.Add('-hide_banner'); $ffmpegArgs.Add('-y'); $ffmpegArgs.Add('-v'); $ffmpegArgs.Add('error')
        foreach ($item in $有效输入列表) {
            $ffmpegArgs.Add('-i'); $ffmpegArgs.Add($item.有效路径)
        }
        for ($i = 0; $i -lt $有效输入列表.Count; $i++) { $ffmpegArgs.Add('-map'); $ffmpegArgs.Add([string]$i) }
        $ffmpegArgs.Add('-c'); $ffmpegArgs.Add('copy')
        $ffmpegArgs.Add('-map_metadata'); $ffmpegArgs.Add('-1')
        $ffmpegArgs.Add('-map_chapters'); $ffmpegArgs.Add('-1')

        # 给每个轨道写入 title 元数据,注明来源文件名,便于播放器里区分同类型轨道。
        $全局流序号 = 0
        foreach ($item in $有效输入列表) {
            $来源名 = [System.IO.Path]::GetFileName([string]$item.原始路径)
            for ($s = 0; $s -lt [int]$item.信息.StreamCount; $s++) {
                $ffmpegArgs.Add(('-metadata:s:{0}' -f $全局流序号)); $ffmpegArgs.Add(('title={0}' -f $来源名))
                $全局流序号++
            }
        }

        $ffmpegArgs.Add($内部输出文件)

        Write-Host ("开始叠加:{0} 个输入文件共 {1} 条轨道 → {2}(纯流复制,不重编码)" -f $有效输入列表.Count, $全局流序号, $内部输出文件) -ForegroundColor Cyan
        调用_外部命令 -Exe 'ffmpeg' -ArgumentList ([string[]]$ffmpegArgs.ToArray()) | Out-Null

        # -------- 校验输出 --------
        $输出信息 = 获取_媒体信息 -Path $内部输出文件
        if ([int]$输出信息.StreamCount -ne $全局流序号) {
            throw ("输出轨道数量异常:期望 {0} 条,实际 {1} 条。输出文件:{2}" -f $全局流序号, $输出信息.StreamCount, $内部输出文件)
        }

        # -------- 交付 --------
        if ($内部输出文件 -ne $用户输出文件) {
            Move-Item -LiteralPath $内部输出文件 -Destination $用户输出文件 -Force
            Write-Host ("已将结果文件命名为:{0}(注意:容器实际为 MKV)" -f $用户输出文件) -ForegroundColor Yellow
        }

        Write-Host ("完成:{0}" -f $用户输出文件) -ForegroundColor Green
        Write-Host ("共叠加 {0} 条轨道(时长 {1:F3}s)。同名类型轨道可在播放器内手动切换。" -f $全局流序号, $输出信息.Duration) -ForegroundColor DarkCyan

        if ($详细模式) {
            Write-Host "已保留临时目录:$工作目录" -ForegroundColor Yellow
        }
    } finally {
        if (-not $详细模式) {
            try { if (Test-Path -LiteralPath $工作目录) { Remove-Item -LiteralPath $工作目录 -Recurse -Force -ErrorAction SilentlyContinue } } catch { }
        }
    }

    $global:LASTEXITCODE = 0
}