407 lines
11 KiB
Python
407 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import colorsys
|
|
import math
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from io import BytesIO
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import requests
|
|
from PIL import Image
|
|
|
|
HA_URL = "http://192.168.1.105:8123"
|
|
HA_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZjM1MmRhNjg1ZWM0MGJiYTc4YTZlNWY3ZmQ0YTA1YSIsImlhdCI6MTc3NjI4MTI0MiwiZXhwIjoyMDkxNjQxMjQyfQ.rR0Kfwk5Smr06_9KJSDn3YPXiRlokEZZFiAa_naNs78"
|
|
LIGHT_ENTITY = "light.luzsala_luz_sala"
|
|
|
|
# Ajustes visuais
|
|
BRIGHTNESS_PCT = 70
|
|
SATURATION_BOOST = 1.15
|
|
MIN_SATURATION = 0.20
|
|
MIN_VALUE = 0.18
|
|
REQUEST_TIMEOUT = 10
|
|
MAX_VALUE = 0.82
|
|
PALETTE_SIZE = 32
|
|
OKLCH_LIGHTNESS = 0.72
|
|
OKLCH_CHROMA = 0.24
|
|
MAX_BRIGHTNESS_STEP = 0.10
|
|
PLAYERCTL_BIN = os.environ.get("PLAYERCTL_BIN", "playerctl")
|
|
|
|
|
|
def run_playerctl(field: str, player: str | None = None) -> str:
|
|
command = [PLAYERCTL_BIN]
|
|
if player:
|
|
command.extend(["--player", player])
|
|
command.extend(["metadata", field])
|
|
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return ""
|
|
return result.stdout.strip()
|
|
|
|
|
|
def list_players() -> list[str]:
|
|
result = subprocess.run(
|
|
[PLAYERCTL_BIN, "-l"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def player_status(player: str) -> str:
|
|
result = subprocess.run(
|
|
[PLAYERCTL_BIN, "--player", player, "status"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return ""
|
|
return result.stdout.strip()
|
|
|
|
|
|
def choose_active_player() -> str:
|
|
for player in list_players():
|
|
if player_status(player) == "Playing":
|
|
return player
|
|
return ""
|
|
|
|
|
|
def get_track_url(player: str) -> str:
|
|
return run_playerctl("xesam:url", player)
|
|
|
|
|
|
def get_player_name(player: str) -> str:
|
|
result = subprocess.run(
|
|
[PLAYERCTL_BIN, "--player", player, "metadata", "--format", "{{playerName}}"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return player
|
|
return result.stdout.strip() or player
|
|
|
|
|
|
def youtube_video_id(url: str) -> str:
|
|
if not url:
|
|
return ""
|
|
|
|
parsed = urlparse(url)
|
|
host = parsed.netloc.lower()
|
|
path = parsed.path.strip("/")
|
|
|
|
if host in {"youtu.be", "www.youtu.be"}:
|
|
return path.split("/", 1)[0]
|
|
|
|
if "youtube.com" not in host:
|
|
return ""
|
|
|
|
if path == "watch":
|
|
return parse_qs(parsed.query).get("v", [""])[0]
|
|
|
|
parts = path.split("/")
|
|
if len(parts) >= 2 and parts[0] in {"shorts", "live", "embed", "v"}:
|
|
return parts[1]
|
|
|
|
return ""
|
|
|
|
|
|
def youtube_thumbnail_url(player: str) -> str:
|
|
video_id = youtube_video_id(get_track_url(player))
|
|
if not video_id:
|
|
return ""
|
|
return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
|
|
|
|
|
|
def get_current_track_id(player: str) -> str:
|
|
title = run_playerctl("title", player)
|
|
artist = run_playerctl("artist", player)
|
|
art = run_playerctl("mpris:artUrl", player)
|
|
url = get_track_url(player)
|
|
player_name = get_player_name(player)
|
|
return f"{player_name}::{artist}::{title}::{art or url}"
|
|
|
|
|
|
def get_album_art_url(player: str) -> str:
|
|
art_url = run_playerctl("mpris:artUrl", player)
|
|
if art_url:
|
|
return art_url
|
|
|
|
track_url = get_track_url(player)
|
|
if youtube_video_id(track_url):
|
|
thumb_url = youtube_thumbnail_url(player)
|
|
if thumb_url:
|
|
print(f"Usando thumbnail do YouTube: {thumb_url}")
|
|
return thumb_url
|
|
|
|
return ""
|
|
|
|
|
|
def load_image_from_url(url: str) -> Image.Image:
|
|
if url.startswith("file://"):
|
|
local_path = url[7:]
|
|
return Image.open(local_path).convert("RGB")
|
|
|
|
resp = requests.get(url, timeout=REQUEST_TIMEOUT)
|
|
resp.raise_for_status()
|
|
return Image.open(BytesIO(resp.content)).convert("RGB")
|
|
|
|
|
|
def image_pixels(img: Image.Image) -> list[tuple[int, int, int]]:
|
|
if hasattr(img, "get_flattened_data"):
|
|
return list(img.get_flattened_data())
|
|
return list(img.getdata())
|
|
|
|
|
|
def hue_distance(a: float, b: float) -> float:
|
|
diff = abs(a - b)
|
|
return min(diff, 1.0 - diff)
|
|
|
|
|
|
def rgb_to_hsv(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
|
|
r, g, b = rgb
|
|
return colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
|
|
|
|
|
|
def hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
|
|
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
|
return (int(r * 255), int(g * 255), int(b * 255))
|
|
|
|
|
|
def srgb_channel_to_linear(channel: float) -> float:
|
|
if channel <= 0.04045:
|
|
return channel / 12.92
|
|
return ((channel + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def linear_channel_to_srgb(channel: float) -> float:
|
|
if channel <= 0.0031308:
|
|
return 12.92 * channel
|
|
return 1.055 * (channel ** (1 / 2.4)) - 0.055
|
|
|
|
|
|
def rgb_to_oklab(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
|
|
r, g, b = rgb
|
|
rl = srgb_channel_to_linear(r / 255.0)
|
|
gl = srgb_channel_to_linear(g / 255.0)
|
|
bl = srgb_channel_to_linear(b / 255.0)
|
|
|
|
l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl
|
|
m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl
|
|
s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl
|
|
|
|
l_ = l ** (1 / 3)
|
|
m_ = m ** (1 / 3)
|
|
s_ = s ** (1 / 3)
|
|
|
|
return (
|
|
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
|
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
|
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
|
)
|
|
|
|
|
|
def oklab_to_rgb(lightness: float, a: float, b: float) -> tuple[int, int, int]:
|
|
l_ = lightness + 0.3963377774 * a + 0.2158037573 * b
|
|
m_ = lightness - 0.1055613458 * a - 0.0638541728 * b
|
|
s_ = lightness - 0.0894841775 * a - 1.2914855480 * b
|
|
|
|
l = l_ ** 3
|
|
m = m_ ** 3
|
|
s = s_ ** 3
|
|
|
|
rl = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
|
|
gl = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
|
|
bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
|
|
|
|
r = min(1.0, max(0.0, linear_channel_to_srgb(rl)))
|
|
g = min(1.0, max(0.0, linear_channel_to_srgb(gl)))
|
|
b = min(1.0, max(0.0, linear_channel_to_srgb(bl)))
|
|
return (int(r * 255), int(g * 255), int(b * 255))
|
|
|
|
|
|
def rgb_to_oklch(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
|
|
lightness, a, b = rgb_to_oklab(rgb)
|
|
chroma = (a * a + b * b) ** 0.5
|
|
hue = (0.0 if chroma == 0 else (math.atan2(b, a) / (2 * math.pi)) % 1.0)
|
|
return (lightness, chroma, hue)
|
|
|
|
|
|
def oklch_to_rgb(lightness: float, chroma: float, hue: float) -> tuple[int, int, int]:
|
|
angle = 2 * math.pi * hue
|
|
a = chroma * math.cos(angle)
|
|
b = chroma * math.sin(angle)
|
|
return oklab_to_rgb(lightness, a, b)
|
|
|
|
|
|
def pure_tone_palette() -> list[tuple[int, int, int]]:
|
|
return [
|
|
oklch_to_rgb(OKLCH_LIGHTNESS, OKLCH_CHROMA, index / PALETTE_SIZE)
|
|
for index in range(PALETTE_SIZE)
|
|
]
|
|
|
|
|
|
PURE_TONE_PALETTE = pure_tone_palette()
|
|
|
|
|
|
def clamp_brightness(rgb: tuple[int, int, int], previous_rgb: tuple[int, int, int] | None) -> tuple[int, int, int]:
|
|
if previous_rgb is None:
|
|
return rgb
|
|
|
|
h, s, v = rgb_to_hsv(rgb)
|
|
_, _, prev_v = rgb_to_hsv(previous_rgb)
|
|
|
|
limited_v = max(prev_v - MAX_BRIGHTNESS_STEP, min(prev_v + MAX_BRIGHTNESS_STEP, v))
|
|
return hsv_to_rgb(h, s, limited_v)
|
|
|
|
|
|
def snap_to_palette(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
|
|
_, _, hue = rgb_to_oklch(rgb)
|
|
|
|
best = min(
|
|
PURE_TONE_PALETTE,
|
|
key=lambda candidate: hue_distance(hue, rgb_to_oklch(candidate)[2]),
|
|
)
|
|
|
|
best_lightness, _, best_hue = rgb_to_oklch(best)
|
|
return oklch_to_rgb(best_lightness, OKLCH_CHROMA, best_hue)
|
|
|
|
|
|
def good_pixels(img: Image.Image) -> list[tuple[int, int, int]]:
|
|
img = img.resize((80, 80))
|
|
pixels = image_pixels(img)
|
|
|
|
filtered: list[tuple[int, int, int]] = []
|
|
for r, g, b in pixels:
|
|
rf, gf, bf = r / 255.0, g / 255.0, b / 255.0
|
|
h, s, v = colorsys.rgb_to_hsv(rf, gf, bf)
|
|
|
|
# ignora pixels muito escuros e quase cinza
|
|
if v < MIN_VALUE:
|
|
continue
|
|
if s < MIN_SATURATION:
|
|
continue
|
|
if v > MAX_VALUE and s < 0.45:
|
|
continue
|
|
|
|
filtered.append((r, g, b))
|
|
|
|
return filtered
|
|
|
|
|
|
def dominant_color(img: Image.Image) -> tuple[int, int, int]:
|
|
pixels = good_pixels(img)
|
|
if not pixels:
|
|
# fallback simples
|
|
small = img.resize((50, 50))
|
|
colors = small.getcolors(50 * 50)
|
|
if not colors:
|
|
return (255, 255, 255)
|
|
colors.sort(reverse=True)
|
|
return colors[0][1]
|
|
|
|
# Quantiza para agrupar tons parecidos
|
|
buckets: dict[tuple[int, int, int], int] = {}
|
|
for r, g, b in pixels:
|
|
key = (r // 16 * 16, g // 16 * 16, b // 16 * 16)
|
|
buckets[key] = buckets.get(key, 0) + 1
|
|
|
|
best = max(
|
|
buckets.items(),
|
|
key=lambda item: item[1] * bucket_weight(item[0]),
|
|
)[0]
|
|
return boost_color(best)
|
|
|
|
|
|
def bucket_weight(rgb: tuple[int, int, int]) -> float:
|
|
_, saturation, value = rgb_to_hsv(rgb)
|
|
|
|
saturation_score = 0.6 + saturation
|
|
brightness_midpoint = 0.62
|
|
brightness_penalty = abs(value - brightness_midpoint) * 1.2
|
|
return max(0.2, saturation_score - brightness_penalty)
|
|
|
|
|
|
def boost_color(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
|
|
h, s, v = rgb_to_hsv(rgb)
|
|
|
|
s = min(1.0, s * SATURATION_BOOST)
|
|
v = min(0.74, max(v, 0.48))
|
|
|
|
return hsv_to_rgb(h, s, v)
|
|
|
|
|
|
def set_light_color(rgb: tuple[int, int, int]) -> None:
|
|
response = requests.post(
|
|
f"{HA_URL}/api/services/light/turn_on",
|
|
headers={
|
|
"Authorization": f"Bearer {HA_TOKEN}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"entity_id": LIGHT_ENTITY,
|
|
"rgb_color": list(rgb),
|
|
"brightness_pct": BRIGHTNESS_PCT,
|
|
},
|
|
timeout=REQUEST_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
|
|
|
|
def main() -> None:
|
|
print("Monitorando música atual. Ctrl+C para sair.")
|
|
last_track = ""
|
|
last_rgb: tuple[int, int, int] | None = None
|
|
|
|
while True:
|
|
try:
|
|
player = choose_active_player()
|
|
if not player:
|
|
time.sleep(2)
|
|
continue
|
|
|
|
track_id = get_current_track_id(player)
|
|
|
|
if not track_id or track_id == "::":
|
|
time.sleep(2)
|
|
continue
|
|
|
|
if track_id != last_track:
|
|
art_url = get_album_art_url(player)
|
|
if not art_url:
|
|
print("Sem capa disponível para a faixa atual.")
|
|
last_track = track_id
|
|
time.sleep(2)
|
|
continue
|
|
|
|
print(f"Nova faixa detectada: {track_id}")
|
|
img = load_image_from_url(art_url)
|
|
raw_rgb = dominant_color(img)
|
|
palette_rgb = snap_to_palette(raw_rgb)
|
|
rgb = clamp_brightness(palette_rgb, last_rgb)
|
|
print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}")
|
|
print(f"Aplicando cor: {rgb}")
|
|
set_light_color(rgb)
|
|
last_track = track_id
|
|
last_rgb = rgb
|
|
|
|
time.sleep(2)
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nEncerrado.")
|
|
break
|
|
except Exception as exc:
|
|
print(f"Erro: {exc}")
|
|
time.sleep(3)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|