Skip to content

Commit adf7fea

Browse files
committed
Align diffusion bundles with metadata.json v0.2 schema (apple#33)
Diffusion exports now produce metadata.json (same v0.2 schema as LLM and segmenter bundles) instead of the legacy pipeline.json format. The pipeline config lives under a "diffusion" key in metadata.json, alongside standard fields (kind, name, assets, source, compression, compilation) shared by all bundle types. Swift runner: reads metadata.json first. If only pipeline.json is found, prints an error, which will be removed in upcoming weeks
1 parent d83f0b0 commit adf7fea

4 files changed

Lines changed: 108 additions & 40 deletions

File tree

python/src/coreai_models/diffusion/pipeline.py

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ async def _async_export_diffusion(config: DiffusionExportConfig) -> dict[str, st
137137
_save_tokenizer(config.hf_model_id, output_path, hf_pipe, overwrite=config.overwrite)
138138

139139
# 4. Write pipeline.json
140-
_write_pipeline_json(hf_pipe, config.hf_model_id, pipeline_type, output_path)
140+
_write_metadata_json(
141+
hf_pipe, config.hf_model_id, pipeline_type, output_path, config.compression, results
142+
)
141143

142144
# Summary
143145
logger.info("=== Export Summary ===")
@@ -296,33 +298,63 @@ def _save_tokenizer(model_id: str, output_path: Path, hf_pipe: Any, overwrite: b
296298

297299

298300
# ---------------------------------------------------------------------------
299-
# pipeline.json
301+
# metadata.json (v0.2 schema — aligned with LLM and segmenter bundles)
300302
# ---------------------------------------------------------------------------
301303

304+
METADATA_VERSION = "0.2"
302305

303-
def _write_pipeline_json(
304-
hf_pipe: Any, model_id: str, pipeline_type: str, output_path: Path
306+
307+
def _write_metadata_json(
308+
hf_pipe: Any,
309+
model_id: str,
310+
pipeline_type: str,
311+
output_path: Path,
312+
compression: str,
313+
exported_assets: dict[str, str],
305314
) -> None:
306-
"""Write pipeline.json with model metadata for the Swift pipeline."""
315+
"""Write metadata.json with the v0.2 bundle schema for diffusion models."""
316+
from datetime import datetime
317+
307318
if pipeline_type == "flux2":
308-
pipeline_json = _build_flux2_pipeline_json(hf_pipe, model_id)
319+
diffusion_config = _build_flux2_config(hf_pipe, model_id)
309320
else:
310-
pipeline_json = _build_sd_pipeline_json(hf_pipe, model_id, pipeline_type)
321+
diffusion_config = _build_sd_config(hf_pipe, model_id, pipeline_type)
322+
323+
# Build assets map from exported component paths
324+
assets: dict[str, str] = {}
325+
for name, path_str in exported_assets.items():
326+
assets[name] = Path(path_str).name
327+
328+
metadata = {
329+
"metadata_version": METADATA_VERSION,
330+
"kind": "diffusion",
331+
"name": output_path.name,
332+
"assets": assets,
333+
"diffusion": diffusion_config,
334+
"source": {
335+
"model_definition": "torch",
336+
"hf_model_id": model_id,
337+
},
338+
"compression": compression if compression != "none" else None,
339+
"compilation": {
340+
"date": datetime.now().astimezone().isoformat(),
341+
"targets": [],
342+
},
343+
}
311344

312-
json_path = output_path / "pipeline.json"
345+
json_path = output_path / "metadata.json"
313346
with open(json_path, "w") as f:
314-
json.dump(pipeline_json, f, indent=2)
315-
logger.info(f"Saved pipeline.json to {json_path}")
347+
json.dump(metadata, f, indent=2)
348+
logger.info(f"Saved metadata.json to {json_path}")
316349

317350

318-
def _build_flux2_pipeline_json(hf_pipe: Any, model_id: str) -> dict:
351+
def _build_flux2_config(hf_pipe: Any, model_id: str) -> dict:
319352
vae_config = hf_pipe.vae.config
320353
transformer_config = hf_pipe.transformer.config
321354

322355
vae_scale_power = len(vae_config.block_out_channels) - 1
323356
vae_spatial_scale = 2**vae_scale_power
324357
default_sample_size = getattr(transformer_config, "default_sample_size", 64)
325-
# FLUX.2 uses 2x2 patchification, so actual image size is doubled
326358
image_size = default_sample_size * vae_spatial_scale * 2
327359

328360
scaling_factor = getattr(vae_config, "scaling_factor", 1.0)
@@ -333,7 +365,6 @@ def _build_flux2_pipeline_json(hf_pipe: Any, model_id: str) -> dict:
333365
rope_theta = getattr(transformer_config, "rope_theta", 2000.0)
334366

335367
return {
336-
"model_id": model_id,
337368
"type": "flux2",
338369
"prediction_type": "flow_matching",
339370
"encoder_scale_factor": scaling_factor,
@@ -349,7 +380,7 @@ def _build_flux2_pipeline_json(hf_pipe: Any, model_id: str) -> dict:
349380
}
350381

351382

352-
def _build_sd_pipeline_json(hf_pipe: Any, model_id: str, pipeline_type: str = "sd") -> dict:
383+
def _build_sd_config(hf_pipe: Any, model_id: str, pipeline_type: str = "sd") -> dict:
353384
scheduler_config = hf_pipe.scheduler.config
354385
vae_config = hf_pipe.vae.config
355386

@@ -364,8 +395,7 @@ def _build_sd_pipeline_json(hf_pipe: Any, model_id: str, pipeline_type: str = "s
364395
vae_spatial_scale = 2**vae_scale_power
365396
image_size = denoiser_config.sample_size * vae_spatial_scale
366397

367-
pipeline_json: dict[str, Any] = {
368-
"model_id": model_id,
398+
config: dict[str, Any] = {
369399
"type": "stable-diffusion-3" if is_sd3 else "stable-diffusion",
370400
"prediction_type": "flow" if is_sd3 else prediction_type,
371401
"encoder_scale_factor": scaling_factor,
@@ -376,18 +406,15 @@ def _build_sd_pipeline_json(hf_pipe: Any, model_id: str, pipeline_type: str = "s
376406
"default_steps": 28 if is_sd3 else 50,
377407
}
378408

379-
if is_sd3:
380-
# Autodetect also works for SD3 (recognizes MMDiT / text_encoder_2
381-
# substrings); emitting explicit paths keeps pipeline.json self-
382-
# documenting and guards against future detect() changes.
383-
pipeline_json["components"] = {
384-
"text_encoder": "TextEncoder.aimodel",
385-
"text_encoder_2": "TextEncoder2.aimodel",
386-
"unet": "MMDiT.aimodel",
387-
"vae_decoder": "VAEDecoder.aimodel",
388-
}
389-
390-
return pipeline_json
409+
# Include scheduler defaults for reproducibility
410+
config["scheduler"] = {
411+
"training_steps": getattr(scheduler_config, "num_train_timesteps", 1000),
412+
"beta_start": getattr(scheduler_config, "beta_start", 0.00085),
413+
"beta_end": getattr(scheduler_config, "beta_end", 0.012),
414+
"beta_schedule": getattr(scheduler_config, "beta_schedule", "scaled_linear"),
415+
}
416+
417+
return config
391418

392419

393420
# ---------------------------------------------------------------------------

swift/Sources/CoreAIDiffusionPipeline/Pipelines/PipelineDescriptor+CoreAI.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,18 @@ public struct CoreAIDiffusionComponents: Sendable {
1717
/// Errors during pipeline loading.
1818
public enum PipelineLoadError: Error, LocalizedError {
1919
case missingComponent(String)
20+
case missingConfig(String)
21+
case deprecatedFormat(String)
2022
case configMismatch(field: String, expected: String, actual: String)
2123

2224
public var errorDescription: String? {
2325
switch self {
2426
case .missingComponent(let name):
2527
return "Required component '\(name)' not found in model directory"
28+
case .missingConfig(let detail):
29+
return "Invalid bundle configuration: \(detail)"
30+
case .deprecatedFormat(let message):
31+
return message
2632
case .configMismatch(let field, let expected, let actual):
2733
return "Config mismatch for '\(field)': config says \(expected), model says \(actual)"
2834
}

swift/Sources/CoreAIDiffusionPipeline/Pipelines/PipelineDescriptor.swift

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,27 @@ public struct PipelineDescriptor: Codable, Sendable {
105105

106106
/// Load or detect a pipeline descriptor from a model directory.
107107
///
108-
/// - `.auto`: reads `pipeline.json` if present, otherwise scans for known component filenames
109-
/// - `.file`: reads from a specific config URL
110-
/// - `.explicit`: uses the provided descriptor as-is
108+
/// Priority:
109+
/// 1. `metadata.json` (v0.2 schema with `kind: "diffusion"`)
110+
/// 2. `pipeline.json` (deprecated — prints migration warning)
111+
/// 3. Directory scan for known component filenames
111112
///
112113
/// Fields left nil by auto-detection are filled in later during `loadComponents(from:)`
113114
/// by inspecting the actual model descriptors.
114115
public static func resolve(at url: URL, config: ConfigSource = .auto) throws -> PipelineDescriptor {
115116
switch config {
116117
case .auto:
117-
let configURL = url.appendingPathComponent("pipeline.json")
118-
if FileManager.default.fileExists(atPath: configURL.path) {
119-
return try load(from: configURL)
118+
let metadataURL = url.appendingPathComponent("metadata.json")
119+
if FileManager.default.fileExists(atPath: metadataURL.path) {
120+
return try loadFromMetadata(at: metadataURL)
121+
}
122+
let pipelineURL = url.appendingPathComponent("pipeline.json")
123+
if FileManager.default.fileExists(atPath: pipelineURL.path) {
124+
throw PipelineLoadError.deprecatedFormat(
125+
"This bundle uses the legacy pipeline.json format which is no longer supported.\n"
126+
+ "Please re-export with `coreai.diffusion.export` to produce metadata.json.\n"
127+
+ "See: https://github.com/apple/coreai-models/issues/TBD"
128+
)
120129
}
121130
return detect(at: url)
122131
case .file(let configURL):
@@ -126,6 +135,33 @@ public struct PipelineDescriptor: Codable, Sendable {
126135
}
127136
}
128137

138+
/// Parse a metadata.json file (v0.2 schema) and extract the diffusion config.
139+
public static func loadFromMetadata(at url: URL) throws -> PipelineDescriptor {
140+
let data = try Data(contentsOf: url)
141+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
142+
143+
guard let diffusion = json["diffusion"] as? [String: Any] else {
144+
throw PipelineLoadError.missingConfig("metadata.json has no 'diffusion' block")
145+
}
146+
guard let assets = json["assets"] as? [String: String] else {
147+
throw PipelineLoadError.missingConfig("metadata.json has no 'assets' map")
148+
}
149+
150+
let decoder = JSONDecoder()
151+
decoder.keyDecodingStrategy = .convertFromSnakeCase
152+
let diffusionData = try JSONSerialization.data(withJSONObject: diffusion)
153+
var descriptor = try decoder.decode(PipelineDescriptor.self, from: diffusionData)
154+
155+
// Map assets to component paths
156+
descriptor.components.textEncoder = assets["text_encoder"]
157+
descriptor.components.textEncoder2 = assets["text_encoder_2"]
158+
descriptor.components.unet = assets["transformer"] ?? assets["unet"]
159+
descriptor.components.vaeDecoder = assets["vae_decoder"]
160+
descriptor.components.vaeEncoder = assets["vae_encoder"]
161+
162+
return descriptor
163+
}
164+
129165
/// Parse a pipeline.json file.
130166
/// Supports both the new format (with `components`) and the legacy format
131167
/// (where component paths are inferred from the directory).

swift/Tests/DiffusionPipelineTests/PipelineDescriptorTests.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ struct PipelineDescriptorTests {
157157
#expect(descriptor.components.unet == "Transformer.aimodel")
158158
}
159159

160-
@Test("Resolve prefers pipeline.json over detection")
161-
func resolvePrefersPipelineJSON() throws {
160+
@Test("Resolve errors on legacy pipeline.json")
161+
func resolveRejectsLegacyPipelineJSON() throws {
162162
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("sd_resolve_\(UUID())")
163163
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
164164
defer { try? FileManager.default.removeItem(at: dir) }
@@ -175,11 +175,10 @@ struct PipelineDescriptorTests {
175175
}
176176
"""
177177
try json.write(to: dir.appendingPathComponent("pipeline.json"), atomically: true, encoding: .utf8)
178-
try "".write(to: dir.appendingPathComponent("TextEncoder.aimodel"), atomically: true, encoding: .utf8)
179178

180-
let descriptor = try PipelineDescriptor.resolve(at: dir)
181-
#expect(descriptor.version == "2.0")
182-
#expect(descriptor.components.textEncoder == "custom_encoder.aimodel")
179+
#expect(throws: PipelineLoadError.self) {
180+
_ = try PipelineDescriptor.resolve(at: dir)
181+
}
183182
}
184183

185184
@Test("Resolve with explicit config ignores directory")

0 commit comments

Comments
 (0)