@echo off rem ========================================================================== rem rem ffcompress 2.2 - video compression for Telegram and Discord. rem Supports NVIDIA (NVENC), AMD (AMF), Intel (QSV) and CPU. rem rem THIS FILE IS PLAIN TEXT. You are reading it right now. rem Below the exit line there are three PowerShell scripts, in plain text, rem with comments. There is no base64, no encryption, no compression and rem no compiled program hidden inside. You can read all of it. rem rem What happens when you run it: rem 1. The installer prints exactly what it is going to do and asks first. rem 2. It downloads FFmpeg from the official build page on GitHub. rem 3. It puts files into your AppData folder, no administrator rights. rem 4. It adds an entry to the Explorer right-click menu. rem 5. It tests itself on a generated clip and deletes that clip. rem rem Administrator rights are NOT requested. Nothing is written to rem C:\Windows, Program Files or any other system folder. No autostart rem entry, no service and no scheduled task is created. Uninstall is the rem normal one, through Settings, Apps. rem rem Why ExecutionPolicy Bypass: Windows blocks running PowerShell scripts rem from a file by default. The switch lifts that block for this one run rem only and changes nothing in the system. rem rem Source of FFmpeg: https://github.com/BtbN/FFmpeg-Builds/releases rem rem ========================================================================== setlocal EnableExtensions chcp 65001 >nul 2>&1 title ffcompress setup set "PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" set "FFC_SELF=%~f0" set "TMPPS=%TEMP%\ffcompress-setup.ps1" if not exist "%PS%" goto nops if exist "%TMPPS%" del "%TMPPS%" >nul 2>&1 rem Copy the first section (the installer itself) into a file and run it. "%PS%" -NoProfile -ExecutionPolicy Bypass -Command "$e=New-Object Text.UTF8Encoding $false; $L=[IO.File]::ReadAllLines($env:FFC_SELF,$e); $a=[Array]::IndexOf($L,'#=== FFCOMPRESS-INSTALLER ==='); $b=[Array]::IndexOf($L,'#=== FFCOMPRESS-ENGINE ==='); if($a -lt 0 -or $b -le $a){exit 1}; $t=($L[($a+1)..($b-1)] -join ([char]13+[char]10)); [IO.File]::WriteAllText($env:TMPPS,$t,(New-Object Text.UTF8Encoding $true)); exit 0" rem Arguments are passed through, so "ffcompress-install.cmd -Yes" installs rem without asking. Without arguments it always asks first. if not exist "%TMPPS%" goto fail "%PS%" -NoProfile -ExecutionPolicy Bypass -File "%TMPPS%" %* del "%TMPPS%" >nul 2>&1 goto done :nops echo. echo PowerShell was not found. Windows 10 or 11 is required. echo. pause goto done :fail echo. echo Could not read the installer section from this file. echo Make sure it was downloaded completely and not modified. echo. pause :done endlocal exit /b #=== FFCOMPRESS-INSTALLER === <# ffcompress — установщик (v2.2). Ставится в папку профиля пользователя, права администратора не нужны. Движок и деинсталлятор лежат открытым текстом в этом же .cmd-файле, в разделах после маркеров — установщик просто достаёт их оттуда. Поддерживает аппаратное ускорение NVIDIA (NVENC), AMD (AMF), Intel (QSV) и софтверный фоллбэк на CPU (libx264). #> [CmdletBinding()] param( # Пропустить вопрос о согласии (нужно для автоматической проверки). [switch] $Yes ) $ErrorActionPreference = 'Stop' try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } try { $Host.UI.RawUI.WindowTitle = 'ffcompress setup' } catch { } $script:LogLines = New-Object Collections.Generic.List[string] function Say([string]$text = '', [string]$color = 'Gray') { Write-Host $text -ForegroundColor $color $script:LogLines.Add($text) } # ------------------------------------------------------------- настройки ---- $VERSION = '2.2' $APP = Join-Path $env:LOCALAPPDATA 'ffcompress' $FF_PAGE = 'https://github.com/GyanD/codexffmpeg/releases/tag/4.4.1' $FF_URL = 'https://github.com/GyanD/codexffmpeg/releases/download/4.4.1/ffmpeg-4.4.1-essentials_build.zip' $EXTS = @( '.mp4', '.mkv', '.mov', '.avi', '.webm', '.m4v', '.wmv', '.flv', '.ts', '.m2ts', '.mts', '.mpg', '.mpeg', '.3gp', '.ogv' ) $LEGACY_EXTS = @('.avi', '.flac', '.mkv', '.mov', '.mp4', '.wav', '.weba', '.webm') $OLD_VERBS = @('Сжать', 'Перевод в MP4') $MENU_KEY = 'ffcompress' $ITEMS = @( @{ Key = '01_balanced'; Label = 'Balanced — 1080p60, для Telegram'; Preset = 'balanced' } @{ Key = '02_quality'; Label = 'Quality — родное разрешение и fps'; Preset = 'quality' } @{ Key = '03_performance'; Label = 'Performance — 720p30, минимум веса'; Preset = 'performance' } @{ Key = '04_discord10'; Label = 'Discord — вписать в 10 МБ'; Preset = 'discord10' } @{ Key = '05_discord25'; Label = 'Discord Nitro — вписать в 25 МБ'; Preset = 'discord25' } @{ Key = '06_discord50'; Label = 'Discord Nitro — вписать в 50 МБ'; Preset = 'discord50' } ) $MARK_ENGINE = '#=== FFCOMPRESS-ENGINE ===' $MARK_UNINSTALL = '#=== FFCOMPRESS-UNINSTALLER ===' $MARK_END = '#=== FFCOMPRESS-END ===' # ------------------------------------------------------------ оформление ---- $DOT = [string][char]0x00B7 $LINE = [string][char]0x2500 $BLOCK = [string][char]0x2588 $SHADE = [string][char]0x2591 function Get-Width { try { $w = [Console]::WindowWidth; if ($w -gt 20) { return $w } } catch { } return 100 } function Write-Rule { Say (' ' + $LINE * ([Math]::Min((Get-Width) - 4, 70))) 'DarkGray' } function Write-Step([string]$t) { Say ''; Say " $t" 'Cyan' } function Write-Ok([string]$t) { Say " [+] $t" 'Green' } function Write-Info([string]$t) { Say " $t" 'DarkGray' } function Write-Warn([string]$t) { Say " [!] $t" 'Yellow' } function Write-Bad([string]$t) { Say " [x] $t" 'Red' } function Save-Log { try { if (-not (Test-Path -LiteralPath $APP)) { return } $head = @( "ffcompress $VERSION - installation log", 'Everything the installer did, in the order it happened.', '' ) [IO.File]::WriteAllLines((Join-Path $APP 'install-log.txt'), ($head + $script:LogLines), (New-Object Text.UTF8Encoding($true))) } catch { } } function Stop-Install([string]$msg) { Say '' Write-Bad $msg Say '' Save-Log try { Read-Host ' Press Enter to close' | Out-Null } catch { Start-Sleep 20 } exit 1 } function Format-Size([double]$b) { if ($b -ge 1GB) { return ('{0:N2} GB' -f ($b / 1GB)) } if ($b -ge 1MB) { return ('{0:N0} MB' -f ($b / 1MB)) } return ('{0:N0} KB' -f ($b / 1KB)) } function Show-Progress([int]$pct, [string]$tail) { $barW = [Math]::Max(10, [Math]::Min(30, (Get-Width) - 44)) $fill = [int][Math]::Round($barW * $pct / 100.0) $bar = $BLOCK * $fill + $SHADE * ($barW - $fill) $line = " [$bar] {0,3}% $tail" -f $pct [Console]::Write("`r" + $line.PadRight((Get-Width) - 2)) } function Get-FileWithProgress([string]$url, [string]$dest) { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 $req = [Net.HttpWebRequest]::Create($url) $req.UserAgent = 'ffcompress-installer' $req.Timeout = 60000 $resp = $req.GetResponse() $final = $resp.ResponseUri.Scheme + '://' + $resp.ResponseUri.Host + $resp.ResponseUri.AbsolutePath $total = $resp.ContentLength $in = $resp.GetResponseStream() $out = [IO.File]::Create($dest) try { $buf = New-Object byte[] 262144 $done = 0L $last = -1 while (($n = $in.Read($buf, 0, $buf.Length)) -gt 0) { $out.Write($buf, 0, $n) $done += $n if ($total -gt 0) { $pct = [int](100 * $done / $total) if ($pct -ne $last) { Show-Progress $pct ("{0} of {1}" -f (Format-Size $done), (Format-Size $total)) $last = $pct } } } } finally { $out.Dispose(); $in.Dispose(); $resp.Dispose() [Console]::Write("`r" + (' ' * ((Get-Width) - 1)) + "`r") } return $final } function Test-FfPair([string]$ff, [string]$fp) { try { $enc = & $ff -hide_banner -encoders if ($LASTEXITCODE -ne 0) { return $false } $null = & $fp -v error -show_entries format=duration -of csv=p=0 -f lavfi -i 'testsrc2=duration=1' return $true } catch { return $false } } function Get-Section([string]$file, [string]$from, [string]$to, [string]$what) { $lines = [IO.File]::ReadAllLines($file, (New-Object Text.UTF8Encoding($false))) $i = [Array]::IndexOf($lines, $from) $j = [Array]::IndexOf($lines, $to) if ($i -lt 0 -or $j -lt 0 -or $j -le ($i + 1)) { Stop-Install "section '$what' not found in the setup file. Download it again, it is damaged." } return ($lines[($i + 1)..($j - 1)] -join "`r`n") } function Write-ScriptFile([string]$text, [string]$dest) { [IO.File]::WriteAllText($dest, $text, (New-Object Text.UTF8Encoding($true))) } # ========================================================== начало ==== $SELF = $env:FFC_SELF if (-not $SELF -or -not (Test-Path -LiteralPath $SELF)) { Say '' Write-Bad 'Run ffcompress-install.cmd, not this file on its own.' Say '' try { Read-Host ' Press Enter to close' | Out-Null } catch { Start-Sleep 20 } exit 1 } try { Clear-Host } catch { } Say '' Write-Host ' ffcompress' -ForegroundColor Cyan -NoNewline Say " $DOT version $VERSION" 'DarkGray' Write-Rule # --------------------------------------------------- 1. проверка системы ---- Write-Step '1. Checking the system' if ([Environment]::OSVersion.Version.Major -lt 10) { Stop-Install 'Windows 10 or 11 is required.' } Write-Ok ('Windows ' + [Environment]::OSVersion.Version) if ($PSVersionTable.PSVersion.Major -lt 5) { Stop-Install 'PowerShell 5.1 or newer is required.' } Write-Ok ('PowerShell ' + $PSVersionTable.PSVersion) $gpus = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue) $gpuNames = ($gpus | ForEach-Object { $_.Name }) -join ', ' Write-Ok "Graphics: $gpuNames" if ((Test-Path -LiteralPath $APP)) { Write-Info 'a previous installation was found - it will be updated in place' } # ------------------------------------------ 2. читаем разделы из .cmd ---- Write-Step '2. Reading the bundled scripts' $engineText = Get-Section $SELF $MARK_ENGINE $MARK_UNINSTALL 'engine' $uninstallText = Get-Section $SELF $MARK_UNINSTALL $MARK_END 'uninstaller' Write-Ok ('engine {0:N0} lines, uninstaller {1:N0} lines' -f ($engineText -split "`r`n").Count, ($uninstallText -split "`r`n").Count) # ---------------------------------------------- 3. ffmpeg и ffprobe ---- Write-Step '3. Installing a fresh FFmpeg' $FF = Join-Path $APP 'ffmpeg.exe' $FP = Join-Path $APP 'ffprobe.exe' $drive = ([IO.Path]::GetPathRoot($APP)).TrimEnd('\') $free = (Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'").FreeSpace if ($free -lt 600MB) { Stop-Install ("at least 600 MB is needed on $drive, free: " + (Format-Size $free)) } New-Item -ItemType Directory -Force $APP | Out-Null Write-Info $FF_URL $zip = Join-Path $env:TEMP ('ffc_' + [Guid]::NewGuid().ToString('N') + '.zip') try { $finalUrl = Get-FileWithProgress $FF_URL $zip $zi = Get-Item -LiteralPath $zip $hash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash Write-Ok ('downloaded ' + (Format-Size $zi.Length) + ' from ' + ([Uri]$finalUrl).Host) Write-Info ('SHA-256 ' + $hash) Add-Type -AssemblyName System.IO.Compression.FileSystem $arc = [IO.Compression.ZipFile]::OpenRead($zip) try { foreach ($exe in 'ffmpeg.exe', 'ffprobe.exe') { $e = $arc.Entries | Where-Object { $_.FullName -like "*/bin/$exe" } | Select-Object -First 1 if (-not $e) { throw "$exe is not in the archive" } [IO.Compression.ZipFileExtensions]::ExtractToFile($e, (Join-Path $APP $exe), $true) } } finally { $arc.Dispose() } } catch { Stop-Install ('could not get FFmpeg: ' + $_.Exception.Message) } finally { if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue } } if (-not (Test-FfPair $FF $FP)) { Stop-Install 'the downloaded FFmpeg failed the check.' } Write-Ok (& $FF -hide_banner -version | Select-Object -First 1) # ------------------------------------------- 4. проверка аппаратных энкодеров ---- Write-Step '4. Testing hardware acceleration (NVENC / AMF / QSV)' $hwType = 'cpu' $test = Join-Path $env:TEMP ('ffc_hw_' + [Guid]::NewGuid().ToString('N') + '.mp4') # 1. NVIDIA NVENC try { $null = & $FF -hide_banner -loglevel fatal -y -f lavfi -i 'testsrc2=size=320x240:rate=30' ` -t 1 -c:v h264_nvenc -preset p4 -pix_fmt yuv420p $test 2>&1 if (($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $test) -and ((Get-Item -LiteralPath $test).Length -gt 0)) { $hwType = 'nvenc' } } catch { } finally { if (Test-Path -LiteralPath $test) { Remove-Item -LiteralPath $test -Force -ErrorAction SilentlyContinue } } # 2. AMD AMF if ($hwType -eq 'cpu') { try { $null = & $FF -hide_banner -loglevel fatal -y -f lavfi -i 'testsrc2=size=320x240:rate=30' ` -t 1 -c:v h264_amf -pix_fmt yuv420p $test 2>&1 if (($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $test) -and ((Get-Item -LiteralPath $test).Length -gt 0)) { $hwType = 'amf' } } catch { } finally { if (Test-Path -LiteralPath $test) { Remove-Item -LiteralPath $test -Force -ErrorAction SilentlyContinue } } } # 3. Intel QSV if ($hwType -eq 'cpu') { try { $null = & $FF -hide_banner -loglevel fatal -y -f lavfi -i 'testsrc2=size=320x240:rate=30' ` -t 1 -c:v h264_qsv -pix_fmt nv12 $test 2>&1 if (($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $test) -and ((Get-Item -LiteralPath $test).Length -gt 0)) { $hwType = 'qsv' } } catch { } finally { if (Test-Path -LiteralPath $test) { Remove-Item -LiteralPath $test -Force -ErrorAction SilentlyContinue } } } switch ($hwType) { 'nvenc' { Write-Ok 'NVIDIA NVENC active — hardware acceleration enabled on GPU' } 'amf' { Write-Ok 'AMD AMF active — hardware acceleration enabled on Radeon GPU' } 'qsv' { Write-Ok 'Intel QuickSync (QSV) active — hardware acceleration enabled' } default { Write-Warn 'No hardware encoder found — will encode via CPU (libx264)' } } # --------------------------------------------------------- 5. установка ---- Write-Step '5. Installing the program' Write-ScriptFile $engineText (Join-Path $APP 'Compress.ps1') Write-ScriptFile $uninstallText (Join-Path $APP 'Uninstall.ps1') Write-Ok "files are in place: $APP" # ------------------------------------------------------ 6. меню в реестре ---- Write-Step '6. Adding the right-click menu entries' $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $engine = Join-Path $APP 'Compress.ps1' $base = 'HKCU:\SOFTWARE\Classes\SystemFileAssociations' try { foreach ($ext in ($LEGACY_EXTS + $EXTS | Select-Object -Unique)) { foreach ($old in $OLD_VERBS) { $k = "$base\$ext\shell\$old" if (Test-Path -LiteralPath $k) { Remove-Item -LiteralPath $k -Recurse -Force } } } foreach ($ext in $EXTS) { $menu = "$base\$ext\shell\$MENU_KEY" if (Test-Path -LiteralPath $menu) { Remove-Item -LiteralPath $menu -Recurse -Force } New-Item -Path $menu -Force | Out-Null Set-ItemProperty -LiteralPath $menu -Name 'MUIVerb' -Value 'Сжать видео' Set-ItemProperty -LiteralPath $menu -Name 'Icon' -Value 'imageres.dll,135' Set-ItemProperty -LiteralPath $menu -Name 'subcommands' -Value '' foreach ($it in $ITEMS) { $itemKey = "$menu\shell\$($it.Key)" New-Item -Path $itemKey -Force | Out-Null Set-ItemProperty -LiteralPath $itemKey -Name 'MUIVerb' -Value $it.Label $cmdKey = "$itemKey\command" New-Item -Path $cmdKey -Force | Out-Null $cmd = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}" -Preset {2} -Path "%1"' -f $psExe, $engine, $it.Preset Set-ItemProperty -LiteralPath $cmdKey -Name '(default)' -Value $cmd } } } catch { Stop-Install ('could not write the menu entries: ' + $_.Exception.Message) } $made = 0 foreach ($ext in $EXTS) { $sub = "$base\$ext\shell\$MENU_KEY\shell" if (Test-Path -LiteralPath $sub) { $made += @(Get-ChildItem -LiteralPath $sub).Count } } $expect = $EXTS.Count * $ITEMS.Count if ($made -ne $expect) { Stop-Install "only $made of $expect menu entries were written." } Write-Ok "submenu on $($EXTS.Count) video extensions, $($ITEMS.Count) presets each" # --------------------------------------- запись для удаления программ ---- $unKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ffcompress' if (-not (Test-Path -LiteralPath $unKey)) { New-Item -Path $unKey -Force | Out-Null } $size = 0 Get-ChildItem -LiteralPath $APP -File | ForEach-Object { $size += $_.Length } $props = @{ DisplayName = 'ffcompress - video compression for Telegram and Discord' DisplayVersion = $VERSION DisplayIcon = (Join-Path $env:SystemRoot 'System32\imageres.dll') + ',135' InstallLocation = $APP UninstallString = '"' + $psExe + '" -NoProfile -ExecutionPolicy Bypass -File "' + (Join-Path $APP 'Uninstall.ps1') + '"' EstimatedSize = [int]($size / 1KB) NoModify = 1 NoRepair = 1 } foreach ($k in $props.Keys) { Set-ItemProperty -LiteralPath $unKey -Name $k -Value $props[$k] } Write-Ok 'registered in Settings -> Apps, so it uninstalls the normal way' # ---------------------------------------------------------- 7. самотест ---- Write-Step '7. Self-test: running all presets' $tdir = Join-Path $env:TEMP ('ffc_test_' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Force $tdir | Out-Null $clip = Join-Path $tdir 'test.mp4' $results = @() try { $genArgs = @('-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', '-t', '2', '-shortest', '-pix_fmt', 'yuv420p') if ($hwType -eq 'nvenc') { $genArgs += @('-c:v', 'h264_nvenc', '-b:v', '10M') } elseif ($hwType -eq 'amf') { $genArgs += @('-c:v', 'h264_amf', '-b:v', '10M') } elseif ($hwType -eq 'qsv') { $genArgs += @('-c:v', 'h264_qsv', '-b:v', '10M') } else { $genArgs += @('-c:v', 'libx264', '-crf', '20', '-preset', 'ultrafast') } $genArgs += @('-c:a', 'aac', $clip) $null = & $FF @genArgs if (-not (Test-Path -LiteralPath $clip)) { throw 'could not create the test clip' } foreach ($it in $ITEMS) { $p = $it.Preset $outFile = Join-Path $tdir ("test_$p.mp4") $null = & $psExe -NoProfile -ExecutionPolicy Bypass -File $engine -Preset $p -NoPause -Path $clip $code = $LASTEXITCODE $bad = @() $mb = 0 if ($code -ne 0 -or -not (Test-Path -LiteralPath $outFile)) { $bad += "no file produced (exit $code)" } else { $len = (Get-Item -LiteralPath $outFile).Length $mb = [Math]::Round($len / 1MB, 2) if ($len -le 0) { $bad += 'empty file' } } $results += [pscustomobject]@{ Preset = $p; Mb = $mb; Bad = ($bad -join ', ') } } } catch { $results += [pscustomobject]@{ Preset = 'general'; Mb = 0; Bad = $_.Exception.Message } } finally { if (Test-Path -LiteralPath $tdir) { Remove-Item -LiteralPath $tdir -Recurse -Force -ErrorAction SilentlyContinue } } $failed = @($results | Where-Object { $_.Bad }) foreach ($r in $results) { if (-not $r.Bad) { Write-Ok ('{0,-14} {1,6} MB' -f $r.Preset, $r.Mb) } else { Write-Bad ('{0,-14} {1}' -f $r.Preset, $r.Bad) } } # ------------------------------------------------------------- 8. итог ---- Say '' Write-Rule if ($failed.Count -eq 0) { Say ' Done. Installed and verified.' 'Green' } else { Say ' Installed, but the self-test found problems (see above).' 'Yellow' } Say '' Say ' Right-click a video -> "Show more options" -> "Сжать видео"' Say '' Say ' Balanced 1080p60, streams smoothly in Telegram' 'Gray' Say ' Quality original resolution and fps' 'Gray' Say ' Performance 720p30, smallest file' 'Gray' Say ' Discord 10 MB fit into standard 10 MB limit' 'Gray' Say ' Discord 25 MB fit into Nitro 25 MB limit' 'Gray' Say ' Discord 50 MB fit into Nitro 50 MB limit' 'Gray' Say '' Say ' Result lands next to original, copies to clipboard & sends Toast alert.' Say '' Say " Installed in $APP, full log in install-log.txt there." 'DarkGray' Say ' Uninstall: Settings -> Apps -> ffcompress' 'DarkGray' Say '' Write-Rule Save-Log Say '' if (-not $Yes) { try { Read-Host ' Press Enter to close' | Out-Null } catch { Start-Sleep 30 } } #=== FFCOMPRESS-ENGINE === <# ffcompress 2.2 — сжатие видео для Telegram и Discord. Поддержка NVIDIA (NVENC), AMD (AMF), Intel (QSV) и софтверного кодирования. Пресеты: Balanced, Quality, Performance, Discord (10 / 25 / 50 МБ). Очистка метаданных (-map_metadata -1), параллельная очередь на 2 потока и всплывающие Toast-уведомления Windows. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [string] $Path, [ValidateSet('balanced', 'quality', 'performance', 'discord10', 'discord25', 'discord50', 'discord')] [string] $Preset = 'balanced', [switch] $NoPause ) if ($Preset -eq 'discord') { $Preset = 'discord10' } $ErrorActionPreference = 'Stop' try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } try { $Host.UI.RawUI.WindowTitle = 'ffcompress' } catch { } # ---------------------------------------------------------------- пресеты ---- $PRESETS = @{ 'balanced' = @{ Title = 'Balanced - 1080p60, smooth in Telegram' Mode = 'cq'; BoxW = 1920; BoxH = 1080; Fps = 60 Cq = 28; Nv = 'medium'; MaxRate = '8M'; BufSize = '8M' Audio = 128; Suffix = '_balanced' } 'quality' = @{ Title = 'Quality - original resolution and fps' Mode = 'cq'; BoxW = 0; BoxH = 0; Fps = 0 Cq = 30; Nv = 'slow'; MaxRate = '12M'; BufSize = '12M' Audio = 192; Suffix = '_quality' } 'performance' = @{ Title = 'Performance - 720p30, smallest size' Mode = 'cq'; BoxW = 1280; BoxH = 720; Fps = 30 Cq = 31; Nv = 'fast'; MaxRate = '3M'; BufSize = '3M' Audio = 96; Suffix = '_performance' } 'discord10' = @{ Title = 'Discord - fit into 10 MB' Mode = 'target'; TargetBytes = 9700000; HardLimit = 9950000; LimitLabel = '10 MB' Nv = 'medium'; Suffix = '_discord10' } 'discord25' = @{ Title = 'Discord Nitro - fit into 25 MB' Mode = 'target'; TargetBytes = 24400000; HardLimit = 24950000; LimitLabel = '25 MB' Nv = 'medium'; Suffix = '_discord25' } 'discord50' = @{ Title = 'Discord Nitro - fit into 50 MB' Mode = 'target'; TargetBytes = 48800000; HardLimit = 49900000; LimitLabel = '50 MB' Nv = 'medium'; Suffix = '_discord50' } } $P = $PRESETS[$Preset] $LBL = 9 # --------------------------------------------------------------- утилиты ---- function Get-Width { try { $w = [Console]::WindowWidth if ($w -gt 20) { return $w } } catch { } return 100 } function Write-Rule { Write-Host (' ' + ([string][char]0x2500) * ([Math]::Min((Get-Width) - 4, 66))) -ForegroundColor DarkGray } function Write-Row([string]$label, [string]$value, [string]$color = 'Gray') { Write-Host (' ' + $label.PadRight($LBL)) -ForegroundColor DarkGray -NoNewline Write-Host $value -ForegroundColor $color } function Stop-Fail([string]$message) { Write-Host '' Write-Host " Error: $message" -ForegroundColor Red Write-Host '' if (-not $NoPause) { try { Read-Host ' Press Enter to close' | Out-Null } catch { Start-Sleep -Seconds 20 } } exit 1 } function Format-Size([double]$bytes) { if ($bytes -ge 1GB) { return ('{0:N2} GB' -f ($bytes / 1GB)) } if ($bytes -ge 1MB) { return ('{0:N1} MB' -f ($bytes / 1MB)) } return ('{0:N0} KB' -f ($bytes / 1KB)) } function Format-Clock([double]$seconds) { if ($seconds -lt 0) { $seconds = 0 } $ts = [TimeSpan]::FromSeconds([Math]::Round($seconds)) if ($ts.TotalHours -ge 1) { return ('{0}:{1:00}:{2:00}' -f [int]$ts.TotalHours, $ts.Minutes, $ts.Seconds) } return ('{0}:{1:00}' -f $ts.Minutes, $ts.Seconds) } function Get-EvenDown([double]$n) { $i = [int][Math]::Floor($n) if ($i % 2 -ne 0) { $i-- } if ($i -lt 2) { $i = 2 } return $i } function Get-EvenNear([double]$n) { $i = [int][Math]::Round($n / 2.0, [MidpointRounding]::AwayFromZero) * 2 if ($i -lt 2) { $i = 2 } return $i } function Get-FitSize([int]$w, [int]$h, [int]$bw, [int]$bh) { if ($w -le $bw -and $h -le $bh) { return @((Get-EvenDown $w), (Get-EvenDown $h)) } $k = [Math]::Min($bw / [double]$w, $bh / [double]$h) return @((Get-EvenNear ($w * $k)), (Get-EvenNear ($h * $k))) } function Get-Fps($stream) { foreach ($f in @($stream.avg_frame_rate, $stream.r_frame_rate)) { if ($f -and ($f -match '^(\d+)/(\d+)$') -and ([int]$Matches[2] -ne 0)) { $x = [int]$Matches[1] / [double][int]$Matches[2] if ($x -gt 0.1 -and $x -lt 1000) { return $x } } } return 0 } function Get-TargetPlan([int]$dw, [int]$dh, [double]$sfps, [double]$dur, [double]$targetBytes, [bool]$withAudio) { $totalKbps = ($targetBytes * 8.0 / $dur) / 1000.0 if ($withAudio) { if ($totalKbps -lt 200) { $a = 32 } elseif ($totalKbps -lt 800) { $a = 64 } elseif ($totalKbps -lt 2500) { $a = 96 } else { $a = 128 } } else { $a = 0 } $vKbps = [int](($totalKbps - $a) * 0.93) if ($vKbps -lt 80) { $vKbps = 80 } if ($sfps -lt 1) { $sfps = 30.0 } $ladder = @( @{ W = 1920; H = 1080; F = 60 }, @{ W = 1920; H = 1080; F = 30 }, @{ W = 1280; H = 720; F = 60 }, @{ W = 1280; H = 720; F = 30 }, @{ W = 960; H = 540; F = 30 }, @{ W = 854; H = 480; F = 30 }, @{ W = 640; H = 360; F = 30 } ) $MIN_BPP = 0.075 foreach ($step in $ladder) { $f = [Math]::Min([double]$step.F, $sfps) $fit = Get-FitSize $dw $dh $step.W $step.H $bpp = ($vKbps * 1000.0) / ($fit[0] * $fit[1] * $f) if ($bpp -ge $MIN_BPP) { return @{ W = $fit[0]; H = $fit[1]; F = $f; VKbps = $vKbps; AKbps = $a; Bpp = $bpp } } } $fit = Get-FitSize $dw $dh 640 360 $f = [Math]::Min(30.0, $sfps) if ($f -lt 1) { $f = 1 } return @{ W = $fit[0]; H = $fit[1]; F = $f; VKbps = $vKbps; AKbps = $a Bpp = ($vKbps * 1000.0) / ($fit[0] * $fit[1] * $f) } } function ConvertFrom-FfTime([string]$s) { if ($s -and ($s -match '^(\d+):(\d{2}):(\d{2}(?:\.\d+)?)$')) { return ([int]$Matches[1] * 3600) + ([int]$Matches[2] * 60) + [double]$Matches[3] } return -1 } $script:LastLen = 0 function Show-Encode([double]$done, [double]$total, [double]$speed, [double]$bytes) { $w = Get-Width $parts = @() if ($total -gt 0) { $pct = [Math]::Min(100.0, 100.0 * $done / $total) $barW = [Math]::Max(10, [Math]::Min(28, $w - 52)) $fill = [int][Math]::Round($barW * $pct / 100.0) $bar = ([string][char]0x2588) * $fill + ([string][char]0x2591) * ($barW - $fill) $parts += "[$bar]" $parts += ('{0,3:N0}%' -f $pct) if ($speed -gt 0.01 -and $pct -lt 99.5) { $parts += ('left ' + (Format-Clock (($total - $done) / $speed))) } $parts += ((Format-Clock $done) + '/' + (Format-Clock $total)) } else { $parts += ('done ' + (Format-Clock $done)) } if ($bytes -gt 0) { $parts += (Format-Size $bytes) } if ($speed -gt 0.01) { $parts += ('{0:N1}x' -f $speed) } $line = ' ' + ($parts -join ' ') if ($line.Length -gt ($w - 1)) { $line = $line.Substring(0, $w - 1) } $pad = [Math]::Max(0, $script:LastLen - $line.Length) [Console]::Write("`r" + $line + (' ' * $pad)) $script:LastLen = $line.Length } function Clear-EncodeLine { if ($script:LastLen -gt 0) { [Console]::Write("`r" + (' ' * $script:LastLen) + "`r") $script:LastLen = 0 } } function Send-ToastNotification([string]$title, [string]$message) { try { [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null $template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02 $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template) $textNodes = $xml.GetElementsByTagName("text") $textNodes.Item(0).AppendChild($xml.CreateTextNode($title)) | Out-Null $textNodes.Item(1).AppendChild($xml.CreateTextNode($message)) | Out-Null $toast = [Windows.UI.Notifications.ToastNotification]::new($xml) [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ffcompress").Show($toast) } catch { } try { [System.Media.SystemSounds]::Asterisk.Play() } catch { } } # ------------------------------------------------------------- подготовка ---- $binDir = $PSScriptRoot $FFMPEG = Join-Path $binDir 'ffmpeg.exe' $FFPROBE = Join-Path $binDir 'ffprobe.exe' if (-not (Test-Path -LiteralPath $FFMPEG)) { $FFMPEG = 'ffmpeg' } if (-not (Test-Path -LiteralPath $FFPROBE)) { $FFPROBE = 'ffprobe' } foreach ($tool in $FFMPEG, $FFPROBE) { if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { Stop-Fail "$tool not found. Reinstall ffcompress." } } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Stop-Fail "file not found: $Path" } $src = Get-Item -LiteralPath $Path $arrow = '->' Write-Host '' Write-Host " ffcompress $([char]0x00B7) $($P.Title)" -ForegroundColor Cyan Write-Rule Write-Row 'File' $src.Name 'White' # Семафор на 2 параллельных процесса кодирования $sem = $null $held = $false try { $sem = New-Object System.Threading.Semaphore(2, 2, 'Local\ffcompress_semaphore') } catch { $sem = $null } try { if (-not $sem) { Write-Row 'Queue' 'encoding directly (no queue)' 'DarkGray' } elseif ($sem.WaitOne(0)) { $held = $true } else { Write-Row 'Queue' 'slots full (max 2), waiting for free worker...' 'DarkYellow' $held = $sem.WaitOne() } # ------------------------------------------------------------- probe ---- $probe = & $FFPROBE -v error -show_streams -show_format -of json -i $src.FullName if ($LASTEXITCODE -ne 0 -or -not $probe) { Stop-Fail 'ffprobe could not read the file (corrupt, or not a video?)' } $info = ($probe -join '') | ConvertFrom-Json $v = $info.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1 if (-not $v) { Stop-Fail 'no video stream in this file' } $hasAudio = [bool]($info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1) $duration = 0.0 if ($info.format.duration -and ($info.format.duration -as [double])) { $duration = [double]$info.format.duration } $rot = 0 if ($v.side_data_list) { foreach ($sd in $v.side_data_list) { if ($null -ne $sd.rotation) { $rot = [int][Math]::Round([double]$sd.rotation) } } } $rot = ((($rot % 360) + 360) % 360) $srcW = [int]$v.width $srcH = [int]$v.height if ($rot -eq 90 -or $rot -eq 270) { $dispW = $srcH; $dispH = $srcW } else { $dispW = $srcW; $dispH = $srcH } $srcFps = Get-Fps $v $isHdr = @('smpte2084', 'arib-std-b67') -contains $v.color_transfer $nvdecOk = @('yuv420p', 'yuvj420p', 'nv12', 'yuv420p10le', 'p010le') -contains $v.pix_fmt $useSoftware = ($isHdr -or ($rot -ne 0) -or (-not $nvdecOk)) $interlaced = @('tt', 'bb', 'tb', 'bt') -contains $v.field_order # ------------------------------------------- разрешение, fps и битрейт ---- $mode = $P.Mode $cq = $P.Cq $vKbps = 0 $audioKbps = $P.Audio $maxRate = $P.MaxRate $bufSize = $P.BufSize if ($mode -eq 'target') { if ($duration -le 0) { Stop-Fail 'could not read duration to compute target bitrate' } $plan = Get-TargetPlan $dispW $dispH $srcFps $duration $P.TargetBytes $hasAudio $tw = $plan.W; $th = $plan.H $outFps = $plan.F $vKbps = $plan.VKbps $audioKbps = $plan.AKbps $srcKbps = $src.Length * 8.0 / $duration / 1000.0 if ($vKbps -ge ($srcKbps * 0.9)) { $mode = 'cq'; $cq = 26; $maxRate = '0'; $bufSize = '0' if (-not $audioKbps) { $audioKbps = 128 } Write-Row 'Note' 'source already fits the limit - using high quality mode instead' 'DarkGray' } } else { if ($P.BoxW -gt 0) { $fit = Get-FitSize $dispW $dispH $P.BoxW $P.BoxH } else { $fit = @((Get-EvenDown $dispW), (Get-EvenDown $dispH)) } $tw = $fit[0]; $th = $fit[1] if ($P.Fps -gt 0 -and $srcFps -gt ($P.Fps + 0.01)) { $outFps = [double]$P.Fps } else { $outFps = $srcFps } } $capFps = ($outFps -gt 0 -and $srcFps -gt ($outFps + 0.01)) # ------------------------------------------------------------- шапка ---- $sizeLine = Format-Size $src.Length if ($duration -gt 0) { $sizeLine += " $([char]0x00B7) $(Format-Clock $duration)" } Write-Row 'Size' $sizeLine $srcDesc = "${srcW}x${srcH}" if ($srcFps -gt 0) { $srcDesc += (' @ {0:N0}' -f $srcFps) } $outDesc = "${tw}x${th}" if ($outFps -gt 0) { $outDesc += (' @ {0:N0}' -f $outFps) } if ($mode -eq 'target') { $how = "$($vKbps) kbit/s to fit $($P.LimitLabel)" } else { $how = "CQ $cq" } Write-Row 'Video' "$srcDesc $arrow $outDesc $($v.codec_name) $arrow h264 $how" $notes = @() if ($interlaced) { $notes += 'interlaced, deinterlacing' } if ($isHdr) { $notes += 'HDR -> SDR tonemapped' } if ($rot -ne 0) { $notes += "rotation $rot deg applied" } if (-not $nvdecOk) { $notes += "$($v.pix_fmt): software decode" } if (-not $hasAudio) { $notes += 'no audio' } if ($notes.Count -gt 0) { Write-Row '' ($notes -join ', ') 'DarkYellow' } # ------------------------------------------------- имя выходного файла ---- try { $probeFile = Join-Path $src.DirectoryName (".ffcompress_write_test_" + [Guid]::NewGuid().ToString('N')) [IO.File]::WriteAllText($probeFile, 'x') Remove-Item -LiteralPath $probeFile -Force } catch { Stop-Fail ("cannot write next to source file. Folder is read-only or locked: " + $src.DirectoryName) } $base = [IO.Path]::GetFileNameWithoutExtension($src.Name) $out = Join-Path $src.DirectoryName ($base + $P.Suffix + '.mp4') $n = 2 while (Test-Path -LiteralPath $out) { $out = Join-Path $src.DirectoryName ($base + $P.Suffix + "_$n.mp4") $n++ } # ------------------------------------------------------ общие аргументы ---- $gop = [int][Math]::Round(2 * [Math]::Max($outFps, 1)) if ($gop -lt 2) { $gop = 2 } # Очистка метаданных (-map_metadata -1) для приватности $map = @('-map', '0:v:0') if ($hasAudio) { $map += @('-map', '0:a:0?') } $map += @('-sn', '-dn', '-map_chapters', '-1', '-map_metadata', '-1') if ($hasAudio) { $aud = @('-c:a', 'aac', '-b:a', "${audioKbps}k", '-ac', '2') } else { $aud = @('-an') } $rate = @() if ($capFps) { $rate = @('-r', ('{0:N0}' -f $outFps), '-vsync', 'cfr') } $QFLAGS_NV = @('-profile:v', 'high', '-level:v', 'auto', '-pix_fmt', 'yuv420p', '-g', "$gop") if ($interlaced) { $diCuda = 'yadif_cuda=mode=0,'; $diSoft = 'bwdif=mode=0,' } else { $diCuda = ''; $diSoft = '' } $vfCuda = $diCuda + ('scale_cuda=w={0}:h={1}:format=yuv420p:interp_algo=lanczos:passthrough=0' -f $tw, $th) $vfSoft = $diSoft + ('scale=w={0}:h={1}:flags=lanczos,format=yuv420p' -f $tw, $th) $vfHdr = $diSoft + (('zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,' + 'tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,' + 'scale=w={0}:h={1}:flags=lanczos,format=yuv420p') -f $tw, $th) $tagsSdr = @('-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709') # ------------------------------------------------------------- энкод ---- Write-Host '' $sw = [Diagnostics.Stopwatch]::StartNew() $ok = $false $usedName = '' $sizeRetry = $false for ($pass = 0; $pass -lt 2; $pass++) { if ($mode -eq 'target') { $encNv = @('-c:v', 'h264_nvenc', '-preset', $P.Nv, '-rc', 'vbr', '-b:v', "${vKbps}k", '-maxrate', "$([int]($vKbps*1.25))k", '-bufsize', "$([int]($vKbps*1.6))k") + $QFLAGS_NV $encAmf = @('-c:v', 'h264_amf', '-quality', 'quality', '-rc', 'vbr_latency', '-b:v', "${vKbps}k", '-maxrate', "$([int]($vKbps*1.25))k", '-bufsize', "$([int]($vKbps*1.6))k", '-profile:v', 'high', '-g', "$gop") $encQsv = @('-c:v', 'h264_qsv', '-preset', 'medium', '-b:v', "${vKbps}k", '-maxrate', "$([int]($vKbps*1.25))k", '-bufsize', "$([int]($vKbps*1.6))k", '-profile:v', 'high', '-g', "$gop") $encCpu = @('-c:v', 'libx264', '-b:v', "${vKbps}k", '-maxrate', "$([int]($vKbps*1.25))k", '-bufsize', "$([int]($vKbps*1.6))k", '-preset', 'medium', '-pix_fmt', 'yuv420p', '-profile:v', 'high', '-level:v', '4.2', '-g', "$gop") } else { $encNv = @('-c:v', 'h264_nvenc', '-preset', $P.Nv, '-rc', 'vbr', '-cq', "$cq", '-b:v', '0', '-maxrate', $maxRate, '-bufsize', $bufSize) + $QFLAGS_NV $encAmf = @('-c:v', 'h264_amf', '-quality', 'quality', '-rc', 'cqp', '-qp_i', "$cq", '-qp_p', "$cq", '-profile:v', 'high', '-g', "$gop") $encQsv = @('-c:v', 'h264_qsv', '-preset', 'medium', '-global_quality', "$cq", '-profile:v', 'high', '-g', "$gop") $encCpu = @('-c:v', 'libx264', '-crf', '21', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-profile:v', 'high', '-level:v', '4.2', '-g', "$gop") } # Мульти-GPU и фоллбэк лестница $attempts = @() $attempts += @{ Name = 'NVIDIA GPU (NVENC hardware encode)'; Pre = @(); Vf = $vfSoft; Enc = $encNv; Tags = @() } if (-not $useSoftware) { $attempts += @{ Name = 'NVIDIA GPU (CUDA decode + NVENC)'; Pre = @('-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda'); Vf = $vfCuda; Enc = $encNv; Tags = @() } } if ($isHdr) { $attempts += @{ Name = 'NVIDIA NVENC (HDR->SDR CPU)'; Pre = @(); Vf = $vfHdr; Enc = $encNv; Tags = $tagsSdr } } $attempts += @{ Name = 'AMD AMF GPU Encoder'; Pre = @(); Vf = $vfSoft; Enc = $encAmf; Tags = @() } $attempts += @{ Name = 'Intel QuickSync (QSV) GPU Encoder'; Pre = @(); Vf = $vfSoft; Enc = $encQsv; Tags = @() } if ($isHdr) { $attempts += @{ Name = 'x264 CPU + HDR Tone-map'; Pre = @(); Vf = $vfHdr; Enc = $encCpu; Tags = $tagsSdr } } $attempts += @{ Name = 'x264 CPU (universal fallback)'; Pre = @(); Vf = $vfSoft; Enc = $encCpu; Tags = @() } $ok = $false for ($i = 0; $i -lt $attempts.Count; $i++) { $a = $attempts[$i] if ($pass -gt 0 -and $i -eq 0) { Write-Row 'Mode' "over the limit, retry at ${vKbps} kbit/s" 'DarkYellow' } elseif ($i -eq 0) { Write-Row 'Mode' $a.Name 'Green' } else { Write-Row 'Mode' ('trying fallback: ' + $a.Name) 'DarkYellow' } if ($i -eq ($attempts.Count - 1)) { $level = 'warning' } else { $level = 'fatal' } $ffArgs = @('-hide_banner', '-loglevel', $level, '-nostats', '-progress', 'pipe:1', '-y') + $a.Pre + @('-i', $src.FullName) + $map + @('-vf', $a.Vf) + $rate + $a.Enc + $a.Tags + $aud + @('-movflags', '+faststart', $out) $script:LastLen = 0 $cur = @{ t = 0.0; speed = 0.0; bytes = 0.0 } & $FFMPEG @ffArgs | ForEach-Object { $eq = $_.IndexOf('=') if ($eq -lt 1) { return } $key = $_.Substring(0, $eq).Trim() $val = $_.Substring($eq + 1).Trim() switch ($key) { 'out_time' { $x = ConvertFrom-FfTime $val; if ($x -ge 0) { $cur.t = $x } } 'speed' { if ($val -match '^([\d.]+)x$') { $cur.speed = [double]$Matches[1] } } 'total_size' { if ($val -as [double]) { $cur.bytes = [double]$val } } 'progress' { Show-Encode $cur.t $duration $cur.speed $cur.bytes } } } $code = $LASTEXITCODE Clear-EncodeLine if ($code -eq 0 -and (Test-Path -LiteralPath $out) -and (Get-Item -LiteralPath $out).Length -gt 0) { $ok = $true $usedName = $a.Name break } if (Test-Path -LiteralPath $out) { Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue } } if (-not $ok) { break } if ($mode -ne 'target') { break } $len = (Get-Item -LiteralPath $out).Length if ($len -le $P.HardLimit -or $pass -eq 1) { break } $vKbps = [int]($vKbps * ($P.TargetBytes / [double]$len) * 0.97) if ($vKbps -lt 80) { $vKbps = 80 } Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue $sizeRetry = $true } $sw.Stop() if (-not $ok) { Stop-Fail 'no encoding mode worked. Scroll up to inspect the error.' } # ------------------------------------------------------------ отчёт ---- $res = Get-Item -LiteralPath $out Write-Host '' Write-Rule $head = " Done in $(Format-Clock $sw.Elapsed.TotalSeconds)" if ($duration -gt 0 -and $sw.Elapsed.TotalSeconds -gt 0) { $head += (' ({0:N1}x faster than realtime)' -f ($duration / $sw.Elapsed.TotalSeconds)) } Write-Host $head -ForegroundColor Cyan Write-Host '' Write-Row 'Before' (Format-Size $src.Length) $cutStr = '' if ($res.Length -lt $src.Length) { $cut = 100.0 * (1 - ($res.Length / [double]$src.Length)) $cutStr = ('{0:N0}% smaller' -f $cut) Write-Row 'After' ('{0} {1} {2:N0}% ({3:N1}x smaller)' -f ` (Format-Size $res.Length), ([char]0x25BC), $cut, ($src.Length / [double]$res.Length)) 'Green' } else { Write-Row 'After' (Format-Size $res.Length) 'Yellow' } if ($duration -gt 0) { Write-Row 'Bitrate' ('{0:N1} Mbit/s' -f (($res.Length * 8) / $duration / 1000000)) } if ($P.Mode -eq 'target') { if ($res.Length -le $P.HardLimit) { Write-Row 'Limit' "fits the $($P.LimitLabel) limit" 'Green' } else { Write-Row 'Limit' "could not squeeze into $($P.LimitLabel)" 'Yellow' } } Write-Row 'Mode' $usedName 'DarkGray' Write-Row 'File' $res.Name 'White' try { Set-Clipboard -LiteralPath $res.FullName Write-Row '' 'copied to clipboard - press Ctrl+V right in the chat' 'DarkGray' } catch { Write-Row '' '(could not copy to clipboard)' 'DarkGray' } # Отправка всплывающего системного уведомления Windows $toastTitle = "ffcompress: $([IO.Path]::GetFileName($res.FullName))" $toastMsg = "Готово! $(Format-Size $res.Length)" if ($cutStr) { $toastMsg += " ($cutStr)" } $toastMsg += " • Скопировано в буфер обмена" Send-ToastNotification $toastTitle $toastMsg Write-Host '' if (-not $NoPause) { Start-Sleep -Seconds 4 } } finally { if ($sem) { if ($held) { try { $sem.Release() | Out-Null } catch { } } $sem.Dispose() } } #=== FFCOMPRESS-UNINSTALLER === <# ffcompress — удаление. Снимает пункты контекстного меню, запись из «Установка и удаление программ» и файлы программы. #> [CmdletBinding()] param([switch] $Quiet) $ErrorActionPreference = 'Continue' try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } try { $Host.UI.RawUI.WindowTitle = 'ffcompress uninstall' } catch { } $APP = Join-Path $env:LOCALAPPDATA 'ffcompress' $EXTS = @( '.mp4', '.mkv', '.mov', '.avi', '.webm', '.m4v', '.wmv', '.flv', '.ts', '.m2ts', '.mts', '.mpg', '.mpeg', '.3gp', '.ogv', '.flac', '.wav', '.weba' ) $VERBS = @('ffcompress', 'Сжать', 'Перевод в MP4') Write-Host '' Write-Host ' Removing ffcompress' -ForegroundColor Cyan Write-Host '' $removed = 0 foreach ($ext in $EXTS) { foreach ($verb in $VERBS) { $key = "HKCU:\SOFTWARE\Classes\SystemFileAssociations\$ext\shell\$verb" if (Test-Path -LiteralPath $key) { Remove-Item -LiteralPath $key -Recurse -Force -ErrorAction SilentlyContinue $removed++ } } foreach ($tail in "$ext\shell", $ext) { $key = "HKCU:\SOFTWARE\Classes\SystemFileAssociations\$tail" if (-not (Test-Path -LiteralPath $key)) { continue } $item = Get-Item -LiteralPath $key -ErrorAction SilentlyContinue if ($item -and $item.SubKeyCount -eq 0 -and $item.ValueCount -eq 0) { Remove-Item -LiteralPath $key -Force -ErrorAction SilentlyContinue } } } Write-Host " [+] menu entries removed: $removed" -ForegroundColor Green $unins = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ffcompress' if (Test-Path -LiteralPath $unins) { Remove-Item -LiteralPath $unins -Recurse -Force -ErrorAction SilentlyContinue Write-Host ' [+] entry in Settings -> Apps removed' -ForegroundColor Green } $left = @() if (Test-Path -LiteralPath $APP) { $self = $PSCommandPath foreach ($f in Get-ChildItem -LiteralPath $APP -File -ErrorAction SilentlyContinue) { if ($self -and $f.FullName -eq $self) { $left += $f.Name; continue } try { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop } catch { $left += $f.Name } } Write-Host ' [+] program files deleted (ffmpeg included)' -ForegroundColor Green } Write-Host '' if ($left.Count -gt 0) { Write-Host ' One file is still in use - this script itself:' -ForegroundColor DarkGray Write-Host " $APP" -ForegroundColor DarkGray Write-Host ' You can delete that folder by hand, or just leave it.' -ForegroundColor DarkGray } else { try { Remove-Item -LiteralPath $APP -Recurse -Force -ErrorAction Stop } catch { } } Write-Host '' Write-Host ' Done. ffcompress is removed.' -ForegroundColor Green Write-Host '' if (-not $Quiet) { try { Read-Host ' Press Enter to close' | Out-Null } catch { Start-Sleep 10 } } #=== FFCOMPRESS-END ===