187 lines
5.8 KiB
Python
187 lines
5.8 KiB
Python
import json
|
|
import time
|
|
|
|
from .shared import (
|
|
_LOG,
|
|
_fmt_json,
|
|
_parse_resolution,
|
|
_COMPOSITION_FALLBACK,
|
|
_timed_generate,
|
|
_extract_json,
|
|
)
|
|
|
|
|
|
class CompositionInferenceNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"mood_json": ("STRING", {"multiline": True}),
|
|
"subject_json": ("STRING", {"multiline": True}),
|
|
"ollama_url": ("STRING", {
|
|
"default": "http://localhost:11434/api/generate"
|
|
}),
|
|
"model": ("STRING", {"default": "qwen3:14b"}),
|
|
"analysis_temperature": ("FLOAT", {
|
|
"default": 0.4, "min": 0.0, "max": 1.5, "step": 0.1
|
|
}),
|
|
"prompt_temperature": ("FLOAT", {
|
|
"default": 0.8, "min": 0.0, "max": 1.5, "step": 0.1
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "INT", "INT")
|
|
RETURN_NAMES = ("composition_json", "composition_prompt", "width", "height")
|
|
FUNCTION = "infer"
|
|
CATEGORY = "audio/analysis"
|
|
|
|
def _build_composition_json_prompt(self, mood_json, subject_json):
|
|
subject_section = (
|
|
f"Subject analysis (primary signal — inferred from lyrics, focus fragments, or song description):\n{_fmt_json(subject_json)}"
|
|
if subject_json
|
|
else "Subject analysis: not available — infer from mood alone, default to environment_dominant framing."
|
|
)
|
|
return f"""
|
|
You are an art director inferring visual composition from music analysis and lyrical subject material.
|
|
|
|
{subject_section}
|
|
|
|
Sonic mood analysis (supporting atmospheric context):
|
|
{_fmt_json(mood_json)}
|
|
|
|
Infer the optimal visual composition for an image that expresses this music.
|
|
|
|
Consider:
|
|
- Does the subject dominate, or does the environment carry the weight?
|
|
- What orientation and ratio best serves the emotional content?
|
|
- Where does the subject sit within the frame?
|
|
- How much negative space reinforces the mood?
|
|
- What camera distance and crop serve the subject?
|
|
|
|
Return only valid JSON with this exact structure:
|
|
{{
|
|
"aspect_ratio": {{
|
|
"orientation": "portrait",
|
|
"ratio": "4:5",
|
|
"recommended_resolution": "1024x1280"
|
|
}},
|
|
"subject_placement": {{
|
|
"position": "lower_right",
|
|
"size_weight": 0.35
|
|
}},
|
|
"environment": {{
|
|
"weight": 0.65,
|
|
"negative_space": "high"
|
|
}},
|
|
"camera": {{
|
|
"distance": "medium",
|
|
"framing_style": "environment_dominant",
|
|
"crop": "waist_up"
|
|
}}
|
|
}}
|
|
|
|
Constraints:
|
|
- orientation must be one of: portrait, landscape, square
|
|
- ratio must be one of: 4:5, 16:9, 9:16, 1:1, 3:2, 2:3
|
|
- recommended_resolution must be one of: 1024x1280, 1280x1024, 768x1344, 1344x768, 896x1152, 1152x896, 1024x1024, 832x1216, 1216x832
|
|
- position must be one of: center, lower_left, lower_right, upper_left, upper_right, off_frame
|
|
- negative_space must be one of: low, medium, high
|
|
- distance must be one of: close, medium, wide
|
|
- framing_style must be one of: subject_dominant, environment_dominant, balanced
|
|
- crop must be one of: full_body, waist_up, bust, face_only, none
|
|
|
|
If subject analysis is unavailable, set framing_style to environment_dominant and crop to none.
|
|
|
|
Do not include any text before or after the JSON.
|
|
"""
|
|
|
|
def _build_composition_prose_request(self, composition_json):
|
|
return f"""
|
|
You are an art director translating a composition analysis into image-generation prompt language.
|
|
|
|
Composition analysis:
|
|
{_fmt_json(composition_json)}
|
|
|
|
Write a short, precise image-generation prompt that describes:
|
|
- the framing and orientation
|
|
- where the subject sits in the frame (if present)
|
|
- the relationship between subject and environment
|
|
- camera distance and crop
|
|
- negative space quality
|
|
|
|
Keep it:
|
|
- concise (one to two sentences)
|
|
- visual and specific
|
|
- in image-generation prompt style (no explanation, no metadata)
|
|
|
|
Output only the final composition prompt.
|
|
"""
|
|
|
|
def infer(
|
|
self,
|
|
mood_json,
|
|
subject_json,
|
|
ollama_url,
|
|
model,
|
|
analysis_temperature,
|
|
prompt_temperature,
|
|
):
|
|
t0 = time.time()
|
|
print(f"{_LOG} [Composition] model: {model}")
|
|
|
|
try:
|
|
mood = json.loads(mood_json) if isinstance(mood_json, str) else mood_json
|
|
except (json.JSONDecodeError, TypeError):
|
|
mood = {}
|
|
|
|
try:
|
|
subject = json.loads(subject_json) if isinstance(subject_json, str) else subject_json
|
|
except (json.JSONDecodeError, TypeError):
|
|
subject = {}
|
|
|
|
if not subject:
|
|
print(f"{_LOG} [Composition] ⚠ subject_json empty — inferring from mood only")
|
|
|
|
raw_composition = _timed_generate(
|
|
"composition inference", ollama_url, model,
|
|
self._build_composition_json_prompt(mood, subject),
|
|
analysis_temperature,
|
|
)
|
|
composition = _extract_json(raw_composition)
|
|
|
|
if "error" in composition:
|
|
print(f"{_LOG} [Composition] ⚠ falling back to safe composition defaults")
|
|
composition = _COMPOSITION_FALLBACK
|
|
|
|
width, height = _parse_resolution(
|
|
composition.get("aspect_ratio", {}).get("recommended_resolution", "1024x1024")
|
|
)
|
|
|
|
composition_prompt = _timed_generate(
|
|
"composition prompt", ollama_url, model,
|
|
self._build_composition_prose_request(composition),
|
|
prompt_temperature,
|
|
)
|
|
|
|
if not composition_prompt.strip():
|
|
print(f"{_LOG} [Composition] ⚠ empty composition prompt returned")
|
|
|
|
print(f"{_LOG} [Composition] done total: {time.time()-t0:.1f}s")
|
|
|
|
return (
|
|
_fmt_json(composition),
|
|
composition_prompt,
|
|
width,
|
|
height,
|
|
)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"CompositionInferenceNode": CompositionInferenceNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"CompositionInferenceNode": "Composition Inference",
|
|
}
|