File size: 9,021 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
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1
"""Canonical structured-JSON caption: schema, robust parsing, and assembly.

The Cosmos3 model's native text-prompt format is structured JSON (see
``docs/prompt_upsampling.md``).  This module is the single source of truth for
that format on the *captioning / training* side:

* :data:`CAPTION_JSON_KEY` — the JSONL / ``t2w_window`` key under which the
  structured caption object is stored (preferred over the dense ``caption``).
* :class:`StructuredCaption` — a permissive pydantic model mirroring
  ``inference/prompting_templates/external_api/t2v_i2v_video_json_schema.json``.
* :func:`parse_structured_caption` — robustly extract the Phase-1
  ``<scene_draft>`` JSON object from a VLM response.
* :func:`assemble_caption_json` — combine the Phase-1 draft, the polished
  Phase-2 dense narrative (stored as ``temporal_caption``), and the clip's real
  media fields into a single validated caption object.

The model is intentionally permissive (every field optional, ``extra="allow"``)
so that partial or slightly-off VLM output still round-trips instead of being
dropped; the goal is structural validation, not rejection.
"""

import json
import re
from typing import Any

import pydantic
from pydantic import ConfigDict

# Key used in the SFT JSONL ``t2w_windows[]`` entries and recognised by the SFT
# loader (sft_dataset.py) as the highest-priority caption.  Kept here so the
# captioner, the JSONL converter, and the loader cannot drift apart.
CAPTION_JSON_KEY = "caption_json"

_PERMISSIVE = ConfigDict(extra="allow")


class _Base(pydantic.BaseModel):
    model_config = _PERMISSIVE


class Subject(_Base):
    description: str | None = None
    appearance_details: str | None = None
    relationship: str | None = None
    location: str | None = None
    relative_size: str | None = None
    orientation: str | None = None
    pose: str | None = None
    action: str | None = None
    state_changes: str | None = None
    clothing: str | None = None
    expression: str | None = None
    gender: str | None = None
    age: str | None = None
    skin_tone_and_texture: str | None = None
    facial_features: str | None = None
    number_of_subjects: int | None = None
    number_of_arms: int | None = None
    number_of_legs: int | None = None


class Lighting(_Base):
    conditions: str | None = None
    direction: str | None = None
    shadows: str | None = None
    illumination_effect: str | None = None


class Aesthetics(_Base):
    composition: str | None = None
    color_scheme: str | None = None
    mood_atmosphere: str | None = None
    patterns: str | None = None


class Cinematography(_Base):
    camera_motion: str | None = None
    framing: str | None = None
    camera_angle: str | None = None
    depth_of_field: str | None = None
    focus: str | None = None
    lens_focal_length: str | None = None


class Action(_Base):
    time: str | None = None
    description: str | None = None


class TextElement(_Base):
    text: str | None = None
    category: str | None = None
    appearance: str | None = None
    spatial_temporal: str | None = None
    context: str | None = None


class Segment(_Base):
    segment_index: int | None = None
    time_range: str | None = None
    description: str | None = None
    key_changes: str | None = None
    camera: str | None = None


class Resolution(_Base):
    H: int | None = None
    W: int | None = None


class StructuredCaption(_Base):
    """Permissive mirror of the external-API T2V/I2V JSON schema."""

    subjects: list[Subject] | None = None
    background_setting: str | None = None
    lighting: Lighting | None = None
    aesthetics: Aesthetics | None = None
    cinematography: Cinematography | None = None
    style_medium: str | None = None
    artistic_style: str | None = None
    context: str | None = None
    actions: list[Action] | None = None
    text_and_signage_elements: list[TextElement] | None = None
    segments: list[Segment] | None = None
    transitions: list[str] | None = None
    temporal_caption: str | None = None
    audio_description: str | None = None
    resolution: Resolution | None = None
    aspect_ratio: str | None = None
    duration: str | None = None
    fps: int | None = None


def extract_xml_tag(text: str, tag: str) -> str | None:
    """Return the inner text of ``<tag>...</tag>`` (DOTALL), or ``None``."""
    match = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", text, re.DOTALL)
    return match.group(1).strip() if match else None


def _strip_code_fences(text: str) -> str:
    """Strip a leading ```json / ``` fence and trailing ``` if present."""
    cleaned = text.strip()
    if cleaned.startswith("```"):
        cleaned = re.sub(r"^```(?:json)?\s*\n?", "", cleaned)
        cleaned = re.sub(r"\n?```\s*$", "", cleaned)
    return cleaned.strip()


def _first_json_object(text: str) -> str | None:
    """Return the first balanced ``{...}`` block in ``text``, or ``None``.

    Brace-counting fallback for when the model wraps the JSON in prose without
    fences/tags.  Ignores braces inside double-quoted strings.
    """
    start = text.find("{")
    if start < 0:
        return None
    depth = 0
    in_str = False
    escaped = False
    for i in range(start, len(text)):
        ch = text[i]
        if in_str:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == '"':
                in_str = False
            continue
        if ch == '"':
            in_str = True
        elif ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return text[start : i + 1]
    return None


def parse_structured_caption(text: str) -> dict | None:
    """Extract the Phase-1 ``<scene_draft>`` JSON object from a VLM response.

    Resolution order, each tolerant of ```` ```json ```` fences:

    1. The ``<scene_draft>`` XML block.
    2. The whole response (if it is itself a JSON object).
    3. The first balanced ``{...}`` block anywhere in the response.

    Returns the parsed ``dict`` on success, or ``None`` if no valid JSON object
    can be recovered (the caller should retry).
    """
    candidates: list[str] = []
    tagged = extract_xml_tag(text, "scene_draft")
    if tagged is not None:
        candidates.append(tagged)
    candidates.append(text)

    for candidate in candidates:
        cleaned = _strip_code_fences(candidate)
        for blob in (cleaned, _first_json_object(cleaned)):
            if not blob:
                continue
            try:
                parsed = json.loads(blob)
            except json.JSONDecodeError:
                continue
            if isinstance(parsed, dict):
                return parsed
    return None


def aspect_ratio_str(width: int, height: int) -> str:
    """Reduce ``width``/``height`` to a ``"W,H"`` ratio string (e.g. ``"1,1"``)."""
    from math import gcd

    if width <= 0 or height <= 0:
        return ""
    g = gcd(int(width), int(height)) or 1
    return f"{int(width) // g},{int(height) // g}"


def media_fields_from_metadata(meta: dict) -> dict:
    """Build the caption's media fields from :func:`probe_video_metadata` output.

    Uses the clip's *actual* values (not the canonical generation enums): the
    enums constrain the upsampler's generation params, not ground-truth captions.
    """
    width, height = int(meta["width"]), int(meta["height"])
    return {
        "resolution": {"H": height, "W": width},
        "aspect_ratio": aspect_ratio_str(width, height),
        "duration": f"{round(float(meta['duration']))}s",
        "fps": int(round(float(meta["fps"]))),
    }


def assemble_caption_json(scene_draft: dict, final_prompt: str, media: dict) -> dict:
    """Assemble the final caption object and validate it.

    * ``temporal_caption`` is set to the polished Phase-2 ``final_prompt`` (this
      is what keeps the dense narrative available *inside* the JSON and equal to
      ``caption.txt``), overriding any draft value from Phase 1.
    * ``media`` (from :func:`media_fields_from_metadata`) is merged in.

    Returns a normalised ``dict`` (None-valued fields dropped, types coerced).
    Raises ``pydantic.ValidationError`` if the structure is unusable.
    """
    data: dict[str, Any] = dict(scene_draft)
    data["temporal_caption"] = (final_prompt or "").strip()
    data.update(media)
    model = StructuredCaption.model_validate(data)
    return model.model_dump(exclude_none=True, mode="json")


def caption_json_to_prompt(caption_json: dict) -> str:
    """Serialise a caption object to the compact JSON string fed to the model.

    Single source of truth for how a structured caption becomes model text, so
    training (sft_dataset.py) and inference prompts use byte-identical encoding.
    """
    return json.dumps(caption_json, ensure_ascii=False)