import io import math import random import re from collections import defaultdict from typing import BinaryIO, Union from PIL import Image def encode_to_base36(num: int) -> str: """Кодирует неотрицательное число в компактный строковый идентификатор.""" if num < 0: raise ValueError("Число должно быть неотрицательным") charset = "HYPN0xyzabcdefghijklmnopqrstuvwABCDEFGIJKLMOQRSTUVWXZ123456789" if num < len(charset): return charset[num] result = [] while num > 0: result.append(charset[num % len(charset)]) num //= len(charset) return "".join(reversed(result)) def generate_shape_def(shape: str, radius: int, shape_id: str) -> str: """Генерирует SVG-элемент для тега в зависимости от типа фигуры.""" r = radius match shape: case "ring": inner_r = max(1, round(r * 0.55)) if inner_r >= r: inner_r = max(0, r - 1) if inner_r == 0: return f'' return ( f'' ) case "square": size = r * 2 rx = max(0.5, r * 0.1) return f'' case "diamond": return f'' case "triangle": return f'' case "hexagon": w = round(r * 0.866) h_half = round(r * 0.5) return f'' case "star": points = [] inner_r = r * 0.45 for i in range(10): angle = i * math.pi / 5 - math.pi / 2 curr_r = r if i % 2 == 0 else inner_r px = round(curr_r * math.cos(angle), 1) py = round(curr_r * math.sin(angle), 1) points.append(f"{px},{py}") pts_str = " ".join(points) return f'' case "cross": arm = max(1, round(r * 0.35)) return f'' case "line": th = max(1, round(r * 0.25)) return f'' case "wave": th = max(1.0, r * 0.3) return f'' case "heart": r_top = round(r * 0.3) r_mid = round(r * 0.4) return ( f'' ) case "circle" | _: return f'' def generate_halftone_svg( image: Union[Image.Image, BinaryIO, bytes, str], *, cols: int = 35, max_radius: int = 8, shape: str = "circle", color: str = "#a855ff", opacity: float = 0.9, blink: int = 6, rotation: int = 0, scale: int = 980, angle: int = 0, animation_variants: int = 12, seed: int = 42, ) -> str: """ Генерирует чистый оптимизированный SVG-халфтон из растрового изображения. Параметры: - image: PIL Image, файловый объект (BytesIO), байты или путь к файлу - cols: число точек по наибольшей стороне сетки (10-200) - max_radius: максимальный радиус/размер точки (3-20) - shape: тип фигуры ('circle', 'ring', 'square', 'diamond', 'triangle', 'hexagon', 'star', 'cross', 'line', 'wave', 'heart') - color: основной HEX цвет (например '#a855ff') - opacity: коэффициент непрозрачности (0.0 - 1.0) - blink: интенсивность мерцания (0 - выключено/статичный, 1-10 - скорость) - rotation: угол покачивания в градусах (-15 .. +15) - scale: масштаб пульсации (800 .. 1040, где 980 = 0.98) - angle: наклон растровой сетки (-44 .. +45) - animation_variants: число вариантов CSS-задержек - seed: сид для детерминированного распределения анимаций TODO ПОЧИНИТЬ-УЛУЧШИТЬ: ПОЧИНИЛИ ДЛЯ ПРОСТЫХ ФИГУР - max_radius (радиус точки): - шаг ячейки STEP = 20, базовый радиус R0 = 10 - при d=0 .. 10 -- зазор между точками - при d>10 -- перекрытие и наползание точек - angle: сейчас это просто поворот картинки. Нужно чтобы: - нужно чтобы это был поворт картинки (т.е. ряды-столбцы были со смещение-поворотом, как при полиграфии) - blink: что-то страннное. пока не понял -- ПРОЧЕЕ: -- C "кольцами" что-то не так (они кружочки) -- Проверить: scale и rotation кажется не вокруг центра. -- ЕСЛИ ЦВЕТ ЧЕРЕЗ ИНТЕРФЕЙС НЕ МЕНЯЛСЯ (ДЕФОЛТНЫЙ), ТО ЛУЧШЕ (???) НАЗНАЧАТЬ СЛУЧАНЫЙ (или из набора). А то слишком однообразные цвета герераций в галерее -- Если задан большой размер точки, rotation или scale -- то нужно делать "паспорту", иначе точки "вылезают" за пределы ViewPort """ rng = random.Random(seed) # 1. Загрузка и подготовка изображения в градациях серого if isinstance(image, (bytes, bytearray)): pil_img = Image.open(io.BytesIO(image)) elif hasattr(image, "read"): if hasattr(image, "seek"): image.seek(0) pil_img = Image.open(image) elif isinstance(image, str): pil_img = Image.open(image) elif isinstance(image, Image.Image): pil_img = image else: raise ValueError("Неподдерживаемый тип входного изображения") # Конвертируем в Grayscale img = pil_img.convert("L") # Обработка наклона сетки (angle): поворачиваем изображение при необходимости if angle != 0: # Поворачиваем с сохранением пропорций и белым фоном (255 = прозрачно/пусто) img = img.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=255) # Вычисляем размеры сетки: значение `cols` задает число точек по наибольшей стороне (ширине или высоте) if img.width >= img.height: grid_width = max(1, cols) grid_height = max(1, round(cols * (img.height / img.width))) else: grid_height = max(1, cols) grid_width = max(1, round(cols * (img.width / img.height))) img_resized = img.resize((grid_width, grid_height), Image.Resampling.LANCZOS) # Базовый шаг ячейки сетки (номинальный радиус касания точек R0 = 10, диаметр D0 = 20) STEP = 20 # Расчет запаса/паспарту (margin) по краям холста: # Учитывает увеличенный радиус точек (при наползании) и масштабирование при пульсации/повороте scale_val = max(0.5, min(1.5, scale / 1000.0)) max_effective_radius = max_radius * max(1.0, scale_val) max_visual_extent = math.ceil(max_effective_radius * 1.42) + 2 margin = max(4, max_visual_extent - STEP // 2 + 2) width = grid_width * STEP + 2 * margin height = grid_height * STEP + 2 * margin # 2. Формирование классов анимации animation_classes = [] is_animated = blink > 0 # Длительность цикла анимации: при blink=10 -> 0.6s, при blink=1 -> 2.4s duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0 for i in range(animation_variants): delay = (i / animation_variants) * duration * 1.5 encoded_i = encode_to_base36(i) delay_str = f"{int(delay)}" if delay == int(delay) else f"{delay:.2f}".rstrip("0").rstrip(".") animation_classes.append(f".a{encoded_i}{{--d:{delay_str}s}}") # 3. Обход пикселей и группировка elements_by_class = defaultdict(list) unique_radii = set() for y in range(grid_height): for x in range(grid_width): brightness = img_resized.getpixel((x, y)) factor = (255 - brightness) / 255.0 # Отсекаем слишком светлые участки (шум фона) if factor < 0.12: continue radius = int(factor * max_radius) if radius == 0: continue cx = margin + x * STEP + STEP // 2 cy = margin + y * STEP + STEP // 2 anim_class = rng.randint(0, animation_variants - 1) encoded_class = encode_to_base36(anim_class) elements_by_class[encoded_class].append((radius, cx, cy)) unique_radii.add(radius) # 4. Формирование defs_list = [""] for radius in sorted(unique_radii): shape_id = f"s{encode_to_base36(radius)}" defs_list.append(generate_shape_def(shape, radius, shape_id)) defs_list.append("") defs_html = "".join(defs_list) # 5. Формирование групп groups = [] for anim_class in sorted(elements_by_class.keys()): group_elements = elements_by_class[anim_class] uses = "".join( f'' for radius, cx, cy in group_elements ) if uses: groups.append(f'{uses}') animation_css = "".join(animation_classes) groups_html = "".join(groups) # 6. Стилизация и Keyframes scale_val = max(0.5, min(1.5, scale / 1000.0)) scale_str = f"{scale_val:.3f}".rstrip("0").rstrip(".") rot_str = f"{rotation}deg" # Корректный цвет с прозрачностью clean_color = color.strip() if color else "#a855ff" if not clean_color.startswith("#"): clean_color = f"#{clean_color}" # Добавляем альфа-канал в hex при необходимости или используем CSS opacity fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};" if is_animated: # Управление паузой/запуском через CSS Custom Property --hypn0-play. # По умолчанию running (для живого превью и детального просмотра). # В галерее значение переопределяется на paused для 0% нагрузки на процессор. anim_rule = ( f"animation:noise {duration:.2f}s ease-in-out infinite alternate;" f"animation-delay:var(--d);" f"animation-play-state:var(--hypn0-play,running);" ) keyframes_rule = ( f"@keyframes noise{{" f"0%{{opacity:{max(0.2, opacity * 0.7):.2f};transform:scale(1) rotate(0deg)}}" f"50%{{opacity:{opacity:.2f}}}" f"100%{{opacity:{max(0.1, opacity * 0.5):.2f};transform:scale({scale_str}) rotate({rot_str})}}" f"}}" ) else: anim_rule = "animation:none;" keyframes_rule = "" svg_css = ( f"" ) svg_template = ( f'\n' f'' f'{svg_css}' f'Generated by Hypn0 (https://hypn0.xyz)' f'{defs_html}' f'{groups_html}' f'' ) return svg_template def prepare_gallery_svg(svg_content: str) -> str: """ Модифицирует SVG для долговременного хранения в галерее: внедряет CSS Custom Property (--hypn0-play: paused), которое свободно наследуется внутрь элементов и теневых деревьев . Логика работы: 1. В покое (--hypn0-play: paused): 0% потребления CPU и GPU браузера в ленте галереи. 2. При наведении курсора (:host(:hover) в Shadow DOM или svg:hover при открытии файла): значение переключается на running, и анимация оживает. """ if not svg_content: return svg_content # Наследуемая переменная паузы и оверрайд стилей фигур # Важно: селектор :host(:hover) работает при вставке в Shadow DOM карточки, # а svg:hover работает, если файл открыт напрямую в браузере. hover_css = ( "svg{--hypn0-play:paused}" ":host(:hover) svg,svg:hover{--hypn0-play:running!important}" ".shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}" ) if "" in svg_content: return svg_content.replace("", f"{hover_css}", 1) return svg_content def prepare_active_svg(svg_content: str) -> str: """ Возвращает полностью активный SVG без паузы анимации вне ховера для страницы детального просмотра (/gallery/) и скачивания пользователем. Удаляет оверрайды пауз (--hypn0-play: paused), восстанавливая оригинальные авторские цвета, прозрачности и непрерывную живую анимацию на полной мощности. """ if not svg_content: return svg_content # Удаляем внедренные правила паузы через CSS-переменные и старые форматы gallery_css_variants = [ "svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", ] cleaned = svg_content for gcss in gallery_css_variants: cleaned = cleaned.replace(gcss, "") cleaned = re.sub(r"svg\s*\{[^}]*--hypn0-play:\s*paused[^}]*\}", "", cleaned) cleaned = re.sub(r"(:host\(:hover\)\s*svg\s*,\s*)?svg:hover\s*\{[^}]*--hypn0-play:[^}]*\}", "", cleaned) cleaned = re.sub( r"\.shape,circle,rect,polygon,path\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}", "", cleaned, ) cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", cleaned) cleaned = re.sub(r"svg:not\(:hover\)[^{]*\{[^}]*\}", "", cleaned) old_hover_css = ( "svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect," "svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}" ) cleaned = cleaned.replace(old_hover_css, "") return cleaned def analyze_svg_structure(svg_content: str) -> dict: """ Анализирует разметку SVG и возвращает наукообразную детальную статистику по геометрическим примитивам, классам, анимациям и энтропии данных. """ if not svg_content: return { "total_bytes": 0, "total_kb": 0.0, "total_oscillators": 0, "use_count": 0, "circle_count": 0, "rect_count": 0, "polygon_count": 0, "path_count": 0, "defs_count": 0, "groups_count": 0, "keyframes_count": 0, "classes_count": 0, "viewbox": "0 0 1000 1000", "width": 1000, "height": 1000, "aspect_ratio": "1:1", "density": 0.0, "bytes_per_quantum": 0.0, } total_bytes = len(svg_content.encode("utf-8")) # Подсчет векторных сущностей use_count = len(re.findall(r"= 3 else 1000 height = int(float(vb_parts[3])) if len(vb_parts) >= 4 else 1000 except (ValueError, IndexError): width, height = 1000, 1000 gcd_val = math.gcd(width, height) aspect_ratio = f"{width // gcd_val}:{height // gcd_val}" if gcd_val > 0 else "1:1" # Наукообразные показатели total_oscillators = use_count if use_count > 0 else defs_count bytes_per_quantum = round(total_bytes / max(total_oscillators, 1), 1) density = round(total_oscillators / max((width * height) / 1048576.0, 1.0), 2) return { "total_bytes": total_bytes, "total_kb": round(total_bytes / 1024, 2), "total_oscillators": total_oscillators, "use_count": use_count, "circle_count": circle_count, "rect_count": rect_count, "polygon_count": polygon_count, "path_count": path_count, "defs_count": defs_count, "groups_count": groups_count, "keyframes_count": keyframes_count, "classes_count": classes_count, "viewbox": viewbox, "width": width, "height": height, "aspect_ratio": aspect_ratio, "density": density, "bytes_per_quantum": bytes_per_quantum, }