"""Typed configuration from .env / environment. Loading rules (see SPEC_NEW §5): - DATABASE_URL is required at startup — everything needs the DB. - External-service credentials are optional at load time so DRY_RUN and tests work without them, but any module that actually needs one must call `settings.require("NAME")`, which fails loudly with the exact missing var. """ from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict REPO_ROOT = Path(__file__).resolve().parent.parent MEDIA_DIR = REPO_ROOT / "media" FIXTURES_DIR = REPO_ROOT / "fixtures" class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=REPO_ROOT / ".env", env_file_encoding="utf-8", extra="ignore", ) # Database (required) database_url: str # v2's tables live in this schema. Default "public"; set to e.g. "ugc" to share # an existing Supabase project without touching its other schemas (SPEC_NEW §3). db_schema: str = "public" # Supabase Storage — when set, avatar/product image uploads go to a public # bucket here instead of local disk (so everything lives in Supabase). supabase_url: str | None = None supabase_service_role_key: str | None = None supabase_bucket: str = "startframes" # Anthropic anthropic_api_key: str | None = None anthropic_model: str = "claude-sonnet-4-6" # Vertex AI / Veo google_cloud_project: str | None = None google_application_credentials: str | None = None vertex_location: str | None = None veo_model_id: str | None = None nano_banana_model: str | None = None # Nano Banana Pro (Gemini image) on Vertex — §16.3 # Windsor.ai (ad-metrics ingest — SPEC_NEW §7.6) windsor_api_key: str | None = None windsor_connector: str = "facebook" # configured connector (Meta now; TikTok later) windsor_fields: str | None = None # optional override; default = ad-level daily metrics # Meta (publish only — ingest is Windsor.ai above) meta_access_token: str | None = None meta_ad_account_id: str | None = None meta_api_version: str | None = None meta_test_campaign_id: str | None = None meta_page_id: str | None = None # Behavior / guardrails dry_run: bool = True max_generations_per_day: int = 40 qc_pass_threshold: float = 0.85 qc_flag_threshold: float = 0.70 max_segment_attempts: int = 3 # Video engine (SPEC_NEW §11) veo_resolution: str = "720p" # all clips 720p, exactly like the MVP seed_takes: int = 2 # hook seed candidates to generate (§11.3) seed_autopick: bool = True # auto-pick best hook take via QC; human override veo_rai_autoretry: bool = False # OFF = surface safety blocks (v1 default, §12.5) veo_rai_rephrase: bool = True # LEGACY — auto-retry now swaps the SEED, not words (§12.5) veo_rai_max_retries: int = 3 # auto rephrase+retry loop length per safety block (§12.5) # Speaking-pace model for dynamic duration (§11.1). These MIRROR the defaults # baked into pipeline/duration.py; re-pacing is a deliberate change to both. words_per_second: float = 2.3 tail_budget_s: float = 0.7 # B-roll (Seedance, SPEC_NEW §15) seedance_provider: str = "volcengine" # fal | replicate | volcengine (owner uses Volcengine Ark) seedance_api_key: str | None = None # Volcengine Ark API key (ARK_API_KEY) seedance_model: str = "doubao-seedance-1-0-lite-i2v-250428" # verify in your Ark console seedance_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" # Assembly (SPEC_NEW §11.4) loudnorm_target_i: float = -14.0 # integrated LUFS caption_max_words_per_line: int = 4 # 2–4 words/line, burned ASS captions_enabled: bool = True # best-effort; skipped if ASR unavailable # QC / ASR kb_whisper_model: str = "KBLab/kb-whisper-large" kb_whisper_fallback_model: str = "KBLab/kb-whisper-medium" kb_whisper_device: str = "auto" kb_whisper_compute_type: str = "" # Rules target_cpa: float = 300.0 # Approval UI approval_ui_user: str = "admin" approval_ui_password: str | None = None def require(self, name: str) -> str: """Return a config value, failing loudly if it is unset/empty.""" value = getattr(self, name.lower(), None) if value is None or (isinstance(value, str) and not value.strip()): raise RuntimeError( f"Missing required configuration: {name.upper()}. " f"Set it in .env (see .env.example)." ) return value @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings() # type: ignore[call-arg] # database_url comes from env def reset_settings_cache() -> None: """Test helper: force re-read of the environment.""" get_settings.cache_clear()