bleam 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/cli.cjs +23 -4
  2. package/dist/cli.d.cts +2 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +23 -4
  5. package/dist/{ui-234Plg7Z.d.cts → ui-Bg11tvlc.d.ts} +7 -7
  6. package/dist/ui.d.ts +1 -1
  7. package/dist/window.d.ts +1 -1
  8. package/package.json +4 -1
  9. package/templates/image-generation/app/index.tsx +1 -1
  10. package/templates/native/.gitattributes +1 -0
  11. package/templates/native/App.tsx +26 -0
  12. package/templates/native/app.json +12 -0
  13. package/templates/native/index.ts +8 -0
  14. package/templates/native/ios/.xcode.env +11 -0
  15. package/templates/native/ios/Bleam/AI/Flux2/Configuration/Flux2Config.swift +288 -0
  16. package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryConfig.swift +233 -0
  17. package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryOptimizationConfig.swift +157 -0
  18. package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +425 -0
  19. package/templates/native/ios/Bleam/AI/Flux2/Configuration/QuantizationConfig.swift +138 -0
  20. package/templates/native/ios/Bleam/AI/Flux2/Configuration/TransformerRepoProfile.swift +59 -0
  21. package/templates/native/ios/Bleam/AI/Flux2/Configuration/VAEConfig.swift +134 -0
  22. package/templates/native/ios/Bleam/AI/Flux2/Flux2Core.swift +55 -0
  23. package/templates/native/ios/Bleam/AI/Flux2/Loading/WeightLoader.swift +1192 -0
  24. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +1282 -0
  25. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/LatentUtils.swift +191 -0
  26. package/templates/native/ios/Bleam/AI/Flux2/Scheduler/FlowMatchEulerScheduler.swift +279 -0
  27. package/templates/native/ios/Bleam/AI/Flux2/Text/KleinTextEncoder.swift +380 -0
  28. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Attention.swift +379 -0
  29. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Embeddings.swift +159 -0
  30. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FeedForward.swift +154 -0
  31. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FusedKernels.swift +198 -0
  32. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2KVCache.swift +36 -0
  33. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Modulation.swift +193 -0
  34. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2ParallelAttention.swift +389 -0
  35. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2RoPE.swift +304 -0
  36. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2SingleBlock.swift +290 -0
  37. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Transformer.swift +530 -0
  38. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2TransformerBlock.swift +362 -0
  39. package/templates/native/ios/Bleam/AI/Flux2/Utils/Flux2Debug.swift +101 -0
  40. package/templates/native/ios/Bleam/AI/Flux2/Utils/MLXCheckpoint.swift +118 -0
  41. package/templates/native/ios/Bleam/AI/Flux2/Utils/MemoryManager.swift +201 -0
  42. package/templates/native/ios/Bleam/AI/Flux2/VAE/AutoencoderKL.swift +476 -0
  43. package/templates/native/ios/Bleam/AI/Flux2/VAE/ResnetBlock.swift +316 -0
  44. package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEDecoder.swift +120 -0
  45. package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEEncoder.swift +136 -0
  46. package/templates/native/ios/Bleam/AppDelegate.swift +373 -0
  47. package/templates/native/ios/Bleam/Appearance.swift +62 -0
  48. package/templates/native/ios/Bleam/Bridging-Header.h +3 -0
  49. package/templates/native/ios/Bleam/Images.xcassets/AppIcon.appiconset/Contents.json +13 -0
  50. package/templates/native/ios/Bleam/Images.xcassets/Contents.json +6 -0
  51. package/templates/native/ios/Bleam/Info.plist +76 -0
  52. package/templates/native/ios/Bleam/PrivacyInfo.xcprivacy +48 -0
  53. package/templates/native/ios/Bleam/SceneDelegate.swift +58 -0
  54. package/templates/native/ios/Bleam/SplashScreen.storyboard +47 -0
  55. package/templates/native/ios/Bleam/Supporting/Expo.plist +6 -0
  56. package/templates/native/ios/Bleam/bleam.entitlements +10 -0
  57. package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +1146 -0
  58. package/templates/native/ios/Bleam.xcodeproj/xcshareddata/xcschemes/Bleam.xcscheme +88 -0
  59. package/templates/native/ios/GenerationService/GenerationService.entitlements +10 -0
  60. package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +591 -0
  61. package/templates/native/ios/GenerationService/Info.plist +31 -0
  62. package/templates/native/ios/GenerationService/main.swift +165 -0
  63. package/templates/native/ios/PlatformHelper/Info.plist +29 -0
  64. package/templates/native/ios/PlatformHelper/main.swift +335 -0
  65. package/templates/native/ios/Podfile +198 -0
  66. package/templates/native/ios/Podfile.lock +2284 -0
  67. package/templates/native/ios/Podfile.properties.json +5 -0
  68. package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +12 -0
  69. package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +124 -0
  70. package/templates/native/metro.config.js +6 -0
  71. package/templates/native/modules/bleam-runtime/BleamRuntime.podspec +14 -0
  72. package/templates/native/modules/bleam-runtime/expo-module.config.json +8 -0
  73. package/templates/native/modules/bleam-runtime/ios/AIModule.swift +1258 -0
  74. package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +135 -0
  75. package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +313 -0
  76. package/templates/native/modules/bleam-runtime/package.json +5 -0
  77. package/templates/native/package.json +39 -0
  78. package/templates/native/scripts/build/mlx-frameworks.ts +421 -0
  79. package/templates/native/scripts/start/index.ts +474 -0
  80. package/templates/native/scripts/start/known-failures.ts +20 -0
  81. package/templates/native/scripts/start/loader.ts +76 -0
  82. package/templates/native/scripts/start/native-state.ts +242 -0
  83. package/templates/native/scripts/start/package.json +4 -0
  84. package/templates/native/scripts/start/swift-packages.ts +414 -0
  85. package/templates/native/scripts/start/xcode-formatter.ts +106 -0
  86. package/templates/native/tsconfig.json +14 -0
  87. package/templates/native/yarn.lock +3749 -0
  88. package/dist/ui-BJmXhz9Q.d.ts +0 -92
  89. package/dist/ui-Dd7SXdbg.d.cts +0 -92
  90. package/dist/ui-Ds88eETu.d.ts +0 -92
  91. package/dist/ui-TaqnB5SP.d.ts +0 -92
  92. package/dist/ui-WQvnXL0W.d.ts +0 -92
@@ -0,0 +1,1282 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import MLX
4
+ import MLXNN
5
+
6
+ public struct FluxImageRunnerPaths: Sendable {
7
+ public let transformer: URL
8
+ public let vae: URL
9
+
10
+ public init(transformer: URL, vae: URL) {
11
+ self.transformer = transformer
12
+ self.vae = vae
13
+ }
14
+ }
15
+
16
+ public enum FluxImageGenerationMode: Sendable {
17
+ case textToImage
18
+ case imageToImage(sourceImage: CGImage?, maskImage: CGImage?, maskMode: FluxImageMaskMode, referenceImages: [CGImage], referenceInfluences: [Float]?, strength: Float)
19
+ }
20
+
21
+ public enum FluxImageMaskMode: Sendable {
22
+ case paint
23
+ case preserve
24
+ }
25
+
26
+ public struct FluxLoRAConfig: Sendable {
27
+ public let id: String?
28
+ public let path: String
29
+ public let scale: Float
30
+
31
+ public init(id: String? = nil, path: String, scale: Float) {
32
+ self.id = id
33
+ self.path = path
34
+ self.scale = scale
35
+ }
36
+ }
37
+
38
+ private enum LoRARuntimeWarnings {
39
+ nonisolated(unsafe) static var skippedShapeMismatchCount = 0
40
+ static let maxWarnings = 12
41
+
42
+ static func warnShapeMismatch(layerName: String?, inputShape: [Int], downShape: [Int], upShape: [Int]) {
43
+ skippedShapeMismatchCount += 1
44
+ guard skippedShapeMismatchCount <= maxWarnings else { return }
45
+ Flux2Debug.warning(
46
+ "Skipping runtime LoRA shape mismatch: layer=\(layerName ?? "unknown") input=\(inputShape), down=\(downShape), up=\(upShape)"
47
+ )
48
+ }
49
+ }
50
+
51
+ private struct LoRATensorDiagnostic {
52
+ let key: String
53
+ let shape: [Int]
54
+ let min: Float
55
+ let max: Float
56
+ let mean: Float
57
+ let nonFiniteCount: Int
58
+ }
59
+
60
+ private struct LoRADiagnostics {
61
+ let fileName: String
62
+ let tensorCount: Int
63
+ let layerCount: Int
64
+ let rank: Int
65
+ let targetModel: String
66
+ let sampleTensors: [LoRATensorDiagnostic]
67
+ let nonFiniteTensorCount: Int
68
+ let nonFiniteValueCount: Int
69
+ }
70
+
71
+ private struct LoRADimensionMismatch {
72
+ let transformerLayer: String
73
+ let loraLayer: String
74
+ let loraName: String
75
+ let expectedInFeatures: Int
76
+ let expectedOutFeatures: Int
77
+ let downShape: [Int]
78
+ let upShape: [Int]
79
+
80
+ var logDescription: String {
81
+ "\(loraName) layer=\(loraLayer) transformer=\(transformerLayer) expected=[out:\(expectedOutFeatures), in:\(expectedInFeatures)] down=\(downShape) up=\(upShape)"
82
+ }
83
+ }
84
+
85
+ private struct LoRAFileApplyReport {
86
+ let name: String
87
+ let loadedLayerCount: Int
88
+ let matchedLayerCount: Int
89
+ let appliedLayerCount: Int
90
+ let dimensionMismatchCount: Int
91
+ let sampleMatchedLayers: [String]
92
+ let sampleUnmatchedLayers: [String]
93
+ let sampleDimensionMismatches: [LoRADimensionMismatch]
94
+ }
95
+
96
+ private struct LoRAApplyReport {
97
+ let loadedLayerCount: Int
98
+ let transformerLinearLayerCount: Int
99
+ let matchedLayerCount: Int
100
+ let replacedLayerCount: Int
101
+ let updatedLayerCount: Int
102
+ let dimensionMismatchCount: Int
103
+ let sampleDimensionMismatches: [LoRADimensionMismatch]
104
+ let sampleUnmatchedLoRALayers: [String]
105
+ let sampleTransformerLayers: [String]
106
+ let loraReports: [LoRAFileApplyReport]
107
+
108
+ var appliedLayerCount: Int { replacedLayerCount + updatedLayerCount }
109
+ }
110
+
111
+ private struct TransformerLinearTarget {
112
+ let module: Module
113
+ let inputFeatures: Int
114
+ let outputFeatures: Int
115
+ }
116
+
117
+ private struct LoRAContribution {
118
+ let down: MLXArray
119
+ let up: MLXArray
120
+ let scale: Float
121
+ }
122
+
123
+ private final class LoRALinear: Linear, @unchecked Sendable {
124
+ private let down: MLXArray
125
+ private let up: MLXArray
126
+ private let scale: Float
127
+ private let debugName: String?
128
+
129
+ init(base: Linear, down: MLXArray, up: MLXArray, scale: Float, debugName: String?) {
130
+ self.down = down
131
+ self.up = up
132
+ self.scale = scale
133
+ self.debugName = debugName
134
+ super.init(weight: base.weight, bias: base.bias)
135
+ }
136
+
137
+ override func callAsFunction(_ x: MLXArray) -> MLXArray {
138
+ let result = super.callAsFunction(x)
139
+ guard down.shape.count == 2,
140
+ up.shape.count == 2,
141
+ x.shape.last == down.shape[1],
142
+ down.shape[0] == up.shape[1] else {
143
+ LoRARuntimeWarnings.warnShapeMismatch(
144
+ layerName: debugName,
145
+ inputShape: x.shape,
146
+ downShape: down.shape,
147
+ upShape: up.shape
148
+ )
149
+ return result
150
+ }
151
+ let delta = matmul(matmul(x, down.transposed(1, 0)), up.transposed(1, 0)) * scale
152
+ return result + delta.asType(result.dtype)
153
+ }
154
+ }
155
+
156
+ private final class LoRAQuantizedLinear: QuantizedLinear, @unchecked Sendable {
157
+ private let down: MLXArray
158
+ private let up: MLXArray
159
+ private let scale: Float
160
+ private let debugName: String?
161
+
162
+ init(base: QuantizedLinear, down: MLXArray, up: MLXArray, scale: Float, debugName: String?) {
163
+ self.down = down
164
+ self.up = up
165
+ self.scale = scale
166
+ self.debugName = debugName
167
+ super.init(
168
+ weight: base.weight,
169
+ bias: base.bias,
170
+ scales: base.scales,
171
+ biases: base.biases,
172
+ groupSize: base.groupSize,
173
+ bits: base.bits,
174
+ mode: base.mode
175
+ )
176
+ }
177
+
178
+ override func callAsFunction(_ x: MLXArray) -> MLXArray {
179
+ let result = super.callAsFunction(x)
180
+ guard down.shape.count == 2,
181
+ up.shape.count == 2,
182
+ x.shape.last == down.shape[1],
183
+ down.shape[0] == up.shape[1] else {
184
+ LoRARuntimeWarnings.warnShapeMismatch(
185
+ layerName: debugName,
186
+ inputShape: x.shape,
187
+ downShape: down.shape,
188
+ upShape: up.shape
189
+ )
190
+ return result
191
+ }
192
+ let delta = matmul(matmul(x, down.transposed(1, 0)), up.transposed(1, 0)) * scale
193
+ return result + delta.asType(result.dtype)
194
+ }
195
+ }
196
+
197
+ public final class FluxImageRunner: @unchecked Sendable {
198
+ private static let referenceImageMaxLongSide = 768
199
+
200
+ private let paths: FluxImageRunnerPaths
201
+ private let scheduler = FlowMatchEulerScheduler()
202
+ private var transformer: Flux2Transformer2DModel?
203
+ private var vae: AutoencoderKLFlux2?
204
+
205
+ public init(paths: FluxImageRunnerPaths) {
206
+ self.paths = paths
207
+ }
208
+
209
+ public func generate(
210
+ textEmbeddings: MLXArray,
211
+ height requestedHeight: Int,
212
+ width requestedWidth: Int,
213
+ steps: Int,
214
+ guidance: Float,
215
+ seed: UInt64?,
216
+ mode: FluxImageGenerationMode = .textToImage,
217
+ loras: [FluxLoRAConfig] = [],
218
+ sigmas: [Float]? = nil,
219
+ onStage: ((String) -> Void)? = nil,
220
+ onProgress: (@Sendable (Int, Int) -> Void)? = nil
221
+ ) throws -> CGImage {
222
+ let (height, width) = LatentUtils.validateDimensions(height: requestedHeight, width: requestedWidth)
223
+ try validateTextEmbeddings(textEmbeddings)
224
+
225
+ if let seed {
226
+ MLXRandom.seed(seed)
227
+ }
228
+
229
+ defer {
230
+ // Failed generations can otherwise leave partially loaded weights
231
+ // behind. Keep this path conservative until we add explicit model
232
+ // lifecycle controls and memory telemetry.
233
+ self.transformer = nil
234
+ self.vae = nil
235
+ MLX.Memory.clearCache()
236
+ }
237
+
238
+ let textLength = textEmbeddings.shape[1]
239
+ let (textIds, outputImageIds, _) = LatentUtils.combinePositionIDs(
240
+ textLength: textLength,
241
+ height: height,
242
+ width: width
243
+ )
244
+
245
+ let conditioning = try encodeImageConditioning(
246
+ mode: mode,
247
+ height: height,
248
+ width: width,
249
+ steps: steps,
250
+ sigmas: sigmas
251
+ )
252
+ var packedLatents = conditioning.initialLatents
253
+ let imageIds = conditioning.imageIds ?? outputImageIds
254
+ let outputSequenceLength = conditioning.outputSequenceLength
255
+ eval(packedLatents)
256
+
257
+ do {
258
+ onStage?("denoise")
259
+ // Keep the Flux transformer alive only for denoising. The VAE is
260
+ // loaded after this scope so both large model families are not
261
+ // resident together on memory-constrained machines.
262
+ let transformer = try loadTransformer()
263
+ try applyLoRAs(loras, to: transformer)
264
+
265
+ let effectiveSteps = scheduler.sigmas.count - 1
266
+ for stepIndex in 0..<effectiveSteps {
267
+ if Task.isCancelled {
268
+ throw Flux2Error.generationCancelled
269
+ }
270
+
271
+ let sigma = scheduler.sigmas[stepIndex]
272
+ let timestep = MLXArray([sigma])
273
+ let hiddenStates = conditioning.referenceLatents.map {
274
+ concatenated([packedLatents, $0], axis: 1)
275
+ } ?? packedLatents
276
+ eval(hiddenStates)
277
+ let noisePrediction = transformer(
278
+ hiddenStates: hiddenStates,
279
+ encoderHiddenStates: textEmbeddings,
280
+ timestep: timestep,
281
+ guidance: nil,
282
+ imgIds: imageIds,
283
+ txtIds: textIds
284
+ )
285
+ let outputNoisePrediction = conditioning.referenceLatents == nil
286
+ ? noisePrediction
287
+ : noisePrediction[0..., 0..<outputSequenceLength, 0...]
288
+
289
+ packedLatents = scheduler.step(
290
+ modelOutput: outputNoisePrediction,
291
+ timestep: sigma,
292
+ sample: packedLatents
293
+ )
294
+ eval(packedLatents)
295
+
296
+ if let maskedBlend = conditioning.maskedBlend {
297
+ let sigmaNext = stepIndex + 1 < scheduler.sigmas.count ? scheduler.sigmas[stepIndex + 1] : 0
298
+ let freshNoise = MLXRandom.normal(packedLatents.shape)
299
+ let nextSigma = MLXArray([sigmaNext])
300
+ let originalNoised = (1 - nextSigma) * maskedBlend.sourceLatents + nextSigma * freshNoise
301
+ packedLatents = (1 - maskedBlend.maskLatents) * originalNoised + maskedBlend.maskLatents * packedLatents
302
+ eval(packedLatents)
303
+ }
304
+
305
+ onProgress?(stepIndex + 1, effectiveSteps)
306
+
307
+ if (stepIndex + 1) % 5 == 0 {
308
+ MLX.Memory.clearCache()
309
+ }
310
+ }
311
+ }
312
+ self.transformer = nil
313
+ MLX.Memory.clearCache()
314
+
315
+ onStage?("decode")
316
+ let vae = try loadVAE()
317
+
318
+ var patchified = LatentUtils.unpackSequenceToPatchified(packedLatents, height: height, width: width)
319
+ patchified = LatentUtils.denormalizeLatentsWithBatchNorm(
320
+ patchified,
321
+ runningMean: vae.batchNormRunningMean,
322
+ runningVar: vae.batchNormRunningVar
323
+ )
324
+ eval(patchified)
325
+
326
+ let latents = LatentUtils.unpatchifyLatents(patchified)
327
+ eval(latents)
328
+
329
+ let decoded = vae.decode(latents)
330
+ eval(decoded)
331
+
332
+ guard var image = postprocessVAEOutput(decoded) else {
333
+ throw Flux2Error.imageProcessingFailed("Failed to convert VAE output to CGImage")
334
+ }
335
+ if case let .imageToImage(sourceImage?, maskImage?, maskMode, _, _, _) = mode {
336
+ image = Self.compositePreservedSource(
337
+ generated: image,
338
+ source: sourceImage,
339
+ mask: maskImage,
340
+ maskMode: maskMode,
341
+ targetHeight: height,
342
+ targetWidth: width
343
+ ) ?? image
344
+ }
345
+
346
+ // Do not cache the decoder for now. This keeps repeated generations
347
+ // slower, but avoids carrying VAE weights after the image is written.
348
+ self.vae = nil
349
+ MLX.Memory.clearCache()
350
+ return image
351
+ }
352
+
353
+ private struct ImageConditioning {
354
+ let initialLatents: MLXArray
355
+ let referenceLatents: MLXArray?
356
+ let imageIds: MLXArray?
357
+ let outputSequenceLength: Int
358
+ let maskedBlend: MaskedBlend?
359
+ }
360
+
361
+ private struct MaskedBlend {
362
+ let sourceLatents: MLXArray
363
+ let maskLatents: MLXArray
364
+ }
365
+
366
+ private func encodeImageConditioning(
367
+ mode: FluxImageGenerationMode,
368
+ height: Int,
369
+ width: Int,
370
+ steps: Int,
371
+ sigmas: [Float]?
372
+ ) throws -> ImageConditioning {
373
+ let randomNoise = LatentUtils.packPatchifiedToSequence(
374
+ LatentUtils.generatePatchifiedLatents(height: height, width: width)
375
+ )
376
+ eval(randomNoise)
377
+
378
+ guard case let .imageToImage(sourceImage, maskImage, maskMode, referenceImages, referenceInfluences, strength) = mode else {
379
+ scheduler.setTimesteps(numInferenceSteps: steps, imageSeqLen: randomNoise.shape[1], strength: 1.0)
380
+ if let sigmas { scheduler.setCustomSigmas(sigmas) }
381
+ return ImageConditioning(
382
+ initialLatents: randomNoise,
383
+ referenceLatents: nil,
384
+ imageIds: nil,
385
+ outputSequenceLength: randomNoise.shape[1],
386
+ maskedBlend: nil
387
+ )
388
+ }
389
+
390
+ guard sourceImage != nil || !referenceImages.isEmpty else {
391
+ throw Flux2Error.invalidConfiguration("Image-to-image mode requires a source image or references")
392
+ }
393
+ guard referenceImages.count <= 3 else {
394
+ throw Flux2Error.invalidConfiguration("Provide at most 3 reference images")
395
+ }
396
+ if let referenceInfluences, referenceInfluences.count != referenceImages.count {
397
+ throw Flux2Error.invalidConfiguration("referenceInfluences count must match reference image count")
398
+ }
399
+ if maskImage != nil, sourceImage == nil {
400
+ throw Flux2Error.invalidConfiguration("Masked image generation requires a source image")
401
+ }
402
+
403
+ scheduler.setTimesteps(numInferenceSteps: steps, imageSeqLen: randomNoise.shape[1], strength: strength)
404
+ if let sigmas { scheduler.setCustomSigmas(sigmas) }
405
+ let vae = try loadVAE()
406
+ let sourcePacked: MLXArray?
407
+ if let sourceImage, strength < 1.0 || maskImage != nil {
408
+ let sourceProcessed = preprocessImageForVAE(sourceImage, targetHeight: height, targetWidth: width)
409
+ let sourceRaw = vae.encode(sourceProcessed, samplePosterior: false)
410
+ var sourcePatchified = LatentUtils.packLatentsToPatchified(sourceRaw)
411
+ sourcePatchified = LatentUtils.normalizeLatentsWithBatchNorm(
412
+ sourcePatchified,
413
+ runningMean: vae.batchNormRunningMean,
414
+ runningVar: vae.batchNormRunningVar
415
+ )
416
+ let packed = LatentUtils.packPatchifiedToSequence(sourcePatchified)
417
+ eval(packed)
418
+ sourcePacked = packed
419
+ } else {
420
+ sourcePacked = nil
421
+ }
422
+
423
+ let maskedBlend: MaskedBlend?
424
+ let maskLatents: MLXArray?
425
+ if let sourcePacked, let maskImage {
426
+ var packedMask = Self.packMaskForLatentBlending(maskImage, targetHeight: height, targetWidth: width)
427
+ if maskMode == .preserve {
428
+ packedMask = 1 - packedMask
429
+ }
430
+ eval(packedMask)
431
+ maskLatents = packedMask
432
+ maskedBlend = MaskedBlend(sourceLatents: sourcePacked, maskLatents: packedMask)
433
+ } else {
434
+ maskLatents = nil
435
+ maskedBlend = nil
436
+ }
437
+
438
+ let initialLatents: MLXArray
439
+ if let sourcePacked, let maskLatents {
440
+ let sourceNoised = scheduler.scaleNoise(
441
+ sample: sourcePacked,
442
+ sigma: scheduler.initialSigma,
443
+ noise: randomNoise
444
+ )
445
+ initialLatents = (1 - maskLatents) * sourceNoised + maskLatents * randomNoise
446
+ } else if let sourcePacked, strength < 1.0 {
447
+ initialLatents = scheduler.scaleNoise(
448
+ sample: sourcePacked,
449
+ sigma: scheduler.initialSigma,
450
+ noise: randomNoise
451
+ )
452
+ } else {
453
+ initialLatents = randomNoise
454
+ }
455
+ eval(initialLatents)
456
+
457
+ let encodedReferences = try encodeReferenceImages(referenceImages, influences: referenceInfluences, vae: vae)
458
+ self.vae = nil
459
+ MLX.Memory.clearCache()
460
+
461
+ let imageIds: MLXArray?
462
+ if let referencePositionIds = encodedReferences.positionIds {
463
+ let outputImageIds = LatentUtils.generateImagePositionIDs(height: height, width: width)
464
+ imageIds = concatenated([outputImageIds, referencePositionIds], axis: 0)
465
+ } else {
466
+ imageIds = nil
467
+ }
468
+
469
+ return ImageConditioning(
470
+ initialLatents: initialLatents,
471
+ referenceLatents: encodedReferences.latents,
472
+ imageIds: imageIds,
473
+ outputSequenceLength: randomNoise.shape[1],
474
+ maskedBlend: maskedBlend
475
+ )
476
+ }
477
+
478
+ private static func packMaskForLatentBlending(
479
+ _ mask: CGImage,
480
+ targetHeight: Int,
481
+ targetWidth: Int
482
+ ) -> MLXArray {
483
+ let latentHeight = targetHeight / 16
484
+ let latentWidth = targetWidth / 16
485
+ var pixels = [UInt8](repeating: 0, count: latentHeight * latentWidth)
486
+
487
+ guard let context = CGContext(
488
+ data: &pixels,
489
+ width: latentWidth,
490
+ height: latentHeight,
491
+ bitsPerComponent: 8,
492
+ bytesPerRow: latentWidth,
493
+ space: CGColorSpaceCreateDeviceGray(),
494
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
495
+ ) else {
496
+ return MLXArray.zeros([1, latentHeight * latentWidth, 1])
497
+ }
498
+
499
+ context.interpolationQuality = .high
500
+ context.draw(mask, in: CGRect(x: 0, y: 0, width: latentWidth, height: latentHeight))
501
+ let values = pixels.map { Float($0) / 255 }
502
+ return MLXArray(values).reshaped([1, latentHeight * latentWidth, 1])
503
+ }
504
+
505
+ private static func compositePreservedSource(
506
+ generated: CGImage,
507
+ source: CGImage,
508
+ mask: CGImage,
509
+ maskMode: FluxImageMaskMode,
510
+ targetHeight: Int,
511
+ targetWidth: Int
512
+ ) -> CGImage? {
513
+ let bytesPerPixel = 4
514
+ let bytesPerRow = bytesPerPixel * targetWidth
515
+ var generatedPixels = [UInt8](repeating: 0, count: targetHeight * bytesPerRow)
516
+ var sourcePixels = [UInt8](repeating: 0, count: targetHeight * bytesPerRow)
517
+ var maskPixels = [UInt8](repeating: 0, count: targetHeight * targetWidth)
518
+
519
+ guard let generatedContext = CGContext(
520
+ data: &generatedPixels,
521
+ width: targetWidth,
522
+ height: targetHeight,
523
+ bitsPerComponent: 8,
524
+ bytesPerRow: bytesPerRow,
525
+ space: CGColorSpaceCreateDeviceRGB(),
526
+ bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
527
+ ), let sourceContext = CGContext(
528
+ data: &sourcePixels,
529
+ width: targetWidth,
530
+ height: targetHeight,
531
+ bitsPerComponent: 8,
532
+ bytesPerRow: bytesPerRow,
533
+ space: CGColorSpaceCreateDeviceRGB(),
534
+ bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
535
+ ), let maskContext = CGContext(
536
+ data: &maskPixels,
537
+ width: targetWidth,
538
+ height: targetHeight,
539
+ bitsPerComponent: 8,
540
+ bytesPerRow: targetWidth,
541
+ space: CGColorSpaceCreateDeviceGray(),
542
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
543
+ ) else {
544
+ return nil
545
+ }
546
+
547
+ let targetRect = CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)
548
+ generatedContext.interpolationQuality = .high
549
+ generatedContext.draw(generated, in: targetRect)
550
+ sourceContext.interpolationQuality = .high
551
+ sourceContext.draw(source, in: targetRect)
552
+ maskContext.interpolationQuality = .high
553
+ maskContext.draw(mask, in: targetRect)
554
+
555
+ for index in 0..<(targetHeight * targetWidth) {
556
+ let maskValue = maskPixels[index]
557
+ let shouldPreserve = maskMode == .paint ? maskValue < 128 : maskValue >= 128
558
+ guard shouldPreserve else { continue }
559
+ let byteIndex = index * bytesPerPixel
560
+ generatedPixels[byteIndex] = sourcePixels[byteIndex]
561
+ generatedPixels[byteIndex + 1] = sourcePixels[byteIndex + 1]
562
+ generatedPixels[byteIndex + 2] = sourcePixels[byteIndex + 2]
563
+ }
564
+
565
+ return generatedContext.makeImage()
566
+ }
567
+
568
+ private func encodeReferenceImages(
569
+ _ images: [CGImage],
570
+ influences: [Float]?,
571
+ vae: AutoencoderKLFlux2
572
+ ) throws -> (latents: MLXArray?, positionIds: MLXArray?) {
573
+ guard !images.isEmpty else {
574
+ return (nil, nil)
575
+ }
576
+
577
+ var allLatents: [MLXArray] = []
578
+ var latentHeights: [Int] = []
579
+ var latentWidths: [Int] = []
580
+
581
+ for (index, image) in images.enumerated() {
582
+ let size = Self.resolveReferenceImageDimensions(image)
583
+ let processed = preprocessImageForVAE(image, targetHeight: size.height, targetWidth: size.width)
584
+ let raw = vae.encode(processed, samplePosterior: false)
585
+ var patchified = LatentUtils.packLatentsToPatchified(raw)
586
+ patchified = LatentUtils.normalizeLatentsWithBatchNorm(
587
+ patchified,
588
+ runningMean: vae.batchNormRunningMean,
589
+ runningVar: vae.batchNormRunningVar
590
+ )
591
+ eval(patchified)
592
+
593
+ var packed = LatentUtils.packPatchifiedToSequence(patchified).squeezed(axis: 0)
594
+ if let influence = influences?[index] {
595
+ packed = packed * influence
596
+ eval(packed)
597
+ }
598
+ allLatents.append(packed)
599
+ latentHeights.append(size.height / 16)
600
+ latentWidths.append(size.width / 16)
601
+ }
602
+
603
+ let latents = concatenated(allLatents, axis: 0).expandedDimensions(axis: 0)
604
+ let positionIds = LatentUtils.generateReferenceImagePositionIDs(
605
+ latentHeights: latentHeights,
606
+ latentWidths: latentWidths,
607
+ scale: 10
608
+ )
609
+ return (latents, positionIds)
610
+ }
611
+
612
+ private static func resolveReferenceImageDimensions(_ image: CGImage) -> (height: Int, width: Int) {
613
+ let factor = 16
614
+ let longSide = max(image.height, image.width)
615
+ let scale = min(1.0, CGFloat(referenceImageMaxLongSide) / CGFloat(longSide))
616
+
617
+ func alignedDown(_ value: CGFloat) -> Int {
618
+ max(factor, Int(value) / factor * factor)
619
+ }
620
+
621
+ return LatentUtils.validateDimensions(
622
+ height: alignedDown(CGFloat(image.height) * scale),
623
+ width: alignedDown(CGFloat(image.width) * scale)
624
+ )
625
+ }
626
+
627
+ private func preprocessImageForVAE(_ image: CGImage, targetHeight: Int, targetWidth: Int) -> MLXArray {
628
+ let bytesPerPixel = 4
629
+ let bytesPerRow = bytesPerPixel * targetWidth
630
+ var pixelData = [UInt8](repeating: 0, count: targetHeight * bytesPerRow)
631
+
632
+ guard let context = CGContext(
633
+ data: &pixelData,
634
+ width: targetWidth,
635
+ height: targetHeight,
636
+ bitsPerComponent: 8,
637
+ bytesPerRow: bytesPerRow,
638
+ space: CGColorSpaceCreateDeviceRGB(),
639
+ bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
640
+ ) else {
641
+ return MLXRandom.normal([1, 3, targetHeight, targetWidth])
642
+ }
643
+
644
+ context.interpolationQuality = .high
645
+ context.draw(image, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
646
+
647
+ var floatData = [Float](repeating: 0, count: targetHeight * targetWidth * 3)
648
+ for y in 0..<targetHeight {
649
+ for x in 0..<targetWidth {
650
+ let pixelIndex = y * targetWidth + x
651
+ let byteIndex = y * bytesPerRow + x * bytesPerPixel
652
+ floatData[pixelIndex] = Float(pixelData[byteIndex]) / 127.5 - 1.0
653
+ floatData[targetHeight * targetWidth + pixelIndex] = Float(pixelData[byteIndex + 1]) / 127.5 - 1.0
654
+ floatData[2 * targetHeight * targetWidth + pixelIndex] = Float(pixelData[byteIndex + 2]) / 127.5 - 1.0
655
+ }
656
+ }
657
+
658
+ return MLXArray(floatData).reshaped([1, 3, targetHeight, targetWidth])
659
+ }
660
+
661
+ private func validateTextEmbeddings(_ textEmbeddings: MLXArray) throws {
662
+ guard textEmbeddings.shape.count == 3 else {
663
+ throw Flux2Error.invalidConfiguration("Expected text embeddings rank 3, got shape \(textEmbeddings.shape)")
664
+ }
665
+
666
+ guard textEmbeddings.shape[0] == 1 else {
667
+ throw Flux2Error.invalidConfiguration("Only batch size 1 is supported for image generation")
668
+ }
669
+
670
+ guard textEmbeddings.shape[2] == Flux2Model.klein4B.jointAttentionDim else {
671
+ throw Flux2Error.invalidConfiguration(
672
+ "Expected Klein 4B text embedding width \(Flux2Model.klein4B.jointAttentionDim), got \(textEmbeddings.shape[2])"
673
+ )
674
+ }
675
+ }
676
+
677
+ private func applyLoRAs(_ loras: [FluxLoRAConfig], to model: Flux2Transformer2DModel) throws {
678
+ guard !loras.isEmpty else { return }
679
+ var linearModules: [String: TransformerLinearTarget] = [:]
680
+ for (path, module) in model.leafModules().flattened() {
681
+ if let quantizedLinear = module as? QuantizedLinear {
682
+ guard let dims = Self.quantizedLinearDims(quantizedLinear) else { continue }
683
+ linearModules[path] = TransformerLinearTarget(module: quantizedLinear, inputFeatures: dims.in, outputFeatures: dims.out)
684
+ } else if let linear = module as? Linear {
685
+ guard let dims = Self.linearDims(weightShape: linear.weight.shape) else { continue }
686
+ linearModules[path] = TransformerLinearTarget(module: linear, inputFeatures: dims.in, outputFeatures: dims.out)
687
+ }
688
+ }
689
+
690
+ var contributionsByTarget: [String: [LoRAContribution]] = [:]
691
+ var loadedLayerCount = 0
692
+ var matchedLayers = Set<String>()
693
+ var dimensionMismatchCount = 0
694
+ var sampleDimensionMismatches: [LoRADimensionMismatch] = []
695
+ var sampleUnmatchedLoRALayers: [String] = []
696
+ var fileReports: [LoRAFileApplyReport] = []
697
+ for lora in loras {
698
+ let name = lora.id ?? URL(fileURLWithPath: lora.path).deletingPathExtension().lastPathComponent
699
+ let tensors = try loadArrays(url: URL(fileURLWithPath: lora.path))
700
+ let groups = Self.groupLoRATensors(tensors)
701
+ let diagnostics = Self.loraDiagnostics(filePath: lora.path, tensors: tensors, groups: groups)
702
+ Self.logLoRADiagnostics(diagnostics)
703
+ loadedLayerCount += groups.count
704
+ var fileMatchedLayers = Set<String>()
705
+ var fileAppliedLayers = Set<String>()
706
+ var fileMismatchLayers = Set<String>()
707
+ var fileMismatchSamples: [LoRADimensionMismatch] = []
708
+ var fileUnmatchedLayers: [String] = []
709
+ var applied = 0
710
+ for (target, group) in groups {
711
+ guard let down = group.down, let up = group.up else { continue }
712
+ guard let linear = linearModules[target] else {
713
+ if fileUnmatchedLayers.count < 8 { fileUnmatchedLayers.append(target) }
714
+ if sampleUnmatchedLoRALayers.count < 8 { sampleUnmatchedLoRALayers.append(target) }
715
+ continue
716
+ }
717
+ fileMatchedLayers.insert(target)
718
+ matchedLayers.insert(target)
719
+ guard let normalized = Self.normalizedLoRAPair(
720
+ down: down,
721
+ up: up,
722
+ inputFeatures: linear.inputFeatures,
723
+ outputFeatures: linear.outputFeatures
724
+ ) else {
725
+ let mismatch = LoRADimensionMismatch(
726
+ transformerLayer: target,
727
+ loraLayer: target,
728
+ loraName: name,
729
+ expectedInFeatures: linear.inputFeatures,
730
+ expectedOutFeatures: linear.outputFeatures,
731
+ downShape: down.shape,
732
+ upShape: up.shape
733
+ )
734
+ dimensionMismatchCount += 1
735
+ fileMismatchLayers.insert(target)
736
+ if sampleDimensionMismatches.count < 8 { sampleDimensionMismatches.append(mismatch) }
737
+ if fileMismatchSamples.count < 4 { fileMismatchSamples.append(mismatch) }
738
+ continue
739
+ }
740
+ let rank = max(1, down.shape.first ?? 1)
741
+ let alphaScale = group.alpha.map { Float($0.item(Float.self)) / Float(rank) } ?? 1
742
+ contributionsByTarget[target, default: []].append(LoRAContribution(
743
+ down: normalized.down,
744
+ up: normalized.up,
745
+ scale: lora.scale * alphaScale
746
+ ))
747
+ fileAppliedLayers.insert(target)
748
+ applied += 1
749
+ }
750
+ Flux2Debug.log("Loaded LoRA \"\(name)\": layers=\(diagnostics.layerCount), rank=\(diagnostics.rank), target=\(diagnostics.targetModel)")
751
+ fileReports.append(LoRAFileApplyReport(
752
+ name: name,
753
+ loadedLayerCount: groups.count,
754
+ matchedLayerCount: fileMatchedLayers.count,
755
+ appliedLayerCount: applied,
756
+ dimensionMismatchCount: fileMismatchLayers.count,
757
+ sampleMatchedLayers: Array(fileMatchedLayers.sorted().prefix(8)),
758
+ sampleUnmatchedLayers: fileUnmatchedLayers,
759
+ sampleDimensionMismatches: fileMismatchSamples
760
+ ))
761
+ }
762
+
763
+ var moduleUpdates: [String: Module] = [:]
764
+ for target in contributionsByTarget.keys.sorted() {
765
+ guard let contributions = contributionsByTarget[target] else { continue }
766
+ guard let linear = linearModules[target], !contributions.isEmpty else { continue }
767
+ var combinedDown: MLXArray?
768
+ var combinedUp: MLXArray?
769
+ for contribution in contributions {
770
+ let scaledDown = contribution.down * contribution.scale
771
+ if let existingDown = combinedDown, let existingUp = combinedUp {
772
+ combinedDown = concatenated([existingDown, scaledDown], axis: 0)
773
+ combinedUp = concatenated([existingUp, contribution.up], axis: 1)
774
+ } else {
775
+ combinedDown = scaledDown
776
+ combinedUp = contribution.up
777
+ }
778
+ }
779
+ guard let combinedDown, let combinedUp else { continue }
780
+ let materializedDown = combinedDown.asType(.float16)
781
+ let materializedUp = combinedUp.asType(.float16)
782
+ eval(materializedDown, materializedUp)
783
+ if let quantized = linear.module as? QuantizedLinear {
784
+ moduleUpdates[target] = LoRAQuantizedLinear(
785
+ base: quantized,
786
+ down: materializedDown,
787
+ up: materializedUp,
788
+ scale: 1,
789
+ debugName: target
790
+ )
791
+ } else if let dense = linear.module as? Linear {
792
+ moduleUpdates[target] = LoRALinear(
793
+ base: dense,
794
+ down: materializedDown,
795
+ up: materializedUp,
796
+ scale: 1,
797
+ debugName: target
798
+ )
799
+ }
800
+ }
801
+ contributionsByTarget.removeAll()
802
+ MLX.Memory.clearCache()
803
+
804
+ let report = LoRAApplyReport(
805
+ loadedLayerCount: loadedLayerCount,
806
+ transformerLinearLayerCount: linearModules.count,
807
+ matchedLayerCount: matchedLayers.count,
808
+ replacedLayerCount: moduleUpdates.count,
809
+ updatedLayerCount: 0,
810
+ dimensionMismatchCount: dimensionMismatchCount,
811
+ sampleDimensionMismatches: sampleDimensionMismatches,
812
+ sampleUnmatchedLoRALayers: sampleUnmatchedLoRALayers,
813
+ sampleTransformerLayers: Array(linearModules.keys.sorted().prefix(8)),
814
+ loraReports: fileReports
815
+ )
816
+ Self.logLoRAApplyReport(report, queuedCount: loras.count)
817
+
818
+ guard !moduleUpdates.isEmpty else {
819
+ let sample = report.sampleDimensionMismatches.first?.logDescription
820
+ ?? report.sampleUnmatchedLoRALayers.first
821
+ ?? "none"
822
+ throw Flux2Error.invalidConfiguration(
823
+ "No compatible LoRA targets found (loaded=\(report.loadedLayerCount), matched=\(report.matchedLayerCount), dimensionMismatches=\(report.dimensionMismatchCount), sample=\(sample))"
824
+ )
825
+ }
826
+ model.update(modules: ModuleChildren.unflattened(moduleUpdates))
827
+ eval(model.parameters())
828
+ MLX.Memory.clearCache()
829
+ }
830
+
831
+ private struct LoRATensorGroup {
832
+ var down: MLXArray?
833
+ var up: MLXArray?
834
+ var alpha: MLXArray?
835
+ }
836
+
837
+ private static func linearDims(weightShape: [Int]) -> (out: Int, in: Int)? {
838
+ guard weightShape.count >= 2 else { return nil }
839
+ return (out: weightShape[weightShape.count - 2], in: weightShape[weightShape.count - 1])
840
+ }
841
+
842
+ private static func quantizedLinearDims(_ linear: QuantizedLinear) -> (out: Int, in: Int)? {
843
+ let weightShape = linear.weight.shape
844
+ guard weightShape.count >= 2, linear.bits > 0, 32 % linear.bits == 0 else { return nil }
845
+ let out = weightShape[weightShape.count - 2]
846
+ let packedIn = weightShape[weightShape.count - 1]
847
+ return (out: out, in: packedIn * (32 / linear.bits))
848
+ }
849
+
850
+ private static func normalizedLoRAPair(
851
+ down: MLXArray,
852
+ up: MLXArray,
853
+ inputFeatures: Int,
854
+ outputFeatures: Int
855
+ ) -> (down: MLXArray, up: MLXArray)? {
856
+ guard down.shape.count == 2, up.shape.count == 2 else { return nil }
857
+ if down.shape[1] == inputFeatures,
858
+ up.shape[0] == outputFeatures,
859
+ down.shape[0] == up.shape[1] {
860
+ return (down, up)
861
+ }
862
+ if down.shape[0] == inputFeatures,
863
+ up.shape[1] == outputFeatures,
864
+ down.shape[1] == up.shape[0] {
865
+ return (down.transposed(1, 0), up.transposed(1, 0))
866
+ }
867
+ return nil
868
+ }
869
+
870
+ private static func loraDiagnostics(
871
+ filePath: String,
872
+ tensors: [String: MLXArray],
873
+ groups: [String: LoRATensorGroup]
874
+ ) -> LoRADiagnostics {
875
+ var sampleTensors: [LoRATensorDiagnostic] = []
876
+ var nonFiniteTensorCount = 0
877
+ var nonFiniteValueCount = 0
878
+ let visibleKeys = tensors.keys.filter { !$0.hasPrefix("__") }.sorted()
879
+ for key in visibleKeys.prefix(3) {
880
+ guard let value = tensors[key] else { continue }
881
+ let diagnostic = tensorDiagnostic(key: key, value: value)
882
+ if diagnostic.nonFiniteCount > 0 {
883
+ nonFiniteTensorCount += 1
884
+ nonFiniteValueCount += diagnostic.nonFiniteCount
885
+ }
886
+ sampleTensors.append(diagnostic)
887
+ }
888
+
889
+ return LoRADiagnostics(
890
+ fileName: URL(fileURLWithPath: filePath).lastPathComponent,
891
+ tensorCount: visibleKeys.count,
892
+ layerCount: groups.count,
893
+ rank: groups.values.compactMap { $0.down?.shape.first }.first ?? 0,
894
+ targetModel: detectLoRATargetModel(Array(groups.keys)),
895
+ sampleTensors: sampleTensors,
896
+ nonFiniteTensorCount: nonFiniteTensorCount,
897
+ nonFiniteValueCount: nonFiniteValueCount
898
+ )
899
+ }
900
+
901
+ private static func tensorDiagnostic(key: String, value: MLXArray) -> LoRATensorDiagnostic {
902
+ let checked = value.asType(.float32)
903
+ eval(checked)
904
+ let values = checked.asArray(Float.self)
905
+ var minValue = Float.greatestFiniteMagnitude
906
+ var maxValue = -Float.greatestFiniteMagnitude
907
+ var sum: Float = 0
908
+ var finiteCount = 0
909
+ var nonFiniteCount = 0
910
+
911
+ for value in values {
912
+ guard value.isFinite else {
913
+ nonFiniteCount += 1
914
+ continue
915
+ }
916
+ minValue = min(minValue, value)
917
+ maxValue = max(maxValue, value)
918
+ sum += value
919
+ finiteCount += 1
920
+ }
921
+
922
+ if finiteCount == 0 {
923
+ minValue = 0
924
+ maxValue = 0
925
+ }
926
+
927
+ return LoRATensorDiagnostic(
928
+ key: key,
929
+ shape: value.shape,
930
+ min: minValue,
931
+ max: maxValue,
932
+ mean: finiteCount > 0 ? sum / Float(finiteCount) : 0,
933
+ nonFiniteCount: nonFiniteCount
934
+ )
935
+ }
936
+
937
+ private static func detectLoRATargetModel(_ layers: [String]) -> String {
938
+ var doubleBlocks = Set<Int>()
939
+ var singleBlocks = Set<Int>()
940
+ for path in layers {
941
+ if path.hasPrefix("transformerBlocks.") {
942
+ doubleBlocks.insert(extractBlockIndex(from: path, prefix: "transformerBlocks."))
943
+ } else if path.hasPrefix("singleTransformerBlocks.") {
944
+ singleBlocks.insert(extractBlockIndex(from: path, prefix: "singleTransformerBlocks."))
945
+ }
946
+ }
947
+
948
+ let maxDouble = doubleBlocks.max() ?? 0
949
+ let maxSingle = singleBlocks.max() ?? 0
950
+ if maxDouble == 4 && maxSingle == 19 { return "klein-4b" }
951
+ if maxDouble == 7 && maxSingle == 23 { return "klein-9b" }
952
+ if maxDouble == 7 && maxSingle == 47 { return "dev" }
953
+ return "unknown"
954
+ }
955
+
956
+ private static func extractBlockIndex(from path: String, prefix: String) -> Int {
957
+ guard let range = path.range(of: prefix) else { return 0 }
958
+ let afterPrefix = path[range.upperBound...]
959
+ let index = afterPrefix.prefix(while: { $0.isNumber })
960
+ return Int(index) ?? 0
961
+ }
962
+
963
+ private static func logLoRADiagnostics(_ diagnostics: LoRADiagnostics) {
964
+ Flux2Debug.log(
965
+ "LoRA diagnostics file=\(diagnostics.fileName) tensors=\(diagnostics.tensorCount) layers=\(diagnostics.layerCount) rank=\(diagnostics.rank) target=\(diagnostics.targetModel) nonFiniteTensors=\(diagnostics.nonFiniteTensorCount) nonFiniteValues=\(diagnostics.nonFiniteValueCount)"
966
+ )
967
+ for tensor in diagnostics.sampleTensors {
968
+ Flux2Debug.log(
969
+ "LoRA tensor sample key=\(tensor.key) shape=\(tensor.shape) min=\(tensor.min) max=\(tensor.max) mean=\(tensor.mean) nonFinite=\(tensor.nonFiniteCount)"
970
+ )
971
+ }
972
+ if diagnostics.nonFiniteValueCount > 0 {
973
+ Flux2Debug.warning("LoRA contains non-finite values: file=\(diagnostics.fileName), values=\(diagnostics.nonFiniteValueCount)")
974
+ }
975
+ }
976
+
977
+ private static func logLoRAApplyReport(_ report: LoRAApplyReport, queuedCount: Int) {
978
+ Flux2Debug.log(
979
+ "Applied \(queuedCount) queued LoRA files dynamically to transformer: loadedLayers=\(report.loadedLayerCount), transformerLinearLayers=\(report.transformerLinearLayerCount), matchedLayers=\(report.matchedLayerCount), appliedLayers=\(report.appliedLayerCount), dimensionMismatches=\(report.dimensionMismatchCount)"
980
+ )
981
+ for loraReport in report.loraReports {
982
+ if loraReport.appliedLayerCount > 0 {
983
+ Flux2Debug.log(
984
+ "LoRA \"\(loraReport.name)\" used: loaded=\(loraReport.loadedLayerCount), matched=\(loraReport.matchedLayerCount), applied=\(loraReport.appliedLayerCount), mismatches=\(loraReport.dimensionMismatchCount)"
985
+ )
986
+ } else {
987
+ Flux2Debug.warning(
988
+ "LoRA \"\(loraReport.name)\" loaded but not used: loaded=\(loraReport.loadedLayerCount), matched=\(loraReport.matchedLayerCount), applied=0, mismatches=\(loraReport.dimensionMismatchCount)"
989
+ )
990
+ }
991
+
992
+ if loraReport.matchedLayerCount < loraReport.loadedLayerCount, !loraReport.sampleUnmatchedLayers.isEmpty {
993
+ Flux2Debug.warning("LoRA \"\(loraReport.name)\" sample unmatched layers: \(loraReport.sampleUnmatchedLayers.joined(separator: ", "))")
994
+ }
995
+ for mismatch in loraReport.sampleDimensionMismatches {
996
+ Flux2Debug.warning("LoRA \"\(loraReport.name)\" dimension mismatch: \(mismatch.logDescription)")
997
+ }
998
+ }
999
+
1000
+ if report.appliedLayerCount == 0 {
1001
+ for mismatch in report.sampleDimensionMismatches {
1002
+ Flux2Debug.warning("Flux LoRA dimension mismatch: \(mismatch.logDescription)")
1003
+ }
1004
+ Flux2Debug.warning("No Flux LoRA layers were applied. Sample unmatched LoRA layers: \(report.sampleUnmatchedLoRALayers.joined(separator: ", "))")
1005
+ Flux2Debug.warning("Sample transformer linear layers: \(report.sampleTransformerLayers.joined(separator: ", "))")
1006
+ }
1007
+ }
1008
+
1009
+ private static func groupLoRATensors(_ tensors: [String: MLXArray]) -> [String: LoRATensorGroup] {
1010
+ var rawGroups: [String: LoRATensorGroup] = [:]
1011
+ for (rawKey, value) in tensors {
1012
+ guard let parsed = parseLoRAKey(rawKey) else { continue }
1013
+ var group = rawGroups[parsed.target] ?? LoRATensorGroup()
1014
+ switch parsed.kind {
1015
+ case "down": group.down = value
1016
+ case "up": group.up = value
1017
+ case "alpha": group.alpha = value
1018
+ default: break
1019
+ }
1020
+ rawGroups[parsed.target] = group
1021
+ }
1022
+
1023
+ var groups: [String: LoRATensorGroup] = [:]
1024
+ for (rawTarget, group) in rawGroups {
1025
+ if isCombinedQKVLayer(rawTarget),
1026
+ let up = group.up,
1027
+ up.shape.count == 2,
1028
+ up.shape[0].isMultiple(of: 3) {
1029
+ let outputSize = up.shape[0] / 3
1030
+ let targets = qkvTargets(for: rawTarget)
1031
+ guard targets.count == 3 else { continue }
1032
+ for (index, target) in targets.enumerated() {
1033
+ groups[target] = LoRATensorGroup(
1034
+ down: group.down,
1035
+ up: up[(index * outputSize)..<((index + 1) * outputSize), 0...],
1036
+ alpha: group.alpha
1037
+ )
1038
+ }
1039
+ } else if let target = mapLoRATarget(rawTarget) {
1040
+ groups[target] = group
1041
+ }
1042
+ }
1043
+ return groups
1044
+ }
1045
+
1046
+ private static func parseLoRAKey(_ rawKey: String) -> (target: String, kind: String)? {
1047
+ var key = rawKey
1048
+ for prefix in ["transformer.", "diffusion_model.", "model.diffusion_model.", "base_model.model."] {
1049
+ if key.hasPrefix(prefix) { key.removeFirst(prefix.count) }
1050
+ }
1051
+
1052
+ let patterns: [(String, String)] = [
1053
+ (".lora_down.weight", "down"),
1054
+ (".lora_A.weight", "down"),
1055
+ (".lora.up.weight", "up"),
1056
+ (".lora_up.weight", "up"),
1057
+ (".lora_B.weight", "up"),
1058
+ (".alpha", "alpha"),
1059
+ ]
1060
+ for (suffix, kind) in patterns where key.hasSuffix(suffix) {
1061
+ let base = normalizeLoRATarget(String(key.dropLast(suffix.count)))
1062
+ return (base, kind)
1063
+ }
1064
+ return nil
1065
+ }
1066
+
1067
+ private static func normalizeLoRATarget(_ rawTarget: String) -> String {
1068
+ var target = rawTarget
1069
+ for prefix in ["model.diffusion_model.", "base_model.model.", "diffusion_model.", "transformer."] {
1070
+ if target.hasPrefix(prefix) {
1071
+ target.removeFirst(prefix.count)
1072
+ break
1073
+ }
1074
+ }
1075
+
1076
+ let doublePrefix = "lora_unet_double_blocks_"
1077
+ let singlePrefix = "lora_unet_single_blocks_"
1078
+ if target.hasPrefix(singlePrefix) {
1079
+ let remainder = String(target.dropFirst(singlePrefix.count))
1080
+ let parts = remainder.split(separator: "_", maxSplits: 1).map(String.init)
1081
+ guard parts.count == 2, Int(parts[0]) != nil else { return target }
1082
+ switch parts[1] {
1083
+ case "linear1": return "single_blocks.\(parts[0]).linear1"
1084
+ case "linear2": return "single_blocks.\(parts[0]).linear2"
1085
+ default: return target
1086
+ }
1087
+ }
1088
+ if target.hasPrefix(doublePrefix) {
1089
+ let remainder = String(target.dropFirst(doublePrefix.count))
1090
+ let parts = remainder.split(separator: "_", maxSplits: 1).map(String.init)
1091
+ guard parts.count == 2, Int(parts[0]) != nil else { return target }
1092
+ let suffix: String
1093
+ switch parts[1] {
1094
+ case "img_attn_qkv": suffix = "img_attn.qkv"
1095
+ case "txt_attn_qkv": suffix = "txt_attn.qkv"
1096
+ case "img_attn_proj": suffix = "img_attn.proj"
1097
+ case "txt_attn_proj": suffix = "txt_attn.proj"
1098
+ case "img_mlp_0": suffix = "img_mlp.0"
1099
+ case "img_mlp_2": suffix = "img_mlp.2"
1100
+ case "txt_mlp_0": suffix = "txt_mlp.0"
1101
+ case "txt_mlp_2": suffix = "txt_mlp.2"
1102
+ default: return target
1103
+ }
1104
+ return "double_blocks.\(parts[0]).\(suffix)"
1105
+ }
1106
+ return target
1107
+ }
1108
+
1109
+ private static func isCombinedQKVLayer(_ target: String) -> Bool {
1110
+ target.contains(".img_attn.qkv") || target.contains(".txt_attn.qkv")
1111
+ }
1112
+
1113
+ private static func qkvTargets(for target: String) -> [String] {
1114
+ let index = extractBlockIndex(from: target, prefix: "double_blocks.")
1115
+ if target.contains(".img_attn.qkv") {
1116
+ return [
1117
+ "transformerBlocks.\(index).attn.toQ",
1118
+ "transformerBlocks.\(index).attn.toK",
1119
+ "transformerBlocks.\(index).attn.toV",
1120
+ ]
1121
+ }
1122
+ if target.contains(".txt_attn.qkv") {
1123
+ return [
1124
+ "transformerBlocks.\(index).attn.addQProj",
1125
+ "transformerBlocks.\(index).attn.addKProj",
1126
+ "transformerBlocks.\(index).attn.addVProj",
1127
+ ]
1128
+ }
1129
+ return []
1130
+ }
1131
+
1132
+ private static func mapLoRATarget(_ key: String) -> String? {
1133
+ let direct = key
1134
+ .replacingOccurrences(of: ".processor", with: "")
1135
+ .replacingOccurrences(of: ".to_q", with: ".toQ")
1136
+ .replacingOccurrences(of: ".to_k", with: ".toK")
1137
+ .replacingOccurrences(of: ".to_v", with: ".toV")
1138
+ .replacingOccurrences(of: ".to_out.0", with: ".toOut")
1139
+ .replacingOccurrences(of: ".add_q_proj", with: ".addQProj")
1140
+ .replacingOccurrences(of: ".add_k_proj", with: ".addKProj")
1141
+ .replacingOccurrences(of: ".add_v_proj", with: ".addVProj")
1142
+ .replacingOccurrences(of: ".to_add_out", with: ".toAddOut")
1143
+ if direct.hasPrefix("transformerBlocks.") || direct.hasPrefix("singleTransformerBlocks.") || direct == "xEmbedder" || direct == "contextEmbedder" || direct == "projOut" {
1144
+ return direct
1145
+ }
1146
+
1147
+ if key.hasPrefix("double_blocks.") {
1148
+ let index = extractBlockIndex(from: key, prefix: "double_blocks.")
1149
+ if key.contains(".img_attn.proj") { return "transformerBlocks.\(index).attn.toOut" }
1150
+ if key.contains(".txt_attn.proj") { return "transformerBlocks.\(index).attn.toAddOut" }
1151
+ if key.contains(".img_mlp.0") { return "transformerBlocks.\(index).ff.activation.proj" }
1152
+ if key.contains(".img_mlp.2") { return "transformerBlocks.\(index).ff.linearOut" }
1153
+ if key.contains(".txt_mlp.0") { return "transformerBlocks.\(index).ffContext.activation.proj" }
1154
+ if key.contains(".txt_mlp.2") { return "transformerBlocks.\(index).ffContext.linearOut" }
1155
+ }
1156
+
1157
+ if key.hasPrefix("single_blocks.") {
1158
+ let index = extractBlockIndex(from: key, prefix: "single_blocks.")
1159
+ if key.contains(".linear1") { return "singleTransformerBlocks.\(index).attn.toQkvMlp" }
1160
+ if key.contains(".linear2") { return "singleTransformerBlocks.\(index).attn.toOut" }
1161
+ }
1162
+
1163
+ if key.hasPrefix("double_blocks.") {
1164
+ let parts = key.split(separator: ".")
1165
+ guard parts.count >= 3, let index = Int(parts[1]) else { return nil }
1166
+ let suffix = parts.dropFirst(2).joined(separator: ".")
1167
+ switch suffix {
1168
+ case "img_to_q": return "transformerBlocks.\(index).attn.toQ"
1169
+ case "img_to_k": return "transformerBlocks.\(index).attn.toK"
1170
+ case "img_to_v": return "transformerBlocks.\(index).attn.toV"
1171
+ case "img_to_out": return "transformerBlocks.\(index).attn.toOut"
1172
+ case "txt_to_q": return "transformerBlocks.\(index).attn.addQProj"
1173
+ case "txt_to_k": return "transformerBlocks.\(index).attn.addKProj"
1174
+ case "txt_to_v": return "transformerBlocks.\(index).attn.addVProj"
1175
+ case "txt_to_out": return "transformerBlocks.\(index).attn.toAddOut"
1176
+ case "img_mlp_in": return "transformerBlocks.\(index).ff.activation.proj"
1177
+ case "img_mlp_out": return "transformerBlocks.\(index).ff.linearOut"
1178
+ case "txt_mlp_in": return "transformerBlocks.\(index).ffContext.activation.proj"
1179
+ case "txt_mlp_out": return "transformerBlocks.\(index).ffContext.linearOut"
1180
+ default: return nil
1181
+ }
1182
+ }
1183
+
1184
+ if key.hasPrefix("single_blocks.") {
1185
+ let parts = key.split(separator: ".")
1186
+ guard parts.count >= 3, let index = Int(parts[1]) else { return nil }
1187
+ let suffix = parts.dropFirst(2).joined(separator: ".")
1188
+ switch suffix {
1189
+ case "to_qkv_mlp", "linear1": return "singleTransformerBlocks.\(index).attn.toQkvMlp"
1190
+ case "to_out", "linear2": return "singleTransformerBlocks.\(index).attn.toOut"
1191
+ default: return nil
1192
+ }
1193
+ }
1194
+
1195
+ if key.hasPrefix("transformer_blocks.") {
1196
+ let parts = key.split(separator: ".")
1197
+ guard parts.count >= 4, let index = Int(parts[1]) else { return nil }
1198
+ let suffix = parts.dropFirst(2).joined(separator: ".")
1199
+ switch suffix {
1200
+ case "attn.to_q": return "transformerBlocks.\(index).attn.toQ"
1201
+ case "attn.to_k": return "transformerBlocks.\(index).attn.toK"
1202
+ case "attn.to_v": return "transformerBlocks.\(index).attn.toV"
1203
+ case "attn.to_out.0", "attn.to_out": return "transformerBlocks.\(index).attn.toOut"
1204
+ case "attn.add_q_proj": return "transformerBlocks.\(index).attn.addQProj"
1205
+ case "attn.add_k_proj": return "transformerBlocks.\(index).attn.addKProj"
1206
+ case "attn.add_v_proj": return "transformerBlocks.\(index).attn.addVProj"
1207
+ case "attn.to_add_out": return "transformerBlocks.\(index).attn.toAddOut"
1208
+ case "ff.net.0.proj", "ff.proj": return "transformerBlocks.\(index).ff.activation.proj"
1209
+ case "ff.net.2", "ff.linear": return "transformerBlocks.\(index).ff.linearOut"
1210
+ case "ff_context.net.0.proj", "ff_context.proj": return "transformerBlocks.\(index).ffContext.activation.proj"
1211
+ case "ff_context.net.2", "ff_context.linear": return "transformerBlocks.\(index).ffContext.linearOut"
1212
+ default: return nil
1213
+ }
1214
+ }
1215
+
1216
+ if key.hasPrefix("single_transformer_blocks.") {
1217
+ let parts = key.split(separator: ".")
1218
+ guard parts.count >= 4, let index = Int(parts[1]) else { return nil }
1219
+ let suffix = parts.dropFirst(2).joined(separator: ".")
1220
+ switch suffix {
1221
+ case "proj_mlp", "attn.to_qkv_mlp", "linear1": return "singleTransformerBlocks.\(index).attn.toQkvMlp"
1222
+ case "proj_out", "attn.to_out", "linear2": return "singleTransformerBlocks.\(index).attn.toOut"
1223
+ default: return nil
1224
+ }
1225
+ }
1226
+
1227
+ switch key {
1228
+ case "img_in", "x_embedder": return "xEmbedder"
1229
+ case "txt_in", "context_embedder": return "contextEmbedder"
1230
+ case "final_layer.linear", "proj_out": return "projOut"
1231
+ default: return nil
1232
+ }
1233
+ }
1234
+
1235
+ private func loadTransformer() throws -> Flux2Transformer2DModel {
1236
+ if let transformer {
1237
+ return transformer
1238
+ }
1239
+
1240
+ let profile = ModelRegistry.TransformerRepo.moxinOrgKlein4B8bit.profile
1241
+ let configURL = paths.transformer.appendingPathComponent("config.json")
1242
+ let config = FileManager.default.fileExists(atPath: configURL.path)
1243
+ ? (try? Flux2TransformerConfig.load(from: configURL)) ?? .klein4B
1244
+ : .klein4B
1245
+
1246
+ let loaded = Flux2Transformer2DModel(
1247
+ config: config,
1248
+ memoryOptimization: .moderate,
1249
+ usesNormOutBias: profile.usesNormOutBias
1250
+ )
1251
+ transformer = loaded
1252
+
1253
+ var weights = try Flux2WeightLoader.loadWeights(from: paths.transformer)
1254
+ try Flux2WeightLoader.applyTransformerWeights(&weights, to: loaded, profile: profile)
1255
+ weights.removeAll()
1256
+ eval(loaded.parameters())
1257
+ MLX.Memory.clearCache()
1258
+
1259
+ return loaded
1260
+ }
1261
+
1262
+ private func loadVAE() throws -> AutoencoderKLFlux2 {
1263
+ if let vae {
1264
+ return vae
1265
+ }
1266
+
1267
+ let configURL = paths.vae.appendingPathComponent("config.json")
1268
+ let config = FileManager.default.fileExists(atPath: configURL.path)
1269
+ ? try VAEConfig.load(from: configURL)
1270
+ : .flux2Dev
1271
+
1272
+ let loaded = AutoencoderKLFlux2(config: config)
1273
+ vae = loaded
1274
+
1275
+ let weights = try Flux2WeightLoader.loadWeights(from: paths.vae)
1276
+ try Flux2WeightLoader.applyVAEWeights(weights, to: loaded)
1277
+ eval(loaded.parameters())
1278
+ MLX.Memory.clearCache()
1279
+
1280
+ return loaded
1281
+ }
1282
+ }