public/压缩到指定大小.ps1
|
function 压缩到指定大小 { <# .SYNOPSIS 将视频文件重编码到指定大小,视频轨统一转为 HEVC,其余轨道原样保留。 .DESCRIPTION 采用二次编码(two-pass)方法压缩视频:先全片预分析一遍(Pass 1),再按计算出的目标码率正式编码(Pass 2),使输出文件体积尽量贴近指定的目标大小。只重编码第一个视频轨;音频、字幕、附件等其它轨道全部以流复制方式原样搬进输出,不做任何改动。 HEVC 编码器优先使用 NVIDIA 显卡的 hevc_nvenc;本机没有可用的 NVENC 时回退到 CPU libx265。输出容器固定为 MKV,保证任意轨道都能顺利复用。输出文件已存在时会被覆盖。 码率预算 = (目标大小 - 容器预留富余 - 非视频轨字节数) × 8 ÷ 时长,其中非视频轨字节数通过 ffprobe 逐包统计,保证预算准确。 .PARAMETER 输入文件 要压缩的视频文件路径,必须存在且至少包含一个视频轨。 .PARAMETER 输出文件 输出文件路径。省略时在输入文件同目录生成"原文件名_压缩.mkv"。 .PARAMETER 目标大小 期望的输出文件大小,支持 700MB、1.5G、512000KB、104857600 等写法。输出会略小于该值。 .EXAMPLE 压缩到指定大小 -输入文件 'D:\电影.mkv' -目标大小 700MB 将 D:\电影.mkv 压缩为 D:\电影_压缩.mkv,目标体积 700MB,视频轨转为 HEVC。 .EXAMPLE 压缩到指定大小 -输入文件 'D:\电影.mkv' -输出文件 'D:\out.mkv' -目标大小 2G 压缩到 2GB 并指定输出路径。 .NOTES 依赖 ffmpeg 和 ffprobe。无 NVIDIA NVENC 时使用 libx265 CPU 编码,速度会明显变慢。 视频轨已经是 HEVC 时也会重新编码,以便达到目标大小。 #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0, HelpMessage = '要压缩的视频文件路径')] [string]$输入文件, [Parameter(Position = 1)] [string]$输出文件, [Parameter(Mandatory, Position = 2, HelpMessage = '目标文件大小,如 700MB、1.5G')] [string]$目标大小 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:输出外部进程信息 = $false $输入文件 = 规范化_用户路径参数 -Path $输入文件 if ($输出文件) { $输出文件 = 规范化_用户路径参数 -Path $输出文件 } $目标大小 = 规范化_用户路径参数 -Path $目标大小 确保_ffmpeg_ffprobe_可用 if (-not (Test-Path -LiteralPath $输入文件)) { throw "文件不存在: $输入文件" } $输入绝对路径 = (Resolve-Path -LiteralPath $输入文件).Path # ── 解析目标大小 ────────────────────────────────────────────── $目标字节 = 解析_大小文本到字节 -文本 $目标大小 if ($目标字节 -lt 1MB) { throw '目标大小过小(最小限制 1MB),请重新指定。' } # ── 探测输入 ────────────────────────────────────────────────── $探测结果 = 获取_媒体流探测数据 -Path $输入绝对路径 $全部轨道 = @($探测结果.streams) $视频轨列表 = @($全部轨道 | Where-Object { $_.codec_type -eq 'video' }) if ($视频轨列表.Count -eq 0) { throw "输入文件没有视频轨: $输入文件" } $视频轨 = $视频轨列表[0] $视频序号 = [int]$视频轨.index $视频宽 = [int]$视频轨.width $视频高 = [int]$视频轨.height $源视频编码 = [string]$视频轨.codec_name # 时长:优先用视频轨自带 duration,其次用 format.duration $时长秒 = 0.0 $轨时长属性 = 取_对象属性值 -Obj $视频轨 -Name 'duration' if ($轨时长属性) { try { $时长秒 = [double]$轨时长属性 } catch { $时长秒 = 0.0 } } if ($时长秒 -le 0) { $时长秒 = [double](获取_媒体信息 -Path $输入绝对路径).Duration } if ($时长秒 -le 0) { throw "无法从输入文件获取时长: $输入文件" } # 统计非视频轨(音频+字幕+附件等)的总字节数,用于码率预算 $非视频轨序号 = @($全部轨道 | Where-Object { $_.codec_type -ne 'video' } | ForEach-Object { [int]$_.index }) $非视频轨字节 = [int64]0 if ($非视频轨序号.Count -gt 0) { $包统计 = 取_按流序号统计包字节数 -Path $输入绝对路径 -StreamIndices $非视频轨序号 foreach ($序 in $非视频轨序号) { if ($包统计.ContainsKey($序)) { $非视频轨字节 += [int64]$包统计[$序] } } } # ── 选择 HEVC 编码器:NVIDIA hevc_nvenc 优先,无则 CPU libx265 ── $ffmpeg路径 = 解决_工具执行路径 -工具名 'ffmpeg' if (测试_ffmpeg视频编码器可用 -FfmpegPath $ffmpeg路径 -编码器名称 'hevc_nvenc') { $使用gpu = $true $编码器名 = 'hevc_nvenc' } else { $使用gpu = $false $编码器名 = 'libx265' } # ── 计算视频目标码率 ───────────────────────────────────────── $容器富余字节 = [int64]131072 # MKV 头/SeekHead/元数据等预留 $可用视频字节 = $目标字节 - $非视频轨字节 - $容器富余字节 if ($可用视频字节 -le 0) { throw ("目标大小 {0} 过小:仅非视频轨就占 {1},装不下任何视频码率。" -f (格式化_字节到文本 $目标字节), (格式化_字节到文本 $非视频轨字节)) } $视频目标bps = [int64]([math]::Floor(($可用视频字节 * 8.0) / $时长秒)) if ($视频目标bps -lt [int64]32000) { $视频目标bps = [int64]32000 } # 保底 32kbps $视频目标kbps = [int64]([math]::Floor($视频目标bps / 1000.0)) # ── 默认输出路径 ───────────────────────────────────────────── if (-not $输出文件) { $目录 = [System.IO.Path]::GetDirectoryName($输入绝对路径) $基础名 = [System.IO.Path]::GetFileNameWithoutExtension($输入绝对路径) $输出文件 = Join-Path $目录 "${基础名}_压缩.mkv" } $输出绝对路径 = [System.IO.Path]::GetFullPath($输出文件) if ($输出绝对路径 -ieq $输入绝对路径) { throw '输出文件不能与输入文件相同,请换一个输出路径。' } # 二次编码统计日志统一放临时目录(Pass 1/2 共用),结束后清理 $临时目录 = Join-Path ([System.IO.Path]::GetTempPath()) ("视频工坊压缩_" + [Guid]::NewGuid().ToString('N')) $passlog前缀 = Join-Path $临时目录 'ffmpeg2pass' New-Item -ItemType Directory -Path $临时目录 -Force | Out-Null # ── 打印摘要 ───────────────────────────────────────────────── Write-Host '' Write-Host '压缩到指定大小(二次编码 / HEVC)' -ForegroundColor Cyan Write-Host (" 输入 : {0}" -f $输入绝对路径) Write-Host (" 视频轨 #{0} : {1} {2}x{3} 时长 {4}s" -f $视频序号, $源视频编码, $视频宽, $视频高, ([math]::Round($时长秒, 1))) Write-Host (" 其它轨道 : {0} 条,全部原样复制(共 {1})" -f $非视频轨序号.Count, (格式化_字节到文本 $非视频轨字节)) if ($使用gpu) { Write-Host (" 编码器 : {0}(NVIDIA GPU)" -f $编码器名) } else { Write-Host (" 编码器 : {0}(CPU,未检测到 NVIDIA NVENC)" -f $编码器名) } Write-Host (" 视频目标码率 : {0} kbps" -f ([math]::Round($视频目标bps / 1000.0, 1))) Write-Host (" 目标大小 : {0}({1})" -f $目标大小, (格式化_字节到文本 $目标字节)) Write-Host (" 输出 : {0}" -f $输出绝对路径) Write-Host '' # ── 组装两遍编码参数 ───────────────────────────────────────── if ($使用gpu) { $视频公共参数 = @( '-c:v', 'hevc_nvenc', '-preset', 'p4', '-tune', 'hq', '-b:v', "$视频目标bps", '-maxrate', "$视频目标bps", '-bufsize', "$($视频目标bps * 2)", '-passlogfile', $passlog前缀 ) $pass1参数 = @('-hide_banner', '-nostdin', '-y', '-i', $输入绝对路径) + $视频公共参数 + @('-pass', '1', '-map', '0:v:0', '-an', '-sn', '-dn', '-f', 'null', 'NUL') $pass2视频参数 = $视频公共参数 + @('-pass', '2') } else { $x265统计文件 = Join-Path $临时目录 'x265_stats.log' $视频公共参数 = @( '-c:v', 'libx265', '-preset', 'medium', '-b:v', "$视频目标bps" ) $pass1参数 = @('-hide_banner', '-nostdin', '-y', '-i', $输入绝对路径) + $视频公共参数 + @('-x265-params', ("pass=1:bitrate={0}:stats={1}" -f $视频目标kbps, $x265统计文件), '-map', '0:v:0', '-an', '-sn', '-dn', '-f', 'null', 'NUL') $pass2视频参数 = $视频公共参数 + @('-x265-params', ("pass=2:bitrate={0}:stats={1}" -f $视频目标kbps, $x265统计文件)) } $pass2参数 = @('-hide_banner', '-nostdin', '-y', '-i', $输入绝对路径) + $pass2视频参数 + @('-map', '0', '-c:a', 'copy', '-c:s', 'copy', '-c:d', 'copy', $输出绝对路径) try { # Pass 1:预分析 Write-Host 'Pass 1 / 2 预分析中...' -ForegroundColor DarkYellow $旧错误偏好 = $ErrorActionPreference $ErrorActionPreference = 'SilentlyContinue' & $ffmpeg路径 @pass1参数 $退出码 = $LASTEXITCODE $ErrorActionPreference = $旧错误偏好 if ($退出码 -ne 0) { throw "压缩失败:Pass 1 预分析出错(退出码 $退出码)" } # Pass 2:正式编码输出(libx265 会在当前目录写临时分块文件,切换到临时目录避免污染) Write-Host 'Pass 2 / 2 正式编码中...' -ForegroundColor DarkYellow if ($使用gpu) { $ErrorActionPreference = 'SilentlyContinue' & $ffmpeg路径 @pass2参数 $退出码 = $LASTEXITCODE $ErrorActionPreference = $旧错误偏好 } else { $旧目录 = (Get-Location).Path try { Set-Location -LiteralPath $临时目录 $ErrorActionPreference = 'SilentlyContinue' & $ffmpeg路径 @pass2参数 $退出码 = $LASTEXITCODE $ErrorActionPreference = $旧错误偏好 } finally { Set-Location -LiteralPath $旧目录 } } if ($退出码 -ne 0) { throw "压缩失败:Pass 2 正式编码出错(退出码 $退出码)" } } finally { Remove-Item -LiteralPath $临时目录 -Recurse -Force -ErrorAction SilentlyContinue } # ── 完成,校验输出大小 ──────────────────────────────────────── $实际字节 = (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 (格式化_字节到文本 $实际字节), (格式化_字节到文本 $目标字节), $达成比例) if ($实际字节 -gt $目标字节) { Write-Warning ("输出超出目标大小约 {0},可尝试再降低目标大小后重试。" -f (格式化_字节到文本 ($实际字节 - $目标字节))) } } |