Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
447 lines
22 KiB
Swift
447 lines
22 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import PDFKit
|
|
|
|
/// What a tool produced. `message` is for the read-only tools (Info, Read QR,
|
|
/// List Contents, Statistics) which show a result instead of writing a file.
|
|
struct ToolOutcome {
|
|
var outputs: [URL] = []
|
|
var message: String?
|
|
var messageTitle: String?
|
|
var revealInFinder: Bool = true
|
|
}
|
|
|
|
/// Runs a `ToolID` over one or more files. Every tool is written so that a
|
|
/// failure on one file does not silently swallow the rest: the first error is
|
|
/// reported after the successful outputs are collected.
|
|
enum ToolRunner {
|
|
static func run(
|
|
tool: ToolID,
|
|
inputs: [URL],
|
|
params: ToolParams,
|
|
cancellation: Cancellation? = nil,
|
|
progress: ((Double) -> Void)? = nil
|
|
) throws -> ToolOutcome {
|
|
guard inputs.count >= tool.minimumFileCount else {
|
|
throw ConversionError.failed("\(tool.title) needs at least \(tool.minimumFileCount) files.")
|
|
}
|
|
try cancellation?.check()
|
|
|
|
switch tool {
|
|
case .compressImage:
|
|
let quality = Double(params.int("quality", default: 70)) / 100
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let format = FileKind.format(of: source) ?? .jpg
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-compressed")
|
|
try ImageTools.compress(source, at: destination, format: format, quality: quality)
|
|
return destination
|
|
}
|
|
|
|
case .resizeImage:
|
|
let width = params.int("width", default: 1920)
|
|
let height = params.int("height", default: 1920)
|
|
let percent = params.int("percent", default: 100)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-resized")
|
|
try ImageTools.resize(source, at: destination, maxWidth: width, maxHeight: height, percent: percent)
|
|
return destination
|
|
}
|
|
|
|
case .cropImage:
|
|
let inset = params.int("inset", default: 0)
|
|
let aspect = params.string("aspect", default: "Keep")
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-cropped")
|
|
try ImageTools.crop(source, at: destination, inset: inset, aspect: aspect)
|
|
return destination
|
|
}
|
|
|
|
case .rotateImage:
|
|
let degrees = params.string("angle", default: "90° right").hasPrefix("90° r") ? 90 : (params.string("angle", default: "").hasPrefix("180") ? 180 : -90)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-rotated")
|
|
try ImageTools.rotate(source, at: destination, degrees: degrees)
|
|
return destination
|
|
}
|
|
|
|
case .flipImage:
|
|
let horizontal = params.string("axis", default: "Horizontal") == "Horizontal"
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-flipped")
|
|
try ImageTools.flip(source, at: destination, horizontal: horizontal)
|
|
return destination
|
|
}
|
|
|
|
case .grayscaleImage:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-gray", extension: "png")
|
|
try ImageTools.grayscale(source, at: destination)
|
|
return destination
|
|
}
|
|
|
|
case .stripMetadata:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let format = FileKind.format(of: source) ?? .jpg
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-clean")
|
|
try ImageTools.stripMetadata(source, at: destination, format: format)
|
|
return destination
|
|
}
|
|
|
|
case .watermarkImage:
|
|
let text = params.string("text", default: "©")
|
|
let position = params.string("position", default: "Bottom Right")
|
|
let opacity = Double(params.int("opacity", default: 45))
|
|
let size = Double(params.int("size", default: 16))
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-watermark")
|
|
try ImageTools.watermark(source, at: destination, text: text, position: position, opacity: opacity, sizePercent: size)
|
|
return destination
|
|
}
|
|
|
|
case .readQR:
|
|
var results: [String] = []
|
|
for source in inputs {
|
|
try cancellation?.check()
|
|
let payload = ImageTools.readQR(source)
|
|
results.append("\(source.lastPathComponent):\n\(payload ?? "No barcode found")")
|
|
}
|
|
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Barcode")
|
|
|
|
case .compressVideo:
|
|
let quality = params.string("preset", default: "Medium")
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let ext = container(for: source, fallback: "mp4")
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: ext)
|
|
try MediaTools.compressVideo(source, at: destination, quality: quality)
|
|
return destination
|
|
}
|
|
|
|
case .trimVideo, .trimAudio:
|
|
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
|
|
let end = Timecode.seconds(from: params.string("end", default: ""))
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let ext = container(for: source, fallback: tool == .trimAudio ? "m4a" : "mp4")
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-trimmed", extension: ext)
|
|
try trim(source, at: destination, start: start, end: end, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .muteVideo:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let ext = container(for: source, fallback: "mp4")
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-muted", extension: ext)
|
|
try AVConverter.mute(source: source, at: destination, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .speedVideo, .speedAudio:
|
|
let rate = params.double("rate", default: 2)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let ext = container(for: source, fallback: tool == .speedAudio ? "m4a" : "mp4")
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-\(rateString(rate))x", extension: ext)
|
|
try AVConverter.changeSpeed(source: source, at: destination, rate: rate, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .snapshotVideo:
|
|
let at = Timecode.seconds(from: params.string("at", default: "1")) ?? 1
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-frame", extension: "png")
|
|
try AVConverter.snapshot(source: source, at: destination, time: at, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .videoToGIF:
|
|
let fps = params.int("fps", default: 12)
|
|
let width = params.int("width", default: 480)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "gif")
|
|
try AVConverter.makeGIF(source: source, at: destination, fps: fps, maxWidth: width, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .resizeVideo:
|
|
let width = params.int("width", default: 1280)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let ext = container(for: source, fallback: "mp4")
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-resized", extension: ext)
|
|
try AVConverter.resize(source: source, at: destination, width: width, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .normalizeAudio:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = MediaTools.audioSibling(for: source, suffix: "-normalized")
|
|
try MediaTools.normalizeAudio(source, at: destination)
|
|
return destination
|
|
}
|
|
|
|
case .channelsAudio:
|
|
let mono = params.string("mode", default: "Mono") == "Mono"
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = MediaTools.audioSibling(for: source, suffix: mono ? "-mono" : "-stereo")
|
|
try MediaTools.channels(source, at: destination, mono: mono)
|
|
return destination
|
|
}
|
|
|
|
case .bleepAudio:
|
|
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
|
|
let end = Timecode.seconds(from: params.string("end", default: "1")) ?? 1
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = MediaTools.audioSibling(for: source, suffix: "-bleeped")
|
|
try MediaTools.bleep(source, at: destination, start: start, end: max(end, start + 0.05))
|
|
return destination
|
|
}
|
|
|
|
case .compressAudio:
|
|
let bitrate = params.string("bitrate", default: "128")
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = MediaTools.audioSibling(for: source, suffix: "-compressed")
|
|
try MediaTools.compressAudio(source, at: destination, bitrate: bitrate)
|
|
return destination
|
|
}
|
|
|
|
case .mergePDF:
|
|
let first = inputs[0]
|
|
let destination = OutputNaming.availableURL(
|
|
for: first.deletingLastPathComponent()
|
|
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf")
|
|
)
|
|
try PDFConverter.merge(inputs, at: destination, cancellation: cancellation)
|
|
progress?(1)
|
|
return ToolOutcome(outputs: [destination])
|
|
|
|
case .splitPDF:
|
|
let perFile = max(1, params.int("pages", default: 1))
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let directory = OutputNaming.artifactDirectory(for: source, suffix: " split")
|
|
let written = try PDFConverter.split(source, pagesPerFile: perFile, into: directory, cancellation: cancellation)
|
|
guard let first = written.first else { throw ConversionError.failed("Nothing to split.") }
|
|
return first
|
|
}
|
|
|
|
case .rotatePDF:
|
|
let angle = params.string("angle", default: "90° right")
|
|
let degrees = angle.hasPrefix("180") ? 180 : (angle.hasPrefix("90° l") ? -90 : 90)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-rotated", extension: "pdf")
|
|
try PDFConverter.rotate(source, degrees: degrees, at: destination)
|
|
return destination
|
|
}
|
|
|
|
case .compressPDF:
|
|
let quality = Double(params.int("quality", default: 55)) / 100
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: "pdf")
|
|
try PDFConverter.compress(source, at: destination, quality: quality, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .encryptPDF:
|
|
let password = params.string("password", default: "")
|
|
guard !password.isEmpty else { throw ConversionError.failed("Enter a password first.") }
|
|
let owner = params.string("owner", default: "")
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-encrypted", extension: "pdf")
|
|
try PDFConverter.encrypt(source, at: destination, password: password, ownerPassword: owner)
|
|
return destination
|
|
}
|
|
|
|
case .decryptPDF:
|
|
let password = params.string("password", default: "")
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-decrypted", extension: "pdf")
|
|
guard let document = PDFDocument(url: source),
|
|
document.unlock(withPassword: password) || !document.isLocked else {
|
|
throw ConversionError.failed("Wrong password or the PDF is not encrypted.")
|
|
}
|
|
guard document.write(to: destination) else {
|
|
throw ConversionError.failed("Could not write the decrypted PDF.")
|
|
}
|
|
return destination
|
|
}
|
|
|
|
case .imagesToPDF:
|
|
let images: [(CGImage, String?)] = try inputs.map { source in
|
|
guard let image = Raster.firstImage(at: source) else {
|
|
throw ConversionError.unreadable(source.lastPathComponent)
|
|
}
|
|
return (image, source.lastPathComponent)
|
|
}
|
|
let first = inputs[0]
|
|
let destination = OutputNaming.availableURL(
|
|
for: first.deletingLastPathComponent()
|
|
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " images.pdf")
|
|
)
|
|
try PDFConverter.imagesToPDF(images, at: destination, cancellation: cancellation)
|
|
progress?(1)
|
|
return ToolOutcome(outputs: [destination])
|
|
|
|
case .collageImages:
|
|
let columns = max(1, params.int("columns", default: 3))
|
|
let page = params.string("page", default: "A4")
|
|
let margin = Double(params.int("margin", default: 24))
|
|
let captions = params.bool("captions", default: false)
|
|
let entries: [(CGImage, String)] = try inputs.map { source in
|
|
guard let image = Raster.firstImage(at: source) else {
|
|
throw ConversionError.unreadable(source.lastPathComponent)
|
|
}
|
|
return (image, source.lastPathComponent)
|
|
}
|
|
let first = inputs[0]
|
|
let destination = OutputNaming.availableURL(
|
|
for: first.deletingLastPathComponent()
|
|
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " collage.pdf")
|
|
)
|
|
try PDFConverter.collage(
|
|
entries,
|
|
at: destination,
|
|
columns: columns,
|
|
page: page,
|
|
margin: margin,
|
|
captions: captions,
|
|
cancellation: cancellation
|
|
)
|
|
progress?(1)
|
|
return ToolOutcome(outputs: [destination])
|
|
|
|
case .extractPDFText:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let text = PDFConverter.extractText(source)
|
|
guard !text.trimmed.isEmpty else {
|
|
throw ConversionError.failed("No text layer in \(source.lastPathComponent).")
|
|
}
|
|
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "txt")
|
|
try TextDocument.write(text, to: destination)
|
|
return destination
|
|
}
|
|
|
|
case .documentToPDF:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "pdf")
|
|
try DocumentConverter.convert(source: source, to: .pdf, at: destination, cancellation: cancellation)
|
|
return destination
|
|
}
|
|
|
|
case .wordCount:
|
|
var results: [String] = []
|
|
for source in inputs {
|
|
try cancellation?.check()
|
|
let text = TextDocument.plainText(from: source) ?? ""
|
|
let words = text.split { $0 == " " || $0.isNewline }.count
|
|
let lines = text.components(separatedBy: "\n").count
|
|
results.append("""
|
|
\(source.lastPathComponent)
|
|
\(words) words · \(text.count) characters · \(lines) lines
|
|
""")
|
|
}
|
|
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Statistics")
|
|
|
|
case .sheetsToCSV:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let rows = try SpreadsheetConverter.read(source, cancellation: cancellation)
|
|
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "csv")
|
|
try SpreadsheetConverter.write(rows, to: destination, delimiter: ",")
|
|
return destination
|
|
}
|
|
|
|
case .shiftSubtitle:
|
|
let offset = params.double("offset", default: 0)
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let format = FileKind.format(of: source) ?? .srt
|
|
let cues = SubtitleConverter.shift(try SubtitleConverter.parse(source), by: offset)
|
|
let destination = OutputNaming.sibling(for: source, suffix: "-shifted")
|
|
try SubtitleConverter.write(cues, to: destination, format: format == .vtt ? .vtt : .srt)
|
|
return destination
|
|
}
|
|
|
|
case .extractArchive:
|
|
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
|
let directory = OutputNaming.artifactDirectory(for: source, suffix: " extracted")
|
|
let result = try ArchiveConverter.extract(source: source, into: directory, cancellation: cancellation, progress: nil)
|
|
return result
|
|
}
|
|
|
|
case .listArchive:
|
|
var results: [String] = []
|
|
for source in inputs {
|
|
try cancellation?.check()
|
|
results.append("\(source.lastPathComponent)\n\((try? ArchiveConverter.list(source)) ?? "Unreadable")")
|
|
}
|
|
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Archive")
|
|
|
|
case .mediaInfo:
|
|
var results: [String] = []
|
|
for source in inputs {
|
|
try cancellation?.check()
|
|
results.append(MediaInfo.describe(source))
|
|
}
|
|
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info")
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private static func mapFiles(
|
|
_ inputs: [URL],
|
|
cancellation: Cancellation?,
|
|
progress: ((Double) -> Void)?,
|
|
_ transform: (URL) throws -> URL
|
|
) throws -> ToolOutcome {
|
|
var outputs: [URL] = []
|
|
var firstError: Error?
|
|
for (index, source) in inputs.enumerated() {
|
|
try cancellation?.check()
|
|
do {
|
|
let output = try transform(source)
|
|
outputs.append(output)
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch {
|
|
if firstError == nil { firstError = error }
|
|
}
|
|
progress?(Double(index + 1) / Double(inputs.count))
|
|
}
|
|
if outputs.isEmpty, let firstError { throw firstError }
|
|
return ToolOutcome(outputs: outputs)
|
|
}
|
|
|
|
/// Keeps the source container when it is one we can write, otherwise uses
|
|
/// the fallback (`mp4` for video, `m4a` for audio).
|
|
private static func container(for source: URL, fallback: String) -> String {
|
|
let ext = FileKind.canonicalExtension(source.pathExtension)
|
|
let videoContainers = ["mp4", "mov", "m4v"]
|
|
let audioContainers = ["m4a", "mp3", "wav", "aiff", "flac", "ogg", "opus", "wma"]
|
|
if videoContainers.contains(ext) || audioContainers.contains(ext) { return ext }
|
|
return fallback
|
|
}
|
|
|
|
private static func rateString(_ rate: Double) -> String {
|
|
rate == rate.rounded() ? String(Int(rate)) : String(format: "%.2f", rate)
|
|
}
|
|
|
|
private static func trim(_ source: URL, at destination: URL, start: Double, end: Double?, cancellation: Cancellation?) throws {
|
|
let ext = FileKind.canonicalExtension(source.pathExtension)
|
|
if ["mp4", "mov", "m4v", "m4a"].contains(ext) {
|
|
try AVConverter.trim(source: source, at: destination, start: start, end: end, cancellation: cancellation)
|
|
return
|
|
}
|
|
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
|
var arguments = ["-ss", String(format: "%.3f", start)]
|
|
if let end, end > start { arguments += ["-t", String(format: "%.3f", end - start)] }
|
|
arguments += ["-c:a", "copy"]
|
|
try FFmpeg.filter(source: source, at: destination, arguments: arguments)
|
|
return
|
|
}
|
|
if ["wav", "aiff"].contains(ext) {
|
|
let staged = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("konverter-trim-\(UUID().uuidString).m4a")
|
|
defer { try? FileManager.default.removeItem(at: staged) }
|
|
try AVConverter.trim(source: source, at: staged, start: start, end: end, cancellation: cancellation)
|
|
try CoreAudio.convert(source: staged, to: FileKind.format(of: destination) ?? .wav, at: destination, cancellation: cancellation)
|
|
return
|
|
}
|
|
throw ConversionError.missingTool("Trimming .\(ext) needs ffmpeg — brew install ffmpeg")
|
|
}
|
|
}
|