| 1 | """Shared Echo 1.5 R2V request contract for CLI and scheduled local inference.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import base64 |
| 6 | import binascii |
| 7 | import json |
| 8 | import hashlib |
| 9 | from dataclasses import asdict, dataclass, field, replace |
| 10 | from pathlib import Path |
| 11 | from typing import Any |
| 12 | from urllib.parse import unquote, urlparse |
| 13 | |
| 14 | |
| 15 | R2V_SCHEMA_VERSION = "echo15.r2v.v1" |
| 16 | MAX_MEMORY_SLOTS = 7 |
| 17 | |
| 18 | |
| 19 | def _required_text(payload: dict[str, Any], key: str) -> str: |
| 20 | value = payload.get(key) |
| 21 | if not isinstance(value, str) or not value.strip(): |
| 22 | raise ValueError(f"{key} must be a non-empty string") |
| 23 | return value.strip() |
| 24 | |
| 25 | |
| 26 | def _resolve_resource(value: Any, *, base_dir: Path, field_name: str) -> str | None: |
| 27 | if value is None: |
| 28 | return None |
| 29 | if not isinstance(value, str) or not value.strip(): |
| 30 | raise ValueError(f"{field_name} must be a non-empty path or URL") |
| 31 | source = value.strip() |
| 32 | parsed = urlparse(source) |
| 33 | if parsed.scheme == "data": |
| 34 | header, separator, encoded = source.partition(",") |
| 35 | if not separator or ";base64" not in header.lower(): |
| 36 | raise ValueError(f"{field_name} must use a base64 data URL") |
| 37 | try: |
| 38 | content = base64.b64decode(encoded, validate=True) |
| 39 | except (binascii.Error, ValueError) as exc: |
| 40 | raise ValueError(f"{field_name} has invalid base64 data") from exc |
| 41 | if not content: |
| 42 | raise ValueError(f"{field_name} data URL must not be empty") |
| 43 | |
| 44 | resource_dir = base_dir / "inline_resources" |
| 45 | resource_dir.mkdir(parents=True, exist_ok=True) |
| 46 | resource_path = resource_dir / f"{hashlib.sha256(content).hexdigest()}.bin" |
| 47 | if not resource_path.exists(): |
| 48 | try: |
| 49 | with resource_path.open("xb") as handle: |
| 50 | handle.write(content) |
| 51 | except FileExistsError: |
| 52 | pass |
| 53 | return str(resource_path.resolve()) |
| 54 | if parsed.scheme in {"http", "https"}: |
| 55 | return source |
| 56 | if parsed.scheme == "file": |
| 57 | if parsed.netloc not in {"", "localhost"}: |
| 58 | raise ValueError(f"{field_name} does not support remote file URLs") |
| 59 | path = Path(unquote(parsed.path)) |
| 60 | elif parsed.scheme: |
| 61 | raise ValueError(f"{field_name} must use HTTP(S), file://, or a local path") |
| 62 | else: |
| 63 | path = Path(source).expanduser() |
| 64 | if not path.is_absolute(): |
| 65 | path = base_dir / path |
| 66 | return str(path.resolve()) |
| 67 | |
| 68 | |
| 69 | @dataclass(frozen=True) |
| 70 | class R2VMemorySlot: |
| 71 | """One ordered production-compatible memory slot.""" |
| 72 | |
| 73 | image_url: str | None = None |
| 74 | audio_url: str | None = None |
| 75 | audio_mode: str | None = None |
| 76 | shot_id: str | None = None |
| 77 | metadata: dict[str, Any] = field(default_factory=dict) |
| 78 | |
| 79 | def as_payload(self) -> dict[str, Any]: |
| 80 | return { |
| 81 | key: value |
| 82 | for key, value in asdict(self).items() |
| 83 | if value is not None and value != {} |
| 84 | } |
| 85 | |
| 86 | |
| 87 | @dataclass(frozen=True) |
| 88 | class R2VRequest: |
| 89 | """A fully resolved R2V request consumed by conditioning and inference.""" |
| 90 | |
| 91 | work_id: str |
| 92 | shot_id: str |
| 93 | prompt: str |
| 94 | memory_slots: tuple[R2VMemorySlot, ...] |
| 95 | condition_img: str | None |
| 96 | num_frames: int |
| 97 | width: int |
| 98 | height: int |
| 99 | seed: int |
| 100 | duration_sec: float | None = None |
| 101 | source_path: Path | None = field(default=None, compare=False) |
| 102 | request_sha256: str | None = field(default=None, compare=False) |
| 103 | |
| 104 | def as_payload(self) -> dict[str, Any]: |
| 105 | payload: dict[str, Any] = { |
| 106 | "work_id": self.work_id, |
| 107 | "shot_id": self.shot_id, |
| 108 | "prompt": self.prompt, |
| 109 | "condition_img": self.condition_img, |
| 110 | "memory_slots": [slot.as_payload() for slot in self.memory_slots], |
| 111 | "num_frames": self.num_frames, |
| 112 | "width": self.width, |
| 113 | "height": self.height, |
| 114 | "seed": self.seed, |
| 115 | } |
| 116 | if self.duration_sec is not None: |
| 117 | payload["duration_sec"] = self.duration_sec |
| 118 | return payload |
| 119 | |
| 120 | |
| 121 | def normalize_memory_slots( |
| 122 | values: Any, |
| 123 | *, |
| 124 | base_dir: Path, |
| 125 | resolve_resources: bool, |
| 126 | ) -> tuple[R2VMemorySlot, ...]: |
| 127 | if not isinstance(values, list): |
| 128 | raise ValueError("memory_slots must be a list") |
| 129 | if len(values) > MAX_MEMORY_SLOTS: |
| 130 | raise ValueError(f"memory_slots supports at most {MAX_MEMORY_SLOTS} entries") |
| 131 | |
| 132 | slots: list[R2VMemorySlot] = [] |
| 133 | for index, raw in enumerate(values): |
| 134 | if not isinstance(raw, dict): |
| 135 | raise ValueError(f"memory_slots[{index}] must be an object") |
| 136 | shot_id = str(raw.get("shot_id") or "").strip() or None |
| 137 | image_url = str(raw.get("image_url") or "").strip() or None |
| 138 | image_mode = str(raw.get("image_mode") or "").strip().lower() or None |
| 139 | audio_url = str(raw.get("audio_url") or "").strip() or None |
| 140 | audio_mode = str(raw.get("audio_mode") or "").strip().lower() or None |
| 141 | metadata = raw.get("metadata") or {} |
| 142 | if not isinstance(metadata, dict): |
| 143 | raise ValueError(f"memory_slots[{index}].metadata must be an object") |
| 144 | if image_mode: |
| 145 | raise ValueError(f"memory_slots[{index}].image_mode is not supported") |
| 146 | if bool(shot_id) == bool(image_url): |
| 147 | raise ValueError( |
| 148 | f"memory_slots[{index}] requires exactly one of shot_id or image_url" |
| 149 | ) |
| 150 | if shot_id and (audio_url or audio_mode): |
| 151 | raise ValueError( |
| 152 | f"memory_slots[{index}].shot_id cannot be combined with audio fields" |
| 153 | ) |
| 154 | if audio_mode not in {None, "empty"}: |
| 155 | raise ValueError(f"memory_slots[{index}].audio_mode only supports 'empty'") |
| 156 | if audio_url and audio_mode == "empty": |
| 157 | raise ValueError( |
| 158 | f"memory_slots[{index}].audio_url conflicts with audio_mode='empty'" |
| 159 | ) |
| 160 | if image_url and not audio_url and audio_mode is None: |
| 161 | audio_mode = "empty" |
| 162 | if resolve_resources: |
| 163 | image_url = _resolve_resource( |
| 164 | image_url, base_dir=base_dir, field_name=f"memory_slots[{index}].image_url" |
| 165 | ) |
| 166 | audio_url = _resolve_resource( |
| 167 | audio_url, base_dir=base_dir, field_name=f"memory_slots[{index}].audio_url" |
| 168 | ) |
| 169 | slots.append( |
| 170 | R2VMemorySlot( |
| 171 | shot_id=shot_id, |
| 172 | image_url=image_url, |
| 173 | audio_url=audio_url, |
| 174 | audio_mode=audio_mode, |
| 175 | metadata=dict(metadata), |
| 176 | ) |
| 177 | ) |
| 178 | return tuple(slots) |
| 179 | |
| 180 | |
| 181 | def normalize_r2v_payload( |
| 182 | payload: dict[str, Any], |
| 183 | *, |
| 184 | base_dir: str | Path = ".", |
| 185 | default_num_frames: int = 241, |
| 186 | default_width: int = 1280, |
| 187 | default_height: int = 736, |
| 188 | default_seed: int = 42, |
| 189 | prompt_max_chars: int | None = 1500, |
| 190 | resolve_resources: bool = True, |
| 191 | ) -> R2VRequest: |
| 192 | """Validate the online R2V schema and optionally resolve local resources.""" |
| 193 | |
| 194 | if not isinstance(payload, dict): |
| 195 | raise ValueError("R2V request must be a JSON object") |
| 196 | if "payload" in payload and isinstance(payload["payload"], dict): |
| 197 | legacy = payload["payload"] |
| 198 | shot = legacy.get("shot") |
| 199 | if not isinstance(shot, dict): |
| 200 | raise ValueError("director envelope payload.shot must be an object") |
| 201 | payload = { |
| 202 | "work_id": legacy.get("work_id") or payload.get("job", {}).get("work_id"), |
| 203 | "shot_id": shot.get("shot_key") or str(shot.get("shot_id") or ""), |
| 204 | "prompt": shot.get("text"), |
| 205 | "condition_img": legacy.get("condition_img"), |
| 206 | "memory_slots": legacy.get("memory_slots", []), |
| 207 | "num_frames": shot.get("num_frames"), |
| 208 | "duration_sec": shot.get("duration_sec"), |
| 209 | "width": shot.get("width"), |
| 210 | "height": shot.get("height"), |
| 211 | "seed": shot.get("seed"), |
| 212 | } |
| 213 | |
| 214 | work_id = _required_text(payload, "work_id") |
| 215 | shot_id = _required_text(payload, "shot_id") |
| 216 | prompt = _required_text(payload, "prompt") |
| 217 | if prompt_max_chars is not None: |
| 218 | if prompt_max_chars <= 0: |
| 219 | raise ValueError("prompt_max_chars must be positive") |
| 220 | prompt = prompt[:prompt_max_chars] |
| 221 | |
| 222 | root = Path(base_dir).expanduser().resolve() |
| 223 | slots = normalize_memory_slots( |
| 224 | payload.get("memory_slots", []), |
| 225 | base_dir=root, |
| 226 | resolve_resources=resolve_resources, |
| 227 | ) |
| 228 | condition_img = payload.get("condition_img") |
| 229 | if resolve_resources: |
| 230 | condition_img = _resolve_resource( |
| 231 | condition_img, base_dir=root, field_name="condition_img" |
| 232 | ) |
| 233 | elif condition_img is not None: |
| 234 | condition_img = str(condition_img).strip() or None |
| 235 | |
| 236 | num_frames = int(payload.get("num_frames") or default_num_frames) |
| 237 | width = int(payload.get("width") or default_width) |
| 238 | height = int(payload.get("height") or default_height) |
| 239 | seed_value = payload.get("seed") |
| 240 | seed = int(default_seed if seed_value is None else seed_value) |
| 241 | duration_value = payload.get("duration_sec") |
| 242 | duration_sec = float(duration_value) if duration_value is not None else None |
| 243 | if min(num_frames, width, height) <= 0: |
| 244 | raise ValueError("num_frames, width, and height must be positive") |
| 245 | if duration_sec is not None and duration_sec <= 0: |
| 246 | raise ValueError("duration_sec must be positive") |
| 247 | |
| 248 | return R2VRequest( |
| 249 | work_id=work_id, |
| 250 | shot_id=shot_id, |
| 251 | prompt=prompt, |
| 252 | condition_img=condition_img, |
| 253 | memory_slots=slots, |
| 254 | num_frames=num_frames, |
| 255 | width=width, |
| 256 | height=height, |
| 257 | seed=seed, |
| 258 | duration_sec=duration_sec, |
| 259 | ) |
| 260 | |
| 261 | |
| 262 | def load_r2v_request( |
| 263 | path: str | Path, |
| 264 | **defaults: Any, |
| 265 | ) -> R2VRequest: |
| 266 | source = Path(path).expanduser().resolve() |
| 267 | with source.open("r", encoding="utf-8") as handle: |
| 268 | payload = json.load(handle) |
| 269 | request = normalize_r2v_payload(payload, base_dir=source.parent, **defaults) |
| 270 | fingerprint = hashlib.sha256( |
| 271 | json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode( |
| 272 | "utf-8" |
| 273 | ) |
| 274 | ).hexdigest() |
| 275 | return replace(request, source_path=source, request_sha256=fingerprint) |
| 276 |