diff --git a/App/ContentView.swift b/App/ContentView.swift index 188c8c0..a60c9cb 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -1310,6 +1310,9 @@ struct ContentView: View { settings.lastLoras = params.loras settings.lastModel = params.model settings.lastQuantize = params.quantize + // Low RAM lives in Settings -> Models; read it now so a toggle applies to + // this run instead of only after a relaunch. + params.applyMemoryDefaults(from: settings) let wasIdle = !isAnyStoreRunning let jobs = fluxJobs(count: count, scenarioPrompts: scenarioPrompts) for job in jobs { diff --git a/MLXBits Image Studio.xcodeproj/project.pbxproj b/MLXBits Image Studio.xcodeproj/project.pbxproj index 93d2d79..dcaa3ae 100644 --- a/MLXBits Image Studio.xcodeproj/project.pbxproj +++ b/MLXBits Image Studio.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ 65E93BDC06E71759215E9C61 /* Ideogram4Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E769B611A03FA4372F553CA /* Ideogram4Job.swift */; }; 6619EBBB32F00CABCF2BC509 /* scenario_prompt.md in Resources */ = {isa = PBXBuildFile; fileRef = FE1832089B5311712FEF1286 /* scenario_prompt.md */; }; 66343C5D9463A03976D60C07 /* InsetTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADB0159980B7D99BDE55C34 /* InsetTextEditor.swift */; }; + 49BF0B410A6E1BD8CC6F2566 /* ImageDropTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138ECB7E18FDB462914EEDFE /* ImageDropTarget.swift */; }; 67E20D03F48F76817A738C86 /* ModelDefaultsView+Forms.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318C96A132452AB2AC3F26D5 /* ModelDefaultsView+Forms.swift */; }; 6B4A7C4FEE6A46B30749A080 /* OutputDirectoryPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141CB0282F7313611083C575 /* OutputDirectoryPromptView.swift */; }; 6EA849A3F3916E8506FAF825 /* FluxModelCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB8EA10AEC906888FA045BF3 /* FluxModelCatalog.swift */; }; @@ -253,6 +254,7 @@ A1C8704700A92EDD979FE683 /* IdeogramCaptionEditorView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IdeogramCaptionEditorView+Actions.swift"; sourceTree = ""; }; A31CFEFF049480510A99BB2F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A8EABE58BCB04CF6EDBF9751 /* BinaryDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryDetector.swift; sourceTree = ""; }; + 138ECB7E18FDB462914EEDFE /* ImageDropTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDropTarget.swift; sourceTree = ""; }; AADB0159980B7D99BDE55C34 /* InsetTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetTextEditor.swift; sourceTree = ""; }; AAF31F61799658BA5ECA26FB /* MLXBitsImageStudioApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLXBitsImageStudioApp.swift; sourceTree = ""; }; ACD13236B5EAE62E8F45C42D /* Krea2Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Krea2Job.swift; sourceTree = ""; }; @@ -338,6 +340,7 @@ 654AC3A3DC3E6FAE9A129B80 /* ColorPaletteEditor.swift */, B4ED4622A2079F5ED9546458 /* DimensionConstraints.swift */, 86F98F9F31228EFCA1201025 /* DimensionSliderRow.swift */, + 138ECB7E18FDB462914EEDFE /* ImageDropTarget.swift */, 8B2A8574EE932E2F7883D31C /* ImageMetadataInfo.swift */, D776D62562D137DBCD692E19 /* ImageMetadataPanel.swift */, A0CBC2E2A6CCF99FAB3F5B32 /* InfoButton.swift */, @@ -774,6 +777,7 @@ 517F419A238A38CFD483F7F1 /* IdeogramCaptionEditorView.swift in Sources */, F05EFC3ABE1FE57C17649886 /* IdeogramCaptionGenerator.swift in Sources */, F93392996A4F3521EE07554F /* IdeogramElementCard.swift in Sources */, + 49BF0B410A6E1BD8CC6F2566 /* ImageDropTarget.swift in Sources */, FA4F045A3562D02ADD46F383 /* ImageMetadataInfo.swift in Sources */, E3AFA2AD37A47559BE6AF63B /* ImageMetadataPanel.swift in Sources */, F14D3C7DD61093CF32259D13 /* InfoButton.swift in Sources */, diff --git a/Models/FluxModelCatalog.swift b/Models/FluxModelCatalog.swift index 5916c8b..686fc7f 100644 --- a/Models/FluxModelCatalog.swift +++ b/Models/FluxModelCatalog.swift @@ -132,13 +132,14 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { } /// Soft cap (in estimated tokens) for the prompt field, or `nil` to hide the - /// counter. FLUX.2 Klein and Krea 2 encode prompts with Qwen at + /// counter. FLUX.2 Klein encodes prompts with Qwen at /// `max_sequence_length=512`; text beyond that is truncated by the backend /// before it reaches the model. Ideogram routes through its own params panel; /// `custom` has an unknown encoder. var promptTokenSoftCap: Int? { switch self { - case .flux2Klein4B, .flux2Klein9B, .flux2KleinBase4B, .flux2KleinBase9B, .krea2: 512 + case .flux2Klein4B, .flux2Klein9B, .flux2KleinBase4B, .flux2KleinBase9B: 512 + case .krea2: 1024 // krea/Krea-2 qwen3vl tokenizer, max_length=1024 case .zimageTurbo, .zimage: 512 // Qwen3-4B encoder at max_sequence_length=512 case .ideogram4, .custom: nil } diff --git a/Stores/JobStore.swift b/Stores/JobStore.swift index 04dbe58..f4b741f 100644 --- a/Stores/JobStore.swift +++ b/Stores/JobStore.swift @@ -71,6 +71,8 @@ final class JobStore { lowRam: batchJob.lowRam, imagePath: batchJob.imagePath, imageStrength: batchJob.imageStrength, + isEditMode: batchJob.isEditMode, + editImagePaths: batchJob.editImagePaths, board: batchJob.board, createdAt: batchJob.createdAt ) diff --git a/Stores/ParamsPanelState.swift b/Stores/ParamsPanelState.swift index b9edf59..a7e6bc0 100644 --- a/Stores/ParamsPanelState.swift +++ b/Stores/ParamsPanelState.swift @@ -74,6 +74,14 @@ final class ParamsPanelState { prompt = settings.lastPrompt } + /// Re-reads Low RAM for the picked model. Called just before a job is built so a + /// Settings edit lands on this run rather than only on the next launch. It is read + /// at launch and on a model change, and nowhere else, so a mid-session toggle was + /// silently ignored for the rest of the session. + func applyMemoryDefaults(from settings: AppSettings) { + lowRam = settings.resolvedDefaults(for: model).lowRam + } + /// When a generated candidate becomes the img2img reference while the /// prompt box still holds wildcards, adopt that candidate's resolved /// prompt so refinement varies the exact base (and collapses to one job) @@ -100,6 +108,11 @@ final class ParamsPanelState { lowRam = meta.lowRam imagePath = meta.imagePath imageStrength = meta.imageStrength + // An edit is only reproducible with its references, and its prompt is an + // instruction ("put her in a red coat") that txt2img has no way to honor, + // so the mode follows what the sidecar recorded. + editImagePaths = meta.editImagePaths ?? [] + isEditMode = !editImagePaths.isEmpty loras = meta.loras board = meta.board ?? "" seed = newSeed ? -1 : meta.seed diff --git a/Utilities/MetadataSidecar.swift b/Utilities/MetadataSidecar.swift index 27bc4ce..edd99ee 100644 --- a/Utilities/MetadataSidecar.swift +++ b/Utilities/MetadataSidecar.swift @@ -62,6 +62,7 @@ nonisolated struct GenerationMetadata: Codable { lowRam: job.lowRam, imagePath: job.imagePath, imageStrength: job.imageStrength, + editImagePaths: job.editImagePaths.isEmpty ? nil : job.editImagePaths, loras: job.loras, board: job.board.isEmpty ? nil : job.board, generatedAt: job.completedAt ?? Date(), @@ -84,6 +85,9 @@ nonisolated struct GenerationMetadata: Codable { var lowRam: Bool var imagePath: String var imageStrength: Double + /// Reference images of a FLUX.2 Edit. Optional because no sidecar written + /// before this existed carries it, and `nil` reads back as "not an edit". + var editImagePaths: [String]? var loras: [LoraEntry] var board: String? var generatedAt: Date diff --git a/Views/Krea2/Krea2ParamsPanelView.swift b/Views/Krea2/Krea2ParamsPanelView.swift index 0c66b10..f010e45 100644 --- a/Views/Krea2/Krea2ParamsPanelView.swift +++ b/Views/Krea2/Krea2ParamsPanelView.swift @@ -296,26 +296,10 @@ struct Krea2ParamsPanelView: View { } } } - .dropDestination(for: String.self, action: { paths, _ in - guard let path = paths.first else { return false } - let ext = (path as NSString).pathExtension.lowercased() - guard Self.imageExtensions.contains(ext) else { return false } + .imageDropTarget(extensions: Self.imageExtensions, isTargeted: $isImageDropTargeted) { paths in + guard let path = paths.first else { return } params.imagePath = path params.adoptResolvedPromptForImg2Img(at: path) - return true - }, isTargeted: { isImageDropTargeted = $0 }) - .onDrop(of: [.fileURL], isTargeted: $isImageDropTargeted) { providers in - providers.first?.loadDataRepresentation(forTypeIdentifier: "public.file-url") { data, _ in - guard let data, - let url = URL(dataRepresentation: data, relativeTo: nil) else { return } - let ext = url.pathExtension.lowercased() - guard Self.imageExtensions.contains(ext) else { return } - DispatchQueue.main.async { - self.params.imagePath = url.path - self.params.adoptResolvedPromptForImg2Img(at: url.path) - } - } - return true } .dropHighlight(isImageDropTargeted) } diff --git a/Views/ParamsPanel/ParamsPanelView.swift b/Views/ParamsPanel/ParamsPanelView.swift index 3fecb40..b385e89 100644 --- a/Views/ParamsPanel/ParamsPanelView.swift +++ b/Views/ParamsPanel/ParamsPanelView.swift @@ -481,26 +481,10 @@ struct ParamsPanelView: View { } } } - .dropDestination(for: String.self, action: { paths, _ in - guard let path = paths.first else { return false } - let ext = (path as NSString).pathExtension.lowercased() - guard Self.imageExtensions.contains(ext) else { return false } + .imageDropTarget(extensions: Self.imageExtensions, isTargeted: $isImageDropTargeted) { paths in + guard let path = paths.first else { return } params.imagePath = path params.adoptResolvedPromptForImg2Img(at: path) - return true - }, isTargeted: { isImageDropTargeted = $0 }) - .onDrop(of: [.fileURL], isTargeted: $isImageDropTargeted) { providers in - providers.first?.loadDataRepresentation(forTypeIdentifier: "public.file-url") { data, _ in - guard let data, - let url = URL(dataRepresentation: data, relativeTo: nil) else { return } - let ext = url.pathExtension.lowercased() - guard Self.imageExtensions.contains(ext) else { return } - DispatchQueue.main.async { - self.params.imagePath = url.path - self.params.adoptResolvedPromptForImg2Img(at: url.path) - } - } - return true } .dropHighlight(isImageDropTargeted) } @@ -599,25 +583,14 @@ struct ParamsPanelView: View { } } } - .dropDestination(for: String.self, action: { paths, _ in - let valid = paths.filter { Self.imageExtensions.contains(($0 as NSString).pathExtension.lowercased()) } - guard !valid.isEmpty else { return false } - for path in valid where !params.editImagePaths.contains(path) { + .imageDropTarget( + extensions: Self.imageExtensions, + isTargeted: $isEditDropTargeted, + allowsMultiple: true + ) { paths in + for path in paths where !params.editImagePaths.contains(path) { params.editImagePaths.append(path) } - return true - }, isTargeted: { isEditDropTargeted = $0 }) - .onDrop(of: [.fileURL], isTargeted: $isEditDropTargeted) { providers in - for provider in providers { - provider.loadDataRepresentation(forTypeIdentifier: "public.file-url") { data, _ in - guard let data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } - guard Self.imageExtensions.contains(url.pathExtension.lowercased()) else { return } - DispatchQueue.main.async { - if !self.params.editImagePaths.contains(url.path) { self.params.editImagePaths.append(url.path) } - } - } - } - return true } .dropHighlight(isEditDropTargeted) } diff --git a/Views/Shared/ImageDropTarget.swift b/Views/Shared/ImageDropTarget.swift new file mode 100644 index 0000000..3319e67 --- /dev/null +++ b/Views/Shared/ImageDropTarget.swift @@ -0,0 +1,95 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// One drop target that accepts an image from either source the app can be handed one. +/// +/// Two sources, two pasteboard types: +/// - the in-app gallery is an `NSCollectionView` whose drag writes the file path as +/// plain text (`GalleryCollectionView.pasteboardWriterForItemAt`), and +/// - a Finder drag arrives as `public.file-url`. +/// +/// The wells used to declare those as two separate targets on the same view — +/// `.dropDestination(for: String.self)` for the first and `.onDrop(of: [.fileURL])` +/// for the second. Stacking two drop targets means only one of them ever receives +/// the drag session, so whichever lost was silently dead. This handles both +/// representations inside a single `.onDrop`. +extension View { + /// - Parameters: + /// - extensions: lowercased file extensions to accept; anything else is ignored. + /// - isTargeted: bound to the hover state so the well can highlight itself. + /// - allowsMultiple: when false only the first accepted path is delivered. + /// - onPaths: receives the accepted paths on the main actor, never empty. + func imageDropTarget( + extensions: Set, + isTargeted: Binding, + allowsMultiple: Bool = false, + onPaths: @escaping ([String]) -> Void + ) -> some View { + onDrop(of: [.fileURL, .text], isTargeted: isTargeted) { providers in + let candidates = allowsMultiple ? providers : Array(providers.prefix(1)) + guard !candidates.isEmpty else { return false } + ImageDropResolver.resolve(providers: candidates) { paths in + let accepted = paths.filter { extensions.contains(($0 as NSString).pathExtension.lowercased()) } + guard !accepted.isEmpty else { return } + onPaths(allowsMultiple ? accepted : Array(accepted.prefix(1))) + } + return true + } + } +} + +/// Turns dropped item providers into file paths, whichever representation they carry. +enum ImageDropResolver { + static func resolve(providers: [NSItemProvider], completion: @escaping ([String]) -> Void) { + let group = DispatchGroup() + // Indexed so the delivered order matches the drop order regardless of which + // provider finishes loading first — it decides multi-image reference order. + var byIndex: [Int: String] = [:] + let lock = NSLock() + + for (index, provider) in providers.enumerated() { + group.enter() + path(from: provider) { path in + if let path { + lock.lock(); byIndex[index] = path; lock.unlock() + } + group.leave() + } + } + + group.notify(queue: .main) { + completion(byIndex.keys.sorted().compactMap { byIndex[$0] }) + } + } + + private static func path(from provider: NSItemProvider, completion: @escaping (String?) -> Void) { + let fileURL = UTType.fileURL.identifier + if provider.hasItemConformingToTypeIdentifier(fileURL) { + provider.loadDataRepresentation(forTypeIdentifier: fileURL) { data, _ in + guard let data, let url = URL(dataRepresentation: data, relativeTo: nil) else { + completion(nil); return + } + completion(url.path) + } + return + } + // The gallery hands over a plain path string; tolerate a file:// URL too, since + // some sources write the text representation of a URL rather than a bare path. + let text = UTType.utf8PlainText.identifier + if provider.hasItemConformingToTypeIdentifier(text) { + provider.loadDataRepresentation(forTypeIdentifier: text) { data, _ in + guard let data, let raw = String(data: data, encoding: .utf8) else { + completion(nil); return + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("file://"), let url = URL(string: trimmed) { + completion(url.path) + } else { + completion(trimmed.isEmpty ? nil : trimmed) + } + } + return + } + completion(nil) + } +} diff --git a/Views/ZImage/ZImageParamsPanelView.swift b/Views/ZImage/ZImageParamsPanelView.swift index e96f737..cb172d3 100644 --- a/Views/ZImage/ZImageParamsPanelView.swift +++ b/Views/ZImage/ZImageParamsPanelView.swift @@ -300,26 +300,10 @@ struct ZImageParamsPanelView: View { } } } - .dropDestination(for: String.self, action: { paths, _ in - guard let path = paths.first else { return false } - let ext = (path as NSString).pathExtension.lowercased() - guard Self.imageExtensions.contains(ext) else { return false } + .imageDropTarget(extensions: Self.imageExtensions, isTargeted: $isImageDropTargeted) { paths in + guard let path = paths.first else { return } params.imagePath = path params.adoptResolvedPromptForImg2Img(at: path) - return true - }, isTargeted: { isImageDropTargeted = $0 }) - .onDrop(of: [.fileURL], isTargeted: $isImageDropTargeted) { providers in - providers.first?.loadDataRepresentation(forTypeIdentifier: "public.file-url") { data, _ in - guard let data, - let url = URL(dataRepresentation: data, relativeTo: nil) else { return } - let ext = url.pathExtension.lowercased() - guard Self.imageExtensions.contains(ext) else { return } - DispatchQueue.main.async { - self.params.imagePath = url.path - self.params.adoptResolvedPromptForImg2Img(at: url.path) - } - } - return true } .dropHighlight(isImageDropTargeted) }