param( [string]$ConfigPath = "$PSScriptRoot\config.json" ) $ErrorActionPreference = "Continue" function Ensure-Folder { param([string]$Path) if (!(Test-Path $Path)) { New-Item -ItemType Directory -Force -Path $Path | Out-Null } } function Write-Log { param([string]$Message) $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $line = "[$ts] $Message" try { Write-Host $line } catch {} try { if ($script:LogFile) { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 } } catch {} } function Write-Ledger { param([hashtable]$Record) try { $Record["timestamp"] = (Get-Date).ToString("o") $json = $Record | ConvertTo-Json -Compress -Depth 8 Add-Content -Path $script:Config.ledger_path -Value $json -Encoding UTF8 } catch {} } function Load-Ledger { $done = @{} try { if (!(Test-Path $script:Config.ledger_path)) { return $done } foreach ($line in Get-Content -Path $script:Config.ledger_path -Encoding UTF8 -ErrorAction SilentlyContinue) { if ([string]::IsNullOrWhiteSpace($line)) { continue } try { $r = $line | ConvertFrom-Json if ($r.status -eq "done" -and $r.identity -and $r.preset) { $done["$($r.identity)|$($r.preset)"] = $true } } catch {} } } catch {} return $done } function Load-Config { param([string]$Path) if (!(Test-Path $Path)) { throw "Config file not found: $Path" } return (Get-Content -Path $Path -Raw -Encoding UTF8) | ConvertFrom-Json } function Resolve-FFmpeg { param([string]$ConfiguredPath) if ([string]::IsNullOrWhiteSpace($ConfiguredPath)) { $ConfiguredPath = "ffmpeg.exe" } $local = Join-Path $PSScriptRoot $ConfiguredPath if (Test-Path $local) { return $local } $cmd = Get-Command $ConfiguredPath -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } $cmd2 = Get-Command "ffmpeg.exe" -ErrorAction SilentlyContinue if ($cmd2) { return $cmd2.Source } throw "ffmpeg.exe not found. Put ffmpeg.exe in this folder or install FFmpeg in PATH." } function Get-Identity { param([System.IO.FileInfo]$File) return "$($File.FullName.ToLowerInvariant())|$($File.Length)|$($File.LastWriteTimeUtc.ToString('o'))" } function Is-File-Stable { param([System.IO.FileInfo]$File, [int]$MinimumAgeSeconds) try { $age = (Get-Date) - $File.LastWriteTime if ($age.TotalSeconds -lt $MinimumAgeSeconds) { return $false } $len1 = $File.Length Start-Sleep -Seconds 2 $fresh = Get-Item -LiteralPath $File.FullName -ErrorAction Stop return ($len1 -eq $fresh.Length) } catch { return $false } } function Run-FFmpeg { param( [string]$FFmpeg, [System.IO.FileInfo]$File, [string]$Preset, [string]$Output, [string[]]$Args ) $identity = Get-Identity -File $File $ledgerKey = "$identity|$Preset" if ($script:DoneJobs.ContainsKey($ledgerKey)) { Write-Log "SKIP ledger done: $Preset :: $($File.FullName)" return } if ((Test-Path $Output) -and $script:Config.skip_if_output_exists) { Write-Log "SKIP output exists: $Output" Write-Ledger @{status="done"; reason="output_exists"; preset=$Preset; source=$File.FullName; identity=$identity; source_size=$File.Length; output=$Output; output_size=(Get-Item $Output).Length} $script:DoneJobs[$ledgerKey] = $true return } Ensure-Folder (Split-Path -Parent $Output) Write-Ledger @{status="processing"; preset=$Preset; source=$File.FullName; identity=$identity; source_size=$File.Length; output=$Output} $allArgs = @("-y", "-hide_banner", "-i", $File.FullName) + $Args + @($Output) Write-Log "ENCODE $Preset -> $Output" $p = Start-Process -FilePath $FFmpeg -ArgumentList $allArgs -NoNewWindow -Wait -PassThru if ($p.ExitCode -eq 0 -and (Test-Path $Output)) { $size = (Get-Item $Output).Length Write-Log "DONE $Preset ($size bytes)" Write-Ledger @{status="done"; preset=$Preset; source=$File.FullName; identity=$identity; source_size=$File.Length; output=$Output; output_size=$size; exit_code=$p.ExitCode} $script:DoneJobs[$ledgerKey] = $true } else { Write-Log "FAILED $Preset exit=$($p.ExitCode): $($File.FullName)" Write-Ledger @{status="failed"; preset=$Preset; source=$File.FullName; identity=$identity; source_size=$File.Length; output=$Output; exit_code=$p.ExitCode} } } function Process-Video { param([System.IO.FileInfo]$File, [string]$FFmpeg) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($File.Name) $safeBase = $baseName -replace '[\\/:*?"<>|]', '_' if ($script:Config.presets.make_4k_mp4_original_quality_remux) { $outDir = Join-Path $script:Config.output_root "4K-MP4-OriginalQuality" $out = Join-Path $outDir ($safeBase + "_4K_OriginalQuality_faststart.mp4") $args = @("-map","0:v:0","-map","0:a:0?","-c","copy","-tag:v","hvc1","-movflags","+faststart") Run-FFmpeg -FFmpeg $FFmpeg -File $File -Preset "4K_MP4_ORIGINALQUALITY_REMUX" -Output $out -Args $args } if ($script:Config.presets.make_1080p_h264_fallback) { $outDir = Join-Path $script:Config.output_root "Fallback-1080p-H264" $out = Join-Path $outDir ($safeBase + "_FALLBACK_1080p_H264.mp4") $args = @("-map","0:v:0","-map","0:a:0?","-vf","scale=-2:1080","-c:v","h264_nvenc","-preset","p5","-rc","vbr","-cq","23","-b:v","0","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-movflags","+faststart") Run-FFmpeg -FFmpeg $FFmpeg -File $File -Preset "FALLBACK_1080p_H264" -Output $out -Args $args } if ($script:Config.presets.make_4k_h265_16mbps_streamable) { $outDir = Join-Path $script:Config.output_root "4K-H265-16Mbps-Streamable" $out = Join-Path $outDir ($safeBase + "_4K_H265_16Mbps_Streamable.mp4") $args = @("-map","0:v:0","-map","0:a:0?","-c:v","hevc_nvenc","-b:v","16M","-maxrate","16M","-bufsize","32M","-tag:v","hvc1","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-movflags","+faststart") Run-FFmpeg -FFmpeg $FFmpeg -File $File -Preset "4K_H265_16MBPS_STREAMABLE" -Output $out -Args $args } if ($script:Config.presets.make_4k_h265_30mbps_high_detail) { $outDir = Join-Path $script:Config.output_root "4K-H265-30Mbps-HighDetail" $out = Join-Path $outDir ($safeBase + "_4K_H265_30Mbps_HighDetail.mp4") $args = @("-map","0:v:0","-map","0:a:0?","-c:v","hevc_nvenc","-b:v","30M","-maxrate","30M","-bufsize","60M","-tag:v","hvc1","-pix_fmt","yuv420p","-c:a","aac","-b:a","192k","-movflags","+faststart") Run-FFmpeg -FFmpeg $FFmpeg -File $File -Preset "4K_H265_30MBPS_HIGHDETAIL" -Output $out -Args $args } if ($script:Config.presets.make_4k_h265_quality_based_screen_recording) { $outDir = Join-Path $script:Config.output_root "4K-H265-QualityBased-ScreenRecording" $out = Join-Path $outDir ($safeBase + "_4K_H265_QualityBased_ScreenRecording.mp4") $args = @("-map","0:v:0","-map","0:a:0?","-c:v","hevc_nvenc","-preset","p5","-rc","vbr","-cq","18","-b:v","0","-tag:v","hvc1","-pix_fmt","yuv420p","-c:a","aac","-b:a","192k","-movflags","+faststart") Run-FFmpeg -FFmpeg $FFmpeg -File $File -Preset "4K_H265_QUALITYBASED_SCREENRECORDING" -Output $out -Args $args } } try { $script:Config = Load-Config $ConfigPath Ensure-Folder $script:Config.output_root Ensure-Folder $script:Config.log_folder Ensure-Folder (Split-Path -Parent $script:Config.ledger_path) $script:LogFile = Join-Path $script:Config.log_folder ("AutoConverter_" + (Get-Date -Format "yyyyMMdd_HHmmss") + ".log") $ffmpeg = Resolve-FFmpeg $script:Config.ffmpeg_path $script:DoneJobs = Load-Ledger Write-Log "Open Source Auto-Converter started. Profile: $($script:Config.profile_name)" Write-Log "FFmpeg: $ffmpeg" Write-Log "Ledger done jobs loaded: $($script:DoneJobs.Count)" Write-Log "Polling every $($script:Config.poll_seconds) seconds." while ($true) { foreach ($folder in $script:Config.source_folders) { if (!(Test-Path $folder)) { continue } $files = Get-ChildItem -LiteralPath $folder -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $script:Config.extensions -contains $_.Extension } foreach ($file in $files) { if (!(Is-File-Stable -File $file -MinimumAgeSeconds ([int]$script:Config.minimum_file_age_seconds))) { continue } Write-Log "FOUND stable video: $($file.FullName)" Process-Video -File $file -FFmpeg $ffmpeg } } Start-Sleep -Seconds ([int]$script:Config.poll_seconds) } } catch { try { Write-Log "FATAL ERROR: $($_.Exception.Message)" } catch {} try { Write-Host "FATAL ERROR: $($_.Exception.Message)" -ForegroundColor Red; Read-Host "Press Enter" | Out-Null } catch {} }