"""Veo prompt builder — ALL Swedish prompting rules live here (SPEC_NEW §7.2). Rules encoded: 1. Dialogue verbatim in quotes, exactly as spoken. Nothing improvised. 2. Explicit: the person speaks Swedish with a natural native accent. 3. Duration-aware word cap. A segment must fit its clip length; the hard cap is MAX_SPOKEN_WORDS = 18 (SPEC_NEW §12.1/§12.2). Hard fail here if exceeded. 4. One speaker per segment. No background voices. 5. Always append the no-text/no-watermark instruction. 6. UGC style block: handheld/casual framing, natural light, concrete Swedish setting, vertical 9:16. 7. End instruction: brief natural silence after the line. LOAD-BEARING — the audio-aware stitch (SPEC_NEW §11.4) relies on a detectable gap before any tail junk. Keep it. 8. Avatar continuity: persona description verbatim in every segment prompt. 9. Numbers/prices must already be spelled out in Swedish words — hard fail on digits in the spoken text. 10. Reject quote characters in spoken text (they would break verbatim quoting). The spoken-word counter is shared with pipeline/duration.py, so the word cap and the chosen duration bucket can never disagree (SPEC_NEW §12.2). """ from __future__ import annotations import re from typing import Any from ..duration import MAX_SPOKEN_WORDS, count_spoken_words __all__ = [ "DEFAULT_PROMPT_TEMPLATE", "MAX_SPOKEN_WORDS", "NO_TEXT_SUFFIX", "PromptRuleViolation", "build_veo_prompt", "count_spoken_words", "fill_template", "persona_description", "render_prompt", "validate_spoken_text", ] # The startframe-centric default (generalized from the MVP donor's seed template). # The avatar's start-frame image defines the entire scene; this template locks it # and only varies the spoken line in {script_chunk} (SPEC_NEW §11.2). DEFAULT_PROMPT_TEMPLATE = ( "The person from the attached start-frame image speaks directly to the camera in " "fluent, native Swedish (rikssvenska) with a casual, warm, conversational tone — " "natural volume, not announcer-style. Vertical 9:16 framing, authentic iPhone " "front-camera selfie aesthetic, no selfie arm visible. They keep steady, natural eye " "contact with the lens, blinking and breathing normally. They say one short sentence " 'in Swedish and then stop: "{script_chunk}". After that single sentence they stay ' "quiet for the rest of the clip — mouth closing into a small soft smile, still looking " "calmly into the camera. Keep EVERYTHING from the attached start-frame image exactly as " "shown: the same face, hair, clothing, framing, pose, lighting, and background — only the " "mouth, natural blinking, breathing, and tiny unconscious micro-movements change. " "Authentic, lived-in UGC energy — real, calm, natural; NOT presenter mode, NOT polished, " "NOT performative. Authentic skin texture, no smoothing or beautification, no studio " "lighting. Audio: quiet natural ambience as if captured by a phone microphone; the voice " "is the clear focus during the spoken line. No music, no other voices. " "No on-screen text, no captions, no subtitles, no logos, no watermarks." ) # Default concrete Swedish settings per segment kind. A brief/script can # override via the `setting` argument; these keep rule 6 satisfied either way. DEFAULT_SETTINGS = { "hook": "ett ljust svenskt kök med vita luckor och en kaffekopp på bänken", "body": "ett vardagsrum med ljusa träväggar, en grå soffa och växter i fönstret", "cta": "en hall i en svensk lägenhet med en spegel och ytterkläder på krokar", } NO_TEXT_SUFFIX = "No subtitles, no captions, no on-screen text, no watermarks." class PromptRuleViolation(ValueError): """A segment violates a hard prompting rule. Never send this to Veo.""" def validate_spoken_text(spoken_text: str) -> None: text = spoken_text.strip() if not text: raise PromptRuleViolation("spoken_text is empty") n = count_spoken_words(text) if n > MAX_SPOKEN_WORDS: raise PromptRuleViolation( f"spoken_text has {n} words; the hard limit is {MAX_SPOKEN_WORDS} words " f"(it must fit an 8s clip with a tail, SPEC_NEW §12.1): {text!r}" ) if re.search(r"\d", text): raise PromptRuleViolation( "spoken_text contains digits — numbers and prices must be spelled out in Swedish " f"words ('trehundra kronor', not '300 kr'): {text!r}" ) if '"' in text or "”" in text or "“" in text: raise PromptRuleViolation( f"spoken_text contains quote characters, which would break verbatim quoting: {text!r}" ) def persona_description(persona: dict[str, Any]) -> str: """Render avatars.persona verbatim, field by field, for continuity (rule 8).""" order = ["age_range", "look", "voice_notes", "wardrobe", "vibe"] parts = [str(persona[k]) for k in order if persona.get(k)] parts += [str(v) for k, v in persona.items() if k not in order and v] if not parts: raise PromptRuleViolation("avatar persona is empty — cannot keep continuity across segments") return ", ".join(parts) def build_veo_prompt( spoken_text: str, kind: str, persona: dict[str, Any], setting: str | None = None, ) -> str: validate_spoken_text(spoken_text) if kind not in ("hook", "body", "cta"): raise PromptRuleViolation(f"unknown segment kind {kind!r}") place = setting or DEFAULT_SETTINGS[kind] who = persona_description(persona) lines = [ # Subject + continuity (rule 8) f"A UGC-style selfie video of one person: {who}.", # Verbatim dialogue + Swedish + single speaker (rules 1, 2, 4). # The "exactly these words and nothing else: \"...\"" phrasing is matched # by duration.extract_spoken_sentence — keep it stable. ( "The person looks into the camera and says, in Swedish with a natural native " f'Swedish accent, exactly these words and nothing else: "{spoken_text.strip()}". ' "The dialogue must be spoken verbatim — no improvisation, no added words. " "One speaker only; no background voices, no other people talking." ), # UGC style block (rule 6) ( f"Style: authentic UGC. Handheld selfie framing or a casual tripod shot, natural " f"lighting, slightly imperfect framing is fine. Real-world Swedish setting: {place}. " "Vertical 9:16 video." ), # Tail silence (rule 7) — load-bearing for the smart stitch "After the spoken line, the person pauses with a brief natural silence before the video ends.", # No on-screen text (rule 5) — always the final instruction NO_TEXT_SUFFIX, ] return "\n".join(lines) # ── Startframe-centric prompting (the primary path, SPEC_NEW §11.2) ─────────── def fill_template(template: str, spoken_text: str, *, tone: str = "casual, warm", product_name: str = "produkten") -> str: """Slot the (validated) spoken line into an avatar's scene-locking template.""" line = spoken_text.strip() if "{script_chunk}" in template: out = template.replace("{script_chunk}", line) else: # template without the slot → append the verbatim line so Veo still speaks it out = template.rstrip() + ( f'\nThey say, in Swedish, exactly these words and nothing else: "{line}".' ) return out.replace("{tone}", tone).replace("{product_name}", product_name) def render_prompt(avatar: dict[str, Any], spoken_text: str, kind: str = "hook", setting: str | None = None) -> str: """Build the Veo prompt for one segment. Primary path: the avatar has a start-frame image + a `prompt_template` — the template defines the whole scene and we only vary the spoken line. Fallback (legacy avatars with only a persona): build_veo_prompt from the persona text. Either way the line is validated (≤16 words, no digits, no quotes). """ validate_spoken_text(spoken_text) template = (avatar or {}).get("prompt_template") if template and str(template).strip(): return fill_template(template, spoken_text) persona = (avatar or {}).get("persona") or {} return build_veo_prompt(spoken_text, kind, persona, setting)