File size: 52,351 Bytes
9f818c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1

import json
import math
import os
from functools import cache
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Self, cast, override

import pydantic
import pynvml
from typing_extensions import assert_never
from tyro.conf import Suppress

from cosmos_framework.inference.common.args import (
    IMAGE_EXTENSIONS,
    VIDEO_EXTENSIONS,
    ArgsBase,
    CfgpSize,
    CheckpointConfig,
    OverridesBase,
    ResolvedFilePath,
    ResolvedFilePathOrUrl,
    SampleArgs,
    SampleOverrides,
    SetupArgs,
    SetupOverrides,
    StrEnum,
    Training,
    _deep_merge,
    download_file,
)
from cosmos_framework.inference.common.config import CONFIG_DIR, PACKAGE_DIR
from cosmos_framework.utils import log
from cosmos_framework.utils.checkpoint_db import CheckpointDirHf
from cosmos_framework.utils.flags import SMOKE, TRAINING

if TYPE_CHECKING:
    from cosmos_framework.configs.base.defaults.model_config import OmniMoTModelConfig
    from cosmos_framework.inference.common.inference import Inference


def _load_transfer_prompt_path(path: str | Path) -> str:
    """Load a transfer prompt from a ``.json`` or plain ``.txt`` file."""
    resolved = Path(path)
    text = resolved.read_text()
    if resolved.suffix.lower() == ".json":
        return json.dumps(json.loads(text))
    return text.strip()


def _load_transfer_negative_prompt_file(path: str | Path) -> str:
    """Load a JSON negative caption file for transfer inference."""
    candidate = Path(path)
    if not candidate.is_file():
        defaults_path = PACKAGE_DIR / "defaults" / candidate.name
        if defaults_path.is_file():
            candidate = defaults_path
        else:
            raise FileNotFoundError(f"Missing negative prompt file: {path} (also checked {defaults_path})")
    return json.dumps(json.loads(candidate.read_text()))


@cache
def _load_modality_defaults(model_mode: str) -> dict[str, Any]:
    default_file = PACKAGE_DIR / f"defaults/{model_mode}/sample_args.json"
    if not default_file.exists():
        raise FileNotFoundError(f"Missing modality defaults: {default_file}")
    data = json.loads(default_file.read_text())
    neg_file = data.pop("negative_prompt_file", None)
    if neg_file is not None:
        neg_path = PACKAGE_DIR / "defaults" / neg_file
        if not neg_path.exists():
            raise FileNotFoundError(f"Missing negative prompt file: {neg_path}")
        data["negative_prompt"] = json.dumps(json.loads(neg_path.read_text()))
    return data


Guidance = Annotated[float, pydantic.Field(ge=0, le=7)]
GuidanceInterval = tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]
PromptUpsamplerProbability = Annotated[float, pydantic.Field(ge=0, le=1)]
ControlGuidance = Annotated[float, pydantic.Field(ge=0, le=10)]


class SamplingArgs(ArgsBase):
    num_steps: pydantic.PositiveInt
    guidance: Guidance
    guidance_interval: GuidanceInterval | None
    normalize_cfg: bool
    shift: float
    sigma_max: float


class SamplingOverrides(OverridesBase):
    """Sampling arguments for 'OmniMoTModel.generate_samples'."""

    num_steps: Training[pydantic.PositiveInt | None] = None
    """Number of steps for the diffusion model."""
    guidance: Training[Guidance | None] = None
    """Guidance scale for the diffusion model."""
    guidance_interval: Training[GuidanceInterval | None] = None
    """Guidance interval for the diffusion model."""
    normalize_cfg: Training[bool | None] = None
    """If True, normalize the CFG output."""
    shift: Training[float | None] = None
    """Shift in the UniPC sampler. Ignored when sampler='edm'."""
    sigma_max: Training[float | None] = None
    """Maximum sigma for the EDM sampler. Ignored when sampler='unipc'."""

    def _build_sampling(self, model_config: "OmniMoTModelConfig", sample_meta: "SampleMeta"):
        if sample_meta.model_mode.is_reasoner:
            # Diffusion sampling fields are unused by the reasoner but required by
            # OmniSampleArgs validation; fill in inert sentinels.
            if self.num_steps is None:
                self.num_steps = 1
            if self.guidance is None:
                self.guidance = 0.0
            if self.normalize_cfg is None:
                self.normalize_cfg = False
            if self.shift is None:
                self.shift = 0.0
            if self.sigma_max is None:
                self.sigma_max = 0.0
            return
        assert self.num_steps is not None
        if SMOKE:
            self.num_steps = min(self.num_steps, 1)


InferenceResolution = Literal["256", "480", "720", "768", "1080"]
if TRAINING:
    Resolution = Literal["256", "480", "704", "720", "768", "1080"]
else:
    Resolution = InferenceResolution
AspectRatio = Literal["1,1", "4,3", "3,4", "16,9", "9,16"]

# Resolutions that only support image generation (num_frames == 1). Video
# generation at these resolutions is rejected by ``_build_vision_data`` because
# the model wasn't trained on temporal data above 720p and ``MAX_NUM_FRAMES``
# has no entry for them.
IMAGE_ONLY_RESOLUTIONS: frozenset[str] = frozenset({"1080"})

MIN_NUM_FRAMES = 24
MAX_NUM_FRAMES: dict[Resolution, int] = {
    "256": 400,
    "480": 300,
    "704": 200,
    "720": 200,
    "768": 200,
}


ModelSize = Literal["0.6B", "2B", "8B", "30B-A3B", "32B", "235B-A22B"]


class ModelMode(StrEnum):
    TEXT2IMAGE = "text2image"
    TEXT2VIDEO = "text2video"
    IMAGE2IMAGE = "image2image"
    IMAGE2VIDEO = "image2video"
    VIDEO2VIDEO = "video2video"

    # Action
    FORWARD_DYNAMICS = "forward_dynamics"
    INVERSE_DYNAMICS = "inverse_dynamics"
    POLICY = "policy"

    REASONER = "reasoner"

    @property
    def is_action(self) -> bool:
        return self in ACTION_MODEL_MODES

    @property
    def is_reasoner(self) -> bool:
        return self in REASONER_MODEL_MODES


# Image-output modes: ``num_frames`` defaults to 1 and the output is saved as a still image.
_IMAGE_OUTPUT_MODES: frozenset[ModelMode] = frozenset({ModelMode.TEXT2IMAGE, ModelMode.IMAGE2IMAGE})

# Modes that produce action tensors and require a model with ``action_gen=True``.
ACTION_MODEL_MODES: frozenset[ModelMode] = frozenset(
    {ModelMode.FORWARD_DYNAMICS, ModelMode.INVERSE_DYNAMICS, ModelMode.POLICY}
)

REASONER_MODEL_MODES: frozenset[ModelMode] = frozenset({ModelMode.REASONER})


class VisionMode(StrEnum):
    IMAGE = "image"
    VIDEO = "video"

    @classmethod
    def from_model_mode(cls, model_mode: ModelMode) -> Self:
        return cls.IMAGE if model_mode in _IMAGE_OUTPUT_MODES else cls.VIDEO


class ConditionVisionMode(StrEnum):
    IMAGE = "image"
    VIDEO = "video"


class NegativeMetadataMode(StrEnum):
    NONE = "none"
    SAME = "same"
    INVERSE = "inverse"


class TransferHintKey(StrEnum):
    EDGE = "edge"
    BLUR = "blur"
    DEPTH = "depth"
    SEG = "seg"
    WSM = "wsm"


class PresetEdgeThreshold(StrEnum):
    VERY_LOW = "very_low"
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    VERY_HIGH = "very_high"


class PresetBlurStrength(StrEnum):
    NONE = "none"
    VERY_LOW = "very_low"
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    VERY_HIGH = "very_high"


class TransferArgs(ArgsBase):
    """Resolved transfer inference arguments for a single control hint."""

    control_path: ResolvedFilePathOrUrl | None = None


class EdgeTransferArgs(TransferArgs):
    preset_edge_threshold: PresetEdgeThreshold = PresetEdgeThreshold.MEDIUM


class BlurTransferArgs(TransferArgs):
    preset_blur_strength: PresetBlurStrength = PresetBlurStrength.MEDIUM


class TransferOverrides(OverridesBase):
    """Transfer inference overrides for a single control hint (all optional)."""

    control_path: ResolvedFilePathOrUrl | None = None
    """Path or URL to pre-computed control input."""

    def download(self, output_dir: Path):
        if self.control_path is not None:
            self.control_path = download_file(self.control_path, output_dir, "transfer_control")


class EdgeTransferOverrides(TransferOverrides):
    preset_edge_threshold: PresetEdgeThreshold | None = None
    """Edge detection threshold preset."""


class BlurTransferOverrides(TransferOverrides):
    preset_blur_strength: PresetBlurStrength | None = None
    """Blur strength preset."""


class SampleMeta(pydantic.BaseModel):
    model_mode: ModelMode
    vision_mode: VisionMode
    condition_vision_mode: ConditionVisionMode | None


RESOLUTION_ADAPTER = pydantic.TypeAdapter(Resolution)
ASPECT_RATIO_ADAPTER = pydantic.TypeAdapter(AspectRatio)

DEFAULT_CONDITION_FRAME_INDEXES_VISION: dict[ConditionVisionMode, list[int]] = {
    ConditionVisionMode.IMAGE: [0],
    ConditionVisionMode.VIDEO: [0, 1],
}


class TextDataArgs(ArgsBase):
    prompt: str

    negative_prompt: str | None

    duration_template: str | None
    resolution_template: str | None
    negative_metadata_mode: NegativeMetadataMode
    inverse_duration_template: str
    inverse_resolution_template: str
    negative_prompt_keep_metadata: bool


class TextDataOverrides(OverridesBase):
    prompt_path: ResolvedFilePath | None = None
    """Path to a .txt file containing the prompt. Only one of 'prompt' or 'prompt_path' should be provided."""
    prompt: str | None = None
    """Text prompt for generation. Only one of 'prompt' or 'prompt_path' should be provided."""

    negative_prompt: str | None = None
    """Negative prompt - describing what you don't want in the generated video."""

    duration_template: Training[str | None] = None
    """Template string for appending duration/fps to prompt. Use {duration} and {fps} placeholders."""
    resolution_template: Training[str | None] = None
    """Template string for appending resolution to prompt. Use {height} and {width} placeholders."""
    negative_metadata_mode: Training[NegativeMetadataMode | None] = None
    """Negative prompt metadata mode: 'none', 'same', or 'inverse'."""
    inverse_duration_template: Training[str | None] = None
    """Inverse template for duration/fps metadata in the negative prompt."""
    inverse_resolution_template: Training[str | None] = None
    """Inverse template for resolution metadata in the negative prompt."""
    negative_prompt_keep_metadata: Training[bool | None] = None
    """Compatibility flag. If True and mode is 'none', mode is promoted to 'same'."""

    def _build_text_data(self, model_config: "OmniMoTModelConfig", sample_meta: SampleMeta):
        if self.prompt is not None:
            pass
        elif self.prompt_path is not None:
            transfer_self = cast("_TransferDataBase", self)
            if transfer_self.transfer_hints:
                self.prompt = _load_transfer_prompt_path(self.prompt_path)
            else:
                self.prompt = self.prompt_path.read_text().strip()
        else:
            self.prompt = ""

        if sample_meta.model_mode.is_reasoner:
            # Negative-prompt / metadata-template fields are unused by the reasoner
            # but required by OmniSampleArgs validation; fill in inert sentinels.
            if self.negative_metadata_mode is None:
                self.negative_metadata_mode = NegativeMetadataMode.NONE
            if self.inverse_duration_template is None:
                self.inverse_duration_template = ""
            if self.inverse_resolution_template is None:
                self.inverse_resolution_template = ""
            if self.negative_prompt_keep_metadata is None:
                self.negative_prompt_keep_metadata = False
            return

        if self.negative_prompt_keep_metadata and self.negative_metadata_mode == NegativeMetadataMode.NONE:
            self.negative_metadata_mode = NegativeMetadataMode.SAME


Fps = Annotated[int, pydantic.Field(ge=1)]
VideoSaveQuality = Annotated[int, pydantic.Field(ge=0, le=10)]
ImageSaveQuality = Annotated[int, pydantic.Field(ge=0, le=100)]


class _VisionDataBase:
    @property
    def condition_vision_mode(self) -> ConditionVisionMode | None:
        self = cast(VisionDataOverrides, self)
        if self.vision_path is not None:
            vision_ext = Path(self.vision_path).suffix.lower()
            if vision_ext in IMAGE_EXTENSIONS:
                return ConditionVisionMode.IMAGE
            elif vision_ext in VIDEO_EXTENSIONS:
                return ConditionVisionMode.VIDEO
            else:
                raise ValueError(f"Invalid vision extension: {vision_ext}")
        else:
            return None


class VisionDataArgs(ArgsBase, _VisionDataBase):
    vision_path: ResolvedFilePath | None
    condition_frame_indexes_vision: list[int]
    condition_video_keep: Literal["first", "last"]

    resolution: Resolution | None
    aspect_ratio: AspectRatio | None
    fps: pydantic.PositiveInt
    num_frames: pydantic.PositiveInt
    video_save_quality: VideoSaveQuality
    image_save_quality: ImageSaveQuality

    @property
    def duration(self) -> float:
        return self.num_frames / self.fps

    @property
    def vision_size(self) -> tuple[int, int]:
        """Vision size (width, height) in pixels.

        Per the VisionDataOverrides.aspect_ratio docstring, ``None`` means
        "default to 16:9 for all modes except image_edit". This property is
        only reached by non-image_edit code paths (image_edit branches off in
        cosmos3/inference.py:_get_image_edit_sample_data before this is
        consulted), so the documented legacy default applies here. Callers
        that want native aspect-ratio preservation (e.g. transfer inference)
        autodetect via cosmos_framework.inference.vision.read_and_resize_media before reaching
        this property and never observe the fallback.
        """
        from cosmos_framework.data.vfm.utils import IMAGE_RES_SIZE_INFO, VIDEO_RES_SIZE_INFO

        assert self.resolution
        aspect_ratio: AspectRatio = self.aspect_ratio or "16,9"
        if self.num_frames == 1:
            return IMAGE_RES_SIZE_INFO[self.resolution][aspect_ratio]
        else:
            return VIDEO_RES_SIZE_INFO[self.resolution][aspect_ratio]

    @property
    def vision_extension(self) -> str:
        return ".jpg" if self.num_frames == 1 else ".mp4"


class VisionDataOverrides(OverridesBase, _VisionDataBase):
    # Vision condition fields
    vision_path: ResolvedFilePathOrUrl | None = None
    """Path or URL to conditioning image/video."""
    condition_frame_indexes_vision: Training[list[int] | None] = None
    """Latent frame indices to condition on. Defaults to [0] for image, [0, 1] for video."""
    condition_video_keep: Training[Literal["first", "last"] | None] = None
    """Whether to take the first or last ``max_frames`` of the conditioning
    video when it is longer than needed. Defaults to ``"first"``. No effect
    on image conditioning."""

    # Vision fields
    resolution: Resolution | None = None
    """Vision resolution.
    
    Defaults to model config resolution.
    """
    aspect_ratio: AspectRatio | None = None
    """Vision aspect ratio. When None, image_edit preserves the input image's native
    aspect ratio; all other modes default to 16:9."""
    fps: Fps | None = None
    """Vision frames per second. Recommended range [10, 30]; quality may be degraded outside of this range."""
    num_frames: pydantic.PositiveInt | None = None
    """Number of vision frames.

    Range by resolution: 256p: [24, 400], 480p: [24, 300], 720p/768p: [24, 200].
    Image-only resolutions (e.g. 1080p) require num_frames=1.
    """
    video_save_quality: Training[VideoSaveQuality | None] = None
    """Quality of the saved video (0-10)."""
    image_save_quality: Training[ImageSaveQuality | None] = None
    """Quality of the saved image (0-100)."""

    @override
    def download(self, output_dir: Path):
        super().download(output_dir)
        self.vision_path = download_file(self.vision_path, output_dir, "vision")

    def _build_vision_data(self, model_config: "OmniMoTModelConfig", sample_meta: SampleMeta):
        """Finalize and validate in-place."""
        if self.vision_path and "://" in self.vision_path:
            raise ValueError("Must call `download()` before building vision data")

        # Reasoner mode treats ``vision_path`` as a PIL image source; resolution/fps/num_frames are unused.
        if sample_meta.model_mode.is_reasoner:
            self.condition_frame_indexes_vision = self.condition_frame_indexes_vision or []
            self.condition_video_keep = self.condition_video_keep or "first"
            self.num_frames = self.num_frames or 1
            # Vision-output fields are unused by the reasoner but required by
            # OmniSampleArgs validation; fill in inert sentinels.
            if self.fps is None:
                self.fps = 1
            if self.video_save_quality is None:
                self.video_save_quality = 0
            if self.image_save_quality is None:
                self.image_save_quality = 0
            return

        if self.condition_frame_indexes_vision is None:
            if sample_meta.condition_vision_mode:
                self.condition_frame_indexes_vision = DEFAULT_CONDITION_FRAME_INDEXES_VISION[
                    sample_meta.condition_vision_mode
                ]
            else:
                self.condition_frame_indexes_vision = []

        if self.condition_video_keep is None:
            self.condition_video_keep = "first"

        # Image edit defaults to input image size
        if sample_meta.model_mode != ModelMode.IMAGE2IMAGE:
            if self.resolution is None:
                self.resolution = RESOLUTION_ADAPTER.validate_python(model_config.resolution)

        # Image-output modes always emit a single frame; infer it so callers don't
        # have to set ``num_frames=1`` in every text2image / image2image preset.
        if self.num_frames is None and sample_meta.vision_mode == VisionMode.IMAGE:
            self.num_frames = 1
        assert self.num_frames is not None
        if self.fps is not None and (self.fps < 10 or self.fps > 30):
            log.warning(f"FPS {self.fps} is outside the recommended range [10, 30]. Quality may be degraded.")
        if self.num_frames > 1:
            assert self.resolution is not None
            if self.resolution in IMAGE_ONLY_RESOLUTIONS:
                raise ValueError(
                    f"Resolution {self.resolution!r} only supports image generation (num_frames=1). "
                    f"For video, use one of: {sorted(MAX_NUM_FRAMES)}"
                )
            if self.num_frames < MIN_NUM_FRAMES or self.num_frames > MAX_NUM_FRAMES[self.resolution]:
                log.warning(
                    f"Number of frames {self.num_frames} is outside the recommended range [{MIN_NUM_FRAMES}, {MAX_NUM_FRAMES[self.resolution]}]. Quality may be degraded."
                )
        if SMOKE:
            self.num_frames = min(self.num_frames, 2)
        temporal_compression_factor = model_config.tokenizer.temporal_compression_factor
        self.num_frames = (
            math.ceil((self.num_frames - 1) / temporal_compression_factor) * temporal_compression_factor + 1
        )


class SoundDataArgs(ArgsBase):
    enable_sound: bool = False


class SoundDataOverrides(OverridesBase):
    """Sound data overrides."""

    enable_sound: Training[bool | None] = None
    """Enable joint video+sound generation (t2vs mode). Requires a checkpoint with sound modules."""

    def _build_sound_data(self, model_config: "OmniMoTModelConfig", sample_meta: SampleMeta):
        if self.enable_sound is None:
            self.enable_sound = False
        if self.enable_sound and not model_config.sound_gen:
            raise ValueError(
                "enable_sound=True requires a model with a sound tokenizer "
                "(model.config.sound_gen=True), but the loaded checkpoint has no sound tokenizer"
            )


class ActionDataArgs(ArgsBase):
    action_path: ResolvedFilePath | None = None
    domain_name: str = ""
    image_size: pydantic.PositiveInt = 256
    action_chunk_size: pydantic.PositiveInt = 16
    raw_action_dim: int | None = None
    view_point: str | None = None


class ActionDataOverrides(OverridesBase):
    """Action data overrides."""

    action_path: Training[ResolvedFilePathOrUrl | None] = None
    """Path to action JSON file. Required for forward_dynamics mode."""
    domain_name: Training[str | None] = None
    """Action domain name passed to get_domain_id()."""
    image_size: Training[pydantic.PositiveInt | None] = None
    """Target image height in pixels (aspect-ratio-preserving resize)."""
    action_chunk_size: Training[pydantic.PositiveInt | None] = None
    """Number of action steps to predict."""
    raw_action_dim: Training[pydantic.PositiveInt | None] = None
    """Dimension of the raw action data. Required when action_path is not provided."""
    view_point: Training[str | None] = None
    """Viewpoint description for the action prompt."""

    @override
    def download(self, output_dir: Path):
        super().download(output_dir)
        self.action_path = download_file(self.action_path, output_dir, "action")

    def _build_action_data(self, model_config: "OmniMoTModelConfig", sample_meta: SampleMeta):
        if self.domain_name is None:
            self.domain_name = ""
        if self.image_size is None:
            self.image_size = 256
        if self.action_chunk_size is None:
            self.action_chunk_size = 16
        if self.view_point is None:
            self.view_point = "ego_view"

        mode = sample_meta.model_mode
        if not mode.is_action:
            return
        if not model_config.action_gen:
            raise ValueError(
                f"model_mode={mode.value!r} requires a model with action support "
                "(model.config.action_gen=True), but the loaded checkpoint has action_gen=False"
            )
        match mode:
            case ModelMode.FORWARD_DYNAMICS:
                if self.action_path is None:
                    raise ValueError(f"'action_path' is required for model_mode={mode.value!r}")
            case ModelMode.INVERSE_DYNAMICS | ModelMode.POLICY:
                pass
            case _:
                assert_never(mode)

        if self.action_path and "://" in self.action_path:
            raise ValueError("Must call `download()` before building action data")


_ReasonerTemperature = Annotated[float, pydantic.Field(gt=0, le=100)]
_ReasonerTopP = Annotated[float, pydantic.Field(gt=0, le=1)]
_ReasonerRepetitionPenalty = Annotated[float, pydantic.Field(gt=0)]


class ReasonerDataArgs(ArgsBase):
    """Resolved reasoner (VLM) text-generation arguments. All fields are ``| None`` so
    non-reasoner samples (which never populate these) pass ``OmniSampleArgs`` validation;
    runtime values for reasoner mode come from ``defaults/reasoner/sample_args.json``."""

    max_new_tokens: pydantic.PositiveInt | None = None
    do_sample: bool | None = None
    temperature: _ReasonerTemperature | None = None
    top_k: pydantic.PositiveInt | None = None
    top_p: _ReasonerTopP | None = None
    repetition_penalty: _ReasonerRepetitionPenalty | None = None
    presence_penalty: float | None = None


class ReasonerDataOverrides(OverridesBase):
    """Reasoner overrides for ``model_mode='reasoner'``. ``vision_path`` (if set) is
    used as the conditioning image; the VLM processor handles preprocessing."""

    max_new_tokens: pydantic.PositiveInt | None = None
    """Maximum number of new tokens to generate per prompt."""
    do_sample: bool | None = None
    """If True, sample from the logits; otherwise greedy decode."""
    temperature: _ReasonerTemperature | None = None
    """Sampling temperature. Ignored when ``do_sample`` is False."""
    top_k: pydantic.PositiveInt | None = None
    """Top-k logit truncation. Ignored when ``do_sample`` is False."""
    top_p: _ReasonerTopP | None = None
    """Nucleus-sampling threshold ``0 < top_p <= 1``. Ignored when ``do_sample`` is False."""
    repetition_penalty: _ReasonerRepetitionPenalty | None = None
    """CTRL/HF-style multiplicative repetition penalty (>0). ``1.0`` is identity."""
    presence_penalty: float | None = None
    """Additive presence penalty (any sign). ``0.0`` is identity."""

    def _build_reasoner_data(self, model_config: "OmniMoTModelConfig", sample_meta: SampleMeta):
        if not sample_meta.model_mode.is_reasoner:
            return
        self = cast("SampleDataOverrides", self)
        if not self.prompt.strip():
            raise ValueError("Reasoner inference requires a non-empty 'prompt'.")


class _TransferDataBase:
    @property
    def transfer_hints(self) -> dict[TransferHintKey, TransferOverrides | TransferArgs]:
        # Iteration order is `TransferHintKey` enum order, not JSON-key order — keep this
        # deterministic so the model sees a stable [ctrl_1, ..., ctrl_N] sequence.
        return {key: getattr(self, key.value) for key in TransferHintKey if getattr(self, key.value) is not None}


class TransferDataArgs(ArgsBase, _TransferDataBase):
    control_guidance: ControlGuidance = 1.0
    control_guidance_interval: GuidanceInterval | None = None
    edge: EdgeTransferArgs | None = None
    blur: BlurTransferArgs | None = None
    depth: TransferArgs | None = None
    seg: TransferArgs | None = None
    wsm: TransferArgs | None = None
    negative_prompt_file: str | None = None
    """JSON negative caption file for transfer specs (absolute path or filename under ``defaults/``)."""
    num_video_frames_per_chunk: pydantic.PositiveInt | None = None
    num_conditional_frames: pydantic.NonNegativeInt | None = None
    max_frames: pydantic.PositiveInt | None = None
    show_control_condition: bool | None = None
    show_input: bool | None = None
    num_first_chunk_conditional_frames: pydantic.NonNegativeInt | None = None
    share_vision_temporal_positions: bool | None = None


class TransferDataOverrides(OverridesBase, _TransferDataBase):
    """Transfer inference overrides — activated when at least one control hint is set."""

    control_guidance: Training[ControlGuidance | None] = None
    """Control-CFG scale for transfer. The control map stays in the main forward; 1.0 disables
    the extra comparison forward that drops control-map vision items. Values > 1.0 blend
    velocities from with-maps vs without-maps forwards on the generated clip."""
    control_guidance_interval: Training[GuidanceInterval | None] = None
    """Timestep interval [lo, hi] (0–1000) in which control-CFG is applied; None applies
    on every step."""
    edge: EdgeTransferOverrides | None = None
    """Edge (Canny) control-hint overrides; set to activate the edge transfer hint."""
    blur: BlurTransferOverrides | None = None
    """Blur control-hint overrides; set to activate the blur transfer hint."""
    depth: TransferOverrides | None = None
    """Depth control-hint overrides; set to activate the depth transfer hint."""
    seg: TransferOverrides | None = None
    """Segmentation control-hint overrides; set to activate the segmentation transfer hint."""
    wsm: TransferOverrides | None = None
    """World-surface-map (WSM) control-hint overrides; set to activate the WSM transfer hint."""
    negative_prompt_file: str | None = None
    """JSON negative caption file for transfer specs (absolute path or filename under ``defaults/``)."""
    num_video_frames_per_chunk: pydantic.PositiveInt | None = None
    """Number of video frames generated per autoregressive chunk."""
    num_conditional_frames: pydantic.NonNegativeInt | None = None
    """Number of conditioning frames carried into each generated chunk."""
    max_frames: pydantic.PositiveInt | None = None
    """Maximum number of frames to generate across all chunks."""
    show_control_condition: bool | None = None
    """If set, include the control condition (hint map) in the saved output."""
    show_input: bool | None = None
    """If set, include the input video alongside the generated output."""
    num_first_chunk_conditional_frames: pydantic.NonNegativeInt | None = None
    """Number of conditioning frames for the first chunk (defaults to ``num_conditional_frames``)."""
    share_vision_temporal_positions: bool | None = None
    """Share vision temporal position ids across autoregressive chunks."""

    @pydantic.model_validator(mode="after")
    def _validate_transfer_hints(self) -> Self:
        hint_field_names = {k.value for k in TransferHintKey}
        # ``control_guidance`` is concretized to its no-op default (1.0) by
        # ``_build_transfer_data`` for *every* sample, transfer or not, so the
        # default value must round-trip without a hint. Only an *explicit*
        # non-default ``control_guidance`` signals an intended-but-incomplete
        # transfer config, so it is checked separately below.
        exempt = hint_field_names | {"control_guidance"}
        transfer_only = [
            name
            for name in TransferDataOverrides.__annotations__
            if name in type(self).model_fields and name not in exempt
        ]
        configured = any(getattr(self, f) is not None for f in transfer_only)
        cg = self.control_guidance
        cg_configured = cg is not None and cg != 1.0
        if (configured or cg_configured) and not self.transfer_hints:
            raise ValueError(
                f"transfer inference requires at least one control hint ({', '.join(k.value for k in TransferHintKey)})"
            )
        return self

    @override
    def download(self, output_dir: Path):
        super().download(output_dir)
        for config in self.transfer_hints.values():
            assert isinstance(config, TransferOverrides)
            config.download(output_dir)

    _TRANSFER_SAMPLE_DEFAULTS: ClassVar[dict[str, Any]] = {
        "num_video_frames_per_chunk": 93,
        "num_conditional_frames": 1,
        "max_frames": 5000,
        "show_control_condition": False,
        "show_input": False,
        "num_first_chunk_conditional_frames": 0,
        "share_vision_temporal_positions": True,
    }
    _TRANSFER_HINT_DEFAULTS: ClassVar[dict[TransferHintKey, dict[str, Any]]] = {
        TransferHintKey.EDGE: {"preset_edge_threshold": PresetEdgeThreshold.MEDIUM},
        TransferHintKey.BLUR: {"preset_blur_strength": PresetBlurStrength.MEDIUM},
    }
    # Tuned guidance / control_guidance per transfer task. Applied when the input JSON omits
    # these fields (generic video2video sampling defaults otherwise apply).
    _TRANSFER_DEFAULTS: ClassVar[dict[TransferHintKey, dict[str, Any]]] = {
        TransferHintKey.EDGE: {"guidance": 3.0, "control_guidance": 1.5, "shift": 10.0},
        TransferHintKey.BLUR: {"guidance": 3.0, "control_guidance": 1.5, "shift": 10.0},
        TransferHintKey.DEPTH: {"guidance": 3.0, "control_guidance": 1.5, "shift": 10.0},
        TransferHintKey.SEG: {"guidance": 3.0, "control_guidance": 2.0, "shift": 10.0},
        TransferHintKey.WSM: {
            "guidance": 1.0,
            "control_guidance": 3.0,
            "shift": 10.0,
            "num_frames": 101,
            "fps": 10,
            "num_video_frames_per_chunk": 101,
        },
    }

    def _build_transfer_data(
        self,
        model_config: "OmniMoTModelConfig",
        sample_meta: SampleMeta,
        *,
        user_fields: frozenset[str] | None = None,
    ):
        self = cast("SampleDataOverrides", self)
        # ``control_guidance`` is a required float in ``TransferDataArgs``.
        # Keep it concrete even for non-transfer samples so ``OmniSampleArgs``
        # validation never sees ``None``.
        if self.control_guidance is None:
            self.control_guidance = 1.0
        hints = self.transfer_hints
        if not hints:
            return

        if self.negative_prompt is None and self.negative_prompt_file is not None:
            self.negative_prompt = _load_transfer_negative_prompt_file(self.negative_prompt_file)

        for field, default in self._TRANSFER_SAMPLE_DEFAULTS.items():
            if getattr(self, field) is None:
                setattr(self, field, default)

        for hint_key, config in hints.items():
            for field, default in self._TRANSFER_HINT_DEFAULTS.get(hint_key, {}).items():
                if getattr(config, field) is None:
                    setattr(config, field, default)

            if self.vision_path is None and config.control_path is None:
                raise ValueError(
                    f"transfer inference requires 'vision_path' or a pre-computed 'control_path' (hint: {hint_key})"
                )

        if len(hints) == 1:
            hint_key = next(iter(hints))
            for field, value in self._TRANSFER_DEFAULTS[hint_key].items():
                if user_fields is None or field not in user_fields:
                    setattr(self, field, value)


class _SampleDataBase:
    @property
    def resolved_model_mode(self) -> ModelMode:
        """Return ``model_mode`` if set, else infer the VFM modality from ``vision_path`` and ``num_frames``."""
        self = cast(SampleDataOverrides, self)
        if self.model_mode is not None:
            return self.model_mode
        input_mode = self.condition_vision_mode.value if self.condition_vision_mode else "text"
        output_mode = VisionMode.IMAGE.value if self.num_frames == 1 else VisionMode.VIDEO.value
        return ModelMode(f"{input_mode}2{output_mode}")

    @property
    def sample_meta(self) -> SampleMeta:
        self = cast(SampleDataOverrides, self)
        mode = self.resolved_model_mode
        return SampleMeta(
            model_mode=mode,
            vision_mode=VisionMode.from_model_mode(mode),
            condition_vision_mode=self.condition_vision_mode,
        )


class SampleDataArgs(
    _SampleDataBase,
    TextDataArgs,
    VisionDataArgs,
    SoundDataArgs,
    ActionDataArgs,
    ReasonerDataArgs,
    TransferDataArgs,
):
    model_mode: ModelMode


class SampleDataOverrides(
    _SampleDataBase,
    TextDataOverrides,
    VisionDataOverrides,
    SoundDataOverrides,
    ActionDataOverrides,
    ReasonerDataOverrides,
    TransferDataOverrides,
):
    """Sample data arguments for 'OmniMoTModel.generate_samples'."""

    model_mode: ModelMode | None = None
    """Generation modality. When omitted, the VFM modality is inferred from ``vision_path`` and
    ``num_frames``; action modes must be set explicitly."""


class PromptUpsamplingArgs(ArgsBase):
    native_prompt_upsampling: bool = False
    """If True, use the native prompt upsampler."""
    prompt_upsampler_max_tokens: pydantic.PositiveInt
    """Maximum tokens generated by the prompt upsampler."""
    prompt_upsampler_temperature: pydantic.NonNegativeFloat
    """Native prompt upsampler sampling temperature."""
    prompt_upsampler_top_p: PromptUpsamplerProbability
    """Native prompt upsampler nucleus sampling probability."""
    prompt_upsampler_top_k: pydantic.NonNegativeInt
    """Native prompt upsampler top-k sampling limit."""
    prompt_upsampler_repetition_penalty: pydantic.PositiveFloat
    """Native prompt upsampler CTRL/HF-style multiplicative repetition penalty.

    Applied to logits at vocab positions already seen in each caption's
    history (prompt + everything generated so far).  ``>1.0`` discourages
    verbatim repetition, ``<1.0`` encourages it, ``1.0`` is identity and
    adds zero overhead to the reasoner AR loop.  Constrained ``> 0`` so
    the CTRL formula (``logit /= penalty`` for positive logits, ``logit *
    penalty`` for negative) stays well-defined.
    """
    prompt_upsampler_presence_penalty: float
    """Native prompt upsampler OpenAI-style additive presence penalty.

    Subtracted once from every logit at a vocab position already seen in
    each caption's history (binary presence, not frequency).  ``>0``
    discourages reuse, ``<0`` encourages it, ``0`` is identity.
    Unconstrained sign: negative values are valid for legitimate "favor
    repetition" use cases.
    """
    prompt_upsampler_seed: int | None = None
    """Optional integer seed for the native prompt upsampler's sampling RNG.

    When set (and ``prompt_upsampler_temperature > 0`` so the AR loop
    actually samples), a device-local ``torch.Generator`` is seeded once
    inside ``_impl_generate_reasoner_text`` and threaded into every
    ``torch.multinomial`` draw, making the upsampled caption a
    deterministic function of ``seed``, the prompt, and the penalty
    masks.  ``None`` (default) consumes the device's default RNG,
    preserving the pre-seed behavior.  Greedy decoding (temperature 0)
    never reads the generator, so the value has no effect in that case.
    Under multi-rank inference, callers that need cross-rank agreement
    on sampled upsampler tokens must pass the same seed on every rank.
    """


class PromptUpsamplingOverrides(OverridesBase):
    prompt_upsampling: Training[bool | None] = None
    """If True, replace the prompt with a dense JSON prompt."""
    prompt_upsampler_max_tokens: pydantic.PositiveInt | None = None
    """Maximum tokens generated by the prompt upsampler."""
    prompt_upsampler_temperature: pydantic.NonNegativeFloat | None = None
    """Native prompt upsampler sampling temperature."""
    prompt_upsampler_top_p: PromptUpsamplerProbability | None = None
    """Native prompt upsampler nucleus sampling probability."""
    prompt_upsampler_top_k: pydantic.NonNegativeInt | None = None
    """Native prompt upsampler top-k sampling limit."""
    prompt_upsampler_repetition_penalty: pydantic.PositiveFloat | None = None
    """Native prompt upsampler CTRL/HF-style multiplicative repetition penalty (>0)."""
    prompt_upsampler_presence_penalty: float | None = None
    """Native prompt upsampler OpenAI-style additive presence penalty (any sign)."""
    prompt_upsampler_seed: int | None = None
    """Optional integer seed for the native prompt upsampler's sampling RNG."""

    def _build_prompt_upsampling(self, *, model_config: "OmniMoTModelConfig") -> None:
        if self.prompt_upsampler_max_tokens is None:
            self.prompt_upsampler_max_tokens = 20000
        if self.prompt_upsampler_temperature is None:
            self.prompt_upsampler_temperature = 0.7
        if self.prompt_upsampler_top_p is None:
            self.prompt_upsampler_top_p = 0.8
        if self.prompt_upsampler_top_k is None:
            self.prompt_upsampler_top_k = 20
        if self.prompt_upsampler_repetition_penalty is None:
            self.prompt_upsampler_repetition_penalty = 1.0
        if self.prompt_upsampler_presence_penalty is None:
            self.prompt_upsampler_presence_penalty = 1.5
        if self.prompt_upsampler_seed is None:
            self.prompt_upsampler_seed = 3407


class OmniSampleArgs(
    SampleArgs,
    SamplingArgs,
    SampleDataArgs,
    PromptUpsamplingArgs,
): ...


class OmniSampleOverrides(
    SampleOverrides,
    SamplingOverrides,
    SampleDataOverrides,
    PromptUpsamplingOverrides,
):
    defaults_file: ResolvedFilePath | None = None
    """Path to a JSON file of per-modality default sample fields. Overrides the built-in defaults."""

    _VLM_MODEL_SIZE: ClassVar[dict[str, ModelSize]] = {
        "Qwen/Qwen3-0.6B": "0.6B",
        "Qwen/Qwen3-VL-2B-Instruct": "2B",
        "Qwen/Qwen3-VL-8B-Instruct": "8B",
        "Qwen/Qwen3-VL-32B-Instruct": "32B",
        "Qwen/Qwen3-VL-30B-A3B-Instruct": "30B-A3B",
        "Qwen/Qwen3-VL-235B-A22B-Instruct": "235B-A22B",
    }

    _RESOLUTION_SHIFT_DEFAULTS: ClassVar[dict[(ModelSize, Resolution), float]] = {
        ("8B", "256"): 3.0,
        ("8B", "480"): 5.0,
        ("8B", "720"): 10.0,
        ("8B", "768"): 10.0,
        ("32B", "256"): 5.0,
        ("32B", "480"): 5.0,
        ("32B", "720"): 5.0,
        ("32B", "768"): 5.0,
    }

    @override
    def build_sample(self, *, model_config: Any) -> OmniSampleArgs:
        model_config = cast("OmniMoTModelConfig", model_config)
        sample_meta = self.sample_meta

        # Apply per-modality defaults from JSON config files.
        # User-provided values take precedence over JSON defaults.
        if self.defaults_file is not None:
            defaults = json.loads(self.defaults_file.read_text())
        else:
            defaults = _load_modality_defaults(sample_meta.model_mode)
        overrides = self.model_dump(exclude_none=True)
        shift_configured = "shift" in overrides or defaults.get("shift") is not None
        user_fields = frozenset(overrides)
        merged_data = _deep_merge(defaults, overrides)
        merged_data = {k: v for k, v in merged_data.items() if k in type(self).model_fields}
        merged = type(self).model_validate(merged_data)

        self.__dict__.update(merged.__dict__)
        self.model_mode = sample_meta.model_mode

        self._build_sample()
        self._build_sampling(model_config=model_config, sample_meta=sample_meta)
        self._build_text_data(model_config=model_config, sample_meta=sample_meta)
        self._build_vision_data(model_config=model_config, sample_meta=sample_meta)

        self._build_prompt_upsampling(model_config=model_config)

        self._build_action_data(model_config=model_config, sample_meta=sample_meta)

        self._build_sound_data(model_config=model_config, sample_meta=sample_meta)

        self._build_reasoner_data(model_config=model_config, sample_meta=sample_meta)

        self._build_transfer_data(
            model_config=model_config, sample_meta=sample_meta, user_fields=user_fields
        )

        if not shift_configured and not sample_meta.model_mode.is_reasoner:
            model_size = self._VLM_MODEL_SIZE[model_config.vlm_config.model_name]
            key = (model_size, self.resolution)
            if key in self._RESOLUTION_SHIFT_DEFAULTS:
                self.shift = self._RESOLUTION_SHIFT_DEFAULTS[key]

        # Engage the in-model (V4.2-template) native upsampler exactly
        # when the user opted into upsampling AND the dense
        # (endpoint-backed) path did not successfully apply it.
        # ``prompt_upsampling_applied`` is:
        #   * ``True``  — dense path applied, native should skip.
        #   * ``False`` — dense path declined (no endpoint configured),
        #                  native should take over.
        #   * ``None``  — dense path never ran.  Two cases produce this:
        #       (a) release builds, where the dense dispatcher and the
        #           ``prompt_upsampling_applied`` field itself are both
        #           stripped, so the attribute does not exist on the
        #           instance — ``getattr(..., None)`` papers over the
        #           AttributeError;
        #       (b) ``prompt_upsampling`` was not requested, in which
        #           case the leading ``is True`` short-circuits anyway.
        # Both ``False`` and ``None`` should engage native upsampling
        # when the user opted in, so the gate is ``not applied`` rather
        # than ``applied is False`` (which would miss case (a)).
        prompt_upsampling_applied = getattr(self, "prompt_upsampling_applied", None)
        native_prompt_upsampling = self.prompt_upsampling is True and not prompt_upsampling_applied
        return self._build(OmniSampleArgs, native_prompt_upsampling=native_prompt_upsampling)


_MODEL_MEMORY_FACTOR: int = int(1e9) * 2 * 2  # 1B params/tower * 2 bytes/param (bfloat16) * 2 towers
MODEL_MEMORY_BYTES_BY_SIZE: dict[ModelSize, int] = {
    "0.6B": round(0.6 * _MODEL_MEMORY_FACTOR),
    "2B": 2 * _MODEL_MEMORY_FACTOR,
    "8B": 8 * _MODEL_MEMORY_FACTOR,
    "30B-A3B": 30 * _MODEL_MEMORY_FACTOR,
    "32B": 32 * _MODEL_MEMORY_FACTOR,
    "235B-A22B": 235 * _MODEL_MEMORY_FACTOR,
}

_CHECKPOINTS: dict[str, CheckpointConfig] = {
    "Cosmos3-Nano": CheckpointConfig(
        model_memory_bytes=MODEL_MEMORY_BYTES_BY_SIZE["8B"],
        config_file=str(CONFIG_DIR / "model/Cosmos3-Nano.yaml"),
        s3_uri="s3://bucket1/cosmos3_vfm/cosmos3_ga_midtraining/cosmos3_ga_16bm8b_v2_midtrain/checkpoints/iter_000006000/",
        hf=CheckpointDirHf(
            repository="nvidia/Cosmos3-Nano",
            revision="main",
        ),
    ),
    "Cosmos3-Super": CheckpointConfig(
        model_memory_bytes=MODEL_MEMORY_BYTES_BY_SIZE["32B"],
        config_file=str(CONFIG_DIR / "model/Cosmos3-Super.yaml"),
        s3_uri="s3://bucket1/cosmos3_vfm/cosmos3_ga_midtraining/cosmos3_ga_64bm32b_v3_midtrain/checkpoints/iter_000001800/",
        hf=CheckpointDirHf(
            repository="nvidia/Cosmos3-Super",
            revision="main",
        ),
    ),
    # Task-specialized Super variants published as diffusers HF checkpoints.
    # s3_uri is unused for HF-backed checkpoints (kept for parity with the
    # registry schema); the architecture lives in each model YAML.
    "Cosmos3-Super-Image2Video": CheckpointConfig(
        model_memory_bytes=MODEL_MEMORY_BYTES_BY_SIZE["32B"],
        config_file=str(CONFIG_DIR / "model/Cosmos3-Super.yaml"),
        s3_uri="s3://bucket1/cosmos3_vfm/cosmos3_ga_image2video/",
        hf=CheckpointDirHf(
            repository="nvidia/Cosmos3-Super-Image2Video",
            revision="main",
        ),
        # Self-contained checkpoint: use its bundled processor instead of
        # downloading the base Cosmos3-Super repo just for the tokenizer.
        vlm_processor_from_checkpoint=True,
    ),
    "Cosmos3-Super-Text2Image": CheckpointConfig(
        model_memory_bytes=MODEL_MEMORY_BYTES_BY_SIZE["32B"],
        config_file=str(CONFIG_DIR / "model/Cosmos3-Super.yaml"),
        s3_uri="s3://bucket1/cosmos3_vfm/cosmos3_ga_text2image/",
        hf=CheckpointDirHf(
            repository="nvidia/Cosmos3-Super-Text2Image",
            revision="main",
        ),
        # Self-contained checkpoint: use its bundled processor instead of
        # downloading the base Cosmos3-Super repo just for the tokenizer.
        vlm_processor_from_checkpoint=True,
    ),
}
DEFAULT_CHECKPOINT_NAME = "Cosmos3-Nano"
DEFAULT_CHECKPOINT = _CHECKPOINTS[DEFAULT_CHECKPOINT_NAME]

MAX_CP_SIZE = 32
CpSize = Annotated[int, pydantic.Field(ge=1, le=MAX_CP_SIZE)]


class OmniSetupArgs(SetupArgs):
    variant: Suppress[Literal["omni"]] = "omni"
    """Discriminator."""

    # pyrefly: ignore[bad-override]
    sample_overrides: OmniSampleOverrides

    sampler: Literal["unipc", "edm"]

    # Override defaults
    cp_size: CpSize

    @override
    @classmethod
    def get_sample_overrides_cls(cls) -> type[SampleOverrides]:
        return OmniSampleOverrides

    @override
    @classmethod
    def get_sample_args_cls(cls) -> type[SampleArgs]:
        return OmniSampleArgs

    @override
    @classmethod
    def get_inference_cls(cls) -> type["Inference"]:
        from cosmos_framework.inference.inference import OmniInference

        return OmniInference

    @pydantic.model_validator(mode="after")
    def _validate_parallelism(self) -> Self:
        world_size = int(os.environ.get("WORLD_SIZE", "0"))

        if world_size:
            if self.dp_shard_size * self.dp_replicate_size > world_size:
                raise ValueError(
                    f"dp_shard_size({self.dp_shard_size}) * dp_replicate_size({self.dp_replicate_size}) must be <= WORLD_SIZE({world_size})"
                )
            if world_size % (self.dp_shard_size * self.dp_replicate_size) != 0:
                raise ValueError(
                    f"dp_shard_size({self.dp_shard_size}) * dp_replicate_size({self.dp_replicate_size}) must divide WORLD_SIZE({world_size})"
                )

        if world_size:
            if self.cp_size * self.cfgp_size > world_size:
                raise ValueError(
                    f"cp_size({self.cp_size}) * cfgp_size({self.cfgp_size}) must be <= WORLD_SIZE({world_size})"
                )
            if world_size % (self.cp_size * self.cfgp_size) != 0:
                raise ValueError(
                    f"cp_size({self.cp_size}) * cfgp_size({self.cfgp_size}) must divide WORLD_SIZE({world_size})"
                )

        return self


class OmniSetupOverrides(SetupOverrides):
    variant: Suppress[Literal["omni"]] = "omni"
    """Discriminator."""

    CHECKPOINTS: ClassVar[dict[str, CheckpointConfig]] = _CHECKPOINTS

    sample_overrides: OmniSampleOverrides = OmniSampleOverrides()

    model_size: Training[ModelSize | None] = None

    sampler: Literal["unipc", "edm"] = "unipc"

    # Override defaults
    dp_replicate_size: pydantic.NonNegativeInt = 0
    dp_shard_size: pydantic.NonNegativeInt = 0
    cp_size: CpSize | Literal[0] = 0
    cfgp_size: CfgpSize | Literal[0] = 0

    use_cuda_graphs: bool = False

    compiled_region: Literal["all", "language"] = "all"
    # Unsupported
    tp_size: Suppress[pydantic.NonNegativeInt] = 1

    def _build_model_parallelism(self, world_size: int, device_memory_bytes: int):
        if not self.dp_shard_size:
            # Shard the model across every rank by default (full FSDP). world_size == 0
            # means we're not under torchrun (single process) -> a single shard.
            self.dp_shard_size = max(1, world_size)
        if not self.dp_replicate_size:
            self.dp_replicate_size = max(1, world_size // self.dp_shard_size)

    def _build_context_parallelism(self, world_size: int):
        if not self.cfgp_size:
            match self.parallelism_preset:
                case "throughput":
                    self.cfgp_size = 1
                case "latency":
                    self.cfgp_size = max(1, min(2, world_size))
                case _:
                    assert_never(self.parallelism_preset)
        if not self.cp_size:
            match self.parallelism_preset:
                case "throughput":
                    self.cp_size = 1
                case "latency":
                    self.cp_size = max(1, min(MAX_CP_SIZE, world_size // self.cfgp_size))
                case _:
                    assert_never(self.parallelism_preset)

    @override
    def _build_parallelism(self, world_size: int | None, local_world_size: int | None, device_memory_bytes: int | None):
        if world_size is None:
            world_size = int(os.environ.get("WORLD_SIZE", "0"))
        if local_world_size is None:
            local_world_size = int(os.environ.get("LOCAL_WORLD_SIZE", str(world_size)))
        if device_memory_bytes is None:
            device_memory_bytes = _get_device_memory_bytes()

        if self.model_memory_bytes is None and self.model_size is not None:
            self.model_memory_bytes = MODEL_MEMORY_BYTES_BY_SIZE[self.model_size]
        self._build_model_parallelism(world_size=world_size, device_memory_bytes=device_memory_bytes)
        self._build_context_parallelism(world_size=world_size)

    @override
    def build_setup(
        self, world_size: int | None = None, local_world_size: int | None = None, device_memory_bytes: int | None = None
    ) -> OmniSetupArgs:
        self._build_setup()
        self._build_checkpoint(checkpoints=self.CHECKPOINTS)
        self._build_parallelism(
            world_size=world_size, local_world_size=local_world_size, device_memory_bytes=device_memory_bytes
        )
        return self._build(OmniSetupArgs)


# Reserved: the memory-based shard-size heuristic. No longer used as the default
# (we now shard across every rank), but kept for future opt-in / reference.
def _get_dp_shard_size(
    model_memory_bytes: int, device_memory_bytes: int, device_memory_utilization: float = 0.75
) -> int:
    return math.ceil(model_memory_bytes / device_memory_bytes / device_memory_utilization)


@cache
def _get_device_memory_bytes() -> int:
    try:
        pynvml.nvmlInit()
        handle = pynvml.nvmlDeviceGetHandleByIndex(0)
        info = pynvml.nvmlDeviceGetMemoryInfo(handle)
        pynvml.nvmlShutdown()
        return info.total
    except Exception:
        # Fallback for unified memory architectures (e.g., GB10) where
        # nvmlDeviceGetMemoryInfo is not supported.
        import torch
        if torch.cuda.is_available():
            return int(torch.cuda.get_device_properties(0).total_memory)
        return 128 * 1024**3  # Default 128GB