tirtc-device-builder 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +30 -13
  4. package/package.json +1 -1
  5. package/skills/tirtc-esp32-builder/SKILL.md +11 -5
  6. package/skills/tirtc-esp32-builder/USAGE.md +7 -3
  7. package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +66 -0
  8. package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +94 -0
  9. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +12 -6
  10. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +5 -0
  11. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +51 -0
  12. package/skills/tirtc-esp32-builder/assets/report-template.md +11 -0
  13. package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
  14. package/skills/tirtc-esp32-builder/references/capability-rules.md +11 -7
  15. package/skills/tirtc-esp32-builder/references/environment.md +8 -0
  16. package/skills/tirtc-esp32-builder/references/hardware-ir.md +18 -1
  17. package/skills/tirtc-esp32-builder/references/porting-risks.md +1 -1
  18. package/skills/tirtc-esp32-builder/references/reporting.md +6 -2
  19. package/skills/tirtc-esp32-builder/references/video-contract.md +30 -0
  20. package/skills/tirtc-esp32-builder/references/workflow.md +10 -2
  21. package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
  22. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +406 -68
  23. package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
  24. package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
  25. package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
  26. package/skills/tirtc-esp32-builder/scripts/video_contract.py +333 -0
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Install the portable audio semantic gate into a generated ESP-IDF project."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ MARKER = "# tirtc-audio-semantic-gate-v1"
13
+ BLOCK = f"""
14
+
15
+ {MARKER}
16
+ add_custom_target(tirtc_audio_semantic_gate ALL
17
+ COMMAND "${{PYTHON}}"
18
+ "${{CMAKE_CURRENT_LIST_DIR}}/tools/verify_audio_contract.py"
19
+ "${{CMAKE_CURRENT_LIST_DIR}}/board-audio-contract.json"
20
+ --project "${{CMAKE_CURRENT_LIST_DIR}}"
21
+ --evidence-out "${{CMAKE_BINARY_DIR}}/audio-contract-evidence.json"
22
+ COMMENT "Validating codec clocks, I2S topology, slots and shared-signal handoff"
23
+ VERBATIM
24
+ )
25
+ add_dependencies(${{CMAKE_PROJECT_NAME}}.elf tirtc_audio_semantic_gate)
26
+ """
27
+
28
+
29
+ def install(project: Path) -> None:
30
+ project = project.expanduser().resolve()
31
+ cmake = project / "CMakeLists.txt"
32
+ contract = project / "board-audio-contract.json"
33
+ if not cmake.is_file():
34
+ raise ValueError(f"ESP-IDF project CMakeLists.txt not found: {cmake}")
35
+ if not contract.is_file():
36
+ raise ValueError(
37
+ f"audio contract not found: {contract}; create and verify it before installing the gate"
38
+ )
39
+ tools = project / "tools"
40
+ tools.mkdir(parents=True, exist_ok=True)
41
+ destination = tools / "verify_audio_contract.py"
42
+ source = Path(__file__).resolve().with_name("audio_contract.py")
43
+ if destination.exists() and destination.read_bytes() != source.read_bytes():
44
+ raise ValueError(f"refusing to overwrite a different gate: {destination}")
45
+ if not destination.exists():
46
+ shutil.copyfile(source, destination)
47
+ text = cmake.read_text(encoding="utf-8")
48
+ if MARKER not in text:
49
+ cmake.write_text(text.rstrip() + BLOCK + "\n", encoding="utf-8")
50
+
51
+
52
+ def main() -> int:
53
+ parser = argparse.ArgumentParser()
54
+ parser.add_argument("project", type=Path)
55
+ args = parser.parse_args()
56
+ try:
57
+ install(args.project)
58
+ except ValueError as exc:
59
+ print(f"error: {exc}", file=sys.stderr)
60
+ return 2
61
+ print(f"installed TiRTC audio semantic gate: {args.project.resolve()}")
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Install the portable TiRTC video semantic gate into an ESP-IDF project."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ MARKER = "# tirtc-video-semantic-gate-v1"
13
+ BLOCK = f"""
14
+
15
+ {MARKER}
16
+ add_custom_target(tirtc_video_semantic_gate ALL
17
+ COMMAND "${{PYTHON}}"
18
+ "${{CMAKE_CURRENT_LIST_DIR}}/tools/verify_video_contract.py"
19
+ "${{CMAKE_CURRENT_LIST_DIR}}/board-video-contract.json"
20
+ --project "${{CMAKE_CURRENT_LIST_DIR}}"
21
+ --evidence-out "${{CMAKE_BINARY_DIR}}/video-contract-evidence.json"
22
+ COMMENT "Validating camera lock, CPU isolation, complete JPEG framing, backpressure and memory"
23
+ VERBATIM
24
+ )
25
+ add_dependencies(${{CMAKE_PROJECT_NAME}}.elf tirtc_video_semantic_gate)
26
+ """
27
+
28
+
29
+ def install(project: Path) -> None:
30
+ project = project.expanduser().resolve()
31
+ cmake = project / "CMakeLists.txt"
32
+ contract = project / "board-video-contract.json"
33
+ if not cmake.is_file():
34
+ raise ValueError(f"ESP-IDF project CMakeLists.txt not found: {cmake}")
35
+ if not contract.is_file():
36
+ raise ValueError(
37
+ f"video contract not found: {contract}; create and verify it before installing the gate"
38
+ )
39
+ tools = project / "tools"
40
+ tools.mkdir(parents=True, exist_ok=True)
41
+ destination = tools / "verify_video_contract.py"
42
+ source = Path(__file__).resolve().with_name("video_contract.py")
43
+ if destination.exists() and destination.read_bytes() != source.read_bytes():
44
+ raise ValueError(f"refusing to overwrite a different gate: {destination}")
45
+ if not destination.exists():
46
+ shutil.copyfile(source, destination)
47
+ text = cmake.read_text(encoding="utf-8")
48
+ if MARKER not in text:
49
+ cmake.write_text(text.rstrip() + BLOCK + "\n", encoding="utf-8")
50
+
51
+
52
+ def main() -> int:
53
+ parser = argparse.ArgumentParser()
54
+ parser.add_argument("project", type=Path)
55
+ args = parser.parse_args()
56
+ try:
57
+ install(args.project)
58
+ except ValueError as exc:
59
+ print(f"error: {exc}", file=sys.stderr)
60
+ return 2
61
+ print(f"installed TiRTC video semantic gate: {args.project.resolve()}")
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Check that a generated TiRTC ESP32 source package can move to another machine."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ ABSOLUTE_PATH = re.compile(r"(?:^|[\s\"'])(?:/home/|/root/|[A-Za-z]:[\\/])")
15
+ BUILD_INPUT_NAMES = {"CMakeLists.txt", "idf_component.yml", "idf_component.yaml"}
16
+ BUILD_INPUT_SUFFIXES = {".c", ".h", ".cmake", ".py", ".sh"}
17
+ SKIP_PARTS = {"build", "managed_components", ".git"}
18
+
19
+
20
+ def source_files(project: Path):
21
+ for path in project.rglob("*"):
22
+ relative = path.relative_to(project)
23
+ if not path.is_file() or SKIP_PARTS.intersection(relative.parts):
24
+ continue
25
+ if path.name in BUILD_INPUT_NAMES or path.suffix.lower() in BUILD_INPUT_SUFFIXES:
26
+ yield path
27
+
28
+
29
+ def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
30
+ project = project_path.expanduser().resolve()
31
+ errors: list[str] = []
32
+ if not (project / "CMakeLists.txt").is_file():
33
+ return {"ok": False, "errors": ["CMakeLists.txt is missing"]}
34
+ if not (project / "dependencies.lock").is_file():
35
+ errors.append("dependencies.lock is missing")
36
+ sdk_required = (
37
+ project / "third_party" / "tirtc" / "include" / "tirtc" / "tiRTC.h",
38
+ project / "third_party" / "tirtc" / "lib" / "libTiRTC.a",
39
+ project / "third_party" / "tirtc" / "manifest" / "build-contract.env",
40
+ )
41
+ missing_sdk = [str(path.relative_to(project)) for path in sdk_required if not path.is_file()]
42
+ if missing_sdk:
43
+ errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
44
+ if export and (project / "build" / "CMakeCache.txt").exists():
45
+ errors.append("export source contains build/CMakeCache.txt from another machine")
46
+
47
+ for path in source_files(project):
48
+ relative = path.relative_to(project)
49
+ text = path.read_text(encoding="utf-8", errors="replace")
50
+ if ABSOLUTE_PATH.search(text):
51
+ errors.append(f"build input contains a machine-specific absolute path: {relative}")
52
+ if path.name == "CMakeLists.txt" or path.suffix.lower() == ".cmake":
53
+ for number, line in enumerate(text.splitlines(), 1):
54
+ stripped = line.strip()
55
+ if stripped.startswith("COMMAND ") and ".sh" in stripped:
56
+ command = stripped[len("COMMAND ") :].lstrip('"')
57
+ if not command.startswith("bash ") and not command.startswith("${BASH"):
58
+ errors.append(
59
+ f"shell gate relies on executable permission: {relative}:{number}"
60
+ )
61
+ return {"ok": not errors, "errors": errors}
62
+
63
+
64
+ def main() -> int:
65
+ parser = argparse.ArgumentParser()
66
+ parser.add_argument("project", type=Path)
67
+ parser.add_argument(
68
+ "--export",
69
+ action="store_true",
70
+ help="also reject a carried build/CMakeCache.txt",
71
+ )
72
+ parser.add_argument("--json", action="store_true")
73
+ args = parser.parse_args()
74
+ result = check_project(args.project, export=args.export)
75
+ if args.json:
76
+ print(json.dumps(result, ensure_ascii=False, indent=2))
77
+ elif result["ok"]:
78
+ print("PASS: project source is relocatable")
79
+ else:
80
+ for error in result["errors"]:
81
+ print(f"FAIL: {error}", file=sys.stderr)
82
+ return 0 if result["ok"] else 3
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ """Verify a project-local TiRTC video pipeline contract."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ VIDEO_MEDIA = {
16
+ "mjpeg": "TIRTC_VIDEO_JPEG",
17
+ "h264": "TIRTC_VIDEO_H264",
18
+ "h265": "TIRTC_VIDEO_H265",
19
+ }
20
+
21
+
22
+ def sha256_file(path: Path) -> str:
23
+ digest = hashlib.sha256()
24
+ with path.open("rb") as stream:
25
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
26
+ digest.update(chunk)
27
+ return digest.hexdigest()
28
+
29
+
30
+ def project_file(project: Path, value: Any, label: str) -> Path:
31
+ if not isinstance(value, str) or not value.strip():
32
+ raise ValueError(f"{label} must be a non-empty project-relative path")
33
+ relative = Path(value)
34
+ if relative.is_absolute():
35
+ raise ValueError(f"{label} must be project-relative, got {value!r}")
36
+ resolved = (project / relative).resolve()
37
+ if project != resolved and project not in resolved.parents:
38
+ raise ValueError(f"{label} escapes project root: {value!r}")
39
+ return resolved
40
+
41
+
42
+ def mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]:
43
+ if not isinstance(value, dict):
44
+ errors.append(f"{label} must be an object")
45
+ return {}
46
+ return value
47
+
48
+
49
+ def positive_int(value: Any, label: str, errors: list[str]) -> int | None:
50
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
51
+ errors.append(f"{label} must be a positive integer")
52
+ return None
53
+ return value
54
+
55
+
56
+ def lock_versions(lock_text: str) -> dict[str, str]:
57
+ versions: dict[str, str] = {}
58
+ current: str | None = None
59
+ for line in lock_text.splitlines():
60
+ match = re.match(r"^ ([^ ].*):$", line)
61
+ if match:
62
+ current = match.group(1)
63
+ continue
64
+ if current is not None:
65
+ version = re.match(r"^ version:\s*['\"]?([^'\"\s]+)", line)
66
+ if version:
67
+ versions[current] = version.group(1)
68
+ current = None
69
+ return versions
70
+
71
+
72
+ def check_dependencies(
73
+ project: Path,
74
+ contract: dict[str, Any],
75
+ errors: list[str],
76
+ inputs: dict[str, str],
77
+ ) -> None:
78
+ dependencies = contract.get("dependencies")
79
+ if not isinstance(dependencies, dict) or not dependencies:
80
+ errors.append("dependencies must map component names to exact locked versions")
81
+ return
82
+ lock = project / "dependencies.lock"
83
+ if not lock.is_file():
84
+ errors.append("dependencies.lock is missing")
85
+ return
86
+ text = lock.read_text(encoding="utf-8", errors="replace")
87
+ inputs["dependencies.lock"] = sha256_file(lock)
88
+ locked = lock_versions(text)
89
+ for name, expected in dependencies.items():
90
+ if not isinstance(name, str) or not isinstance(expected, str) or not expected:
91
+ errors.append("dependencies entries must be non-empty string pairs")
92
+ elif locked.get(name) != expected:
93
+ errors.append(
94
+ f"locked dependency mismatch for {name}: "
95
+ f"expected {expected}, got {locked.get(name, 'missing')}"
96
+ )
97
+
98
+
99
+ def check_scheduler(
100
+ project: Path,
101
+ contract: dict[str, Any],
102
+ errors: list[str],
103
+ inputs: dict[str, str],
104
+ ) -> None:
105
+ scheduler = mapping(contract.get("scheduler"), "scheduler", errors)
106
+ wifi_core = scheduler.get("wifi_core")
107
+ camera_core = scheduler.get("camera_core")
108
+ for value, label in ((wifi_core, "wifi_core"), (camera_core, "camera_core")):
109
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
110
+ errors.append(f"scheduler.{label} must be an integer >= 0")
111
+ if isinstance(wifi_core, int) and isinstance(camera_core, int) and wifi_core == camera_core:
112
+ errors.append("camera event processing must not share the configured Wi-Fi core")
113
+ config_files = scheduler.get("config_files")
114
+ if not isinstance(config_files, list) or not config_files:
115
+ errors.append("scheduler.config_files must be a non-empty array")
116
+ return
117
+ for index, value in enumerate(config_files):
118
+ try:
119
+ config = project_file(project, value, f"scheduler.config_files[{index}]")
120
+ except ValueError as exc:
121
+ errors.append(str(exc))
122
+ continue
123
+ if not config.is_file():
124
+ errors.append(f"scheduler config does not exist: {config}")
125
+ continue
126
+ text = config.read_text(encoding="utf-8", errors="replace")
127
+ inputs[str(config.relative_to(project))] = sha256_file(config)
128
+ if isinstance(camera_core, int) and f"CONFIG_CAMERA_CORE{camera_core}=y" not in text:
129
+ errors.append(f"{config.relative_to(project)} does not select camera core {camera_core}")
130
+ if isinstance(wifi_core, int) and f"CONFIG_CAMERA_CORE{wifi_core}=y" in text:
131
+ errors.append(f"{config.relative_to(project)} pins camera processing to Wi-Fi core {wifi_core}")
132
+ wifi_config_files = scheduler.get("wifi_config_files")
133
+ if not isinstance(wifi_config_files, list) or not wifi_config_files:
134
+ errors.append("scheduler.wifi_config_files must be a non-empty array")
135
+ return
136
+ for index, value in enumerate(wifi_config_files):
137
+ try:
138
+ config = project_file(
139
+ project, value, f"scheduler.wifi_config_files[{index}]"
140
+ )
141
+ except ValueError as exc:
142
+ errors.append(str(exc))
143
+ continue
144
+ if not config.is_file():
145
+ errors.append(f"Wi-Fi scheduler config does not exist: {config}")
146
+ continue
147
+ text = config.read_text(encoding="utf-8", errors="replace")
148
+ inputs[str(config.relative_to(project))] = sha256_file(config)
149
+ if (
150
+ isinstance(wifi_core, int)
151
+ and f"CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_{wifi_core}=y" not in text
152
+ ):
153
+ errors.append(
154
+ f"{config.relative_to(project)} does not pin Wi-Fi task to core {wifi_core}"
155
+ )
156
+
157
+
158
+ def check_pipeline(contract: dict[str, Any], errors: list[str]) -> None:
159
+ camera = mapping(contract.get("camera"), "camera", errors)
160
+ codec = camera.get("codec")
161
+ if codec not in VIDEO_MEDIA:
162
+ errors.append("camera.codec must be one of h264, h265, mjpeg")
163
+ else:
164
+ boundary_field = (
165
+ "complete_jpeg_per_send"
166
+ if codec == "mjpeg"
167
+ else "complete_access_unit_per_send"
168
+ )
169
+ if camera.get(boundary_field) is not True:
170
+ errors.append(f"camera.{boundary_field} must be true")
171
+ expected_media = VIDEO_MEDIA[codec]
172
+ if camera.get("media") != expected_media:
173
+ errors.append(f"camera.media must be {expected_media} for {codec}")
174
+ if camera.get("stream_id") != 11:
175
+ errors.append("camera.stream_id must be 11 for the H5 video contract")
176
+ frame_buffers = positive_int(camera.get("frame_buffers"), "camera.frame_buffers", errors)
177
+ if frame_buffers is not None and frame_buffers < 2:
178
+ errors.append("camera.frame_buffers must be at least 2 for the selected realtime pipeline")
179
+ accepted_pids = camera.get("accepted_sensor_pids")
180
+ if (
181
+ not isinstance(accepted_pids, list)
182
+ or not accepted_pids
183
+ or any(
184
+ isinstance(pid, bool)
185
+ or not isinstance(pid, int)
186
+ or pid < 0
187
+ or pid > 0xFFFF
188
+ for pid in accepted_pids
189
+ )
190
+ or len(set(accepted_pids)) != len(accepted_pids)
191
+ ):
192
+ errors.append(
193
+ "camera.accepted_sensor_pids must be a non-empty unique uint16 array"
194
+ )
195
+ if camera.get("unknown_sensor_policy") != "reject":
196
+ errors.append("camera.unknown_sensor_policy must be reject")
197
+
198
+ memory = mapping(contract.get("memory"), "memory", errors)
199
+ send_buffer = positive_int(
200
+ memory.get("max_send_buffer_bytes"), "memory.max_send_buffer_bytes", errors
201
+ )
202
+ frame_field = (
203
+ "max_complete_jpeg_bytes"
204
+ if codec == "mjpeg"
205
+ else "max_complete_access_unit_bytes"
206
+ )
207
+ complete_frame = positive_int(
208
+ memory.get(frame_field), f"memory.{frame_field}", errors
209
+ )
210
+ backpressure = positive_int(
211
+ memory.get("video_backpressure_bytes"), "memory.video_backpressure_bytes", errors
212
+ )
213
+ if None not in (send_buffer, complete_frame, backpressure):
214
+ if complete_frame > backpressure:
215
+ errors.append(
216
+ "one complete encoded frame/access unit must fit below the "
217
+ "video backpressure threshold"
218
+ )
219
+ if backpressure >= send_buffer:
220
+ errors.append("video backpressure threshold must be lower than max send buffer")
221
+
222
+
223
+ def check_assertions(
224
+ project: Path,
225
+ contract: dict[str, Any],
226
+ errors: list[str],
227
+ inputs: dict[str, str],
228
+ ) -> None:
229
+ assertions = contract.get("implementation_assertions")
230
+ if not isinstance(assertions, list) or not assertions:
231
+ errors.append("implementation_assertions must be a non-empty array")
232
+ return
233
+ for index, item in enumerate(assertions):
234
+ label = f"implementation_assertions[{index}]"
235
+ assertion = mapping(item, label, errors)
236
+ try:
237
+ source = project_file(project, assertion.get("file"), f"{label}.file")
238
+ except ValueError as exc:
239
+ errors.append(str(exc))
240
+ continue
241
+ if not source.is_file():
242
+ errors.append(f"{label}.file does not exist: {source}")
243
+ continue
244
+ text = source.read_text(encoding="utf-8", errors="replace")
245
+ compact = re.sub(r"\s+", "", text)
246
+ inputs[str(source.relative_to(project))] = sha256_file(source)
247
+ for field, haystack, should_exist in (
248
+ ("contains", text, True),
249
+ ("contains_compact", compact, True),
250
+ ("absent", text, False),
251
+ ("absent_compact", compact, False),
252
+ ):
253
+ needles = assertion.get(field, [])
254
+ if not isinstance(needles, list) or any(
255
+ not isinstance(needle, str) or not needle for needle in needles
256
+ ):
257
+ errors.append(f"{label}.{field} must be an array of non-empty strings")
258
+ continue
259
+ for needle in needles:
260
+ found = needle in haystack
261
+ if found != should_exist:
262
+ verb = "missing" if should_exist else "contains forbidden"
263
+ errors.append(f"{label} {verb} {field} token: {needle}")
264
+
265
+
266
+ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
267
+ project = project_path.expanduser().resolve()
268
+ contract_file = contract_path.expanduser().resolve()
269
+ if not project.is_dir():
270
+ return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
271
+ if not contract_file.is_file():
272
+ return {"ok": False, "errors": [f"video contract does not exist: {contract_file}"]}
273
+ if project != contract_file and project not in contract_file.parents:
274
+ return {"ok": False, "errors": ["video contract must be inside the project"]}
275
+ try:
276
+ contract = json.loads(contract_file.read_text(encoding="utf-8"))
277
+ except (OSError, json.JSONDecodeError) as exc:
278
+ return {"ok": False, "errors": [f"invalid video contract: {exc}"]}
279
+ if not isinstance(contract, dict):
280
+ return {"ok": False, "errors": ["video contract root must be an object"]}
281
+ errors: list[str] = []
282
+ inputs = {str(contract_file.relative_to(project)): sha256_file(contract_file)}
283
+ if contract.get("schema_version") != 1:
284
+ errors.append("schema_version must be 1")
285
+ evidence = contract.get("evidence")
286
+ if not isinstance(evidence, list) or len(evidence) < 2 or any(
287
+ not isinstance(item, str) or not item for item in evidence
288
+ ):
289
+ errors.append("evidence must contain at least two non-empty source IDs")
290
+ check_dependencies(project, contract, errors, inputs)
291
+ check_scheduler(project, contract, errors, inputs)
292
+ check_pipeline(contract, errors)
293
+ check_assertions(project, contract, errors, inputs)
294
+ camera = contract.get("camera", {})
295
+ memory = contract.get("memory", {})
296
+ return {
297
+ "ok": not errors,
298
+ "summary": (
299
+ f"codec={camera.get('codec')} stream={camera.get('stream_id')} "
300
+ f"buffers={camera.get('frame_buffers')} "
301
+ f"send_buffer={memory.get('max_send_buffer_bytes')}"
302
+ ),
303
+ "inputs": inputs,
304
+ "errors": errors,
305
+ }
306
+
307
+
308
+ def main() -> int:
309
+ parser = argparse.ArgumentParser(
310
+ description="Verify locked camera dependencies, scheduler isolation, JPEG framing, memory, and adapter assertions."
311
+ )
312
+ parser.add_argument("contract", type=Path)
313
+ parser.add_argument("--project", type=Path, required=True)
314
+ parser.add_argument("--json", action="store_true")
315
+ parser.add_argument("--evidence-out", type=Path)
316
+ args = parser.parse_args()
317
+ result = verify_contract(args.contract, args.project)
318
+ if args.evidence_out is not None:
319
+ output = args.evidence_out.expanduser().resolve()
320
+ output.parent.mkdir(parents=True, exist_ok=True)
321
+ output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
322
+ if args.json:
323
+ print(json.dumps(result, ensure_ascii=False, indent=2))
324
+ elif result["ok"]:
325
+ print(f"PASS: video semantic contract: {result['summary']}")
326
+ else:
327
+ for error in result["errors"]:
328
+ print(f"FAIL: {error}", file=sys.stderr)
329
+ return 0 if result["ok"] else 3
330
+
331
+
332
+ if __name__ == "__main__":
333
+ raise SystemExit(main())