From b679645eedaba530f2ddc1bcd9bdbc7eb8314f67 Mon Sep 17 00:00:00 2001 From: eugeniozamengopontrelli Date: Sat, 5 Sep 2026 22:06:58 +0200 Subject: [PATCH 1/2] feat: a question with an obvious answer is not asked The screen already refuses to show a control that would be ignored. This is the same rule pointed at questions. Every planned conversion is read as five facts - is something being thrown away, did the user ask for that themselves, is anything undefined, does this make a different kind of thing or more files than it was given, does it touch the originals - and those five decide between running it, asking once, and the existing replace-or-move dialog. The case it is built for: drop a folder onto a preset chosen a minute ago and nothing is asked at all. A size the user typed into a filename counts as their answer, so `holiday_10MB.jpg` runs; the same ceiling arriving from a preset does not, and gets one sober popup with what it actually costs. That popup runs the real chain on one representative file per kind into a scratch folder, so the before and after are measured rather than guessed, and it is not written into anybody's history. A film is the exception: encoding one to show what encoding it looks like is the conversion itself, done twice, so it gets a frame and a sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578 --- Sources/Forge/Models/ConfirmationLevel.swift | 125 ++++++++ Sources/Forge/Models/ConvertKind.swift | 24 ++ Sources/Forge/Processors/FileProcessor.swift | 9 +- Sources/Forge/Processors/ImageProcessor.swift | 12 +- .../Services/ProcessingCoordinator.swift | 28 +- Sources/Forge/Views/BatchProcessingView.swift | 85 +++++- Sources/Forge/Views/ConfirmationSheet.swift | 266 ++++++++++++++++++ Tests/ForgeTests/Models/test_Confidence.swift | 213 ++++++++++++++ 8 files changed, 754 insertions(+), 8 deletions(-) create mode 100644 Sources/Forge/Models/ConfirmationLevel.swift create mode 100644 Sources/Forge/Views/ConfirmationSheet.swift create mode 100644 Tests/ForgeTests/Models/test_Confidence.swift diff --git a/Sources/Forge/Models/ConfirmationLevel.swift b/Sources/Forge/Models/ConfirmationLevel.swift new file mode 100644 index 0000000..c2d8c8b --- /dev/null +++ b/Sources/Forge/Models/ConfirmationLevel.swift @@ -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 + ) + } +} diff --git a/Sources/Forge/Models/ConvertKind.swift b/Sources/Forge/Models/ConvertKind.swift index fdba92d..68200b4 100644 --- a/Sources/Forge/Models/ConvertKind.swift +++ b/Sources/Forge/Models/ConvertKind.swift @@ -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 { diff --git a/Sources/Forge/Processors/FileProcessor.swift b/Sources/Forge/Processors/FileProcessor.swift index 480be65..4cbac69 100644 --- a/Sources/Forge/Processors/FileProcessor.swift +++ b/Sources/Forge/Processors/FileProcessor.swift @@ -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 } } diff --git a/Sources/Forge/Processors/ImageProcessor.swift b/Sources/Forge/Processors/ImageProcessor.swift index 9b4f297..36851ad 100644 --- a/Sources/Forge/Processors/ImageProcessor.swift +++ b/Sources/Forge/Processors/ImageProcessor.swift @@ -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 { @@ -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) @@ -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()) } ) } @@ -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 @@ -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 { diff --git a/Sources/Forge/Services/ProcessingCoordinator.swift b/Sources/Forge/Services/ProcessingCoordinator.swift index afafc4f..5bedf37 100644 --- a/Sources/Forge/Services/ProcessingCoordinator.swift +++ b/Sources/Forge/Services/ProcessingCoordinator.swift @@ -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() @@ -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 ) } @@ -300,7 +323,8 @@ actor ProcessingCoordinator { outputSize: result.outputSize, outputDimensions: result.outputDimensions, duration: result.duration, - additionalOutputs: committed.extras + additionalOutputs: committed.extras, + appliedQuality: result.appliedQuality ) } diff --git a/Sources/Forge/Views/BatchProcessingView.swift b/Sources/Forge/Views/BatchProcessingView.swift index 00d782c..ff765e5 100644 --- a/Sources/Forge/Views/BatchProcessingView.swift +++ b/Sources/Forge/Views/BatchProcessingView.swift @@ -10,6 +10,9 @@ struct BatchProcessingView: View { @State private var showingOptions = false @State private var isTargeted = false @State private var confirming = false + /// The conversions worth one question, one per kind. Empty means there is + /// nothing to ask. + @State private var asking: [ConfirmationGroup] = [] /// The kinds of file in the list. What the sheet offers is decided from this, /// so a PDF is asked different questions than a video. @@ -93,6 +96,17 @@ struct BatchProcessingView: View { } ) } + .sheet(isPresented: Binding(get: { !asking.isEmpty }, set: { if !$0 { asking = [] } })) { + ConfirmationSheet( + groups: asking, + coordinator: model.coordinator, + onConvert: { + asking = [] + start() + }, + onCancel: { asking = [] } + ) + } .confirmationDialog(summaryTitle, isPresented: $confirming) { Button(choice.destinationMode == .overwrite ? "Replace Files" : "Move Files", role: .destructive) { start() @@ -173,12 +187,79 @@ struct BatchProcessingView: View { private func add(_ urls: [URL]) { let wasEmpty = vm.files.isEmpty vm.add(urls) - if wasEmpty, !vm.files.isEmpty { + guard !vm.files.isEmpty else { return } + if wasEmpty { // A batch starts from the general preference and can then disagree with // it, which is what makes the preference a default rather than a law. choice.fitMode = model.settings.defaultFitMode - showingOptions = true } + route() + } + + /// What, if anything, to ask before converting what was just dropped. + /// + /// The rule is `ConfirmationLevel`'s, and the whole point of it is the first + /// case: a drop whose outcome is already settled - the second batch through a + /// preset chosen a minute ago, or a file the user renamed to say what they + /// wanted - just runs. What is left is one question, not one per file. + private func route() { + let plans = vm.files.map { + ConversionPlan(file: $0, preset: preset, destinationMode: choice.destinationMode) + } + + switch ConfirmationLevel.forBatch(plans) { + case .silent: + start() + + case .block: + // Replacing or moving the originals, which has always been asked about. + confirming = true + + case .confirm: + // Nothing says what these should become yet: that question is the sheet, + // and the sheet is the whole answer to it. + if plans.contains(where: \.hasUndefinedRequiredParams) { + showingOptions = true + } else { + asking = groups(from: plans) + } + } + } + + /// One group per kind, standing for every file of that kind in the batch. + private func groups(from plans: [ConversionPlan]) -> [ConfirmationGroup] { + guard let preset else { return [] } + + var seen: [ConvertKind: (file: ProcessableFile, plan: ConversionPlan, count: Int)] = [:] + for (file, plan) in zip(vm.files, plans) { + guard let kind = ConvertKind(fileType: file.fileType) else { continue } + if var already = seen[kind] { + already.count += 1 + // The one worth asking about represents the group; a silent file in a + // group that also has a surprising one is not the file to show. + if plan.confirmationLevel > already.plan.confirmationLevel { + already.file = file + already.plan = plan + } + seen[kind] = already + } else { + seen[kind] = (file, plan, 1) + } + } + + return seen + .filter { $0.value.plan.confirmationLevel == .confirm } + .map { kind, group in + ConfirmationGroup( + kind: kind, + representative: group.file, + count: group.count, + preset: preset, + plan: group.plan, + destination: choice.destinationURL + ) + } + .sorted { $0.kind.rawValue < $1.kind.rawValue } } private func clear() { diff --git a/Sources/Forge/Views/ConfirmationSheet.swift b/Sources/Forge/Views/ConfirmationSheet.swift new file mode 100644 index 0000000..3b763c9 --- /dev/null +++ b/Sources/Forge/Views/ConfirmationSheet.swift @@ -0,0 +1,266 @@ +import AVFoundation +import AppKit +import ImageIO +import SwiftUI +import UniformTypeIdentifiers + +/// One conversion worth asking about, and the file that stands for it. +/// +/// One per kind, never one per file: a hundred photos going the same way is one +/// question, and asking it a hundred times is the interruption this gate exists +/// to remove. +struct ConfirmationGroup: Identifiable { + let kind: ConvertKind + let representative: ProcessableFile + let count: Int + let preset: RulePreset + let plan: ConversionPlan + let destination: URL? + + var id: String { kind.rawValue } +} + +/// The sober popup: what this actually produces, before it produces it. +/// +/// Not an editor. Everything that can be changed lives in the Convert sheet; +/// this says what is about to happen and takes yes or no. +struct ConfirmationSheet: View { + let groups: [ConfirmationGroup] + let coordinator: ProcessingCoordinator + let onConvert: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.title3.weight(.semibold)) + if let sentence = groups.first?.plan.reason?.sentence { + Text(sentence).font(.callout).foregroundStyle(.secondary) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + ForEach(groups) { group in + ConfirmationRow(group: group, coordinator: coordinator) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 420) + + Divider() + + HStack { + Spacer() + Button("Cancel", role: .cancel) { onCancel() } + .keyboardShortcut(.cancelAction) + Button("Convert") { onConvert() } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + .padding(16) + } + .frame(width: 520) + } + + private var title: String { + let files = groups.reduce(0) { $0 + $1.count } + return files == 1 ? "Convert this file?" : "Convert these \(files) files?" + } +} + +/// One kind's row: the numbers, and a look at the result where looking is +/// cheap enough to be worth it. +private struct ConfirmationRow: View { + let group: ConfirmationGroup + let coordinator: ProcessingCoordinator + + @StateObject private var run = PreviewRun() + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: group.kind.icon).foregroundStyle(.secondary) + Text(group.count == 1 + ? group.representative.fileName + : "\(group.count) \(group.kind.plural), like \(group.representative.fileName)") + .font(.callout.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + } + + switch run.state { + case .working: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Converting one of them to show you…").font(.caption).foregroundStyle(.secondary) + } + + case .failed(let reason): + Text(reason) + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + + case .ready(let outcome): + VStack(alignment: .leading, spacing: 8) { + if outcome.before != nil || outcome.after != nil { + HStack(spacing: 6) { pictures(outcome) } + } + Text(outcome.summary).font(.callout).monospacedDigit() + if let note = outcome.note { + Text(note) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .task { await run.make(group, with: coordinator) } + } + + @ViewBuilder + private func pictures(_ outcome: PreviewRun.Outcome) -> some View { + if let before = outcome.before { thumbnail(before, label: "Now") } + if outcome.before != nil, outcome.after != nil { + Image(systemName: "arrow.right").foregroundStyle(.tertiary) + } + if let after = outcome.after { thumbnail(after, label: "After") } + } + + private func thumbnail(_ image: NSImage, label: String) -> some View { + VStack(spacing: 4) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(width: 120, height: 90) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.1))) + Text(label).font(.caption2).foregroundStyle(.secondary) + } + } +} + +/// Runs one representative file through the real chain, into scratch. +/// +/// Scratch only, and deleted the moment a picture has been made of it: a +/// preview must never write where the user's files are, and must leave nothing +/// behind when they say no. +@MainActor +final class PreviewRun: ObservableObject { + + struct Outcome { + var before: NSImage? + var after: NSImage? + /// The line with the numbers in it. + var summary: String + /// Where the files land, or why there is nothing to look at. + var note: String? + } + + enum State { + case working + case ready(Outcome) + case failed(String) + } + + @Published private(set) var state: State = .working + private var started = false + + func make(_ group: ConfirmationGroup, with coordinator: ProcessingCoordinator) async { + // A row redraws whenever anything above it changes; the conversion happens + // once. + guard !started else { return } + started = true + + switch group.kind.previewCost { + case .runIt: + await convert(group, with: coordinator) + + case .oneFrame: + state = .ready(Outcome( + before: await Self.frame(of: group.representative.url), + summary: describe(group), + note: "Encoding it to show you would be the conversion itself, so this is the film as it stands." + )) + + case .describeIt: + state = .ready(Outcome(summary: describe(group))) + } + } + + private func convert(_ group: ConfirmationGroup, with coordinator: ProcessingCoordinator) async { + let scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("forge-preview-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: scratch) } + + do { + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + let result = try await coordinator.preview(group.representative, with: group.preset, into: scratch) + + var summary = group.representative.fileSize.formatted(.byteCount(style: .file)) + + " → " + result.outputSize.formatted(.byteCount(style: .file)) + if let quality = result.appliedQuality { summary += " @ q\(quality)" } + if let size = result.outputDimensions { summary += ", \(size.width) × \(size.height)" } + + let written = 1 + result.additionalOutputs.count + var note: String? + if written > 1 { + let folder = group.destination?.lastPathComponent ?? "the destination folder" + note = "\(written) files per \(group.kind.title), into \(folder)." + } + + state = .ready(Outcome( + before: Self.thumbnail(of: group.representative.url), + after: Self.thumbnail(of: result.outputURL), + summary: summary, + note: note + )) + } catch { + state = .failed(error.localizedDescription) + } + } + + /// What will happen, for the kinds too expensive to show. + private func describe(_ group: ConfirmationGroup) -> String { + let size = group.representative.fileSize.formatted(.byteCount(style: .file)) + guard let target = group.preset.targetFormat + .flatMap(FormatCatalog.fileExtension(for:))? + .uppercased() else { + return "\(size), \(group.preset.name)" + } + return "\(size) → \(target). The finished size is known once it runs." + } + + /// A small picture of a file, made by ImageIO at the size it is shown. + /// Decoding a whole image to draw a thumbnail is how a preview of a sixty + /// megapixel photograph costs a gigabyte. + static func thumbnail(of url: URL, maximumPixels: Int = 480) -> NSImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixels, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + } + + /// The first frame of a film, which is the cheapest true thing to show about + /// one. + static func frame(of url: URL) async -> NSImage? { + let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url)) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 480, height: 480) + guard let image = try? await generator.image(at: .zero).image else { return nil } + return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + } +} diff --git a/Tests/ForgeTests/Models/test_Confidence.swift b/Tests/ForgeTests/Models/test_Confidence.swift new file mode 100644 index 0000000..20e5ca9 --- /dev/null +++ b/Tests/ForgeTests/Models/test_Confidence.swift @@ -0,0 +1,213 @@ +import XCTest +import UniformTypeIdentifiers +@testable import Forge + +/// Which conversions are worth a question, and which are not. +/// +/// The rule is five facts wide, so it is tested as a table: every column on its +/// own, and the two that argue with each other - a size ceiling the user typed +/// against one a preset brought along - side by side. +final class ConfidenceTests: BaseTestCase { + + // MARK: - The rule itself + + func test_plain_conversionAsksNothing() { + XCTAssertEqual(ConfirmationLevel(for: ConversionPlan()), .silent) + } + + /// The whole point: a size the user typed into the filename is their + /// instruction, not a surprise to be confirmed back at them. + func test_explicitMagic_isSilent() { + let plan = ConversionPlan(isLossyOrMagic: true, magicWasExplicit: true) + XCTAssertEqual(ConfirmationLevel(for: plan), .silent) + } + + func test_inferredMagic_asksOnce() { + let plan = ConversionPlan(isLossyOrMagic: true, magicWasExplicit: false) + XCTAssertEqual(ConfirmationLevel(for: plan), .confirm) + XCTAssertEqual(plan.reason, .inferredCeiling) + } + + func test_undefinedParameters_ask() { + let plan = ConversionPlan(hasUndefinedRequiredParams: true) + XCTAssertEqual(ConfirmationLevel(for: plan), .confirm) + XCTAssertEqual(plan.reason, .undefined) + } + + func test_crossDomainOrExpanding_asks() { + let plan = ConversionPlan(isCrossDomainOrExpanding: true) + XCTAssertEqual(ConfirmationLevel(for: plan), .confirm) + XCTAssertEqual(plan.reason, .crossDomain) + } + + /// Replacing or moving the originals outranks everything, explicit or not. + func test_touchingTheOriginals_blocks() { + let plan = ConversionPlan( + isLossyOrMagic: true, magicWasExplicit: true, touchesExistingFiles: true + ) + XCTAssertEqual(ConfirmationLevel(for: plan), .block) + XCTAssertEqual(plan.reason, .touchesOriginals) + } + + // MARK: - A whole batch + + func test_batch_ofSettledFilesAsksNothing() { + let settled = [ConversionPlan(), ConversionPlan(isLossyOrMagic: true, magicWasExplicit: true)] + XCTAssertEqual(ConfirmationLevel.forBatch(settled), .silent) + } + + /// One surprising file among a hundred settled ones is still one question. + func test_batch_takesTheMostSeriousThingInIt() { + let mixed = [ + ConversionPlan(), + ConversionPlan(isCrossDomainOrExpanding: true), + ConversionPlan(), + ] + XCTAssertEqual(ConfirmationLevel.forBatch(mixed), .confirm) + + let overwriting = mixed + [ConversionPlan(touchesExistingFiles: true)] + XCTAssertEqual(ConfirmationLevel.forBatch(overwriting), .block) + } + + func test_batch_ofNothingAsksNothing() { + XCTAssertEqual(ConfirmationLevel.forBatch([]), .silent) + } + + // MARK: - Read off real files + + /// PNG to JPEG at a preset's quality: one file in, one of the same kind out. + func test_plan_readsAnOrdinaryConversionAsSilent() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday.png"))) + let plan = ConversionPlan( + file: file, + preset: .make(format: .jpeg, quality: 80, category: .image), + destinationMode: .copyTo + ) + + XCTAssertFalse(plan.isLossyOrMagic, "a quality setting is not magic; it is what was asked for") + XCTAssertFalse(plan.isCrossDomainOrExpanding) + XCTAssertEqual(plan.confirmationLevel, .silent) + } + + /// The headline case: the size is in the name the user typed. + func test_plan_readsTheCeilingOutOfTheFilename() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday_10MB.png"))) + let plan = ConversionPlan( + file: file, + preset: .make(format: .jpeg, category: .image), + destinationMode: .copyTo + ) + + XCTAssertTrue(plan.isLossyOrMagic) + XCTAssertTrue(plan.magicWasExplicit) + XCTAssertEqual( + plan.confirmationLevel, .silent, + "they typed it; do not ask them to confirm their own typing" + ) + } + + /// The same ceiling, arriving from a preset the user did not set just now. + func test_plan_readsACeilingFromThePresetAsWorthAsking() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday.png"))) + var preset = RulePreset.make(format: .jpeg, category: .image) + preset.actions.append(.limitSize(bytes: 10_000_000)) + + let plan = ConversionPlan(file: file, preset: preset, destinationMode: .copyTo) + + XCTAssertTrue(plan.isLossyOrMagic) + XCTAssertFalse(plan.magicWasExplicit) + XCTAssertEqual(plan.confirmationLevel, .confirm) + } + + func test_plan_readsNoPresetAsUndefined() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday.png"))) + let plan = ConversionPlan(file: file, preset: nil, destinationMode: .copyTo) + + XCTAssertTrue(plan.hasUndefinedRequiredParams) + XCTAssertEqual(plan.confirmationLevel, .confirm) + } + + /// An image asked for text is a reading job, not a conversion of the same + /// kind, and it is worth showing what comes out. + func test_plan_readsImageToTextAsCrossDomain() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("scan.png"))) + let plan = ConversionPlan( + file: file, + preset: .make(format: .plainText, category: .image), + destinationMode: .copyTo + ) + + XCTAssertTrue(plan.isCrossDomainOrExpanding) + XCTAssertEqual(plan.confirmationLevel, .confirm) + } + + /// One file in, two out. + func test_plan_readsAPresetWithTwoFormatsAsExpanding() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday.png"))) + var preset = RulePreset.make(format: .jpeg, category: .image) + preset.actions.append(.convertFormat(to: .png)) + + let plan = ConversionPlan(file: file, preset: preset, destinationMode: .copyTo) + + XCTAssertTrue(plan.isCrossDomainOrExpanding) + XCTAssertEqual(plan.confirmationLevel, .confirm) + } + + func test_plan_readsOverwriteAsBlocking() throws { + let file = try ProcessableFile(url: try Fixture.image(at: path("holiday_10MB.png"))) + for mode in [DestinationMode.overwrite, .moveTo] { + let plan = ConversionPlan( + file: file, + preset: .make(format: .jpeg, category: .image), + destinationMode: mode + ) + XCTAssertEqual(plan.confirmationLevel, .block, "\(mode) changes what is already on disk") + } + } + + // MARK: - The preview behind the popup + + /// The popup shows the real thing, so it runs the real chain - into scratch, + /// and without becoming an event in anybody's history. + func test_preview_convertsForRealAndRecordsNothing() async throws { + let source = try Fixture.image(at: path("holiday.png")) + let scratch = try folder("preview") + + let result = try await coordinator().preview( + try ProcessableFile(url: source), + with: .make(format: .jpeg, quality: 60, category: .image), + into: scratch + ) + + XCTAssertEqual(result.outputURL.pathExtension, "jpeg") + XCTAssertGreaterThan(result.outputSize, 0) + XCTAssertEqual(result.appliedQuality, 60, "the sheet says what the quality was") + XCTAssertTrue(exists(result.outputURL)) + XCTAssertEqual( + result.outputURL.deletingLastPathComponent().standardizedFileURL, + scratch.standardizedFileURL, + "a preview writes into scratch, never beside the user's file" + ) + + let history = try await store.loadHistory() + XCTAssertTrue(history.isEmpty, "a preview somebody cancelled did not happen") + } + + /// A ceiling is kept by writing the file again lower until it fits, so the + /// quality is a result. The sheet's "@ q72" comes from here. + func test_preview_reportsTheQualityACeilingSettledOn() async throws { + let source = try Fixture.image(at: path("big.png"), width: 1400, height: 1000) + let scratch = try folder("preview") + + var preset = RulePreset.make(format: .jpeg, quality: 95, category: .image) + preset.actions.append(.limitSize(bytes: 20_000)) + + let result = try await coordinator().preview( + try ProcessableFile(url: source), with: preset, into: scratch + ) + + XCTAssertLessThanOrEqual(result.outputSize, 20_000) + let quality = try XCTUnwrap(result.appliedQuality) + XCTAssertLessThan(quality, 95, "getting under the ceiling cost quality, and the number says how much") + } +} From 009bd043ca3aa71e1cc0da89ca011a16d42230c7 Mon Sep 17 00:00:00 2001 From: eugeniozamengopontrelli Date: Sat, 5 Sep 2026 23:25:35 +0200 Subject: [PATCH 2/2] fix: a drop during a conversion joins the list, it does not start a second one Routing every drop through the gate meant that files dropped while a batch was running went straight to "nothing to ask, run it" - a second batch over the same list, two conversions racing for the same output names, the second reporting files the first had not finished writing. Guarded in both places it can happen: the screen does not route a drop while it is busy, and the batch itself refuses to start twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578 --- Sources/Forge/Views/BatchProcessingView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Forge/Views/BatchProcessingView.swift b/Sources/Forge/Views/BatchProcessingView.swift index ff765e5..e679c9a 100644 --- a/Sources/Forge/Views/BatchProcessingView.swift +++ b/Sources/Forge/Views/BatchProcessingView.swift @@ -203,6 +203,11 @@ struct BatchProcessingView: View { /// preset chosen a minute ago, or a file the user renamed to say what they /// wanted - just runs. What is left is one question, not one per file. private func route() { + // Files dropped while a batch is running join the list and wait their turn. + // Routing them would start a second batch over the same files, which is two + // conversions writing the same output names at once. + guard !vm.isProcessing else { return } + let plans = vm.files.map { ConversionPlan(file: $0, preset: preset, destinationMode: choice.destinationMode) } @@ -408,7 +413,9 @@ final class BatchViewModel: ObservableObject { } func convert(model: AppModel, preset: RulePreset, mode: DestinationMode, destination: URL?) async { - guard !files.isEmpty else { return } + // Two batches over one list would race for the same output names, and the + // second would report files the first is still writing. + guard !files.isEmpty, !isProcessing else { return } let cancellation = self.cancellation cancellation.reset()