From 8c604de686aaf4f946e53ef4f03855e2fcdbdacc Mon Sep 17 00:00:00 2001 From: Bobby Vensko Date: Wed, 23 Sep 2026 19:23:07 -0400 Subject: [PATCH] feat: add Qwen-Image 2.1 model family Adds Qwen/Qwen-Image-2.1 as a sixth family (`.qwenImage`) driving `mflux-generate-qwen-2.1`, which ships in mflux 0.20.0. The picker row is disabled with a note on older installs, as for every other family. - One variant, 40 steps, guidance 1.0 (the model is trained guidance-free). The negative prompt is only sent when guidance > 1, the one case where mflux runs true CFG. - Text-to-image and img2img (the atomic `--image PATH STRENGTH` flag). - BF16/Q8/Q4. Q8/Q4 quantize the DiT in memory at load, so every level shares the one ~33 GB BF16 download: no mflux-save pass, Settings downloads the official repo with `hf download`, and the cache row lists a single BF16 entry (deleting a Q4 chip would otherwise delete the shared checkpoint). BF16 is the default on 64 GB+ Macs, per mflux. - Queue, stepwise preview, gallery, metadata sidecars with remix/apply, SeedVR2 source inheritance, and a Settings -> Models form. - Subprocess only (no warm-driver pipeline); a Qwen run ejects any warm model first. No LoRA, which mflux does not support for this model, so the family is kept out of the LoRA editors via `supportsLoras`. Also fixes `isCompleteHFCache` (and the Settings download progress) for the newer huggingface_hub layout, where a repo's blobs/ entries are symlinks into a shared blob store: sizing the link instead of its target reported a fully downloaded model as missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SF8fB465MqsDHCYnKYzEoL --- AGENTS.md | 7 +- App/ContentView.swift | 150 +++++++- App/MLXBitsImageStudioApp.swift | 4 + .../project.pbxproj | 40 +++ Models/FluxModelCatalog.swift | 89 ++++- Models/ModelFamily.swift | 13 +- Models/QwenImageJob.swift | 190 ++++++++++ README.md | 6 +- ROADMAP.md | 14 + Runner/QwenImageJobRunner.swift | 146 ++++++++ Stores/AppSettings.swift | 15 + Stores/GalleryStore.swift | 30 +- Stores/QwenImageJobStore.swift | 157 +++++++++ Tests/QwenImageTests.swift | 204 +++++++++++ Utilities/BinaryDetector.swift | 9 + Utilities/MetadataSidecar.swift | 93 ++++- Views/Gallery/GenerationGalleryView.swift | 4 + Views/ParamsPanel/ModelPickerView.swift | 10 +- Views/ParamsPanel/ParamsPanelView.swift | 3 + Views/PreviewPane/GalleryItemDetailView.swift | 28 +- Views/PreviewPane/PreviewPaneView.swift | 74 ++++ Views/Queue/QueueDrawerView.swift | 167 +++++++++ .../QwenImage/QwenImageParamsPanelState.swift | 179 ++++++++++ .../QwenImage/QwenImageParamsPanelView.swift | 324 ++++++++++++++++++ Views/QwenImage/QwenImagePreviewViews.swift | 212 ++++++++++++ .../ModelDefaultsView+QwenImageForm.swift | 48 +++ Views/Settings/ModelDefaultsView.swift | 39 ++- Views/Settings/SettingsView.swift | 2 +- Views/Shared/DimensionConstraints.swift | 4 + Views/Shared/ImageMetadataInfo.swift | 39 +++ 30 files changed, 2263 insertions(+), 37 deletions(-) create mode 100644 Models/QwenImageJob.swift create mode 100644 Runner/QwenImageJobRunner.swift create mode 100644 Stores/QwenImageJobStore.swift create mode 100644 Tests/QwenImageTests.swift create mode 100644 Views/QwenImage/QwenImageParamsPanelState.swift create mode 100644 Views/QwenImage/QwenImageParamsPanelView.swift create mode 100644 Views/QwenImage/QwenImagePreviewViews.swift create mode 100644 Views/Settings/ModelDefaultsView+QwenImageForm.swift diff --git a/AGENTS.md b/AGENTS.md index 2eae517..7af909d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,9 @@ the Python driver and `Runner/DriverProtocol.swift`. ## Naming conventions -Five model families, defined in `Models/ModelFamily.swift`: `flux`, `ideogram4`, -`krea2`, `zimage`, `seedvr2`. Files follow the family name exactly: +Six model families, defined in `Models/ModelFamily.swift`: `flux`, `ideogram4`, +`krea2`, `zimage`, `qwenImage`, `seedvr2`. Files follow the family name exactly +(`qwenImage` files are spelled `QwenImage…`): | Concern | Path | |---|---| @@ -108,7 +109,7 @@ next load. When adding a parameter, prefer extending the family's `buildArgs` over touching `JobRunner` itself. If a change needs `JobRunner` edits, it probably belongs to -all five families. +every family. ## Recipe: add a model family diff --git a/App/ContentView.swift b/App/ContentView.swift index 16772ae..fb6118f 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -48,6 +48,8 @@ struct ContentView: View { @Environment(Krea2JobRunner.self) private var krea2Runner @Environment(ZImageJobStore.self) private var zimageStore @Environment(ZImageJobRunner.self) private var zimageRunner + @Environment(QwenImageJobStore.self) private var qwenImageStore + @Environment(QwenImageJobRunner.self) private var qwenImageRunner @Environment(SeedVR2JobStore.self) private var seedVR2Store @Environment(SeedVR2JobRunner.self) private var seedVR2Runner @Environment(GenerationCoordinator.self) private var coordinator @@ -70,6 +72,7 @@ struct ContentView: View { @State private var ideogramParams = Ideogram4ParamsPanelState() @State private var krea2Params = Krea2ParamsPanelState() @State private var zimageParams = ZImageParamsPanelState() + @State private var qwenImageParams = QwenImageParamsPanelState() /// Armed in `onAppear` when restoring the last-used model will change /// `params.model` away from its initial default. Swallows the resulting /// `onChange(of: params.model)` so the interactive "switch model → reset to @@ -96,7 +99,7 @@ struct ContentView: View { private var isAnyStoreRunning: Bool { store.isRunning || ideogram4Store.isRunning || krea2Store.isRunning - || zimageStore.isRunning || seedVR2Store.isRunning + || zimageStore.isRunning || qwenImageStore.isRunning || seedVR2Store.isRunning } /// Every image currently attached to an img2img / edit drop area, across the @@ -114,6 +117,9 @@ struct ContentView: View { if !zimageParams.imagePath.isEmpty { paths.insert(zimageParams.imagePath) } + if !qwenImageParams.imagePath.isEmpty { + paths.insert(qwenImageParams.imagePath) + } return paths } @@ -121,6 +127,7 @@ struct ContentView: View { ParamsPanelView( params: params, ideogramParams: ideogramParams, krea2Params: krea2Params, zimageParams: zimageParams, + qwenImageParams: qwenImageParams, onQueueScenarioBatch: queueScenarioBatch ) .frame(width: 350) @@ -141,6 +148,9 @@ struct ContentView: View { if params.model.isZImage { return zimageParams.quantize } + if params.model.isQwenImage { + return qwenImageParams.quantize + } return params.quantize }, set: { v in @@ -151,6 +161,8 @@ struct ContentView: View { krea2Params.quantize = v } else if params.model.isZImage { zimageParams.quantize = v + } else if params.model.isQwenImage { + qwenImageParams.quantize = v } else { params.quantize = v } @@ -220,10 +232,12 @@ struct ContentView: View { onApplyKrea2Settings: { meta in applyKrea2(meta, newSeed: false) }, onRemixZImage: { meta in applyZImage(meta, newSeed: true); generate() }, onApplyZImageSettings: { meta in applyZImage(meta, newSeed: false) }, + onRemixQwenImage: { meta in applyQwenImage(meta, newSeed: true); generate() }, + onApplyQwenImageSettings: { meta in applyQwenImage(meta, newSeed: false) }, onUseInImg2Img: useInImg2Img, onCancel: { runner.cancel(); ideogram4Runner.cancel(); krea2Runner.cancel() - zimageRunner.cancel(); seedVR2Runner.cancel() + zimageRunner.cancel(); qwenImageRunner.cancel(); seedVR2Runner.cancel() }, onClear: clearPreview, onEditBoxesOverImage: editBoxesOverImage, @@ -321,6 +335,7 @@ struct ContentView: View { ideogramParams.applyDefaults(settings: settings, library: loraLibrary) krea2Params.applyDefaults(settings: settings, library: loraLibrary) zimageParams.applyDefaults(settings: settings, library: loraLibrary) + qwenImageParams.applyDefaults(settings: settings) try? IdeogramPromptConfig.seedIfNeeded() try? ScenarioPromptConfig.seedIfNeeded() try? ScenarioExamplePrompts.seedIfNeeded() @@ -398,6 +413,14 @@ struct ContentView: View { guard count > 1 else { return } gallery.scan(outputDir: settings.outputDir) } + .onChange(of: qwenImageRunner.lastCompletedOutputPath) { _, path in + pendingSelectPath = path + gallery.scan(outputDir: settings.outputDir) + } + .onChange(of: qwenImageRunner.batchImageLanded) { _, count in + guard count > 1 else { return } + gallery.scan(outputDir: settings.outputDir) + } .onChange(of: seedVR2Runner.lastCompletedOutputPath) { _, path in pendingSelectPath = path gallery.scan(outputDir: settings.outputDir) @@ -425,6 +448,11 @@ struct ContentView: View { pumpQueues() } } + .onChange(of: qwenImageStore.isRunning) { _, running in + if !running { + pumpQueues() + } + } .onChange(of: seedVR2Store.isRunning) { _, running in if !running { pumpQueues() @@ -566,6 +594,11 @@ struct ContentView: View { selectedGalleryItem = nil previewState = .activeZImageJob(job) } + .onChange(of: qwenImageRunner.activeJob?.id) { _, id in + guard let id, let job = qwenImageStore.jobs.first(where: { $0.id == id }) else { return } + selectedGalleryItem = nil + previewState = .activeQwenImageJob(job) + } .onChange(of: seedVR2Runner.activeJob?.id) { _, id in guard let id, let job = seedVR2Store.jobs.first(where: { $0.id == id }) else { return } selectedGalleryItem = nil @@ -755,6 +788,24 @@ struct ContentView: View { .environment(zimageRunner) .environment(settings) .environment(coordinator) + } else if params.modelFamily == .qwenImage { + QwenImageQueueDrawerView(selectedJob: Binding( + get: { + if case let .activeQwenImageJob(j) = previewState { + return j + } + return nil + }, + set: { job in + if let j = job { + previewState = .activeQwenImageJob(j) + } + } + )) + .environment(qwenImageStore) + .environment(qwenImageRunner) + .environment(settings) + .environment(coordinator) } else { QueueDrawerView(selectedJob: Binding( get: { @@ -803,6 +854,8 @@ struct ContentView: View { krea2Params.isReadyToGenerate(settings: settings) case .zimage: zimageParams.isReadyToGenerate(settings: settings) + case .qwenImage: + qwenImageParams.isReadyToGenerate(settings: settings) case .seedvr2: false // upscaler — never the picker-selected family } @@ -823,6 +876,7 @@ struct ContentView: View { case .ideogram4: ideogramParams.seed == -1 case .krea2: krea2Params.seed == -1 case .zimage: zimageParams.seed == -1 + case .qwenImage: qwenImageParams.seed == -1 case .seedvr2: false } if seedIsRandom { @@ -891,6 +945,7 @@ struct ContentView: View { case .ideogram4: ideogramParams.seed != -1 case .krea2: krea2Params.seed != -1 case .zimage: zimageParams.seed != -1 + case .qwenImage: qwenImageParams.seed != -1 case .seedvr2: false } } @@ -908,6 +963,7 @@ struct ContentView: View { ideogramParams.seed = -1 krea2Params.seed = -1 zimageParams.seed = -1 + qwenImageParams.seed = -1 } label: { Image(systemName: "xmark.circle.fill").font(.caption2) } @@ -998,6 +1054,22 @@ struct ContentView: View { .foregroundStyle(.secondary) } } + } else if qwenImageStore.isRunning { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + if let job = qwenImageRunner.activeJob, job.seeds.count > 1 { + let done = job.completedSeedsInBatch + let total = job.seeds.count + Text("\(done)/\(total) images") + .monospacedDigit() + .foregroundStyle(.secondary) + } else { + let remaining = qwenImageStore.pendingJobs.count + 1 + Text("\(remaining) left") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } } else if seedVR2Store.isRunning { // SeedVR2 is single-output (no batch), so just show the count of // upscales still queued behind the running one. @@ -1165,6 +1237,11 @@ struct ContentView: View { : saved return true } + if m.isQwenImage { + // One variant and no LoRAs: the Qwen-Image form keeps its own state, + // so there is nothing to realign; returning true only keeps the Flux reset off it. + return true + } return false } @@ -1200,6 +1277,13 @@ struct ContentView: View { zimageParams.apply(metadata: meta, newSeed: newSeed) } + /// Switches the params panel to Qwen-Image 2.1 and replays a completed + /// generation's settings. + private func applyQwenImage(_ meta: QwenImageMetadata, newSeed: Bool) { + selectFamily(.qwenImage21, customRepo: meta.customModelRepo) + qwenImageParams.apply(metadata: meta, newSeed: newSeed) + } + /// Loads the image's bounding boxes (and dimensions) into the live Ideogram /// form and opens the box-overlay editor over the image. Prompt fields, seed, /// and preset are left untouched so the user can adjust boxes in isolation. @@ -1252,6 +1336,8 @@ struct ContentView: View { previewState = .activeKrea2Job(j) } else if let j = zimageRunner.activeJob { previewState = .activeZImageJob(j) + } else if let j = qwenImageRunner.activeJob { + previewState = .activeQwenImageJob(j) } else if let j = seedVR2Runner.activeJob { previewState = .activeSeedVR2Job(j) } else { @@ -1523,6 +1609,9 @@ struct ContentView: View { case .zimage: generateZImage(count: count, scenarioPrompts: scenarioPrompts) + case .qwenImage: + generateQwenImage(count: count, scenarioPrompts: scenarioPrompts) + case .seedvr2: break // upscaler — never the picker-selected family; started via startUpscale } @@ -1622,6 +1711,60 @@ struct ContentView: View { } } + /// Enqueues Qwen-Image 2.1 jobs for one Generate press. Wildcards fan out like + /// the Z-Image path: one job per option of the largest {a|b} group (capped 10), + /// or the batch count when set, each with its own resolved prompt + sidecar. + private func generateQwenImage(count: Int, scenarioPrompts: [String]? = nil) { + guard qwenImageParams.isReadyToGenerate(settings: settings) || scenarioPrompts?.isEmpty == false else { + return + } + // Remember the picker selection across sessions. `custom` must survive as + // itself, not be flattened to the variant it happens to load as. + settings.lastModel = params.model == .custom ? .custom : qwenImageParams.variant + settings.lastQwenImage = qwenImageParams.snapshot() // remember the form across launches + if scenarioPrompts == nil { + settings.recordPromptUse(qwenImageParams.prompt) + } + let wasIdle = !isAnyStoreRunning + let variants = min(WildcardExpander.variantCount(qwenImageParams.prompt), 10) + let jobs: [QwenImageJob] + if let scenarioPrompts, !scenarioPrompts.isEmpty { + jobs = scenarioPrompts.map { + qwenImageParams.makeJob( + count: 1, + customModelRepo: params.effectiveCustomRepo, + resolvedPrompt: collapseWildcards(($0, qwenImageParams.negativePrompt)) + ) + } + } else if variants > 1 { + let jobCount = count > 1 ? count : variants + let positives = WildcardExpander.expandVariants(qwenImageParams.prompt, count: jobCount) + let negatives = WildcardExpander.expandVariants(qwenImageParams.negativePrompt, count: jobCount) + jobs = (0 ..< jobCount).map { + qwenImageParams.makeJob( + count: 1, + customModelRepo: params.effectiveCustomRepo, + resolvedPrompt: (positives[$0], negatives[$0]) + ) + } + } else { + jobs = [qwenImageParams.makeJob(count: count, customModelRepo: params.effectiveCustomRepo)] + } + for job in jobs { + qwenImageStore.add(job) + } + // Qwen-Image runs as a one-shot CLI (~45 GB at BF16), so free any warm model + // first rather than wait out the picker's debounced switch eviction. + if wasIdle { + driverController.eject(reason: "model_switch") + } + qwenImageRunner.runNext(in: qwenImageStore, settings: settings, coordinator: coordinator, timing: timing) + if wasIdle, let firstJob = jobs.first { + selectedGalleryItem = nil + previewState = .activeQwenImageJob(firstJob) + } + } + // MARK: - Warm model chip (LM Studio-style eject) /// Header indicator for the driver's resident model: name, measured @@ -1843,6 +1986,9 @@ struct ContentView: View { } else if !zimageStore.pendingJobs.isEmpty { driverController.eject(reason: "model_switch") zimageRunner.runNext(in: zimageStore, settings: settings, coordinator: coordinator, timing: timing) + } else if !qwenImageStore.pendingJobs.isEmpty { + driverController.eject(reason: "model_switch") + qwenImageRunner.runNext(in: qwenImageStore, settings: settings, coordinator: coordinator, timing: timing) } else if !seedVR2Store.pendingJobs.isEmpty { // Free any warm generative model before the upscale — SeedVR2 loads its own // weights in a fresh CLI process, and holding both risks OOM. diff --git a/App/MLXBitsImageStudioApp.swift b/App/MLXBitsImageStudioApp.swift index eb4c395..4bbb86b 100644 --- a/App/MLXBitsImageStudioApp.swift +++ b/App/MLXBitsImageStudioApp.swift @@ -13,6 +13,8 @@ struct MLXBitsImageStudioApp: App { @State private var krea2Runner = Krea2JobRunner() @State private var zimageStore = ZImageJobStore() @State private var zimageRunner = ZImageJobRunner() + @State private var qwenImageStore = QwenImageJobStore() + @State private var qwenImageRunner = QwenImageJobRunner() @State private var seedVR2Store = SeedVR2JobStore() @State private var seedVR2Runner = SeedVR2JobRunner() @State private var coordinator = GenerationCoordinator() @@ -34,6 +36,8 @@ struct MLXBitsImageStudioApp: App { .environment(krea2Runner) .environment(zimageStore) .environment(zimageRunner) + .environment(qwenImageStore) + .environment(qwenImageRunner) .environment(seedVR2Store) .environment(seedVR2Runner) .environment(coordinator) diff --git a/MLXBits Image Studio.xcodeproj/project.pbxproj b/MLXBits Image Studio.xcodeproj/project.pbxproj index 1d51ee2..2ab2a79 100644 --- a/MLXBits Image Studio.xcodeproj/project.pbxproj +++ b/MLXBits Image Studio.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 0656414DEF830D93E90FBA77 /* BatchMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD54E0519CA91161F18FF31 /* BatchMenuButton.swift */; }; 0703F36886C67EE775626A49 /* SeedVR2JobStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BC6415AB342C85594335A1 /* SeedVR2JobStore.swift */; }; 096271A30E2FB543FB276AD7 /* SeedVR2Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F563EF965605798AA7F58B6 /* SeedVR2Job.swift */; }; + 0A034DE0728125D91896BBA9 /* QwenImageParamsPanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4328D68E626F6EC55CF94DF /* QwenImageParamsPanelState.swift */; }; 0BB3953DA1DD49FF37B3DBEB /* GenerationGalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FD595FF908251904AF94B9 /* GenerationGalleryView.swift */; }; 0C16B16763774DCD7712F5A1 /* IdeogramCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FDAD831E19BE75C734220 /* IdeogramCaption.swift */; }; 0CB0E07846DEFA72DB96A2BB /* PromptTokenEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85816C5F60D1644A2B0181F8 /* PromptTokenEstimator.swift */; }; @@ -23,6 +24,7 @@ 0D07E483A86332FC85422E90 /* RunnerSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6325B3C3F37C272F06328B /* RunnerSupportTests.swift */; }; 0E9571F6EEBDEE1180FF13BE /* ComfyLoraNameResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A21C08C353EB1404BCEB46BC /* ComfyLoraNameResolutionTests.swift */; }; 0F280E349F7241B82C9D7EC7 /* DriverProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8007637B14F14AA8B9FD8A /* DriverProtocol.swift */; }; + 12F040FFC43E3DB70222E65F /* QwenImageJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0DFA5DBC406A883BD4E075 /* QwenImageJob.swift */; }; 15C9FB8C83845AA4C5806F9A /* PromptLLMSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE707F9648CFE301222C21A /* PromptLLMSettingsView.swift */; }; 1650803712A3824AA0802E4C /* IdeogramCaptionEditorView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C8704700A92EDD979FE683 /* IdeogramCaptionEditorView+Actions.swift */; }; 16DC20A31C83D44C009AA770 /* WildcardExpander.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989B03F4B6A6D224348122FD /* WildcardExpander.swift */; }; @@ -49,6 +51,7 @@ 333970340121D49FD8E0298C /* PromptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4443AEA2099305CADF36EE5 /* PromptHistory.swift */; }; 3461F0A77AE564C88AA9B423 /* IconButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F160B133D562A1B4415BEF /* IconButtonStyle.swift */; }; 3498856F6B39D9B78EA1B08E /* GemmaChatRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45EB6A26F997E4EB0AF2B374 /* GemmaChatRunner.swift */; }; + 36E05AE7392A3C84027EA9FD /* QwenImageJobStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59418783E009C059260A359A /* QwenImageJobStore.swift */; }; 3990D357A4FEA083CD53B224 /* ModelDefaultsView+Rows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E4D8EFC1BACB33C690D6C2 /* ModelDefaultsView+Rows.swift */; }; 39FB05E51886E08ECB5A2CDA /* ZImageJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A742DE26429AC83DE8D166 /* ZImageJobRunner.swift */; }; 3AFED2C263CCBA74B59D34C4 /* ScenarioBatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9BC624F6CEF346F6A7F07E /* ScenarioBatchQueue.swift */; }; @@ -76,6 +79,7 @@ 5AA9A2C97A8FFFA68E0EB360 /* GalleryMetadataStripping.swift in Sources */ = {isa = PBXBuildFile; fileRef = F812C375B01307E326002869 /* GalleryMetadataStripping.swift */; }; 5CAF6B557C0FC1A727F5EFB8 /* Krea2Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD13236B5EAE62E8F45C42D /* Krea2Job.swift */; }; 5D1F115D51D6DA55F3B1EF10 /* OpenAIChatClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90C6D1E633010C5A77C0DBE /* OpenAIChatClientTests.swift */; }; + 5DF648F48C733F24F37091D6 /* QwenImageParamsPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 936626491673BD715834C5B8 /* QwenImageParamsPanelView.swift */; }; 61E4944EF2E7D266D8080B48 /* ZImageJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8DBAFEF3E0B6403D90EE29 /* ZImageJob.swift */; }; 62C2027429B9D3CD98665509 /* FullSizeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B646F0AF9AC8FBF9494F772D /* FullSizeImageView.swift */; }; 635C82517A4D9D8A01AC05AC /* ColorHexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E918DA5C6AF5C7590F13E /* ColorHexTests.swift */; }; @@ -102,6 +106,7 @@ 8C60284A9547C1107436C267 /* Ideogram4ParamsPanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57926FDFB7BD2FF38C94E765 /* Ideogram4ParamsPanelState.swift */; }; 8DAF2E907B2C91E89F817835 /* StepwisePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF4EE70D84ED34589363FC7 /* StepwisePreviewView.swift */; }; 8F06B91D903EE9A1417F6084 /* ModelFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDEE5ACF45EAB01F0E6CEF8 /* ModelFamily.swift */; }; + 8F9A1A63AAE9696550B7B0A5 /* ModelDefaultsView+QwenImageForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DB5504A29F4C93B53FD82C2 /* ModelDefaultsView+QwenImageForm.swift */; }; 8FB58B2482EBD66B089B3875 /* RunnerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655AC8400B9B30688813DF1A /* RunnerSupport.swift */; }; 932581AC6ABF9B321570FCB3 /* MLXBitsImageStudioApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF31F61799658BA5ECA26FB /* MLXBitsImageStudioApp.swift */; }; 939D3FE8CED702E3C86445B9 /* WildcardExpanderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95C286991226EA679AA0285 /* WildcardExpanderTests.swift */; }; @@ -129,6 +134,7 @@ C39DDE672CEEACCB8116B7B7 /* PassiveTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221656805C1026EDD0D6B9EA /* PassiveTextField.swift */; }; C43B03BB2FA61D5548DC5BB7 /* PromptHistoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B26244A619EF7BFE99AE9FB /* PromptHistoryPickerView.swift */; }; C9BB5D5DC950F0CF9D565CB5 /* BBoxEditorView+Gestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9CF53C1CEFCECFF3FE2374 /* BBoxEditorView+Gestures.swift */; }; + CC3B190F6ABDB78F393ADED0 /* QwenImagePreviewViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013544DD34B90FC04B84DB43 /* QwenImagePreviewViews.swift */; }; CC5BB2544F37500F928E8399 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3C1F3A0AA78460E83FD525 /* AppSettings.swift */; }; CD14F4A85DA57D9FAFA02045 /* ParamsPanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9474B6243420062519AB6BA /* ParamsPanelState.swift */; }; CE720A2FCD4D53E50CBDF11D /* LoraBaseModelHeuristic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE8D8821D022789E6524216 /* LoraBaseModelHeuristic.swift */; }; @@ -136,12 +142,14 @@ D0AAAABB26407B93F0126BDA /* UvInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 562FDB8B64EB1EEFFA0FFC6D /* UvInstaller.swift */; }; D380D3C1477B19506CEEB0BF /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB933A545C60752280A661AE /* Debouncer.swift */; }; D83BE145C93A460F115D96E8 /* CompareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E7AEA39A7CEEEC9D948F54 /* CompareView.swift */; }; + D9D6D270254595E59C185A09 /* QwenImageJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6DB8B86CA7094CEA5FDC4BD /* QwenImageJobRunner.swift */; }; DB9493BA714715D9A5EAA040 /* DimensionSliderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F98F9F31228EFCA1201025 /* DimensionSliderRow.swift */; }; DD1E02EE297AC49EACB88A1E /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87C7C46A40C6F9B9CF0C21D /* AboutView.swift */; }; DDADD3C50B04B2550F527A4C /* Ideogram4Preset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915F5EABE3BC064418630BC8 /* Ideogram4Preset.swift */; }; E00311006B99AF066C81DB4F /* ViewerFullScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04991A0086D680A3F52A907C /* ViewerFullScreen.swift */; }; E2E41732CD031DA45C3E19BA /* LoraLibraryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E08D8BC7BE1DE5473731C4 /* LoraLibraryStore.swift */; }; E3AFA2AD37A47559BE6AF63B /* ImageMetadataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776D62562D137DBCD692E19 /* ImageMetadataPanel.swift */; }; + E56F1943230AD4A4EAEA11D4 /* QwenImageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CD06A344BD1AFA20EB6ED9 /* QwenImageTests.swift */; }; E644615EFBDA336E54EF04EE /* ZImageParamsPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F07AFF0DD77A3B5CD7EFFDF /* ZImageParamsPanelView.swift */; }; E9CA85A03944BAC9A4FB9BA9 /* GalleryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB12782BB6D8B424591F3DE /* GalleryStore.swift */; }; EA83B400F4041AFE4F5297BA /* OpenAIChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985F3C0FD2619F10E9F451D5 /* OpenAIChatClient.swift */; }; @@ -173,6 +181,7 @@ /* Begin PBXFileReference section */ 00F416C559B37939372672BB /* ModelDefaultsView+ZImageForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModelDefaultsView+ZImageForm.swift"; sourceTree = ""; }; + 013544DD34B90FC04B84DB43 /* QwenImagePreviewViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImagePreviewViews.swift; sourceTree = ""; }; 014853D76AFC31CB2CC63B45 /* MfluxInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MfluxInstaller.swift; sourceTree = ""; }; 038221717CE794F77B7AEBF1 /* ComfyModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfyModelStore.swift; sourceTree = ""; }; 041A9FFA059AD1587FDA4199 /* SeedVR2JobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedVR2JobRunner.swift; sourceTree = ""; }; @@ -192,6 +201,7 @@ 16A563E555A53728042A51DB /* BBoxEditorView+Subviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BBoxEditorView+Subviews.swift"; sourceTree = ""; }; 1748E64B34608DBEDF0A6DAA /* ScenarioBatchQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioBatchQueueTests.swift; sourceTree = ""; }; 174F374D4B5F7A05666D31B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 17CD06A344BD1AFA20EB6ED9 /* QwenImageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImageTests.swift; sourceTree = ""; }; 1AFC7127BA6FCFDD4BA4E83E /* Krea2JobStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Krea2JobStore.swift; sourceTree = ""; }; 1CAFABDBFAB1501FDEBA8F28 /* SectionCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionCard.swift; sourceTree = ""; }; 20929E59AB251C273A8C9999 /* OpenAIChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAIChat.swift; sourceTree = ""; }; @@ -201,6 +211,7 @@ 2B35B01141A73113FEFB7B60 /* ScenarioGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioGeneratorTests.swift; sourceTree = ""; }; 2C5A2E7EBC193994B449F4A0 /* PidDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PidDecode.swift; sourceTree = ""; }; 2CDEE5ACF45EAB01F0E6CEF8 /* ModelFamily.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFamily.swift; sourceTree = ""; }; + 2D0DFA5DBC406A883BD4E075 /* QwenImageJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImageJob.swift; sourceTree = ""; }; 2E769B611A03FA4372F553CA /* Ideogram4Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ideogram4Job.swift; sourceTree = ""; }; 2F563EF965605798AA7F58B6 /* SeedVR2Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedVR2Job.swift; sourceTree = ""; }; 2F6325B3C3F37C272F06328B /* RunnerSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerSupportTests.swift; sourceTree = ""; }; @@ -227,6 +238,7 @@ 5454BD52BE3BB9A9EE3B0397 /* IdeogramCaptionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdeogramCaptionTests.swift; sourceTree = ""; }; 562FDB8B64EB1EEFFA0FFC6D /* UvInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UvInstaller.swift; sourceTree = ""; }; 57926FDFB7BD2FF38C94E765 /* Ideogram4ParamsPanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ideogram4ParamsPanelState.swift; sourceTree = ""; }; + 59418783E009C059260A359A /* QwenImageJobStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImageJobStore.swift; sourceTree = ""; }; 5A2396A0BCCE61568682C1DA /* Krea2JobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Krea2JobRunner.swift; sourceTree = ""; }; 5B04A1F9CFDF301EF539392C /* BBoxSemantics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BBoxSemantics.swift; sourceTree = ""; }; 5C6E918DA5C6AF5C7590F13E /* ColorHexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHexTests.swift; sourceTree = ""; }; @@ -243,6 +255,7 @@ 6C5A00F53C846D0792D55029 /* ModelDefaultsView+Krea2Form.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModelDefaultsView+Krea2Form.swift"; sourceTree = ""; }; 6CB12782BB6D8B424591F3DE /* GalleryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryStore.swift; sourceTree = ""; }; 6CD54E0519CA91161F18FF31 /* BatchMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchMenuButton.swift; sourceTree = ""; }; + 6DB5504A29F4C93B53FD82C2 /* ModelDefaultsView+QwenImageForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModelDefaultsView+QwenImageForm.swift"; sourceTree = ""; }; 6E9CF53C1CEFCECFF3FE2374 /* BBoxEditorView+Gestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BBoxEditorView+Gestures.swift"; sourceTree = ""; }; 6EAF1C67E02A6EFACEB795A8 /* krea2_scenario_prompt_example.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = krea2_scenario_prompt_example.md; sourceTree = ""; }; 6F07AFF0DD77A3B5CD7EFFDF /* ZImageParamsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZImageParamsPanelView.swift; sourceTree = ""; }; @@ -268,6 +281,7 @@ 915F5EABE3BC064418630BC8 /* Ideogram4Preset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ideogram4Preset.swift; sourceTree = ""; }; 91C5CA95EAA14C772647C853 /* FluxJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FluxJob.swift; sourceTree = ""; }; 92EE9C285AD48DF8F7110B0D /* MLXBits Image StudioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "MLXBits Image StudioTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 936626491673BD715834C5B8 /* QwenImageParamsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImageParamsPanelView.swift; sourceTree = ""; }; 93FDF598D2EFF5DA1D382120 /* Ideogram4ParamsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ideogram4ParamsPanelView.swift; sourceTree = ""; }; 96B20E83673119F893B43BBB /* FolderComboBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderComboBox.swift; sourceTree = ""; }; 985F3C0FD2619F10E9F451D5 /* OpenAIChatClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAIChatClient.swift; sourceTree = ""; }; @@ -278,6 +292,7 @@ A1C8704700A92EDD979FE683 /* IdeogramCaptionEditorView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IdeogramCaptionEditorView+Actions.swift"; sourceTree = ""; }; A21C08C353EB1404BCEB46BC /* ComfyLoraNameResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfyLoraNameResolutionTests.swift; sourceTree = ""; }; A31CFEFF049480510A99BB2F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A6DB8B86CA7094CEA5FDC4BD /* QwenImageJobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImageJobRunner.swift; sourceTree = ""; }; A8E8C1AA006E7FA05A525DE1 /* ComfyHistoryFailureDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfyHistoryFailureDecodeTests.swift; sourceTree = ""; }; A8EABE58BCB04CF6EDBF9751 /* BinaryDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryDetector.swift; sourceTree = ""; }; AADB0159980B7D99BDE55C34 /* InsetTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetTextEditor.swift; sourceTree = ""; }; @@ -287,6 +302,7 @@ B29DBB0224B6FFEBB151E5A7 /* DimensionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DimensionPickerView.swift; sourceTree = ""; }; B2D1921B86C433025600FEB8 /* ComfyProgressDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfyProgressDecodeTests.swift; sourceTree = ""; }; B2E81D7DE425AAF8EF038DEC /* LoraEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoraEntry.swift; sourceTree = ""; }; + B4328D68E626F6EC55CF94DF /* QwenImageParamsPanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QwenImageParamsPanelState.swift; sourceTree = ""; }; B4ED4622A2079F5ED9546458 /* DimensionConstraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DimensionConstraints.swift; sourceTree = ""; }; B5A80EFA17087C3CF5ED894C /* PromptTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptTemplate.swift; sourceTree = ""; }; B646F0AF9AC8FBF9494F772D /* FullSizeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullSizeImageView.swift; sourceTree = ""; }; @@ -362,6 +378,16 @@ path = Utilities; sourceTree = ""; }; + 37044D063DE42916BA1FCDE4 /* QwenImage */ = { + isa = PBXGroup; + children = ( + B4328D68E626F6EC55CF94DF /* QwenImageParamsPanelState.swift */, + 936626491673BD715834C5B8 /* QwenImageParamsPanelView.swift */, + 013544DD34B90FC04B84DB43 /* QwenImagePreviewViews.swift */, + ); + path = QwenImage; + sourceTree = ""; + }; 3E5DE97DAACB7E26744F7BE9 /* Shared */ = { isa = PBXGroup; children = ( @@ -413,6 +439,7 @@ 5E9882E2488E7CDE2179ECBE /* LoraBaseModelHeuristicTests.swift */, C921C6EC83B36F702B982D48 /* MfluxInstallerTests.swift */, C90C6D1E633010C5A77C0DBE /* OpenAIChatClientTests.swift */, + 17CD06A344BD1AFA20EB6ED9 /* QwenImageTests.swift */, 2F6325B3C3F37C272F06328B /* RunnerSupportTests.swift */, 1748E64B34608DBEDF0A6DAA /* ScenarioBatchQueueTests.swift */, 2B35B01141A73113FEFB7B60 /* ScenarioGeneratorTests.swift */, @@ -481,6 +508,7 @@ D07C86B92053779C5988498B /* JobRunner.swift */, 5A2396A0BCCE61568682C1DA /* Krea2JobRunner.swift */, 0B8A946E00348D9BD857672E /* MfluxDriverController.swift */, + A6DB8B86CA7094CEA5FDC4BD /* QwenImageJobRunner.swift */, 655AC8400B9B30688813DF1A /* RunnerSupport.swift */, 041A9FFA059AD1587FDA4199 /* SeedVR2JobRunner.swift */, E2A742DE26429AC83DE8D166 /* ZImageJobRunner.swift */, @@ -519,6 +547,7 @@ 318C96A132452AB2AC3F26D5 /* ModelDefaultsView+Forms.swift */, 4420F429A61B2BD0284305E8 /* ModelDefaultsView+Ideogram4Form.swift */, 6C5A00F53C846D0792D55029 /* ModelDefaultsView+Krea2Form.swift */, + 6DB5504A29F4C93B53FD82C2 /* ModelDefaultsView+QwenImageForm.swift */, 72E4D8EFC1BACB33C690D6C2 /* ModelDefaultsView+Rows.swift */, 35DEFE532DC648483265CCB7 /* ModelDefaultsView+SeedVR2Form.swift */, 00F416C559B37939372672BB /* ModelDefaultsView+ZImageForm.swift */, @@ -564,6 +593,7 @@ 72528118E0360DB5F808B0BC /* ParamsPanel */, B4967038153E9BBCF696B8BA /* PreviewPane */, 03F175B8A6A79FC1A8184DE3 /* Queue */, + 37044D063DE42916BA1FCDE4 /* QwenImage */, AE6CA2D7C353EC349D6C918D /* Settings */, 3E5DE97DAACB7E26744F7BE9 /* Shared */, B5D5026DA8A95AC4A2F72758 /* ZImage */, @@ -607,6 +637,7 @@ C4443AEA2099305CADF36EE5 /* PromptHistory.swift */, B5A80EFA17087C3CF5ED894C /* PromptTemplate.swift */, 85816C5F60D1644A2B0181F8 /* PromptTokenEstimator.swift */, + 2D0DFA5DBC406A883BD4E075 /* QwenImageJob.swift */, 2F563EF965605798AA7F58B6 /* SeedVR2Job.swift */, 9F8DBAFEF3E0B6403D90EE29 /* ZImageJob.swift */, ); @@ -626,6 +657,7 @@ 1AFC7127BA6FCFDD4BA4E83E /* Krea2JobStore.swift */, 75E08D8BC7BE1DE5473731C4 /* LoraLibraryStore.swift */, B9474B6243420062519AB6BA /* ParamsPanelState.swift */, + 59418783E009C059260A359A /* QwenImageJobStore.swift */, C5BC6415AB342C85594335A1 /* SeedVR2JobStore.swift */, 0BE207A0AFDE1EFE6C815E47 /* TimingStore.swift */, FA542CC2211E134C4BBE819D /* ZImageJobStore.swift */, @@ -854,6 +886,7 @@ 67E20D03F48F76817A738C86 /* ModelDefaultsView+Forms.swift in Sources */, EF113144EC0E822A2551EF74 /* ModelDefaultsView+Ideogram4Form.swift in Sources */, B3ABE3D77F1AC8AEBFCFE13E /* ModelDefaultsView+Krea2Form.swift in Sources */, + 8F9A1A63AAE9696550B7B0A5 /* ModelDefaultsView+QwenImageForm.swift in Sources */, 3990D357A4FEA083CD53B224 /* ModelDefaultsView+Rows.swift in Sources */, 1B4F4FBA717098E4C12A58A5 /* ModelDefaultsView+SeedVR2Form.swift in Sources */, 3B99A4928633B8D1F563F68A /* ModelDefaultsView+ZImageForm.swift in Sources */, @@ -877,6 +910,12 @@ 79A52A4EFE11E18330D636B9 /* PromptTemplatePickerView.swift in Sources */, 0CB0E07846DEFA72DB96A2BB /* PromptTokenEstimator.swift in Sources */, A8FE71ACDB9C1020D2FCD2AF /* QueueDrawerView.swift in Sources */, + 12F040FFC43E3DB70222E65F /* QwenImageJob.swift in Sources */, + D9D6D270254595E59C185A09 /* QwenImageJobRunner.swift in Sources */, + 36E05AE7392A3C84027EA9FD /* QwenImageJobStore.swift in Sources */, + 0A034DE0728125D91896BBA9 /* QwenImageParamsPanelState.swift in Sources */, + 5DF648F48C733F24F37091D6 /* QwenImageParamsPanelView.swift in Sources */, + CC3B190F6ABDB78F393ADED0 /* QwenImagePreviewViews.swift in Sources */, 8FB58B2482EBD66B089B3875 /* RunnerSupport.swift in Sources */, 3AFED2C263CCBA74B59D34C4 /* ScenarioBatchQueue.swift in Sources */, F3821108E24EEEEA9C759A95 /* ScenarioGenerator.swift in Sources */, @@ -920,6 +959,7 @@ 24636A27FCA3C0A9B76AE57D /* LoraBaseModelHeuristicTests.swift in Sources */, F25C2722C97A6A8CA4EC5729 /* MfluxInstallerTests.swift in Sources */, 5D1F115D51D6DA55F3B1EF10 /* OpenAIChatClientTests.swift in Sources */, + E56F1943230AD4A4EAEA11D4 /* QwenImageTests.swift in Sources */, 0D07E483A86332FC85422E90 /* RunnerSupportTests.swift in Sources */, 45A5BAA44F5602C038720620 /* ScenarioBatchQueueTests.swift in Sources */, 59160C7CE171391D5D9CAECA /* ScenarioGeneratorTests.swift in Sources */, diff --git a/Models/FluxModelCatalog.swift b/Models/FluxModelCatalog.swift index 7fb414d..5aa2b51 100644 --- a/Models/FluxModelCatalog.swift +++ b/Models/FluxModelCatalog.swift @@ -9,6 +9,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case krea2 case zimageTurbo = "z-image-turbo" case zimage = "z-image" + case qwenImage21 = "qwen-image-2.1" case custom /// Flux.2 variants only — used for base-model pickers and LoRA sections. @@ -18,7 +19,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { /// All non-custom models shown in Settings → Models. static var allModels: [Self] { - builtIn + [.ideogram4, .krea2, .zimageTurbo, .zimage] + builtIn + [.ideogram4, .krea2, .zimageTurbo, .zimage, .qwenImage21] } /// Models the `custom` picker entry can load through — every variant the app @@ -41,8 +42,12 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { if blobs.contains(where: { $0.lastPathComponent.hasSuffix(".incomplete") }) { return false } + // Newer huggingface_hub releases keep the payload in a shared hub-level blob + // store and leave each repo's blobs/ entries as symlinks into it, so size the + // link target: the link itself is a few dozen bytes and would read as a + // metadata-only partial. let totalBytes = blobs.reduce(0) { sum, url in - sum + ((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + sum + ((try? url.resolvingSymlinksInPath().resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) } // Every supported model's weights are multiple GB; a config-only partial is ~tens of MB. return totalBytes > 1_073_741_824 @@ -89,8 +94,13 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { self == .zimageTurbo } + /// Qwen-Image 2.1, which runs through its own ``ModelFamily/qwenImage`` pipeline. + var isQwenImage: Bool { + self == .qwenImage21 + } + var isFlux: Bool { - !isIdeogram4 && !isKrea2 && !isZImage && self != .custom + !isIdeogram4 && !isKrea2 && !isZImage && !isQwenImage && self != .custom } var displayName: String { @@ -103,6 +113,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: "Krea 2 Turbo" case .zimageTurbo: "Z-Image Turbo" case .zimage: "Z-Image" + case .qwenImage21: "Qwen-Image 2.1" case .custom: "Custom Model" } } @@ -117,6 +128,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: 8 // turbo reference default case .zimageTurbo: 9 // mflux z-image-turbo default case .zimage: 50 // mflux z-image default + case .qwenImage21: 40 // mflux qwen-2.1 reference default default: isDistilled ? 4 : 50 } } @@ -127,12 +139,13 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: 1.0 // turbo reference (CFG 1.0) case .zimageTurbo: 1.0 // guidance-free turbo (model forces guidance 0) case .zimage: 3.5 // base model classifier-free guidance + case .qwenImage21: 1.0 // trained guidance-free; > 1 plus a negative prompt enables true CFG default: isDistilled ? 1.0 : 3.5 } } var supportsNegativePrompt: Bool { - self == .custom || self == .zimage + self == .custom || self == .zimage || self == .qwenImage21 } /// Soft cap (in estimated tokens) for the prompt field, or `nil` to hide the @@ -145,6 +158,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { 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 .qwenImage21: 2048 // Qwen3-VL tokenizer, max_length=2048 case .ideogram4, .custom: nil } } @@ -156,12 +170,19 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .ideogram4: 28.0 // FP8 checkpoint case .krea2: 27.0 // krea/Krea-2-Turbo (~26.6 GB bf16) case .zimageTurbo, .zimage: 20.0 // 6.15B DiT + Qwen3-4B encoder + VAE, bf16 + case .qwenImage21: 33.0 // 7.1B DiT (14.2) + Qwen3-VL-8B encoder (17.5) + VAE (1.35), bf16 case .custom: 0 } } var recommendedQuantize: Int { - 8 + // Qwen-Image 2.1 quantizes in memory on every load and upstream measures BF16 + // as both its fastest and most accurate path (~45 GB peak), so only fall back + // to Q8 below the 64 GB the mflux README calls comfortable. + if isQwenImage { + return ProcessInfo.processInfo.physicalMemory >= 64 * 1_073_741_824 ? 0 : 8 + } + return 8 } var mfluxModelID: String { @@ -169,7 +190,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: "krea/Krea-2-Turbo" // official repo; rawValue "krea2" is reserved for local paths/keys case .zimageTurbo: "Tongyi-MAI/Z-Image-Turbo" case .zimage: "Tongyi-MAI/Z-Image" - default: rawValue + default: rawValue // qwenImage21: the mflux registry alias, which resolves to Qwen/Qwen-Image-2.1 } } @@ -181,6 +202,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .ideogram4: .ideogram4 case .krea2: .krea2 case .zimageTurbo, .zimage: .zimage + case .qwenImage21: .qwenImage case .flux2Klein4B, .flux2Klein9B, .flux2KleinBase4B, .flux2KleinBase9B, .custom: .flux } } @@ -198,6 +220,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: "mflux-generate-krea2" case .zimageTurbo: "mflux-generate-z-image-turbo" case .zimage: "mflux-generate-z-image" + case .qwenImage21: "mflux-generate-qwen-2.1" case .custom: nil } } @@ -218,6 +241,7 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: "krea/Krea-2-Turbo" case .zimageTurbo: "Tongyi-MAI/Z-Image-Turbo" case .zimage: "Tongyi-MAI/Z-Image" + case .qwenImage21: "Qwen/Qwen-Image-2.1" case .custom: nil } } @@ -233,13 +257,23 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { case .krea2: "krea-2-turbo" case .zimageTurbo: "z-image-turbo" case .zimage: "tongyi-mai--z-image" + case .qwenImage21: "models--qwen--qwen-image-2.1" case .custom: "" } } + /// Precision levels the Settings cache row lists. Qwen-Image 2.1 has one + /// checkpoint on disk whatever the level, so it lists (and deletes) only that. + var cachedQuantizeLevels: [Int] { + isQwenImage ? [0] : [0, 4, 8] + } + /// Returns true if any quantized variant of this model is fully downloaded in the HuggingFace hub cache. var isOnDisk: Bool { guard self != .custom else { return false } + if isQwenImage { + return onDiskURL(quantize: 0) != nil + } let hubDir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cache/huggingface/hub") guard let entries = try? FileManager.default.contentsOfDirectory(atPath: hubDir.path) else { return false } @@ -280,6 +314,20 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { } } + /// The repo a Settings download fetches as-is with `hf download`, or nil when + /// caching is an `mflux-save` quantize pass instead. Pre-quantized repos and + /// Ideogram's FP8 checkpoint load straight from the HF cache; Qwen-Image 2.1 + /// quantizes in memory at load, so its one BF16 repo serves every level. + func directDownloadRepoID(quantize: Int) -> String? { + if let repo = preQuantizedRepoID(quantize: quantize) { + return repo + } + if isIdeogram4 { + return "ideogram-ai/ideogram-4-fp8" + } + return isQwenImage ? bf16HFRepoID : nil + } + /// Approximate on-disk / unified-memory footprint in GB for a quantize level. /// Ideogram 4 ships as FP8 (already 8-bit), so its Q8 is roughly FP8-sized while /// Q4 roughly halves it — the generic BF16×factor model doesn't apply. @@ -300,6 +348,16 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { default: return 27 } } + // Qwen-Image 2.1 quantizes the DiT in memory at load; the Qwen3-VL encoder + // (~17.5 GB) never quantizes. Peak unified memory at 1024², measured on an + // M3 Ultra (BF16) and from the upstream mflux PR (Q8); Q4 extrapolated. + if isQwenImage { + switch quantize { + case 4: return 26 + case 8: return 31 + default: return 45 + } + } // Z-Image quantizes the transformer; the Qwen3-4B encoder + VAE stay BF16, // so the footprint sits above the generic BF16×factor estimate. if isZImage { @@ -313,6 +371,14 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { return approximateBF16SizeGB * factor } + /// Approximate download size in GB for a quantize level. Equal to + /// ``approximateSizeGB(quantize:)`` except for models that only ever download + /// one checkpoint and quantize it in memory, where every level fetches the + /// same BF16 repo. + func approximateDownloadGB(quantize: Int) -> Double { + isQwenImage ? approximateBF16SizeGB : approximateSizeGB(quantize: quantize) + } + /// Returns true for a specific quantize level, checking the mflux saved-weights dir first, /// then falling back to the HuggingFace hub cache. func isOnDisk(quantize: Int, savedIn cacheDir: URL) -> Bool { @@ -341,6 +407,17 @@ enum FluxModelVariant: String, CaseIterable, Codable, Hashable { let hubDir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cache/huggingface/hub") guard let entries = try? FileManager.default.contentsOfDirectory(atPath: hubDir.path) else { return nil } + // Qwen-Image 2.1 quantizes in memory from the one official BF16 repo, so every + // level is on disk exactly when that repo is. Matched by its exact cache name: + // a substring match would also catch third-party "Qwen-Image-2.1-*bit" packs + // that mflux cannot load. + if isQwenImage { + guard let repo = bf16HFRepoID else { return nil } + let cacheName = "models--" + repo.replacingOccurrences(of: "/", with: "--") + guard entries.contains(cacheName) else { return nil } + let url = hubDir.appendingPathComponent(cacheName) + return Self.isCompleteHFCache(at: url) ? url : nil + } // A published pre-quantized repo maps directly to its hub cache dir // (models--org--name), so match it explicitly rather than by substring. if quantize > 0, let repo = preQuantizedRepoID(quantize: quantize) { diff --git a/Models/ModelFamily.swift b/Models/ModelFamily.swift index f811eda..eebf9b9 100644 --- a/Models/ModelFamily.swift +++ b/Models/ModelFamily.swift @@ -8,6 +8,11 @@ enum ModelFamily: String, CaseIterable, Codable { /// share one generation pipeline (see ``ZImageJobRunner``). The specific /// variant a job used is carried on ``ZImageJob/modelVariant``. case zimage = "Z-Image" + /// Qwen-Image 2.1: a single guidance-free 7B DiT with a Qwen3-VL text encoder, + /// driven by `mflux-generate-qwen-2.1` (see ``QwenImageJobRunner``). mflux ships + /// no LoRA support for it, so it is left out of the LoRA surfaces + /// (see ``supportsLoras``). + case qwenImage = "Qwen-Image" /// SeedVR2 upscaler. Not a model-picker family — it's an action applied to an /// existing image (see ``SeedVR2JobRunner``). Present only so its runner has a /// distinct ``GenerationCoordinator`` gate identity (serializes against the @@ -15,7 +20,13 @@ enum ModelFamily: String, CaseIterable, Codable { case seedvr2 = "SeedVR2" /// Families the user can pick and generate with (excludes the upscaler). - static let generative: [Self] = [.flux, .ideogram4, .krea2, .zimage] + static let generative: [Self] = [.flux, .ideogram4, .krea2, .zimage, .qwenImage] + /// Whether the family's mflux pipeline accepts LoRAs. Drives which families + /// the LoRA library and stack editors offer. + var supportsLoras: Bool { + self != .qwenImage && self != .seedvr2 + } + /// Stable, machine-safe identifier for use as a persisted-dictionary key (the /// `rawValue`s above are human display strings and contain spaces). The ComfyUI /// checkpoint store is keyed by this so the settings UI and the runner agree. diff --git a/Models/QwenImageJob.swift b/Models/QwenImageJob.swift new file mode 100644 index 0000000..ec20b67 --- /dev/null +++ b/Models/QwenImageJob.swift @@ -0,0 +1,190 @@ +import AppKit +import Foundation + +/// A single Qwen-Image 2.1 generation request managed by ``QwenImageJobStore``. +/// +/// `QwenImageJob` holds both the *input parameters* submitted by the user and the +/// *runtime state* updated while the job executes. All properties are observable; +/// views bind to them directly. Jobs are persisted by ``QwenImageJobStore`` and +/// survive app restarts. +/// +/// Qwen-Image 2.1 is a single-stream, block-causal 7.1B DiT with a Qwen3-VL text +/// encoder, sampled guidance-free by default (guidance 1.0). A negative prompt only +/// takes effect with guidance above 1, which switches mflux to true classifier-free +/// guidance. `imagePath` empty = pure text-to-image. mflux has no LoRA support for +/// this model, so there is no LoRA stack. +@Observable +final class QwenImageJob: Identifiable { + let id: UUID + /// Repo ID or local path chosen via the picker's `Custom…` entry, loaded + /// through the Qwen-Image 2.1 pipeline. Empty = the official checkpoint. + var customModelRepo: String + var prompt: String + var negativePrompt: String + var width: Int + var height: Int + /// Requested seed. Use `-1` to let the runner pick a random seed at execution time. + var seed: Int + /// Multiple seeds for batch generation. Empty array = single-seed run. + var seeds: [Int] + var steps: Int + var guidance: Double + /// 0 = BF16. 8/4 quantize the DiT in memory at load; the text encoder stays BF16. + var quantize: Int + /// Optional init image for img2img. Empty string = pure text-to-image. + var imagePath: String + /// How strongly the init image influences the output (0.05–0.95). Only used when `imagePath` is set. + var imageStrength: Double + /// Output subfolder within the global output directory. Empty string = root. + var board: String + + var status: JobStatus + /// Raw stdout/stderr from the generation process. Appended in real time during a run. + var log: String + /// Absolute path to the finished image file. Set when the run succeeds. + var outputPath: String? + var outputPaths: [String] = [] // transient: multi-seed outputs, not persisted + var outputThumbnails: [Data] = [] // transient: thumbnails for fan-out, not persisted + var completedSeedsInBatch: Int = 0 // transient: incremented as each seed's image lands + /// The seed that was actually used. Differs from ``seed`` when ``seed`` is `-1`. + var resolvedSeed: Int? + /// JPEG thumbnail cached in memory and persisted with the job. + var thumbnailData: Data? + var currentStep: Int + var totalSteps: Int + var latestStepwisePath: String? + var statusLine: String = "" // transient: live CLI status for display during load/download + var stepTiming: String? // transient: "1:23 elapsed · 0:05 left" from tqdm + var isDenoising: Bool = false // transient: true once the denoising loop emits its first 0/N line + let createdAt: Date + var startedAt: Date? + var completedAt: Date? + + /// The catalog entry every Qwen-Image job runs as. + var modelVariant: FluxModelVariant { + .qwenImage21 + } + + /// Whether this run uses true classifier-free guidance: a negative prompt only + /// matters once guidance is above 1. + var usesTrueCFG: Bool { + guidance > 1.0 + } + + var progressFraction: Double { + guard totalSteps > 0 else { return 0 } + return Double(currentStep) / Double(totalSteps) + } + + var displayName: String { + let name = customModelRepo.isEmpty + ? modelVariant.displayName + : customModelRepo.split(separator: "/").last.map(String.init) ?? "custom" + return "\(name) · \(width)×\(height) · \(steps) steps" + } + + init( + id: UUID = UUID(), + customModelRepo: String = "", + prompt: String = "", + negativePrompt: String = "", + width: Int = 1024, + height: Int = 1024, + seed: Int = -1, + seeds: [Int] = [], + steps: Int = 40, + guidance: Double = 1.0, + quantize: Int = 0, + imagePath: String = "", + imageStrength: Double = 0.75, + board: String = "Default", + createdAt: Date = Date() + ) { + self.id = id + self.customModelRepo = customModelRepo + self.prompt = prompt + self.negativePrompt = negativePrompt + self.width = width + self.height = height + self.seed = seed + self.seeds = seeds + self.steps = steps + self.guidance = guidance + self.quantize = quantize + self.imagePath = imagePath + self.imageStrength = imageStrength + self.board = board + status = .pending + log = "" + currentStep = 0 + totalSteps = steps + self.createdAt = createdAt + } +} + +extension QwenImageJob: Codable { + enum CodingKeys: String, CodingKey { + case id, customModelRepo, prompt, negativePrompt + case width, height, seed, seeds, steps, guidance, quantize, imagePath, imageStrength, board + case status, log, outputPath, resolvedSeed, thumbnailData + case currentStep, totalSteps, createdAt, startedAt, completedAt + } + + convenience init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + id: c.decode(UUID.self, forKey: .id), + customModelRepo: (try? c.decode(String.self, forKey: .customModelRepo)) ?? "", + prompt: (try? c.decode(String.self, forKey: .prompt)) ?? "", + negativePrompt: (try? c.decode(String.self, forKey: .negativePrompt)) ?? "", + width: (try? c.decode(Int.self, forKey: .width)) ?? 1024, + height: (try? c.decode(Int.self, forKey: .height)) ?? 1024, + seed: (try? c.decode(Int.self, forKey: .seed)) ?? -1, + seeds: (try? c.decode([Int].self, forKey: .seeds)) ?? [], + steps: (try? c.decode(Int.self, forKey: .steps)) ?? 40, + guidance: (try? c.decode(Double.self, forKey: .guidance)) ?? 1.0, + quantize: (try? c.decode(Int.self, forKey: .quantize)) ?? 0, + imagePath: (try? c.decode(String.self, forKey: .imagePath)) ?? "", + imageStrength: (try? c.decode(Double.self, forKey: .imageStrength)) ?? 0.75, + board: (try? c.decode(String.self, forKey: .board)) ?? "Default", + createdAt: (try? c.decode(Date.self, forKey: .createdAt)) ?? Date() + ) + status = (try? c.decode(JobStatus.self, forKey: .status)) ?? .pending + log = (try? c.decode(String.self, forKey: .log)) ?? "" + outputPath = try? c.decode(String.self, forKey: .outputPath) + resolvedSeed = try? c.decode(Int.self, forKey: .resolvedSeed) + thumbnailData = try? c.decode(Data.self, forKey: .thumbnailData) + currentStep = (try? c.decode(Int.self, forKey: .currentStep)) ?? 0 + totalSteps = (try? c.decode(Int.self, forKey: .totalSteps)) ?? steps + startedAt = try? c.decode(Date.self, forKey: .startedAt) + completedAt = try? c.decode(Date.self, forKey: .completedAt) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(customModelRepo, forKey: .customModelRepo) + try c.encode(prompt, forKey: .prompt) + try c.encode(negativePrompt, forKey: .negativePrompt) + try c.encode(width, forKey: .width) + try c.encode(height, forKey: .height) + try c.encode(seed, forKey: .seed) + try c.encode(seeds, forKey: .seeds) + try c.encode(steps, forKey: .steps) + try c.encode(guidance, forKey: .guidance) + try c.encode(quantize, forKey: .quantize) + try c.encode(imagePath, forKey: .imagePath) + try c.encode(imageStrength, forKey: .imageStrength) + try c.encode(board, forKey: .board) + try c.encode(status, forKey: .status) + try c.encode(log, forKey: .log) + try c.encode(currentStep, forKey: .currentStep) + try c.encode(totalSteps, forKey: .totalSteps) + try c.encode(createdAt, forKey: .createdAt) + try c.encodeIfPresent(outputPath, forKey: .outputPath) + try c.encodeIfPresent(resolvedSeed, forKey: .resolvedSeed) + try c.encodeIfPresent(thumbnailData, forKey: .thumbnailData) + try c.encodeIfPresent(startedAt, forKey: .startedAt) + try c.encodeIfPresent(completedAt, forKey: .completedAt) + } +} diff --git a/README.md b/README.md index 59e3a6e..923f755 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MLXBits Image Studio -A native macOS Swift app for **FLUX, Krea 2, Z-Image, and (prototype) Ideogram 4 image generation** powered by [mflux](https://github.com/filipstrand/mflux) and Apple MLX. Queue jobs, watch generations unfold step-by-step, cull and compare results in a Lightroom-style gallery, and even write prompts with a local LLM — all without a CLI, and NOT yet another Electron container "app". +A native macOS Swift app for **FLUX, Krea 2, Z-Image, Qwen-Image 2.1, and (prototype) Ideogram 4 image generation** powered by [mflux](https://github.com/filipstrand/mflux) and Apple MLX. Queue jobs, watch generations unfold step-by-step, cull and compare results in a Lightroom-style gallery, and even write prompts with a local LLM — all without a CLI, and NOT yet another Electron container "app". > **Requires macOS Tahoe 26.0+** and Apple Silicon M-series. [mflux](https://github.com/filipstrand/mflux) is installed automatically on first launch. @@ -29,9 +29,10 @@ A native macOS Swift app for **FLUX, Krea 2, Z-Image, and (prototype) Ideogram 4 ## Features -- **Text-to-image and image-to-image** generation via FLUX.2 Klein, Krea 2 Turbo, and Z-Image models +- **Text-to-image and image-to-image** generation via FLUX.2 Klein, Krea 2 Turbo, Z-Image, and Qwen-Image 2.1 models - **Krea 2 Turbo** — fast photographic model family, with img2img support - **Z-Image & Z-Image Turbo** — Tongyi's 6B single-stream model; the distilled Turbo (9-step, guidance-free) and the base variant (classifier-free guidance + negative prompt), both with img2img and LoRA +- **Qwen-Image 2.1**: Qwen's 7B single-stream DiT with a Qwen3-VL text encoder; guidance-free 40-step sampling, long prompts and legible in-image text, img2img, and optional true CFG (guidance > 1 plus a negative prompt). Needs mflux 0.20.0+ - **Ideogram 4** *(prototype)* — structured-caption generation with a regional bounding-box layout editor and Gemma-assisted caption authoring (see [Ideogram 4](#ideogram-4) below) - **Warm-model engine** — keeps the model loaded between generations, eliminating reload time on consecutive runs - **Scenario generator** — turn a rough outline into finished prompts with a local LLM (no API key, no network round-trip) @@ -60,6 +61,7 @@ A native macOS Swift app for **FLUX, Krea 2, Z-Image, and (prototype) Ideogram 4 | Krea 2 Turbo | ~few | Fast photographic model; text-to-image and img2img | | Z-Image Turbo | 9 | Tongyi's distilled 6B single-stream model; guidance-free, text-to-image and img2img; BF16/Q8/Q4 (Q4 loads a pre-quantized MLX repo) | | Z-Image (base) | ~50 | Base Z-Image with classifier-free guidance and a negative prompt | +| Qwen-Image 2.1 | 40 | Qwen's 7B DiT; guidance-free, text-to-image and img2img; BF16/Q8/Q4 quantized in memory from one ~33 GB download (~45 GB peak at BF16). Needs mflux 0.20.0+ | | Ideogram 4 *(prototype)* | preset | Structured-caption model; FP8/Q8/Q4 precision selector (gated repo — accept terms on HuggingFace). Needs mflux 0.18.1+ — see below | | Custom | any | Any HuggingFace repo ID or local path, loaded as any model above — pick the architecture it matches under "Loads as" | diff --git a/ROADMAP.md b/ROADMAP.md index 2255047..06143a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,6 +31,20 @@ Deferred: **Qwen-Image-Edit** (~58 GB + net-new multi-image edit UI) and **FIBO* ## Shipped +### Qwen-Image 2.1 + +Qwen's second-generation text-to-image model (7.1B single-stream, block-causal +DiT; Qwen3-VL text encoder; 64-channel 16×-compression VAE), shipped as a +`.qwenImage` family driving `mflux-generate-qwen-2.1` (mflux 0.20.0+). One +variant, sampled guidance-free at 40 steps by default; guidance above 1 plus a +negative prompt switches mflux to true CFG. Text-to-image and img2img, a +BF16/Q8/Q4 selector (Q8/Q4 quantize the DiT in memory at load, so every level +shares the one `Qwen/Qwen-Image-2.1` download and there is no `mflux-save` +pass), metadata sidecars with remix/apply, SeedVR2 source inheritance, and a +Settings → Models form. One-shot CLI only (no warm-driver pipeline) and no +LoRA, which mflux does not support for this model yet. Editing, multi-reference +input, and RGBA output are not in mflux's CLI yet either. + ### Z-Image & Z-Image Turbo Alibaba Tongyi's single-stream DiT text-to-image model, shipped as a new diff --git a/Runner/QwenImageJobRunner.swift b/Runner/QwenImageJobRunner.swift new file mode 100644 index 0000000..133aaf1 --- /dev/null +++ b/Runner/QwenImageJobRunner.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Executes Qwen-Image 2.1 generation jobs by driving the `mflux-generate-qwen-2.1` +/// CLI (mflux 0.20.0+). The shared ``JobRunner`` engine owns the process lifecycle; +/// this spec supplies the Qwen-Image-specific behavior. Weights come from +/// `Qwen/Qwen-Image-2.1` (diffusers layout, converted on load). Q8/Q4 quantize the +/// DiT in memory at load (the Qwen3-VL text encoder always stays BF16), so there +/// is no `mflux-save` pass and no pre-quantized repo. Runs as a subprocess only; +/// the warm driver has no Qwen-Image pipeline. +typealias QwenImageJobRunner = JobRunner + +extension QwenImageJob: GeneratedJob {} +extension QwenImageJobStore: GenerationJobStore {} + +enum QwenImageRunnerSpec: JobRunnerSpec { + typealias Job = QwenImageJob + typealias Store = QwenImageJobStore + + static let family: ModelFamily = .qwenImage + static let stepwiseSubdir = "stepwise-qwenimage" + static let outputPrefix = "qwenimage" + static let encodingLabel = "Generating" + + /// Qwen-Image 2.1 is trained guidance-free. mflux runs true classifier-free + /// guidance only when guidance is above 1 *and* a negative prompt is present, + /// so below that threshold the negative prompt is dropped rather than sent to + /// be silently ignored. + static func negativePromptArg(for job: QwenImageJob) -> String? { + guard job.usesTrueCFG else { return nil } + let trimmed = job.negativePrompt.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : job.negativePrompt + } + + static func binaryName(job _: QwenImageJob) -> String { + "mflux-generate-qwen-2.1" + } + + static func binaryPath(job _: QwenImageJob, settings: AppSettings) -> String { + settings.mfluxQwenImageBinaryPath() + } + + /// Never an `mflux-save` pass: quantization happens in memory at load. + static func quantSaveDestination(job _: QwenImageJob, settings _: AppSettings) -> URL? { + nil + } + + static func saveBinaryPath(settings: AppSettings) -> String { + BinaryDetector.mfluxSave(in: settings.mfluxBinaryDir) + } + + static func saveModelID(job: QwenImageJob) -> String { + job.modelVariant.mfluxModelID + } + + /// img2img runs fewer denoise steps than requested (the image-strength schedule + /// drops the leading steps), so the tqdm total can be < job.steps. + static func acceptsProgressTotal(_ total: Int, job: QwenImageJob) -> Bool { + total <= job.steps + } + + static func timingModelKey(job: QwenImageJob) -> String { + TimingStore.modelKey(job.modelVariant.rawValue, customRepo: job.customModelRepo) + } + + static func timingLowRam(job _: QwenImageJob) -> Bool { + false + } + + static func writeMetadata(job: QwenImageJob, seed: Int, startedAt: Date?, generatedAt: Date, path: String) { + var meta = QwenImageMetadata.from(job: job) + meta.seed = seed + meta.startedAt = startedAt + meta.generatedAt = generatedAt + MetadataSidecar.writeQwenImage(meta, for: path) + } + + /// A repo ID or path that replaces the official checkpoint: the picker's + /// `Custom…` entry first, then the Settings → Models override. Both name a + /// specific set of weights, so neither takes a `--quantize` pass. + private static func modelSourceOverride(job: QwenImageJob, settings: AppSettings) -> String? { + let custom = job.customModelRepo.trimmingCharacters(in: .whitespaces) + if !custom.isEmpty { + return custom + } + let override = (settings.defaults(for: job.modelVariant).modelRepoOverride ?? "") + .trimmingCharacters(in: .whitespaces) + return override.isEmpty ? nil : override + } + + static func buildArgs(job: QwenImageJob, ctx: JobRunContext, settings: AppSettings) -> [String] { + arguments( + job: job, ctx: ctx, + modelSource: modelSourceOverride(job: job, settings: settings), + mlxCacheLimitGB: settings.mlxCacheLimitGB + ) + } + + /// The CLI arguments for `job`, given the settings-derived inputs already + /// resolved: `modelSource` is the custom/override checkpoint (nil = official). + /// Split from ``buildArgs(job:ctx:settings:)`` so it is testable without an + /// `AppSettings`, which reads and writes the user's settings file. + static func arguments( + job: QwenImageJob, ctx: JobRunContext, modelSource: String?, mlxCacheLimitGB: Double + ) -> [String] { + var args: [String] = [] + + if let source = modelSource { + // `--base-model` pins the geometry mflux loads a third-party checkpoint as. + args += ["--model", source, "--base-model", job.modelVariant.mfluxModelID] + } else { + args += ["--model", job.modelVariant.mfluxModelID] + if job.quantize > 0 { + args += ["--quantize", "\(job.quantize)"] + } + } + + args += ["--prompt", job.prompt] + if let negative = negativePromptArg(for: job) { + args += ["--negative-prompt", negative] + } + args += ["--width", "\(job.width)", "--height", "\(job.height)"] + args += ["--steps", "\(job.steps)"] + args += ["--guidance", String(format: "%.2f", job.guidance)] + args += ["--output", ctx.outputFile] + + if job.seeds.isEmpty { + args += ["--seed", "\(ctx.seed)"] + } else { + args += ["--seed"] + job.seeds.map { "\($0)" } + } + + // img2img: an init image seeds the latents; empty path = pure text-to-image. + // The CLI only exists on mflux 0.20.0+, which has the atomic `--image PATH STRENGTH`. + if !job.imagePath.isEmpty { + args += ["--image", job.imagePath, String(format: "%.2f", job.imageStrength)] + } + + if mlxCacheLimitGB > 0 { + args += ["--mlx-cache-limit-gb", String(format: "%.1f", mlxCacheLimitGB)] + } + + args += ["--stepwise-image-output-dir", ctx.stepwiseDir.path] + + return args + } +} diff --git a/Stores/AppSettings.swift b/Stores/AppSettings.swift index 4378fe7..c06b46d 100644 --- a/Stores/AppSettings.swift +++ b/Stores/AppSettings.swift @@ -104,6 +104,8 @@ class AppSettings { var comfyCheckpoint: [String: String]? /// Z-Image last-used form (remembered across launches) var lastZImage: ZImageFormState? + /// Qwen-Image 2.1 last-used form (remembered across launches) + var lastQwenImage: QwenImageFormState? /// SeedVR2 upscale defaults (remembered across launches) var seedVR2Use7B: Bool? var seedVR2Quantize: Int? @@ -406,6 +408,11 @@ class AppSettings { didSet { save() } } + /// Last-used Qwen-Image 2.1 form, restored on next launch. + var lastQwenImage: QwenImageFormState? { + didSet { save() } + } + // MARK: - SeedVR2 upscale defaults /// Default SeedVR2 model size: false = 3B (fast), true = 7B (quality). @@ -584,6 +591,7 @@ class AppSettings { return caches.appendingPathComponent("mflux") } + // swiftlint:disable:next function_body_length init() { let s = Self.loadStored() let model = s.defaultModel ?? .flux2Klein9B @@ -628,6 +636,7 @@ class AppSettings { lastCustomBaseModel = s.lastCustomBaseModel ?? .flux2Klein9B lastKrea2 = s.lastKrea2 lastZImage = s.lastZImage + lastQwenImage = s.lastQwenImage comfyURL = s.comfyURL ?? "" // Migrate a pre-split saved `comfyCheckpoint` entry into the UNet slot so existing config keeps working. var unetMap = s.comfyUNet ?? [:] @@ -816,6 +825,7 @@ class AppSettings { s.lastCustomBaseModel = lastCustomBaseModel s.lastKrea2 = lastKrea2 s.lastZImage = lastZImage + s.lastQwenImage = lastQwenImage s.comfyURL = comfyURL s.comfyUNet = comfyUNet s.comfyClip = comfyClip @@ -882,6 +892,11 @@ class AppSettings { : BinaryDetector.mfluxGenerateZImage(in: mfluxBinaryDir) } + /// Resolves `mflux-generate-qwen-2.1` (mflux 0.20.0+). + func mfluxQwenImageBinaryPath() -> String { + BinaryDetector.mfluxGenerateQwenImage21(in: mfluxBinaryDir) + } + func mfluxSeedVR2BinaryPath() -> String { BinaryDetector.mfluxUpscaleSeedVR2(in: mfluxBinaryDir) } diff --git a/Stores/GalleryStore.swift b/Stores/GalleryStore.swift index da4a49c..d1ae3ab 100644 --- a/Stores/GalleryStore.swift +++ b/Stores/GalleryStore.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import AppKit import Foundation import UniformTypeIdentifiers @@ -17,6 +18,7 @@ struct GalleryItem: Identifiable, Equatable { var ideogram4Metadata: Ideogram4Metadata? var krea2Metadata: Krea2Metadata? var zimageMetadata: ZImageMetadata? + var qwenImageMetadata: QwenImageMetadata? var seedVR2Metadata: SeedVR2Metadata? /// Lightroom-style cull verdict, persisted as an xattr on the file (see ``GalleryCulling``). var flag: PickFlag? @@ -51,6 +53,9 @@ struct GalleryItem: Identifiable, Equatable { if name.hasPrefix("zimage") { return .zimage } + if name.hasPrefix("qwenimage") { + return .qwenImage + } if name.hasPrefix("seedvr2") { // Prefer the embedded source metadata (definitive, and correct even for a // chained upscale whose source filename is itself "seedvr2_…"). @@ -63,6 +68,9 @@ struct GalleryItem: Identifiable, Equatable { if seedVR2Metadata?.sourceZImage != nil { return .zimage } + if seedVR2Metadata?.sourceQwenImage != nil { + return .qwenImage + } if seedVR2Metadata?.sourceFlux != nil { return .flux } @@ -77,6 +85,9 @@ struct GalleryItem: Identifiable, Equatable { if src.hasPrefix("zimage") { return .zimage } + if src.hasPrefix("qwenimage") { + return .qwenImage + } return .flux } return .flux @@ -466,11 +477,13 @@ nonisolated private func scanDirectory( let ideogramMeta: Ideogram4Metadata? let krea2Meta: Krea2Metadata? let zimageMeta: ZImageMetadata? + let qwenImageMeta: QwenImageMetadata? let seedVR2Meta: SeedVR2Metadata? let priorHasMetadata = prior?.metadata != nil || prior?.ideogram4Metadata != nil || prior?.krea2Metadata != nil || prior?.zimageMetadata != nil + || prior?.qwenImageMetadata != nil || prior?.seedVR2Metadata != nil if let prior, prior.modifiedAt == modDate, priorHasMetadata { // Unchanged file already carrying sidecar metadata: skip the JSON re-read. @@ -479,19 +492,23 @@ nonisolated private func scanDirectory( ideogramMeta = prior.ideogram4Metadata krea2Meta = prior.krea2Metadata zimageMeta = prior.zimageMetadata + qwenImageMeta = prior.qwenImageMetadata seedVR2Meta = prior.seedVR2Metadata } else { // New or changed file — or one whose sidecar hadn't landed yet when last // scanned (batch sidecars are written just after the PNG), so retry the read. // Z-Image is probed by filename prefix first — its sidecar shares the FLUX // shape closely enough that a blind FLUX decode could mis-claim it. - let isZImageFile = url.lastPathComponent.lowercased().hasPrefix("zimage") - zimageMeta = isZImageFile ? MetadataSidecar.readZImage(for: url.path) : nil - fluxMeta = zimageMeta == nil ? MetadataSidecar.read(for: url.path) : nil - ideogramMeta = fluxMeta == nil && zimageMeta == nil ? MetadataSidecar.readIdeogram4(for: url.path) : nil - krea2Meta = fluxMeta == nil && ideogramMeta == nil && zimageMeta == nil + // Qwen-Image likewise, since its sidecar would also decode as Krea 2. + let lowerName = url.lastPathComponent.lowercased() + zimageMeta = lowerName.hasPrefix("zimage") ? MetadataSidecar.readZImage(for: url.path) : nil + qwenImageMeta = lowerName.hasPrefix("qwenimage") ? MetadataSidecar.readQwenImage(for: url.path) : nil + let claimedByPrefix = zimageMeta != nil || qwenImageMeta != nil + fluxMeta = claimedByPrefix ? nil : MetadataSidecar.read(for: url.path) + ideogramMeta = fluxMeta == nil && !claimedByPrefix ? MetadataSidecar.readIdeogram4(for: url.path) : nil + krea2Meta = fluxMeta == nil && ideogramMeta == nil && !claimedByPrefix ? MetadataSidecar.readKrea2(for: url.path) : nil - seedVR2Meta = fluxMeta == nil && ideogramMeta == nil && krea2Meta == nil && zimageMeta == nil + seedVR2Meta = fluxMeta == nil && ideogramMeta == nil && krea2Meta == nil && !claimedByPrefix ? MetadataSidecar.readSeedVR2(for: url.path) : nil } // Flags/ratings live in xattrs and never change the file's mtime, so the @@ -505,6 +522,7 @@ nonisolated private func scanDirectory( ideogram4Metadata: ideogramMeta, krea2Metadata: krea2Meta, zimageMetadata: zimageMeta, + qwenImageMetadata: qwenImageMeta, seedVR2Metadata: seedVR2Meta, flag: GalleryCulling.readFlag(path: url.path), rating: GalleryCulling.readRating(path: url.path) diff --git a/Stores/QwenImageJobStore.swift b/Stores/QwenImageJobStore.swift new file mode 100644 index 0000000..c5e71df --- /dev/null +++ b/Stores/QwenImageJobStore.swift @@ -0,0 +1,157 @@ +import Foundation + +/// Persists and manages the Qwen-Image 2.1 generation queue. +@Observable +@MainActor +final class QwenImageJobStore { + private static let appSupportURL: URL = { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return base.appendingPathComponent("MLXBits Image Studio", isDirectory: true) + }() + + private static let jobsURL: URL = appSupportURL.appendingPathComponent("qwenimage-jobs.json") + private static let maxJobs = 100 + + var jobs: [QwenImageJob] = [] + var isRunning: Bool = false + + @ObservationIgnored private let saveDebouncer = Debouncer() + + var pendingJobs: [QwenImageJob] { + jobs.filter { $0.status == .pending } + } + + init() { + load() + } + + // MARK: - Queue management + + func add(_ job: QwenImageJob) { + jobs.insert(job, at: 0) + pruneIfNeeded() + save() + } + + func addBatch(_ jobs: [QwenImageJob]) { + for job in jobs.reversed() { + self.jobs.insert(job, at: 0) + } + pruneIfNeeded() + save() + } + + func expandBatchJob(_ batchJob: QwenImageJob) { + guard let idx = jobs.firstIndex(where: { $0.id == batchJob.id }) else { return } + let expanded = zip(batchJob.seeds, batchJob.outputPaths).enumerated().map { i, pair in + let (seed, path) = pair + let job = QwenImageJob( + customModelRepo: batchJob.customModelRepo, + prompt: batchJob.prompt, + negativePrompt: batchJob.negativePrompt, + width: batchJob.width, + height: batchJob.height, + seed: seed, + steps: batchJob.steps, + guidance: batchJob.guidance, + quantize: batchJob.quantize, + imagePath: batchJob.imagePath, + imageStrength: batchJob.imageStrength, + board: batchJob.board, + createdAt: batchJob.createdAt + ) + job.status = .completed + job.resolvedSeed = seed + job.outputPath = path + job.thumbnailData = batchJob.outputThumbnails.indices.contains(i) ? batchJob.outputThumbnails[i] : nil + job.log = batchJob.log + job.currentStep = batchJob.steps + job.totalSteps = batchJob.steps + job.startedAt = batchJob.startedAt + job.completedAt = batchJob.completedAt + return job + } + jobs.replaceSubrange(idx ... idx, with: expanded) + } + + func remove(ids: Set) { + jobs.removeAll { ids.contains($0.id) } + save() + } + + func cancelJob(_ job: QwenImageJob) { + guard case .pending = job.status else { return } + job.status = .cancelled + save() + } + + func cancelAllPending() { + for job in jobs where job.status == .pending { + job.status = .cancelled + } + save() + } + + func purgeTerminal() { + jobs.removeAll { $0.status.isTerminal } + save() + } + + func restart(_ job: QwenImageJob) { + job.status = .pending + job.log = "" + job.outputPath = nil + job.resolvedSeed = nil + job.thumbnailData = nil + job.currentStep = 0 + job.totalSteps = job.steps + job.startedAt = nil + job.completedAt = nil + job.latestStepwisePath = nil + jobs.removeAll { $0.id == job.id } + jobs.insert(job, at: 0) + save() + } + + // MARK: - Persistence + + private func pruneIfNeeded() { + guard jobs.count > Self.maxJobs else { return } + var result = jobs + while result.count > Self.maxJobs { + if let idx = result.indices.reversed().first(where: { result[$0].status.isTerminal }) { + result.remove(at: idx) + } else { + break + } + } + jobs = result + } + + /// Debounced: encoding up to 100 jobs (logs + thumbnails) after every mutation is + /// too heavy to do per call. The Debouncer flushes pending work at app termination. + func save() { + saveDebouncer.schedule { [weak self] in self?.saveNow() } + } + + private func saveNow() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(Array(jobs.prefix(Self.maxJobs))) { + try? FileManager.default.createDirectory(at: Self.appSupportURL, withIntermediateDirectories: true) + try? data.write(to: Self.jobsURL, options: .atomic) + } + } + + private func load() { + guard let data = try? Data(contentsOf: Self.jobsURL) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let loaded = try? decoder.decode([QwenImageJob].self, from: data) else { return } + for job in loaded where job.status == .running { + job.status = .failed("Interrupted: the app was quit during generation") + } + jobs = loaded + } +} diff --git a/Tests/QwenImageTests.swift b/Tests/QwenImageTests.swift new file mode 100644 index 0000000..017545b --- /dev/null +++ b/Tests/QwenImageTests.swift @@ -0,0 +1,204 @@ +import Foundation +@testable import MLXBits_Image_Studio +import Testing + +struct QwenImageRunnerArgsTests { + private let ctx = JobRunContext( + seed: 42, outputFile: "/tmp/qwenimage_out.png", + stepwiseDir: URL(fileURLWithPath: "/tmp/stepwise-qwenimage"), promptFile: nil + ) + + private func args(_ job: QwenImageJob, modelSource: String? = nil, cacheGB: Double = 0) -> [String] { + QwenImageRunnerSpec.arguments(job: job, ctx: ctx, modelSource: modelSource, mlxCacheLimitGB: cacheGB) + } + + /// The value following `flag`, or nil when the flag is absent. + private func value(_ flag: String, in args: [String]) -> String? { + guard let i = args.firstIndex(of: flag), args.indices.contains(i + 1) else { return nil } + return args[i + 1] + } + + @Test func defaultsRunTheRegistryModelAtBF16GuidanceFree() { + let a = args(QwenImageJob(prompt: "a fox")) + #expect(value("--model", in: a) == "qwen-image-2.1") + #expect(!a.contains("--quantize")) + #expect(!a.contains("--base-model")) + #expect(value("--steps", in: a) == "40") + #expect(value("--guidance", in: a) == "1.00") + #expect(value("--seed", in: a) == "42") + #expect(value("--output", in: a) == "/tmp/qwenimage_out.png") + #expect(!a.contains("--negative-prompt")) + #expect(!a.contains("--image")) + #expect(!a.contains("--mlx-cache-limit-gb")) + } + + @Test func quantizeIsAppliedInMemory() { + #expect(value("--quantize", in: args(QwenImageJob(prompt: "p", quantize: 8))) == "8") + #expect(value("--quantize", in: args(QwenImageJob(prompt: "p", quantize: 4))) == "4") + } + + /// mflux runs true CFG only when guidance > 1 and a negative prompt is set. + @Test func negativePromptOnlyTravelsWithTrueCFG() { + let guidanceFree = args(QwenImageJob(prompt: "p", negativePrompt: "blurry", guidance: 1.0)) + #expect(!guidanceFree.contains("--negative-prompt")) + + let cfg = args(QwenImageJob(prompt: "p", negativePrompt: "blurry", guidance: 4.0)) + #expect(value("--negative-prompt", in: cfg) == "blurry") + #expect(value("--guidance", in: cfg) == "4.00") + + let blank = args(QwenImageJob(prompt: "p", negativePrompt: " \n", guidance: 4.0)) + #expect(!blank.contains("--negative-prompt")) + } + + @Test func img2imgUsesTheAtomicImageFlag() { + let a = args(QwenImageJob(prompt: "p", imagePath: "/tmp/in.png", imageStrength: 0.6)) + let i = a.firstIndex(of: "--image") + #expect(i.map { Array(a[$0 ... $0 + 2]) } == ["--image", "/tmp/in.png", "0.60"]) + #expect(!a.contains("--image-path")) + } + + @Test func customCheckpointPinsTheBaseModelAndSkipsQuantize() { + let a = args(QwenImageJob(prompt: "p", quantize: 8), modelSource: "org/qwen-finetune") + #expect(value("--model", in: a) == "org/qwen-finetune") + #expect(value("--base-model", in: a) == "qwen-image-2.1") + #expect(!a.contains("--quantize")) + } + + @Test func batchSeedsAreAllPassed() { + let a = args(QwenImageJob(prompt: "p", seeds: [7, 8, 9])) + let i = a.firstIndex(of: "--seed") + #expect(i.map { Array(a[$0 ... $0 + 3]) } == ["--seed", "7", "8", "9"]) + } + + @Test func mlxCacheLimitIsForwarded() { + #expect(value("--mlx-cache-limit-gb", in: args(QwenImageJob(prompt: "p"), cacheGB: 8)) == "8.0") + } +} + +struct QwenImageCatalogTests { + @Test func variantRunsThroughItsOwnFamilyAndCLI() { + let v = FluxModelVariant.qwenImage21 + #expect(v.family == .qwenImage) + #expect(v.isQwenImage) + #expect(!v.isFlux) + #expect(v.generateCLIName == "mflux-generate-qwen-2.1") + #expect(v.mfluxModelID == "qwen-image-2.1") + #expect(v.defaultSteps == 40) + #expect(v.defaultGuidance == 1.0) + #expect(v.promptTokenSoftCap == 2048) + #expect(FluxModelVariant.allModels.contains(v)) + } + + @Test func familyIsGenerativeButOutsideTheLoraSurfaces() { + #expect(ModelFamily.generative.contains(.qwenImage)) + #expect(!ModelFamily.qwenImage.supportsLoras) + #expect(ModelFamily.zimage.supportsLoras) + #expect(!ModelFamily.seedvr2.supportsLoras) + } + + /// Every precision quantizes the one official BF16 repo in memory, so Settings + /// downloads that repo directly and lists a single cached entry. + @Test func everyPrecisionSharesTheOneBF16Download() { + let v = FluxModelVariant.qwenImage21 + for q in [0, 4, 8] { + #expect(v.directDownloadRepoID(quantize: q) == "Qwen/Qwen-Image-2.1") + #expect(v.approximateDownloadGB(quantize: q) == v.approximateBF16SizeGB) + } + #expect(v.cachedQuantizeLevels == [0]) + #expect(v.approximateSizeGB(quantize: 0) > v.approximateSizeGB(quantize: 8)) + #expect(v.approximateSizeGB(quantize: 8) > v.approximateSizeGB(quantize: 4)) + } + + @Test func otherModelsKeepTheirDownloadRouting() { + #expect(FluxModelVariant.zimageTurbo.directDownloadRepoID(quantize: 4) == "filipstrand/Z-Image-Turbo-mflux-4bit") + #expect(FluxModelVariant.zimageTurbo.directDownloadRepoID(quantize: 8) == nil) + #expect(FluxModelVariant.ideogram4.directDownloadRepoID(quantize: 0) == "ideogram-ai/ideogram-4-fp8") + #expect(FluxModelVariant.flux2Klein9B.directDownloadRepoID(quantize: 0) == nil) + #expect(FluxModelVariant.krea2.cachedQuantizeLevels == [0, 4, 8]) + } + + @Test func dimensionsSnapToSixteens() { + let c = DimensionConstraints.qwenImage + #expect(c.snap(1000) == 1008) + #expect(c.snap(100) == 256) + #expect(c.snap(5000) == 2048) + } +} + +struct QwenImageSidecarTests { + private func tempImagePath() throws -> String { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("qwenimage-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("qwenimage_test.png").path + } + + @Test func metadataRoundTripsThroughTheSidecar() throws { + let path = try tempImagePath() + let job = QwenImageJob( + prompt: "a lighthouse", negativePrompt: "fog", width: 1280, height: 720, + steps: 30, guidance: 3.5, quantize: 8, board: "Coast" + ) + job.resolvedSeed = 1234 + MetadataSidecar.writeQwenImage(QwenImageMetadata.from(job: job), for: path) + + let meta = try #require(MetadataSidecar.readQwenImage(for: path)) + #expect(meta.model == "qwen-image-2.1") + #expect(meta.prompt == "a lighthouse") + #expect(meta.negativePrompt == "fog") + #expect(meta.seed == 1234) + #expect(meta.width == 1280 && meta.height == 720) + #expect(meta.steps == 30 && meta.guidance == 3.5 && meta.quantize == 8) + #expect(meta.board == "Coast") + #expect(meta.customModelRepo == nil) + } + + /// A Flux sidecar also carries `model`, so the reader must check its value. + @Test func aSidecarForAnotherModelIsNotReadAsQwenImage() throws { + let path = try tempImagePath() + let json = """ + {"model":"flux2-klein-9b","prompt":"x","seed":1,"steps":4,"guidance":1, + "width":512,"height":512,"quantize":8,"generatedAt":"2026-09-23T00:00:00Z"} + """ + try Data(json.utf8).write(to: MetadataSidecar.sidecarURL(for: path)) + #expect(MetadataSidecar.readQwenImage(for: path) == nil) + } +} + +struct HFCacheCompletenessTests { + /// Newer huggingface_hub keeps payloads in a shared blob store and leaves each + /// repo's `blobs/` entries as symlinks; the completeness check must size the + /// link target, not the link. + @Test func symlinkedBlobsCountTheirTargetSize() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("hfcache-tests-\(UUID().uuidString)", isDirectory: true) + let store = root.appendingPathComponent("blobs/ab", isDirectory: true) + let repoBlobs = root.appendingPathComponent("hub/models--Qwen--Qwen-Image-2.1/blobs", isDirectory: true) + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repoBlobs, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + // A sparse 1.5 GiB payload: full logical size, no real disk use. + let payload = store.appendingPathComponent("abcdef") + FileManager.default.createFile(atPath: payload.path, contents: nil) + let handle = try FileHandle(forWritingTo: payload) + try handle.truncate(atOffset: 1_610_612_736) + try handle.close() + + try FileManager.default.createSymbolicLink( + atPath: repoBlobs.appendingPathComponent("abcdef").path, + withDestinationPath: "../../../blobs/ab/abcdef" + ) + #expect(FluxModelVariant.isCompleteHFCache(at: repoBlobs.deletingLastPathComponent())) + } + + @Test func aMetadataOnlyRepoIsIncomplete() throws { + let repo = FileManager.default.temporaryDirectory + .appendingPathComponent("hfcache-tests-\(UUID().uuidString)/models--x--y", isDirectory: true) + let blobs = repo.appendingPathComponent("blobs", isDirectory: true) + try FileManager.default.createDirectory(at: blobs, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: repo.deletingLastPathComponent()) } + try Data("{}".utf8).write(to: blobs.appendingPathComponent("config")) + #expect(!FluxModelVariant.isCompleteHFCache(at: repo)) + } +} diff --git a/Utilities/BinaryDetector.swift b/Utilities/BinaryDetector.swift index 2a1a7aa..95398f4 100644 --- a/Utilities/BinaryDetector.swift +++ b/Utilities/BinaryDetector.swift @@ -242,6 +242,15 @@ nonisolated enum BinaryDetector { return FileManager.default.fileExists(atPath: path) ? path : detect("mflux-generate-z-image") } + /// Returns the full path to mflux-generate-qwen-2.1 given a binary directory. + static func mfluxGenerateQwenImage21(in dir: String) -> String { + if dir.isEmpty { + return detect("mflux-generate-qwen-2.1") + } + let path = "\(dir)/mflux-generate-qwen-2.1" + return FileManager.default.fileExists(atPath: path) ? path : detect("mflux-generate-qwen-2.1") + } + /// Returns the full path to mflux-upscale-seedvr2 given a binary directory. static func mfluxUpscaleSeedVR2(in dir: String) -> String { if dir.isEmpty { diff --git a/Utilities/MetadataSidecar.swift b/Utilities/MetadataSidecar.swift index 2804f02..582c88b 100644 --- a/Utilities/MetadataSidecar.swift +++ b/Utilities/MetadataSidecar.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import Foundation // MARK: - Ideogram 4 metadata @@ -232,6 +233,58 @@ nonisolated struct ZImageMetadata: Codable { } } +// MARK: - Qwen-Image metadata + +nonisolated struct QwenImageMetadata: Codable { + @MainActor static func from(job: QwenImageJob) -> Self { + Self( + model: job.modelVariant.rawValue, + customModelRepo: job.customModelRepo.isEmpty ? nil : job.customModelRepo, + prompt: job.prompt, + negativePrompt: job.negativePrompt.isEmpty ? nil : job.negativePrompt, + seed: job.resolvedSeed ?? job.seed, + steps: job.steps, + guidance: job.guidance, + width: job.width, + height: job.height, + quantize: job.quantize, + imagePath: job.imagePath.isEmpty ? nil : job.imagePath, + imageStrength: job.imagePath.isEmpty ? nil : job.imageStrength, + board: job.board.isEmpty ? nil : job.board, + generatedAt: job.completedAt ?? Date(), + startedAt: job.startedAt, + log: job.log.isEmpty ? nil : job.log + ) + } + + /// Always "qwen-image-2.1". ``MetadataSidecar/readQwenImage(for:)`` checks it, + /// so a Flux sidecar (which also carries a `model`) never reads as Qwen-Image, + /// and Z-Image / Krea 2 sidecars (which have none) fail to decode as this. + var model: String + /// Custom checkpoint this was generated from, when the picker's `Custom…` + /// entry was loaded as Qwen-Image 2.1. + var customModelRepo: String? + var prompt: String + var negativePrompt: String? + var seed: Int + var steps: Int + var guidance: Double + var width: Int + var height: Int + var quantize: Int + var imagePath: String? + var imageStrength: Double? + var board: String? + var generatedAt: Date + var startedAt: Date? + var log: String? + + var displayModelName: String { + customModelRepo.flatMap { $0.split(separator: "/").last.map(String.init) } + ?? FluxModelVariant.qwenImage21.displayName + } +} + // MARK: - SeedVR2 metadata /// The pre-upscale generation metadata resolved from a source image's sidecar. @@ -241,6 +294,7 @@ nonisolated struct SeedVR2Source { var ideogram4: Ideogram4Metadata? var krea2: Krea2Metadata? var zimage: ZImageMetadata? + var qwenImage: QwenImageMetadata? } /// Source-forward display fields for a SeedVR2 upscale, derived from the source @@ -298,6 +352,16 @@ nonisolated struct SeedVR2DisplayFields { seed = s.seed width = s.width height = s.height + } else if let s = source.qwenImage { + prompt = s.prompt + negativePrompt = s.negativePrompt ?? "" + sourceModel = s.displayModelName + steps = s.steps + guidance = s.guidance + loras = [] + seed = s.seed + width = s.width + height = s.height } else { return nil } @@ -323,7 +387,8 @@ nonisolated struct SeedVR2Metadata: Codable { sourceFlux: source.flux, sourceIdeogram4: source.ideogram4, sourceKrea2: source.krea2, - sourceZImage: source.zimage + sourceZImage: source.zimage, + sourceQwenImage: source.qwenImage ) } @@ -341,6 +406,9 @@ nonisolated struct SeedVR2Metadata: Codable { if name.hasPrefix("zimage"), let zimage = MetadataSidecar.readZImage(for: sourcePath) { return SeedVR2Source(zimage: zimage) } + if name.hasPrefix("qwenimage"), let qwenImage = MetadataSidecar.readQwenImage(for: sourcePath) { + return SeedVR2Source(qwenImage: qwenImage) + } if name.hasPrefix("krea2"), let krea2 = MetadataSidecar.readKrea2(for: sourcePath) { return SeedVR2Source(krea2: krea2) } @@ -361,7 +429,8 @@ nonisolated struct SeedVR2Metadata: Codable { flux: priorUpscale.sourceFlux, ideogram4: priorUpscale.sourceIdeogram4, krea2: priorUpscale.sourceKrea2, - zimage: priorUpscale.sourceZImage + zimage: priorUpscale.sourceZImage, + qwenImage: priorUpscale.sourceQwenImage ) } return SeedVR2Source() @@ -387,6 +456,7 @@ nonisolated struct SeedVR2Metadata: Codable { var sourceIdeogram4: Ideogram4Metadata? var sourceKrea2: Krea2Metadata? var sourceZImage: ZImageMetadata? + var sourceQwenImage: QwenImageMetadata? } enum MetadataSidecar { @@ -426,6 +496,15 @@ enum MetadataSidecar { try? data.write(to: url, options: .atomic) } + static func writeQwenImage(_ metadata: QwenImageMetadata, for imagePath: String) { + let url = sidecarURL(for: imagePath) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(metadata) else { return } + try? data.write(to: url, options: .atomic) + } + static func writeSeedVR2(_ metadata: SeedVR2Metadata, for imagePath: String) { let url = sidecarURL(for: imagePath) let encoder = JSONEncoder() @@ -467,6 +546,16 @@ enum MetadataSidecar { return try? decoder.decode(ZImageMetadata.self, from: data) } + nonisolated static func readQwenImage(for imagePath: String) -> QwenImageMetadata? { + let url = sidecarURL(for: imagePath) + guard let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let meta = try? decoder.decode(QwenImageMetadata.self, from: data), + meta.model == FluxModelVariant.qwenImage21.rawValue else { return nil } + return meta + } + nonisolated static func readSeedVR2(for imagePath: String) -> SeedVR2Metadata? { let url = sidecarURL(for: imagePath) guard let data = try? Data(contentsOf: url) else { return nil } diff --git a/Views/Gallery/GenerationGalleryView.swift b/Views/Gallery/GenerationGalleryView.swift index 021167c..0db4f57 100644 --- a/Views/Gallery/GenerationGalleryView.swift +++ b/Views/Gallery/GenerationGalleryView.swift @@ -814,6 +814,10 @@ struct GenerationGalleryView: View { parts.append(String(meta.seed)) parts.append(contentsOf: (meta.loras ?? []).map(\.displayName)) } + if let meta = item.qwenImageMetadata { + parts.append(meta.prompt) + parts.append(String(meta.seed)) + } return parts.joined(separator: " ").lowercased() } diff --git a/Views/ParamsPanel/ModelPickerView.swift b/Views/ParamsPanel/ModelPickerView.swift index e04aa99..8b7fe84 100644 --- a/Views/ParamsPanel/ModelPickerView.swift +++ b/Views/ParamsPanel/ModelPickerView.swift @@ -69,7 +69,7 @@ struct ModelPickerView: View { Text(v.displayName).tag(v) } Divider() - ForEach([FluxModelVariant.ideogram4, .krea2, .zimageTurbo, .zimage], id: \.self) { v in + ForEach([FluxModelVariant.ideogram4, .krea2, .zimageTurbo, .zimage, .qwenImage21], id: \.self) { v in modelPickerRow(v) } Divider() @@ -224,12 +224,14 @@ struct ModelPickerView: View { default: .red } let onDisk = model.isOnDisk(quantize: quantize, savedIn: settings.effectiveMfluxCacheDir) + // In-memory-quantizing models download one checkpoint whatever the precision. + let downloadGB = model.approximateDownloadGB(quantize: quantize) // The disk pill is a binary download-state signal — green when cached, // neutral "download" otherwise. Size-based warning colors live on the RAM // pill only, so a not-yet-downloaded model never looks like a warning. let diskLabel = onDisk - ? "≈\(String(format: "%.0f", gb)) GB cached" - : "≈\(String(format: "%.0f", gb)) GB to download" + ? "≈\(String(format: "%.0f", downloadGB)) GB cached" + : "≈\(String(format: "%.0f", downloadGB)) GB to download" let diskColor: Color = onDisk ? .green : .secondary let diskIcon = onDisk ? "internaldrive" : "arrow.down.circle" let quantName = quantize == 0 ? model.baseWeightLabel : "Q\(quantize)" @@ -240,7 +242,7 @@ struct ModelPickerView: View { default: "" } let diskInfoTitle = "\(model.displayName) \(quantName)" - let diskInfoBody = "\(quantName) weights cached locally (~\(Int(gb)) GB). \(qualityNote)" + let diskInfoBody = "\(quantName) weights cached locally (~\(Int(downloadGB)) GB). \(qualityNote)" return VRAMEstimate( label: label, color: color, diskLabel: diskLabel, diskColor: diskColor, diskIcon: diskIcon, diff --git a/Views/ParamsPanel/ParamsPanelView.swift b/Views/ParamsPanel/ParamsPanelView.swift index 8f97553..9296da7 100644 --- a/Views/ParamsPanel/ParamsPanelView.swift +++ b/Views/ParamsPanel/ParamsPanelView.swift @@ -21,6 +21,7 @@ struct ParamsPanelView: View { @Bindable var ideogramParams: Ideogram4ParamsPanelState @Bindable var krea2Params: Krea2ParamsPanelState @Bindable var zimageParams: ZImageParamsPanelState + @Bindable var qwenImageParams: QwenImageParamsPanelState /// Turns a batch of scenario-generated prompts into one image job apiece. /// Owned by ContentView (which holds the stores and runners) and forwarded to /// whichever family's panel is showing. @@ -65,6 +66,8 @@ struct ParamsPanelView: View { Krea2ParamsPanelView(params: krea2Params, onQueueScenarioBatch: onQueueScenarioBatch) case .zimage: ZImageParamsPanelView(params: zimageParams, onQueueScenarioBatch: onQueueScenarioBatch) + case .qwenImage: + QwenImageParamsPanelView(params: qwenImageParams, onQueueScenarioBatch: onQueueScenarioBatch) case .seedvr2: EmptyView() // upscaler has no params panel — driven by the Upscale sheet } diff --git a/Views/PreviewPane/GalleryItemDetailView.swift b/Views/PreviewPane/GalleryItemDetailView.swift index f9fe578..0040501 100644 --- a/Views/PreviewPane/GalleryItemDetailView.swift +++ b/Views/PreviewPane/GalleryItemDetailView.swift @@ -11,6 +11,8 @@ struct GalleryItemDetailView: View { var onApplyKrea2Settings: ((Krea2Metadata) -> Void)? var onRemixZImage: ((ZImageMetadata) -> Void)? var onApplyZImageSettings: ((ZImageMetadata) -> Void)? + var onRemixQwenImage: ((QwenImageMetadata) -> Void)? + var onApplyQwenImageSettings: ((QwenImageMetadata) -> Void)? let onUseInImg2Img: (String) -> Void var onEditBoxesOverImage: ((Ideogram4Metadata, NSImage) -> Void)? var onShowFullSize: ((NSImage) -> Void)? @@ -28,6 +30,8 @@ struct GalleryItemDetailView: View { ImageMetadataInfo(krea2Item: item) ?? ImageMetadataInfo(path: item.path) } else if item.zimageMetadata != nil { ImageMetadataInfo(zimageItem: item) ?? ImageMetadataInfo(path: item.path) + } else if item.qwenImageMetadata != nil { + ImageMetadataInfo(qwenImageItem: item) ?? ImageMetadataInfo(path: item.path) } else if item.seedVR2Metadata != nil { ImageMetadataInfo(seedVR2Item: item) ?? ImageMetadataInfo(path: item.path) } else { @@ -82,6 +86,10 @@ struct GalleryItemDetailView: View { Divider() Button("Apply Settings") { onApplyZImageSettings?(correctedZImage(meta)) } Button("Remix (new seed)") { onRemixZImage?(meta) } + } else if let meta = item.qwenImageMetadata { + Divider() + Button("Apply Settings") { onApplyQwenImageSettings?(correctedQwenImage(meta)) } + Button("Remix (new seed)") { onRemixQwenImage?(meta) } } if let onUpscale { Divider() @@ -107,7 +115,7 @@ struct GalleryItemDetailView: View { onApplySettings: applySettingsAction, onRemix: remixAction, onUseInImg2Img: item.ideogram4Metadata == nil && item.krea2Metadata == nil - && item.zimageMetadata == nil + && item.zimageMetadata == nil && item.qwenImageMetadata == nil ? { onUseInImg2Img(item.path) } : nil, onEditBoxes: editBoxesAction, onRevealInFinder: { @@ -177,6 +185,9 @@ struct GalleryItemDetailView: View { if let meta = item.zimageMetadata, let fn = onApplyZImageSettings { return { fn(correctedZImage(meta)) } } + if let meta = item.qwenImageMetadata, let fn = onApplyQwenImageSettings { + return { fn(correctedQwenImage(meta)) } + } // SeedVR2 upscale: replay from the source generation metadata folded into the // upscale's sidecar, so an upscale is as re-applicable as its original. if let src = item.seedVR2Metadata { @@ -196,6 +207,9 @@ struct GalleryItemDetailView: View { if let meta = src.sourceZImage, let fn = onApplyZImageSettings { return { fn(correctedZImage(meta)) } } + if let meta = src.sourceQwenImage, let fn = onApplyQwenImageSettings { + return { fn(correctedQwenImage(meta)) } + } } return nil } @@ -213,6 +227,9 @@ struct GalleryItemDetailView: View { if let meta = item.zimageMetadata, let fn = onRemixZImage { return { fn(meta) } } + if let meta = item.qwenImageMetadata, let fn = onRemixQwenImage { + return { fn(meta) } + } if let src = item.seedVR2Metadata { if let meta = src.sourceFlux { return { onRemix(meta) } @@ -226,6 +243,9 @@ struct GalleryItemDetailView: View { if let meta = src.sourceZImage, let fn = onRemixZImage { return { fn(meta) } } + if let meta = src.sourceQwenImage, let fn = onRemixQwenImage { + return { fn(meta) } + } } return nil } @@ -257,6 +277,12 @@ struct GalleryItemDetailView: View { return corrected } + private func correctedQwenImage(_ meta: QwenImageMetadata) -> QwenImageMetadata { + var corrected = meta + corrected.board = item.board == "Default" ? nil : item.board + return corrected + } + private func logSheet(log: String) -> some View { NavigationStack { ScrollView { diff --git a/Views/PreviewPane/PreviewPaneView.swift b/Views/PreviewPane/PreviewPaneView.swift index 67bd4f3..870874f 100644 --- a/Views/PreviewPane/PreviewPaneView.swift +++ b/Views/PreviewPane/PreviewPaneView.swift @@ -8,6 +8,7 @@ enum PreviewState { case activeIdeogram4Job(Ideogram4Job) case activeKrea2Job(Krea2Job) case activeZImageJob(ZImageJob) + case activeQwenImageJob(QwenImageJob) case activeSeedVR2Job(SeedVR2Job) case galleryItem(GalleryItem) } @@ -25,6 +26,8 @@ struct PreviewPaneView: View { var onApplyKrea2Settings: ((Krea2Metadata) -> Void)? var onRemixZImage: ((ZImageMetadata) -> Void)? var onApplyZImageSettings: ((ZImageMetadata) -> Void)? + var onRemixQwenImage: ((QwenImageMetadata) -> Void)? + var onApplyQwenImageSettings: ((QwenImageMetadata) -> Void)? let onUseInImg2Img: (String) -> Void let onCancel: () -> Void let onClear: () -> Void @@ -147,6 +150,28 @@ struct PreviewPaneView: View { zimagePendingView(job: job) } + case let .activeQwenImageJob(job): + switch job.status { + case .running: + QwenImageStepwisePreviewView(job: job, onCancel: onCancel) + + case .completed: + QwenImageCompletedPreviewView( + job: job, + onRemix: onRemixQwenImage, + onApplySettings: onApplyQwenImageSettings + ) + + case let .failed(msg): + qwenImageFailedView(message: msg, job: job) + + case .cancelled: + cancelledView + + case .pending: + qwenImagePendingView(job: job) + } + case let .activeSeedVR2Job(job): switch job.status { case .running: @@ -176,6 +201,8 @@ struct PreviewPaneView: View { onApplyKrea2Settings: onApplyKrea2Settings, onRemixZImage: onRemixZImage, onApplyZImageSettings: onApplyZImageSettings, + onRemixQwenImage: onRemixQwenImage, + onApplyQwenImageSettings: onApplyQwenImageSettings, onUseInImg2Img: onUseInImg2Img, onEditBoxesOverImage: onEditBoxesOverImage, onShowFullSize: onShowFullSize, @@ -274,6 +301,12 @@ struct PreviewPaneView: View { } return true + case let .activeQwenImageJob(job): + if case .running = job.status { + return false + } + return true + case let .activeSeedVR2Job(job): if case .running = job.status { return false @@ -544,6 +577,47 @@ struct PreviewPaneView: View { } } + private func qwenImageFailedView(message: String, job: QwenImageJob) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text("Generation failed") + .font(.headline) + Spacer() + } + .padding() + Divider() + ScrollViewReader { proxy in + ScrollView { + Text(job.log.isEmpty ? message : job.log) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + Color.clear.frame(height: 1).id("errEnd") + } + .onAppear { proxy.scrollTo("errEnd") } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func qwenImagePendingView(job: QwenImageJob) -> some View { + VStack(spacing: 12) { + Image(systemName: "clock") + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + Text("Waiting in queue") + .font(.title3) + .foregroundStyle(.secondary) + Text(job.displayName) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + private func seedVR2FailedView(message: String, job: SeedVR2Job) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { diff --git a/Views/Queue/QueueDrawerView.swift b/Views/Queue/QueueDrawerView.swift index c137469..9155110 100644 --- a/Views/Queue/QueueDrawerView.swift +++ b/Views/Queue/QueueDrawerView.swift @@ -681,6 +681,173 @@ private struct ZImageQueueJobRow: View { } } +// MARK: - QwenImageQueueDrawerView + +struct QwenImageQueueDrawerView: View { + @Environment(QwenImageJobStore.self) private var store + @Environment(QwenImageJobRunner.self) private var runner + @Environment(AppSettings.self) private var settings + @Environment(GenerationCoordinator.self) private var coordinator + @Environment(TimingStore.self) private var timing + + @Binding var selectedJob: QwenImageJob? + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if store.jobs.isEmpty { + emptyState + } else { + jobList + } + } + } + + private var header: some View { + HStack { + Text("Queue") + .font(.headline) + Spacer() + if store.jobs.contains(where: \.status.isTerminal) { + Button { + store.purgeTerminal() + } label: { + Image(systemName: "trash") + .font(.caption) + } + .buttonStyle(.iconButton) + .foregroundStyle(.secondary) + .focusable(false) + .help("Remove all completed, failed, and cancelled jobs") + } + if store.isRunning { + Button(runner.isStopping ? "Force Stop" : "Stop") { runner.cancel() } + .buttonStyle(.borderless) + .controlSize(.small) + .foregroundStyle(.red) + .focusable(false) + .help(runner.isStopping + ? "Stop immediately without waiting for the current step; the warm model is unloaded" + : "Stop the current job; remaining pending jobs continue") + + Button(runner.isStopping ? "Force Stop All" : "Stop All") { + runner.cancel() + store.cancelAllPending() + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + .focusable(false) + .help(runner.isStopping + ? "Stop immediately without waiting for the current step, and cancel all pending jobs" + : "Stop the current job and cancel all pending jobs") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var jobList: some View { + List(selection: Binding( + get: { selectedJob?.id }, + set: { id in selectedJob = store.jobs.first { $0.id == id } } + )) { + ForEach(store.jobs) { job in + QwenImageQueueJobRow( + job: job, + onRestart: isRestartable(job) ? { restart(job) } : nil, + onCancel: isCancellable(job) ? { cancelJob(job) } : nil + ) + .tag(job.id) + } + } + .listStyle(.sidebar) + } + + private var emptyState: some View { + VStack { + Spacer() + Text("No jobs") + .font(.callout) + .foregroundStyle(.tertiary) + Spacer() + } + } + + private func isRestartable(_ job: QwenImageJob) -> Bool { + switch job.status { + case .failed, .cancelled: true + default: false + } + } + + private func isCancellable(_ job: QwenImageJob) -> Bool { + switch job.status { + case .pending, .running: true + default: false + } + } + + private func restart(_ job: QwenImageJob) { + store.restart(job) + runner.runNext(in: store, settings: settings, coordinator: coordinator, timing: timing) + } + + private func cancelJob(_ job: QwenImageJob) { + if case .running = job.status { + runner.cancel() + } else { + store.cancelJob(job) + } + } +} + +// MARK: - QwenImageQueueJobRow + +private struct QwenImageQueueJobRow: View { + let job: QwenImageJob + var onRestart: (() -> Void)? + var onCancel: (() -> Void)? + + var body: some View { + HStack(spacing: 8) { + JobStatusIcon(status: job.status) + VStack(alignment: .leading, spacing: 2) { + Text(job.prompt.isEmpty ? "(empty prompt)" : job.prompt) + .font(.caption) + .lineLimit(2) + Text(job.displayName) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if let onCancel { + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + } + // Compact: queue rows are dense and cancel/restart sit side by side. + .buttonStyle(.iconButtonCompact) + .focusable(false) + .help("Cancel this job") + } + if let onRestart { + Button(action: onRestart) { + Image(systemName: "arrow.counterclockwise") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.iconButtonCompact) + .focusable(false) + .help("Re-queue this job") + } + } + .padding(.vertical, 2) + } +} + // MARK: - SeedVR2QueueDrawerView /// Queue drawer for the SeedVR2 upscaler. Mirrors ``Krea2QueueDrawerView`` but is diff --git a/Views/QwenImage/QwenImageParamsPanelState.swift b/Views/QwenImage/QwenImageParamsPanelState.swift new file mode 100644 index 0000000..3c9a046 --- /dev/null +++ b/Views/QwenImage/QwenImageParamsPanelState.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Persistable snapshot of the Qwen-Image 2.1 form, remembered across app launches. +/// Decoded defensively (every field `decodeIfPresent ?? default`) so adding a +/// field never silently wipes a saved snapshot. Seed is intentionally not stored: +/// generation defaults to a fresh random seed on launch, matching Flux. +struct QwenImageFormState: Codable { + var prompt: String = "" + var negativePrompt: String = "" + var width: Int = 1024 + var height: Int = 1024 + var steps: Int = 40 + var guidance: Double = 1.0 + var quantize: Int = 0 + var imagePath: String = "" + var imageStrength: Double = 0.75 + var board: String = "" + + init() {} + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + prompt = (try? c.decode(String.self, forKey: .prompt)) ?? "" + negativePrompt = (try? c.decode(String.self, forKey: .negativePrompt)) ?? "" + width = (try? c.decode(Int.self, forKey: .width)) ?? 1024 + height = (try? c.decode(Int.self, forKey: .height)) ?? 1024 + steps = (try? c.decode(Int.self, forKey: .steps)) ?? 40 + guidance = (try? c.decode(Double.self, forKey: .guidance)) ?? 1.0 + quantize = (try? c.decode(Int.self, forKey: .quantize)) ?? 0 + imagePath = (try? c.decode(String.self, forKey: .imagePath)) ?? "" + imageStrength = (try? c.decode(Double.self, forKey: .imageStrength)) ?? 0.75 + board = (try? c.decode(String.self, forKey: .board)) ?? "" + } +} + +/// Observable form state for the Qwen-Image 2.1 params panel. +@Observable +@MainActor +final class QwenImageParamsPanelState { + var prompt: String = "" + var negativePrompt: String = "" + var width: Int = 1024 + var height: Int = 1024 + var steps: Int = 40 + var guidance: Double = 1.0 + var seed: Int = -1 + var batchSeeds: [Int] = [] + var quantize: Int = 0 + var imagePath: String = "" + var imageStrength: Double = 0.75 + var board: String = "" + + /// The catalog entry this form generates with. + var variant: FluxModelVariant { + .qwenImage21 + } + + /// Whether the negative prompt takes effect: mflux runs true classifier-free + /// guidance only above guidance 1. + var usesTrueCFG: Bool { + guidance > 1.0 + } + + var canGenerate: Bool { + !prompt.trimmingCharacters(in: .whitespaces).isEmpty + } + + func applyDefaults(settings: AppSettings) { + // Restore the last-used form across launches when present; otherwise fall + // back to the model defaults from Settings → Models. + if let s = settings.lastQwenImage { + prompt = s.prompt + negativePrompt = s.negativePrompt + width = s.width + height = s.height + steps = s.steps + guidance = s.guidance + quantize = s.quantize + imagePath = s.imagePath + imageStrength = s.imageStrength + board = s.board + seed = -1 + return + } + let d = settings.resolvedDefaults(for: variant) + steps = d.steps + guidance = d.guidance + quantize = d.quantize + width = d.width + height = d.height + board = settings.defaultBoard + seed = -1 + } + + /// Captures the current form for cross-launch persistence (seed excluded). + func snapshot() -> QwenImageFormState { + var s = QwenImageFormState() + s.prompt = prompt + s.negativePrompt = negativePrompt + s.width = width + s.height = height + s.steps = steps + s.guidance = guidance + s.quantize = quantize + s.imagePath = imagePath + s.imageStrength = imageStrength + s.board = board + return s + } + + /// Adopts a generated candidate's resolved prompt when it becomes the + /// img2img reference and the prompt box still holds wildcards, so + /// refinement varies the exact base instead of re-sampling. + func adoptResolvedPromptForImg2Img(at path: String) { + guard WildcardExpander.containsWildcards(prompt), + let meta = MetadataSidecar.readQwenImage(for: path) else { return } + prompt = meta.prompt + negativePrompt = meta.negativePrompt ?? "" + } + + /// Replays a completed generation's settings back into the form. + /// `newSeed == true` (Remix) resets the seed to random; otherwise the original + /// seed is restored. + func apply(metadata meta: QwenImageMetadata, newSeed: Bool) { + prompt = meta.prompt + negativePrompt = meta.negativePrompt ?? "" + width = meta.width + height = meta.height + steps = meta.steps + guidance = meta.guidance + quantize = meta.quantize + imagePath = meta.imagePath ?? "" + imageStrength = meta.imageStrength ?? 0.75 + board = meta.board ?? "" + batchSeeds = [] + seed = newSeed ? -1 : meta.seed + } + + /// Builds a job. `resolvedPrompt` supplies fully-resolved prompt text for + /// wildcard batches; when nil, any wildcards collapse to a single sample. + /// `customModelRepo` carries the picker's `Custom…` entry when a custom + /// checkpoint is being loaded through the Qwen-Image pipeline; empty otherwise. + func makeJob( + count: Int = 1, + customModelRepo: String = "", + resolvedPrompt: (positive: String, negative: String)? = nil + ) -> QwenImageJob { + let finalPrompt = resolvedPrompt?.positive + ?? WildcardExpander.expandVariants(prompt, count: 1).first ?? prompt + let finalNegative = resolvedPrompt?.negative + ?? WildcardExpander.expandVariants(negativePrompt, count: 1).first ?? negativePrompt + let job = QwenImageJob( + customModelRepo: customModelRepo, + prompt: finalPrompt, + negativePrompt: finalNegative, + width: width, + height: height, + seed: seed, + steps: steps, + guidance: guidance, + quantize: quantize, + imagePath: imagePath, + imageStrength: imageStrength, + board: board + ) + if count > 1 { + // Matches Flux.2's batch button: auto-generate N random seeds into one + // job (single mflux process, model loaded once). + job.seeds = (0 ..< count).map { _ in Int(UInt32.random(in: 0 ..< UInt32.max)) } + } else if !batchSeeds.isEmpty { + job.seeds = batchSeeds + } + return job + } + + func isReadyToGenerate(settings _: AppSettings) -> Bool { + canGenerate + } +} diff --git a/Views/QwenImage/QwenImageParamsPanelView.swift b/Views/QwenImage/QwenImageParamsPanelView.swift new file mode 100644 index 0000000..063e401 --- /dev/null +++ b/Views/QwenImage/QwenImageParamsPanelView.swift @@ -0,0 +1,324 @@ +import AppKit +import SwiftUI + +/// Qwen-Image 2.1 submission form. Text-to-image (+ optional img2img init image): +/// prompt, dimensions, steps, seed/batch, guidance, and output folder. The model is +/// sampled guidance-free by default; raising guidance above 1 with a negative +/// prompt switches mflux to true classifier-free guidance. mflux has no LoRA +/// support for Qwen-Image 2.1, so there is no LoRA section. +struct QwenImageParamsPanelView: View { + private static let imageExtensions: Set = ["png", "jpg", "jpeg", "webp"] + + @Bindable var params: QwenImageParamsPanelState + /// Forwarded from ContentView: turns scenario-generated prompts into one job each. + var onQueueScenarioBatch: ([String]) -> Void = { _ in } + @Environment(AppSettings.self) private var settings + @Environment(GalleryStore.self) private var gallery + @Environment(TimingStore.self) private var timing + + @State private var isImageDropTargeted: Bool = false + @StateObject private var clipboardMonitor = + ClipboardImageMonitor(imageExtensions: ["png", "jpg", "jpeg", "webp"]) + + /// Learned-time estimate for the current Qwen-Image configuration. + private var estimate: TimingStore.Estimate? { + timing.estimate( + model: params.variant.rawValue, + quantize: params.quantize, lowRam: false, + steps: params.steps, + megapixels: Double(params.width * params.height) / 1_000_000 + ) + } + + var body: some View { + SectionContainerView( + title: "Prompt", + info: "Describe what you want to generate. Qwen-Image 2.1 reads long, detailed " + + "prompts (up to ~2048 tokens) and renders legible text well.\n\n" + + "The negative prompt only takes effect with guidance above 1, which turns " + + "on true classifier-free guidance (and doubles the work per step).\n\n" + + "Wildcards: {red|black|white} makes Generate run one job per option " + + "(up to 10), in order. Smaller groups cycle; a batch count overrides." + ) { + GrowingPromptField( + text: $params.prompt, + placeholder: "Describe your image…", + label: "Prompt", + hint: "Describe the image you want to generate", + tokenSoftCap: params.variant.promptTokenSoftCap + ) + GrowingPromptField( + text: $params.negativePrompt, + placeholder: params.usesTrueCFG + ? "Negative prompt (optional)…" + : "Negative prompt (needs guidance > 1)…", + label: "Negative prompt", + hint: "Describe elements to avoid. Only used when guidance is above 1." + ) + } accessory: { + HStack(spacing: 6) { + ScenarioGeneratorButton( + onSelect: { params.prompt = $0 }, + onQueue: onQueueScenarioBatch + ) + PromptHistoryButton { params.prompt = $0 } + } + } + + Divider() + + SectionContainerView( + title: "Image Input", + info: "Optional reference image for image-to-image generation. Drag an image " + + "here or click to browse. Higher strength = closer to the original image. " + + "Lower strength = more creative, prompt dominates." + ) { + img2ImgSection + } + .onAppear { clipboardMonitor.start() } + .onDisappear { clipboardMonitor.stop() } + + Divider() + + SectionContainerView( + title: "Folder", + info: "Organizes generated images into named subfolders inside your output " + + "directory. Leave as Default to keep everything in one place." + ) { + FolderComboBox( + text: $params.board, + options: gallery.boards.filter { $0 != "Default" }, + placeholder: "Default" + ) + .accessibilityLabel("Output group") + .accessibilityHint("Subfolder name for organizing generated images") + } + + Divider() + + SectionContainerView(title: nil, info: nil) { + VStack(alignment: .leading, spacing: 6) { + DimensionPickerView(width: $params.width, height: $params.height, constraints: .qwenImage) + GenerationEstimateView(estimate: estimate, width: params.width, height: params.height) + } + } + + Divider() + + SectionContainerView(title: nil, info: nil) { + stepsAndSeedRow + } + + Divider() + + SectionContainerView( + title: "Guidance", + info: "Qwen-Image 2.1 is trained to run guidance-free, so 1.0 is the reference " + + "setting. Above 1, together with a negative prompt, mflux runs true " + + "classifier-free guidance: stricter prompt adherence at twice the compute " + + "per step. Without a negative prompt, values above 1 have no effect." + ) { + guidanceRow + } + .padding(.bottom, 8) + } + + // MARK: - Steps + Seed (one row) + + private var stepsAndSeedRow: some View { + HStack(alignment: .top, spacing: 12) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 3) { + Text("Steps").font(.caption2).fontWeight(.medium).foregroundStyle(.secondary) + InfoButton( + title: "Denoising Steps", + description: "Qwen-Image 2.1's reference setting is 40 steps. Around 20 " + + "gives a quick draft; more than 50 adds compute with diminishing returns." + ) + } + Stepper(value: $params.steps, in: 1 ... 100) { + TextField("", value: $params.steps, format: .number) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .onSubmit { params.steps = max(1, min(100, params.steps)) } + } + .accessibilityLabel("Steps") + .accessibilityValue("\(params.steps)") + } + + Divider().frame(height: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 3) { + Text("Seed").font(.caption2).fontWeight(.medium).foregroundStyle(.secondary) + InfoButton( + title: "Random Seed", + description: "The same seed + prompt produces the same image every time. " + + "Use -1 for a unique result each run." + ) + } + HStack(spacing: 4) { + TextField("-1", value: $params.seed, format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .frame(width: 100) + .accessibilityLabel("Seed") + .accessibilityHint("Use -1 for random") + Button { + params.seed = Int.random(in: 0 ..< 1_000_000_000) + } label: { + Image(systemName: "dice").font(.caption) + } + .buttonStyle(.iconButton) + .accessibilityLabel("Pick random seed") + Button { + params.seed = -1 + } label: { + Image(systemName: "arrow.counterclockwise").font(.caption) + } + .buttonStyle(.iconButton) + .accessibilityLabel("Reset to random (-1)") + } + } + Spacer(minLength: 0) + } + } + + // MARK: - Guidance + + private var guidanceRow: some View { + HStack(spacing: 6) { + Slider(value: $params.guidance, in: 1.0 ... 10.0, step: 0.5) + .accessibilityLabel("Guidance") + .accessibilityValue(String(format: "%.1f", params.guidance)) + .accessibilityHint("1 runs guidance-free. Higher enables true CFG with a negative prompt.") + Text(String(format: "%.1f", params.guidance)) + .font(.system(.caption, design: .monospaced)) + .frame(width: 28) + } + } + + // MARK: - Img2img + + private var img2ImgSection: some View { + VStack(alignment: .leading, spacing: 6) { + if !params.imagePath.isEmpty { + HStack(spacing: 4) { + Spacer() + Button { + params.imagePath = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.iconButton) + .accessibilityLabel("Remove reference image") + } + } + + if params.imagePath.isEmpty { + HStack(spacing: 6) { + Button { browseImage() } label: { + HStack { + Image(systemName: "photo.badge.plus") + Text("Choose Image…") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Choose reference image") + .accessibilityHint("Opens a file picker to select an image for img2img generation") + + if clipboardMonitor.hasImage { + Button { pasteImage() } label: { + Image(systemName: "doc.on.clipboard") + .padding(.vertical, 6) + .padding(.horizontal, 4) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Paste image from clipboard") + .help("Paste image from clipboard") + } + } + } else { + HStack(spacing: 8) { + if let img = NSImage(contentsOfFile: params.imagePath) { + Image(nsImage: img) + .resizable() + .scaledToFill() + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .thumbnailHoverPreview(imagePath: params.imagePath) + } + VStack(alignment: .leading, spacing: 4) { + Text(URL(fileURLWithPath: params.imagePath).lastPathComponent) + .font(.caption).lineLimit(1).truncationMode(.middle) + HStack(spacing: 4) { + Text("Strength").font(.caption2).foregroundStyle(.secondary) + InfoButton( + title: "Image Strength", + description: "How faithfully the output follows the original image." + + " High strength (75–95%) = stays close to the original, subtle" + + " changes. Low strength (15–30%) = more creative freedom, prompt" + + " dominates. Think of it as image preservation, not prompt strength." + ) + Slider(value: $params.imageStrength, in: 0.05 ... 0.95) + .onChange(of: params.imageStrength) { _, v in params.imageStrength = round(v / 0.05) * 0.05 } + .accessibilityLabel("Image strength") + .accessibilityValue(String(format: "%.0f%%", params.imageStrength * 100)) + .accessibilityHint("How much the reference image influences the output. Lower = more faithful to original.") + Text(String(format: "%.0f%%", params.imageStrength * 100)) + .font(.caption2).monospacedDigit().frame(width: 30) + } + } + } + } + } + .imageDropTarget(extensions: Self.imageExtensions, isTargeted: $isImageDropTargeted) { paths in + guard let path = paths.first else { return } + params.imagePath = path + params.adoptResolvedPromptForImg2Img(at: path) + } + .dropHighlight(isImageDropTargeted) + } + + // MARK: - Img2img helpers + + private func pasteImage() { + let pb = NSPasteboard.general + // Prefer a file URL so we keep the original file on disk. + if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], + let url = urls.first(where: { Self.imageExtensions.contains($0.pathExtension.lowercased()) }) { + params.imagePath = url.path + return + } + // Fall back to raw image data, saved to a temp PNG. + guard let image = NSImage(pasteboard: pb) else { return } + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("pasted-qwenimage-\(Int(Date().timeIntervalSince1970)).png") + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { return } + try? png.write(to: tmp) + params.imagePath = tmp.path + } + + private func browseImage() { + let panel = NSOpenPanel() + panel.allowsOtherFileTypes = true + panel.title = "Select Reference Image" + if panel.runModal() == .OK, let url = panel.url { + let ext = url.pathExtension.lowercased() + if Self.imageExtensions.contains(ext) { + params.imagePath = url.path + } + } + } +} diff --git a/Views/QwenImage/QwenImagePreviewViews.swift b/Views/QwenImage/QwenImagePreviewViews.swift new file mode 100644 index 0000000..89631c5 --- /dev/null +++ b/Views/QwenImage/QwenImagePreviewViews.swift @@ -0,0 +1,212 @@ +import SwiftUI + +/// Live stepwise preview for a running Qwen-Image job. Concrete copy of +/// ``StepwisePreviewView`` (the codebase keeps one per model family) bound to +/// ``QwenImageJob``. +struct QwenImageStepwisePreviewView: View { + let job: QwenImageJob + let onCancel: () -> Void + + @Environment(AppSettings.self) private var settings + @State private var displayedImage: NSImage? + @State private var showLog: Bool = false + + var body: some View { + VStack(spacing: 0) { + if showLog { + logView + } else { + ZStack { + Color(nsColor: .windowBackgroundColor) + if let img = displayedImage { + Image(nsImage: img) + .resizable() + .scaledToFit() + } else { + generatingPlaceholder + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + } + + VStack(spacing: 8) { + if !showLog { + Group { + if job.isDenoising, job.totalSteps > 0 { + ProgressView(value: Double(job.currentStep) / Double(job.totalSteps)) + } else { + ProgressView() + } + } + .progressViewStyle(.linear) + .padding(.horizontal) + } + + HStack(spacing: 8) { + progressLabel + Spacer() + Button(showLog ? "Hide Log" : "View Log") { showLog.toggle() } + .buttonStyle(.bordered) + .controlSize(.small) + Button("Cancel") { onCancel() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(.red) + } + .padding(.horizontal) + } + .padding(.bottom, 16) + } + .onAppear { loadStepwiseImage(path: job.latestStepwisePath) } + .onChange(of: job.latestStepwisePath) { _, path in loadStepwiseImage(path: path) } + } + + private var logView: some View { + LogTextView( + text: job.log.isEmpty ? "No output yet." : job.log, + fontSize: settings.logFontSize + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var generatingPlaceholder: some View { + VStack(spacing: 12) { + ProgressView().scaleEffect(1.5) + Text("Generating…") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var progressLabel: some View { + if job.isDenoising { + VStack(alignment: .leading, spacing: 1) { + // Mirror tqdm exactly: currentStep is the completed-step count the CLI shows. + Text("Step \(job.currentStep)/\(job.totalSteps)") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + if let timing = job.stepTiming { + Text(timing) + .font(.caption2) + .monospacedDigit() + .foregroundStyle(.tertiary) + } + } + } else { + Text(job.statusLine.isEmpty ? "Loading model…" : job.statusLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + private func loadStepwiseImage(path: String?) { + guard let path else { + displayedImage = nil + return + } + Task.detached(priority: .userInitiated) { + let img = NSImage(contentsOfFile: path) + await MainActor.run { + guard path == job.latestStepwisePath else { return } + displayedImage = img + } + } + } +} + +/// Completed-image preview for a Qwen-Image job. Shows the image plus reveal/log +/// affordances and Apply-Settings / Remix. (In practice the preview flips to the +/// gallery item on completion; this is the queue-reselect path.) +struct QwenImageCompletedPreviewView: View { + let job: QwenImageJob + var onRemix: ((QwenImageMetadata) -> Void)? + var onApplySettings: ((QwenImageMetadata) -> Void)? + + @State private var image: NSImage? + @State private var showingLog: Bool = false + + var body: some View { + VStack(spacing: 0) { + ZStack { + Color.black.opacity(0.05) + if let img = image { + Image(nsImage: img).resizable().scaledToFit() + } else { + ProgressView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .contextMenu { + if let path = job.outputPath { + Button("Copy Image") { + guard let img = NSImage(contentsOfFile: path) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.writeObjects([img]) + } + Divider() + } + if let fn = onApplySettings { + Button("Apply Settings") { fn(metadata) } + } + if let fn = onRemix { + Button("Remix (new seed)") { fn(metadata) } + } + if let path = job.outputPath { + Divider() + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "") + } + } + } + + ImageMetadataPanel( + info: ImageMetadataInfo(qwenImageJob: job), + onApplySettings: onApplySettings.map { fn in { fn(metadata) } }, + onRemix: onRemix.map { fn in { fn(metadata) } }, + onUseInImg2Img: nil, + onRevealInFinder: job.outputPath.map { path in { + NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "") + } + }, + onShowLog: job.log.isEmpty ? nil : { showingLog = true } + ) + } + .onAppear { loadImage() } + .onChange(of: job.outputPath) { _, _ in loadImage() } + .sheet(isPresented: $showingLog) { logSheet } + } + + private var metadata: QwenImageMetadata { + QwenImageMetadata.from(job: job) + } + + private var logSheet: some View { + NavigationStack { + LogTextView(text: job.log, fontSize: NSFont.smallSystemFontSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Generation Log") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showingLog = false } + } + } + } + .frame(width: 640, height: 480) + } + + private func loadImage() { + guard let path = job.outputPath else { return } + Task.detached(priority: .userInitiated) { + let img = NSImage(contentsOfFile: path) + await MainActor.run { image = img } + } + } +} diff --git a/Views/Settings/ModelDefaultsView+QwenImageForm.swift b/Views/Settings/ModelDefaultsView+QwenImageForm.swift new file mode 100644 index 0000000..d7e7bdb --- /dev/null +++ b/Views/Settings/ModelDefaultsView+QwenImageForm.swift @@ -0,0 +1,48 @@ +import SwiftUI + +// MARK: - Qwen-Image 2.1 per-model form body +// +// Qwen-Image 2.1 is text-to-image (+ img2img). It exposes only the controls the +// `mflux-generate-qwen-2.1` CLI backstops: steps, guidance, quantize, model +// source, and canvas. Q8/Q4 quantize in memory at load (the Qwen3-VL text encoder +// stays BF16). There is no low-RAM streaming, so that row is intentionally absent. + +extension ModelDefaultsView { + func qwenImageFormContent(model: FluxModelVariant) -> some View { + let d = settings.defaults(for: model) + return Form { + Section { + stepsPicker(model: model, current: d.steps) + guidancePicker(model: model, current: d.guidance) + quantizePicker(model: model, current: d.quantize) + modelRepoField(model: model, current: d.modelRepoOverride) + } header: { + Text("Generation") + } footer: { + Text("Guidance 1.0 is the model's guidance-free reference. Above 1 it only " + + "takes effect together with a negative prompt.") + .font(.caption).foregroundStyle(.tertiary) + } + + Section { + widthPicker(model: model, current: d.width) + heightPicker(model: model, current: d.height) + } header: { + Text("Canvas") + } footer: { + Text("Falls back to the global default size in Generation if not overridden here.") + .font(.caption).foregroundStyle(.tertiary) + } + + Section { + Button("Reset to Defaults", role: .destructive) { + settings.updateDefaults(ModelDefaults(), for: model) + } + .foregroundStyle(.red) + .accessibilityLabel("Reset \(model.displayName) to built-in defaults") + } + } + .formStyle(.grouped) + .id(model) + } +} diff --git a/Views/Settings/ModelDefaultsView.swift b/Views/Settings/ModelDefaultsView.swift index 5d8618a..21f9a6f 100644 --- a/Views/Settings/ModelDefaultsView.swift +++ b/Views/Settings/ModelDefaultsView.swift @@ -175,6 +175,15 @@ struct ModelDefaultsView: View { .padding(.vertical, 2) .tag(Selection.model(.zimage)) } + Section("Qwen-Image") { + VStack(alignment: .leading, spacing: 2) { + Text("Qwen-Image 2.1").font(.callout) + Text("\(FluxModelVariant.qwenImage21.defaultSteps) steps · BF16/Q8/Q4") + .font(.caption2).foregroundStyle(.secondary) + } + .padding(.vertical, 2) + .tag(Selection.model(.qwenImage21)) + } Section("Upscale") { VStack(alignment: .leading, spacing: 2) { Text("SeedVR2").font(.callout) @@ -206,6 +215,8 @@ struct ModelDefaultsView: View { .task { await ensureComfyDiscovery() } } else if selectedModel.isZImage { zimageFormContent(model: selectedModel) + } else if selectedModel.isQwenImage { + qwenImageFormContent(model: selectedModel) } else { formContent(model: selectedModel, defaults: settings.defaults(for: selectedModel)) } @@ -316,7 +327,7 @@ struct ModelDefaultsView: View { // A model with a pre-quantized repo (Ideogram, Z-Image Turbo Q4) // just downloads it; only an mflux-save pass from a local BF16 base // is a conversion. - let usesPreQuant = model.isIdeogram4 || model.preQuantizedRepoID(quantize: quantize) != nil + let usesPreQuant = model.directDownloadRepoID(quantize: quantize) != nil let verb = !usesPreQuant && model.isOnDisk(quantize: 0, savedIn: settings.effectiveMfluxCacheDir) && quantize != 0 ? "Converting" : "Downloading" @@ -358,7 +369,9 @@ struct ModelDefaultsView: View { HStack(spacing: 6) { // swiftlint:disable:next redundant_discardable_let let _ = cacheRevision // invalidates view when cache changes on disk - let cachedVariants = [0, 4, 8].filter { model.isOnDisk(quantize: $0, savedIn: settings.effectiveMfluxCacheDir) } + let cachedVariants = model.cachedQuantizeLevels.filter { + model.isOnDisk(quantize: $0, savedIn: settings.effectiveMfluxCacheDir) + } ForEach(cachedVariants, id: \.self) { qLevel in HStack(spacing: 3) { Text(qLevel == 0 ? model.baseWeightLabel : "Q\(qLevel)") @@ -378,7 +391,10 @@ struct ModelDefaultsView: View { .foregroundStyle(.green) } let cacheDir = settings.effectiveMfluxCacheDir - ForEach([0, 4, 8].filter { !model.isOnDisk(quantize: $0, savedIn: cacheDir) }, id: \.self) { qLevel in + ForEach( + model.cachedQuantizeLevels.filter { !model.isOnDisk(quantize: $0, savedIn: cacheDir) }, + id: \.self + ) { qLevel in let qLabel = qLevel == 0 ? model.baseWeightLabel : "Q\(qLevel)" Button("Download \(qLabel)") { startCache(model: model, quantize: qLevel) @@ -410,7 +426,7 @@ struct ModelDefaultsView: View { // load those weights straight from the HF cache, so a plain `hf download` // of that repo is all that's needed — no mflux-save quantize pass (which // would also leave a redundant full-precision copy on disk). - if model.isIdeogram4 || model.preQuantizedRepoID(quantize: quantize) != nil { + if model.directDownloadRepoID(quantize: quantize) != nil { Task { await runPreQuantizedDownload(model: model, quantize: quantize) } } else { Task { await runMfluxSave(model: model, quantize: quantize) } @@ -420,24 +436,29 @@ struct ModelDefaultsView: View { /// " · 4.4 / 27 GB" while an Ideogram repo is downloading, polled from the blobs /// dir (includes in-flight `.incomplete` files). Empty for other models / no bytes yet. private func downloadedSuffix(model: FluxModelVariant, quantize: Int) -> String { - guard model.isIdeogram4 || model.preQuantizedRepoID(quantize: quantize) != nil else { return "" } - let repo = model.preQuantizedRepoID(quantize: quantize) ?? "ideogram-ai/ideogram-4-fp8" + guard let repo = model.directDownloadRepoID(quantize: quantize) else { return "" } let cacheName = "models--" + repo.replacingOccurrences(of: "/", with: "--") let blobs = settings.hfHubDir.appendingPathComponent(cacheName).appendingPathComponent("blobs") guard let entries = try? FileManager.default.contentsOfDirectory( at: blobs, includingPropertiesForKeys: [.fileSizeKey] ) else { return "" } - let bytes = entries.reduce(0) { $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) } + // Blob entries may be symlinks into the shared hub blob store; size the target. + let bytes = entries.reduce(0) { + $0 + ((try? $1.resolvingSymlinksInPath().resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } guard bytes > 0 else { return "" } let gb = Double(bytes) / 1_073_741_824 - let total = model.approximateSizeGB(quantize: quantize) + let total = model.approximateDownloadGB(quantize: quantize) return total > 0 ? " · \(String(format: "%.1f", gb)) / \(String(format: "%.0f", total)) GB" : " · \(String(format: "%.1f", gb)) GB" } private func runPreQuantizedDownload(model: FluxModelVariant, quantize: Int) async { - let repo = model.preQuantizedRepoID(quantize: quantize) ?? "ideogram-ai/ideogram-4-fp8" + guard let repo = model.directDownloadRepoID(quantize: quantize) else { + cachePhase = .failed("No downloadable repo for \(model.displayName).") + return + } // The Hugging Face CLI is `hf` now — `huggingface-cli` is a deprecated no-op shim. let hfBinary = BinaryDetector.detect("hf") guard !hfBinary.isEmpty else { diff --git a/Views/Settings/SettingsView.swift b/Views/Settings/SettingsView.swift index 2233af7..f2f4800 100644 --- a/Views/Settings/SettingsView.swift +++ b/Views/Settings/SettingsView.swift @@ -237,7 +237,7 @@ struct SettingsView: View { .labelsHidden() Picker("", selection: $loraFamily) { - ForEach(ModelFamily.generative, id: \.self) { Text($0.rawValue).tag($0) } + ForEach(ModelFamily.generative.filter(\.supportsLoras), id: \.self) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) .labelsHidden() diff --git a/Views/Shared/DimensionConstraints.swift b/Views/Shared/DimensionConstraints.swift index 211d9e7..6421c08 100644 --- a/Views/Shared/DimensionConstraints.swift +++ b/Views/Shared/DimensionConstraints.swift @@ -22,6 +22,10 @@ struct DimensionConstraints: Equatable { /// renders while keeping the floor above the model's minimum. static let zimage = Self(range: 256 ... 2048, step: 16, maxArea: nil) + /// Qwen-Image 2.1: multiples of 16 (one latent token per 16×16 tile of its + /// 16×-compression VAE), 256–2048 per side. Tuned around ~1 MP. + static let qwenImage = Self(range: 256 ... 2048, step: 16, maxArea: nil) + /// FLUX.2 (all variants — distilled *klein* and dev *klein-base*, 4B and 9B — /// share one architecture and the same ~4 MP ceiling). Multiples of 32 to match /// the FLUX.2 VAE stride. The per-axis ceiling is generous (4096) so wide aspect diff --git a/Views/Shared/ImageMetadataInfo.swift b/Views/Shared/ImageMetadataInfo.swift index 5fec0c4..b58e8f4 100644 --- a/Views/Shared/ImageMetadataInfo.swift +++ b/Views/Shared/ImageMetadataInfo.swift @@ -178,6 +178,45 @@ struct ImageMetadataInfo { } } + init(qwenImageJob job: QwenImageJob) { + prompt = job.prompt + negativePrompt = job.usesTrueCFG ? job.negativePrompt : "" + modelName = job.customModelRepo.isEmpty + ? job.modelVariant.displayName + : job.customModelRepo.split(separator: "/").last.map(String.init) ?? job.customModelRepo + seed = job.resolvedSeed ?? job.seed + width = job.width + height = job.height + steps = job.steps + guidance = job.guidance + loras = [] + filePath = job.outputPath + log = job.log.isEmpty ? nil : job.log + if job.seeds.isEmpty, let started = job.startedAt, let ended = job.completedAt { + let secs = Int(ended.timeIntervalSince(started)) + generationTime = "\(secs / 60)m \(secs % 60)s" + } + } + + init?(qwenImageItem: GalleryItem) { + guard let meta = qwenImageItem.qwenImageMetadata else { return nil } + prompt = meta.prompt + negativePrompt = meta.guidance > 1.0 ? meta.negativePrompt ?? "" : "" + modelName = meta.displayModelName + seed = meta.seed + width = meta.width + height = meta.height + steps = meta.steps + guidance = meta.guidance + loras = [] + filePath = qwenImageItem.path + log = meta.log + if let started = meta.startedAt { + let secs = Int(meta.generatedAt.timeIntervalSince(started)) + generationTime = "\(secs / 60)m \(secs % 60)s" + } + } + init?(zimageItem: GalleryItem) { guard let meta = zimageItem.zimageMetadata else { return nil } prompt = meta.prompt