V1
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
"""Outfit recommendations and ComfyUI illustration for Alfred weather view.
|
||||
|
||||
This service reuses the same ComfyUI instance as Hermes, but calls ComfyUI
|
||||
HTTP directly to avoid replacing Alfred's central view while generating the
|
||||
illustration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, date as Date
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv, dotenv_values
|
||||
|
||||
# Charger l'environnement avant de calculer BASE et WORKFLOWS_DIR.
|
||||
# Flask exécute son load_dotenv() après l'import de ce module.
|
||||
load_dotenv()
|
||||
load_dotenv('/opt/hermes/MCP_comfyui/.env', override=False)
|
||||
_MCP_SETTINGS = dotenv_values('/opt/hermes/MCP_comfyui/.env')
|
||||
|
||||
from . import ha_weather
|
||||
|
||||
CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache')))
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json'
|
||||
BASE = (os.getenv('ALFRED_COMFYUI_URL') or _MCP_SETTINGS.get('COMFYUI_URL') or os.getenv('COMFYUI_URL') or 'http://192.168.1.13:8188').rstrip('/')
|
||||
WORKFLOWS_DIR = Path(os.getenv('ALFRED_COMFYUI_WORKFLOWS_DIR') or os.getenv('WORKFLOWS_DIR') or '/opt/hermes/MCP_comfyui/workflows')
|
||||
PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6')
|
||||
TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300'))
|
||||
TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12'))
|
||||
ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on')
|
||||
IMAGE_SIZE = min(1024, max(512, int(os.getenv('ALFRED_WEATHER_COMFY_SIZE', '768'))))
|
||||
PROMPT_VERSION = 'outfits-v3-duo-20261025'
|
||||
|
||||
# Un seul job génération météo à la fois : évite les doublons concurrents.
|
||||
ILLUSTRATION_LOCK = threading.RLock()
|
||||
IMAGE_DIR = CACHE_DIR / 'weather_outfit_images'
|
||||
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Deux portraits distincts, réunis dans une seule image finale.
|
||||
PORTRAIT_WIDTH = min(512, IMAGE_SIZE)
|
||||
PORTRAIT_HEIGHT = IMAGE_SIZE
|
||||
|
||||
|
||||
def _load_cache() -> dict:
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(cache: dict) -> None:
|
||||
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def _pick_day(data: dict, forced_date: str | None = None) -> dict:
|
||||
"""Retourne uniquement la météo du jour demandé ; jamais une autre journée."""
|
||||
target = str(forced_date or data.get('focus_date') or '').strip()
|
||||
try:
|
||||
requested = Date.fromisoformat(target)
|
||||
except ValueError as exc:
|
||||
raise ValueError('Date météo invalide : format AAAA-MM-JJ attendu.') from exc
|
||||
|
||||
for day in data.get('daily', []):
|
||||
if str(day.get('date')) == target:
|
||||
return day
|
||||
|
||||
# Certaines intégrations HA ne donnent que des prévisions horaires.
|
||||
matching = [h for h in data.get('hourly', []) if h.get('date') == target]
|
||||
if matching:
|
||||
def available(key):
|
||||
return [float(h[key]) for h in matching if h.get(key) is not None]
|
||||
temps = available('temperature')
|
||||
chances = available('precipitation_probability')
|
||||
winds = available('wind_speed')
|
||||
conditions = [str(h['condition']) for h in matching if h.get('condition')]
|
||||
condition = Counter(conditions).most_common(1)[0][0] if conditions else 'unknown'
|
||||
return {
|
||||
'date': target,
|
||||
'condition': condition,
|
||||
'description': ha_weather.CONDITIONS.get(condition, 'conditions non précisées'),
|
||||
'temperature': max(temps) if temps else None,
|
||||
'templow': min(temps) if temps else None,
|
||||
'precipitation_probability': max(chances) if chances else None,
|
||||
'wind_speed': max(winds) if winds else None,
|
||||
}
|
||||
|
||||
# Les observations actuelles ne doivent être utilisées QUE pour aujourd'hui.
|
||||
if requested == datetime.now(ha_weather.TZ).date():
|
||||
current = data.get('current', {})
|
||||
return {
|
||||
'date': target,
|
||||
'condition': current.get('condition', 'unknown'),
|
||||
'description': current.get('description', 'conditions non précisées'),
|
||||
'temperature': current.get('temperature'),
|
||||
'templow': current.get('temperature'),
|
||||
'precipitation_probability': None,
|
||||
'wind_speed': current.get('wind_speed'),
|
||||
}
|
||||
raise ValueError(f'Aucune prévision Home Assistant disponible pour le {requested:%d/%m/%Y}. Aucune image ne sera générée avec les données d’un autre jour.')
|
||||
|
||||
|
||||
def _temp_band(high: float | None, low: float | None) -> str:
|
||||
ref = high if high is not None else low
|
||||
if ref is None:
|
||||
return 'mild'
|
||||
if ref < 6:
|
||||
return 'cold'
|
||||
if ref < 14:
|
||||
return 'cool'
|
||||
if ref < 23:
|
||||
return 'mild'
|
||||
return 'warm'
|
||||
|
||||
|
||||
def _recommend(day: dict) -> dict:
|
||||
high = day.get('temperature')
|
||||
low = day.get('templow')
|
||||
pop = day.get('precipitation_probability')
|
||||
wind = day.get('wind_speed')
|
||||
cond = str(day.get('condition') or '')
|
||||
band = _temp_band(high, low)
|
||||
rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45)
|
||||
sunny = cond in {'sunny', 'partlycloudy'}
|
||||
windy = wind is not None and wind >= 25
|
||||
cold = band in {'cold', 'cool'}
|
||||
|
||||
child = []
|
||||
adult = []
|
||||
child_acc = []
|
||||
adult_acc = []
|
||||
|
||||
if band == 'cold':
|
||||
child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées']
|
||||
adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées']
|
||||
child_acc += ['bonnet', 'écharpe']
|
||||
adult_acc += ['écharpe']
|
||||
elif band == 'cool':
|
||||
child += ['veste légère', 'pantalon', 'baskets']
|
||||
adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées']
|
||||
elif band == 'mild':
|
||||
child += ['sweat ou gilet', 'pantalon léger', 'baskets']
|
||||
adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets']
|
||||
else:
|
||||
child += ['t-shirt', 'short ou pantalon léger', 'baskets légères']
|
||||
adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères']
|
||||
child_acc += ['casquette']
|
||||
adult_acc += ['lunettes de soleil']
|
||||
|
||||
if rainy:
|
||||
child_acc += ['imperméable', 'parapluie']
|
||||
adult_acc += ['parapluie']
|
||||
if 'chaussures fermées' not in child: child.append('chaussures fermées')
|
||||
if 'chaussures fermées' not in adult: adult.append('chaussures fermées')
|
||||
if windy:
|
||||
child_acc += ['coupe-vent']
|
||||
adult_acc += ['coupe-vent']
|
||||
if sunny and band != 'cold':
|
||||
if 'casquette' not in child_acc: child_acc += ['casquette']
|
||||
if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil']
|
||||
|
||||
def uniq(seq):
|
||||
seen=[]
|
||||
for x in seq:
|
||||
if x not in seen: seen.append(x)
|
||||
return seen
|
||||
|
||||
return {
|
||||
'summary': day.get('description', 'Conditions du jour'),
|
||||
'child': {
|
||||
'title': 'Fille · 8 ans',
|
||||
'clothes': uniq(child),
|
||||
'accessories': uniq(child_acc),
|
||||
},
|
||||
'adult': {
|
||||
'title': 'Homme · 42 ans',
|
||||
'clothes': uniq(adult),
|
||||
'accessories': uniq(adult_acc),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _weather_signature(day: dict, outfits: dict) -> str:
|
||||
# Version explicite pour ne pas ressortir des images incorrectes de l'ancien cache.
|
||||
raw = json.dumps({'version': PROMPT_VERSION, 'date': day.get('date'),
|
||||
'day': day, 'outfits': outfits, 'size': IMAGE_SIZE},
|
||||
ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _extract_images(history: dict) -> list[dict]:
|
||||
images = []
|
||||
for node_id, output in history.get('outputs', {}).items():
|
||||
for image in output.get('images', []):
|
||||
filename = image.get('filename')
|
||||
if not filename:
|
||||
continue
|
||||
subfolder = image.get('subfolder', '')
|
||||
image_type = image.get('type', 'output')
|
||||
query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type})
|
||||
images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)})
|
||||
return images
|
||||
|
||||
|
||||
NEGATIVE_PROMPT = (
|
||||
'more than one person, group photo, two people, extra person, background crowd, '
|
||||
'mirror reflection, human silhouette in background, duplicate heads, cloned body, '
|
||||
'deformed face, malformed face, mutated hands, extra limbs, extra fingers, '
|
||||
'disfigured features, asymmetrical face, photorealistic, horror, blurry, '
|
||||
'text, captions, logos, watermark'
|
||||
)
|
||||
|
||||
|
||||
def _prepare_workflow(workflow: dict, prompt: str) -> dict:
|
||||
"""Adapte le workflow ComfyUI utilisé par Hermes, sans toucher au fichier source."""
|
||||
node = workflow.get(PROMPT_NODE)
|
||||
if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict) or 'text' not in node['inputs']:
|
||||
raise ValueError(f'Nœud de prompt ComfyUI {PROMPT_NODE} introuvable ou incompatible.')
|
||||
node['inputs']['text'] = prompt
|
||||
for sampler in workflow.values():
|
||||
if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'):
|
||||
continue
|
||||
negative_link = (sampler.get('inputs') or {}).get('negative')
|
||||
if not isinstance(negative_link, (list, tuple)) or not negative_link:
|
||||
continue
|
||||
neg = workflow.get(str(negative_link[0]))
|
||||
if neg and neg.get('class_type') == 'CLIPTextEncode' and 'text' in neg.get('inputs', {}):
|
||||
neg['inputs']['text'] = NEGATIVE_PROMPT
|
||||
for latent in workflow.values():
|
||||
if latent.get('class_type') in ('EmptyLatentImage', 'EmptySD3LatentImage'):
|
||||
inputs = latent.get('inputs') or {}
|
||||
if 'width' in inputs and 'height' in inputs:
|
||||
inputs['width'] = PORTRAIT_WIDTH
|
||||
inputs['height'] = PORTRAIT_HEIGHT
|
||||
return workflow
|
||||
|
||||
|
||||
def _submit_workflow(prompt: str) -> str:
|
||||
workflow_path = WORKFLOWS_DIR / 'text_to_image.json'
|
||||
if not workflow_path.exists():
|
||||
raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}')
|
||||
workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt)
|
||||
response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()['prompt_id']
|
||||
|
||||
|
||||
def _wait_history(prompt_id: str) -> dict:
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if prompt_id in result:
|
||||
return result[prompt_id]
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f'ComfyUI timeout ({prompt_id})')
|
||||
|
||||
|
||||
def _single_character_prompt(kind: str, day: dict, outfits: dict) -> str:
|
||||
"""Un SEUL personnage par image, même style graphique pour les deux."""
|
||||
profile = outfits[kind]
|
||||
if kind == 'child':
|
||||
identity = (
|
||||
'ONE young GIRL, eight years old, clearly a primary-school child, '
|
||||
'small body proportions of a young child, friendly and natural face, '
|
||||
'casual appropriate haircut, no grown-up features. '
|
||||
)
|
||||
else:
|
||||
identity = (
|
||||
'ONE adult MAN, forty-two years old, unmistakably masculine adult, '
|
||||
'short neat hair, mature male face, optionally light stubble, '
|
||||
'adult male shoulders and body proportions, not a teenager. '
|
||||
)
|
||||
weather = str(day.get('description') or 'daily weather')
|
||||
return (
|
||||
'Single character fashion illustration for a family weather dashboard. '
|
||||
'Exactly ONE character, standing ALONE. '
|
||||
+ identity +
|
||||
'Full body visible from head to shoes, centrally positioned, front three-quarter view, '
|
||||
'generous empty space around body, relaxed neutral standing pose, smiling gently. '
|
||||
'Soft polished 2D digital illustration, smooth clean outlines, tasteful pastel colors, '
|
||||
'slight dimensional shading, consistent editorial children-book illustration style, '
|
||||
'simple light neutral plain studio background, no other people, no human reflections, '
|
||||
'no poster, no photographs, no text. '
|
||||
f'Weather for {day.get("date", "selected day")}: {weather}. '
|
||||
f'Clothing: {", ".join(profile["clothes"])}. '
|
||||
f'Accessories: {", ".join(profile["accessories"]) if profile["accessories"] else "none"}. '
|
||||
'Correct age and correct body proportions. Clothing must be clearly visible.'
|
||||
)
|
||||
|
||||
|
||||
def _render_one(kind: str, day: dict, outfits: dict):
|
||||
"""Génère et récupère un portrait individuel auprès du ComfyUI déjà installé."""
|
||||
prompt = _single_character_prompt(kind, day, outfits)
|
||||
prompt_id = _submit_workflow(prompt)
|
||||
result = _wait_history(prompt_id)
|
||||
images = _extract_images(result)
|
||||
if not images:
|
||||
raise RuntimeError(f'Aucune illustration ComfyUI générée pour {kind}.')
|
||||
result_image = requests.get(images[0]['url'], timeout=45)
|
||||
result_image.raise_for_status()
|
||||
if len(result_image.content) > 18_000_000:
|
||||
raise RuntimeError('Image ComfyUI trop volumineuse (limite 18 Mo).')
|
||||
from PIL import Image
|
||||
with Image.open(io.BytesIO(result_image.content)) as raw:
|
||||
raw.load()
|
||||
img = raw.convert('RGB')
|
||||
return img, prompt_id
|
||||
|
||||
|
||||
def _compose_duo(child_img, adult_img, dest: Path):
|
||||
"""Assemble exactement deux portraits dans UN SEUL carré, comme la maquette."""
|
||||
from PIL import Image, ImageOps, ImageDraw
|
||||
w = 700
|
||||
h = 700
|
||||
gap = 12
|
||||
tile_w = (w - gap) // 2
|
||||
tile_h = h
|
||||
canvas = Image.new('RGB', (w, h), (42, 50, 56))
|
||||
# Recadrage centré : aucune troisième image/personne n'est ajoutée au montage.
|
||||
left = ImageOps.fit(child_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5))
|
||||
right = ImageOps.fit(adult_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5))
|
||||
canvas.paste(left, (0, 0))
|
||||
canvas.paste(right, (tile_w + gap, 0))
|
||||
# Trait de séparation discret, les deux personnages forment une seule image.
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.rounded_rectangle((tile_w, 0, tile_w + gap - 1, h - 1), radius=2, fill=(42, 50, 56))
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = dest.with_suffix('.tmp.jpg')
|
||||
canvas.save(temp, format='JPEG', quality=88, optimize=True)
|
||||
temp.replace(dest)
|
||||
|
||||
|
||||
def _cache_url(file_path: Path) -> str:
|
||||
"""URL autonome sans nouvelle route Flask ; données gardées dans le cache Alfred."""
|
||||
return 'data:image/jpeg;base64,' + base64.b64encode(file_path.read_bytes()).decode('ascii')
|
||||
|
||||
|
||||
def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None:
|
||||
if not ENABLED:
|
||||
return None
|
||||
sig = _weather_signature(day, outfits)
|
||||
image_path = IMAGE_DIR / (sig + '.jpg')
|
||||
with ILLUSTRATION_LOCK:
|
||||
cache = _load_cache()
|
||||
entry = cache.get(sig)
|
||||
now_ts = time.time()
|
||||
if (entry and image_path.is_file() and not refresh
|
||||
and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600):
|
||||
return {
|
||||
'url': _cache_url(image_path),
|
||||
'cached_at': entry.get('cached_at'),
|
||||
'prompt_ids': entry.get('prompt_ids', []),
|
||||
'from_cache': True,
|
||||
}
|
||||
child, child_id = _render_one('child', day, outfits)
|
||||
adult, adult_id = _render_one('adult', day, outfits)
|
||||
_compose_duo(child, adult, image_path)
|
||||
entry = {
|
||||
'file': image_path.name,
|
||||
'prompt_ids': [child_id, adult_id],
|
||||
'cached_at': now_ts,
|
||||
}
|
||||
cache[sig] = entry
|
||||
_save_cache(cache)
|
||||
return {
|
||||
'url': _cache_url(image_path),
|
||||
'prompt_ids': entry['prompt_ids'],
|
||||
'cached_at': now_ts,
|
||||
'from_cache': False,
|
||||
}
|
||||
|
||||
|
||||
def build_payload(q: str = '', date: str = '', label: str = '', refresh: bool = False) -> dict:
|
||||
data = ha_weather.get_weather(q)
|
||||
if date:
|
||||
try:
|
||||
Date.fromisoformat(date)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError('Date invalide (AAAA-MM-JJ requis).') from exc
|
||||
target = date or data.get('focus_date', '')
|
||||
day = _pick_day(data, target)
|
||||
focus_label = label or data.get('focus_label', 'aujourd’hui')
|
||||
outfits = _recommend(day)
|
||||
illustration = None
|
||||
illustration_error = None
|
||||
try:
|
||||
illustration = _generate_illustration(day, outfits, focus_label, refresh=refresh)
|
||||
except (OSError, ValueError, RuntimeError, TimeoutError, ImportError, requests.RequestException) as exc:
|
||||
# La partie texte doit rester affichable si ComfyUI est indisponible.
|
||||
illustration_error = f'{type(exc).__name__} : {exc}'
|
||||
print(f'[ALFRED METEO] Illustration indisponible : {illustration_error}', flush=True)
|
||||
return {
|
||||
'type': 'weather_outfits',
|
||||
'focus_label': focus_label,
|
||||
'date': day.get('date'),
|
||||
'condition': day.get('condition'),
|
||||
'description': day.get('description'),
|
||||
'temperature': day.get('temperature'),
|
||||
'templow': day.get('templow'),
|
||||
'precipitation_probability': day.get('precipitation_probability'),
|
||||
'wind_speed': day.get('wind_speed'),
|
||||
'summary': outfits.get('summary'),
|
||||
'child': outfits['child'],
|
||||
'adult': outfits['adult'],
|
||||
'image': illustration,
|
||||
'illustration_error': illustration_error,
|
||||
}
|
||||
Reference in New Issue
Block a user