Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions Sources/Forge/Models/ConfirmationLevel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Foundation

/// How much of a question a conversion is.
///
/// The screen already refuses to show a control that would be ignored. This is
/// the same rule pointed at questions: one with an obvious answer is not asked.
/// A JPEG dropped onto a preset that makes JPEGs is not a decision, and neither
/// is a size somebody typed into the filename themselves.
enum ConfirmationLevel: Int, Comparable, Sendable {
/// Run it. Nothing here the user did not already say.
case silent
/// Ask once, with what the result will actually be.
case confirm
/// Ask before touching what is already on disk. This is the existing
/// replace-or-move dialog, routed through the same gate rather than beside it.
case block

static func < (lhs: ConfirmationLevel, rhs: ConfirmationLevel) -> Bool {
lhs.rawValue < rhs.rawValue
}

/// The one question a whole batch is worth: the most serious thing in it.
///
/// A batch where every file is settled asks nothing at all, which is the
/// case this gate is built for - drop a folder onto a preset chosen a minute
/// ago and it simply runs.
static func forBatch(_ plans: [ConversionPlan]) -> ConfirmationLevel {
plans.map { ConfirmationLevel(for: $0) }.max() ?? .silent
}

/// The rule, in one place and in order of seriousness.
init(for plan: ConversionPlan) {
if plan.touchesExistingFiles {
self = .block
} else if plan.hasUndefinedRequiredParams || plan.isCrossDomainOrExpanding {
self = .confirm
} else if plan.isLossyOrMagic && !plan.magicWasExplicit {
self = .confirm
} else {
self = .silent
}
}
}

/// The five facts about a planned conversion that decide whether to ask.
///
/// Facts rather than file types: the rule is a table with five columns, and a
/// pile of `if`s about formats would be a different rule per format and a
/// different answer per reading of it.
struct ConversionPlan: Equatable, Sendable {
/// Something is being thrown away that the encoder was not simply told to
/// throw away — a ceiling on the finished size, which resolves to whatever
/// quality it takes to get there.
var isLossyOrMagic = false
/// The user typed that ceiling into the filename. Then it is not a surprise,
/// it is the instruction, and interrupting them to confirm their own typing
/// is the interruption this whole gate exists to remove.
var magicWasExplicit = false
/// Nothing here says what the file should become.
var hasUndefinedRequiredParams = false
/// A picture asked to become a film, a PDF asked to become twenty images:
/// the answer is not one file of the same sort, and that is worth a look.
var isCrossDomainOrExpanding = false
/// The originals are replaced or removed.
var touchesExistingFiles = false

var confirmationLevel: ConfirmationLevel { ConfirmationLevel(for: self) }

/// Why this one is being asked about, for the sentence at the top of the
/// sheet. The order matches the rule.
var reason: Reason? {
if touchesExistingFiles { return .touchesOriginals }
if hasUndefinedRequiredParams { return .undefined }
if isCrossDomainOrExpanding { return .crossDomain }
if isLossyOrMagic && !magicWasExplicit { return .inferredCeiling }
return nil
}

enum Reason: Sendable {
case undefined
case crossDomain
case inferredCeiling
case touchesOriginals

var sentence: String {
switch self {
case .undefined:
return "Nothing yet says what these should become."
case .crossDomain:
return "This makes something of a different kind, so here is what comes out."
case .inferredCeiling:
return "The size ceiling comes from the preset rather than from the name you typed, "
+ "so here is what it costs."
case .touchesOriginals:
return "This changes files you already have."
}
}
}
}

extension ConversionPlan {
/// What is actually about to happen to one file.
///
/// The operations are read the way `ProcessingCoordinator` reads them, size
/// in the filename included, because a plan that disagrees with the run is
/// worse than no plan at all.
init(file: ProcessableFile, preset: RulePreset?, destinationMode: DestinationMode) {
let operations = preset.map { SizeInName.applying(to: $0.toOperations(), from: file.fileName) } ?? []
let ceiling = SizeInName.ceiling(in: file.fileName)

let source = ConvertKind(fileType: file.fileType)
let target = preset?.targetFormat.flatMap { ConvertKind(fileType: $0) }
let formats = preset.map { ProcessingCoordinator.formats(of: $0).count } ?? 0

self.init(
isLossyOrMagic: operations.contains { if case .limitSize = $0 { return true } else { return false } },
magicWasExplicit: ceiling != nil,
hasUndefinedRequiredParams: operations.isEmpty,
// One input and more than one output file is expanding whatever the kinds
// are: a preset naming two formats writes two files per file.
isCrossDomainOrExpanding: formats > 1 || (target != nil && target != source),
touchesExistingFiles: destinationMode != .copyTo
)
}
}
24 changes: 24 additions & 0 deletions Sources/Forge/Models/ConvertKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,30 @@ enum ConvertKind: String, CaseIterable, Sendable {
}
}

/// What a preview of this kind may cost.
///
/// A picture converts in a moment, so the preview is the real thing. A film
/// does not: encoding one to show somebody what encoding it would look like
/// is the conversion they have not agreed to yet, done twice. It gets a frame
/// and a description instead, and a recording gets the description alone -
/// there is nothing to look at in a sound file.
var previewCost: PreviewCost {
switch self {
case .image, .document, .data, .model, .subtitle, .font: return .runIt
case .video: return .oneFrame
case .audio: return .describeIt
}
}

enum PreviewCost: Sendable {
/// Convert the one file for real, into scratch.
case runIt
/// Show a frame of the source and say what will happen.
case oneFrame
/// Say what will happen.
case describeIt
}

/// Whether a file of this kind can be asked for that format, taken from the
/// same list the sheet offers rather than from a second one kept beside it.
func offers(_ format: UTType) -> Bool {
Expand Down
9 changes: 8 additions & 1 deletion Sources/Forge/Processors/FileProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,26 @@ struct ProcessingResult: Sendable {
/// Extra files the conversion produced, in order. One input can legitimately
/// yield many outputs: a twenty-page PDF becomes twenty images.
let additionalOutputs: [URL]
/// The quality the encoder settled on, where that was a result rather than a
/// setting. A size ceiling is kept by writing the file again lower until it
/// fits, so the number that got there is only known afterwards - and it is
/// the interesting half of "10 MB, and what did that cost".
let appliedQuality: Int?

init(
outputURL: URL,
outputSize: Int64,
outputDimensions: (width: Int, height: Int)?,
duration: TimeInterval,
additionalOutputs: [URL] = []
additionalOutputs: [URL] = [],
appliedQuality: Int? = nil
) {
self.outputURL = outputURL
self.outputSize = outputSize
self.outputDimensions = outputDimensions
self.duration = duration
self.additionalOutputs = additionalOutputs
self.appliedQuality = appliedQuality
}
}

Expand Down
12 changes: 9 additions & 3 deletions Sources/Forge/Processors/ImageProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable {
// An animation asked to become a movie is written as one, which is what
// turns a GIF back into video.
var extras: [URL] = []
// What the encoder was asked for, until a ceiling settles on something else.
var settled: Float? = isLossy(outputUTI) ? quality(from: operations) : nil
if FormatCatalog.isWritableVideo(outputUTI) {
try await MovieWriter.write(rendered, to: output, as: outputUTI) { progress(0.9 + $0 * 0.1) }
} else {
Expand All @@ -108,7 +110,7 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable {
// the encoder, so it is kept the only way a promise about a result can
// be: write it, measure it, and write it again lower until it fits.
if let ceiling = Self.sizeCeiling(from: operations) {
try squeeze(rendered, to: output, as: outputUTI, options: options, ceiling: ceiling)
settled = try squeeze(rendered, to: output, as: outputUTI, options: options, ceiling: ceiling)
}
}
progress(1.0)
Expand All @@ -119,7 +121,8 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable {
outputSize: attributes[.size] as? Int64 ?? 0,
outputDimensions: (rendered[0].image.width, rendered[0].image.height),
duration: Date().timeIntervalSince(start),
additionalOutputs: extras
additionalOutputs: extras,
appliedQuality: settled.map { Int(($0 * 100).rounded()) }
)
}

Expand Down Expand Up @@ -274,13 +277,15 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable {
/// quality left to give, the pixels go. Both have a floor: past it the file
/// is not the file anybody asked for, and saying so beats handing back
/// something unrecognisable that happens to be small.
/// - Returns: the quality it settled on, which is what the ceiling cost.
@discardableResult
private func squeeze(
_ frames: [ImageFrame],
to output: URL,
as uti: UTType,
options: [CFString: Any],
ceiling: Int
) throws {
) throws -> Float {
let lowestQuality: Float = 0.2
let smallestScale: CGFloat = 0.15
let attemptsAllowed = 12
Expand Down Expand Up @@ -323,6 +328,7 @@ final class ImageProcessor: FileProcessor, @unchecked Sendable {
+ "\(Int64(finalSize).formatted(.byteCount(style: .file)))."
)
}
return quality
}

private static func fileSize(of url: URL) throws -> Int {
Expand Down
28 changes: 26 additions & 2 deletions Sources/Forge/Services/ProcessingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,28 @@ actor ProcessingCoordinator {
return try await task.value
}

/// Convert one file into a folder of the caller's choosing, without writing
/// it down.
///
/// The same chain, the same processors, the same output planning - it is the
/// real conversion, which is the only kind worth showing somebody before they
/// agree to it. What it is not is an event in their history: a preview they
/// looked at and cancelled did not happen.
func preview(
_ file: ProcessableFile,
with preset: RulePreset,
into folder: URL,
progress: @escaping @Sendable (Double) -> Void = { _ in }
) async throws -> ProcessingResult {
try await run(
file: file,
preset: preset,
destinationMode: .copyTo,
destinationURL: folder,
progress: progress
)
}

func cancelAll() {
for task in activeTasks.values { task.cancel() }
activeTasks.removeAll()
Expand Down Expand Up @@ -172,7 +194,8 @@ actor ProcessingCoordinator {
outputSize: results.reduce(0) { $0 + $1.outputSize },
outputDimensions: first.outputDimensions,
duration: results.reduce(0) { $0 + $1.duration },
additionalOutputs: first.additionalOutputs + results.dropFirst().map(\.outputURL)
additionalOutputs: first.additionalOutputs + results.dropFirst().map(\.outputURL),
appliedQuality: first.appliedQuality
)
}

Expand Down Expand Up @@ -300,7 +323,8 @@ actor ProcessingCoordinator {
outputSize: result.outputSize,
outputDimensions: result.outputDimensions,
duration: result.duration,
additionalOutputs: committed.extras
additionalOutputs: committed.extras,
appliedQuality: result.appliedQuality
)
}

Expand Down
Loading
Loading