From 6e6209208333bddf80853ea5d8b77942406ad84a Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Tue, 28 Jul 2026 03:47:47 -0300 Subject: [PATCH 1/5] fix: one drop target per image well instead of two competing ones Each well declared two drop targets on the same view: .dropDestination(for: String.self) for an in-app gallery drag (the gallery is an NSCollectionView whose pasteboard writer puts the path in as plain text) and .onDrop(of: [.fileURL]) for a Finder drag. Only one of two stacked targets ever receives the drag session, so one of the two sources was silently dead at every well and dropping looked broken depending on where the image came from. Replaces both with a single .imageDropTarget that resolves either representation, public.file-url or plain text holding a path or a file:// URL, inside one .onDrop. The multi-image edit list keeps drop order by resolving providers by index rather than by whichever loads first, since that order decides how the reference images reach the model. --- .../project.pbxproj | 4 + Views/Krea2/Krea2ParamsPanelView.swift | 20 +--- Views/ParamsPanel/ParamsPanelView.swift | 43 ++------- Views/Shared/ImageDropTarget.swift | 95 +++++++++++++++++++ Views/ZImage/ZImageParamsPanelView.swift | 20 +--- 5 files changed, 111 insertions(+), 71 deletions(-) create mode 100644 Views/Shared/ImageDropTarget.swift 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/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) } From 0f6482287a8ddc116da76e53eb02d1427780c0da Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Tue, 28 Jul 2026 03:47:47 -0300 Subject: [PATCH 2/5] fix(krea2): prompt token soft cap is 1024, not 512 Krea 2 was grouped with FLUX.2 Klein at 512, but it encodes through a qwen3vl tokenizer with max_length=1024. The counter warned on prompts that were well inside the real limit. --- Models/FluxModelCatalog.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 } From ceaa4d37c5dd96f69aad64123f0c335a3bb45973 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Tue, 28 Jul 2026 03:47:47 -0300 Subject: [PATCH 3/5] fix: batch expansion dropped the edit fields expandBatchJob rebuilds each seed's job by hand and omitted isEditMode and editImagePaths, both of which exist on FluxJob. It runs after completion, so nothing generates wrong; the queue's memory of a finished edit batch was just incomplete, and remixing from it lost the references. --- Stores/JobStore.swift | 2 ++ 1 file changed, 2 insertions(+) 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 ) From 47e4c3acb5fc71798cecebbe6996438a6c03ec2a Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Tue, 28 Jul 2026 03:48:10 -0300 Subject: [PATCH 4/5] fix: a FLUX.2 Edit's reference images were never recorded GenerationMetadata had no field for them, so the sidecar could not describe how an edit was made and Remix came back in Generate mode holding an edit instruction as its prompt, which txt2img has no way to honor. The field is optional: no sidecar written before this carries it, and nil reads back as "not an edit", so older images remix exactly as they did. --- Stores/ParamsPanelState.swift | 5 +++++ Utilities/MetadataSidecar.swift | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/Stores/ParamsPanelState.swift b/Stores/ParamsPanelState.swift index b9edf59..6d98284 100644 --- a/Stores/ParamsPanelState.swift +++ b/Stores/ParamsPanelState.swift @@ -100,6 +100,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 From e80f72b1ca8ada19c9a926c9a570ff1f9e899e4e Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Tue, 28 Jul 2026 03:48:28 -0300 Subject: [PATCH 5/5] fix: Low RAM applied only after a relaunch It is read at launch and on a model change, and nowhere else, so toggling it in Settings mid-session left the panel holding the old value for the rest of the session while the UI gave no sign of that. It is now re-read just before the jobs are built. --- App/ContentView.swift | 3 +++ Stores/ParamsPanelState.swift | 8 ++++++++ 2 files changed, 11 insertions(+) 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/Stores/ParamsPanelState.swift b/Stores/ParamsPanelState.swift index 6d98284..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)