357 lines
14 KiB
PowerShell
357 lines
14 KiB
PowerShell
param(
|
|
[string]$Target,
|
|
[string]$Source
|
|
)
|
|
|
|
# Satsuma converter. Called by Run.vbs from the right-click menu.
|
|
# Target is the format wanted (jpg, png, mp4, pdf ...). Source is the full path of the file.
|
|
|
|
$ErrorActionPreference = 'Continue'
|
|
$root = $PSScriptRoot
|
|
$logFile = Join-Path $root 'log.txt'
|
|
|
|
function Write-Log([string]$text) {
|
|
try {
|
|
if ((Test-Path -LiteralPath $logFile) -and ((Get-Item -LiteralPath $logFile).Length -gt 1048576)) {
|
|
Remove-Item -LiteralPath $logFile -Force
|
|
}
|
|
Add-Content -LiteralPath $logFile -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $text)
|
|
} catch { }
|
|
}
|
|
|
|
function Limit-Text([string]$s, [int]$max) {
|
|
if ($null -eq $s) { return '' }
|
|
$s = ($s -replace '\s+', ' ').Trim()
|
|
if ($s.Length -gt $max) { $s = $s.Substring(0, $max - 3) + '...' }
|
|
return $s
|
|
}
|
|
|
|
# ---------- tray notifications ----------
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
Add-Type -AssemblyName System.Drawing
|
|
$tray = New-Object System.Windows.Forms.NotifyIcon
|
|
$icoPath = Join-Path $root 'satsuma.ico'
|
|
if (Test-Path -LiteralPath $icoPath) {
|
|
$tray.Icon = New-Object System.Drawing.Icon($icoPath)
|
|
} else {
|
|
$tray.Icon = [System.Drawing.SystemIcons]::Information
|
|
}
|
|
$tray.Visible = $true
|
|
|
|
function Send-Note([string]$title, [string]$text, [string]$kind) {
|
|
try {
|
|
$tray.BalloonTipTitle = (Limit-Text $title 60)
|
|
$tray.BalloonTipText = (Limit-Text $text 250)
|
|
$tray.BalloonTipIcon = $kind
|
|
$tray.ShowBalloonTip(5000)
|
|
} catch { }
|
|
}
|
|
|
|
# ---------- helpers ----------
|
|
function Test-Nonempty([string]$p) {
|
|
return ((Test-Path -LiteralPath $p) -and ((Get-Item -LiteralPath $p).Length -gt 0))
|
|
}
|
|
|
|
function Remove-IfExists([string]$p) {
|
|
if (Test-Path -LiteralPath $p) {
|
|
Remove-Item -LiteralPath $p -Force -Recurse -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Get-OutPath([string]$src, [string]$ext) {
|
|
$dir = [System.IO.Path]::GetDirectoryName($src)
|
|
$base = [System.IO.Path]::GetFileNameWithoutExtension($src)
|
|
$candidate = Join-Path $dir ($base + '.' + $ext)
|
|
$n = 1
|
|
while (Test-Path -LiteralPath $candidate) {
|
|
$candidate = Join-Path $dir ('{0} ({1}).{2}' -f $base, $n, $ext)
|
|
$n++
|
|
}
|
|
return $candidate
|
|
}
|
|
|
|
function Invoke-Tool([string]$exe, [string[]]$arguments) {
|
|
# Turn every output line (including error lines) into plain text so messages stay readable.
|
|
$text = & $exe @arguments 2>&1 | ForEach-Object { $_.ToString() } | Out-String
|
|
$code = $LASTEXITCODE
|
|
return @{ Code = $code; Output = $text }
|
|
}
|
|
|
|
function Get-Ffmpeg {
|
|
$p = Join-Path $root 'bin\ffmpeg.exe'
|
|
if (Test-Path -LiteralPath $p) { return $p }
|
|
$c = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue
|
|
if ($c) { return $c.Source }
|
|
throw 'ffmpeg was not found. Run Install.bat again.'
|
|
}
|
|
|
|
function Get-Magick {
|
|
$dir = Join-Path $root 'bin\magick'
|
|
if (Test-Path -LiteralPath $dir) {
|
|
$f = Get-ChildItem -LiteralPath $dir -Filter 'magick.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($f) { return $f.FullName }
|
|
}
|
|
foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
|
|
if ($base -and (Test-Path -LiteralPath $base)) {
|
|
$d = Get-ChildItem -LiteralPath $base -Filter 'ImageMagick*' -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1
|
|
if ($d) {
|
|
$m = Join-Path $d.FullName 'magick.exe'
|
|
if (Test-Path -LiteralPath $m) { return $m }
|
|
}
|
|
}
|
|
}
|
|
$c = Get-Command magick.exe -ErrorAction SilentlyContinue
|
|
if ($c) { return $c.Source }
|
|
throw 'ImageMagick was not found. Run Install.bat again.'
|
|
}
|
|
|
|
function Find-Soffice {
|
|
$candidates = @(
|
|
(Join-Path $env:ProgramFiles 'LibreOffice\program\soffice.exe'),
|
|
(Join-Path ${env:ProgramFiles(x86)} 'LibreOffice\program\soffice.exe')
|
|
)
|
|
foreach ($c in $candidates) {
|
|
if ($c -and (Test-Path -LiteralPath $c)) { return $c }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Free-Com($obj) {
|
|
if ($null -ne $obj) {
|
|
try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($obj) } catch { }
|
|
}
|
|
}
|
|
|
|
# ---------- video ----------
|
|
function Convert-Video([string]$src, [string]$out, [string]$t) {
|
|
$ff = Get-Ffmpeg
|
|
$pre = @('-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', $src, '-map', '0:v:0', '-map', '0:a?')
|
|
# Several encoders refuse odd sizes (321x241 for example), so round every size down to even.
|
|
$evenSize = @('-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2')
|
|
$x264 = @('-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-pix_fmt', 'yuv420p')
|
|
$aac = @('-c:a', 'aac', '-b:a', '192k')
|
|
|
|
switch ($t) {
|
|
'mp4' { $enc = $x264 + $aac + @('-movflags', '+faststart') }
|
|
'mov' { $enc = $x264 + $aac }
|
|
'mkv' { $enc = $x264 + $aac }
|
|
'flv' { $enc = $x264 + $aac + @('-ar', '44100') }
|
|
'webm' { $enc = @('-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0', '-row-mt', '1', '-cpu-used', '4', '-pix_fmt', 'yuv420p', '-c:a', 'libopus', '-b:a', '128k') }
|
|
'avi' { $enc = @('-c:v', 'mpeg4', '-q:v', '3', '-pix_fmt', 'yuv420p', '-c:a', 'libmp3lame', '-q:a', '3', '-ac', '2') }
|
|
'wmv' { $enc = @('-c:v', 'wmv2', '-b:v', '5M', '-pix_fmt', 'yuv420p', '-c:a', 'wmav2', '-b:a', '192k', '-ac', '2') }
|
|
'mpg' { $enc = @('-c:v', 'mpeg2video', '-q:v', '3', '-pix_fmt', 'yuv420p', '-c:a', 'mp2', '-b:a', '192k', '-ar', '48000', '-ac', '2') }
|
|
default { throw ('Unsupported video format: ' + $t) }
|
|
}
|
|
|
|
# MKV can hold nearly anything, so try a fast lossless copy first.
|
|
if ($t -eq 'mkv') {
|
|
$r = Invoke-Tool $ff ($pre + @('-c', 'copy', $out))
|
|
if (($r.Code -eq 0) -and (Test-Nonempty $out)) { return }
|
|
Remove-IfExists $out
|
|
}
|
|
|
|
$r = Invoke-Tool $ff ($pre + $evenSize + $enc + @($out))
|
|
if (($r.Code -ne 0) -or -not (Test-Nonempty $out)) {
|
|
Remove-IfExists $out
|
|
throw ('ffmpeg failed: ' + (Limit-Text $r.Output 200))
|
|
}
|
|
}
|
|
|
|
# ---------- audio (also pulls the sound out of a video) ----------
|
|
function Convert-Audio([string]$src, [string]$out, [string]$t) {
|
|
$ff = Get-Ffmpeg
|
|
$pre = @('-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', $src, '-vn', '-map', '0:a:0')
|
|
|
|
switch ($t) {
|
|
'mp3' { $enc = @('-c:a', 'libmp3lame', '-q:a', '2') }
|
|
'wav' { $enc = @('-c:a', 'pcm_s16le') }
|
|
'flac' { $enc = @('-c:a', 'flac') }
|
|
'ogg' { $enc = @('-c:a', 'libvorbis', '-q:a', '5') }
|
|
'm4a' { $enc = @('-c:a', 'aac', '-b:a', '192k') }
|
|
'opus' { $enc = @('-c:a', 'libopus', '-b:a', '128k') }
|
|
default { throw ('Unsupported audio format: ' + $t) }
|
|
}
|
|
|
|
$r = Invoke-Tool $ff ($pre + $enc + @($out))
|
|
if (($r.Code -ne 0) -or -not (Test-Nonempty $out)) {
|
|
Remove-IfExists $out
|
|
throw ('ffmpeg failed: ' + (Limit-Text $r.Output 200))
|
|
}
|
|
}
|
|
|
|
# ---------- images ----------
|
|
function Convert-Image([string]$src, [string]$out, [string]$srcExt, [string]$t) {
|
|
$mg = Get-Magick
|
|
$stamp = [guid]::NewGuid().ToString('N')
|
|
# Work on safe temp names so odd characters in file names cannot confuse ImageMagick.
|
|
$tmpIn = Join-Path $env:TEMP ('satsuma_{0}_in.{1}' -f $stamp, $srcExt)
|
|
$tmpOut = Join-Path $env:TEMP ('satsuma_{0}_out.{1}' -f $stamp, $t)
|
|
try {
|
|
Copy-Item -LiteralPath $src -Destination $tmpIn -Force
|
|
$mArgs = @(($tmpIn + '[0]'), '-auto-orient')
|
|
switch ($t) {
|
|
'jpg' { $mArgs += @('-background', 'white', '-alpha', 'remove', '-alpha', 'off', '-quality', '92') }
|
|
'bmp' { $mArgs += @('-background', 'white', '-alpha', 'remove', '-alpha', 'off') }
|
|
'pdf' { $mArgs += @('-background', 'white', '-alpha', 'remove', '-alpha', 'off', '-quality', '92') }
|
|
'webp' { $mArgs += @('-quality', '90') }
|
|
'tiff' { $mArgs += @('-compress', 'lzw') }
|
|
'png' { }
|
|
'gif' { }
|
|
default { throw ('Unsupported image format: ' + $t) }
|
|
}
|
|
$mArgs += $tmpOut
|
|
$r = Invoke-Tool $mg $mArgs
|
|
if (($r.Code -ne 0) -or -not (Test-Nonempty $tmpOut)) {
|
|
throw ('ImageMagick failed: ' + (Limit-Text $r.Output 200))
|
|
}
|
|
Move-Item -LiteralPath $tmpOut -Destination $out -Force
|
|
} finally {
|
|
Remove-IfExists $tmpIn
|
|
Remove-IfExists $tmpOut
|
|
}
|
|
}
|
|
|
|
# ---------- documents to PDF ----------
|
|
function Convert-WithWord([string]$src, [string]$out) {
|
|
$app = $null
|
|
$doc = $null
|
|
try {
|
|
$app = New-Object -ComObject Word.Application
|
|
$app.Visible = $false
|
|
$app.DisplayAlerts = 0
|
|
# The odd password stops Word from showing a password box nobody can see.
|
|
$doc = $app.Documents.Open($src, $false, $true, $false, 'satsuma-no-password')
|
|
$doc.ExportAsFixedFormat($out, 17)
|
|
} finally {
|
|
if ($null -ne $doc) { try { $doc.Close(0) } catch { } }
|
|
if ($null -ne $app) { try { $app.Quit(0) } catch { } }
|
|
Free-Com $doc
|
|
Free-Com $app
|
|
}
|
|
}
|
|
|
|
function Convert-WithExcel([string]$src, [string]$out) {
|
|
$app = $null
|
|
$book = $null
|
|
try {
|
|
$app = New-Object -ComObject Excel.Application
|
|
$app.Visible = $false
|
|
$app.DisplayAlerts = $false
|
|
$missing = [System.Reflection.Missing]::Value
|
|
$book = $app.Workbooks.Open($src, 0, $true, $missing, 'satsuma-no-password')
|
|
$book.ExportAsFixedFormat(0, $out)
|
|
} finally {
|
|
if ($null -ne $book) { try { $book.Close($false) } catch { } }
|
|
if ($null -ne $app) { try { $app.Quit() } catch { } }
|
|
Free-Com $book
|
|
Free-Com $app
|
|
}
|
|
}
|
|
|
|
function Convert-WithPowerPoint([string]$src, [string]$out) {
|
|
$app = $null
|
|
$pres = $null
|
|
try {
|
|
$app = New-Object -ComObject PowerPoint.Application
|
|
$pres = $app.Presentations.Open($src, -1, 0, 0)
|
|
$pres.SaveAs($out, 32)
|
|
} finally {
|
|
if ($null -ne $pres) { try { $pres.Close() } catch { } }
|
|
if ($null -ne $app) { try { $app.Quit() } catch { } }
|
|
Free-Com $pres
|
|
Free-Com $app
|
|
}
|
|
}
|
|
|
|
function Convert-Document([string]$src, [string]$out, [string]$ext) {
|
|
$wordExts = @('doc', 'docx', 'rtf', 'txt', 'odt')
|
|
$excelExts = @('xls', 'xlsx', 'ods')
|
|
$slideExts = @('ppt', 'pptx', 'odp')
|
|
$problems = @()
|
|
|
|
try {
|
|
if ($wordExts -contains $ext) { Convert-WithWord $src $out }
|
|
elseif ($excelExts -contains $ext) { Convert-WithExcel $src $out }
|
|
elseif ($slideExts -contains $ext) { Convert-WithPowerPoint $src $out }
|
|
else { throw ('Unsupported document type: ' + $ext) }
|
|
} catch {
|
|
$problems += $_.Exception.Message
|
|
}
|
|
if (Test-Nonempty $out) { return }
|
|
Remove-IfExists $out
|
|
|
|
$soffice = Find-Soffice
|
|
if ($soffice) {
|
|
$tmpDir = Join-Path $env:TEMP ('satsuma_' + [guid]::NewGuid().ToString('N'))
|
|
[void](New-Item -ItemType Directory -Force -Path $tmpDir)
|
|
try {
|
|
$r = Invoke-Tool $soffice @('--headless', '--convert-to', 'pdf', '--outdir', $tmpDir, $src)
|
|
$made = Join-Path $tmpDir ([System.IO.Path]::GetFileNameWithoutExtension($src) + '.pdf')
|
|
if (Test-Nonempty $made) {
|
|
Move-Item -LiteralPath $made -Destination $out -Force
|
|
return
|
|
}
|
|
$problems += ('LibreOffice: ' + $r.Output)
|
|
} finally {
|
|
Remove-IfExists $tmpDir
|
|
}
|
|
} else {
|
|
$problems += 'LibreOffice not installed'
|
|
}
|
|
|
|
throw ('Could not make a PDF. This needs Microsoft Office or LibreOffice installed. ' + (Limit-Text ($problems -join ' | ') 160))
|
|
}
|
|
|
|
# ---------- main ----------
|
|
$videoTargets = @('mp4', 'mkv', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mpg')
|
|
$audioTargets = @('mp3', 'wav', 'flac', 'ogg', 'm4a', 'opus')
|
|
$imageTargets = @('jpg', 'png', 'webp', 'bmp', 'tiff', 'gif')
|
|
$docExts = @('doc', 'docx', 'rtf', 'txt', 'odt', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp')
|
|
|
|
$mutex = New-Object System.Threading.Mutex($false, 'Local\SatsumaQueue')
|
|
$haveLock = $false
|
|
$exitCode = 0
|
|
$name = ''
|
|
|
|
try {
|
|
if ([string]::IsNullOrWhiteSpace($Target) -or [string]::IsNullOrWhiteSpace($Source)) {
|
|
throw 'Missing format or file name.'
|
|
}
|
|
$Target = $Target.ToLower()
|
|
$name = [System.IO.Path]::GetFileName($Source)
|
|
$srcExt = [System.IO.Path]::GetExtension($Source).TrimStart('.').ToLower()
|
|
|
|
# One conversion at a time, so picking many files does not overload the machine.
|
|
try { $haveLock = $mutex.WaitOne() } catch [System.Threading.AbandonedMutexException] { $haveLock = $true }
|
|
|
|
if (-not (Test-Path -LiteralPath $Source)) { throw ('File not found: ' + $Source) }
|
|
|
|
Send-Note 'Satsuma' ('Converting {0} to {1}' -f $name, $Target.ToUpper()) 'Info'
|
|
$out = Get-OutPath $Source $Target
|
|
|
|
if ($Target -eq 'pdf') {
|
|
if ($docExts -contains $srcExt) { Convert-Document $Source $out $srcExt }
|
|
else { Convert-Image $Source $out $srcExt 'pdf' }
|
|
}
|
|
elseif ($videoTargets -contains $Target) { Convert-Video $Source $out $Target }
|
|
elseif ($audioTargets -contains $Target) { Convert-Audio $Source $out $Target }
|
|
elseif ($imageTargets -contains $Target) { Convert-Image $Source $out $srcExt $Target }
|
|
else { throw ('Unknown format: ' + $Target) }
|
|
|
|
$outName = [System.IO.Path]::GetFileName($out)
|
|
Write-Log ('OK {0} -> {1}' -f $Source, $out)
|
|
Send-Note 'Satsuma: done' $outName 'Info'
|
|
}
|
|
catch {
|
|
$msg = $_.Exception.Message
|
|
Write-Log ('FAILED {0} -> {1}: {2}' -f $Source, $Target, $msg)
|
|
Send-Note 'Satsuma could not convert' ($name + ': ' + $msg) 'Error'
|
|
$exitCode = 1
|
|
}
|
|
finally {
|
|
if ($haveLock) { try { $mutex.ReleaseMutex() } catch { } }
|
|
Start-Sleep -Seconds 5
|
|
try { $tray.Visible = $false; $tray.Dispose() } catch { }
|
|
}
|
|
exit $exitCode
|