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,88 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Scheme
3
+ LastUpgradeVersion = "1130"
4
+ version = "1.3">
5
+ <BuildAction
6
+ parallelizeBuildables = "YES"
7
+ buildImplicitDependencies = "YES">
8
+ <BuildActionEntries>
9
+ <BuildActionEntry
10
+ buildForTesting = "YES"
11
+ buildForRunning = "YES"
12
+ buildForProfiling = "YES"
13
+ buildForArchiving = "YES"
14
+ buildForAnalyzing = "YES">
15
+ <BuildableReference
16
+ BuildableIdentifier = "primary"
17
+ BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
18
+ BuildableName = "bleam.app"
19
+ BlueprintName = "bleam"
20
+ ReferencedContainer = "container:bleam.xcodeproj">
21
+ </BuildableReference>
22
+ </BuildActionEntry>
23
+ </BuildActionEntries>
24
+ </BuildAction>
25
+ <TestAction
26
+ buildConfiguration = "Debug"
27
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29
+ shouldUseLaunchSchemeArgsEnv = "YES">
30
+ <Testables>
31
+ <TestableReference
32
+ skipped = "NO">
33
+ <BuildableReference
34
+ BuildableIdentifier = "primary"
35
+ BlueprintIdentifier = "00E356ED1AD99517003FC87E"
36
+ BuildableName = "bleamTests.xctest"
37
+ BlueprintName = "bleamTests"
38
+ ReferencedContainer = "container:bleam.xcodeproj">
39
+ </BuildableReference>
40
+ </TestableReference>
41
+ </Testables>
42
+ </TestAction>
43
+ <LaunchAction
44
+ buildConfiguration = "Debug"
45
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
46
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
47
+ launchStyle = "0"
48
+ useCustomWorkingDirectory = "NO"
49
+ ignoresPersistentStateOnLaunch = "NO"
50
+ debugDocumentVersioning = "YES"
51
+ debugServiceExtension = "internal"
52
+ allowLocationSimulation = "YES">
53
+ <BuildableProductRunnable
54
+ runnableDebuggingMode = "0">
55
+ <BuildableReference
56
+ BuildableIdentifier = "primary"
57
+ BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
58
+ BuildableName = "bleam.app"
59
+ BlueprintName = "bleam"
60
+ ReferencedContainer = "container:bleam.xcodeproj">
61
+ </BuildableReference>
62
+ </BuildableProductRunnable>
63
+ </LaunchAction>
64
+ <ProfileAction
65
+ buildConfiguration = "Release"
66
+ shouldUseLaunchSchemeArgsEnv = "YES"
67
+ savedToolIdentifier = ""
68
+ useCustomWorkingDirectory = "NO"
69
+ debugDocumentVersioning = "YES">
70
+ <BuildableProductRunnable
71
+ runnableDebuggingMode = "0">
72
+ <BuildableReference
73
+ BuildableIdentifier = "primary"
74
+ BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
75
+ BuildableName = "bleam.app"
76
+ BlueprintName = "bleam"
77
+ ReferencedContainer = "container:bleam.xcodeproj">
78
+ </BuildableReference>
79
+ </BuildableProductRunnable>
80
+ </ProfileAction>
81
+ <AnalyzeAction
82
+ buildConfiguration = "Debug">
83
+ </AnalyzeAction>
84
+ <ArchiveAction
85
+ buildConfiguration = "Release"
86
+ revealArchiveInOrganizer = "YES">
87
+ </ArchiveAction>
88
+ </Scheme>
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.application-groups</key>
6
+ <array>
7
+ <string>group.dev.bleam.app</string>
8
+ </array>
9
+ </dict>
10
+ </plist>
@@ -0,0 +1,591 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import ImageIO
4
+ import MLX
5
+ import UniformTypeIdentifiers
6
+
7
+ final class ImageGenerationRunner: @unchecked Sendable {
8
+ private let request: GenerationWorkerRequest
9
+ private let emit: @Sendable (GenerationWorkerEvent) -> Void
10
+ private var isCancelled = false
11
+
12
+ init(
13
+ request: GenerationWorkerRequest,
14
+ emit: @escaping @Sendable (GenerationWorkerEvent) -> Void
15
+ ) {
16
+ self.request = request
17
+ self.emit = emit
18
+ }
19
+
20
+ func cancel() {
21
+ isCancelled = true
22
+ }
23
+
24
+ func run(isCancelled externalCancellation: @escaping @Sendable () -> Bool) async throws {
25
+ let startedAt = Date()
26
+ let jobID = request.jobID.uuidString
27
+ Flux2Debug.setLogSink { [emit] level, message in
28
+ let logLevel: String
29
+ switch level {
30
+ case .verbose: logLevel = "debug"
31
+ case .info: logLevel = "info"
32
+ case .warning: logLevel = "warn"
33
+ case .error: logLevel = "error"
34
+ }
35
+ emit(.init(
36
+ type: .log,
37
+ message: message,
38
+ level: logLevel,
39
+ category: "flux2",
40
+ jobID: jobID
41
+ ))
42
+ }
43
+ logMemory("job start")
44
+ defer {
45
+ Flux2Debug.setLogSink(nil)
46
+ MLX.Memory.clearCache()
47
+ log("job elapsed=\(Self.formatDuration(Date().timeIntervalSince(startedAt)))")
48
+ logMemory("job end")
49
+ }
50
+
51
+ try validateRequest()
52
+ try FileManager.default.createDirectory(
53
+ at: URL(fileURLWithPath: request.stagingDirectoryPath, isDirectory: true),
54
+ withIntermediateDirectories: true
55
+ )
56
+ emit(.init(type: .ready, message: nil))
57
+
58
+ guard !shouldCancel(externalCancellation) else {
59
+ throw CancellationError()
60
+ }
61
+
62
+ let textEmbeddings: MLXArray = try timed("text encoder", stage: "encode") {
63
+ let embeddings: MLXArray = try { () throws -> MLXArray in
64
+ // The XPC service is the only place that may hold MLX generation
65
+ // models. Keep the text encoder scoped to this closure so its weights
66
+ // are gone before the Flux transformer stage starts.
67
+ let textEncoder = KleinTextEncoder(
68
+ modelPath: URL(fileURLWithPath: request.textEncoderPath, isDirectory: true)
69
+ )
70
+ return try textEncoder.encode(
71
+ inputIds: request.inputIds,
72
+ attentionMask: request.attentionMask
73
+ )
74
+ }()
75
+ MLX.Memory.clearCache()
76
+ logMemory("after text encoder release")
77
+ return embeddings
78
+ }
79
+
80
+ guard !shouldCancel(externalCancellation) else {
81
+ throw CancellationError()
82
+ }
83
+
84
+ let image = try timed("flux generation", stage: "denoise") {
85
+ let generationMode = try generationMode()
86
+ let outputWidth = request.outpaintWidth ?? request.width
87
+ let outputHeight = request.outpaintHeight ?? request.height
88
+ // First service version is deliberately memory-first: the runner releases
89
+ // text encoder, transformer, and VAE stages instead of keeping a warm
90
+ // pipeline. Add warm caching later only behind explicit idle trimming.
91
+ let runner = FluxImageRunner(paths: FluxImageRunnerPaths(
92
+ transformer: URL(fileURLWithPath: request.transformerPath, isDirectory: true),
93
+ vae: URL(fileURLWithPath: request.vaePath, isDirectory: true)
94
+ ))
95
+ var fluxStage = "denoise"
96
+ do {
97
+ return try runner.generate(
98
+ textEmbeddings: textEmbeddings,
99
+ height: outputHeight,
100
+ width: outputWidth,
101
+ steps: request.steps,
102
+ guidance: Float(request.guidance),
103
+ seed: request.seed.flatMap { $0 >= 0 ? UInt64($0) : nil },
104
+ mode: generationMode,
105
+ loras: loraConfigs(),
106
+ sigmas: request.sigmaSchedule?.map(Float.init),
107
+ onStage: { stage in fluxStage = stage },
108
+ onProgress: { [emit] currentStep, totalSteps in
109
+ emit(.init(type: .progress, currentStep: currentStep, totalSteps: totalSteps))
110
+ }
111
+ )
112
+ } catch {
113
+ throw GenerationWorkerFailure(message: error.localizedDescription, stage: fluxStage)
114
+ }
115
+ }
116
+
117
+ guard !shouldCancel(externalCancellation) else {
118
+ throw CancellationError()
119
+ }
120
+
121
+ let outputURL = try timed("png write", stage: "write") {
122
+ try Self.writePNG(image, directory: URL(
123
+ fileURLWithPath: request.stagingDirectoryPath,
124
+ isDirectory: true
125
+ ))
126
+ }
127
+
128
+ emit(.init(
129
+ type: .completed,
130
+ path: outputURL.path,
131
+ width: image.width,
132
+ height: image.height,
133
+ model: request.model,
134
+ metadata: GenerationWorkerMetadata(
135
+ model: request.model,
136
+ width: image.width,
137
+ height: image.height,
138
+ steps: request.steps,
139
+ guidance: request.guidance,
140
+ strength: request.strength,
141
+ seed: request.seed,
142
+ elapsedMs: Int(Date().timeIntervalSince(startedAt) * 1000),
143
+ appliedLoRAs: appliedLoRAs(),
144
+ sigmaSchedule: request.sigmaSchedule
145
+ )
146
+ ))
147
+ }
148
+
149
+ private func shouldCancel(_ externalCancellation: @escaping @Sendable () -> Bool) -> Bool {
150
+ Task.isCancelled || isCancelled || externalCancellation()
151
+ }
152
+
153
+ private func validateRequest() throws {
154
+ guard request.model == "flux2-klein-4b-8bit-abliterated" else {
155
+ throw GenerationWorkerFailure(message: "Unsupported image model: \(request.model)")
156
+ }
157
+ guard !request.prompt.isEmpty else {
158
+ throw GenerationWorkerFailure(message: "Expected prompt")
159
+ }
160
+ guard request.width > 0, request.height > 0, request.steps > 0, request.guidance > 0 else {
161
+ throw GenerationWorkerFailure(message: "Invalid generation options")
162
+ }
163
+ if let strength = request.strength, !(strength > 0 && strength <= 1) {
164
+ throw GenerationWorkerFailure(message: "Image strength must be greater than 0 and at most 1")
165
+ }
166
+ if let sigmas = request.sigmaSchedule {
167
+ guard !sigmas.isEmpty,
168
+ sigmas.enumerated().allSatisfy({ index, sigma in
169
+ sigma.isFinite && sigma > 0 && sigma <= 1 && (index == 0 || sigma < sigmas[index - 1])
170
+ })
171
+ else {
172
+ throw GenerationWorkerFailure(message: "sigmaSchedule must contain descending values greater than 0 and at most 1")
173
+ }
174
+ guard sigmas.count == request.steps else {
175
+ throw GenerationWorkerFailure(message: "sigmaSchedule count must match steps")
176
+ }
177
+ }
178
+ if let sourceImagePath = request.sourceImagePath, sourceImagePath.isEmpty {
179
+ throw GenerationWorkerFailure(message: "Source image path must not be empty")
180
+ }
181
+ if let maskImagePath = request.maskImagePath, maskImagePath.isEmpty {
182
+ throw GenerationWorkerFailure(message: "Mask image path must not be empty")
183
+ }
184
+ if request.maskImagePath != nil, request.sourceImagePath == nil {
185
+ throw GenerationWorkerFailure(message: "Mask image requires a source image")
186
+ }
187
+ if let maskMode = request.maskMode, maskMode != "paint" && maskMode != "preserve" {
188
+ throw GenerationWorkerFailure(message: "maskMode must be paint or preserve")
189
+ }
190
+ if request.outpaintWidth != nil || request.outpaintHeight != nil || request.outpaintSourceX != nil || request.outpaintSourceY != nil || request.outpaintSourceWidth != nil || request.outpaintSourceHeight != nil {
191
+ guard request.sourceImagePath != nil else {
192
+ throw GenerationWorkerFailure(message: "Outpaint requires a source image")
193
+ }
194
+ guard let width = request.outpaintWidth, width > 0, let height = request.outpaintHeight, height > 0 else {
195
+ throw GenerationWorkerFailure(message: "Outpaint requires positive width and height")
196
+ }
197
+ guard let sourceX = request.outpaintSourceX,
198
+ let sourceY = request.outpaintSourceY,
199
+ let sourceWidth = request.outpaintSourceWidth, sourceWidth > 0,
200
+ let sourceHeight = request.outpaintSourceHeight, sourceHeight > 0
201
+ else {
202
+ throw GenerationWorkerFailure(message: "Outpaint requires a valid source rect")
203
+ }
204
+ guard sourceX < width, sourceY < height, sourceX + sourceWidth > 0, sourceY + sourceHeight > 0 else {
205
+ throw GenerationWorkerFailure(message: "Outpaint source rect must intersect the canvas")
206
+ }
207
+ if request.outpaintAutoCrop != true,
208
+ (sourceX < 0 || sourceY < 0 || sourceX + sourceWidth > width || sourceY + sourceHeight > height)
209
+ {
210
+ throw GenerationWorkerFailure(message: "Outpaint source rect must fit within the canvas unless autoCrop is enabled")
211
+ }
212
+ }
213
+ if let resizeMode = request.resizeMode, !["stretch", "fit", "crop"].contains(resizeMode) {
214
+ throw GenerationWorkerFailure(message: "Resize mode must be stretch, fit, or crop")
215
+ }
216
+ if let maxInputLongSide = request.maxInputLongSide, maxInputLongSide != 0 && maxInputLongSide < 64 {
217
+ throw GenerationWorkerFailure(message: "maxInputLongSide must be 0 or at least 64")
218
+ }
219
+ guard request.referenceImagePaths.allSatisfy({ !$0.isEmpty }) else {
220
+ throw GenerationWorkerFailure(message: "Reference image paths must not be empty")
221
+ }
222
+ guard request.referenceImagePaths.count <= 3 else {
223
+ throw GenerationWorkerFailure(message: "Provide at most 3 reference images")
224
+ }
225
+ if let referenceInfluences = request.referenceInfluences, referenceInfluences.count != request.referenceImagePaths.count {
226
+ throw GenerationWorkerFailure(message: "referenceInfluences count must match reference image count")
227
+ }
228
+ if let referenceInfluences = request.referenceInfluences, !referenceInfluences.allSatisfy({ $0.isFinite && $0 >= 0 }) {
229
+ throw GenerationWorkerFailure(message: "referenceInfluences must be non-negative numbers")
230
+ }
231
+ guard request.loraPaths.allSatisfy({ !$0.isEmpty }) else {
232
+ throw GenerationWorkerFailure(message: "LoRA paths must not be empty")
233
+ }
234
+ if let loraScales = request.loraScales, loraScales.count != request.loraPaths.count {
235
+ throw GenerationWorkerFailure(message: "loraScales count must match LoRA path count")
236
+ }
237
+ if let loraScales = request.loraScales, !loraScales.allSatisfy({ $0.isFinite && $0 > 0 }) {
238
+ throw GenerationWorkerFailure(message: "loraScales must be positive numbers")
239
+ }
240
+ if let loraIds = request.loraIds, loraIds.count != request.loraPaths.count {
241
+ throw GenerationWorkerFailure(message: "loraIds count must match LoRA path count")
242
+ }
243
+ guard !request.inputIds.isEmpty, request.inputIds.count == request.attentionMask.count else {
244
+ throw GenerationWorkerFailure(message: "Invalid tokenized prompt")
245
+ }
246
+
247
+ let fileManager = FileManager.default
248
+ for path in [request.modelRoot, request.textEncoderPath, request.transformerPath, request.vaePath] {
249
+ var isDirectory: ObjCBool = false
250
+ guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue else {
251
+ throw GenerationWorkerFailure(message: "Missing model directory: \(path)")
252
+ }
253
+ }
254
+
255
+ for path in [request.sourceImagePath, request.maskImagePath].compactMap({ $0 }) + request.referenceImagePaths + request.loraPaths {
256
+ var isDirectory: ObjCBool = false
257
+ guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue else {
258
+ throw GenerationWorkerFailure(message: "Missing input file: \(path)")
259
+ }
260
+ }
261
+
262
+ for path in request.loraPaths {
263
+ try Self.validateSafetensors(atPath: path)
264
+ }
265
+ }
266
+
267
+ private static func validateSafetensors(atPath path: String) throws {
268
+ let url = URL(fileURLWithPath: path)
269
+ let fileSize: UInt64
270
+ do {
271
+ let attributes = try FileManager.default.attributesOfItem(atPath: path)
272
+ fileSize = (attributes[.size] as? NSNumber)?.uint64Value ?? 0
273
+ } catch {
274
+ throw GenerationWorkerFailure(message: "Unable to inspect LoRA file: \(path)")
275
+ }
276
+
277
+ guard fileSize > 8 else {
278
+ throw GenerationWorkerFailure(message: "Invalid LoRA safetensors file (missing 8-byte header): \(path)")
279
+ }
280
+
281
+ do {
282
+ let handle = try FileHandle(forReadingFrom: url)
283
+ defer { try? handle.close() }
284
+
285
+ guard let prefix = try handle.read(upToCount: 8), prefix.count == 8 else {
286
+ throw GenerationWorkerFailure(message: "Invalid LoRA safetensors file (unable to read header): \(path)")
287
+ }
288
+ let headerLength = prefix.enumerated().reduce(UInt64(0)) { value, byte in
289
+ value | (UInt64(byte.element) << UInt64(byte.offset * 8))
290
+ }
291
+ guard headerLength > 0,
292
+ headerLength <= 100_000_000,
293
+ headerLength <= fileSize - 8,
294
+ headerLength <= UInt64(Int.max)
295
+ else {
296
+ throw GenerationWorkerFailure(message: "Invalid LoRA safetensors header length: \(path)")
297
+ }
298
+
299
+ guard let header = try handle.read(upToCount: Int(headerLength)),
300
+ header.count == Int(headerLength),
301
+ let object = try JSONSerialization.jsonObject(with: header) as? [String: Any]
302
+ else {
303
+ throw GenerationWorkerFailure(message: "Invalid LoRA safetensors header: \(path)")
304
+ }
305
+
306
+ let dataSize = fileSize - 8 - headerLength
307
+ var tensorCount = 0
308
+ for (key, value) in object where key != "__metadata__" {
309
+ guard let tensor = value as? [String: Any],
310
+ let offsets = tensor["data_offsets"] as? [NSNumber],
311
+ offsets.count == 2
312
+ else {
313
+ throw GenerationWorkerFailure(message: "Invalid LoRA tensor metadata for \(key): \(path)")
314
+ }
315
+ let start = offsets[0].uint64Value
316
+ let end = offsets[1].uint64Value
317
+ guard start <= end, end <= dataSize else {
318
+ throw GenerationWorkerFailure(message: "LoRA tensor data is truncated for \(key): \(path)")
319
+ }
320
+ tensorCount += 1
321
+ }
322
+ guard tensorCount > 0 else {
323
+ throw GenerationWorkerFailure(message: "LoRA safetensors file contains no tensors: \(path)")
324
+ }
325
+ } catch let failure as GenerationWorkerFailure {
326
+ throw failure
327
+ } catch {
328
+ throw GenerationWorkerFailure(message: "Invalid LoRA safetensors file: \(path) - \(error.localizedDescription)")
329
+ }
330
+ }
331
+
332
+ private func loraConfigs() -> [FluxLoRAConfig] {
333
+ request.loraPaths.enumerated().map { index, path in
334
+ FluxLoRAConfig(
335
+ id: request.loraIds?[safe: index],
336
+ path: path,
337
+ scale: Float(request.loraScales?[safe: index] ?? 1)
338
+ )
339
+ }
340
+ }
341
+
342
+ private func appliedLoRAs() -> [GenerationWorkerLoRA]? {
343
+ guard !request.loraPaths.isEmpty else { return nil }
344
+ return request.loraPaths.enumerated().map { index, path in
345
+ GenerationWorkerLoRA(
346
+ id: request.loraIds?[safe: index],
347
+ path: path,
348
+ scale: request.loraScales?[safe: index] ?? 1
349
+ )
350
+ }
351
+ }
352
+
353
+ private func generationMode() throws -> FluxImageGenerationMode {
354
+ let maxInputLongSide = request.maxInputLongSide.flatMap { $0 > 0 ? $0 : nil }
355
+ let sourceImage = try request.sourceImagePath.map { try Self.loadImage(path: $0, maxLongSide: maxInputLongSide) }
356
+ let maskImage = try request.maskImagePath.map { try Self.loadImage(path: $0, maxLongSide: maxInputLongSide) }
357
+ let loadedReferences = try request.referenceImagePaths.map { try Self.loadImage(path: $0, maxLongSide: maxInputLongSide) }
358
+ let targetWidth = request.outpaintWidth ?? request.width
359
+ let targetHeight = request.outpaintHeight ?? request.height
360
+ let resizeMode = request.resizeMode ?? "stretch"
361
+ let referenceImages = try loadedReferences.map {
362
+ try Self.resizeImage($0, width: targetWidth, height: targetHeight, mode: resizeMode)
363
+ }
364
+ let preparedSource: CGImage?
365
+ let preparedMask: CGImage?
366
+ if let sourceImage, request.outpaintWidth != nil || request.outpaintHeight != nil {
367
+ let prepared = try Self.outpaintImages(sourceImage: sourceImage, request: request)
368
+ preparedSource = prepared.source
369
+ preparedMask = prepared.mask
370
+ log("outpaint canvas=\(request.outpaintWidth ?? request.width)x\(request.outpaintHeight ?? request.height) sourceRect=\(request.outpaintSourceX ?? 0),\(request.outpaintSourceY ?? 0),\(request.outpaintSourceWidth ?? 0),\(request.outpaintSourceHeight ?? 0)")
371
+ } else {
372
+ preparedSource = try sourceImage.map {
373
+ try Self.resizeImage($0, width: targetWidth, height: targetHeight, mode: resizeMode)
374
+ }
375
+ preparedMask = try maskImage.map {
376
+ try Self.resizeImage($0, width: targetWidth, height: targetHeight, mode: "stretch", grayscale: true)
377
+ }
378
+ }
379
+
380
+ guard sourceImage != nil || !referenceImages.isEmpty else {
381
+ return .textToImage
382
+ }
383
+
384
+ log("generation mode=imageToImage source=\(preparedSource != nil) mask=\(preparedMask != nil) refs=\(referenceImages.count) strength=\(request.strength ?? 0.8)")
385
+
386
+ return .imageToImage(
387
+ sourceImage: preparedSource,
388
+ maskImage: preparedMask,
389
+ maskMode: request.maskMode == "preserve" ? .preserve : .paint,
390
+ referenceImages: referenceImages,
391
+ referenceInfluences: request.referenceInfluences?.map(Float.init),
392
+ strength: Float(request.strength ?? 0.8)
393
+ )
394
+ }
395
+
396
+ private static func loadImage(path: String, maxLongSide: Int?) throws -> CGImage {
397
+ let url = URL(fileURLWithPath: path)
398
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
399
+ throw GenerationWorkerFailure(message: "Failed to load image: \(path)")
400
+ }
401
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
402
+ let pixelWidth = properties?[kCGImagePropertyPixelWidth] as? Int ?? 1
403
+ let pixelHeight = properties?[kCGImagePropertyPixelHeight] as? Int ?? 1
404
+ let thumbnailMax = maxLongSide.map { min($0, max(pixelWidth, pixelHeight)) } ?? max(pixelWidth, pixelHeight)
405
+ let options: [CFString: Any] = [
406
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
407
+ kCGImageSourceCreateThumbnailWithTransform: true,
408
+ kCGImageSourceThumbnailMaxPixelSize: max(1, thumbnailMax),
409
+ ]
410
+ guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
411
+ throw GenerationWorkerFailure(message: "Failed to decode image: \(path)")
412
+ }
413
+ return try resizeImage(image, width: image.width, height: image.height, mode: "stretch")
414
+ }
415
+
416
+ private static func resizeImage(
417
+ _ image: CGImage,
418
+ width: Int,
419
+ height: Int,
420
+ mode: String,
421
+ grayscale: Bool = false
422
+ ) throws -> CGImage {
423
+ let colorSpace = grayscale
424
+ ? CGColorSpaceCreateDeviceGray()
425
+ : (CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB())
426
+ let bitmapInfo = grayscale
427
+ ? CGImageAlphaInfo.none.rawValue
428
+ : CGImageAlphaInfo.noneSkipLast.rawValue
429
+ guard let context = CGContext(
430
+ data: nil,
431
+ width: width,
432
+ height: height,
433
+ bitsPerComponent: 8,
434
+ bytesPerRow: 0,
435
+ space: colorSpace,
436
+ bitmapInfo: bitmapInfo
437
+ ) else {
438
+ throw GenerationWorkerFailure(message: "Failed to create normalized image canvas")
439
+ }
440
+ context.setFillColor(gray: 0, alpha: 1)
441
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
442
+ context.interpolationQuality = .high
443
+
444
+ let canvas = CGSize(width: width, height: height)
445
+ let source = CGSize(width: image.width, height: image.height)
446
+ let destination: CGRect
447
+ if mode == "stretch" {
448
+ destination = CGRect(origin: .zero, size: canvas)
449
+ } else {
450
+ let scale = mode == "fit"
451
+ ? min(canvas.width / source.width, canvas.height / source.height)
452
+ : max(canvas.width / source.width, canvas.height / source.height)
453
+ let size = CGSize(width: source.width * scale, height: source.height * scale)
454
+ destination = CGRect(
455
+ x: (canvas.width - size.width) / 2,
456
+ y: (canvas.height - size.height) / 2,
457
+ width: size.width,
458
+ height: size.height
459
+ )
460
+ }
461
+ context.draw(image, in: destination)
462
+ guard let result = context.makeImage() else {
463
+ throw GenerationWorkerFailure(message: "Failed to normalize image")
464
+ }
465
+ return result
466
+ }
467
+
468
+ private static func outpaintImages(sourceImage: CGImage, request: GenerationWorkerRequest) throws -> (source: CGImage, mask: CGImage) {
469
+ guard let width = request.outpaintWidth, let height = request.outpaintHeight else {
470
+ throw GenerationWorkerFailure(message: "Outpaint requires width and height")
471
+ }
472
+ guard let sourceX = request.outpaintSourceX,
473
+ let sourceY = request.outpaintSourceY,
474
+ let sourceWidth = request.outpaintSourceWidth,
475
+ let sourceHeight = request.outpaintSourceHeight
476
+ else {
477
+ throw GenerationWorkerFailure(message: "Outpaint requires a source rect")
478
+ }
479
+ guard sourceWidth > 0, sourceHeight > 0,
480
+ sourceX < width, sourceY < height, sourceX + sourceWidth > 0, sourceY + sourceHeight > 0
481
+ else {
482
+ throw GenerationWorkerFailure(message: "Outpaint source rect must intersect the canvas")
483
+ }
484
+
485
+ guard let sourceContext = CGContext(
486
+ data: nil,
487
+ width: width,
488
+ height: height,
489
+ bitsPerComponent: 8,
490
+ bytesPerRow: 0,
491
+ space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
492
+ bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
493
+ ) else {
494
+ throw GenerationWorkerFailure(message: "Failed to create outpaint source canvas")
495
+ }
496
+ sourceContext.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
497
+ sourceContext.fill(CGRect(x: 0, y: 0, width: width, height: height))
498
+ sourceContext.interpolationQuality = .high
499
+ let sourceRect = CGRect(x: CGFloat(sourceX), y: CGFloat(sourceY), width: CGFloat(sourceWidth), height: CGFloat(sourceHeight))
500
+ sourceContext.draw(sourceImage, in: sourceRect)
501
+ guard let expandedSource = sourceContext.makeImage() else {
502
+ throw GenerationWorkerFailure(message: "Failed to create outpaint source image")
503
+ }
504
+
505
+ guard let maskContext = CGContext(
506
+ data: nil,
507
+ width: width,
508
+ height: height,
509
+ bitsPerComponent: 8,
510
+ bytesPerRow: 0,
511
+ space: CGColorSpaceCreateDeviceGray(),
512
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
513
+ ) else {
514
+ throw GenerationWorkerFailure(message: "Failed to create outpaint mask canvas")
515
+ }
516
+ maskContext.setFillColor(gray: 1, alpha: 1)
517
+ maskContext.fill(CGRect(x: 0, y: 0, width: width, height: height))
518
+ maskContext.setFillColor(gray: 0, alpha: 1)
519
+ maskContext.fill(sourceRect)
520
+ guard let outpaintMask = maskContext.makeImage() else {
521
+ throw GenerationWorkerFailure(message: "Failed to create outpaint mask image")
522
+ }
523
+
524
+ return (expandedSource, outpaintMask)
525
+ }
526
+
527
+ private func timed<T>(_ label: String, stage: String, _ operation: () throws -> T) throws -> T {
528
+ logMemory("before \(label)")
529
+ let startedAt = Date()
530
+ do {
531
+ let value = try operation()
532
+ log("\(label) elapsed=\(Self.formatDuration(Date().timeIntervalSince(startedAt)))")
533
+ logMemory("after \(label)")
534
+ return value
535
+ } catch {
536
+ log("\(label) failed elapsed=\(Self.formatDuration(Date().timeIntervalSince(startedAt))) error=\(error.localizedDescription)")
537
+ logMemory("after failed \(label)")
538
+ if let failure = error as? GenerationWorkerFailure, failure.stage != nil {
539
+ throw failure
540
+ }
541
+ if error is CancellationError {
542
+ throw error
543
+ }
544
+ throw GenerationWorkerFailure(message: error.localizedDescription, stage: stage)
545
+ }
546
+ }
547
+
548
+ private static func writePNG(_ image: CGImage, directory: URL) throws -> URL {
549
+ do {
550
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
551
+ } catch {
552
+ throw GenerationWorkerFailure(message: "Failed to create output directory: \(directory.path) - \(error.localizedDescription)")
553
+ }
554
+
555
+ let outputURL = directory.appendingPathComponent("flux-\(UUID().uuidString).png")
556
+ guard let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else {
557
+ throw GenerationWorkerFailure(message: "Failed to create image destination: \(outputURL.path)")
558
+ }
559
+
560
+ CGImageDestinationAddImage(destination, image, nil)
561
+ guard CGImageDestinationFinalize(destination) else {
562
+ throw GenerationWorkerFailure(message: "Failed to write generated image: \(outputURL.path)")
563
+ }
564
+ return outputURL
565
+ }
566
+
567
+ private func logMemory(_ label: String) {
568
+ log("\(label) active=\(MLX.Memory.activeMemory / 1_048_576)MB cache=\(MLX.Memory.cacheMemory / 1_048_576)MB")
569
+ }
570
+
571
+ private func log(_ message: String) {
572
+ print("[GenerationService] \(request.jobID) \(message)")
573
+ emit(.init(type: .log, message: message, level: "info", category: "imagegen", jobID: request.jobID.uuidString))
574
+ }
575
+
576
+ private static func formatDuration(_ duration: TimeInterval) -> String {
577
+ if duration < 60 {
578
+ return String(format: "%.1fs", duration)
579
+ }
580
+
581
+ let minutes = Int(duration) / 60
582
+ let seconds = duration - Double(minutes * 60)
583
+ return String(format: "%dm %.1fs", minutes, seconds)
584
+ }
585
+ }
586
+
587
+ private extension Array {
588
+ subscript(safe index: Int) -> Element? {
589
+ indices.contains(index) ? self[index] : nil
590
+ }
591
+ }