456 lines
14 KiB
Python
456 lines
14 KiB
Python
"""
|
||
Рендер кадров анимации 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),
|
||
}
|