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.
- package/dist/cli.cjs +23 -4
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +23 -4
- package/dist/{ui-234Plg7Z.d.cts → ui-Bg11tvlc.d.ts} +7 -7
- package/dist/ui.d.ts +1 -1
- package/dist/window.d.ts +1 -1
- package/package.json +4 -1
- package/templates/image-generation/app/index.tsx +1 -1
- package/templates/native/.gitattributes +1 -0
- package/templates/native/App.tsx +26 -0
- package/templates/native/app.json +12 -0
- package/templates/native/index.ts +8 -0
- package/templates/native/ios/.xcode.env +11 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/Flux2Config.swift +288 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryConfig.swift +233 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryOptimizationConfig.swift +157 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +425 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/QuantizationConfig.swift +138 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/TransformerRepoProfile.swift +59 -0
- package/templates/native/ios/Bleam/AI/Flux2/Configuration/VAEConfig.swift +134 -0
- package/templates/native/ios/Bleam/AI/Flux2/Flux2Core.swift +55 -0
- package/templates/native/ios/Bleam/AI/Flux2/Loading/WeightLoader.swift +1192 -0
- package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +1282 -0
- package/templates/native/ios/Bleam/AI/Flux2/Pipeline/LatentUtils.swift +191 -0
- package/templates/native/ios/Bleam/AI/Flux2/Scheduler/FlowMatchEulerScheduler.swift +279 -0
- package/templates/native/ios/Bleam/AI/Flux2/Text/KleinTextEncoder.swift +380 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Attention.swift +379 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Embeddings.swift +159 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FeedForward.swift +154 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FusedKernels.swift +198 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2KVCache.swift +36 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Modulation.swift +193 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2ParallelAttention.swift +389 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2RoPE.swift +304 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2SingleBlock.swift +290 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Transformer.swift +530 -0
- package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2TransformerBlock.swift +362 -0
- package/templates/native/ios/Bleam/AI/Flux2/Utils/Flux2Debug.swift +101 -0
- package/templates/native/ios/Bleam/AI/Flux2/Utils/MLXCheckpoint.swift +118 -0
- package/templates/native/ios/Bleam/AI/Flux2/Utils/MemoryManager.swift +201 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/AutoencoderKL.swift +476 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/ResnetBlock.swift +316 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEDecoder.swift +120 -0
- package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEEncoder.swift +136 -0
- package/templates/native/ios/Bleam/AppDelegate.swift +373 -0
- package/templates/native/ios/Bleam/Appearance.swift +62 -0
- package/templates/native/ios/Bleam/Bridging-Header.h +3 -0
- package/templates/native/ios/Bleam/Images.xcassets/AppIcon.appiconset/Contents.json +13 -0
- package/templates/native/ios/Bleam/Images.xcassets/Contents.json +6 -0
- package/templates/native/ios/Bleam/Info.plist +76 -0
- package/templates/native/ios/Bleam/PrivacyInfo.xcprivacy +48 -0
- package/templates/native/ios/Bleam/SceneDelegate.swift +58 -0
- package/templates/native/ios/Bleam/SplashScreen.storyboard +47 -0
- package/templates/native/ios/Bleam/Supporting/Expo.plist +6 -0
- package/templates/native/ios/Bleam/bleam.entitlements +10 -0
- package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +1146 -0
- package/templates/native/ios/Bleam.xcodeproj/xcshareddata/xcschemes/Bleam.xcscheme +88 -0
- package/templates/native/ios/GenerationService/GenerationService.entitlements +10 -0
- package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +591 -0
- package/templates/native/ios/GenerationService/Info.plist +31 -0
- package/templates/native/ios/GenerationService/main.swift +165 -0
- package/templates/native/ios/PlatformHelper/Info.plist +29 -0
- package/templates/native/ios/PlatformHelper/main.swift +335 -0
- package/templates/native/ios/Podfile +198 -0
- package/templates/native/ios/Podfile.lock +2284 -0
- package/templates/native/ios/Podfile.properties.json +5 -0
- package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +12 -0
- package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +124 -0
- package/templates/native/metro.config.js +6 -0
- package/templates/native/modules/bleam-runtime/BleamRuntime.podspec +14 -0
- package/templates/native/modules/bleam-runtime/expo-module.config.json +8 -0
- package/templates/native/modules/bleam-runtime/ios/AIModule.swift +1258 -0
- package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +135 -0
- package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +313 -0
- package/templates/native/modules/bleam-runtime/package.json +5 -0
- package/templates/native/package.json +39 -0
- package/templates/native/scripts/build/mlx-frameworks.ts +421 -0
- package/templates/native/scripts/start/index.ts +474 -0
- package/templates/native/scripts/start/known-failures.ts +20 -0
- package/templates/native/scripts/start/loader.ts +76 -0
- package/templates/native/scripts/start/native-state.ts +242 -0
- package/templates/native/scripts/start/package.json +4 -0
- package/templates/native/scripts/start/swift-packages.ts +414 -0
- package/templates/native/scripts/start/xcode-formatter.ts +106 -0
- package/templates/native/tsconfig.json +14 -0
- package/templates/native/yarn.lock +3749 -0
- package/dist/ui-BJmXhz9Q.d.ts +0 -92
- package/dist/ui-Dd7SXdbg.d.cts +0 -92
- package/dist/ui-Ds88eETu.d.ts +0 -92
- package/dist/ui-TaqnB5SP.d.ts +0 -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
|
+
}
|