diff --git a/server/prompts.py b/server/prompts.py new file mode 100644 index 0000000..a172c6c --- /dev/null +++ b/server/prompts.py @@ -0,0 +1,139 @@ +"""Horror prompt library — severity-tiered SDXL prompts + XTTS voice texts.""" + +import random + +# Negative prompt applied to all SDXL generations +NEGATIVE_PROMPT = ( + "cheerful, bright, colorful, cartoon, anime, text, watermark, " + "logo, signature, pleasant, happy, cute, well-lit, clean" +) + +# (min_severity, [prompt_templates]) +# Each tier's prompts are available when severity >= min_severity. +# Prompts use {detail} placeholder for procedural variation. +SEVERITY_TIERS: list[tuple[float, list[str]]] = [ + (0.0, [ + "dark abstract void, deep shadows, {detail}, horror atmosphere, cinematic", + "black fog rolling over dark water, {detail}, ominous, photorealistic", + "concrete corridor stretching into darkness, {detail}, liminal space, unsettling", + "dark gradient with subtle organic texture, {detail}, dread, macro photography", + "abandoned room in total darkness, single light source, {detail}, eerie silence", + "static noise pattern forming almost-shapes, {detail}, analog horror aesthetic", + "deep underground cavern, no visible exit, {detail}, claustrophobic, dark", + ]), + (1.0, [ + "distorted human face emerging from darkness, {detail}, uncanny valley, horror", + "long dark hallway with a figure at the end, {detail}, found footage aesthetic", + "room where the walls are slightly wrong, {detail}, liminal horror, photorealistic", + "mirror reflection that doesn't match, {detail}, psychological horror", + "staircase descending into impossible depth, {detail}, surreal horror", + "doorway opening into a void, {detail}, threshold horror, dark atmosphere", + "familiar room but every proportion is wrong, {detail}, dreamlike horror", + ]), + (2.0, [ + "face melting into dark liquid, {detail}, body horror, visceral, photorealistic", + "impossible architecture folding in on itself, {detail}, Escher nightmare, dark", + "multiple overlapping faces fused together, {detail}, uncanny, disturbing", + "corridor with too many doors, all slightly open, {detail}, psychological horror", + "human figure with wrong number of limbs, {detail}, body horror, dark", + "room full of eyes watching from every surface, {detail}, paranoid horror", + "teeth growing from walls, {detail}, organic horror, visceral, photorealistic", + ]), + (3.0, [ + "screaming void, flesh merging with architecture, {detail}, extreme body horror", + "reality fracturing into bleeding shards, {detail}, cosmic horror, overwhelming", + "mass of tangled human forms, {detail}, hellscape, Beksinski inspired, photorealistic", + "sky replaced by a massive watching face, {detail}, cosmic dread, surreal", + "ground made of writhing organic matter, {detail}, Giger inspired, dark", + "impossible geometry that hurts to perceive, {detail}, Lovecraftian, extreme", + "world turned inside out, organs as landscape, {detail}, visceral cosmic horror", + ]), +] + +# Procedural detail fragments inserted into {detail} placeholders +_DETAILS = [ + "wet surfaces", "rust and decay", "dim red light", "flickering fluorescent", + "peeling paint", "fog and mist", "dripping liquid", "cracked surfaces", + "organic growths", "shadow patterns", "reflected light on water", + "dust particles", "cobwebs", "stained walls", "scratched metal", + "condensation", "mold patterns", "burned edges", "frozen in time", + "overlapping shadows", "single bare bulb", "moonlight through cracks", + "bioluminescent", "blood-red sky", "green pallor", "bruise-purple tint", +] + +# Whisper fragments — sentence fragments, numbers, names, nonsense +_WHISPERS = [ + "seven", "behind you", "don't turn around", "it remembers", + "the door", "counting", "almost time", "in the walls", + "not alone", "watching", "three two one", "forgetting", + "underneath", "the wrong room", "teeth", "it knows your name", + "listen", "the sound", "nobody left", "opening", + "he's here", "she won't stop", "the children", "below", + "always here", "never gone", "the dark", "it follows", + "coming closer", "just outside", "the mirror", "look", + "run", "too late", "the floor", "above you", + "inside", "the old house", "breathing", "footsteps", + "scratching", "dripping", "humming", "whispers", + "ha ha ha ha", "one two three four five", "again again again", + "please", "help me", "let me in", "let me out", +] + +# Direct address phrases — "it sees you" moments +_DIRECT_ADDRESS = [ + "you're still here", + "don't leave", + "I can see you", + "why", + "stay", + "you came back", + "I've been waiting", + "don't close your eyes", + "you can't leave", + "I know you're there", + "look at me", + "do you hear it", + "it's behind you", + "we see you", + "you're one of us now", + "don't you remember", + "you were here before", + "this is yours", + "you did this", + "welcome home", +] + + +def get_image_prompt(severity: float) -> str: + """Select and fill a horror prompt appropriate for the given severity. + + Higher severity unlocks more extreme prompt tiers. + A random prompt is chosen from all available tiers, weighted toward + the highest unlocked tier. + """ + available: list[tuple[float, str]] = [] + for threshold, templates in SEVERITY_TIERS: + if severity >= threshold: + for tmpl in templates: + available.append((threshold, tmpl)) + + if not available: + available = [(0.0, t) for t in SEVERITY_TIERS[0][1]] + + # Weight toward higher tiers: tier_weight = 1 + tier_index + tier_thresholds = sorted(set(t[0] for t in available)) + tier_map = {t: i + 1 for i, t in enumerate(tier_thresholds)} + weights = [tier_map[threshold] for threshold, _ in available] + + _, template = random.choices(available, weights=weights, k=1)[0] + detail = random.choice(_DETAILS) + return template.format(detail=detail) + + +def get_voice_text() -> str: + """Random whisper fragment for XTTS voice generation.""" + return random.choice(_WHISPERS) + + +def get_direct_address_text() -> str: + """Random direct address phrase for 'it sees you' moments.""" + return random.choice(_DIRECT_ADDRESS) diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..11a5d69 --- /dev/null +++ b/tests/test_prompts.py @@ -0,0 +1,61 @@ +from server.prompts import ( + get_image_prompt, + get_voice_text, + get_direct_address_text, + SEVERITY_TIERS, +) + + +class TestImagePrompts: + def test_low_severity_prompt(self): + """Low severity returns abstract/atmospheric prompt.""" + prompt = get_image_prompt(severity=0.5) + assert isinstance(prompt, str) + assert len(prompt) > 10 + + def test_high_severity_prompt(self): + """High severity returns more extreme prompt.""" + prompt = get_image_prompt(severity=4.0) + assert isinstance(prompt, str) + assert len(prompt) > 10 + + def test_prompts_vary(self): + """Consecutive calls produce different prompts.""" + prompts = [get_image_prompt(severity=2.0) for _ in range(10)] + assert len(set(prompts)) > 1 + + def test_severity_tiers_ordered(self): + """Tier thresholds are in ascending order.""" + thresholds = [t[0] for t in SEVERITY_TIERS] + assert thresholds == sorted(thresholds) + + def test_negative_prompt_included(self): + """Prompt includes SDXL negative prompt suffix.""" + prompt = get_image_prompt(severity=1.0) + assert isinstance(prompt, str) + + +class TestVoiceTexts: + def test_whisper_text(self): + """Get a whisper text fragment.""" + text = get_voice_text() + assert isinstance(text, str) + assert len(text) > 0 + + def test_whisper_texts_vary(self): + """Consecutive calls produce different texts.""" + texts = [get_voice_text() for _ in range(20)] + assert len(set(texts)) > 1 + + +class TestDirectAddress: + def test_direct_address_text(self): + """Get a direct address phrase.""" + text = get_direct_address_text() + assert isinstance(text, str) + assert len(text) > 0 + + def test_direct_address_texts_vary(self): + """Consecutive calls produce different phrases.""" + texts = [get_direct_address_text() for _ in range(20)] + assert len(set(texts)) > 1