File size: 20,449 Bytes
a1833c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1

"""Convert transformers checkpoint to diffusers checkpoint."""

from cosmos_framework.inference.common.init import init_script

init_script(
    env={
        "COSMOS_DEVICE": "cpu",
    }
)

import copy
import json
import shutil
import struct
from pathlib import Path
from typing import Annotated, Any, Literal

import pydantic
import tyro

from cosmos_framework.inference.args import OmniSetupOverrides
from cosmos_framework.inference.common.args import CheckpointOverrides, ResolvedPath
from cosmos_framework.inference.common.checkpoints import register_checkpoints
from cosmos_framework.inference.common.config import deserialize_config_dict, serialize_config_dict
from cosmos_framework.inference.common.public_model_config import (
    build_public_model_config,
    load_model_config_from_hf_config,
)
from cosmos_framework.utils import log
from cosmos_framework.utils.checkpoint_db import CheckpointConfig, CheckpointDirHf


class Args(pydantic.BaseModel):
    checkpoint: CheckpointOverrides
    """Transformers checkpoint."""
    output_path: Annotated[ResolvedPath, tyro.conf.arg(aliases=("-o",))]
    """Output diffusers checkpoint directory."""

    config_only: bool = False
    """If True, only save config."""

    skip_vision_encoder: bool = False
    """Do not save the vision encoder sidecar in the Diffusers checkpoint."""

    distilled_scheduler: Literal["auto", "on", "off"] = "auto"
    """Scheduler export mode for distilled (few-step) checkpoints.

    * auto: export the distilled FlowMatchEuler scheduler when the checkpoint
      defines `fixed_step_sampler_config`, else UniPC.
    * on: require and always export the distilled scheduler.
    * off: always export the UniPC scheduler.
    """

    repo_id: str | None = None
    """HF repo id embedded in modular_model_index.json component specs.

    Defaults to the output directory name (matches diffusers' own default). Set
    this to the target Hub id (e.g. 'nvidia/Cosmos3-Super-Image2Video-4Step')
    when preparing a release so the modular pipeline resolves its components.
    """

    edge_include_reasoner: bool = True
    """Keep the pinned Edge metadata and vision sidecar in the final repository."""

    edge_reasoner_repo_id: str = "nvidia/Cosmos3-Edge"
    """Hugging Face repository containing the Cosmos3 Edge reasoner checkpoint."""

    edge_reasoner_revision: str = "be935d6931e4e176d7353abad41ca529d7b33b12"
    """Pinned revision of the Cosmos3 Edge reasoner checkpoint."""

    edge_reasoner_path: Path | None = None
    """Optional local Cosmos3 Edge reasoner snapshot, used instead of downloading it."""

    edge_action_chunk_size: pydantic.PositiveInt | None = None
    """Optional action chunk size override for a Cosmos3 Edge policy manifest."""


class EdgePolicyMetadata(pydantic.BaseModel):
    """Policy metadata resolved from the Stage 1 Edge export."""

    action_chunk_size: pydantic.PositiveInt
    conditioning_fps: pydantic.PositiveFloat
    domain_name: str = pydantic.Field(min_length=1)


class SafetensorsIndexMetadata(pydantic.BaseModel):
    total_size: int = 0


class SafetensorsIndex(pydantic.BaseModel):
    metadata: SafetensorsIndexMetadata = pydantic.Field(default_factory=SafetensorsIndexMetadata)
    weight_map: dict[str, str] = pydantic.Field(default_factory=dict)

    def update(self, safetensors_path: Path, rel_path: str, exclude_key_prefixes: tuple[str, ...] = ()) -> None:
        with safetensors_path.open("rb") as f:
            header_size = struct.unpack("<Q", f.read(8))[0]
            header = json.loads(f.read(header_size).decode("utf-8"))

        for name, info in header.items():
            if name == "__metadata__":
                continue
            if name.startswith(exclude_key_prefixes):
                continue
            self.metadata.total_size += info["data_offsets"][1] - info["data_offsets"][0]
            if name in self.weight_map:
                raise ValueError(f"Key {name} already in weight map")
            self.weight_map[name] = rel_path

    def update_dir(self, safetensors_dir: Path, rel_path: str):
        for safetensors_path in safetensors_dir.glob("*.safetensors"):
            self.update(safetensors_path, f"{rel_path}/{safetensors_path.name}")


def _write_diffusers_weight_index(output_path: Path) -> None:
    """Write the root index over Diffusers component shards."""
    index = SafetensorsIndex()
    index.update_dir(output_path / "transformer", "transformer")
    vision_encoder_path = output_path / "vision_encoder/model.safetensors"
    if vision_encoder_path.is_file():
        index.update(vision_encoder_path, "vision_encoder/model.safetensors")
    (output_path / "model.safetensors.index.json").write_text(index.model_dump_json(indent=2))


def _build_public_export_model_config(model_dict: dict[str, Any]) -> dict[str, Any]:
    """Remove unsupported internal-only settings before building the public config."""
    public_model_dict = copy.deepcopy(model_dict)
    quantization = public_model_dict.get("config", {}).pop("quantization", None)
    if quantization is not None:
        quantization_values = {key: value for key, value in quantization.items() if key not in {"_type", "_target_"}}
        disabled_quantization = {
            "exclude_regex": [],
            "include_regex": [],
            "method": None,
        }
        if quantization_values != disabled_quantization:
            raise ValueError(
                "Cannot export an enabled or non-default internal quantization config to the public Cosmos3 schema: "
                f"{quantization_values}"
            )
    return build_public_model_config(public_model_dict)


def _is_edge_model_config(model_dict: dict[str, Any]) -> bool:
    """Return whether a model config uses the Nemotron Cosmos3 Edge backbone."""
    config = model_dict.get("config", {})
    vlm_config = config.get("vlm_config", {})
    pretrained_weights = vlm_config.get("pretrained_weights", {})
    model_instance = vlm_config.get("model_instance", {})
    return bool(
        pretrained_weights.get("checkpoint_format") == "nemotron_3_dense_vl"
        or "Nemotron3" in str(model_instance.get("_target_", ""))
    )


def _load_edge_policy_metadata(checkpoint_path: Path) -> EdgePolicyMetadata:
    """Load policy metadata emitted by the config-driven Stage 1 export."""
    metadata_path = checkpoint_path / "checkpoint.json"
    if not metadata_path.is_file():
        raise FileNotFoundError(
            "Cosmos3 Edge conversion requires the Stage 1 export checkpoint.json; "
            f"no metadata file was found at {metadata_path}."
        )

    checkpoint_metadata = deserialize_config_dict(metadata_path)
    policy_metadata = checkpoint_metadata.get("policy")
    if not isinstance(policy_metadata, dict):
        raise ValueError(
            "Cosmos3 Edge checkpoint.json is missing `policy` metadata. Re-export the DCP checkpoint with "
            "export_model.py so action_chunk_size, conditioning_fps, and domain_name come from the experiment config."
        )
    try:
        return EdgePolicyMetadata.model_validate(policy_metadata)
    except pydantic.ValidationError as exc:
        raise ValueError("Invalid Cosmos3 Edge policy metadata in checkpoint.json.") from exc


# Modular pipeline / blocks classes keyed by whether the export is distilled.
# The distilled variant samples on a fixed schedule with guidance baked into the
# weights; the base variant uses the standard Cosmos3 omni blocks.
MODULAR_PIPELINE_CLASSES = {
    False: ("Cosmos3OmniModularPipeline", "Cosmos3OmniBlocks"),
    True: ("Cosmos3DistilledModularPipeline", "Cosmos3DistilledBlocks"),
}


def _write_modular_model_index(output_path: Path, repo_id: str) -> None:
    """Write modular_model_index.json next to the task-based model_index.json.

    Component classes (tokenizer, scheduler, vae, transformer) are derived from
    the already-written model_index.json rather than hardcoded, so the modular
    index always matches whatever the pipeline save produced (e.g. UniPC vs.
    FlowMatchEuler scheduler, slow vs. fast tokenizer). The distilled vs. base
    pipeline/blocks classes are selected from the exported scheduler config.

    Note: the distilled modular pipeline classes are not yet in a released
    diffusers version (they ship via the modular Cosmos3 PR), so `_diffusers_version`
    is copied from the pipeline save rather than asserted, and the classes are only
    referenced by name here — loading the result requires a diffusers build that
    provides them.
    """
    model_index = json.loads((output_path / "model_index.json").read_text())

    # Distilled checkpoints register `fixed_step_sampler_config` on the scheduler.
    scheduler_config_path = output_path / "scheduler" / "scheduler_config.json"
    is_distilled = False
    distilled_sigmas = None
    if scheduler_config_path.is_file():
        scheduler_config = json.loads(scheduler_config_path.read_text())
        fixed_step_cfg = scheduler_config.get("fixed_step_sampler_config")
        is_distilled = fixed_step_cfg is not None
        if fixed_step_cfg and fixed_step_cfg.get("t_list"):
            distilled_sigmas = [float(s) for s in fixed_step_cfg["t_list"]]

    pipeline_class, blocks_class = MODULAR_PIPELINE_CLASSES[is_distilled]

    modular_index: dict[str, Any] = {
        "_blocks_class_name": blocks_class,
        "_class_name": pipeline_class,
        "_diffusers_version": model_index.get("_diffusers_version"),
        "is_distilled": is_distilled,
    }
    if is_distilled:
        # diffusers distilled modular pipeline reads its fixed sampling schedule from this pipeline config rather
        # than off the scheduler; `scheduler_config.json` keeps `fixed_step_sampler_config` for non-modular
        # consumers (e.g. vllm-omni).
        modular_index["distilled_sigmas"] = distilled_sigmas

    for name, value in model_index.items():
        # Keep only saved components: [library, class] pairs with non-null entries.
        # Skips meta keys (_class_name, ...) and unset components ([null, null]).
        if name.startswith("_"):
            continue
        if not (isinstance(value, list) and len(value) == 2):
            continue
        library, class_name = value
        if library is None or class_name is None:
            continue
        modular_index[name] = [
            library,
            class_name,
            {
                "pretrained_model_name_or_path": repo_id,
                "subfolder": name,
                "type_hint": [library, class_name],
                "variant": None,
            },
        ]

    (output_path / "modular_model_index.json").write_text(json.dumps(modular_index, indent=2) + "\n")
    log.info(f"Wrote modular_model_index.json ({pipeline_class}, is_distilled={is_distilled}).")


def convert_model_to_diffusers(args: Args) -> None:
    register_checkpoints()
    checkpoint_config = args.checkpoint.build_checkpoint(checkpoints=OmniSetupOverrides.CHECKPOINTS)
    checkpoint_path = checkpoint_config.download_checkpoint()
    model_dict = checkpoint_config.load_model_config_dict()
    is_edge_model = _is_edge_model_config(model_dict)

    from cosmos_framework.scripts import _convert_model_to_diffusers

    if is_edge_model and (Path(checkpoint_path) / ".metadata").is_file():
        raise ValueError(
            "Raw Cosmos3 Edge DCP cannot be converted directly. Run export_model.py first with the experiment "
            "config, then pass the resulting HF directory to convert_model_to_diffusers.py."
        )

    if is_edge_model:
        if args.config_only:
            raise ValueError("Cosmos3 Edge conversion does not support --config-only.")
        if args.skip_vision_encoder:
            raise ValueError("Cosmos3 Edge conversion cannot skip the reasoner vision assets.")
        if not args.edge_include_reasoner:
            raise ValueError("Cosmos3 Edge conversion requires the pinned reasoner metadata and vision assets.")
        # Fail fast with an actionable message if the installed Diffusers build lacks the
        # Edge transformer API (the exported public config isn't detected as Edge by the
        # low-level checkpoint sniff, so gate here where is_edge_model is already known).
        _convert_model_to_diffusers._validate_edge_transformer_support()
        # Action-policy Edge checkpoints carry a `policy` block (action_chunk_size /
        # conditioning_fps / domain_name) that the diffusers pipeline needs. Non-action
        # Edge (e.g. a video SFT) has no such metadata — convert it too, just without the
        # policy block. The core reasoner+vision conversion below is not action-specific.
        is_action_policy = bool(model_dict["config"]["action_gen"])
        edge_policy_metadata = _load_edge_policy_metadata(Path(checkpoint_path)) if is_action_policy else None

        args.output_path.mkdir(parents=True, exist_ok=True)
        _convert_model_to_diffusers.convert_model_to_diffusers(
            _convert_model_to_diffusers.Args(
                checkpoint_path=Path(checkpoint_path),
                output=str(args.output_path),
                save_pipeline=True,
                dtype="bf16",
                include_reasoner=True,
                reasoner_repo_id=args.edge_reasoner_repo_id,
                reasoner_revision=args.edge_reasoner_revision,
                reasoner_path=args.edge_reasoner_path,
            )
        )
        # The published Edge repository follows the existing Diffusers layout:
        # its root index points at component shards, rather than at an
        # intermediate native Transformers `model.safetensors` file.
        _convert_model_to_diffusers._write_diffusers_safetensors_index(args.output_path)
        (args.output_path / "model.safetensors").unlink(missing_ok=True)
        _write_modular_model_index(args.output_path, repo_id=args.repo_id or args.output_path.name)

        checkpoint_payload: dict[str, Any] = {
            "config_file": None,
            "experiment": None,
            "experiment_overrides": None,
        }
        if edge_policy_metadata is not None:
            checkpoint_payload["policy"] = {
                "action_chunk_size": (
                    args.edge_action_chunk_size
                    if args.edge_action_chunk_size is not None
                    else edge_policy_metadata.action_chunk_size
                ),
                "conditioning_fps": edge_policy_metadata.conditioning_fps,
                "domain_name": edge_policy_metadata.domain_name,
            }
        elif args.edge_action_chunk_size is not None:
            raise ValueError(
                "--edge-action-chunk-size was set, but this Edge checkpoint is not an action policy "
                "(config action_gen is false), so there is no policy manifest to override."
            )
        serialize_config_dict(checkpoint_payload, args.output_path / "checkpoint.json")
        print(f"Saved diffusers checkpoint to {args.output_path}")
        return

    model_dict = load_model_config_from_hf_config(deserialize_config_dict(checkpoint_path / "config.json"))
    args.output_path.mkdir(parents=True, exist_ok=True)

    supports_action = model_dict["config"]["action_gen"]
    supports_sound = model_dict["config"]["sound_gen"]
    if not supports_action:
        log.warning(
            "The checkpoint does not support action generation. For some checkpoints, like "
            "'Cosmos3-Super-Image2Video' it's fine, but make sure it's expected."
        )
    if not supports_sound:
        log.warning(
            "The checkpoint does not support sound generation. For some checkpoints, like "
            "'Cosmos3-Nano-Policy-DROID' it's fine, but make sure it's expected."
        )

    sound_tokenizer_path: Path | None = None
    sound_tokenizer_config_path: Path | None = None
    if supports_sound and not args.config_only:
        sound_tokenizer_dir, sound_tokenizer_name = model_dict["config"]["sound_tokenizer"]["avae_path"].rsplit("/", 1)
        sound_tokenizer_checkpoint = CheckpointConfig.maybe_from_uri(f"s3://bucket/{sound_tokenizer_dir}")
        assert sound_tokenizer_checkpoint is not None
        sound_tokenizer_local = Path(sound_tokenizer_checkpoint.hf.download())
        # HF-published checkpoints ship sound_tokenizer/ in the diffusers layout
        # (config.json + diffusion_pytorch_model.safetensors), which the converter
        # consumes directly; fall back to the legacy AVAE file pair named by the
        # model config's avae_path.
        sound_tokenizer_path = sound_tokenizer_local / "diffusion_pytorch_model.safetensors"
        sound_tokenizer_config_path = sound_tokenizer_local / "config.json"
        if not sound_tokenizer_path.is_file():
            sound_tokenizer_path = sound_tokenizer_local / sound_tokenizer_name
            sound_tokenizer_config_path = sound_tokenizer_path.with_suffix(".json")
        assert sound_tokenizer_path.is_file(), f"Sound tokenizer checkpoint not found: {sound_tokenizer_path}"
        assert sound_tokenizer_config_path.is_file(), f"Sound tokenizer config not found: {sound_tokenizer_config_path}"

    vlm_config = model_dict["config"]["vlm_config"]
    tokenizer_config = vlm_config["tokenizer"]
    vision_encoder_model = (
        tokenizer_config.get("pretrained_model_name")
        or tokenizer_config.get("tokenizer_type")
        or vlm_config["model_name"]
    )

    if not args.config_only:
        _args = _convert_model_to_diffusers.Args(
            checkpoint_path=str(checkpoint_path),
            output=str(args.output_path),
            save_pipeline=True,
            dtype="bf16",
            sound_tokenizer_path=str(sound_tokenizer_path) if sound_tokenizer_path else None,
            sound_tokenizer_config_path=str(sound_tokenizer_config_path) if sound_tokenizer_config_path else None,
            include_sound_tokenizer=supports_sound,
            vision_encoder_model=vision_encoder_model,
            skip_vision_encoder=args.skip_vision_encoder,
            distilled_scheduler=args.distilled_scheduler,
        )
        _convert_model_to_diffusers.convert_model_to_diffusers(_args)

    # Add vlm files
    vlm_repository = model_dict["config"]["vlm_config"]["model_name"]
    vlm_checkpoint = CheckpointDirHf(
        repository=vlm_repository,
        revision="main",
        include=("*.jinja", "*.json", "*.txt"),
    )
    vlm_checkpoint_path = vlm_checkpoint.download()
    for pattern in vlm_checkpoint.include:
        for p in Path(vlm_checkpoint_path).glob(pattern):
            if p.name == "model.safetensors.index.json":
                continue
            shutil.copy(p, args.output_path / p.name)

    # Add top-level config
    config_dict = deserialize_config_dict(args.output_path / "config.json")
    config_dict["architectures"] = ["Cosmos3ForConditionalGeneration"]
    config_dict["model_type"] = "cosmos3_omni"
    # vLLM's `_prepare_weights` breaks after the first pattern with any match, so
    # collapse to a single glob spanning both component subdirs. The unified
    # `model.safetensors.index.json` written below dedupes the consolidated shard.
    config_dict["allow_patterns_overrides"] = ["*/*.safetensors"]
    config_dict["model"] = _build_public_export_model_config(model_dict)
    serialize_config_dict(config_dict, args.output_path / "config.json")

    if not args.config_only:
        # Add top-level index
        _write_diffusers_weight_index(args.output_path)

        # Modular pipeline index (for diffusers ModularPipeline.from_pretrained).
        # Written alongside the task-based model_index.json produced by the save.
        _write_modular_model_index(args.output_path, repo_id=args.repo_id or args.output_path.name)

    checkpoint_metadata_path = checkpoint_path / "checkpoint.json"
    if not checkpoint_metadata_path.is_file():
        checkpoint_metadata_path = checkpoint_path.parent / "checkpoint.json"
    shutil.copy(checkpoint_metadata_path, args.output_path / "checkpoint.json")

    print(f"Saved diffusers checkpoint to {args.output_path}")


def main():
    args = tyro.cli(Args, description=__doc__, config=(tyro.conf.OmitArgPrefixes,))
    convert_model_to_diffusers(args)


if __name__ == "__main__":
    main()