From 0bf393616fa4f5d5eb69697eccfb755f896a8f58 Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 6 Sep 2026 02:10:42 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=8F=D0=B4=D1=80=D0=BE=20(15)=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=D1=8B=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=20+=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hypn0/hypn0_site/forms.py | 2 +- hypn0/hypn0_site/services/halftone.py | 58 +++++++++++++++++++++------ hypn0/hypn0_site/tests.py | 29 +++++++++++++- hypn0/templates/index.html | 6 +-- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/hypn0/hypn0_site/forms.py b/hypn0/hypn0_site/forms.py index 0289fa0..fc36d9f 100644 --- a/hypn0/hypn0_site/forms.py +++ b/hypn0/hypn0_site/forms.py @@ -45,7 +45,7 @@ class HalftoneGenerateForm(forms.Form): required=False, ) blink = forms.IntegerField( - min_value=0, + min_value=1, max_value=10, initial=6, required=False, diff --git a/hypn0/hypn0_site/services/halftone.py b/hypn0/hypn0_site/services/halftone.py index 132d54e..e92e7dd 100644 --- a/hypn0/hypn0_site/services/halftone.py +++ b/hypn0/hypn0_site/services/halftone.py @@ -269,13 +269,23 @@ def generate_halftone_svg( 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 + # Генерируем правило задержки и центра в 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}}") + animation_classes.append(f".a{encoded_class}{{--d:{delay_str}s{origin_rule}}}") uses = "".join( f'' @@ -290,15 +300,29 @@ def generate_halftone_svg( # 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" + 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}" - # Добавляем альфа-канал в hex при необходимости или используем CSS opacity - fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.1f};" + 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. @@ -309,23 +333,29 @@ def generate_halftone_svg( 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:{max(0.2, opacity * 0.7):.1f};transform:scale(1) rotate(0deg)}}" - f"50%{{opacity:{opacity:.1f}}}" - f"100%{{opacity:{max(0.1, opacity * 0.5):.1f};transform:scale({scale_str}) rotate({rot_str})}}" + 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"" ) @@ -364,7 +394,7 @@ def prepare_gallery_svg(svg_content: str) -> str: hover_css = ( "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}" + "g[class^=\"a\"],circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}" ) if "" in svg_content: @@ -386,12 +416,14 @@ def prepare_active_svg(svg_content: str) -> str: # Удаляем внедренные правила паузы через 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: @@ -400,7 +432,7 @@ def prepare_active_svg(svg_content: str) -> str: 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(,text)?\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}", + r"(g\[class\^=\"a\"\],)?(\.shape,)?circle,rect,polygon,path(,text)?\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}", "", cleaned, ) diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py index 27d1b2c..2a19ad7 100644 --- a/hypn0/hypn0_site/tests.py +++ b/hypn0/hypn0_site/tests.py @@ -126,7 +126,34 @@ class HalftoneServiceTests(BaseMediaTestCase): self.assertIn("rotate(12deg)", svg) self.assertIn("scale(0.92)", svg) self.assertIn("#ff5500", svg) - self.assertIn(".aH{--d:0s}", svg) + self.assertIn(".aH{--d:0s", svg) + self.assertIn("transform-origin:", svg) + + def test_generate_optimized_styles_and_transforms(self): + # 1. При дефолтных параметрах (rotation=0, scale=1000) transform в @keyframes noise отсутствует + svg_default = generate_halftone_svg(self.img, cols=20, rotation=0, scale=1000) + self.assertNotIn("rotate(", svg_default) + self.assertNotIn("scale(", svg_default) + self.assertIn("@keyframes noise{0%{opacity:0.6}50%{opacity:0.9}100%{opacity:0.5}}", svg_default) + + # 2. Только rotation задан -> scale отсутствует + svg_rot = generate_halftone_svg(self.img, cols=20, rotation=15, scale=1000) + self.assertIn("rotate(15deg)", svg_rot) + self.assertNotIn("scale(", svg_rot) + + # 3. Только scale задан -> rotate отсутствует + svg_scale = generate_halftone_svg(self.img, cols=20, rotation=0, scale=850) + self.assertIn("scale(0.85)", svg_scale) + self.assertNotIn("rotate(", svg_scale) + + # 4. Прозрачность 1.0 -> opacity отсутствует в fill_style + svg_opaque = generate_halftone_svg(self.img, cols=20, opacity=1.0) + self.assertIn("fill:#a855ff;stroke:#a855ff;}", svg_opaque) + self.assertNotIn("opacity:1.0;", svg_opaque) + + # 5. Прозрачность 0.8 -> opacity присутствует + svg_trans = generate_halftone_svg(self.img, cols=20, opacity=0.8) + self.assertIn("opacity:0.8;", svg_trans) def test_generate_from_bytes(self): buf = io.BytesIO() diff --git a/hypn0/templates/index.html b/hypn0/templates/index.html index 3ad4808..6c96598 100644 --- a/hypn0/templates/index.html +++ b/hypn0/templates/index.html @@ -351,12 +351,12 @@
- {# ПОВОРОТЫ #}