@echo off setlocal EnableExtensions title MKV Converter REM ============================================================ REM REM MKV CONVERTER - VERSION GRAFICA REM REM Requisitos: REM ffmpeg.exe REM ffprobe.exe REM REM Ambos deben estar junto a este BAT. REM REM ============================================================ set "APP_DIR=%~dp0" set "FFMPEG=%APP_DIR%ffmpeg.exe" set "FFPROBE=%APP_DIR%ffprobe.exe" if not exist "%FFMPEG%" ( echo. echo [ERROR] No se encuentra ffmpeg.exe echo. echo Debe estar junto a este archivo BAT: echo. echo %APP_DIR% echo. pause exit /b 1 ) if not exist "%FFPROBE%" ( echo. echo [ERROR] No se encuentra ffprobe.exe echo. echo Debe estar junto a este archivo BAT: echo. echo %APP_DIR% echo. pause exit /b 1 ) REM ============================================================ REM Lanzar interfaz grafica REM ============================================================ start "" powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -Command ^ "$bat='%~f0'; $lines=[System.IO.File]::ReadAllLines($bat,[System.Text.Encoding]::UTF8); $start=[Array]::IndexOf($lines,'# POWERSHELL_GUI_START'); if($start -lt 0){[System.Windows.Forms.MessageBox]::Show('No se encontro el codigo grafico.','MKV Converter'); exit 1}; $code=($lines[($start+1)..($lines.Length-1)] -join [Environment]::NewLine); Invoke-Expression $code" exit /b 0 # POWERSHELL_GUI_START Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() # ============================================================ # CONFIGURACION # ============================================================ $ErrorActionPreference = 'Stop' $AppDir = Split-Path -Parent $bat $FFmpeg = Join-Path $AppDir 'ffmpeg.exe' $FFprobe = Join-Path $AppDir 'ffprobe.exe' # Tamaño elegido por el usuario. Copy Direct compara contra este valor. # Para NVENC se reservan siempre 50 MB de margen de seguridad. $script:TargetSizeMB = 2000 $script:TargetSizeManualText = $null $script:SliderExponent = 6.0 $script:TargetSizeBytes = [Int64]($script:TargetSizeMB * 1000000) $script:LimitBytes = [Int64](($script:TargetSizeMB - 50) * 1000000) $script:Files = New-Object System.Collections.ArrayList $script:CurrentFileIndex = -1 $script:CurrentFileData = $null $script:CurrentAudioTracks = @() $script:CurrentSubtitleTracks = @() $script:Converting = $false $script:Reordering = $false $script:CancelRequested = $false $script:ShowOutputFolder = $false $script:AllowClose = $false $script:ActiveFfmpegProcess = $null $script:ActiveOutputFile = $null $script:SelectedFileCopyMode = $false $script:UpdatingTargetSize = $false $script:FormattingTargetSizeText = $false $script:TargetSizeEditUnit = 'GB' $script:FFmpegProgressSeconds = 0.0 $script:FFmpegProgressDuration = 0.0 $script:FFmpegLastError = '' $script:FFmpegSpeed = 0.0 # ============================================================ # FUNCIONES GENERALES # ============================================================ function Show-Error { param( [string]$Message, [string]$Title = 'MKV Converter' ) [System.Windows.Forms.MessageBox]::Show( $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } function Show-Info { param( [string]$Message, [string]$Title = 'MKV Converter' ) [System.Windows.Forms.MessageBox]::Show( $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null } function Format-Bytes { param([Int64]$Bytes) if ($Bytes -ge 1TB) { return ('{0:N2} TB' -f ($Bytes / 1TB)) } if ($Bytes -ge 1GB) { return ('{0:N2} GB' -f ($Bytes / 1GB)) } if ($Bytes -ge 1MB) { return ('{0:N2} MB' -f ($Bytes / 1MB)) } return ('{0:N0} KB' -f ($Bytes / 1KB)) } function Format-Duration { param([double]$Seconds) if ($Seconds -le 0) { return '?' } $ts = [TimeSpan]::FromSeconds($Seconds) $hours = [int][math]::Floor($ts.TotalHours) $minutes = $ts.Minutes $seconds = $ts.Seconds if ($hours -gt 0) { return ('{0}h {1:00}m {2:00}s' -f $hours,$minutes,$seconds) } return ('{0}m {1:00}s' -f $minutes,$seconds) } function Get-TargetSizeText { if ($null -ne $script:TargetSizeManualText -and $script:TargetSizeManualText -ne '') { return $script:TargetSizeManualText } $mb = [double]$script:TargetSizeMB if ($mb -ge 1000000) { return ('{0:0.00} TB' -f ($mb / 1000000.0)) } if ($mb -ge 1000) { return ('{0:0.00} GB' -f ($mb / 1000.0)) } return ('{0:0} MB' -f $mb) } function Format-ExactTargetSizeText { param([Int64]$Bytes,[string]$Unit) $u = $Unit.ToUpperInvariant() if ($Bytes -lt 0) { $Bytes = 0 } # La unidad real de almacenamiento es el byte. Por eso la precision maxima # es: 9 decimales en GB, 12 en TB y 6 en MB. No existe aqui ningun tope de 1 TB. if ($u -eq 'TB') { $value = [decimal]$Bytes / [decimal]1000000000000 $number = $value.ToString('0.############',[Globalization.CultureInfo]::InvariantCulture) } elseif ($u -eq 'GB') { $value = [decimal]$Bytes / [decimal]1000000000 $number = $value.ToString('0.#########',[Globalization.CultureInfo]::InvariantCulture) } else { $value = [decimal]$Bytes / [decimal]1000000 $number = $value.ToString('0.######',[Globalization.CultureInfo]::InvariantCulture) } return ('{0} {1}' -f $number,$u) } function Format-GroupedTargetSizeText { param([Int64]$Bytes,[string]$Unit) $u = $Unit.ToUpperInvariant() if ($Bytes -lt 0) { $Bytes = 0 } switch ($u) { 'EB' { $value = [decimal]$Bytes / [decimal]1000000000000000000; $pattern = '0.##################' } 'PB' { $value = [decimal]$Bytes / [decimal]1000000000000000; $pattern = '0.###############' } 'TB' { $value = [decimal]$Bytes / [decimal]1000000000000; $pattern = '0.############' } 'GB' { $value = [decimal]$Bytes / [decimal]1000000000; $pattern = '0.#########' } default { $value = [decimal]$Bytes / [decimal]1000000; $u = 'MB'; $pattern = '0.######' } } $number = $value.ToString($pattern,[Globalization.CultureInfo]::InvariantCulture) if ($number.Contains('.')) { $parts = $number.Split('.') $whole = $parts[0] $fraction = $parts[1] $groups = New-Object System.Collections.Generic.List[string] for ($i = 0; $i -lt $fraction.Length; $i += 3) { $take = [math]::Min(3,$fraction.Length - $i) [void]$groups.Add($fraction.Substring($i,$take)) } $number = $whole + ',' + ($groups -join ',') } return ('{0} {1}' -f $number,$u) } function Get-TargetSizeInputValue { $bytes = [Int64]$script:TargetSizeBytes if ($bytes -ge [Int64]1000000000000) { return (Format-ExactTargetSizeText $bytes 'TB').Split(' ')[0] } return (Format-ExactTargetSizeText $bytes 'GB').Split(' ')[0] } # ============================================================ # FUNCION MATEMATICA DEL SLIDER # # x = posicion normalizada del slider (0..1) # y = tamaño normalizado (0..1) # # Puntos de diseño: # 0% = 0 GB # 30% = 4 GB # 60% = 15 GB # 80% = 40 GB # 100% = 1000 GB (1 TB) # # La curva usada es: # y = x^1.128758 / (x^1.128758 + 90.8584*(1-x)^0.983650) # # El slider sigue directamente la curva, sin pasos artificiales por encima de 2 GB. # ============================================================ $script:SliderCurveExponentA = 1.128758 $script:SliderCurveExponentB = 0.983650 $script:SliderCurveCoefficient = 90.8584 function Convert-SliderRatioToTargetMB { param([double]$Ratio) if ([double]::IsNaN($Ratio) -or [double]::IsInfinity($Ratio)) { $Ratio = 0.0 } if ($Ratio -lt 0.0) { $Ratio = 0.0 } if ($Ratio -gt 1.0) { $Ratio = 1.0 } if ($Ratio -le 0.0) { return 0.0 } if ($Ratio -ge 1.0) { return 1000000.0 } $xA = [math]::Pow($Ratio, $script:SliderCurveExponentA) $oneMinusX = 1.0 - $Ratio $oneMinusXB = [math]::Pow($oneMinusX, $script:SliderCurveExponentB) $denominator = $xA + ($script:SliderCurveCoefficient * $oneMinusXB) if ($denominator -le 0.0) { return 0.0 } return (1000000.0 * ($xA / $denominator)) } function Convert-TargetMBToSliderRatio { param([double]$TargetMB) if ([double]::IsNaN($TargetMB) -or [double]::IsInfinity($TargetMB)) { return 0.0 } if ($TargetMB -le 0.0) { return 0.0 } if ($TargetMB -ge 1000000.0) { return 1.0 } $target = $TargetMB / 1000000.0 $low = 0.0 $high = 1.0 # La inversa no tiene una expresion elemental sencilla para esta curva. # Una busqueda binaria de alta precision mantiene slider y tamaño sincronizados. for ($i = 0; $i -lt 60; $i++) { $mid = ($low + $high) / 2.0 $value = Convert-SliderRatioToTargetMB $mid $valueRatio = $value / 1000000.0 if ($valueRatio -lt $target) { $low = $mid } else { $high = $mid } } return (($low + $high) / 2.0) } function Get-SliderValueFromTargetMB { param([double]$TargetMB) if ($null -eq $trkTargetSize) { return 0 } $ratio = Convert-TargetMBToSliderRatio $TargetMB $sliderMin = [double]$trkTargetSize.Minimum $sliderMax = [double]$trkTargetSize.Maximum $sliderSpan = $sliderMax - $sliderMin if ($sliderSpan -le 0.0) { return [int]$sliderMin } $value = [int][math]::Round( $sliderMin + ($ratio * $sliderSpan), 0, [MidpointRounding]::AwayFromZero ) if ($value -lt $trkTargetSize.Minimum) { $value = $trkTargetSize.Minimum } if ($value -gt $trkTargetSize.Maximum) { $value = $trkTargetSize.Maximum } return $value } function Set-TargetSizeMB { param( [decimal]$RequestedMB, [switch]$PreserveExact ) if ($RequestedMB -is [double] -and ([double]::IsNaN([double]$RequestedMB) -or [double]::IsInfinity([double]$RequestedMB))) { return $false } # La entrada manual no tiene tope de 1 TB. Solo queda limitada por la # capacidad real del valor Int64 expresado en bytes. $exactMB = [decimal]$RequestedMB if ($exactMB -lt 0) { $exactMB = 0 } $maxMB = [decimal][Int64]::MaxValue / [decimal]1000000 if ($exactMB -gt $maxMB) { $exactMB = $maxMB } $byteDecimal = $exactMB * [decimal]1000000 $roundedBytes = [decimal]::Round($byteDecimal,0,[MidpointRounding]::AwayFromZero) if ($roundedBytes -lt 0) { $roundedBytes = 0 } if ($roundedBytes -gt [decimal][Int64]::MaxValue) { $roundedBytes = [decimal][Int64]::MaxValue } $script:TargetSizeBytes = [Int64]$roundedBytes $script:TargetSizeMB = [double]([decimal]$script:TargetSizeBytes / [decimal]1000000) if (-not $PreserveExact) { $script:TargetSizeManualText = $null } # LimitBytes se mantiene en Int64 y usa exactamente el tamaño real menos 50 MB. $effectiveBytes = $script:TargetSizeBytes - [Int64]50000000 if ($effectiveBytes -lt 0) { $effectiveBytes = 0 } $script:LimitBytes = $effectiveBytes if ($null -ne $trkTargetSize) { $sliderValue = Get-SliderValueFromTargetMB $script:TargetSizeMB if ($trkTargetSize.Value -ne $sliderValue) { $oldUpdating = $script:UpdatingTargetSize $script:UpdatingTargetSize = $true try { $trkTargetSize.Value = $sliderValue } finally { $script:UpdatingTargetSize = $oldUpdating } } } if ($null -ne $lblTargetSizeValue) { $lblTargetSizeValue.Text = Get-TargetSizeText } if ($null -ne $txtTargetSizeEdit -and -not $txtTargetSizeEdit.Visible) { $txtTargetSizeEdit.Text = Get-TargetSizeInputValue } if ($null -ne $script:CurrentFileData) { # Copy Direct usa el tamaño mostrado por el slider; NO resta 50 MB. $script:SelectedFileCopyMode = ([Int64]$script:CurrentFileData.SizeBytes -le [Int64]$script:TargetSizeBytes) Update-ModeDisplay Update-AudioDetails } return $true } function Get-EffectiveLimitText { $effectiveMB = [math]::Max(0.0, [double]$script:TargetSizeMB - 50.0) if ($effectiveMB -ge 1000000) { return ('{0:0.00} TB' -f ($effectiveMB / 1000000.0)) } if ($effectiveMB -ge 1000) { return ('{0:0.00} GB' -f ($effectiveMB / 1000.0)) } return ('{0:N0} MB' -f $effectiveMB) } function Parse-TargetSizeInput { param( [string]$Text, [string]$DefaultUnit = 'GB' ) $clean = $Text.Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($clean)) { return $null } $unit = $DefaultUnit.ToUpperInvariant() if ($clean -match '(eb|pb|tb|gb|mb)\s*$') { $unit = $Matches[1].ToUpperInvariant() $clean = $clean.Substring(0,$clean.Length - 2).Trim() } $clean = $clean -replace '\s+', '' if ($clean -notmatch '^([+-]?\d+)(?:[,.](.*))?$') { return $null } $whole = $Matches[1] $fraction = [string]$Matches[2] if ($Matches[2] -ne $null) { # La primera coma/punto es el separador decimal; cualquier coma/punto # posterior forma parte del agrupado de los decimales. $fraction = $fraction -replace '[,.]', '' $clean = $whole + '.' + $fraction } else { $clean = $whole } $value = [decimal]0 if (-not [decimal]::TryParse($clean,[Globalization.NumberStyles]::Number,[Globalization.CultureInfo]::InvariantCulture,[ref]$value)) { return $null } if ($value -lt 0) { $value = 0 } switch ($unit) { 'EB' { $multiplier = [decimal]1000000000000000000 } 'PB' { $multiplier = [decimal]1000000000000000 } 'TB' { $multiplier = [decimal]1000000000000 } 'GB' { $multiplier = [decimal]1000000000 } default { $unit = 'MB'; $multiplier = [decimal]1000000 } } return [PSCustomObject]@{ MB = ($value * $multiplier / [decimal]1000000) Unit = $unit } } function Get-SafeOutputPath { param( [string]$Directory, [string]$BaseName ) $candidate = Join-Path $Directory ($BaseName + '.mp4') $n = 1 while (Test-Path -LiteralPath $candidate) { $candidate = Join-Path $Directory ("{0} ({1}).mp4" -f $BaseName,$n) $n++ } return $candidate } function Get-LanguageName { param([string]$Code) if ([string]::IsNullOrWhiteSpace($Code)) { return 'Idioma no indicado' } switch -Regex ($Code.ToLower()) { '^(spa|es)$' { return 'Español' } '^(cat|ca)$' { return 'Catalán' } '^(eng|en)$' { return 'Inglés' } '^(fra|fre|fr)$' { return 'Francés' } '^(deu|ger|de)$' { return 'Alemán' } '^(ita|it)$' { return 'Italiano' } '^(por|pt)$' { return 'Portugués' } '^(jpn|ja)$' { return 'Japonés' } '^(kor|ko)$' { return 'Coreano' } '^(chi|zho|zh)$' { return 'Chino' } '^(rus|ru)$' { return 'Ruso' } '^(ara|ar)$' { return 'Árabe' } '^(nld|dut|nl)$' { return 'Neerlandés' } '^(pol|pl)$' { return 'Polaco' } '^(tur|tr)$' { return 'Turco' } '^(swe|sv)$' { return 'Sueco' } '^(dan|da)$' { return 'Danés' } '^(nor|no)$' { return 'Noruego' } '^(fin|fi)$' { return 'Finés' } '^(ces|cze|cs)$' { return 'Checo' } '^(hun|hu)$' { return 'Húngaro' } '^(ron|rum|ro)$' { return 'Rumano' } '^(ukr|uk)$' { return 'Ucraniano' } '^(heb|he)$' { return 'Hebreo' } '^(hin|hi)$' { return 'Hindi' } '^(tha|th)$' { return 'Tailandés' } '^(vie|vi)$' { return 'Vietnamita' } '^(ind|id)$' { return 'Indonesio' } '^(und)$' { return 'Idioma no indicado' } default { return $Code } } } function Get-ChannelText { param([int]$Channels) switch ($Channels) { 1 { return '1.0 Mono' } 2 { return '2.0 Stereo' } 6 { return '5.1' } 8 { return '7.1' } default { if ($Channels -gt 0) { return "$Channels canales" } return '? canales' } } } function Get-CodecText { param([string]$Codec) switch ($Codec.ToLower()) { 'aac' { return 'AAC' } 'ac3' { return 'AC-3' } 'eac3' { return 'E-AC-3' } 'dts' { return 'DTS' } 'dca' { return 'DTS' } 'truehd' { return 'TrueHD' } 'flac' { return 'FLAC' } 'opus' { return 'Opus' } 'vorbis' { return 'Vorbis' } 'mp3' { return 'MP3' } 'pcm_s16le' { return 'PCM 16-bit' } 'pcm_s24le' { return 'PCM 24-bit' } 'pcm_s32le' { return 'PCM 32-bit' } default { if ([string]::IsNullOrWhiteSpace($Codec)) { return 'Desconocido' } return $Codec } } } function Get-SubtitleCodecName { param([string]$Codec) switch ($Codec.ToLower()) { 'hdmv_pgs_subtitle' { return 'PGS' } 'dvd_subtitle' { return 'DVD' } 'dvb_subtitle' { return 'DVB' } 'dvb_teletext' { return 'Teletexto' } 'subrip' { return 'SRT' } 'ass' { return 'ASS' } 'ssa' { return 'SSA' } 'webvtt' { return 'WebVTT' } 'mov_text' { return 'mov_text' } default { return $Codec } } } # ============================================================ # FFPROBE # ============================================================ function Invoke-FFprobeJson { param( [string]$File ) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $FFprobe $psi.Arguments = '-v error -show_streams -show_format -of json "' + $File.Replace('"','\"') + '"' $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi [void]$process.Start() $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 0) { throw "ffprobe no pudo analizar el archivo.`r`n`r`n$stderr" } if ([string]::IsNullOrWhiteSpace($stdout)) { throw 'ffprobe no devolvió información.' } return $stdout | ConvertFrom-Json } # ============================================================ # ANALIZAR AUDIO # ============================================================ function Convert-AudioTrack { param( $Stream, [int]$Number ) $languageCode = 'und' if ($null -ne $Stream.tags) { if ($null -ne $Stream.tags.language) { $languageCode = [string]$Stream.tags.language } } $languageName = Get-LanguageName $languageCode $title = 'Sin título' if ($null -ne $Stream.tags) { if ($null -ne $Stream.tags.title) { if (-not [string]::IsNullOrWhiteSpace([string]$Stream.tags.title)) { $title = [string]$Stream.tags.title } } } $codec = [string]$Stream.codec_name $codecDisplay = Get-CodecText $codec $channels = 0 if ($null -ne $Stream.channels) { $channels = [int]$Stream.channels } $channelText = Get-ChannelText $channels $sampleRate = 0 if ($null -ne $Stream.sample_rate) { [int]::TryParse( [string]$Stream.sample_rate, [ref]$sampleRate ) | Out-Null } $sampleRateText = '?' if ($sampleRate -gt 0) { $sampleRateText = '{0:N3}' -f ($sampleRate / 1000) } $bits = '?' if ($null -ne $Stream.bits_per_raw_sample) { if ([string]$Stream.bits_per_raw_sample -ne '0') { $bits = [string]$Stream.bits_per_raw_sample } } if ($bits -eq '?') { if ($null -ne $Stream.bits_per_sample) { if ([string]$Stream.bits_per_sample -ne '0') { $bits = [string]$Stream.bits_per_sample } } } # BITRATE: leer el valor REAL que ya esté almacenado/expuesto por el MKV/FFprobe. # No se calcula a partir de tamaño/duración. $bps = $null if ($null -ne $Stream.tags) { # Matroska puede guardar BPS con distintos sufijos de idioma, por ejemplo # BPS, BPS-eng, BPS-und, etc. Recorremos todas las propiedades para no # depender de un único nombre concreto. foreach ($tag in @($Stream.tags.PSObject.Properties)) { if ([string]$tag.Name -match '(?i)^BPS(?:-[^-]+)?$') { $candidate = [string]$tag.Value if (-not [string]::IsNullOrWhiteSpace($candidate)) { [double]$candidateNumber = 0 if ([double]::TryParse( $candidate, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$candidateNumber )) { if ($candidateNumber -gt 0) { $bps = $candidate break } } } } } } # Segundo origen: bitrate que FFprobe expone directamente en el stream. if ([string]::IsNullOrWhiteSpace($bps)) { if ($null -ne $Stream.bit_rate) { $candidate = [string]$Stream.bit_rate [double]$candidateNumber = 0 if ([double]::TryParse( $candidate, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$candidateNumber )) { if ($candidateNumber -gt 0) { $bps = $candidate } } } } $bitrate = '?' if (-not [string]::IsNullOrWhiteSpace($bps)) { [double]$bpsNumber = 0 if ([double]::TryParse( $bps, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$bpsNumber )) { if ($bpsNumber -gt 0) { $bitrate = [math]::Round($bpsNumber / 1000) } } } $duration = 0 if ($null -ne $Stream.duration) { [double]::TryParse( [string]$Stream.duration, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$duration ) | Out-Null } if ($duration -le 0 -and $null -ne $Stream.tags) { foreach ($tag in @($Stream.tags.PSObject.Properties)) { if ([string]$tag.Name -match '(?i)^DURATION(?:-[^-]+)?$') { try { $d = [string]$tag.Value $parts = $d.Split(':') if ($parts.Count -eq 3) { $duration = ([double]$parts[0] * 3600) + ([double]$parts[1] * 60) + ([double]$parts[2]) } } catch {} if ($duration -gt 0) { break } } } } $bytes = 0 if ($null -ne $Stream.tags) { foreach ($tag in @($Stream.tags.PSObject.Properties)) { if ([string]$tag.Name -match '(?i)^NUMBER_OF_BYTES(?:-[^-]+)?$') { [Int64]$candidateBytes = 0 if ([Int64]::TryParse([string]$tag.Value,[ref]$candidateBytes)) { if ($candidateBytes -gt 0) { $bytes = $candidateBytes break } } } } } $default = $false $original = $false if ($null -ne $Stream.disposition) { if ($null -ne $Stream.disposition.default) { $default = ([int]$Stream.disposition.default -eq 1) } if ($null -ne $Stream.disposition.original) { $original = ([int]$Stream.disposition.original -eq 1) } } [PSCustomObject]@{ Number = $Number Index = [int]$Stream.index LanguageCode = $languageCode Language = $languageName Title = $title Codec = $codec CodecDisplay = $codecDisplay Channels = $channels ChannelText = $channelText Bitrate = $bitrate SampleRate = $sampleRate SampleRateText = $sampleRateText Bits = $bits Duration = $duration Bytes = $bytes Default = $default Original = $original BpsRaw = $bps } } # ============================================================ # ANALIZAR SUBTITULOS # ============================================================ function Convert-SubtitleTrack { param( $Stream, [int]$Number ) $codec = [string]$Stream.codec_name $compatible = $true switch ($codec.ToLower()) { 'hdmv_pgs_subtitle' { $compatible = $false } 'dvd_subtitle' { $compatible = $false } 'dvb_subtitle' { $compatible = $false } 'dvb_teletext' { $compatible = $false } } $languageCode = 'und' if ($null -ne $Stream.tags) { if ($null -ne $Stream.tags.language) { $languageCode = [string]$Stream.tags.language } } $title = '' if ($null -ne $Stream.tags) { if ($null -ne $Stream.tags.title) { $title = [string]$Stream.tags.title } } [PSCustomObject]@{ Number = $Number Index = [int]$Stream.index LanguageCode = $languageCode Language = Get-LanguageName $languageCode Title = $title Codec = $codec CodecDisplay = Get-SubtitleCodecName $codec Compatible = $compatible } } # ============================================================ # INFORMACION DE VIDEO # ============================================================ function Get-VideoCodecText { param([string]$Codec) switch ($Codec.ToLower()) { 'h264' { return 'H.264' } 'hevc' { return 'HEVC (H.265)' } 'av1' { return 'AV1' } 'vp9' { return 'VP9' } 'vp8' { return 'VP8' } default { if ([string]::IsNullOrWhiteSpace($Codec)) { return 'Desconocido' } return $Codec } } } function Get-VideoBitDepth { param($Stream) $bits = 0 if ($null -ne $Stream.bits_per_raw_sample) { [int]::TryParse( [string]$Stream.bits_per_raw_sample, [ref]$bits ) | Out-Null } if ($bits -le 0 -and $null -ne $Stream.bits_per_sample) { [int]::TryParse( [string]$Stream.bits_per_sample, [ref]$bits ) | Out-Null } if ($bits -le 0 -and $null -ne $Stream.pix_fmt) { if ([string]$Stream.pix_fmt -match '(p010|yuv420p10|yuv422p10|yuv444p10|10le|10be)') { $bits = 10 } elseif ([string]$Stream.pix_fmt -match '(p012|12le|12be|yuv420p12|yuv422p12|yuv444p12)') { $bits = 12 } } if ($bits -gt 0) { return "$bits-bit" } return '' } function Get-VideoProfileText { param( [string]$Codec, [string]$Profile ) if ([string]::IsNullOrWhiteSpace($Profile)) { return '' } switch ($Codec.ToLower()) { 'hevc' { switch -Regex ($Profile) { '^Main 10$' { return 'Main 10' } '^Main 12$' { return 'Main 12' } '^Main$' { return 'Main' } default { return $Profile } } } default { return $Profile } } } function Get-VideoLevelText { param([string]$Level) if ( [string]::IsNullOrWhiteSpace($Level) -or $Level -eq '0' ) { return '' } if ($Level -match '^\d+$') { $n = [int]$Level if ($n -ge 10 -and $n -lt 100) { return ( 'Level {0}.{1}' -f [math]::Floor($n / 10), ($n % 10) ) } } if ($Level -match '^\d+\.\d+$') { return "Level $Level" } return "Level $Level" } function Get-VideoFpsText { param($Stream) $fps = 0.0 $raw = '' if ($null -ne $Stream.avg_frame_rate) { $raw = [string]$Stream.avg_frame_rate } if ( [string]::IsNullOrWhiteSpace($raw) -or $raw -eq '0/0' ) { if ($null -ne $Stream.r_frame_rate) { $raw = [string]$Stream.r_frame_rate } } if ($raw -match '^(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)$') { $den = [double]$Matches[2] if ($den -ne 0) { $fps = [double]$Matches[1] / $den } } elseif ($raw -match '^\d+(?:\.\d+)?$') { $fps = [double]$raw } if ($fps -le 0) { return '' } return ('{0:0.###} fps' -f $fps) } function Get-VideoBitrateText { param( $Stream, $Format ) $bps = 0.0 if ($null -ne $Stream.bit_rate) { [double]::TryParse( [string]$Stream.bit_rate, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$bps ) | Out-Null } if ($bps -le 0 -and $null -ne $Stream.tags) { if ($null -ne $Stream.tags.'BPS-eng') { [double]::TryParse( [string]$Stream.tags.'BPS-eng', [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$bps ) | Out-Null } } if ($bps -le 0 -and $null -ne $Format) { [double]$formatBps = 0 if ($null -ne $Format.bit_rate) { [double]::TryParse( [string]$Format.bit_rate, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$formatBps ) | Out-Null } [double]$duration = 0 if ($null -ne $Format.duration) { [double]::TryParse( [string]$Format.duration, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$duration ) | Out-Null } if ( $formatBps -gt 0 -and $duration -gt 0 ) { $audioBps = 0.0 if ($null -ne $Format._audio_bps) { $audioBps = [double]$Format._audio_bps } $bps = $formatBps - $audioBps if ($bps -lt 0) { $bps = 0 } } } if ($bps -le 0) { return '' } $kbps = [math]::Round($bps / 1000) if ($kbps -lt 1) { return '' } return ('{0:N0} Kbps' -f $kbps) } function Get-VideoVfrText { param($Stream) $avg = [string]$Stream.avg_frame_rate $raw = [string]$Stream.r_frame_rate if ( [string]::IsNullOrWhiteSpace($avg) -or [string]::IsNullOrWhiteSpace($raw) -or $avg -eq '0/0' -or $raw -eq '0/0' ) { return '' } try { $a = 0.0 $r = 0.0 if ( $avg -match '^(\d+)/(\d+)$' -and [double]$Matches[2] -ne 0 ) { $a = [double]$Matches[1] / [double]$Matches[2] } if ( $raw -match '^(\d+)/(\d+)$' -and [double]$Matches[2] -ne 0 ) { $r = [double]$Matches[1] / [double]$Matches[2] } if ( $a -gt 0 -and $r -gt 0 -and [math]::Abs($a - $r) -gt 0.01 ) { return 'VFR' } } catch {} return '' } function Get-VideoHdrText { param($Stream) $sideData = @($Stream.side_data_list) foreach ($sd in $sideData) { $type = [string]$sd.side_data_type if ($type -match 'DOVI|Dolby Vision') { return 'Dolby Vision' } if ( $type -match 'Mastering display metadata|Content light level' ) { return 'HDR10' } } $transfer = [string]$Stream.color_transfer $primaries = [string]$Stream.color_primaries if ($transfer -eq 'arib-std-b67') { return 'HLG' } if ( $transfer -eq 'smpte2084' -and $primaries -eq 'bt2020' ) { return 'HDR10' } if ( $transfer -match 'smpte2084|arib-std-b67' ) { return 'HDR' } return '' } # ============================================================ # ANALIZAR ARCHIVO # ============================================================ function Analyze-File { param( [string]$File ) if (-not (Test-Path -LiteralPath $File)) { throw "No existe el archivo:`r`n$File" } $info = Get-Item -LiteralPath $File $probe = Invoke-FFprobeJson $File $audioTracks = @() $subtitleTracks = @() $audioNumber = 0 $subtitleNumber = 0 foreach ($stream in @($probe.streams)) { if ([string]$stream.codec_type -eq 'audio') { $audioNumber++ $audioTracks += Convert-AudioTrack ` -Stream $stream ` -Number $audioNumber } if ([string]$stream.codec_type -eq 'subtitle') { $subtitleNumber++ $subtitleTracks += Convert-SubtitleTrack ` -Stream $stream ` -Number $subtitleNumber } } $duration = 0 if ($null -ne $probe.format) { if ($null -ne $probe.format.duration) { [double]::TryParse( [string]$probe.format.duration, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$duration ) | Out-Null } } $videoStreams = @( @($probe.streams) | Where-Object { $_.codec_type -eq 'video' } ) $width = 0 $height = 0 $videoCodec = '' $videoCodecText = '' $videoBitDepth = '' $videoProfile = '' $videoLevel = '' $videoHdr = '' $videoFps = '' $videoBitrate = '' $videoVfr = '' $vs = $null if ($videoStreams.Count -gt 0) { $vs = $videoStreams[0] $width = [int]$vs.width $height = [int]$vs.height $videoCodec = [string]$vs.codec_name $videoCodecText = Get-VideoCodecText $videoCodec $videoBitDepth = Get-VideoBitDepth $vs $videoProfile = Get-VideoProfileText ` $videoCodec ` ([string]$vs.profile) $videoLevel = Get-VideoLevelText ` ([string]$vs.level) $videoHdr = Get-VideoHdrText $vs $videoFps = Get-VideoFpsText $vs $audioBpsForVideoFallback = 0.0 foreach ($a in @($audioTracks)) { if ( -not [string]::IsNullOrWhiteSpace( [string]$a.BpsRaw ) ) { [double]$ab = 0 [double]::TryParse( [string]$a.BpsRaw, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$ab ) | Out-Null if ($ab -gt 0) { $audioBpsForVideoFallback += $ab } } } if ($null -ne $probe.format) { $probe.format | Add-Member ` -NotePropertyName _audio_bps ` -NotePropertyValue $audioBpsForVideoFallback ` -Force } $videoBitrate = Get-VideoBitrateText ` $vs ` $probe.format $videoVfr = Get-VideoVfrText $vs } [PSCustomObject]@{ Path = $File Name = $info.Name BaseName = $info.BaseName SizeBytes = [Int64]$info.Length SizeText = Format-Bytes $info.Length Duration = $duration DurationText = Format-Duration $duration Width = $width Height = $height VideoCodec = $videoCodec VideoCodecText = $videoCodecText VideoBitDepth = $videoBitDepth VideoProfile = $videoProfile VideoLevel = $videoLevel VideoHdr = $videoHdr VideoFps = $videoFps VideoBitrate = $videoBitrate VideoVfr = $videoVfr AudioTracks = $audioTracks SubtitleTracks = $subtitleTracks Probe = $probe } } # ============================================================ # CALCULAR BITRATE AAC # ============================================================ function Get-SelectedAudioBitrate { param($Track) $value = 0 if ( -not [string]::IsNullOrWhiteSpace( [string]$Track.BpsRaw ) ) { [double]::TryParse( [string]$Track.BpsRaw, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$value ) | Out-Null } if ($value -le 0) { if ( $Track.Bitrate -ne '?' -and $null -ne $Track.Bitrate ) { $value = [double]$Track.Bitrate * 1000 } } if ($value -le 0) { return 320 } $kbps = [math]::Round($value / 1000) if ($kbps -lt 1) { $kbps = 320 } return [int]$kbps } # ============================================================ # CALCULAR BITRATE VIDEO # ============================================================ function Get-VideoBitrate { param( [double]$Duration, [int]$AudioKbps ) if ($Duration -le 0) { return 5000 } $audioMB = ($AudioKbps * $Duration) / 8192 $targetVideoMB = ($script:LimitBytes / 1000000) - $audioMB $videoKbps = [math]::Round( ($targetVideoMB * 8192) / $Duration ) return [int]$videoKbps } # ============================================================ # INTERFAZ # ============================================================ $form = New-Object System.Windows.Forms.Form $form.Text = 'MKV Converter' $form.StartPosition = 'CenterScreen' $form.Size = New-Object System.Drawing.Size(1536,1024) $form.MinimumSize = New-Object System.Drawing.Size(800,600) $form.BackColor = [System.Drawing.Color]::FromArgb(12,15,20) $form.ForeColor = [System.Drawing.Color]::FromArgb(228,232,238) $form.Font = New-Object System.Drawing.Font('Segoe UI',10) $nightBg = [System.Drawing.Color]::FromArgb(12,15,20) $panelBg = [System.Drawing.Color]::FromArgb(16,21,29) $panelBg2 = [System.Drawing.Color]::FromArgb(20,26,35) $controlBg = [System.Drawing.Color]::FromArgb(23,29,39) $border = [System.Drawing.Color]::FromArgb(39,49,63) $text = [System.Drawing.Color]::FromArgb(226,231,239) $muted = [System.Drawing.Color]::FromArgb(166,177,194) $soft = [System.Drawing.Color]::FromArgb(126,140,160) $accent = [System.Drawing.Color]::FromArgb(205,212,222) function Set-NightButton([System.Windows.Forms.Button]$b) { $b.FlatStyle = 'Flat' $b.FlatAppearance.BorderSize = 1 $b.FlatAppearance.BorderColor = $border $b.BackColor = $controlBg $b.ForeColor = $text $b.Font = New-Object System.Drawing.Font('Segoe UI Semibold',10) } function Set-NightGroup([System.Windows.Forms.GroupBox]$g) { $g.ForeColor = $text $g.BackColor = $panelBg $g.Font = New-Object System.Drawing.Font('Segoe UI Semibold',10) } # CABECERA $title = New-Object System.Windows.Forms.Label $title.Text = 'MKV Converter' $title.Font = New-Object System.Drawing.Font('Segoe UI Semibold',21) $title.ForeColor = $text $title.Location = New-Object System.Drawing.Point(98,17) $title.Size = New-Object System.Drawing.Size(500,42) $form.Controls.Add($title) $iconPanel = New-Object System.Windows.Forms.Panel $iconPanel.Location = New-Object System.Drawing.Point(27,16) $iconPanel.Size = New-Object System.Drawing.Size(44,44) $iconPanel.BackColor = $controlBg $iconPanel.BorderStyle = 'FixedSingle' $form.Controls.Add($iconPanel) $iconLabel = New-Object System.Windows.Forms.Label $iconLabel.Text = '▣' $iconLabel.Font = New-Object System.Drawing.Font('Segoe UI Symbol',23) $iconLabel.ForeColor = $accent $iconLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $iconLabel.Dock = 'Fill' $iconPanel.Controls.Add($iconLabel) $subtitle = New-Object System.Windows.Forms.Label $subtitle.Text = 'Conversión inteligente · NVENC · Selector de audio' $subtitle.Font = New-Object System.Drawing.Font('Segoe UI',9) $subtitle.ForeColor = $soft $subtitle.Location = New-Object System.Drawing.Point(100,55) $subtitle.Size = New-Object System.Drawing.Size(700,22) $form.Controls.Add($subtitle) # FILA SUPERIOR $groupInfo = New-Object System.Windows.Forms.GroupBox $groupInfo.Text = ' Información del archivo ' $groupInfo.Location = New-Object System.Drawing.Point(27,88) $groupInfo.Size = New-Object System.Drawing.Size(504,292) Set-NightGroup $groupInfo $form.Controls.Add($groupInfo) $lblFileInfo = New-Object System.Windows.Forms.Label $lblFileInfo.Text = 'Añade un archivo MKV para comenzar.' $lblFileInfo.ForeColor = $text $lblFileInfo.Font = New-Object System.Drawing.Font('Segoe UI',11) $lblFileInfo.Location = New-Object System.Drawing.Point(28,52) $lblFileInfo.Size = New-Object System.Drawing.Size(445,205) $groupInfo.Controls.Add($lblFileInfo) $groupTarget = New-Object System.Windows.Forms.GroupBox $groupTarget.Text = ' Tamaño objetivo ' $groupTarget.Location = New-Object System.Drawing.Point(531,88) $groupTarget.Size = New-Object System.Drawing.Size(636,292) Set-NightGroup $groupTarget $form.Controls.Add($groupTarget) $lblTargetSize = New-Object System.Windows.Forms.Label $lblTargetSize.Text = 'Tamaño objetivo:' $lblTargetSize.ForeColor = $text $lblTargetSize.Font = New-Object System.Drawing.Font('Segoe UI Semibold',12) $lblTargetSize.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $lblTargetSize.Location = New-Object System.Drawing.Point(240,51) $lblTargetSize.Size = New-Object System.Drawing.Size(180,24) $groupTarget.Controls.Add($lblTargetSize) $pnlTargetSizeEditBorder = New-Object System.Windows.Forms.Panel $pnlTargetSizeEditBorder.Location = New-Object System.Drawing.Point(240,80) $pnlTargetSizeEditBorder.Size = New-Object System.Drawing.Size(180,56) $pnlTargetSizeEditBorder.BackColor = $controlBg $pnlTargetSizeEditBorder.Padding = New-Object System.Windows.Forms.Padding(0) $pnlTargetSizeEditBorder.Add_Paint({ param($sender,$e); $pen=New-Object System.Drawing.Pen($border,1); try{$e.Graphics.DrawRectangle($pen,0,0,$sender.ClientSize.Width-1,$sender.ClientSize.Height-1)}finally{$pen.Dispose()} }) $groupTarget.Controls.Add($pnlTargetSizeEditBorder) $txtTargetSizeEdit = New-Object System.Windows.Forms.TextBox $txtTargetSizeEdit.Text = Get-TargetSizeText $txtTargetSizeEdit.ForeColor = $text $txtTargetSizeEdit.BackColor = $controlBg $txtTargetSizeEdit.Font = New-Object System.Drawing.Font('Segoe UI Semibold',18) $txtTargetSizeEdit.Location = New-Object System.Drawing.Point(5,6) $txtTargetSizeEdit.Size = New-Object System.Drawing.Size(168,42) $txtTargetSizeEdit.AutoSize = $false $txtTargetSizeEdit.BorderStyle = 'None' $txtTargetSizeEdit.TextAlign = [System.Windows.Forms.HorizontalAlignment]::Center $txtTargetSizeEdit.Margin = New-Object System.Windows.Forms.Padding(0) $txtTargetSizeEdit.Multiline = $false $txtTargetSizeEdit.TabStop = $false $txtTargetSizeEdit.Cursor = [System.Windows.Forms.Cursors]::IBeam $txtTargetSizeEdit.ShortcutsEnabled = $true $txtTargetSizeEdit.HideSelection = $false $pnlTargetSizeEditBorder.Controls.Add($txtTargetSizeEdit) $lblTargetSizeValue = $txtTargetSizeEdit $trkTargetSize = New-Object System.Windows.Forms.TrackBar $trkTargetSize.Minimum = 0 $trkTargetSize.Maximum = 50000 $trkTargetSize.Value = Get-SliderValueFromTargetMB $script:TargetSizeMB $trkTargetSize.SmallChange = 1 $trkTargetSize.LargeChange = 5 $trkTargetSize.TickFrequency = 25 $trkTargetSize.AutoSize = $false $trkTargetSize.Location = New-Object System.Drawing.Point(26,152) $trkTargetSize.Size = New-Object System.Drawing.Size(580,38) $trkTargetSize.BackColor = $panelBg $groupTarget.Controls.Add($trkTargetSize) foreach($spec in @(@('200 MB',20),@('1 GB',136),@('2 GB',286),@('5 GB',426),@('10 GB',548))) { $l=New-Object System.Windows.Forms.Label; $l.Text=$spec[0]; $l.Location=New-Object System.Drawing.Point([int]$spec[1],199); $l.Size=New-Object System.Drawing.Size(70,24); $l.ForeColor=$muted; $groupTarget.Controls.Add($l) } $lblTargetSizeHint = New-Object System.Windows.Forms.Label $lblTargetSizeHint.Text = '− 50 MB de margen de seguridad en NVENC' $lblTargetSizeHint.ForeColor = $soft $lblTargetSizeHint.Font = New-Object System.Drawing.Font('Segoe UI',8.5) $lblTargetSizeHint.Location = New-Object System.Drawing.Point(160,232) $lblTargetSizeHint.Size = New-Object System.Drawing.Size(320,22) $lblTargetSizeHint.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $groupTarget.Controls.Add($lblTargetSizeHint) $groupActions = New-Object System.Windows.Forms.Panel $groupActions.Location = New-Object System.Drawing.Point(1187,88) $groupActions.Size = New-Object System.Drawing.Size(322,292) $groupActions.BackColor = $panelBg $groupActions.BorderStyle = 'FixedSingle' $form.Controls.Add($groupActions) $btnConvert = New-Object System.Windows.Forms.Button $btnConvert.Text = '▶ Iniciar conversión' $btnConvert.Location = New-Object System.Drawing.Point(10,10) $btnConvert.Size = New-Object System.Drawing.Size(302,70) Set-NightButton $btnConvert $btnConvert.Font = New-Object System.Drawing.Font('Segoe UI Semibold',13) $groupActions.Controls.Add($btnConvert) $btnBrowse = New-Object System.Windows.Forms.Button $btnBrowse.Text = '▣ Examinar carpeta de salida' $btnBrowse.Location = New-Object System.Drawing.Point(10,90) $btnBrowse.Size = New-Object System.Drawing.Size(302,70) Set-NightButton $btnBrowse $groupActions.Controls.Add($btnBrowse) $btnInfo = New-Object System.Windows.Forms.Button $btnInfo.Text = '⚙ Opciones / Cómo funciona' $btnInfo.Location = New-Object System.Drawing.Point(10,170) $btnInfo.Size = New-Object System.Drawing.Size(302,70) Set-NightButton $btnInfo $groupActions.Controls.Add($btnInfo) # SEGUNDA FILA $groupAudio = New-Object System.Windows.Forms.GroupBox $groupAudio.Text = ' Pistas de audio ' $groupAudio.Location = New-Object System.Drawing.Point(27,399) $groupAudio.Size = New-Object System.Drawing.Size(362,173) Set-NightGroup $groupAudio $form.Controls.Add($groupAudio) $comboAudio = New-Object System.Windows.Forms.CheckedListBox $comboAudio.BackColor=$controlBg; $comboAudio.ForeColor=$text; $comboAudio.Location=New-Object System.Drawing.Point(18,38); $comboAudio.Size=New-Object System.Drawing.Size(326,76); $comboAudio.BorderStyle='FixedSingle'; $comboAudio.CheckOnClick=$true; $comboAudio.HorizontalScrollbar=$true; $comboAudio.IntegralHeight=$false $groupAudio.Controls.Add($comboAudio) $lblAudioDetails=New-Object System.Windows.Forms.Label; $lblAudioDetails.Text='No hay audio seleccionado.'; $lblAudioDetails.ForeColor=$muted; $lblAudioDetails.Location=New-Object System.Drawing.Point(18,122); $lblAudioDetails.Size=New-Object System.Drawing.Size(326,32); $groupAudio.Controls.Add($lblAudioDetails) $groupSubtitles=New-Object System.Windows.Forms.GroupBox; $groupSubtitles.Text=' Subtítulos '; $groupSubtitles.Location=New-Object System.Drawing.Point(389,399); $groupSubtitles.Size=New-Object System.Drawing.Size(371,173); Set-NightGroup $groupSubtitles; $form.Controls.Add($groupSubtitles) $lblSubtitlePanel=New-Object System.Windows.Forms.Label; $lblSubtitlePanel.Text="Subtítulos detectados`r`n`r`nLa selección se gestiona automáticamente según las pistas compatibles."; $lblSubtitlePanel.ForeColor=$muted; $lblSubtitlePanel.Location=New-Object System.Drawing.Point(18,38); $lblSubtitlePanel.Size=New-Object System.Drawing.Size(335,112); $lblSubtitlePanel.Font=New-Object System.Drawing.Font('Segoe UI',10); $groupSubtitles.Controls.Add($lblSubtitlePanel) $groupMode=New-Object System.Windows.Forms.GroupBox; $groupMode.Text=' Modo de copia '; $groupMode.Location=New-Object System.Drawing.Point(760,399); $groupMode.Size=New-Object System.Drawing.Size(356,173); Set-NightGroup $groupMode; $form.Controls.Add($groupMode) $rbNormal=New-Object System.Windows.Forms.RadioButton; $rbNormal.Text='Normal (re-encode)'; $rbNormal.Location=New-Object System.Drawing.Point(25,51); $rbNormal.Size=New-Object System.Drawing.Size(300,32); $rbNormal.ForeColor=$text; $rbNormal.BackColor=$panelBg; $rbNormal.Checked=$true; $rbNormal.Enabled=$false; $groupMode.Controls.Add($rbNormal) $rbDirect=New-Object System.Windows.Forms.RadioButton; $rbDirect.Text='Copiar directo (sin re-encode)'; $rbDirect.Location=New-Object System.Drawing.Point(25,91); $rbDirect.Size=New-Object System.Drawing.Size(300,32); $rbDirect.ForeColor=$text; $rbDirect.BackColor=$panelBg; $rbDirect.Enabled=$false; $groupMode.Controls.Add($rbDirect) $lblMode=New-Object System.Windows.Forms.Label; $lblMode.Text='Modo: esperando archivo'; $lblMode.ForeColor=$muted; $lblMode.Font=New-Object System.Drawing.Font('Segoe UI Semibold',9.5); $lblMode.Location=New-Object System.Drawing.Point(25,132); $lblMode.Size=New-Object System.Drawing.Size(315,22); $groupMode.Controls.Add($lblMode) $groupAdvanced=New-Object System.Windows.Forms.GroupBox; $groupAdvanced.Text=' Opciones avanzadas '; $groupAdvanced.Location=New-Object System.Drawing.Point(1116,399); $groupAdvanced.Size=New-Object System.Drawing.Size(393,173); Set-NightGroup $groupAdvanced; $form.Controls.Add($groupAdvanced) foreach($spec in @(@('Mantener estructura de carpetas',20,40),@('Forzar salida a MKV',20,76),@('Eliminar pistas no seleccionadas',20,112))) { $c=New-Object System.Windows.Forms.CheckBox; $c.Text=$spec[0]; $c.Location=New-Object System.Drawing.Point([int]$spec[1],[int]$spec[2]); $c.Size=New-Object System.Drawing.Size(340,28); $c.ForeColor=$text; $c.BackColor=$panelBg; $c.Enabled=$false; $groupAdvanced.Controls.Add($c) } # COLA DE ARCHIVOS $groupFiles=New-Object System.Windows.Forms.GroupBox; $groupFiles.Text=' Cola de archivos '; $groupFiles.Location=New-Object System.Drawing.Point(27,590); $groupFiles.Size=New-Object System.Drawing.Size(1208,328); Set-NightGroup $groupFiles; $form.Controls.Add($groupFiles) $listFiles=New-Object System.Windows.Forms.ListBox; $listFiles.BackColor=$panelBg2; $listFiles.ForeColor=$text; $listFiles.BorderStyle='FixedSingle'; $listFiles.SelectionMode='MultiExtended'; $listFiles.Location=New-Object System.Drawing.Point(18,38); $listFiles.Size=New-Object System.Drawing.Size(1170,265); $listFiles.AllowDrop=$true; $groupFiles.Controls.Add($listFiles) $queuePanel=New-Object System.Windows.Forms.Panel; $queuePanel.Location=New-Object System.Drawing.Point(1260,590); $queuePanel.Size=New-Object System.Drawing.Size(249,328); $queuePanel.BackColor=$panelBg; $queuePanel.BorderStyle='FixedSingle'; $form.Controls.Add($queuePanel) $btnAdd=New-Object System.Windows.Forms.Button; $btnAdd.Text='⊕ Agregar archivos'; $btnAdd.Location=New-Object System.Drawing.Point(10,10); $btnAdd.Size=New-Object System.Drawing.Size(229,58); Set-NightButton $btnAdd; $queuePanel.Controls.Add($btnAdd) $btnRemove=New-Object System.Windows.Forms.Button; $btnRemove.Text='▱ Quitar seleccionados'; $btnRemove.Location=New-Object System.Drawing.Point(10,76); $btnRemove.Size=New-Object System.Drawing.Size(229,58); Set-NightButton $btnRemove; $queuePanel.Controls.Add($btnRemove) $btnClear=New-Object System.Windows.Forms.Button; $btnClear.Text='⌧ Limpiar lista'; $btnClear.Location=New-Object System.Drawing.Point(10,142); $btnClear.Size=New-Object System.Drawing.Size(229,58); Set-NightButton $btnClear; $queuePanel.Controls.Add($btnClear) $btnMoveUp=New-Object System.Windows.Forms.Button; $btnMoveUp.Text='↑ Mover arriba'; $btnMoveUp.Location=New-Object System.Drawing.Point(10,210); $btnMoveUp.Size=New-Object System.Drawing.Size(229,48); Set-NightButton $btnMoveUp; $btnMoveUp.Enabled=$false; $queuePanel.Controls.Add($btnMoveUp) $btnMoveDown=New-Object System.Windows.Forms.Button; $btnMoveDown.Text='↓ Mover abajo'; $btnMoveDown.Location=New-Object System.Drawing.Point(10,264); $btnMoveDown.Size=New-Object System.Drawing.Size(229,48); Set-NightButton $btnMoveDown; $btnMoveDown.Enabled=$false; $queuePanel.Controls.Add($btnMoveDown) # RUTA DE SALIDA: control funcional oculto, porque la referencia no muestra la ruta. $groupOutput=New-Object System.Windows.Forms.GroupBox; $groupOutput.Visible=$false; $txtOutput=New-Object System.Windows.Forms.TextBox; $txtOutput.Text=$AppDir; $groupOutput.Controls.Add($txtOutput); $form.Controls.Add($groupOutput) # BARRA INFERIOR $statusPanel=New-Object System.Windows.Forms.Panel; $statusPanel.Location=New-Object System.Drawing.Point(0,943); $statusPanel.Size=New-Object System.Drawing.Size(1536,81); $statusPanel.BackColor=[System.Drawing.Color]::FromArgb(14,18,25); $statusPanel.BorderStyle='FixedSingle'; $form.Controls.Add($statusPanel) $statusDot=New-Object System.Windows.Forms.Label; $statusDot.Text='◉'; $statusDot.Font=New-Object System.Drawing.Font('Segoe UI Symbol',13); $statusDot.ForeColor=$muted; $statusDot.Location=New-Object System.Drawing.Point(31,24); $statusDot.Size=New-Object System.Drawing.Size(30,28); $statusPanel.Controls.Add($statusDot) $lblStatus=New-Object System.Windows.Forms.Label; $lblStatus.Text='Listo para convertir'; $lblStatus.ForeColor=$muted; $lblStatus.Location=New-Object System.Drawing.Point(66,24); $lblStatus.Size=New-Object System.Drawing.Size(850,28); $lblStatus.Font=New-Object System.Drawing.Font('Segoe UI',10); $statusPanel.Controls.Add($lblStatus) $lblQueueCount=New-Object System.Windows.Forms.Label; $lblQueueCount.Text='0 / 0'; $lblQueueCount.ForeColor=$muted; $lblQueueCount.TextAlign=[System.Drawing.ContentAlignment]::MiddleRight; $lblQueueCount.Location=New-Object System.Drawing.Point(1030,23); $lblQueueCount.Size=New-Object System.Drawing.Size(65,30); $statusPanel.Controls.Add($lblQueueCount) $progress=New-Object System.Windows.Forms.ProgressBar; $progress.Minimum=0; $progress.Maximum=100; $progress.Value=0; $progress.Style='Continuous'; $progress.Location=New-Object System.Drawing.Point(1093,27); $progress.Size=New-Object System.Drawing.Size(354,20); $statusPanel.Controls.Add($progress) $lblProgress=New-Object System.Windows.Forms.Label; $lblProgress.Text='0%'; $lblProgress.ForeColor=$muted; $lblProgress.TextAlign=[System.Drawing.ContentAlignment]::MiddleRight; $lblProgress.Location=New-Object System.Drawing.Point(1452,22); $lblProgress.Size=New-Object System.Drawing.Size(55,30); $statusPanel.Controls.Add($lblProgress) $lblLimit=New-Object System.Windows.Forms.Label; $lblLimit.Text=('Tamaño límite de cálculo: {0}' -f (Get-EffectiveLimitText)); $lblLimit.Visible=$false; $form.Controls.Add($lblLimit) # Ventana informativa no modal: puede permanecer abierta mientras se usa # la ventana principal. El RichTextBox permite seleccionar y copiar texto # y dispone de barra de desplazamiento vertical. $infoForm = $null function Show-ProgramInfo { if ($null -ne $script:InfoForm -and -not $script:InfoForm.IsDisposed) { $script:InfoForm.WindowState = [System.Windows.Forms.FormWindowState]::Normal $script:InfoForm.BringToFront() $script:InfoForm.Activate() return } $script:InfoForm = New-Object System.Windows.Forms.Form $script:InfoForm.Text = 'MK5 Converter — Cómo funciona' $script:InfoForm.StartPosition = 'CenterScreen' $script:InfoForm.Size = New-Object System.Drawing.Size(820,680) $script:InfoForm.MinimumSize = New-Object System.Drawing.Size(620,480) $script:InfoForm.BackColor = [System.Drawing.Color]::FromArgb(25,25,28) $script:InfoForm.ForeColor = [System.Drawing.Color]::White $script:InfoForm.Font = New-Object System.Drawing.Font('Segoe UI',9.5) $script:InfoForm.ShowInTaskbar = $true # Barra superior de la ventana informativa, con el botón para guardar # una copia completa del BAT actual en formato TXT. $infoTopBar = New-Object System.Windows.Forms.Panel $infoTopBar.Dock = 'Top' $infoTopBar.Height = 52 $infoTopBar.BackColor = [System.Drawing.Color]::FromArgb(25,25,28) $infoTopBar.Padding = New-Object System.Windows.Forms.Padding(0,8,10,8) $btnDownloadCode = New-Object System.Windows.Forms.Button $btnDownloadCode.Text = 'Descargar código' $btnDownloadCode.Font = New-Object System.Drawing.Font('Segoe UI Semibold',9) $btnDownloadCode.Size = New-Object System.Drawing.Size(135,34) $btnDownloadCode.Dock = 'Right' $btnDownloadCode.FlatStyle = 'Flat' $btnDownloadCode.ForeColor = [System.Drawing.Color]::White $btnDownloadCode.BackColor = [System.Drawing.Color]::FromArgb(55,75,95) $btnDownloadCode.FlatAppearance.BorderSize = 1 $btnDownloadCode.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(75,100,125) $btnDownloadCode.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(65,90,115) $btnDownloadCode.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(45,65,85) $infoTopBar.Controls.Add($btnDownloadCode) $infoText = New-Object System.Windows.Forms.RichTextBox $infoText.Dock = 'Fill' $infoText.ReadOnly = $true $infoText.BackColor = [System.Drawing.Color]::FromArgb(32,32,36) $infoText.ForeColor = [System.Drawing.Color]::Gainsboro $infoText.BorderStyle = 'None' $infoText.Font = New-Object System.Drawing.Font('Segoe UI',10) $infoText.DetectUrls = $false $infoText.ScrollBars = [System.Windows.Forms.RichTextBoxScrollBars]::Vertical $infoText.WordWrap = $true $infoText.Margin = New-Object System.Windows.Forms.Padding(12) $infoContent = @' MK5 CONVERTER — CÓMO FUNCIONA ¿QUÉ HACE EL PROGRAMA? MK5 Converter convierte archivos MKV a MP4 usando un tamaño objetivo configurable. El programa analiza primero cada vídeo para conocer sus características y, según el caso, decide si puede copiarlo directamente o si necesita recodificarlo. La lista de archivos conserva el orden que aparece en pantalla y ese mismo orden es el que se utiliza al convertir. 1. AÑADIR ARCHIVOS Puedes pulsar «Añadir» para seleccionar uno o varios MKV, o arrastrarlos directamente a la lista. Los archivos aparecen en el orden en el que se incorporan. Los duplicados no se añaden. Puedes seleccionar varios archivos con Ctrl + clic y seleccionar un bloque con Shift + clic. Con las flechas ▲ y ▼ puedes mover uno o varios archivos dentro de la lista. Los archivos seleccionados mantienen su orden entre ellos. 2. SELECCIONAR UN ARCHIVO Al seleccionar un MKV, el programa lo analiza y muestra su información. También carga las pistas de audio disponibles para que puedas elegir cuáles conservar. Reordenar archivos no obliga a volver a analizar el vídeo, por lo que las flechas son rápidas y fluidas. 3. AUDIO El programa permite trabajar con las pistas de audio detectadas y seleccionar las que deben incluirse en el MP4. La selección de audio se mantiene separada del procesamiento de vídeo. 4. SUBTÍTULOS Las pistas de subtítulos se gestionan durante la conversión según la información disponible en el MKV y la lógica de selección del programa. 5. COPIA DIRECTA Cuando el vídeo puede mantenerse sin recodificación, el programa utiliza copia directa: -c:v copy y -c:a copy. En ese caso no se utiliza NVENC, no se cambia la profundidad de bits ni se recodifica el vídeo. Es la opción que conserva el vídeo original sin volver a comprimirlo. 6. RECODIFICACIÓN DE VÍDEO Cuando es necesario recodificar, MK5 utiliza el codificador HEVC de NVIDIA mediante NVENC (hevc_nvenc). El preset es siempre P7, orientado a la máxima calidad de NVENC. Además utiliza VBR y multipass a resolución completa (fullres), de modo que la segunda pasada interna ayuda a repartir mejor los bits en las zonas que más lo necesitan sin cambiar el bitrate objetivo. También se utiliza cuantización adaptativa espacial (Spatial AQ) con intensidad 10 para mejorar la distribución perceptual de los bits. Estas opciones priorizan la calidad y no están pensadas para obtener la máxima velocidad posible. 7. OBJETIVO DEL CONVERSOR: TAMAÑO CONFIGURABLE El tamaño objetivo se puede elegir con el slider desde 0 MB hasta 1 TB. Si el vídeo pesa igual o menos que el tamaño mostrado, se utiliza COPIA DIRECTA. Si pesa más, se utiliza NVENC. Para NVENC se reservan siempre 50 MB de margen de seguridad en el cálculo efectivo. • Si el archivo pesa igual o menos que el tamaño elegido, se conserva mediante copia directa cuando la ruta de salida es compatible: no se vuelve a comprimir el vídeo y se mantiene en modo lossless respecto al vídeo original. • Si el archivo supera el tamaño elegido, se recodifica con NVENC buscando la máxima calidad perceptual posible dentro del tamaño efectivo disponible. El cálculo de NVENC reserva 50 MB respecto al tamaño seleccionado. Para la recodificación, el bitrate de vídeo se calcula a partir del límite real en bytes, la duración y el bitrate de audio exacto que se va a utilizar en la salida. Además se estima de forma dinámica el espacio necesario para los átomos, índices, metadatos y pistas del contenedor MP4, en lugar de descontar siempre una reserva fija de 2 MB. Así se aprovecha mejor el espacio disponible sin cambiar el límite máximo. El cálculo de bitrate conserva los límites de seguridad establecidos por el programa (200–20.000 Kbps). P7 y multipass se encargan de aprovechar lo mejor posible los bits disponibles, pero el objetivo de tamaño sigue siendo el mismo. 8. PROFUNDIDAD DE BITS El programa intenta mantener la profundidad de bits de origen de forma apropiada para HEVC NVENC: • Origen 8-bit → salida 8-bit (yuv420p). • Origen 10-bit → salida 10-bit (p010le). • Origen 12-bit → salida 10-bit (p010le), porque NVENC HEVC no genera una salida HEVC NVENC real de 12 bits en este flujo. No se convierte innecesariamente un vídeo 8-bit a 10-bit. 9. COLOR Y HDR Durante la recodificación se conservan explícitamente las características de color declaradas por el vídeo de origen cuando están disponibles: primarias, transferencia, matriz/colorspace y rango. Si el origen es HDR, el programa mantiene el flujo HDR en lugar de convertirlo a SDR. Esto incluye los casos HDR10 y otras señales HDR compatibles con la información disponible en el archivo. Cuando existen metadatos HDR estáticos disponibles, como mastering display y MaxCLL/MaxFALL, FFmpeg puede transportarlos al proceso de salida. Un origen HDR de 12 bits pasa a 10 bits, pero sigue siendo HDR. 10. PROGRESO Durante la conversión, la interfaz muestra el porcentaje, el tamaño escrito, la velocidad en MB/s y una estimación del tiempo restante. La barra de progreso utiliza el tamaño real que va teniendo el archivo de salida junto con el progreso temporal comunicado por FFmpeg. La actualización de estadísticas de FFmpeg se mantiene en 0,25 segundos. Esto solo controla la frecuencia con la que se informa del progreso; no significa que el codificador realice una codificación nueva cada 0,25 segundos. 11. CONVERSIÓN DE VARIOS ARCHIVOS Al pulsar «CONVERTIR», el programa procesa los MKV siguiendo exactamente el orden actual de la lista. Si has reorganizado la lista con ▲ y ▼, ese nuevo orden es el que se utilizará. 12. CARPETA DE SALIDA La carpeta de salida se puede cambiar mediante «Examinar». Los archivos generados se guardan en la carpeta seleccionada siguiendo la lógica de nombres del programa. 13. LÍMITE DE TAMAÑO El slider permite elegir desde 0 MB hasta 1 TB. El tamaño mostrado decide COPIA DIRECTA o NVENC directamente: si el vídeo cabe, se copia; si no cabe, se recodifica. Cuando se usa NVENC, el cálculo interno reserva 50 MB de margen de seguridad. 14. QUÉ NO CAMBIA AL REORDENAR Mover archivos con ▲ o ▼ solamente cambia el orden de la cola. No cambia la configuración de calidad, el bitrate, el audio seleccionado, el procesamiento HDR ni el método de codificación. RESUMEN MK5 Converter está diseñado para convertir cualquier vídeo a MP4 con un tamaño objetivo configurable: si ya está dentro del tamaño elegido, intenta conservarlo mediante copia directa/lossless; si lo supera, lo transforma mediante HEVC NVENC P7, VBR, multipass fullres y Spatial AQ, buscando la máxima calidad posible dentro del tamaño efectivo. El modo Copy Direct no descuenta los 50 MB; esos 50 MB se reservan únicamente para el cálculo de NVENC. Mantiene la profundidad de bits adecuada y las características de color/HDR del origen cuando están disponibles. Finalmente procesa todos los archivos siguiendo el orden visible en la lista. '@ $infoText.Text = $infoContent.Trim() $infoText.Select(0,0) $btnDownloadCode.Add_Click({ try { $saveDialog = New-Object System.Windows.Forms.SaveFileDialog $saveDialog.Title = 'Guardar código completo de MK5 Converter' $saveDialog.Filter = 'Archivo de texto (*.txt)|*.txt|Todos los archivos (*.*)|*.*' $saveDialog.DefaultExt = 'txt' $saveDialog.AddExtension = $true $saveDialog.FileName = 'MK5_Converter_codigo_completo.txt' $saveDialog.OverwritePrompt = $true if ($saveDialog.ShowDialog($script:InfoForm) -eq [System.Windows.Forms.DialogResult]::OK) { $batContent = [System.IO.File]::ReadAllText($bat, [System.Text.Encoding]::UTF8) $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($saveDialog.FileName, $batContent, $utf8NoBom) # Ventana de confirmación personalizada para mostrar la ruta de forma limpia. $savedForm = New-Object System.Windows.Forms.Form $savedForm.Text = 'MK5 Converter' $savedForm.Size = New-Object System.Drawing.Size(560,235) $savedForm.MinimumSize = New-Object System.Drawing.Size(560,235) $savedForm.MaximumSize = New-Object System.Drawing.Size(560,235) $savedForm.StartPosition = 'CenterParent' $savedForm.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog $savedForm.MaximizeBox = $false $savedForm.MinimizeBox = $false $savedForm.ShowInTaskbar = $false $savedForm.Font = New-Object System.Drawing.Font('Segoe UI',10) $iconBox = New-Object System.Windows.Forms.PictureBox $iconBox.Location = New-Object System.Drawing.Point(24,24) $iconBox.Size = New-Object System.Drawing.Size(42,42) $iconBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage $iconBox.Image = [System.Drawing.SystemIcons]::Information.ToBitmap() $titleLabel = New-Object System.Windows.Forms.Label $titleLabel.Location = New-Object System.Drawing.Point(82,22) $titleLabel.Size = New-Object System.Drawing.Size(440,32) $titleLabel.Font = New-Object System.Drawing.Font('Segoe UI Semibold',12) $titleLabel.Text = 'Código completo guardado correctamente' $pathLabel = New-Object System.Windows.Forms.Label $pathLabel.Location = New-Object System.Drawing.Point(82,62) $pathLabel.Size = New-Object System.Drawing.Size(440,24) $pathLabel.Text = 'Archivo guardado en:' $pathBox = New-Object System.Windows.Forms.Label $pathBox.Location = New-Object System.Drawing.Point(82,88) $pathBox.Size = New-Object System.Drawing.Size(440,38) $pathBox.AutoEllipsis = $true $pathBox.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $pathBox.Text = [string]$saveDialog.FileName $folderButton = New-Object System.Windows.Forms.Button $folderButton.Text = 'Abrir código' $folderButton.Size = New-Object System.Drawing.Size(130,34) $folderButton.Location = New-Object System.Drawing.Point(276,145) $folderButton.FlatStyle = [System.Windows.Forms.FlatStyle]::System # Guardamos la ruta directamente en el boton para que el evento # funcione aunque el dialogo SaveFileDialog ya haya terminado. $folderButton.Tag = [string]$saveDialog.FileName # IMPORTANTE: estos eventos usan GetNewClosure() para conservar # exactamente este boton y esta ruta. Sin esto PowerShell puede # resolver $folderButton a otra variable/boton cuando el evento # se ejecuta mas tarde. $folderClickHandler = { try { $savedPath = [string]$this.Tag if ([string]::IsNullOrWhiteSpace($savedPath)) { return } if (-not (Test-Path -LiteralPath $savedPath -PathType Leaf)) { [System.Windows.Forms.MessageBox]::Show( ('El archivo no existe:`n`n{0}' -f $savedPath), 'MK5 Converter', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning ) | Out-Null return } # Abrimos directamente el archivo con la aplicacion asociada de Windows. Start-Process -FilePath $savedPath -WindowStyle Normal | Out-Null # Una vez abierto correctamente, cerramos esta ventana # exactamente como si se hubiera pulsado Aceptar. $savedForm.DialogResult = [System.Windows.Forms.DialogResult]::OK } catch { try { [System.Windows.Forms.MessageBox]::Show( ('No se pudo abrir el archivo:`n`n{0}' -f $_.Exception.Message), 'MK5 Converter', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } catch {} } }.GetNewClosure() $folderButton.Add_Click($folderClickHandler) $folderKeyHandler = { if ($_.KeyCode -eq [System.Windows.Forms.Keys]::Enter) { $this.PerformClick() $_.SuppressKeyPress = $true $_.Handled = $true } }.GetNewClosure() $folderButton.Add_KeyDown($folderKeyHandler) $okButton = New-Object System.Windows.Forms.Button $okButton.Text = 'Aceptar' $okButton.Size = New-Object System.Drawing.Size(100,34) $okButton.Location = New-Object System.Drawing.Point(422,145) $okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK $okButton.FlatStyle = [System.Windows.Forms.FlatStyle]::System # Aceptar queda enfocado al abrir el dialogo. # Enter activa el boton que tenga actualmente el foco. # Las flechas izquierda/derecha permiten cambiar el foco entre botones. [void]$savedForm.Controls.Add($iconBox) [void]$savedForm.Controls.Add($titleLabel) [void]$savedForm.Controls.Add($pathLabel) [void]$savedForm.Controls.Add($pathBox) [void]$savedForm.Controls.Add($folderButton) [void]$savedForm.Controls.Add($okButton) # Orden de teclado y foco inicial: Aceptar es el primer boton. $folderButton.TabIndex = 1 $okButton.TabIndex = 0 $savedForm.ActiveControl = $okButton $okButton.Focus() | Out-Null $savedForm.Add_KeyDown({ if ($_.KeyCode -eq [System.Windows.Forms.Keys]::Left -or $_.KeyCode -eq [System.Windows.Forms.Keys]::Right) { if ($savedForm.ActiveControl -eq $okButton) { $folderButton.Focus() | Out-Null } else { $okButton.Focus() | Out-Null } $_.SuppressKeyPress = $true } elseif ($_.KeyCode -eq [System.Windows.Forms.Keys]::Enter -and $savedForm.ActiveControl -eq $folderButton) { $savedForm.ActiveControl.PerformClick() $_.SuppressKeyPress = $true $_.Handled = $true } }) $savedForm.KeyPreview = $true [void]$savedForm.ShowDialog($script:InfoForm) $iconBox.Image.Dispose() $savedForm.Dispose() } } catch { [System.Windows.Forms.MessageBox]::Show( $script:InfoForm, ('No se pudo guardar el código:`n`n{0}' -f $_.Exception.Message), 'MK5 Converter', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } }) $script:InfoForm.Controls.Add($infoText) $script:InfoForm.Controls.Add($infoTopBar) $script:InfoForm.Add_FormClosed({ $script:InfoForm = $null }) # Ventana independiente y no modal: puede usarse junto a la ventana principal. $script:InfoForm.Show() | Out-Null $script:InfoForm.BringToFront() } $btnInfo.Add_Click({ Show-ProgramInfo }) # Icono de carpeta dibujado para evitar problemas con emojis # y, además, dejar una separación limpia entre el icono y el texto. $folderIcon = New-Object System.Drawing.Bitmap(40,20) $folderGraphics = [System.Drawing.Graphics]::FromImage($folderIcon) $folderGraphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias $folderGraphics.Clear([System.Drawing.Color]::Transparent) $folderBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(245,205,95)) $folderPath = New-Object System.Drawing.Drawing2D.GraphicsPath $folderPath.AddPolygon(@( (New-Object System.Drawing.Point(2,6)), (New-Object System.Drawing.Point(9,6)), (New-Object System.Drawing.Point(11,3)), (New-Object System.Drawing.Point(17,3)), (New-Object System.Drawing.Point(19,6)), (New-Object System.Drawing.Point(26,6)), (New-Object System.Drawing.Point(24,17)), (New-Object System.Drawing.Point(3,17)) )) $folderGraphics.FillPath($folderBrush,$folderPath) $folderPath.Dispose() $folderBrush.Dispose() $folderGraphics.Dispose() # Estilo del boton de carpeta: verde oscuro sobrio, geometrico y limpio. $btnConvert.FlatAppearance.BorderSize = 1 $btnConvert.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(38,92,66) $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(42,108,76) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(31,82,59) # ============================================================ # DRAG & DROP # ============================================================ function Add-MkvFiles { param( [string[]]$Paths ) foreach ($path in $Paths) { if ([string]::IsNullOrWhiteSpace($path)) { continue } if ( -not ( Test-Path ` -LiteralPath $path ` -PathType Leaf ) ) { continue } $extension = [System.IO.Path]::GetExtension($path) if ($extension.ToLower() -ne '.mkv') { continue } $exists = $script:Files | Where-Object { $_ -eq $path } if ($null -ne $exists) { continue } [void]$script:Files.Add($path) [void]$listFiles.Items.Add( [System.IO.Path]::GetFileName($path) ) } if ($script:Files.Count -gt 0) { if ($listFiles.SelectedIndex -lt 0) { $listFiles.SelectedIndex = 0 } } } $listFiles.Add_DragEnter({ if ( $_.Data.GetDataPresent( [System.Windows.Forms.DataFormats]::FileDrop ) ) { $_.Effect = [System.Windows.Forms.DragDropEffects]::Copy } }) $listFiles.Add_DragDrop({ $files = $_.Data.GetData( [System.Windows.Forms.DataFormats]::FileDrop ) Add-MkvFiles $files }) # ============================================================ # CAMBIAR TAMAÑO OBJETIVO # ============================================================ $trkTargetSize.Add_ValueChanged({ if ($script:UpdatingTargetSize) { return } $script:UpdatingTargetSize = $true try { # Convertir la posicion REAL del slider mediante la nueva curva. $sliderMin = [double]$trkTargetSize.Minimum $sliderMax = [double]$trkTargetSize.Maximum $sliderSpan = $sliderMax - $sliderMin if ($sliderSpan -le 0.0) { $sliderSpan = 1.0 } $ratio = ([double]$trkTargetSize.Value - $sliderMin) / $sliderSpan if ($ratio -lt 0.0) { $ratio = 0.0 } if ($ratio -gt 1.0) { $ratio = 1.0 } $rawMB = Convert-SliderRatioToTargetMB $ratio $script:TargetSizeManualText = $null # Sin cuantizacion artificial: el slider usa directamente la curva. # El valor interno se ajusta a bytes, que es la precision maxima real. $script:TargetSizeMB = [double]$rawMB if ($script:TargetSizeMB -lt 0) { $script:TargetSizeMB = 0.0 } if ($script:TargetSizeMB -gt 1000000) { $script:TargetSizeMB = 1000000.0 } $script:TargetSizeBytes = [Int64]([math]::Round($script:TargetSizeMB * 1000000.0,0,[MidpointRounding]::AwayFromZero)) $script:TargetSizeMB = $script:TargetSizeBytes / 1000000.0 $effectiveMB = [math]::Max(0, $script:TargetSizeMB - 50) $script:LimitBytes = [Int64]($effectiveMB * 1000000) $lblTargetSizeValue.Text = Get-TargetSizeText if ($null -ne $script:CurrentFileData) { # El modo se decide directamente con el tamaño visible del slider. $script:SelectedFileCopyMode = ([Int64]$script:CurrentFileData.SizeBytes -le [Int64]$script:TargetSizeBytes) Update-ModeDisplay Update-AudioDetails } } finally { $script:UpdatingTargetSize = $false } }) function Commit-TargetSizeEdit { $parsed = Parse-TargetSizeInput $txtTargetSizeEdit.Text $script:TargetSizeEditUnit if ($null -eq $parsed) { $txtTargetSizeEdit.Text = Get-TargetSizeText return } [void](Set-TargetSizeMB $parsed.MB -PreserveExact) # Mantener la unidad con la que el usuario empezó a editar. Si el valor # supera 1000 de esa unidad, subir automaticamente: MB -> GB -> TB -> PB -> EB. $unit = $parsed.Unit $bytes = [Int64]$script:TargetSizeBytes switch ($unit) { 'MB' { if ($bytes -ge [Int64]1000000000) { $unit = 'GB' }; if ($bytes -ge [Int64]1000000000000) { $unit = 'TB' }; if ($bytes -ge [Int64]1000000000000000) { $unit = 'PB' }; if ($bytes -ge [Int64]1000000000000000000) { $unit = 'EB' } } 'GB' { if ($bytes -ge [Int64]1000000000000) { $unit = 'TB' }; if ($bytes -ge [Int64]1000000000000000) { $unit = 'PB' }; if ($bytes -ge [Int64]1000000000000000000) { $unit = 'EB' } } 'TB' { if ($bytes -ge [Int64]1000000000000000) { $unit = 'PB' }; if ($bytes -ge [Int64]1000000000000000000) { $unit = 'EB' } } 'PB' { if ($bytes -ge [Int64]1000000000000000000) { $unit = 'EB' } } } # La unidad elegida se conserva y las comas quedan escritas despues de aceptar. $script:TargetSizeEditUnit = $unit $script:TargetSizeManualText = Format-GroupedTargetSizeText $script:TargetSizeBytes $unit $script:FormattingTargetSizeText = $true try { $txtTargetSizeEdit.Text = $script:TargetSizeManualText $txtTargetSizeEdit.SelectionStart = $txtTargetSizeEdit.Text.Length } finally { $script:FormattingTargetSizeText = $false } } $txtTargetSizeEdit.Add_KeyDown({ if ($_.KeyCode -eq [System.Windows.Forms.Keys]::Enter) { Commit-TargetSizeEdit $_.SuppressKeyPress = $true $_.Handled = $true } elseif ($_.KeyCode -eq [System.Windows.Forms.Keys]::Escape) { $script:TargetSizeManualText = $null $txtTargetSizeEdit.Text = Get-TargetSizeText $_.SuppressKeyPress = $true $_.Handled = $true } }) # Al hacer clic en cualquier otro control se acepta el valor, igual que Enter. $script:CommitTargetSizeOnClick = { if ($this -ne $txtTargetSizeEdit -and $txtTargetSizeEdit.Focused) { Commit-TargetSizeEdit } }.GetNewClosure() function Register-TargetSizeCommitOnClick { param([System.Windows.Forms.Control]$Control) if ($Control -ne $txtTargetSizeEdit) { $Control.Add_MouseDown($script:CommitTargetSizeOnClick) } foreach ($child in $Control.Controls) { Register-TargetSizeCommitOnClick $child } } # ============================================================ # AÑADIR $btnAdd.Add_Click({ $dialog = New-Object System.Windows.Forms.OpenFileDialog $dialog.Title = 'Seleccionar archivos MKV' $dialog.Filter = 'Archivos MKV (*.mkv)|*.mkv' $dialog.Multiselect = $true if ($dialog.ShowDialog() -eq 'OK') { Add-MkvFiles $dialog.FileNames } }) # ============================================================ # ELIMINAR # ============================================================ $btnRemove.Add_Click({ if ($script:Converting) { return } $index = $listFiles.SelectedIndex if ($index -ge 0) { $script:Files.RemoveAt($index) $listFiles.Items.RemoveAt($index) if ($script:Files.Count -gt 0) { if ($index -ge $script:Files.Count) { $index = $script:Files.Count - 1 } $listFiles.SelectedIndex = $index } else { $script:CurrentFileData = $null $comboAudio.Items.Clear() $lblAudioDetails.Text = 'No hay audio seleccionado.' $lblFileInfo.Text = 'Añade un archivo MKV para comenzar.' $lblMode.Text = 'Modo: esperando archivo' $lblLimit.Visible = $false $progress.Value = 0 $lblProgress.Text = '0%' $lblStatus.Text = 'Listo.' $script:ShowOutputFolder = $false $btnConvert.Image = $null $btnConvert.Text = '▶ CONVERTIR' $btnConvert.BackColor = [System.Drawing.Color]::FromArgb( 35, 145, 85 ) $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(42,108,76) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(31,82,59) $btnConvert.Enabled = $true } } }) # ============================================================ # MOVER ARCHIVOS EN EL ORDEN # ============================================================ function Update-MoveButtons { if ($script:Converting -or $script:Reordering) { $btnMoveUp.Enabled = $false $btnMoveDown.Enabled = $false return } $count = $script:Files.Count if ($count -le 0 -or $listFiles.SelectedIndices.Count -eq 0) { $btnMoveUp.Enabled = $false $btnMoveDown.Enabled = $false return } $selected = New-Object 'System.Collections.Generic.HashSet[int]' foreach ($i in $listFiles.SelectedIndices) { [void]$selected.Add([int]$i) } $canMoveUp = $false for ($i = 1; $i -lt $count; $i++) { if ($selected.Contains($i) -and -not $selected.Contains($i - 1)) { $canMoveUp = $true break } } $canMoveDown = $false for ($i = 0; $i -lt ($count - 1); $i++) { if ($selected.Contains($i) -and -not $selected.Contains($i + 1)) { $canMoveDown = $true break } } $btnMoveUp.Enabled = $canMoveUp $btnMoveDown.Enabled = $canMoveDown } $btnMoveUp.Add_Click({ if ($script:Converting -or $script:Reordering) { return } $count = $script:Files.Count if ($count -le 1 -or $listFiles.SelectedIndices.Count -eq 0) { return } # Copiamos orden y selección para poder hacer desplazamientos de un # paso sin perder la selección múltiple ni el orden interno de los bloques. $order = [System.Collections.Generic.List[string]]::new() $flags = [System.Collections.Generic.List[bool]]::new() foreach ($i in 0..($count - 1)) { [void]$order.Add([string]$script:Files[$i]) [void]$flags.Add($listFiles.GetSelected($i)) } # Un elemento seleccionado sube una posición si tiene delante uno no seleccionado. # Al recorrer de arriba abajo, los bloques seleccionados se mueven juntos. for ($i = 1; $i -lt $count; $i++) { if ($flags[$i] -and -not $flags[$i - 1]) { $tmp = $order[$i - 1] $order[$i - 1] = $order[$i] $order[$i] = $tmp $tmpFlag = $flags[$i - 1] $flags[$i - 1] = $flags[$i] $flags[$i] = $tmpFlag } } $script:Reordering = $true $listFiles.BeginUpdate() try { $script:Files.Clear() foreach ($path in $order) { [void]$script:Files.Add($path) } $listFiles.Items.Clear() foreach ($path in $script:Files) { [void]$listFiles.Items.Add([System.IO.Path]::GetFileName($path)) } for ($i = 0; $i -lt $count; $i++) { if ($flags[$i]) { $listFiles.SetSelected($i,$true) } } } finally { $listFiles.EndUpdate() $script:Reordering = $false } $listFiles.Focus() Update-MoveButtons }) $btnMoveDown.Add_Click({ if ($script:Converting -or $script:Reordering) { return } $count = $script:Files.Count if ($count -le 1 -or $listFiles.SelectedIndices.Count -eq 0) { return } $order = [System.Collections.Generic.List[string]]::new() $flags = [System.Collections.Generic.List[bool]]::new() foreach ($i in 0..($count - 1)) { [void]$order.Add([string]$script:Files[$i]) [void]$flags.Add($listFiles.GetSelected($i)) } # Recorremos de abajo arriba para que los elementos seleccionados bajen # una posición sin adelantarse entre sí y manteniendo su orden interno. for ($i = $count - 2; $i -ge 0; $i--) { if ($flags[$i] -and -not $flags[$i + 1]) { $tmp = $order[$i + 1] $order[$i + 1] = $order[$i] $order[$i] = $tmp $tmpFlag = $flags[$i + 1] $flags[$i + 1] = $flags[$i] $flags[$i] = $tmpFlag } } $script:Reordering = $true $listFiles.BeginUpdate() try { $script:Files.Clear() foreach ($path in $order) { [void]$script:Files.Add($path) } $listFiles.Items.Clear() foreach ($path in $script:Files) { [void]$listFiles.Items.Add([System.IO.Path]::GetFileName($path)) } for ($i = 0; $i -lt $count; $i++) { if ($flags[$i]) { $listFiles.SetSelected($i,$true) } } } finally { $listFiles.EndUpdate() $script:Reordering = $false } $listFiles.Focus() Update-MoveButtons }) # ============================================================ # LIMPIAR # ============================================================ $btnClear.Add_Click({ if ($script:Converting) { return } $script:Files.Clear() $listFiles.Items.Clear() $script:CurrentFileData = $null $comboAudio.Items.Clear() $comboAudio.Enabled = $true $lblAudioDetails.Text = 'No hay audio seleccionado.' $lblFileInfo.Text = 'Añade un archivo MKV para comenzar.' $lblMode.Text = 'Modo: esperando archivo' $progress.Value = 0 $lblProgress.Text = '0%' $lblStatus.Text = 'Listo.' $script:ShowOutputFolder = $false $btnConvert.Image = $null $btnConvert.Text = '▶ CONVERTIR' $btnConvert.BackColor = [System.Drawing.Color]::FromArgb( 35, 145, 85 ) $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(42,108,76) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(31,82,59) $btnConvert.Enabled = $true }) # ============================================================ # CARPETA SALIDA # ============================================================ $btnBrowse.Add_Click({ if ($script:Converting) { return } $dialog = New-Object System.Windows.Forms.FolderBrowserDialog $dialog.Description = 'Selecciona la carpeta de salida' if ( Test-Path ` -LiteralPath $txtOutput.Text ) { $dialog.SelectedPath = $txtOutput.Text } if ( $dialog.ShowDialog() -eq 'OK' ) { $txtOutput.Text = $dialog.SelectedPath } }) # ============================================================ # ACTUALIZAR INFORMACION # ============================================================ function Refresh-CurrentFile { $index = $listFiles.SelectedIndex if ($index -lt 0) { return } if ($index -ge $script:Files.Count) { return } $file = $script:Files[$index] $script:CurrentFileIndex = $index $lblStatus.Text = 'Analizando archivo...' $form.Refresh() try { $data = Analyze-File $file $script:CurrentFileData = $data $script:CurrentAudioTracks = @($data.AudioTracks) $script:CurrentSubtitleTracks = @($data.SubtitleTracks) $script:SelectedFileCopyMode = ($data.SizeBytes -le $script:TargetSizeBytes) # La indicación dorada cambia según el tamaño del archivo: # por encima del límite avisamos del límite; por debajo mostramos # el tamaño estimado de la salida. if ([Int64]$data.SizeBytes -gt [Int64]$script:TargetSizeBytes) { $lblLimit.Text = ('Tamaño límite de cálculo: {0}' -f (Get-EffectiveLimitText)) } else { # En archivos por debajo del límite, la etiqueta mostrará el peso # estimado de salida cuando se actualice el modo y el audio. $lblLimit.Text = 'Calculando tamaño estimado...' } $lblLimit.Visible = $true if ( $data.SizeBytes -le $script:TargetSizeBytes ) { $mode = 'COPIA DIRECTA' } else { $mode = 'NVENC HEVC' } $videoInfo = $data.VideoCodecText if ($data.VideoBitDepth) { $videoInfo += ' | ' + $data.VideoBitDepth } if ($data.VideoProfile) { $videoInfo += ' | ' + $data.VideoProfile } if ($data.VideoLevel) { $videoInfo += ' | ' + $data.VideoLevel } if ($data.VideoHdr) { $videoInfo += ' | ' + $data.VideoHdr } if ($data.VideoFps) { $videoInfo += ' | ' + $data.VideoFps } if ($data.VideoVfr) { $videoInfo += ' | ' + $data.VideoVfr } if ($data.VideoBitrate) { $videoInfo += ' | Bitrate de vídeo: ' + $data.VideoBitrate } $resolutionText = if ($data.Width -and $data.Height) { 'Resolución: {0}×{1} px' -f $data.Width,$data.Height } else { 'Resolución desconocida' } $lblFileInfo.Text = ( ('{0}' + [Environment]::NewLine + '{1} | Duración: {2} | Tamaño: {3}' + [Environment]::NewLine + 'Vídeo: {4}') -f $data.Name, $resolutionText, $data.DurationText, $data.SizeText, $videoInfo ) $lblMode.Text = ('Modo: {0}' -f $mode) $comboAudio.Items.Clear() foreach ($track in $script:CurrentAudioTracks) { $defaultText = '' if ($track.Default) { $defaultText = ' [DEFAULT]' } $originalText = '' if ($track.Original) { $originalText = ' [ORIGINAL]' } $text = ( '{0}. {1} — {2} — {3} — {4} — {5} kbps — {6} kHz — {7}-bit{8}{9}' -f $track.Number, $track.Language, $track.Title, $track.CodecDisplay, $track.ChannelText, $track.Bitrate, $track.SampleRateText, $track.Bits, $defaultText, $originalText ) [void]$comboAudio.Items.Add( $text ) } if ($script:CurrentAudioTracks.Count -gt 0) { $comboAudio.SelectedIndex = 0 if ( $data.SizeBytes -le $script:TargetSizeBytes ) { for ( $audioIndex = 0; $audioIndex -lt $script:CurrentAudioTracks.Count; $audioIndex++ ) { $comboAudio.SetItemCheckState( $audioIndex, [System.Windows.Forms.CheckState]::Checked ) } $comboAudio.Enabled = $false } else { $comboAudio.Enabled = $true $comboAudio.SetItemCheckState( 0, [System.Windows.Forms.CheckState]::Checked ) } } else { [void]$comboAudio.Items.Add( 'No hay pistas de audio' ) $comboAudio.SelectedIndex = 0 } Update-ModeDisplay $subtitleCompatible = @( $script:CurrentSubtitleTracks | Where-Object { $_.Compatible } ) $subtitleBitmap = @( $script:CurrentSubtitleTracks | Where-Object { -not $_.Compatible } ) $subtitleText = '' if ( $script:CurrentSubtitleTracks.Count -eq 0 ) { $subtitleText = 'Sin subtítulos.' } else { $subtitleText = ( 'Subtítulos: {0} de texto compatibles' -f $subtitleCompatible.Count ) if ($subtitleBitmap.Count -gt 0) { $subtitleText += ( ' | {0} PGS/bitmap/teletexto se descartarán' -f $subtitleBitmap.Count ) } } $lblStatus.Text = $subtitleText Update-AudioDetails } catch { $lblStatus.Text = 'Error analizando el archivo.' Show-Error $_.Exception.Message } } # ============================================================ # INFORMACION AUDIO # ============================================================ function Update-ModeDisplay { if ($null -eq $script:CurrentFileData) { return } if ($script:SelectedFileCopyMode) { if (-not $script:Converting -and $script:CurrentAudioTracks.Count -gt 0) { for ($audioIndex = 0; $audioIndex -lt $script:CurrentAudioTracks.Count; $audioIndex++) { $comboAudio.SetItemCheckState($audioIndex, [System.Windows.Forms.CheckState]::Checked) } $comboAudio.Enabled = $false } $copyBitrateText = [string]$script:CurrentFileData.VideoBitrate if ([string]::IsNullOrWhiteSpace($copyBitrateText)) { $lblMode.Text = 'Modo: COPIA DIRECTA' } else { $lblMode.Text = 'Modo: COPIA DIRECTA | Bitrate de vídeo: {0}' -f $copyBitrateText } # En copia directa el tamaño exportado es, salvo metadatos del # contenedor, el mismo archivo original. if ([Int64]$script:CurrentFileData.SizeBytes -le [Int64]$script:TargetSizeBytes) { $lblLimit.Text = 'Tamaño estimado: {0}' -f $script:CurrentFileData.SizeText $lblLimit.Visible = $true } return } if (-not $script:Converting) { $comboAudio.Enabled = $true } $checked = @($comboAudio.CheckedIndices) $tracks = @() foreach ($i in $checked) { if ($i -ge 0 -and $i -lt $script:CurrentAudioTracks.Count) { $tracks += $script:CurrentAudioTracks[$i] } } if ($tracks.Count -eq 0 -and $script:CurrentAudioTracks.Count -gt 0) { $tracks = @($script:CurrentAudioTracks[0]) } $audioKbps = 0 foreach ($track in $tracks) { $audioKbps += Get-SelectedAudioBitrate $track } $targetVideoKbps = Get-VideoBitrate ` -Duration $script:CurrentFileData.Duration ` -AudioKbps $audioKbps $lblMode.Text = 'Modo: NVENC HEVC | Bitrate de vídeo: {0:N0} Kbps' -f $targetVideoKbps $estimatedOutputMB = (($targetVideoKbps + $audioKbps) * $script:CurrentFileData.Duration) / 8000 if ($estimatedOutputMB -lt 0) { $estimatedOutputMB = 0 } $lblLimit.Text = 'Tamaño estimado: {0:N0} MB' -f $estimatedOutputMB $lblLimit.Visible = $true } function Update-AudioDetails { $checked = @($comboAudio.CheckedIndices) if ($checked.Count -eq 0) { $lblAudioDetails.Text = 'No hay audio seleccionado.'; return } $tracks = @() foreach ($i in $checked) { if ($i -ge 0 -and $i -lt $script:CurrentAudioTracks.Count) { $tracks += $script:CurrentAudioTracks[$i] } } if ($tracks.Count -eq 0) { $lblAudioDetails.Text = 'No hay audio seleccionado.'; return } if ($tracks.Count -eq 1) { $track = $tracks[0] $defaultText = if ($track.Default) { 'Sí' } else { 'No' } $originalText = if ($track.Original) { 'Sí' } else { 'No' } $sizeText = if ($track.Bytes -gt 0) { '{0:N2} MB' -f ($track.Bytes / 1000000) } else { '?' } $outputText = if ($script:SelectedFileCopyMode) { '→ AUDIO DE SALIDA: COPIA DIRECTA (sin recodificar)' } else { '→ AUDIO DE SALIDA: AAC' } $lblAudioDetails.Text = ( "Codec: {0} | Canales: {1} | Bitrate de audio: {2} kbps | Sample rate: {3} kHz | Profundidad: {4}-bit`r`n" + "Duración: {5} | Tamaño: {6} | Default: {7} | Original: {8} | {9}" ) -f $track.CodecDisplay,$track.ChannelText,$track.Bitrate,$track.SampleRateText,$track.Bits, (Format-Duration $track.Duration),$sizeText,$defaultText,$originalText,$outputText } else { $names = @($tracks | ForEach-Object { $_.Language }) -join ', ' [Int64]$totalAudioBytes = 0 $sizes = @( $tracks | ForEach-Object { $trackBytes = [Int64]$_.Bytes if ($trackBytes -gt 0) { $totalAudioBytes += $trackBytes } $trackSizeText = if ($trackBytes -gt 0) { Format-Bytes $trackBytes } else { '?' } '{0}: {1}' -f $_.Language,$trackSizeText } ) -join ' | ' if ($script:SelectedFileCopyMode) { $outputText = 'Audio de salida: COPIA DIRECTA (sin recodificar)' } else { $outputText = 'Audio de salida: AAC' } $totalSizeText = if ($totalAudioBytes -gt 0) { Format-Bytes $totalAudioBytes } else { '?' } $lblAudioDetails.Text = ( "Pistas seleccionadas: {0} | Idiomas: {1}`r`nTamaño por pista: {2} | Total audio: {3} | {4}" ) -f $tracks.Count,$names,$sizes,$totalSizeText,$outputText } } $comboAudio.Add_ItemCheck({ $form.BeginInvoke( [Action]{ Update-AudioDetails Update-ModeDisplay } ) | Out-Null }) $listFiles.Add_SelectedIndexChanged({ if (-not $script:Converting -and -not $script:Reordering) { Refresh-CurrentFile } Update-MoveButtons }) Update-MoveButtons # ============================================================ # ACTUALIZAR PROGRESO # ============================================================ function Update-ProgressUi { if (-not $script:Converting) { return } try { $outputFile = [string]$script:CurrentOutputFile $inputBytes = [Int64]$script:CurrentInputBytes if ([string]::IsNullOrWhiteSpace($outputFile) -or $inputBytes -le 0 -or -not (Test-Path -LiteralPath $outputFile)) { if ($progress.Value -lt 3) { $progress.Value = [math]::Min(3,$progress.Value + 1); $lblProgress.Text = ('{0}%' -f $progress.Value) } return } $currentBytes = [Int64](Get-Item -LiteralPath $outputFile).Length $percent = [int][math]::Floor(($currentBytes / $inputBytes) * 100) if ($percent -lt 0) { $percent = 0 }; if ($percent -gt 98) { $percent = 98 } $progress.Value = $percent $lblProgress.Text = ('{0}%' -f $percent) $now = [DateTime]::UtcNow if ($null -eq $script:OutputSampleAt) { $script:OutputSampleAt = $now $script:OutputSampleBytes = $currentBytes } $elapsed = ($now - $script:OutputSampleAt).TotalSeconds if ($elapsed -ge 1.50) { $written = [math]::Max(0,($currentBytes - [Int64]$script:OutputSampleBytes)) $instantBps = $written / $elapsed if ($script:OutputBytesPerSecond -gt 0) { $script:OutputBytesPerSecond = ($script:OutputBytesPerSecond * 0.80) + ($instantBps * 0.20) } else { $script:OutputBytesPerSecond = $instantBps } $script:OutputSampleAt = $now $script:OutputSampleBytes = $currentBytes } if ($script:CurrentCopyMode) { $lblStatus.Text = ('Copiando sin recodificar... {0}' -f (Format-Bytes $currentBytes)) return } $speedText = if ($script:OutputBytesPerSecond -gt 0) { '{0:N2} MB/s' -f ($script:OutputBytesPerSecond / 1MB) } else { 'calculando velocidad...' } # ETA estilo Windows: se calcula con la misma velocidad media suavizada # que se muestra en MB/s. La velocidad solo cambia cada 1,5 s y cada # nueva medición se mezcla con la anterior, evitando saltos bruscos. $etaText = 'Calculando tiempo restante...' if ($script:OutputBytesPerSecond -gt 0 -and $currentBytes -gt 0) { $estimatedFinalBytes = [double]$LimitBytes $duration = [double]$script:FFmpegProgressDuration $mediaSeconds = [double]$script:FFmpegProgressSeconds if ($duration -gt 0 -and $mediaSeconds -gt 0) { $fraction = $mediaSeconds / $duration if ($fraction -ge 0.02 -and $fraction -le 1.0) { $estimatedFinalBytes = [double]$currentBytes / $fraction if ($estimatedFinalBytes -gt [double]$LimitBytes) { $estimatedFinalBytes = [double]$LimitBytes } } } $remainingBytes = [math]::Max(0,($estimatedFinalBytes - [double]$currentBytes)) $remainingSeconds = $remainingBytes / [double]$script:OutputBytesPerSecond if ($remainingSeconds -lt 1) { $remainingSeconds = 1 } $etaText = 'Tiempo restante: {0}' -f (Format-Duration $remainingSeconds) } $lblStatus.Text = ('Codificando NVENC... {0} | {1} | {2}' -f (Format-Bytes $currentBytes),$speedText,$etaText) } catch {} } # ============================================================ # EJECUTAR FFMPEG # ============================================================ function Start-FFmpegConversion { param( [string]$InputFile, [string]$OutputFile, [array]$AudioTracks, [int]$AudioBitrate, [int]$VideoBitrate, [bool]$CopyMode, [array]$SubtitleTracks, [double]$Duration = 0, $VideoStream = $null ) $arguments = New-Object System.Collections.Generic.List[string] [void]$arguments.Add( '-hide_banner' ) [void]$arguments.Add( '-y' ) [void]$arguments.Add( '-i' ) [void]$arguments.Add( '"' + $InputFile.Replace('"','\"') + '"' ) [void]$arguments.Add( '-map_chapters' ) [void]$arguments.Add( '0' ) # ======================================================== # VIDEO # ======================================================== [void]$arguments.Add( '-map' ) [void]$arguments.Add( '0:v' ) # ======================================================== # AUDIO # ======================================================== foreach ($audio in $AudioTracks) { [void]$arguments.Add( '-map' ) [void]$arguments.Add( "0:$($audio.Index)" ) } # ======================================================== # SUBTITULOS # ======================================================== foreach ($sub in $SubtitleTracks) { if ($sub.Compatible) { [void]$arguments.Add( '-map' ) [void]$arguments.Add( "0:$($sub.Index)?" ) } } # ======================================================== # CODECS # ======================================================== if ($CopyMode) { [void]$arguments.Add( '-c:v' ) [void]$arguments.Add( 'copy' ) [void]$arguments.Add( '-c:a' ) [void]$arguments.Add( 'copy' ) } else { [void]$arguments.Add( '-c:v' ) [void]$arguments.Add( 'hevc_nvenc' ) [void]$arguments.Add( '-b:v' ) [void]$arguments.Add( "${VideoBitrate}k" ) [void]$arguments.Add( '-preset' ) [void]$arguments.Add( 'p7' ) # P7 ya es el preset de máxima calidad de NVENC. Estas opciones # completan el perfil de calidad para un uso de archivo/almacenamiento: # HQ explícito, VBR explícito, doble pasada a resolución completa y # cuantización adaptativa espacial. No cambian el bitrate objetivo. [void]$arguments.Add('-tune') [void]$arguments.Add('hq') [void]$arguments.Add('-rc') [void]$arguments.Add('vbr') [void]$arguments.Add('-multipass') [void]$arguments.Add('fullres') [void]$arguments.Add('-spatial-aq') [void]$arguments.Add('1') [void]$arguments.Add('-aq-strength') [void]$arguments.Add('10') [void]$arguments.Add( '-pix_fmt' ) # 8-bit se mantiene en 8-bit; 10-bit se mantiene en 10-bit; # 12-bit se reduce a 10-bit porque HEVC NVENC no ofrece salida 12-bit. if ([string]$script:CurrentVideoBitDepth -eq '10-bit' -or [string]$script:CurrentVideoBitDepth -eq '12-bit') { [void]$arguments.Add('p010le') } else { [void]$arguments.Add('yuv420p') } # HEVC Main 10 para 10/12-bit (12-bit de entrada se reduce a 10-bit); # Main para 8-bit. Evita depender de una selección implícita del perfil. [void]$arguments.Add('-profile:v') if ([string]$script:CurrentVideoBitDepth -eq '10-bit' -or [string]$script:CurrentVideoBitDepth -eq '12-bit') { [void]$arguments.Add('main10') } else { [void]$arguments.Add('main') } [void]$arguments.Add( '-tag:v' ) [void]$arguments.Add( 'hvc1' ) # Conservar explícitamente las características de color declaradas # por el vídeo de origen. Se reutiliza el mismo stream que FFprobe ya # obtuvo durante Analyze-File: no se vuelve a analizar el archivo. try { foreach ($pair in @( @('-color_primaries', [string]$VideoStream.color_primaries), @('-color_trc', [string]$VideoStream.color_transfer), @('-colorspace', [string]$VideoStream.color_space), @('-color_range', [string]$VideoStream.color_range) )) { if (-not [string]::IsNullOrWhiteSpace($pair[1]) -and $pair[1] -ne 'unknown') { [void]$arguments.Add($pair[0]) [void]$arguments.Add($pair[1]) } } } catch {} # Copiar los metadatos del contenedor de origen. FFmpeg/NVENC puede # transportar además el side data HDR disponible durante la decodificación # (por ejemplo, mastering display y MaxCLL/MaxFALL cuando está presente). [void]$arguments.Add('-map_metadata') [void]$arguments.Add('0') [void]$arguments.Add( '-c:a' ) [void]$arguments.Add( 'aac' ) [void]$arguments.Add( '-b:a' ) [void]$arguments.Add( "${AudioBitrate}k" ) } # ======================================================== # SUBTITULOS # ======================================================== if ($SubtitleTracks.Count -gt 0) { [void]$arguments.Add( '-c:s' ) [void]$arguments.Add( 'mov_text' ) } else { [void]$arguments.Add( '-sn' ) } # ======================================================== # PROGRESO FFMPEG # # Se usa un archivo temporal, no pipe:1. De este modo no hay # callbacks DataReceived que puedan cerrar PowerShell y dejar # FFmpeg convirtiendo sin interfaz. # ======================================================== $progressFile = Join-Path ([System.IO.Path]::GetTempPath()) ( 'MKVConverter-' + [Guid]::NewGuid().ToString('N') + '.progress' ) [void]$arguments.Add('-progress') [void]$arguments.Add('"' + $progressFile.Replace('"','\"') + '"') [void]$arguments.Add('-nostats') [void]$arguments.Add('-stats_period') [void]$arguments.Add('0.25') # ======================================================== # ARCHIVO SALIDA # ======================================================== [void]$arguments.Add( '"' + $OutputFile.Replace('"','\"') + '"' ) $argString = $arguments -join ' ' $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $FFmpeg $psi.Arguments = $argString $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $false $psi.RedirectStandardError = $false $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi # Conservamos una referencia para no dejar FFmpeg huérfano si se cierra la ventana. $script:ActiveFfmpegProcess = $process $script:FFmpegLastError = '' $script:FFmpegProgressSeconds = 0.0 $script:FFmpegProgressDuration = $Duration $script:FFmpegSpeed = 0.0 $script:CurrentCopyMode = $CopyMode $script:CurrentOutputFile = $OutputFile $script:OutputSampleAt = $null $script:OutputSampleBytes = 0 $script:OutputBytesPerSecond = 0.0 try { $script:CurrentInputBytes = [Int64]( Get-Item ` -LiteralPath $InputFile ).Length } catch { $script:CurrentInputBytes = 0 } # ======================================================== # INICIAR Y VIGILAR FFMPEG # ======================================================== try { [void]$process.Start() } catch { throw ('No se pudo iniciar FFmpeg.`r`n`r`n' + $_.Exception.Message) } try { while (-not $process.HasExited) { [System.Windows.Forms.Application]::DoEvents() # Leer este archivo es seguro aunque FFmpeg lo esté escribiendo. # Si justo está bloqueado, se ignora ese ciclo y se vuelve a leer. try { if (Test-Path -LiteralPath $progressFile) { $progressText = [System.IO.File]::ReadAllText( $progressFile, [System.Text.Encoding]::ASCII ) if ($progressText -match '(?m)^out_time_ms=(\d+)\s*$') { [double]$timeUs = 0 if ([double]::TryParse( $Matches[1], [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$timeUs )) { $script:FFmpegProgressSeconds = $timeUs / 1000000 } } if ($progressText -match '(?m)^speed=([0-9]+(?:\.[0-9]+)?)x\s*$') { [double]$ffmpegSpeed = 0 if ([double]::TryParse( $Matches[1], [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$ffmpegSpeed )) { if ($ffmpegSpeed -gt 0) { $script:FFmpegSpeed = $ffmpegSpeed } } } } } catch {} Update-ProgressUi if ($script:CancelRequested) { try { $process.Kill() } catch {} throw 'Conversión cancelada por el usuario.' } Start-Sleep -Milliseconds 80 } try { $process.WaitForExit() } catch {} if ($script:CancelRequested) { throw 'Conversión cancelada por el usuario.' } } finally { $script:ActiveFfmpegProcess = $null try { if (Test-Path -LiteralPath $progressFile) { Remove-Item -LiteralPath $progressFile -Force -ErrorAction Stop } } catch {} } # ======================================================== # COMPROBAR RESULTADO # ======================================================== $exitCode = $process.ExitCode if ($exitCode -ne 0) { $lastError = $script:FFmpegLastError.Trim() if ( [string]::IsNullOrWhiteSpace( $lastError ) ) { $lastError = 'FFmpeg no proporcionó información adicional.' } throw ( "FFmpeg terminó con código $exitCode.`r`n`r`n" + $lastError ) } if ( -not ( Test-Path ` -LiteralPath $OutputFile ) ) { throw ( 'FFmpeg terminó correctamente pero no se encontró ' + 'el MP4 de salida.' ) } # ======================================================== # FINAL # ======================================================== $progress.Value = 100 $lblProgress.Text = '100%' $lblStatus.Text = 'Finalizando archivo...' $form.Refresh() Start-Sleep ` -Milliseconds 100 [System.Windows.Forms.Application]::DoEvents() } # ============================================================ # CONVERTIR # ============================================================ $btnConvert.Add_Click({ if ($script:Converting) { if (-not $script:CancelRequested) { $script:CancelRequested = $true $btnConvert.Enabled = $false $btnConvert.Text = '⏳ ABORTANDO...' $lblStatus.Text = 'Abortando conversión y eliminando el MP4 parcial...' $form.Refresh() try { if ($null -ne $script:ActiveFfmpegProcess -and -not $script:ActiveFfmpegProcess.HasExited) { $script:ActiveFfmpegProcess.Kill() } } catch {} } return } # -------------------------------------------------------- # SI YA TERMINO, EL BOTON SOLO ABRE LA CARPETA # -------------------------------------------------------- if ($script:ShowOutputFolder) { $folderToOpen = $txtOutput.Text.Trim() if ( Test-Path ` -LiteralPath $folderToOpen ) { Start-Process ` explorer.exe ` -ArgumentList ( '"' + $folderToOpen + '"' ) } return } # -------------------------------------------------------- # EVITAR DOBLE CONVERSION # -------------------------------------------------------- if ($script:Converting) { return } # -------------------------------------------------------- # COMPROBAR ARCHIVOS # -------------------------------------------------------- if ($script:Files.Count -eq 0) { Show-Info ` 'Añade al menos un archivo MKV.' return } $outputDirectory = $txtOutput.Text.Trim() if ( [string]::IsNullOrWhiteSpace( $outputDirectory ) ) { Show-Error ` 'Selecciona una carpeta de salida.' return } if ( -not ( Test-Path ` -LiteralPath $outputDirectory ) ) { Show-Error ` 'La carpeta de salida no existe.' return } # ======================================================== # INICIAR # ======================================================== $script:Converting = $true $script:CancelRequested = $false $script:ShowOutputFolder = $false $script:FFmpegProgressSeconds = 0.0 $script:FFmpegProgressDuration = 0.0 $script:FFmpegSpeed = 0.0 $script:CurrentOutputFile = '' $script:CurrentInputBytes = 0 $script:CurrentCopyMode = $false $btnConvert.Enabled = $false $btnConvert.Text = '⏳ CONVIRTIENDO...' $btnConvert.Image = $null $btnConvert.BackColor = [System.Drawing.Color]::FromArgb( 100, 100, 45 ) # Restaurar el hover verde del boton CONVERTIR # para que al iniciar una nueva conversion no herede # el hover azul del boton MOSTRAR CARPETA. $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(42,108,76) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(31,82,59) $btnConvert.Image = $null $btnConvert.ImageAlign = [System.Drawing.ContentAlignment]::MiddleCenter $btnConvert.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $btnConvert.TextImageRelation = [System.Windows.Forms.TextImageRelation]::Overlay $btnConvert.Padding = New-Object System.Windows.Forms.Padding(0) $btnConvert.Enabled = $true $btnConvert.Text = '■ ABORTAR MISIÓN' $btnConvert.BackColor = [System.Drawing.Color]::FromArgb(175,45,45) # Mismo nivel proporcional de oscurecimiento que el hover verde. $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(145,37,37) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(145,37,37) $btnAdd.Enabled = $false # Durante la conversión no se permite cambiar la carpeta de salida # ni escribir manualmente una ruta. $btnBrowse.Enabled = $false $txtOutput.Enabled = $false $btnRemove.Enabled = $false $btnClear.Enabled = $false # Durante la conversión no se permite modificar las pistas de audio ni el tamaño objetivo. $comboAudio.Enabled = $false $trkTargetSize.Enabled = $false $lblTargetSizeValue.Enabled = $false try { # ==================================================== # PROCESAR TODOS LOS ARCHIVOS # ==================================================== for ( $fileIndex = 0; $fileIndex -lt $script:Files.Count; $fileIndex++ ) { if ($script:CancelRequested) { throw ( 'Conversión cancelada por el usuario.' ) } $inputFile = $script:Files[$fileIndex] $listFiles.SelectedIndex = $fileIndex [System.Windows.Forms.Application]::DoEvents() $data = Analyze-File $inputFile $audioTracks = @($data.AudioTracks) $subtitleTracks = @( $data.SubtitleTracks | Where-Object { $_.Compatible } ) if ($audioTracks.Count -eq 0) { Show-Error ( "El archivo no contiene pistas de audio:`r`n`r`n" + $data.Name ) continue } # ================================================= # DETERMINAR MODO # ================================================= $copyMode = ( $data.SizeBytes -le $script:TargetSizeBytes ) # ================================================= # MODO COPIA DIRECTA # ================================================= if ($copyMode) { # TODOS LOS AUDIOS $selectedAudios = @($audioTracks) $audioBitrate = 0 foreach ( $selectedAudio in $selectedAudios ) { $audioBitrate += Get-SelectedAudioBitrate ` $selectedAudio } $videoBitrate = 0 $copyBitrateText = [string]$data.VideoBitrate if ([string]::IsNullOrWhiteSpace($copyBitrateText)) { $lblMode.Text = 'Modo: COPIA DIRECTA' } else { $lblMode.Text = 'Modo: COPIA DIRECTA | Bitrate de vídeo: {0}' -f $copyBitrateText } if ([Int64]$data.SizeBytes -le [Int64]$script:TargetSizeBytes) { $lblLimit.Text = 'Tamaño estimado: {0}' -f $data.SizeText $lblLimit.Visible = $true } $lblStatus.Text = ('Archivo ≤ {0} bytes. Copiando vídeo y TODOS los audios...' -f $script:TargetSizeBytes) $form.Refresh() } # ================================================= # MODO NVENC # ================================================= else { $selectedAudioIndices = @( $comboAudio.CheckedIndices ) $selectedAudios = @() foreach ( $idx in $selectedAudioIndices ) { if ( $idx -ge 0 -and $idx -lt $audioTracks.Count ) { $selectedAudios += $audioTracks[$idx] } } if ($selectedAudios.Count -eq 0) { $selectedAudios = @($audioTracks[0]) if ( $fileIndex -eq $listFiles.SelectedIndex ) { $comboAudio.SetItemCheckState( 0, [System.Windows.Forms.CheckState]::Checked ) } } $audioBitrate = 0 foreach ( $selectedAudio in $selectedAudios ) { $audioBitrate += Get-SelectedAudioBitrate ` $selectedAudio } $videoBitrate = Get-VideoBitrate ` -Duration $data.Duration ` -AudioKbps $audioBitrate $lblMode.Text = ( 'Modo: NVENC HEVC | Bitrate de vídeo: {0:N0} Kbps' -f $videoBitrate ) $estimatedOutputMB = (($videoBitrate + $audioBitrate) * $data.Duration) / 8000 if ($estimatedOutputMB -lt 0) { $estimatedOutputMB = 0 } $lblLimit.Text = 'Tamaño estimado: {0:N0} MB' -f $estimatedOutputMB $lblLimit.Visible = $true $lblStatus.Text = 'Preparando codificación NVIDIA NVENC...' $form.Refresh() } # ================================================= # GENERAR SALIDA # ================================================= $outputFile = Get-SafeOutputPath ` -Directory $outputDirectory ` -BaseName $data.BaseName $script:CurrentOutputFile = $outputFile $script:CurrentInputBytes = $data.SizeBytes $script:CurrentCopyMode = $copyMode $progress.Value = 0 $lblProgress.Text = '0%' $lblStatus.Text = ( 'Convirtiendo: {0}' -f $data.Name ) $form.Refresh() # ================================================= # EJECUTAR # ================================================= $script:ActiveOutputFile = $outputFile $script:CurrentVideoBitDepth = [string]$data.VideoBitDepth Start-FFmpegConversion ` -InputFile $inputFile ` -OutputFile $outputFile ` -AudioTracks $selectedAudios ` -AudioBitrate $audioBitrate ` -VideoBitrate $videoBitrate ` -CopyMode $copyMode ` -SubtitleTracks $subtitleTracks ` -Duration $data.Duration ` -VideoStream $data.VideoStream # ================================================= $script:ActiveOutputFile = $null # RESULTADO # ================================================= $finalFile = Get-Item ` -LiteralPath $outputFile $sizeFinal = Format-Bytes $finalFile.Length $progress.Value = 100 $lblProgress.Text = '100%' $lblStatus.Text = ( '✓ LISTO: {0} | {1}' -f $finalFile.Name, $sizeFinal ) $form.Refresh() Start-Sleep ` -Milliseconds 300 [System.Windows.Forms.Application]::DoEvents() } # ==================================================== # TODO TERMINADO # # IMPORTANTE: # NO se cierra el programa. # ==================================================== $progress.Value = 100 $lblProgress.Text = '100%' $script:ShowOutputFolder = $true $lblMode.Text = '✓ LISTO — conversión terminada' $lblStatus.Text = 'Conversión terminada. El MP4 está listo en la carpeta de salida.' $btnConvert.Text = 'MOSTRAR CARPETA' $btnConvert.Image = $folderIcon $btnConvert.ImageAlign = [System.Drawing.ContentAlignment]::MiddleLeft $btnConvert.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $btnConvert.TextImageRelation = [System.Windows.Forms.TextImageRelation]::ImageBeforeText $btnConvert.Padding = New-Object System.Windows.Forms.Padding(10,0,10,0) $btnConvert.FlatAppearance.BorderSize = 1 $btnConvert.FlatAppearance.BorderColor = [System.Drawing.Color]::FromArgb(74,112,140) $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(132,170,198) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(82,119,148) $btnConvert.BackColor = [System.Drawing.Color]::FromArgb(104,140,166) $btnConvert.Enabled = $true $form.Refresh() [System.Windows.Forms.Application]::DoEvents() } catch { if ($_.Exception.Message -eq 'Conversión cancelada por el usuario.') { $partialFile = [string]$script:ActiveOutputFile $removed = $true if (-not [string]::IsNullOrWhiteSpace($partialFile)) { for ($attempt = 1; $attempt -le 20; $attempt++) { try { if (Test-Path -LiteralPath $partialFile) { Remove-Item -LiteralPath $partialFile -Force -ErrorAction Stop } } catch {} if (-not (Test-Path -LiteralPath $partialFile)) { break } Start-Sleep -Milliseconds 100 } $removed = -not (Test-Path -LiteralPath $partialFile) } $script:ActiveOutputFile = $null $lblStatus.Text = 'Conversión abortada.' $progress.Value = 0 $lblProgress.Text = '0%' if ($removed) { Show-Info "Conversión abortada.`r`n`r`nSe ha verificado que el MP4 parcial se ha eliminado." } else { Show-Error ("La conversión se abortó, pero no se pudo eliminar el archivo parcial:`r`n`r`n" + $partialFile) } } else { $lblStatus.Text = 'Se produjo un error.' Show-Error $_.Exception.Message } } finally { $script:Converting = $false $btnAdd.Enabled = $true # Volver a permitir cambiar la carpeta de salida al terminar. $btnBrowse.Enabled = $true $txtOutput.Enabled = $true $btnRemove.Enabled = $true $btnClear.Enabled = $true $trkTargetSize.Enabled = $true $lblTargetSizeValue.Enabled = $true if ($null -ne $script:CurrentFileData) { if ($script:SelectedFileCopyMode) { $comboAudio.Enabled = $false } else { $comboAudio.Enabled = $true } } else { $comboAudio.Enabled = $true } # ---------------------------------------------------- # SI TERMINO CORRECTAMENTE: # dejamos MOSTRAR CARPETA. # # SI HUBO ERROR/CANCELACION: # volvemos a CONVERTIR. # ---------------------------------------------------- if (-not $script:ShowOutputFolder) { $btnConvert.Enabled = $true $btnConvert.Image = $null $btnConvert.Text = '▶ CONVERTIR' $btnConvert.BackColor = [System.Drawing.Color]::FromArgb( 35, 145, 85 ) $btnConvert.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(42,108,76) $btnConvert.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(31,82,59) } } }) # ============================================================ # DOBLE CLICK EN ARCHIVO # ============================================================ $listFiles.Add_DoubleClick({ if ( -not $script:Converting -and $listFiles.SelectedIndex -ge 0 ) { Refresh-CurrentFile } }) # ============================================================ # ESC = CANCELAR # ============================================================ $form.Add_KeyDown({ if ( $_.KeyCode -eq [System.Windows.Forms.Keys]::Escape ) { if ($script:Converting) { $script:CancelRequested = $true } } }) $form.KeyPreview = $true # ============================================================ # CERRAR VENTANA # ============================================================ $form.Add_FormClosing({ if ($script:Converting) { $result = [System.Windows.Forms.MessageBox]::Show( 'Hay una conversión en curso. ¿Quieres salir?', 'MKV Converter', [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning ) if ( $result -ne [System.Windows.Forms.DialogResult]::Yes ) { $_.Cancel = $true return } $script:CancelRequested = $true $script:AllowClose = $true return } # -------------------------------------------------------- # Si NO está convirtiendo, cerrar normalmente. # -------------------------------------------------------- $script:AllowClose = $true }) # ============================================================ # ============================================================ # PROTEGER CIERRE # ============================================================ $form.Add_FormClosed({ try { if ($null -ne $script:ActiveFfmpegProcess -and -not $script:ActiveFfmpegProcess.HasExited) { $script:ActiveFfmpegProcess.Kill() } } catch {} }) # ============================================================ # ADAPTACION RESPONSIVA REAL A LA PANTALLA Y A LA VENTANA # El diseño usa 1536x1024 como referencia, pero escala X e Y de # forma independiente. Así aprovecha también todo el ancho disponible # en pantallas panorámicas y se reajusta cada vez que la ventana cambia. # ============================================================ $form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None $script:BaseClientWidth = 1536 $script:BaseClientHeight = 1024 $script:LayoutSnapshot = New-Object 'System.Collections.Generic.List[object]' function Save-LayoutSnapshot { param([System.Windows.Forms.Control]$root) $script:LayoutSnapshot.Clear() function Save-ControlTree { param([System.Windows.Forms.Control]$parent) foreach ($c in $parent.Controls) { $fontSize = 0.0 $fontStyle = [System.Drawing.FontStyle]::Regular $fontName = 'Segoe UI' try { $fontSize = [double]$c.Font.Size $fontStyle = $c.Font.Style $fontName = $c.Font.FontFamily.Name } catch {} $script:LayoutSnapshot.Add([pscustomobject]@{ Control = $c X = [int]$c.Left Y = [int]$c.Top W = [int]$c.Width H = [int]$c.Height FontName = $fontName FontSize = $fontSize FontStyle = $fontStyle }) | Out-Null if ($c.Controls.Count -gt 0) { Save-ControlTree $c } } } Save-ControlTree $root } function Apply-ResponsiveLayout { param( [int]$targetW = 0, [int]$targetH = 0 ) if ($script:LayoutSnapshot.Count -eq 0) { return } if ($targetW -le 0) { $targetW = $form.ClientSize.Width } if ($targetH -le 0) { $targetH = $form.ClientSize.Height } $targetW = [Math]::Max(800,$targetW) $targetH = [Math]::Max(600,$targetH) # Escala independiente X/Y: en una pantalla panorámica se aprovecha # todo el ancho en vez de dejar una franja vacía a la derecha. $scaleX = [double]$targetW / [double]$script:BaseClientWidth $scaleY = [double]$targetH / [double]$script:BaseClientHeight if ($scaleX -le 0) { $scaleX = 1.0 } if ($scaleY -le 0) { $scaleY = 1.0 } $form.SuspendLayout() try { foreach ($item in $script:LayoutSnapshot) { $c = $item.Control if ($null -eq $c -or $c.IsDisposed) { continue } $c.Location = New-Object System.Drawing.Point( [int][Math]::Round($item.X * $scaleX), [int][Math]::Round($item.Y * $scaleY) ) $c.Size = New-Object System.Drawing.Size( [Math]::Max(1,[int][Math]::Round($item.W * $scaleX)), [Math]::Max(1,[int][Math]::Round($item.H * $scaleY)) ) if ($item.FontSize -gt 0) { try { # La fuente usa la escala menor para conservar legibilidad # aunque la pantalla tenga una relación de aspecto distinta. $fontScale = [Math]::Min($scaleX,$scaleY) $newSize = [Math]::Max(6.0,$item.FontSize * $fontScale) $c.Font = New-Object System.Drawing.Font($item.FontName,$newSize,$item.FontStyle) } catch {} } } } finally { $form.ResumeLayout($true) } } Save-LayoutSnapshot $form $script:ApplyingResponsiveLayout = $false $form.Add_Resize({ if ($script:ApplyingResponsiveLayout) { return } if ($form.WindowState -eq [System.Windows.Forms.FormWindowState]::Minimized) { return } $script:ApplyingResponsiveLayout = $true try { # Al cambiar manualmente el tamaño, el contenido se reajusta al # nuevo ClientSize exacto de la ventana. Apply-ResponsiveLayout $form.ClientSize.Width $form.ClientSize.Height } finally { $script:ApplyingResponsiveLayout = $false } }) # MOSTRAR # ============================================================ $form.Add_Shown({ $work = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea $form.StartPosition = 'Manual' $form.Location = New-Object System.Drawing.Point($work.X,$work.Y) $form.ClientSize = New-Object System.Drawing.Size($work.Width,$work.Height) Apply-ResponsiveLayout $work.Width $work.Height $form.Activate() }) # Application.Run es el bucle de mensajes nativo de Windows. # La ventana no se cierra ni el BAT continúa hasta que el usuario # la cierre explícitamente. Register-TargetSizeCommitOnClick $form [System.Windows.Forms.Application]::Run($form)