calesthio/google-gemini-omni-video
Generate, reference, and conversationally edit short videos with Google's Gemini Omni Flash through the Gemini Developer API or Gemini Enterprise Agent Platform. Use when a task specifically needs Gemini Omni video, multimodal reference roles, time-directed prompting, audio-aware generation, Interactions API state, or a precise comparison with Veo, Nano Banana, consumer Gemini, or gateway routes.
npx skills add https://github.com/calesthio/generative-media-skills --skill google-gemini-omni-video
Use this skill for the first-party Google model gemini-omni-flash-preview. Treat all availability, prices, limits, and preview behavior below as verified on 2026-07-10 and re-check the cited first-party pages before a production launch.
Ask which backend, data region, source media, edit mode, aspect ratio, desired duration, audio/dialogue, intended audience, rights status, and spend approval apply. Do not silently translate an Omni request into a different product.
| Surface | Contract in scope | Do not conflate it with |
|---|---|---|
| Gemini Developer API | POST https://generativelanguage.googleapis.com/v1beta/interactions; API/auth key; paid tier only for Omni | Consumer Gemini app, AI Studio UI behavior, or Google Cloud data controls |
| Gemini Enterprise Agent Platform | POST .../v1beta1/projects/{project}/locations/{location}/interactions; OAuth/IAM; Agent Platform API; exact host depends on verified location contract | A drop-in Developer API endpoint or the legacy Veo operation schema |
| Gemini Omni Flash | Native short video generation, references, audio generation, and conversational editing | Veo, which is a specialized video family with different endpoints and features such as extension/interpolation in supported Veo versions |
| Gemini native image (“Nano Bananaâ€) | Separate image generation/editing models | Omni video output |
| Gemini app, Flow, YouTube | Consumer/creator products with their own access, settings, and terms | A promise about API retention, schema, availability, or provenance metadata |
| Third-party gateway/reseller | Out of scope for this skill | First-party Google billing, privacy, safety, or retry guarantees |
FACT: Gemini Omni Flash is a preview model, although the Interactions API itself is GA. The Developer API model page specifies text/image/video input, video output, a 1,048,576-token context, and 3–10 second 720p/24 FPS output. The Cloud model page publishes a different 131,072-token input limit and 57,920-token output limit. Apply limits only to the backend that documents them.
FACT: The Cloud preview page publishes a June 30, 2027 retirement date. The Developer API page does not promise the same lifecycle. Pin the model ID, monitor release notes, and keep a provider-bound migration test.
Use the Developer API for the shortest first-party integration and conversational state through an API key. New AI Studio keys are authorization keys bound to service accounts; unrestricted standard keys stopped working June 19, 2026, and all standard keys are scheduled for rejection in September 2026. Keep keys server-side, restrict them, and use GEMINI_API_KEY or GOOGLE_API_KEY (GOOGLE_API_KEY wins if both exist).
Use Gemini Enterprise Agent Platform when Cloud IAM, Cloud contracts, audit/security controls, GCS delivery, or Cloud data governance is required. Authenticate with Application Default Credentials or a short-lived OAuth access token. The dedicated Omni video guide currently shows location=global, whose endpoint gives no regional processing control. The newer Cloud deployment page, however, lists Omni under eu and us multi-regions and says those endpoints keep ML processing within the selected jurisdiction. Treat that as a first-party route-documentation conflict: for regulated workloads, use the aiplatform.eu.rep.googleapis.com or aiplatform.us.rep.googleapis.com Interactions route only after the project console, SDK smoke test, contract, or support confirms that exact Omni route; otherwise stop rather than falling back to global.
Do not infer Cloud availability from Developer availability or vice versa. As of the verification date, the Cloud model page itself is inconsistent: it marks Pay-as-you-go unsupported and fixed quota supported, yet also lists Standard PayGo in global. Treat Cloud consumption mode, quota allocation, and price as UNKNOWN until the project console or Google support confirms them. Do not submit a Cloud create based only on the Developer price.
Developer API facts:
16:9 and 9:16 (landscape default).text_to_video, image_to_video, reference_to_video, and edit under generation_config.video_config.task; omission lets the model infer.previous_interaction_id edit creates a new video. The parent interaction must be stored.steps[].type == "model_output"; output_video is an SDK convenience field.Current Developer API exclusions:
temperature, top_p, stop sequences, dedicated negative_prompts, provisioned throughput, or YouTube media source.DOCUMENTATION CONFLICT: Google’s model card describes a native multimodal model that accepts audio, and the Developer pricing table lists audio input pricing, but the current Developer Omni guide expressly says uploaded audio references are unsupported. Follow the API guide: describe desired speech, music, ambience, and effects in text. The Cloud model page says audio is input-only, but verify the exact Cloud request guide before sending it.
DOCUMENTATION CONFLICT: The generic Interactions model table (updated 2026-07-09) omits Omni, while the dedicated Omni guide and changelog require Interactions. Use the dedicated guide and model endpoint, and keep a smoke test because this is a new preview.
Developer Omni has no free tier. The published standard prices are $1.50 per million input tokens for text/image/video/audio, $9 per million text output tokens, and $17.50 per million video output tokens including thinking. Google meters 720p output at 5,792 tokens/second, approximately $0.10/second. Exact video-only arithmetic is $0.10136/second; a 10-second output is $1.0136 before input and any text output.
Before every create or edit:
turns × ($1.0136 maximum video output + estimated inputs/text output) for Developer API. Label this an estimate, not a hard provider cap.429 RESOURCE_EXHAUSTED as a capacity/quota signal. Honor any retry hint and back off polling/read operations; do not automatically replay a create with an unknown outcome.store=false disables parent state/background. Separate abuse retention is 55 days and may involve authorized review. Paid content is not used for product improvement, but abuse logging remains; consumer/unpaid terms do not transfer.Specify subject, action, environment, shot, lens/camera motion, lighting, palette, pacing, physics, and sound. For one shot, say single continuous unbroken shot; no scene cuts. Put exclusions in ordinary text: No dialogue. No extra sound effects.
Use natural timing or timecodes:
Single continuous unbroken 9:16 shot; no scene cuts.
[0-3s] A paper kite rises above a quiet dawn beach; slow handheld push-in.
[3-6s] The kite crosses the sun and the camera tilts upward.
Audio: soft surf and light wind. No dialogue, no music.
Keep all lettering and logos out of frame.
Timecodes are prompt direction, not a frame-accurate editing guarantee. Inspect the result.
For image roles, keep media order stable and bind it explicitly:
<FIRST_FRAME> uses the image as the starting frame.<IMAGE_REF_N> identifies a reference image; numbering begins at 0.[# Sources <FIRST_FRAME>@Image1] or [# References <IMAGE_REF_0>@Image1].Google shows multiple references but the Developer guide does not publish a numeric maximum. Do not invent one. The Cloud model page separately publishes up to 10 images per prompt.
For edits, make one change per turn: Make the room lighting warmer. Keep everything else the same. Identity, layout, motion, text, and audio consistency can drift across edits. The model card calls consistency across edits, complex motion, and perfect text known limitations; verify rather than claiming preservation.
Prefer URI delivery for output over 4 MB. Save the initial interaction ID and URI immediately: a later GET /interactions/{id} currently returns inline base64 even if the create requested URI, so the URI is guaranteed only in the initial response or SSE stream. Poll the corresponding File resource until ACTIVE; fail on FAILED or deadline; then download with authentication into a temporary file, validate, and atomically rename.
There is no documented Omni create idempotency key. Before POST, persist an attempt record containing a client attempt UUID, request hash, approval, and posting state. If the connection fails after submission and no interaction ID is available, mark create_outcome_unknown, stop, and reconcile project logs/billing with an operator. Never “retry just in case.†GET, file-state polling, and downloads are read-only and may use bounded jittered retries.
The following complete Python example covers text, stateful conversational, and separately staged uploaded-video editing. It defaults to offline dry-run and makes no paid call. Install google-genai>=2.3.0 only for execution.
#!/usr/bin/env python3
import argparse, base64, hashlib, http.client, ipaddress, json, os, random; import socket, ssl, subprocess, tempfile, threading, time, uuid; from decimal import Decimal, InvalidOperation; from fractions import Fraction
from pathlib import Path; from urllib.parse import parse_qs, urlsplit; MODEL = "gemini-omni-flash-preview"; ESTIMATED_MAX_USD = 1.10 # one 10s Developer turn; estimate, not a provider cap
MAX_VIDEO_BYTES = 80 * 1024 * 1024; MAX_TOOL_BYTES = 1024 * 1024; DOWNLOAD_HOST = "generativelanguage.googleapis.com"
def canonical(value) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
def atomic_json(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True); fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(value, f, indent=2, sort_keys=True); f.flush(); os.fsync(f.fileno())
os.replace(tmp, path)
finally:
if os.path.exists(tmp): os.unlink(tmp)
def claim_once(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True); fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(value, f, indent=2, sort_keys=True); f.flush(); os.fsync(f.fileno())
def read_record(path: Path) -> dict:
if path.stat().st_size > MAX_TOOL_BYTES: raise RuntimeError("Attempt record size rejected")
with path.open("rb") as source: raw = source.read(MAX_TOOL_BYTES + 1)
if not raw or len(raw) > MAX_TOOL_BYTES: raise RuntimeError("Attempt record size rejected")
value = json.loads(raw)
if not isinstance(value, dict): raise RuntimeError("Attempt record must be an object")
return value
def run_bounded(argv, timeout, cap=MAX_TOOL_BYTES):
proc = subprocess.Popen(argv, shell=False, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE); out, err, lock, overflow = bytearray(), bytearray(), threading.Lock(), threading.Event()
total = 0
def drain(stream, bucket):
nonlocal total
while True:
chunk = stream.read1(65536) if hasattr(stream, "read1") else stream.read(65536)
if not chunk: break
with lock:
room = max(0, cap - total); bucket.extend(chunk[:room]); total += len(chunk)
if total > cap: overflow.set()
if overflow.is_set():
try: proc.kill()
except OSError: pass
threads = [threading.Thread(target=drain, args=(proc.stdout, out), daemon=True), threading.Thread(target=drain, args=(proc.stderr, err), daemon=True)]
for thread in threads: thread.start()
timed_out = False
try: code = proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out = True; proc.kill(); code = proc.wait()
for thread in threads: thread.join()
return {"code": code, "stdout": bytes(out), "stderr": bytes(err), "timeout": timed_out, "overflow": overflow.is_set()}
class PinnedHTTPSConnection(http.client.HTTPSConnection):
def __init__(self, host, ip, timeout=30):
super().__init__(host, 443, timeout=timeout, context=ssl.create_default_context()); self.ip = ip
def connect(self):
sock = socket.create_connection((self.ip, self.port), self.timeout); self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
def generated_file_name(uri: str) -> str:
parsed = urlsplit(uri)
if (parsed.scheme != "https" or parsed.hostname != DOWNLOAD_HOST or parsed.username or parsed.password or parsed.fragment or parsed.port not in (None, 443)):
raise RuntimeError("Generated file URI policy rejected")
prefix = "/v1beta/files/"
if not parsed.path.startswith(prefix) or not parsed.path.endswith(":download"):
raise RuntimeError("Generated file URI path rejected")
name = parsed.path[len(prefix):-len(":download")]
if not name or not name.isalnum() or parse_qs(parsed.query) != {"alt": ["media"]}:
raise RuntimeError("Generated file URI identifier/query rejected")
return "files/" + name
def download_generated(uri: str, api_key: str, parent: Path) -> Path:
name = generated_file_name(uri).split("/", 1)[1]; addresses = sorted({item[4][0] for item in socket.getaddrinfo(DOWNLOAD_HOST, 443, type=socket.SOCK_STREAM)})
if not addresses or any(not ipaddress.ip_address(item).is_global for item in addresses):
raise RuntimeError("Generated file host did not resolve publicly")
conn, temp_path = PinnedHTTPSConnection(DOWNLOAD_HOST, addresses[0], timeout=60), None
try:
conn.request("GET", f"/v1beta/files/{name}:download?alt=media", headers={"x-goog-api-key": api_key, "Accept": "video/mp4"}); response = conn.getresponse()
if 300 <= response.status < 400: raise RuntimeError("Generated file redirects are rejected")
if response.status != 200: raise RuntimeError(f"Generated file HTTP {response.status}")
mime = response.getheader("Content-Type", "").split(";", 1)[0].lower()
if mime != "video/mp4": raise RuntimeError("Generated file MIME rejected")
length = response.getheader("Content-Length")
if length is not None and (not length.isdigit() or int(length) > MAX_VIDEO_BYTES):
raise RuntimeError("Generated file declared size rejected")
fd, name_on_disk = tempfile.mkstemp(prefix=".omni-", suffix=".mp4", dir=parent); temp_path = Path(name_on_disk); total = 0
with os.fdopen(fd, "wb") as f:
while True:
chunk = response.read(1024 * 1024)
if not chunk: break
total += len(chunk)
if total > MAX_VIDEO_BYTES: raise RuntimeError("Generated file crossed byte cap")
f.write(chunk)
f.flush(); os.fsync(f.fileno())
return temp_path
except BaseException:
if temp_path is not None and temp_path.exists(): temp_path.unlink()
raise
finally:
conn.close()
def inline_generated(data: str, parent: Path) -> Path:
if len(data) > ((MAX_VIDEO_BYTES + 2) // 3) * 4 + 4:
raise RuntimeError("Inline video encoding exceeds byte cap")
blob = base64.b64decode(data, validate=True)
if not blob or len(blob) > MAX_VIDEO_BYTES: raise RuntimeError("Inline video size rejected")
fd, name = tempfile.mkstemp(prefix=".omni-", suffix=".mp4", dir=parent)
with os.fdopen(fd, "wb") as f: f.write(blob); f.flush(); os.fsync(f.fileno())
return Path(name)
def validate_and_publish(temp_path: Path, destination: Path) -> dict:
try:
with temp_path.open("rb") as f: header = f.read(12)
if len(header) < 12 or header[4:8] != b"ftyp": raise RuntimeError("MP4 signature rejected")
probe = run_bounded(["ffprobe", "-v", "error", "-show_entries", "format=duration,size:stream=index,codec_type,codec_name,width,height,r_frame_rate", "-of", "json", str(temp_path)], 30)
if probe["timeout"] or probe["overflow"] or probe["code"] != 0: raise RuntimeError("ffprobe failed")
metadata = json.loads(probe["stdout"]); streams = metadata.get("streams", []); videos = [item for item in streams if item.get("codec_type") == "video"]
if len(videos) != 1 or not any(item.get("codec_type") == "audio" for item in streams):
raise RuntimeError("Expected one video stream and generated audio")
width, height = videos[0].get("width"), videos[0].get("height")
if sorted((width, height)) != [720, 1280]: raise RuntimeError("Expected 720p dimensions")
if abs(float(Fraction(videos[0].get("r_frame_rate", "0/1"))) - 24.0) > 0.1:
raise RuntimeError("Expected 24 FPS")
duration = float(metadata.get("format", {}).get("duration", 0))
if not 2.9 <= duration <= 10.1: raise RuntimeError("Video duration outside 3-10 seconds")
decode = run_bounded(["ffmpeg", "-v", "error", "-i", str(temp_path), "-f", "null", "-"], 300)
if decode["timeout"] or decode["overflow"] or decode["code"] != 0: raise RuntimeError("Full decode failed")
if destination.exists(): raise RuntimeError("Output already exists")
digest = hashlib.sha256()
with temp_path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""): digest.update(chunk)
artifact = {"path": str(destination), "bytes": temp_path.stat().st_size, "sha256": digest.hexdigest(), "probe": metadata}; os.replace(temp_path, destination); temp_path = None
return artifact
finally:
if temp_path is not None and temp_path.exists(): temp_path.unlink()
def retry_after(exc) -> float | None:
response = getattr(exc, "response", None); headers = getattr(response, "headers", {}) or {}; raw = headers.get("retry-after") or headers.get("Retry-After")
try: value = float(raw)
except (TypeError, ValueError): return None
return value if 0 <= value <= 60 else None
def safe_read(call, deadline, attempts=5):
for attempt in range(attempts):
try: return call()
except Exception as exc:
if attempt + 1 == attempts or time.monotonic() >= deadline: raise
delay = retry_after(exc) or min(20.0, 2 ** attempt) * (1 + random.random() * .25)
if time.monotonic() + delay >= deadline: raise
time.sleep(delay)
def find_video(interaction):
convenience = getattr(interaction, "output_video", None)
if convenience: return convenience
for step in getattr(interaction, "steps", []) or []:
if getattr(step, "type", None) == "model_output":
for item in getattr(step, "content", []) or []:
if getattr(item, "type", None) == "video": return item
raise RuntimeError("No video output in completed interaction")
def delete_remote_files(client, items) -> list[dict]:
results = []
for role, name in items:
try:
client.files.delete(name=name) # one cleanup attempt; ambiguity is recorded for an operator; results.append({"role": role, "name": name, "status": "deleted"})
except Exception as exc:
results.append({"role": role, "name": name, "status": "delete_outcome_unknown", "errorType": type(exc).__name__})
return results
def load_source_manifest(path: Path, jurisdiction: str | None) -> dict:
if jurisdiction in {"eea", "ch", "uk"}:
raise RuntimeError("Uploaded-video editing is unavailable in this jurisdiction")
if jurisdiction != "other":
raise RuntimeError("--source-jurisdiction other is required for an uploaded-video edit")
if path.is_symlink() or not path.is_file(): raise RuntimeError("Source manifest path rejected")
if path.stat().st_size > MAX_TOOL_BYTES: raise RuntimeError("Source manifest size rejected")
with path.open("rb") as source_file: raw = source_file.read(MAX_TOOL_BYTES + 1)
if not raw or len(raw) > MAX_TOOL_BYTES: raise RuntimeError("Source manifest size rejected")
value = json.loads(raw)
if not isinstance(value, dict): raise RuntimeError("Source manifest must be an object")
required = {"name", "uri", "sha256", "bytes", "mime", "state", "deleteAfterUse", "durationSeconds", "localFullDecodePassed", "rightsApproved"}
if set(value) != required: raise RuntimeError("Source manifest schema rejected")
name = value["name"]
if not isinstance(name, str) or not name.startswith("files/") or not name[6:].isalnum():
raise RuntimeError("Source File resource name rejected")
parsed = urlsplit(value["uri"] if isinstance(value["uri"], str) else "")
if (parsed.scheme != "https" or parsed.hostname != DOWNLOAD_HOST or parsed.username or parsed.password or parsed.port not in (None, 443) or parsed.query or parsed.fragment or parsed.path != "/v1beta/" + name):
raise RuntimeError("Source File URI rejected")
sha = value["sha256"]
if not isinstance(sha, str) or len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
raise RuntimeError("Source SHA-256 rejected")
if (type(value["bytes"]) is not int or not 0 < value["bytes"] <= 2 * 1024 * 1024 * 1024 or value["mime"] != "video/mp4" or value["state"] != "ACTIVE" or value["deleteAfterUse"] is not True or value["localFullDecodePassed"] is not True or value["rightsApproved"] is not True):
raise RuntimeError("Source validation/lifecycle assertions rejected")
try: duration = Decimal(str(value["durationSeconds"]))
except InvalidOperation as exc: raise RuntimeError("Source duration rejected") from exc
if not duration.is_finite() or not Decimal("0") < duration <= Decimal("10"):
raise RuntimeError("Uploaded source must be no longer than 10 seconds")
return value
def main() -> int:
p = argparse.ArgumentParser(); p.add_argument("--prompt", required=True); p.add_argument("--aspect", choices=("16:9", "9:16"), default="16:9")
p.add_argument("--stateful", action="store_true", help="store this turn so it can be edited later"); p.add_argument("--previous-id", help="stored parent interaction for a paid edit turn")
p.add_argument("--source-manifest", type=Path, help="bounded manifest for an already ACTIVE, locally validated Developer File upload"); p.add_argument("--source-jurisdiction", choices=("other", "eea", "ch", "uk"))
p.add_argument("--output", type=Path, default=Path("omni-output.mp4")); p.add_argument("--attempt-log", type=Path, default=Path("omni-attempt.json"))
p.add_argument("--attempt-id", required=True, help="fresh canonical UUIDv4 used for this one paid turn"); p.add_argument("--execute", action="store_true")
p.add_argument("--resume", action="store_true", help="read/query a claimed attempt; never create"); p.add_argument("--max-usd", default="1.10"); p.add_argument("--approval-sha256")
p.add_argument("--show-full-plan", action="store_true"); a = p.parse_args()
if not a.prompt.strip() or len(a.prompt.encode("utf-8")) > 8000:
raise SystemExit("Prompt must be 1..8000 UTF-8 bytes")
if a.output.suffix.lower() != ".mp4": raise SystemExit("Output must end in .mp4")
try: attempt = uuid.UUID(a.attempt_id); maximum = Decimal(a.max_usd)
except (ValueError, InvalidOperation) as exc: raise SystemExit("Attempt ID or maximum USD is invalid") from exc
if attempt.version != 4 or str(attempt) != a.attempt_id.lower(): raise SystemExit("Attempt ID must be canonical UUIDv4")
if not maximum.is_finite() or maximum <= 0 or maximum < Decimal("1.10"):
raise SystemExit("Maximum USD must be finite, positive, and cover 1.10")
output, attempt_log = a.output.resolve(), a.attempt_log.resolve()
if output == attempt_log: raise SystemExit("Output and attempt record must differ")
if a.source_jurisdiction and not a.source_manifest:
raise SystemExit("--source-jurisdiction requires --source-manifest")
if a.source_manifest and a.previous_id:
raise SystemExit("Choose either an uploaded-video edit or a previous-interaction edit")
source = load_source_manifest(a.source_manifest.resolve(), a.source_jurisdiction) if a.source_manifest else None
request = { "model": MODEL, "input": a.prompt, "response_format": {"type": "video", "delivery": "uri", "aspect_ratio": a.aspect}, "background": False, "stream": False, "store": a.stateful or bool(a.previous_id), }
if source:
request["input"] = [{"type": "document", "uri": source["uri"]}, {"type": "text", "text": a.prompt}]; request["generation_config"] = {"video_config": {"task": "edit"}}
if a.previous_id: request["previous_interaction_id"] = a.previous_id
request_hash = hashlib.sha256(canonical(request)).hexdigest()
envelope = {"backend": "gemini-developer-api", "endpoint": "/v1beta/interactions", "model": MODEL, "request": request, "sourceHashes": ([] if not source else [{"sha256": source["sha256"], "bytes": source["bytes"], "mime": source["mime"], "fileName": source["name"], "jurisdiction": a.source_jurisdiction, "deleteAfterUse": True}]), "attemptId": str(attempt), "attemptRecord": str(attempt_log), "outputPolicy": {"path": str(output), "mime": "video/mp4", "maxBytes": MAX_VIDEO_BYTES, "ffprobe": True, "fullDecode": True, "requireAudio": True}, "cost": {"estimatedMaxUsd": "1.10", "approvedMaxUsd": format(maximum, "f"), "priceAsOf": "2026-07-10", "paidCreates": 1}, "retention": {"storeInteraction": request["store"], "previousInteractionId": a.previous_id}}
approval = hashlib.sha256(canonical(envelope)).hexdigest(); redacted_input = f"sha256:{hashlib.sha256(a.prompt.encode()).hexdigest()}"
if source:
redacted_input = [{"type": "document", "uriSha256": hashlib.sha256(source["uri"].encode()).hexdigest()}, {"type": "text", "sha256": hashlib.sha256(a.prompt.encode()).hexdigest()}]
safe_envelope = {**envelope, "request": {**request, "input": redacted_input}}; print(json.dumps({"dry_run": not a.execute, "approval_sha256": approval, "plan": safe_envelope}, indent=2))
if a.show_full_plan: print(json.dumps({"protected_full_envelope": envelope}, indent=2))
if not a.execute: return 0
if a.approval_sha256 != approval: raise SystemExit("Exact approval hash mismatch")
api_key = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
if not api_key:
raise SystemExit("Set GEMINI_API_KEY or GOOGLE_API_KEY server-side")
from google import genai; from google.genai import types; create_client = genai.Client(api_key=api_key, http_options=types.HttpOptions( timeout=600000, retry_options=types.HttpRetryOptions(attempts=1)))
read_client = genai.Client(api_key=api_key, http_options=types.HttpOptions( timeout=30000, retry_options=types.HttpRetryOptions(attempts=1)))
if source:
info = safe_read(lambda: read_client.files.get(name=source["name"]), time.monotonic() + 120); state = getattr(getattr(info, "state", None), "name", getattr(info, "state", None))
if state != "ACTIVE" or getattr(info, "uri", None) != source["uri"]:
raise RuntimeError("Approved source upload is not the same ACTIVE File resource")
if a.resume:
if not attempt_log.exists(): raise SystemExit("Resume record does not exist")
record = read_record(attempt_log)
if (record.get("attempt_id") != str(attempt) or record.get("approval_sha256") != approval or record.get("request_sha256") != request_hash):
raise SystemExit("Resume record does not match this exact approval")
interaction_id = record.get("interaction_id")
if not isinstance(interaction_id, str) or not interaction_id:
raise SystemExit("Create outcome is unknown without an interaction ID; do not replay")
interaction = safe_read(lambda: read_client.interactions.get(interaction_id), time.monotonic() + 120)
else:
record = {"attempt_id": str(attempt), "backend": "gemini-developer-api", "model": MODEL, "approval_sha256": approval, "request_sha256": request_hash, "status": "posting", "approved_max_usd": format(maximum, "f"), "interaction_id": None, "source_file_name": source["name"] if source else None, "output_uri_sha256": None, "created_unix": int(time.time())}
try: claim_once(attempt_log, record)
except FileExistsError as exc: raise SystemExit("Attempt is already claimed; use --resume, never replay") from exc
try:
interaction = create_client.interactions.create(**request) # exactly one network attempt
except Exception as exc:
record.update(status="create_outcome_unknown", error_type=type(exc).__name__, updated_unix=int(time.time())); atomic_json(attempt_log, record)
raise RuntimeError("Create outcome unknown; reconcile logs/billing, do not retry") from exc
interaction_id = getattr(interaction, "id", None)
if not isinstance(interaction_id, str) or not interaction_id:
record.update(status="create_outcome_unknown", error_type="MissingInteractionId"); atomic_json(attempt_log, record)
raise RuntimeError("Create returned no interaction ID; do not retry")
record.update(interaction_id=interaction_id, status="interaction_id_saved", updated_unix=int(time.time())); atomic_json(attempt_log, record) # first action after create: preserve provider identity
video = find_video(interaction); uri = getattr(video, "uri", None); data = getattr(video, "data", None)
if uri:
file_name = generated_file_name(uri); record.update(status="output_uri_saved", output_uri_sha256=hashlib.sha256(uri.encode()).hexdigest(), generated_file_name=file_name, updated_unix=int(time.time()))
atomic_json(attempt_log, record)
else: file_name = None
if uri:
deadline = time.monotonic() + 900
while True:
info = safe_read(lambda: read_client.files.get(name=file_name), deadline); state = getattr(getattr(info, "state", None), "name", getattr(info, "state", None))
if state == "ACTIVE": break
if state == "FAILED": raise RuntimeError("Provider file processing failed")
if state not in {"PROCESSING", "PENDING", None}: raise RuntimeError(f"Unknown file state {state!r}")
if time.monotonic() >= deadline: raise TimeoutError("File processing exceeded 15 minutes")
time.sleep(5 + random.random())
output.parent.mkdir(parents=True, exist_ok=True); temp_path = download_generated(uri, api_key, output.parent)
elif data:
output.parent.mkdir(parents=True, exist_ok=True); temp_path = inline_generated(data, output.parent)
else:
raise RuntimeError("Video has neither URI nor data")
try:
artifact = validate_and_publish(temp_path, output)
except BaseException as exc:
cleanup_items = ([] if not file_name else [("generated_output", file_name)])
if source: cleanup_items.append(("uploaded_source", source["name"]))
cleanup_results = delete_remote_files(read_client, cleanup_items); cleanup_unknown = any(item["status"] != "deleted" for item in cleanup_results)
record.update(status="artifact_validation_failed_cleanup_pending" if cleanup_unknown else "artifact_validation_failed", error_type=type(exc).__name__, remote_file_cleanup=cleanup_results, cleanup_action=("Inspect/delete the recorded File resources; do not replay create" if cleanup_unknown else None), updated_unix=int(time.time()))
atomic_json(attempt_log, record); raise
cleanup_items = ([] if not file_name else [("generated_output", file_name)])
if source: cleanup_items.append(("uploaded_source", source["name"]))
record.update(status="artifact_saved_cleanup_pending" if cleanup_items else "artifact_saved", artifact=artifact, retention=envelope["retention"], rights_review="required", creative_qa="pending", synthetic_disclosure="required", synthid_expected=True, developer_c2pa="not_documented", updated_unix=int(time.time()))
atomic_json(attempt_log, record)
if cleanup_items:
cleanup_results = delete_remote_files(read_client, cleanup_items); cleanup_unknown = any(item["status"] != "deleted" for item in cleanup_results)
record.update(status="artifact_saved_cleanup_pending" if cleanup_unknown else "artifact_saved", remote_file_cleanup=cleanup_results, cleanup_action=("Inspect/delete the recorded File resources; do not replay create" if cleanup_unknown else None), updated_unix=int(time.time()))
atomic_json(attempt_log, record)
if cleanup_unknown:
raise RuntimeError("Artifact saved, but remote File cleanup needs operator reconciliation")
print(json.dumps(record, indent=2)); return 0
if __name__ == "__main__": raise SystemExit(main())
Dry-run first:
python omni_generate.py --attempt-id "7f8f06b7-9e68-4f0a-bf16-3eaf3ee9d6c4" --prompt "Single continuous shot of rain on a greenhouse roof. Audio: rain only. No dialogue."
After approval, execute exactly one paid turn:
python omni_generate.py --execute --approval-sha256 "<exact unchanged digest>" --max-usd 1.10 --attempt-id "7f8f06b7-9e68-4f0a-bf16-3eaf3ee9d6c4" --prompt "Single continuous shot of rain on a greenhouse roof. Audio: rain only. No dialogue."
For a first turn you intend to edit, add --stateful before dry-run and keep it unchanged for execution; a default standalone result is stateless and cannot later become a parent. Record the retention choice in approval. Recover a known interaction with the same arguments plus --resume --execute; resume never enters the create branch.
For image input, replace input with ordered items and set the task explicitly:
request["input"] = [
{"type": "image", "data": base64.b64encode(image_bytes).decode(), "mime_type": "image/png"},
{"type": "text", "text": "<FIRST_FRAME> single unbroken shot; the paper boat drifts forward. Keep its design unchanged. Audio: creek only."},
]
request["generation_config"] = {"video_config": {"task": "image_to_video"}}
Before image substitution, bound/content-validate/hash bytes and approve rights. For uploaded-video edit, separately approve staging: reject symlinks/polyglots, cap at 2 GB, require MP4 ≤10 seconds, bounded-probe/full-decode/hash, upload once, then poll PROCESSING → ACTIVE|FAILED. Record Google’s exact values:
{"name":"files/abc123","uri":"https://generativelanguage.googleapis.com/v1beta/files/abc123","sha256":"<64 lowercase hex>","bytes":1234567,"mime":"video/mp4","state":"ACTIVE","deleteAfterUse":true,"durationSeconds":8.0,"localFullDecodePassed":true,"rightsApproved":true}
Pass --source-manifest source.json --source-jurisdiction other unchanged to dry-run/execution; use protected --show-full-plan to inspect exact prompt/URI. Execution rechecks the ACTIVE URI, uses document input, and deletes source/generated Files after validation. Record ambiguous create/cleanup for reconciliation, never replay. Stop in eea, ch, or uk; never use the broken ≤3-second reference path.
Cloud’s documented synchronous request supports explicit duration strings from 3s through 10s, GCS URI delivery, and a list-valued response_format. Do not copy these fields into Developer code without checking its reference.
Use this fail-closed transaction contract only after the exact project route, quota mode, and price have been verified; until then, prepare a dry-run but do not offer a generic production request:
create_outcome_unknown; reconcile rather than replay.Retry-After. Persist only a protected interaction ID, request/approval hash, exact cleanup URI, response hash, and sanitized state—never the raw response or model thoughts. Require exactly one returned video URI under the approved GCS prefix.video/mp4, a declared and observed byte cap, MP4 ftyp magic, exactly one 720p/approximately-24-FPS video stream, generated audio, 3–10 second duration, bounded ffprobe, full bounded ffmpeg decode, and a successful current c2patool validation. Refuse an existing destination; publish by atomic rename and hash.artifact_saved_cleanup_pending with the exact URI; never create again to solve cleanup. Revalidate C2PA after any publication transform.For background:true, apply the same create/identity rules and bounded read-only polling; Cloud documents async retention up to 14 days. The controlling Cloud agreement, abuse-monitoring exception, optional request/response logging, 24-hour in-memory cache setting, bucket IAM/residency/retention, and interaction/object deletion remain separate approval fields.
PROCESSING: bounded polling with jitter; FAILED is terminal. URI loss cannot be repaired by assuming GET interaction will return it.Use these labels in plans and reports:
Take calesthio/google-gemini-omni-video from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.