Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1,455 @@
"""
Рендер кадров анимации 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),
}