V1
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
"""Outfit recommendations and ComfyUI illustration for Alfred weather view.
|
||||
|
||||
V4 improvements:
|
||||
- refresh really regenerates a new image (new random seeds, cache bypass);
|
||||
- best-of generation with lightweight vision review;
|
||||
- more reliable accessory rendering by limiting visible accessories in the image;
|
||||
- still generates the child and adult separately, then assembles them in one image.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import random
|
||||
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-v4-bestof-20261025'
|
||||
MAX_ATTEMPTS_PER_CHARACTER = max(1, min(3, int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS', '2'))))
|
||||
QUALITY_TARGET = float(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE', '8.6'))
|
||||
REVIEW_ENABLED = os.getenv('ALFRED_WEATHER_REVIEW', 'true').lower() in ('1','true','yes','on')
|
||||
|
||||
# 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
|
||||
DISPLAY_SIZE = int(os.getenv('ALFRED_WEATHER_DISPLAY_SIZE', '350'))
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
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, oversized accessory, floating accessory'
|
||||
)
|
||||
|
||||
|
||||
def _random_seed() -> int:
|
||||
return random.randint(1, 2**31 - 1)
|
||||
|
||||
|
||||
def _prepare_workflow(workflow: dict, prompt: str, force_new_seed: bool = False) -> dict:
|
||||
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 item in workflow.values():
|
||||
inputs = item.get('inputs') or {}
|
||||
if item.get('class_type') == 'CLIPTextEncode' and 'text' in inputs:
|
||||
# negative prompt link target handled below when referenced by sampler.
|
||||
pass
|
||||
if force_new_seed:
|
||||
for key in ('seed', 'noise_seed'):
|
||||
if key in inputs and isinstance(inputs[key], (int, float)):
|
||||
inputs[key] = _random_seed()
|
||||
if item.get('class_type') in ('RandomNoise', 'Noise') and force_new_seed:
|
||||
for key in ('noise_seed', 'seed'):
|
||||
if key in inputs and isinstance(inputs[key], (int, float)):
|
||||
inputs[key] = _random_seed()
|
||||
|
||||
for sampler in workflow.values():
|
||||
if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'):
|
||||
continue
|
||||
inputs = sampler.get('inputs') or {}
|
||||
if force_new_seed:
|
||||
for key in ('seed', 'noise_seed'):
|
||||
if key in inputs and isinstance(inputs[key], (int, float)):
|
||||
inputs[key] = _random_seed()
|
||||
negative_link = inputs.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, force_new_seed: bool = False) -> 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, force_new_seed=force_new_seed)
|
||||
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 _primary_accessories(kind: str, outfits: dict) -> list[str]:
|
||||
accessories = list(outfits[kind]['accessories'])
|
||||
ordered = []
|
||||
priorities = ['parapluie', 'imperméable', 'bonnet', 'écharpe', 'coupe-vent', 'casquette', 'lunettes de soleil']
|
||||
for wanted in priorities:
|
||||
if wanted in accessories and wanted not in ordered:
|
||||
ordered.append(wanted)
|
||||
for acc in accessories:
|
||||
if acc not in ordered:
|
||||
ordered.append(acc)
|
||||
return ordered[:2]
|
||||
|
||||
|
||||
def _single_character_prompt(kind: str, day: dict, outfits: dict) -> str:
|
||||
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')
|
||||
primary_acc = _primary_accessories(kind, outfits)
|
||||
image_acc = ', '.join(primary_acc) if primary_acc else 'none'
|
||||
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'Visible accessories to show clearly: {image_acc}. '
|
||||
'Show at most two accessories, and only if they are natural to wear or hold. '
|
||||
'Correct age and correct body proportions. Clothing must be clearly visible. '
|
||||
'High priority: one person only, clean natural face, no deformations.'
|
||||
)
|
||||
|
||||
|
||||
def _render_image_from_url(url: str):
|
||||
from PIL import Image
|
||||
response = requests.get(url, timeout=45)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > 18_000_000:
|
||||
raise RuntimeError('Image ComfyUI trop volumineuse (limite 18 Mo).')
|
||||
with Image.open(io.BytesIO(response.content)) as raw:
|
||||
raw.load()
|
||||
return raw.convert('RGB')
|
||||
|
||||
|
||||
def _vision_review(kind: str, prompt_summary: str, image_url: str) -> dict:
|
||||
"""Lightweight review using the existing ComfyUI vision workflow when available."""
|
||||
default = {'score': 7.0, 'ok': True, 'issues': []}
|
||||
if not REVIEW_ENABLED:
|
||||
return default
|
||||
workflow_path = WORKFLOWS_DIR / 'vision_analyze.json'
|
||||
if not workflow_path.exists():
|
||||
return default
|
||||
image_node = os.getenv('COMFY_VISION_IMAGE_NODE', '1')
|
||||
prompt_node = os.getenv('COMFY_VISION_PROMPT_NODE', '2')
|
||||
try:
|
||||
workflow = json.loads(workflow_path.read_text(encoding='utf-8'))
|
||||
workflow[str(image_node)]['inputs']['image'] = image_url
|
||||
review_prompt = (
|
||||
'Review a fashion illustration candidate for a weather dashboard. '
|
||||
f'Expected subject: {prompt_summary}. '
|
||||
'Answer ONLY with compact JSON object with keys: '
|
||||
'people_count (int), gender_age_ok (bool), face_ok (bool), accessories_ok (bool), '
|
||||
'deformation (bool), score (number 0-10), issues (array of strings).'
|
||||
)
|
||||
workflow[str(prompt_node)]['inputs']['text'] = review_prompt
|
||||
response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30)
|
||||
response.raise_for_status()
|
||||
prompt_id = response.json()['prompt_id']
|
||||
history = _wait_history(prompt_id)
|
||||
raw_text = None
|
||||
for _, output in history.get('outputs', {}).items():
|
||||
for key in ('text', 'result', 'string'):
|
||||
if key in output:
|
||||
raw_text = output[key]
|
||||
break
|
||||
if 'texts' in output and output['texts']:
|
||||
raw_text = output['texts'][0]
|
||||
break
|
||||
if isinstance(raw_text, list):
|
||||
raw_text = raw_text[0] if raw_text else ''
|
||||
if isinstance(raw_text, dict):
|
||||
parsed = raw_text
|
||||
else:
|
||||
text = str(raw_text or '').strip()
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end+1]
|
||||
parsed = json.loads(text)
|
||||
score = float(parsed.get('score', 0) or 0)
|
||||
people_count = int(parsed.get('people_count', 1) or 1)
|
||||
gender_age_ok = bool(parsed.get('gender_age_ok', True))
|
||||
face_ok = bool(parsed.get('face_ok', True))
|
||||
accessories_ok = bool(parsed.get('accessories_ok', True))
|
||||
deformation = bool(parsed.get('deformation', False))
|
||||
issues = list(parsed.get('issues', []))
|
||||
ok = (people_count == 1 and gender_age_ok and face_ok and not deformation)
|
||||
return {'score': score, 'ok': ok, 'issues': issues, 'people_count': people_count,
|
||||
'gender_age_ok': gender_age_ok, 'face_ok': face_ok, 'accessories_ok': accessories_ok,
|
||||
'deformation': deformation}
|
||||
except Exception as exc:
|
||||
return {'score': 7.0, 'ok': True, 'issues': [f'review_fallback:{exc}']}
|
||||
|
||||
|
||||
def _render_best_one(kind: str, day: dict, outfits: dict, refresh: bool = False):
|
||||
prompt = _single_character_prompt(kind, day, outfits)
|
||||
summary = f'{kind} with clothes {", ".join(outfits[kind]["clothes"])} and accessories {", ".join(_primary_accessories(kind, outfits)) or "none"}'
|
||||
best = None
|
||||
best_review = None
|
||||
for attempt in range(1, MAX_ATTEMPTS_PER_CHARACTER + 1):
|
||||
prompt_id = _submit_workflow(prompt, force_new_seed=True if (refresh or attempt > 1) else True)
|
||||
result = _wait_history(prompt_id)
|
||||
images = _extract_images(result)
|
||||
if not images:
|
||||
raise RuntimeError(f'Aucune illustration ComfyUI générée pour {kind}.')
|
||||
url = images[0]['url']
|
||||
image = _render_image_from_url(url)
|
||||
review = _vision_review(kind, summary, url)
|
||||
candidate = {'image': image, 'prompt_id': prompt_id, 'review': review}
|
||||
if best is None or review.get('score', 0) > best_review.get('score', 0):
|
||||
best = candidate
|
||||
best_review = review
|
||||
if review.get('ok') and review.get('score', 0) >= QUALITY_TARGET:
|
||||
break
|
||||
return best['image'], best['prompt_id'], best_review
|
||||
|
||||
|
||||
def _compose_duo(child_img, adult_img, dest: Path):
|
||||
from PIL import Image, ImageOps, ImageDraw
|
||||
w = DISPLAY_SIZE * 2 + 12
|
||||
h = DISPLAY_SIZE
|
||||
gap = 12
|
||||
tile_w = DISPLAY_SIZE
|
||||
tile_h = DISPLAY_SIZE
|
||||
canvas = Image.new('RGB', (w, h), (42, 50, 56))
|
||||
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))
|
||||
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=90, optimize=True)
|
||||
temp.replace(dest)
|
||||
|
||||
|
||||
def _cache_url(file_path: Path) -> str:
|
||||
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', []),
|
||||
'reviews': entry.get('reviews', {}),
|
||||
'from_cache': True,
|
||||
}
|
||||
child, child_id, child_review = _render_best_one('child', day, outfits, refresh=refresh)
|
||||
adult, adult_id, adult_review = _render_best_one('adult', day, outfits, refresh=refresh)
|
||||
_compose_duo(child, adult, image_path)
|
||||
entry = {
|
||||
'file': image_path.name,
|
||||
'prompt_ids': [child_id, adult_id],
|
||||
'reviews': {'child': child_review, 'adult': adult_review},
|
||||
'cached_at': now_ts,
|
||||
'focus_label': focus_label,
|
||||
}
|
||||
cache[sig] = entry
|
||||
_save_cache(cache)
|
||||
return {
|
||||
'url': _cache_url(image_path),
|
||||
'prompt_ids': entry['prompt_ids'],
|
||||
'reviews': entry['reviews'],
|
||||
'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:
|
||||
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