返回 JoyAI-Echo
r2v_schema.py
根目录 / echo_longvideo / r2v_schema.py
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
276 lines PYTHON