Update to sync. labs
Snapshot Nov 6, 2026 · 18:14 UTC · version 2.1.1
Package or technical metadata updated
Package or technical metadata updated
Observed in package metadata. These changes alone do not establish a new customer-facing feature.
Up to 3 plugins free · no card required.
All fields and original values.
These are saved file metadata. Open a changed file to read its text edits.
Package file
Nov 3, 2026 · 18:14 UTC
57be430d0a2e66eb5c0e9a26c195a8e9e544969883a7b6a07bdbcc82a2fc1a69
Nov 6, 2026 · 18:14 UTC
039f542ee8388401f93a765963e5ccbe614ad5043c7f63cab64c82d63fdf35a9
Package file
Nov 3, 2026 · 18:14 UTC
3436
Nov 6, 2026 · 18:14 UTC
3477
Package file
Nov 3, 2026 · 18:14 UTC
Not present
Nov 6, 2026 · 18:14 UTC
{"sha256":"5ea1e02f1e61e34fec4f22d82e0d099199621cb70cf29800a265a1b7a8fd1360","size":5751}
Package file
Nov 3, 2026 · 18:14 UTC
Not present
Nov 6, 2026 · 18:14 UTC
{"sha256":"feedb17d19d88a1c87834635d39b362709453130f03e82c66916d758717775ba","size":233}
Compare saved observations
Download comparison JSONChanged files
Choose a file to see its text changes. Binary or large files show metadata only.
skills/ai-video-to-talking/SKILL.md →
skills/ai-video-to-talking/agents/openai.yaml →
skills/ai-video-to-talking/references/api.md →
skills/ai-video-to-talking/references/connect.md →
skills/ai-video-to-talking/references/media-input.md →
skills/ai-video-to-talking/references/source-clip.md →
skills/batch-lipsync/SKILL.md →
skills/batch-lipsync/agents/openai.yaml →
skills/batch-lipsync/references/batch-api.md →
skills/batch-lipsync/references/connect.md →
skills/batch-lipsync/references/media-input.md →
skills/batch-lipsync/scripts/sync_batch.py →
skills/edit-dialogue/SKILL.md →
skills/edit-dialogue/agents/openai.yaml →
skills/edit-dialogue/references/connect.md →
skills/edit-dialogue/references/media-input.md →
skills/lipsync-video/SKILL.md →
skills/lipsync-video/agents/openai.yaml →
skills/lipsync/agents/openai.yaml →
skills/lipsync/references/api.md →
skills/lipsync/references/connect.md →
skills/lipsync/references/media-input.md →
skills/localize-campaign/SKILL.md →
skills/localize-campaign/agents/openai.yaml →
skills/localize-campaign/references/api.md →
skills/localize-campaign/references/connect.md →
skills/localize-campaign/references/media-input.md →
skills/setup/agents/openai.yaml →
skills/setup/references/connect.md →
skills/sync-api/agents/openai.yaml →
skills/sync-api/references/api.md →
skills/sync-api/references/batch-api.md →
skills/sync-api/references/python.md →
skills/sync-api/references/typescript.md →
skills/sync-api/references/webhooks.md →
skills/talking-photo/SKILL.md →
skills/talking-photo/agents/openai.yaml →
skills/talking-photo/references/api.md →
skills/talking-photo/references/connect.md →
skills/talking-photo/references/media-input.md →
skills/translate-and-dub/SKILL.md →
skills/translate-and-dub/agents/openai.yaml →
skills/translate-and-dub/references/api.md →
skills/translate-and-dub/references/connect.md →
skills/translate-and-dub/references/media-input.md →
skills/troubleshoot/SKILL.md →
skills/troubleshoot/agents/openai.yaml →
skills/batch-lipsync/scripts/sync_batch.py
− removed · + added
--- before +++ after @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +"""Run many sync. lipsync generations from a CSV manifest, safely and resumably. + +Usage: + sync_batch.py estimate MANIFEST.csv [--model sync-3] + sync_batch.py run MANIFEST.csv [--out DIR] [--concurrency N] [--model sync-3] [--only ID,ID] [--retry-failed] + sync_batch.py submit-batch MANIFEST.csv [--dry-run] [--webhook-url URL] [--model sync-3] + sync_batch.py batch-status MANIFEST.csv [--wait] [--out DIR] + +Manifest columns: id, video or image, audio or (script + voice_id), optional sync_mode and model. +Values can be local paths, https URLs or sync. asset IDs. Needs SYNC_API_KEY. + +State lives next to the manifest in MANIFEST.csv.state.json: uploaded asset IDs, one +idempotency key and request fingerprint per row, generation IDs and results. Re-running a +command resumes from it: completed rows are skipped, submitted rows are polled instead of +resubmitted, and a row whose inputs changed after submission is refused. Only one command +runs per manifest at a time. Results are also written to results.csv. +""" +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +API = os.environ.get("SYNC_API_BASE", "https://api.sync.so").rstrip("/") +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) +TERMINAL = {"COMPLETED", "FAILED", "REJECTED"} +SYNC_MODES = {"bounce", "loop", "cut_off", "silence", "remap"} +MIME = { + ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm", ".avi": "video/x-msvideo", + ".wav": "audio/wav", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac", + ".ogg": "audio/ogg", ".flac": "audio/flac", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", +} +WORDS_PER_SECOND = 2.5 +MAX_TRANSIENT_RETRIES = 6 +# The API protects an idempotency key for 7 days. Past that, a replay without a known +# generation ID could start a second generation, so it needs a human check first. +REPLAY_WINDOW_SECONDS = 6 * 24 * 3600 + + +class ApiError(Exception): + def __init__(self, status: int, body, headers=None): + self.status, self.body, self.headers = status, body, headers or {} + self.code = body.get("errorCode") if isinstance(body, dict) else None + message = body.get("message") if isinstance(body, dict) else body + super().__init__(f"HTTP {status} {self.code or ''}: {message}".replace(" ", " ")) + + @property + def json(self) -> dict: + return self.body if isinstance(self.body, dict) else {} + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Never follow redirects with the API key attached; signed URLs are fetched without it.""" + + def redirect_request(self, *args, **kwargs): + return None + + +_api_opener = urllib.request.build_opener(_NoRedirect) + + +def api_raw(method: str, path: str, body=None, headers=None, timeout=70): + """Call the sync. API and return (status, headers, bytes). Redirects are returned, not followed.""" + key = os.environ.get("SYNC_API_KEY") + if not key: + sys.exit("SYNC_API_KEY is not set. Create a key at https://sync.so/settings/api-keys.") + data = json.dumps(body).encode() if body is not None else None + url = path if path.startswith("http") else f"{API}{path}" + req = urllib.request.Request(url, data=data, method=method) + req.add_header("x-api-key", key) + if data is not None: + req.add_header("Content-Type", "application/json") + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with _api_opener.open(req, timeout=timeout) as resp: + return resp.status, dict(resp.headers), resp.read() + except urllib.error.HTTPError as err: + if err.code in (301, 302, 303, 307, 308): + return err.code, dict(err.headers), b"" + raw = err.read().decode(errors="replace") + try: + parsed = json.loads(raw) + except ValueError: + parsed = raw + raise ApiError(err.code, parsed, dict(err.headers)) from None + + +def api(method: str, path: str, body=None, headers=None, timeout=70): + _, _, raw = api_raw(method, path, body, headers, timeout) + return json.loads(raw) if raw else {} + + +def fetch(url: str, dest: Path | None = None): + """GET a signed or public URL, without the API key.""" + with urllib.request.urlopen(url, timeout=300) as resp: + if dest is None: + return resp.read() + tmp = dest.with_name(f"{dest.name}.{os.getpid()}.part") + with open(tmp, "wb") as fh: + shutil.copyfileobj(resp, fh) + tmp.replace(dest) + return None + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +class ManifestLock: + """One command per manifest at a time, across processes.""" + + def __init__(self, manifest: Path): + self.path = manifest.with_name(manifest.name + ".lock") + self.fh = None + + def __enter__(self): + self.fh = open(self.path, "a+") + try: + import fcntl + fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except ImportError: # Windows: fall back to an exclusive lock file + self.fh.close() + try: + self.fh = os.fdopen(os.open(str(self.path) + ".win", os.O_CREAT | os.O_EXCL | os.O_WRONLY), "w") + except FileExistsError: + sys.exit(f"Another sync_batch.py command is already running on this manifest ({self.path}.win).") + except OSError: + sys.exit("Another sync_batch.py command is already running on this manifest. Wait for it to finish.") + return self + + def __exit__(self, *exc): + win = Path(str(self.path) + ".win") + if self.fh: + self.fh.close() + if win.exists(): + win.unlink() + + +class State: + def __init__(self, manifest: Path): + self.path = manifest.with_name(manifest.name + ".state.json") + self.lock = threading.RLock() + self.data = {"run": uuid.uuid4().hex[:12], "assets": {}, "rows": {}, "batch": None} + if self.path.exists(): + self.data.update(json.loads(self.path.read_text())) + + def row(self, row_id: str) -> dict: + with self.lock: + return dict(self.data["rows"].get(row_id, {})) + + def update(self, row_id: str, **fields): + with self.lock: + self.data["rows"].setdefault(row_id, {}).update(fields) + self.save() + + def save(self): + with self.lock: + tmp = self.path.with_name(f"{self.path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(self.data, indent=2)) + tmp.replace(self.path) + + def batch_unresolved(self) -> bool: + batch = self.data.get("batch") or {} + return bool(batch.get("submitting") or (batch.get("id") and batch.get("status") not in ("COMPLETED", "FAILED"))) + + +def read_manifest(path: Path) -> list[dict]: + with open(path, newline="", encoding="utf-8-sig") as fh: + rows = [{(k or "").strip().lower(): (v or "").strip() for k, v in r.items()} for r in csv.DictReader(fh)] + problems, seen = [], set() + for n, r in enumerate(rows, start=2): + rid = r.get("id", "") + if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", rid): + problems.append(f"line {n}: id must be 1-80 letters, digits, '_' or '-'") + if rid in seen: + problems.append(f"line {n}: duplicate id {rid}") + seen.add(rid) + if bool(r.get("video")) == bool(r.get("image")): + problems.append(f"line {n} ({rid}): set exactly one of video or image") + if bool(r.get("audio")) == bool(r.get("script")): + problems.append(f"line {n} ({rid}): set exactly one of audio or script") + if r.get("script") and not r.get("voice_id"): + problems.append(f"line {n} ({rid}): script needs voice_id") + if r.get("sync_mode") and r["sync_mode"] not in SYNC_MODES: + problems.append(f"line {n} ({rid}): sync_mode must be one of {', '.join(sorted(SYNC_MODES))}") + for col in ("video", "image", "audio"): + v = r.get(col) + if v and not (v.startswith("https://") or UUID_RE.match(v) or (path.parent / Path(v).expanduser()).is_file()): + problems.append(f"line {n} ({rid}): {col} {v!r} is not a file, https URL or asset ID") + if not rows: + problems.append("the manifest has no rows") + if problems: + sys.exit("Fix the manifest first:\n " + "\n ".join(problems)) + return rows + + +def local_path(value: str, manifest_dir: Path) -> Path | None: + if value.startswith("https://") or UUID_RE.match(value): + return None + return (manifest_dir / Path(value).expanduser()).resolve() + + +def fingerprint(row: dict, args, manifest_dir: Path) -> str: + """Identity of what a row asks for, including the bytes of local files.""" + parts = {"model": row.get("model") or args.model, "sync_mode": row.get("sync_mode", ""), + "script": row.get("script", ""), "voice_id": row.get("voice_id", "")} + for col in ("video", "image", "audio"): + value = row.get(col, "") + path = local_path(value, manifest_dir) if value else None + parts[col] = f"sha256:{sha256_file(path)}" if path else value + return hashlib.sha256(json.dumps(parts, sort_keys=True).encode()).hexdigest() + + +def check_unchanged(rows: list[dict], args, state: State, manifest_dir: Path) -> dict: + """Refuse rows whose inputs changed after they were submitted; returns id -> fingerprint.""" + prints, changed = {}, [] + for row in rows: + prints[row["id"]] = fp = fingerprint(row, args, manifest_dir) + saved = state.row(row["id"]) + if saved.get("fingerprint") and saved["fingerprint"] != fp and (saved.get("key") or saved.get("generationId")): + changed.append(row["id"]) + if changed: + sys.exit("These rows changed after they were submitted: " + ", ".join(changed) + + ".\nGive each changed row a new id (a new paid generation), or restore its original inputs.") + return prints + + +def resolve_media(value: str, kind: str, state: State, manifest_dir: Path) -> dict: + """Return {"url": ...} or {"assetId": ...}, uploading each local file only once.""" + path = local_path(value, manifest_dir) + if path is None: + return {"url": value} if value.startswith("https://") else {"assetId": value} + with state.lock: # one upload per file content, even when rows share it + cache_key = f"sha256:{sha256_file(path)}" + cached = state.data["assets"].get(cache_key) + if cached: + return {"assetId": cached} + content_type = MIME.get(path.suffix.lower()) + if not content_type: + sys.exit(f"Unsupported file type: {path}") + size = path.stat().st_size + presign = api("POST", "/v2/assets/upload", {"fileName": path.name, "contentType": content_type, "size": size}) + with open(path, "rb") as fh: + put = urllib.request.Request(presign["uploadUrl"], data=fh.read(), method="PUT") + put.add_header("Content-Type", content_type) + urllib.request.urlopen(put, timeout=600).read() + asset = api("POST", "/v2/assets", {"url": presign["url"], "type": kind.upper(), "name": path.name}) + state.data["assets"][cache_key] = asset["id"] + state.save() + return {"assetId": asset["id"]} + + +def payload_for(row: dict, args, state: State, manifest_dir: Path) -> dict: + visual = "video" if row.get("video") else "image" + inputs = [{"type": visual, **resolve_media(row[visual], visual, state, manifest_dir)}] + if row.get("audio"): + inputs.append({"type": "audio", **resolve_media(row["audio"], "audio", state, manifest_dir)}) + else: + inputs.append({"type": "text", "provider": {"name": "elevenlabs", "voiceId": row["voice_id"], "script": row["script"]}}) + body = {"model": row.get("model") or args.model, "input": inputs, "outputFileName": row["id"]} + if row.get("sync_mode") and visual == "video": + body["options"] = {"sync_mode": row["sync_mode"]} + return body + + +def media_seconds(value: str, manifest_dir: Path): + if UUID_RE.match(value): + try: + return api("GET", f"/v2/assets/{value}").get("durationSeconds") + except ApiError: + return None + if not shutil.which("ffprobe"): + return None + path = local_path(value, manifest_dir) + out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path or value)], + capture_output=True, text=True) + try: + return float(out.stdout.strip()) + except ValueError: + return None + + +def billable_seconds(row: dict, manifest_dir: Path): + """The output length sync. bills: the speech, adjusted for cut_off and silence on video.""" + speech = (max(1.0, len(row["script"].split()) / WORDS_PER_SECOND) if row.get("script") + else media_seconds(row["audio"], manifest_dir)) + if not speech: + return None + mode = row.get("sync_mode") + if row.get("video") and mode in ("cut_off", "silence"): + video = media_seconds(row["video"], manifest_dir) + if not video: + return None + return min(speech, video) if mode == "cut_off" else max(speech, video) + return speech + + +def cmd_estimate(args): + manifest = Path(args.manifest) + rows = read_manifest(manifest) + total, unpriced, approx = 0.0, [], False + print(f"{'id':<28} {'seconds':>8} {'usd':>9}") + for row in rows: + seconds = billable_seconds(row, manifest.parent) + cost = None + if seconds: + est = api("POST", "/v2/generations/estimate", {"model": row.get("model") or args.model, "duration": round(seconds, 2)}) + cost = est.get("estimatedGenerationCost") + if not isinstance(cost, (int, float)): + unpriced.append(row["id"]) + print(f"{row['id']:<28} {seconds or '?':>8} {'?':>9}") + continue + total += cost + approx = approx or bool(row.get("script")) + print(f"{row['id']:<28} {seconds:>8.1f} {cost:>9.2f}") + note = " Rows with a script are approximate, and speech synthesis is billed separately." if approx else "" + if unpriced: + print(f"\nKnown subtotal ${total:.2f} for {len(rows) - len(unpriced)} of {len(rows)} rows.{note}") + print(f"Couldn't price {len(unpriced)} rows (install ffprobe, or check them by hand): {', '.join(unpriced)}") + sys.exit(2) + print(f"\n{len(rows)} rows, estimated total ${total:.2f}.{note}") + + +class Slots: + """A concurrency limit that shrinks to the plan's real limit when the API reports it.""" + + def __init__(self, limit: int): + self.limit, self.active, self.cond = max(1, limit), 0, threading.Condition() + + def acquire(self): + with self.cond: + while self.active >= self.limit: + self.cond.wait() + self.active += 1 + + def release(self): + with self.cond: + self.active -= 1 + self.cond.notify_all() + + def learn(self, limit): + if isinstance(limit, int) and limit > 0: + with self.cond: + self.limit = min(self.limit, limit) + + +def submit(body: dict, key: str, slots: Slots) -> dict: + """POST /v2/generate with a fixed idempotency key; every retry reuses it.""" + attempt = 0 + while True: + try: + return api("POST", "/v2/generate", body, {"Idempotency-Key": key}) + except ApiError as err: + info = err.json + if err.status == 429: + slots.learn(info.get("concurrencyLimit")) + time.sleep(float(info.get("retryAfterSeconds") or err.headers.get("Retry-After") or 20)) + continue + if err.status == 409 and err.code == "IDEMPOTENCY_IN_PROGRESS": + time.sleep(2) + continue + if info.get("generationId") and err.status in (409, 503): + return {"id": info["generationId"], "status": "PENDING"} + attempt += 1 + if (err.status >= 500 or err.status == 408) and attempt <= MAX_TRANSIENT_RETRIES: + time.sleep(float(err.headers.get("Retry-After") or min(60, 2 ** attempt))) + continue + raise + except (urllib.error.URLError, TimeoutError, ConnectionError): + attempt += 1 + if attempt > MAX_TRANSIENT_RETRIES: + raise + time.sleep(min(60, 2 ** attempt)) + + +def wait_for(gen_id: str) -> dict: + while True: + try: + gen = api("GET", f"/v2/generate/{gen_id}?wait=true&timeout=10") + except (urllib.error.URLError, TimeoutError, ConnectionError): + time.sleep(5) + continue + except ApiError as err: + if err.status == 429 or err.status >= 500: + time.sleep(10) + continue + raise + if gen.get("status") in TERMINAL: + return gen + time.sleep(3) + + +def download(rid: str, url: str, out: str | None, state: State): + if not out or not url: + return + dest = Path(out).resolve() / f"{rid}.mp4" + if dest.exists(): + return + dest.parent.mkdir(parents=True, exist_ok=True) + fetch(url, dest) + state.update(rid, file=str(dest)) + + +def process_row(row, args, state: State, slots: Slots, manifest_dir: Path, fp: str): + rid = row["id"] + saved = state.row(rid) + if saved.get("status") == "COMPLETED" and saved.get("outputUrl"): + download(rid, saved["outputUrl"], args.out, state) + print(f"{rid}: already completed", flush=True) + return + retry = saved.get("status") in {"FAILED", "REJECTED"} and args.retry_failed + if saved.get("status") in {"FAILED", "REJECTED"} and not retry: + print(f"{rid}: {saved['status']} earlier ({saved.get('error')}); pass --retry-failed to try again", flush=True) + return + key = saved.get("key") or f"{state.data['run']}-{rid}"[:128] + try: + if saved.get("generationId") and not retry: + gen = wait_for(saved["generationId"]) + else: + submitted_at = saved.get("submittedAt") + if submitted_at and not saved.get("generationId") and time.time() - submitted_at > REPLAY_WINDOW_SECONDS: + print(f"{rid}: submitted {int((time.time() - submitted_at) / 86400)} days ago with no confirmed generation. " + f"Check GET /v2/generations for it before retrying; skipped.", flush=True) + return + body = payload_for(row, args, state, manifest_dir) + # Save the key, request identity and time before the request, so a crash can't lose them. + state.update(rid, key=key, fingerprint=fp, mode="single", submittedAt=submitted_at or time.time()) + slots.acquire() + try: + # After a confirmed failure the same key starts a fresh attempt, per the API's idempotency rules. + created = submit(body, key, slots) + state.update(rid, generationId=created["id"], status=created.get("status", "PENDING"), error=None) + gen = wait_for(created["id"]) + finally: + slots.release() + state.update(rid, status=gen["status"], outputUrl=gen.get("outputUrl"), error=gen.get("error") or gen.get("errorCode")) + if gen["status"] == "COMPLETED": + download(rid, gen.get("outputUrl"), args.out, state) + suffix = "" if gen["status"] == "COMPLETED" else f" ({gen.get('error') or gen.get('errorCode')})" + print(f"{rid}: {gen['status']}{suffix}", flush=True) + except ApiError as err: + state.update(rid, status="FAILED", error=str(err)) + print(f"{rid}: FAILED ({err})", flush=True) + + +def write_results(manifest: Path, rows: list[dict], state: State): + out = manifest.with_name("results.csv") + with open(out, "w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["id", "status", "generation_id", "output_url", "file", "error"]) + for row in rows: + s = state.data["rows"].get(row["id"], {}) + w.writerow([row["id"], s.get("status", "NOT_STARTED"), s.get("generationId") or "", + s.get("outputUrl") or "", s.get("file", ""), s.get("error") or ""]) + statuses = [state.data["rows"].get(r["id"], {}).get("status") for r in rows] + print(f"\n{statuses.count('COMPLETED')}/{len(rows)} completed, " + f"{sum(s in ('FAILED', 'REJECTED') for s in statuses)} failed. Results: {out}") + + +def cmd_run(args): + manifest = Path(args.manifest) + all_rows = read_manifest(manifest) + rows = all_rows + if args.only: + wanted = {x.strip() for x in args.only.split(",") if x.strip()} + rows = [r for r in all_rows if r["id"] in wanted] + missing = wanted - {r["id"] for r in rows} + if missing: + sys.exit(f"Unknown row ids: {', '.join(sorted(missing))}") + state = State(manifest) + if state.batch_unresolved(): + in_batch = [r["id"] for r in rows if state.row(r["id"]).get("mode") == "batch"] + if (state.data["batch"] or {}).get("submitting") or in_batch: + sys.exit("A batch for this manifest is still running or its submission is unconfirmed. " + "Run batch-status first, so its rows aren't submitted twice.") + prints = check_unchanged(rows, args, state, manifest.parent) + state.save() + slots = Slots(args.concurrency) + with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as pool: + for future in [pool.submit(process_row, r, args, state, slots, manifest.parent, prints[r["id"]]) for r in rows]: + future.result() + write_results(manifest, all_rows, state) + + +def cmd_submit_batch(args): + manifest = Path(args.manifest) + rows = read_manifest(manifest) + state = State(manifest) + batch = state.data.get("batch") or {} + if batch.get("submitting") and not args.dry_run: + sys.exit("A previous submit may have reached sync. without a reply. List your batches with GET /v2/batch " + "and check for it before submitting again; then remove the 'batch' entry from the state file.") + if state.batch_unresolved() and not args.dry_run: + sys.exit(f"Batch {batch['id']} is still running. Use batch-status.") + prints = check_unchanged(rows, args, state, manifest.parent) + # Rows already submitted on their own (a pilot, or an earlier run) stay out of the batch. + pending = [r for r in rows if not state.row(r["id"]).get("generationId") and state.row(r["id"]).get("status") != "COMPLETED"] + skipped = len(rows) - len(pending) + if not pending: + sys.exit("Every row was already submitted. Use run to follow them up.") + body = {"rows": [{"request_id": r["id"], "endpoint": "/v2/generate", + "payload": payload_for(r, args, state, manifest.parent)} for r in pending]} + if args.webhook_url: + body["webhook_url"] = args.webhook_url + if args.dry_run: + body["dry_run"] = True + print(json.dumps(api("POST", "/v2/batch", body)) + (f" ({skipped} rows already submitted were left out)" if skipped else "")) + return + state.data["batch"] = {"submitting": True, "rows": [r["id"] for r in pending], "submittedAt": time.time()} + for r in pending: + state.data["rows"].setdefault(r["id"], {}).update(mode="batch", fingerprint=prints[r["id"]]) + state.save() + created = api("POST", "/v2/batch", body) + state.data["batch"] = {"id": created["id"], "status": created.get("status"), "rows": [r["id"] for r in pending]} + state.save() + print(f"Batch {created['id']} submitted with {len(pending)} rows" + + (f" ({skipped} already submitted rows left out)" if skipped else "") + ". Check on it with batch-status.") + + +def cmd_batch_status(args): + manifest = Path(args.manifest) + rows = read_manifest(manifest) + state = State(manifest) + batch_id = (state.data.get("batch") or {}).get("id") + if not batch_id: + sys.exit("No confirmed batch in the state file. Run submit-batch first, or reconcile with GET /v2/batch.") + while True: + batch = api("GET", f"/v2/batch/{batch_id}") + print(f"{batch_id}: {batch.get('status')} {json.dumps(batch.get('metrics') or {})}", flush=True) + if batch.get("status") in {"COMPLETED", "FAILED"} or not args.wait: + break + time.sleep(args.interval) + state.data["batch"]["status"] = batch.get("status") + state.save() + if batch.get("status") != "COMPLETED" or not batch.get("outputUrl"): + return + # The results URL answers with a redirect to a signed file; fetch that without the API key. + status, headers, raw = api_raw("GET", batch["outputUrl"]) + text = fetch(headers.get("Location") or headers.get("location")).decode() if 300 <= status < 400 else raw.decode() + for line in filter(None, (ln.strip() for ln in text.splitlines())): + rec = json.loads(line) + rid, resp, error = rec["request_id"], rec.get("response") or {}, rec.get("error") + state.update(rid, status=rec.get("status"), generationId=resp.get("id"), + outputUrl=resp.get("output_url") or resp.get("outputUrl"), + error=error.get("message") if isinstance(error, dict) else error) + if rec.get("status") == "COMPLETED": + download(rid, state.row(rid).get("outputUrl"), args.out, state) + write_results(manifest, rows, state) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + for name in ("estimate", "run", "submit-batch", "batch-status"): + p = sub.add_parser(name) + p.add_argument("manifest") + p.add_argument("--model", default="sync-3") + if name in ("run", "batch-status"): + p.add_argument("--out", help="download finished videos into this directory") + if name == "run": + p.add_argument("--concurrency", type=int, default=1, + help="generations in flight; set it to the plan's generationLimits.concurrency") + p.add_argument("--only", help="comma-separated row ids, for a pilot run") + p.add_argument("--retry-failed", action="store_true", help="start new attempts for FAILED or REJECTED rows") + if name == "submit-batch": + p.add_argument("--dry-run", action="store_true", help="validate only; creates nothing") + p.add_argument("--webhook-url") + if name == "batch-status": + p.add_argument("--wait", action="store_true", help="keep checking until the batch finishes") + p.add_argument("--interval", type=float, default=60, help="seconds between checks with --wait") + args = parser.parse_args() + commands = {"estimate": cmd_estimate, "run": cmd_run, "submit-batch": cmd_submit_batch, "batch-status": cmd_batch_status} + if args.command == "estimate": + commands[args.command](args) + return + with ManifestLock(Path(args.manifest)): + commands[args.command](args) + + +if __name__ == "__main__": + main()
Full technical diff · 62 changed fields
changed /files/.codex-plugin~1plugin.json/sha256
"57be430d0a2e66eb5c0e9a26c195a8e9e544969883a7b6a07bdbcc82a2fc1a69"
"039f542ee8388401f93a765963e5ccbe614ad5043c7f63cab64c82d63fdf35a9"
changed /files/.codex-plugin~1plugin.json/size
3436
3477
added /files/skills~1ai-video-to-talking~1SKILL.md
Field was absent
{
"sha256": "5ea1e02f1e61e34fec4f22d82e0d099199621cb70cf29800a265a1b7a8fd1360",
"size": 5751
}added /files/skills~1ai-video-to-talking~1agents~1openai.yaml
Field was absent
{
"sha256": "feedb17d19d88a1c87834635d39b362709453130f03e82c66916d758717775ba",
"size": 233
}added /files/skills~1ai-video-to-talking~1references~1api.md
Field was absent
{
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
}added /files/skills~1ai-video-to-talking~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1ai-video-to-talking~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}added /files/skills~1ai-video-to-talking~1references~1source-clip.md
Field was absent
{
"sha256": "8546d0a63b02210027655531e3fb1a20bcbdfec10894f740897f1e8f892d370b",
"size": 2167
}added /files/skills~1batch-lipsync~1SKILL.md
Field was absent
{
"sha256": "1c207d521664b8587de3910bafb04a0721999bfa0692b4a6e74cc4cd78c7395f",
"size": 6425
}added /files/skills~1batch-lipsync~1agents~1openai.yaml
Field was absent
{
"sha256": "8bcd0983adff0da6aa5177a2a87e9de425ea9115d195ad8ca1b95421a41b71a8",
"size": 194
}added /files/skills~1batch-lipsync~1references~1batch-api.md
Field was absent
{
"sha256": "3f9caaaccdda807942d04d3328beb16c3105ed181ee2a80f8caf6f5a985f745a",
"size": 3787
}added /files/skills~1batch-lipsync~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1batch-lipsync~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}added /files/skills~1batch-lipsync~1scripts~1sync_batch.py
Field was absent
{
"sha256": "b4c01decdcdc3eb8be8f5535f379ed41faa2b4e687d56ac4fe1859b33651359c",
"size": 27525
}changed /files/skills~1edit-dialogue~1SKILL.md/sha256
"880aa5421d80a7a3c395a2367e070221daf63f958dde13b912cdd763fd74a2ba"
"64879bd5ede7d75261f7fd72f690bb89b316050c16d8d790237c4078ed1bde6f"
changed /files/skills~1edit-dialogue~1SKILL.md/size
3594
5141
changed /files/skills~1edit-dialogue~1agents~1openai.yaml/sha256
"e02542ea183d7a3d356091aa412a5c55541cd66aa8210b203431bd779a15b43b"
"6159ae06234f0f4991c7ac061231bb33f1d4f5cd822bc6b5fb5a8471697ac94e"
changed /files/skills~1edit-dialogue~1agents~1openai.yaml/size
203
206
added /files/skills~1edit-dialogue~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1edit-dialogue~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}removed /files/skills~1lipsync-video~1SKILL.md
{
"sha256": "a10625f62c954ffcce19392f631a0421813ecda225285828ff74c8ceb6817076",
"size": 4395
}Field is absent
removed /files/skills~1lipsync-video~1agents~1openai.yaml
{
"sha256": "a1ae3cfeb2b6c118912a63b3edb198f314c2d735b9eb3487e90602a8d12631cf",
"size": 205
}Field is absent
added /files/skills~1lipsync~1SKILL.md
Field was absent
{
"sha256": "aab4944b204493b12920917cd90b4e9999ce6eaa7c362ef302571cd2bad37a2c",
"size": 5124
}added /files/skills~1lipsync~1agents~1openai.yaml
Field was absent
{
"sha256": "a611c56385745beb85b4c222011857be45ad6c7af583d885b18009d635ac39be",
"size": 194
}added /files/skills~1lipsync~1references~1api.md
Field was absent
{
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
}added /files/skills~1lipsync~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1lipsync~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}added /files/skills~1localize-campaign~1SKILL.md
Field was absent
{
"sha256": "48176aacaaaef977a806f2509ad4f7955e9375f0dd7363d4629f57ab6f5ba4f0",
"size": 5089
}added /files/skills~1localize-campaign~1agents~1openai.yaml
Field was absent
{
"sha256": "2681aa94d3de0b50ac2dcbe513cfa8f1ba1ce2653d32d0f8ff8e6c7c0cad41bf",
"size": 218
}added /files/skills~1localize-campaign~1references~1api.md
Field was absent
{
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
}added /files/skills~1localize-campaign~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1localize-campaign~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}changed /files/skills~1setup~1SKILL.md/sha256
"7687308da0731c5ef23a4ad4352610d0f71023d0d611a637d27178d96468cb1e"
"904bb4d86c53eaf1753e28ae3c70a7a9099298ce984bd5b3553171ee7c8b3f98"
changed /files/skills~1setup~1SKILL.md/size
1811
2452
changed /files/skills~1setup~1agents~1openai.yaml/sha256
"854273f833b6ee10c8b34e7ce203fef8ee58181208977851eddc28188d17302c"
"c95bb92bad2840d984fed9ac5b81dc8b9d8bca27905219cb23bfede8ba9e7598"
changed /files/skills~1setup~1agents~1openai.yaml/size
229
244
added /files/skills~1setup~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1sync-api~1SKILL.md
Field was absent
{
"sha256": "1319bf8b74e31f7a8ea90907b0b1921cfb653a4014a2b36bc157d196d56dc7d3",
"size": 5779
}added /files/skills~1sync-api~1agents~1openai.yaml
Field was absent
{
"sha256": "50cc0dbcc9a0601d84d01f2d6a0f270b460e00f106688eae67742d7d8c36d21c",
"size": 198
}added /files/skills~1sync-api~1references~1api.md
Field was absent
{
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
}added /files/skills~1sync-api~1references~1batch-api.md
Field was absent
{
"sha256": "3f9caaaccdda807942d04d3328beb16c3105ed181ee2a80f8caf6f5a985f745a",
"size": 3787
}added /files/skills~1sync-api~1references~1python.md
Field was absent
{
"sha256": "4f1902a9daecbe0f2f8f4f0872a0a9d7cf991373c809fce589b6b77ad3e603c2",
"size": 5050
}added /files/skills~1sync-api~1references~1typescript.md
Field was absent
{
"sha256": "0a4b7d2236a8c4ffa9ca1fce5363813a07a93fade659f186080d83d8ab234f40",
"size": 5665
}added /files/skills~1sync-api~1references~1webhooks.md
Field was absent
{
"sha256": "b03f957d176006d5578fc2255689424736bab12069f9819829716fe88b6ee4d3",
"size": 3381
}changed /files/skills~1talking-photo~1SKILL.md/sha256
"39999c45ef608b062f5d154079690ef6cf31f5184513a88c6b8853f5ab2aa31b"
"8a74d7fb7ccaf791e6b751e3eb88d7c63c78eed29523333490a89c0b8dd0787f"
changed /files/skills~1talking-photo~1SKILL.md/size
3723
4616
changed /files/skills~1talking-photo~1agents~1openai.yaml/sha256
"cf50b0a370b0a7afe7b7b01b011e11c2ed2d34675a5917976d1c89a7a81b6643"
"d26d7f517f4d8c2f74f9ea87cc04d084ddb27b775afc3810f63ec8d12bef3384"
changed /files/skills~1talking-photo~1agents~1openai.yaml/size
213
192
added /files/skills~1talking-photo~1references~1api.md
Field was absent
{
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
}added /files/skills~1talking-photo~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1talking-photo~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}changed /files/skills~1translate-and-dub~1SKILL.md/sha256
"540b899b2d5d5ab48d1b82a6867ebf328cf07f88260f8fff6d55121e63c3d98d"
"a5dc78642d83853740678b1b147ca6e285c7729c7cb27ddf908969f0c811cf3a"
changed /files/skills~1translate-and-dub~1SKILL.md/size
3361
3705
changed /files/skills~1translate-and-dub~1agents~1openai.yaml/sha256
"b3e44c828051940be246b69af9162ad981fcf09b05ec55e325fdf0b5c3b4784b"
"3aa37f041039194300b7a7d276347cc351b47343f2e0e7a5f9471906ba4b37d9"
changed /files/skills~1translate-and-dub~1agents~1openai.yaml/size
221
197
added /files/skills~1translate-and-dub~1references~1api.md
Field was absent
{
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
}added /files/skills~1translate-and-dub~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1translate-and-dub~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}added /files/skills~1troubleshoot~1SKILL.md
Field was absent
{
"sha256": "fd3a3b42cf2841ee296ebb4598fd1f0b751ef159668cd985d63cc2091f92f00a",
"size": 4745
}added /files/skills~1troubleshoot~1agents~1openai.yaml
Field was absent
{
"sha256": "53dd5c854ff7bdfd343c92665efed3f87f12cf51123cce1c2d2e74a9bb1bec49",
"size": 195
}added /files/skills~1troubleshoot~1references~1connect.md
Field was absent
{
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
}added /files/skills~1troubleshoot~1references~1media-input.md
Field was absent
{
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}Full snapshot data
{
"files": {
".app.json": {
"sha256": "065f3762ad1a4c2cd9a8ac6ddae574f0e9c3af1b17da64ad82f1483b203ad46c",
"size": 101
},
".codex-plugin/plugin.json": {
"sha256": "039f542ee8388401f93a765963e5ccbe614ad5043c7f63cab64c82d63fdf35a9",
"size": 3477
},
".mcp.json": {
"sha256": "78150adcf50228f4fe0d14a87e7c3199f3315b95a8624393025d3b2597a917a7",
"size": 90
},
"assets/logo.png": {
"sha256": "883e7d7abc470c735c256068205ef4582a2c6f561c6bcebcd3f5c011fe25804f",
"size": 8266
},
"skills/ai-video-to-talking/SKILL.md": {
"sha256": "5ea1e02f1e61e34fec4f22d82e0d099199621cb70cf29800a265a1b7a8fd1360",
"size": 5751
},
"skills/ai-video-to-talking/agents/openai.yaml": {
"sha256": "feedb17d19d88a1c87834635d39b362709453130f03e82c66916d758717775ba",
"size": 233
},
"skills/ai-video-to-talking/references/api.md": {
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
},
"skills/ai-video-to-talking/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/ai-video-to-talking/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/ai-video-to-talking/references/source-clip.md": {
"sha256": "8546d0a63b02210027655531e3fb1a20bcbdfec10894f740897f1e8f892d370b",
"size": 2167
},
"skills/batch-lipsync/SKILL.md": {
"sha256": "1c207d521664b8587de3910bafb04a0721999bfa0692b4a6e74cc4cd78c7395f",
"size": 6425
},
"skills/batch-lipsync/agents/openai.yaml": {
"sha256": "8bcd0983adff0da6aa5177a2a87e9de425ea9115d195ad8ca1b95421a41b71a8",
"size": 194
},
"skills/batch-lipsync/references/batch-api.md": {
"sha256": "3f9caaaccdda807942d04d3328beb16c3105ed181ee2a80f8caf6f5a985f745a",
"size": 3787
},
"skills/batch-lipsync/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/batch-lipsync/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/batch-lipsync/scripts/sync_batch.py": {
"sha256": "b4c01decdcdc3eb8be8f5535f379ed41faa2b4e687d56ac4fe1859b33651359c",
"size": 27525
},
"skills/edit-dialogue/SKILL.md": {
"sha256": "64879bd5ede7d75261f7fd72f690bb89b316050c16d8d790237c4078ed1bde6f",
"size": 5141
},
"skills/edit-dialogue/agents/openai.yaml": {
"sha256": "6159ae06234f0f4991c7ac061231bb33f1d4f5cd822bc6b5fb5a8471697ac94e",
"size": 206
},
"skills/edit-dialogue/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/edit-dialogue/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/lipsync/SKILL.md": {
"sha256": "aab4944b204493b12920917cd90b4e9999ce6eaa7c362ef302571cd2bad37a2c",
"size": 5124
},
"skills/lipsync/agents/openai.yaml": {
"sha256": "a611c56385745beb85b4c222011857be45ad6c7af583d885b18009d635ac39be",
"size": 194
},
"skills/lipsync/references/api.md": {
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
},
"skills/lipsync/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/lipsync/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/localize-campaign/SKILL.md": {
"sha256": "48176aacaaaef977a806f2509ad4f7955e9375f0dd7363d4629f57ab6f5ba4f0",
"size": 5089
},
"skills/localize-campaign/agents/openai.yaml": {
"sha256": "2681aa94d3de0b50ac2dcbe513cfa8f1ba1ce2653d32d0f8ff8e6c7c0cad41bf",
"size": 218
},
"skills/localize-campaign/references/api.md": {
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
},
"skills/localize-campaign/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/localize-campaign/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/setup/SKILL.md": {
"sha256": "904bb4d86c53eaf1753e28ae3c70a7a9099298ce984bd5b3553171ee7c8b3f98",
"size": 2452
},
"skills/setup/agents/openai.yaml": {
"sha256": "c95bb92bad2840d984fed9ac5b81dc8b9d8bca27905219cb23bfede8ba9e7598",
"size": 244
},
"skills/setup/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/sync-api/SKILL.md": {
"sha256": "1319bf8b74e31f7a8ea90907b0b1921cfb653a4014a2b36bc157d196d56dc7d3",
"size": 5779
},
"skills/sync-api/agents/openai.yaml": {
"sha256": "50cc0dbcc9a0601d84d01f2d6a0f270b460e00f106688eae67742d7d8c36d21c",
"size": 198
},
"skills/sync-api/references/api.md": {
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
},
"skills/sync-api/references/batch-api.md": {
"sha256": "3f9caaaccdda807942d04d3328beb16c3105ed181ee2a80f8caf6f5a985f745a",
"size": 3787
},
"skills/sync-api/references/python.md": {
"sha256": "4f1902a9daecbe0f2f8f4f0872a0a9d7cf991373c809fce589b6b77ad3e603c2",
"size": 5050
},
"skills/sync-api/references/typescript.md": {
"sha256": "0a4b7d2236a8c4ffa9ca1fce5363813a07a93fade659f186080d83d8ab234f40",
"size": 5665
},
"skills/sync-api/references/webhooks.md": {
"sha256": "b03f957d176006d5578fc2255689424736bab12069f9819829716fe88b6ee4d3",
"size": 3381
},
"skills/talking-photo/SKILL.md": {
"sha256": "8a74d7fb7ccaf791e6b751e3eb88d7c63c78eed29523333490a89c0b8dd0787f",
"size": 4616
},
"skills/talking-photo/agents/openai.yaml": {
"sha256": "d26d7f517f4d8c2f74f9ea87cc04d084ddb27b775afc3810f63ec8d12bef3384",
"size": 192
},
"skills/talking-photo/references/api.md": {
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
},
"skills/talking-photo/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/talking-photo/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/translate-and-dub/SKILL.md": {
"sha256": "a5dc78642d83853740678b1b147ca6e285c7729c7cb27ddf908969f0c811cf3a",
"size": 3705
},
"skills/translate-and-dub/agents/openai.yaml": {
"sha256": "3aa37f041039194300b7a7d276347cc351b47343f2e0e7a5f9471906ba4b37d9",
"size": 197
},
"skills/translate-and-dub/references/api.md": {
"sha256": "f8fb4753c43c8dad50600409aa5e09bf8e540a4828990e277a0fc60130a062a2",
"size": 4169
},
"skills/translate-and-dub/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/translate-and-dub/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
},
"skills/troubleshoot/SKILL.md": {
"sha256": "fd3a3b42cf2841ee296ebb4598fd1f0b751ef159668cd985d63cc2091f92f00a",
"size": 4745
},
"skills/troubleshoot/agents/openai.yaml": {
"sha256": "53dd5c854ff7bdfd343c92665efed3f87f12cf51123cce1c2d2e74a9bb1bec49",
"size": 195
},
"skills/troubleshoot/references/connect.md": {
"sha256": "a3ca25776209ce4ceaac1233e6b58494b1bc59e90509f09d6c36629350d5b3e1",
"size": 3346
},
"skills/troubleshoot/references/media-input.md": {
"sha256": "8029e5aca62797e02c375cc4d8ebe079383c6e900afe2ce13aad410552219e32",
"size": 3853
}
}
}SHA-256 of public snapshot: 40babc6b1efb94fcdb0f4a78ada23d0aa6650c4761dde8b069ec90fdaee8c901