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