Spaces:
Runtime error
Runtime error
| """Veo 3.1 on Vertex AI β EXACTLY as the MVP donor does it (SPEC_NEW Β§11.2). | |
| Generation goes through the raw Vertex `:predictLongRunning` / `:fetchPredictOperation` | |
| REST endpoints (a direct port of the donor's worker `veo.ts`), NOT the google-genai | |
| SDK. This guarantees the request matches the proven MVP path byte-for-byte: | |
| parameters: aspectRatio 9:16 Β· durationSeconds (dynamic 4/6/8) Β· sampleCount 1 Β· | |
| seed (locked) Β· generateAudio true Β· resolution 720p | |
| instances : [{ prompt, image: { bytesBase64Encoded, mimeType } }] β the startframe | |
| 720p on every clip (owner requirement). The seed is passed explicitly in the request | |
| body, so identity continuity can't be silently dropped by an SDK version. | |
| Guarantees (unchanged): | |
| - MAX_GENERATIONS_PER_DAY enforced against generation_log per UTC day (dry-run and | |
| real counted separately). | |
| - DRY_RUN=true never touches Vertex: the fixture clip is copied; the log records | |
| duration/seed/image so Phase-1 acceptance is provable without creds. | |
| - Safety blocks raise VeoSafetyBlock and are handled by the RAI policy (Β§12.5). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import re | |
| import shutil | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| from typing import Any | |
| from urllib.parse import quote | |
| import httpx | |
| from . import db, duration | |
| from .config import FIXTURES_DIR, MEDIA_DIR, get_settings | |
| DEFAULT_SEGMENT_SECONDS = 8 | |
| POLL_INTERVAL_S = 15 | |
| POLL_TIMEOUT_S = 15 * 60 | |
| TRANSPORT_RETRIES = 3 | |
| SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] | |
| SEGMENTS_DIR = MEDIA_DIR / "segments" | |
| DRY_RUN_OP_PREFIX = "dry-run/" | |
| FIXTURE_CLIP = FIXTURES_DIR / "good_clip.mp4" | |
| class DailyCapExceeded(RuntimeError): | |
| pass | |
| class VeoSafetyBlock(RuntimeError): | |
| """Safety/RAI filtering. Handled by the RAI policy in generate_and_download.""" | |
| def __init__(self, reasons: list[str]): | |
| self.reasons = reasons | |
| super().__init__(f"Veo safety filter blocked the generation: {reasons}") | |
| def _is_rai_message(msg: str | None) -> bool: | |
| """A Veo operation error that is actually a safety/RAI block (MVP isRaiError).""" | |
| m = (msg or "").lower() | |
| return any(k in m for k in ( | |
| "safety", "usage guidelines", "responsible ai", "blocked", "filtered", | |
| "policy", "violat", "prohibited", "rai", | |
| )) | |
| # ββ Daily cap ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generations_today(dry_run: bool) -> int: | |
| row = db.fetch_one( | |
| """ | |
| select count(*) as n from generation_log | |
| where dry_run = %s | |
| and (started_at at time zone 'utc')::date = (now() at time zone 'utc')::date | |
| """, | |
| (dry_run,), | |
| ) | |
| return int(row["n"]) if row else 0 | |
| def enforce_daily_cap() -> None: | |
| s = get_settings() | |
| used = generations_today(s.dry_run) | |
| if used >= s.max_generations_per_day: | |
| mode = "dry-run" if s.dry_run else "REAL" | |
| raise DailyCapExceeded( | |
| f"MAX_GENERATIONS_PER_DAY reached: {used}/{s.max_generations_per_day} {mode} " | |
| f"generations already submitted today (UTC). The two-take hook spends " | |
| f"SEED_TAKES extra generations per video (SPEC_NEW Β§10)." | |
| ) | |
| def _log_generation(segment_id: str, dry_run: bool, model_id: str | None) -> None: | |
| db.execute( | |
| "insert into generation_log (segment_id, dry_run, model_id) values (%s, %s, %s)", | |
| (segment_id, dry_run, model_id), | |
| ) | |
| # ββ Vertex REST (port of the donor's veo.ts) βββββββββββββββββββββββββββββββββ | |
| def _vertex_base() -> str: | |
| s = get_settings() | |
| proj = s.require("GOOGLE_CLOUD_PROJECT") | |
| loc = s.require("VERTEX_LOCATION") | |
| model = s.require("VEO_MODEL_ID") | |
| return ( | |
| f"https://{loc}-aiplatform.googleapis.com/v1/projects/{proj}" | |
| f"/locations/{loc}/publishers/google/models/{model}" | |
| ) | |
| _creds = None | |
| def _access_token() -> str: | |
| """Mint a Vertex access token from the service-account / ADC credentials at | |
| GOOGLE_APPLICATION_CREDENTIALS (or application-default credentials).""" | |
| global _creds | |
| import google.auth | |
| from google.auth.transport.requests import Request as GoogleAuthRequest | |
| if _creds is None: | |
| s = get_settings() | |
| path = s.google_application_credentials | |
| if path: | |
| if not Path(path).is_file(): | |
| raise RuntimeError( | |
| f"GOOGLE_APPLICATION_CREDENTIALS points to a missing file: {path!r}." | |
| ) | |
| _creds, _ = google.auth.load_credentials_from_file(path, scopes=SCOPES) | |
| else: | |
| _creds, _ = google.auth.default(scopes=SCOPES) | |
| if not _creds.valid: | |
| _creds.refresh(GoogleAuthRequest()) | |
| return _creds.token | |
| def _auth_headers() -> dict[str, str]: | |
| s = get_settings() | |
| return { | |
| "Authorization": f"Bearer {_access_token()}", | |
| "x-goog-user-project": s.require("GOOGLE_CLOUD_PROJECT"), | |
| "content-type": "application/json", | |
| } | |
| def _has_startframe(ref: str | None) -> bool: | |
| if not ref: | |
| return False | |
| return ref.startswith(("http://", "https://")) or Path(ref).is_file() | |
| def _image_inline(ref: str) -> dict[str, str]: | |
| """Inline base64 the startframe β from a local file OR an http(s) URL (e.g. a | |
| Supabase Storage public URL). Matches the donor, which fetches the image URL.""" | |
| if ref.startswith(("http://", "https://")): | |
| r = httpx.get(ref, timeout=60) | |
| if r.status_code >= 400: | |
| raise RuntimeError(f"startframe fetch {r.status_code}: {r.text[:200]}") | |
| mime = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip() | |
| return {"bytesBase64Encoded": base64.b64encode(r.content).decode(), "mimeType": mime} | |
| p = Path(ref) | |
| mime = {".png": "image/png", ".webp": "image/webp"}.get(p.suffix.lower(), "image/jpeg") | |
| return {"bytesBase64Encoded": base64.b64encode(p.read_bytes()).decode(), "mimeType": mime} | |
| def _submit(segment: dict[str, Any], avatar: dict[str, Any] | None, duration_s: int, seed: int) -> str: | |
| """POST :predictLongRunning exactly like the donor's veo.ts. Returns the op name.""" | |
| s = get_settings() | |
| instance: dict[str, Any] = {"prompt": segment["veo_prompt"]} | |
| ref = avatar.get("ref_image_path") if avatar else None | |
| if _has_startframe(ref): | |
| instance["image"] = _image_inline(ref) # the startframe, inline base64 | |
| body = { | |
| "instances": [instance], | |
| "parameters": { | |
| "aspectRatio": "9:16", | |
| "durationSeconds": int(duration_s), | |
| "sampleCount": 1, | |
| "seed": int(seed), | |
| "generateAudio": True, | |
| "resolution": s.veo_resolution, # 720p | |
| }, | |
| } | |
| url = f"{_vertex_base()}:predictLongRunning" | |
| last: Exception | None = None | |
| for attempt in range(TRANSPORT_RETRIES): | |
| try: | |
| r = httpx.post(url, headers=_auth_headers(), json=body, timeout=120) | |
| if r.status_code == 429 or r.status_code >= 500: | |
| last = RuntimeError(f"Veo start {r.status_code}: {r.text[:300]}") | |
| time.sleep(2**attempt) | |
| continue | |
| if r.status_code >= 400: | |
| raise RuntimeError(f"Veo start {r.status_code}: {r.text[:500]}") | |
| return r.json()["name"] | |
| except httpx.TransportError as exc: | |
| last = exc | |
| time.sleep(2**attempt) | |
| raise RuntimeError(f"Veo submission failed after retries: {last}") | |
| def _download_gcs(gcs_uri: str) -> bytes: | |
| m = re.match(r"^gs://([^/]+)/(.+)$", gcs_uri) | |
| if not m: | |
| raise RuntimeError(f"bad gcsUri: {gcs_uri}") | |
| url = ( | |
| f"https://storage.googleapis.com/storage/v1/b/{m.group(1)}" | |
| f"/o/{quote(m.group(2), safe='')}?alt=media" | |
| ) | |
| s = get_settings() | |
| r = httpx.get( | |
| url, | |
| headers={"Authorization": f"Bearer {_access_token()}", | |
| "x-goog-user-project": s.require("GOOGLE_CLOUD_PROJECT")}, | |
| timeout=300, | |
| ) | |
| if r.status_code >= 400: | |
| raise RuntimeError(f"GCS download {r.status_code}: {r.text[:300]}") | |
| return r.content | |
| # ββ Per-segment render facts (duration + seed) βββββββββββββββββββββββββββββββ | |
| def _resolve_render_facts(seg: dict[str, Any]) -> tuple[int, int]: | |
| s = get_settings() | |
| dur = seg.get("duration_s") | |
| if not dur: | |
| dur = duration.decide_duration(seg["spoken_text"], s.words_per_second, s.tail_budget_s) | |
| seed = seg.get("seed") | |
| if seed is None: | |
| seed = duration.random_seed() | |
| dur, seed = int(dur), int(seed) | |
| db.execute("update segments set duration_s = %s, seed = %s where id = %s", (dur, seed, str(seg["id"]))) | |
| return dur, seed | |
| # ββ Contract functions βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_segment(segment_id: str) -> str: | |
| s = get_settings() | |
| seg = db.fetch_one("select * from segments where id = %s", (segment_id,)) | |
| if seg is None: | |
| raise RuntimeError(f"no segment {segment_id}") | |
| avatar = db.fetch_one( | |
| "select a.* from avatars a join videos v on v.avatar_id = a.id where v.id = %s", | |
| (seg["video_id"],), | |
| ) | |
| duration_s, seed = _resolve_render_facts(seg) | |
| image_attached = _has_startframe(avatar.get("ref_image_path") if avatar else None) | |
| enforce_daily_cap() | |
| db.execute("update segments set status = 'generating', attempts = attempts + 1 where id = %s", (segment_id,)) | |
| if s.dry_run: | |
| op_name = f"{DRY_RUN_OP_PREFIX}{uuid.uuid4()}" | |
| _log_generation(segment_id, True, f"dry-run-fixture 720p dur={duration_s} seed={seed} img={image_attached}") | |
| db.execute("update segments set veo_operation = %s where id = %s", (op_name, segment_id)) | |
| return op_name | |
| _log_generation(segment_id, False, s.veo_model_id) | |
| op_name = _submit(seg, avatar, duration_s, seed) | |
| db.execute("update segments set veo_operation = %s where id = %s", (op_name, segment_id)) | |
| return op_name | |
| def poll(operation_name: str, segment_id: str | None = None) -> dict[str, Any]: | |
| if operation_name.startswith(DRY_RUN_OP_PREFIX): | |
| seg_id = segment_id or _segment_for_operation(operation_name) | |
| dest = SEGMENTS_DIR / f"{seg_id}.mp4" | |
| dest.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copyfile(FIXTURE_CLIP, dest) | |
| return {"status": "done", "file": str(dest)} | |
| r = httpx.post(f"{_vertex_base()}:fetchPredictOperation", headers=_auth_headers(), | |
| json={"operationName": operation_name}, timeout=60) | |
| if r.status_code >= 400: | |
| raise RuntimeError(f"Veo poll {r.status_code}: {r.text[:300]}") | |
| data = r.json() | |
| if not data.get("done"): | |
| return {"status": "running"} | |
| if data.get("error"): | |
| err = data["error"] | |
| msg = err.get("message") if isinstance(err, dict) else str(err) | |
| if _is_rai_message(msg): # safety block delivered as an op error | |
| raise VeoSafetyBlock([str(msg)]) | |
| raise RuntimeError(f"Veo operation failed: {err}") | |
| resp = data.get("response") or {} | |
| gen_resp = resp.get("generateVideoResponse") or {} | |
| filtered = resp.get("raiMediaFilteredCount") or gen_resp.get("raiMediaFilteredCount") | |
| if filtered: | |
| reasons = resp.get("raiMediaFilteredReasons") or gen_resp.get("raiMediaFilteredReasons") or ["RAI filtered"] | |
| raise VeoSafetyBlock(list(reasons)) | |
| videos = resp.get("videos") or resp.get("generatedSamples") or gen_resp.get("generatedSamples") or [] | |
| first = videos[0] if videos else None | |
| if not first: | |
| raise RuntimeError(f"Veo done with no video; response={json.dumps(resp)[:400]}") | |
| seg_id = segment_id or _segment_for_operation(operation_name) | |
| dest = SEGMENTS_DIR / f"{seg_id}.mp4" | |
| dest.parent.mkdir(parents=True, exist_ok=True) | |
| b64 = first.get("bytesBase64Encoded") | |
| if b64: | |
| dest.write_bytes(base64.b64decode(b64)) | |
| return {"status": "done", "file": str(dest)} | |
| gcs = first.get("gcsUri") or (first.get("video") or {}).get("uri") | |
| if gcs: | |
| dest.write_bytes(_download_gcs(gcs)) | |
| return {"status": "done", "file": str(dest)} | |
| raise RuntimeError("Veo response had neither bytes nor gcsUri") | |
| def _segment_for_operation(operation_name: str) -> str: | |
| row = db.fetch_one("select id from segments where veo_operation = %s", (operation_name,)) | |
| if row is None: | |
| raise RuntimeError(f"no segment recorded for operation {operation_name}") | |
| return str(row["id"]) | |
| def _poll_until_done(op_name: str, segment_id: str) -> str: | |
| deadline = time.monotonic() + POLL_TIMEOUT_S | |
| while True: | |
| result = poll(op_name, segment_id=segment_id) | |
| if result["status"] == "done": | |
| return result["file"] | |
| if time.monotonic() > deadline: | |
| raise TimeoutError(f"Veo operation {op_name} still running after {POLL_TIMEOUT_S}s") | |
| time.sleep(POLL_INTERVAL_S) | |
| # ββ RAI policy (SPEC_NEW Β§12.5) ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _rephrase_segment_line(segment_id: str) -> str | None: | |
| """Sonnet rephrases the spoken line (softer/less imperative) and swaps it into | |
| the segment's prompt β the scene/startframe stay identical. Returns the new line.""" | |
| s = get_settings() | |
| seg = db.fetch_one("select * from segments where id = %s", (segment_id,)) | |
| if seg is None: | |
| return None | |
| from . import agents | |
| from .prompts.veo_segment import validate_spoken_text | |
| new_text = agents.rephrase_spoken_line(seg["spoken_text"]) | |
| validate_spoken_text(new_text) | |
| old_q = f'"{seg["spoken_text"].strip()}"' | |
| new_prompt = seg["veo_prompt"].replace(old_q, f'"{new_text.strip()}"', 1) | |
| new_dur = duration.decide_duration(new_text, s.words_per_second, s.tail_budget_s) | |
| db.execute( | |
| "update segments set spoken_text = %s, veo_prompt = %s, duration_s = %s where id = %s", | |
| (new_text, new_prompt, new_dur, segment_id), | |
| ) | |
| return new_text | |
| def _set_gen_note(segment_id: str, note: str | None) -> None: | |
| """Surface a transient generation status to the UI (e.g. RAI seed-swap progress). | |
| Stored on videos.gen_note β not a creative attribute, never in videos.tags.""" | |
| db.execute( | |
| "update videos set gen_note = %s where id = (select video_id from segments where id = %s)", | |
| (note, str(segment_id)), | |
| ) | |
| def _handle_safety_block(segment_id: str, first_exc: VeoSafetyBlock) -> str: | |
| """On a Veo safety block (Β§12.5): Veo's RAI filter is non-deterministic and usually | |
| trips on the rendered VIDEO, not the words β so we retry on a **fresh seed** (the words | |
| and startframe stay identical), looping up to VEO_RAI_MAX_RETRIES times. A new seed | |
| almost always escapes the false positive. The retry runs **automatically**; its progress | |
| is surfaced live via videos.gen_note. Only when the loop is exhausted does it surface as | |
| failed β and only then does the UI show a 'FΓΆrsΓΆk igen' button.""" | |
| s = get_settings() | |
| if not s.veo_rai_autoretry: | |
| raise first_exc | |
| last_exc = first_exc | |
| for attempt in range(1, max(1, s.veo_rai_max_retries) + 1): | |
| new_seed = duration.random_seed() | |
| db.execute("update segments set seed = %s where id = %s", (new_seed, segment_id)) | |
| _set_gen_note(segment_id, | |
| f"Veo blockade klippet β byter seed (fΓΆrsΓΆk {attempt}/{s.veo_rai_max_retries})β¦") | |
| print(f"[RAI] segment {segment_id}: safety block β auto-retry {attempt}/{s.veo_rai_max_retries} " | |
| f"on a fresh seed {new_seed} (same words + startframe).") | |
| op_name = generate_segment(segment_id) | |
| try: | |
| return _poll_until_done(op_name, segment_id) | |
| except VeoSafetyBlock as e2: | |
| last_exc = e2 | |
| continue | |
| print(f"[RAI] segment {segment_id}: still blocked after {s.veo_rai_max_retries} fresh seeds β surfacing " | |
| f"(the UI now shows 'FΓΆrsΓΆk igen' for a fresh {s.veo_rai_max_retries}-retry loop).") | |
| raise last_exc | |
| def _maybe_upload_clip(segment_id: str, local_path: str) -> str: | |
| """Upload a generated clip to Supabase Storage (so nothing lives only locally); | |
| return the public URL, or the local path on failure / dry-run.""" | |
| from . import storage | |
| if get_settings().dry_run or not storage.enabled(): | |
| return local_path | |
| try: | |
| return storage.upload_bytes("gens", f"segments/{segment_id}.mp4", | |
| Path(local_path).read_bytes(), "video/mp4") | |
| except Exception as exc: # noqa: BLE001 β keep local on failure | |
| print(f"[storage] clip upload failed ({exc}); keeping local") | |
| return local_path | |
| def generate_and_download(segment_id: str) -> str: | |
| op_name = generate_segment(segment_id) | |
| try: | |
| path = _poll_until_done(op_name, segment_id) | |
| except VeoSafetyBlock as exc: | |
| path = _handle_safety_block(segment_id, exc) | |
| file_path = _maybe_upload_clip(segment_id, path) | |
| db.execute("update segments set file_path = %s where id = %s", (file_path, segment_id)) | |
| _set_gen_note(segment_id, None) # clear any RAI seed-swap note β this segment is done | |
| return file_path | |