Spaces:
Running on L40S
Running on L40S
| # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| # SPDX-License-Identifier: OpenMDW-1.1 | |
| """Prompt upsampling client for Cosmos3 generation/evaluation scripts. | |
| This module has two layers: | |
| 1. Prompt builders that turn a terse user prompt into a model-specific chat | |
| request for text-to-image, text-to-video, or image-to-video upsampling. | |
| 2. A small OpenAI-compatible HTTP client and CLI that send those requests to an | |
| external upsampler endpoint and write normalized JSON prompts to disk. | |
| The code is intentionally endpoint-light: all prompt shape decisions live here, | |
| while authentication, model choice, and endpoint URL are supplied by callers or | |
| environment variables. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import logging | |
| import mimetypes | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from base64 import b64encode | |
| from collections.abc import Callable | |
| from dataclasses import dataclass | |
| from functools import lru_cache | |
| from pathlib import Path | |
| from string import Template | |
| from typing import Any | |
| import requests | |
| from requests.adapters import HTTPAdapter | |
| from urllib3.util.retry import Retry | |
| # In Cosmos3 training, we used `json.dumps(...)` for converting dict-structured caption objects to string. | |
| # This JSON-formatted string was then used as text caption input to the tokenizer. | |
| # This has the side effect of converting non-ASCII characters to their ASCII equivalents. | |
| # Although this is not ideal for languages like Chinese, it's how the model was trained. | |
| # For the prompt upsampling client, we therefore ensure that JSON output is ASCII-only by default. | |
| # If this JSON_ENSURE_ASCII environment variable is set to 0, we use `json.dumps(..., ensure_ascii=False)` | |
| # instead and characters like `中文` will be preserved and get tokenized instead of `\\u4e2d\\u6587`. | |
| JSON_ENSURE_ASCII = bool(int(os.environ.get("JSON_ENSURE_ASCII", "1"))) | |
| SYSTEM_MESSAGE: dict[str, Any] = { | |
| "role": "system", | |
| "content": [{"type": "text", "text": "You are a helpful assistant."}], | |
| } | |
| DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" | |
| log = logging.getLogger(__name__) | |
| PROMPT_UPSAMPLER_MODES = ("text2image", "text2video", "image2video", "posttrain_image2video") | |
| # Prompt templates are stored outside this Python file so teams can iterate on | |
| # prompt wording/schema contracts without editing the request/CLI plumbing. | |
| RESOLUTION_RATIO_DICT: dict[str, dict[str, dict[str, int]]] = { | |
| "256": { | |
| "1,1": {"W": 256, "H": 256}, | |
| "4,3": {"W": 320, "H": 256}, | |
| "3,4": {"W": 256, "H": 320}, | |
| "16,9": {"W": 320, "H": 192}, | |
| "9,16": {"W": 192, "H": 320}, | |
| }, | |
| "480": { | |
| "1,1": {"W": 640, "H": 640}, | |
| "4,3": {"W": 736, "H": 544}, | |
| "3,4": {"W": 544, "H": 736}, | |
| "16,9": {"W": 832, "H": 480}, | |
| "9,16": {"W": 480, "H": 832}, | |
| }, | |
| "720": { | |
| "1,1": {"W": 960, "H": 960}, | |
| "4,3": {"W": 1104, "H": 832}, | |
| "3,4": {"W": 832, "H": 1104}, | |
| "16,9": {"W": 1280, "H": 720}, | |
| "9,16": {"W": 720, "H": 1280}, | |
| }, | |
| "768": { | |
| "1,1": {"W": 1024, "H": 1024}, | |
| "4,3": {"W": 1184, "H": 880}, | |
| "3,4": {"W": 880, "H": 1184}, | |
| "16,9": {"W": 1360, "H": 768}, | |
| "9,16": {"W": 768, "H": 1360}, | |
| }, | |
| } | |
| PROMPTING_TEMPLATES_DIR = Path(__file__).with_name("prompting_templates") / "external_api" | |
| def _load_prompting_template(filename: str) -> str: | |
| """Load a built-in template once and strip the trailing newline. | |
| ``Template.substitute`` is used later, so template files should use | |
| ``$json_template``, ``$nl_description``, and ``$resolution_ratio_dict`` | |
| placeholders rather than Python ``str.format`` braces. | |
| """ | |
| return (PROMPTING_TEMPLATES_DIR / filename).read_text(encoding="utf-8").rstrip("\n") | |
| T2I_JSON_TEMPLATE = _load_prompting_template("t2i_json_schema.json") | |
| T2V_JSON_TEMPLATE = _load_prompting_template("t2v_i2v_video_json_schema.json") | |
| T2I_PROMPT_TEMPLATE = Template(_load_prompting_template("t2i_prompt.txt")) | |
| T2V_PROMPT_TEMPLATE = Template(_load_prompting_template("t2v_i2v_video_prompt.txt")) | |
| POSTTRAIN_I2V_JSON_TEMPLATE = _load_prompting_template("posttrained_i2v_json_schema.json") | |
| POSTTRAIN_I2V_PROMPT_TEMPLATE = Template(_load_prompting_template("posttrained_i2v_prompt.txt")) | |
| def _resolution_ratio_dict_text() -> str: | |
| """Return the valid output resolution table rendered for prompt injection.""" | |
| resolution_ratio_dict = { | |
| resolution: {aspect_ratio: {"H": size["H"], "W": size["W"]} for aspect_ratio, size in aspect_ratio_dict.items()} | |
| for resolution, aspect_ratio_dict in RESOLUTION_RATIO_DICT.items() | |
| } | |
| return json.dumps(resolution_ratio_dict, indent=2) | |
| def build_nl_description( | |
| prompt: str, | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str | None = None, | |
| fps: int | None = None, | |
| ) -> str: | |
| """Append literal output parameters to the user prompt. | |
| The external upsampler receives one text field, so we make generation | |
| metadata explicit in prose. The templates instruct the model to copy these | |
| values back into the JSON output. | |
| """ | |
| params = [f"resolution {resolution}", f"aspect_ratio {aspect_ratio}"] | |
| if duration is not None: | |
| params.append(f"duration {duration}") | |
| if fps is not None: | |
| params.append(f"fps {fps}") | |
| return f"{prompt.strip()}\n\nOutput parameters: {', '.join(params)}." | |
| def derive_duration_label(num_frames: int, fps: int) -> str: | |
| """Convert frame count and FPS to the compact duration label used by inference.""" | |
| if fps <= 0: | |
| raise ValueError("fps must be positive.") | |
| seconds = int(num_frames / fps) | |
| return f"{seconds}s" | |
| def build_t2i_prompt_text(prompt: str, *, resolution: str, aspect_ratio: str) -> str: | |
| """Render the complete text-to-image upsampler prompt.""" | |
| nl_description = build_nl_description(prompt, resolution=resolution, aspect_ratio=aspect_ratio) | |
| return T2I_PROMPT_TEMPLATE.substitute( | |
| json_template=T2I_JSON_TEMPLATE, | |
| nl_description=nl_description, | |
| resolution_ratio_dict=_resolution_ratio_dict_text(), | |
| ) | |
| def build_t2v_prompt_text( | |
| prompt: str, | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| image_conditioned: bool = False, | |
| ) -> str: | |
| """Render the complete video upsampler prompt. | |
| ``image_conditioned`` keeps I2V on the same JSON schema as T2V while adding | |
| instructions that the attached image is visual ground truth for frame 0. | |
| """ | |
| nl_description = build_nl_description( | |
| prompt, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ) | |
| intro = "Given the user's natural-language request below" | |
| image_note = "" | |
| if image_conditioned: | |
| intro = "Given the attached starting frame image and the user's natural-language request below" | |
| image_note = "\nIMPORTANT - IMAGE INPUT: The attached image is the first frame of the video. Use it as visual ground truth for subject appearance, setting, lighting, and colors. The natural-language request primarily describes temporal/action intent. Your JSON must be consistent with what is visible in the image.\n" | |
| return T2V_PROMPT_TEMPLATE.substitute( | |
| image_note=image_note, | |
| intro=intro, | |
| json_template=T2V_JSON_TEMPLATE, | |
| nl_description=nl_description, | |
| resolution_ratio_dict=_resolution_ratio_dict_text(), | |
| ) | |
| def build_posttrain_i2v_prompt_text(prompt: str) -> str: | |
| """Render the posttrained image-to-video upsampler prompt. | |
| Unlike the external-API video template, the posttrained template fills the | |
| output-parameter fields (resolution/aspect_ratio/duration/fps) post-hoc, so | |
| the raw user instruction is passed through verbatim without an appended | |
| ``Output parameters`` summary. | |
| """ | |
| return POSTTRAIN_I2V_PROMPT_TEMPLATE.substitute( | |
| json_template=POSTTRAIN_I2V_JSON_TEMPLATE, | |
| nl_description=prompt.strip(), | |
| ) | |
| def build_t2i_messages( | |
| prompt: str, | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| user_prompt: str | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Build OpenAI-compatible chat messages for text-to-image upsampling.""" | |
| message_text = user_prompt or build_t2i_prompt_text(prompt, resolution=resolution, aspect_ratio=aspect_ratio) | |
| return [ | |
| SYSTEM_MESSAGE, | |
| { | |
| "role": "user", | |
| "content": [{"type": "text", "text": message_text}], | |
| }, | |
| ] | |
| def build_t2v_messages( | |
| prompt: str, | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| user_prompt: str | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Build OpenAI-compatible chat messages for text-to-video upsampling.""" | |
| message_text = user_prompt or build_t2v_prompt_text( | |
| prompt, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ) | |
| return [ | |
| SYSTEM_MESSAGE, | |
| { | |
| "role": "user", | |
| "content": [{"type": "text", "text": message_text}], | |
| }, | |
| ] | |
| def build_i2v_messages( | |
| prompt: str, | |
| *, | |
| image_url: str, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| user_prompt: str | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Build OpenAI-compatible chat messages for image-to-video upsampling. | |
| ``image_url`` may be a remote URL, a data URL, or a gateway-specific raw | |
| base64 string. The caller owns converting local files to a supported form. | |
| """ | |
| message_text = user_prompt or build_t2v_prompt_text( | |
| prompt, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| image_conditioned=True, | |
| ) | |
| return [ | |
| SYSTEM_MESSAGE, | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| {"type": "text", "text": message_text}, | |
| ], | |
| }, | |
| ] | |
| def build_posttrain_i2v_messages( | |
| prompt: str, | |
| *, | |
| image_url: str, | |
| user_prompt: str | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Build OpenAI-compatible chat messages for posttrained image-to-video upsampling. | |
| ``image_url`` may be a remote URL, a data URL, or a gateway-specific raw | |
| base64 string. The caller owns converting local files to a supported form. | |
| """ | |
| message_text = user_prompt or build_posttrain_i2v_prompt_text(prompt) | |
| return [ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| {"type": "text", "text": message_text}, | |
| ], | |
| }, | |
| ] | |
| def _extract_xml_tag(text: str, tag: str) -> str | None: | |
| """Return the stripped inner text of the first ``<tag>...</tag>`` block, if present.""" | |
| match = re.search(rf"<{re.escape(tag)}>(.*?)</{re.escape(tag)}>", text, flags=re.DOTALL) | |
| if match is None: | |
| return None | |
| inner = match.group(1).strip() | |
| return inner or None | |
| def extract_json_object(text: str) -> dict[str, Any]: | |
| """Extract a JSON object from a raw model response. | |
| Upsamplers may return either a bare JSON object or a fenced `````json``` | |
| block. This helper normalizes both forms and rejects non-object payloads. | |
| """ | |
| cleaned = text.strip() | |
| fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, flags=re.DOTALL) | |
| if fence_match: | |
| cleaned = fence_match.group(1).strip() | |
| parsed = json.loads(cleaned) | |
| if not isinstance(parsed, dict): | |
| raise ValueError("Upsampler response JSON must be an object.") | |
| return parsed | |
| def extract_json_object_text(text: str) -> str: | |
| """Extract and normalize a JSON object from a raw model response.""" | |
| parsed = extract_json_object(text) | |
| return json.dumps(parsed, ensure_ascii=JSON_ENSURE_ASCII) | |
| def image_path_to_data_url(path: str | Path) -> str: | |
| """Encode a local image path as a data URL for OpenAI-compatible VLM requests.""" | |
| image_path = Path(path) | |
| mime_type = mimetypes.guess_type(str(image_path))[0] or "image/png" | |
| encoded = b64encode(image_path.read_bytes()).decode("ascii") | |
| return f"data:{mime_type};base64,{encoded}" | |
| def image_path_to_base64(path: str | Path) -> str: | |
| """Encode a local image path as raw base64 for gateways that do not accept data URLs.""" | |
| return b64encode(Path(path).read_bytes()).decode("ascii") | |
| def _compact_json_object(data: dict[str, Any]) -> str: | |
| """Serialize a JSON object in the compact format expected by inference.""" | |
| return json.dumps(data, ensure_ascii=JSON_ENSURE_ASCII) | |
| def _apply_t2i_output_parameters(data: dict[str, Any], *, resolution: str, aspect_ratio: str) -> dict[str, Any]: | |
| """Force canonical image metadata into an upsampled T2I JSON object.""" | |
| if resolution not in RESOLUTION_RATIO_DICT: | |
| raise ValueError(f"Unsupported upsampler resolution {resolution!r}.") | |
| if aspect_ratio not in RESOLUTION_RATIO_DICT[resolution]: | |
| raise ValueError(f"Unsupported upsampler aspect_ratio {aspect_ratio!r} for resolution {resolution!r}.") | |
| resolution_pair = RESOLUTION_RATIO_DICT[resolution][aspect_ratio] | |
| data["resolution"] = {"H": resolution_pair["H"], "W": resolution_pair["W"]} | |
| data["aspect_ratio"] = aspect_ratio | |
| return data | |
| def _apply_t2v_output_parameters( | |
| data: dict[str, Any], | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| ) -> dict[str, Any]: | |
| """Force canonical video metadata into an upsampled T2V/I2V JSON object.""" | |
| data = _apply_t2i_output_parameters(data, resolution=resolution, aspect_ratio=aspect_ratio) | |
| data["duration"] = duration | |
| data["fps"] = fps | |
| return data | |
| class PromptUpsamplerConfig: | |
| """Connection and sampling settings for ``PromptUpsamplerClient``. | |
| ``endpoint_url`` may be a bare host, a ``/v1`` base URL, or a full | |
| ``/chat/completions`` URL; it is normalized by the client. Sampling | |
| parameters set to ``None`` are omitted from the request payload so the same | |
| client can work across gateways with different accepted knobs. | |
| """ | |
| endpoint_url: str | |
| model: str | None = None | |
| api_token: str | None = None | |
| timeout_s: float = 300.0 | |
| max_tokens: int = 8192 | |
| max_retries: int = 5 | |
| retry_base_delay_s: float = 1.0 | |
| temperature: float | None = 0.7 | |
| top_p: float | None = 0.8 | |
| top_k: int | None = 20 | |
| min_p: float | None = None | |
| connection_max_retries: int = 2 | |
| connection_pool_size: int = 4 | |
| class PromptUpsamplerClient: | |
| """Small OpenAI-compatible chat-completions client with explicit retries. | |
| The client deliberately avoids depending on the OpenAI SDK so it can be | |
| used in lightweight eval scripts. | |
| """ | |
| def __init__( | |
| self, | |
| config: PromptUpsamplerConfig, | |
| *, | |
| session: requests.Session | None = None, | |
| sleep: Callable[[float], None] = time.sleep, | |
| ) -> None: | |
| self.config = config | |
| self._base_url = _normalize_openai_base_url(config.endpoint_url) | |
| self._session = _make_session(config) if session is None else session | |
| self._sleep = sleep | |
| def list_models(self) -> list[str]: | |
| """Fetch model ids from an OpenAI-compatible ``/models`` endpoint.""" | |
| payload = self._with_retries("list models", lambda: self._request_json("GET", f"{self._base_url}/models")) | |
| data = payload.get("data") | |
| if not isinstance(data, list): | |
| raise ValueError("Model list response missing 'data' list.") | |
| models: list[str] = [] | |
| for item in data: | |
| if isinstance(item, dict) and isinstance(item.get("id"), str): | |
| models.append(item["id"]) | |
| if not models: | |
| raise ValueError("Model list response did not include any model ids.") | |
| return models | |
| def upsample_t2i( | |
| self, | |
| prompt: str, | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| user_prompt: str | None = None, | |
| ) -> str: | |
| """Upsample a text-to-image prompt and return a compact JSON string.""" | |
| messages = build_t2i_messages( | |
| prompt, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| user_prompt=user_prompt, | |
| ) | |
| return self._upsample_messages_with_parameters( | |
| messages, | |
| lambda data: _apply_t2i_output_parameters(data, resolution=resolution, aspect_ratio=aspect_ratio), | |
| ) | |
| def upsample_t2v( | |
| self, | |
| prompt: str, | |
| *, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| user_prompt: str | None = None, | |
| ) -> str: | |
| """Upsample a text-to-video prompt and return a compact JSON string.""" | |
| messages = build_t2v_messages( | |
| prompt, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| user_prompt=user_prompt, | |
| ) | |
| return self._upsample_messages_with_parameters( | |
| messages, | |
| lambda data: _apply_t2v_output_parameters( | |
| data, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ), | |
| ) | |
| def upsample_i2v( | |
| self, | |
| prompt: str, | |
| *, | |
| image_url: str, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| user_prompt: str | None = None, | |
| ) -> str: | |
| """Upsample an image-to-video prompt and return a compact JSON string.""" | |
| messages = build_i2v_messages( | |
| prompt, | |
| image_url=image_url, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| user_prompt=user_prompt, | |
| ) | |
| return self._upsample_messages_with_parameters( | |
| messages, | |
| lambda data: _apply_t2v_output_parameters( | |
| data, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ), | |
| ) | |
| def upsample_posttrain_i2v( | |
| self, | |
| prompt: str, | |
| *, | |
| image_url: str, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| user_prompt: str | None = None, | |
| ) -> dict[str, Any]: | |
| """Upsample a posttrained image-to-video prompt into a positive/negative record. | |
| The posttrained Cosmos3-Super-Image2Video contract emits a | |
| ``<final_prompt>`` JSON block plus a per-sample ``<negative_prompt>`` | |
| block. The positive prompt is returned as a compact JSON string under | |
| ``prompt`` (with output parameters pinned post-hoc), and the negative | |
| prompt, when present, is returned as a sibling string. | |
| """ | |
| messages = build_posttrain_i2v_messages(prompt, image_url=image_url, user_prompt=user_prompt) | |
| def _call() -> dict[str, Any]: | |
| content = self._chat_completion(messages) | |
| # The JSON description must live inside <final_prompt> tags; a | |
| # response without them violates the posttrained contract and is | |
| # retried rather than silently parsed from arbitrary text. | |
| final_prompt = _extract_xml_tag(content, "final_prompt") | |
| if final_prompt is None: | |
| raise ValueError("Posttrained upsampler response missing <final_prompt> block.") | |
| data = _apply_t2v_output_parameters( | |
| extract_json_object(final_prompt), | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ) | |
| record: dict[str, Any] = {"prompt": _compact_json_object(data)} | |
| negative = _extract_xml_tag(content, "negative_prompt") | |
| if negative is not None: | |
| record["negative_prompt"] = negative | |
| return record | |
| return self._with_retries("upsample prompt", _call) | |
| def upsample_messages(self, messages: list[dict[str, Any]]) -> str: | |
| """Upsample a pre-built chat message list without injecting metadata.""" | |
| def _call() -> str: | |
| content = self._chat_completion(messages) | |
| return extract_json_object_text(content) | |
| return self._with_retries("upsample prompt", _call) | |
| def _upsample_messages_with_parameters( | |
| self, | |
| messages: list[dict[str, Any]], | |
| apply_parameters: Callable[[dict[str, Any]], dict[str, Any]], | |
| ) -> str: | |
| """Call the model, parse the JSON object, and pin output parameters. | |
| Pinning metadata after the model response makes the output robust to | |
| small formatting/copy mistakes by the LLM and keeps inference metadata | |
| exactly aligned with CLI args. | |
| """ | |
| def _call() -> str: | |
| content = self._chat_completion(messages) | |
| data = apply_parameters(extract_json_object(content)) | |
| return _compact_json_object(data) | |
| return self._with_retries("upsample prompt", _call) | |
| def _get_model(self) -> str: | |
| """Resolve the model name from config, env, or endpoint discovery.""" | |
| if self.config.model: | |
| return self.config.model | |
| env_model = os.environ.get("PROMPT_UPSAMPLER_MODEL") | |
| if env_model: | |
| self.config.model = env_model | |
| return env_model | |
| self.config.model = self.list_models()[0] | |
| return self.config.model | |
| def _chat_completion(self, messages: list[dict[str, Any]]) -> str: | |
| """Send messages to the configured endpoint and return assistant text.""" | |
| model = self._get_model() | |
| log.debug(f"[prompt-upsampling] _chat_completion: model={model}, base_url={self._base_url}") | |
| # Keep provider-specific sampling params optional. Several gateways | |
| # reject unsupported keys instead of ignoring them. | |
| payload = { | |
| "model": model, | |
| "messages": messages, | |
| "max_tokens": self.config.max_tokens, | |
| } | |
| if self.config.temperature is not None: | |
| payload["temperature"] = self.config.temperature | |
| if self.config.top_p is not None: | |
| payload["top_p"] = self.config.top_p | |
| if self.config.top_k is not None: | |
| payload["top_k"] = self.config.top_k | |
| if self.config.min_p is not None: | |
| payload["min_p"] = self.config.min_p | |
| response = self._request_json("POST", f"{self._base_url}/chat/completions", payload=payload) | |
| choices = response.get("choices") | |
| if not isinstance(choices, list) or not choices: | |
| raise ValueError("Chat completion response missing choices.") | |
| first_choice = choices[0] | |
| if not isinstance(first_choice, dict): | |
| raise ValueError("Chat completion choice must be an object.") | |
| message = first_choice.get("message") | |
| if not isinstance(message, dict): | |
| raise ValueError("Chat completion choice missing message.") | |
| return _message_content_to_text(message.get("content")) | |
| def _request_json(self, method: str, url: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: | |
| """Issue one HTTP request and parse a JSON object response.""" | |
| headers = {"Accept": "application/json", "User-Agent": DEFAULT_USER_AGENT} | |
| if payload is not None: | |
| headers["Content-Type"] = "application/json" | |
| token = self.config.api_token | |
| if token: | |
| headers["Authorization"] = f"Bearer {token}" | |
| log.debug( | |
| f"[prompt-upsampling] _request_json: {method} {url} token={'***' + token[-4:] if token else '(none)'}" | |
| ) | |
| try: | |
| response = self._session.request( | |
| method, | |
| url, | |
| json=payload, | |
| headers=headers, | |
| timeout=self.config.timeout_s, | |
| ) | |
| except requests.RequestException as exc: | |
| log.debug(f"[prompt-upsampling] _request_json FAILED: {exc}") | |
| raise RuntimeError(f"Failed to reach {url}: {exc}") from exc | |
| log.debug(f"[prompt-upsampling] _request_json response: status={response.status_code}") | |
| if not response.ok: | |
| raise RuntimeError(f"HTTP {response.status_code} from {url}: {response.text[:1000]}") | |
| try: | |
| parsed = response.json() | |
| except ValueError as exc: | |
| raise RuntimeError(f"Response from {url} was not valid JSON: {response.text[:1000]}") from exc | |
| if not isinstance(parsed, dict): | |
| raise RuntimeError(f"Response from {url} must be a JSON object.") | |
| return parsed | |
| def _with_retries(self, operation: str, fn: Callable[[], Any]) -> Any: | |
| """Retry transient endpoint/model failures with exponential backoff.""" | |
| if self.config.max_retries < 1: | |
| raise ValueError("max_retries must be >= 1.") | |
| last_exc: Exception | None = None | |
| for attempt in range(self.config.max_retries): | |
| try: | |
| return fn() | |
| except Exception as exc: | |
| last_exc = exc | |
| if attempt == self.config.max_retries - 1: | |
| break | |
| self._sleep(self.config.retry_base_delay_s * (2**attempt)) | |
| raise RuntimeError( | |
| f"Prompt upsampler failed to {operation} after {self.config.max_retries} attempts: {last_exc}" | |
| ) from last_exc | |
| def _normalize_openai_base_url(url: str) -> str: | |
| """Normalize user-provided endpoint strings to a request base URL.""" | |
| normalized = url.strip().rstrip("/") | |
| if not normalized: | |
| raise ValueError("endpoint_url cannot be empty.") | |
| if not normalized.startswith(("http://", "https://")): | |
| normalized = f"https://{normalized}" | |
| if normalized.endswith("/chat/completions"): | |
| normalized = normalized[: -len("/chat/completions")] | |
| if not normalized.endswith("/v1"): | |
| normalized = f"{normalized}/v1" | |
| return normalized | |
| def _make_session(config: PromptUpsamplerConfig) -> requests.Session: | |
| """Create a requests session with connection-level retry behavior.""" | |
| session = requests.Session() | |
| retry = Retry( | |
| total=config.connection_max_retries, | |
| connect=config.connection_max_retries, | |
| read=0, | |
| status=0, | |
| backoff_factor=0.25, | |
| allowed_methods=None, | |
| ) | |
| adapter = HTTPAdapter( | |
| pool_connections=config.connection_pool_size, | |
| pool_maxsize=config.connection_pool_size, | |
| max_retries=retry, | |
| ) | |
| session.mount("https://", adapter) | |
| session.mount("http://", adapter) | |
| return session | |
| def _message_content_to_text(content: Any) -> str: | |
| """Convert OpenAI message content variants into plain assistant text.""" | |
| if isinstance(content, str) and content.strip(): | |
| return content | |
| if isinstance(content, list): | |
| parts: list[str] = [] | |
| for item in content: | |
| if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): | |
| parts.append(item["text"]) | |
| text = "".join(parts).strip() | |
| if text: | |
| return text | |
| raise ValueError("Chat completion message content is empty or unsupported.") | |
| def _normalize_prompt_upsampler_mode(mode: str) -> str: | |
| """Validate and normalize the CLI mode name.""" | |
| normalized = mode.strip().lower().replace("-", "_") | |
| if normalized not in PROMPT_UPSAMPLER_MODES: | |
| valid_modes = ", ".join(PROMPT_UPSAMPLER_MODES) | |
| raise ValueError(f"Unsupported prompt upsampling mode {mode!r}. Valid modes: {valid_modes}.") | |
| return normalized | |
| def _read_prompt_lines(path: str | Path) -> list[str]: | |
| """Read one prompt per non-empty line from a text file.""" | |
| prompts = [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines()] | |
| return [prompt for prompt in prompts if prompt] | |
| def _read_optional_image_lines(path: str | Path | None) -> list[str] | None: | |
| """Read optional one-image-per-prompt entries for I2V mode.""" | |
| if path is None: | |
| return None | |
| image_lines = [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines()] | |
| return [image_line for image_line in image_lines if image_line] | |
| def _resolve_image_url(image: str) -> str: | |
| """Resolve an image-list entry into the form sent to the endpoint. | |
| Remote URLs and explicit data URLs pass through unchanged. Existing local | |
| files are encoded as data URLs to match the Streamlit/server I2V path. If a | |
| path-like string cannot be inspected, it is passed through so callers can | |
| provide gateway-specific references. | |
| """ | |
| if image.startswith(("http://", "https://", "data:")): | |
| return image | |
| image_path = Path(image) | |
| try: | |
| if image_path.exists(): | |
| return image_path_to_data_url(image_path) | |
| except OSError: | |
| return image | |
| return image | |
| def _optional_float(value: str) -> float | None: | |
| """Parse a float CLI argument that can also be disabled with ``none``.""" | |
| if value.strip().lower() in {"none", "null"}: | |
| return None | |
| return float(value) | |
| def configure_prompting_templates( | |
| *, | |
| mode: str, | |
| prompt_template_path: str | Path | None = None, | |
| json_template_path: str | Path | None = None, | |
| ) -> None: | |
| """Override prompt upsampler templates for the selected mode. | |
| The default templates target the external API prompt format, but eval jobs | |
| sometimes need reasoner-style or experiment-specific prompts. This function | |
| swaps the in-memory templates before any prompt text is built. It is global | |
| by design because the module-level builders use cached ``Template`` | |
| instances. | |
| """ | |
| if prompt_template_path is None and json_template_path is None: | |
| return | |
| global T2I_JSON_TEMPLATE, T2I_PROMPT_TEMPLATE, T2V_JSON_TEMPLATE, T2V_PROMPT_TEMPLATE | |
| global POSTTRAIN_I2V_JSON_TEMPLATE, POSTTRAIN_I2V_PROMPT_TEMPLATE | |
| normalized_mode = _normalize_prompt_upsampler_mode(mode) | |
| if normalized_mode == "posttrain_image2video": | |
| # Posttrained I2V keeps its own template slots, fully separate from the | |
| # external-API T2V/I2V template so the two contracts never collide. | |
| if json_template_path is not None: | |
| POSTTRAIN_I2V_JSON_TEMPLATE = Path(json_template_path).read_text(encoding="utf-8").rstrip("\n") | |
| if prompt_template_path is not None: | |
| prompt_template = Path(prompt_template_path).read_text(encoding="utf-8").rstrip("\n") | |
| POSTTRAIN_I2V_PROMPT_TEMPLATE = Template(prompt_template) | |
| return | |
| if normalized_mode == "text2image": | |
| # T2I has its own schema and prompt contract. | |
| if json_template_path is not None: | |
| T2I_JSON_TEMPLATE = Path(json_template_path).read_text(encoding="utf-8").rstrip("\n") | |
| if prompt_template_path is not None: | |
| prompt_template = Path(prompt_template_path).read_text(encoding="utf-8").rstrip("\n") | |
| T2I_PROMPT_TEMPLATE = Template(prompt_template) | |
| return | |
| if normalized_mode in {"text2video", "image2video"}: | |
| # T2V and I2V both use the video template slots in this module. A | |
| # caller can still pass I2V-specific files through --prompt-template | |
| # and --json-template before any prompt text is rendered. | |
| if json_template_path is not None: | |
| T2V_JSON_TEMPLATE = Path(json_template_path).read_text(encoding="utf-8").rstrip("\n") | |
| if prompt_template_path is not None: | |
| prompt_template = Path(prompt_template_path).read_text(encoding="utf-8").rstrip("\n") | |
| T2V_PROMPT_TEMPLATE = Template(prompt_template) | |
| return | |
| raise AssertionError(f"Unhandled prompt upsampling mode: {normalized_mode}") | |
| def _prompt_record(upsampled_json: str) -> dict[str, Any]: | |
| """Wrap an upsampled JSON string into the unified record. | |
| The string is re-parsed through ``extract_json_object`` (validating it is a | |
| JSON object) and re-serialized to the compact form, so every mode emits a | |
| consistently-normalized ``prompt`` string. | |
| """ | |
| return {"prompt": _compact_json_object(extract_json_object(upsampled_json))} | |
| def _upsample_prompt_for_mode( | |
| client: PromptUpsamplerClient, | |
| prompt: str, | |
| *, | |
| mode: str, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| image_url: str | None = None, | |
| ) -> dict[str, Any]: | |
| """Dispatch one prompt to the modality-specific client method. | |
| Returns a unified JSON record for this prompt: every mode produces a compact | |
| JSON ``prompt`` string, and ``posttrain_image2video`` additionally includes | |
| a ``negative_prompt`` string when the model returns one. | |
| """ | |
| normalized_mode = _normalize_prompt_upsampler_mode(mode) | |
| if normalized_mode == "text2image": | |
| return _prompt_record(client.upsample_t2i(prompt, resolution=resolution, aspect_ratio=aspect_ratio)) | |
| if normalized_mode == "text2video": | |
| return _prompt_record( | |
| client.upsample_t2v( | |
| prompt, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ) | |
| ) | |
| if normalized_mode == "image2video": | |
| if image_url is None: | |
| raise ValueError("image2video mode requires --image-url or --image-list.") | |
| return _prompt_record( | |
| client.upsample_i2v( | |
| prompt, | |
| image_url=_resolve_image_url(image_url), | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ) | |
| ) | |
| if normalized_mode == "posttrain_image2video": | |
| if image_url is None: | |
| raise ValueError("posttrain_image2video mode requires --image-url or --image-list.") | |
| return client.upsample_posttrain_i2v( | |
| prompt, | |
| image_url=_resolve_image_url(image_url), | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| ) | |
| raise AssertionError(f"Unhandled prompt upsampling mode: {normalized_mode}") | |
| def upsample_prompt_file( | |
| client: PromptUpsamplerClient, | |
| *, | |
| input_path: str | Path, | |
| output_path: str | Path, | |
| mode: str, | |
| resolution: str, | |
| aspect_ratio: str, | |
| duration: str, | |
| fps: int, | |
| image_url: str | None = None, | |
| image_list_path: str | Path | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Upsample one prompt per input line and write JSON records to disk. | |
| Input is intentionally simple: one non-empty line per prompt. For I2V, the | |
| optional image list must have the same number of non-empty lines and is | |
| matched by index. Output is one ``prompt_<index>.json`` file per prompt in | |
| the requested output directory. | |
| Every record has the same shape: a compact JSON ``prompt`` string, plus a | |
| ``negative_prompt`` string for ``posttrain_image2video`` when the model | |
| returns one. | |
| """ | |
| prompts = _read_prompt_lines(input_path) | |
| if not prompts: | |
| raise ValueError(f"No prompts found in {input_path}.") | |
| image_urls = _read_optional_image_lines(image_list_path) | |
| if image_urls is not None and len(image_urls) != len(prompts): | |
| raise ValueError(f"Expected {len(prompts)} image entries in {image_list_path}, found {len(image_urls)}.") | |
| results: list[dict[str, Any]] = [] | |
| for index, prompt in enumerate(prompts): | |
| # ``current_image_url`` remains None for text-only modes and is | |
| # validated only if the selected mode actually needs it. | |
| current_image_url = image_urls[index] if image_urls is not None else image_url | |
| record = _upsample_prompt_for_mode( | |
| client, | |
| prompt, | |
| mode=mode, | |
| resolution=resolution, | |
| aspect_ratio=aspect_ratio, | |
| duration=duration, | |
| fps=fps, | |
| image_url=current_image_url, | |
| ) | |
| results.append(record) | |
| output = Path(output_path) | |
| output.mkdir(parents=True, exist_ok=True) | |
| for index, result in enumerate(results): | |
| prompt_output = output / f"prompt_{index}.json" | |
| prompt_output.write_text(json.dumps(result, ensure_ascii=JSON_ENSURE_ASCII, indent=2) + "\n", encoding="utf-8") | |
| return results | |
| def build_cli_parser() -> argparse.ArgumentParser: | |
| """Build the standalone CLI parser for batch prompt upsampling.""" | |
| parser = argparse.ArgumentParser(description="Upsample a text file of prompts into Cosmos3 JSON prompts.") | |
| parser.add_argument("--input", required=True, help="Text file with one prompt per non-empty line.") | |
| parser.add_argument("--output", required=True, help="Output directory for per-prompt JSON files.") | |
| parser.add_argument( | |
| "--mode", | |
| required=True, | |
| choices=PROMPT_UPSAMPLER_MODES, | |
| help="Prompt upsampling mode, e.g. text2image, text2video, or image2video.", | |
| ) | |
| parser.add_argument( | |
| "--endpoint-url", | |
| default=os.environ.get("PROMPT_UPSAMPLER_ENDPOINT_URL"), | |
| help="OpenAI-compatible endpoint URL. Defaults to PROMPT_UPSAMPLER_ENDPOINT_URL.", | |
| ) | |
| parser.add_argument( | |
| "--model", | |
| default=os.environ.get("PROMPT_UPSAMPLER_MODEL"), | |
| help="Model name. Defaults to PROMPT_UPSAMPLER_MODEL.", | |
| ) | |
| parser.add_argument( | |
| "--api-token", | |
| default=os.environ.get("PROMPT_UPSAMPLER_API_TOKEN"), | |
| help="API token. Defaults to PROMPT_UPSAMPLER_API_TOKEN.", | |
| ) | |
| parser.add_argument( | |
| "--prompt-template", | |
| default=os.environ.get("PROMPT_UPSAMPLER_PROMPT_TEMPLATE"), | |
| help=( | |
| "Prompt template path for the selected mode. Defaults to PROMPT_UPSAMPLER_PROMPT_TEMPLATE, " | |
| "then the built-in template." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--json-template", | |
| default=os.environ.get("PROMPT_UPSAMPLER_JSON_TEMPLATE"), | |
| help=( | |
| "JSON schema template path for the selected mode. Defaults to PROMPT_UPSAMPLER_JSON_TEMPLATE, " | |
| "then the built-in template." | |
| ), | |
| ) | |
| parser.add_argument("--resolution", default="480", choices=sorted(RESOLUTION_RATIO_DICT), help="Resolution tier.") | |
| parser.add_argument("--aspect-ratio", default="16,9", help="Aspect ratio key, e.g. 16,9, 9,16, or 1,1.") | |
| parser.add_argument("--duration", default="5s", help="Video duration metadata for text2video/image2video.") | |
| parser.add_argument("--fps", type=int, default=24, help="Video FPS metadata for text2video/image2video.") | |
| parser.add_argument( | |
| "--image-url", default=None, help="Shared image URL or local image path for all image2video prompts." | |
| ) | |
| parser.add_argument( | |
| "--image-list", | |
| default=None, | |
| help="Text file with one image URL or local image path per prompt for image2video.", | |
| ) | |
| parser.add_argument("--timeout-s", type=float, default=300.0, help="Request timeout in seconds.") | |
| parser.add_argument("--max-tokens", type=int, default=8192, help="Maximum response tokens.") | |
| parser.add_argument("--max-retries", type=int, default=5, help="Prompt upsampling retries per prompt.") | |
| parser.add_argument("--retry-base-delay-s", type=float, default=1.0, help="Base exponential retry delay.") | |
| parser.add_argument( | |
| "--temperature", type=_optional_float, default=None, help="Optional sampling temperature, or 'none'." | |
| ) | |
| parser.add_argument("--top-p", type=_optional_float, default=None, help="Optional sampling top-p, or 'none'.") | |
| parser.add_argument("--top-k", type=int, default=20, help="Sampling top-k.") | |
| parser.add_argument("--min-p", type=float, default=None, help="Optional sampling min-p.") | |
| parser.add_argument("--verbose", action="store_true", help="Enable debug logging.") | |
| return parser | |
| def cli_main(argv: list[str] | None = None) -> int: | |
| """CLI entry point used by ``python -m cosmos_framework.inference.prompt_upsampling``.""" | |
| parser = build_cli_parser() | |
| args = parser.parse_args(argv) | |
| logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) | |
| if args.endpoint_url is None: | |
| parser.error("--endpoint-url is required unless PROMPT_UPSAMPLER_ENDPOINT_URL is set.") | |
| if args.image_url is not None and args.image_list is not None: | |
| parser.error("Pass only one of --image-url or --image-list.") | |
| configure_prompting_templates( | |
| mode=args.mode, | |
| prompt_template_path=args.prompt_template, | |
| json_template_path=args.json_template, | |
| ) | |
| # Build the client after CLI validation so endpoint/model/token mistakes | |
| # fail before any input file is processed. | |
| config = PromptUpsamplerConfig( | |
| endpoint_url=args.endpoint_url, | |
| model=args.model, | |
| api_token=args.api_token, | |
| timeout_s=args.timeout_s, | |
| max_tokens=args.max_tokens, | |
| max_retries=args.max_retries, | |
| retry_base_delay_s=args.retry_base_delay_s, | |
| temperature=args.temperature, | |
| top_p=args.top_p, | |
| top_k=args.top_k, | |
| min_p=args.min_p, | |
| ) | |
| client = PromptUpsamplerClient(config) | |
| results = upsample_prompt_file( | |
| client, | |
| input_path=args.input, | |
| output_path=args.output, | |
| mode=args.mode, | |
| resolution=args.resolution, | |
| aspect_ratio=args.aspect_ratio, | |
| duration=args.duration, | |
| fps=args.fps, | |
| image_url=args.image_url, | |
| image_list_path=args.image_list, | |
| ) | |
| log.info("Wrote %d upsampled prompts to %s", len(results), args.output) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(cli_main()) | |