File size: 4,100 Bytes
871ff87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
"""Prompt rendering for the FLUX.2-klein backend."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from aamcq.profile import VisualProfile

# Template structure follows the BFL klein prompting guide: scene front-loaded,
# natural-language prose, trailing "Key: value." markers. Lighting is embedded
# mid-sentence as an environmental modifier — klein responds to it more
# strongly there than as a trailing key.
#   docs.bfl.ml/guides/prompting_guide_flux2_klein
PROMPT_TEMPLATES: dict[str, str] = {
    "flux2_klein": (
        "{scene}, lit by {lighting_phrase}, rendered as {medium_phrase} "
        "in {style_phrase}. Color palette: {color_lc}."
    ),
}

# Medium-to-phrase map: bare vocab words like "Ink Drawing" or "Pixel Art"
# aren't idiomatic article-bearing noun phrases, so we standardize the wording
# here to keep the rendered prompt grammatical.
ART_MEDIUM_PHRASES: dict[str, str] = {
    "Oil Painting": "an oil painting",
    "Watercolor": "a watercolor painting",
    "Ink Drawing": "an ink drawing",
    "Digital Painting": "a digital painting",
    "Pixel Art": "a pixel-art illustration",
    "Pencil Sketch": "a pencil sketch",
}

# Style-to-phrase map. Most styles are rendered as "{X} style"; Minimalism and
# Art Deco use expanded phrases because the bare word doesn't activate klein's
# flat-design / decorative-geometric signals.
ART_STYLE_PHRASES: dict[str, str] = {
    "Impressionism": "Impressionism style",
    "Anime": "Anime style",
    "Photorealism": "Photorealism style",
    "Cubism": "Cubism style",
    "Minimalism": "flat minimalist style with sparse composition",
    "Art Deco": "Art Deco style with bold geometric shapes, clean symmetry, and ornamental lines",
}

# Lighting-to-phrase map, spanning a (temperature × hardness × key × source)
# grid so the 6 values are maximally distinguishable in the rendered image.
LIGHTING_PHRASES: dict[str, str] = {
    "Golden Hour": "golden hour sunset light",
    "Moody Low-Key": "moody low-key dramatic light",
    "Soft Overcast": "soft diffused overcast daylight",
    "Harsh Noon": "harsh direct noon sunlight",
    "Neon Glow": "pink and cyan neon glow",
    "Candlelit": "warm dim amber glow",
}

NEGATIVE_PROMPTS: dict[str, str | None] = {
    "flux2_klein": None,
}

MODEL_SPECS: dict[str, dict[str, object]] = {
    "flux2_klein": {
        "model_id": "black-forest-labs/FLUX.2-klein-9B",
        "num_inference_steps": 4,
        "guidance_scale": 1.0,
    },
}

Backend = Literal["flux2_klein"]


@dataclass(frozen=True)
class RenderedPrompt:
    backend: str
    prompt: str
    negative_prompt: str | None


def _format_kwargs(profile: VisualProfile, base_prompt: str) -> dict[str, str]:
    if profile.art_medium not in ART_MEDIUM_PHRASES:
        raise ValueError(f"no ART_MEDIUM_PHRASES entry for {profile.art_medium!r}")
    if profile.lighting not in LIGHTING_PHRASES:
        raise ValueError(f"no LIGHTING_PHRASES entry for {profile.lighting!r}")
    if profile.art_style not in ART_STYLE_PHRASES:
        raise ValueError(f"no ART_STYLE_PHRASES entry for {profile.art_style!r}")
    return {
        "scene": base_prompt,
        "medium_phrase": ART_MEDIUM_PHRASES[profile.art_medium],
        "lighting_phrase": LIGHTING_PHRASES[profile.lighting],
        "style_phrase": ART_STYLE_PHRASES[profile.art_style],
        "color_lc": profile.color.lower(),
    }


def render(profile: VisualProfile, base_prompt: str, backend: Backend = "flux2_klein") -> RenderedPrompt:
    if backend not in PROMPT_TEMPLATES:
        raise ValueError(f"unknown backend {backend!r}; expected one of {list(PROMPT_TEMPLATES)}")
    prompt = PROMPT_TEMPLATES[backend].format(**_format_kwargs(profile, base_prompt))
    prompt = _post_process(prompt)
    return RenderedPrompt(
        backend=backend,
        prompt=prompt,
        negative_prompt=NEGATIVE_PROMPTS[backend],
    )


def _post_process(prompt: str) -> str:
    return (
        " ".join(prompt.split())
        .replace(" ,", ",")
        .replace(",,", ",")
        .rstrip(", ")
    )