Files
site/WESP_REL/app/services/plymouth_frame_renderer.py
T
2026-07-17 12:57:18 +03:00

456 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Рендер кадров анимации WESP (как wesp-logo-loader.js) для темы Plymouth 800×600.
Требует Pillow (на Pi: apt install python3-pil).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
try:
from PIL import Image, ImageDraw
except ImportError: # pragma: no cover - tested via mock in unit tests
Image = None # type: ignore[misc, assignment]
ImageDraw = None # type: ignore[misc, assignment]
LOGO_SRC_WIDTH = 2222
LOGO_SRC_HEIGHT = 1024
INITIAL_CELL = 37
DURATION_MS_DEFAULT = 6000
WAVE2_START_MS = 0.36 * 4500
WAVE2_SPAN_MS = 0.34 * 6800
DURATION_BASE = 4750.0
WAVE2_START = WAVE2_START_MS / DURATION_BASE
WAVE2_SPAN = WAVE2_SPAN_MS / DURATION_BASE
WAVE2_END = (WAVE2_START_MS + WAVE2_SPAN_MS) / DURATION_BASE
HOLD_END = 0.94
PIXEL_SCALE = WAVE2_START / 0.36
WAVE0_START = 0.04 * PIXEL_SCALE
WAVE0_SPAN = 0.20 * PIXEL_SCALE
WAVE1_START = WAVE0_START + WAVE0_SPAN - 0.04 * PIXEL_SCALE
WAVE1_SPAN = 0.20 * PIXEL_SCALE
PIXEL_FADE = 0.012
PIXEL_GAP = 1
ASSEMBLY_FEATHER = 10
COLOR_CROSSFADE = 0.024
BRAND_WHITE = (238, 240, 243)
PIXEL_GRAY = (148, 152, 158) # wesp-logo-loader.js PIXEL_GRAY
# Тёмная тема: ровный фон как --setup-page-bg (без точек и градиента веб-лоадера)
BG_RGB = (15, 17, 20) # #0f1114
# Меньше, чем embedded 0.88 в веб-лоадере
LOGO_MAX_WIDTH_RATIO = 0.58
LOGO_MAX_HEIGHT_RATIO = 0.30
PLYMOUTH_WIDTH = 800
PLYMOUTH_HEIGHT = 600
PLYMOUTH_FPS = 25
def pillow_available() -> bool:
return Image is not None
def animation_end_ms(duration_ms: int = DURATION_MS_DEFAULT) -> int:
# Обрезаем на сборке логотипа (wave2), без shimmer и без хвоста до duration_ms
return int(WAVE2_END * duration_ms) + 60
def frame_count(duration_ms: int = DURATION_MS_DEFAULT, fps: int = PLYMOUTH_FPS) -> int:
end_ms = animation_end_ms(duration_ms)
return max(1, int(math.ceil(end_ms * fps / 1000.0)))
def wave2_end_ms(duration_ms: int) -> int:
return int(WAVE2_END * duration_ms)
@dataclass
class LogoLayout:
scale: float
draw_width: float
draw_height: float
offset_x: float
offset_y: float
@dataclass
class LogoCell:
col: int
row: int
wave0_at: float
wave1_at: float
color: Tuple[int, int, int]
gray: Tuple[int, int, int]
def _hash_cell(col: int, row: int) -> int:
return ((col * 92837111) ^ (row * 689287499)) & 0xFFFFFFFF
def _ease_in_out_quad(t: float) -> float:
if t < 0.5:
return 2 * t * t
return 1 - ((-2 * t + 2) ** 2) / 2
def _lerp_color(
c1: Sequence[float], c2: Sequence[float], t: float
) -> Tuple[int, int, int]:
return (
int(c1[0] + (c2[0] - c1[0]) * t),
int(c1[1] + (c2[1] - c1[1]) * t),
int(c1[2] + (c2[2] - c1[2]) * t),
)
def _desaturate(r: int, g: int, b: int, amount: float = 0.72) -> Tuple[int, int, int]:
gray = (r + g + b) / 3.0
return (
int(r + (gray - r) * amount),
int(g + (gray - g) * amount),
int(b + (gray - b) * amount),
)
def _is_logo_ink(r: int, g: int, b: int, a: int) -> bool:
if a < 20:
return False
if r + g + b < 80:
return False
return b > 70 and b >= r and b >= g * 0.85
def _is_logo_blue_fringe(r: int, g: int, b: int, a: int) -> bool:
if a < 1:
return False
if _is_logo_ink(r, g, b, a):
return True
return b > r + 5 and b > g - 2 and b > 68 and r + g + b < 700
def _knockout_background(img: Image.Image) -> Image.Image:
rgba = img.convert("RGBA")
px = rgba.load()
w, h = rgba.size
for y in range(h):
for x in range(w):
r, g, b, a = px[x, y]
if a < 1:
continue
if _is_logo_blue_fringe(r, g, b, a):
continue
px[x, y] = (r, g, b, 0)
return rgba
def _load_logo_buffer(logo_path: Path) -> Image.Image:
if Image is None:
raise RuntimeError("Pillow не установлен (python3-pil / pip install Pillow)")
src = Image.open(logo_path).convert("RGBA")
canvas = Image.new("RGBA", (LOGO_SRC_WIDTH, LOGO_SRC_HEIGHT), (0, 0, 0, 0))
scale = min(LOGO_SRC_WIDTH / src.width, LOGO_SRC_HEIGHT / src.height)
draw_w = int(src.width * scale)
draw_h = int(src.height * scale)
offset_x = (LOGO_SRC_WIDTH - draw_w) // 2
offset_y = (LOGO_SRC_HEIGHT - draw_h) // 2
resized = src.resize((draw_w, draw_h), Image.Resampling.LANCZOS)
canvas.paste(resized, (offset_x, offset_y), resized)
return _knockout_background(canvas)
def _recolor_visible_pixels(img: Image.Image, rgb: Tuple[int, int, int]) -> Image.Image:
rgba = img.convert("RGBA")
px = rgba.load()
w, h = rgba.size
for y in range(h):
for x in range(w):
r, g, b, a = px[x, y]
if a < 1:
continue
px[x, y] = (rgb[0], rgb[1], rgb[2], a)
return rgba
def _compute_logo_layout(
viewport_w: int, viewport_h: int, embedded: bool = True
) -> LogoLayout:
max_w = viewport_w * LOGO_MAX_WIDTH_RATIO
max_h = viewport_h * LOGO_MAX_HEIGHT_RATIO
if not embedded:
max_w = viewport_w * 0.88
max_h = viewport_h * 0.34
scale = min(max_w / LOGO_SRC_WIDTH, max_h / LOGO_SRC_HEIGHT)
draw_w = LOGO_SRC_WIDTH * scale
draw_h = LOGO_SRC_HEIGHT * scale
offset_x = (viewport_w - draw_w) / 2
if embedded:
offset_y = (viewport_h - draw_h) / 2
else:
offset_y = viewport_h * 0.44 - draw_h / 2
return LogoLayout(scale, draw_w, draw_h, offset_x, offset_y)
def _build_logo_cells(
logo: Image.Image,
cell: int,
accent: Optional[Tuple[int, int, int]] = None,
) -> List[LogoCell]:
rgba = logo.convert("RGBA")
px = rgba.load()
cols = math.ceil(LOGO_SRC_WIDTH / cell)
rows = math.ceil(LOGO_SRC_HEIGHT / cell)
col_denom = max(1, cols - 1)
cells: List[LogoCell] = []
for row in range(rows):
for col in range(cols):
cx = min(LOGO_SRC_WIDTH - 1, col * cell + cell // 2)
cy = min(LOGO_SRC_HEIGHT - 1, row * cell + cell // 2)
r, g, b, a = px[cx, cy]
if not _is_logo_ink(r, g, b, a):
continue
noise = (_hash_cell(col, row) % 1000) / 1000.0
sweep = max(0.0, min(1.0, col / col_denom + (noise - 0.5) * 0.028))
if accent is not None:
pixel_color = accent
pixel_gray = PIXEL_GRAY
else:
pixel_color = (r, g, b)
pixel_gray = _desaturate(r, g, b)
cells.append(
LogoCell(
col=col,
row=row,
wave0_at=WAVE0_START + sweep * WAVE0_SPAN,
wave1_at=WAVE1_START + sweep * WAVE1_SPAN,
color=pixel_color,
gray=pixel_gray,
)
)
return cells
def _cell_rect(
layout: LogoLayout, cell: int, col: int, row: int
) -> Tuple[int, int, int, int]:
cols = math.ceil(LOGO_SRC_WIDTH / cell)
rows = math.ceil(LOGO_SRC_HEIGHT / cell)
cell_w = layout.draw_width / cols
cell_h = layout.draw_height / rows
x0 = layout.offset_x + col * cell_w
y0 = layout.offset_y + row * cell_h
x1 = layout.offset_x + (col + 1) * cell_w
y1 = layout.offset_y + (row + 1) * cell_h
return (
round(x0),
round(y0),
max(1, round(x1) - round(x0)),
max(1, round(y1) - round(y0)),
)
def _pixel_appear(t: float, start_at: float) -> float:
appear = min(1.0, (t - start_at) / PIXEL_FADE)
return 1.0 if appear >= 0.7 else appear
def _resolve_pixel_color(item: LogoCell, t: float) -> Tuple[int, int, int]:
if t < item.wave1_at:
return item.gray
blend = min(1.0, (t - item.wave1_at) / COLOR_CROSSFADE)
if blend >= 1.0:
return item.color
return _lerp_color(item.gray, item.color, blend)
def _resolve_pixel_alpha(item: LogoCell, t: float) -> float:
if t < item.wave0_at:
return 0.0
alpha = _pixel_appear(t, item.wave0_at)
if t >= item.wave1_at:
alpha = max(alpha, _pixel_appear(t, item.wave1_at))
return alpha
def _feather_pixel_alpha(alpha: float, cell_mid: float, front_x: float, feather: float) -> float:
if cell_mid <= front_x or cell_mid >= front_x + feather:
return alpha
return alpha * ((cell_mid - front_x) / feather)
def _wave2_front_x(t: float, layout: LogoLayout) -> float:
if t < WAVE2_START:
return layout.offset_x
progress = _ease_in_out_quad(min(1.0, (t - WAVE2_START) / WAVE2_SPAN))
return layout.offset_x + layout.draw_width * progress
_bg_cache: Dict[Tuple[int, int], Image.Image] = {}
def _background_base(width: int, height: int) -> Image.Image:
key = (width, height)
cached = _bg_cache.get(key)
if cached is not None:
return cached
base = Image.new("RGB", (width, height), BG_RGB)
_bg_cache[key] = base
return base
def _draw_background(frame: Image.Image, width: int, height: int) -> None:
"""Ровный тёмный фон без точек (в веб darkTheme фон даёт CSS, не canvas)."""
frame.paste(_background_base(width, height))
def _draw_sharp_logo_left_of(
frame: Image.Image,
logo: Image.Image,
layout: LogoLayout,
right_x: float,
) -> None:
"""Чёткий логотип слева от границы wave2 (без градиентной «палки»)."""
left = int(layout.offset_x)
top = int(layout.offset_y)
width = int(layout.draw_width)
height = int(layout.draw_height)
clip_w = max(0, min(width, int(round(right_x - left))))
if clip_w <= 0:
return
logo_r = logo.resize((width, height), Image.Resampling.LANCZOS)
part = logo_r.crop((0, 0, clip_w, height))
frame.paste(part, (left, top), part)
def _paste_logo_scaled(
frame: Image.Image, logo: Image.Image, layout: LogoLayout, alpha: float = 1.0
) -> None:
if alpha <= 0:
return
target = logo.resize(
(max(1, int(layout.draw_width)), max(1, int(layout.draw_height))),
Image.Resampling.LANCZOS,
)
if alpha >= 0.999:
frame.paste(target, (int(layout.offset_x), int(layout.offset_y)), target)
return
layer = target.copy()
a = layer.split()[3]
a = a.point(lambda p: int(p * alpha))
layer.putalpha(a)
frame.paste(layer, (int(layout.offset_x), int(layout.offset_y)), layer)
def _draw_waves(
frame: Image.Image,
draw: ImageDraw.ImageDraw,
layout: LogoLayout,
cell: int,
cells: List[LogoCell],
t: float,
logo: Image.Image,
) -> None:
front_x = _wave2_front_x(t, layout)
sharp_right = layout.offset_x + layout.draw_width
in_wave2 = t >= WAVE2_START
if in_wave2 and front_x >= sharp_right - 1:
_paste_logo_scaled(frame, logo, layout, 1.0)
return
if in_wave2 and front_x > layout.offset_x + 1:
_draw_sharp_logo_left_of(frame, logo, layout, front_x)
for item in cells:
if t < item.wave0_at:
continue
rect = _cell_rect(layout, cell, item.col, item.row)
cell_mid = rect[0] + rect[2] * 0.5
if in_wave2 and cell_mid < front_x - ASSEMBLY_FEATHER:
continue
alpha = _resolve_pixel_alpha(item, t)
if in_wave2:
alpha = _feather_pixel_alpha(alpha, cell_mid, front_x, ASSEMBLY_FEATHER)
if alpha <= 0:
continue
color = _resolve_pixel_color(item, t)
gap = min(PIXEL_GAP, max(0, rect[2] - 1), max(0, rect[3] - 1))
inset = gap * 0.5
x = int(rect[0] + inset)
y = int(rect[1] + inset)
w = max(1, int(rect[2] - gap))
h = max(1, int(rect[3] - gap))
draw.rectangle(
(x, y, x + w - 1, y + h - 1),
fill=(*color, int(255 * alpha)),
)
def render_frame(
logo: Image.Image,
cells: List[LogoCell],
layout: LogoLayout,
elapsed_ms: int,
duration_ms: int = DURATION_MS_DEFAULT,
width: int = PLYMOUTH_WIDTH,
height: int = PLYMOUTH_HEIGHT,
cell: int = INITIAL_CELL,
) -> Image.Image:
if Image is None:
raise RuntimeError("Pillow не установлен")
frame = Image.new("RGBA", (width, height), (*BG_RGB, 255))
draw = ImageDraw.Draw(frame, "RGBA")
t = min(1.0, elapsed_ms / duration_ms)
_draw_background(frame, width, height)
if t >= WAVE2_END:
_paste_logo_scaled(frame, logo, layout, 1.0)
else:
_draw_waves(frame, draw, layout, cell, cells, t, logo)
return frame.convert("RGB")
def render_all_frames(
logo_path: Path,
out_dir: Path,
*,
width: int = PLYMOUTH_WIDTH,
height: int = PLYMOUTH_HEIGHT,
duration_ms: int = DURATION_MS_DEFAULT,
fps: int = PLYMOUTH_FPS,
progress_callback: Optional[Any] = None,
) -> Dict[str, Any]:
"""Пишет animation/0.png … в out_dir. Возвращает сводку."""
out_dir = out_dir.resolve()
anim_dir = out_dir / "animation"
anim_dir.mkdir(parents=True, exist_ok=True)
logo_blue = _load_logo_buffer(logo_path)
logo = _recolor_visible_pixels(logo_blue, BRAND_WHITE)
cells = _build_logo_cells(logo_blue, INITIAL_CELL, accent=BRAND_WHITE)
layout = _compute_logo_layout(width, height, embedded=True)
total = frame_count(duration_ms, fps)
end_ms = animation_end_ms(duration_ms)
for idx in range(total):
elapsed = min(end_ms, int(idx * 1000 / fps))
frame = render_frame(logo, cells, layout, elapsed, duration_ms, width, height)
frame.save(anim_dir / f"{idx}.png", optimize=True)
if progress_callback:
progress_callback(idx + 1, total)
return {
"frames": total,
"width": width,
"height": height,
"duration_ms": duration_ms,
"fps": fps,
"logo_path": str(logo_path),
"output_dir": str(anim_dir),
}