import io import math import random import re from collections import defaultdict from typing import BinaryIO, Optional, 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, max_radius: int = 8, char: Optional[str] = None ) -> 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 "snowflake": points = [] inner_r = max(0.5, r * 0.25) for i in range(16): angle = i * math.pi / 8 - 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 "binary": if char is None: # Вероятностная функция: вероятность "0" пропорциональна оптической плотности # В тенях преобладают '0' (~85%), но с шансом ~15% выскакивают '1' # В светах преобладают '1' (~85%), но с шансом ~15% выскакивают '0' prob_zero = min(0.85, max(0.15, (r / max_radius) if max_radius else 0.5)) char = "0" if random.random() < prob_zero else "1" font_size = max(4, round(r * 2.0)) return ( f'{char}' ) 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', 'snowflake', '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: сид для детерминированного распределения анимаций ПОЧИНИТЬ-УЛУЧШИТЬ: -- ЕСЛИ ЦВЕТ ЧЕРЕЗ ИНТЕРФЕЙС НЕ МЕНЯЛСЯ (ДЕФОЛТНЫЙ), ТО ЛУЧШЕ (???) НАЗНАЧАТЬ СЛУЧАЙНЫЙ (или из набора). А то слишком однообразные цвета генераций в галерее """ 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") # Вычисляем размеры базовой сетки по исходным пропорциям изображения # Значение `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) img_w, img_h = img_resized.width, img_resized.height # Базовый шаг ячейки сетки (номинальный радиус касания точек 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 # Функция билинейной интерполяции для сэмплирования яркости в дробных координатах сетки def sample_brightness(fx: float, fy: float) -> float: """Возвращает интерполированную яркость (0-255) в нормализованных координатах img_resized.""" if fx < 0 or fx > img_w - 1 or fy < 0 or fy > img_h - 1: # За пределами изображения считаем фон белым (255) if fx < -0.5 or fx > img_w - 0.5 or fy < -0.5 or fy > img_h - 0.5: return 255.0 fx = max(0.0, min(img_w - 1.0, fx)) fy = max(0.0, min(img_h - 1.0, fy)) x0 = int(math.floor(fx)) y0 = int(math.floor(fy)) x1 = min(x0 + 1, img_w - 1) y1 = min(y0 + 1, img_h - 1) wx = fx - x0 wy = fy - y0 p00 = img_resized.getpixel((x0, y0)) p10 = img_resized.getpixel((x1, y0)) p01 = img_resized.getpixel((x0, y1)) p11 = img_resized.getpixel((x1, y1)) top = p00 * (1.0 - wx) + p10 * wx bottom = p01 * (1.0 - wx) + p11 * wx return top * (1.0 - wy) + bottom * wy # 2. Обход узлов наклонной сетки и группировка elements_by_class = defaultdict(list) unique_shape_keys = set() is_animated = blink > 0 # Центр изображения на холсте (в координатах SVG без марджина) canvas_cx = (grid_width * STEP) / 2.0 canvas_cy = (grid_height * STEP) / 2.0 rad = math.radians(angle) cos_a = math.cos(rad) sin_a = math.sin(rad) # Векторы шага сетки вдоль осей U (строка) и V (столбец) dx_u, dy_u = STEP * cos_a, STEP * sin_a dx_v, dy_v = -STEP * sin_a, STEP * cos_a # Диапазон охвата узлов сетки с запасом, чтобы перекрыть весь повернутый холст max_dim = math.hypot(grid_width, grid_height) u_range = int(math.ceil(max_dim / 2.0)) + 2 v_range = int(math.ceil(max_dim / 2.0)) + 2 # Перебираем узлы сетки вокруг центра for v in range(-v_range, v_range + 1): for u in range(-u_range, u_range + 1): # Точные вещественные координаты узла относительно центра холста rel_x = u * dx_u + v * dx_v rel_y = u * dy_u + v * dy_v # Координаты на видимом холсте (от 0 до grid_width * STEP) pos_x = canvas_cx + rel_x pos_y = canvas_cy + rel_y # Проверяем, попадает ли узел в пределы видимой области холста if not (0 <= pos_x <= grid_width * STEP and 0 <= pos_y <= grid_height * STEP): continue # Координаты в сетке img_resized sample_fx = (pos_x / (grid_width * STEP)) * (img_w - 1) sample_fy = (pos_y / (grid_height * STEP)) * (img_h - 1) brightness = sample_brightness(sample_fx, sample_fy) factor = (255.0 - brightness) / 255.0 # Отсекаем слишком светлые участки (шум фона) if factor < 0.12: continue radius = int(factor * max_radius) if radius == 0: continue # Итоговые координаты центра точки в SVG (с учетом паспарту-margin) cx = round(margin + pos_x) cy = round(margin + pos_y) anim_index = rng.randint(0, animation_variants - 1) if is_animated else 0 encoded_class = encode_to_base36(anim_index) if shape == "binary": # Вероятность выпадения '0' плавно растет с оптической плотностью prob_zero = 0.15 + 0.70 * (radius / max_radius if max_radius else factor) char = "0" if rng.random() < prob_zero else "1" shape_id = f"s{encode_to_base36(radius)}{char}" unique_shape_keys.add((radius, char, shape_id)) else: shape_id = f"s{encode_to_base36(radius)}" unique_shape_keys.add((radius, None, shape_id)) elements_by_class[encoded_class].append((shape_id, cx, cy, anim_index)) # 3. Формирование defs_list = [""] for radius, char, shape_id in sorted(unique_shape_keys, key=lambda k: (k[0], k[1] or "")): defs_list.append(generate_shape_def(shape, radius, shape_id, max_radius=max_radius, char=char)) defs_list.append("") defs_html = "".join(defs_list) # 4. Формирование групп и CSS классов задержек # Список простых чисел для создания богатой апериодической полиритмии мерцания PRIME_DELAYS = [0, 0.1, 0.3, 0.5, 0.7, 1.1, 1.3, 1.7, 1.9, 2.3, 2.9, 3.1] duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0 groups = [] animation_classes = [] # Сортируем группы по индексу анимации для детерминированного порядка for encoded_class in sorted(elements_by_class.keys(), key=lambda c: elements_by_class[c][0][3]): group_elements = elements_by_class[encoded_class] if not group_elements: continue anim_index = group_elements[0][3] if is_animated: prime_base = PRIME_DELAYS[anim_index % len(PRIME_DELAYS)] # Масштабируем задержку относительно длительности анимации с округлением до десятых delay = round(prime_base * (duration / 2.0), 1) # Вычисляем уникальный центр трансформации для каждой группы анимации. # Центры группируются вокруг центра холста (50% 50%) с органическим разбросом, # благодаря чему группы дышат и покачиваются вокруг слегка смещенных фокусов. angle_jitter = (anim_index * (2 * math.pi / max(1, animation_variants))) + (seed % 7) spread_factor = 0.02 + 0.13 * abs((scale - 1000) / 200.0) # от 2% до 15% orig_x = round(50.0 + math.cos(angle_jitter) * spread_factor * 100) orig_y = round(50.0 + math.sin(angle_jitter) * spread_factor * 100) origin_rule = f";transform-origin:{orig_x}% {orig_y}%" else: delay = 0.0 origin_rule = "" # Генерируем правило задержки и центра в CSS if is_animated: delay_str = f"{int(delay)}" if delay == int(delay) else f"{delay:.1f}".rstrip("0").rstrip(".") animation_classes.append(f".a{encoded_class}{{--d:{delay_str}s{origin_rule}}}") uses = "".join( f'' for shape_id, 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(".") has_scale = scale_val != 1.0 has_rotation = rotation != 0 transform_0 = [] transform_100 = [] if has_scale: transform_0.append("scale(1)") transform_100.append(f"scale({scale_str})") if has_rotation: transform_0.append("rotate(0deg)") transform_100.append(f"rotate({rotation}deg)") tf_0_rule = f";transform:{' '.join(transform_0)}" if transform_0 else "" tf_100_rule = f";transform:{' '.join(transform_100)}" if transform_100 else "" # Корректный цвет и прозрачность clean_color = color.strip() if color else "#a855ff" if not clean_color.startswith("#"): clean_color = f"#{clean_color}" has_custom_opacity = round(opacity, 2) < 1.0 opacity_rule = f"opacity:{opacity:.1f};" if has_custom_opacity else "" fill_style = f"fill:{clean_color};stroke:{clean_color};{opacity_rule}" if is_animated: # Управление паузой/запуском через CSS Custom Property --hypn0-play. # По умолчанию running (для живого превью и детального просмотра). # В галерее значение переопределяется на paused для 0% нагрузки на процессор. anim_rule = ( f"animation:noise {duration:.1f}s ease-in-out infinite alternate;" f"animation-delay:var(--d,0s);" f"animation-play-state:var(--hypn0-play,running);" ) op_0 = f"{max(0.2, opacity * 0.7):.1f}".rstrip("0").rstrip(".") op_50 = f"{opacity:.1f}".rstrip("0").rstrip(".") op_100 = f"{max(0.1, opacity * 0.5):.1f}".rstrip("0").rstrip(".") keyframes_rule = ( f"@keyframes noise{{" f"0%{{opacity:{op_0}{tf_0_rule}}}" f"50%{{opacity:{op_50}}}" f"100%{{opacity:{op_100}{tf_100_rule}}}" f"}}" ) else: anim_rule = "animation:none;" keyframes_rule = "" frozen_opacity = f";opacity:{opacity:.1f}!important" if has_custom_opacity else "" 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}" "g[class^=\"a\"],circle,rect,polygon,path,text{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}g[class^=\"a\"],circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}", "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}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path,text{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}", "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}", "svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}g[class^=\"a\"],circle,rect,polygon,path,text{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"(g\[class\^=\"a\"\],)?(\.shape,)?circle,rect,polygon,path(,text)?\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, }